Compare commits

...
52 Commits
Author SHA1 Message Date
Lucas.Xu 2c513be305 chore: bump version 0.2.5 (#2924) 2023-07-02 15:15:57 +08:00
Lucas.Xu 11d05b303d feat: support inserting local image (#2913) 2023-07-02 11:46:45 +08:00
Richard Shiue c870dbeac4 test: add more cover integration tests (#2922) 2023-07-01 23:13:09 +08:00
Richard ShiueandLucas.Xu 3acd36e580 test: fix calendar integration test #2919 (#2920)
* test: fix calendar integration test #2919

* chore: update frontend/appflowy_flutter/integration_test/database_calendar_test.dart

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>

* test: fix date cell test

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2023-07-01 20:29:49 +08:00
Aman Negi 95d4fb6865 feat: add outline block (#2750) 2023-06-29 20:28:30 +08:00
Muhammad Rizwan 30b52a29fd feat: support Inline page reference #2196 (#2898) 2023-06-29 10:04:24 +08:00
Richard Shiue a17d6b7eec test: fix integration test (#2906)
* test: fix calendar integration test

* test: add comment for workaround
2023-06-28 22:11:50 +08:00
Kilu.He 18ed553296 feat: support equation block (#2903) 2023-06-28 12:39:04 +08:00
Richard Shiue 7f74fd6149 refactor: cover node widget code (#2899)
* chore: change initial cover type's name to none

* chore: refactor cover node widget

* chore: use a constant instead of magic value

* fix: make the size of icon hover effect smaller

* chore: improve appearance of selected color

* test: add cover integration tests

* fix: inner ring of selected color in dark mode

* refactor: cover node to document header node

* test: simplify tests

* chore: rename files
2023-06-27 15:17:51 +08:00
LainHE a3e09f6021 chore: update language zh-CN (#2900) 2023-06-27 11:11:09 +08:00
Kilu.He 5e0a0f92ff feat: support text equation (#2876) 2023-06-27 10:44:06 +08:00
Alex Wallen 2a2b5fe246 [feat] Add actions to error widget. (#2865)
* chore: rebase error?

* fix: revert changes in Cargo.toml

* feat: add actions row
2023-06-27 09:55:10 +08:00
rmri f914e7aa33 chore: Update hu-HU.json (#2837)
Fixed typos and added missing items in Hungarian translation.
2023-06-27 09:54:46 +08:00
Alex Wallen 386d3f2ba7 feat github redirect (#2867)
* feat: add GitHub to question bubble

* feat: translate to feedback
2023-06-27 09:53:38 +08:00
Nathan.fooo 88faa0f02a feat: using cached view (#2877) 2023-06-23 23:19:34 +08:00
Lucas.Xu f3d6c66f5c chore: bump version 0.2.4 (#2890) 2023-06-23 15:36:44 +08:00
Lucas.Xu d665153a9f fix: 0.2.3 known issues (#2886)
* fix: option menu position

* fix: rename helps highlight the text for the user

* fix: export as markdown file name invalid

* chore: align the emoji in callout

* fix: leave more space in the editor

* fix: 0.2.3 known issues

* chore: add flutter pub get in flutter.toml
2023-06-23 10:54:35 +08:00
Askarbek Zadaulyandqinluhe eee32110f4 feat: nested views
* chore: remove folder code merge page and folder into navitem component

* chore: test fix

* fix: nav item expand fix

* fix: unfold page and active page

* fix: nav item click area fix

* chore: remove old components

* chore: remove old code

* chore: cell controller reorganize

* chore: nav item optimizations

* fix: add async queue to fix data problem

* chore: change semantics of new folder button

* chore: move row methods to database controller

---------

Co-authored-by: qinluhe <qinluhe.twodog@gmail.com>
2023-06-23 10:17:50 +08:00
Richard Shiue 9834eccc7b fix: remove icon button not working in popover (#2885) 2023-06-23 10:01:06 +08:00
Richard Shiue bef7fe87aa test: add more calendar integration tests (#2883)
* test: make sure that the row details page opens

* test: add more calendar tests
2023-06-22 23:40:49 +08:00
Lucas.Xu feceb430cf chore: optimize CI (#2875) 2023-06-22 21:22:46 +08:00
Nathan.foooandvedon a29c8ab27a fix: cell focus issue on windows (#2880)
* fix: cell focus issue on windows

* fix: try to fix

* fix: cell focus not working on Windows platform there are multiple text cells

* docs: add documentation

* chore: adjust row detail page ui

* test: add test

* test: fix test

---------

Co-authored-by: vedon <vedon.fu@gmail.com>
2023-06-22 20:16:31 +08:00
Nathan.foooandvedon f1bfcb6066 feat: only allow one windows instance (#2879)
Co-authored-by: vedon <vedon.fu@gmail.com>
2023-06-22 13:25:01 +08:00
Richard Shiue 99cfda0a5c test: more integration tests (#2871)
* test: date cell integration tests

* test: single/multi-select integration tests
2023-06-21 22:53:49 +08:00
Lucas.Xu 9bd629aaef fix: undo redo for the transforming block will raise an error (#2869)
* fix: undo redo for the transforming block will raise an error

* test: add golden for editing document

* test: add undo redo test
2023-06-21 19:53:29 +08:00
Nathan.fooo d302f6b3fb chore: adjust ui (#2872) 2023-06-21 19:47:30 +08:00
Nathan.foooandLucas.Xu 5595649f53 chore: add database sort integration test (#2866)
* chore: add sort test

* chore: add tests

* chore: update tests

* fix: switch folder test

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2023-06-21 19:10:16 +08:00
Nathan.fooo 7de17ab0b8 chore: bump version 0.2.3 (#2864)
* chore: bump version 0.2.3

* chore: update version in pubspec
2023-06-21 15:13:36 +08:00
Nathan.fooo 13ff38756f feat: put all user data into a folder when choosing a custom storage path (#2861) 2023-06-21 12:15:32 +08:00
Lucas.Xu 197e5b3a79 feat: upgrade editor version to latest 0.1.4 2023-06-21 11:36:50 +08:00
Alex Wallen 5cced92646 [feat] make error widget sexy (#2825)
* feat: add translations

* feat: style error page

* chore: update api usage

* feat: add GitHub redirect

* chore: rebase error?

* chore: remove todo comment

* chore: remove impossible code

* fix: update url launcher invocation

* chore: use new api

* fix: analyzer errors

* fix: revert changes in Cargo.toml
2023-06-21 10:03:39 +08:00
Nathan.fooo e50d708c21 feat: create database view on same database (#2829)
* feat: create database view on same database

* feat: switch tag between views

* fix: calendar tool bar

* fix: set layout setting

* chore: update collab rev

* fix: board layout issue

* test: add integration tests

* test: add calendar start from day test
2023-06-20 23:48:34 +08:00
Nathan.fooo 79fc7c4cfe fix: import markdown file (#2841) 2023-06-19 21:20:53 +08:00
Kilu.He 202cd90711 fix: build tauri app failed in macOS (#2828) 2023-06-17 22:02:03 +08:00
Kilu.He 177f7c4fa3 Support document multiple instance and change doc_id from String to &str (#2808)
* fix: support multiple document

* fix: change the doc_id params to ref

* fix: function to converge subscription state

* fix: mousedown behavior update

* fix: turn to textblock when the enter pressed in empty block

* fix: support cut

* fix: emoji caret

* fix: support slash arrow key

* fix: eslint padding-line-between-statements

* fix: add comment

* fix: block side menu bugs

* fix: support key event to select block menu option

* fix: support side menu arrow key

* fix: optimizate selectOptionByUpDown

* fix: format
2023-06-17 14:25:30 +08:00
Lucas.Xu d986e01d3e chore: use retry action v1.0.36 2023-06-16 20:28:31 +08:00
Daniil Golubev 8a8915c662 chore: update russian localization (#2787)
* feat(locale): add missing lines

* feat(locale): update russian translations
2023-06-16 15:33:48 +08:00
efc857d752 chore: some ui improvements (#2791)
* chore: some ui improvements

* fix: integration test

* feat: language selector on welcome page (#2796)

* feat: add language selector on welcome page

* feat: add hover effect and refactor layout

* test: add basic languge selector testing

* chore: increate place holder width

* fix: add catch error for setLocale and finish the testing

* chore: update comment

* feat: refactor the skip login in page and add tests

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>

* feat: row document (#2792)

* chore: create orphan view handler

* feat: save icon url and cover url in view

* feat: implement emoji picker UI

* chore: config ui

* chore: config ui again

* chore: replace RowPB with RowMetaPB to exposing more row information

* fix: compile error

* feat: show emoji in row

* chore: update

* test: insert emoji test

* test: add update emoji test

* test: add remove emoji test

* test: add create field tests

* test: add create row and delete row integration tests

* test: add create row from row menu

* test: document in row detail page

* test: delete, duplicate row in row detail page

* test: check the row count displayed in grid page

* test: rename existing field in grid page

* test: update field type of exisiting field in grid page

* test: delete field test

* test: add duplicate field test

* test: add hide field test

* test: add edit text cell test

* test: add insert text to text cell test

* test: add edit number cell test

* test: add edit multiple number cells

* test: add edit checkbox cell test

* feat: integrate editor into database row

* test: add edit create time and last edit time cell test

* test: add edit date cell by selecting a date test

* chore: remove unused code

* chore: update checklist bg color

* test: add update database layout test

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>

* test: fix test

* test: add create select option test

---------

Co-authored-by: Yijing Huang <hyj891204@gmail.com>
Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
Co-authored-by: Nathan.fooo <86001920+appflowy@users.noreply.github.com>
Co-authored-by: nathan <nathan@appflowy.io>
2023-06-16 15:32:28 +08:00
Nathan.fooo 14dee6b797 test: filter integration (#2821)
* test: add checklist filter test

* fix: widget reference to invalid databaseController

* fix: SelectOptionFilterList doesn't expand to fill the CustomScrollView

* test: add single select and multi-select filter test

* ci: set protoc version
2023-06-16 14:32:32 +08:00
Lucas.Xu 63eee8b415 chore: retry codecov 2023-06-16 13:32:24 +08:00
Nathan.fooo d96a1d8bd4 test: import database integration (#2803)
* feat: support importing database raw data

* feat: verify import database test

* test: fix test

* ci: update integration test ci config

* ci: codecov with os flag

* ci: update docker command

* ci: update docker command

* ci: update docker command

* ci: update docker command

* test: add filter test
2023-06-15 22:43:07 +08:00
Lucas.Xu 430325c731 chore: bump version 0.2.2 (#2804) 2023-06-15 18:55:21 +08:00
Lucas.Xu d5884ad2b5 fix: unable insert a reference database (#2798)
* fix: unable insert a reference database

* test: add reference database tests

* feat: set min height for document inside database
2023-06-15 16:33:44 +08:00
Kilu.He 95f8b2e9a4 chore: create redo/undo bridge (#2760)
* chore: create redo/undo bridge

* chore: update test

* chore: review update

* chore: review update

* chore: react redo/undo

* chore: review update

* chore: add test

* chore: review update

* chore: generate document id

* chore: update undo/redo

* chore: update cargo lock
2023-06-15 10:37:51 +08:00
Nathan.foooandLucas.Xu 27dd719aa8 feat: row document (#2792)
* chore: create orphan view handler

* feat: save icon url and cover url in view

* feat: implement emoji picker UI

* chore: config ui

* chore: config ui again

* chore: replace RowPB with RowMetaPB to exposing more row information

* fix: compile error

* feat: show emoji in row

* chore: update

* test: insert emoji test

* test: add update emoji test

* test: add remove emoji test

* test: add create field tests

* test: add create row and delete row integration tests

* test: add create row from row menu

* test: document in row detail page

* test: delete, duplicate row in row detail page

* test: check the row count displayed in grid page

* test: rename existing field in grid page

* test: update field type of exisiting field in grid page

* test: delete field test

* test: add duplicate field test

* test: add hide field test

* test: add edit text cell test

* test: add insert text to text cell test

* test: add edit number cell test

* test: add edit multiple number cells

* test: add edit checkbox cell test

* feat: integrate editor into database row

* test: add edit create time and last edit time cell test

* test: add edit date cell by selecting a date test

* chore: remove unused code

* chore: update checklist bg color

* test: add update database layout test

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2023-06-14 22:16:33 +08:00
Yijing HuangandLucas.Xu b8983e4466 feat: language selector on welcome page (#2796)
* feat: add language selector on welcome page

* feat: add hover effect and refactor layout

* test: add basic languge selector testing

* chore: increate place holder width

* fix: add catch error for setLocale and finish the testing

* chore: update comment

* feat: refactor the skip login in page and add tests

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2023-06-14 19:14:41 +08:00
Kilu.HeandLucas.Xu 00c0934df6 Support text block add link (#2730)
* feat: support text block href attribute

* fix: double click didn't select range

* fix: link update

* chore: ts lint

* chore: add new line

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>

* chore: update get word indices function

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2023-06-13 15:59:18 +08:00
Lucas.Xu aca3c90eb3 chore: optimize the integration test UI 2023-06-12 21:53:39 +08:00
Lucas.Xu 8b4d1868e5 test: add integration test 2023-06-12 20:13:25 +08:00
Lucas.Xu 5bcf48a4f9 fix: the markdown file name doesn't update after renaming the document (#2777)
* fix: the markdown file name doesn't update after renaming the document

* test: add integration test
2023-06-12 14:29:09 +08:00
Lucas.Xu 335861706e feat: support importing multiple md files all at once (#2778) 2023-06-12 14:27:38 +08:00
Nathan.foooandLucas.Xu 4f5672c2af refactor: create view interface (#2772)
* refactor: create view interface

* chore: update doc

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2023-06-12 12:57:01 +08:00
493 changed files with 20240 additions and 6649 deletions

No files matched your search

+4 -3
View File
@@ -21,12 +21,13 @@ on:
- unlocked
- ready_for_review
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build-app:
if: github.event.pull_request.draft != true
concurrency:
group: docker_ci-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
runs-on: ubuntu-latest
steps:
- name: Checkout source code
+17 -5
View File
@@ -25,8 +25,13 @@ env:
FLUTTER_VERSION: "3.10.1"
RUST_TOOLCHAIN: "1.70"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
build:
if: github.event.pull_request.draft != true
strategy:
fail-fast: false
matrix:
@@ -119,9 +124,16 @@ jobs:
run: |
cargo make dart_unit_test
- uses: codecov/codecov-action@v3
- name: Upload coverage to Codecov
uses: Wandalen/wretry.action@v1.0.36
with:
name: appflowy
flags: appflowy_flutter_unit_test
fail_ci_if_error: true
verbose: true
action: codecov/codecov-action@v3
with: |
name: appflowy
flags: appflowy_flutter_unit_test
fail_ci_if_error: true
verbose: true
os: ${{ matrix.os }}
attempt_limit: 5
attempt_delay: 10000
+44 -33
View File
@@ -6,7 +6,7 @@ on:
- "main"
- "release/*"
paths:
- ".github/workflows/flutter_ci.yaml"
- ".github/workflows/integration_test.yml"
- "frontend/**"
- "!frontend/appflowy_tauri/**"
@@ -15,15 +15,22 @@ on:
- "main"
- "release/*"
paths:
- ".github/workflows/flutter_ci.yaml"
- ".github/workflows/integration_test.yml"
- "frontend/**"
- "!frontend/appflowy_tauri/**"
env:
CARGO_TERM_COLOR: always
FLUTTER_VERSION: "3.10.1"
RUST_TOOLCHAIN: "1.70"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
tests:
if: github.event.pull_request.draft != true
strategy:
matrix:
os: [ubuntu-latest]
@@ -36,31 +43,35 @@ jobs:
with:
toolchain: "stable-2022-04-07"
- uses: subosito/flutter-action@v2
- name: Install Rust toolchain
id: rust_toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
override: true
profile: minimal
- name: Install flutter
id: flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: "3.10.1"
flutter-version: ${{ env.FLUTTER_VERSION }}
cache: true
- name: Cache Cargo
uses: actions/cache@v2
- uses: Swatinem/rust-cache@v2
with:
path: |
~/.cargo
key: ${{ runner.os }}-cargo-${{ steps.rust_toolchain.outputs.rustc_hash }}-${{ hashFiles('./frontend/rust-lib/Cargo.toml') }}
prefix-key: ${{ matrix.os }}
workspaces: |
frontend/rust-lib
- name: Cache Rust
id: cache-rust-target
uses: actions/cache@v2
- uses: davidB/rust-cargo-make@v1
with:
path: |
frontend/rust-lib/target
shared-lib/target
key: ${{ runner.os }}-rust-rust-lib-share-lib-${{ steps.rust_toolchain.outputs.rustc_hash }}-${{ hashFiles('./frontend/rust-lib/Cargo.toml') }}
version: '0.36.6'
- name: Setup Environment
- name: Install prerequisites
working-directory: frontend
run: |
cargo install --force cargo-make
cargo install --force duckscript_cli
if [ "$RUNNER_OS" == "Linux" ]; then
sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub
@@ -73,14 +84,8 @@ jobs:
elif [ "$RUNNER_OS" == "macOS" ]; then
echo 'do nothing'
fi
shell: bash
- if: steps.cache-cargo.outputs.cache-hit != 'true'
name: Rust Deps
working-directory: frontend
run: |
cargo install cargo-make
cargo make appflowy-flutter-deps-tools
shell: bash
- name: Config Flutter
run: |
@@ -112,17 +117,23 @@ jobs:
export DISPLAY=:99
sudo Xvfb -ac :99 -screen 0 1280x1024x24 > /dev/null 2>&1 &
sudo apt-get install network-manager
flutter test integration_test/runner.dart -d Linux --coverage --verbose
flutter test integration_test/runner.dart -d Linux --coverage
elif [ "$RUNNER_OS" == "macOS" ]; then
flutter test integration_test/runner.dart -d macOS --coverage --verbose
flutter test integration_test/runner.dart -d macOS --coverage
elif [ "$RUNNER_OS" == "Windows" ]; then
flutter test integration_test/runner.dart -d Windows --coverage --verbose
flutter test integration_test/runner.dart -d Windows --coverage
fi
shell: bash
- uses: codecov/codecov-action@v3
- name: Upload coverage to Codecov
uses: Wandalen/wretry.action@v1.0.36
with:
name: appflowy
flags: appflowy_flutter_integrateion_test
fail_ci_if_error: true
verbose: true
action: codecov/codecov-action@v3
with: |
name: appflowy
flags: appflowy_flutter_integrateion_test
fail_ci_if_error: true
verbose: true
os: ${{ matrix.os }}
attempt_limit: 5
attempt_delay: 10000
+7 -1
View File
@@ -2,6 +2,7 @@ name: Tauri-CI
on:
pull_request:
paths:
- ".github/workflows/tauri_ci.yaml"
- "frontend/rust-lib/**"
- "frontend/appflowy_tauri/**"
@@ -10,8 +11,13 @@ env:
PNPM_VERSION: "8.5.0"
RUST_TOOLCHAIN: "1.70"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
jobs:
tauri-build:
if: github.event.pull_request.draft != true
strategy:
fail-fast: false
matrix:
@@ -38,7 +44,7 @@ jobs:
with:
prefix-key: 'ubuntu-latest-tauri'
workspaces: |
frontend/rust-lib
frontend/rust-lib
frontend/appflowy_tauri/src-tauri
- name: install dependencies (windows only)
+49 -4
View File
@@ -1,16 +1,51 @@
# Release Notes
## Version 0.2.1 - 06/11/2023
## Version 0.2.4 - 06/23/2023
### Bug fixes:
- Unable to copy and paste a word
- Some UI issues
## Version 0.2.4 - 06/23/2023
### Bug fixes:
- Unable to copy and paste a word
- Some UI issues
## Version 0.2.3 - 06/21/2023
### New Features
- Added support for creating or referencing a calendar in the document
- Added `+` icon in grid's add field
-
- Added support for creating multiple database views for existing database
## Version 0.2.2 - 06/15/2023
### New Features
- Added support for embedding a document in the database's row detail page
- Added support for inserting an emoji in the database's row detail page
### Other Updates
- Added language selector on the welcome page
- Added support for importing multiple markdown files all at once
## Version 0.2.1 - 06/11/2023
### New Features
- Added support for creating or referencing a calendar in the document
- Added `+` icon in grid's add field
### Other Updates
- Added vertical padding for progress bar
- Hide url cell accessory when the content is empty
### Bug fixes:
- Fixed unable to export markdown
- Fixed adding vertical padding for progress bar
- Fixed database view didn't update after the database layout changed.
@@ -18,6 +53,7 @@
## Version 0.2.0 - 06/08/2023
### New Features
- Improved checklists to support each cell having its own list
- Drag and drop calendar events
- Switch layouts (calendar, grid, kanban) of a database
@@ -31,44 +67,53 @@
- Added support for an 'Option' button to delete, duplicate, and customize block actions
### Other Updates
- Added support for importing v0.1.x documents and databases
- Added support for database import and export to CSV
- Optimized scroll behavior in documents.
- Redesigned the launch page
### Bug fixes
- Fixed bugs related to numbers
- Fixed issues with referenced databases in documents
- Fixed menu overflow issues in documents
### Data migration
The data format of this version is not compatible with previous versions. Therefore, to migrate your data to the new version, you need to use the export and import functions. Please follow the guide to learn how to export and import your data.
#### Export files in v0.1.6
https://github.com/AppFlowy-IO/AppFlowy/assets/11863087/0c89bf2b-cd97-4a7b-b627-59df8d2967d9
#### Import files in v0.2.0
https://github.com/AppFlowy-IO/AppFlowy/assets/11863087/7b392f35-4972-497a-8a7f-f38efced32e2
## Version 0.1.5 - 11/05/2023
### Bug Fixes
- Fix: calendar dates don't match with weekdays.
- Fix: sort numbers in Grid.
## Version 0.1.4 - 04/05/2023
### New features
- Use AppFlowy’s calendar views to plan and manage tasks and deadlines.
- Writing can be improved with the help of OpenAI.
## Version 0.1.3 - 24/04/2023
### New features
- Launch the official Dark Mode.
- Customize the font color and highlight color by setting a hex color value and an opacity level.
### Bug Fixes
- Fix: the slash menu can be triggered by all other keyboards than English.
- Fix: convert the single asterisk to italic text and the double asterisks to bold text.
+1 -1
View File
@@ -23,7 +23,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
CARGO_MAKE_CRATE_FS_NAME = "dart_ffi"
CARGO_MAKE_CRATE_NAME = "dart-ffi"
LIB_NAME = "dart_ffi"
CURRENT_APP_VERSION = "0.2.1"
CURRENT_APP_VERSION = "0.2.5"
FLUTTER_DESKTOP_FEATURES = "dart,rev-sqlite"
PRODUCT_NAME = "AppFlowy"
# CRATE_TYPE: https://doc.rust-lang.org/reference/linkage.html
+2
View File
@@ -72,3 +72,5 @@ windows/flutter/dart_ffi/
*.env
coverage/
**/failures/*.png
@@ -0,0 +1,5 @@
<svg width="20" height="20" viewBox="0 0 20 20" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M9.99999 15.8333C13.2217 15.8333 15.8333 13.2216 15.8333 9.99996C15.8333 6.7783 13.2217 4.16663 9.99999 4.16663C6.77833 4.16663 4.16666 6.7783 4.16666 9.99996C4.16666 13.2216 6.77833 15.8333 9.99999 15.8333Z" stroke="#333333" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M4.16666 10H15.8333" stroke="#333333" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M10.0002 4.16663C11.4593 5.764 12.2884 7.83698 12.3335 9.99996C12.2884 12.1629 11.4593 14.2359 10.0002 15.8333C8.54109 14.2359 7.7119 12.1629 7.66684 9.99996C7.7119 7.83698 8.54109 5.764 10.0002 4.16663V4.16663Z" stroke="#333333" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 831 B

@@ -0,0 +1,11 @@
"{""id"":""2_OVWb"",""name"":""Name"",""field_type"":0,""visibility"":true,""width"":150,""type_options"":{""0"":{""data"":""""}},""is_primary"":true}","{""id"":""xjmOSi"",""name"":""Type"",""field_type"":3,""visibility"":true,""width"":150,""type_options"":{""3"":{""content"":""{\""options\"":[{\""id\"":\""t1WZ\"",\""name\"":\""s6\"",\""color\"":\""Lime\""},{\""id\"":\""GzNa\"",\""name\"":\""s5\"",\""color\"":\""Yellow\""},{\""id\"":\""l_8w\"",\""name\"":\""s4\"",\""color\"":\""Orange\""},{\""id\"":\""TzVT\"",\""name\"":\""s3\"",\""color\"":\""LightPink\""},{\""id\"":\""b5WF\"",\""name\"":\""s2\"",\""color\"":\""Pink\""},{\""id\"":\""AcHA\"",\""name\"":\""s1\"",\""color\"":\""Purple\""}],\""disable_color\"":false}""}},""is_primary"":false}","{""id"":""Hpbiwr"",""name"":""Done"",""field_type"":5,""visibility"":true,""width"":150,""type_options"":{""5"":{""is_selected"":false}},""is_primary"":false}","{""id"":""F7WLnw"",""name"":""checklist"",""field_type"":7,""visibility"":true,""width"":120,""type_options"":{""0"":{""data"":""""},""7"":{}},""is_primary"":false}","{""id"":""KABhMe"",""name"":""number"",""field_type"":1,""visibility"":true,""width"":120,""type_options"":{""1"":{""format"":0,""symbol"":""RUB"",""scale"":0,""name"":""Number""},""0"":{""scale"":0,""data"":"""",""format"":0,""name"":""Number"",""symbol"":""RUB""}},""is_primary"":false}","{""id"":""lEn6Bv"",""name"":""date"",""field_type"":2,""visibility"":true,""width"":120,""type_options"":{""2"":{""field_type"":2,""time_format"":1,""timezone_id"":"""",""date_format"":3},""0"":{""field_type"":2,""date_format"":3,""time_format"":1,""data"":"""",""timezone_id"":""""}},""is_primary"":false}","{""id"":""B8Prnx"",""name"":""url"",""field_type"":6,""visibility"":true,""width"":120,""type_options"":{""6"":{""content"":"""",""url"":""""},""0"":{""content"":"""",""data"":"""",""url"":""""}},""is_primary"":false}","{""id"":""MwUow4"",""name"":""multi-select"",""field_type"":4,""visibility"":true,""width"":240,""type_options"":{""0"":{""content"":""{\""options\"":[],\""disable_color\"":false}"",""data"":""""},""4"":{""content"":""{\""options\"":[{\""id\"":\""__Us\"",\""name\"":\""m7\"",\""color\"":\""Green\""},{\""id\"":\""n9-g\"",\""name\"":\""m6\"",\""color\"":\""Lime\""},{\""id\"":\""KFYu\"",\""name\"":\""m5\"",\""color\"":\""Yellow\""},{\""id\"":\""KftP\"",\""name\"":\""m4\"",\""color\"":\""Orange\""},{\""id\"":\""5lWo\"",\""name\"":\""m3\"",\""color\"":\""LightPink\""},{\""id\"":\""Djrz\"",\""name\"":\""m2\"",\""color\"":\""Pink\""},{\""id\"":\""2uRu\"",\""name\"":\""m1\"",\""color\"":\""Purple\""}],\""disable_color\"":false}""}},""is_primary"":false}"
"{""field_type"":0,""created_at"":1686793246,""data"":""A"",""last_modified"":1686793246}","{""last_modified"":1686793275,""created_at"":1686793261,""data"":""AcHA"",""field_type"":3}","{""created_at"":1686793241,""field_type"":5,""last_modified"":1686793241,""data"":""Yes""}","{""data"":""{\""options\"":[{\""id\"":\""pi1A\"",\""name\"":\""t1\"",\""color\"":\""Purple\""},{\""id\"":\""6Pym\"",\""name\"":\""t2\"",\""color\"":\""Purple\""},{\""id\"":\""erEe\"",\""name\"":\""t3\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""pi1A\"",\""6Pym\""]}"",""created_at"":1686793302,""field_type"":7,""last_modified"":1686793308}","{""created_at"":1686793333,""field_type"":1,""data"":""-1"",""last_modified"":1686793333}","{""last_modified"":1686793370,""field_type"":2,""data"":""1685583770"",""include_time"":false,""created_at"":1686793370}","{""created_at"":1686793395,""data"":""appflowy.io"",""field_type"":6,""last_modified"":1686793399,""url"":""https://appflowy.io""}","{""last_modified"":1686793446,""field_type"":4,""data"":""2uRu"",""created_at"":1686793428}"
"{""last_modified"":1686793247,""data"":""B"",""field_type"":0,""created_at"":1686793247}","{""created_at"":1686793278,""data"":""b5WF"",""field_type"":3,""last_modified"":1686793278}","{""created_at"":1686793292,""last_modified"":1686793292,""data"":""Yes"",""field_type"":5}","{""data"":""{\""options\"":[{\""id\"":\""YHDO\"",\""name\"":\""t1\"",\""color\"":\""Purple\""},{\""id\"":\""QjtW\"",\""name\"":\""t2\"",\""color\"":\""Purple\""},{\""id\"":\""K2nM\"",\""name\"":\""t3\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""YHDO\""]}"",""field_type"":7,""last_modified"":1686793318,""created_at"":1686793311}","{""data"":""-2"",""last_modified"":1686793335,""created_at"":1686793335,""field_type"":1}","{""field_type"":2,""data"":""1685670174"",""include_time"":false,""created_at"":1686793374,""last_modified"":1686793374}","{""last_modified"":1686793403,""field_type"":6,""created_at"":1686793399,""url"":"""",""data"":""no url""}","{""data"":""2uRu,Djrz"",""field_type"":4,""last_modified"":1686793449,""created_at"":1686793449}"
"{""data"":""C"",""created_at"":1686793248,""last_modified"":1686793248,""field_type"":0}","{""created_at"":1686793280,""field_type"":3,""data"":""TzVT"",""last_modified"":1686793280}","{""data"":""Yes"",""last_modified"":1686793292,""field_type"":5,""created_at"":1686793292}","{""last_modified"":1686793329,""field_type"":7,""created_at"":1686793322,""data"":""{\""options\"":[{\""id\"":\""iWM1\"",\""name\"":\""t1\"",\""color\"":\""Purple\""},{\""id\"":\""WDvF\"",\""name\"":\""t2\"",\""color\"":\""Purple\""},{\""id\"":\""w3k7\"",\""name\"":\""t3\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""iWM1\"",\""WDvF\"",\""w3k7\""]}""}","{""field_type"":1,""last_modified"":1686793339,""data"":""0.1"",""created_at"":1686793339}","{""last_modified"":1686793377,""data"":""1685756577"",""created_at"":1686793377,""include_time"":false,""field_type"":2}","{""created_at"":1686793403,""field_type"":6,""data"":""appflowy.io"",""last_modified"":1686793408,""url"":""https://appflowy.io""}","{""data"":""2uRu,Djrz,5lWo"",""created_at"":1686793453,""last_modified"":1686793454,""field_type"":4}"
"{""data"":""D"",""last_modified"":1686793249,""created_at"":1686793249,""field_type"":0}","{""data"":""l_8w"",""created_at"":1686793284,""last_modified"":1686793284,""field_type"":3}","{""data"":""Yes"",""created_at"":1686793293,""last_modified"":1686793293,""field_type"":5}",,"{""field_type"":1,""last_modified"":1686793341,""created_at"":1686793341,""data"":""0.2""}","{""created_at"":1686793379,""last_modified"":1686793379,""field_type"":2,""data"":""1685842979"",""include_time"":false}","{""last_modified"":1686793419,""field_type"":6,""created_at"":1686793408,""data"":""https://github.com/AppFlowy-IO/"",""url"":""https://github.com/AppFlowy-IO/""}","{""data"":""2uRu,Djrz,5lWo"",""last_modified"":1686793459,""field_type"":4,""created_at"":1686793459}"
"{""field_type"":0,""last_modified"":1686793250,""created_at"":1686793250,""data"":""E""}","{""field_type"":3,""last_modified"":1686793290,""created_at"":1686793290,""data"":""GzNa""}","{""last_modified"":1686793294,""created_at"":1686793294,""data"":""Yes"",""field_type"":5}",,"{""created_at"":1686793346,""field_type"":1,""last_modified"":1686793346,""data"":""1""}","{""last_modified"":1686793383,""data"":""1685929383"",""field_type"":2,""include_time"":false,""created_at"":1686793383}","{""field_type"":6,""url"":"""",""data"":"""",""last_modified"":1686793421,""created_at"":1686793419}","{""field_type"":4,""last_modified"":1686793465,""data"":""2uRu,Djrz,5lWo,KFYu,KftP"",""created_at"":1686793463}"
"{""field_type"":0,""created_at"":1686793251,""data"":"""",""last_modified"":1686793289}",,,,"{""data"":""2"",""field_type"":1,""created_at"":1686793347,""last_modified"":1686793347}","{""include_time"":false,""data"":""1685929385"",""last_modified"":1686793385,""field_type"":2,""created_at"":1686793385}",,
"{""created_at"":1686793254,""field_type"":0,""last_modified"":1686793288,""data"":""""}",,,,"{""created_at"":1686793351,""last_modified"":1686793351,""data"":""10"",""field_type"":1}","{""include_time"":false,""data"":""1686879792"",""field_type"":2,""created_at"":1686793392,""last_modified"":1686793392}",,
,,,,"{""last_modified"":1686793354,""created_at"":1686793354,""field_type"":1,""data"":""11""}",,,
,,,,"{""field_type"":1,""last_modified"":1686793356,""data"":""12"",""created_at"":1686793356}",,,
,,,,,,,
@@ -0,0 +1,22 @@
# Welcome to AppFlowy!
## Here are the basics
- [ ] Click anywhere and just start typing.
- [ ] Highlight any text, and use the editing menu to _style_ **your** <u>writing</u> `however` you ~~like.~~
- [ ] As soon as you type /a menu will pop up. Select different types of content blocks you can add.
- [ ] Type `/` followed by `/bullet` or `/num` to create a list.
- [x] Click `+ New Page `button at the bottom of your sidebar to add a new page.
- [ ] Click `+` next to any page title in the sidebar to quickly add a new subpage, `Document`, `Grid`, or `Kanban Board`.
---
## Keyboard shortcuts, markdown, and code block
1. Keyboard shortcuts [guide](https://appflowy.gitbook.io/docs/essential-documentation/shortcuts)
1. Markdown [reference](https://appflowy.gitbook.io/docs/essential-documentation/markdown)
1. Type `/code` to insert a code block
## Have a question❓
> Click `?` at the bottom right for help and support.
@@ -0,0 +1 @@
# test
@@ -51,6 +51,13 @@
"import": "Import",
"moreOptions": "More options"
},
"importPanel": {
"textAndMarkdown": "Text & Markdown",
"documentFromV010": "Document from v0.1.0",
"databaseFromV010": "Database from v0.1.0",
"csv": "CSV",
"database": "Database"
},
"disclosureAction": {
"rename": "Rename",
"delete": "Delete",
@@ -83,7 +90,8 @@
"name": "Debug Info",
"success": "Copied debug info to clipboard!",
"fail": "Unable to copy debug info to clipboard"
}
},
"feedback": "Feedback"
},
"menuAppHeader": {
"addPageTooltip": "Quickly add a page inside",
@@ -216,6 +224,7 @@
"changeLocationTooltips": "Change the data directory",
"change": "Change",
"openLocationTooltips": "Open another data directory",
"openCurrentDataFolder": "Open current data directory",
"recoverLocationTooltips": "Reset to AppFlowy's default data directory",
"exportFileSuccess": "Export file successfully!",
"exportFileFail": "Export file failed!",
@@ -229,6 +238,8 @@
}
},
"grid": {
"deleteView": "Are you sure you want to delete this view?",
"createView": "New",
"settings": {
"filter": "Filter",
"sort": "Sort",
@@ -374,6 +385,9 @@
"createANewCalendar": "Create a new Calendar"
}
},
"selectionMenu": {
"outline": "Outline"
},
"plugins": {
"referencedBoard": "Referenced Board",
"referencedGrid": "Referenced Grid",
@@ -438,6 +452,12 @@
"center": "Center",
"right": "Right",
"defaultColor": "Default"
},
"image": {
"copiedToPasteBoard": "The image link has been copied to the clipboard"
},
"outline": {
"addHeadingToCreateOutline": "Add headings to create a table of contents."
}
}
},
@@ -468,5 +488,10 @@
"name": "Calendar layout"
},
"referencedCalendarPrefix": "View of"
},
"errorDialog": {
"title": "AppFlowy Error",
"howToFixFallback": "We're sorry for the inconvenience! Submit an issue on our GitHub page that describes your error.",
"github": "View on GitHub"
}
}
}
@@ -120,14 +120,14 @@
"oAuth": {
"err": {
"failedTitle": "Sikertelen bejelentkezés.",
"failedMsg": "Kérjük győződj meg róla, hogy elvégezted a bejelentkezési folyamatot a böngésződben"
"failedMsg": "Kérjük győződj meg róla, hogy elvégezted a bejelentkezési folyamatot a böngésződben!"
},
"google": {
"title": "Bejelentkezés Google-al",
"title": "Bejelentkezés Google-lal",
"instruction1": "Ahhoz, hogy hozzáférj a Google Kontaktjaidhoz, kérjük hatalmazd fel ezt az alkalmazást a böngésződben.",
"instruction2": "Másold ezt a kódot a vágólapra az ikonra kattintással vagy a szöveg kijelölésével:",
"instruction3": "Nyisd meg ezt a linket a böngésződben, és írjd be a fenti kódot:",
"instruction4": "Nyomd meg az alábbi gombot, ha elvégezted a registrációt:"
"instruction3": "Nyisd meg ezt a linket a böngésződben, és írd be a fenti kódot:",
"instruction4": "Nyomd meg az alábbi gombot, ha elvégezted a regisztrációt:"
}
},
"settings": {
@@ -139,15 +139,15 @@
},
"appearance": {
"themeMode": {
"label": "Theme Mode",
"label": "Téma",
"light": "Világos mód",
"dark": "Éjjeli mód",
"system": "Adapt to System"
"system": "Rendszerbeállítás követése"
}
}
},
"sideBar": {
"openSidebar": "Open sidebar",
"closeSidebar": "Close sidebar"
"openSidebar": "Oldalsáv megnyitása",
"closeSidebar": "Oldalsáv bezárása"
}
}
}
@@ -45,7 +45,14 @@
"medium": "средний",
"large": "большой",
"fontSize": "Размер шрифта",
"import": "Импортировать"
"import": "Импортировать",
"moreOptions": ""
},
"importPanel": {
"textAndMarkdown": "Текст и Markdown",
"documentFromV010": "Документ из v0.1.0",
"databaseFromV010": "База данных из v0.1.0",
"csv": "CSV"
},
"disclosureAction": {
"rename": "Переименовать",
@@ -108,8 +115,9 @@
"openAsPage": "Открыть как страницу",
"addNewRow": "Добавить новую строку",
"openMenu": "Открыть меню",
"viewDataBase": "Просмотр базы данных",
"referencePage": "Ссылаются на {name}"
"dragRow": "Долгое нажатие для изменения порядка строк",
"viewDataBase": "Открыть базу данных",
"referencePage": "Ссылки на {name}"
},
"sideBar": {
"closeSidebar": "Закрыть боковое меню",
@@ -123,7 +131,7 @@
},
"contactsPage": {
"title": "Контакты",
"whatsHappening": "Какие события на этой неделе?",
"whatsHappening": "Что нового на этой неделе?",
"addContact": "Добавить контакт",
"editContact": "Редактировать контакт"
},
@@ -182,15 +190,20 @@
"theme": "Тема"
},
"files": {
"copy": "Копировать",
"defaultLocation": "Где сейчас хранятся ваши данные",
"exportData": "Экспорт данных",
"doubleTapToCopy": "Нажмите дважды, чтобы скопировать путь",
"restoreLocation": "Восстановить путь AppFlowy по умолчанию",
"customizeLocation": "Открыть другую папку",
"restartApp": "Пожалуйста, перезапустите приложение, чтобы изменения вступили в силу.",
"exportDatabase": "Экспорт базы данных",
"selectFiles": "Выбрать файлы, которые необходимо экспортировать",
"selectAll": "Выбрать всё",
"deselectAll": "Снять выделение",
"createNewFolder": "Создать новую папку",
"createNewFolderDesc": "Указать, где хранить свои данные ...",
"defineWhereYourDataIsStored": "Указать хранилище данных",
"open": "Открыть",
"openFolder": "Открыть существующую папку",
"openFolderDesc": "Чтение и запись в существующую папку AppFlowy ...",
@@ -199,9 +212,17 @@
"locationDesc": "Выбрать имя папки данных AppFlowy",
"browser": "Обзор",
"create": "Создать",
"set": "Set",
"folderPath": "Путь к вашей папке",
"locationCannotBeEmpty": "Путь не может быть пустым",
"pathCopiedSnackbar": "Путь скопирован в буфер обмена!"
"pathCopiedSnackbar": "Путь скопирован в буфер обмена!",
"changeLocationTooltips": "Сменить местоположения",
"change": "Изменить",
"openLocationTooltips": "Открыть другую директорию данных",
"recoverLocationTooltips": "Сбросить к местоположению по умолчанию",
"exportFileSuccess": "Экспорт завершён!",
"exportFileFail": "Не удалость экспортировать!",
"export": "Экспорт"
},
"user": {
"name": "Имя",
@@ -221,7 +242,8 @@
"deleteFilter": "Удалить фильтр",
"filterBy": "Фильтровать по...",
"typeAValue": "Введите значение...",
"layout": "Раскладка"
"layout": "Вид",
"databaseLayout": "Вид базы данных"
},
"textFilter": {
"contains": "Содержит",
@@ -272,6 +294,8 @@
"textFieldName": "Текст",
"checkboxFieldName": "Чекбокс",
"dateFieldName": "Дата",
"updatedAtFieldName": "Последнее изменение",
"createdAtFieldName": "Создано",
"numberFieldName": "Число",
"singleSelectFieldName": "Выбор",
"multiSelectFieldName": "Выбор нескольких",
@@ -341,12 +365,16 @@
},
"slashMenu": {
"board": {
"selectABoardToLinkTo": "Выбрать доску для связи с",
"selectABoardToLinkTo": "Выбрать доску",
"createANewBoard": "Создать доску"
},
"grid": {
"selectAGridToLinkTo": "Выберите сетку для связи с",
"selectAGridToLinkTo": "Выберите сетку",
"createANewGrid": "Создать сетку"
},
"calendar": {
"selectACalendarToLinkTo": "Выбрать календарь",
"createANewCalendar": "Создать календарь"
}
},
"plugins": {
@@ -363,6 +391,8 @@
"smartEditFixSpelling": "Исправить правописание",
"warning": "⚠️ Ответы ИИ могут быть неправильными или неточными.",
"smartEditSummarize": "Выделить суть",
"smartEditImproveWriting": "Улучшить",
"smartEditMakeLonger": "Продолжить",
"smartEditCouldNotFetchResult": "Не могу получить ответ от OpenAI",
"smartEditCouldNotFetchKey": "Не могу получить токен OpenAI",
"smartEditDisabled": "Подключить OpenAI",
@@ -394,6 +424,21 @@
"mathEquation": {
"addMathEquation": "Добавить математическое выражение",
"editMathEquation": "Редактировать математическое выражение"
},
"optionAction": {
"click": "Кликните",
"toOpenMenu": " чтобы открыть меню",
"delete": "Удалить",
"duplicate": "Дублировать",
"turnInto": "Превратить",
"moveUp": "Поднять",
"moveDown": "Опустить",
"color": "Цвет",
"align": "Выравнивание",
"left": "По левому краю",
"center": "По центру",
"right": "По правому краю",
"defaultColor": "Цвет по умолчанию"
}
}
},
@@ -417,7 +462,12 @@
"showWeekNumbers": "Показывать номера недель",
"showWeekends": "Показывать выходные",
"firstDayOfWeek": "Первый день недели",
"layoutDateField": "Вид календаря"
}
"layoutDateField": "Вид календаря",
"noDateTitle": "No Date",
"noDateHint": "Unscheduled events will show up here",
"clickToAdd": "Click to add to the calendar",
"name": "Calendar layout"
},
"referencedCalendarPrefix": "Вид"
}
}
@@ -6,6 +6,8 @@
"subscribeNewsletterText": "消息订阅",
"letsGoButtonText": "开始",
"title": "标题",
"youCanAlso": "您还可以",
"and": "以及",
"signUp": {
"buttonText": "注册",
"title": "注册 @:appName 账户",
@@ -21,6 +23,7 @@
"signIn": {
"loginTitle": "登录 @:appName",
"loginButtonText": "登录",
"loginAsGuestButtonText": "开始使用",
"buttonText": "登录",
"forgotPassword": "忘记密码?",
"emailHint": "邮箱",
@@ -40,6 +43,21 @@
"markdown": "Markdown",
"copyLink": "复制链接"
},
"moreAction": {
"small": "小",
"medium": "中",
"large": "大",
"fontSize": "字号",
"import": "导入",
"moreOptions": "更多选项"
},
"importPanel": {
"textAndMarkdown": "文本 & Markdown",
"documentFromV010": "来自 v0.1.0 的文档",
"databaseFromV010": "来自 v0.1.0 的数据库",
"csv": "CSV",
"database": "数据库"
},
"disclosureAction": {
"rename": "重命名",
"delete": "删除",
@@ -54,7 +72,7 @@
"pageHeader": {
"fileName": "文件名",
"lastModified": "最近修改",
"created": "创建"
"created": "创建时间"
}
},
"deletePagePrompt": {
@@ -64,8 +82,10 @@
},
"dialogCreatePageNameHint": "页面名称",
"questionBubble": {
"shortcuts": "快捷键",
"whatsNew": "新功能?",
"help": "帮助 & 支持",
"markdown": "Markdown",
"debug": {
"name": "调试信息",
"success": "将调试信息复制到剪贴板!",
@@ -90,14 +110,18 @@
"inlineCode": "内联代码",
"quote": "块引用",
"header": "标题",
"highlight": "高亮"
"highlight": "高亮",
"color": "颜色"
},
"tooltip": {
"lightMode": "切换到亮色模式",
"darkMode": "切换到暗色模式",
"openAsPage": "作为页面打开",
"addNewRow": "增加一行",
"openMenu": "点击打开菜单"
"addNewRow": "添加一行",
"openMenu": "点击打开菜单",
"dragRow": "拖动来调整行",
"viewDataBase": "查看数据库",
"referencePage": "这个 {name} 正在被引用"
},
"sideBar": {
"openSidebar": "打开侧边栏",
@@ -117,11 +141,19 @@
},
"button": {
"OK": "确认",
"Done": "完成",
"Cancel": "取消",
"signIn": "登录",
"signOut": "登出",
"complete": "完成",
"save": "保存"
"save": "保存",
"generate": "生成",
"esc": "ESC",
"keep": "保留",
"tryAgain": "重试",
"discard": "放弃",
"replace": "替换",
"insertBelow": "下方插入"
},
"label": {
"welcome": "欢迎!",
@@ -149,37 +181,133 @@
"appearance": "外观",
"language": "语言",
"user": "用户",
"files": "文件",
"open": "打开设置"
},
"appearance": {
"themeMode": {
"label": "Theme Mode",
"label": "主题模式",
"light": "日间模式",
"dark": "夜间模式",
"system": "Adapt to System"
}
"system": "系统自适应"
},
"theme": "主题"
},
"files": {
"copy": "复制",
"defaultLocation": "读取文件和数据存储位置",
"exportData": "导出您的数据",
"doubleTapToCopy": "双击复制路径",
"restoreLocation": "恢复为 AppFlowy 默认路径",
"customizeLocation": "打开另一个文件夹",
"restartApp": "请重启 app 使设置生效",
"exportDatabase": "导出数据库",
"selectFiles": "选择需要导出的文件",
"selectAll": "全选",
"deselectAll": "取消全选",
"createNewFolder": "新建文件夹",
"createNewFolderDesc": "告诉我们您要将数据存储在何处",
"defineWhereYourDataIsStored": "定义数据存储位置",
"open": "打开",
"openFolder": "打开现有文件夹",
"openFolderDesc": "读取并将其写入您现有的 AppFlowy 文件夹",
"folderHintText": "文件夹名",
"location": "正在新建文件夹",
"locationDesc": "为您的 AppFlowy 数据文件夹选择一个名称",
"browser": "浏览",
"create": "新建",
"set": "设置",
"folderPath": "保存文件夹的路径",
"locationCannotBeEmpty": "路径不能为空",
"pathCopiedSnackbar": "文件存储路径已被复制到剪贴板!",
"changeLocationTooltips": "更改数据目录",
"change": "更改",
"openLocationTooltips": "打开另一个数据目录",
"openCurrentDataFolder": "打开当前数据目录",
"recoverLocationTooltips": "恢复为 AppFlowy 默认数据目录",
"exportFileSuccess": "导出文件成功!",
"exportFileFail": "导出文件失败!",
"export": "导出"
},
"user": {
"name": "名字",
"icon": "图标",
"selectAnIcon": "选择一个图标",
"pleaseInputYourOpenAIKey": "请输入您的 OpenAI 密钥"
}
},
"grid": {
"deleteView": "您确定要删除这个视窗吗?",
"createView": "新建",
"settings": {
"filter": "过滤器",
"filter": "筛选",
"sort": "以……排序",
"sortBy": "排序",
"Properties": "属性",
"group": "组"
"group": "组",
"addFilter": "添加筛选",
"deleteFilter": "删除筛选",
"filterBy": "以……筛选",
"typeAValue": "请输入一个值",
"layout": "布局",
"databaseLayout": "布局"
},
"textFilter": {
"contains": "包含",
"doesNotContain": "不包含",
"endsWith": "以……结束",
"startWith": "以……开始",
"is": "等于",
"isNot": "不等于",
"isEmpty": "为空",
"isNotEmpty": "不为空",
"choicechipPrefix": {
"isNot": "不等于",
"startWith": "以……开始",
"endWith": "以……结束",
"isEmpty": "为空",
"isNotEmpty": "不为空"
}
},
"checkboxFilter": {
"isChecked": "已勾选",
"isUnchecked": "未勾选",
"choicechipPrefix": {
"is": "is"
}
},
"checklistFilter": {
"isComplete": "已完成",
"isIncomplted": "未完成"
},
"singleSelectOptionFilter": {
"is": "等于",
"isNot": "不等于",
"isEmpty": "为空",
"isNotEmpty": "不为空"
},
"multiSelectOptionFilter": {
"contains": "包含",
"doesNotContain": "不包含",
"isEmpty": "为空",
"isNotEmpty": "不为空"
},
"field": {
"hide": "隐藏",
"insertLeft": "左侧插入",
"insertRight": "右侧插入",
"duplicate": "拷贝",
"duplicate": "复制",
"delete": "删除",
"textFieldName": "文本",
"checkboxFieldName": "勾选框",
"dateFieldName": "日期",
"updatedAtFieldName": "修改时间",
"createdAtFieldName": "创建时间",
"numberFieldName": "数字",
"singleSelectFieldName": "单项选择器",
"multiSelectFieldName": "多项选择器",
"urlFieldName": "链接",
"checklistFieldName": "清单",
"numberFormat": "数字格式",
"dateFormat": "日期格式",
"includeTime": "包含时间",
@@ -187,6 +315,7 @@
"dateFormatISO": "年-月-日",
"dateFormatLocal": "月/日/年",
"dateFormatUS": "年/月/日",
"dateFormatDayMonthYear": "日/月/年",
"timeFormat": "时间格式",
"invalidTimeFormat": "时间格式错误",
"timeFormatTwelveHour": "12小时制",
@@ -195,16 +324,23 @@
"optionTitle": "标签",
"addOption": "添加标签",
"editProperty": "编辑列属性",
"newProperty": "增加一列",
"newProperty": "添加一列",
"deleteFieldPromptMessage": "确定要删除这个属性吗? "
},
"sort": {
"ascending": "升序",
"descending": "降序",
"deleteSort": "删除排序",
"addSort": "添加排序"
},
"row": {
"duplicate": "复制",
"delete": "删除",
"textPlaceholder": "空",
"copyProperty": "复制列",
"count": "数量",
"newRow": "添加一行"
"newRow": "添加一行",
"action": "执行"
},
"selectOption": {
"create": "新建",
@@ -222,19 +358,130 @@
"panelTitle": "选择或新建一个标签",
"searchOption": "搜索标签"
},
"menuName": "网格"
"checklist": {
"panelTitle": "添加项"
},
"menuName": "网格",
"referencedGridPrefix": "视图"
},
"document": {
"menuName": "文档",
"date": {
"timeHintTextInTwelveHour": "01:00 PM",
"timeHintTextInTwentyFourHour": "13:00"
},
"slashMenu": {
"board": {
"selectABoardToLinkTo": "选择要链接到的看板",
"createANewBoard": "新建看板"
},
"grid": {
"selectAGridToLinkTo": "选择要链接到的网格",
"createANewGrid": "新建网格"
},
"calendar": {
"selectACalendarToLinkTo": "选择要链接到的日历",
"createANewCalendar": "新建日历"
}
},
"plugins": {
"referencedBoard": "引用的看板",
"referencedGrid": "引用的网格",
"referencedCalendar": "引用的日历",
"autoGeneratorMenuItemName": "OpenAI 创作",
"autoGeneratorTitleName": "OpenAI: 让 AI 写些什么...",
"autoGeneratorLearnMore": "学习更多",
"autoGeneratorGenerate": "生成",
"autoGeneratorHintText": "让 OpenAI ...",
"autoGeneratorCantGetOpenAIKey": "无法获得 OpenAI 密钥",
"autoGeneratorRewrite": "重写",
"smartEdit": "AI 助手",
"openAI": "OpenAI",
"smartEditFixSpelling": "修正拼写",
"warning": "⚠️ AI 可能不准确或具有误导性.",
"smartEditSummarize": "总结",
"smartEditImproveWriting": "提高写作水平",
"smartEditMakeLonger": "丰富内容",
"smartEditCouldNotFetchResult": "无法从 OpenAI 获取到结果",
"smartEditCouldNotFetchKey": "无法获取到 OpenAI 密钥",
"smartEditDisabled": "在设置中连接 OpenAI",
"discardResponse": "您是否要放弃 AI 继续写作?",
"cover": {
"changeCover": "修改封面",
"colors": "颜色",
"images": "图像",
"clearAll": "清除所有",
"abstract": "摘要",
"addCover": "添加封面",
"addLocalImage": "添加本地图像",
"invalidImageUrl": "无效的图像网址",
"failedToAddImageToGallery": "无法将图片添加到库",
"enterImageUrl": "输入图像网址",
"add": "添加",
"back": "返回",
"saveToGallery": "保存至库",
"removeIcon": "移除图标",
"pasteImageUrl": "粘贴图片网址",
"or": "或",
"pickFromFiles": "从文件中选取",
"couldNotFetchImage": "无法获取到图像",
"imageSavingFailed": "图像保存失败",
"addIcon": "添加图标",
"coverRemoveAlert": "删除后将从封面中移除。",
"alertDialogConfirmation": "您确定您要继续吗?"
},
"mathEquation": {
"addMathEquation": "添加数学公式",
"editMathEquation": "编辑数学公式"
},
"optionAction": {
"click": "点击",
"toOpenMenu": " 来打开菜单",
"delete": "删除",
"duplicate": "复制",
"turnInto": "变成",
"moveUp": "上移",
"moveDown": "下移",
"color": "颜色",
"align": "对齐",
"left": "左",
"center": "中心",
"right": "又",
"defaultColor": "默认"
}
}
},
"board": {
"column": {
"create_new_card": "新建"
},
"menuName": "看板"
"menuName": "看板",
"referencedBoardPrefix": "视图"
},
"calendar": {
"menuName": "日历",
"defaultNewCalendarTitle": "未命名",
"navigation": {
"today": "今天",
"jumpToday": "跳转到今天",
"previousMonth": "上一月",
"nextMonth": "下一月"
},
"settings": {
"showWeekNumbers": "显示周数",
"showWeekends": "显示周末",
"firstDayOfWeek": "一周开始于",
"layoutDateField": "以……为日历布局",
"noDateTitle": "没有日期",
"noDateHint": "计划外事件将显示在此处",
"clickToAdd": "单击以添加到日历",
"name": "日历布局"
},
"referencedCalendarPrefix": "视图"
},
"errorDialog": {
"title": "AppFlowy 错误",
"howToFixFallback": "对于给您带来的不便, 我们深表歉意! 请在我们的 GitHub 页面上提交 issue 并描述您遇到的错误。",
"github": "在 GitHub 查看"
}
}
}
@@ -1,35 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('cover image', () {
const location = 'cover_image';
setUp(() async {
await TestFolder.cleanTestLocation(location);
await TestFolder.setTestLocation(location);
});
tearDown(() async {
await TestFolder.cleanTestLocation(location);
});
tearDownAll(() async {
await TestFolder.cleanTestLocation(null);
});
testWidgets(
'hovering on cover image will display change and delete cover image buttons',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.hoverOnCoverPluginAddButton();
await tester.expectToSeePluginAddCoverAndIconButton();
});
});
}
@@ -0,0 +1,264 @@
import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pbenum.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'util/database_test_op.dart';
import 'util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('database', () {
const location = 'appflowy';
setUp(() async {
await TestFolder.cleanTestLocation(location);
await TestFolder.setTestLocation(location);
});
tearDown(() async {
await TestFolder.cleanTestLocation(location);
});
tearDownAll(() async {
await TestFolder.cleanTestLocation(null);
});
testWidgets('update calendar layout', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateCalendarButton();
// open setting
await tester.tapDatabaseSettingButton();
await tester.tapDatabaseLayoutButton();
await tester.selectDatabaseLayoutType(DatabaseLayoutPB.Board);
await tester.assertCurrentDatabaseLayoutType(DatabaseLayoutPB.Board);
await tester.tapDatabaseSettingButton();
await tester.tapDatabaseLayoutButton();
await tester.selectDatabaseLayoutType(DatabaseLayoutPB.Grid);
await tester.assertCurrentDatabaseLayoutType(DatabaseLayoutPB.Grid);
await tester.pumpAndSettle();
});
testWidgets('calendar start from day setting', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// Create calendar view
await tester.createNewPageWithName(ViewLayoutPB.Calendar, 'calendar');
// Open setting
await tester.tapDatabaseSettingButton();
await tester.tapCalendarLayoutSettingButton();
// select the first day of week is Monday
await tester.tapFirstDayOfWeek();
await tester.tapFirstDayOfWeekStartFromMonday();
// Open the other page and open the new calendar page again
await tester.openPage(readme);
await tester.pumpAndSettle(const Duration(milliseconds: 300));
await tester.openPage('calendar');
// Open setting again and check the start from Monday is selected
await tester.tapDatabaseSettingButton();
await tester.tapCalendarLayoutSettingButton();
await tester.tapFirstDayOfWeek();
tester.assertFirstDayOfWeekStartFromMonday();
await tester.pumpAndSettle();
});
testWidgets('create new calendar event using new button', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// Create the calendar view
await tester.tapAddButton();
await tester.tapCreateCalendarButton();
// Scroll until today's date cell is visible
await tester.scrollToToday();
// Hover over today's calendar cell
await tester.hoverOnTodayCalendarCell();
// Tap on create new event button
await tester.tapAddCalendarEventButton();
// Make sure that the row details page is opened
tester.assertRowDetailPageOpened();
// Dismiss the row details page
await tester.dismissRowDetailPage();
// Make sure that the event is inserted in the cell
tester.assertNumberOfEventsInCalendar(1);
// Create another event on the same day
await tester.hoverOnTodayCalendarCell();
await tester.tapAddCalendarEventButton();
await tester.dismissRowDetailPage();
tester.assertNumberOfEventsInCalendar(2);
tester.assertNumberofEventsOnSpecificDay(2, DateTime.now());
});
testWidgets('create new calendar event by double-clicking', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// Create the calendar view
await tester.tapAddButton();
await tester.tapCreateCalendarButton();
// Scroll until today's date cell is visible
await tester.scrollToToday();
// Double click on today's calendar cell to create a new event
await tester.doubleClickCalendarCell(DateTime.now());
// Make sure that the row details page is opened
tester.assertRowDetailPageOpened();
// Dismiss the row details page
await tester.dismissRowDetailPage();
// Make sure that the event is inserted in the cell
tester.assertNumberOfEventsInCalendar(1);
// Create another event on the same day
await tester.doubleClickCalendarCell(DateTime.now());
await tester.dismissRowDetailPage();
tester.assertNumberOfEventsInCalendar(2);
tester.assertNumberofEventsOnSpecificDay(2, DateTime.now());
});
testWidgets('duplicate/delete a calendar event', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// Create the calendar view
await tester.tapAddButton();
await tester.tapCreateCalendarButton();
// Create a new event in today's calendar cell
await tester.scrollToToday();
await tester.doubleClickCalendarCell(DateTime.now());
// Duplicate the event
await tester.tapRowDetailPageDuplicateRowButton();
await tester.dismissRowDetailPage();
// Check that there are 2 events
tester.assertNumberOfEventsInCalendar(2);
// Delete an event
await tester.openCalendarEvent(index: 0);
await tester.tapRowDetailPageDeleteRowButton();
// Check that there is 1 event
tester.assertNumberOfEventsInCalendar(1);
});
testWidgets('edit the title of a calendar date event', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// Create the calendar view
await tester.tapAddButton();
await tester.tapCreateCalendarButton();
// Create a new event in today's calendar cell
await tester.scrollToToday();
await tester.doubleClickCalendarCell(DateTime.now());
await tester.dismissRowDetailPage();
// Click on the event
await tester.openCalendarEvent(index: 0);
// Make sure that the row details page is opened
tester.assertRowDetailPageOpened();
// Change the title of the event
await tester.editTitleInRowDetailPage('hello world');
// Dismiss the row details page
await tester.dismissRowDetailPage();
// Make sure that the event is edited
tester.assertNumberOfEventsInCalendar(1, title: 'hello world');
});
testWidgets(
'edit the date of the date field being used to layout the calendar',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// Create the calendar view
await tester.tapAddButton();
await tester.tapCreateCalendarButton();
// Create a new event in today's calendar cell
final today = DateTime.now();
await tester.scrollToToday();
await tester.doubleClickCalendarCell(today);
await tester.dismissRowDetailPage();
// Make sure that the event is today
tester.assertNumberofEventsOnSpecificDay(1, today);
// Click on the event
await tester.openCalendarEvent(index: 0);
// Open the date editor of the event
await tester.tapDateCellInRowDetailPage();
await tester.findDateEditor(findsOneWidget);
// Edit the event's date. To avoid selecting a day outside of the current month, the new date will be one day closer to the middle of the month.
final newDate = today.day < 15
? today.add(const Duration(days: 1))
: today.subtract(const Duration(days: 1));
await tester.selectDay(content: newDate.day);
await tester.dismissCellEditor();
// Dismiss the row details page
await tester.dismissRowDetailPage();
// Make sure that the event is edited
tester.assertNumberOfEventsInCalendar(1);
tester.assertNumberofEventsOnSpecificDay(1, newDate);
});
testWidgets('reschedule an event by drag-and-drop', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// Create the calendar view
await tester.tapAddButton();
await tester.tapCreateCalendarButton();
// Create a new event on the first of this month
final today = DateTime.now();
final firstOfThisMonth = DateTime(today.year, today.month, 1);
await tester.doubleClickCalendarCell(firstOfThisMonth);
await tester.dismissRowDetailPage();
// Drag and drop the event onto the next week, same day
await tester.dragDropRescheduleCalendarEvent(firstOfThisMonth);
// Make sure that the event has been rescheduled to the new date
tester.assertNumberOfEventsInCalendar(1);
tester.assertNumberofEventsOnSpecificDay(
1,
firstOfThisMonth.add(const Duration(days: 7)),
);
});
});
}
@@ -0,0 +1,452 @@
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:intl/intl.dart';
import 'util/database_test_op.dart';
import 'util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('grid cell', () {
const location = 'appflowy';
setUp(() async {
await TestFolder.cleanTestLocation(location);
await TestFolder.setTestLocation(location);
});
tearDown(() async {
await TestFolder.cleanTestLocation(location);
});
tearDownAll(() async {
await TestFolder.cleanTestLocation(null);
});
testWidgets('edit text cell', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
await tester.editCell(
rowIndex: 0,
fieldType: FieldType.RichText,
input: 'hello world',
);
await tester.assertCellContent(
rowIndex: 0,
fieldType: FieldType.RichText,
content: 'hello world',
);
await tester.pumpAndSettle();
});
// Makesure the text cells are filled with the right content when there are
// multiple text cell
testWidgets('edit multiple text cells', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.createNewPageWithName(ViewLayoutPB.Grid, 'my grid');
await tester.createField(FieldType.RichText, 'description');
await tester.editCell(
rowIndex: 0,
fieldType: FieldType.RichText,
input: 'hello',
);
await tester.editCell(
rowIndex: 0,
fieldType: FieldType.RichText,
input: 'world',
cellIndex: 1,
);
await tester.assertCellContent(
rowIndex: 0,
fieldType: FieldType.RichText,
content: 'hello',
cellIndex: 0,
);
await tester.assertCellContent(
rowIndex: 0,
fieldType: FieldType.RichText,
content: 'world',
cellIndex: 1,
);
await tester.pumpAndSettle();
});
testWidgets('edit number cell', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
const fieldType = FieldType.Number;
// Create a number field
await tester.createField(fieldType, fieldType.name);
await tester.editCell(
rowIndex: 0,
fieldType: fieldType,
input: '-1',
);
// edit the next cell to force the previous cell at row 0 to lose focus
await tester.editCell(
rowIndex: 1,
fieldType: fieldType,
input: '0.2',
);
// -1 -> -1
await tester.assertCellContent(
rowIndex: 0,
fieldType: fieldType,
content: '-1',
);
// edit the next cell to force the previous cell at row 1 to lose focus
await tester.editCell(
rowIndex: 2,
fieldType: fieldType,
input: '.1',
);
// 0.2 -> 0.2
await tester.assertCellContent(
rowIndex: 1,
fieldType: fieldType,
content: '0.2',
);
// edit the next cell to force the previous cell at row 2 to lose focus
await tester.editCell(
rowIndex: 0,
fieldType: fieldType,
input: '',
);
// .1 -> 0.1
await tester.assertCellContent(
rowIndex: 2,
fieldType: fieldType,
content: '0.1',
);
await tester.pumpAndSettle();
});
testWidgets('edit checkbox cell', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
await tester.assertCheckboxCell(rowIndex: 0, isSelected: false);
await tester.tapCheckboxCellInGrid(rowIndex: 0);
await tester.assertCheckboxCell(rowIndex: 0, isSelected: true);
await tester.tapCheckboxCellInGrid(rowIndex: 1);
await tester.tapCheckboxCellInGrid(rowIndex: 2);
await tester.assertCheckboxCell(rowIndex: 1, isSelected: true);
await tester.assertCheckboxCell(rowIndex: 2, isSelected: true);
await tester.pumpAndSettle();
});
testWidgets('edit create time cell', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
const fieldType = FieldType.CreatedTime;
// Create a create time field
// The create time field is not editable
await tester.createField(fieldType, fieldType.name);
await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType);
await tester.findDateEditor(findsNothing);
await tester.pumpAndSettle();
});
testWidgets('edit last time cell', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
const fieldType = FieldType.LastEditedTime;
// Create a last time field
// The last time field is not editable
await tester.createField(fieldType, fieldType.name);
await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType);
await tester.findDateEditor(findsNothing);
await tester.pumpAndSettle();
});
testWidgets('edit time cell', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
const fieldType = FieldType.DateTime;
await tester.createField(fieldType, fieldType.name);
// Tap the cell to invoke the field editor
await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType);
await tester.findDateEditor(findsOneWidget);
// Toggle include time
await tester.toggleIncludeTime();
// Dismiss the cell editor
await tester.dismissCellEditor();
await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType);
await tester.findDateEditor(findsOneWidget);
// Turn off include time
await tester.toggleIncludeTime();
// Select a date
final today = DateTime.now();
await tester.selectDay(content: today.day);
await tester.dismissCellEditor();
await tester.assertDateCellInGrid(
rowIndex: 0,
fieldType: fieldType,
content: DateFormat('MMM dd, y').format(today),
);
await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType);
await tester.findDateEditor(findsOneWidget);
// Toggle include time
final now = DateTime.now();
await tester.toggleIncludeTime();
await tester.dismissCellEditor();
await tester.assertDateCellInGrid(
rowIndex: 0,
fieldType: fieldType,
content: DateFormat('MMM dd, y HH:mm').format(now),
);
await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType);
await tester.findDateEditor(findsOneWidget);
// Change date format
await tester.changeDateFormat();
await tester.dismissCellEditor();
await tester.assertDateCellInGrid(
rowIndex: 0,
fieldType: fieldType,
content: DateFormat('dd/MM/y HH:mm').format(now),
);
await tester.tapCellInGrid(rowIndex: 0, fieldType: fieldType);
await tester.findDateEditor(findsOneWidget);
// Change time format
await tester.changeTimeFormat();
await tester.dismissCellEditor();
await tester.assertDateCellInGrid(
rowIndex: 0,
fieldType: fieldType,
content: DateFormat('dd/MM/y hh:mm a').format(now),
);
await tester.pumpAndSettle();
});
testWidgets('edit single select cell', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
const fieldType = FieldType.SingleSelect;
await tester.tapAddButton();
// When create a grid, it will create a single select field by default
await tester.tapCreateGridButton();
// Tap the cell to invoke the selection option editor
await tester.tapSelectOptionCellInGrid(rowIndex: 0, fieldType: fieldType);
await tester.findSelectOptionEditor(findsOneWidget);
// Create a new select option
await tester.createOption(name: 'tag 1');
await tester.dismissCellEditor();
// Make sure the option is created and displayed in the cell
await tester.findSelectOptionWithNameInGrid(
rowIndex: 0,
name: 'tag 1',
);
await tester.tapSelectOptionCellInGrid(rowIndex: 0, fieldType: fieldType);
await tester.findSelectOptionEditor(findsOneWidget);
// Create another select option
await tester.createOption(name: 'tag 2');
await tester.dismissCellEditor();
await tester.findSelectOptionWithNameInGrid(
rowIndex: 0,
name: 'tag 2',
);
await tester.assertNumberOfSelectedOptionsInGrid(
rowIndex: 0,
matcher: findsOneWidget,
);
await tester.tapSelectOptionCellInGrid(rowIndex: 0, fieldType: fieldType);
await tester.findSelectOptionEditor(findsOneWidget);
// switch to first option
await tester.selectOption(name: 'tag 1');
await tester.dismissCellEditor();
await tester.findSelectOptionWithNameInGrid(
rowIndex: 0,
name: 'tag 1',
);
await tester.assertNumberOfSelectedOptionsInGrid(
rowIndex: 0,
matcher: findsOneWidget,
);
await tester.tapSelectOptionCellInGrid(rowIndex: 0, fieldType: fieldType);
await tester.findSelectOptionEditor(findsOneWidget);
// Deselect the currently-selected option
await tester.selectOption(name: 'tag 1');
await tester.dismissCellEditor();
await tester.assertNumberOfSelectedOptionsInGrid(
rowIndex: 0,
matcher: findsNothing,
);
await tester.pumpAndSettle();
});
testWidgets('edit multi select cell', (tester) async {
final tags = [
'tag 1',
'tag 2',
'tag 3',
'tag 4',
];
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
const fieldType = FieldType.MultiSelect;
await tester.createField(fieldType, fieldType.name);
// Tap the cell to invoke the selection option editor
await tester.tapSelectOptionCellInGrid(rowIndex: 0, fieldType: fieldType);
await tester.findSelectOptionEditor(findsOneWidget);
// Create a new select option
await tester.createOption(name: tags.first);
await tester.dismissCellEditor();
// Make sure the option is created and displayed in the cell
await tester.findSelectOptionWithNameInGrid(
rowIndex: 0,
name: tags.first,
);
await tester.tapSelectOptionCellInGrid(rowIndex: 0, fieldType: fieldType);
await tester.findSelectOptionEditor(findsOneWidget);
// Create some other select options
await tester.createOption(name: tags[1]);
await tester.createOption(name: tags[2]);
await tester.createOption(name: tags[3]);
await tester.dismissCellEditor();
for (final tag in tags) {
await tester.findSelectOptionWithNameInGrid(
rowIndex: 0,
name: tag,
);
}
await tester.assertNumberOfSelectedOptionsInGrid(
rowIndex: 0,
matcher: findsNWidgets(4),
);
await tester.tapSelectOptionCellInGrid(rowIndex: 0, fieldType: fieldType);
await tester.findSelectOptionEditor(findsOneWidget);
// Deselect all options
for (final tag in tags) {
await tester.selectOption(name: tag);
}
await tester.dismissCellEditor();
await tester.assertNumberOfSelectedOptionsInGrid(
rowIndex: 0,
matcher: findsNothing,
);
await tester.tapSelectOptionCellInGrid(rowIndex: 0, fieldType: fieldType);
await tester.findSelectOptionEditor(findsOneWidget);
// Select some options
await tester.selectOption(name: tags[1]);
await tester.selectOption(name: tags[3]);
await tester.dismissCellEditor();
await tester.findSelectOptionWithNameInGrid(
rowIndex: 0,
name: tags[1],
);
await tester.findSelectOptionWithNameInGrid(
rowIndex: 0,
name: tags[3],
);
await tester.assertNumberOfSelectedOptionsInGrid(
rowIndex: 0,
matcher: findsNWidgets(2),
);
await tester.pumpAndSettle();
});
});
}
@@ -0,0 +1,201 @@
import 'package:appflowy/plugins/database_view/grid/presentation/grid_page.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'util/database_test_op.dart';
import 'util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('grid page', () {
const location = 'appflowy';
setUp(() async {
await TestFolder.cleanTestLocation(location);
await TestFolder.setTestLocation(location);
});
tearDown(() async {
await TestFolder.cleanTestLocation(location);
});
tearDownAll(() async {
await TestFolder.cleanTestLocation(null);
});
testWidgets('rename existing field', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
// Invoke the field editor
await tester.tapGridFieldWithName('Name');
await tester.tapEditPropertyButton();
await tester.renameField('hello world');
await tester.dismissFieldEditor();
await tester.tapGridFieldWithName('hello world');
await tester.pumpAndSettle();
});
testWidgets('update field type of existing field', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
// Invoke the field editor
await tester.tapGridFieldWithName('Type');
await tester.tapEditPropertyButton();
await tester.tapTypeOptionButton();
await tester.selectFieldType(FieldType.Checkbox);
await tester.dismissFieldEditor();
await tester.assertFieldTypeWithFieldName(
'Type',
FieldType.Checkbox,
);
await tester.pumpAndSettle();
});
testWidgets('create a field and rename it', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// create a new grid
await tester.tapAddButton();
await tester.tapCreateGridButton();
// create a field
await tester.createField(FieldType.Checklist, 'checklist');
// check the field is created successfully
await tester.findFieldWithName('checklist');
await tester.pumpAndSettle();
});
testWidgets('delete field', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
// create a field
await tester.createField(FieldType.Checkbox, 'New field 1');
// Delete the field
await tester.tapGridFieldWithName('New field 1');
await tester.tapDeletePropertyButton();
// confirm delete
await tester.tapDialogOkButton();
await tester.noFieldWithName('New field 1');
await tester.pumpAndSettle();
});
testWidgets('duplicate field', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
// create a field
await tester.scrollToRight(find.byType(GridPage));
await tester.tapNewPropertyButton();
await tester.renameField('New field 1');
await tester.dismissFieldEditor();
// Delete the field
await tester.tapGridFieldWithName('New field 1');
await tester.tapDuplicatePropertyButton();
await tester.findFieldWithName('New field 1 (copy)');
await tester.pumpAndSettle();
});
testWidgets('hide field', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
// create a field
await tester.scrollToRight(find.byType(GridPage));
await tester.tapNewPropertyButton();
await tester.renameField('New field 1');
await tester.dismissFieldEditor();
// Delete the field
await tester.tapGridFieldWithName('New field 1');
await tester.tapHidePropertyButton();
await tester.noFieldWithName('New field 1');
await tester.pumpAndSettle();
});
testWidgets('create checklist field ', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
await tester.scrollToRight(find.byType(GridPage));
await tester.tapNewPropertyButton();
// Open the type option menu
await tester.tapTypeOptionButton();
await tester.selectFieldType(FieldType.Checklist);
// After update the field type, the cells should be updated
await tester.findCellByFieldType(FieldType.Checklist);
await tester.pumpAndSettle();
});
testWidgets('create list of fields', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
for (final fieldType in [
FieldType.Checklist,
FieldType.DateTime,
FieldType.Number,
FieldType.URL,
FieldType.MultiSelect,
FieldType.LastEditedTime,
FieldType.CreatedTime,
FieldType.Checkbox,
]) {
await tester.scrollToRight(find.byType(GridPage));
await tester.tapNewPropertyButton();
await tester.renameField(fieldType.name);
// Open the type option menu
await tester.tapTypeOptionButton();
await tester.selectFieldType(fieldType);
await tester.dismissFieldEditor();
// After update the field type, the cells should be updated
await tester.findCellByFieldType(fieldType);
await tester.pumpAndSettle();
}
});
});
}
@@ -0,0 +1,158 @@
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/filter/choicechip/checkbox.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/filter/choicechip/text.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'util/database_test_op.dart';
import 'util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('grid', () {
const location = 'import_files';
setUp(() async {
await TestFolder.cleanTestLocation(location);
await TestFolder.setTestLocation(location);
});
tearDown(() async {
await TestFolder.cleanTestLocation(location);
});
tearDownAll(() async {
await TestFolder.cleanTestLocation(null);
});
testWidgets('add text filter', (tester) async {
await tester.openV020database();
// create a filter
await tester.tapDatabaseFilterButton();
await tester.tapCreateFilterByFieldType(FieldType.RichText, 'Name');
await tester.tapFilterButtonInGrid('Name');
// enter 'A' in the filter text field
await tester.assertNumberOfRowsInGridPage(10);
await tester.enterTextInTextFilter('A');
await tester.assertNumberOfRowsInGridPage(1);
// after remove the filter, the grid should show all rows
await tester.enterTextInTextFilter('');
await tester.assertNumberOfRowsInGridPage(10);
await tester.enterTextInTextFilter('B');
await tester.assertNumberOfRowsInGridPage(1);
// open the menu to delete the filter
await tester.tapDisclosureButtonInFinder(find.byType(TextFilterEditor));
await tester.tapDeleteFilterButtonInGrid();
await tester.assertNumberOfRowsInGridPage(10);
await tester.pumpAndSettle();
});
testWidgets('add checkbox filter', (tester) async {
await tester.openV020database();
// create a filter
await tester.tapDatabaseFilterButton();
await tester.tapCreateFilterByFieldType(FieldType.Checkbox, 'Done');
await tester.assertNumberOfRowsInGridPage(5);
await tester.tapFilterButtonInGrid('Done');
await tester.tapCheckboxFilterButtonInGrid();
await tester.tapUnCheckedButtonOnCheckboxFilter();
await tester.assertNumberOfRowsInGridPage(5);
await tester
.tapDisclosureButtonInFinder(find.byType(CheckboxFilterEditor));
await tester.tapDeleteFilterButtonInGrid();
await tester.assertNumberOfRowsInGridPage(10);
await tester.pumpAndSettle();
});
testWidgets('add checklist filter', (tester) async {
await tester.openV020database();
// create a filter
await tester.tapDatabaseFilterButton();
await tester.tapCreateFilterByFieldType(FieldType.Checklist, 'checklist');
// By default, the condition of checklist filter is 'uncompleted'
await tester.assertNumberOfRowsInGridPage(9);
await tester.tapFilterButtonInGrid('checklist');
await tester.tapChecklistFilterButtonInGrid();
await tester.tapCompletedButtonOnChecklistFilter();
await tester.assertNumberOfRowsInGridPage(1);
await tester.pumpAndSettle();
});
testWidgets('add single select filter', (tester) async {
await tester.openV020database();
// create a filter
await tester.tapDatabaseFilterButton();
await tester.tapCreateFilterByFieldType(FieldType.SingleSelect, 'Type');
await tester.tapFilterButtonInGrid('Type');
// select the option 's6'
await tester.tapOptionFilterWithName('s6');
await tester.assertNumberOfRowsInGridPage(0);
// unselect the option 's6'
await tester.tapOptionFilterWithName('s6');
await tester.assertNumberOfRowsInGridPage(10);
// select the option 's5'
await tester.tapOptionFilterWithName('s5');
await tester.assertNumberOfRowsInGridPage(1);
// select the option 's4'
await tester.tapOptionFilterWithName('s4');
// The row with 's4' or 's5' should be shown.
await tester.assertNumberOfRowsInGridPage(2);
await tester.pumpAndSettle();
});
testWidgets('add multi select filter', (tester) async {
await tester.openV020database();
// create a filter
await tester.tapDatabaseFilterButton();
await tester.tapCreateFilterByFieldType(
FieldType.MultiSelect,
'multi-select',
);
await tester.tapFilterButtonInGrid('multi-select');
await tester.scrollOptionFilterListByOffset(const Offset(0, -200));
// select the option 'm1'. Any option with 'm1' should be shown.
await tester.tapOptionFilterWithName('m1');
await tester.assertNumberOfRowsInGridPage(5);
await tester.tapOptionFilterWithName('m1');
// select the option 'm2'. Any option with 'm2' should be shown.
await tester.tapOptionFilterWithName('m2');
await tester.assertNumberOfRowsInGridPage(4);
await tester.tapOptionFilterWithName('m2');
// select the option 'm4'. Any option with 'm4' should be shown.
await tester.tapOptionFilterWithName('m4');
await tester.assertNumberOfRowsInGridPage(1);
await tester.pumpAndSettle();
});
});
}
@@ -0,0 +1,245 @@
import 'package:appflowy/plugins/database_view/widgets/row/row_banner.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'util/database_test_op.dart';
import 'util/emoji.dart';
import 'util/ime.dart';
import 'util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('grid', () {
const location = 'appflowy';
setUp(() async {
await TestFolder.cleanTestLocation(location);
await TestFolder.setTestLocation(location);
});
tearDown(() async {
await TestFolder.cleanTestLocation(location);
});
tearDownAll(() async {
await TestFolder.cleanTestLocation(null);
});
testWidgets('row details page opens', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// Create a new grid
await tester.tapAddButton();
await tester.tapCreateGridButton();
// Hover first row and then open the row page
await tester.openFirstRowDetailPage();
// Make sure that the row page is opened
tester.assertRowDetailPageOpened();
});
testWidgets('insert emoji in the row detail page', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// Create a new grid
await tester.tapAddButton();
await tester.tapCreateGridButton();
// Hover first row and then open the row page
await tester.openFirstRowDetailPage();
await tester.hoverRowBanner();
await tester.openEmojiPicker();
await tester.switchToEmojiList();
await tester.tapEmoji('😀');
// After select the emoji, the EmojiButton will show up
await tester.tapButton(find.byType(EmojiButton));
});
testWidgets('update emoji in the row detail page', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// Create a new grid
await tester.tapAddButton();
await tester.tapCreateGridButton();
// Hover first row and then open the row page
await tester.openFirstRowDetailPage();
await tester.hoverRowBanner();
await tester.openEmojiPicker();
await tester.switchToEmojiList();
await tester.tapEmoji('😀');
// Update existing selected emoji
await tester.tapButton(find.byType(EmojiButton));
await tester.switchToEmojiList();
await tester.tapEmoji('😅');
// The emoji already displayed in the row banner
final emojiText = find.byWidgetPredicate(
(widget) => widget is FlowyText && widget.text == '😅',
);
// The number of emoji should be two. One in the row displayed in the grid
// one in the row detail page.
expect(emojiText, findsNWidgets(2));
});
testWidgets('remove emoji in the row detail page', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// Create a new grid
await tester.tapAddButton();
await tester.tapCreateGridButton();
// Hover first row and then open the row page
await tester.openFirstRowDetailPage();
await tester.hoverRowBanner();
await tester.openEmojiPicker();
await tester.switchToEmojiList();
await tester.tapEmoji('😀');
// Remove the emoji
await tester.tapButton(find.byType(RemoveEmojiButton));
final emojiText = find.byWidgetPredicate(
(widget) => widget is FlowyText && widget.text == '😀',
);
expect(emojiText, findsNothing);
});
testWidgets('create list of fields in row detail page', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// Create a new grid
await tester.tapAddButton();
await tester.tapCreateGridButton();
// Hover first row and then open the row page
await tester.openFirstRowDetailPage();
for (final fieldType in [
FieldType.Checklist,
FieldType.DateTime,
FieldType.Number,
FieldType.URL,
FieldType.MultiSelect,
FieldType.LastEditedTime,
FieldType.CreatedTime,
FieldType.Checkbox,
]) {
await tester.tapRowDetailPageCreatePropertyButton();
await tester.renameField(fieldType.name);
// Open the type option menu
await tester.tapTypeOptionButton();
await tester.selectFieldType(fieldType);
await tester.dismissFieldEditor();
// After update the field type, the cells should be updated
await tester.findCellByFieldType(fieldType);
await tester.scrollRowDetailByOffset(const Offset(0, -50));
}
});
testWidgets('check document is exist in row detail page', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// Create a new grid
await tester.tapAddButton();
await tester.tapCreateGridButton();
// Hover first row and then open the row page
await tester.openFirstRowDetailPage();
// Each row detail page should have a document
await tester.assertDocumentExistInRowDetailPage();
});
testWidgets('update the content of the document and re-open it',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// Create a new grid
await tester.tapAddButton();
await tester.tapCreateGridButton();
// Hover first row and then open the row page
await tester.openFirstRowDetailPage();
// Wait for the document to be loaded
await tester.wait(500);
// Focus on the editor
final textBlock = find.byType(TextBlockComponentWidget);
await tester.tapAt(tester.getCenter(textBlock));
// Input some text
const inputText = 'Hello world';
await tester.ime.insertText(inputText);
expect(
find.textContaining(inputText, findRichText: true),
findsOneWidget,
);
// Tap outside to dismiss the field
await tester.tapAt(Offset.zero);
await tester.pumpAndSettle();
// Re-open the document
await tester.openFirstRowDetailPage();
expect(
find.textContaining(inputText, findRichText: true),
findsOneWidget,
);
});
testWidgets('delete row in row detail page', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// Create a new grid
await tester.tapAddButton();
await tester.tapCreateGridButton();
// Hover first row and then open the row page
await tester.openFirstRowDetailPage();
await tester.tapRowDetailPageDeleteRowButton();
await tester.tapEscButton();
await tester.assertNumberOfRowsInGridPage(2);
});
testWidgets('duplicate row in row detail page', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// Create a new grid
await tester.tapAddButton();
await tester.tapCreateGridButton();
// Hover first row and then open the row page
await tester.openFirstRowDetailPage();
await tester.tapRowDetailPageDuplicateRowButton();
await tester.tapEscButton();
await tester.assertNumberOfRowsInGridPage(4);
});
});
}
@@ -0,0 +1,85 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'util/database_test_op.dart';
import 'util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('grid', () {
const location = 'appflowy';
setUp(() async {
await TestFolder.cleanTestLocation(location);
await TestFolder.setTestLocation(location);
});
tearDown(() async {
await TestFolder.cleanTestLocation(location);
});
tearDownAll(() async {
await TestFolder.cleanTestLocation(null);
});
testWidgets('create row of the grid', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
await tester.tapCreateRowButtonInGrid();
// The initial number of rows is 3
await tester.assertNumberOfRowsInGridPage(4);
await tester.pumpAndSettle();
});
testWidgets('create row from row menu of the grid', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
await tester.hoverOnFirstRowOfGrid();
await tester.tapCreateRowButtonInRowMenuOfGrid();
// The initial number of rows is 3
await tester.assertNumberOfRowsInGridPage(4);
await tester.assertRowCountInGridPage(4);
await tester.pumpAndSettle();
});
testWidgets('delete row of the grid', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
await tester.hoverOnFirstRowOfGrid();
// Open the row menu and then click the delete
await tester.tapRowMenuButtonInGrid();
await tester.tapDeleteOnRowMenu();
// The initial number of rows is 3
await tester.assertNumberOfRowsInGridPage(2);
await tester.assertRowCountInGridPage(2);
await tester.pumpAndSettle();
});
testWidgets('check number of row indicator in the initial grid',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
await tester.assertRowCountInGridPage(3);
await tester.pumpAndSettle();
});
});
}
@@ -0,0 +1,66 @@
import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'util/database_test_op.dart';
import 'util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('grid', () {
const location = 'appflowy';
setUp(() async {
await TestFolder.cleanTestLocation(location);
await TestFolder.setTestLocation(location);
});
tearDown(() async {
await TestFolder.cleanTestLocation(location);
});
tearDownAll(() async {
await TestFolder.cleanTestLocation(null);
});
testWidgets('update layout', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
// open setting
await tester.tapDatabaseSettingButton();
// select the layout
await tester.tapDatabaseLayoutButton();
// select layout by board
await tester.selectDatabaseLayoutType(DatabaseLayoutPB.Board);
await tester.assertCurrentDatabaseLayoutType(DatabaseLayoutPB.Board);
await tester.pumpAndSettle();
});
testWidgets('update layout multiple times', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
// open setting
await tester.tapDatabaseSettingButton();
await tester.tapDatabaseLayoutButton();
await tester.selectDatabaseLayoutType(DatabaseLayoutPB.Board);
await tester.assertCurrentDatabaseLayoutType(DatabaseLayoutPB.Board);
await tester.tapDatabaseSettingButton();
await tester.tapDatabaseLayoutButton();
await tester.selectDatabaseLayoutType(DatabaseLayoutPB.Calendar);
await tester.assertCurrentDatabaseLayoutType(DatabaseLayoutPB.Calendar);
await tester.pumpAndSettle();
});
});
}
@@ -0,0 +1,215 @@
import 'dart:io';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'util/database_test_op.dart';
import 'util/mock/mock_file_picker.dart';
import 'util/util.dart';
import 'package:path/path.dart' as p;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('database', () {
const location = 'import_files';
setUp(() async {
await TestFolder.cleanTestLocation(location);
await TestFolder.setTestLocation(location);
});
tearDown(() async {
await TestFolder.cleanTestLocation(location);
});
tearDownAll(() async {
await TestFolder.cleanTestLocation(null);
});
testWidgets('import v0.2.0 database data', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// expect to see a readme page
tester.expectToSeePageName(readme);
await tester.tapAddButton();
await tester.tapImportButton();
final testFileNames = ['v020.afdb'];
final fileLocation = await tester.currentFileLocation();
for (final fileName in testFileNames) {
final str = await rootBundle.loadString(
p.join(
'assets/test/workspaces/database',
fileName,
),
);
File(p.join(fileLocation, fileName)).writeAsStringSync(str);
}
// mock get files
await mockPickFilePaths(testFileNames, name: location);
await tester.tapDatabaseRawDataButton();
await tester.openPage('v020');
// check the import content
// await tester.assertCellContent(
// rowIndex: 7,
// fieldType: FieldType.RichText,
// // fieldName: 'Name',
// content: '',
// );
// check the text cell
final textCells = <String>['A', 'B', 'C', 'D', 'E', '', '', '', '', ''];
for (final (index, content) in textCells.indexed) {
await tester.assertCellContent(
rowIndex: index,
fieldType: FieldType.RichText,
content: content,
);
}
// check the checkbox cell
final checkboxCells = <bool>[
true,
true,
true,
true,
true,
false,
false,
false,
false,
false
];
for (final (index, content) in checkboxCells.indexed) {
await tester.assertCheckboxCell(
rowIndex: index,
isSelected: content,
);
}
// check the number cell
final numberCells = <String>[
'-1',
'-2',
'0.1',
'0.2',
'1',
'2',
'10',
'11',
'12',
''
];
for (final (index, content) in numberCells.indexed) {
await tester.assertCellContent(
rowIndex: index,
fieldType: FieldType.Number,
content: content,
);
}
// check the url cell
final urlCells = <String>[
'appflowy.io',
'no url',
'appflowy.io',
'https://github.com/AppFlowy-IO/',
'',
'',
];
for (final (index, content) in urlCells.indexed) {
await tester.assertCellContent(
rowIndex: index,
fieldType: FieldType.URL,
content: content,
);
}
// check the single select cell
final singleSelectCells = <String>[
's1',
's2',
's3',
's4',
's5',
'',
'',
'',
'',
'',
];
for (final (index, content) in singleSelectCells.indexed) {
await tester.assertSingleSelectOption(
rowIndex: index,
content: content,
);
}
// check the multi select cell
final List<List<String>> multiSelectCells = [
['m1'],
['m1', 'm2'],
['m1', 'm2', 'm3'],
['m1', 'm2', 'm3'],
['m1', 'm2', 'm3', 'm4', 'm5'],
[],
[],
[],
[],
[],
];
for (final (index, contents) in multiSelectCells.indexed) {
await tester.assertMultiSelectOption(
rowIndex: index,
contents: contents,
);
}
// check the checklist cell
final List<double> checklistCells = [
0.6,
0.3,
1.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
];
for (final (index, percent) in checklistCells.indexed) {
await tester.assertChecklistCellInGrid(
rowIndex: index,
percent: percent,
);
}
// check the date cell
final List<String> dateCells = [
'Jun 01, 2023',
'Jun 02, 2023',
'Jun 03, 2023',
'Jun 04, 2023',
'Jun 05, 2023',
'Jun 05, 2023',
'Jun 16, 2023',
'',
'',
''
];
for (final (index, content) in dateCells.indexed) {
await tester.assertDateCellInGrid(
rowIndex: index,
fieldType: FieldType.DateTime,
content: content,
);
}
});
});
}
@@ -0,0 +1,283 @@
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'util/database_test_op.dart';
import 'util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('grid', () {
const location = 'import_files';
setUp(() async {
await TestFolder.cleanTestLocation(location);
await TestFolder.setTestLocation(location);
});
tearDown(() async {
await TestFolder.cleanTestLocation(location);
});
tearDownAll(() async {
await TestFolder.cleanTestLocation(null);
});
testWidgets('add text sort', (tester) async {
await tester.openV020database();
// create a filter
await tester.tapDatabaseSortButton();
await tester.tapCreateSortByFieldType(FieldType.RichText, 'Name');
// check the text cell order
final textCells = <String>[
'',
'',
'',
'',
'',
'A',
'B',
'C',
'D',
'E',
];
for (final (index, content) in textCells.indexed) {
await tester.assertCellContent(
rowIndex: index,
fieldType: FieldType.RichText,
content: content,
);
}
// open the sort menu and select order by descending
await tester.tapSortMenuInSettingBar();
await tester.tapSortButtonByName('Name');
await tester.tapSortByDescending();
for (final (index, content) in <String>[
'E',
'D',
'C',
'B',
'A',
'',
'',
'',
'',
'',
].indexed) {
await tester.assertCellContent(
rowIndex: index,
fieldType: FieldType.RichText,
content: content,
);
}
// delete all sorts
await tester.tapSortMenuInSettingBar();
await tester.tapAllSortButton();
// check the text cell order
for (final (index, content) in <String>[
'A',
'B',
'C',
'D',
'E',
'',
'',
'',
'',
'',
].indexed) {
await tester.assertCellContent(
rowIndex: index,
fieldType: FieldType.RichText,
content: content,
);
}
await tester.pumpAndSettle();
});
testWidgets('add checkbox sort', (tester) async {
await tester.openV020database();
// create a filter
await tester.tapDatabaseSortButton();
await tester.tapCreateSortByFieldType(FieldType.Checkbox, 'Done');
// check the checkbox cell order
for (final (index, content) in <bool>[
false,
false,
false,
false,
false,
true,
true,
true,
true,
true,
].indexed) {
await tester.assertCheckboxCell(
rowIndex: index,
isSelected: content,
);
}
// open the sort menu and select order by descending
await tester.tapSortMenuInSettingBar();
await tester.tapSortButtonByName('Done');
await tester.tapSortByDescending();
for (final (index, content) in <bool>[
true,
true,
true,
true,
true,
false,
false,
false,
false,
false,
].indexed) {
await tester.assertCheckboxCell(
rowIndex: index,
isSelected: content,
);
}
await tester.pumpAndSettle();
});
testWidgets('add number sort', (tester) async {
await tester.openV020database();
// create a filter
await tester.tapDatabaseSortButton();
await tester.tapCreateSortByFieldType(FieldType.Number, 'number');
// check the number cell order
for (final (index, content) in <String>[
'',
'-2',
'-1',
'0.1',
'0.2',
'1',
'2',
'10',
'11',
'12',
].indexed) {
await tester.assertCellContent(
rowIndex: index,
fieldType: FieldType.Number,
content: content,
);
}
// open the sort menu and select order by descending
await tester.tapSortMenuInSettingBar();
await tester.tapSortButtonByName('number');
await tester.tapSortByDescending();
for (final (index, content) in <String>[
'12',
'11',
'10',
'2',
'1',
'0.2',
'0.1',
'-1',
'-2',
'',
].indexed) {
await tester.assertCellContent(
rowIndex: index,
fieldType: FieldType.Number,
content: content,
);
}
await tester.pumpAndSettle();
});
testWidgets('add number and text sort', (tester) async {
await tester.openV020database();
// create a filter
await tester.tapDatabaseSortButton();
await tester.tapCreateSortByFieldType(FieldType.Number, 'number');
// open the sort menu and select number order by descending
await tester.tapSortMenuInSettingBar();
await tester.tapSortButtonByName('number');
await tester.tapSortByDescending();
for (final (index, content) in <String>[
'12',
'11',
'10',
'2',
'1',
'0.2',
'0.1',
'-1',
'-2',
'',
].indexed) {
await tester.assertCellContent(
rowIndex: index,
fieldType: FieldType.Number,
content: content,
);
}
await tester.tapSortMenuInSettingBar();
await tester.tapCreateSortByFieldTypeInSortMenu(
FieldType.RichText,
'Name',
);
// check number cell order
for (final (index, content) in <String>[
'12',
'11',
'10',
'2',
'',
'-1',
'-2',
'0.1',
'0.2',
'1',
].indexed) {
await tester.assertCellContent(
rowIndex: index,
fieldType: FieldType.Number,
content: content,
);
}
// check text cell order
for (final (index, content) in <String>[
'',
'',
'',
'',
'',
'A',
'B',
'C',
'D',
'E',
].indexed) {
await tester.assertCellContent(
rowIndex: index,
fieldType: FieldType.RichText,
content: content,
);
}
await tester.pumpAndSettle();
});
});
}
@@ -0,0 +1,96 @@
import 'package:appflowy/plugins/database_view/tar_bar/tar_bar_add_button.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'util/database_test_op.dart';
import 'util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('database', () {
const location = 'appflowy';
setUp(() async {
await TestFolder.cleanTestLocation(location);
await TestFolder.setTestLocation(location);
});
tearDown(() async {
await TestFolder.cleanTestLocation(location);
});
tearDownAll(() async {
await TestFolder.cleanTestLocation(null);
});
testWidgets('create linked view', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
// Create board view
await tester.tapCreateLinkedDatabaseViewButton(AddButtonAction.board);
tester.assertCurrentDatabaseTagIs(DatabaseLayoutPB.Board);
// Create grid view
await tester.tapCreateLinkedDatabaseViewButton(AddButtonAction.grid);
tester.assertCurrentDatabaseTagIs(DatabaseLayoutPB.Grid);
// Create calendar view
await tester.tapCreateLinkedDatabaseViewButton(AddButtonAction.calendar);
tester.assertCurrentDatabaseTagIs(DatabaseLayoutPB.Calendar);
await tester.pumpAndSettle();
});
testWidgets('rename and delete linked view', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
// Create board view
await tester.tapCreateLinkedDatabaseViewButton(AddButtonAction.board);
tester.assertCurrentDatabaseTagIs(DatabaseLayoutPB.Board);
// rename board view
await tester.renameLinkedView(
tester.findTabBarLinkViewByViewLayout(ViewLayoutPB.Board),
'new board',
);
final findBoard = tester.findTabBarLinkViewByViewName('new board');
expect(findBoard, findsOneWidget);
// delete the board
await tester.deleteDatebaseView(findBoard);
expect(tester.findTabBarLinkViewByViewName('new board'), findsNothing);
await tester.pumpAndSettle();
});
testWidgets('delete the last database view', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.tapAddButton();
await tester.tapCreateGridButton();
// Create board view
await tester.tapCreateLinkedDatabaseViewButton(AddButtonAction.board);
tester.assertCurrentDatabaseTagIs(DatabaseLayoutPB.Board);
// delete the board
await tester.deleteDatebaseView(
tester.findTabBarLinkViewByViewLayout(ViewLayoutPB.Board),
);
await tester.pumpAndSettle();
});
});
}
@@ -0,0 +1,135 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../util/emoji.dart';
import '../util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('cover image', () {
const location = 'cover_image';
setUp(() async {
await TestFolder.cleanTestLocation(location);
await TestFolder.setTestLocation(location);
});
tearDown(() async {
await TestFolder.cleanTestLocation(location);
});
tearDownAll(() async {
await TestFolder.cleanTestLocation(null);
});
testWidgets('document cover tests', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
tester.expectToSeeNoDocumentCover();
// Hover over cover toolbar to show 'Add Cover' and 'Add Icon' buttons
await tester.editor.hoverOnCoverToolbar();
tester.expectToSeePluginAddCoverAndIconButton();
// Insert a document cover
await tester.editor.tapOnAddCover();
tester.expectToSeeDocumentCover(CoverType.asset);
// Hover over the cover to show the 'Change Cover' and delete buttons
await tester.editor.hoverOnCover();
tester.expectChangeCoverAndDeleteButton();
// Change cover to a solid color background
await tester.editor.tapOnChangeCover();
await tester.editor.switchSolidColorBackground();
await tester.editor.dismissCoverPicker();
tester.expectToSeeDocumentCover(CoverType.color);
// Change cover to a network image
const imageUrl =
"https://raw.githubusercontent.com/AppFlowy-IO/AppFlowy/main/frontend/appflowy_flutter/assets/images/appflowy_launch_splash.jpg";
await tester.editor.hoverOnCover();
await tester.editor.tapOnChangeCover();
await tester.editor.addNetworkImageCover(imageUrl);
await tester.editor.switchNetworkImageCover(imageUrl);
await tester.editor.dismissCoverPicker();
tester.expectToSeeDocumentCover(CoverType.file);
// Remove the cover
await tester.editor.hoverOnCover();
await tester.editor.tapOnRemoveCover();
tester.expectToSeeNoDocumentCover();
});
testWidgets('document icon tests', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
tester.expectToSeeDocumentIcon(null);
// Hover over cover toolbar to show the 'Add Cover' and 'Add Icon' buttons
await tester.editor.hoverOnCoverToolbar();
tester.expectToSeePluginAddCoverAndIconButton();
// Insert a document icon
await tester.editor.tapAddIconButton();
await tester.switchToEmojiList();
await tester.tapEmoji('😀');
tester.expectToSeeDocumentIcon('😀');
// Remove the document icon from the cover toolbar
await tester.editor.hoverOnCoverToolbar();
await tester.editor.tapRemoveIconButton();
tester.expectToSeeDocumentIcon(null);
// Add the icon back for further testing
await tester.editor.hoverOnCoverToolbar();
await tester.editor.tapAddIconButton();
await tester.switchToEmojiList();
await tester.tapEmoji('😀');
tester.expectToSeeDocumentIcon('😀');
// Change the document icon
await tester.editor.tapOnIconWidget();
await tester.switchToEmojiList();
await tester.tapEmoji('😅');
tester.expectToSeeDocumentIcon('😅');
// Remove the document icon from the icon picker
await tester.editor.tapOnIconWidget();
await tester.editor.tapRemoveIconButton(isInPicker: true);
tester.expectToSeeDocumentIcon(null);
});
testWidgets('icon and cover at the same time', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
tester.expectToSeeDocumentIcon(null);
tester.expectToSeeNoDocumentCover();
// Hover over cover toolbar to show the 'Add Cover' and 'Add Icon' buttons
await tester.editor.hoverOnCoverToolbar();
tester.expectToSeePluginAddCoverAndIconButton();
// Insert a document icon
await tester.editor.tapAddIconButton();
await tester.switchToEmojiList();
await tester.tapEmoji('😀');
// Insert a document cover
await tester.editor.tapOnAddCover();
// Expect to see the icon and cover at the same time
tester.expectToSeeDocumentIcon('😀');
tester.expectToSeeDocumentCover(CoverType.asset);
// Hover over the cover toolbar and see that neither icons are shown
await tester.editor.hoverOnCoverToolbar();
tester.expectToSeeEmptyDocumentHeaderToolbar();
});
});
}
@@ -4,7 +4,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'util/util.dart';
import '../util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
@@ -0,0 +1,111 @@
import 'package:appflowy/plugins/database_view/board/presentation/board_page.dart';
import 'package:appflowy/plugins/database_view/calendar/presentation/calendar_page.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/grid_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/link_to_page_widget.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('database view in document', () {
const location = 'database_view';
setUp(() async {
await TestFolder.cleanTestLocation(location);
await TestFolder.setTestLocation(location);
});
tearDown(() async {
await TestFolder.cleanTestLocation(null);
});
testWidgets('insert a referenced grid', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await insertReferenceDatabase(tester, ViewLayoutPB.Grid);
// validate the referenced grid is inserted
expect(
find.descendant(
of: find.byType(AppFlowyEditor),
matching: find.byType(GridPage),
),
findsOneWidget,
);
});
testWidgets('insert a referenced board', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await insertReferenceDatabase(tester, ViewLayoutPB.Board);
// validate the referenced board is inserted
expect(
find.descendant(
of: find.byType(AppFlowyEditor),
matching: find.byType(BoardPage),
),
findsOneWidget,
);
});
testWidgets('insert a referenced calendar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await insertReferenceDatabase(tester, ViewLayoutPB.Calendar);
// validate the referenced grid is inserted
expect(
find.descendant(
of: find.byType(AppFlowyEditor),
matching: find.byType(CalendarPage),
),
findsOneWidget,
);
});
});
}
/// Insert a referenced database of [layout] into the document
Future<void> insertReferenceDatabase(
WidgetTester tester,
ViewLayoutPB layout,
) async {
// create a new grid
final id = uuid();
final name = '${layout.name}_$id';
await tester.createNewPageWithName(
layout,
name,
);
// create a new document
await tester.createNewPageWithName(
ViewLayoutPB.Document,
'insert_a_reference_${layout.name}',
);
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
// insert a referenced view
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
layout.referencedMenuName,
);
final linkToPageMenu = find.byType(LinkToPageMenu);
expect(linkToPageMenu, findsOneWidget);
final referencedDatabase = find.descendant(
of: linkToPageMenu,
matching: find.findTextInFlowyText(name),
);
expect(referencedDatabase, findsOneWidget);
await tester.tapButton(referencedDatabase);
}
@@ -0,0 +1,129 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flowy_infra_ui/widget/error_page.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('inline page view in document', () {
const location = 'inline_page';
setUp(() async {
await TestFolder.cleanTestLocation(location);
await TestFolder.setTestLocation(location);
});
tearDown(() async {
await TestFolder.cleanTestLocation(null);
});
testWidgets('insert a inline page - grid', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await insertingInlinePage(tester, ViewLayoutPB.Grid);
final mentionBlock = find.byType(MentionPageBlock);
expect(mentionBlock, findsOneWidget);
await tester.tapButton(mentionBlock);
});
testWidgets('insert a inline page - board', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await insertingInlinePage(tester, ViewLayoutPB.Board);
final mentionBlock = find.byType(MentionPageBlock);
expect(mentionBlock, findsOneWidget);
await tester.tapButton(mentionBlock);
});
testWidgets('insert a inline page - calendar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await insertingInlinePage(tester, ViewLayoutPB.Calendar);
final mentionBlock = find.byType(MentionPageBlock);
expect(mentionBlock, findsOneWidget);
await tester.tapButton(mentionBlock);
});
testWidgets('insert a inline page - document', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await insertingInlinePage(tester, ViewLayoutPB.Document);
final mentionBlock = find.byType(MentionPageBlock);
expect(mentionBlock, findsOneWidget);
await tester.tapButton(mentionBlock);
});
testWidgets('insert a inline page and rename it', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
final pageName = await insertingInlinePage(tester, ViewLayoutPB.Document);
// rename
await tester.hoverOnPageName(pageName);
const newName = 'RenameToNewPageName';
await tester.renamePage(newName);
final finder = find.descendant(
of: find.byType(MentionPageBlock),
matching: find.findTextInFlowyText(newName),
);
expect(finder, findsOneWidget);
});
testWidgets('insert a inline page and delete it', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
final pageName = await insertingInlinePage(tester, ViewLayoutPB.Grid);
// rename
await tester.hoverOnPageName(pageName);
await tester.tapDeletePageButton();
final finder = find.descendant(
of: find.byType(MentionPageBlock),
matching: find.findTextInFlowyText(pageName),
);
expect(finder, findsOneWidget);
await tester.tapButton(finder);
expect(find.byType(FlowyErrorPage), findsOneWidget);
});
});
}
/// Insert a referenced database of [layout] into the document
Future<String> insertingInlinePage(
WidgetTester tester,
ViewLayoutPB layout,
) async {
// create a new grid
final id = uuid();
final name = '${layout.name}_$id';
await tester.createNewPageWithName(
layout,
name,
);
// create a new document
await tester.createNewPageWithName(
ViewLayoutPB.Document,
'insert_a_inline_page_${layout.name}',
);
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
// insert a inline page
await tester.editor.showAtMenu();
await tester.editor.tapAtMenuItemWithName(name);
return name;
}
@@ -0,0 +1,141 @@
import 'dart:io';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../util/ime.dart';
import '../util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('edit document', () {
const location = 'appflowy';
setUp(() async {
await TestFolder.cleanTestLocation(location);
await TestFolder.setTestLocation(location);
});
tearDown(() async {
await TestFolder.cleanTestLocation(null);
});
testWidgets('redo & undo', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// create a new document called Sample
const pageName = 'Sample';
await tester.createNewPageWithName(ViewLayoutPB.Document, pageName);
// focus on the editor
await tester.editor.tapLineOfEditorAt(0);
// insert 1. to trigger it to be a numbered list
await tester.ime.insertText('1. ');
expect(find.text('1.', findRichText: true), findsOneWidget);
expect(
tester.editor.getCurrentEditorState().getNodeAtPath([0])!.type,
NumberedListBlockKeys.type,
);
// undo
// numbered list will be reverted to paragraph
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyZ,
isControlPressed: Platform.isWindows || Platform.isLinux,
isMetaPressed: Platform.isMacOS,
);
expect(
tester.editor.getCurrentEditorState().getNodeAtPath([0])!.type,
ParagraphBlockKeys.type,
);
// redo
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyZ,
isControlPressed: Platform.isWindows || Platform.isLinux,
isMetaPressed: Platform.isMacOS,
isShiftPressed: true,
);
expect(
tester.editor.getCurrentEditorState().getNodeAtPath([0])!.type,
NumberedListBlockKeys.type,
);
// switch to other page and switch back
await tester.openPage(readme);
await tester.openPage(pageName);
// the numbered list should be kept
expect(
tester.editor.getCurrentEditorState().getNodeAtPath([0])!.type,
NumberedListBlockKeys.type,
);
});
testWidgets('write a readme document', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// create a new document called Sample
const pageName = 'Sample';
await tester.createNewPageWithName(ViewLayoutPB.Document, pageName);
// focus on the editor
await tester.editor.tapLineOfEditorAt(0);
// mock inputting the sample
final lines = _sample.split('\n');
for (final line in lines) {
await tester.ime.insertText(line);
await tester.ime.insertCharacter('\n');
}
// switch to other page and switch back
await tester.openPage(readme);
await tester.openPage(pageName);
// this screenshots are different on different platform, so comment it out temporarily.
// check the document
// await expectLater(
// find.byType(AppFlowyEditor),
// matchesGoldenFile('document/edit_document_test.png'),
// );
});
});
}
// TODO(Lucas.Xu): there're no shorctcuts for underline, format code yet.
const _sample = r'''
# Heading 1
## Heading 2
### Heading 3
---
[] Highlight any text, and use the editing menu to _style_ **your** writing `however` you ~~like.~~
[] Type followed by bullet or num to create a list.
[x] Click `+ New Page` button at the bottom of your sidebar to add a new page.
[] Click `+` next to any page title in the sidebar to quickly add a new subpage, `Document`, `Grid`, or `Kanban Board`.
---
* bulleted list 1
* bulleted list 2
* bulleted list 3
bulleted list 4
---
1. numbered list 1
2. numbered list 2
3. numbered list 3
numbered list 4
---
" quote''';
Binary file not shown.

After

Width:  |  Height:  |  Size: 67 KiB

@@ -0,0 +1,60 @@
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'util/mock/mock_file_picker.dart';
import 'util/util.dart';
import 'package:path/path.dart' as p;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('import files', () {
const location = 'import_files';
setUp(() async {
await TestFolder.cleanTestLocation(location);
await TestFolder.setTestLocation(location);
});
tearDown(() async {
await TestFolder.cleanTestLocation(location);
});
tearDownAll(() async {
await TestFolder.cleanTestLocation(null);
});
testWidgets('import multiple markdown files', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// expect to see a readme page
tester.expectToSeePageName(readme);
await tester.tapAddButton();
await tester.tapImportButton();
final testFileNames = ['test1.md', 'test2.md'];
final fileLocation = await tester.currentFileLocation();
for (final fileName in testFileNames) {
final str = await rootBundle.loadString(
p.join(
'assets/test/workspaces/markdowns',
fileName,
),
);
File(p.join(fileLocation, fileName)).writeAsStringSync(str);
}
// mock get files
await mockPickFilePaths(testFileNames, name: location);
await tester.tapTextAndMarkdownButton();
tester.expectToSeePageName('test1');
tester.expectToSeePageName('test2');
});
});
}
@@ -0,0 +1,45 @@
import 'package:appflowy/workspace/presentation/settings/widgets/settings_language_view.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('document', () {
const location = 'appflowy';
setUpAll(() async {
await TestFolder.setTestLocation(location);
});
tearDownAll(() async {
await TestFolder.cleanTestLocation(null);
});
testWidgets(
'change the language successfully when launching the app for the first time',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapLanguageSelectorOnWelcomePage();
expect(find.byType(LanguageItemsListView), findsOneWidget);
await tester.tapLanguageItem(languageCode: 'zh', countryCode: 'CN');
tester.expectToSeeText('开始');
await tester.tapLanguageItem(languageCode: 'en', scrollDelta: -100);
tester.expectToSeeText('Quick Start');
await tester.tapLanguageItem(languageCode: 'it', countryCode: 'IT');
tester.expectToSeeText('Andiamo');
});
/// Make sure this test is executed after the test above.
testWidgets('check the language after relaunching the app', (tester) async {
await tester.initializeAppFlowy();
tester.expectToSeeText('Andiamo');
});
});
}
@@ -0,0 +1,114 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/outline/outline_block_component.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../util/ime.dart';
import '../util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('outline block test', () {
const location = 'outline_test';
setUp(() async {
await TestFolder.cleanTestLocation(location);
await TestFolder.setTestLocation(location);
});
tearDown(() async {
await TestFolder.cleanTestLocation(null);
});
testWidgets('insert an outline block', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.createNewPageWithName(
ViewLayoutPB.Document,
'outline_test',
);
await tester.editor.tapLineOfEditorAt(0);
await insertOutlineInDocument(tester);
// validate the outline is inserted
expect(find.byType(OutlineBlockWidget), findsOneWidget);
});
testWidgets('insert an outline block and check if headings are visible',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.createNewPageWithName(
ViewLayoutPB.Document,
'outline_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText('# Heading 1\n');
await tester.ime.insertText('## Heading 2\n');
await tester.ime.insertText('### Heading 3\n');
/* Results in:
* # Heading 1
* ## Heading 2
* ### Heading 3
*/
await tester.editor.tapLineOfEditorAt(3);
await insertOutlineInDocument(tester);
expect(
find.descendant(
of: find.byType(OutlineBlockWidget),
matching: find.text('Heading 1'),
),
findsOneWidget,
);
// Heading 2 is prefixed with a bullet
expect(
find.descendant(
of: find.byType(OutlineBlockWidget),
matching: find.text('Heading 2'),
),
findsOneWidget,
);
// Heading 3 is prefixed with a dash
expect(
find.descendant(
of: find.byType(OutlineBlockWidget),
matching: find.text('Heading 3'),
),
findsOneWidget,
);
// update the Heading 1 to Heading 1Hello world
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText('Hello world');
expect(
find.descendant(
of: find.byType(OutlineBlockWidget),
matching: find.text('Heading 1Hello world'),
),
findsOneWidget,
);
});
});
}
/// Inserts an outline block in the document
Future<void> insertOutlineInDocument(WidgetTester tester) async {
// open the actions menu and insert the outline block
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_selectionMenu_outline.tr(),
);
await tester.pumpAndSettle();
}
@@ -1,8 +1,23 @@
import 'package:integration_test/integration_test.dart';
import 'switch_folder_test.dart' as switch_folder_test;
import 'document_test.dart' as document_test;
import 'cover_image_test.dart' as cover_image_test;
import 'document/document_test.dart' as document_test;
import 'document/cover_image_test.dart' as cover_image_test;
import 'share_markdown_test.dart' as share_markdown_test;
import 'import_files_test.dart' as import_files_test;
import 'document/document_with_database_test.dart'
as document_with_database_test;
import 'document/edit_document_test.dart' as edit_document_test;
import 'database_cell_test.dart' as database_cell_test;
import 'database_field_test.dart' as database_field_test;
import 'database_share_test.dart' as database_share_test;
import 'database_row_page_test.dart' as database_row_page_test;
import 'database_row_test.dart' as database_row_test;
import 'database_setting_test.dart' as database_setting_test;
import 'database_filter_test.dart' as database_filter_test;
import 'database_view_test.dart' as database_view_test;
import 'database_calendar_test.dart' as database_calendar_test;
import 'database_sort_test.dart' as database_sort_test;
/// The main task runner for all integration tests in AppFlowy.
///
@@ -14,8 +29,27 @@ import 'cover_image_test.dart' as cover_image_test;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
switch_folder_test.main();
share_markdown_test.main();
import_files_test.main();
// Document integration tests
cover_image_test.main();
document_test.main();
document_with_database_test.main();
edit_document_test.main();
// Database integration tests
database_cell_test.main();
database_field_test.main();
database_share_test.main();
database_row_page_test.main();
database_row_test.main();
database_setting_test.main();
database_filter_test.main();
database_sort_test.main();
database_view_test.main();
database_calendar_test.main();
// board_test.main();
// empty_document_test.main();
// smart_menu_test.main();
@@ -1,5 +1,6 @@
import 'dart:io';
import 'package:appflowy/plugins/document/presentation/share/share_button.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -47,6 +48,38 @@ void main() {
final markdown = file.readAsStringSync();
expect(markdown, expectedMarkdown);
});
testWidgets(
'share the markdown after renaming the document name',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// expect to see a readme page
tester.expectToSeePageName(readme);
// rename the document
await tester.hoverOnPageName(readme);
await tester.renamePage('example');
final shareButton = find.byType(ShareActionList);
final shareButtonState =
tester.state(shareButton) as ShareActionListState;
final path =
await mockSaveFilePath(location, '${shareButtonState.name}.md');
// click the share button and select markdown
await tester.tapShareButton();
await tester.tapMarkdownButton();
// expect to see the success dialog
tester.expectToExportSuccess();
final file = File(path);
final isExist = file.existsSync();
expect(isExist, true);
},
);
});
}
@@ -1,9 +1,12 @@
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy/workspace/application/settings/settings_location_cubit.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'util/mock/mock_file_picker.dart';
import 'util/util.dart';
import 'package:path/path.dart' as p;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
@@ -25,17 +28,17 @@ void main() {
});
testWidgets('switch to B from A, then switch to A again', (tester) async {
const String userA = 'userA';
const String userB = 'userB';
final userA = uuid();
final userB = uuid();
await TestFolder.cleanTestLocation(userA);
await TestFolder.cleanTestLocation(userB);
await TestFolder.setTestLocation(userA);
await TestFolder.setTestLocation(p.join(userA, appFlowyDataFolder));
await tester.initializeAppFlowy();
await tester.tapGoButton();
tester.expectToSeeWelcomePage();
tester.expectToSeeHomePage();
// switch to user B
{
@@ -51,7 +54,7 @@ void main() {
await mockGetDirectoryPath(userB);
await tester.tapCustomLocationButton();
await tester.pumpAndSettle();
tester.expectToSeeWelcomePage();
tester.expectToSeeHomePage();
// set user name to userB
await tester.openSettings();
@@ -69,7 +72,7 @@ void main() {
await tester.tapCustomLocationButton();
await tester.pumpAndSettle();
tester.expectToSeeWelcomePage();
tester.expectToSeeHomePage();
tester.expectToSeeUserName(userA);
}
@@ -84,7 +87,7 @@ void main() {
await tester.tapCustomLocationButton();
await tester.pumpAndSettle();
tester.expectToSeeWelcomePage();
tester.expectToSeeHomePage();
tester.expectToSeeUserName(userB);
}
@@ -98,7 +101,7 @@ void main() {
await tester.tapGoButton();
// home and readme document
tester.expectToSeeWelcomePage();
tester.expectToSeeHomePage();
// open settings and restore the location
await tester.openSettings();
@@ -1,9 +1,12 @@
import 'dart:io';
import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/main.dart' as app;
import 'package:appflowy/startup/entry_point.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/startup/tasks/prelude.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path_provider/path_provider.dart';
@@ -41,7 +44,7 @@ class TestFolder {
/// Get default location under development environment.
static Future<String> defaultDevelopmentLocation() async {
final dir = await appFlowyDocumentDirectory();
final dir = await appFlowyApplicationDataDirectory();
return dir.path;
}
@@ -59,18 +62,18 @@ class TestFolder {
extension AppFlowyTestBase on WidgetTester {
Future<void> initializeAppFlowy() async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
const MethodChannel('hotkey_manager'),
(MethodCall methodCall) async {
if (methodCall.method == 'unregisterAll') {
// do nothing
}
.setMockMethodCallHandler(const MethodChannel('hotkey_manager'),
(MethodCall methodCall) async {
if (methodCall.method == 'unregisterAll') {
// do nothing
}
return;
},
);
return;
});
WidgetsFlutterBinding.ensureInitialized();
await FlowyRunner.run(FlowyApp(), IntegrationMode.integrationTest);
await app.main();
await wait(3000);
await pumpAndSettle(const Duration(seconds: 2));
}
@@ -82,7 +85,11 @@ extension AppFlowyTestBase on WidgetTester {
bool warnIfMissed = true,
int milliseconds = 500,
}) async {
await tap(finder);
await tap(
finder,
buttons: buttons,
warnIfMissed: warnIfMissed,
);
await pumpAndSettle(Duration(milliseconds: milliseconds));
return;
}
@@ -91,7 +98,16 @@ extension AppFlowyTestBase on WidgetTester {
String tr, {
int milliseconds = 500,
}) async {
final button = find.text(tr, findRichText: true);
Finder button = find.text(
tr,
findRichText: true,
skipOffstage: false,
);
if (button.evaluate().isEmpty) {
button = find.byWidgetPredicate(
(widget) => widget is FlowyText && widget.text == tr,
);
}
await tapButton(
button,
milliseconds: milliseconds,
@@ -111,8 +127,39 @@ extension AppFlowyTestBase on WidgetTester {
return;
}
Future<void> doubleTapButton(
Finder finder, {
int? pointer,
int buttons = kPrimaryButton,
bool warnIfMissed = true,
int milliseconds = 500,
}) async {
await tapButton(
finder,
pointer: pointer,
buttons: buttons,
warnIfMissed: warnIfMissed,
milliseconds: kDoubleTapMinTime.inMilliseconds,
);
await tapButton(
finder,
pointer: pointer,
buttons: buttons,
warnIfMissed: warnIfMissed,
milliseconds: milliseconds,
);
}
Future<void> wait(int milliseconds) async {
await pumpAndSettle(Duration(milliseconds: milliseconds));
return;
}
}
extension AppFlowyFinderTestBase on CommonFinders {
Finder findTextInFlowyText(String text) {
return find.byWidgetPredicate(
(widget) => widget is FlowyText && widget.text == text,
);
}
}
@@ -1,92 +1,199 @@
import 'dart:ui';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/banner.dart';
import 'package:appflowy/plugins/document/presentation/share/share_button.dart';
import 'package:appflowy/user/presentation/skip_log_in_screen.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy/workspace/presentation/home/menu/app/header/add_button.dart';
import 'package:appflowy/workspace/presentation/home/menu/app/section/item.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/settings_language_view.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/buttons/primary_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'base.dart';
const String readme = 'Read me';
import 'util.dart';
extension CommonOperations on WidgetTester {
/// Get current file location of AppFlowy.
Future<String> currentFileLocation() async {
return TestFolder.currentLocation();
}
/// Tap the GetStart button on the launch page.
Future<void> tapGoButton() async {
final goButton = find.byType(GoButton);
await tapButton(goButton);
}
Future<void> tapCreateButton() async {
await tapButtonWithName(LocaleKeys.settings_files_create.tr());
}
void expectToSeeWelcomePage() {
expect(find.byType(HomeStack), findsOneWidget);
expect(find.textContaining('Read me'), findsOneWidget);
}
/// Tap the + button on the home page.
Future<void> tapAddButton() async {
final addButton = find.byType(AddButton);
await tapButton(addButton);
}
/// Tap the create document button.
///
/// Must call [tapAddButton] first.
Future<void> tapCreateDocumentButton() async {
await tapButtonWithName(LocaleKeys.document_menuName.tr());
}
Finder findPageName(String name) {
return find.byWidgetPredicate(
(widget) => widget is ViewSectionItem && widget.view.name == name,
);
/// Tap the create grid button.
///
/// Must call [tapAddButton] first.
Future<void> tapCreateGridButton() async {
await tapButtonWithName(LocaleKeys.grid_menuName.tr());
}
void expectToSeePageName(String name) {
final pageName = findPageName(name);
expect(pageName, findsOneWidget);
/// Tap the create grid button.
///
/// Must call [tapAddButton] first.
Future<void> tapCreateCalendarButton() async {
await tapButtonWithName(LocaleKeys.calendar_menuName.tr());
}
void expectNotToSeePageName(String name) {
final pageName = findPageName(name);
expect(pageName, findsNothing);
/// Tap the import button.
///
/// Must call [tapAddButton] first.
Future<void> tapImportButton() async {
await tapButtonWithName(LocaleKeys.moreAction_import.tr());
}
Future<void> hoverOnPageName(String name) async {
final pageName = find.byWidgetPredicate(
(widget) => widget is ViewSectionItem && widget.view.name == name,
/// Tap the import from text & markdown button.
///
/// Must call [tapImportButton] first.
Future<void> tapTextAndMarkdownButton() async {
await tapButtonWithName(LocaleKeys.importPanel_textAndMarkdown.tr());
}
/// Tap the LanguageSelectorOnWelcomePage widget on the launch page.
Future<void> tapLanguageSelectorOnWelcomePage() async {
final languageSelector = find.byType(LanguageSelectorOnWelcomePage);
await tapButton(languageSelector);
}
/// Tap languageItem on LanguageItemsListView.
///
/// [scrollDelta] is the distance to scroll the ListView.
/// Default value is 100
///
/// If it is positive -> scroll down.
///
/// If it is negative -> scroll up.
Future<void> tapLanguageItem({
required String languageCode,
String? countryCode,
double? scrollDelta,
}) async {
final languageItemsListView = find.descendant(
of: find.byType(ListView),
matching: find.byType(Scrollable),
);
final gesture = await createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: Offset.zero);
addTearDown(gesture.removePointer);
await pump();
await gesture.moveTo(getCenter(pageName));
await pumpAndSettle();
final languageItem = find.byWidgetPredicate(
(widget) =>
widget is LanguageItem &&
widget.locale.languageCode == languageCode &&
widget.locale.countryCode == countryCode,
);
// scroll the ListView until zHCNLanguageItem shows on the screen.
await scrollUntilVisible(
languageItem,
scrollDelta ?? 100,
scrollable: languageItemsListView,
// maxHeight of LanguageItemsListView
maxScrolls: 400,
);
try {
await tapButton(languageItem);
} on FlutterError catch (e) {
Log.warn('tapLanguageItem error: $e');
}
}
/// Hover on the widget.
Future<void> hoverOnWidget(
Finder finder, {
Offset? offset,
Future<void> Function()? onHover,
}) async {
try {
final gesture = await createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: Offset.zero);
await pump();
await gesture.moveTo(offset ?? getCenter(finder));
await pumpAndSettle();
await onHover?.call();
await gesture.removePointer();
} catch (err) {
Log.error('hoverOnWidget error: $err');
}
}
/// Hover on the page name.
Future<void> hoverOnPageName(
String name, {
Future<void> Function()? onHover,
bool useLast = true,
}) async {
if (useLast) {
await hoverOnWidget(findPageName(name).last, onHover: onHover);
} else {
await hoverOnWidget(findPageName(name).first, onHover: onHover);
}
}
/// open the page with given name.
Future<void> openPage(String name) async {
final finder = findPageName(name);
expect(finder, findsOneWidget);
await tapButton(finder);
}
/// Tap the ... button beside the page name.
///
/// Must call [hoverOnPageName] first.
Future<void> tapPageOptionButton() async {
final optionButton = find.byType(ViewDisclosureButton);
await tapButton(optionButton);
}
/// Tap the delete page button.
Future<void> tapDeletePageButton() async {
await tapPageOptionButton();
await tapButtonWithName(ViewDisclosureAction.delete.name);
}
void expectToSeeDocumentBanner() {
expect(find.byType(DocumentBanner), findsOneWidget);
/// Tap the rename page button.
Future<void> tapRenamePageButton() async {
await tapPageOptionButton();
await tapButtonWithName(ViewDisclosureAction.rename.name);
}
void expectNotToSeeDocumentBanner() {
expect(find.byType(DocumentBanner), findsNothing);
/// Rename the page.
Future<void> renamePage(String name) async {
await tapRenamePageButton();
await enterText(find.byType(TextFormField), name);
await tapOKButton();
}
Future<void> tapOKButton() async {
final okButton = find.byWidgetPredicate(
(widget) =>
widget is PrimaryTextButton &&
widget.label == LocaleKeys.button_OK.tr(),
);
await tapButton(okButton);
}
/// Tap the restore button.
///
/// the restore button will show after the current page is deleted.
Future<void> tapRestoreButton() async {
final restoreButton = find.textContaining(
LocaleKeys.deletePagePrompt_restore.tr(),
@@ -94,6 +201,9 @@ extension CommonOperations on WidgetTester {
await tapButton(restoreButton);
}
/// Tap the delete permanently button.
///
/// the restore button will show after the current page is deleted.
Future<void> tapDeletePermanentlyButton() async {
final restoreButton = find.textContaining(
LocaleKeys.deletePagePrompt_deletePermanent.tr(),
@@ -101,6 +211,7 @@ extension CommonOperations on WidgetTester {
await tapButton(restoreButton);
}
/// Tap the share button above the document page.
Future<void> tapShareButton() async {
final shareButton = find.byWidgetPredicate(
(widget) => widget is DocumentShareButton,
@@ -108,6 +219,9 @@ extension CommonOperations on WidgetTester {
await tapButton(shareButton);
}
/// Tap the export markdown button
///
/// Must call [tapShareButton] first.
Future<void> tapMarkdownButton() async {
final markdownButton = find.textContaining(
LocaleKeys.shareAction_markdown.tr(),
@@ -115,44 +229,86 @@ extension CommonOperations on WidgetTester {
await tapButton(markdownButton);
}
void expectToExportSuccess() {
final exportSuccess = find.byWidgetPredicate(
(widget) =>
widget is FlowyText &&
widget.title == LocaleKeys.settings_files_exportFileSuccess.tr(),
);
expect(exportSuccess, findsOneWidget);
}
Future<void> createNewPageWithName(ViewLayoutPB layout, String name) async {
// create a new page
await tapAddButton();
await tapButtonWithName(layout.menuName);
await pumpAndSettle();
Future<void> hoverOnCoverPluginAddButton() async {
final editor = find.byWidgetPredicate(
(widget) => widget is AppFlowyEditor,
// hover on it and change it's name
await hoverOnPageName(
LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
onHover: () async {
await renamePage(name);
await pumpAndSettle();
},
);
final gesture = await createGesture(kind: PointerDeviceKind.mouse);
await gesture.addPointer(location: Offset.zero);
addTearDown(gesture.removePointer);
await pump();
final topLeft = getTopLeft(editor).translate(20, 20);
await gesture.moveTo(topLeft);
await pumpAndSettle();
}
Future<void> expectToSeePluginAddCoverAndIconButton() async {
final addCover = find.textContaining(
LocaleKeys.document_plugins_cover_addCover.tr(),
);
final addIcon = find.textContaining(
LocaleKeys.document_plugins_cover_addIcon.tr(),
);
expect(addCover, findsOneWidget);
expect(addIcon, findsOneWidget);
Future<void> simulateKeyEvent(
LogicalKeyboardKey key, {
bool isControlPressed = false,
bool isShiftPressed = false,
bool isAltPressed = false,
bool isMetaPressed = false,
}) async {
if (isControlPressed) {
await simulateKeyDownEvent(LogicalKeyboardKey.control);
}
if (isShiftPressed) {
await simulateKeyDownEvent(LogicalKeyboardKey.shift);
}
if (isAltPressed) {
await simulateKeyDownEvent(LogicalKeyboardKey.alt);
}
if (isMetaPressed) {
await simulateKeyDownEvent(LogicalKeyboardKey.meta);
}
await simulateKeyDownEvent(key);
await simulateKeyUpEvent(key);
if (isControlPressed) {
await simulateKeyUpEvent(LogicalKeyboardKey.control);
}
if (isShiftPressed) {
await simulateKeyUpEvent(LogicalKeyboardKey.shift);
}
if (isAltPressed) {
await simulateKeyUpEvent(LogicalKeyboardKey.alt);
}
if (isMetaPressed) {
await simulateKeyUpEvent(LogicalKeyboardKey.meta);
}
await pumpAndSettle();
}
}
void expectToSeeUserName(String name) {
final userName = find.byWidgetPredicate(
(widget) => widget is FlowyText && widget.title == name,
);
expect(userName, findsOneWidget);
extension ViewLayoutPBTest on ViewLayoutPB {
String get menuName {
switch (this) {
case ViewLayoutPB.Grid:
return LocaleKeys.grid_menuName.tr();
case ViewLayoutPB.Board:
return LocaleKeys.board_menuName.tr();
case ViewLayoutPB.Document:
return LocaleKeys.document_menuName.tr();
case ViewLayoutPB.Calendar:
return LocaleKeys.calendar_menuName.tr();
default:
throw UnsupportedError('Unsupported layout: $this');
}
}
String get referencedMenuName {
switch (this) {
case ViewLayoutPB.Grid:
return LocaleKeys.document_plugins_referencedGrid.tr();
case ViewLayoutPB.Board:
return LocaleKeys.document_plugins_referencedBoard.tr();
case ViewLayoutPB.Calendar:
return LocaleKeys.document_plugins_referencedCalendar.tr();
default:
throw UnsupportedError('Unsupported layout: $this');
}
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,168 @@
import 'dart:ui';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/custom_cover_picker.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_popover.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'ime.dart';
import 'util.dart';
extension EditorWidgetTester on WidgetTester {
EditorOperations get editor => EditorOperations(this);
}
class EditorOperations {
const EditorOperations(this.tester);
final WidgetTester tester;
EditorState getCurrentEditorState() {
return tester
.widget<AppFlowyEditor>(find.byType(AppFlowyEditor))
.editorState;
}
/// Tap the line of editor at [index]
Future<void> tapLineOfEditorAt(int index) async {
final textBlocks = find.byType(FlowyRichText);
index = index.clamp(0, textBlocks.evaluate().length - 1);
await tester.tapAt(tester.getTopRight(textBlocks.at(index)));
await tester.pumpAndSettle();
}
/// Hover on cover plugin button above the document
Future<void> hoverOnCoverToolbar() async {
final coverToolbar = find.byType(DocumentHeaderToolbar);
await tester.startGesture(
tester.getBottomLeft(coverToolbar).translate(5, -5),
kind: PointerDeviceKind.mouse,
);
await tester.pumpAndSettle();
}
/// Taps on the 'Add Icon' button in the cover toolbar
Future<void> tapAddIconButton() async {
await tester.tapButtonWithName(
LocaleKeys.document_plugins_cover_addIcon.tr(),
);
expect(find.byType(EmojiPopover), findsOneWidget);
}
/// Taps the 'Remove Icon' button in the cover toolbar and the icon popover
Future<void> tapRemoveIconButton({bool isInPicker = false}) async {
Finder button =
find.text(LocaleKeys.document_plugins_cover_removeIcon.tr());
if (isInPicker) {
button = find.descendant(of: find.byType(EmojiPopover), matching: button);
}
await tester.tapButton(button);
}
/// Requires that the document must already have an icon. This opens the icon
/// picker
Future<void> tapOnIconWidget() async {
final iconWidget = find.byType(EmojiIconWidget);
await tester.tapButton(iconWidget);
}
Future<void> tapOnAddCover() async {
await tester.tapButtonWithName(
LocaleKeys.document_plugins_cover_addCover.tr(),
);
}
Future<void> tapOnChangeCover() async {
await tester.tapButtonWithName(
LocaleKeys.document_plugins_cover_changeCover.tr(),
);
}
Future<void> switchSolidColorBackground() async {
final findPurpleButton = find.byWidgetPredicate(
(widget) => widget is ColorItem && widget.option.colorHex == "ffe8e0ff",
);
await tester.tapButton(findPurpleButton);
}
Future<void> addNetworkImageCover(String imageUrl) async {
final findNewImageButton = find.byType(NewCustomCoverButton);
await tester.tapButton(findNewImageButton);
final imageUrlTextField = find.descendant(
of: find.byType(NetworkImageUrlInput),
matching: find.byType(TextField),
);
await tester.enterText(imageUrlTextField, imageUrl);
await tester.tapButtonWithName(
LocaleKeys.document_plugins_cover_add.tr(),
);
await tester.tapButtonWithName(
LocaleKeys.document_plugins_cover_saveToGallery.tr(),
);
}
Future<void> switchNetworkImageCover(String imageUrl) async {
final image = find.byWidgetPredicate(
(widget) => widget is ImageGridItem,
);
await tester.tapButton(image);
}
Future<void> tapOnRemoveCover() async {
await tester.tapButton(find.byType(DeleteCoverButton));
}
/// A cover must be present in the document to function properly since this
/// catches all cover types collectively
Future<void> hoverOnCover() async {
final cover = find.byType(DocumentCover);
await tester.startGesture(
tester.getCenter(cover),
kind: PointerDeviceKind.mouse,
);
await tester.pumpAndSettle();
}
Future<void> dismissCoverPicker() async {
await tester.sendKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
}
/// trigger the slash command (selection menu)
Future<void> showSlashMenu() async {
await tester.ime.insertCharacter('/');
}
/// trigger the slash command (selection menu)
Future<void> showAtMenu() async {
await tester.ime.insertCharacter('@');
}
/// Tap the slash menu item with [name]
///
/// Must call [showSlashMenu] first.
Future<void> tapSlashMenuItemWithName(String name) async {
final slashMenuItem = find.text(name, findRichText: true);
await tester.tapButton(slashMenuItem);
}
/// Tap the at menu item with [name]
///
/// Must call [showAtMenu] first.
Future<void> tapAtMenuItemWithName(String name) async {
final atMenuItem = find.descendant(
of: find.byType(SelectionMenuWidget),
matching: find.text(name, findRichText: true),
);
await tester.tapButton(atMenuItem);
}
}
@@ -0,0 +1,17 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'base.dart';
extension EmojiTestExtension on WidgetTester {
/// Must call [openEmojiPicker] first
Future<void> switchToEmojiList() async {
final icon = find.byIcon(Icons.tag_faces);
await tapButton(icon);
}
Future<void> tapEmoji(String emoji) async {
final emojiWidget = find.text(emoji);
await tapButton(emojiWidget);
}
}
@@ -0,0 +1,135 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/banner.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_header_node_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy/workspace/presentation/home/menu/app/section/item.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter_test/flutter_test.dart';
const String readme = 'Read me';
extension Expectation on WidgetTester {
/// Expect to see the home page and with a default read me page.
void expectToSeeHomePage() {
expect(find.byType(HomeStack), findsOneWidget);
expect(find.textContaining(readme), findsWidgets);
}
/// Expect to see the page name on the home page.
void expectToSeePageName(String name) {
final pageName = findPageName(name);
expect(pageName, findsOneWidget);
}
/// Expect not to see the page name on the home page.
void expectNotToSeePageName(String name) {
final pageName = findPageName(name);
expect(pageName, findsNothing);
}
/// Expect to see the document banner.
void expectToSeeDocumentBanner() {
expect(find.byType(DocumentBanner), findsOneWidget);
}
/// Expect not to see the document banner.
void expectNotToSeeDocumentBanner() {
expect(find.byType(DocumentBanner), findsNothing);
}
/// Expect to the markdown file export success dialog.
void expectToExportSuccess() {
final exportSuccess = find.byWidgetPredicate(
(widget) =>
widget is FlowyText &&
widget.text == LocaleKeys.settings_files_exportFileSuccess.tr(),
);
expect(exportSuccess, findsOneWidget);
}
/// Expect to see the add button and icon button in the cover toolbar
void expectToSeePluginAddCoverAndIconButton() {
final addCover = find.textContaining(
LocaleKeys.document_plugins_cover_addCover.tr(),
);
final addIcon = find.textContaining(
LocaleKeys.document_plugins_cover_addIcon.tr(),
);
expect(addCover, findsOneWidget);
expect(addIcon, findsOneWidget);
}
/// Expect to see the document header toolbar empty
void expectToSeeEmptyDocumentHeaderToolbar() {
final addCover = find.textContaining(
LocaleKeys.document_plugins_cover_addCover.tr(),
);
final addIcon = find.textContaining(
LocaleKeys.document_plugins_cover_addIcon.tr(),
);
expect(addCover, findsNothing);
expect(addIcon, findsNothing);
}
void expectToSeeDocumentIcon(String? emoji) {
if (emoji == null) {
final iconWidget = find.byType(EmojiIconWidget);
expect(iconWidget, findsNothing);
return;
}
final iconWidget = find.byWidgetPredicate(
(widget) => widget is EmojiIconWidget && widget.emoji == emoji,
);
expect(iconWidget, findsOneWidget);
}
void expectToSeeDocumentCover(CoverType type) {
final findCover = find.byWidgetPredicate(
(widget) => widget is DocumentCover && widget.coverType == type,
);
expect(findCover, findsOneWidget);
}
void expectToSeeNoDocumentCover() {
final findCover = find.byType(DocumentCover);
expect(findCover, findsNothing);
}
void expectChangeCoverAndDeleteButton() {
final findChangeCover = find.text(
LocaleKeys.document_plugins_cover_changeCover.tr(),
);
final findRemoveIcon = find.byType(DeleteCoverButton);
expect(findChangeCover, findsOneWidget);
expect(findRemoveIcon, findsOneWidget);
}
/// Expect to see the user name on the home page
void expectToSeeUserName(String name) {
final userName = find.byWidgetPredicate(
(widget) => widget is FlowyText && widget.text == name,
);
expect(userName, findsOneWidget);
}
/// Expect to see a text
void expectToSeeText(String text) {
Finder textWidget = find.textContaining(text, findRichText: true);
if (textWidget.evaluate().isEmpty) {
textWidget = find.byWidgetPredicate(
(widget) => widget is FlowyText && widget.text == text,
);
}
expect(textWidget, findsOneWidget);
}
/// Find the page name on the home page.
Finder findPageName(String name) {
return find.byWidgetPredicate(
(widget) => widget is ViewSectionItem && widget.view.name == name,
skipOffstage: false,
);
}
}
@@ -0,0 +1,48 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
extension IME on WidgetTester {
IMESimulator get ime => IMESimulator(this);
}
class IMESimulator {
IMESimulator(this.tester) {
client = findTextInputClient();
}
final WidgetTester tester;
late final TextInputClient client;
Future<void> insertText(String text) async {
for (final c in text.characters) {
await insertCharacter(c);
}
}
Future<void> insertCharacter(String character) async {
final value = client.currentTextEditingValue;
if (value == null) {
assert(false);
return;
}
final text = value.text
.replaceRange(value.selection.start, value.selection.end, character);
final textEditingValue = TextEditingValue(
text: text,
selection: TextSelection.collapsed(
offset: value.selection.baseOffset + 1,
),
composing: TextRange.empty,
);
client.updateEditingValue(textEditingValue);
await tester.pumpAndSettle();
}
TextInputClient findTextInputClient() {
final finder = find.byType(KeyboardServiceWidget);
final KeyboardServiceWidgetState state = tester.state(finder);
return state.textInputService as TextInputClient;
}
}
@@ -1,15 +1,19 @@
import 'dart:io';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/file_picker/file_picker_service.dart';
import 'package:file_picker/src/file_picker.dart' as fp;
import 'package:file_picker/file_picker.dart' as fp;
import 'package:path/path.dart' as p;
import '../util.dart';
class MockFilePicker implements FilePickerService {
MockFilePicker({
required this.mockPath,
this.mockPath = '',
this.mockPaths = const [],
});
final String mockPath;
final List<String> mockPaths;
@override
Future<String?> getDirectoryPath({String? title}) {
@@ -41,7 +45,14 @@ class MockFilePicker implements FilePickerService {
bool withReadStream = false,
bool lockParentWindow = false,
}) {
throw UnimplementedError();
final platformFiles = mockPaths
.map((e) => fp.PlatformFile(path: e, name: '', size: 0))
.toList();
return Future.value(
FilePickerResult(
platformFiles,
),
);
}
}
@@ -67,3 +78,24 @@ Future<String> mockSaveFilePath(String? name, String fileName) async {
);
return path;
}
Future<List<String>> mockPickFilePaths(
List<String> fileNames, {
String? name,
String? customPath,
}) async {
late final Directory dir;
if (customPath != null) {
dir = Directory(customPath);
} else {
dir = await TestFolder.testLocation(name);
}
final paths = fileNames.map((e) => p.join(dir.path, e)).toList();
getIt.unregister<FilePickerService>();
getIt.registerFactory<FilePickerService>(
() => MockFilePicker(
mockPaths: paths,
),
);
return paths;
}
@@ -2,3 +2,5 @@ export 'base.dart';
export 'common_operations.dart';
export 'settings.dart';
export 'data.dart';
export 'expectation.dart';
export 'editor_test_operations.dart';
+4 -1
View File
@@ -6,5 +6,8 @@ import 'startup/startup.dart';
Future<void> main() async {
WidgetsFlutterBinding.ensureInitialized();
await FlowyRunner.run(FlowyApp());
await FlowyRunner.run(
FlowyApp(),
integrationEnv(),
);
}
@@ -1,5 +1,6 @@
import 'dart:async';
import 'package:appflowy/plugins/database_view/application/field/field_listener.dart';
import 'package:appflowy/plugins/database_view/application/row/row_meta_listener.dart';
import 'package:appflowy/plugins/database_view/application/row/row_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
@@ -22,39 +23,45 @@ import 'cell_service.dart';
///
// ignore: must_be_immutable
class CellController<T, D> extends Equatable {
final DatabaseCellContext cellContext;
DatabaseCellContext _cellContext;
final CellCache _cellCache;
final CellCacheKey _cacheKey;
final FieldBackendService _fieldBackendSvc;
final SingleFieldListener _fieldListener;
final CellDataLoader<T> _cellDataLoader;
final CellDataPersistence<D> _cellDataPersistence;
CellListener? _cellListener;
RowMetaListener? _rowMetaListener;
SingleFieldListener? _fieldListener;
CellDataNotifier<T?>? _cellDataNotifier;
VoidCallback? _onCellFieldChanged;
VoidCallback? _onRowMetaChanged;
Timer? _loadDataOperation;
Timer? _saveDataOperation;
String get viewId => cellContext.viewId;
String get viewId => _cellContext.viewId;
RowId get rowId => cellContext.rowId;
RowId get rowId => _cellContext.rowId;
String get fieldId => cellContext.fieldInfo.id;
String get fieldId => _cellContext.fieldInfo.id;
FieldInfo get fieldInfo => cellContext.fieldInfo;
FieldInfo get fieldInfo => _cellContext.fieldInfo;
FieldType get fieldType => cellContext.fieldInfo.fieldType;
FieldType get fieldType => _cellContext.fieldInfo.fieldType;
String? get emoji => _cellContext.emoji;
CellController({
required this.cellContext,
required DatabaseCellContext cellContext,
required CellCache cellCache,
required CellDataLoader<T> cellDataLoader,
required CellDataPersistence<D> cellDataPersistence,
}) : _cellCache = cellCache,
}) : _cellContext = cellContext,
_cellCache = cellCache,
_cellDataLoader = cellDataLoader,
_cellDataPersistence = cellDataPersistence,
_rowMetaListener = RowMetaListener(cellContext.rowId),
_fieldListener = SingleFieldListener(fieldId: cellContext.fieldId),
_fieldBackendSvc = FieldBackendService(
viewId: cellContext.viewId,
@@ -84,20 +91,22 @@ class CellController<T, D> extends Equatable {
);
/// 2.Listen on the field event and load the cell data if needed.
_fieldListener.start(
onFieldChanged: (result) {
result.fold(
(fieldPB) {
/// reloadOnFieldChanged should be true if you need to load the data when the corresponding field is changed
/// For example:
/// ¥12 -> $12
if (_cellDataLoader.reloadOnFieldChanged) {
_loadData();
}
_onCellFieldChanged?.call();
},
(err) => Log.error(err),
);
_fieldListener?.start(
onFieldChanged: (fieldPB) {
/// reloadOnFieldChanged should be true if you need to load the data when the corresponding field is changed
/// For example:
/// ¥12 -> $12
if (_cellDataLoader.reloadOnFieldChanged) {
_loadData();
}
_onCellFieldChanged?.call();
},
);
_rowMetaListener?.start(
callback: (newRowMeta) {
_cellContext = _cellContext.copyWith(rowMeta: newRowMeta);
_onRowMetaChanged?.call();
},
);
}
@@ -105,9 +114,11 @@ class CellController<T, D> extends Equatable {
/// Listen on the cell content or field changes
VoidCallback? startListening({
required void Function(T?) onCellChanged,
VoidCallback? onRowMetaChanged,
VoidCallback? onCellFieldChanged,
}) {
_onCellFieldChanged = onCellFieldChanged;
_onRowMetaChanged = onRowMetaChanged;
/// Notify the listener, the cell data was changed.
onCellChangedFn() => onCellChanged(_cellDataNotifier?.value);
@@ -186,18 +197,26 @@ class CellController<T, D> extends Equatable {
}
Future<void> dispose() async {
await _rowMetaListener?.stop();
_rowMetaListener = null;
await _cellListener?.stop();
_cellListener = null;
await _fieldListener?.stop();
_fieldListener = null;
_loadDataOperation?.cancel();
_saveDataOperation?.cancel();
_cellDataNotifier?.dispose();
await _fieldListener.stop();
_cellDataNotifier = null;
_onRowMetaChanged = null;
}
@override
List<Object> get props => [
_cellCache.get(_cacheKey) ?? "",
cellContext.rowId + cellContext.fieldInfo.id
_cellContext.rowId + _cellContext.fieldInfo.id
];
}
@@ -2,6 +2,7 @@ import 'dart:async';
import 'dart:collection';
import 'package:appflowy_backend/protobuf/flowy-database2/checklist_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/url_entities.pb.dart';
import 'package:dartz/dartz.dart';
@@ -52,18 +53,23 @@ class CellBackendService {
class DatabaseCellContext with _$DatabaseCellContext {
const factory DatabaseCellContext({
required String viewId,
required RowId rowId,
required RowMetaPB rowMeta,
required FieldInfo fieldInfo,
}) = _DatabaseCellContext;
// ignore: unused_element
const DatabaseCellContext._();
String get rowId => rowMeta.id;
String get fieldId => fieldInfo.id;
FieldType get fieldType => fieldInfo.fieldType;
ValueKey key() {
return ValueKey("$rowId$fieldId${fieldInfo.fieldType}");
return ValueKey("${rowMeta.id}$fieldId${fieldInfo.fieldType}");
}
/// Only the primary field can have an emoji.
String? get emoji => fieldInfo.isPrimary ? rowMeta.icon : null;
}
@@ -1,4 +1,3 @@
import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/cell_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/checklist_entities.pb.dart';
@@ -7,17 +6,23 @@ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:dartz/dartz.dart';
class ChecklistCellBackendService {
final DatabaseCellContext cellContext;
final String viewId;
final String fieldId;
final String rowId;
ChecklistCellBackendService({required this.cellContext});
ChecklistCellBackendService({
required this.viewId,
required this.fieldId,
required this.rowId,
});
Future<Either<Unit, FlowyError>> create({
required String name,
}) {
final payload = ChecklistCellDataChangesetPB.create()
..viewId = cellContext.viewId
..fieldId = cellContext.fieldInfo.id
..rowId = cellContext.rowId
..viewId = viewId
..fieldId = fieldId
..rowId = rowId
..insertOptions.add(name);
return DatabaseEventUpdateChecklistCell(payload).send();
@@ -27,9 +32,9 @@ class ChecklistCellBackendService {
required List<String> optionIds,
}) {
final payload = ChecklistCellDataChangesetPB.create()
..viewId = cellContext.viewId
..fieldId = cellContext.fieldInfo.id
..rowId = cellContext.rowId
..viewId = viewId
..fieldId = fieldId
..rowId = rowId
..deleteOptionIds.addAll(optionIds);
return DatabaseEventUpdateChecklistCell(payload).send();
@@ -39,9 +44,9 @@ class ChecklistCellBackendService {
required String optionId,
}) {
final payload = ChecklistCellDataChangesetPB.create()
..viewId = cellContext.viewId
..fieldId = cellContext.fieldInfo.id
..rowId = cellContext.rowId
..viewId = viewId
..fieldId = fieldId
..rowId = rowId
..selectedOptionIds.add(optionId);
return DatabaseEventUpdateChecklistCell(payload).send();
@@ -51,9 +56,9 @@ class ChecklistCellBackendService {
required SelectOptionPB option,
}) {
final payload = ChecklistCellDataChangesetPB.create()
..viewId = cellContext.viewId
..fieldId = cellContext.fieldInfo.id
..rowId = cellContext.rowId
..viewId = viewId
..fieldId = fieldId
..rowId = rowId
..updateOptions.add(option);
return DatabaseEventUpdateChecklistCell(payload).send();
@@ -61,10 +66,9 @@ class ChecklistCellBackendService {
Future<Either<ChecklistCellDataPB, FlowyError>> getCellData() {
final payload = CellIdPB.create()
..fieldId = cellContext.fieldInfo.id
..viewId = cellContext.viewId
..rowId = cellContext.rowId
..rowId = cellContext.rowId;
..viewId = viewId
..fieldId = fieldId
..rowId = rowId;
return DatabaseEventGetChecklistCellData(payload).send();
}
@@ -1,6 +1,4 @@
import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart';
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_service.dart';
import 'package:appflowy/plugins/database_view/application/row/row_service.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option.pb.dart';
import 'package:dartz/dartz.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
@@ -8,12 +6,15 @@ import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/cell_entities.pb.dart';
class SelectOptionCellBackendService {
final DatabaseCellContext cellContext;
SelectOptionCellBackendService({required this.cellContext});
final String viewId;
final String fieldId;
final String rowId;
String get viewId => cellContext.viewId;
String get fieldId => cellContext.fieldInfo.id;
RowId get rowId => cellContext.rowId;
SelectOptionCellBackendService({
required this.viewId,
required this.fieldId,
required this.rowId,
});
Future<Either<Unit, FlowyError>> create({
required String name,
@@ -1,8 +1,8 @@
import 'package:appflowy/plugins/database_view/application/field/field_controller.dart';
import 'package:appflowy/plugins/database_view/application/layout/calendar_setting_listener.dart';
import 'package:appflowy/plugins/database_view/application/view/view_cache.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/calendar_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/database_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/group.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/group_changeset.pb.dart';
@@ -15,6 +15,7 @@ import 'dart:async';
import 'package:dartz/dartz.dart';
import 'database_view_service.dart';
import 'defines.dart';
import 'layout/layout_service.dart';
import 'layout/layout_setting_listener.dart';
import 'row/row_cache.dart';
import 'group/group_listener.dart';
@@ -49,16 +50,11 @@ class DatabaseLayoutSettingCallbacks {
});
}
class CalendarLayoutCallbacks {
final void Function(DatabaseLayoutSettingPB) onCalendarLayoutChanged;
CalendarLayoutCallbacks({required this.onCalendarLayoutChanged});
}
class DatabaseCallbacks {
OnDatabaseChanged? onDatabaseChanged;
OnFieldsChanged? onFieldsChanged;
OnFiltersChanged? onFiltersChanged;
OnSortsChanged? onSortsChanged;
OnNumOfRowsChanged? onNumOfRowsChanged;
OnRowsDeleted? onRowsDeleted;
OnRowsUpdated? onRowsUpdated;
@@ -69,6 +65,7 @@ class DatabaseCallbacks {
this.onNumOfRowsChanged,
this.onFieldsChanged,
this.onFiltersChanged,
this.onSortsChanged,
this.onRowsUpdated,
this.onRowsDeleted,
this.onRowsCreated,
@@ -79,15 +76,14 @@ class DatabaseController {
final String viewId;
final DatabaseViewBackendService _databaseViewBackendSvc;
final FieldController fieldController;
DatabaseLayoutPB? databaseLayout;
DatabaseLayoutPB databaseLayout;
DatabaseLayoutSettingPB? databaseLayoutSetting;
late DatabaseViewCache _viewCache;
// Callbacks
DatabaseCallbacks? _databaseCallbacks;
GroupCallbacks? _groupCallbacks;
DatabaseLayoutSettingCallbacks? _layoutCallbacks;
CalendarLayoutCallbacks? _calendarLayoutCallbacks;
final List<DatabaseCallbacks> _databaseCallbacks = [];
final List<GroupCallbacks> _groupCallbacks = [];
final List<DatabaseLayoutSettingCallbacks> _layoutCallbacks = [];
// Getters
RowCache get rowCache => _viewCache.rowCache;
@@ -95,15 +91,14 @@ class DatabaseController {
// Listener
final DatabaseGroupListener _groupListener;
final DatabaseLayoutSettingListener _layoutListener;
final DatabaseCalendarLayoutListener _calendarLayoutListener;
DatabaseController({required ViewPB view})
: viewId = view.id,
_databaseViewBackendSvc = DatabaseViewBackendService(viewId: view.id),
fieldController = FieldController(viewId: view.id),
_groupListener = DatabaseGroupListener(view.id),
_layoutListener = DatabaseLayoutSettingListener(view.id),
_calendarLayoutListener = DatabaseCalendarLayoutListener(view.id) {
databaseLayout = databaseLayoutFromViewLayout(view.layout),
_layoutListener = DatabaseLayoutSettingListener(view.id) {
_viewCache = DatabaseViewCache(
viewId: viewId,
fieldController: fieldController,
@@ -114,44 +109,51 @@ class DatabaseController {
_listenOnLayoutChanged();
}
void setListener({
void addListener({
DatabaseCallbacks? onDatabaseChanged,
DatabaseLayoutSettingCallbacks? onLayoutChanged,
GroupCallbacks? onGroupChanged,
CalendarLayoutCallbacks? onCalendarLayoutChanged,
}) {
_layoutCallbacks = onLayoutChanged;
_databaseCallbacks = onDatabaseChanged;
_groupCallbacks = onGroupChanged;
_calendarLayoutCallbacks = onCalendarLayoutChanged;
if (onLayoutChanged != null) {
_layoutCallbacks.add(onLayoutChanged);
}
if (onDatabaseChanged != null) {
_databaseCallbacks.add(onDatabaseChanged);
}
if (onGroupChanged != null) {
_groupCallbacks.add(onGroupChanged);
}
}
Future<Either<Unit, FlowyError>> open() async {
return _databaseViewBackendSvc.openGrid().then((result) {
return _databaseViewBackendSvc.openDatabase().then((result) {
return result.fold(
(database) async {
(DatabasePB database) async {
databaseLayout = database.layoutType;
if (databaseLayout == DatabaseLayoutPB.Calendar) {
_listenOnCalendarLayoutChanged();
}
_databaseCallbacks?.onDatabaseChanged?.call(database);
_viewCache.rowCache.setInitialRows(database.rows);
return await fieldController
.loadFields(
// Load the actual database field data.
final fieldsOrFail = await fieldController.loadFields(
fieldIds: database.fields,
)
.then(
(result) {
return result.fold(
(l) => Future(() async {
await _loadGroups();
await _loadLayoutSetting();
return left(l);
}),
(err) => right(err),
);
);
return fieldsOrFail.fold(
(fields) {
// Notify the database is changed after the fields are loaded.
// The database won't can't be used until the fields are loaded.
for (final callback in _databaseCallbacks) {
callback.onDatabaseChanged?.call(database);
}
_viewCache.rowCache.setInitialRows(database.rows);
return Future(() async {
await _loadGroups();
await _loadLayoutSetting();
return left(fields);
});
},
(err) {
Log.error(err);
return right(err);
},
);
},
@@ -160,7 +162,7 @@ class DatabaseController {
});
}
Future<Either<RowPB, FlowyError>> createRow({
Future<Either<RowMetaPB, FlowyError>> createRow({
RowId? startRowId,
String? groupId,
void Function(RowDataBuilder builder)? withCells,
@@ -181,9 +183,9 @@ class DatabaseController {
}
Future<Either<Unit, FlowyError>> moveGroupRow({
required RowPB fromRow,
required RowMetaPB fromRow,
required String groupId,
RowPB? toRow,
RowMetaPB? toRow,
}) {
return _databaseViewBackendSvc.moveGroupRow(
fromRowId: fromRow.id,
@@ -193,12 +195,12 @@ class DatabaseController {
}
Future<Either<Unit, FlowyError>> moveRow({
required RowPB fromRow,
required RowPB toRow,
required String fromRowId,
required String toRowId,
}) {
return _databaseViewBackendSvc.moveRow(
fromRowId: fromRow.id,
toRowId: toRow.id,
fromRowId: fromRowId,
toRowId: toRowId,
);
}
@@ -212,11 +214,14 @@ class DatabaseController {
);
}
Future<void> updateCalenderLayoutSetting(
CalendarLayoutSettingPB layoutSetting,
Future<void> updateLayoutSetting(
CalendarLayoutSettingPB calendarlLayoutSetting,
) async {
await _databaseViewBackendSvc
.updateLayoutSetting(calendarLayoutSetting: layoutSetting)
.updateLayoutSetting(
calendarLayoutSetting: calendarlLayoutSetting,
layoutType: databaseLayout,
)
.then((result) {
result.fold((l) => null, (r) => Log.error(r));
});
@@ -227,10 +232,9 @@ class DatabaseController {
await fieldController.dispose();
await _groupListener.stop();
await _viewCache.dispose();
_databaseCallbacks = null;
_groupCallbacks = null;
_layoutCallbacks = null;
_calendarLayoutCallbacks = null;
_databaseCallbacks.clear();
_groupCallbacks.clear();
_layoutCallbacks.clear();
}
Future<void> _loadGroups() async {
@@ -238,7 +242,9 @@ class DatabaseController {
return Future(
() => result.fold(
(groups) {
_groupCallbacks?.onGroupByField?.call(groups.items);
for (final callback in _groupCallbacks) {
callback.onGroupByField?.call(groups.items);
}
},
(err) => Log.error(err),
),
@@ -246,46 +252,63 @@ class DatabaseController {
}
Future<void> _loadLayoutSetting() async {
if (databaseLayout != null) {
_databaseViewBackendSvc.getLayoutSetting(databaseLayout!).then((result) {
result.fold(
(newDatabaseLayoutSetting) {
databaseLayoutSetting = newDatabaseLayoutSetting;
databaseLayoutSetting?.freeze();
_databaseViewBackendSvc.getLayoutSetting(databaseLayout).then((result) {
result.fold(
(newDatabaseLayoutSetting) {
databaseLayoutSetting = newDatabaseLayoutSetting;
databaseLayoutSetting?.freeze();
_layoutCallbacks?.onLoadLayout(newDatabaseLayoutSetting);
},
(r) => Log.error(r),
);
});
}
for (final callback in _layoutCallbacks) {
callback.onLoadLayout(newDatabaseLayoutSetting);
}
},
(r) => Log.error(r),
);
});
}
void _listenOnRowsChanged() {
final callbacks = DatabaseViewCallbacks(
onNumOfRowsChanged: (rows, rowByRowId, reason) {
_databaseCallbacks?.onNumOfRowsChanged?.call(rows, rowByRowId, reason);
for (final callback in _databaseCallbacks) {
callback.onNumOfRowsChanged?.call(rows, rowByRowId, reason);
}
},
onRowsDeleted: (ids) {
_databaseCallbacks?.onRowsDeleted?.call(ids);
for (final callback in _databaseCallbacks) {
callback.onRowsDeleted?.call(ids);
}
},
onRowsUpdated: (ids) {
_databaseCallbacks?.onRowsUpdated?.call(ids);
onRowsUpdated: (ids, reason) {
for (final callback in _databaseCallbacks) {
callback.onRowsUpdated?.call(ids, reason);
}
},
onRowsCreated: (ids) {
_databaseCallbacks?.onRowsCreated?.call(ids);
for (final callback in _databaseCallbacks) {
callback.onRowsCreated?.call(ids);
}
},
);
_viewCache.setListener(callbacks);
_viewCache.addListener(callbacks);
}
void _listenOnFieldsChanged() {
fieldController.addListener(
onReceiveFields: (fields) {
_databaseCallbacks?.onFieldsChanged?.call(UnmodifiableListView(fields));
for (final callback in _databaseCallbacks) {
callback.onFieldsChanged?.call(UnmodifiableListView(fields));
}
},
onSorts: (sorts) {
for (final callback in _databaseCallbacks) {
callback.onSortsChanged?.call(sorts);
}
},
onFilters: (filters) {
_databaseCallbacks?.onFiltersChanged?.call(filters);
for (final callback in _databaseCallbacks) {
callback.onFiltersChanged?.call(filters);
}
},
);
}
@@ -296,15 +319,21 @@ class DatabaseController {
result.fold(
(changeset) {
if (changeset.updateGroups.isNotEmpty) {
_groupCallbacks?.onUpdateGroup?.call(changeset.updateGroups);
for (final callback in _groupCallbacks) {
callback.onUpdateGroup?.call(changeset.updateGroups);
}
}
if (changeset.deletedGroups.isNotEmpty) {
_groupCallbacks?.onDeleteGroup?.call(changeset.deletedGroups);
for (final callback in _groupCallbacks) {
callback.onDeleteGroup?.call(changeset.deletedGroups);
}
}
for (final insertedGroup in changeset.insertedGroups) {
_groupCallbacks?.onInsertGroup?.call(insertedGroup);
for (final callback in _groupCallbacks) {
callback.onInsertGroup?.call(insertedGroup);
}
}
},
(r) => Log.error(r),
@@ -313,7 +342,9 @@ class DatabaseController {
onGroupByNewField: (result) {
result.fold(
(groups) {
_groupCallbacks?.onGroupByField?.call(groups);
for (final callback in _groupCallbacks) {
callback.onGroupByField?.call(groups);
}
},
(r) => Log.error(r),
);
@@ -325,24 +356,13 @@ class DatabaseController {
_layoutListener.start(
onLayoutChanged: (result) {
result.fold(
(newDatabaseLayoutSetting) {
databaseLayoutSetting = newDatabaseLayoutSetting;
(newLayout) {
databaseLayoutSetting = newLayout;
databaseLayoutSetting?.freeze();
_layoutCallbacks?.onLayoutChanged(newDatabaseLayoutSetting);
},
(r) => Log.error(r),
);
},
);
}
void _listenOnCalendarLayoutChanged() {
_calendarLayoutListener.start(
onCalendarLayoutChanged: (result) {
result.fold(
(l) {
_calendarLayoutCallbacks?.onCalendarLayoutChanged(l);
for (final callback in _layoutCallbacks) {
callback.onLayoutChanged(newLayout);
}
},
(r) => Log.error(r),
);
@@ -25,12 +25,12 @@ class DatabaseViewBackendService {
.then((value) => value.leftMap((l) => l.value));
}
Future<Either<DatabasePB, FlowyError>> openGrid() async {
Future<Either<DatabasePB, FlowyError>> openDatabase() async {
final payload = DatabaseViewIdPB(value: viewId);
return DatabaseEventGetDatabase(payload).send();
}
Future<Either<RowPB, FlowyError>> createRow({
Future<Either<RowMetaPB, FlowyError>> createRow({
RowId? startRowId,
String? groupId,
Map<String, String>? cellDataByFieldId,
@@ -113,9 +113,12 @@ class DatabaseViewBackendService {
}
Future<Either<Unit, FlowyError>> updateLayoutSetting({
required DatabaseLayoutPB layoutType,
CalendarLayoutSettingPB? calendarLayoutSetting,
}) {
final payload = LayoutSettingChangesetPB.create()..viewId = viewId;
final payload = LayoutSettingChangesetPB.create()
..viewId = viewId
..layoutType = layoutType;
if (calendarLayoutSetting != null) {
payload.calendar = calendarLayoutSetting;
}
@@ -1,5 +1,6 @@
import 'dart:collection';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/sort/sort_info.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/database_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
@@ -10,10 +11,14 @@ import 'row/row_service.dart';
typedef OnFieldsChanged = void Function(UnmodifiableListView<FieldInfo>);
typedef OnFiltersChanged = void Function(List<FilterInfo>);
typedef OnSortsChanged = void Function(List<SortInfo>);
typedef OnDatabaseChanged = void Function(DatabasePB);
typedef OnRowsCreated = void Function(List<RowId> ids);
typedef OnRowsUpdated = void Function(List<RowId> ids);
typedef OnRowsUpdated = void Function(
List<RowId> ids,
RowsChangedReason reason,
);
typedef OnRowsDeleted = void Function(List<RowId> ids);
typedef OnNumOfRowsChanged = void Function(
UnmodifiableListView<RowInfo> rows,
@@ -1,4 +1,3 @@
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@@ -52,14 +51,11 @@ class FieldCellBloc extends Bloc<FieldCellEvent, FieldCellState> {
void _startListening() {
_fieldListener.start(
onFieldChanged: (result) {
onFieldChanged: (updatedField) {
if (isClosed) {
return;
}
result.fold(
(field) => add(FieldCellEvent.didReceiveFieldUpdate(field)),
(err) => Log.error(err),
);
add(FieldCellEvent.didReceiveFieldUpdate(updatedField));
},
);
}
@@ -33,7 +33,8 @@ class _GridFieldNotifier extends ChangeNotifier {
notifyListeners();
}
List<FieldInfo> get fieldInfos => _fieldInfos;
UnmodifiableListView<FieldInfo> get fieldInfos =>
UnmodifiableListView(_fieldInfos);
}
class _GridFilterNotifier extends ChangeNotifier {
@@ -85,9 +86,11 @@ class FieldController {
final FilterBackendService _filterBackendSvc;
final SortBackendService _sortBackendSvc;
bool _isDisposed = false;
// Field callbacks
final Map<OnReceiveFields, VoidCallback> _fieldCallbacks = {};
_GridFieldNotifier? _fieldNotifier = _GridFieldNotifier();
final _GridFieldNotifier _fieldNotifier = _GridFieldNotifier();
// Field updated callbacks
final Map<OnReceiveUpdateFields, void Function(List<FieldInfo>)>
@@ -107,15 +110,15 @@ class FieldController {
final Map<String, SortPB> _sortPBByFieldId = {};
// Getters
List<FieldInfo> get fieldInfos => [..._fieldNotifier?.fieldInfos ?? []];
List<FieldInfo> get fieldInfos => [..._fieldNotifier.fieldInfos];
List<FilterInfo> get filterInfos => [..._filterNotifier?.filters ?? []];
List<SortInfo> get sortInfos => [..._sortNotifier?.sorts ?? []];
FieldInfo? getField(String fieldId) {
final fields = _fieldNotifier?.fieldInfos
.where((element) => element.id == fieldId)
.toList() ??
[];
final fields = _fieldNotifier.fieldInfos
.where((element) => element.id == fieldId)
.toList();
if (fields.isEmpty) {
return null;
}
@@ -169,6 +172,10 @@ class FieldController {
_listenOnSortChanged();
_settingBackendSvc.getSetting().then((result) {
if (_isDisposed) {
return;
}
result.fold(
(setting) => _updateSetting(setting),
(err) => Log.error(err),
@@ -257,6 +264,10 @@ class FieldController {
_filtersListener.start(
onFilterChanged: (result) {
if (_isDisposed) {
return;
}
result.fold(
(FilterChangesetNotificationPB changeset) {
final List<FilterInfo> filters = filterInfos;
@@ -351,6 +362,9 @@ class FieldController {
_sortsListener.start(
onSortChanged: (result) {
if (_isDisposed) {
return;
}
result.fold(
(SortChangesetNotificationPB changeset) {
final List<SortInfo> newSortInfos = sortInfos;
@@ -371,6 +385,10 @@ class FieldController {
//Listen on setting changes
_settingListener.start(
onSettingUpdated: (result) {
if (_isDisposed) {
return;
}
result.fold(
(setting) => _updateSetting(setting),
(r) => Log.error(r),
@@ -385,6 +403,9 @@ class FieldController {
onFieldsChanged: (result) {
result.fold(
(changeset) {
if (_isDisposed) {
return;
}
_deleteFields(changeset.deletedFields);
_insertFields(changeset.insertedFields);
@@ -417,27 +438,29 @@ class FieldController {
}
void _updateFieldInfos() {
if (_fieldNotifier != null) {
for (final field in _fieldNotifier!.fieldInfos) {
field._isGroupField = _groupConfigurationByFieldId[field.id] != null;
field._hasFilter = _filterPBByFieldId[field.id] != null;
field._hasSort = _sortPBByFieldId[field.id] != null;
}
_fieldNotifier?.notify();
for (final field in _fieldNotifier.fieldInfos) {
field._isGroupField = _groupConfigurationByFieldId[field.id] != null;
field._hasFilter = _filterPBByFieldId[field.id] != null;
field._hasSort = _sortPBByFieldId[field.id] != null;
}
_fieldNotifier.notify();
}
Future<void> dispose() async {
if (_isDisposed) {
Log.warn('FieldController is already disposed');
return;
}
_isDisposed = true;
await _fieldListener.stop();
await _filtersListener.stop();
await _settingListener.stop();
await _sortsListener.stop();
for (final callback in _fieldCallbacks.values) {
_fieldNotifier?.removeListener(callback);
_fieldNotifier.removeListener(callback);
}
_fieldNotifier?.dispose();
_fieldNotifier = null;
_fieldNotifier.dispose();
for (final callback in _filterCallbacks.values) {
_filterNotifier?.removeListener(callback);
@@ -460,7 +483,11 @@ class FieldController {
return Future(
() => result.fold(
(newFields) {
_fieldNotifier?.fieldInfos =
if (_isDisposed) {
return left(unit);
}
_fieldNotifier.fieldInfos =
newFields.map((field) => FieldInfo(field: field)).toList();
_loadFilters();
_loadSorts();
@@ -551,7 +578,7 @@ class FieldController {
}
_fieldCallbacks[onReceiveFields] = callback;
_fieldNotifier?.addListener(callback);
_fieldNotifier.addListener(callback);
}
if (onFilters != null) {
@@ -588,7 +615,7 @@ class FieldController {
if (onFieldsListener != null) {
final callback = _fieldCallbacks.remove(onFieldsListener);
if (callback != null) {
_fieldNotifier?.removeListener(callback);
_fieldNotifier.removeListener(callback);
}
}
if (onFiltersListener != null) {
@@ -616,7 +643,7 @@ class FieldController {
};
newFields.retainWhere((field) => (deletedFieldMap[field.id] == null));
_fieldNotifier?.fieldInfos = newFields;
_fieldNotifier.fieldInfos = newFields;
}
void _insertFields(List<IndexFieldPB> insertedFields) {
@@ -632,7 +659,7 @@ class FieldController {
newFieldInfos.add(fieldInfo);
}
}
_fieldNotifier?.fieldInfos = newFieldInfos;
_fieldNotifier.fieldInfos = newFieldInfos;
}
List<FieldInfo> _updateFields(List<FieldPB> updatedFieldPBs) {
@@ -654,7 +681,7 @@ class FieldController {
}
if (updatedFields.isNotEmpty) {
_fieldNotifier?.fieldInfos = newFields;
_fieldNotifier.fieldInfos = newFields;
}
return updatedFields;
}
@@ -1,4 +1,5 @@
import 'package:appflowy/core/notification/grid_notification.dart';
import 'package:appflowy_backend/log.dart';
import 'package:dartz/dartz.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/notification.pb.dart';
@@ -7,12 +8,11 @@ import 'dart:async';
import 'dart:typed_data';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
typedef UpdateFieldNotifiedValue = Either<FieldPB, FlowyError>;
typedef UpdateFieldNotifiedValue = FieldPB;
class SingleFieldListener {
final String fieldId;
PublishNotifier<UpdateFieldNotifiedValue>? _updateFieldNotifier =
PublishNotifier();
void Function(UpdateFieldNotifiedValue)? _updateFieldNotifier;
DatabaseNotificationListener? _listener;
SingleFieldListener({required this.fieldId});
@@ -20,7 +20,7 @@ class SingleFieldListener {
void start({
required void Function(UpdateFieldNotifiedValue) onFieldChanged,
}) {
_updateFieldNotifier?.addPublishListener(onFieldChanged);
_updateFieldNotifier = onFieldChanged;
_listener = DatabaseNotificationListener(
objectId: fieldId,
handler: _handler,
@@ -34,9 +34,8 @@ class SingleFieldListener {
switch (ty) {
case DatabaseNotification.DidUpdateField:
result.fold(
(payload) =>
_updateFieldNotifier?.value = left(FieldPB.fromBuffer(payload)),
(error) => _updateFieldNotifier?.value = right(error),
(payload) => _updateFieldNotifier?.call(FieldPB.fromBuffer(payload)),
(error) => Log.error(error),
);
break;
default:
@@ -46,7 +45,6 @@ class SingleFieldListener {
Future<void> stop() async {
await _listener?.stop();
_updateFieldNotifier?.dispose();
_updateFieldNotifier = null;
}
}
@@ -99,6 +99,14 @@ class FieldBackendService {
);
});
}
/// Returns the primary field of the view.
static Future<Either<FieldPB, FlowyError>> getPrimaryField({
required String viewId,
}) {
final payload = DatabaseViewIdPB.create()..value = viewId;
return DatabaseEventGetPrimaryField(payload).send();
}
}
@freezed
@@ -15,13 +15,13 @@ class DatabaseLayoutBackendService {
}) {
final payload = UpdateViewPayloadPB.create()
..viewId = viewId
..layout = _viewLayoutFromDatabaseLayout(layout);
..layout = viewLayoutFromDatabaseLayout(layout);
return FolderEventUpdateView(payload).send();
}
}
ViewLayoutPB _viewLayoutFromDatabaseLayout(DatabaseLayoutPB databaseLayout) {
ViewLayoutPB viewLayoutFromDatabaseLayout(DatabaseLayoutPB databaseLayout) {
switch (databaseLayout) {
case DatabaseLayoutPB.Board:
return ViewLayoutPB.Board;
@@ -33,3 +33,16 @@ ViewLayoutPB _viewLayoutFromDatabaseLayout(DatabaseLayoutPB databaseLayout) {
throw UnimplementedError;
}
}
DatabaseLayoutPB databaseLayoutFromViewLayout(ViewLayoutPB viewLayout) {
switch (viewLayout) {
case ViewLayoutPB.Board:
return DatabaseLayoutPB.Board;
case ViewLayoutPB.Calendar:
return DatabaseLayoutPB.Calendar;
case ViewLayoutPB.Grid:
return DatabaseLayoutPB.Grid;
default:
throw UnimplementedError;
}
}
@@ -0,0 +1,163 @@
import 'package:appflowy/plugins/database_view/application/field/field_listener.dart';
import 'package:appflowy/plugins/database_view/application/field/field_service.dart';
import 'package:appflowy/plugins/database_view/application/row/row_service.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'row_meta_listener.dart';
part 'row_banner_bloc.freezed.dart';
class RowBannerBloc extends Bloc<RowBannerEvent, RowBannerState> {
final String viewId;
final RowBackendService _rowBackendSvc;
final RowMetaListener _metaListener;
SingleFieldListener? _fieldListener;
RowBannerBloc({
required this.viewId,
required RowMetaPB rowMeta,
}) : _rowBackendSvc = RowBackendService(viewId: viewId),
_metaListener = RowMetaListener(rowMeta.id),
super(RowBannerState.initial(rowMeta)) {
on<RowBannerEvent>(
(event, emit) async {
event.when(
initial: () async {
_loadPrimaryField();
await _listenRowMeteChanged();
},
didReceiveRowMeta: (RowMetaPB rowMeta) {
emit(
state.copyWith(
rowMeta: rowMeta,
),
);
},
setCover: (String coverURL) {
_updateMeta(coverURL: coverURL);
},
setIcon: (String iconURL) {
_updateMeta(iconURL: iconURL);
},
didReceiveFieldUpdate: (updatedField) {
emit(
state.copyWith(
primaryField: updatedField,
loadingState: const LoadingState.finish(),
),
);
},
);
},
);
}
@override
Future<void> close() async {
await _metaListener.stop();
await _fieldListener?.stop();
_fieldListener = null;
return super.close();
}
Future<void> _loadPrimaryField() async {
final fieldOrError =
await FieldBackendService.getPrimaryField(viewId: viewId);
fieldOrError.fold(
(primaryField) {
if (!isClosed) {
_fieldListener = SingleFieldListener(fieldId: primaryField.id);
_fieldListener?.start(
onFieldChanged: (updatedField) {
if (!isClosed) {
add(RowBannerEvent.didReceiveFieldUpdate(updatedField));
}
},
);
add(RowBannerEvent.didReceiveFieldUpdate(primaryField));
}
},
(r) => Log.error(r),
);
}
/// Listen the changes of the row meta and then update the banner
Future<void> _listenRowMeteChanged() async {
_metaListener.start(
callback: (rowMeta) {
add(RowBannerEvent.didReceiveRowMeta(rowMeta));
},
);
}
/// Update the meta of the row and the view
Future<void> _updateMeta({
String? iconURL,
String? coverURL,
}) async {
// Most of the time, the result is success, so we don't need to handle it.
await _rowBackendSvc
.updateMeta(
iconURL: iconURL,
coverURL: coverURL,
rowId: state.rowMeta.id,
)
.then((result) {
result.fold(
(l) => null,
(err) => Log.error(err),
);
});
// Set the icon and cover of the view
ViewBackendService.updateView(
viewId: viewId,
iconURL: iconURL,
coverURL: coverURL,
).then((result) {
result.fold(
(l) => null,
(err) => Log.error(err),
);
});
}
}
@freezed
class RowBannerEvent with _$RowBannerEvent {
const factory RowBannerEvent.initial() = _Initial;
const factory RowBannerEvent.didReceiveRowMeta(RowMetaPB rowMeta) =
_DidReceiveRowMeta;
const factory RowBannerEvent.didReceiveFieldUpdate(FieldPB field) =
_DidReceiveFieldUdate;
const factory RowBannerEvent.setIcon(String iconURL) = _SetIcon;
const factory RowBannerEvent.setCover(String coverURL) = _SetCover;
}
@freezed
class RowBannerState with _$RowBannerState {
const factory RowBannerState({
ViewPB? view,
FieldPB? primaryField,
required RowMetaPB rowMeta,
required LoadingState loadingState,
}) = _RowBannerState;
factory RowBannerState.initial(RowMetaPB rowMetaPB) => RowBannerState(
rowMeta: rowMetaPB,
loadingState: const LoadingState.loading(),
);
}
@freezed
class LoadingState with _$LoadingState {
const factory LoadingState.loading() = _Loading;
const factory LoadingState.finish() = _Finish;
}
@@ -38,17 +38,21 @@ class RowCache {
final RowCacheDelegate _delegate;
final RowChangesetNotifier _rowChangeReasonNotifier;
/// Returns a unmodifiable list of RowInfo
UnmodifiableListView<RowInfo> get rowInfos {
final visibleRows = [..._rowList.rows];
return UnmodifiableListView(visibleRows);
}
/// Returns a unmodifiable map of rowId to RowInfo
UnmodifiableMapView<RowId, RowInfo> get rowByRowId {
return UnmodifiableMapView(_rowList.rowInfoByRowId);
}
CellCache get cellCache => _cellCache;
RowsChangedReason get changeReason => _rowChangeReasonNotifier.reason;
RowCache({
required this.viewId,
required RowFieldsDelegate fieldsDelegate,
@@ -70,7 +74,7 @@ class RowCache {
return _rowList.get(rowId);
}
void setInitialRows(List<RowPB> rows) {
void setInitialRows(List<RowMetaPB> rows) {
for (final row in rows) {
final rowInfo = buildGridRow(row);
_rowList.add(rowInfo);
@@ -128,7 +132,7 @@ class RowCache {
void _insertRows(List<InsertedRowPB> insertRows) {
for (final insertedRow in insertRows) {
final insertedIndex =
_rowList.insert(insertedRow.index, buildGridRow(insertedRow.row));
_rowList.insert(insertedRow.index, buildGridRow(insertedRow.rowMeta));
if (insertedIndex != null) {
_rowChangeReasonNotifier
.receive(RowsChangedReason.insert(insertedIndex));
@@ -138,20 +142,23 @@ class RowCache {
void _updateRows(List<UpdatedRowPB> updatedRows) {
if (updatedRows.isEmpty) return;
final List<RowPB> rowPBs = [];
final List<RowMetaPB> updatedList = [];
for (final updatedRow in updatedRows) {
for (final fieldId in updatedRow.fieldIds) {
final key = CellCacheKey(
fieldId: fieldId,
rowId: updatedRow.row.id,
rowId: updatedRow.rowId,
);
_cellCache.remove(key);
}
rowPBs.add(updatedRow.row);
if (updatedRow.hasRowMeta()) {
updatedList.add(updatedRow.rowMeta);
}
}
final updatedIndexs =
_rowList.updateRows(rowPBs, (rowPB) => buildGridRow(rowPB));
_rowList.updateRows(updatedList, (rowId) => buildGridRow(rowId));
if (updatedIndexs.isNotEmpty) {
_rowChangeReasonNotifier.receive(RowsChangedReason.update(updatedIndexs));
}
@@ -169,7 +176,7 @@ class RowCache {
void _showRows(List<InsertedRowPB> visibleRows) {
for (final insertedRow in visibleRows) {
final insertedIndex =
_rowList.insert(insertedRow.index, buildGridRow(insertedRow.row));
_rowList.insert(insertedRow.index, buildGridRow(insertedRow.rowMeta));
if (insertedIndex != null) {
_rowChangeReasonNotifier
.receive(RowsChangedReason.insert(insertedIndex));
@@ -197,8 +204,9 @@ class RowCache {
if (onCellUpdated != null) {
final rowInfo = _rowList.get(rowId);
if (rowInfo != null) {
final CellContextByFieldId cellDataMap =
_makeGridCells(rowId, rowInfo.rowPB);
final CellContextByFieldId cellDataMap = _makeGridCells(
rowInfo.rowMeta,
);
onCellUpdated(cellDataMap, _rowChangeReasonNotifier.reason);
}
}
@@ -220,12 +228,12 @@ class RowCache {
_rowChangeReasonNotifier.removeListener(callback);
}
CellContextByFieldId loadGridCells(RowId rowId) {
final RowPB? data = _rowList.get(rowId)?.rowPB;
if (data == null) {
_loadRow(rowId);
CellContextByFieldId loadGridCells(RowMetaPB rowMeta) {
final rowInfo = _rowList.get(rowMeta.id);
if (rowInfo == null) {
_loadRow(rowMeta.id);
}
return _makeGridCells(rowId, data);
return _makeGridCells(rowMeta);
}
Future<void> _loadRow(RowId rowId) async {
@@ -233,57 +241,51 @@ class RowCache {
..viewId = viewId
..rowId = rowId;
final result = await DatabaseEventGetRow(payload).send();
final result = await DatabaseEventGetRowMeta(payload).send();
result.fold(
(optionRow) => _refreshRow(optionRow),
(rowMetaPB) {
final rowInfo = _rowList.get(rowMetaPB.id);
final rowIndex = _rowList.indexOfRow(rowMetaPB.id);
if (rowInfo != null && rowIndex != null) {
final updatedRowInfo = rowInfo.copyWith(rowMeta: rowMetaPB);
_rowList.remove(rowMetaPB.id);
_rowList.insert(rowIndex, updatedRowInfo);
final UpdatedIndexMap updatedIndexs = UpdatedIndexMap();
updatedIndexs[rowMetaPB.id] = UpdatedIndex(
index: rowIndex,
rowId: rowMetaPB.id,
);
_rowChangeReasonNotifier
.receive(RowsChangedReason.update(updatedIndexs));
}
},
(err) => Log.error(err),
);
}
CellContextByFieldId _makeGridCells(RowId rowId, RowPB? row) {
CellContextByFieldId _makeGridCells(RowMetaPB rowMeta) {
// ignore: prefer_collection_literals
final cellDataMap = CellContextByFieldId();
final cellContextMap = CellContextByFieldId();
for (final field in _delegate.fields) {
if (field.visibility) {
cellDataMap[field.id] = DatabaseCellContext(
rowId: rowId,
cellContextMap[field.id] = DatabaseCellContext(
rowMeta: rowMeta,
viewId: viewId,
fieldInfo: field,
);
}
}
return cellDataMap;
return cellContextMap;
}
void _refreshRow(OptionalRowPB optionRow) {
if (!optionRow.hasRow()) {
return;
}
final updatedRow = optionRow.row;
updatedRow.freeze();
final rowInfo = _rowList.get(updatedRow.id);
final rowIndex = _rowList.indexOfRow(updatedRow.id);
if (rowInfo != null && rowIndex != null) {
final updatedRowInfo = rowInfo.copyWith(rowPB: updatedRow);
_rowList.remove(updatedRow.id);
_rowList.insert(rowIndex, updatedRowInfo);
final UpdatedIndexMap updatedIndexs = UpdatedIndexMap();
updatedIndexs[rowInfo.rowPB.id] = UpdatedIndex(
index: rowIndex,
rowId: updatedRowInfo.rowPB.id,
);
_rowChangeReasonNotifier.receive(RowsChangedReason.update(updatedIndexs));
}
}
RowInfo buildGridRow(RowPB rowPB) {
RowInfo buildGridRow(RowMetaPB rowMetaPB) {
return RowInfo(
viewId: viewId,
fields: _delegate.fields,
rowPB: rowPB,
rowId: rowMetaPB.id,
rowMeta: rowMetaPB,
);
}
}
@@ -310,9 +312,10 @@ class RowChangesetNotifier extends ChangeNotifier {
@unfreezed
class RowInfo with _$RowInfo {
factory RowInfo({
required String rowId,
required String viewId,
required UnmodifiableListView<FieldInfo> fields,
required RowPB rowPB,
required RowMetaPB rowMeta,
}) = _RowInfo;
}
@@ -1,12 +1,12 @@
import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart';
import 'package:flutter/material.dart';
import '../cell/cell_service.dart';
import 'row_cache.dart';
import 'row_service.dart';
typedef OnRowChanged = void Function(CellContextByFieldId, RowsChangedReason);
class RowController {
final RowId rowId;
final RowMetaPB rowMeta;
final String? groupId;
final String viewId;
final List<VoidCallback> _onRowChangedListeners = [];
@@ -14,24 +14,27 @@ class RowController {
get cellCache => _rowCache.cellCache;
get rowId => rowMeta.id;
RowController({
required this.rowId,
required this.rowMeta,
required this.viewId,
required RowCache rowCache,
this.groupId,
}) : _rowCache = rowCache;
CellContextByFieldId loadData() {
return _rowCache.loadGridCells(rowId);
return _rowCache.loadGridCells(rowMeta);
}
void addListener({OnRowChanged? onRowChanged}) {
_onRowChangedListeners.add(
_rowCache.addListener(
rowId: rowId,
onCellUpdated: onRowChanged,
),
final fn = _rowCache.addListener(
rowId: rowMeta.id,
onCellUpdated: onRowChanged,
);
// Add the listener to the list so that we can remove it later.
_onRowChangedListeners.add(fn);
}
void dispose() {
@@ -25,10 +25,9 @@ class RowList {
}
void add(RowInfo rowInfo) {
final rowId = rowInfo.rowPB.id;
final rowId = rowInfo.rowId;
if (contains(rowId)) {
final index =
_rowInfos.indexWhere((element) => element.rowPB.id == rowId);
final index = _rowInfos.indexWhere((element) => element.rowId == rowId);
_rowInfos.removeAt(index);
_rowInfos.insert(index, rowInfo);
} else {
@@ -38,7 +37,7 @@ class RowList {
}
InsertedIndex? insert(int index, RowInfo rowInfo) {
final rowId = rowInfo.rowPB.id;
final rowId = rowInfo.rowId;
var insertedIndex = index;
if (_rowInfos.length <= insertedIndex) {
insertedIndex = _rowInfos.length;
@@ -62,7 +61,7 @@ class RowList {
if (rowInfo != null) {
final index = _rowInfos.indexOf(rowInfo);
if (index != -1) {
rowInfoByRowId.remove(rowInfo.rowPB.id);
rowInfoByRowId.remove(rowInfo.rowId);
_rowInfos.remove(rowInfo);
}
return DeletedIndex(index: index, rowInfo: rowInfo);
@@ -73,23 +72,23 @@ class RowList {
InsertedIndexs insertRows(
List<InsertedRowPB> insertedRows,
RowInfo Function(RowPB) builder,
RowInfo Function(RowMetaPB) builder,
) {
final InsertedIndexs insertIndexs = [];
for (final insertRow in insertedRows) {
final isContains = contains(insertRow.row.id);
final isContains = contains(insertRow.rowMeta.id);
var index = insertRow.index;
if (_rowInfos.length < index) {
index = _rowInfos.length;
}
insert(index, builder(insertRow.row));
insert(index, builder(insertRow.rowMeta));
if (!isContains) {
insertIndexs.add(
InsertedIndex(
index: index,
rowId: insertRow.row.id,
rowId: insertRow.rowMeta.id,
),
);
}
@@ -105,10 +104,10 @@ class RowList {
};
_rowInfos.asMap().forEach((index, RowInfo rowInfo) {
if (deletedRowByRowId[rowInfo.rowPB.id] == null) {
if (deletedRowByRowId[rowInfo.rowId] == null) {
newRows.add(rowInfo);
} else {
rowInfoByRowId.remove(rowInfo.rowPB.id);
rowInfoByRowId.remove(rowInfo.rowId);
deletedIndex.add(DeletedIndex(index: index, rowInfo: rowInfo));
}
});
@@ -117,19 +116,21 @@ class RowList {
}
UpdatedIndexMap updateRows(
List<RowPB> updatedRows,
RowInfo Function(RowPB) builder,
List<RowMetaPB> rowMetas,
RowInfo Function(RowMetaPB) builder,
) {
final UpdatedIndexMap updatedIndexs = UpdatedIndexMap();
for (final RowPB updatedRow in updatedRows) {
final rowId = updatedRow.id;
for (final rowMeta in rowMetas) {
final index = _rowInfos.indexWhere(
(rowInfo) => rowInfo.rowPB.id == rowId,
(rowInfo) => rowInfo.rowId == rowMeta.id,
);
if (index != -1) {
final rowInfo = builder(updatedRow);
final rowInfo = builder(rowMeta);
insert(index, rowInfo);
updatedIndexs[rowId] = UpdatedIndex(index: index, rowId: rowId);
updatedIndexs[rowMeta.id] = UpdatedIndex(
index: index,
rowId: rowMeta.id,
);
}
}
return updatedIndexs;
@@ -148,7 +149,7 @@ class RowList {
void moveRow(RowId rowId, int oldIndex, int newIndex) {
final index = _rowInfos.indexWhere(
(rowInfo) => rowInfo.rowPB.id == rowId,
(rowInfo) => rowInfo.rowId == rowId,
);
if (index != -1) {
final rowInfo = remove(rowId)!.rowInfo;
@@ -0,0 +1,49 @@
import 'dart:typed_data';
import 'package:appflowy/core/notification/grid_notification.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:dartz/dartz.dart';
typedef RowMetaCallback = void Function(RowMetaPB);
class RowMetaListener {
final String rowId;
RowMetaCallback? _callback;
DatabaseNotificationListener? _listener;
RowMetaListener(this.rowId);
void start({required RowMetaCallback callback}) {
_callback = callback;
_listener = DatabaseNotificationListener(
objectId: rowId,
handler: _handler,
);
}
void _handler(
DatabaseNotification ty,
Either<Uint8List, FlowyError> result,
) {
switch (ty) {
case DatabaseNotification.DidUpdateRowMeta:
result.fold(
(payload) {
if (_callback != null) {
_callback!(RowMetaPB.fromBuffer(payload));
}
},
(error) => Log.error(error),
);
break;
default:
break;
}
}
Future<void> stop() async {
await _listener?.stop();
_callback = null;
}
}
@@ -12,7 +12,7 @@ class RowBackendService {
required this.viewId,
});
Future<Either<RowPB, FlowyError>> createRow(RowId rowId) {
Future<Either<RowMetaPB, FlowyError>> createRowAfterRow(RowId rowId) {
final payload = CreateRowPayloadPB.create()
..viewId = viewId
..startRowId = rowId;
@@ -28,6 +28,33 @@ class RowBackendService {
return DatabaseEventGetRow(payload).send();
}
Future<Either<RowMetaPB, FlowyError>> getRowMeta(RowId rowId) {
final payload = RowIdPB.create()
..viewId = viewId
..rowId = rowId;
return DatabaseEventGetRowMeta(payload).send();
}
Future<Either<Unit, FlowyError>> updateMeta({
required String rowId,
String? iconURL,
String? coverURL,
}) {
final payload = UpdateRowMetaChangesetPB.create()
..viewId = viewId
..id = rowId;
if (iconURL != null) {
payload.iconUrl = iconURL;
}
if (coverURL != null) {
payload.coverUrl = coverURL;
}
return DatabaseEventUpdateRowMeta(payload).send();
}
Future<Either<Unit, FlowyError>> deleteRow(RowId rowId) {
final payload = RowIdPB.create()
..viewId = viewId
@@ -1,103 +0,0 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:dartz/dartz.dart';
part 'setting_bloc.freezed.dart';
class DatabaseSettingBloc
extends Bloc<DatabaseSettingEvent, DatabaseSettingState> {
final String viewId;
DatabaseSettingBloc({required this.viewId})
: super(DatabaseSettingState.initial()) {
on<DatabaseSettingEvent>(
(event, emit) async {
event.map(
performAction: (_PerformAction value) {
emit(state.copyWith(selectedAction: Some(value.action)));
},
);
},
);
}
}
@freezed
class DatabaseSettingEvent with _$DatabaseSettingEvent {
const factory DatabaseSettingEvent.performAction(
DatabaseSettingAction action,
) = _PerformAction;
}
@freezed
class DatabaseSettingState with _$DatabaseSettingState {
const factory DatabaseSettingState({
required Option<DatabaseSettingAction> selectedAction,
}) = _DatabaseSettingState;
factory DatabaseSettingState.initial() => DatabaseSettingState(
selectedAction: none(),
);
}
enum DatabaseSettingAction {
showProperties,
showLayout,
showGroup,
showCalendarLayout,
}
extension DatabaseSettingActionExtension on DatabaseSettingAction {
String iconName() {
switch (this) {
case DatabaseSettingAction.showProperties:
return 'grid/setting/properties';
case DatabaseSettingAction.showLayout:
return 'grid/setting/database_layout';
case DatabaseSettingAction.showGroup:
return 'grid/setting/group';
case DatabaseSettingAction.showCalendarLayout:
return 'grid/setting/calendar_layout';
}
}
String title() {
switch (this) {
case DatabaseSettingAction.showProperties:
return LocaleKeys.grid_settings_Properties.tr();
case DatabaseSettingAction.showLayout:
return LocaleKeys.grid_settings_databaseLayout.tr();
case DatabaseSettingAction.showGroup:
return LocaleKeys.grid_settings_group.tr();
case DatabaseSettingAction.showCalendarLayout:
return LocaleKeys.calendar_settings_name.tr();
}
}
}
/// Returns the list of actions that should be shown for the given database layout.
List<DatabaseSettingAction> actionsForDatabaseLayout(DatabaseLayoutPB? layout) {
switch (layout) {
case DatabaseLayoutPB.Board:
return [
DatabaseSettingAction.showProperties,
DatabaseSettingAction.showLayout,
DatabaseSettingAction.showGroup,
];
case DatabaseLayoutPB.Calendar:
return [
DatabaseSettingAction.showProperties,
DatabaseSettingAction.showLayout,
DatabaseSettingAction.showCalendarLayout,
];
case DatabaseLayoutPB.Grid:
return [
DatabaseSettingAction.showProperties,
DatabaseSettingAction.showLayout,
];
default:
return [];
}
}
@@ -0,0 +1,290 @@
import 'package:appflowy/plugins/database_view/tar_bar/tab_bar_view.dart';
import 'package:appflowy/plugins/database_view/tar_bar/tar_bar_add_button.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'database_controller.dart';
import 'database_view_service.dart';
part 'tar_bar_bloc.freezed.dart';
class GridTabBarBloc extends Bloc<GridTabBarEvent, GridTabBarState> {
GridTabBarBloc({
bool isInlineView = false,
required ViewPB view,
}) : super(GridTabBarState.initial(view)) {
on<GridTabBarEvent>(
(event, emit) async {
event.when(
initial: () {
_listenInlineViewChanged();
_loadChildView();
},
didLoadChildViews: (List<ViewPB> childViews) {
emit(
state.copyWith(
tabBars: [
...state.tabBars,
...childViews.map(
(newChildView) => TarBar(view: newChildView),
),
],
tabBarControllerByViewId: _extendsTabBarController(childViews),
),
);
},
selectView: (String viewId) {
final index =
state.tabBars.indexWhere((element) => element.viewId == viewId);
if (index != -1) {
emit(
state.copyWith(selectedIndex: index),
);
}
},
createView: (action) {
_createLinkedView(action.name, action.layoutType);
},
deleteView: (String viewId) async {
final result = await ViewBackendService.delete(viewId: viewId);
result.fold(
(l) {},
(r) => Log.error(r),
);
},
renameView: (String viewId, String newName) {
ViewBackendService.updateView(viewId: viewId, name: newName);
},
didUpdateChildViews: (updatePB) async {
if (updatePB.createChildViews.isNotEmpty) {
final allTabBars = [
...state.tabBars,
...updatePB.createChildViews.map((e) => TarBar(view: e))
];
emit(
state.copyWith(
tabBars: allTabBars,
selectedIndex: state.tabBars.length,
tabBarControllerByViewId:
_extendsTabBarController(updatePB.createChildViews),
),
);
}
if (updatePB.deleteChildViews.isNotEmpty) {
final allTabBars = [...state.tabBars];
final tabBarControllerByViewId = {
...state.tabBarControllerByViewId
};
var newSelectedIndex = state.selectedIndex;
for (final viewId in updatePB.deleteChildViews) {
final index = allTabBars.indexWhere(
(element) => element.viewId == viewId,
);
if (index != -1) {
final tarBar = allTabBars.removeAt(index);
// Dispose the controller when the tab is removed.
final controller =
tabBarControllerByViewId.remove(tarBar.viewId);
controller?.dispose();
}
if (index == state.selectedIndex) {
if (index > 0 && allTabBars.isNotEmpty) {
newSelectedIndex = index - 1;
}
}
}
emit(
state.copyWith(
tabBars: allTabBars,
selectedIndex: newSelectedIndex,
tabBarControllerByViewId: tabBarControllerByViewId,
),
);
}
},
viewDidUpdate: (ViewPB updatedView) {
final index = state.tabBars.indexWhere(
(element) => element.viewId == updatedView.id,
);
if (index != -1) {
final allTabBars = [...state.tabBars];
final updatedTabBar = TarBar(view: updatedView);
allTabBars[index] = updatedTabBar;
emit(state.copyWith(tabBars: allTabBars));
}
},
);
},
);
}
@override
Future<void> close() async {
for (final tabBar in state.tabBars) {
await state.tabBarControllerByViewId[tabBar.viewId]?.dispose();
}
return super.close();
}
void _listenInlineViewChanged() {
final controller = state.tabBarControllerByViewId[state.parentView.id];
controller?.onViewUpdated = (newView) {
add(GridTabBarEvent.viewDidUpdate(newView));
};
// Only listen the child view changes when the parent view is inline.
controller?.onViewChildViewChanged = (update) {
add(GridTabBarEvent.didUpdateChildViews(update));
};
}
/// Create tab bar controllers for the new views and return the updated map.
Map<String, DatabaseTarBarController> _extendsTabBarController(
List<ViewPB> newViews,
) {
final tabBarControllerByViewId = {...state.tabBarControllerByViewId};
for (final view in newViews) {
final controller = DatabaseTarBarController(view: view);
controller.onViewUpdated = (newView) {
add(GridTabBarEvent.viewDidUpdate(newView));
};
tabBarControllerByViewId[view.id] = controller;
}
return tabBarControllerByViewId;
}
Future<void> _createLinkedView(String name, ViewLayoutPB layoutType) async {
final viewId = state.parentView.id;
final databaseIdOrError =
await DatabaseViewBackendService(viewId: viewId).getDatabaseId();
databaseIdOrError.fold(
(databaseId) async {
final linkedViewOrError =
await ViewBackendService.createDatabaseLinkedView(
parentViewId: viewId,
databaseId: databaseId,
layoutType: layoutType,
name: name,
);
linkedViewOrError.fold(
(linkedView) {},
(err) => Log.error(err),
);
},
(r) => Log.error(r),
);
}
Future<void> _loadChildView() async {
ViewBackendService.getChildViews(viewId: state.parentView.id)
.then((viewsOrFail) {
if (isClosed) {
return;
}
viewsOrFail.fold(
(views) => add(GridTabBarEvent.didLoadChildViews(views)),
(err) => Log.error(err),
);
});
}
}
@freezed
class GridTabBarEvent with _$GridTabBarEvent {
const factory GridTabBarEvent.initial() = _Initial;
const factory GridTabBarEvent.didLoadChildViews(
List<ViewPB> childViews,
) = _DidLoadChildViews;
const factory GridTabBarEvent.selectView(String viewId) = _DidSelectView;
const factory GridTabBarEvent.createView(AddButtonAction action) =
_CreateView;
const factory GridTabBarEvent.renameView(String viewId, String newName) =
_RenameView;
const factory GridTabBarEvent.deleteView(String viewId) = _DeleteView;
const factory GridTabBarEvent.didUpdateChildViews(
ChildViewUpdatePB updatePB,
) = _DidUpdateChildViews;
const factory GridTabBarEvent.viewDidUpdate(ViewPB view) = _ViewDidUpdate;
}
@freezed
class GridTabBarState with _$GridTabBarState {
const factory GridTabBarState({
required ViewPB parentView,
required int selectedIndex,
required List<TarBar> tabBars,
required Map<String, DatabaseTarBarController> tabBarControllerByViewId,
}) = _GridTabBarState;
factory GridTabBarState.initial(ViewPB view) {
final tabBar = TarBar(view: view);
return GridTabBarState(
parentView: view,
selectedIndex: 0,
tabBars: [tabBar],
tabBarControllerByViewId: {
view.id: DatabaseTarBarController(
view: view,
)
},
);
}
}
class TarBar extends Equatable {
final ViewPB view;
final DatabaseTabBarItemBuilder _builder;
String get viewId => view.id;
DatabaseTabBarItemBuilder get builder => _builder;
ViewLayoutPB get layout => view.layout;
TarBar({
required this.view,
}) : _builder = view.tarBarItem();
@override
List<Object?> get props => [view.hashCode];
}
typedef OnViewUpdated = void Function(ViewPB newView);
typedef OnViewChildViewChanged = void Function(
ChildViewUpdatePB childViewUpdate,
);
class DatabaseTarBarController {
ViewPB view;
final DatabaseController controller;
final ViewListener viewListener;
OnViewUpdated? onViewUpdated;
OnViewChildViewChanged? onViewChildViewChanged;
DatabaseTarBarController({
required this.view,
}) : controller = DatabaseController(view: view),
viewListener = ViewListener(viewId: view.id) {
viewListener.start(
onViewChildViewsUpdated: (update) {
onViewChildViewChanged?.call(update);
},
onViewUpdated: (newView) {
view = newView;
onViewUpdated?.call(newView);
},
);
}
Future<void> dispose() async {
await viewListener.stop();
await controller.dispose();
}
}
@@ -35,7 +35,7 @@ class DatabaseViewCache {
final String viewId;
late RowCache _rowCache;
final DatabaseViewListener _databaseViewListener;
DatabaseViewCallbacks? _callbacks;
final List<DatabaseViewCallbacks> _callbacks = [];
UnmodifiableListView<RowInfo> get rowInfos => _rowCache.rowInfos;
RowCache get rowCache => _rowCache;
@@ -61,20 +61,28 @@ class DatabaseViewCache {
_rowCache.applyRowsChanged(changeset);
if (changeset.deletedRows.isNotEmpty) {
_callbacks?.onRowsDeleted?.call(changeset.deletedRows);
for (final callback in _callbacks) {
callback.onRowsDeleted?.call(changeset.deletedRows);
}
}
if (changeset.updatedRows.isNotEmpty) {
_callbacks?.onRowsUpdated
?.call(changeset.updatedRows.map((e) => e.row.id).toList());
for (final callback in _callbacks) {
callback.onRowsUpdated?.call(
changeset.updatedRows.map((e) => e.rowId).toList(),
_rowCache.changeReason,
);
}
}
if (changeset.insertedRows.isNotEmpty) {
_callbacks?.onRowsCreated?.call(
changeset.insertedRows
.map((insertedRow) => insertedRow.row.id)
.toList(),
);
for (final callback in _callbacks) {
callback.onRowsCreated?.call(
changeset.insertedRows
.map((insertedRow) => insertedRow.rowMeta.id)
.toList(),
);
}
}
},
(err) => Log.error(err),
@@ -101,21 +109,25 @@ class DatabaseViewCache {
);
_rowCache.onRowsChanged(
(reason) => _callbacks?.onNumOfRowsChanged?.call(
rowInfos,
_rowCache.rowByRowId,
reason,
),
(reason) {
for (final callback in _callbacks) {
callback.onNumOfRowsChanged?.call(
rowInfos,
_rowCache.rowByRowId,
reason,
);
}
},
);
}
Future<void> dispose() async {
await _databaseViewListener.stop();
await _rowCache.dispose();
_callbacks = null;
_callbacks.clear();
}
void setListener(DatabaseViewCallbacks callbacks) {
_callbacks = callbacks;
void addListener(DatabaseViewCallbacks callbacks) {
_callbacks.add(callbacks);
}
}
@@ -28,11 +28,10 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
FieldController get fieldController => databaseController.fieldController;
String get viewId => databaseController.viewId;
BoardBloc({required ViewPB view})
: databaseController = DatabaseController(
view: view,
),
super(BoardState.initial(view.id)) {
BoardBloc({
required ViewPB view,
required this.databaseController,
}) : super(BoardState.initial(view.id)) {
boardController = AppFlowyBoardController(
onMoveGroup: (
fromGroupId,
@@ -156,7 +155,7 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
);
}
void _groupItemStartEditing(GroupPB group, RowPB row, bool isEdit) {
void _groupItemStartEditing(GroupPB group, RowMetaPB row, bool isEdit) {
final fieldInfo = fieldController.getField(group.fieldId);
if (fieldInfo == null) {
Log.warn("fieldInfo should not be null");
@@ -168,7 +167,6 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
@override
Future<void> close() async {
await databaseController.dispose();
for (final controller in groupControllers.values) {
controller.dispose();
}
@@ -235,7 +233,7 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
},
);
databaseController.setListener(
databaseController.addListener(
onDatabaseChanged: onDatabaseChanged,
onGroupChanged: onGroupChanged,
);
@@ -302,12 +300,12 @@ class BoardEvent with _$BoardEvent {
const factory BoardEvent.createHeaderRow(String groupId) = _CreateHeaderRow;
const factory BoardEvent.didCreateRow(
GroupPB group,
RowPB row,
RowMetaPB row,
int? index,
) = _DidCreateRow;
const factory BoardEvent.startEditingRow(
GroupPB group,
RowPB row,
RowMetaPB row,
) = _StartEditRow;
const factory BoardEvent.endEditingRow(RowId rowId) = _EndEditRow;
const factory BoardEvent.didReceiveError(FlowyError error) = _DidReceiveError;
@@ -371,7 +369,7 @@ class GridFieldEquatable extends Equatable {
}
class GroupItem extends AppFlowyGroupItem {
final RowPB row;
final RowMetaPB row;
final FieldInfo fieldInfo;
GroupItem({
@@ -389,7 +387,7 @@ class GroupItem extends AppFlowyGroupItem {
class GroupControllerDelegateImpl extends GroupControllerDelegate {
final FieldController fieldController;
final AppFlowyBoardController controller;
final void Function(String, RowPB, int?) onNewColumnItem;
final void Function(String, RowMetaPB, int?) onNewColumnItem;
GroupControllerDelegateImpl({
required this.controller,
@@ -398,7 +396,7 @@ class GroupControllerDelegateImpl extends GroupControllerDelegate {
});
@override
void insertRow(GroupPB group, RowPB row, int? index) {
void insertRow(GroupPB group, RowMetaPB row, int? index) {
final fieldInfo = fieldController.getField(group.fieldId);
if (fieldInfo == null) {
Log.warn("fieldInfo should not be null");
@@ -426,7 +424,7 @@ class GroupControllerDelegateImpl extends GroupControllerDelegate {
}
@override
void updateRow(GroupPB group, RowPB row) {
void updateRow(GroupPB group, RowMetaPB row) {
final fieldInfo = fieldController.getField(group.fieldId);
if (fieldInfo == null) {
Log.warn("fieldInfo should not be null");
@@ -442,7 +440,7 @@ class GroupControllerDelegateImpl extends GroupControllerDelegate {
}
@override
void addNewRow(GroupPB group, RowPB row, int? index) {
void addNewRow(GroupPB group, RowMetaPB row, int? index) {
final fieldInfo = fieldController.getField(group.fieldId);
if (fieldInfo == null) {
Log.warn("fieldInfo should not be null");
@@ -465,7 +463,7 @@ class GroupControllerDelegateImpl extends GroupControllerDelegate {
class BoardEditingRow {
GroupPB group;
RowPB row;
RowMetaPB row;
int? index;
BoardEditingRow({
@@ -12,9 +12,9 @@ typedef OnGroupError = void Function(FlowyError);
abstract class GroupControllerDelegate {
void removeRow(GroupPB group, RowId rowId);
void insertRow(GroupPB group, RowPB row, int? index);
void updateRow(GroupPB group, RowPB row);
void addNewRow(GroupPB group, RowPB row, int? index);
void insertRow(GroupPB group, RowMetaPB row, int? index);
void updateRow(GroupPB group, RowMetaPB row);
void addNewRow(GroupPB group, RowMetaPB row, int? index);
}
class GroupController {
@@ -28,7 +28,7 @@ class GroupController {
required this.delegate,
}) : _listener = SingleGroupListener(group);
RowPB? rowAtIndex(int index) {
RowMetaPB? rowAtIndex(int index) {
if (index < group.rows.length) {
return group.rows[index];
} else {
@@ -36,7 +36,7 @@ class GroupController {
}
}
RowPB? lastRow() {
RowMetaPB? lastRow() {
if (group.rows.isEmpty) return null;
return group.rows.last;
}
@@ -55,15 +55,15 @@ class GroupController {
final index = insertedRow.hasIndex() ? insertedRow.index : null;
if (insertedRow.hasIndex() &&
group.rows.length > insertedRow.index) {
group.rows.insert(insertedRow.index, insertedRow.row);
group.rows.insert(insertedRow.index, insertedRow.rowMeta);
} else {
group.rows.add(insertedRow.row);
group.rows.add(insertedRow.rowMeta);
}
if (insertedRow.isNew) {
delegate.addNewRow(group, insertedRow.row, index);
delegate.addNewRow(group, insertedRow.rowMeta, index);
} else {
delegate.insertRow(group, insertedRow.row, index);
delegate.insertRow(group, insertedRow.rowMeta, index);
}
}
@@ -1,19 +1,14 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/util.dart';
import 'package:appflowy/plugins/database_view/tar_bar/tab_bar_view.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy/workspace/presentation/widgets/left_bar_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'presentation/board_page.dart';
class BoardPluginBuilder implements PluginBuilder {
@override
Plugin build(dynamic data) {
if (data is ViewPB) {
return BoardPlugin(pluginType: pluginType, view: data);
return DatabaseTabBarViewPlugin(pluginType: pluginType, view: data);
} else {
throw FlowyPluginException.invalidData;
}
@@ -36,51 +31,3 @@ class BoardPluginConfig implements PluginConfig {
@override
bool get creatable => true;
}
class BoardPlugin extends Plugin {
@override
final ViewPluginNotifier notifier;
final PluginType _pluginType;
BoardPlugin({
required ViewPB view,
required PluginType pluginType,
}) : _pluginType = pluginType,
notifier = ViewPluginNotifier(view: view);
@override
PluginWidgetBuilder get widgetBuilder =>
BoardPluginWidgetBuilder(notifier: notifier);
@override
PluginId get id => notifier.view.id;
@override
PluginType get pluginType => _pluginType;
}
class BoardPluginWidgetBuilder extends PluginWidgetBuilder {
final ViewPluginNotifier notifier;
BoardPluginWidgetBuilder({required this.notifier, Key? key});
ViewPB get view => notifier.view;
@override
Widget get leftBarItem => ViewLeftBarItem(view: view);
@override
Widget buildWidget({PluginContext? context}) {
notifier.isDeleted.addListener(() {
notifier.isDeleted.value.fold(() => null, (deletedView) {
if (deletedView.hasIndex()) {
context?.onDeleted(view, deletedView.index);
}
});
});
return BoardPage(key: ValueKey(view.id), view: view);
}
@override
List<NavigationItem> get navigationItems => [this];
}
@@ -3,9 +3,12 @@
import 'dart:collection';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/application/database_controller.dart';
import 'package:appflowy/plugins/database_view/application/field/field_controller.dart';
import 'package:appflowy/plugins/database_view/application/row/row_cache.dart';
import 'package:appflowy/plugins/database_view/application/row/row_data_controller.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database_view/tar_bar/tab_bar_view.dart';
import 'package:appflowy/plugins/database_view/widgets/row/row_detail.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
@@ -24,11 +27,48 @@ import '../../widgets/card/card_cell_builder.dart';
import '../../widgets/row/cell_builder.dart';
import '../application/board_bloc.dart';
import '../../widgets/card/card.dart';
import 'toolbar/board_toolbar.dart';
import 'toolbar/board_setting_bar.dart';
class BoardPageTabBarBuilderImpl implements DatabaseTabBarItemBuilder {
@override
Widget content(
BuildContext context,
ViewPB view,
DatabaseController controller,
) {
return BoardPage(
key: _makeValueKey(controller),
view: view,
databaseController: controller,
);
}
@override
Widget settingBar(BuildContext context, DatabaseController controller) {
return BoardSettingBar(
key: _makeValueKey(controller),
databaseController: controller,
);
}
@override
Widget settingBarExtension(
BuildContext context,
DatabaseController controller,
) {
return SizedBox.fromSize();
}
ValueKey _makeValueKey(DatabaseController controller) {
return ValueKey(controller.viewId);
}
}
class BoardPage extends StatelessWidget {
final DatabaseController databaseController;
BoardPage({
required this.view,
required this.databaseController,
Key? key,
this.onEditStateChanged,
}) : super(key: ValueKey(view.id));
@@ -41,8 +81,10 @@ class BoardPage extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) =>
BoardBloc(view: view)..add(const BoardEvent.initial()),
create: (context) => BoardBloc(
view: view,
databaseController: databaseController,
)..add(const BoardEvent.initial()),
child: BlocBuilder<BoardBloc, BoardState>(
buildWhen: (p, c) => p.loadingState != c.loadingState,
builder: (context, state) {
@@ -54,7 +96,7 @@ class BoardPage extends StatelessWidget {
(_) => BoardContent(
onEditStateChanged: onEditStateChanged,
),
(err) => FlowyErrorPage(err.toString()),
(err) => FlowyErrorPage.message(err.toString(), howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(),),
);
},
);
@@ -110,14 +152,9 @@ class _BoardContentState extends State<BoardContent> {
child: BlocBuilder<BoardBloc, BoardState>(
buildWhen: (previous, current) => previous.groupIds != current.groupIds,
builder: (context, state) {
final column = Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [const _ToolbarBlocAdaptor(), _buildBoard(context)],
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 20),
child: column,
padding: GridSize.contentInsets,
child: _buildBoard(context),
);
},
),
@@ -125,22 +162,20 @@ class _BoardContentState extends State<BoardContent> {
}
Widget _buildBoard(BuildContext context) {
return Expanded(
child: AppFlowyBoard(
boardScrollController: scrollManager,
scrollController: ScrollController(),
controller: context.read<BoardBloc>().boardController,
headerBuilder: _buildHeader,
footerBuilder: _buildFooter,
cardBuilder: (_, column, columnItem) => _buildCard(
context,
column,
columnItem,
),
groupConstraints: const BoxConstraints.tightFor(width: 300),
config: AppFlowyBoardConfig(
groupBackgroundColor: Theme.of(context).colorScheme.surfaceVariant,
),
return AppFlowyBoard(
boardScrollController: scrollManager,
scrollController: ScrollController(),
controller: context.read<BoardBloc>().boardController,
headerBuilder: _buildHeader,
footerBuilder: _buildFooter,
cardBuilder: (_, column, columnItem) => _buildCard(
context,
column,
columnItem,
),
groupConstraints: const BoxConstraints.tightFor(width: 300),
config: AppFlowyBoardConfig(
groupBackgroundColor: Theme.of(context).colorScheme.surfaceVariant,
),
);
}
@@ -231,7 +266,7 @@ class _BoardContentState extends State<BoardContent> {
) {
final groupItem = afGroupItem as GroupItem;
final groupData = afGroupData.customData as GroupData;
final rowPB = groupItem.row;
final rowMeta = groupItem.row;
final rowCache = context.read<BoardBloc>().getRowCache();
/// Return placeholder widget if the rowCache is null.
@@ -255,7 +290,7 @@ class _BoardContentState extends State<BoardContent> {
margin: config.cardPadding,
decoration: _makeBoxDecoration(context),
child: RowCard<String>(
row: rowPB,
rowMeta: rowMeta,
viewId: viewId,
rowCache: rowCache,
cardData: groupData.group.groupId,
@@ -267,7 +302,7 @@ class _BoardContentState extends State<BoardContent> {
viewId,
groupData.group.groupId,
fieldController,
rowPB,
rowMeta,
rowCache,
context,
),
@@ -305,18 +340,19 @@ class _BoardContentState extends State<BoardContent> {
String viewId,
String groupId,
FieldController fieldController,
RowPB rowPB,
RowMetaPB rowMetaPB,
RowCache rowCache,
BuildContext context,
) {
final rowInfo = RowInfo(
viewId: viewId,
fields: UnmodifiableListView(fieldController.fieldInfos),
rowPB: rowPB,
rowMeta: rowMetaPB,
rowId: rowMetaPB.id,
);
final dataController = RowController(
rowId: rowInfo.rowPB.id,
rowMeta: rowInfo.rowMeta,
viewId: rowInfo.viewId,
rowCache: rowCache,
groupId: groupId,
@@ -334,17 +370,6 @@ class _BoardContentState extends State<BoardContent> {
}
}
class _ToolbarBlocAdaptor extends StatelessWidget {
const _ToolbarBlocAdaptor({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return BlocBuilder<BoardBloc, BoardState>(
builder: (context, state) => const BoardToolbar(),
);
}
}
Widget? _buildHeaderIcon(GroupData customData) {
Widget? widget;
switch (customData.fieldType) {
@@ -1,10 +1,11 @@
import 'package:appflowy/plugins/database_view/board/application/board_bloc.dart';
import 'package:appflowy/plugins/database_view/application/database_controller.dart';
import 'package:appflowy/plugins/database_view/widgets/setting/setting_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class BoardToolbar extends StatelessWidget {
const BoardToolbar({
class BoardSettingBar extends StatelessWidget {
final DatabaseController databaseController;
const BoardSettingBar({
required this.databaseController,
Key? key,
}) : super(key: key);
@@ -15,9 +16,7 @@ class BoardToolbar extends StatelessWidget {
child: Row(
children: [
const Spacer(),
SettingButton(
databaseController: context.read<BoardBloc>().databaseController,
),
SettingButton(databaseController: databaseController),
],
),
);
@@ -27,9 +27,8 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
CellCache get cellCache => databaseController.rowCache.cellCache;
RowCache get rowCache => databaseController.rowCache;
CalendarBloc({required ViewPB view})
: databaseController = DatabaseController(view: view),
super(CalendarState.initial()) {
CalendarBloc({required ViewPB view, required this.databaseController})
: super(CalendarState.initial()) {
on<CalendarEvent>(
(event, emit) async {
await event.when(
@@ -39,6 +38,12 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
_loadAllEvents();
},
didReceiveCalendarSettings: (CalendarLayoutSettingPB settings) {
// If the field id changed, reload all events
state.settings.fold(() => null, (oldSetting) {
if (oldSetting.fieldId != settings.fieldId) {
_loadAllEvents();
}
});
emit(state.copyWith(settings: Some(settings)));
},
didReceiveDatabaseUpdate: (DatabasePB database) {
@@ -53,10 +58,6 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
),
);
},
didReceiveNewLayoutField: (CalendarLayoutSettingPB layoutSettings) {
_loadAllEvents();
emit(state.copyWith(settings: Some(layoutSettings)));
},
createEvent: (DateTime date, String title) async {
await _createEvent(date, title);
},
@@ -105,12 +106,6 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
);
}
@override
Future<void> close() async {
await databaseController.dispose();
return super.close();
}
FieldInfo? _getCalendarFieldInfo(String fieldId) {
final fieldInfos = databaseController.fieldController.fieldInfos;
final index = fieldInfos.indexWhere(
@@ -149,7 +144,9 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
Future<void> _createEvent(DateTime date, String title) async {
return state.settings.fold(
() => null,
() {
Log.warn('Calendar settings not found');
},
(settings) async {
final dateField = _getCalendarFieldInfo(settings.fieldId);
final titleField = _getTitleFieldInfo();
@@ -207,7 +204,7 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
Future<void> _updateCalendarLayoutSetting(
CalendarLayoutSettingPB layoutSetting,
) async {
return databaseController.updateCalenderLayoutSetting(layoutSetting);
return databaseController.updateLayoutSetting(layoutSetting);
}
Future<CalendarEventData<CalendarDayEvent>?> _loadEvent(RowId rowId) async {
@@ -268,7 +265,7 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
final eventData = CalendarDayEvent(
event: eventPB,
eventId: eventPB.rowId,
eventId: eventPB.rowMeta.id,
dateFieldId: eventPB.dateFieldId,
date: date,
);
@@ -310,7 +307,7 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
}
add(CalendarEvent.didDeleteEvents(rowIds));
},
onRowsUpdated: (rowIds) async {
onRowsUpdated: (rowIds, reason) async {
if (isClosed) {
return;
}
@@ -333,14 +330,9 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
onLoadLayout: _didReceiveLayoutSetting,
);
final onCalendarLayoutFieldChanged = CalendarLayoutCallbacks(
onCalendarLayoutChanged: _didReceiveNewLayoutField,
);
databaseController.setListener(
databaseController.addListener(
onDatabaseChanged: onDatabaseChanged,
onLayoutChanged: onLayoutChanged,
onCalendarLayoutChanged: onCalendarLayoutFieldChanged,
);
}
@@ -353,13 +345,6 @@ class CalendarBloc extends Bloc<CalendarEvent, CalendarState> {
}
}
void _didReceiveNewLayoutField(DatabaseLayoutSettingPB layoutSetting) {
if (layoutSetting.hasCalendar()) {
if (isClosed) return;
add(CalendarEvent.didReceiveNewLayoutField(layoutSetting.calendar));
}
}
bool isEventDayChanged(CalendarEventData<CalendarDayEvent> event) {
final index = state.allEvents.indexWhere(
(element) => element.event!.eventId == event.event!.eventId,
@@ -426,10 +411,6 @@ class CalendarEvent with _$CalendarEvent {
const factory CalendarEvent.didReceiveDatabaseUpdate(DatabasePB database) =
_ReceiveDatabaseUpdate;
const factory CalendarEvent.didReceiveNewLayoutField(
CalendarLayoutSettingPB layoutSettings,
) = _DidReceiveNewLayoutField;
}
@freezed
@@ -0,0 +1,167 @@
import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart';
import 'package:appflowy/plugins/database_view/application/field/field_controller.dart';
import 'package:appflowy/plugins/database_view/application/row/row_service.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:dartz/dartz.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../application/database_controller.dart';
import '../../application/row/row_cache.dart';
part 'unschedule_event_bloc.freezed.dart';
class UnscheduleEventsBloc
extends Bloc<UnscheduleEventsEvent, UnscheduleEventsState> {
final DatabaseController databaseController;
Map<String, FieldInfo> fieldInfoByFieldId = {};
// Getters
String get viewId => databaseController.viewId;
FieldController get fieldController => databaseController.fieldController;
CellCache get cellCache => databaseController.rowCache.cellCache;
RowCache get rowCache => databaseController.rowCache;
UnscheduleEventsBloc({
required this.databaseController,
}) : super(UnscheduleEventsState.initial()) {
on<UnscheduleEventsEvent>(
(event, emit) async {
await event.when(
initial: () async {
_startListening();
_loadAllEvents();
},
didLoadAllEvents: (events) {
emit(
state.copyWith(
allEvents: events,
unscheduleEvents:
events.where((element) => !element.isScheduled).toList(),
),
);
},
didDeleteEvents: (List<RowId> deletedRowIds) {
final events = [...state.allEvents];
events.retainWhere(
(element) => !deletedRowIds.contains(element.rowMeta.id),
);
emit(
state.copyWith(
allEvents: events,
unscheduleEvents:
events.where((element) => !element.isScheduled).toList(),
),
);
},
didReceiveEvent: (CalendarEventPB event) {
emit(
state.copyWith(
allEvents: [...state.allEvents, event],
),
);
},
);
},
);
}
Future<CalendarEventPB?> _loadEvent(
RowId rowId,
) async {
final payload = RowIdPB(viewId: viewId, rowId: rowId);
return DatabaseEventGetCalendarEvent(payload).send().then(
(result) => result.fold(
(eventPB) => eventPB,
(r) {
Log.error(r);
return null;
},
),
);
}
Future<void> _loadAllEvents() async {
final payload = CalendarEventRequestPB.create()..viewId = viewId;
DatabaseEventGetAllCalendarEvents(payload).send().then((result) {
result.fold(
(events) {
if (!isClosed) {
add(UnscheduleEventsEvent.didLoadAllEvents(events.items));
}
},
(r) => Log.error(r),
);
});
}
void _startListening() {
final onDatabaseChanged = DatabaseCallbacks(
onRowsCreated: (rowIds) async {
if (isClosed) {
return;
}
for (final id in rowIds) {
final event = await _loadEvent(id);
if (event != null && !isClosed) {
add(UnscheduleEventsEvent.didReceiveEvent(event));
}
}
},
onRowsDeleted: (rowIds) {
if (isClosed) {
return;
}
add(UnscheduleEventsEvent.didDeleteEvents(rowIds));
},
onRowsUpdated: (rowIds, reason) async {
if (isClosed) {
return;
}
for (final id in rowIds) {
final event = await _loadEvent(id);
if (event != null) {
add(UnscheduleEventsEvent.didDeleteEvents([id]));
add(UnscheduleEventsEvent.didReceiveEvent(event));
}
}
},
);
databaseController.addListener(onDatabaseChanged: onDatabaseChanged);
}
}
@freezed
class UnscheduleEventsEvent with _$UnscheduleEventsEvent {
const factory UnscheduleEventsEvent.initial() = _InitialCalendar;
// Called after loading all the current evnets
const factory UnscheduleEventsEvent.didLoadAllEvents(
List<CalendarEventPB> events,
) = _ReceiveUnscheduleEventsEvents;
const factory UnscheduleEventsEvent.didDeleteEvents(List<RowId> rowIds) =
_DidDeleteEvents;
const factory UnscheduleEventsEvent.didReceiveEvent(
CalendarEventPB event,
) = _DidReceiveEvent;
}
@freezed
class UnscheduleEventsState with _$UnscheduleEventsState {
const factory UnscheduleEventsState({
required Option<DatabasePB> database,
required List<CalendarEventPB> allEvents,
required List<CalendarEventPB> unscheduleEvents,
}) = _UnscheduleEventsState;
factory UnscheduleEventsState.initial() => UnscheduleEventsState(
database: none(),
allEvents: [],
unscheduleEvents: [],
);
}
@@ -1,19 +1,14 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/tar_bar/tab_bar_view.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy/workspace/presentation/widgets/left_bar_item.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:flutter/material.dart';
import '../../util.dart';
import 'presentation/calendar_page.dart';
class CalendarPluginBuilder extends PluginBuilder {
@override
Plugin build(dynamic data) {
if (data is ViewPB) {
return CalendarPlugin(pluginType: pluginType, view: data);
return DatabaseTabBarViewPlugin(pluginType: pluginType, view: data);
} else {
throw FlowyPluginException.invalidData;
}
@@ -36,51 +31,3 @@ class CalendarPluginConfig implements PluginConfig {
@override
bool get creatable => true;
}
class CalendarPlugin extends Plugin {
@override
final ViewPluginNotifier notifier;
final PluginType _pluginType;
CalendarPlugin({
required ViewPB view,
required PluginType pluginType,
}) : _pluginType = pluginType,
notifier = ViewPluginNotifier(view: view);
@override
PluginWidgetBuilder get widgetBuilder =>
CalendarPluginWidgetBuilder(notifier: notifier);
@override
PluginId get id => notifier.view.id;
@override
PluginType get pluginType => _pluginType;
}
class CalendarPluginWidgetBuilder extends PluginWidgetBuilder {
final ViewPluginNotifier notifier;
CalendarPluginWidgetBuilder({required this.notifier, Key? key});
ViewPB get view => notifier.view;
@override
Widget get leftBarItem => ViewLeftBarItem(view: view);
@override
Widget buildWidget({PluginContext? context}) {
notifier.isDeleted.addListener(() {
notifier.isDeleted.value.fold(() => null, (deletedView) {
if (deletedView.hasIndex()) {
context?.onDeleted(view, deletedView.index);
}
});
});
return CalendarPage(key: ValueKey(view.id), view: view);
}
@override
List<NavigationItem> get navigationItems => [this];
}
@@ -113,7 +113,7 @@ class CalendarDayCard extends StatelessWidget {
.add(CalendarEvent.moveEvent(event, date));
},
),
_NewEventButton(onCreate: () => onCreateEvent(date)),
NewEventButton(onCreate: () => onCreateEvent(date)),
MouseRegion(
onEnter: (p) => notifyEnter(context, true),
onExit: (p) => notifyEnter(context, false),
@@ -160,9 +160,10 @@ class _Header extends StatelessWidget {
}
}
class _NewEventButton extends StatelessWidget {
@visibleForTesting
class NewEventButton extends StatelessWidget {
final VoidCallback onCreate;
const _NewEventButton({required this.onCreate, Key? key}) : super(key: key);
const NewEventButton({required this.onCreate, Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
@@ -254,7 +255,7 @@ class _EventList extends StatelessWidget {
Widget build(BuildContext context) {
return Flexible(
child: ListView.separated(
itemBuilder: (BuildContext context, int index) => _EventCard(
itemBuilder: (BuildContext context, int index) => EventCard(
event: events[index],
viewId: viewId,
rowCache: rowCache,
@@ -270,13 +271,14 @@ class _EventList extends StatelessWidget {
}
}
class _EventCard extends StatelessWidget {
@visibleForTesting
class EventCard extends StatelessWidget {
final CalendarDayEvent event;
final String viewId;
final RowCache rowCache;
final BoxConstraints constraints;
const _EventCard({
const EventCard({
required this.event,
required this.viewId,
required this.rowCache,
@@ -301,7 +303,7 @@ class _EventCard extends StatelessWidget {
// Add the key here to make sure the card is rebuilt when the cells
// in this row are updated.
key: ValueKey(event.eventId),
row: rowInfo!.rowPB,
rowMeta: rowInfo!.rowMeta,
viewId: viewId,
rowCache: rowCache,
cardData: event.dateFieldId,
@@ -309,7 +311,7 @@ class _EventCard extends StatelessWidget {
cellBuilder: cellBuilder,
openCard: (context) => showEventDetails(
context: context,
event: event,
event: event.event,
viewId: viewId,
rowCache: rowCache,
),
@@ -1,5 +1,9 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/application/database_controller.dart';
import 'package:appflowy/plugins/database_view/calendar/application/calendar_bloc.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database_view/tar_bar/tab_bar_view.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/calendar_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:calendar_view/calendar_view.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -15,11 +19,51 @@ import '../../widgets/row/cell_builder.dart';
import '../../widgets/row/row_detail.dart';
import 'calendar_day.dart';
import 'layout/sizes.dart';
import 'toolbar/calendar_toolbar.dart';
import 'toolbar/calendar_setting_bar.dart';
class CalendarPageTabBarBuilderImpl implements DatabaseTabBarItemBuilder {
@override
Widget content(
BuildContext context,
ViewPB view,
DatabaseController controller,
) {
return CalendarPage(
key: _makeValueKey(controller),
view: view,
databaseController: controller,
);
}
@override
Widget settingBar(BuildContext context, DatabaseController controller) {
return CalendarSettingBar(
key: _makeValueKey(controller),
databaseController: controller,
);
}
@override
Widget settingBarExtension(
BuildContext context,
DatabaseController controller,
) {
return SizedBox.fromSize();
}
ValueKey _makeValueKey(DatabaseController controller) {
return ValueKey(controller.viewId);
}
}
class CalendarPage extends StatefulWidget {
final ViewPB view;
const CalendarPage({required this.view, super.key});
final DatabaseController databaseController;
const CalendarPage({
required this.view,
required this.databaseController,
super.key,
});
@override
State<CalendarPage> createState() => _CalendarPageState();
@@ -33,8 +77,10 @@ class _CalendarPageState extends State<CalendarPage> {
@override
void initState() {
_calendarState = GlobalKey<MonthViewState>();
_calendarBloc = CalendarBloc(view: widget.view)
..add(const CalendarEvent.initial());
_calendarBloc = CalendarBloc(
view: widget.view,
databaseController: widget.databaseController,
)..add(const CalendarEvent.initial());
super.initState();
}
@@ -79,7 +125,7 @@ class _CalendarPageState extends State<CalendarPage> {
if (state.editingEvent != null) {
showEventDetails(
context: context,
event: state.editingEvent!.event!,
event: state.editingEvent!.event!.event,
viewId: widget.view.id,
rowCache: _calendarBloc.rowCache,
);
@@ -115,8 +161,6 @@ class _CalendarPageState extends State<CalendarPage> {
builder: (context, state) {
return Column(
children: [
// const _ToolbarBlocAdaptor(),
const CalendarToolbar(),
_buildCalendar(
_eventController,
state.settings
@@ -133,15 +177,18 @@ class _CalendarPageState extends State<CalendarPage> {
Widget _buildCalendar(EventController eventController, int firstDayOfWeek) {
return Expanded(
child: MonthView(
key: _calendarState,
controller: _eventController,
cellAspectRatio: .6,
startDay: _weekdayFromInt(firstDayOfWeek),
borderColor: Theme.of(context).dividerColor,
headerBuilder: _headerNavigatorBuilder,
weekDayBuilder: _headerWeekDayBuilder,
cellBuilder: _calendarDayBuilder,
child: Padding(
padding: GridSize.contentInsets,
child: MonthView(
key: _calendarState,
controller: _eventController,
cellAspectRatio: .6,
startDay: _weekdayFromInt(firstDayOfWeek),
borderColor: Theme.of(context).dividerColor,
headerBuilder: _headerNavigatorBuilder,
weekDayBuilder: _headerWeekDayBuilder,
cellBuilder: _calendarDayBuilder,
),
),
);
}
@@ -238,12 +285,12 @@ class _CalendarPageState extends State<CalendarPage> {
void showEventDetails({
required BuildContext context,
required CalendarDayEvent event,
required CalendarEventPB event,
required String viewId,
required RowCache rowCache,
}) {
final dataController = RowController(
rowId: event.eventId,
rowMeta: event.rowMeta,
viewId: viewId,
rowCache: rowCache,
);
@@ -351,21 +351,16 @@ class FirstDayOfWeek extends StatelessWidget {
final symbols =
DateFormat.EEEE(context.locale.toLanguageTag()).dateSymbols;
// starts from sunday
final items = symbols.WEEKDAYS.asMap().entries.map((entry) {
final index = entry.key;
final string = entry.value;
return SizedBox(
height: GridSize.popoverItemHeight,
child: FlowyButton(
text: FlowyText.medium(string),
onTap: () {
onUpdated(index);
popoverMutex.close();
},
rightIcon: firstDayOfWeek == index
? const FlowySvg(name: 'grid/checkmark')
: null,
),
const len = 2;
final items = symbols.WEEKDAYS.take(len).indexed.map((entry) {
return StartFromButton(
title: entry.$2,
dayIndex: entry.$1,
isSelected: firstDayOfWeek == entry.$1,
onTap: (index) {
onUpdated(index);
popoverMutex.close();
},
);
}).toList();
@@ -376,7 +371,7 @@ class FirstDayOfWeek extends StatelessWidget {
itemBuilder: (context, index) => items[index],
separatorBuilder: (context, index) =>
VSpace(GridSize.typeOptionSeparatorHeight),
itemCount: 2,
itemCount: len,
),
);
},
@@ -426,3 +421,29 @@ enum CalendarLayoutSettingAction {
showWeekNumber,
showTimeLine,
}
class StartFromButton extends StatelessWidget {
final int dayIndex;
final String title;
final bool isSelected;
final void Function(int) onTap;
const StartFromButton({
required this.title,
required this.dayIndex,
required this.onTap,
required this.isSelected,
super.key,
});
@override
Widget build(BuildContext context) {
return SizedBox(
height: GridSize.popoverItemHeight,
child: FlowyButton(
text: FlowyText.medium(title),
onTap: () => onTap(dayIndex),
rightIcon: isSelected ? const FlowySvg(name: 'grid/checkmark') : null,
),
);
}
}
@@ -0,0 +1,178 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/application/database_controller.dart';
import 'package:appflowy/plugins/database_view/application/row/row_cache.dart';
import 'package:appflowy/plugins/database_view/calendar/application/unschedule_event_bloc.dart';
import 'package:appflowy/plugins/database_view/calendar/presentation/calendar_page.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database_view/widgets/setting/setting_button.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/calendar_entities.pb.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class CalendarSettingBar extends StatelessWidget {
final DatabaseController databaseController;
const CalendarSettingBar({
required this.databaseController,
super.key,
});
@override
Widget build(BuildContext context) {
return SizedBox(
height: 40,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
UnscheduleEventsButton(databaseController: databaseController),
SettingButton(
databaseController: databaseController,
),
],
),
);
}
}
class UnscheduleEventsButton extends StatefulWidget {
final DatabaseController databaseController;
const UnscheduleEventsButton({
required this.databaseController,
Key? key,
}) : super(key: key);
@override
State<UnscheduleEventsButton> createState() => _UnscheduleEventsButtonState();
}
class _UnscheduleEventsButtonState extends State<UnscheduleEventsButton> {
late final PopoverController _popoverController;
late final UnscheduleEventsBloc _bloc;
@override
void initState() {
super.initState();
_bloc = UnscheduleEventsBloc(databaseController: widget.databaseController)
..add(const UnscheduleEventsEvent.initial());
_popoverController = PopoverController();
}
@override
dispose() {
_bloc.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AppFlowyPopover(
direction: PopoverDirection.bottomWithCenterAligned,
controller: _popoverController,
offset: const Offset(0, 8),
constraints: const BoxConstraints(maxWidth: 300, maxHeight: 600),
child: BlocProvider.value(
value: _bloc,
child: BlocBuilder<UnscheduleEventsBloc, UnscheduleEventsState>(
buildWhen: (previous, current) =>
previous.unscheduleEvents.length !=
current.unscheduleEvents.length,
builder: (context, state) {
return FlowyTextButton(
"${LocaleKeys.calendar_settings_noDateTitle.tr()} (${state.unscheduleEvents.length})",
fillColor: Colors.transparent,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
padding: GridSize.typeOptionContentInsets,
);
},
),
),
popupBuilder: (context) {
return UnscheduleEventsList(
viewId: _bloc.viewId,
rowCache: _bloc.rowCache,
controller: _popoverController,
unscheduleEvents: _bloc.state.unscheduleEvents,
);
},
);
}
}
class UnscheduleEventsList extends StatelessWidget {
final String viewId;
final RowCache rowCache;
final PopoverController controller;
final List<CalendarEventPB> unscheduleEvents;
const UnscheduleEventsList({
required this.viewId,
required this.controller,
required this.unscheduleEvents,
required this.rowCache,
super.key,
});
@override
Widget build(BuildContext context) {
final cells = <Widget>[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: FlowyText.medium(
LocaleKeys.calendar_settings_clickToAdd.tr(),
color: Theme.of(context).hintColor,
overflow: TextOverflow.ellipsis,
),
),
const VSpace(6),
...unscheduleEvents.map(
(e) => UnscheduledEventCell(
event: e,
onPressed: () {
showEventDetails(
context: context,
event: e,
viewId: viewId,
rowCache: rowCache,
);
controller.close();
},
),
)
];
return ListView.separated(
itemBuilder: (context, index) => cells[index],
itemCount: cells.length,
separatorBuilder: (context, index) =>
VSpace(GridSize.typeOptionSeparatorHeight),
shrinkWrap: true,
);
}
}
class UnscheduledEventCell extends StatelessWidget {
final CalendarEventPB event;
final VoidCallback onPressed;
const UnscheduledEventCell({
required this.event,
required this.onPressed,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return SizedBox(
height: GridSize.popoverItemHeight,
child: FlowyButton(
text: FlowyText.medium(
event.title.isEmpty
? LocaleKeys.calendar_defaultNewCalendarTitle.tr()
: event.title,
),
onTap: onPressed,
),
);
}
}
@@ -1,136 +0,0 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/calendar/presentation/calendar_page.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database_view/widgets/setting/setting_button.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:calendar_view/calendar_view.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../application/calendar_bloc.dart';
class CalendarToolbar extends StatelessWidget {
const CalendarToolbar({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
height: 40,
child: Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
const _UnscheduleEventsButton(),
SettingButton(
databaseController: context.read<CalendarBloc>().databaseController,
),
],
),
);
}
}
class _UnscheduleEventsButton extends StatefulWidget {
const _UnscheduleEventsButton({Key? key}) : super(key: key);
@override
State<_UnscheduleEventsButton> createState() =>
_UnscheduleEventsButtonState();
}
class _UnscheduleEventsButtonState extends State<_UnscheduleEventsButton> {
late final PopoverController _controller;
@override
void initState() {
super.initState();
_controller = PopoverController();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<CalendarBloc, CalendarState>(
builder: (context, state) {
final unscheduledEvents = state.allEvents
.where((e) => e.date == DateTime.fromMillisecondsSinceEpoch(0))
.toList();
final viewId = context.read<CalendarBloc>().viewId;
final rowCache = context.read<CalendarBloc>().rowCache;
return AppFlowyPopover(
direction: PopoverDirection.bottomWithCenterAligned,
controller: _controller,
offset: const Offset(0, 8),
constraints: const BoxConstraints(maxWidth: 300, maxHeight: 600),
child: FlowyTextButton(
"${LocaleKeys.calendar_settings_noDateTitle.tr()} (${unscheduledEvents.length})",
fillColor: Colors.transparent,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
padding: GridSize.typeOptionContentInsets,
),
popupBuilder: (context) {
final cells = <Widget>[
Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 4),
child: FlowyText.medium(
// LocaleKeys.calendar_settings_noDateHint.tr(),
LocaleKeys.calendar_settings_clickToAdd.tr(),
color: Theme.of(context).hintColor,
overflow: TextOverflow.ellipsis,
),
),
const VSpace(6),
...unscheduledEvents.map(
(e) => _UnscheduledEventItem(
event: e,
onPressed: () {
showEventDetails(
context: context,
event: e.event!,
viewId: viewId,
rowCache: rowCache,
);
_controller.close();
},
),
)
];
return ListView.separated(
itemBuilder: (context, index) => cells[index],
itemCount: cells.length,
separatorBuilder: (context, index) =>
VSpace(GridSize.typeOptionSeparatorHeight),
shrinkWrap: true,
);
},
);
},
);
}
}
class _UnscheduledEventItem extends StatelessWidget {
final CalendarEventData<CalendarDayEvent> event;
final VoidCallback onPressed;
const _UnscheduledEventItem({
required this.event,
required this.onPressed,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return SizedBox(
height: GridSize.popoverItemHeight,
child: FlowyButton(
text: FlowyText.medium(
event.title.isEmpty
? LocaleKeys.calendar_defaultNewCalendarTitle.tr()
: event.title,
),
onTap: onPressed,
),
);
}
}
@@ -1,61 +0,0 @@
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_listener.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import '../../workspace/presentation/home/home_stack.dart';
/// [DatabaseViewPlugin] is used to build the grid, calendar, and board.
/// It is a wrapper of the [Plugin] class. The underlying [Plugin] is
/// determined by the [ViewPB.pluginType] field.
///
class DatabaseViewPlugin extends Plugin {
final ViewListener _viewListener;
ViewPB _view;
Plugin _innerPlugin;
DatabaseViewPlugin({
required ViewPB view,
}) : _view = view,
_innerPlugin = _makeInnerPlugin(view),
_viewListener = ViewListener(view: view) {
_listenOnLayoutChanged();
}
@override
PluginId get id => _innerPlugin.id;
@override
PluginType get pluginType => _innerPlugin.pluginType;
@override
PluginWidgetBuilder get widgetBuilder => _innerPlugin.widgetBuilder;
@override
void dispose() {
_viewListener.stop();
super.dispose();
}
void _listenOnLayoutChanged() {
_viewListener.start(
onViewUpdated: (result) {
result.fold(
(updatedView) {
if (_view.layout != updatedView.layout) {
_innerPlugin = _makeInnerPlugin(updatedView);
getIt<HomeStackManager>().setPlugin(_innerPlugin);
}
_view = updatedView;
},
(r) => null,
);
},
);
}
}
Plugin _makeInnerPlugin(ViewPB view) {
return makePlugin(pluginType: view.pluginType, data: view);
}
@@ -3,17 +3,17 @@ import 'package:freezed_annotation/freezed_annotation.dart';
part 'grid_accessory_bloc.freezed.dart';
class GridAccessoryMenuBloc
extends Bloc<GridAccessoryMenuEvent, GridAccessoryMenuState> {
class DatabaseViewSettingExtensionBloc extends Bloc<
DatabaseViewSettingExtensionEvent, DatabaseViewSettingExtensionState> {
final String viewId;
GridAccessoryMenuBloc({required this.viewId})
DatabaseViewSettingExtensionBloc({required this.viewId})
: super(
GridAccessoryMenuState.initial(
DatabaseViewSettingExtensionState.initial(
viewId,
),
) {
on<GridAccessoryMenuEvent>(
on<DatabaseViewSettingExtensionEvent>(
(event, emit) async {
event.when(
initial: () {},
@@ -27,22 +27,25 @@ class GridAccessoryMenuBloc
}
@freezed
class GridAccessoryMenuEvent with _$GridAccessoryMenuEvent {
const factory GridAccessoryMenuEvent.initial() = _Initial;
const factory GridAccessoryMenuEvent.toggleMenu() = _MenuVisibleChange;
class DatabaseViewSettingExtensionEvent
with _$DatabaseViewSettingExtensionEvent {
const factory DatabaseViewSettingExtensionEvent.initial() = _Initial;
const factory DatabaseViewSettingExtensionEvent.toggleMenu() =
_MenuVisibleChange;
}
@freezed
class GridAccessoryMenuState with _$GridAccessoryMenuState {
const factory GridAccessoryMenuState({
class DatabaseViewSettingExtensionState
with _$DatabaseViewSettingExtensionState {
const factory DatabaseViewSettingExtensionState({
required String viewId,
required bool isVisible,
}) = _GridAccessoryMenuState;
}) = _DatabaseViewSettingExtensionState;
factory GridAccessoryMenuState.initial(
factory DatabaseViewSettingExtensionState.initial(
String viewId,
) =>
GridAccessoryMenuState(
DatabaseViewSettingExtensionState(
viewId: viewId,
isVisible: false,
);
@@ -1,6 +1,8 @@
import 'dart:async';
import 'package:appflowy/plugins/database_view/application/row/row_cache.dart';
import 'package:appflowy/plugins/database_view/application/row/row_service.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/filter/filter_info.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/sort/sort_info.dart';
import 'package:dartz/dartz.dart';
import 'package:equatable/equatable.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
@@ -33,18 +35,18 @@ class GridBloc extends Bloc<GridEvent, GridState> {
final rowService = RowBackendService(
viewId: rowInfo.viewId,
);
await rowService.deleteRow(rowInfo.rowPB.id);
await rowService.deleteRow(rowInfo.rowId);
},
moveRow: (int from, int to) {
final List<RowInfo> rows = [...state.rowInfos];
final fromRow = rows[from].rowPB;
final toRow = rows[to].rowPB;
final fromRow = rows[from].rowId;
final toRow = rows[to].rowId;
rows.insert(to, rows.removeAt(from));
emit(state.copyWith(rowInfos: rows));
databaseController.moveRow(fromRow: fromRow, toRow: toRow);
databaseController.moveRow(fromRowId: fromRow, toRowId: toRow);
},
didReceiveGridUpdate: (grid) {
emit(state.copyWith(grid: Some(grid)));
@@ -56,7 +58,7 @@ class GridBloc extends Bloc<GridEvent, GridState> {
),
);
},
didReceiveRowUpdate: (newRowInfos, reason) {
didLoadRows: (newRowInfos, reason) {
emit(
state.copyWith(
rowInfos: newRowInfos,
@@ -65,18 +67,28 @@ class GridBloc extends Bloc<GridEvent, GridState> {
),
);
},
didReceveFilters: (List<FilterInfo> filters) {
emit(
state.copyWith(
reorderable: filters.isEmpty && state.sorts.isEmpty,
filters: filters,
),
);
},
didReceveSorts: (List<SortInfo> sorts) {
emit(
state.copyWith(
reorderable: sorts.isEmpty && state.filters.isEmpty,
sorts: sorts,
),
);
},
);
},
);
}
@override
Future<void> close() async {
await databaseController.dispose();
return super.close();
}
RowCache? getRowCache(RowId rowId) {
RowCache getRowCache(RowId rowId) {
return databaseController.rowCache;
}
@@ -89,7 +101,14 @@ class GridBloc extends Bloc<GridEvent, GridState> {
},
onNumOfRowsChanged: (rowInfos, _, reason) {
if (!isClosed) {
add(GridEvent.didReceiveRowUpdate(rowInfos, reason));
add(GridEvent.didLoadRows(rowInfos, reason));
}
},
onRowsUpdated: (rows, reason) {
if (!isClosed) {
add(
GridEvent.didLoadRows(databaseController.rowCache.rowInfos, reason),
);
}
},
onFieldsChanged: (fields) {
@@ -97,8 +116,18 @@ class GridBloc extends Bloc<GridEvent, GridState> {
add(GridEvent.didReceiveFieldUpdate(fields));
}
},
onFiltersChanged: (filters) {
if (!isClosed) {
add(GridEvent.didReceveFilters(filters));
}
},
onSortsChanged: (sorts) {
if (!isClosed) {
add(GridEvent.didReceveSorts(sorts));
}
},
);
databaseController.setListener(onDatabaseChanged: onDatabaseChanged);
databaseController.addListener(onDatabaseChanged: onDatabaseChanged);
}
Future<void> _openGrid(Emitter<GridState> emit) async {
@@ -122,9 +151,9 @@ class GridEvent with _$GridEvent {
const factory GridEvent.createRow() = _CreateRow;
const factory GridEvent.deleteRow(RowInfo rowInfo) = _DeleteRow;
const factory GridEvent.moveRow(int from, int to) = _MoveRow;
const factory GridEvent.didReceiveRowUpdate(
const factory GridEvent.didLoadRows(
List<RowInfo> rows,
RowsChangedReason listState,
RowsChangedReason reason,
) = _DidReceiveRowUpdate;
const factory GridEvent.didReceiveFieldUpdate(
List<FieldInfo> fields,
@@ -133,6 +162,11 @@ class GridEvent with _$GridEvent {
const factory GridEvent.didReceiveGridUpdate(
DatabasePB grid,
) = _DidReceiveGridUpdate;
const factory GridEvent.didReceveFilters(List<FilterInfo> filters) =
_DidReceiveFilters;
const factory GridEvent.didReceveSorts(List<SortInfo> sorts) =
_DidReceiveSorts;
}
@freezed
@@ -144,7 +178,10 @@ class GridState with _$GridState {
required List<RowInfo> rowInfos,
required int rowCount,
required GridLoadingState loadingState,
required bool reorderable,
required RowsChangedReason reason,
required List<SortInfo> sorts,
required List<FilterInfo> filters,
}) = _GridState;
factory GridState.initial(String viewId) => GridState(
@@ -153,8 +190,11 @@ class GridState with _$GridState {
rowCount: 0,
grid: none(),
viewId: viewId,
reorderable: true,
loadingState: const _Loading(),
reason: const InitialListState(),
filters: [],
sorts: [],
);
}
@@ -4,7 +4,6 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:dartz/dartz.dart';
import '../../../application/row/row_cache.dart';
import '../../../application/row/row_service.dart';
part 'row_action_sheet_bloc.freezed.dart';
@@ -13,19 +12,20 @@ class RowActionSheetBloc
extends Bloc<RowActionSheetEvent, RowActionSheetState> {
final RowBackendService _rowService;
RowActionSheetBloc({required RowInfo rowInfo})
: _rowService = RowBackendService(viewId: rowInfo.viewId),
super(RowActionSheetState.initial(rowInfo)) {
RowActionSheetBloc({
required String viewId,
required RowId rowId,
}) : _rowService = RowBackendService(viewId: viewId),
super(RowActionSheetState.initial(rowId)) {
on<RowActionSheetEvent>(
(event, emit) async {
await event.when(
deleteRow: () async {
final result = await _rowService.deleteRow(state.rowData.rowPB.id);
final result = await _rowService.deleteRow(state.rowId);
logResult(result);
},
duplicateRow: () async {
final result =
await _rowService.duplicateRow(rowId: state.rowData.rowPB.id);
final result = await _rowService.duplicateRow(rowId: state.rowId);
logResult(result);
},
);
@@ -47,10 +47,10 @@ class RowActionSheetEvent with _$RowActionSheetEvent {
@freezed
class RowActionSheetState with _$RowActionSheetState {
const factory RowActionSheetState({
required RowInfo rowData,
required RowId rowId,
}) = _RowActionSheetState;
factory RowActionSheetState.initial(RowInfo rowData) => RowActionSheetState(
rowData: rowData,
factory RowActionSheetState.initial(RowId rowId) => RowActionSheetState(
rowId: rowId,
);
}
@@ -15,13 +15,16 @@ part 'row_bloc.freezed.dart';
class RowBloc extends Bloc<RowEvent, RowState> {
final RowBackendService _rowBackendSvc;
final RowController _dataController;
final String viewId;
final String rowId;
RowBloc({
required RowInfo rowInfo,
required this.rowId,
required this.viewId,
required RowController dataController,
}) : _rowBackendSvc = RowBackendService(viewId: rowInfo.viewId),
}) : _rowBackendSvc = RowBackendService(viewId: viewId),
_dataController = dataController,
super(RowState.initial(rowInfo, dataController.loadData())) {
super(RowState.initial(dataController.loadData())) {
on<RowEvent>(
(event, emit) async {
await event.when(
@@ -29,7 +32,7 @@ class RowBloc extends Bloc<RowEvent, RowState> {
await _startListening();
},
createRow: () {
_rowBackendSvc.createRow(rowInfo.rowPB.id);
_rowBackendSvc.createRowAfterRow(rowId);
},
didReceiveCells: (cellByFieldId, reason) async {
final cells = cellByFieldId.values
@@ -78,18 +81,15 @@ class RowEvent with _$RowEvent {
@freezed
class RowState with _$RowState {
const factory RowState({
required RowInfo rowInfo,
required CellContextByFieldId cellByFieldId,
required UnmodifiableListView<GridCellEquatable> cells,
RowsChangedReason? changeReason,
}) = _RowState;
factory RowState.initial(
RowInfo rowInfo,
CellContextByFieldId cellByFieldId,
) =>
RowState(
rowInfo: rowInfo,
cellByFieldId: cellByFieldId,
cells: UnmodifiableListView(
cellByFieldId.values
@@ -27,7 +27,7 @@ class RowDetailBloc extends Bloc<RowDetailEvent, RowDetailState> {
}
},
didReceiveCellDatas: (cells) {
emit(state.copyWith(gridCells: cells));
emit(state.copyWith(cells: cells));
},
deleteField: (fieldId) {
_fieldBackendService(fieldId).deleteField();
@@ -95,10 +95,10 @@ class RowDetailEvent with _$RowDetailEvent {
@freezed
class RowDetailState with _$RowDetailState {
const factory RowDetailState({
required List<DatabaseCellContext> gridCells,
required List<DatabaseCellContext> cells,
}) = _RowDetailState;
factory RowDetailState.initial() => RowDetailState(
gridCells: List.empty(),
cells: List.empty(),
);
}
@@ -0,0 +1,126 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import '../../../application/row/row_service.dart';
part 'row_document_bloc.freezed.dart';
class RowDocumentBloc extends Bloc<RowDocumentEvent, RowDocumentState> {
final String rowId;
final RowBackendService _rowBackendSvc;
RowDocumentBloc({
required this.rowId,
required String viewId,
}) : _rowBackendSvc = RowBackendService(viewId: viewId),
super(RowDocumentState.initial()) {
on<RowDocumentEvent>(
(event, emit) async {
await event.when(
initial: () async {
_getRowDocumentView();
},
didReceiveRowDocument: (view) {
emit(
state.copyWith(
viewPB: view,
loadingState: const LoadingState.finish(),
),
);
},
didReceiveError: (FlowyError error) {
emit(
state.copyWith(
loadingState: LoadingState.error(error),
),
);
},
);
},
);
}
Future<void> _getRowDocumentView() async {
final rowDetailOrError = await _rowBackendSvc.getRowMeta(rowId);
rowDetailOrError.fold(
(RowMetaPB rowMeta) async {
final viewsOrError =
await ViewBackendService.getView(rowMeta.documentId);
if (isClosed) {
return;
}
viewsOrError.fold(
(view) => add(RowDocumentEvent.didReceiveRowDocument(view)),
(error) async {
if (error.code == ErrorCode.RecordNotFound.value) {
// By default, the document of the row is not exist. So creating a
// new document for the given document id of the row.
final documentView =
await _createRowDocumentView(rowMeta.documentId);
if (documentView != null) {
add(RowDocumentEvent.didReceiveRowDocument(documentView));
}
} else {
add(RowDocumentEvent.didReceiveError(error));
}
},
);
},
(err) => Log.error('Failed to get row detail: $err'),
);
}
Future<ViewPB?> _createRowDocumentView(String viewId) async {
final result = await ViewBackendService.createOrphanView(
viewId: viewId,
name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
desc: '',
layoutType: ViewLayoutPB.Document,
);
return result.fold(
(view) => view,
(error) {
Log.error(error);
return null;
},
);
}
}
@freezed
class RowDocumentEvent with _$RowDocumentEvent {
const factory RowDocumentEvent.initial() = _InitialRow;
const factory RowDocumentEvent.didReceiveRowDocument(ViewPB view) =
_DidReceiveRowDocument;
const factory RowDocumentEvent.didReceiveError(FlowyError error) =
_DidReceiveError;
}
@freezed
class RowDocumentState with _$RowDocumentState {
const factory RowDocumentState({
ViewPB? viewPB,
required LoadingState loadingState,
}) = _RowDocumentState;
factory RowDocumentState.initial() => const RowDocumentState(
loadingState: LoadingState.loading(),
);
}
@freezed
class LoadingState with _$LoadingState {
const factory LoadingState.loading() = _Loading;
const factory LoadingState.error(FlowyError error) = _Error;
const factory LoadingState.finish() = _Finish;
}
@@ -1,19 +1,14 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/util.dart';
import 'package:appflowy/plugins/database_view/tar_bar/tab_bar_view.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy/workspace/presentation/widgets/left_bar_item.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:flutter/material.dart';
import 'presentation/grid_page.dart';
class GridPluginBuilder implements PluginBuilder {
@override
Plugin build(dynamic data) {
if (data is ViewPB) {
return GridPlugin(pluginType: pluginType, view: data);
return DatabaseTabBarViewPlugin(pluginType: pluginType, view: data);
} else {
throw FlowyPluginException.invalidData;
}
@@ -36,51 +31,3 @@ class GridPluginConfig implements PluginConfig {
@override
bool get creatable => true;
}
class GridPlugin extends Plugin {
@override
final ViewPluginNotifier notifier;
final PluginType _pluginType;
GridPlugin({
required ViewPB view,
required PluginType pluginType,
}) : _pluginType = pluginType,
notifier = ViewPluginNotifier(view: view);
@override
PluginWidgetBuilder get widgetBuilder =>
GridPluginWidgetBuilder(notifier: notifier);
@override
PluginId get id => notifier.view.id;
@override
PluginType get pluginType => _pluginType;
}
class GridPluginWidgetBuilder extends PluginWidgetBuilder {
final ViewPluginNotifier notifier;
ViewPB get view => notifier.view;
GridPluginWidgetBuilder({required this.notifier, Key? key});
@override
Widget get leftBarItem => ViewLeftBarItem(view: view);
@override
Widget buildWidget({PluginContext? context}) {
notifier.isDeleted.addListener(() {
notifier.isDeleted.value.fold(() => null, (deletedView) {
if (deletedView.hasIndex()) {
context?.onDeleted(view, deletedView.index);
}
});
});
return GridPage(key: ValueKey(view.id), view: view);
}
@override
List<NavigationItem> get navigationItems => [this];
}
@@ -1,5 +1,11 @@
import 'dart:io';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/application/row/row_service.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/toolbar/grid_setting_bar.dart';
import 'package:appflowy/plugins/database_view/tar_bar/setting_menu.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cell_builder.dart';
import 'package:appflowy_backend/log.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui_web.dart';
import 'package:flowy_infra_ui/style_widget/scrolling/styled_scroll_bar.dart';
@@ -13,32 +19,82 @@ import 'package:linked_scroll_controller/linked_scroll_controller.dart';
import '../../application/field/field_controller.dart';
import '../../application/row/row_cache.dart';
import '../../application/row/row_data_controller.dart';
import '../../application/setting/setting_bloc.dart';
import '../application/filter/filter_menu_bloc.dart';
import '../application/grid_bloc.dart';
import '../../application/database_controller.dart';
import '../application/sort/sort_menu_bloc.dart';
import 'grid_scroll.dart';
import '../../tar_bar/tab_bar_view.dart';
import 'layout/layout.dart';
import 'layout/sizes.dart';
import 'widgets/accessory_menu.dart';
import 'widgets/row/row.dart';
import 'widgets/footer/grid_footer.dart';
import 'widgets/header/grid_header.dart';
import '../../widgets/row/row_detail.dart';
import 'widgets/shortcuts.dart';
import 'widgets/toolbar/grid_toolbar.dart';
class ToggleExtensionNotifier extends ChangeNotifier {
bool _isToggled = false;
get isToggled => _isToggled;
void toggle() {
_isToggled = !_isToggled;
notifyListeners();
}
}
class GridPageTabBarBuilderImpl implements DatabaseTabBarItemBuilder {
final _toggleExtension = ToggleExtensionNotifier();
@override
Widget content(
BuildContext context,
ViewPB view,
DatabaseController controller,
) {
return GridPage(
key: _makeValueKey(controller),
view: view,
databaseController: controller,
);
}
@override
Widget settingBar(BuildContext context, DatabaseController controller) {
return GridSettingBar(
key: _makeValueKey(controller),
controller: controller,
toggleExtension: _toggleExtension,
);
}
@override
Widget settingBarExtension(
BuildContext context,
DatabaseController controller,
) {
return DatabaseViewSettingExtension(
key: _makeValueKey(controller),
viewId: controller.viewId,
databaseController: controller,
toggleExtension: _toggleExtension,
);
}
ValueKey _makeValueKey(DatabaseController controller) {
return ValueKey(controller.viewId);
}
}
class GridPage extends StatefulWidget {
GridPage({
final DatabaseController databaseController;
const GridPage({
required this.view,
required this.databaseController,
this.onDeleted,
Key? key,
}) : databaseController = DatabaseController(view: view),
super(key: key);
}) : super(key: key);
final ViewPB view;
final DatabaseController databaseController;
final VoidCallback? onDeleted;
@override
@@ -46,6 +102,11 @@ class GridPage extends StatefulWidget {
}
class _GridPageState extends State<GridPage> {
@override
void initState() {
super.initState();
}
@override
Widget build(BuildContext context) {
return MultiBlocProvider(
@@ -56,21 +117,6 @@ class _GridPageState extends State<GridPage> {
databaseController: widget.databaseController,
)..add(const GridEvent.initial()),
),
BlocProvider<GridFilterMenuBloc>(
create: (context) => GridFilterMenuBloc(
viewId: widget.view.id,
fieldController: widget.databaseController.fieldController,
)..add(const GridFilterMenuEvent.initial()),
),
BlocProvider<SortMenuBloc>(
create: (context) => SortMenuBloc(
viewId: widget.view.id,
fieldController: widget.databaseController.fieldController,
)..add(const SortMenuEvent.initial()),
),
BlocProvider<DatabaseSettingBloc>(
create: (context) => DatabaseSettingBloc(viewId: widget.view.id),
),
],
child: BlocBuilder<GridBloc, GridState>(
builder: (context, state) {
@@ -78,8 +124,13 @@ class _GridPageState extends State<GridPage> {
loading: (_) =>
const Center(child: CircularProgressIndicator.adaptive()),
finish: (result) => result.successOrFail.fold(
(_) => const GridShortcuts(child: FlowyGrid()),
(err) => FlowyErrorPage(err.toString()),
(_) => GridShortcuts(
child: GridPageContent(view: widget.view),
),
(err) => FlowyErrorPage.message(
err.toString(),
howToFix: LocaleKeys.errorDialog_howToFixFallback.tr(),
),
),
);
},
@@ -88,16 +139,18 @@ class _GridPageState extends State<GridPage> {
}
}
class FlowyGrid extends StatefulWidget {
const FlowyGrid({
class GridPageContent extends StatefulWidget {
final ViewPB view;
const GridPageContent({
required this.view,
super.key,
});
@override
State<FlowyGrid> createState() => _FlowyGridState();
State<GridPageContent> createState() => _GridPageContentState();
}
class _FlowyGridState extends State<FlowyGrid> {
class _GridPageContentState extends State<GridPageContent> {
final _scrollController = GridScrollController(
scrollGroupController: LinkedScrollControllerGroup(),
);
@@ -121,140 +174,176 @@ class _FlowyGridState extends State<FlowyGrid> {
buildWhen: (previous, current) => previous.fields != current.fields,
builder: (context, state) {
final contentWidth = GridLayout.headerWidth(state.fields.value);
final child = _WrapScrollView(
scrollController: _scrollController,
contentWidth: contentWidth,
child: _GridRows(
verticalScrollController: _scrollController.verticalController,
),
);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const GridToolbar(),
GridAccessoryMenu(viewId: state.viewId),
_gridHeader(context, state.viewId),
Flexible(child: child),
const _RowCountBadge(),
_GridHeader(headerScrollController: headerScrollController),
_GridRows(
viewId: state.viewId,
contentWidth: contentWidth,
scrollController: _scrollController,
),
const _GridFooter(),
],
);
},
);
}
}
Widget _gridHeader(BuildContext context, String viewId) {
final fieldController =
context.read<GridBloc>().databaseController.fieldController;
return GridHeaderSliverAdaptor(
viewId: viewId,
fieldController: fieldController,
anchorScrollController: headerScrollController,
class _GridHeader extends StatelessWidget {
final ScrollController headerScrollController;
const _GridHeader({required this.headerScrollController});
@override
Widget build(BuildContext context) {
return BlocBuilder<GridBloc, GridState>(
builder: (context, state) {
return GridHeaderSliverAdaptor(
viewId: state.viewId,
fieldController:
context.read<GridBloc>().databaseController.fieldController,
anchorScrollController: headerScrollController,
);
},
);
}
}
class _GridRows extends StatelessWidget {
const _GridRows({
required this.verticalScrollController,
});
final String viewId;
final double contentWidth;
final GridScrollController scrollController;
final ScrollController verticalScrollController;
const _GridRows({
required this.viewId,
required this.contentWidth,
required this.scrollController,
});
@override
Widget build(BuildContext context) {
final filterState = context.watch<GridFilterMenuBloc>().state;
final sortState = context.watch<SortMenuBloc>().state;
return BlocBuilder<GridBloc, GridState>(
buildWhen: (previous, current) => current.reason.maybeWhen(
reorderRows: () => true,
reorderSingleRow: (reorderRow, rowInfo) => true,
delete: (item) => true,
insert: (item) => true,
orElse: () => false,
),
builder: (context, state) {
final rowInfos = state.rowInfos;
final behavior = ScrollConfiguration.of(context).copyWith(
scrollbars: false,
);
return ScrollConfiguration(
behavior: behavior,
child: ReorderableListView.builder(
/// TODO(Xazin): Resolve inconsistent scrollbar behavior
/// This is a workaround related to
/// https://github.com/flutter/flutter/issues/25652
cacheExtent: 5000,
scrollController: verticalScrollController,
buildDefaultDragHandles: false,
proxyDecorator: (child, index, animation) => Material(
color: Colors.white.withOpacity(.1),
child: Opacity(opacity: .5, child: child),
),
onReorder: (fromIndex, newIndex) {
final toIndex = newIndex > fromIndex ? newIndex - 1 : newIndex;
if (fromIndex == toIndex) {
return;
}
context
.read<GridBloc>()
.add(GridEvent.moveRow(fromIndex, toIndex));
},
itemCount: rowInfos.length + 1, // the extra item is the footer
itemBuilder: (context, index) {
if (index < rowInfos.length) {
final rowInfo = rowInfos[index];
return _renderRow(
context,
rowInfo,
index: index,
isSortEnabled: sortState.sortInfos.isNotEmpty,
isFilterEnabled: filterState.filters.isNotEmpty,
);
}
return const _GridFooter(key: Key('gridFooter'));
},
return Flexible(
child: _WrapScrollView(
scrollController: scrollController,
contentWidth: contentWidth,
child: BlocBuilder<GridBloc, GridState>(
buildWhen: (previous, current) => current.reason.maybeWhen(
reorderRows: () => true,
reorderSingleRow: (reorderRow, rowInfo) => true,
delete: (item) => true,
insert: (item) => true,
orElse: () => false,
),
);
},
builder: (context, state) {
final rowInfos = state.rowInfos;
final behavior = ScrollConfiguration.of(context).copyWith(
scrollbars: false,
);
return ScrollConfiguration(
behavior: behavior,
child: _renderList(context, state, rowInfos),
);
},
),
),
);
}
Widget _renderList(
BuildContext context,
GridState state,
List<RowInfo> rowInfos,
) {
if (Platform.isWindows) {
// Workaround: On Windows, the focusing of the text cell is not working
// properly when the list is reorderable. So using the ListView instead.
return ListView.builder(
controller: scrollController.verticalController,
itemCount: rowInfos.length + 1, // the extra item is the footer
itemBuilder: (context, index) {
if (index < rowInfos.length) {
final rowInfo = rowInfos[index];
return _renderRow(
context,
rowInfo.rowId,
isDraggable: false,
index: index,
);
}
return const GridRowBottomBar(key: Key('gridFooter'));
},
);
} else {
return ReorderableListView.builder(
/// TODO(Xazin): Resolve inconsistent scrollbar behavior
/// This is a workaround related to
/// https://github.com/flutter/flutter/issues/25652
cacheExtent: 5000,
scrollController: scrollController.verticalController,
buildDefaultDragHandles: false,
proxyDecorator: (child, index, animation) => Material(
color: Colors.white.withOpacity(.1),
child: Opacity(opacity: .5, child: child),
),
onReorder: (fromIndex, newIndex) {
final toIndex = newIndex > fromIndex ? newIndex - 1 : newIndex;
if (fromIndex == toIndex) {
return;
}
context.read<GridBloc>().add(GridEvent.moveRow(fromIndex, toIndex));
},
itemCount: rowInfos.length + 1, // the extra item is the footer
itemBuilder: (context, index) {
if (index < rowInfos.length) {
final rowInfo = rowInfos[index];
return _renderRow(
context,
rowInfo.rowId,
isDraggable: state.reorderable,
index: index,
);
}
return const GridRowBottomBar(key: Key('gridFooter'));
},
);
}
}
Widget _renderRow(
BuildContext context,
RowInfo rowInfo, {
RowId rowId, {
int? index,
bool isSortEnabled = false,
bool isFilterEnabled = false,
required bool isDraggable,
Animation<double>? animation,
}) {
final rowCache = context.read<GridBloc>().getRowCache(
rowInfo.rowPB.id,
);
final rowCache = context.read<GridBloc>().getRowCache(rowId);
final rowMeta = rowCache.getRow(rowId)?.rowMeta;
/// Return placeholder widget if the rowCache is null.
if (rowCache == null) return const SizedBox.shrink();
/// Return placeholder widget if the rowMeta is null.
if (rowMeta == null) return const SizedBox.shrink();
final fieldController =
context.read<GridBloc>().databaseController.fieldController;
final dataController = RowController(
rowId: rowInfo.rowPB.id,
viewId: rowInfo.viewId,
viewId: viewId,
rowMeta: rowMeta,
rowCache: rowCache,
);
final child = GridRow(
key: ValueKey(rowInfo.rowPB.id),
key: ValueKey(rowMeta.id),
rowId: rowId,
viewId: viewId,
index: index,
isDraggable: !isSortEnabled && !isFilterEnabled,
rowInfo: rowInfo,
isDraggable: isDraggable,
dataController: dataController,
cellBuilder: GridCellBuilder(cellCache: dataController.cellCache),
openDetailPage: (context, cellBuilder) {
_openRowDetailPage(
context,
rowInfo,
rowId,
fieldController,
rowCache,
cellBuilder,
@@ -274,42 +363,32 @@ class _GridRows extends StatelessWidget {
void _openRowDetailPage(
BuildContext context,
RowInfo rowInfo,
RowId rowId,
FieldController fieldController,
RowCache rowCache,
GridCellBuilder cellBuilder,
) {
final dataController = RowController(
viewId: rowInfo.viewId,
rowId: rowInfo.rowPB.id,
rowCache: rowCache,
);
final rowMeta = rowCache.getRow(rowId)?.rowMeta;
// Most of the cases, the rowMeta should not be null.
if (rowMeta != null) {
final dataController = RowController(
viewId: viewId,
rowMeta: rowMeta,
rowCache: rowCache,
);
FlowyOverlay.show(
context: context,
builder: (BuildContext context) {
return RowDetailPage(
cellBuilder: cellBuilder,
rowController: dataController,
);
},
);
}
}
class _GridFooter extends StatelessWidget {
const _GridFooter({
super.key,
});
@override
Widget build(BuildContext context) {
return Container(
padding: GridSize.footerContentInsets,
height: GridSize.footerHeight,
margin: const EdgeInsets.only(bottom: 200),
child: const GridAddRowButton(),
);
FlowyOverlay.show(
context: context,
builder: (BuildContext context) {
return RowDetailPage(
cellBuilder: cellBuilder,
rowController: dataController,
);
},
);
} else {
Log.warn('RowMeta is null for rowId: $rowId');
}
}
}
@@ -343,8 +422,8 @@ class _WrapScrollView extends StatelessWidget {
}
}
class _RowCountBadge extends StatelessWidget {
const _RowCountBadge();
class _GridFooter extends StatelessWidget {
const _GridFooter();
@override
Widget build(BuildContext context) {
@@ -352,15 +431,14 @@ class _RowCountBadge extends StatelessWidget {
selector: (state) => state.rowCount,
builder: (context, rowCount) {
return Padding(
padding: GridSize.footerContentInsets,
padding: GridSize.contentInsets,
child: Row(
mainAxisAlignment: MainAxisAlignment.start,
children: [
FlowyText.medium(
'${LocaleKeys.grid_row_count.tr()} : ',
rowCountString(rowCount),
color: Theme.of(context).hintColor,
),
FlowyText.medium(rowCount.toString()),
],
),
);
@@ -368,3 +446,7 @@ class _RowCountBadge extends StatelessWidget {
);
}
}
String rowCountString(int count) {
return '${LocaleKeys.grid_row_count.tr()} : $count';
}
@@ -36,4 +36,11 @@ class GridSize {
GridSize.headerContainerPadding,
GridSize.headerContainerPadding,
);
static EdgeInsets get contentInsets => EdgeInsets.fromLTRB(
GridSize.leadingHeaderPadding,
GridSize.headerContainerPadding,
GridSize.leadingHeaderPadding,
GridSize.headerContainerPadding,
);
}
@@ -1,90 +0,0 @@
import 'package:appflowy/plugins/database_view/grid/application/filter/filter_menu_bloc.dart';
import 'package:appflowy/plugins/database_view/grid/application/grid_accessory_bloc.dart';
import 'package:appflowy/plugins/database_view/grid/application/sort/sort_menu_bloc.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../layout/sizes.dart';
import 'filter/filter_menu.dart';
import 'sort/sort_menu.dart';
class GridAccessoryMenu extends StatelessWidget {
final String viewId;
const GridAccessoryMenu({required this.viewId, Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => GridAccessoryMenuBloc(viewId: viewId),
child: MultiBlocListener(
listeners: [
BlocListener<GridFilterMenuBloc, GridFilterMenuState>(
listenWhen: (p, c) => p.isVisible != c.isVisible,
listener: (context, state) => context
.read<GridAccessoryMenuBloc>()
.add(const GridAccessoryMenuEvent.toggleMenu()),
),
BlocListener<SortMenuBloc, SortMenuState>(
listenWhen: (p, c) => p.isVisible != c.isVisible,
listener: (context, state) => context
.read<GridAccessoryMenuBloc>()
.add(const GridAccessoryMenuEvent.toggleMenu()),
),
],
child: BlocBuilder<GridAccessoryMenuBloc, GridAccessoryMenuState>(
builder: (context, state) {
if (state.isVisible) {
return const _AccessoryMenu();
} else {
return const SizedBox();
}
},
),
),
);
}
}
class _AccessoryMenu extends StatelessWidget {
const _AccessoryMenu({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return BlocBuilder<GridAccessoryMenuBloc, GridAccessoryMenuState>(
builder: (context, state) {
return _wrapPadding(
Column(
children: [
Divider(
height: 1.0,
color: AFThemeExtension.of(context).toggleOffFill,
),
const VSpace(6),
const IntrinsicHeight(
child: Row(
children: [
SortMenu(),
HSpace(6),
FilterMenu(),
],
),
),
],
),
);
},
);
}
Widget _wrapPadding(Widget child) {
return Padding(
padding: EdgeInsets.symmetric(
horizontal: GridSize.leadingHeaderPadding,
vertical: 6,
),
child: child,
);
}
}
@@ -109,7 +109,7 @@ class _CheckboxFilterEditorState extends State<CheckboxFilterEditor> {
children: [
FlowyText(state.filterInfo.fieldInfo.name),
const HSpace(4),
CheckboxFilterConditionPBList(
CheckboxFilterConditionList(
filterInfo: state.filterInfo,
popoverMutex: popoverMutex,
onCondition: (condition) {
@@ -137,11 +137,11 @@ class _CheckboxFilterEditorState extends State<CheckboxFilterEditor> {
}
}
class CheckboxFilterConditionPBList extends StatelessWidget {
class CheckboxFilterConditionList extends StatelessWidget {
final FilterInfo filterInfo;
final PopoverMutex popoverMutex;
final Function(CheckboxFilterConditionPB) onCondition;
const CheckboxFilterConditionPBList({
const CheckboxFilterConditionList({
required this.filterInfo,
required this.popoverMutex,
required this.onCondition,
@@ -93,7 +93,7 @@ class ChecklistState extends State<ChecklistFilterEditor> {
children: [
FlowyText(state.filterInfo.fieldInfo.name),
const HSpace(4),
ChecklistFilterConditionPBList(
ChecklistFilterConditionList(
filterInfo: state.filterInfo,
),
const Spacer(),
@@ -118,9 +118,9 @@ class ChecklistState extends State<ChecklistFilterEditor> {
}
}
class ChecklistFilterConditionPBList extends StatelessWidget {
class ChecklistFilterConditionList extends StatelessWidget {
final FilterInfo filterInfo;
const ChecklistFilterConditionPBList({
const ChecklistFilterConditionList({
required this.filterInfo,
Key? key,
}) : super(key: key);
@@ -2,7 +2,6 @@ import 'package:appflowy/plugins/database_view/grid/application/filter/select_op
import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option.pb.dart';
import 'package:flowy_infra/image.dart';
import 'package:flowy_infra_ui/style_widget/scrolling/styled_list.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:flutter/material.dart';
@@ -58,16 +57,16 @@ class SelectOptionFilterList extends StatelessWidget {
SelectOptionFilterListState>(
builder: (context, state) {
return ListView.separated(
physics: const NeverScrollableScrollPhysics(),
shrinkWrap: true,
controller: ScrollController(),
itemCount: state.visibleOptions.length,
separatorBuilder: (context, index) {
return VSpace(GridSize.typeOptionSeparatorHeight);
},
physics: StyledScrollPhysics(),
itemBuilder: (BuildContext context, int index) {
final option = state.visibleOptions[index];
return _SelectOptionFilterCell(
return SelectOptionFilterCell(
option: option.optionPB,
isSelected: option.isSelected,
);
@@ -80,21 +79,20 @@ class SelectOptionFilterList extends StatelessWidget {
}
}
class _SelectOptionFilterCell extends StatefulWidget {
class SelectOptionFilterCell extends StatefulWidget {
final SelectOptionPB option;
final bool isSelected;
const _SelectOptionFilterCell({
const SelectOptionFilterCell({
required this.option,
required this.isSelected,
Key? key,
}) : super(key: key);
@override
State<_SelectOptionFilterCell> createState() =>
_SelectOptionFilterCellState();
State<SelectOptionFilterCell> createState() => _SelectOptionFilterCellState();
}
class _SelectOptionFilterCellState extends State<_SelectOptionFilterCell> {
class _SelectOptionFilterCellState extends State<SelectOptionFilterCell> {
@override
Widget build(BuildContext context) {
return SizedBox(
@@ -60,7 +60,7 @@ class _GridCreateFilterListState extends State<GridCreateFilterList> {
final cells = state.creatableFields.map((fieldInfo) {
return SizedBox(
height: GridSize.popoverItemHeight,
child: _FilterPropertyCell(
child: GridFilterPropertyCell(
fieldInfo: fieldInfo,
onTap: (fieldInfo) => createFilter(fieldInfo),
),
@@ -147,10 +147,10 @@ class _FilterTextFieldDelegate extends SliverPersistentHeaderDelegate {
}
}
class _FilterPropertyCell extends StatelessWidget {
class GridFilterPropertyCell extends StatelessWidget {
final FieldInfo fieldInfo;
final Function(FieldInfo) onTap;
const _FilterPropertyCell({
const GridFilterPropertyCell({
required this.fieldInfo,
required this.onTap,
Key? key,
@@ -1,4 +1,5 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/application/field/field_controller.dart';
import 'package:appflowy/plugins/database_view/grid/application/filter/filter_menu_bloc.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -12,37 +13,49 @@ import 'create_filter_list.dart';
import 'filter_menu_item.dart';
class FilterMenu extends StatelessWidget {
const FilterMenu({Key? key}) : super(key: key);
final FieldController fieldController;
const FilterMenu({
required this.fieldController,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return BlocBuilder<GridFilterMenuBloc, GridFilterMenuState>(
builder: (context, state) {
final List<Widget> children = [];
children.addAll(
state.filters
.map((filterInfo) => FilterMenuItem(filterInfo: filterInfo))
.toList(),
);
return BlocProvider<GridFilterMenuBloc>(
create: (context) => GridFilterMenuBloc(
viewId: fieldController.viewId,
fieldController: fieldController,
)..add(
const GridFilterMenuEvent.initial(),
),
child: BlocBuilder<GridFilterMenuBloc, GridFilterMenuState>(
builder: (context, state) {
final List<Widget> children = [];
children.addAll(
state.filters
.map((filterInfo) => FilterMenuItem(filterInfo: filterInfo))
.toList(),
);
if (state.creatableFields.isNotEmpty) {
children.add(AddFilterButton(viewId: state.viewId));
}
if (state.creatableFields.isNotEmpty) {
children.add(AddFilterButton(viewId: state.viewId));
}
return Expanded(
child: Row(
children: [
Expanded(
child: Wrap(
spacing: 6,
runSpacing: 4,
children: children,
return Expanded(
child: Row(
children: [
Expanded(
child: Wrap(
spacing: 6,
runSpacing: 4,
children: children,
),
),
),
],
),
);
},
],
),
);
},
),
);
}
}
@@ -1,5 +1,6 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/grid/application/grid_bloc.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra/image.dart';
@@ -27,3 +28,19 @@ class GridAddRowButton extends StatelessWidget {
);
}
}
class GridRowBottomBar extends StatelessWidget {
const GridRowBottomBar({
super.key,
});
@override
Widget build(BuildContext context) {
return Container(
padding: GridSize.footerContentInsets,
height: GridSize.footerHeight,
margin: const EdgeInsets.only(bottom: 200),
child: const GridAddRowButton(),
);
}
}
@@ -169,17 +169,14 @@ class FieldActionCell extends StatelessWidget {
Widget build(BuildContext context) {
return FlowyButton(
hoverColor: AFThemeExtension.of(context).lightGreyHover,
disable: !enable,
text: FlowyText.medium(
action.title(),
color: enable
? AFThemeExtension.of(context).textColor
: Theme.of(context).disabledColor,
),
onTap: () {
if (enable) {
action.run(context, fieldInfo);
}
},
onTap: () => action.run(context, fieldInfo),
leftIcon: svgWidget(
action.iconName(),
color: enable
Loaded 100 of 493 files, more files were not shown because too many files have changed in this diff. Show more