Compare commits

...
37 Commits
Author SHA1 Message Date
Lucas.Xu daf9b48c73 chore: update CHANGELOG (#3433) 2023-09-18 14:39:25 +08:00
Lucas.Xu 0ea5b3c483 fix: rtl related issues 2023-09-18 14:33:51 +08:00
Nathan.fooo f3148640eb chore: update collab rev id (#3432) 2023-09-18 12:45:16 +08:00
Zackandnathan 1c84ee1d53 feat: integrate appflowy-cloud (#3359)
* feat: draft: code dependency

* chore: update ref

* feat: signup using client_api

* feat: support auto sign_in after sign_up if already confirmed(WIP)

* chore: update collab commit id

* chore: fix compile errors

* chore: user AFServer trait to provide optional service

* chore: refactor workspace

* chore: disable aws config

* chore: return ws connect

* chore: update collab rev

* chore: fmt and clippy

* chore: fix test

* chore: update chrono version

* chore: add script to update the collab crates commit id

* chore: update

---------

Co-authored-by: nathan <nathan@appflowy.io>
2023-09-17 17:14:34 +08:00
xfarrow cecd4f48ab Italian translation fix (#3395)
* Update it-IT.json
2023-09-15 15:43:05 +02:00
Richard Shiue 1e001640aa fix: cannot open popovers (#3410) 2023-09-15 19:01:22 +08:00
Lucas.Xu 3426e5b53e fix: launch review issues (#3413) 2023-09-15 17:51:29 +08:00
Yijing Huang 936b49a63f fix: alignment issue in login page (#3399) 2023-09-15 16:07:23 +08:00
Richard Shiue f6f80b48c9 fix: some UI issues (#3403) 2023-09-14 19:30:33 +08:00
Lucas.Xu 759cb27a43 chore: update version to 0.3.2 (#3404) 2023-09-14 19:23:39 +08:00
Lucas.Xu 26a2bffcd1 fix: some UI issues were present in version 0.3.1. (#3401) 2023-09-14 19:22:32 +08:00
Aman Negi fe55452c79 fix: restore banner theme issues (#3299) 2023-09-14 18:48:42 +08:00
Generic-Ripe 16adacac38 chore: update sv.json (#3386)
In this context board is tavla in Swedish.
Styrelse is board of directors.
2023-09-14 18:47:55 +08:00
Vincenzo De Petris d86531ac92 fix: add left padding to TabsManager widget (#3396) 2023-09-14 12:08:44 +02:00
Nathan.fooo 9231455f0d chore: update collab rev and fix compile (#3398) 2023-09-13 22:11:51 +08:00
Richard Shiue 0c6a1d4ae7 chore: revamp checklist ui (#3380)
* chore: revamp checklist editor  ui

* chore: checklist progress bar

* test: integration tests

* fix: flutter analyzer errors

* fix: checklist percentage complete
2023-09-13 20:44:04 +08:00
Richard Shiue 524efc2620 test: fi convert text to date cell test (#3377) 2023-09-13 19:12:40 +08:00
Richard Shiue 50a4f03931 chore: date editor ui polish (#3367) 2023-09-13 19:12:26 +08:00
Richard Shiue a4681a4042 fix: don't use timestamp fields to make calendar (#3366) 2023-09-13 19:11:46 +08:00
Richard Shiue 1ca130d7de feat: revamp row detail page UI (#3328)
* feat: revamp row detail page UI

* chore: some minor details and fix tests

* fix: fix tests

* chore: remove unused field

* chore: code cleanup

* test: add reordering fields in row page tests

* chore: remove duplicate and delete row events

* chore: timestamp cell ui adjustment

* chore: remove unused code

* test: fix new integration tests
2023-09-13 19:10:08 +08:00
Mathias Mogensen ef6f9a3175 fix: reorder rows on windows (#3279)
* fix: reorder rows on windows

Closes: #3208

* fix: unused import
2023-09-13 11:36:22 +08:00
Vincenzo De Petris aa25aa4474 fix: DateCardCellState date formatting (#3387)
* fix: DateCardCellState date formatting

* refactor: use string interpolation
2023-09-13 11:18:07 +08:00
c7af04b317 feat: support incremental updates for textblock's delta. (#3216)
* feat: support incremental to update textblock's delta

* fix: update test code

* fix: remove console

* fix: update test

* feat: integrate increamental delta in Flutter

* fix: delete quill editor

* fix: delete quill editor

* feat: add csharp in codeblock (#3371)

* chore: pt-PT & pt-BR translation updated  (#3353)

* chore: Ensure Cargo.lock Is Updated Alongside Changes to Cargo.toml (#3361)

* ci: add cargo check workflow

* ci: test cargo.toml

* fix: update test

* fix: code review

* fix: update cargo.toml and cargo.lock

* fix: code review

* fix: rust format

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
Co-authored-by: Mayur Mahajan <47064215+MayurSMahajan@users.noreply.github.com>
Co-authored-by: Carlos Silva <mtbf99@gmail.com>
2023-09-12 20:49:03 +08:00
Mohammad ZolfaghariandLucas.Xu 9565173baf feat: text and layout direction settings (#3247)
* feat: text and layout direction settings

Added ltr|rtl|auto direction button to appflowy toolbar.
Introduced layout and default direction settings.

* chore: formate code

* feat: added hint for direction settings

* fix: flutter analyze

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2023-09-12 19:11:13 +08:00
Lucas.Xu 5f4e3ecc76 fix: unable to paste texts contains section or font tag (#3379) 2023-09-12 15:53:39 +08:00
Lucas.Xu 043c68a333 fix: downgrade the folder path to io.appflowy.appflowy (#3344) 2023-09-12 14:31:05 +08:00
Yijing Huang f639d51c11 feat: auth pages improvement for mobile and desktop platform (#3217) 2023-09-12 10:32:26 +08:00
Lucas.Xu 71071b60b2 feat: improve performance of document page (#3357) 2023-09-12 10:29:41 +08:00
Kilu.He 608255fa0f chore: Ensure Cargo.lock Is Updated Alongside Changes to Cargo.toml (#3361)
* ci: add cargo check workflow

* ci: test cargo.toml
2023-09-11 18:59:35 +08:00
Carlos Silva f8b0a26acc chore: pt-PT & pt-BR translation updated (#3353) 2023-09-11 15:24:08 +08:00
Mayur Mahajan 133dab934b feat: add csharp in codeblock (#3371) 2023-09-11 15:02:57 +08:00
fangwufeng-v a81670c447 feat: new grid (#3167)
* feat: implement database database service by functions

* feat: define database data type

* feat: basic grid store and component

* feat: data communication mechanism and simple table ui. Can add new filed and update field name.

* feat: add grid text cell and grid checkbox cell

* feat: single select cell and multiselect cell

* refactor: fix code review problems

* feat: add new row

* feat: fix tsc error
2023-09-11 10:27:56 +08:00
Richard Shiue 35f84e42cc fix: one-frame glitch when deleting all sorts (#3330) 2023-09-10 15:50:49 +08:00
Yijing Huang 2f6df6a17d chore: remove default ImageFilter (#3349) 2023-09-08 10:09:16 +08:00
Richard Shiue 8bcc6384f2 refactor: different TypeOption between DateTime and LastModified/CreatedAt (#3356)
* fix: stringify date cell includes time if true

* refactor: LastModified and CreatedAt type option

* chore: frontend implementation

* chore: some adjustments and fix tests

* fix: integration tests

* chore: timestamp type option ui
2023-09-08 10:07:24 +08:00
Nathan.fooo a0fc4b86b0 chore: update collab libs rev (#3342) 2023-09-06 16:00:23 +08:00
Nathan.fooo 790580eea4 chore: update cargo build profile (#3332) 2023-09-05 09:31:23 +08:00
444 changed files with 14673 additions and 7382 deletions

No files matched your search

+30 -9
View File
@@ -32,6 +32,26 @@ jobs:
with:
node-version: ${{ env.NODE_VERSION }}
- name: Cache Rust Dependencies
uses: Swatinem/rust-cache@v2
with:
key: rust-dependencies-${{ runner.os }}
workspaces: |
frontend/rust-lib
frontend/appflowy_tauri/src-tauri
- name: Cache Node.js dependencies
uses: actions/cache@v2
with:
path: ~/.npm
key: npm-${{ runner.os }}
- name: Cache node_modules
uses: actions/cache@v2
with:
path: frontend/appflowy_tauri/node_modules
key: node-modules-${{ runner.os }}
- name: Install Rust toolchain
id: rust_toolchain
uses: actions-rs/toolchain@v1
@@ -40,13 +60,6 @@ jobs:
override: true
profile: minimal
- uses: Swatinem/rust-cache@v2
with:
prefix-key: 'ubuntu-latest-tauri'
workspaces: |
frontend/rust-lib
frontend/appflowy_tauri/src-tauri
- name: install dependencies (windows only)
if: matrix.platform == 'windows-latest'
working-directory: frontend
@@ -75,15 +88,23 @@ jobs:
cargo make appflowy-tauri-deps-tools
npm install -g pnpm@${{ env.PNPM_VERSION }}
- name: build
- name: Build
working-directory: frontend/appflowy_tauri
run: |
mkdir dist
pnpm install
cargo make --cwd .. tauri_build
pnpm test
pnpm test:errors
- name: Check for uncommitted changes
run: |
diff_files=$(git status --porcelain)
if [ -n "$diff_files" ]; then
echo "There are uncommitted changes in the working tree. Please commit them before pushing."
exit 1
fi
- uses: tauri-apps/tauri-action@v0
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+2
View File
@@ -41,3 +41,5 @@ pubspec.lock
# ignore the deb filegit
frontend/package
frontend/*.deb
**/Cargo.toml.bak
+13
View File
@@ -1,5 +1,18 @@
# Release Notes
## Version 0.3.2 - 09/18/2023
### New Features
- Improve the performance of the editor, now it is much faster when editing a large document.
- Support for reordering the rows of the database on Windows.
- Revamp the row detail page of the database.
- Revamp the checklist cell editor of the database.
### Bug fixes
- Some UI issues
## Version 0.3.1 - 09/04/2023
### New Features
+9 -1
View File
@@ -20,11 +20,12 @@ run_task = { name = ["restore-crate-type"] }
[env]
RUST_LOG = "info"
CARGO_PROFILE = "dev"
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.3.1"
CURRENT_APP_VERSION = "0.3.2"
FLUTTER_DESKTOP_FEATURES = "dart,rev-sqlite"
PRODUCT_NAME = "AppFlowy"
# CRATE_TYPE: https://doc.rust-lang.org/reference/linkage.html
@@ -75,6 +76,7 @@ BUILD_ACTIVE_ARCHS_ONLY = true
CRATE_TYPE = "staticlib"
[env.production-mac-arm64]
CARGO_PROFILE = "release"
BUILD_FLAG = "release"
TARGET_OS = "macos"
RUST_COMPILE_TARGET = "aarch64-apple-darwin"
@@ -86,6 +88,7 @@ BUILD_ACTIVE_ARCHS_ONLY = false
CRATE_TYPE = "staticlib"
[env.production-mac-x86_64]
CARGO_PROFILE = "release"
BUILD_FLAG = "release"
TARGET_OS = "macos"
RUST_COMPILE_TARGET = "x86_64-apple-darwin"
@@ -97,6 +100,7 @@ BUILD_ACTIVE_ARCHS_ONLY = false
CRATE_TYPE = "staticlib"
[env.production-mac-universal]
CARGO_PROFILE = "release"
BUILD_FLAG = "release"
TARGET_OS = "macos"
FLUTTER_OUTPUT_DIR = "Release"
@@ -114,6 +118,7 @@ CRATE_TYPE = "cdylib"
LIB_EXT = "dll"
[env.production-windows-x86]
CARGO_PROFILE = "release"
BUILD_FLAG = "release"
TARGET_OS = "windows"
RUST_COMPILE_TARGET = "x86_64-pc-windows-msvc"
@@ -133,6 +138,7 @@ LIB_EXT = "so"
LINUX_ARCH = "x64"
[env.production-linux-x86_64]
CARGO_PROFILE = "release"
BUILD_FLAG = "release"
TARGET_OS = "linux"
RUST_COMPILE_TARGET = "x86_64-unknown-linux-gnu"
@@ -153,6 +159,7 @@ LINUX_ARCH = "arm64"
FLUTTER_DESKTOP_FEATURES = "dart,rev-sqlite,openssl_vendored"
[env.production-linux-aarch64]
CARGO_PROFILE = "release"
BUILD_FLAG = "release"
TARGET_OS = "linux"
RUST_COMPILE_TARGET = "aarch64-unknown-linux-gnu"
@@ -202,6 +209,7 @@ FLUTTER_OUTPUT_DIR = "Debug"
FLUTTER_DESKTOP_FEATURES = "dart,rev-sqlite,openssl_vendored"
[env.production-android]
CARGO_PROFILE = "release"
BUILD_FLAG = "release"
TARGET_OS = "android"
CRATE_TYPE = "cdylib"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 30 KiB

@@ -9,7 +9,7 @@ import 'util/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('calendar database view', () {
group('calendar', () {
testWidgets('update calendar layout', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
@@ -116,6 +116,7 @@ void main() {
tester.assertRowDetailPageOpened();
// Duplicate the event
await tester.tapRowDetailPageRowActionButton();
await tester.tapRowDetailPageDuplicateRowButton();
await tester.dismissRowDetailPage();
@@ -125,6 +126,7 @@ void main() {
// Delete an event
await tester.openCalendarEvent(index: 1);
await tester.tapRowDetailPageRowActionButton();
await tester.tapRowDetailPageDeleteRowButton();
// Check that there is 1 event
@@ -155,6 +157,7 @@ void main() {
// Delete the event
await tester.openCalendarEvent(index: 0, date: sameDayNextWeek);
await tester.tapRowDetailPageRowActionButton();
await tester.tapRowDetailPageDeleteRowButton();
// Create a new event in today's calendar cell
@@ -218,7 +218,6 @@ void main() {
await tester.assertDateCellInGrid(
rowIndex: 0,
fieldType: fieldType,
content: DateFormat('MMM dd, y').format(today),
);
@@ -233,7 +232,6 @@ void main() {
await tester.assertDateCellInGrid(
rowIndex: 0,
fieldType: fieldType,
content: DateFormat('MMM dd, y HH:mm').format(now),
);
@@ -247,7 +245,6 @@ void main() {
await tester.assertDateCellInGrid(
rowIndex: 0,
fieldType: fieldType,
content: DateFormat('dd/MM/y HH:mm').format(now),
);
@@ -261,7 +258,6 @@ void main() {
await tester.assertDateCellInGrid(
rowIndex: 0,
fieldType: fieldType,
content: DateFormat('dd/MM/y hh:mm a').format(now),
);
@@ -273,7 +269,6 @@ void main() {
await tester.assertDateCellInGrid(
rowIndex: 0,
fieldType: fieldType,
content: '',
);
@@ -442,4 +437,116 @@ void main() {
await tester.pumpAndSettle();
});
});
testWidgets('edit checklist cell', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
const fieldType = FieldType.Checklist;
await tester.createField(fieldType, fieldType.name);
// assert that there is no progress bar in the grid
tester.assertChecklistCellInGrid(rowIndex: 0, percent: null);
// tap on the first checklist cell
await tester.tapChecklistCellInGrid(rowIndex: 0);
// assert that the checklist editor is shown
tester.assertChecklistEditorVisible(visible: true);
// assert that new task editor is shown
tester.assertNewCheckListTaskEditorVisible(visible: true);
// create a new task with enter
await tester.createNewChecklistTask(name: "task 0", enter: true);
// assert that the task is displayed
tester.assertChecklistTaskInEditor(
index: 0,
name: "task 0",
isChecked: false,
);
// update the task's name
await tester.renameChecklistTask(index: 0, name: "task 1");
// assert that the task's name is updated
tester.assertChecklistTaskInEditor(
index: 0,
name: "task 1",
isChecked: false,
);
// dismiss new task editor
await tester.dismissCellEditor();
tester.assertNewCheckListTaskEditorVisible(visible: false);
// dismiss checklist cell editor
await tester.dismissCellEditor();
// assert that progress bar is shown in grid at 0%
tester.assertChecklistCellInGrid(rowIndex: 0, percent: 0);
// start editing the first checklist cell again, click on new task button
await tester.tapChecklistCellInGrid(rowIndex: 0);
tester.assertNewCheckListTaskEditorVisible(visible: false);
await tester.tapChecklistNewTaskButton();
tester.assertNewCheckListTaskEditorVisible(visible: true);
// create another task with the create button
await tester.createNewChecklistTask(name: "task 2", button: true);
// assert that the task was inserted
tester.assertChecklistTaskInEditor(
index: 1,
name: "task 2",
isChecked: false,
);
// mark it as complete
await tester.checkChecklistTask(index: 1);
// assert that the task was checked in the editor
tester.assertChecklistTaskInEditor(
index: 1,
name: "task 2",
isChecked: true,
);
// dismiss checklist editor
await tester.dismissCellEditor();
await tester.dismissCellEditor();
// assert that progressbar is shown in grid at 50%
tester.assertChecklistCellInGrid(rowIndex: 0, percent: 0.5);
// re-open the cell editor
await tester.tapChecklistCellInGrid(rowIndex: 0);
// hover over first task and delete it
await tester.deleteChecklistTask(index: 0);
// dismiss cell editor
await tester.dismissCellEditor();
// assert that progressbar is shown in grid at 100%
tester.assertChecklistCellInGrid(rowIndex: 0, percent: 1);
// re-open the cell edior
await tester.tapChecklistCellInGrid(rowIndex: 0);
// delete the remaining task
await tester.deleteChecklistTask(index: 0);
// assert that the new task editor is shown
tester.assertNewCheckListTaskEditorVisible(visible: true);
// dismiss the cell editor
await tester.dismissCellEditor();
// check that the progress bar is not viisble
tester.assertChecklistCellInGrid(rowIndex: 0, percent: null);
});
}
@@ -135,7 +135,40 @@ void main() {
}
});
testWidgets('check document is exist in row detail page', (tester) async {
testWidgets('change order of fields and cells', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
// Create a new grid
await tester.createNewPageWithName(layout: ViewLayoutPB.Grid);
// Hover first row and then open the row page
await tester.openFirstRowDetailPage();
// Assert that the first field in the row details page is the select
// option tyoe
tester.assertFirstFieldInRowDetailByType(FieldType.SingleSelect);
// Reorder first field in list
final gesture = await tester.hoverOnFieldInRowDetail(index: 0);
await tester.pumpAndSettle();
await tester.reorderFieldInRowDetail(offset: 30);
// Orders changed, now the checkbox is first
tester.assertFirstFieldInRowDetailByType(FieldType.Checkbox);
await gesture.removePointer();
await tester.pumpAndSettle();
// Reorder second field in list
await tester.hoverOnFieldInRowDetail(index: 1);
await tester.pumpAndSettle();
await tester.reorderFieldInRowDetail(offset: -30);
// First field is now back to select option
tester.assertFirstFieldInRowDetailByType(FieldType.SingleSelect);
});
testWidgets('check document exists in row detail page', (tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
@@ -149,7 +182,7 @@ void main() {
await tester.assertDocumentExistInRowDetailPage();
});
testWidgets('update the content of the document and re-open it',
testWidgets('update the contents of the document and re-open it',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapGoButton();
@@ -239,6 +272,7 @@ void main() {
// Hover first row and then open the row page
await tester.openFirstRowDetailPage();
await tester.tapRowDetailPageRowActionButton();
await tester.tapRowDetailPageDeleteRowButton();
await tester.tapEscButton();
@@ -255,6 +289,7 @@ void main() {
// Hover first row and then open the row page
await tester.openFirstRowDetailPage();
await tester.tapRowDetailPageRowActionButton();
await tester.tapRowDetailPageDuplicateRowButton();
await tester.tapEscButton();
@@ -122,17 +122,17 @@ void main() {
}
// check the checklist cell
final List<double> checklistCells = [
0.6,
0.3,
final List<double?> checklistCells = [
0.67,
0.33,
1.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
0.0,
null,
null,
null,
null,
null,
null,
null,
];
for (final (index, percent) in checklistCells.indexed) {
await tester.assertChecklistCellInGrid(
@@ -157,7 +157,6 @@ void main() {
for (final (index, content) in dateCells.indexed) {
await tester.assertDateCellInGrid(
rowIndex: index,
fieldType: FieldType.DateTime,
content: content,
);
}
@@ -250,6 +250,32 @@ void main() {
);
},
);
testWidgets('paste the html content contains section', (tester) async {
const html =
'''<meta charset='utf-8'><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgb(0, 160, 113);"><strong style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important;">AppFlowy</strong></span></section><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgb(0, 160, 113);"><strong style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important;">Hello World</strong></span></section>''';
await tester.pasteContent(
html: html,
(editorState) {
expect(editorState.document.root.children.length, 2);
final node1 = editorState.getNodeAtPath([0])!;
final node2 = editorState.getNodeAtPath([1])!;
expect(node1.type, ParagraphBlockKeys.type);
expect(node2.type, ParagraphBlockKeys.type);
},
);
});
testWidgets('paste the html from google translation', (tester) async {
const html =
'''<meta charset='utf-8'><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgb(0, 160, 113);"><strong style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important;"><font style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; vertical-align: inherit;"><font style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; vertical-align: inherit;">new force</font></font></strong></span></section><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgb(0, 160, 113);"><strong style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important;"><font style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; vertical-align: inherit;"><font style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; vertical-align: inherit;">Assessment focus: potential motivations, empathy</font></font></strong></span></section><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><br style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important;"></section><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><font style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-Line truncated
await tester.pasteContent(
html: html,
(editorState) {
expect(editorState.document.root.children.length, 8);
},
);
});
}
extension on WidgetTester {
@@ -1,3 +1,4 @@
import 'package:appflowy/generated/locale_keys.g.dart';
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';
@@ -7,7 +8,7 @@ import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_more_action_button.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -22,14 +23,11 @@ void main() {
await tester.tapGoButton();
// create a new page
const name = 'Hello AppFlowy';
await tester.tapNewPageButton();
await tester.enterText(find.byType(TextFormField), name);
await tester.tapOKButton();
// expect to see a new document
tester.expectToSeePageName(
name,
LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
);
// and with one paragraph block
expect(find.byType(TextBlockComponentWidget), findsOneWidget);
@@ -1,4 +1,4 @@
import 'package:appflowy/user/presentation/sign_in_screen.dart';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/sync_setting_view.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -7,15 +7,15 @@ import 'base.dart';
extension AppFlowyAuthTest on WidgetTester {
Future<void> tapGoogleLoginInButton() async {
await tapButton(find.byType(GoogleSignUpButton));
await tapButton(find.byKey(const Key('signInWithGoogleButton')));
}
Future<void> tapSignInAsGuest() async {
await tapButton(find.byType(SignInAsGuestButton));
await tapButton(find.byType(SignInAnonymousButton));
}
void expectToSeeGoogleLoginButton() {
expect(find.byType(GoogleSignUpButton), findsOneWidget);
expect(find.byKey(const Key('signInWithGoogleButton')), findsOneWidget);
}
void assertSwitchValue(Finder finder, bool value) {
@@ -1,7 +1,9 @@
import 'dart:async';
import 'dart:io';
import 'package:appflowy/startup/entry_point.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/presentation/presentation.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@@ -9,8 +11,8 @@ 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';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
class FlowyTestContext {
FlowyTestContext({
@@ -39,8 +41,8 @@ extension AppFlowyTestBase on WidgetTester {
IntegrationMode.integrationTest,
);
await wait(3000);
await pumpAndSettle(const Duration(seconds: 2));
await waitUntilSignInPageShow();
return FlowyTestContext(
applicationDataDirectory: directory,
);
@@ -78,6 +80,27 @@ extension AppFlowyTestBase on WidgetTester {
return directory.path;
}
Future<void> waitUntilSignInPageShow() async {
final finder = find.byType(GoButton);
await pumpUntilFound(finder);
expect(finder, findsOneWidget);
}
Future<void> pumpUntilFound(
Finder finder, {
Duration timeout = const Duration(seconds: 10),
}) async {
bool timerDone = false;
final timer = Timer(timeout, () => timerDone = true);
while (timerDone != true) {
await pump();
if (any(finder)) {
timerDone = true;
}
}
timer.cancel();
}
Future<void> tapButton(
Finder finder, {
int? pointer,
@@ -1,7 +1,10 @@
import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/share/share_button.dart';
import 'package:appflowy/user/presentation/skip_log_in_screen.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/presentation/screens/screens.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar_new_page_button.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/draggable_view_item.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart';
@@ -279,7 +282,14 @@ extension CommonOperations on WidgetTester {
// create a new page
await tapAddViewButton(name: parentName ?? gettingStarted);
await tapButtonWithName(layout.menuName);
await tapOKButton();
final settingsOrFailure = await getIt<KeyValueStorage>().getWithFormat(
KVKeys.showRenameDialogWhenCreatingNewFile,
(value) => bool.parse(value),
);
final showRenameDialog = settingsOrFailure.fold((l) => false, (r) => r);
if (showRenameDialog) {
await tapOKButton();
}
await pumpAndSettle();
// hover on it and change it's name
@@ -1,5 +1,6 @@
import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/board/presentation/board_page.dart';
import 'package:appflowy/plugins/database_view/calendar/application/calendar_bloc.dart';
@@ -38,15 +39,18 @@ import 'package:appflowy/plugins/database_view/widgets/database_layout_ext.dart'
import 'package:appflowy/plugins/database_view/widgets/field/grid_property.dart';
import 'package:appflowy/plugins/database_view/widgets/row/accessory/cell_accessory.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/cells.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/checklist_cell/checklist_cell_editor.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/checklist_cell/checklist_progress_bar.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/date_cell/date_editor.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/select_option_cell/extension.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/select_option_cell/select_option_editor.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/select_option_cell/text_field.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/timestamp_cell/timestamp_cell.dart';
import 'package:appflowy/plugins/database_view/widgets/row/row_action.dart';
import 'package:appflowy/plugins/database_view/widgets/row/row_banner.dart';
import 'package:appflowy/plugins/database_view/widgets/row/row_detail.dart';
import 'package:appflowy/plugins/database_view/widgets/row/row_document.dart';
import 'package:appflowy/plugins/database_view/widgets/row/row_property.dart';
import 'package:appflowy/plugins/database_view/widgets/setting/database_setting.dart';
import 'package:appflowy/plugins/database_view/widgets/setting/setting_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/emoji_picker/emoji_menu_item.dart';
@@ -241,36 +245,43 @@ extension AppFlowyDatabaseTest on WidgetTester {
}
}
/// null percent means no progress bar should be found
Future<void> assertChecklistCellInGrid({
required int rowIndex,
required double percent,
required double? percent,
}) async {
final findCell = cellFinder(rowIndex, FieldType.Checklist);
final finder = find.descendant(
of: findCell,
matching: find.byWidgetPredicate(
(widget) {
if (widget is ChecklistProgressBar) {
return widget.percent == percent;
}
return false;
},
),
);
expect(finder, findsOneWidget);
if (percent == null) {
final finder = find.descendant(
of: findCell,
matching: find.byType(ChecklistProgressBar),
);
expect(finder, findsNothing);
} else {
final finder = find.descendant(
of: findCell,
matching: find.byWidgetPredicate(
(widget) {
if (widget is ChecklistProgressBar) {
return widget.percent == percent;
}
return false;
},
),
);
expect(finder, findsOneWidget);
}
}
Future<void> assertDateCellInGrid({
required int rowIndex,
required FieldType fieldType,
required String content,
}) async {
final findRow = find.byType(GridRow, skipOffstage: false);
final findCell = find.descendant(
of: findRow.at(rowIndex),
matching: find.byWidgetPredicate(
(widget) => widget is GridDateCell && widget.fieldType == fieldType,
),
matching: find.byType(GridDateCell),
skipOffstage: false,
);
@@ -451,6 +462,119 @@ extension AppFlowyDatabaseTest on WidgetTester {
expect(cell, matcher);
}
Future<void> tapChecklistCellInGrid({required int rowIndex}) async {
final findRow = find.byType(GridRow);
final findCell = finderForFieldType(FieldType.Checklist);
final cell = find.descendant(
of: findRow.at(rowIndex),
matching: findCell,
);
await tapButton(cell);
}
void assertChecklistEditorVisible({required bool visible}) {
final editor = find.byType(GridChecklistCellEditor);
if (visible) {
expect(editor, findsOneWidget);
} else {
expect(editor, findsNothing);
}
}
void assertNewCheckListTaskEditorVisible({required bool visible}) {
final editor = find.byType(NewTaskItem);
if (visible) {
expect(editor, findsOneWidget);
} else {
expect(editor, findsNothing);
}
}
Future<void> createNewChecklistTask({
required String name,
enter = false,
button = false,
}) async {
assert(!(enter && button));
final textField = find.descendant(
of: find.byType(NewTaskItem),
matching: find.byType(TextField),
);
await enterText(textField, name);
await pumpAndSettle(const Duration(milliseconds: 300));
if (enter) {
await testTextInput.receiveAction(TextInputAction.done);
await pumpAndSettle(const Duration(milliseconds: 300));
} else {
await tapButton(
find.descendant(
of: find.byType(NewTaskItem),
matching: find.byType(FlowyTextButton),
),
);
}
}
void assertChecklistTaskInEditor({
required int index,
required String name,
required bool isChecked,
}) {
final task = find.byType(ChecklistItem).at(index);
final widget = this.widget<ChecklistItem>(task);
assert(
widget.option.data.name == name && widget.option.isSelected == isChecked,
);
}
Future<void> renameChecklistTask({
required int index,
required String name,
}) async {
final textField = find
.descendant(
of: find.byType(ChecklistItem),
matching: find.byType(TextField),
)
.at(index);
await enterText(textField, name);
await testTextInput.receiveAction(TextInputAction.done);
await pumpAndSettle(const Duration(milliseconds: 300));
}
Future<void> tapChecklistNewTaskButton() async {
await tapButton(find.byType(ChecklistNewTaskButton));
}
Future<void> checkChecklistTask({required int index}) async {
final button = find.descendant(
of: find.byType(ChecklistItem).at(index),
matching: find.byWidgetPredicate(
(widget) => widget is FlowySvg && widget.svg == FlowySvgs.uncheck_s,
),
);
await tapButton(button);
}
Future<void> deleteChecklistTask({required int index}) async {
final task = find.byType(ChecklistItem).at(index);
await startGesture(getCenter(task), kind: PointerDeviceKind.mouse);
await pumpAndSettle();
final button = find.byWidgetPredicate(
(widget) => widget is FlowySvg && widget.svg == FlowySvgs.delete_s,
);
await tapButton(button);
}
Future<void> openFirstRowDetailPage() async {
await hoverOnFirstRowOfGrid();
@@ -487,7 +611,7 @@ extension AppFlowyDatabaseTest on WidgetTester {
expect(banner, findsOneWidget);
await startGesture(
getTopLeft(banner),
getCenter(banner),
kind: PointerDeviceKind.mouse,
);
@@ -526,6 +650,31 @@ extension AppFlowyDatabaseTest on WidgetTester {
await tapButton(deleteButton);
}
Future<TestGesture> hoverOnFieldInRowDetail({required int index}) async {
final fieldButtons = find.byType(FieldCellButton);
final button = find
.descendant(of: find.byType(RowDetailPage), matching: fieldButtons)
.at(index);
return startGesture(
getCenter(button),
kind: PointerDeviceKind.mouse,
);
}
Future<void> reorderFieldInRowDetail({required double offset}) async {
final thumb = find
.byWidgetPredicate(
(widget) => widget is ReorderableDragStartListener && widget.enabled,
)
.first;
await drag(
thumb,
Offset(0, offset),
kind: PointerDeviceKind.mouse,
);
await pumpAndSettle();
}
Future<void> scrollGridByOffset(Offset offset) async {
await drag(find.byType(GridPage), offset);
await pumpAndSettle();
@@ -603,6 +752,10 @@ extension AppFlowyDatabaseTest on WidgetTester {
await tapButton(button);
}
Future<void> tapRowDetailPageRowActionButton() async {
await tapButton(find.byType(RowActionButton));
}
Future<void> tapRowDetailPageCreatePropertyButton() async {
await tapButton(find.byType(CreateRowFieldButton));
}
@@ -672,6 +825,18 @@ extension AppFlowyDatabaseTest on WidgetTester {
expect(field, findsOneWidget);
}
void assertFirstFieldInRowDetailByType(FieldType fieldType) {
final firstField = find
.descendant(
of: find.byType(RowDetailPage),
matching: find.byType(FieldCellButton),
)
.first;
final widget = this.widget<FieldCellButton>(firstField);
expect(widget.field.fieldType, fieldType);
}
Future<void> findFieldWithName(String name) async {
final field = find.byWidgetPredicate(
(widget) => widget is FieldCellButton && widget.field.name == name,
@@ -1287,7 +1452,7 @@ Finder finderForFieldType(FieldType fieldType) {
return find.byType(GridDateCell, skipOffstage: false);
case FieldType.LastEditedTime:
case FieldType.CreatedTime:
return find.byType(GridDateCell, skipOffstage: false);
return find.byType(GridTimestampCell, skipOffstage: false);
case FieldType.SingleSelect:
return find.byType(GridSingleSelectCell, skipOffstage: false);
case FieldType.MultiSelect:
@@ -50,6 +50,8 @@ PODS:
- Toast
- integration_test (0.0.1):
- Flutter
- irondash_engine_context (0.0.1):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
@@ -66,6 +68,8 @@ PODS:
- FlutterMacOS
- sign_in_with_apple (0.0.1):
- Flutter
- super_native_extensions (0.0.1):
- Flutter
- SwiftyGif (5.4.3)
- Toast (4.0.0)
- url_launcher_ios (0.0.1):
@@ -83,11 +87,13 @@ DEPENDENCIES:
- Flutter (from `Flutter`)
- fluttertoast (from `.symlinks/plugins/fluttertoast/ios`)
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- irondash_engine_context (from `.symlinks/plugins/irondash_engine_context/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- rich_clipboard_ios (from `.symlinks/plugins/rich_clipboard_ios/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sign_in_with_apple (from `.symlinks/plugins/sign_in_with_apple/ios`)
- super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`)
@@ -119,6 +125,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/fluttertoast/ios"
integration_test:
:path: ".symlinks/plugins/integration_test/ios"
irondash_engine_context:
:path: ".symlinks/plugins/irondash_engine_context/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
@@ -129,6 +137,8 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sign_in_with_apple:
:path: ".symlinks/plugins/sign_in_with_apple/ios"
super_native_extensions:
:path: ".symlinks/plugins/super_native_extensions/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
webview_flutter_wkwebview:
@@ -146,6 +156,7 @@ SPEC CHECKSUMS:
Flutter: f04841e97a9d0b0a8025694d0796dd46242b2854
fluttertoast: fafc4fa4d01a6a9e4f772ecd190ffa525e9e2d9c
integration_test: 13825b8a9334a850581300559b8839134b124670
irondash_engine_context: 3458bf979b90d616ffb8ae03a150bafe2e860cc9
package_info_plus: fd030dabf36271f146f1f3beacd48f564b0f17f7
path_provider_foundation: eaf5b3e458fc0e5fbb9940fb09980e853fe058b8
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
@@ -153,6 +164,7 @@ SPEC CHECKSUMS:
SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84
shared_preferences_foundation: e2dae3258e06f44cc55f49d42024fd8dd03c590c
sign_in_with_apple: f3bf75217ea4c2c8b91823f225d70230119b8440
super_native_extensions: 49e897b6039bb784226e7354e502a3c68e1659b5
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 08a3dfac5fb39e8759aeb0abbd5d9480f30fc8b4
@@ -16,6 +16,17 @@
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string></string>
<key>CFBundleURLSchemes</key>
<array>
<string>appflowy-flutter</string>
</array>
</dict>
</array>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleVersion</key>
@@ -7,6 +7,10 @@ import 'package:shared_preferences/shared_preferences.dart';
abstract class KeyValueStorage {
Future<void> set(String key, String value);
Future<Either<FlowyError, String>> get(String key);
Future<Either<FlowyError, T>> getWithFormat<T>(
String key,
T Function(String value) formatter,
);
Future<void> remove(String key);
Future<void> clear();
}
@@ -26,6 +30,18 @@ class DartKeyValue implements KeyValueStorage {
return Left(FlowyError());
}
@override
Future<Either<FlowyError, T>> getWithFormat<T>(
String key,
T Function(String value) formatter,
) async {
final value = await get(key);
return value.fold(
(l) => left(l),
(r) => right(formatter(r)),
);
}
@override
Future<void> remove(String key) async {
await _initSharedPreferencesIfNeeded();
@@ -71,6 +87,18 @@ class RustKeyValue implements KeyValueStorage {
return response.swap().map((r) => r.value);
}
@override
Future<Either<FlowyError, T>> getWithFormat<T>(
String key,
T Function(String value) formatter,
) async {
final value = await get(key);
return value.fold(
(l) => left(l),
(r) => right(formatter(r)),
);
}
@override
Future<void> remove(String key) async {
await ConfigEventRemoveKeyValue(
@@ -29,6 +29,8 @@ class KVKeys {
'kDocumentAppearanceFontSize';
static const String kDocumentAppearanceFontFamily =
'kDocumentAppearanceFontFamily';
static const String kDocumentAppearanceDefaultTextDirection =
'kDocumentAppearanceDefaultTextDirection';
/// The key for saving the expanded views
///
@@ -41,4 +43,10 @@ class KVKeys {
/// The value is a json string with the following format:
/// {'SidebarFolderCategoryType.value': true}
static const String expandedFolders = 'expandedFolders';
/// The key for saving if showing the rename dialog when creating a new file
///
/// The value is a boolean string.
static const String showRenameDialogWhenCreatingNewFile =
'showRenameDialogWhenCreatingNewFile';
}
@@ -0,0 +1,47 @@
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/workspace.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:flutter/material.dart';
class MobileHomeScreen extends StatelessWidget {
const MobileHomeScreen({
super.key,
required this.userProfile,
required this.workspaceSetting,
});
static const routeName = "/MobileHomeScreen";
final UserProfilePB userProfile;
final WorkspaceSettingPB workspaceSetting;
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("MobileHomeScreen"),
),
// TODO(yijing): implement home page later
body: Center(
child: Column(
children: [
const Text(
'User',
),
Text(
userProfile.toString(),
),
Text('Workspace name: ${workspaceSetting.workspace.name}'),
ElevatedButton(
onPressed: () async {
await getIt<AuthService>().signOut();
runAppFlowy();
},
child: const Text('Logout'),
)
],
),
),
);
}
}
@@ -2,6 +2,7 @@ import 'package:appflowy_backend/protobuf/flowy-database2/checklist_entities.pb.
import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/timestamp_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/url_entities.pb.dart';
import 'cell_controller.dart';
@@ -14,6 +15,7 @@ typedef SelectOptionCellController
= CellController<SelectOptionCellDataPB, String>;
typedef ChecklistCellController = CellController<ChecklistCellDataPB, String>;
typedef DateCellController = CellController<DateCellDataPB, String>;
typedef TimestampCellController = CellController<TimestampCellDataPB, String>;
typedef URLCellController = CellController<URLCellDataPB, String>;
class CellControllerBuilder {
@@ -41,14 +43,11 @@ class CellControllerBuilder {
TextCellDataPersistence(cellContext: _cellContext),
);
case FieldType.DateTime:
case FieldType.LastEditedTime:
case FieldType.CreatedTime:
final cellDataLoader = CellDataLoader(
cellContext: _cellContext,
parser: DateCellDataParser(),
reloadOnFieldChanged: true,
);
return DateCellController(
cellContext: _cellContext,
cellCache: _cellCache,
@@ -56,6 +55,20 @@ class CellControllerBuilder {
cellDataPersistence:
TextCellDataPersistence(cellContext: _cellContext),
);
case FieldType.LastEditedTime:
case FieldType.CreatedTime:
final cellDataLoader = CellDataLoader(
cellContext: _cellContext,
parser: TimestampCellDataParser(),
reloadOnFieldChanged: true,
);
return TimestampCellController(
cellContext: _cellContext,
cellCache: _cellCache,
cellDataLoader: cellDataLoader,
cellDataPersistence:
TextCellDataPersistence(cellContext: _cellContext),
);
case FieldType.Number:
final cellDataLoader = CellDataLoader(
cellContext: _cellContext,
@@ -73,6 +73,16 @@ class DateCellDataParser implements CellDataParser<DateCellDataPB> {
}
}
class TimestampCellDataParser implements CellDataParser<TimestampCellDataPB> {
@override
TimestampCellDataPB? parserData(List<int> data) {
if (data.isEmpty) {
return null;
}
return TimestampCellDataPB.fromBuffer(data);
}
}
class SelectOptionCellDataParser
implements CellDataParser<SelectOptionCellDataPB> {
@override
@@ -4,6 +4,7 @@ import 'package:appflowy_backend/protobuf/flowy-database2/checklist_entities.pb.
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/timestamp_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/url_entities.pb.dart';
import 'package:dartz/dartz.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
@@ -4,6 +4,7 @@ import 'package:appflowy_backend/protobuf/flowy-database2/checklist_entities.pb.
import 'package:appflowy_backend/protobuf/flowy-database2/select_option.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:dartz/dartz.dart';
import 'package:protobuf/protobuf.dart';
class ChecklistCellBackendService {
final String viewId;
@@ -52,14 +53,19 @@ class ChecklistCellBackendService {
return DatabaseEventUpdateChecklistCell(payload).send();
}
Future<Either<Unit, FlowyError>> update({
Future<Either<Unit, FlowyError>> updateName({
required SelectOptionPB option,
required name,
}) {
option.freeze();
final newOption = option.rebuild((option) {
option.name = name;
});
final payload = ChecklistCellDataChangesetPB.create()
..viewId = viewId
..fieldId = fieldId
..rowId = rowId
..updateOptions.add(option);
..updateOptions.add(newOption);
return DatabaseEventUpdateChecklistCell(payload).send();
}
@@ -395,13 +395,7 @@ class RowDataBuilder {
}
void insertDate(FieldInfo fieldInfo, DateTime date) {
assert(
[
FieldType.DateTime,
FieldType.LastEditedTime,
FieldType.CreatedTime,
].contains(fieldInfo.fieldType),
);
assert(FieldType.DateTime == fieldInfo.fieldType);
final timestamp = date.millisecondsSinceEpoch ~/ 1000;
_cellDataByFieldId[fieldInfo.field.id] = timestamp.toString();
}
@@ -27,13 +27,6 @@ class DateTypeOptionBloc
),
);
},
includeTime: (_IncludeTime value) {
emit(
state.copyWith(
typeOption: _updateTypeOption(includeTime: value.includeTime),
),
);
},
);
},
);
@@ -42,7 +35,6 @@ class DateTypeOptionBloc
DateTypeOptionPB _updateTypeOption({
DateFormatPB? dateFormat,
TimeFormatPB? timeFormat,
bool? includeTime,
}) {
state.typeOption.freeze();
return state.typeOption.rebuild((typeOption) {
@@ -63,8 +55,6 @@ class DateTypeOptionEvent with _$DateTypeOptionEvent {
_DidSelectDateFormat;
const factory DateTypeOptionEvent.didSelectTimeFormat(TimeFormatPB format) =
_DidSelectTimeFormat;
const factory DateTypeOptionEvent.includeTime(bool includeTime) =
_IncludeTime;
}
@freezed
@@ -0,0 +1,76 @@
import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/timestamp_entities.pb.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:protobuf/protobuf.dart';
import 'type_option_context.dart';
part 'timestamp_bloc.freezed.dart';
class TimestampTypeOptionBloc
extends Bloc<TimestampTypeOptionEvent, TimestampTypeOptionState> {
TimestampTypeOptionBloc({
required TimestampTypeOptionContext typeOptionContext,
}) : super(TimestampTypeOptionState.initial(typeOptionContext.typeOption)) {
on<TimestampTypeOptionEvent>(
(event, emit) async {
event.map(
didSelectDateFormat: (_DidSelectDateFormat value) {
_updateTypeOption(dateFormat: value.format, emit: emit);
},
didSelectTimeFormat: (_DidSelectTimeFormat value) {
_updateTypeOption(timeFormat: value.format, emit: emit);
},
includeTime: (_IncludeTime value) {
_updateTypeOption(includeTime: value.includeTime, emit: emit);
},
);
},
);
}
void _updateTypeOption({
DateFormatPB? dateFormat,
TimeFormatPB? timeFormat,
bool? includeTime,
required Emitter<TimestampTypeOptionState> emit,
}) {
state.typeOption.freeze();
final newTypeOption = state.typeOption.rebuild((typeOption) {
if (dateFormat != null) {
typeOption.dateFormat = dateFormat;
}
if (timeFormat != null) {
typeOption.timeFormat = timeFormat;
}
if (includeTime != null) {
typeOption.includeTime = includeTime;
}
});
emit(state.copyWith(typeOption: newTypeOption));
}
}
@freezed
class TimestampTypeOptionEvent with _$TimestampTypeOptionEvent {
const factory TimestampTypeOptionEvent.didSelectDateFormat(
DateFormatPB format,
) = _DidSelectDateFormat;
const factory TimestampTypeOptionEvent.didSelectTimeFormat(
TimeFormatPB format,
) = _DidSelectTimeFormat;
const factory TimestampTypeOptionEvent.includeTime(bool includeTime) =
_IncludeTime;
}
@freezed
class TimestampTypeOptionState with _$TimestampTypeOptionState {
const factory TimestampTypeOptionState({
required TimestampTypeOptionPB typeOption,
}) = _TimestampTypeOptionState;
factory TimestampTypeOptionState.initial(TimestampTypeOptionPB typeOption) =>
TimestampTypeOptionState(typeOption: typeOption);
}
@@ -4,6 +4,7 @@ import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart'
import 'package:appflowy_backend/protobuf/flowy-database2/number_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/text_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/timestamp_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/url_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
@@ -58,7 +59,7 @@ class URLTypeOptionWidgetDataParser extends TypeOptionParser<URLTypeOptionPB> {
}
}
// Date
// DateTime
typedef DateTypeOptionContext = TypeOptionContext<DateTypeOptionPB>;
class DateTypeOptionDataParser extends TypeOptionParser<DateTypeOptionPB> {
@@ -68,6 +69,17 @@ class DateTypeOptionDataParser extends TypeOptionParser<DateTypeOptionPB> {
}
}
// LastModified and CreatedAt
typedef TimestampTypeOptionContext = TypeOptionContext<TimestampTypeOptionPB>;
class TimestampTypeOptionDataParser
extends TypeOptionParser<TimestampTypeOptionPB> {
@override
TimestampTypeOptionPB fromBuffer(List<int> buffer) {
return TimestampTypeOptionPB.fromBuffer(buffer);
}
}
// SingleSelect
typedef SingleSelectTypeOptionContext
= TypeOptionContext<SingleSelectTypeOptionPB>;
@@ -32,11 +32,7 @@ class RowBannerBloc extends Bloc<RowBannerEvent, RowBannerState> {
await _listenRowMeteChanged();
},
didReceiveRowMeta: (RowMetaPB rowMeta) {
emit(
state.copyWith(
rowMeta: rowMeta,
),
);
emit(state.copyWith(rowMeta: rowMeta));
},
setCover: (String coverURL) {
_updateMeta(coverURL: coverURL);
@@ -167,6 +167,29 @@ class EventCard extends StatelessWidget {
);
});
renderHook.addTimestampCellHook((cellData, cardData, _) {
return Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Flexible(
flex: 3,
child: FlowyText.regular(
cellData.dateTime,
fontSize: 10,
color: Theme.of(context).hintColor,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
);
});
renderHook.addSelectOptionHook((selectedOptions, cardData, _) {
if (selectedOptions.isEmpty) {
return const SizedBox.shrink();
@@ -1,23 +1,20 @@
import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart';
import 'package:appflowy/plugins/database_view/application/field/field_service.dart';
import 'package:appflowy/plugins/database_view/application/field_settings/field_settings_service.dart';
import 'package:appflowy/plugins/database_view/application/row/row_service.dart';
import 'package:appflowy/plugins/database_view/application/row/row_controller.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_settings_entities.pb.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'dart:async';
import '../../../application/cell/cell_service.dart';
import '../../../application/field/field_service.dart';
import '../../../application/row/row_controller.dart';
part 'row_detail_bloc.freezed.dart';
class RowDetailBloc extends Bloc<RowDetailEvent, RowDetailState> {
final RowBackendService rowService;
final RowController rowController;
RowDetailBloc({
required this.rowController,
}) : rowService = RowBackendService(viewId: rowController.viewId),
super(RowDetailState.initial()) {
}) : super(RowDetailState.initial()) {
on<RowDetailEvent>(
(event, emit) async {
await event.when(
@@ -58,14 +55,8 @@ class RowDetailBloc extends Bloc<RowDetailEvent, RowDetailState> {
(err) => Log.error(err),
);
},
deleteRow: (rowId) async {
await rowService.deleteRow(rowId);
},
duplicateRow: (String rowId, String? groupId) async {
await rowService.duplicateRow(
rowId: rowId,
groupId: groupId,
);
reorderField: (fieldId, fromIndex, toIndex) async {
await _reorderField(fieldId, fromIndex, toIndex, emit);
},
);
},
@@ -94,6 +85,25 @@ class RowDetailBloc extends Bloc<RowDetailEvent, RowDetailState> {
fieldId: fieldId,
);
}
Future<void> _reorderField(
String fieldId,
int fromIndex,
int toIndex,
Emitter<RowDetailState> emit,
) async {
final cells = List<DatabaseCellContext>.from(state.cells);
cells.insert(toIndex, cells.removeAt(fromIndex));
emit(state.copyWith(cells: cells));
final fieldService =
FieldBackendService(viewId: rowController.viewId, fieldId: fieldId);
final result = await fieldService.moveField(
fromIndex,
toIndex,
);
result.fold((l) {}, (err) => Log.error(err));
}
}
@freezed
@@ -102,9 +112,11 @@ class RowDetailEvent with _$RowDetailEvent {
const factory RowDetailEvent.deleteField(String fieldId) = _DeleteField;
const factory RowDetailEvent.showField(String fieldId) = _ShowField;
const factory RowDetailEvent.hideField(String fieldId) = _HideField;
const factory RowDetailEvent.deleteRow(String rowId) = _DeleteRow;
const factory RowDetailEvent.duplicateRow(String rowId, String? groupId) =
_DuplicateRow;
const factory RowDetailEvent.reorderField(
String fieldId,
int fromIndex,
int toIndex,
) = _ReorderField;
const factory RowDetailEvent.didReceiveCellDatas(
List<DatabaseCellContext> gridCells,
) = _DidReceiveCellDatas;
@@ -1,5 +1,3 @@
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';
@@ -257,59 +255,38 @@ class _GridRows extends StatelessWidget {
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'));
},
);
}
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(
@@ -114,7 +114,7 @@ class _GridCreateFilterListState extends State<GridCreateFilterList> {
class _FilterTextFieldDelegate extends SliverPersistentHeaderDelegate {
_FilterTextFieldDelegate();
double fixHeight = 46;
double fixHeight = 36;
@override
Widget build(
@@ -123,7 +123,7 @@ class _FilterTextFieldDelegate extends SliverPersistentHeaderDelegate {
bool overlapsContent,
) {
return Container(
padding: const EdgeInsets.only(top: 4),
padding: const EdgeInsets.only(bottom: 4),
height: fixHeight,
child: FlowyTextField(
hintText: LocaleKeys.grid_settings_filterBy.tr(),
@@ -105,7 +105,6 @@ class _AddFilterButtonState extends State<AddFilterButton> {
return AppFlowyPopover(
controller: popoverController,
constraints: BoxConstraints.loose(const Size(200, 300)),
margin: const EdgeInsets.all(6),
triggerActions: PopoverTriggerFlags.none,
child: child,
popupBuilder: (BuildContext context) {
@@ -154,29 +154,33 @@ class FieldCellButton extends StatelessWidget {
final FieldPB field;
final int? maxLines;
final BorderRadius? radius;
final EdgeInsets? margin;
const FieldCellButton({
required this.field,
required this.onTap,
this.maxLines = 1,
this.radius = BorderRadius.zero,
this.margin,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return FlowyButton(
hoverColor: AFThemeExtension.of(context).greyHover,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
onTap: onTap,
leftIcon: FlowySvg(
field.fieldType.icon(),
color: Theme.of(context).iconTheme.color,
),
radius: radius,
text: FlowyText.medium(
field.name,
maxLines: maxLines,
overflow: TextOverflow.ellipsis,
color: AFThemeExtension.of(context).textColor,
),
margin: GridSize.cellContentInsets,
margin: margin ?? GridSize.cellContentInsets,
);
}
}
@@ -8,6 +8,7 @@ import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart'
import 'package:appflowy_backend/protobuf/flowy-database2/number_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/text_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/timestamp_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/url_entities.pb.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:protobuf/protobuf.dart' hide FieldInfo;
@@ -20,6 +21,7 @@ import 'multi_select.dart';
import 'number.dart';
import 'rich_text.dart';
import 'single_select.dart';
import 'timestamp.dart';
import 'url.dart';
typedef TypeOptionData = Uint8List;
@@ -73,8 +75,6 @@ TypeOptionWidgetBuilder makeTypeOptionWidgetBuilder({
),
);
case FieldType.DateTime:
case FieldType.LastEditedTime:
case FieldType.CreatedTime:
return DateTypeOptionWidgetBuilder(
makeTypeOptionContextWithDataController<DateTypeOptionPB>(
viewId: viewId,
@@ -83,6 +83,16 @@ TypeOptionWidgetBuilder makeTypeOptionWidgetBuilder({
),
popoverMutex,
);
case FieldType.LastEditedTime:
case FieldType.CreatedTime:
return TimestampTypeOptionWidgetBuilder(
makeTypeOptionContextWithDataController<TimestampTypeOptionPB>(
viewId: viewId,
fieldType: fieldType,
dataController: dataController,
),
popoverMutex,
);
case FieldType.SingleSelect:
return SingleSelectTypeOptionWidgetBuilder(
makeTypeOptionContextWithDataController<SingleSelectTypeOptionPB>(
@@ -203,12 +213,16 @@ TypeOptionContext<T>
dataParser: CheckboxTypeOptionWidgetDataParser(),
) as TypeOptionContext<T>;
case FieldType.DateTime:
case FieldType.LastEditedTime:
case FieldType.CreatedTime:
return DateTypeOptionContext(
dataController: dataController,
dataParser: DateTypeOptionDataParser(),
) as TypeOptionContext<T>;
case FieldType.LastEditedTime:
case FieldType.CreatedTime:
return TimestampTypeOptionContext(
dataController: dataController,
dataParser: TimestampTypeOptionDataParser(),
) as TypeOptionContext<T>;
case FieldType.SingleSelect:
return SingleSelectTypeOptionContext(
dataController: dataController,
@@ -0,0 +1,179 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/application/field/type_option/timestamp_bloc.dart';
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/common/type_option_separator.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_type_option_editor.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle_style.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'builder.dart';
import 'date.dart';
class TimestampTypeOptionWidgetBuilder extends TypeOptionWidgetBuilder {
final TimestampTypeOptionWidget _widget;
TimestampTypeOptionWidgetBuilder(
TimestampTypeOptionContext typeOptionContext,
PopoverMutex popoverMutex,
) : _widget = TimestampTypeOptionWidget(
typeOptionContext: typeOptionContext,
popoverMutex: popoverMutex,
);
@override
Widget? build(BuildContext context) {
return _widget;
}
}
class TimestampTypeOptionWidget extends TypeOptionWidget {
final TimestampTypeOptionContext typeOptionContext;
final PopoverMutex popoverMutex;
const TimestampTypeOptionWidget({
required this.typeOptionContext,
required this.popoverMutex,
Key? key,
}) : super(key: key);
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) =>
TimestampTypeOptionBloc(typeOptionContext: typeOptionContext),
child: BlocConsumer<TimestampTypeOptionBloc, TimestampTypeOptionState>(
listener: (context, state) =>
typeOptionContext.typeOption = state.typeOption,
builder: (context, state) {
final List<Widget> children = [
const TypeOptionSeparator(),
_renderDateFormatButton(context, state.typeOption.dateFormat),
_renderTimeFormatButton(context, state.typeOption.timeFormat),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: IncludeTimeButton(
onChanged: (value) => context
.read<TimestampTypeOptionBloc>()
.add(TimestampTypeOptionEvent.includeTime(!value)),
value: state.typeOption.includeTime,
),
),
];
return ListView.separated(
shrinkWrap: true,
separatorBuilder: (context, index) {
if (index == 0) {
return const SizedBox();
} else {
return VSpace(GridSize.typeOptionSeparatorHeight);
}
},
itemCount: children.length,
itemBuilder: (BuildContext context, int index) => children[index],
);
},
),
);
}
Widget _renderDateFormatButton(
BuildContext context,
DateFormatPB dataFormat,
) {
return AppFlowyPopover(
mutex: popoverMutex,
asBarrier: true,
triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click,
offset: const Offset(8, 0),
constraints: BoxConstraints.loose(const Size(460, 440)),
popupBuilder: (popoverContext) {
return DateFormatList(
selectedFormat: dataFormat,
onSelected: (format) {
context
.read<TimestampTypeOptionBloc>()
.add(TimestampTypeOptionEvent.didSelectDateFormat(format));
PopoverContainer.of(popoverContext).close();
},
);
},
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 12.0),
child: DateFormatButton(),
),
);
}
Widget _renderTimeFormatButton(
BuildContext context,
TimeFormatPB timeFormat,
) {
return AppFlowyPopover(
mutex: popoverMutex,
asBarrier: true,
triggerActions: PopoverTriggerFlags.hover | PopoverTriggerFlags.click,
offset: const Offset(8, 0),
constraints: BoxConstraints.loose(const Size(460, 440)),
popupBuilder: (BuildContext popoverContext) {
return TimeFormatList(
selectedFormat: timeFormat,
onSelected: (format) {
context
.read<TimestampTypeOptionBloc>()
.add(TimestampTypeOptionEvent.didSelectTimeFormat(format));
PopoverContainer.of(popoverContext).close();
},
);
},
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: TimeFormatButton(timeFormat: timeFormat),
),
);
}
}
class IncludeTimeButton extends StatelessWidget {
final bool value;
final Function(bool value) onChanged;
const IncludeTimeButton({
super.key,
required this.onChanged,
required this.value,
});
@override
Widget build(BuildContext context) {
return SizedBox(
height: GridSize.popoverItemHeight,
child: Padding(
padding: GridSize.typeOptionContentInsets,
child: Row(
children: [
FlowySvg(
FlowySvgs.clock_alarm_s,
color: Theme.of(context).iconTheme.color,
),
const HSpace(6),
FlowyText.medium(LocaleKeys.grid_field_includeTime.tr()),
const Spacer(),
Toggle(
value: value,
onChanged: onChanged,
style: ToggleStyle.big,
padding: EdgeInsets.zero,
),
],
),
),
);
}
}
@@ -40,8 +40,7 @@ class RowActions extends StatelessWidget {
.map((action) => _ActionCell(action: action))
.toList();
//
final list = ListView.separated(
return ListView.separated(
shrinkWrap: true,
controller: ScrollController(),
itemCount: cells.length,
@@ -53,7 +52,6 @@ class RowActions extends StatelessWidget {
return cells[index];
},
);
return list;
},
),
);
@@ -70,6 +68,7 @@ class _ActionCell extends StatelessWidget {
height: GridSize.popoverItemHeight,
child: FlowyButton(
hoverColor: AFThemeExtension.of(context).lightGreyHover,
useIntrinsicWidth: true,
text: FlowyText.medium(
action.title(),
color: action.enable()
@@ -161,16 +161,12 @@ class _RowLeadingState extends State<_RowLeading> {
index: widget.index!,
child: RowMenuButton(
isDragEnabled: isDraggable,
openMenu: () {
popoverController.show();
},
openMenu: popoverController.show,
),
),
] else ...[
RowMenuButton(
openMenu: () {
popoverController.show();
},
openMenu: popoverController.show,
),
],
],
@@ -114,7 +114,7 @@ class _GridCreateSortListState extends State<GridCreateSortList> {
class _SortTextFieldDelegate extends SliverPersistentHeaderDelegate {
_SortTextFieldDelegate();
double fixHeight = 46;
double fixHeight = 36;
@override
Widget build(
@@ -123,7 +123,7 @@ class _SortTextFieldDelegate extends SliverPersistentHeaderDelegate {
bool overlapsContent,
) {
return Container(
padding: const EdgeInsets.only(top: 4),
padding: const EdgeInsets.only(bottom: 4),
height: fixHeight,
child: FlowyTextField(
hintText: LocaleKeys.grid_settings_sortBy.tr(),
@@ -210,6 +210,7 @@ class DatabaseDeleteSortButton extends StatelessWidget {
context
.read<SortEditorBloc>()
.add(const SortEditorEvent.deleteAllSorts());
PopoverContainer.of(context).close();
},
leftIcon: const FlowySvg(FlowySvgs.delete_s),
),
@@ -80,7 +80,11 @@ class DateCardCellState with _$DateCardCellState {
String _dateStrFromCellData(DateCellDataPB? cellData) {
String dateStr = "";
if (cellData != null) {
dateStr = "${cellData.date} ${cellData.time}";
if (cellData.includeTime) {
dateStr = '${cellData.date} ${cellData.time}';
} else {
dateStr = cellData.date;
}
}
return dateStr;
}
@@ -0,0 +1,80 @@
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database_view/application/field/field_info.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/timestamp_entities.pb.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'dart:async';
part 'timestamp_card_cell_bloc.freezed.dart';
class TimestampCardCellBloc
extends Bloc<TimestampCardCellEvent, TimestampCardCellState> {
final TimestampCellController cellController;
void Function()? _onCellChangedFn;
TimestampCardCellBloc({required this.cellController})
: super(TimestampCardCellState.initial(cellController)) {
on<TimestampCardCellEvent>(
(event, emit) async {
event.when(
initial: () => _startListening(),
didReceiveCellUpdate: (TimestampCellDataPB? cellData) {
emit(
state.copyWith(
data: cellData,
dateStr: cellData?.dateTime ?? "",
),
);
},
);
},
);
}
@override
Future<void> close() async {
if (_onCellChangedFn != null) {
cellController.removeListener(_onCellChangedFn!);
_onCellChangedFn = null;
}
await cellController.dispose();
return super.close();
}
void _startListening() {
_onCellChangedFn = cellController.startListening(
onCellChanged: ((data) {
if (!isClosed) {
add(TimestampCardCellEvent.didReceiveCellUpdate(data));
}
}),
);
}
}
@freezed
class TimestampCardCellEvent with _$TimestampCardCellEvent {
const factory TimestampCardCellEvent.initial() = _InitialCell;
const factory TimestampCardCellEvent.didReceiveCellUpdate(
TimestampCellDataPB? data,
) = _DidReceiveCellUpdate;
}
@freezed
class TimestampCardCellState with _$TimestampCardCellState {
const factory TimestampCardCellState({
required TimestampCellDataPB? data,
required String dateStr,
required FieldInfo fieldInfo,
}) = _TimestampCardCellState;
factory TimestampCardCellState.initial(TimestampCellController context) {
final cellData = context.getCellData();
return TimestampCardCellState(
fieldInfo: context.fieldInfo,
data: cellData,
dateStr: cellData?.dateTime ?? "",
);
}
}
@@ -10,6 +10,7 @@ import 'cells/date_card_cell.dart';
import 'cells/number_card_cell.dart';
import 'cells/select_option_card_cell.dart';
import 'cells/text_card_cell.dart';
import 'cells/timestamp_card_cell.dart';
import 'cells/url_card_cell.dart';
// T represents as the Generic card data
@@ -39,13 +40,23 @@ class CardCellBuilder<CustomCardData> {
key: key,
);
case FieldType.DateTime:
case FieldType.LastEditedTime:
case FieldType.CreatedTime:
return DateCardCell<CustomCardData>(
renderHook: renderHook?.renderHook[FieldType.DateTime],
cellControllerBuilder: cellControllerBuilder,
key: key,
);
case FieldType.LastEditedTime:
return TimestampCardCell<CustomCardData>(
renderHook: renderHook?.renderHook[FieldType.LastEditedTime],
cellControllerBuilder: cellControllerBuilder,
key: key,
);
case FieldType.CreatedTime:
return TimestampCardCell<CustomCardData>(
renderHook: renderHook?.renderHook[FieldType.CreatedTime],
cellControllerBuilder: cellControllerBuilder,
key: key,
);
case FieldType.SingleSelect:
return SelectOptionCardCell<CustomCardData>(
renderHook: renderHook?.renderHook[FieldType.SingleSelect],
@@ -4,6 +4,7 @@ import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart'
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option.pb.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/timestamp_entities.pb.dart';
import 'package:flutter/material.dart';
typedef CellRenderHook<C, CustomCardData> = Widget? Function(
@@ -50,6 +51,16 @@ class RowCardRenderHook<CustomCardData> {
renderHook[FieldType.DateTime] = _typeSafeHook<DateCellDataPB>(hook);
}
/// Add a render hook for [FieldType.LastEditedTime] and [FieldType.CreatedTime]
void addTimestampCellHook(
CellRenderHook<TimestampCellDataPB, CustomCardData?> hook,
) {
renderHook[FieldType.LastEditedTime] =
_typeSafeHook<TimestampCellDataPB>(hook);
renderHook[FieldType.CreatedTime] =
_typeSafeHook<TimestampCellDataPB>(hook);
}
CellRenderHook<dynamic, CustomCardData> _typeSafeHook<C>(
CellRenderHook<C, CustomCardData?> hook,
) {
@@ -32,10 +32,16 @@ class _ChecklistCardCellState extends State<ChecklistCardCell> {
return BlocProvider.value(
value: _cellBloc,
child: BlocBuilder<ChecklistCardCellBloc, ChecklistCellState>(
builder: (context, state) => Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: ChecklistProgressBar(percent: state.percent),
),
builder: (context, state) {
if (state.allOptions.isEmpty) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.symmetric(vertical: 4),
child: ChecklistProgressBar(percent: state.percent),
);
},
),
);
}
@@ -0,0 +1,79 @@
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/card/bloc/timestamp_card_cell_bloc.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../define.dart';
import 'card_cell.dart';
class TimestampCardCell<CustomCardData> extends CardCell {
final CellControllerBuilder cellControllerBuilder;
final CellRenderHook<dynamic, CustomCardData>? renderHook;
const TimestampCardCell({
required this.cellControllerBuilder,
this.renderHook,
Key? key,
}) : super(key: key);
@override
State<TimestampCardCell> createState() => _TimestampCardCellState();
}
class _TimestampCardCellState extends State<TimestampCardCell> {
late TimestampCardCellBloc _cellBloc;
@override
void initState() {
final cellController =
widget.cellControllerBuilder.build() as TimestampCellController;
_cellBloc = TimestampCardCellBloc(cellController: cellController)
..add(const TimestampCardCellEvent.initial());
super.initState();
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: _cellBloc,
child: BlocBuilder<TimestampCardCellBloc, TimestampCardCellState>(
buildWhen: (previous, current) => previous.dateStr != current.dateStr,
builder: (context, state) {
if (state.dateStr.isEmpty) {
return const SizedBox.shrink();
}
final Widget? custom = widget.renderHook?.call(
state.data,
widget.cardData,
context,
);
if (custom != null) {
return custom;
}
return Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: EdgeInsets.symmetric(
vertical: CardSizes.cardCellVPadding,
),
child: FlowyText.regular(
state.dateStr,
fontSize: 13,
color: Theme.of(context).hintColor,
),
),
);
},
),
);
}
@override
Future<void> dispose() async {
_cellBloc.close();
super.dispose();
}
}
@@ -1,9 +1,9 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -91,50 +91,31 @@ class _PrimaryCellAccessoryState extends State<PrimaryCellAccessory>
class AccessoryHover extends StatefulWidget {
final CellAccessory child;
final EdgeInsets contentPadding;
const AccessoryHover({
required this.child,
this.contentPadding = EdgeInsets.zero,
Key? key,
}) : super(key: key);
const AccessoryHover({required this.child, super.key});
@override
State<AccessoryHover> createState() => _AccessoryHoverState();
}
class _AccessoryHoverState extends State<AccessoryHover> {
late AccessoryHoverState _hoverState;
VoidCallback? _listenerFn;
@override
void initState() {
_hoverState = AccessoryHoverState();
_listenerFn = () =>
_hoverState.onHover = widget.child.onAccessoryHover?.value ?? false;
widget.child.onAccessoryHover?.addListener(_listenerFn!);
super.initState();
}
@override
void dispose() {
_hoverState.dispose();
if (_listenerFn != null) {
widget.child.onAccessoryHover?.removeListener(_listenerFn!);
_listenerFn = null;
}
super.dispose();
}
bool _isHover = false;
@override
Widget build(BuildContext context) {
final List<Widget> children = [
Padding(padding: widget.contentPadding, child: widget.child),
DecoratedBox(
decoration: BoxDecoration(
color: _isHover
? AFThemeExtension.of(context).lightGreyHover
: Colors.transparent,
borderRadius: Corners.s6Border,
),
child: widget.child,
),
];
final accessoryBuilder = widget.child.accessoryBuilder;
if (accessoryBuilder != null) {
if (accessoryBuilder != null && _isHover) {
final accessories = accessoryBuilder(
(GridCellAccessoryBuildContext(
anchorContext: context,
@@ -149,36 +130,20 @@ class _AccessoryHoverState extends State<AccessoryHover> {
);
}
return ChangeNotifierProvider.value(
value: _hoverState,
child: MouseRegion(
cursor: SystemMouseCursors.click,
opaque: false,
onEnter: (p) => setState(() => _hoverState.onHover = true),
onExit: (p) => setState(() => _hoverState.onHover = false),
child: Stack(
fit: StackFit.loose,
alignment: AlignmentDirectional.center,
children: children,
),
return MouseRegion(
cursor: SystemMouseCursors.click,
opaque: false,
onEnter: (p) => setState(() => _isHover = true),
onExit: (p) => setState(() => _isHover = false),
child: Stack(
fit: StackFit.loose,
alignment: AlignmentDirectional.center,
children: children,
),
);
}
}
class AccessoryHoverState extends ChangeNotifier {
bool _onHover = false;
set onHover(bool value) {
if (_onHover != value) {
_onHover = value;
notifyListeners();
}
}
bool get onHover => _onHover;
}
class CellAccessoryContainer extends StatelessWidget {
final List<GridCellAccessoryBuilder> accessories;
const CellAccessoryContainer({required this.accessories, Key? key})
@@ -11,6 +11,7 @@ import 'cells/date_cell/date_cell.dart';
import 'cells/number_cell/number_cell.dart';
import 'cells/select_option_cell/select_option_cell.dart';
import 'cells/text_cell/text_cell.dart';
import 'cells/timestamp_cell/timestamp_cell.dart';
import 'cells/url_cell/url_cell.dart';
/// Build the cell widget in Grid style.
@@ -34,6 +35,7 @@ class GridCellBuilder {
case FieldType.Checkbox:
return GridCheckboxCell(
cellControllerBuilder: cellControllerBuilder,
style: style,
key: key,
);
case FieldType.DateTime:
@@ -41,14 +43,12 @@ class GridCellBuilder {
cellControllerBuilder: cellControllerBuilder,
key: key,
style: style,
fieldType: cellContext.fieldType,
);
case FieldType.LastEditedTime:
case FieldType.CreatedTime:
return GridDateCell(
return GridTimestampCell(
cellControllerBuilder: cellControllerBuilder,
key: key,
editable: false,
style: style,
fieldType: cellContext.fieldType,
);
@@ -72,6 +72,7 @@ class GridCellBuilder {
case FieldType.Number:
return GridNumberCell(
cellControllerBuilder: cellControllerBuilder,
style: style,
key: key,
);
case FieldType.RichText:
@@ -9,12 +9,29 @@ import 'checkbox_cell_bloc.dart';
import '../../../../grid/presentation/layout/sizes.dart';
import '../../cell_builder.dart';
class GridCheckboxCellStyle extends GridCellStyle {
EdgeInsets? cellPadding;
GridCheckboxCellStyle({
this.cellPadding,
});
}
class GridCheckboxCell extends GridCellWidget {
final CellControllerBuilder cellControllerBuilder;
late final GridCheckboxCellStyle cellStyle;
GridCheckboxCell({
required this.cellControllerBuilder,
GridCellStyle? style,
Key? key,
}) : super(key: key);
}) : super(key: key) {
if (style != null) {
cellStyle = (style as GridCheckboxCellStyle);
} else {
cellStyle = GridCheckboxCellStyle();
}
}
@override
GridCellState<GridCheckboxCell> createState() => _CheckboxCellState();
@@ -46,7 +63,8 @@ class _CheckboxCellState extends GridCellState<GridCheckboxCell> {
return Align(
alignment: Alignment.centerLeft,
child: Padding(
padding: GridSize.cellContentInsets,
padding:
widget.cellStyle.cellPadding ?? GridSize.cellContentInsets,
child: FlowyIconButton(
hoverColor: Colors.transparent,
onPressed: () => context
@@ -40,7 +40,7 @@ class GridChecklistCellState extends GridCellState<GridChecklistCell> {
child: AppFlowyPopover(
margin: EdgeInsets.zero,
controller: _popover,
constraints: BoxConstraints.loose(const Size(260, 400)),
constraints: BoxConstraints.loose(const Size(360, 400)),
direction: PopoverDirection.bottomWithLeftAligned,
triggerActions: PopoverTriggerFlags.none,
popupBuilder: (BuildContext context) {
@@ -56,8 +56,12 @@ class GridChecklistCellState extends GridCellState<GridChecklistCell> {
child: Padding(
padding: GridSize.cellContentInsets,
child: BlocBuilder<ChecklistCardCellBloc, ChecklistCellState>(
builder: (context, state) =>
ChecklistProgressBar(percent: state.percent),
builder: (context, state) {
if (state.allOptions.isEmpty) {
return const SizedBox.shrink();
}
return ChecklistProgressBar(percent: state.percent);
},
),
),
),
@@ -29,13 +29,23 @@ class ChecklistCardCellBloc
_loadOptions();
},
didReceiveOptions: (data) {
emit(
state.copyWith(
allOptions: data.options,
selectedOptions: data.selectedOptions,
percent: data.percentage,
),
);
if (data == null) {
emit(
const ChecklistCellState(
allOptions: [],
selectedOptions: [],
percent: 0,
),
);
} else {
emit(
state.copyWith(
allOptions: data.options,
selectedOptions: data.selectedOptions,
percent: data.percentage,
),
);
}
},
);
},
@@ -58,7 +68,7 @@ class ChecklistCardCellBloc
_loadOptions();
},
onCellChanged: (data) {
if (!isClosed && data != null) {
if (!isClosed) {
add(ChecklistCellEvent.didReceiveOptions(data));
}
},
@@ -81,7 +91,7 @@ class ChecklistCardCellBloc
class ChecklistCellEvent with _$ChecklistCellEvent {
const factory ChecklistCellEvent.initial() = _InitialCell;
const factory ChecklistCellEvent.didReceiveOptions(
ChecklistCellDataPB data,
ChecklistCellDataPB? data,
) = _DidReceiveCellUpdate;
}
@@ -1,21 +1,23 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/common/type_option_separator.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../grid/presentation/layout/sizes.dart';
import '../../../../grid/presentation/widgets/header/type_option/select_option_editor.dart';
import 'checklist_cell_editor_bloc.dart';
import 'checklist_progress_bar.dart';
class GridChecklistCellEditor extends StatefulWidget {
final ChecklistCellController cellController;
const GridChecklistCellEditor({required this.cellController, Key? key})
: super(key: key);
const GridChecklistCellEditor({required this.cellController, super.key});
@override
State<GridChecklistCellEditor> createState() =>
@@ -23,167 +25,367 @@ class GridChecklistCellEditor extends StatefulWidget {
}
class _GridChecklistCellEditorState extends State<GridChecklistCellEditor> {
late ChecklistCellEditorBloc bloc;
late PopoverMutex popoverMutex;
late ChecklistCellEditorBloc _bloc;
/// Focus node for the new task text field
late final FocusNode newTaskFocusNode;
/// A flag that determines whether the new task text field is visible
bool _isAddingNewTask = false;
@override
void initState() {
popoverMutex = PopoverMutex();
bloc = ChecklistCellEditorBloc(cellController: widget.cellController);
bloc.add(const ChecklistCellEditorEvent.initial());
super.initState();
}
@override
void dispose() {
bloc.close();
super.dispose();
newTaskFocusNode = FocusNode();
_bloc = ChecklistCellEditorBloc(cellController: widget.cellController)
..add(const ChecklistCellEditorEvent.initial());
}
@override
Widget build(BuildContext context) {
return BlocProvider.value(
value: bloc,
child: BlocBuilder<ChecklistCellEditorBloc, ChecklistCellEditorState>(
value: _bloc,
child: BlocConsumer<ChecklistCellEditorBloc, ChecklistCellEditorState>(
listener: (context, state) {
if (state.allOptions.isEmpty) {
setState(() => _isAddingNewTask = true);
}
},
builder: (context, state) {
final List<Widget> slivers = [
const SliverChecklistProgressBar(),
SliverToBoxAdapter(
child: ListView.separated(
controller: ScrollController(),
shrinkWrap: true,
itemCount: state.allOptions.length,
itemBuilder: (BuildContext context, int index) {
return _ChecklistOptionCell(
option: state.allOptions[index],
popoverMutex: popoverMutex,
);
},
separatorBuilder: (BuildContext context, int index) {
return VSpace(GridSize.typeOptionSeparatorHeight);
},
),
),
];
return Padding(
padding: const EdgeInsets.all(8.0),
child: ScrollConfiguration(
behavior: const ScrollBehavior().copyWith(scrollbars: false),
child: CustomScrollView(
shrinkWrap: true,
slivers: slivers,
controller: ScrollController(),
physics: StyledScrollPhysics(),
),
return Focus(
onKey: (node, event) {
// don't hide new task text field if there are no tasks at all
if (state.allOptions.isNotEmpty &&
event is RawKeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.escape) {
setState(() {
_isAddingNewTask = false;
});
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
child: CustomScrollView(
shrinkWrap: true,
physics: StyledScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: state.allOptions.isEmpty
? const SizedBox.shrink()
: Padding(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 4),
child: ChecklistProgressBar(
percent: state.percent,
),
),
),
),
ChecklistItemList(
options: state.allOptions,
newTaskFocusNode: newTaskFocusNode,
isAddingNewTask: _isAddingNewTask,
onUpdateTask: () => setState(() {
_isAddingNewTask = true;
newTaskFocusNode.requestFocus();
}),
),
const SliverToBoxAdapter(
child: TypeOptionSeparator(spacing: 0.0),
),
SliverToBoxAdapter(
child: ChecklistNewTaskButton(
onTap: () => setState(() => _isAddingNewTask = true),
),
),
],
),
);
},
),
);
}
@override
void dispose() {
_bloc.close();
super.dispose();
}
}
class _ChecklistOptionCell extends StatefulWidget {
/// Displays the a list of all the exisiting tasks and an input field to create
/// a new task if `isAddingNewTask` is true
class ChecklistItemList extends StatefulWidget {
final List<ChecklistSelectOption> options;
final FocusNode newTaskFocusNode;
final bool isAddingNewTask;
final VoidCallback onUpdateTask;
const ChecklistItemList({
super.key,
required this.options,
required this.onUpdateTask,
required this.isAddingNewTask,
required this.newTaskFocusNode,
});
@override
State<ChecklistItemList> createState() => _ChecklistItemListState();
}
class _ChecklistItemListState extends State<ChecklistItemList> {
@override
Widget build(BuildContext context) {
final itemList = [
const VSpace(6.0),
...widget.options.mapIndexed(
(index, option) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: ChecklistItem(
option: option,
onSubmitted:
index == widget.options.length - 1 ? widget.onUpdateTask : null,
key: ValueKey(option.data.id),
// only allow calling the callback for the last task in the list
),
),
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: widget.isAddingNewTask
? NewTaskItem(focusNode: widget.newTaskFocusNode)
: const SizedBox.shrink(),
),
const VSpace(6.0),
];
return SliverList(
delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) => itemList[index],
childCount: itemList.length,
),
);
}
}
/// Represents an existing task
@visibleForTesting
class ChecklistItem extends StatefulWidget {
final ChecklistSelectOption option;
final PopoverMutex popoverMutex;
const _ChecklistOptionCell({
final VoidCallback? onSubmitted;
const ChecklistItem({
required this.option,
required this.popoverMutex,
Key? key,
this.onSubmitted,
}) : super(key: key);
@override
State<_ChecklistOptionCell> createState() => _ChecklistOptionCellState();
State<ChecklistItem> createState() => _ChecklistItemState();
}
class _ChecklistOptionCellState extends State<_ChecklistOptionCell> {
late PopoverController _popoverController;
class _ChecklistItemState extends State<ChecklistItem> {
late final TextEditingController _textController;
late final FocusNode _focusNode;
bool _isHovered = false;
@override
void initState() {
_popoverController = PopoverController();
super.initState();
_textController = TextEditingController(text: widget.option.data.name);
_focusNode = FocusNode(
onKey: (node, event) {
if (event is RawKeyDownEvent &&
event.logicalKey == LogicalKeyboardKey.escape) {
node.unfocus();
return KeyEventResult.handled;
}
return KeyEventResult.ignored;
},
);
}
@override
Widget build(BuildContext context) {
final icon = widget.option.isSelected
? const FlowySvg(
FlowySvgs.check_filled_s,
blendMode: BlendMode.dst,
)
: const FlowySvg(FlowySvgs.uncheck_s);
return _wrapPopover(
SizedBox(
height: GridSize.popoverItemHeight,
child: Row(
children: [
Expanded(
child: FlowyButton(
hoverColor: AFThemeExtension.of(context).lightGreyHover,
text: FlowyText(
widget.option.data.name,
color: AFThemeExtension.of(context).textColor,
),
leftIcon: icon,
onTap: () => context
.read<ChecklistCellEditorBloc>()
.add(ChecklistCellEditorEvent.selectOption(widget.option)),
final icon = FlowySvg(
widget.option.isSelected ? FlowySvgs.check_filled_s : FlowySvgs.uncheck_s,
blendMode: BlendMode.dst,
);
return MouseRegion(
onEnter: (event) => setState(() => _isHovered = true),
onExit: (event) => setState(() => _isHovered = false),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
constraints: BoxConstraints(minHeight: GridSize.popoverItemHeight),
child: DecoratedBox(
decoration: BoxDecoration(
color: _isHovered
? AFThemeExtension.of(context).lightGreyHover
: Colors.transparent,
borderRadius: Corners.s6Border,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
FlowyIconButton(
width: 32,
icon: icon,
hoverColor: Colors.transparent,
onPressed: () => context.read<ChecklistCellEditorBloc>().add(
ChecklistCellEditorEvent.selectTask(widget.option.data),
),
),
),
_disclosureButton(),
],
Expanded(
child: TextField(
controller: _textController,
focusNode: _focusNode,
style: Theme.of(context).textTheme.bodyMedium,
maxLines: 1,
decoration: InputDecoration(
border: InputBorder.none,
isCollapsed: true,
contentPadding: const EdgeInsets.symmetric(
vertical: 6.0,
horizontal: 2.0,
),
hintText: LocaleKeys.grid_checklist_taskHint.tr(),
),
onSubmitted: (taskDescription) {
context.read<ChecklistCellEditorBloc>().add(
ChecklistCellEditorEvent.updateTaskName(
widget.option.data,
taskDescription,
),
);
widget.onSubmitted?.call();
},
),
),
if (_isHovered)
FlowyIconButton(
width: 32,
icon: const FlowySvg(FlowySvgs.delete_s),
hoverColor: Colors.transparent,
iconColorOnHover: Theme.of(context).colorScheme.error,
onPressed: () => context.read<ChecklistCellEditorBloc>().add(
ChecklistCellEditorEvent.deleteTask(widget.option.data),
),
),
],
),
),
),
);
}
}
Widget _disclosureButton() {
return FlowyIconButton(
hoverColor: AFThemeExtension.of(context).lightGreyHover,
width: 20,
onPressed: () => _popoverController.show(),
iconPadding: const EdgeInsets.fromLTRB(2, 2, 2, 2),
icon: FlowySvg(
FlowySvgs.details_s,
color: Theme.of(context).iconTheme.color,
/// Creates a new task after entering the description and pressing enter.
/// This can be cancelled by pressing escape
@visibleForTesting
class NewTaskItem extends StatefulWidget {
final FocusNode focusNode;
const NewTaskItem({super.key, required this.focusNode});
@override
State<NewTaskItem> createState() => _NewTaskItemState();
}
class _NewTaskItemState extends State<NewTaskItem> {
late final TextEditingController _textEditingController;
@override
void initState() {
super.initState();
_textEditingController = TextEditingController();
if (widget.focusNode.canRequestFocus) {
widget.focusNode.requestFocus();
}
}
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8.0),
constraints: BoxConstraints(minHeight: GridSize.popoverItemHeight),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
const FlowyIconButton(
width: 32,
icon: FlowySvg(
FlowySvgs.uncheck_s,
blendMode: BlendMode.dst,
),
hoverColor: Colors.transparent,
),
Expanded(
child: TextField(
focusNode: widget.focusNode,
controller: _textEditingController,
style: Theme.of(context).textTheme.bodyMedium,
maxLines: 1,
decoration: InputDecoration(
border: InputBorder.none,
isCollapsed: true,
contentPadding: const EdgeInsets.symmetric(
vertical: 6.0,
horizontal: 2.0,
),
hintText: LocaleKeys.grid_checklist_taskHint.tr(),
),
onSubmitted: (taskDescription) {
if (taskDescription.trim().isNotEmpty) {
context.read<ChecklistCellEditorBloc>().add(
ChecklistCellEditorEvent.newTask(
taskDescription.trim(),
),
);
}
_textEditingController.clear();
},
),
),
FlowyTextButton(
LocaleKeys.grid_checklist_submitNewTask.tr(),
fontSize: 11,
fillColor: Theme.of(context).colorScheme.primary,
hoverColor: Theme.of(context).colorScheme.primaryContainer,
fontColor: Theme.of(context).colorScheme.onPrimary,
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
onPressed: () {
if (_textEditingController.text.trim().isNotEmpty) {
context.read<ChecklistCellEditorBloc>().add(
ChecklistCellEditorEvent.newTask(
_textEditingController.text..trim(),
),
);
}
_textEditingController.clear();
},
),
],
),
);
}
}
Widget _wrapPopover(Widget child) {
return AppFlowyPopover(
controller: _popoverController,
offset: const Offset(8, 0),
asBarrier: true,
constraints: BoxConstraints.loose(const Size(200, 300)),
mutex: widget.popoverMutex,
triggerActions: PopoverTriggerFlags.none,
child: child,
popupBuilder: (BuildContext popoverContext) {
return SelectOptionTypeOptionEditor(
option: widget.option.data,
onDeleted: () {
context.read<ChecklistCellEditorBloc>().add(
ChecklistCellEditorEvent.deleteOption(widget.option.data),
);
@visibleForTesting
class ChecklistNewTaskButton extends StatelessWidget {
final VoidCallback onTap;
const ChecklistNewTaskButton({super.key, required this.onTap});
_popoverController.close();
},
onUpdated: (updatedOption) {
context.read<ChecklistCellEditorBloc>().add(
ChecklistCellEditorEvent.updateOption(updatedOption),
);
},
showOptions: false,
autoFocus: false,
// Use ValueKey to refresh the UI, otherwise, it will remain the old value.
key: ValueKey(
widget.option.data.id,
),
);
},
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 8.0),
child: SizedBox(
height: 30,
child: FlowyButton(
text: FlowyText.medium(LocaleKeys.grid_checklist_addNew.tr()),
margin: const EdgeInsets.all(6),
leftIcon: const FlowySvg(FlowySvgs.add_s),
onTap: onTap,
),
),
);
}
}
@@ -2,15 +2,22 @@ import 'dart:async';
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database_view/application/cell/checklist_cell_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/checklist_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/select_option.pb.dart';
import 'package:dartz/dartz.dart';
import 'package:appflowy_backend/log.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'checklist_cell_editor_bloc.freezed.dart';
class ChecklistSelectOption {
final bool isSelected;
final SelectOptionPB data;
ChecklistSelectOption(this.isSelected, this.data);
}
class ChecklistCellEditorBloc
extends Bloc<ChecklistCellEditorEvent, ChecklistCellEditorState> {
final ChecklistCellBackendService _checklistCellService;
@@ -31,33 +38,31 @@ class ChecklistCellEditorBloc
_startListening();
_loadOptions();
},
didReceiveOptions: (data) {
didReceiveTasks: (data) {
emit(
state.copyWith(
allOptions: _makeChecklistSelectOptions(data, state.predicate),
percent: data.percentage,
allOptions: _makeChecklistSelectOptions(data),
percent: data?.percentage ?? 0,
),
);
},
newOption: (optionName) {
_createOption(optionName);
newTask: (optionName) async {
await _createOption(optionName);
emit(
state.copyWith(
createOption: Some(optionName),
predicate: '',
),
);
},
deleteOption: (option) {
_deleteOption([option]);
deleteTask: (option) async {
await _deleteOption([option]);
},
updateOption: (option) {
_updateOption(option);
updateTaskName: (option, name) {
_updateOption(option, name);
},
selectOption: (option) async {
await _checklistCellService.select(optionId: option.data.id);
selectTask: (option) async {
await _checklistCellService.select(optionId: option.id);
},
filterOption: (String predicate) {},
);
},
);
@@ -69,22 +74,21 @@ class ChecklistCellEditorBloc
return super.close();
}
void _createOption(String name) async {
Future<void> _createOption(String name) async {
final result = await _checklistCellService.create(name: name);
result.fold((l) => {}, (err) => Log.error(err));
}
void _deleteOption(List<SelectOptionPB> options) async {
Future<void> _deleteOption(List<SelectOptionPB> options) async {
final result = await _checklistCellService.delete(
optionIds: options.map((e) => e.id).toList(),
);
result.fold((l) => null, (err) => Log.error(err));
}
void _updateOption(SelectOptionPB option) async {
final result = await _checklistCellService.update(
option: option,
);
void _updateOption(SelectOptionPB option, String name) async {
final result =
await _checklistCellService.updateName(option: option, name: name);
result.fold((l) => null, (err) => Log.error(err));
}
@@ -94,7 +98,7 @@ class ChecklistCellEditorBloc
if (isClosed) return;
return result.fold(
(data) => add(ChecklistCellEditorEvent.didReceiveOptions(data)),
(data) => add(ChecklistCellEditorEvent.didReceiveTasks(data)),
(err) => Log.error(err),
);
});
@@ -103,8 +107,8 @@ class ChecklistCellEditorBloc
void _startListening() {
cellController.startListening(
onCellChanged: ((data) {
if (!isClosed && data != null) {
add(ChecklistCellEditorEvent.didReceiveOptions(data));
if (!isClosed) {
add(ChecklistCellEditorEvent.didReceiveTasks(data));
}
}),
onCellFieldChanged: () {
@@ -117,20 +121,19 @@ class ChecklistCellEditorBloc
@freezed
class ChecklistCellEditorEvent with _$ChecklistCellEditorEvent {
const factory ChecklistCellEditorEvent.initial() = _Initial;
const factory ChecklistCellEditorEvent.didReceiveOptions(
ChecklistCellDataPB data,
) = _DidReceiveOptions;
const factory ChecklistCellEditorEvent.newOption(String optionName) =
_NewOption;
const factory ChecklistCellEditorEvent.selectOption(
ChecklistSelectOption option,
) = _SelectOption;
const factory ChecklistCellEditorEvent.updateOption(SelectOptionPB option) =
_UpdateOption;
const factory ChecklistCellEditorEvent.deleteOption(SelectOptionPB option) =
_DeleteOption;
const factory ChecklistCellEditorEvent.filterOption(String predicate) =
_FilterOption;
const factory ChecklistCellEditorEvent.didReceiveTasks(
ChecklistCellDataPB? data,
) = _DidReceiveTasks;
const factory ChecklistCellEditorEvent.newTask(String taskName) = _NewOption;
const factory ChecklistCellEditorEvent.selectTask(
SelectOptionPB option,
) = _SelectTask;
const factory ChecklistCellEditorEvent.updateTaskName(
SelectOptionPB option,
String name,
) = _UpdateTaskName;
const factory ChecklistCellEditorEvent.deleteTask(SelectOptionPB option) =
_DeleteTask;
}
@freezed
@@ -139,24 +142,21 @@ class ChecklistCellEditorState with _$ChecklistCellEditorState {
required List<ChecklistSelectOption> allOptions,
required Option<String> createOption,
required double percent,
required String predicate,
}) = _ChecklistCellEditorState;
factory ChecklistCellEditorState.initial(ChecklistCellController context) {
final data = context.getCellData(loadIfNotExist: true);
return ChecklistCellEditorState(
allOptions: _makeChecklistSelectOptions(data, ''),
allOptions: _makeChecklistSelectOptions(data),
createOption: none(),
percent: data?.percentage ?? 0,
predicate: '',
);
}
}
List<ChecklistSelectOption> _makeChecklistSelectOptions(
ChecklistCellDataPB? data,
String predicate,
) {
if (data == null) {
return [];
@@ -164,9 +164,6 @@ List<ChecklistSelectOption> _makeChecklistSelectOptions(
final List<ChecklistSelectOption> options = [];
final List<SelectOptionPB> allOptions = List.from(data.options);
if (predicate.isNotEmpty) {
allOptions.retainWhere((element) => element.name.contains(predicate));
}
final selectedOptionIds = data.selectedOptions.map((e) => e.id).toList();
for (final option in allOptions) {
@@ -177,10 +174,3 @@ List<ChecklistSelectOption> _makeChecklistSelectOptions(
return options;
}
class ChecklistSelectOption {
final bool isSelected;
final SelectOptionPB data;
ChecklistSelectOption(this.isSelected, this.data);
}
@@ -1,129 +1,44 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/checklist_cell/checklist_cell_editor_bloc.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/select_option_cell/extension.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:percent_indicator/percent_indicator.dart';
class ChecklistProgressBar extends StatelessWidget {
class ChecklistProgressBar extends StatefulWidget {
final double percent;
const ChecklistProgressBar({required this.percent, Key? key})
: super(key: key);
@override
Widget build(BuildContext context) {
return LinearPercentIndicator(
lineHeight: 10.0,
percent: percent,
padding: EdgeInsets.zero,
progressColor: percent < 1.0
? SelectOptionColorPB.Purple.toColor(context)
: SelectOptionColorPB.Green.toColor(context),
backgroundColor: AFThemeExtension.of(context).progressBarBGColor,
barRadius: const Radius.circular(5),
);
}
State<ChecklistProgressBar> createState() => _ChecklistProgressBarState();
}
class SliverChecklistProgressBar extends StatelessWidget {
const SliverChecklistProgressBar({Key? key}) : super(key: key);
class _ChecklistProgressBarState extends State<ChecklistProgressBar> {
@override
Widget build(BuildContext context) {
return SliverPersistentHeader(
pinned: true,
delegate: _SliverChecklistProgressBarDelegate(),
);
}
}
class _SliverChecklistProgressBarDelegate
extends SliverPersistentHeaderDelegate {
_SliverChecklistProgressBarDelegate();
double fixHeight = 60;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return const _AutoFocusTextField();
}
@override
double get maxExtent => fixHeight;
@override
double get minExtent => fixHeight;
@override
bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) {
return true;
}
}
class _AutoFocusTextField extends StatefulWidget {
const _AutoFocusTextField();
@override
State<_AutoFocusTextField> createState() => _AutoFocusTextFieldState();
}
class _AutoFocusTextFieldState extends State<_AutoFocusTextField> {
final _focusNode = FocusNode();
@override
Widget build(BuildContext context) {
return BlocBuilder<ChecklistCellEditorBloc, ChecklistCellEditorState>(
builder: (context, state) {
return BlocListener<ChecklistCellEditorBloc, ChecklistCellEditorState>(
listenWhen: (previous, current) =>
previous.createOption != current.createOption,
listener: (context, state) {
if (_focusNode.canRequestFocus) {
_focusNode.requestFocus();
}
},
child: Container(
color: Theme.of(context).cardColor,
child: Padding(
padding: GridSize.typeOptionContentInsets,
child: Column(
children: [
FlowyTextField(
autoFocus: true,
focusNode: _focusNode,
autoClearWhenDone: true,
submitOnLeave: true,
hintText: LocaleKeys.grid_checklist_panelTitle.tr(),
onChanged: (text) {
context
.read<ChecklistCellEditorBloc>()
.add(ChecklistCellEditorEvent.filterOption(text));
},
onSubmitted: (text) {
context
.read<ChecklistCellEditorBloc>()
.add(ChecklistCellEditorEvent.newOption(text));
},
),
Padding(
padding: const EdgeInsets.only(top: 6.0),
child: ChecklistProgressBar(percent: state.percent),
),
],
),
return Row(
children: [
Expanded(
child: LinearPercentIndicator(
lineHeight: 4.0,
percent: widget.percent,
padding: EdgeInsets.zero,
progressColor: Theme.of(context).colorScheme.primary,
backgroundColor: AFThemeExtension.of(context).progressBarBGColor,
barRadius: const Radius.circular(5),
),
),
SizedBox(
width: 36,
child: Align(
alignment: AlignmentDirectional.centerEnd,
child: FlowyText.regular(
"${(widget.percent * 100).round()}%",
fontSize: 11,
color: Theme.of(context).hintColor,
),
),
);
},
),
],
);
}
}
@@ -1,8 +1,8 @@
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../../../grid/presentation/layout/sizes.dart';
@@ -11,9 +11,15 @@ import 'date_cell_bloc.dart';
import 'date_editor.dart';
class DateCellStyle extends GridCellStyle {
String? placeholder;
Alignment alignment;
EdgeInsets? cellPadding;
DateCellStyle({this.alignment = Alignment.center});
DateCellStyle({
this.placeholder,
this.alignment = Alignment.center,
this.cellPadding,
});
}
abstract class GridCellDelegate {
@@ -22,20 +28,12 @@ abstract class GridCellDelegate {
}
class GridDateCell extends GridCellWidget {
final bool editable;
/// The [GridDateCell] is used by Field Type [FieldType.DateTime],
/// [FieldType.CreatedTime], [FieldType.LastEditedTime]. So it needs
/// to know the field type.
final FieldType fieldType;
final CellControllerBuilder cellControllerBuilder;
late final DateCellStyle? cellStyle;
GridDateCell({
GridCellStyle? style,
required this.fieldType,
required this.cellControllerBuilder,
this.editable = true,
Key? key,
}) : super(key: key) {
if (style != null) {
@@ -72,33 +70,30 @@ class _DateCellState extends GridCellState<GridDateCell> {
value: _cellBloc,
child: BlocBuilder<DateCellBloc, DateCellState>(
builder: (context, state) {
Widget dateTextWidget = GridDateCellText(
dateStr: state.dateStr,
alignment: alignment,
return AppFlowyPopover(
controller: _popover,
triggerActions: PopoverTriggerFlags.none,
direction: PopoverDirection.bottomWithLeftAligned,
constraints: BoxConstraints.loose(const Size(260, 520)),
margin: EdgeInsets.zero,
child: GridDateCellText(
dateStr: state.dateStr,
placeholder: widget.cellStyle?.placeholder ?? "",
alignment: alignment,
cellPadding:
widget.cellStyle?.cellPadding ?? GridSize.cellContentInsets,
),
popupBuilder: (BuildContext popoverContent) {
return DateCellEditor(
cellController:
widget.cellControllerBuilder.build() as DateCellController,
onDismissed: () => widget.onCellFocus.value = false,
);
},
onClose: () {
widget.onCellFocus.value = false;
},
);
// If the cell is editable, wrap it in a popover.
if (widget.editable) {
dateTextWidget = AppFlowyPopover(
controller: _popover,
triggerActions: PopoverTriggerFlags.none,
direction: PopoverDirection.bottomWithLeftAligned,
constraints: BoxConstraints.loose(const Size(260, 520)),
margin: EdgeInsets.zero,
child: dateTextWidget,
popupBuilder: (BuildContext popoverContent) {
return DateCellEditor(
cellController: widget.cellControllerBuilder.build()
as DateCellController,
onDismissed: () => widget.onCellFocus.value = false,
);
},
onClose: () {
widget.onCellFocus.value = false;
},
);
}
return dateTextWidget;
},
),
);
@@ -113,10 +108,7 @@ class _DateCellState extends GridCellState<GridDateCell> {
@override
void requestBeginFocus() {
_popover.show();
if (widget.editable) {
widget.onCellFocus.value = true;
}
widget.onCellFocus.value = true;
}
@override
@@ -125,24 +117,31 @@ class _DateCellState extends GridCellState<GridDateCell> {
class GridDateCellText extends StatelessWidget {
final String dateStr;
final String placeholder;
final Alignment alignment;
final EdgeInsets cellPadding;
const GridDateCellText({
required this.dateStr,
required this.placeholder,
required this.alignment,
required this.cellPadding,
super.key,
});
@override
Widget build(BuildContext context) {
return SizedBox.expand(
child: Align(
alignment: alignment,
child: Padding(
padding: GridSize.cellContentInsets,
child: FlowyText.medium(
dateStr,
maxLines: null,
),
final isPlaceholder = dateStr.isEmpty;
final text = isPlaceholder ? placeholder : dateStr;
return Align(
alignment: alignment,
child: Padding(
padding: cellPadding,
child: FlowyText.medium(
text,
color: isPlaceholder
? Theme.of(context).hintColor
: AFThemeExtension.of(context).textColor,
maxLines: null,
),
),
);
@@ -2,8 +2,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle_style.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/type_option/timestamp.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/date_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart';
@@ -11,7 +10,6 @@ import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:dartz/dartz.dart' show Either;
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra/time/duration.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@@ -52,7 +50,7 @@ class _DateCellEditor extends State<DateCellEditor> {
if (snapshot.hasData) {
return _buildWidget(snapshot);
} else {
return const SizedBox();
return const SizedBox.shrink();
}
},
);
@@ -68,7 +66,7 @@ class _DateCellEditor extends State<DateCellEditor> {
},
(err) {
Log.error(err);
return const SizedBox();
return const SizedBox.shrink();
},
);
}
@@ -109,10 +107,6 @@ class _CellCalendarWidgetState extends State<_CellCalendarWidget> {
child: BlocBuilder<DateCellCalendarBloc, DateCellCalendarState>(
builder: (context, state) {
final List<Widget> children = [
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: _buildCalendar(context),
),
AnimatedSwitcher(
duration: const Duration(milliseconds: 300),
child: state.includeTime
@@ -122,11 +116,13 @@ class _CellCalendarWidgetState extends State<_CellCalendarWidget> {
)
: const SizedBox.shrink(),
),
const TypeOptionSeparator(spacing: 12.0),
const DatePicker(),
const VSpace(8.0),
const TypeOptionSeparator(spacing: 8.0),
const _IncludeTimeButton(),
const TypeOptionSeparator(spacing: 12.0),
const TypeOptionSeparator(spacing: 8.0),
DateTypeOptionButton(popoverMutex: popoverMutex),
const TypeOptionSeparator(spacing: 12.0),
VSpace(GridSize.typeOptionSeparatorHeight),
const ClearDateButton(),
];
@@ -135,7 +131,7 @@ class _CellCalendarWidgetState extends State<_CellCalendarWidget> {
controller: ScrollController(),
itemCount: children.length,
itemBuilder: (BuildContext context, int index) => children[index],
padding: const EdgeInsets.symmetric(vertical: 12.0),
padding: const EdgeInsets.only(top: 18.0, bottom: 12.0),
);
},
),
@@ -147,84 +143,103 @@ class _CellCalendarWidgetState extends State<_CellCalendarWidget> {
popoverMutex.dispose();
super.dispose();
}
}
Widget _buildCalendar(BuildContext context) {
class DatePicker extends StatelessWidget {
const DatePicker({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<DateCellCalendarBloc, DateCellCalendarState>(
builder: (context, state) {
final textStyle = Theme.of(context).textTheme.bodyMedium!;
final boxDecoration = BoxDecoration(
color: Theme.of(context).colorScheme.surface,
shape: BoxShape.rectangle,
borderRadius: Corners.s6Border,
shape: BoxShape.circle,
);
return TableCalendar(
firstDay: kFirstDay,
lastDay: kLastDay,
focusedDay: state.focusedDay,
rowHeight: GridSize.popoverItemHeight,
calendarFormat: state.format,
daysOfWeekHeight: GridSize.popoverItemHeight,
headerStyle: HeaderStyle(
formatButtonVisible: false,
titleCentered: true,
titleTextStyle: textStyle,
leftChevronMargin: EdgeInsets.zero,
leftChevronPadding: EdgeInsets.zero,
leftChevronIcon: FlowySvg(
FlowySvgs.arrow_left_s,
color: Theme.of(context).iconTheme.color,
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: TableCalendar(
firstDay: kFirstDay,
lastDay: kLastDay,
focusedDay: state.focusedDay,
rowHeight: 26.0 + 7.0,
calendarFormat: state.format,
daysOfWeekHeight: 17.0 + 8.0,
headerStyle: HeaderStyle(
formatButtonVisible: false,
titleCentered: true,
titleTextStyle: textStyle,
leftChevronMargin: EdgeInsets.zero,
leftChevronPadding: EdgeInsets.zero,
leftChevronIcon: FlowySvg(
FlowySvgs.arrow_left_s,
color: Theme.of(context).iconTheme.color,
),
rightChevronPadding: EdgeInsets.zero,
rightChevronMargin: EdgeInsets.zero,
rightChevronIcon: FlowySvg(
FlowySvgs.arrow_right_s,
color: Theme.of(context).iconTheme.color,
),
headerMargin: EdgeInsets.zero,
headerPadding: const EdgeInsets.only(bottom: 8.0),
),
rightChevronPadding: EdgeInsets.zero,
rightChevronMargin: EdgeInsets.zero,
rightChevronIcon: FlowySvg(
FlowySvgs.arrow_right_s,
color: Theme.of(context).iconTheme.color,
calendarStyle: CalendarStyle(
cellMargin: const EdgeInsets.all(3.5),
defaultDecoration: boxDecoration,
selectedDecoration: boxDecoration.copyWith(
color: Theme.of(context).colorScheme.primary,
),
todayDecoration: boxDecoration.copyWith(
color: Colors.transparent,
border:
Border.all(color: Theme.of(context).colorScheme.primary),
),
weekendDecoration: boxDecoration,
outsideDecoration: boxDecoration,
defaultTextStyle: textStyle,
weekendTextStyle: textStyle,
selectedTextStyle: textStyle.copyWith(
color: Theme.of(context).colorScheme.surface,
),
todayTextStyle: textStyle,
outsideTextStyle: textStyle.copyWith(
color: Theme.of(context).disabledColor,
),
),
headerMargin: const EdgeInsets.only(bottom: 8.0),
),
daysOfWeekStyle: DaysOfWeekStyle(
dowTextFormatter: (date, locale) =>
DateFormat.E(locale).format(date).toUpperCase(),
weekdayStyle: AFThemeExtension.of(context).caption,
weekendStyle: AFThemeExtension.of(context).caption,
),
calendarStyle: CalendarStyle(
cellMargin: const EdgeInsets.all(3),
defaultDecoration: boxDecoration,
selectedDecoration: boxDecoration.copyWith(
color: Theme.of(context).colorScheme.primary,
),
todayDecoration: boxDecoration.copyWith(
color: AFThemeExtension.of(context).lightGreyHover,
),
weekendDecoration: boxDecoration,
outsideDecoration: boxDecoration,
defaultTextStyle: textStyle,
weekendTextStyle: textStyle,
selectedTextStyle: textStyle.copyWith(
color: Theme.of(context).colorScheme.surface,
),
todayTextStyle: textStyle,
outsideTextStyle: textStyle.copyWith(
color: Theme.of(context).disabledColor,
),
),
selectedDayPredicate: (day) => isSameDay(state.dateTime, day),
onDaySelected: (selectedDay, focusedDay) {
context.read<DateCellCalendarBloc>().add(
DateCellCalendarEvent.selectDay(selectedDay.toLocal().date),
calendarBuilders: CalendarBuilders(
dowBuilder: (context, day) {
final locale = context.locale.toLanguageTag();
final label = DateFormat.E(locale).format(day).substring(0, 2);
return Padding(
padding: const EdgeInsets.only(bottom: 8.0),
child: Center(
child: Text(
label,
style: AFThemeExtension.of(context).caption,
),
),
);
},
onFormatChanged: (format) {
context
.read<DateCellCalendarBloc>()
.add(DateCellCalendarEvent.setCalFormat(format));
},
onPageChanged: (focusedDay) {
context
.read<DateCellCalendarBloc>()
.add(DateCellCalendarEvent.setFocusedDay(focusedDay));
},
},
),
selectedDayPredicate: (day) => isSameDay(state.dateTime, day),
onDaySelected: (selectedDay, focusedDay) {
context.read<DateCellCalendarBloc>().add(
DateCellCalendarEvent.selectDay(selectedDay.toLocal().date),
);
},
onFormatChanged: (format) {
context
.read<DateCellCalendarBloc>()
.add(DateCellCalendarEvent.setCalFormat(format));
},
onPageChanged: (focusedDay) {
context
.read<DateCellCalendarBloc>()
.add(DateCellCalendarEvent.setFocusedDay(focusedDay));
},
),
);
},
);
@@ -241,30 +256,11 @@ class _IncludeTimeButton extends StatelessWidget {
builder: (context, includeTime) {
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: SizedBox(
height: GridSize.popoverItemHeight,
child: Padding(
padding: GridSize.typeOptionContentInsets,
child: Row(
children: [
FlowySvg(
FlowySvgs.clock_alarm_s,
color: Theme.of(context).iconTheme.color,
),
const HSpace(6),
FlowyText.medium(LocaleKeys.grid_field_includeTime.tr()),
const Spacer(),
Toggle(
value: includeTime,
onChanged: (value) => context
.read<DateCellCalendarBloc>()
.add(DateCellCalendarEvent.setIncludeTime(!value)),
style: ToggleStyle.big,
padding: EdgeInsets.zero,
),
],
),
),
child: IncludeTimeButton(
onChanged: (value) => context
.read<DateCellCalendarBloc>()
.add(DateCellCalendarEvent.setIncludeTime(!value)),
value: includeTime,
),
);
},
@@ -315,26 +311,21 @@ class _TimeTextFieldState extends State<_TimeTextField> {
return BlocConsumer<DateCellCalendarBloc, DateCellCalendarState>(
listener: (context, state) => _textController.text = state.time ?? "",
builder: (context, state) {
return Column(
children: [
const VSpace(12),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12.0),
child: FlowyTextField(
text: state.time ?? "",
focusNode: _focusNode,
controller: _textController,
submitOnLeave: true,
hintText: state.timeHintText,
errorText: state.timeFormatError,
onSubmitted: (timeStr) {
context
.read<DateCellCalendarBloc>()
.add(DateCellCalendarEvent.setTime(timeStr));
},
),
),
],
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 18.0),
child: FlowyTextField(
text: state.time ?? "",
focusNode: _focusNode,
controller: _textController,
submitOnLeave: true,
hintText: state.timeHintText,
errorText: state.timeFormatError,
onSubmitted: (timeStr) {
context
.read<DateCellCalendarBloc>()
.add(DateCellCalendarEvent.setTime(timeStr));
},
),
);
},
);
@@ -369,7 +360,6 @@ class DateTypeOptionButton extends StatelessWidget {
height: GridSize.popoverItemHeight,
child: FlowyButton(
text: FlowyText.medium(title),
margin: GridSize.typeOptionContentInsets,
rightIcon: const FlowySvg(FlowySvgs.more_s),
),
),
@@ -481,8 +471,6 @@ class ClearDateButton extends StatelessWidget {
.add(const DateCellCalendarEvent.clearDate());
PopoverContainer.of(context).close();
},
leftIcon: const FlowySvg(FlowySvgs.delete_s),
margin: GridSize.typeOptionContentInsets,
),
),
);
@@ -7,13 +7,33 @@ import 'number_cell_bloc.dart';
import '../../../../grid/presentation/layout/sizes.dart';
import '../../cell_builder.dart';
class GridNumberCellStyle extends GridCellStyle {
String? placeholder;
TextStyle? textStyle;
EdgeInsets? cellPadding;
GridNumberCellStyle({
this.placeholder,
this.textStyle,
this.cellPadding,
});
}
class GridNumberCell extends GridCellWidget {
final CellControllerBuilder cellControllerBuilder;
late final GridNumberCellStyle cellStyle;
GridNumberCell({
required this.cellControllerBuilder,
Key? key,
}) : super(key: key);
required GridCellStyle? style,
super.key,
}) {
if (style != null) {
cellStyle = (style as GridNumberCellStyle);
} else {
cellStyle = GridNumberCellStyle();
}
}
@override
GridEditableTextCell<GridNumberCell> createState() => _NumberCellState();
@@ -57,9 +77,10 @@ class _NumberCellState extends GridEditableTextCell<GridNumberCell> {
maxLines: null,
style: Theme.of(context).textTheme.bodyMedium,
textInputAction: TextInputAction.done,
decoration: const InputDecoration(
decoration: InputDecoration(
contentPadding: EdgeInsets.zero,
border: InputBorder.none,
hintText: widget.cellStyle.placeholder,
isDense: true,
),
),
@@ -93,7 +93,7 @@ class SelectOptionTag extends StatelessWidget {
@override
Widget build(BuildContext context) {
EdgeInsets padding =
const EdgeInsets.symmetric(vertical: 2.0, horizontal: 8.0);
const EdgeInsets.symmetric(vertical: 1.5, horizontal: 8.0);
if (onRemove != null) {
padding = padding.copyWith(right: 2.0);
}
@@ -110,6 +110,7 @@ class SelectOptionTag extends StatelessWidget {
Flexible(
child: FlowyText.medium(
name,
fontSize: FontSizes.s11,
overflow: TextOverflow.ellipsis,
color: AFThemeExtension.of(context).textColor,
),
@@ -13,9 +13,11 @@ import 'select_option_editor.dart';
class SelectOptionCellStyle extends GridCellStyle {
String placeholder;
EdgeInsets? cellPadding;
SelectOptionCellStyle({
required this.placeholder,
this.cellPadding,
});
}
@@ -170,10 +172,7 @@ class _SelectOptionWrapState extends State<SelectOptionWrap> {
final Widget child = _buildOptions(context);
final constraints = BoxConstraints.loose(
Size(
SelectOptionCellEditor.editorPanelWidth,
300,
),
Size(SelectOptionCellEditor.editorPanelWidth, 300),
);
return AppFlowyPopover(
controller: widget.popoverController,
@@ -191,7 +190,7 @@ class _SelectOptionWrapState extends State<SelectOptionWrap> {
},
onClose: () => widget.onCellEditing.value = false,
child: Padding(
padding: GridSize.cellContentInsets,
padding: widget.cellStyle?.cellPadding ?? GridSize.cellContentInsets,
child: child,
),
);
@@ -200,9 +199,12 @@ class _SelectOptionWrapState extends State<SelectOptionWrap> {
Widget _buildOptions(BuildContext context) {
final Widget child;
if (widget.selectOptions.isEmpty && widget.cellStyle != null) {
child = FlowyText.medium(
widget.cellStyle!.placeholder,
color: Theme.of(context).hintColor,
child = Padding(
padding: const EdgeInsets.symmetric(vertical: 1),
child: FlowyText.medium(
widget.cellStyle!.placeholder,
color: Theme.of(context).hintColor,
),
);
} else {
final children = widget.selectOptions.map(
@@ -14,6 +14,7 @@ class GridTextCellStyle extends GridCellStyle {
double emojiFontSize;
double emojiHPadding;
bool showEmoji;
EdgeInsets? cellPadding;
GridTextCellStyle({
this.placeholder,
@@ -22,6 +23,7 @@ class GridTextCellStyle extends GridCellStyle {
this.showEmoji = true,
this.emojiFontSize = 16,
this.emojiHPadding = 0,
this.cellPadding,
});
}
@@ -72,10 +74,11 @@ class _GridTextCellState extends GridEditableTextCell<GridTextCell> {
}
},
child: Padding(
padding: EdgeInsets.only(
left: GridSize.cellContentInsets.left,
right: GridSize.cellContentInsets.right,
),
padding: widget.cellStyle.cellPadding ??
EdgeInsets.only(
left: GridSize.cellContentInsets.left,
right: GridSize.cellContentInsets.right,
),
child: Row(
children: [
if (widget.cellStyle.showEmoji)
@@ -0,0 +1,102 @@
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cell_builder.dart';
import 'package:appflowy/plugins/database_view/widgets/row/cells/timestamp_cell/timestamp_cell_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.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 TimestampCellStyle extends GridCellStyle {
String? placeholder;
Alignment alignment;
EdgeInsets? cellPadding;
TimestampCellStyle({
this.placeholder,
this.alignment = Alignment.center,
this.cellPadding,
});
}
class GridTimestampCell extends GridCellWidget {
/// The [GridTimestampCell] is used by both [FieldType.CreatedTime]
/// and [FieldType.LastEditedTime]. So it needs to know the field type.
final FieldType fieldType;
final CellControllerBuilder cellControllerBuilder;
late final TimestampCellStyle? cellStyle;
GridTimestampCell({
GridCellStyle? style,
required this.fieldType,
required this.cellControllerBuilder,
Key? key,
}) : super(key: key) {
if (style != null) {
cellStyle = (style as TimestampCellStyle);
} else {
cellStyle = null;
}
}
@override
GridCellState<GridTimestampCell> createState() => _TimestampCellState();
}
class _TimestampCellState extends GridCellState<GridTimestampCell> {
late TimestampCellBloc _cellBloc;
@override
void initState() {
final cellController =
widget.cellControllerBuilder.build() as TimestampCellController;
_cellBloc = TimestampCellBloc(cellController: cellController)
..add(const TimestampCellEvent.initial());
super.initState();
}
@override
Widget build(BuildContext context) {
final alignment = widget.cellStyle?.alignment ?? Alignment.centerLeft;
final placeholder = widget.cellStyle?.placeholder ?? "";
final padding = widget.cellStyle?.cellPadding ?? GridSize.cellContentInsets;
return BlocProvider.value(
value: _cellBloc,
child: BlocBuilder<TimestampCellBloc, TimestampCellState>(
builder: (context, state) {
final isEmpty = state.dateStr.isEmpty;
final text = isEmpty ? placeholder : state.dateStr;
return Align(
alignment: alignment,
child: Padding(
padding: padding,
child: FlowyText.medium(
text,
color: isEmpty
? Theme.of(context).hintColor
: AFThemeExtension.of(context).textColor,
maxLines: null,
),
),
);
},
),
);
}
@override
Future<void> dispose() async {
_cellBloc.close();
super.dispose();
}
@override
String? onCopy() => _cellBloc.state.dateStr;
@override
void requestBeginFocus() {
return;
}
}
@@ -0,0 +1,80 @@
import 'dart:async';
import 'package:appflowy/plugins/database_view/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database_view/application/field/field_info.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/timestamp_entities.pb.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'timestamp_cell_bloc.freezed.dart';
class TimestampCellBloc extends Bloc<TimestampCellEvent, TimestampCellState> {
final TimestampCellController cellController;
void Function()? _onCellChangedFn;
TimestampCellBloc({required this.cellController})
: super(TimestampCellState.initial(cellController)) {
on<TimestampCellEvent>(
(event, emit) async {
event.when(
initial: () => _startListening(),
didReceiveCellUpdate: (TimestampCellDataPB? cellData) {
emit(
state.copyWith(
data: cellData,
dateStr: cellData?.dateTime ?? "",
),
);
},
);
},
);
}
@override
Future<void> close() async {
if (_onCellChangedFn != null) {
cellController.removeListener(_onCellChangedFn!);
_onCellChangedFn = null;
}
await cellController.dispose();
return super.close();
}
void _startListening() {
_onCellChangedFn = cellController.startListening(
onCellChanged: ((data) {
if (!isClosed) {
add(TimestampCellEvent.didReceiveCellUpdate(data));
}
}),
);
}
}
@freezed
class TimestampCellEvent with _$TimestampCellEvent {
const factory TimestampCellEvent.initial() = _InitialCell;
const factory TimestampCellEvent.didReceiveCellUpdate(
TimestampCellDataPB? data,
) = _DidReceiveCellUpdate;
}
@freezed
class TimestampCellState with _$TimestampCellState {
const factory TimestampCellState({
required TimestampCellDataPB? data,
required String dateStr,
required FieldInfo fieldInfo,
}) = _TimestampCellState;
factory TimestampCellState.initial(TimestampCellController context) {
final cellData = context.getCellData();
return TimestampCellState(
fieldInfo: context.fieldInfo,
data: cellData,
dateStr: cellData?.dateTime ?? "",
);
}
}
@@ -1,18 +1,10 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart';
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_service.dart';
import 'package:appflowy/plugins/database_view/application/row/row_controller.dart';
import 'package:appflowy/plugins/database_view/grid/application/row/row_detail_bloc.dart';
import 'package:appflowy/plugins/database_view/grid/application/row/row_action_sheet_bloc.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_editor.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_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';
@@ -20,36 +12,39 @@ import 'package:flutter_bloc/flutter_bloc.dart';
class RowActionList extends StatelessWidget {
final RowController rowController;
const RowActionList({
required String viewId,
required this.rowController,
Key? key,
}) : super(key: key);
super.key,
});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.only(left: 10),
child: FlowyText(LocaleKeys.grid_row_action.tr()),
return BlocProvider<RowActionSheetBloc>(
create: (context) => RowActionSheetBloc(
viewId: rowController.viewId,
rowId: rowController.rowId,
groupId: rowController.groupId,
),
child: IntrinsicWidth(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
RowDetailPageDuplicateButton(
rowId: rowController.rowId,
groupId: rowController.groupId,
),
const VSpace(4.0),
RowDetailPageDeleteButton(rowId: rowController.rowId),
],
),
const VSpace(15),
RowDetailPageDeleteButton(rowId: rowController.rowId),
RowDetailPageDuplicateButton(
rowId: rowController.rowId,
groupId: rowController.groupId,
),
],
),
);
}
}
class RowDetailPageDeleteButton extends StatelessWidget {
final String rowId;
const RowDetailPageDeleteButton({required this.rowId, Key? key})
: super(key: key);
const RowDetailPageDeleteButton({required this.rowId, super.key});
@override
Widget build(BuildContext context) {
@@ -59,7 +54,9 @@ class RowDetailPageDeleteButton extends StatelessWidget {
text: FlowyText.regular(LocaleKeys.grid_row_delete.tr()),
leftIcon: const FlowySvg(FlowySvgs.trash_m),
onTap: () {
context.read<RowDetailBloc>().add(RowDetailEvent.deleteRow(rowId));
context
.read<RowActionSheetBloc>()
.add(const RowActionSheetEvent.deleteRow());
FlowyOverlay.pop(context);
},
),
@@ -73,8 +70,8 @@ class RowDetailPageDuplicateButton extends StatelessWidget {
const RowDetailPageDuplicateButton({
required this.rowId,
this.groupId,
Key? key,
}) : super(key: key);
super.key,
});
@override
Widget build(BuildContext context) {
@@ -85,91 +82,11 @@ class RowDetailPageDuplicateButton extends StatelessWidget {
leftIcon: const FlowySvg(FlowySvgs.copy_s),
onTap: () {
context
.read<RowDetailBloc>()
.add(RowDetailEvent.duplicateRow(rowId, groupId));
.read<RowActionSheetBloc>()
.add(const RowActionSheetEvent.duplicateRow());
FlowyOverlay.pop(context);
},
),
);
}
}
class CreateRowFieldButton extends StatefulWidget {
final String viewId;
const CreateRowFieldButton({
required this.viewId,
Key? key,
}) : super(key: key);
@override
State<CreateRowFieldButton> createState() => _CreateRowFieldButtonState();
}
class _CreateRowFieldButtonState extends State<CreateRowFieldButton> {
late PopoverController popoverController;
late TypeOptionPB typeOption;
@override
void initState() {
popoverController = PopoverController();
super.initState();
}
@override
Widget build(BuildContext context) {
return AppFlowyPopover(
constraints: BoxConstraints.loose(const Size(240, 200)),
controller: popoverController,
direction: PopoverDirection.topWithLeftAligned,
triggerActions: PopoverTriggerFlags.none,
margin: EdgeInsets.zero,
child: SizedBox(
height: 40,
child: FlowyButton(
text: FlowyText.medium(
LocaleKeys.grid_field_newProperty.tr(),
color: AFThemeExtension.of(context).textColor,
),
hoverColor: AFThemeExtension.of(context).lightGreyHover,
onTap: () async {
final result = await TypeOptionBackendService.createFieldTypeOption(
viewId: widget.viewId,
);
result.fold(
(l) {
typeOption = l;
popoverController.show();
},
(r) => Log.error("Failed to create field type option: $r"),
);
},
leftIcon: FlowySvg(
FlowySvgs.add_m,
color: AFThemeExtension.of(context).textColor,
),
),
),
popupBuilder: (BuildContext popOverContext) {
return FieldEditor(
viewId: widget.viewId,
typeOptionLoader: FieldTypeOptionLoader(
viewId: widget.viewId,
field: typeOption.field_2,
),
onDeleted: (fieldId) {
popoverController.close();
NavigatorAlertDialog(
title: LocaleKeys.grid_field_deleteFieldPromptMessage.tr(),
confirm: () {
context
.read<RowDetailBloc>()
.add(RowDetailEvent.deleteField(fieldId));
},
).show(context);
},
);
},
);
}
}
@@ -1,23 +1,27 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart';
import 'package:appflowy/plugins/database_view/application/field/field_info.dart';
import 'package:appflowy/plugins/database_view/application/row/row_banner_bloc.dart';
import 'package:appflowy/plugins/database_view/application/row/row_controller.dart';
import 'package:appflowy/plugins/database_view/widgets/row/row_action.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/emoji_picker/emoji_picker.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
typedef RowBannerCellBuilder = Widget Function(String fieldId);
import 'cell_builder.dart';
import 'cells/cells.dart';
class RowBanner extends StatefulWidget {
final String viewId;
final RowMetaPB rowMeta;
final RowBannerCellBuilder cellBuilder;
final RowController rowController;
final GridCellBuilder cellBuilder;
const RowBanner({
required this.viewId,
required this.rowMeta,
required this.rowController,
required this.cellBuilder,
super.key,
});
@@ -34,25 +38,39 @@ class _RowBannerState extends State<RowBanner> {
Widget build(BuildContext context) {
return BlocProvider<RowBannerBloc>(
create: (context) => RowBannerBloc(
viewId: widget.viewId,
rowMeta: widget.rowMeta,
viewId: widget.rowController.viewId,
rowMeta: widget.rowController.rowMeta,
)..add(const RowBannerEvent.initial()),
child: MouseRegion(
onEnter: (event) => _isHovering.value = true,
onExit: (event) => _isHovering.value = false,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
child: Stack(
children: [
SizedBox(
height: 30,
child: _BannerAction(
isHovering: _isHovering,
popoverController: popoverController,
Padding(
padding: const EdgeInsets.fromLTRB(60, 34, 60, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox(
height: 30,
child: _BannerAction(
isHovering: _isHovering,
popoverController: popoverController,
),
),
const HSpace(4),
_BannerTitle(
cellBuilder: widget.cellBuilder,
popoverController: popoverController,
rowController: widget.rowController,
),
],
),
),
_BannerTitle(
cellBuilder: widget.cellBuilder,
popoverController: popoverController,
Positioned(
top: 12,
right: 12,
child: RowActionButton(rowController: widget.rowController),
),
],
),
@@ -74,50 +92,54 @@ class _BannerAction extends StatelessWidget {
return ValueListenableBuilder(
valueListenable: isHovering,
builder: (BuildContext context, bool value, Widget? child) {
if (value) {
return BlocBuilder<RowBannerBloc, RowBannerState>(
builder: (context, state) {
final children = <Widget>[];
final rowMeta = state.rowMeta;
if (rowMeta.icon.isEmpty) {
children.add(
EmojiPickerButton(
showEmojiPicker: () => popoverController.show(),
),
);
} else {
children.add(
RemoveEmojiButton(
onRemoved: () {
context
.read<RowBannerBloc>()
.add(const RowBannerEvent.setIcon(''));
},
),
);
}
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
);
},
);
} else {
if (!value) {
return const SizedBox(height: _kBannerActionHeight);
}
return BlocBuilder<RowBannerBloc, RowBannerState>(
builder: (context, state) {
final children = <Widget>[];
final rowMeta = state.rowMeta;
if (rowMeta.icon.isEmpty) {
children.add(
EmojiPickerButton(
showEmojiPicker: () => popoverController.show(),
),
);
} else {
children.add(
RemoveEmojiButton(
onRemoved: () {
context
.read<RowBannerBloc>()
.add(const RowBannerEvent.setIcon(''));
},
),
);
}
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: children,
);
},
);
},
);
}
}
class _BannerTitle extends StatefulWidget {
final RowBannerCellBuilder cellBuilder;
final GridCellBuilder cellBuilder;
final PopoverController popoverController;
final RowController rowController;
const _BannerTitle({
required this.cellBuilder,
required this.popoverController,
});
required this.rowController,
Key? key,
}) : super(key: key);
@override
State<_BannerTitle> createState() => _BannerTitleState();
@@ -139,10 +161,24 @@ class _BannerTitleState extends State<_BannerTitle> {
);
}
children.add(const HSpace(4));
if (state.primaryField != null) {
final style = GridTextCellStyle(
placeholder: LocaleKeys.grid_row_titlePlaceholder.tr(),
textStyle: Theme.of(context).textTheme.titleLarge,
showEmoji: false,
autofocus: true,
cellPadding: EdgeInsets.zero,
);
final cellContext = DatabaseCellContext(
viewId: widget.rowController.viewId,
rowMeta: widget.rowController.rowMeta,
fieldInfo: FieldInfo.initial(state.primaryField!),
);
children.add(
Expanded(
child: widget.cellBuilder(state.primaryField!.id),
child: widget.cellBuilder.build(cellContext, style: style),
),
);
}
@@ -211,16 +247,14 @@ class _EmojiPickerButtonState extends State<EmojiPickerButton> {
Widget build(BuildContext context) {
return SizedBox(
height: 26,
width: 160,
child: FlowyButton(
useIntrinsicWidth: true,
text: FlowyText.medium(
LocaleKeys.document_plugins_cover_addIcon.tr(),
),
leftIcon: const Icon(
Icons.emoji_emotions,
size: 16,
),
leftIcon: const FlowySvg(FlowySvgs.emoji_s),
onTap: widget.showEmojiPicker,
margin: const EdgeInsets.all(4),
),
);
}
@@ -239,16 +273,14 @@ class RemoveEmojiButton extends StatelessWidget {
Widget build(BuildContext context) {
return SizedBox(
height: 26,
width: 160,
child: FlowyButton(
useIntrinsicWidth: true,
text: FlowyText.medium(
LocaleKeys.document_plugins_cover_removeIcon.tr(),
),
leftIcon: const Icon(
Icons.emoji_emotions,
size: 16,
),
leftIcon: const FlowySvg(FlowySvgs.emoji_s),
onTap: onRemoved,
margin: const EdgeInsets.all(4),
),
);
}
@@ -263,3 +295,22 @@ Widget _buildEmojiPicker(OnSubmittedEmoji onSubmitted) {
),
);
}
class RowActionButton extends StatelessWidget {
final RowController rowController;
const RowActionButton({super.key, required this.rowController});
@override
Widget build(BuildContext context) {
return AppFlowyPopover(
direction: PopoverDirection.bottomWithLeftAligned,
popupBuilder: (context) => RowActionList(rowController: rowController),
child: FlowyIconButton(
width: 20,
height: 20,
icon: const FlowySvg(FlowySvgs.details_horizontal_s),
iconColorOnHover: Theme.of(context).colorScheme.onSecondary,
),
);
}
}
@@ -1,18 +1,12 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart';
import 'package:appflowy/plugins/database_view/application/row/row_controller.dart';
import 'package:appflowy/plugins/database_view/grid/application/row/row_detail_bloc.dart';
import 'package:appflowy/plugins/database_view/widgets/row/row_document.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'cell_builder.dart';
import 'cells/text_cell/text_cell.dart';
import 'row_action.dart';
import 'row_banner.dart';
import 'row_property.dart';
@@ -47,17 +41,29 @@ class _RowDetailPageState extends State<RowDetailPage> {
Widget build(BuildContext context) {
return FlowyDialog(
child: BlocProvider(
create: (context) {
return RowDetailBloc(rowController: widget.rowController)
..add(const RowDetailEvent.initial());
},
create: (context) => RowDetailBloc(rowController: widget.rowController)
..add(const RowDetailEvent.initial()),
child: ListView(
controller: scrollController,
children: [
_rowBanner(),
IntrinsicHeight(child: _responsiveRowInfo()),
const Divider(height: 1.0),
const VSpace(10),
RowBanner(
rowController: widget.rowController,
cellBuilder: widget.cellBuilder,
),
const VSpace(16),
Padding(
padding: const EdgeInsets.only(left: 40, right: 60),
child: RowPropertyList(
cellBuilder: widget.cellBuilder,
viewId: widget.rowController.viewId,
),
),
const VSpace(20),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 60),
child: Divider(height: 1.0),
),
const VSpace(20),
RowDocument(
viewId: widget.rowController.viewId,
rowId: widget.rowController.rowId,
@@ -68,104 +74,4 @@ class _RowDetailPageState extends State<RowDetailPage> {
),
);
}
Widget _rowBanner() {
return BlocBuilder<RowDetailBloc, RowDetailState>(
builder: (context, state) {
final paddingOffset = getHorizontalPadding(context);
return Padding(
padding: EdgeInsets.only(
left: paddingOffset,
right: paddingOffset,
top: 20,
),
child: RowBanner(
rowMeta: widget.rowController.rowMeta,
viewId: widget.rowController.viewId,
cellBuilder: (fieldId) {
final fieldInfo = state.cells
.firstWhereOrNull(
(e) => e.fieldInfo.field.id == fieldId,
)
?.fieldInfo;
if (fieldInfo != null) {
final style = GridTextCellStyle(
placeholder: LocaleKeys.grid_row_titlePlaceholder.tr(),
textStyle: Theme.of(context).textTheme.titleLarge,
showEmoji: false,
autofocus: true,
);
final cellContext = DatabaseCellContext(
viewId: widget.rowController.viewId,
rowMeta: widget.rowController.rowMeta,
fieldInfo: fieldInfo,
);
return widget.cellBuilder.build(cellContext, style: style);
} else {
return const SizedBox.shrink();
}
},
),
);
},
);
}
Widget _responsiveRowInfo() {
final rowDataColumn = RowPropertyList(
cellBuilder: widget.cellBuilder,
viewId: widget.rowController.viewId,
);
final rowOptionColumn = RowActionList(
viewId: widget.rowController.viewId,
rowController: widget.rowController,
);
final paddingOffset = getHorizontalPadding(context);
if (MediaQuery.of(context).size.width > 800) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Flexible(
flex: 3,
child: Padding(
padding: EdgeInsets.fromLTRB(paddingOffset, 0, 20, 20),
child: rowDataColumn,
),
),
const VerticalDivider(width: 1.0),
Flexible(
child: Padding(
padding: EdgeInsets.fromLTRB(20, 0, paddingOffset, 0),
child: rowOptionColumn,
),
),
],
);
} else {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: EdgeInsets.fromLTRB(paddingOffset, 0, 20, 20),
child: rowDataColumn,
),
const Divider(height: 1.0),
Padding(
padding: EdgeInsets.symmetric(horizontal: paddingOffset),
child: rowOptionColumn,
)
],
);
}
}
}
double getHorizontalPadding(BuildContext context) {
if (MediaQuery.of(context).size.width > 800) {
return 50;
} else {
return 20;
}
}
@@ -1,28 +1,34 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database_view/application/cell/cell_service.dart';
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_context.dart';
import 'package:appflowy/plugins/database_view/application/field/type_option/type_option_service.dart';
import 'package:appflowy/plugins/database_view/grid/application/row/row_detail_bloc.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_cell.dart';
import 'package:appflowy/plugins/database_view/grid/presentation/widgets/header/field_editor.dart';
import 'package:appflowy/plugins/database_view/widgets/row/row_action.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:collection/collection.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 'accessory/cell_accessory.dart';
import 'cell_builder.dart';
import 'cells/checkbox_cell/checkbox_cell.dart';
import 'cells/date_cell/date_cell.dart';
import 'cells/number_cell/number_cell.dart';
import 'cells/select_option_cell/select_option_cell.dart';
import 'cells/text_cell/text_cell.dart';
import 'cells/timestamp_cell/timestamp_cell.dart';
import 'cells/url_cell/url_cell.dart';
/// Display the row properties in a list. Only use this widget in the
/// [RowDetailPage].
///
class RowPropertyList extends StatelessWidget {
final String viewId;
final GridCellBuilder cellBuilder;
@@ -37,39 +43,88 @@ class RowPropertyList extends StatelessWidget {
return BlocBuilder<RowDetailBloc, RowDetailState>(
buildWhen: (previous, current) => previous.cells != current.cells,
builder: (context, state) {
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// The rest of the fields are displayed in the order of the field
// list
...state.cells
.where((element) => !element.fieldInfo.field.isPrimary)
.map(
(cell) => _PropertyCell(
cellContext: cell,
cellBuilder: cellBuilder,
),
)
.toList(),
const VSpace(20),
// Create a new property(field) button
CreateRowFieldButton(viewId: viewId),
],
final children = state.cells
.where((element) => !element.fieldInfo.field.isPrimary)
.mapIndexed(
(index, cell) => _PropertyCell(
key: ValueKey('row_detail_${cell.fieldId}'),
cellContext: cell,
cellBuilder: cellBuilder,
index: index,
),
)
.toList();
return ReorderableListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
onReorder: (oldIndex, newIndex) {
final reorderedField = children[oldIndex].cellContext.fieldId;
_reorderField(
context,
state.cells,
reorderedField,
oldIndex,
newIndex,
);
},
buildDefaultDragHandles: false,
proxyDecorator: (child, index, animation) => Material(
color: Colors.transparent,
child: Stack(
children: [
child,
const MouseRegion(cursor: SystemMouseCursors.grabbing),
],
),
),
footer: Padding(
padding: const EdgeInsets.only(left: 20),
child: CreateRowFieldButton(viewId: viewId),
),
children: children,
);
},
);
}
void _reorderField(
BuildContext context,
List<DatabaseCellContext> cells,
String reorderedFieldId,
int oldIndex,
int newIndex,
) {
// when reorderiing downwards, need to update index
if (oldIndex < newIndex) {
newIndex--;
}
// also update index when the index is after the index of the primary field
// in the original list of DatabaseCellContext's
final primaryFieldIndex =
cells.indexWhere((element) => element.fieldInfo.isPrimary);
if (oldIndex >= primaryFieldIndex) {
oldIndex++;
}
if (newIndex >= primaryFieldIndex) {
newIndex++;
}
context.read<RowDetailBloc>().add(
RowDetailEvent.reorderField(reorderedFieldId, oldIndex, newIndex),
);
}
}
class _PropertyCell extends StatefulWidget {
final DatabaseCellContext cellContext;
final GridCellBuilder cellBuilder;
final int index;
const _PropertyCell({
required this.cellContext,
required this.cellBuilder,
Key? key,
required this.index,
}) : super(key: key);
@override
@@ -77,45 +132,65 @@ class _PropertyCell extends StatefulWidget {
}
class _PropertyCellState extends State<_PropertyCell> {
final PopoverController popover = PopoverController();
final PopoverController _popoverController = PopoverController();
bool _isFieldHover = false;
@override
Widget build(BuildContext context) {
final style = _customCellStyle(widget.cellContext.fieldType);
final cell = widget.cellBuilder.build(widget.cellContext, style: style);
final gesture = GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => cell.requestFocus.notify(),
child: AccessoryHover(
contentPadding: const EdgeInsets.symmetric(horizontal: 3, vertical: 3),
child: cell,
final dragThumb = MouseRegion(
cursor: SystemMouseCursors.grab,
child: SizedBox(
width: 16,
height: 30,
child: _isFieldHover ? const FlowySvg(FlowySvgs.drag_element_s) : null,
),
);
return IntrinsicHeight(
child: ConstrainedBox(
constraints: const BoxConstraints(minHeight: 30),
final gesture = GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => cell.requestFocus.notify(),
child: AccessoryHover(child: cell),
);
return Container(
margin: const EdgeInsets.only(bottom: 8),
constraints: const BoxConstraints(minHeight: 30),
child: MouseRegion(
onEnter: (event) => setState(() => _isFieldHover = true),
onExit: (event) => setState(() => _isFieldHover = false),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
children: [
ReorderableDragStartListener(
index: widget.index,
enabled: _isFieldHover,
child: dragThumb,
),
const HSpace(4),
AppFlowyPopover(
controller: popover,
controller: _popoverController,
constraints: BoxConstraints.loose(const Size(240, 600)),
margin: EdgeInsets.zero,
triggerActions: PopoverTriggerFlags.none,
direction: PopoverDirection.bottomWithLeftAligned,
popupBuilder: (popoverContext) => buildFieldEditor(),
child: SizedBox(
width: 150,
height: 40,
width: 160,
height: 30,
child: FieldCellButton(
field: widget.cellContext.fieldInfo.field,
onTap: () => popover.show(),
onTap: () => _popoverController.show(),
radius: BorderRadius.circular(6),
margin:
const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
),
),
),
const HSpace(8),
Expanded(child: gesture),
],
),
@@ -132,11 +207,11 @@ class _PropertyCellState extends State<_PropertyCell> {
field: widget.cellContext.fieldInfo.field,
),
onHidden: (fieldId) {
popover.close();
_popoverController.close();
context.read<RowDetailBloc>().add(RowDetailEvent.hideField(fieldId));
},
onDeleted: (fieldId) {
popover.close();
_popoverController.close();
NavigatorAlertDialog(
title: LocaleKeys.grid_field_deleteFieldPromptMessage.tr(),
@@ -154,23 +229,35 @@ class _PropertyCellState extends State<_PropertyCell> {
GridCellStyle? _customCellStyle(FieldType fieldType) {
switch (fieldType) {
case FieldType.Checkbox:
return null;
return GridCheckboxCellStyle(
cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
);
case FieldType.DateTime:
return DateCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
alignment: Alignment.centerLeft,
cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
);
case FieldType.LastEditedTime:
case FieldType.CreatedTime:
return DateCellStyle(
return TimestampCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
alignment: Alignment.centerLeft,
cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6),
);
case FieldType.MultiSelect:
return SelectOptionCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
);
case FieldType.Checklist:
return SelectOptionCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
);
case FieldType.Number:
return null;
return GridNumberCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
);
case FieldType.RichText:
return GridTextCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
@@ -178,6 +265,7 @@ GridCellStyle? _customCellStyle(FieldType fieldType) {
case FieldType.SingleSelect:
return SelectOptionCellStyle(
placeholder: LocaleKeys.grid_row_textPlaceholder.tr(),
cellPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 5),
);
case FieldType.URL:
@@ -191,3 +279,81 @@ GridCellStyle? _customCellStyle(FieldType fieldType) {
}
throw UnimplementedError;
}
class CreateRowFieldButton extends StatefulWidget {
final String viewId;
const CreateRowFieldButton({required this.viewId, super.key});
@override
State<CreateRowFieldButton> createState() => _CreateRowFieldButtonState();
}
class _CreateRowFieldButtonState extends State<CreateRowFieldButton> {
late PopoverController popoverController;
late TypeOptionPB typeOption;
@override
void initState() {
popoverController = PopoverController();
super.initState();
}
@override
Widget build(BuildContext context) {
return AppFlowyPopover(
constraints: BoxConstraints.loose(const Size(240, 200)),
controller: popoverController,
direction: PopoverDirection.topWithLeftAligned,
triggerActions: PopoverTriggerFlags.none,
margin: EdgeInsets.zero,
child: SizedBox(
height: 30,
child: FlowyButton(
margin: const EdgeInsets.symmetric(horizontal: 4, vertical: 6),
text: FlowyText.medium(
LocaleKeys.grid_field_newProperty.tr(),
color: Theme.of(context).hintColor,
),
hoverColor: AFThemeExtension.of(context).lightGreyHover,
onTap: () async {
final result = await TypeOptionBackendService.createFieldTypeOption(
viewId: widget.viewId,
);
result.fold(
(l) {
typeOption = l;
popoverController.show();
},
(r) => Log.error("Failed to create field type option: $r"),
);
},
leftIcon: FlowySvg(
FlowySvgs.add_m,
color: Theme.of(context).hintColor,
),
),
),
popupBuilder: (BuildContext popOverContext) {
return FieldEditor(
viewId: widget.viewId,
typeOptionLoader: FieldTypeOptionLoader(
viewId: widget.viewId,
field: typeOption.field_2,
),
onDeleted: (fieldId) {
popoverController.close();
NavigatorAlertDialog(
title: LocaleKeys.grid_field_deleteFieldPromptMessage.tr(),
confirm: () {
context
.read<RowDetailBloc>()
.add(RowDetailEvent.deleteField(fieldId));
},
).show(context);
},
);
},
);
}
}
@@ -5,7 +5,6 @@ import 'package:appflowy/plugins/document/application/document_data_pb_extension
import 'package:appflowy/plugins/document/application/editor_transaction_adapter.dart';
import 'package:appflowy/plugins/trash/application/trash_service.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/util/json_print.dart';
import 'package:appflowy/workspace/application/doc/doc_listener.dart';
import 'package:appflowy/workspace/application/view/view_listener.dart';
import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart';
@@ -122,10 +121,7 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
/// subscribe to the document content change
void _onDocumentChanged() {
_documentListener.start(
didReceiveUpdate: (docEvent) {
// todo: integrate the document change to the editor
// prettyPrintJson(docEvent.toProto3Json());
},
didReceiveUpdate: syncDocumentDataPB,
);
}
@@ -143,10 +139,6 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
}
Future<void> _initAppFlowyEditorState(DocumentDataPB data) async {
if (kDebugMode) {
prettyPrintJson(data.toProto3Json());
}
final document = data.toDocument();
if (document == null) {
assert(false, 'document is null');
@@ -213,6 +205,24 @@ class DocumentBloc extends Bloc<DocumentEvent, DocumentState> {
await editorState.apply(transaction);
}
}
void syncDocumentDataPB(DocEventPB docEvent) {
// prettyPrintJson(docEvent.toProto3Json());
// todo: integrate the document change to the editor
// for (final event in docEvent.events) {
// for (final blockEvent in event.event) {
// switch (blockEvent.command) {
// case DeltaTypePB.Inserted:
// break;
// case DeltaTypePB.Updated:
// break;
// case DeltaTypePB.Removed:
// break;
// default:
// }
// }
// }
}
}
@freezed
@@ -1,9 +1,8 @@
import 'package:dartz/dartz.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-document2/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/view.pb.dart';
import 'package:dartz/dartz.dart';
class DocumentService {
// unused now.
@@ -46,4 +45,42 @@ class DocumentService {
final result = await DocumentEventApplyAction(payload).send();
return result.swap();
}
/// Creates a new external text.
///
/// Normally, it's used to the block that needs sync long text.
///
/// the delta parameter is the json representation of the delta.
Future<Either<FlowyError, Unit>> createExternalText({
required String documentId,
required String textId,
String? delta,
}) async {
final payload = TextDeltaPayloadPB(
documentId: documentId,
textId: textId,
delta: delta,
);
final result = await DocumentEventCreateText(payload).send();
return result.swap();
}
/// Updates the external text.
///
/// this function is compatible with the [createExternalText] function.
///
/// the delta parameter is the json representation of the delta too.
Future<Either<FlowyError, Unit>> updateExternalText({
required String documentId,
required String textId,
String? delta,
}) async {
final payload = TextDeltaPayloadPB(
documentId: documentId,
textId: textId,
delta: delta,
);
final result = await DocumentEventApplyTextDeltaEvent(payload).send();
return result.swap();
}
}
@@ -3,10 +3,27 @@ import 'dart:convert';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-document2/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart'
show Document, Node, Attributes, Delta, ParagraphBlockKeys, NodeIterator;
show
Document,
Node,
Attributes,
Delta,
ParagraphBlockKeys,
NodeIterator,
NodeExternalValues;
import 'package:collection/collection.dart';
import 'package:nanoid/nanoid.dart';
class ExternalValues extends NodeExternalValues {
const ExternalValues({
required this.externalId,
required this.externalType,
});
final String externalId;
final String externalType;
}
extension DocumentDataPBFromTo on DocumentDataPB {
static DocumentDataPB? fromDocument(Document document) {
final startNode = document.first;
@@ -84,24 +101,51 @@ extension DocumentDataPBFromTo on DocumentDataPB {
children.addAll(childrenIds.map((e) => buildNode(e)).whereNotNull());
}
return block?.toNode(children: children);
return block?.toNode(
children: children,
meta: meta,
);
}
}
extension BlockToNode on BlockPB {
Node toNode({
Iterable<Node>? children,
required MetaPB meta,
}) {
return Node(
final node = Node(
id: id,
type: ty,
attributes: _dataAdapter(ty, data),
attributes: _dataAdapter(ty, data, meta),
children: children ?? [],
);
node.externalValues = ExternalValues(
externalId: externalId,
externalType: externalType,
);
return node;
}
Attributes _dataAdapter(String ty, String data) {
Attributes _dataAdapter(String ty, String data, MetaPB meta) {
final map = Attributes.from(jsonDecode(data));
// it used in the delta case now.
final externalType = this.externalType;
final externalId = this.externalId;
if (externalType.isNotEmpty && externalId.isNotEmpty) {
// the 'text' type is the only type that is supported now.
if (externalType == 'text') {
final deltaString = meta.textMap[externalId];
if (deltaString != null) {
final delta = jsonDecode(deltaString);
map.putIfAbsent(
'delta',
() => delta,
);
}
}
}
final adapter = {
ParagraphBlockKeys.type: (Attributes map) => map
..putIfAbsent(
@@ -1,4 +1,5 @@
import 'dart:async';
import 'dart:convert';
import 'package:appflowy/plugins/document/application/doc_service.dart';
import 'package:appflowy/plugins/document/application/document_data_pb_extension.dart';
@@ -15,6 +16,7 @@ import 'package:appflowy_editor/appflowy_editor.dart'
PathExtensions,
Node,
Path,
Delta,
composeAttributes;
import 'package:collection/collection.dart';
import 'package:nanoid/nanoid.dart';
@@ -32,28 +34,72 @@ class TransactionAdapter {
final DocumentService documentService;
final String documentId;
final bool _enableDebug = true;
Future<void> apply(Transaction transaction, EditorState editorState) async {
Log.debug('transaction => ${transaction.toJson()}');
final stopwatch = Stopwatch()..start();
if (_enableDebug) {
Log.debug('transaction => ${transaction.toJson()}');
}
final actions = transaction.operations
.map((op) => op.toBlockAction(editorState))
.map((op) => op.toBlockAction(editorState, documentId))
.whereNotNull()
.expand((element) => element)
.toList(growable: false); // avoid lazy evaluation
Log.debug('actions => $actions');
final textActions = actions.where(
(e) =>
e.textDeltaType != TextDeltaType.none && e.textDeltaPayloadPB != null,
);
final actionCostTime = stopwatch.elapsedMilliseconds;
for (final textAction in textActions) {
final payload = textAction.textDeltaPayloadPB!;
final type = textAction.textDeltaType;
if (type == TextDeltaType.create) {
await documentService.createExternalText(
documentId: payload.documentId,
textId: payload.textId,
delta: payload.delta,
);
if (_enableDebug) {
Log.debug('create external text: ${payload.delta}');
}
} else if (type == TextDeltaType.update) {
await documentService.updateExternalText(
documentId: payload.documentId,
textId: payload.textId,
delta: payload.delta,
);
if (_enableDebug) {
Log.debug('update external text: ${payload.delta}');
}
}
}
final blockActions =
actions.map((e) => e.blockActionPB).toList(growable: false);
await documentService.applyAction(
documentId: documentId,
actions: actions,
actions: blockActions,
);
final elapsed = stopwatch.elapsedMilliseconds;
stopwatch.stop();
if (_enableDebug) {
Log.debug(
'apply transaction cost: total $elapsed ms, converter action $actionCostTime ms, apply action ${elapsed - actionCostTime} ms',
);
}
}
}
extension BlockAction on Operation {
List<BlockActionPB> toBlockAction(EditorState editorState) {
List<BlockActionWrapper> toBlockAction(
EditorState editorState,
String documentId,
) {
final op = this;
if (op is InsertOperation) {
return op.toBlockAction(editorState);
return op.toBlockAction(editorState, documentId);
} else if (op is UpdateOperation) {
return op.toBlockAction(editorState);
return op.toBlockAction(editorState, documentId);
} else if (op is DeleteOperation) {
return op.toBlockAction(editorState);
}
@@ -62,12 +108,13 @@ extension BlockAction on Operation {
}
extension on InsertOperation {
List<BlockActionPB> toBlockAction(
EditorState editorState, {
List<BlockActionWrapper> toBlockAction(
EditorState editorState,
String documentId, {
Node? previousNode,
}) {
Path currentPath = path;
final List<BlockActionPB> actions = [];
final List<BlockActionWrapper> actions = [];
for (final node in nodes) {
final parentId = node.parent?.id ??
editorState.getNodeAtPath(currentPath.parent)?.id ??
@@ -82,22 +129,58 @@ extension on InsertOperation {
} else {
assert(prevId.isNotEmpty && prevId != node.id);
}
// create the external text if the node contains the delta in its data.
final delta = node.delta;
TextDeltaPayloadPB? textDeltaPayloadPB;
if (delta != null) {
final textId = nanoid(6);
textDeltaPayloadPB = TextDeltaPayloadPB(
documentId: documentId,
textId: textId,
delta: jsonEncode(node.delta!.toJson()),
);
// sync the text id to the node
node.externalValues = ExternalValues(
externalId: textId,
externalType: 'text',
);
}
// remove the delta from the data when the incremental update is stable.
final payload = BlockActionPayloadPB()
..block = node.toBlock(childrenId: nanoid(10))
..block = node.toBlock(childrenId: nanoid(6))
..parentId = parentId
..prevId = prevId;
// pass the external text id to the payload.
if (textDeltaPayloadPB != null) {
payload.textId = textDeltaPayloadPB.textId;
}
assert(payload.block.childrenId.isNotEmpty);
final blockActionPB = BlockActionPB()
..action = BlockActionTypePB.Insert
..payload = payload;
actions.add(
BlockActionPB()
..action = BlockActionTypePB.Insert
..payload = payload,
BlockActionWrapper(
blockActionPB: blockActionPB,
textDeltaPayloadPB: textDeltaPayloadPB,
textDeltaType: TextDeltaType.create,
),
);
if (node.children.isNotEmpty) {
Node? prevChild;
for (final child in node.children) {
actions.addAll(
InsertOperation(currentPath + child.path, [child])
.toBlockAction(editorState, previousNode: prevChild),
InsertOperation(currentPath + child.path, [child]).toBlockAction(
editorState,
documentId,
previousNode: prevChild,
),
);
prevChild = child;
}
@@ -110,8 +193,11 @@ extension on InsertOperation {
}
extension on UpdateOperation {
List<BlockActionPB> toBlockAction(EditorState editorState) {
final List<BlockActionPB> actions = [];
List<BlockActionWrapper> toBlockAction(
EditorState editorState,
String documentId,
) {
final List<BlockActionWrapper> actions = [];
// if the attributes are both empty, we don't need to update
if (const DeepCollectionEquality().equals(attributes, oldAttributes)) {
@@ -125,23 +211,74 @@ extension on UpdateOperation {
final parentId =
node.parent?.id ?? editorState.getNodeAtPath(path.parent)?.id ?? '';
assert(parentId.isNotEmpty);
// create the external text if the node contains the delta in its data.
final prevDelta = oldAttributes['delta'];
final delta = attributes['delta'];
final diff = prevDelta != null && delta != null
? Delta.fromJson(prevDelta).diff(
Delta.fromJson(delta),
)
: null;
final payload = BlockActionPayloadPB()
..block = node.toBlock(
parentId: parentId,
attributes: composeAttributes(oldAttributes, attributes),
)
..parentId = parentId;
actions.add(
BlockActionPB()
..action = BlockActionTypePB.Update
..payload = payload,
);
final blockActionPB = BlockActionPB()
..action = BlockActionTypePB.Update
..payload = payload;
final textId = (node.externalValues as ExternalValues?)?.externalId;
if (textId == null || textId.isEmpty) {
// to be compatible with the old version, we create a new text id if the text id is empty.
final textId = nanoid(6);
final textDeltaPayloadPB = delta == null
? null
: TextDeltaPayloadPB(
documentId: documentId,
textId: textId,
delta: jsonEncode(delta),
);
node.externalValues = ExternalValues(
externalId: textId,
externalType: 'text',
);
actions.add(
BlockActionWrapper(
blockActionPB: blockActionPB,
textDeltaPayloadPB: textDeltaPayloadPB,
textDeltaType: TextDeltaType.create,
),
);
} else {
final textDeltaPayloadPB = delta == null
? null
: TextDeltaPayloadPB(
documentId: documentId,
textId: textId,
delta: jsonEncode(diff),
);
actions.add(
BlockActionWrapper(
blockActionPB: blockActionPB,
textDeltaPayloadPB: textDeltaPayloadPB,
textDeltaType: TextDeltaType.update,
),
);
}
return actions;
}
}
extension on DeleteOperation {
List<BlockActionPB> toBlockAction(EditorState editorState) {
List<BlockActionWrapper> toBlockAction(EditorState editorState) {
final List<BlockActionPB> actions = [];
for (final node in nodes) {
final parentId =
@@ -158,6 +295,26 @@ extension on DeleteOperation {
..payload = payload,
);
}
return actions;
return actions
.map((e) => BlockActionWrapper(blockActionPB: e))
.toList(growable: false);
}
}
enum TextDeltaType {
none,
create,
update,
}
class BlockActionWrapper {
BlockActionWrapper({
required this.blockActionPB,
this.textDeltaType = TextDeltaType.none,
this.textDeltaPayloadPB,
});
final BlockActionPB blockActionPB;
final TextDeltaPayloadPB? textDeltaPayloadPB;
final TextDeltaType textDeltaType;
}
@@ -17,11 +17,12 @@ class DocumentBanner extends StatelessWidget {
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return ConstrainedBox(
constraints: const BoxConstraints(minHeight: 60),
child: Container(
width: double.infinity,
color: Theme.of(context).colorScheme.primary,
color: colorScheme.error,
child: FittedBox(
alignment: Alignment.center,
fit: BoxFit.scaleDown,
@@ -29,7 +30,8 @@ class DocumentBanner extends StatelessWidget {
children: [
FlowyText.medium(
LocaleKeys.deletePagePrompt_text.tr(),
color: Colors.white,
color: colorScheme.onError,
fontSize: 14,
),
const HSpace(20),
BaseStyledButton(
@@ -37,15 +39,14 @@ class DocumentBanner extends StatelessWidget {
minHeight: 40,
contentPadding: EdgeInsets.zero,
bgColor: Colors.transparent,
hoverColor: Theme.of(context).colorScheme.primary,
highlightColor: Theme.of(context).colorScheme.primaryContainer,
outlineColor: Colors.white,
outlineColor: colorScheme.onError,
borderRadius: Corners.s8Border,
onPressed: onRestore,
child: FlowyText.medium(
LocaleKeys.deletePagePrompt_restore.tr(),
color: Theme.of(context).colorScheme.onPrimary,
fontSize: 14,
color: colorScheme.onError,
fontSize: 13,
),
),
const HSpace(20),
@@ -54,15 +55,14 @@ class DocumentBanner extends StatelessWidget {
minHeight: 40,
contentPadding: EdgeInsets.zero,
bgColor: Colors.transparent,
hoverColor: Theme.of(context).colorScheme.primaryContainer,
highlightColor: Theme.of(context).colorScheme.primary,
outlineColor: Colors.white,
outlineColor: colorScheme.onError,
borderRadius: Corners.s8Border,
onPressed: onDelete,
child: FlowyText.medium(
LocaleKeys.deletePagePrompt_deletePermanent.tr(),
color: Theme.of(context).colorScheme.onPrimary,
fontSize: 14,
color: colorScheme.onError,
fontSize: 13,
),
),
],
@@ -2,6 +2,7 @@ import 'package:appflowy/plugins/document/application/doc_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/inline_page/inline_page_reference.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/workspace/application/appearance.dart';
import 'package:appflowy/workspace/application/settings/shortcuts/settings_shortcuts_service.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:collection/collection.dart';
@@ -74,6 +75,8 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
alignToolbarItem,
buildTextColorItem(),
buildHighlightColorItem(),
// TODO: enable it in version 0.3.3
// ...textDirectionItems,
];
late final List<SelectionMenuItem> slashMenuItems;
@@ -150,11 +153,16 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
final (bool autoFocus, Selection? selection) =
_computeAutoFocusParameters();
final editorScrollController = EditorScrollController(
editorState: widget.editorState,
shrinkWrap: widget.shrinkWrap,
scrollController: effectiveScrollController,
);
final editor = AppFlowyEditor(
editorState: widget.editorState,
editable: true,
shrinkWrap: widget.shrinkWrap,
scrollController: effectiveScrollController,
editorScrollController: editorScrollController,
// setup the auto focus parameters
autoFocus: widget.autoFocus ?? autoFocus,
focusedSelection: selection,
@@ -170,17 +178,20 @@ class _AppFlowyEditorPageState extends State<AppFlowyEditorPage> {
footer: const VSpace(200),
);
final layoutDirection =
context.read<AppearanceSettingsCubit>().state.layoutDirection ==
LayoutDirection.rtlLayout
? TextDirection.rtl
: TextDirection.ltr;
return Center(
child: ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: double.infinity,
maxHeight: double.infinity,
),
child: FloatingToolbar(
style: styleCustomizer.floatingToolbarStyleBuilder(),
items: toolbarItems,
editorState: widget.editorState,
scrollController: effectiveScrollController,
child: FloatingToolbar(
style: styleCustomizer.floatingToolbarStyleBuilder(),
items: toolbarItems,
editorState: widget.editorState,
editorScrollController: editorScrollController,
child: Directionality(
textDirection: layoutDirection,
child: editor,
),
),
@@ -1,12 +1,14 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/block_action_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/actions/option_action.dart';
import 'package:appflowy/workspace/application/appearance.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class BlockOptionButton extends StatelessWidget {
const BlockOptionButton({
@@ -38,13 +40,23 @@ class BlockOptionButton extends StatelessWidget {
}).toList();
return PopoverActionList<PopoverAction>(
direction: PopoverDirection.leftWithCenterAligned,
direction:
context.read<AppearanceSettingsCubit>().state.layoutDirection ==
LayoutDirection.rtlLayout
? PopoverDirection.rightWithCenterAligned
: PopoverDirection.leftWithCenterAligned,
actions: popoverActions,
onPopupBuilder: () => blockComponentState.alwaysShowActions = true,
onPopupBuilder: () {
keepEditorFocusNotifier.value += 1;
blockComponentState.alwaysShowActions = true;
},
onClosed: () {
editorState.selectionType = null;
editorState.selection = null;
blockComponentState.alwaysShowActions = false;
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
editorState.selectionType = null;
editorState.selection = null;
blockComponentState.alwaysShowActions = false;
keepEditorFocusNotifier.value -= 1;
});
},
onSelected: (action, controller) {
if (action is OptionActionWrapper) {
@@ -5,7 +5,6 @@ import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_editor/appflowy_editor.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';
@@ -243,7 +242,7 @@ class ColorOptionAction extends PopoverActionCell {
}
final bgColor =
node.attributes[blockComponentBackgroundColor] as String?;
final selectedColor = bgColor?.toColor();
final selectedColor = bgColor?.tryToColor();
// get default background color from themeExtension
final defaultColor = AFThemeExtension.of(context).calloutBGColor;
final colors = [
@@ -1,6 +1,8 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -31,24 +33,31 @@ final alignToolbarItem = ToolbarItem(
data = FlowySvgs.toolbar_align_right_s;
}
final child = FlowySvg(
data,
size: const Size.square(16),
color: isHighlight ? highlightColor : Colors.white,
final child = MouseRegion(
cursor: SystemMouseCursors.click,
child: FlowySvg(
data,
size: const Size.square(20),
color: isHighlight ? highlightColor : Colors.white,
),
);
return _AlignmentButtons(
child: child,
onAlignChanged: (align) async {
await editorState.updateNode(
selection,
(node) => node.copyWith(
attributes: {
...node.attributes,
blockComponentAlign: align,
},
),
);
},
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4.0),
child: _AlignmentButtons(
child: child,
onAlignChanged: (align) async {
await editorState.updateNode(
selection,
(node) => node.copyWith(
attributes: {
...node.attributes,
blockComponentAlign: align,
},
),
);
},
),
);
},
);
@@ -71,11 +80,21 @@ class _AlignmentButtonsState extends State<_AlignmentButtons> {
Widget build(BuildContext context) {
return AppFlowyPopover(
windowPadding: const EdgeInsets.all(0),
margin: const EdgeInsets.all(0),
margin: const EdgeInsets.all(4),
direction: PopoverDirection.bottomWithCenterAligned,
offset: const Offset(0, 10),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.onTertiary,
borderRadius: const BorderRadius.all(Radius.circular(4)),
),
popupBuilder: (_) {
keepEditorFocusNotifier.value += 1;
return _AlignButtons(onAlignChanged: widget.onAlignChanged);
},
onClose: () {
keepEditorFocusNotifier.value -= 1;
},
child: widget.child,
popupBuilder: (_) => _AlignButtons(onAlignChanged: widget.onAlignChanged),
);
}
}
@@ -97,16 +116,19 @@ class _AlignButtons extends StatelessWidget {
const HSpace(4),
_AlignButton(
icon: FlowySvgs.toolbar_align_left_s,
tooltips: LocaleKeys.document_plugins_optionAction_left.tr(),
onTap: () => onAlignChanged('left'),
),
const _Divider(),
_AlignButton(
icon: FlowySvgs.toolbar_align_center_s,
tooltips: LocaleKeys.document_plugins_optionAction_center.tr(),
onTap: () => onAlignChanged('center'),
),
const _Divider(),
_AlignButton(
icon: FlowySvgs.toolbar_align_right_s,
tooltips: LocaleKeys.document_plugins_optionAction_right.tr(),
onTap: () => onAlignChanged('right'),
),
const HSpace(4),
@@ -119,19 +141,25 @@ class _AlignButtons extends StatelessWidget {
class _AlignButton extends StatelessWidget {
const _AlignButton({
required this.icon,
required this.tooltips,
required this.onTap,
});
final FlowySvgData icon;
final String tooltips;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: FlowySvg(
icon,
size: const Size.square(16),
child: Tooltip(
message: tooltips,
child: FlowySvg(
icon,
size: const Size.square(16),
color: Colors.white,
),
),
);
}
@@ -115,7 +115,12 @@ class CalloutBlockComponentWidget extends BlockComponentStatefulWidget {
class _CalloutBlockComponentWidgetState
extends State<CalloutBlockComponentWidget>
with SelectableMixin, DefaultSelectableMixin, BlockComponentConfigurable {
with
SelectableMixin,
DefaultSelectableMixin,
BlockComponentConfigurable,
BlockComponentTextDirectionMixin,
BlockComponentAlignMixin {
// the key used to forward focus to the richtext child
@override
final forwardKey = GlobalKey(debugLabel: 'flowy_rich_text');
@@ -139,26 +144,35 @@ class _CalloutBlockComponentWidgetState
Color get backgroundColor {
final colorString =
node.attributes[CalloutBlockKeys.backgroundColor] as String;
return colorString.toColor() ?? Colors.transparent;
return colorString.tryToColor() ?? Colors.transparent;
}
// get the emoji of the note block from the node's attributes or default to '📌'
String get emoji => node.attributes[CalloutBlockKeys.icon] ?? '📌';
// get access to the editor state via provider
@override
late final editorState = Provider.of<EditorState>(context, listen: false);
// build the callout block widget
@override
Widget build(BuildContext context) {
final textDirection = calculateTextDirection(
layoutDirection: Directionality.maybeOf(context),
);
Widget child = Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
color: backgroundColor,
),
width: double.infinity,
alignment: alignment,
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
textDirection: textDirection,
children: [
// the emoji picker button for the note
Padding(
@@ -178,10 +192,10 @@ class _CalloutBlockComponentWidgetState
},
),
),
Expanded(
Flexible(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: buildCalloutBlockComponent(context),
child: buildCalloutBlockComponent(context, textDirection),
),
),
const HSpace(8.0),
@@ -195,6 +209,17 @@ class _CalloutBlockComponentWidgetState
child: child,
);
child = BlockSelectionContainer(
node: node,
delegate: this,
listenable: editorState.selectionNotifier,
blockColor: editorState.editorStyle.selectionColor,
supportTypes: const [
BlockSelectionType.block,
],
child: child,
);
if (widget.showActions && widget.actionBuilder != null) {
child = BlockComponentActionWrapper(
node: widget.node,
@@ -207,11 +232,15 @@ class _CalloutBlockComponentWidgetState
}
// build the richtext child
Widget buildCalloutBlockComponent(BuildContext context) {
Widget buildCalloutBlockComponent(
BuildContext context,
TextDirection textDirection,
) {
return Padding(
padding: padding,
child: AppFlowyRichText(
key: forwardKey,
delegate: this,
node: widget.node,
editorState: editorState,
placeholderText: placeholderText,
@@ -221,6 +250,9 @@ class _CalloutBlockComponentWidgetState
placeholderTextSpanDecorator: (textSpan) => textSpan.updateTextStyle(
placeholderTextStyle,
),
textDirection: textDirection,
cursorColor: editorState.editorStyle.cursorColor,
selectionColor: editorState.editorStyle.selectionColor,
),
);
}
@@ -3,7 +3,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/base/selec
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:easy_localization/easy_localization.dart' hide TextDirection;
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:highlight/highlight.dart' as highlight;
@@ -98,7 +98,11 @@ class CodeBlockComponentWidget extends BlockComponentStatefulWidget {
}
class _CodeBlockComponentWidgetState extends State<CodeBlockComponentWidget>
with SelectableMixin, DefaultSelectableMixin, BlockComponentConfigurable {
with
SelectableMixin,
DefaultSelectableMixin,
BlockComponentConfigurable,
BlockComponentTextDirectionMixin {
// the key used to forward focus to the richtext child
@override
final forwardKey = GlobalKey(debugLabel: 'flowy_rich_text');
@@ -127,6 +131,7 @@ class _CodeBlockComponentWidgetState extends State<CodeBlockComponentWidget>
'C#',
'CPP',
'Clojure',
'CS',
'CSS',
'Dart',
'Docker',
@@ -174,6 +179,7 @@ class _CodeBlockComponentWidgetState extends State<CodeBlockComponentWidget>
..add('c')
..sort();
@override
late final editorState = context.read<EditorState>();
String? get language => node.attributes[CodeBlockKeys.language] as String?;
@@ -181,6 +187,9 @@ class _CodeBlockComponentWidgetState extends State<CodeBlockComponentWidget>
@override
Widget build(BuildContext context) {
final textDirection = calculateTextDirection(
layoutDirection: Directionality.maybeOf(context),
);
Widget child = Container(
decoration: BoxDecoration(
borderRadius: const BorderRadius.all(Radius.circular(8.0)),
@@ -190,9 +199,10 @@ class _CodeBlockComponentWidgetState extends State<CodeBlockComponentWidget>
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
textDirection: textDirection,
children: [
_buildSwitchLanguageButton(context),
_buildCodeBlock(context),
_buildCodeBlock(context, textDirection),
],
),
);
@@ -203,6 +213,17 @@ class _CodeBlockComponentWidgetState extends State<CodeBlockComponentWidget>
child: child,
);
child = BlockSelectionContainer(
node: node,
delegate: this,
listenable: editorState.selectionNotifier,
blockColor: editorState.editorStyle.selectionColor,
supportTypes: const [
BlockSelectionType.block,
],
child: child,
);
if (widget.showActions && widget.actionBuilder != null) {
child = BlockComponentActionWrapper(
node: widget.node,
@@ -214,7 +235,7 @@ class _CodeBlockComponentWidgetState extends State<CodeBlockComponentWidget>
return child;
}
Widget _buildCodeBlock(BuildContext context) {
Widget _buildCodeBlock(BuildContext context, TextDirection textDirection) {
final delta = node.delta ?? Delta();
final content = delta.toPlainText();
@@ -234,6 +255,7 @@ class _CodeBlockComponentWidgetState extends State<CodeBlockComponentWidget>
padding: widget.padding,
child: AppFlowyRichText(
key: forwardKey,
delegate: this,
node: widget.node,
editorState: editorState,
placeholderText: placeholderText,
@@ -245,6 +267,9 @@ class _CodeBlockComponentWidgetState extends State<CodeBlockComponentWidget>
placeholderTextSpanDecorator: (textSpan) => TextSpan(
style: textStyle,
),
textDirection: textDirection,
cursorColor: editorState.editorStyle.cursorColor,
selectionColor: editorState.editorStyle.selectionColor,
),
);
}
@@ -28,7 +28,7 @@ CommandShortcutEventHandler _copyCommandHandler = (editorState) {
// plain text.
final text = editorState.getTextInSelection(selection).join('\n');
final nodes = editorState.getSelectedNodes(selection);
final nodes = editorState.getSelectedNodes(selection: selection);
final document = Document.blank()..insert([0], nodes);
// in app json
@@ -43,16 +43,32 @@ CommandShortcutEventHandler _pasteCommandHandler = (editorState) {
// 3. image
// 4. plain text
// try to paste the content in order, if any of them is failed, then try the next one
if (inAppJson != null && inAppJson.isNotEmpty) {
await editorState.deleteSelectionIfNeeded();
await editorState.pasteInAppJson(inAppJson);
} else if (html != null && html.isNotEmpty) {
final result = await editorState.pasteInAppJson(inAppJson);
if (result) {
return;
}
}
if (html != null && html.isNotEmpty) {
await editorState.deleteSelectionIfNeeded();
await editorState.pasteHtml(html);
} else if (image != null && image.$2?.isNotEmpty == true) {
final result = await editorState.pasteHtml(html);
if (result) {
return;
}
}
if (image != null && image.$2?.isNotEmpty == true) {
await editorState.deleteSelectionIfNeeded();
await editorState.pasteImage(image.$1, image.$2!);
} else if (plainText != null && plainText.isNotEmpty) {
final result = await editorState.pasteImage(image.$1, image.$2!);
if (result) {
return;
}
}
if (plainText != null && plainText.isNotEmpty) {
await editorState.pastePlainText(plainText);
}
}();
@@ -2,7 +2,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_p
import 'package:appflowy_editor/appflowy_editor.dart';
extension PasteFromHtml on EditorState {
Future<void> pasteHtml(String html) async {
Future<bool> pasteHtml(String html) async {
final nodes = htmlToDocument(html).root.children.toList();
// remove the front and back empty line
while (nodes.isNotEmpty && nodes.first.delta?.isEmpty == true) {
@@ -11,13 +11,15 @@ extension PasteFromHtml on EditorState {
while (nodes.isNotEmpty && nodes.last.delta?.isEmpty == true) {
nodes.removeLast();
}
// if there's no nodes being converted successfully, return false
if (nodes.isEmpty) {
return;
return false;
}
if (nodes.length == 1) {
await pasteSingleLineNode(nodes.first);
} else {
await pasteMultiLineNodes(nodes.toList());
}
return true;
}
}
@@ -15,9 +15,9 @@ extension PasteFromImage on EditorState {
'gif',
];
Future<void> pasteImage(String format, Uint8List imageBytes) async {
Future<bool> pasteImage(String format, Uint8List imageBytes) async {
if (!supportedImageFormats.contains(format)) {
return;
return false;
}
final path = await getIt<ApplicationDataStorage>().getPath();
@@ -37,8 +37,10 @@ extension PasteFromImage on EditorState {
);
await File(copyToPath).writeAsBytes(imageBytes);
await insertImageNode(copyToPath);
return true;
} catch (e) {
Log.error('cannot copy image file', e);
}
return false;
}
}
@@ -5,21 +5,23 @@ import 'package:appflowy_backend/log.dart';
import 'package:appflowy_editor/appflowy_editor.dart' hide Log;
extension PasteFromInAppJson on EditorState {
Future<void> pasteInAppJson(String inAppJson) async {
Future<bool> pasteInAppJson(String inAppJson) async {
try {
final nodes = Document.fromJson(jsonDecode(inAppJson)).root.children;
if (nodes.isEmpty) {
return;
return false;
}
if (nodes.length == 1) {
await pasteSingleLineNode(nodes.first);
} else {
await pasteMultiLineNodes(nodes.toList());
}
return true;
} catch (e) {
Log.error(
'Failed to paste in app json: $inAppJson, error: $e',
);
}
return false;
}
}
@@ -6,7 +6,6 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
@@ -536,7 +535,7 @@ class ColorItem extends StatelessWidget {
dimension: 25,
child: Container(
decoration: BoxDecoration(
color: option.colorHex.toColor(),
color: option.colorHex.tryToColor(),
shape: BoxShape.circle,
),
child: isChecked
@@ -548,7 +547,7 @@ class ColorItem extends StatelessWidget {
color: Theme.of(context).cardColor,
width: 3.0,
),
color: option.colorHex.toColor(),
color: option.colorHex.tryToColor(),
shape: BoxShape.circle,
),
),
@@ -6,7 +6,6 @@ import 'package:appflowy/workspace/presentation/widgets/emoji_picker/emoji_picke
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/widget/rounded_button.dart';
import 'package:flutter/material.dart';
@@ -202,7 +201,7 @@ class _DocumentHeaderToolbarState extends State<DocumentHeaderToolbar> {
child: Container(
alignment: Alignment.bottomLeft,
width: double.infinity,
padding: const EdgeInsets.symmetric(horizontal: 80),
padding: const EdgeInsets.symmetric(horizontal: 40),
child: SizedBox(
height: 28,
child: Row(
@@ -368,7 +367,7 @@ class DocumentCoverState extends State<DocumentCover> {
fit: BoxFit.cover,
);
case CoverType.color:
final color = widget.coverDetails?.toColor() ?? Colors.white;
final color = widget.coverDetails?.tryToColor() ?? Colors.white;
return Container(color: color);
case CoverType.none:
return const SizedBox.shrink();
@@ -2,7 +2,8 @@ import 'dart:async';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:easy_localization/easy_localization.dart' hide TextDirection;
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@@ -70,7 +71,7 @@ class OutlineBlockWidget extends BlockComponentStatefulWidget {
}
class _OutlineBlockWidgetState extends State<OutlineBlockWidget>
with BlockComponentConfigurable {
with BlockComponentConfigurable, BlockComponentTextDirectionMixin {
@override
BlockComponentConfiguration get configuration => widget.configuration;
@@ -84,9 +85,10 @@ class _OutlineBlockWidgetState extends State<OutlineBlockWidget>
if (colorString == null) {
return Colors.transparent;
}
return colorString.toColor() ?? Colors.transparent;
return colorString.tryToColor() ?? Colors.transparent;
}
@override
late EditorState editorState = context.read<EditorState>();
late Stream<(TransactionTime, Transaction)> stream =
editorState.transactionStream;
@@ -109,6 +111,10 @@ class _OutlineBlockWidgetState extends State<OutlineBlockWidget>
}
Widget _buildOutlineBlock() {
final textDirection = calculateTextDirection(
layoutDirection: Directionality.maybeOf(context),
);
final children = getHeadingNodes()
.map(
(e) => Container(
@@ -116,7 +122,10 @@ class _OutlineBlockWidgetState extends State<OutlineBlockWidget>
bottom: 4.0,
),
width: double.infinity,
child: OutlineItemWidget(node: e),
child: OutlineItemWidget(
node: e,
textDirection: textDirection,
),
),
)
.toList();
@@ -136,7 +145,9 @@ class _OutlineBlockWidgetState extends State<OutlineBlockWidget>
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
textDirection: textDirection,
children: children,
),
);
@@ -152,11 +163,13 @@ class OutlineItemWidget extends StatelessWidget {
OutlineItemWidget({
super.key,
required this.node,
required this.textDirection,
}) {
assert(node.type == HeadingBlockKeys.type);
}
final Node node;
final TextDirection textDirection;
@override
Widget build(BuildContext context) {
@@ -167,27 +180,37 @@ class OutlineItemWidget extends StatelessWidget {
style: HoverStyle(
hoverColor: Theme.of(context).hoverColor,
),
child: GestureDetector(
onTap: () => updateBlockSelection(context),
child: Container(
padding: EdgeInsets.only(left: node.leftIndent),
child: Text(
node.outlineItemText,
style: style,
builder: (context, onHover) {
return GestureDetector(
onTap: () => scrollToBlock(context),
child: Row(
textDirection: textDirection,
children: [
HSpace(node.leftIndent),
Text(
node.outlineItemText,
textDirection: textDirection,
style: style.copyWith(
color: onHover
? Theme.of(context).colorScheme.onSecondary
: null,
),
),
],
),
),
),
);
},
);
}
void updateBlockSelection(BuildContext context) async {
void scrollToBlock(BuildContext context) {
final editorState = context.read<EditorState>();
editorState.selectionType = SelectionType.block;
editorState.selection = Selection.collapsed(
Position(path: node.path, offset: node.delta?.length ?? 0),
);
final editorScrollController = context.read<EditorScrollController>();
editorScrollController.itemScrollController.jumpTo(index: node.path.first);
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
editorState.selectionType = null;
editorState.selection = Selection.collapsed(
Position(path: node.path, offset: node.delta?.length ?? 0),
);
});
}
}
@@ -112,7 +112,7 @@ class TableColorOptionAction extends PopoverActionCell {
? TableCellBlockKeys.colBackgroundColor
: TableCellBlockKeys.rowBackgroundColor;
final bgColor = cell?.attributes[key] as String?;
final selectedColor = bgColor?.toColor();
final selectedColor = bgColor?.tryToColor();
// get default background color from themeExtension
final defaultColor = AFThemeExtension.of(context).tableCellBGColor;
final colors = [
@@ -109,7 +109,8 @@ class _ToggleListBlockComponentWidgetState
BlockComponentConfigurable,
BlockComponentBackgroundColorMixin,
NestedBlockComponentStatefulWidgetMixin,
BlockComponentTextDirectionMixin {
BlockComponentTextDirectionMixin,
BlockComponentAlignMixin {
// the key used to forward focus to the richtext child
@override
final forwardKey = GlobalKey(debugLabel: 'flowy_rich_text');
@@ -146,7 +147,10 @@ class _ToggleListBlockComponentWidgetState
}
@override
Widget buildComponent(BuildContext context) {
Widget buildComponent(
BuildContext context, {
bool withBackgroundColor = false,
}) {
final textDirection = calculateTextDirection(
layoutDirection: Directionality.maybeOf(context),
);
@@ -154,8 +158,12 @@ class _ToggleListBlockComponentWidgetState
Widget child = Container(
color: backgroundColor,
width: double.infinity,
alignment: alignment,
child: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.start,
textDirection: textDirection,
children: [
// the emoji picker button for the note
FlowyIconButton(
@@ -168,9 +176,10 @@ class _ToggleListBlockComponentWidgetState
const SizedBox(
width: 4.0,
),
Expanded(
Flexible(
child: AppFlowyRichText(
key: forwardKey,
delegate: this,
node: widget.node,
editorState: editorState,
placeholderText: placeholderText,
@@ -183,6 +192,9 @@ class _ToggleListBlockComponentWidgetState
placeholderTextStyle,
),
textDirection: textDirection,
textAlign: alignment?.toTextAlign,
cursorColor: editorState.editorStyle.cursorColor,
selectionColor: editorState.editorStyle.selectionColor,
),
),
],
@@ -195,6 +207,17 @@ class _ToggleListBlockComponentWidgetState
child: child,
);
child = BlockSelectionContainer(
node: node,
delegate: this,
listenable: editorState.selectionNotifier,
blockColor: editorState.editorStyle.selectionColor,
supportTypes: const [
BlockSelectionType.block,
],
child: child,
);
if (widget.showActions && widget.actionBuilder != null) {
child = BlockComponentActionWrapper(
node: node,
@@ -30,9 +30,13 @@ class EditorStyleCustomizer {
final theme = Theme.of(context);
final fontSize = context.read<DocumentAppearanceCubit>().state.fontSize;
final fontFamily = context.read<DocumentAppearanceCubit>().state.fontFamily;
final defaultTextDirection =
context.read<DocumentAppearanceCubit>().state.defaultTextDirection;
return EditorStyle.desktop(
padding: padding,
cursorColor: theme.colorScheme.primary,
defaultTextDirection: defaultTextDirection,
textStyleConfiguration: TextStyleConfiguration(
text: baseTextStyle(fontFamily).copyWith(
fontSize: fontSize,
@@ -6,18 +6,22 @@ class DocumentAppearance {
const DocumentAppearance({
required this.fontSize,
required this.fontFamily,
this.defaultTextDirection,
});
final double fontSize;
final String fontFamily;
final String? defaultTextDirection;
DocumentAppearance copyWith({
double? fontSize,
String? fontFamily,
String? defaultTextDirection,
}) {
return DocumentAppearance(
fontSize: fontSize ?? this.fontSize,
fontFamily: fontFamily ?? this.fontFamily,
defaultTextDirection: defaultTextDirection,
);
}
}
@@ -32,6 +36,8 @@ class DocumentAppearanceCubit extends Cubit<DocumentAppearance> {
prefs.getDouble(KVKeys.kDocumentAppearanceFontSize) ?? 16.0;
final fontFamily =
prefs.getString(KVKeys.kDocumentAppearanceFontFamily) ?? 'Poppins';
final defaultTextDirection =
prefs.getString(KVKeys.kDocumentAppearanceDefaultTextDirection);
if (isClosed) {
return;
@@ -41,6 +47,7 @@ class DocumentAppearanceCubit extends Cubit<DocumentAppearance> {
state.copyWith(
fontSize: fontSize,
fontFamily: fontFamily,
defaultTextDirection: defaultTextDirection,
),
);
}
@@ -74,4 +81,26 @@ class DocumentAppearanceCubit extends Cubit<DocumentAppearance> {
),
);
}
Future<void> syncDefaultTextDirection(String? direction) async {
final prefs = await SharedPreferences.getInstance();
if (direction == null) {
prefs.remove(KVKeys.kDocumentAppearanceDefaultTextDirection);
} else {
prefs.setString(
KVKeys.kDocumentAppearanceDefaultTextDirection,
direction,
);
}
if (isClosed) {
return;
}
emit(
state.copyWith(
defaultTextDirection: direction,
),
);
}
}
@@ -107,7 +107,7 @@ void _resolveUserDeps(GetIt getIt, IntegrationMode mode) {
() => SignUpBloc(getIt<AuthService>()),
);
getIt.registerFactory<SplashRoute>(() => SplashRoute());
getIt.registerFactory<SplashRouter>(() => SplashRouter());
getIt.registerFactory<EditPanelBloc>(() => EditPanelBloc());
getIt.registerFactory<SplashBloc>(() => SplashBloc());
getIt.registerLazySingleton<NetworkListener>(() => NetworkListener());
@@ -122,8 +122,8 @@ void _resolveHomeDeps(GetIt getIt) {
(user, _) => UserListener(userProfile: user),
);
getIt.registerFactoryParam<WelcomeBloc, UserProfilePB, void>(
(user, _) => WelcomeBloc(
getIt.registerFactoryParam<WorkspaceBloc, UserProfilePB, void>(
(user, _) => WorkspaceBloc(
userService: UserBackendService(userId: user.id),
),
);
@@ -1,6 +1,6 @@
import 'package:appflowy/startup/launch_configuration.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/presentation/splash_screen.dart';
import 'package:appflowy/user/presentation/screens/splash_screen.dart';
import 'package:flutter/material.dart';
class FlowyApp implements EntryPoint {
@@ -0,0 +1,33 @@
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/user/presentation/router.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder2/workspace.pb.dart';
import 'package:flowy_infra_ui/style_widget/snap_bar.dart';
import 'package:flutter/material.dart';
void handleOpenWorkspaceError(BuildContext context, FlowyError error) {
Log.error(error);
switch (error.code) {
case ErrorCode.WorkspaceDataNotSync:
final userFolder = UserFolderPB.fromBuffer(error.payload);
getIt<AuthRouter>().pushWorkspaceErrorScreen(context, userFolder, error);
break;
case ErrorCode.InvalidEncryptSecret:
showSnapBar(
context,
error.msg,
);
break;
default:
showSnapBar(
context,
error.msg,
onClosed: () {
getIt<AuthService>().signOut();
runAppFlowy();
},
);
}
}
@@ -0,0 +1,25 @@
import 'package:appflowy/user/presentation/helpers/helpers.dart';
import 'package:appflowy/user/presentation/presentation.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:dartz/dartz.dart';
import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart';
import 'package:flutter/material.dart';
void handleSuccessOrFail(
Either<UserProfilePB, FlowyError> result,
BuildContext context,
AuthRouter router,
) {
result.fold(
(user) {
if (user.encryptionType == EncryptionTypePB.Symmetric) {
router.pushEncryptionScreen(context, user);
} else {
router.pushHomeScreen(context, user);
}
},
(error) {
handleOpenWorkspaceError(context, error);
},
);
}
@@ -0,0 +1,2 @@
export 'handle_open_workspace_error.dart';
export 'handle_success_or_fail.dart';
@@ -0,0 +1,4 @@
export 'screens/screens.dart';
export 'widgets/widgets.dart';
export 'historical_user.dart';
export 'router.dart';
@@ -1,9 +1,8 @@
import 'package:appflowy/mobile/presentation/mobile_home_page.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/user/presentation/sign_in_screen.dart';
import 'package:appflowy/user/presentation/sign_up_screen.dart';
import 'package:appflowy/user/presentation/skip_log_in_screen.dart';
import 'package:appflowy/user/presentation/welcome_screen.dart';
import 'package:appflowy/user/presentation/screens/screens.dart';
import 'package:appflowy/user/presentation/screens/workspace_start_screen/workspace_start_screen.dart';
import 'package:appflowy/workspace/presentation/home/home_screen.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
@@ -13,64 +12,77 @@ import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart'
show UserProfilePB;
import 'package:appflowy_backend/protobuf/flowy-folder2/protobuf.dart';
import 'package:flutter/material.dart';
import 'encrypt_secret_screen.dart';
import 'workspace_error_screen.dart';
const routerNameRoot = '/';
const routerNameSignUp = '/signUp';
const routerNameSignIn = '/signIn';
const routerNameSkipLogIn = '/skipLogIn';
const routerNameWelcome = '/welcome';
const routerNameHome = '/home';
import 'package:appflowy/util/platform_extension.dart';
class AuthRouter {
void pushForgetPasswordScreen(BuildContext context) {}
void pushWelcomeScreen(BuildContext context, UserProfilePB userProfile) {
getIt<SplashRoute>().pushWelcomeScreen(context, userProfile);
void pushWorkspaceStartScreen(
BuildContext context,
UserProfilePB userProfile,
) {
getIt<SplashRouter>().pushWorkspaceStartScreen(context, userProfile);
}
void pushSignUpScreen(BuildContext context) {
Navigator.of(context).push(
PageRoutes.fade(
() => SignUpScreen(router: getIt<AuthRouter>()),
const RouteSettings(name: routerNameSignUp),
),
);
}
void pushHomeScreenWithWorkSpace(
BuildContext context,
UserProfilePB profile,
WorkspaceSettingPB workspaceSetting,
) {
Navigator.push(
context,
PageRoutes.fade(
() => HomeScreen(
profile,
workspaceSetting,
key: ValueKey(profile.id),
),
const RouteSettings(name: routerNameHome),
RouteDurations.slow.inMilliseconds * .001,
const RouteSettings(name: SignUpScreen.routeName),
),
);
}
/// Navigates to the home screen based on the current workspace and platform.
///
/// This function takes in a [BuildContext] and a [UserProfilePB] object to
/// determine the user's settings and then navigate to the appropriate home screen
/// (`MobileHomeScreen` for mobile platforms, `DesktopHomeScreen` for others).
///
/// It first fetches the current workspace settings using [FolderEventGetCurrentWorkspace].
/// If the workspace settings are successfully fetched, it navigates to the home screen.
/// If there's an error, it defaults to the workspace start screen.
///
/// @param [context] BuildContext for navigating to the appropriate screen.
/// @param [userProfile] UserProfilePB object containing the details of the current user.
///
Future<void> pushHomeScreen(
BuildContext context,
UserProfilePB userProfile,
) async {
final result = await FolderEventGetCurrentWorkspace().send();
result.fold(
(workspaceSettingPB) => pushHomeScreenWithWorkSpace(
context,
userProfile,
workspaceSettingPB,
),
(r) => pushWelcomeScreen(context, userProfile),
(workspaceSetting) {
if (PlatformExtension.isMobile) {
Navigator.of(context).pushAndRemoveUntil(
MaterialPageRoute<void>(
builder: (BuildContext context) => MobileHomeScreen(
key: ValueKey(userProfile.id),
userProfile: userProfile,
workspaceSetting: workspaceSetting,
),
),
// pop up all the pages until [SplashScreen]
(route) => route.settings.name == SplashScreen.routeName,
);
} else {
Navigator.push(
context,
PageRoutes.fade(
() => DesktopHomeScreen(
key: ValueKey(userProfile.id),
userProfile: userProfile,
workspaceSetting: workspaceSetting,
),
const RouteSettings(
name: DesktopHomeScreen.routeName,
),
RouteDurations.slow.inMilliseconds * .001,
),
);
}
},
(error) => pushWorkspaceStartScreen(context, userProfile),
);
}
@@ -85,7 +97,7 @@ class AuthRouter {
user: userProfile,
key: ValueKey(userProfile.id),
),
const RouteSettings(name: routerNameWelcome),
const RouteSettings(name: EncryptSecretScreen.routeName),
RouteDurations.slow.inMilliseconds * .001,
),
);
@@ -103,23 +115,23 @@ class AuthRouter {
await Navigator.of(context).push(
PageRoutes.fade(
() => screen,
const RouteSettings(name: routerNameWelcome),
const RouteSettings(name: WorkspaceErrorScreen.routeName),
RouteDurations.slow.inMilliseconds * .001,
),
);
}
}
class SplashRoute {
Future<void> pushWelcomeScreen(
class SplashRouter {
Future<void> pushWorkspaceStartScreen(
BuildContext context,
UserProfilePB userProfile,
) async {
final screen = WelcomeScreen(userProfile: userProfile);
final screen = WorkspaceStartScreen(userProfile: userProfile);
await Navigator.of(context).push(
PageRoutes.fade(
() => screen,
const RouteSettings(name: routerNameWelcome),
const RouteSettings(name: WorkspaceStartScreen.routeName),
RouteDurations.slow.inMilliseconds * .001,
),
);
@@ -138,18 +150,35 @@ class SplashRoute {
UserProfilePB userProfile,
WorkspaceSettingPB workspaceSetting,
) {
Navigator.push(
context,
PageRoutes.fade(
() => HomeScreen(
userProfile,
workspaceSetting,
key: ValueKey(userProfile.id),
if (PlatformExtension.isMobile) {
Navigator.pushAndRemoveUntil<void>(
context,
MaterialPageRoute<void>(
builder: (BuildContext context) => MobileHomeScreen(
key: ValueKey(userProfile.id),
userProfile: userProfile,
workspaceSetting: workspaceSetting,
),
),
const RouteSettings(name: routerNameWelcome),
RouteDurations.slow.inMilliseconds * .001,
),
);
// pop up all the pages until [SplashScreen]
(route) => route.settings.name == SplashScreen.routeName,
);
} else {
Navigator.push(
context,
PageRoutes.fade(
() => DesktopHomeScreen(
userProfile: userProfile,
workspaceSetting: workspaceSetting,
key: ValueKey(userProfile.id),
),
const RouteSettings(
name: DesktopHomeScreen.routeName,
),
RouteDurations.slow.inMilliseconds * .001,
),
);
}
}
void pushSignInScreen(BuildContext context) {
@@ -157,7 +186,7 @@ class SplashRoute {
context,
PageRoutes.fade(
() => SignInScreen(router: getIt<AuthRouter>()),
const RouteSettings(name: routerNameSignIn),
const RouteSettings(name: SignInScreen.routeName),
RouteDurations.slow.inMilliseconds * .001,
),
);
@@ -171,7 +200,7 @@ class SplashRoute {
router: getIt<AuthRouter>(),
authService: getIt<AuthService>(),
),
const RouteSettings(name: routerNameSkipLogIn),
const RouteSettings(name: SkipLogInScreen.routeName),
RouteDurations.slow.inMilliseconds * .001,
),
);
@@ -1,6 +1,6 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/presentation/sign_in_screen.dart';
import 'package:appflowy/user/presentation/helpers/helpers.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -9,9 +9,10 @@ import 'package:flowy_infra_ui/widget/buttons/secondary_button.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../application/encrypt_secret_bloc.dart';
import '../../application/encrypt_secret_bloc.dart';
class EncryptSecretScreen extends StatefulWidget {
static const routeName = "/EncryptSecretScreen";
final UserProfilePB user;
const EncryptSecretScreen({required this.user, super.key});
@@ -0,0 +1,6 @@
export 'sign_in_screen/sign_in_screen.dart';
export 'skip_log_in_screen.dart';
export 'splash_screen.dart';
export 'sign_up_screen.dart';
export 'encrypt_secret_screen.dart';
export 'workspace_error_screen.dart';
@@ -0,0 +1,238 @@
import 'package:appflowy/core/frameless_window.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
import 'package:appflowy/user/presentation/widgets/widgets.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
class DesktopSignInScreen extends StatelessWidget {
const DesktopSignInScreen({super.key, required this.isLoading});
final bool isLoading;
@override
Widget build(BuildContext context) {
const indicatorMinHeight = 4.0;
return Scaffold(
appBar: const PreferredSize(
preferredSize: Size(double.infinity, 60),
child: MoveWindowDetector(),
),
body: Center(
child: AuthFormContainer(
children: [
FlowyLogoTitle(
title: LocaleKeys.welcomeText.tr(),
logoSize: const Size(60, 60),
),
const VSpace(30),
// Email and password. don't support yet.
/*
...[
const EmailTextField(),
const VSpace(5),
const PasswordTextField(),
const VSpace(20),
const LoginButton(),
const VSpace(10),
const VSpace(10),
SignUpPrompt(router: router),
],
*/
const SignInAnonymousButton(),
// third-party sign in.
const VSpace(20),
const _OrDivider(),
const VSpace(10),
const ThirdPartySignInButtons(),
const VSpace(20),
// loading status
const VSpace(indicatorMinHeight),
isLoading
? const LinearProgressIndicator(
value: null,
minHeight: indicatorMinHeight,
)
: const VSpace(indicatorMinHeight),
// add the same space when there's no loading status.
// ConstrainedBox(
// constraints: const BoxConstraints(maxHeight: 140),
// child: HistoricalUserList(
// didOpenUser: () async {
// await FlowyRunner.run(
// FlowyApp(),
// integrationEnv(),
// );
// },
// ),
// ),
const VSpace(20),
],
),
),
);
}
}
class _OrDivider extends StatelessWidget {
const _OrDivider();
@override
Widget build(BuildContext context) {
return Row(
children: [
const Flexible(
child: Divider(
thickness: 1,
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: FlowyText.regular(LocaleKeys.signIn_or.tr()),
),
const Flexible(
child: Divider(
thickness: 1,
),
),
],
);
}
}
// The following code is migrated from previous signInScreen.dart(for desktop)
// We may need this later when sign up&in feature is ready
// class SignUpPrompt extends StatelessWidget {
// const SignUpPrompt({
// super.key,
// required this.router,
// }) ;
// final AuthRouter router;
// @override
// Widget build(BuildContext context) {
// return Row(
// mainAxisAlignment: MainAxisAlignment.center,
// children: [
// FlowyText.medium(
// LocaleKeys.signIn_dontHaveAnAccount.tr(),
// color: Theme.of(context).hintColor,
// ),
// TextButton(
// style: TextButton.styleFrom(
// textStyle: Theme.of(context).textTheme.bodyMedium,
// ),
// onPressed: () => router.pushSignUpScreen(context),
// child: Text(
// LocaleKeys.signUp_buttonText.tr(),
// style: TextStyle(color: Theme.of(context).colorScheme.primary),
// ),
// ),
// ForgetPasswordButton(router: router),
// ],
// );
// }
// }
// class LoginButton extends StatelessWidget {
// const LoginButton({
// super.key
// }) ;
// @override
// Widget build(BuildContext context) {
// return RoundedTextButton(
// title: LocaleKeys.signIn_loginButtonText.tr(),
// height: 48,
// borderRadius: Corners.s10Border,
// onPressed: () => context
// .read<SignInBloc>()
// .add(const SignInEvent.signedInWithUserEmailAndPassword()),
// );
// }
// }
// class ForgetPasswordButton extends StatelessWidget {
// const ForgetPasswordButton({
// super.key
// required this.router,
// }) ;
// final AuthRouter router;
// @override
// Widget build(BuildContext context) {
// return TextButton(
// style: TextButton.styleFrom(
// textStyle: Theme.of(context).textTheme.bodyMedium,
// ),
// onPressed: () {
// throw UnimplementedError();
// },
// child: Text(
// LocaleKeys.signIn_forgotPassword.tr(),
// style: TextStyle(color: Theme.of(context).colorScheme.primary),
// ),
// );
// }
// }
// class PasswordTextField extends StatelessWidget {
// const PasswordTextField({
// super.key
// }) ;
// @override
// Widget build(BuildContext context) {
// return BlocBuilder<SignInBloc, SignInState>(
// buildWhen: (previous, current) =>
// previous.passwordError != current.passwordError,
// builder: (context, state) {
// return RoundedInputField(
// obscureText: true,
// obscureIcon: const FlowySvg(FlowySvgs.hide_m),
// obscureHideIcon: const FlowySvg(FlowySvgs.show_m),
// hintText: LocaleKeys.signIn_passwordHint.tr(),
// errorText: context
// .read<SignInBloc>()
// .state
// .passwordError
// .fold(() => "", (error) => error),
// onChanged: (value) => context
// .read<SignInBloc>()
// .add(SignInEvent.passwordChanged(value)),
// );
// },
// );
// }
// }
// class EmailTextField extends StatelessWidget {
// const EmailTextField({
// super.key
// }) ;
// @override
// Widget build(BuildContext context) {
// return BlocBuilder<SignInBloc, SignInState>(
// buildWhen: (previous, current) =>
// previous.emailError != current.emailError,
// builder: (context, state) {
// return RoundedInputField(
// hintText: LocaleKeys.signIn_emailHint.tr(),
// errorText: context
// .read<SignInBloc>()
// .state
// .emailError
// .fold(() => "", (error) => error),
// onChanged: (value) =>
// context.read<SignInBloc>().add(SignInEvent.emailChanged(value)),
// );
// },
// );
// }
// }
Loaded 100 of 444 files, more files were not shown because too many files have changed in this diff. Show more