Compare commits

..
70 Commits
Author SHA1 Message Date
Kilu f2991be276 fix: modified web server 2024-06-26 15:23:23 +08:00
Kilu 3645dd1b8b fix: lint error 2024-06-25 18:33:34 +08:00
Kilu 4237a73810 feat: support publish interfaces 2024-06-25 18:26:53 +08:00
Lucas.Xu 80a2ebc216 feat: fetch the publish info to show the publish status 2024-06-21 21:26:52 +08:00
Lucas.Xu 02a9f0dfbb feat: integrate unpublish api 2024-06-21 21:08:48 +08:00
Lucas.Xu 4a8536d403 feat: integrate publish api 2024-06-21 21:02:05 +08:00
Lucas.Xu 9c3f083684 Merge branch 'feat/support-get-encoded-collab-event' of https://github.com/AppFlowy-IO/AppFlowy into feat/support-get-encoded-collab-event 2024-06-21 20:21:47 +08:00
Lucas.Xu 628eace341 Merge branch 'publish' into feat/support-get-encoded-collab-event 2024-06-21 20:20:33 +08:00
qinluhe 77a53ef67a feat: support publish view and unpublish views 2024-06-21 18:46:31 +08:00
Lucas.Xu 5dec6ea545 feat: call the publish event 2024-06-21 16:25:06 +08:00
qinluhe dbcf8ba86f feat: support publish view and unpublish views 2024-06-21 16:19:35 +08:00
qinluhe 63fe1a6ef3 feat: support a event for getting encoded collab of document 2024-06-21 16:07:05 +08:00
Lucas.Xu a2c52508fa chore: refacotor share bloc 2024-06-21 15:32:44 +08:00
Lucas.Xu 0709a2d2ee feat: publish page to the web 2024-06-21 15:23:11 +08:00
Lucas.Xu e73b310076 chore: merge release/0.6.1 2024-06-21 11:24:38 +08:00
Mathias Mogensen 7586a0ed48 fix: settings ui improvements (#5590)
* fix: settings ui improvements

* fix: replace placeholder + fix open folder

* fix: collapse sidebar button on windows

* test: fix tests after changes

* test: missed type cast
2024-06-20 21:01:20 +02:00
Mathias MogensenandLucas.Xu d8b32168a9 feat: hide application to dock macos (#5592)
* feat: hide application to tray macos

* chore: enable reopen the app after closing it

* fix: remove tray

* fix: remove asset

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2024-06-20 21:00:56 +02:00
Mathias Mogensen 8ca541bfa1 fix: reminder race condition (#5587) 2024-06-20 10:13:59 +02:00
Nathan.fooo b64da2c02f refactor: File upload (#5542)
* chore: rename service

* refactor: upload

* chore: save upload meta data

* chore: add sql test

* chore: uploader

* chore: fix upload

* chore: cache file and remove after finish

* chore: retry upload

* chore: pause when netowork unreachable

* chore: add event test

* chore: add test

* chore: clippy

* chore: update client-api commit id

* chore: fix flutter test
2024-06-20 07:44:57 +08:00
Lucas.Xu fdaca36b87 fix: automatically open page after switching space (#5580) 2024-06-19 21:28:49 +08:00
Lucas.Xu 8557383a89 fix: cursor height is inconsistent after upgrading to Flutter 3.22 (#5574)
* fix: cursor height is inconsistent after upgrading to Flutter 3.22

* fix: cursor height is inconsistent after upgrading to Flutter 3.22

* fix: android toolbar height issue
2024-06-19 19:34:47 +08:00
Mathias Mogensen fa86480458 feat: shortcuts page remake (#5567)
* feat: settings shortcuts page remake

* test: add shortcut test

* fix: physics on listview

* fix: menu icon
2024-06-19 10:24:34 +02:00
Nathan.fooo d75eb05707 chore: fix flutter test (#5572) 2024-06-19 13:26:18 +08:00
Lucas.Xu a2b32585a1 chore: bump version 0.6.0 (#5571) 2024-06-19 12:10:05 +08:00
Lucas.Xu 75cea400d2 fix: cursor jump randomly in check list item (#5565)
* chore: remove debug logs

* fix: cursor jump ramdomly in checklist item
2024-06-19 09:33:27 +08:00
Lucas.XuandMathias Mogensen ed82ec8eef chore: remove gl_es env (#5564)
* fix: try without media kit

* chore: remove gl_es env

* Revert "fix: try without media kit"

This reverts commit e331dbfabf.

---------

Co-authored-by: Mathias Mogensen <mathiasrieckm@gmail.com>
2024-06-18 16:16:31 +08:00
Lucas.XuandMathias Mogensen b6debd6c71 fix: remove media_kit (#5563)
Co-authored-by: Mathias Mogensen <mathiasrieckm@gmail.com>
2024-06-18 15:47:52 +08:00
Nathan.fooo a05ed63a3e chore: fix can't delete characters in card (#5562)
* chore: fix can't delete characters in card

* chore: i18n
2024-06-18 13:12:57 +08:00
Nathan.fooo 3e75f1f24a chore: fix checklist potential panic (#5561)
* chore: fix checklist

* chore: fix checklist
2024-06-18 10:16:39 +08:00
Lucas.Xu e607694729 chore: change slash menu hover color (#5555)
* feat: add a pinned icon after page name

* chore: change slack menu hover color

* Revert "feat: add a pinned icon after page name"

This reverts commit a668e0bd09.
2024-06-18 09:55:43 +08:00
ansizheshi 91d870415e chore: update zh-CN translations (#5545)
* chore: update translations with Fink 🐦

* Delete frontend/resources/translations/de-DE.json

* Add files via upload

* Restore en.json

* Restore en.json
2024-06-18 09:52:44 +08:00
Nathan.fooo 21a497924c chore: adjust height (#5558) 2024-06-17 21:38:05 +08:00
Mathias Mogensen 4a126e17ce fix: settings improvements (#5547)
* fix: user workspace bloc changed

* fix: use translate card cell style

* fix: add getworkspacemember

* fix: billing launch review

* fix: disable time field

* fix: member tooltip

* fix: remove my account description

* fix: punctuation

* fix: filter workspace font

* fix: cloud toggle

* fix: minor adjustments

* chore: disable cloud document search

* fix: improve workspace name textfield

* test: move billing test to cloud

* fix: use cache over remote

* fix: clippy and tests

* chore: amend flowy tooltip

* test: add pump and settle

* test: integration test for local auth
2024-06-17 14:30:19 +02:00
Lucas.Xu 8bf97ad5c6 feat: add a pinned icon after page name (#5553) 2024-06-17 19:18:06 +08:00
Lucas.Xu a85d11fb6d feat: collapse space and switch space by shortcuts (#5554)
* chore: improve delete popup style

* feat: support collapsing space and switching space by shortcuts
2024-06-17 18:59:08 +08:00
Lucas.Xu 1fd34bc58a chore: turn off the billing feature (#5551) 2024-06-17 17:33:40 +08:00
Lucas.Xu 70a7b856a2 feat: improve space UX (#5549)
* fix: scroll bar hover color

* chore: replace board icon

* chore: remove close button in upgrade menu

* chore: add translation

* feat: switch to the next space by shortcut

* chore: replace space lock icon

* chore: remove arrow icon in change icon button

* feat: only show other favorites header if the other two is empty

* fix: create new page in current space

* feat: create a new page in a newly created space by default

* chore: update translations

* feat: open the first page after switching space by default

* chore: replace board icon

* fix: open the first page(non-space) after switching workspace

* chore: revert more icon

* chore: revert editor version
2024-06-17 16:23:17 +08:00
Lucas.Xu 49683e6079 fix: migrate the private views only if they are not empty (#5544) 2024-06-17 10:29:15 +08:00
Lucas.Xu 13107b63e9 feat: improve upgrade space toast (#5543)
* fix: divider color

* feat: improve upgrade space toast
2024-06-15 09:03:09 +08:00
Nathan.fooo ce1a6e4fca chore: highlight code block (#5540) 2024-06-14 14:53:53 +08:00
Lucas.Xu 1d23e28eaf chore: enable billing system (#5539) 2024-06-14 08:42:20 +02:00
Lucas.Xu 2a4d78b688 feat: improve hover color in dark mode (#5538) 2024-06-14 14:31:55 +08:00
Lucas.Xu cbe452a73d feat: improve page thumbnail on mobile (#5535)
* feat: improve view thumbnail

* chore: blur the bottom navigation bar

* feat: improve date format

* feat: support url avatar

* fix: remove duplicated divider
2024-06-14 13:15:41 +08:00
Nathan.fooo 27899ee993 chore: save workspace member info to disk (#5536)
* chore: save workspace member info to disk

* chore: fix clippy
2024-06-14 11:34:23 +08:00
Lucas.Xu 785597f53e fix: space issues (#5531)
* fix: display the space icon in breadcrumb and disable the space entry

* fix: the icon and the title are not aligned center in more menu

* fix: incorrect space icon corner radius

* fix: missed tooltip for add new page button

* fix: disable space migration for local user

* chore: use general as default space name

* fix: space name overflow

* fix: only show arrow button when hovering on page

* fix: sidebar tooltip font size

* fix: use space name as hint text

* feat: support adding a new space from space menu

* fix: filter the space view
2024-06-14 09:32:02 +08:00
Nathan.fooo e2ce274718 chore: polish chat UI (#5533)
* chore: adjust chat ui

* chore: clippy
2024-06-14 09:02:06 +08:00
Zack 6be9c00127 chore: update to latest client api (#5529) 2024-06-13 15:14:20 +08:00
Mohammad ZolfaghariandMathias Mogensen aa621a8d84 feat: timer field (#5349)
* feat: wip timer field

* feat: timer field fixing errors

* feat: wip timer field frontend

* fix: parsing TimerCellDataPB

* feat: parse time string to minutes

* fix: don't allow none number input

* fix: timer filter

* style: cargo fmt

* fix: clippy errors

* refactor: rename field type timer to time

* refactor: missed some variable and files to rename

* style: cargo fmt fix

* feat: format time field type data in frontend

* style: fix cargo fmt

* fix: fixes after merge

---------

Co-authored-by: Mathias Mogensen <mathiasrieckm@gmail.com>
2024-06-13 08:52:13 +02:00
Mathias Mogensen 2d4300e931 chore: update version to 0.6.0 (#5528) 2024-06-13 08:25:11 +02:00
Lucas.Xu dc12938ab6 feat: support switching space on mobile (#5527) 2024-06-13 14:14:18 +08:00
Lucas.Xu 2d674060c6 feat: introduce space to manage the pages (#5517)
* fix: resizing icon on mobile

* feat: add space feature

* feat: support creating space

* feat: support creating new space

* feat: support space expand status

* feat: support creating page in space

* feat: support customizing space icon

* feat: display the space icon on space menu

* feat: add space more action button

* fix: flutter analyze

* feat: support editing space icon on more menu

* chore: update translations

* feat: manage space

* feat: delete workspace

* feat: disable delete button if needed

* feat: add private lock

* chore: adjust the old version

* feat: display upgrade button

* feat: support migrating space

* feat: support migrating space

* feat: allow user to upgrade space maunally

* fix: dark mode issue

* fix: create space delay

* chore: translations

* chore: disable workspace test
2024-06-13 13:43:29 +08:00
Nathan.fooo ecca81f3b8 chore: exclude ai columns (#5526) 2024-06-13 11:15:59 +08:00
Lucas.Xu 15329b39d0 chore: update editor version (#5513) 2024-06-13 07:58:05 +08:00
Mathias Mogensen bd5f5f8b9e feat: integrate cloud document search (#5523) 2024-06-13 01:37:19 +02:00
4f4be7eac7 feat: enable group condition (#5248)
* feat: enable group condition

* style: added i18n for date field group conditions

* fix: flutter analyze

* fix: test use i18n

* fix: more localization

---------

Co-authored-by: Mathias Mogensen <42929161+Xazin@users.noreply.github.com>
Co-authored-by: Mathias Mogensen <mathias@appflowy.io>
2024-06-13 00:02:16 +02:00
Nathan.fooo f1a4deb459 chore: support more language (#5522)
* chore: support more language

* chore: adjust ui
2024-06-12 23:17:57 +08:00
Mathias MogensenandZack Fu Zi Xiang 4708c0f779 feat: plan+billing (#5518)
* feat: billing client

* feat: subscribe workspace default impl

* feat: added create subscription

* feat: add get workspace subs

* feat: added subscription cancellation

* feat: add workspace limits api

* fix: update client api

* feat: user billing portal

* feat: billing UI (#5455)

* feat: plan ui

* feat: billing ui

* feat: settings plan comparison dialog

* feat: complete plan+billing ui

* feat: backend integration

* chore: cleaning

* chore: fixes after merge

* fix: dependency issue

* feat: added subscription plan cancellation information

* feat: subscription callback + canceled date

* feat: put behind feature flag

* feat: downgrade/upgrade dialogs

* feat: update limit error codes

* fix: billing refresh + downgrade dialog

* fix: some minor improvements to settings

* chore: use patch for client-api in tauri

* fix: add shared-entity to patch

* fix: compile

* ci: try to add back maximize build space step

* test: increase timeout in failing test

---------

Co-authored-by: Zack Fu Zi Xiang <speed2exe@live.com.sg>
2024-06-12 17:08:55 +02:00
Nathan.fooo 3d7a500550 feat: AI translation in Database (#5515)
* chore: add tranlate field type

* chore: integrate ai translate

* chore: integrate client api

* chore: implement UI
2024-06-12 16:32:28 +08:00
Mathias Mogensen 815c99710e feat: magic link sign-in as anonymous user (#5520)
* feat: magic link sign-in as anonymous user

* chore: remove commented code

* fix: custom sign-in dialog

* fix: bring back sign-out dialog

* fix: add minor space for sync indicator
2024-06-11 22:17:29 +02:00
Murat Mirgün ERCAN c9fe93920f chore: update tr_TR translations (#5499) 2024-06-11 10:17:28 +08:00
Lucas.Xu 5012b99c9b fix: remove member request on launch (#5505) 2024-06-11 10:16:32 +08:00
Mathias Mogensen 6b46372aa0 fix: custom font dropdown (#5502)
* fix: custom font dropdown

* fix: dart analyzer
2024-06-10 15:23:26 +02:00
Mathias Mogensen b6d873db1b chore: remove build bot (#5500) 2024-06-10 09:04:28 +08:00
Lucas.Xu 38d2bd7ee8 fix: board shortcuts conflict with textfield (#5487)
* fix: board shortcuts conflict with text field

* fix: typo

* fix: favorite icon issue in dark mode
2024-06-10 09:04:01 +08:00
Nathan.fooo bb3e9d5bd8 feat: Stream chat message (#5498)
* chore: stream message

* chore: stream message

* chore: fix streaming

* chore: fix clippy
2024-06-09 14:02:32 +08:00
Kilu.He 94060a0a99 fix: generate default document data from document id (#5430)
* fix: generate default document data from document id

* fix: update version
2024-06-09 13:42:09 +08:00
Kilu.He 86696b271e chore: improvements to caching strategies (#5488) 2024-06-07 16:37:07 +08:00
Tianyu Yuan 76fde00cc4 chore: update zh-CN translations (#5484)
* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦
2024-06-07 11:48:23 +08:00
Kilu.He b794f3894e chore: improve coverage of cypress (#5483) 2024-06-06 19:47:14 +08:00
Kilu.He 3b72f90ca5 feat: improve test coverage (#5481) 2024-06-06 17:48:58 +08:00
707 changed files with 25753 additions and 9030 deletions

No files matched your search

-28
View File
@@ -1,28 +0,0 @@
name: Build Bot
on:
issue_comment:
types: [created]
jobs:
dispatch_slash_command:
runs-on: ubuntu-latest
steps:
- name: Checkout source code
uses: actions/checkout@v4
# get build name from pubspec.yaml
- name: Get build version
working-directory: frontend/appflowy_flutter
id: get_build_name
run: |
echo "fetching version from pubspec.yaml..."
echo "build_name=$(grep 'version: ' pubspec.yaml | awk '{print $2}')" >> $GITHUB_OUTPUT
- uses: peter-evans/slash-command-dispatch@v4
with:
token: ${{ secrets.PAT }}
commands: build
static-args: |
ref=refs/pull/${{ github.event.issue.number }}/head
build_name=${{ steps.get_build_name.outputs.build_name }}
+1 -1
View File
@@ -69,4 +69,4 @@ jobs:
SCRIPT_AFTER: |
docker build -t appflowy-web-app .
docker rm -f appflowy-web-app || true
docker run -d -p 80:80 -p 443:443 --name appflowy-web-app appflowy-web-app
docker run -d -p 80:80 -p 443:443 --env-file .env --name appflowy-web-app appflowy-web-app
+8 -8
View File
@@ -32,14 +32,14 @@ jobs:
# swap-size-mb: 1024
# remove-dotnet: 'true'
# # the following step is required to avoid running out of space
# - name: Maximize build space
# run: |
# sudo rm -rf /usr/share/dotnet
# sudo rm -rf /opt/ghc
# sudo rm -rf "/usr/local/share/boost"
# sudo rm -rf "$AGENT_TOOLSDIRECTORY"
# sudo docker image prune --all --force
# the following step is required to avoid running out of space
- name: Maximize build space
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf "/usr/local/share/boost"
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
sudo docker image prune --all --force
- name: Checkout source code
uses: actions/checkout@v4
+11 -5
View File
@@ -6,6 +6,7 @@ on:
- ".github/workflows/web2_ci.yaml"
- "frontend/appflowy_web_app/**"
- "frontend/resources/**"
env:
NODE_VERSION: "18.16.0"
PNPM_VERSION: "8.5.0"
@@ -52,8 +53,13 @@ jobs:
run: |
pnpm run test:unit
- name: Generate and post coverage summary
working-directory: frontend/appflowy_web_app
run: |
pnpm run merge-coverage
- name: Upload coverage to Codecov
uses: codecov/codecov-action@v2
with:
token: cf9245e0-e136-4e21-b0ee-35755fa0c493
files: frontend/appflowy_web_app/coverage/jest/lcov.info,frontend/appflowy_web_app/coverage/cypress/lcov.info
flags: appflowy_web_app
name: frontend/appflowy_web_app
fail_ci_if_error: true
verbose: true
+16
View File
@@ -1,4 +1,20 @@
# Release Notes
## Version 0.6.0 - 19/06/2024
### New Features
- Introduced the "Space" feature to help you organize your pages more efficiently.
### Bug Fixes
- Resolved shortcut conflicts on the board page.
- Resolved an issue where underscores could cause the editor to freeze.
## Version 0.5.9 - 06/06/2024
### New Features
- Revamped the sidebar for both Desktop and Mobile.
- Added support for embedding videos in documents.
- Introduced a hotkey (Cmd/Ctrl + 0) to reset the app scale.
- Supported searching the workspace by page title.
### Bug Fixes
- Fixed the issue preventing the use of Backspace to delete words in Kanban boards.
## Version 0.5.8 - 05/20/2024
### New Features
- Improvement to the Callout block to insert new lines
+1 -1
View File
@@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
CARGO_MAKE_CRATE_FS_NAME = "dart_ffi"
CARGO_MAKE_CRATE_NAME = "dart-ffi"
LIB_NAME = "dart_ffi"
APPFLOWY_VERSION = "0.5.9"
APPFLOWY_VERSION = "0.6.1"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"
@@ -52,7 +52,7 @@ android {
defaultConfig {
// TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
applicationId "io.appflowy.appflowy"
minSdkVersion 23
minSdkVersion 24
targetSdkVersion 33
versionCode flutterVersionCode.toInteger()
versionName flutterVersionName
@@ -1,7 +1,6 @@
import 'anon_user_continue_test.dart' as anon_user_continue_test;
import 'appflowy_cloud_auth_test.dart' as appflowy_cloud_auth_test;
import 'empty_test.dart' as preset_af_cloud_env_test;
// import 'document_sync_test.dart' as document_sync_test;
import 'user_setting_sync_test.dart' as user_sync_test;
import 'workspace/change_name_and_icon_test.dart'
as change_workspace_name_and_icon_test;
@@ -10,13 +9,8 @@ import 'workspace/collaborative_workspace_test.dart'
Future<void> main() async {
preset_af_cloud_env_test.main();
appflowy_cloud_auth_test.main();
// document_sync_test.main();
user_sync_test.main();
anon_user_continue_test.main();
// workspace
@@ -32,69 +32,69 @@ import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
final email = '${uuid()}@appflowy.io';
// final email = '${uuid()}@appflowy.io';
group('collaborative workspace', () {
// combine the create and delete workspace test to reduce the time
testWidgets('create a new workspace, open it and then delete it',
(tester) async {
// only run the test when the feature flag is on
if (!FeatureFlag.collaborativeWorkspace.isOn) {
return;
}
// if (!FeatureFlag.collaborativeWorkspace.isOn) {
// return;
// }
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
email: email,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
// await tester.initializeAppFlowy(
// cloudType: AuthenticatorType.appflowyCloudSelfHost,
// email: email,
// );
// await tester.tapGoogleLoginInButton();
// await tester.expectToSeeHomePageWithGetStartedPage();
const name = 'AppFlowy.IO';
// the workspace will be opened after created
await tester.createCollaborativeWorkspace(name);
// const name = 'AppFlowy.IO';
// // the workspace will be opened after created
// await tester.createCollaborativeWorkspace(name);
final loading = find.byType(Loading);
await tester.pumpUntilNotFound(loading);
// final loading = find.byType(Loading);
// await tester.pumpUntilNotFound(loading);
Finder success;
// Finder success;
final Finder items = find.byType(WorkspaceMenuItem);
// final Finder items = find.byType(WorkspaceMenuItem);
// delete the newly created workspace
await tester.openCollaborativeWorkspaceMenu();
await tester.pumpUntilFound(items);
// // delete the newly created workspace
// await tester.openCollaborativeWorkspaceMenu();
// await tester.pumpUntilFound(items);
expect(items, findsNWidgets(2));
expect(
tester.widget<WorkspaceMenuItem>(items.last).workspace.name,
name,
);
// expect(items, findsNWidgets(2));
// expect(
// tester.widget<WorkspaceMenuItem>(items.last).workspace.name,
// name,
// );
final secondWorkspace = find.byType(WorkspaceMenuItem).last;
await tester.hoverOnWidget(
secondWorkspace,
onHover: () async {
// click the more button
final moreButton = find.byType(WorkspaceMoreActionList);
expect(moreButton, findsOneWidget);
await tester.tapButton(moreButton);
// click the delete button
final deleteButton = find.text(LocaleKeys.button_delete.tr());
expect(deleteButton, findsOneWidget);
await tester.tapButton(deleteButton);
// see the delete confirm dialog
final confirm =
find.text(LocaleKeys.workspace_deleteWorkspaceHintText.tr());
expect(confirm, findsOneWidget);
await tester.tapButton(find.text(LocaleKeys.button_ok.tr()));
// delete success
success = find.text(LocaleKeys.workspace_createSuccess.tr());
await tester.pumpUntilFound(success);
expect(success, findsOneWidget);
await tester.pumpUntilNotFound(success);
},
);
// final secondWorkspace = find.byType(WorkspaceMenuItem).last;
// await tester.hoverOnWidget(
// secondWorkspace,
// onHover: () async {
// // click the more button
// final moreButton = find.byType(WorkspaceMoreActionList);
// expect(moreButton, findsOneWidget);
// await tester.tapButton(moreButton);
// // click the delete button
// final deleteButton = find.text(LocaleKeys.button_delete.tr());
// expect(deleteButton, findsOneWidget);
// await tester.tapButton(deleteButton);
// // see the delete confirm dialog
// final confirm =
// find.text(LocaleKeys.workspace_deleteWorkspaceHintText.tr());
// expect(confirm, findsOneWidget);
// await tester.tapButton(find.text(LocaleKeys.button_ok.tr()));
// // delete success
// success = find.text(LocaleKeys.workspace_createSuccess.tr());
// await tester.pumpUntilFound(success);
// expect(success, findsOneWidget);
// await tester.pumpUntilNotFound(success);
// },
// );
});
});
}
@@ -29,6 +29,7 @@ void main() {
},
);
await tester.tapButtonWithName(LocaleKeys.button_delete.tr());
await tester.tapOKButton();
expect(find.text(name), findsNothing);
});
@@ -41,15 +41,16 @@ void main() {
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
await tester.hoverOnFirstRowOfGrid();
await tester.hoverOnFirstRowOfGrid(() async {
// Open the row menu and then click the delete
await tester.tapRowMenuButtonInGrid();
await tester.pumpAndSettle();
await tester.tapDeleteOnRowMenu();
await tester.pumpAndSettle();
// Open the row menu and then click the delete
await tester.tapRowMenuButtonInGrid();
await tester.tapDeleteOnRowMenu();
// 3 initial rows - 1 deleted
await tester.assertNumberOfRowsInGridPage(2);
await tester.pumpAndSettle();
// 3 initial rows - 1 deleted
await tester.assertNumberOfRowsInGridPage(2);
});
});
testWidgets('check number of row indicator in the initial grid',
@@ -1,5 +1,5 @@
import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -17,25 +17,25 @@ void main() {
await tester.openSettingsPage(SettingsPage.notifications);
await tester.pumpAndSettle();
final switchFinder = find.byType(Switch).first;
final toggleFinder = find.byType(Toggle).first;
// Defaults to enabled
Switch switchWidget = tester.widget(switchFinder);
expect(switchWidget.value, true);
Toggle toggleWidget = tester.widget(toggleFinder);
expect(toggleWidget.value, true);
// Disable
await tester.tap(switchFinder);
await tester.tap(toggleFinder);
await tester.pumpAndSettle();
switchWidget = tester.widget(switchFinder);
expect(switchWidget.value, false);
toggleWidget = tester.widget(toggleFinder);
expect(toggleWidget.value, false);
// Enable again
await tester.tap(switchFinder);
await tester.tap(toggleFinder);
await tester.pumpAndSettle();
switchWidget = tester.widget(switchFinder);
expect(switchWidget.value, true);
toggleWidget = tester.widget(toggleFinder);
expect(toggleWidget.value, true);
});
});
}
@@ -0,0 +1,50 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/auth_operation.dart';
import '../../shared/base.dart';
import '../../shared/expectation.dart';
import '../../shared/settings.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Settings Billing', () {
testWidgets('Local auth cannot see plan+billing', (tester) async {
await tester.initializeAppFlowy();
await tester.tapSignInAsGuest();
await tester.expectToSeeHomePageWithGetStartedPage();
await tester.openSettings();
await tester.pumpAndSettle();
// We check that another settings page is present to ensure
// it's not a fluke
expect(
find.text(
LocaleKeys.settings_workspacePage_menuLabel.tr(),
skipOffstage: false,
),
findsOneWidget,
);
expect(
find.text(
LocaleKeys.settings_planPage_menuLabel.tr(),
skipOffstage: false,
),
findsNothing,
);
expect(
find.text(
LocaleKeys.settings_billingPage_menuLabel.tr(),
skipOffstage: false,
),
findsNothing,
);
});
});
}
@@ -1,9 +1,13 @@
import 'package:integration_test/integration_test.dart';
import 'notifications_settings_test.dart' as notifications_settings_test;
import 'settings_billing_test.dart' as settings_billing_test;
import 'shortcuts_settings_test.dart' as shortcuts_settings_test;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
notifications_settings_test.main();
settings_billing_test.main();
shortcuts_settings_test.main();
}
@@ -0,0 +1,90 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/settings/settings_dialog_bloc.dart';
import 'package:appflowy/workspace/presentation/settings/pages/settings_shortcuts_view.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/keyboard.dart';
import '../../shared/util.dart';
import '../board/board_hide_groups_test.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('shortcuts test', () {
testWidgets('can change and overwrite shortcut', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.openSettings();
await tester.openSettingsPage(SettingsPage.shortcuts);
await tester.pumpAndSettle();
final backspaceCmd =
LocaleKeys.settings_shortcutsPage_keybindings_backspace.tr();
// Input "Delete" into the search field
await tester.enterText(find.byType(TextField), backspaceCmd);
await tester.pumpAndSettle();
await tester.hoverOnWidget(
find.descendant(
of: find.byType(ShortcutSettingTile),
matching: find.text(backspaceCmd),
),
onHover: () async {
await tester.tap(find.byFlowySvg(FlowySvgs.edit_s));
await tester.pumpAndSettle();
await FlowyTestKeyboard.simulateKeyDownEvent(
[
LogicalKeyboardKey.delete,
LogicalKeyboardKey.enter,
],
tester: tester,
);
await tester.pumpAndSettle();
},
);
// We expect to see conflict dialog
expect(
find.text(
LocaleKeys.settings_shortcutsPage_conflictDialog_confirmLabel.tr(),
),
findsOneWidget,
);
// Press on confirm label
await tester.tap(
find.text(
LocaleKeys.settings_shortcutsPage_conflictDialog_confirmLabel.tr(),
),
);
await tester.pumpAndSettle();
// We expect the first ShortcutSettingTile to have one
// [KeyBadge] with `delete` label
final first = tester.widget(find.byType(ShortcutSettingTile).first)
as ShortcutSettingTile;
expect(
first.command.command,
'delete',
);
// And the second one which is `Delete left character` to have none
// as it will have been overwritten
final second = tester.widget(find.byType(ShortcutSettingTile).at(1))
as ShortcutSettingTile;
expect(
second.command.command,
'',
);
});
});
}
@@ -5,6 +5,7 @@ import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widget
import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_supabase_cloud.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -44,6 +45,12 @@ extension AppFlowyAuthTest on WidgetTester {
assert(isSwitched == value);
}
void assertToggleValue(Finder finder, bool value) {
final Toggle switchWidget = widget(finder);
final isSwitched = switchWidget.value;
assert(isSwitched == value);
}
void assertEnableEncryptSwitchValue(bool value) {
assertSwitchValue(
find.descendant(
@@ -65,10 +72,10 @@ extension AppFlowyAuthTest on WidgetTester {
}
void assertAppFlowyCloudEnableSyncSwitchValue(bool value) {
assertSwitchValue(
assertToggleValue(
find.descendant(
of: find.byType(AppFlowyCloudEnableSync),
matching: find.byWidgetPredicate((widget) => widget is Switch),
matching: find.byWidgetPredicate((widget) => widget is Toggle),
),
value,
);
@@ -86,7 +93,7 @@ extension AppFlowyAuthTest on WidgetTester {
Future<void> toggleEnableSync(Type syncButton) async {
final finder = find.descendant(
of: find.byType(syncButton),
matching: find.byWidgetPredicate((widget) => widget is Switch),
matching: find.byWidgetPredicate((widget) => widget is Toggle),
);
await tapButton(finder);
@@ -1,7 +1,6 @@
import 'dart:async';
import 'dart:io';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/services.dart';
@@ -37,8 +36,6 @@ extension AppFlowyTestBase on WidgetTester {
AuthenticatorType? cloudType,
String? email,
}) async {
VideoBlockKit.ensureInitialized();
if (Platform.isLinux || Platform.isWindows || Platform.isMacOS) {
// Set the window size
await binding.setSurfaceSize(windowSize);
@@ -1,5 +1,10 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
@@ -22,6 +27,7 @@ import 'package:appflowy/workspace/presentation/notifications/widgets/flowy_tab.
import 'package:appflowy/workspace/presentation/notifications/widgets/notification_button.dart';
import 'package:appflowy/workspace/presentation/notifications/widgets/notification_tab_bar.dart';
import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/settings_menu.dart';
import 'package:appflowy/workspace/presentation/widgets/more_view_actions/more_view_actions.dart';
import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/common_view_action.dart';
import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart';
@@ -29,10 +35,6 @@ import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/buttons/primary_button.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'emoji.dart';
@@ -619,6 +621,18 @@ extension SettingsFinder on CommonFinders {
matching: find.byType(Scrollable),
)
.first;
Finder findSettingsMenuScrollable() => find
.descendant(
of: find
.descendant(
of: find.byType(SettingsMenu),
matching: find.byType(SingleChildScrollView),
)
.first,
matching: find.byType(Scrollable),
)
.first;
}
extension ViewLayoutPBTest on ViewLayoutPB {
@@ -130,12 +130,12 @@ extension AppFlowyDatabaseTest on WidgetTester {
await openPage('v020', layout: ViewLayoutPB.Grid);
}
Future<void> hoverOnFirstRowOfGrid() async {
Future<void> hoverOnFirstRowOfGrid([Future<void> Function()? onHover]) async {
final findRow = find.byType(GridRow);
expect(findRow, findsWidgets);
final firstRow = findRow.first;
await hoverOnWidget(firstRow);
await hoverOnWidget(firstRow, onHover: onHover);
}
Future<void> editCell({
@@ -876,11 +876,13 @@ extension AppFlowyDatabaseTest on WidgetTester {
}
Future<void> tapRowMenuButtonInGrid() async {
expect(find.byType(RowMenuButton), findsOneWidget);
await tapButton(find.byType(RowMenuButton));
}
/// Should call [tapRowMenuButtonInGrid] first.
Future<void> tapDeleteOnRowMenu() async {
expect(find.text(LocaleKeys.grid_row_delete.tr()), findsOneWidget);
await tapButtonWithName(LocaleKeys.grid_row_delete.tr());
}
@@ -12,6 +12,7 @@ import 'package:flowy_infra_ui/style_widget/text_field.dart';
import 'package:flutter_test/flutter_test.dart';
import '../desktop/board/board_hide_groups_test.dart';
import 'base.dart';
import 'common_operations.dart';
@@ -31,6 +32,14 @@ extension AppFlowySettings on WidgetTester {
final button = find.byWidgetPredicate(
(widget) => widget is SettingsMenuElement && widget.page == page,
);
await scrollUntilVisible(
button,
0,
scrollable: find.findSettingsMenuScrollable(),
);
await pump();
expect(button, findsOneWidget);
await tapButton(button);
return;
@@ -58,12 +58,6 @@ PODS:
- Flutter
- keyboard_height_plugin (0.0.1):
- Flutter
- media_kit_libs_ios_video (1.0.4):
- Flutter
- media_kit_native_event_loop (1.0.0):
- Flutter
- media_kit_video (0.0.1):
- Flutter
- package_info_plus (0.4.5):
- Flutter
- path_provider_foundation (0.0.1):
@@ -72,8 +66,6 @@ PODS:
- permission_handler_apple (9.3.0):
- Flutter
- ReachabilitySwift (5.0.0)
- screen_brightness_ios (0.1.0):
- Flutter
- SDWebImage (5.14.2):
- SDWebImage/Core (= 5.14.2)
- SDWebImage/Core (5.14.2)
@@ -91,10 +83,6 @@ PODS:
- Toast (4.0.0)
- url_launcher_ios (0.0.1):
- Flutter
- volume_controller (0.0.1):
- Flutter
- wakelock_plus (0.0.1):
- Flutter
DEPENDENCIES:
- app_links (from `.symlinks/plugins/app_links/ios`)
@@ -110,20 +98,14 @@ DEPENDENCIES:
- integration_test (from `.symlinks/plugins/integration_test/ios`)
- irondash_engine_context (from `.symlinks/plugins/irondash_engine_context/ios`)
- keyboard_height_plugin (from `.symlinks/plugins/keyboard_height_plugin/ios`)
- media_kit_libs_ios_video (from `.symlinks/plugins/media_kit_libs_ios_video/ios`)
- media_kit_native_event_loop (from `.symlinks/plugins/media_kit_native_event_loop/ios`)
- media_kit_video (from `.symlinks/plugins/media_kit_video/ios`)
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- screen_brightness_ios (from `.symlinks/plugins/screen_brightness_ios/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite (from `.symlinks/plugins/sqflite/darwin`)
- super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- volume_controller (from `.symlinks/plugins/volume_controller/ios`)
- wakelock_plus (from `.symlinks/plugins/wakelock_plus/ios`)
SPEC REPOS:
trunk:
@@ -161,20 +143,12 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/irondash_engine_context/ios"
keyboard_height_plugin:
:path: ".symlinks/plugins/keyboard_height_plugin/ios"
media_kit_libs_ios_video:
:path: ".symlinks/plugins/media_kit_libs_ios_video/ios"
media_kit_native_event_loop:
:path: ".symlinks/plugins/media_kit_native_event_loop/ios"
media_kit_video:
:path: ".symlinks/plugins/media_kit_video/ios"
package_info_plus:
:path: ".symlinks/plugins/package_info_plus/ios"
path_provider_foundation:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
screen_brightness_ios:
:path: ".symlinks/plugins/screen_brightness_ios/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
@@ -185,10 +159,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/super_native_extensions/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
volume_controller:
:path: ".symlinks/plugins/volume_controller/ios"
wakelock_plus:
:path: ".symlinks/plugins/wakelock_plus/ios"
SPEC CHECKSUMS:
app_links: 5ef33d0d295a89d9d16bb81b0e3b0d5f70d6c875
@@ -206,14 +176,10 @@ SPEC CHECKSUMS:
integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4
irondash_engine_context: 3458bf979b90d616ffb8ae03a150bafe2e860cc9
keyboard_height_plugin: 43fa8bba20fd5c4fdeed5076466b8b9d43cc6b86
media_kit_libs_ios_video: a5fe24bc7875ccd6378a0978c13185e1344651c1
media_kit_native_event_loop: e6b2ab20cf0746eb1c33be961fcf79667304fa2a
media_kit_video: 5da63f157170e5bf303bf85453b7ef6971218a2e
package_info_plus: 58f0028419748fad15bf008b270aaa8e54380b1c
path_provider_foundation: 3784922295ac71e43754bd15e0653ccfd36a147c
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
screen_brightness_ios: 715ca807df953bf676d339f11464e438143ee625
SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84
share_plus: c3fef564749587fc939ef86ffb283ceac0baf9f5
shared_preferences_foundation: b4c3b4cddf1c21f02770737f147a3f5da9d39695
@@ -222,8 +188,6 @@ SPEC CHECKSUMS:
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: bbd758c6e7f9fd7b5b1d4cde34d2b95fcce5e812
volume_controller: 531ddf792994285c9b17f9d8a7e4dcdd29b3eae9
wakelock_plus: 78ec7c5b202cab7761af8e2b2b3d0671be6c4ae1
PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca
@@ -81,14 +81,24 @@ class KVKeys {
/// The value is a double string.
static const String scaleFactor = 'scaleFactor';
/// The key for saving the last opened space
/// The key for saving the last opened tab (favorite, recent, space etc.)
///
/// The value is a int string.
static const String lastOpenedSpace = 'lastOpenedSpace';
/// The key for saving the space order
/// The key for saving the space tab order
///
/// The value is a json string with the following format:
/// [0, 1, 2]
static const String spaceOrder = 'spaceOrder';
/// The key for saving the last opened space id (space A, space B)
///
/// The value is a string.
static const String lastOpenedSpaceId = 'lastOpenedSpaceId';
/// The key for saving the upgrade space tag
///
/// The value is a boolean string
static const String hasUpgradedSpace = 'hasUpgradedSpace060';
}
@@ -1,15 +1,16 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/shared/window_title_bar.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/home/home_setting_bloc.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class CocoaWindowChannel {
@@ -122,15 +123,21 @@ class MoveWindowDetectorState extends State<MoveWindowDetector> {
return FlowyTooltip(
richMessage: textSpan,
child: FlowyIconButton(
hoverColor: Colors.transparent,
onPressed: () => context
child: Listener(
behavior: HitTestBehavior.translucent,
onPointerDown: (_) => context
.read<HomeSettingBloc>()
.add(const HomeSettingEvent.collapseMenu()),
iconPadding: const EdgeInsets.all(4.0),
icon: context.read<HomeSettingBloc>().state.isMenuCollapsed
? const FlowySvg(FlowySvgs.show_menu_s)
: const FlowySvg(FlowySvgs.hide_menu_m),
child: FlowyHover(
child: Container(
width: 24,
padding: const EdgeInsets.all(4),
child: const RotatedBox(
quarterTurns: 2,
child: FlowySvg(FlowySvgs.hide_menu_s),
),
),
),
),
);
}
@@ -1,9 +1,10 @@
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy_backend/log.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:string_validator/string_validator.dart';
import 'package:url_launcher/url_launcher.dart' as launcher;
-3
View File
@@ -1,12 +1,9 @@
import 'package:scaled_app/scaled_app.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'startup/startup.dart';
Future<void> main() async {
ScaledWidgetsFlutterBinding.ensureInitialized(scaleFactor: (_) => 1.0);
VideoBlockKit.ensureInitialized();
await runAppFlowy();
}
@@ -33,6 +33,7 @@ class BlockActionBottomSheet extends StatelessWidget {
FlowySvgs.arrow_up_s,
size: Size.square(20),
),
showTopBorder: false,
onTap: () => onAction(BlockActionBottomSheetType.insertAbove),
),
FlowyOptionTile.text(
@@ -48,7 +49,7 @@ class BlockActionBottomSheet extends StatelessWidget {
FlowyOptionTile.text(
showTopBorder: false,
text: LocaleKeys.button_duplicate.tr(),
leftIcon: const FlowySvg(FlowySvgs.m_field_copy_s),
leftIcon: const FlowySvg(FlowySvgs.m_duplicate_s),
onTap: () => onAction(BlockActionBottomSheetType.duplicate),
),
@@ -1,7 +1,7 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/home/shared/mobile_view_card.dart';
import 'package:appflowy/mobile/presentation/home/shared/mobile_page_card.dart';
import 'package:appflowy/mobile/presentation/page_item/mobile_slide_action_button.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart';
@@ -23,7 +23,7 @@ enum MobilePaneActionType {
MobileSlideActionButton actionButton(
BuildContext context, {
MobileViewCardType? cardType,
MobilePageCardType? cardType,
FolderSpaceType? spaceType,
}) {
switch (this) {
@@ -133,13 +133,13 @@ enum MobilePaneActionType {
List<MobileViewItemBottomSheetBodyAction> _buildActions(
ViewPB view, {
MobileViewCardType? cardType,
MobilePageCardType? cardType,
}) {
final isFavorite = view.isFavorite;
if (cardType != null) {
switch (cardType) {
case MobileViewCardType.recent:
case MobilePageCardType.recent:
return [
isFavorite
? MobileViewItemBottomSheetBodyAction.removeFromFavorites
@@ -150,7 +150,7 @@ enum MobilePaneActionType {
MobileViewItemBottomSheetBodyAction.divider,
MobileViewItemBottomSheetBodyAction.removeFromRecent,
];
case MobileViewCardType.favorite:
case MobilePageCardType.favorite:
return [
isFavorite
? MobileViewItemBottomSheetBodyAction.removeFromFavorites
@@ -179,10 +179,9 @@ ActionPane buildEndActionPane(
BuildContext context,
List<MobilePaneActionType> actions, {
bool needSpace = true,
MobileViewCardType? cardType,
MobilePageCardType? cardType,
FolderSpaceType? spaceType,
}) {
debugPrint('actions: $actions');
return ActionPane(
motion: const ScrollMotion(),
extentRatio: actions.length / 5,
@@ -2,6 +2,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/database/field/mobile_field_bottom_sheets.dart';
import 'package:appflowy/plugins/database/application/field/field_controller.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -19,7 +20,10 @@ class MobileRowDetailCreateFieldButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
return ConstrainedBox(
constraints: const BoxConstraints(minWidth: double.infinity),
constraints: BoxConstraints(
minWidth: double.infinity,
minHeight: GridSize.headerHeight,
),
child: TextButton.icon(
style: Theme.of(context).textButtonTheme.style?.copyWith(
shape: WidgetStateProperty.all<RoundedRectangleBorder>(
@@ -1,3 +1,5 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/type_option_menu_item.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
@@ -8,7 +10,6 @@ import 'package:appflowy/util/field_type_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:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'mobile_create_field_screen.dart';
@@ -28,6 +29,7 @@ const mobileSupportedFieldTypes = [
FieldType.CreatedTime,
FieldType.Checkbox,
FieldType.Checklist,
// FieldType.Time,
];
Future<FieldType?> showFieldTypeGridBottomSheet(
@@ -119,6 +119,7 @@ class FieldOptionValues {
case FieldType.RichText:
case FieldType.URL:
case FieldType.Checkbox:
case FieldType.Time:
return null;
case FieldType.Number:
return NumberTypeOptionPB(
@@ -1,6 +1,7 @@
import 'package:appflowy/mobile/application/mobile_router.dart';
import 'package:appflowy/mobile/presentation/home/shared/empty_placeholder.dart';
import 'package:appflowy/mobile/presentation/home/shared/mobile_view_card.dart';
import 'package:appflowy/mobile/presentation/home/shared/mobile_page_card.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart';
import 'package:appflowy/workspace/application/user/prelude.dart';
@@ -68,7 +69,7 @@ class _MobileFavoriteSpaceState extends State<MobileFavoriteSpace>
if (favoriteState.views.isEmpty) {
return const EmptySpacePlaceholder(
type: MobileViewCardType.favorite,
type: MobilePageCardType.favorite,
);
}
@@ -92,11 +93,17 @@ class _FavoriteViews extends StatelessWidget {
@override
Widget build(BuildContext context) {
final borderColor = Theme.of(context).isLightMode
? const Color(0xFFE9E9EC)
: const Color(0x1AFFFFFF);
return Scrollbar(
child: ListView.separated(
key: const PageStorageKey('favorite_views_page_storage_key'),
padding: const EdgeInsets.symmetric(
horizontal: HomeSpaceViewSizes.mHorizontalPadding,
padding: EdgeInsets.only(
left: HomeSpaceViewSizes.mHorizontalPadding,
right: HomeSpaceViewSizes.mHorizontalPadding,
bottom: HomeSpaceViewSizes.mVerticalPadding +
MediaQuery.of(context).padding.bottom,
),
itemBuilder: (context, index) {
final view = favoriteViews[index];
@@ -105,16 +112,16 @@ class _FavoriteViews extends StatelessWidget {
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor,
color: borderColor,
width: 0.5,
),
),
),
child: MobileViewCard(
child: MobileViewPage(
key: ValueKey(view.item.id),
view: view.item,
timestamp: view.timestamp,
type: MobileViewCardType.favorite,
type: MobilePageCardType.favorite,
),
);
},
@@ -28,9 +28,12 @@ class _MobileHomeSpaceState extends State<MobileHomeSpace>
return Scrollbar(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: HomeSpaceViewSizes.mHorizontalPadding,
vertical: HomeSpaceViewSizes.mVerticalPadding,
padding: EdgeInsets.only(
left: HomeSpaceViewSizes.mHorizontalPadding,
right: HomeSpaceViewSizes.mHorizontalPadding,
top: HomeSpaceViewSizes.mVerticalPadding,
bottom: HomeSpaceViewSizes.mVerticalPadding +
MediaQuery.of(context).padding.bottom,
),
child: MobileFolders(
user: widget.userProfile,
@@ -3,9 +3,11 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/mobile_router.dart';
import 'package:appflowy/mobile/presentation/home/home.dart';
import 'package:appflowy/mobile/presentation/home/section_folder/mobile_home_section_folder.dart';
import 'package:appflowy/mobile/presentation/home/space/mobile_space.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -34,16 +36,15 @@ class MobileFolders extends StatelessWidget {
providers: [
BlocProvider(
create: (_) => SidebarSectionsBloc()
..add(
SidebarSectionsEvent.initial(
user,
workspaceId,
),
),
..add(SidebarSectionsEvent.initial(user, workspaceId)),
),
BlocProvider(
create: (_) => FavoriteBloc()..add(const FavoriteEvent.initial()),
),
BlocProvider(
create: (_) => SpaceBloc()
..add(SpaceEvent.initial(user, workspaceId, openFirstPage: false)),
),
],
child: BlocListener<UserWorkspaceBloc, UserWorkspaceState>(
listener: (context, state) {
@@ -53,53 +54,88 @@ class MobileFolders extends StatelessWidget {
state.currentWorkspace?.workspaceId ?? workspaceId,
),
);
context.read<SpaceBloc>().add(
SpaceEvent.reset(
user,
state.currentWorkspace?.workspaceId ?? workspaceId,
),
);
},
child: BlocConsumer<SidebarSectionsBloc, SidebarSectionsState>(
listenWhen: (p, c) =>
p.lastCreatedRootView?.id != c.lastCreatedRootView?.id,
listener: (context, state) {
final lastCreatedRootView = state.lastCreatedRootView;
if (lastCreatedRootView != null) {
context.pushView(lastCreatedRootView);
}
},
builder: (context, state) {
final isCollaborativeWorkspace =
context.read<UserWorkspaceBloc>().state.isCollabWorkspaceOn;
return SlidableAutoCloseBehavior(
child: Column(
children: [
...isCollaborativeWorkspace
? [
MobileSectionFolder(
title: LocaleKeys.sideBar_workspace.tr(),
spaceType: FolderSpaceType.public,
views: state.section.publicViews,
),
const VSpace(8.0),
MobileSectionFolder(
title: LocaleKeys.sideBar_private.tr(),
spaceType: FolderSpaceType.private,
views: state.section.privateViews,
),
]
: [
MobileSectionFolder(
title: LocaleKeys.sideBar_personal.tr(),
spaceType: FolderSpaceType.public,
views: state.section.publicViews,
),
],
const VSpace(4.0),
const _TrashButton(),
],
),
);
},
child: MultiBlocListener(
listeners: [
BlocListener<SpaceBloc, SpaceState>(
listenWhen: (p, c) =>
p.lastCreatedPage?.id != c.lastCreatedPage?.id,
listener: (context, state) {
final lastCreatedPage = state.lastCreatedPage;
if (lastCreatedPage != null) {
context.pushView(lastCreatedPage);
}
},
),
BlocListener<SidebarSectionsBloc, SidebarSectionsState>(
listenWhen: (p, c) =>
p.lastCreatedRootView?.id != c.lastCreatedRootView?.id,
listener: (context, state) {
final lastCreatedPage = state.lastCreatedRootView;
if (lastCreatedPage != null) {
context.pushView(lastCreatedPage);
}
},
),
],
child: BlocBuilder<SidebarSectionsBloc, SidebarSectionsState>(
builder: (context, state) {
return SlidableAutoCloseBehavior(
child: Column(
children: [
..._buildSpaceOrSection(context, state),
const VSpace(4.0),
const _TrashButton(),
],
),
);
},
),
),
),
);
}
List<Widget> _buildSpaceOrSection(
BuildContext context,
SidebarSectionsState state,
) {
if (context.watch<SpaceBloc>().state.spaces.isNotEmpty) {
return [
const MobileSpace(),
];
}
if (context.read<UserWorkspaceBloc>().state.isCollabWorkspaceOn) {
return [
MobileSectionFolder(
title: LocaleKeys.sideBar_workspace.tr(),
spaceType: FolderSpaceType.public,
views: state.section.publicViews,
),
const VSpace(8.0),
MobileSectionFolder(
title: LocaleKeys.sideBar_private.tr(),
spaceType: FolderSpaceType.private,
views: state.section.privateViews,
),
];
}
return [
MobileSectionFolder(
title: LocaleKeys.sideBar_personal.tr(),
spaceType: FolderSpaceType.public,
views: state.section.publicViews,
),
];
}
}
class _TrashButton extends StatelessWidget {
@@ -1,10 +1,13 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:appflowy/mobile/presentation/home/mobile_home_page_header.dart';
import 'package:appflowy/mobile/presentation/home/tab/mobile_space_tab.dart';
import 'package:appflowy/mobile/presentation/home/tab/space_order_bloc.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/recent/cached_recent_service.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
@@ -15,7 +18,6 @@ import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/workspace.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
@@ -57,6 +59,7 @@ class MobileHomeScreen extends StatelessWidget {
return Scaffold(
body: SafeArea(
bottom: false,
child: Provider.value(
value: userProfile,
child: MobileHomePage(
@@ -111,6 +114,9 @@ class _MobileHomePageState extends State<MobileHomePage> {
create: (context) =>
FavoriteBloc()..add(const FavoriteEvent.initial()),
),
BlocProvider.value(
value: getIt<ReminderBloc>()..add(const ReminderEvent.started()),
),
],
child: BlocConsumer<UserWorkspaceBloc, UserWorkspaceState>(
buildWhen: (previous, current) =>
@@ -1,5 +1,6 @@
import 'package:appflowy/mobile/presentation/home/shared/empty_placeholder.dart';
import 'package:appflowy/mobile/presentation/home/shared/mobile_view_card.dart';
import 'package:appflowy/mobile/presentation/home/shared/mobile_page_card.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/recent/prelude.dart';
import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
@@ -36,7 +37,7 @@ class _MobileRecentSpaceState extends State<MobileRecentSpace>
if (recentViews.isEmpty) {
return const Center(
child: EmptySpacePlaceholder(type: MobileViewCardType.recent),
child: EmptySpacePlaceholder(type: MobilePageCardType.recent),
);
}
@@ -63,12 +64,18 @@ class _RecentViews extends StatelessWidget {
@override
Widget build(BuildContext context) {
final borderColor = Theme.of(context).isLightMode
? const Color(0xFFE9E9EC)
: const Color(0x1AFFFFFF);
return SlidableAutoCloseBehavior(
child: Scrollbar(
child: ListView.separated(
key: const PageStorageKey('recent_views_page_storage_key'),
padding: const EdgeInsets.symmetric(
horizontal: HomeSpaceViewSizes.mHorizontalPadding,
padding: EdgeInsets.only(
left: HomeSpaceViewSizes.mHorizontalPadding,
right: HomeSpaceViewSizes.mHorizontalPadding,
bottom: HomeSpaceViewSizes.mVerticalPadding +
MediaQuery.of(context).padding.bottom,
),
itemBuilder: (context, index) {
final sectionView = recentViews[index];
@@ -77,16 +84,16 @@ class _RecentViews extends StatelessWidget {
decoration: BoxDecoration(
border: Border(
bottom: BorderSide(
color: Theme.of(context).dividerColor,
color: borderColor,
width: 0.5,
),
),
),
child: MobileViewCard(
child: MobileViewPage(
key: ValueKey(sectionView.item.id),
view: sectionView.item,
timestamp: sectionView.timestamp,
type: MobileViewCardType.recent,
type: MobilePageCardType.recent,
),
);
},
@@ -1,6 +1,6 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/home/shared/mobile_view_card.dart';
import 'package:appflowy/mobile/presentation/home/shared/mobile_page_card.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -8,7 +8,7 @@ import 'package:flutter/material.dart';
class EmptySpacePlaceholder extends StatelessWidget {
const EmptySpacePlaceholder({super.key, required this.type});
final MobileViewCardType type;
final MobilePageCardType type;
@override
Widget build(BuildContext context) {
@@ -42,14 +42,14 @@ class EmptySpacePlaceholder extends StatelessWidget {
}
String get _emptyPageText => switch (type) {
MobileViewCardType.recent => LocaleKeys.sideBar_emptyRecent.tr(),
MobileViewCardType.favorite => LocaleKeys.sideBar_emptyFavorite.tr(),
MobilePageCardType.recent => LocaleKeys.sideBar_emptyRecent.tr(),
MobilePageCardType.favorite => LocaleKeys.sideBar_emptyFavorite.tr(),
};
String get _emptyPageSubText => switch (type) {
MobileViewCardType.recent =>
MobilePageCardType.recent =>
LocaleKeys.sideBar_emptyRecentDescription.tr(),
MobileViewCardType.favorite =>
MobilePageCardType.favorite =>
LocaleKeys.sideBar_emptyFavoriteDescription.tr(),
};
}
@@ -10,15 +10,16 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.da
import 'package:appflowy/shared/appflowy_network_image.dart';
import 'package:appflowy/shared/flowy_gradient_colors.dart';
import 'package:appflowy/util/string_extension.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart';
import 'package:appflowy/workspace/application/settings/date_time/time_format_ext.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:fixnum/fixnum.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';
@@ -26,19 +27,20 @@ import 'package:flutter_slidable/flutter_slidable.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:provider/provider.dart';
import 'package:string_validator/string_validator.dart';
import 'package:time/time.dart';
enum MobileViewCardType {
enum MobilePageCardType {
recent,
favorite;
String get lastOperationHintText => switch (this) {
MobileViewCardType.recent => LocaleKeys.sideBar_lastViewed.tr(),
MobileViewCardType.favorite => LocaleKeys.sideBar_favoriteAt.tr(),
MobilePageCardType.recent => LocaleKeys.sideBar_lastViewed.tr(),
MobilePageCardType.favorite => LocaleKeys.sideBar_favoriteAt.tr(),
};
}
class MobileViewCard extends StatelessWidget {
const MobileViewCard({
class MobileViewPage extends StatelessWidget {
const MobileViewPage({
super.key,
required this.view,
this.timestamp,
@@ -47,7 +49,7 @@ class MobileViewCard extends StatelessWidget {
final ViewPB view;
final Int64? timestamp;
final MobileViewCardType type;
final MobilePageCardType type;
@override
Widget build(BuildContext context) {
@@ -78,7 +80,6 @@ class MobileViewCard extends StatelessWidget {
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTapUp: (_) => context.pushView(view),
onLongPressUp: () => _showActionSheet(context),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -157,6 +158,7 @@ class MobileViewCard extends StatelessWidget {
return ClipRRect(
borderRadius: BorderRadius.circular(8),
child: _ViewCover(
layout: view.layout,
coverTypeV1: state.coverTypeV1,
coverTypeV2: state.coverTypeV2,
value: state.coverValue,
@@ -189,6 +191,7 @@ class MobileViewCard extends StatelessWidget {
style: Theme.of(context).textTheme.bodyMedium!.copyWith(
fontSize: 16.0,
fontWeight: FontWeight.w600,
height: 1.3,
),
),
],
@@ -207,40 +210,45 @@ class MobileViewCard extends StatelessWidget {
}
Widget _buildLastViewed(BuildContext context) {
final textColor = Theme.of(context).isLightMode
? const Color(0xFF171717)
: Colors.white.withOpacity(0.45);
if (timestamp == null) {
return const SizedBox.shrink();
}
final date = _formatTimestamp(
context,
timestamp!.toInt() * 1000,
);
return FlowyText.regular(
date,
fontSize: 12.0,
color: Theme.of(context).hintColor,
fontSize: 13.0,
color: textColor,
);
}
String _formatTimestamp(int timestamp) {
String _formatTimestamp(BuildContext context, int timestamp) {
final now = DateTime.now();
final dateTime = DateTime.fromMillisecondsSinceEpoch(timestamp);
final difference = now.difference(dateTime);
final String date;
final dateFormate =
context.read<AppearanceSettingsCubit>().state.dateFormat;
final timeFormate =
context.read<AppearanceSettingsCubit>().state.timeFormat;
if (difference.inMinutes < 1) {
date = LocaleKeys.sideBar_justNow.tr();
} else if (difference.inHours < 1) {
} else if (difference.inHours < 1 && dateTime.isToday) {
// Less than 1 hour
date = LocaleKeys.sideBar_minutesAgo
.tr(namedArgs: {'count': difference.inMinutes.toString()});
} else if (difference.inHours >= 1 && difference.inHours < 24) {
// Between 1 hour and 24 hours
date = DateFormat('h:mm a').format(dateTime);
} else if (difference.inDays >= 1 && dateTime.year == now.year) {
// More than 24 hours but within the current year
date = DateFormat('M/d, h:mm a').format(dateTime);
} else if (difference.inHours >= 1 && dateTime.isToday) {
// in same day
date = timeFormate.formatTime(dateTime);
} else {
// Other cases (previous years)
date = DateFormat('M/d/yyyy, h:mm a').format(dateTime);
date = dateFormate.formatDate(dateTime, false);
}
if (difference.inHours >= 1) {
@@ -249,77 +257,24 @@ class MobileViewCard extends StatelessWidget {
return date;
}
Future<void> _showActionSheet(BuildContext context) async {
final viewBloc = context.read<ViewBloc>();
final favoriteBloc = context.read<FavoriteBloc>();
final recentViewsBloc = context.read<RecentViewsBloc?>();
await showMobileBottomSheet(
context,
showDragHandle: true,
showDivider: false,
backgroundColor: AFThemeExtension.of(context).background,
useRootNavigator: true,
builder: (context) {
return MultiBlocProvider(
providers: [
BlocProvider.value(value: viewBloc),
BlocProvider.value(value: favoriteBloc),
if (recentViewsBloc != null)
BlocProvider.value(value: recentViewsBloc),
],
child: BlocBuilder<ViewBloc, ViewState>(
builder: (context, state) {
return MobileViewItemBottomSheet(
view: viewBloc.state.view,
actions: _buildActions(state.view),
);
},
),
);
},
);
}
List<MobileViewItemBottomSheetBodyAction> _buildActions(ViewPB view) {
switch (type) {
case MobileViewCardType.recent:
return [
view.isFavorite
? MobileViewItemBottomSheetBodyAction.removeFromFavorites
: MobileViewItemBottomSheetBodyAction.addToFavorites,
MobileViewItemBottomSheetBodyAction.divider,
if (view.layout != ViewLayoutPB.Chat)
MobileViewItemBottomSheetBodyAction.duplicate,
MobileViewItemBottomSheetBodyAction.divider,
MobileViewItemBottomSheetBodyAction.removeFromRecent,
];
case MobileViewCardType.favorite:
return [
MobileViewItemBottomSheetBodyAction.removeFromFavorites,
MobileViewItemBottomSheetBodyAction.divider,
MobileViewItemBottomSheetBodyAction.duplicate,
];
}
}
}
class _ViewCover extends StatelessWidget {
const _ViewCover({
required this.layout,
required this.coverTypeV1,
this.coverTypeV2,
this.value,
});
final ViewLayoutPB layout;
final CoverType coverTypeV1;
final PageStyleCoverImageType? coverTypeV2;
final String? value;
@override
Widget build(BuildContext context) {
final placeholder = Container(
color: const Color(0xFFE1FBFF),
);
final placeholder = _buildPlaceholder(context);
final value = this.value;
if (value == null) {
return placeholder;
@@ -330,6 +285,45 @@ class _ViewCover extends StatelessWidget {
return _buildCoverV1(context, value, placeholder);
}
Widget _buildPlaceholder(BuildContext context) {
final isLightMode = Theme.of(context).isLightMode;
final (svg, color) = switch (layout) {
ViewLayoutPB.Document => (
FlowySvgs.m_document_thumbnail_m,
isLightMode ? const Color(0xCCEDFBFF) : const Color(0x33658B90)
),
ViewLayoutPB.Grid => (
FlowySvgs.m_grid_thumbnail_m,
isLightMode ? const Color(0xFFF5F4FF) : const Color(0x338B80AD)
),
ViewLayoutPB.Board => (
FlowySvgs.m_board_thumbnail_m,
isLightMode ? const Color(0x7FE0FDD9) : const Color(0x3372936B),
),
ViewLayoutPB.Calendar => (
FlowySvgs.m_calendar_thumbnail_m,
isLightMode ? const Color(0xFFFFF7F0) : const Color(0x33A68B77)
),
ViewLayoutPB.Chat => (
FlowySvgs.m_chat_thumbnail_m,
isLightMode ? const Color(0x66FFE6FD) : const Color(0x33987195)
),
_ => (
FlowySvgs.m_document_thumbnail_m,
isLightMode ? Colors.black : Colors.white
)
};
return ColoredBox(
color: color,
child: Center(
child: FlowySvg(
svg,
blendMode: null,
),
),
);
}
Widget _buildCoverV2(BuildContext context, String value, Widget placeholder) {
final type = coverTypeV2;
if (type == null) {
@@ -0,0 +1,160 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/mobile_router.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/home/space/mobile_space_header.dart';
import 'package:appflowy/mobile/presentation/home/space/mobile_space_menu.dart';
import 'package:appflowy/mobile/presentation/page_item/mobile_view_item.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class MobileSpace extends StatefulWidget {
const MobileSpace({super.key});
@override
State<MobileSpace> createState() => _MobileSpaceState();
}
class _MobileSpaceState extends State<MobileSpace> {
@override
void initState() {
super.initState();
createNewPageNotifier.addListener(_createNewPage);
}
@override
void dispose() {
createNewPageNotifier.removeListener(_createNewPage);
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<SpaceBloc, SpaceState>(
builder: (context, state) {
if (state.spaces.isEmpty) {
return const SizedBox.shrink();
}
final currentSpace = state.currentSpace ?? state.spaces.first;
return Column(
children: [
MobileSpaceHeader(
isExpanded: state.isExpanded,
space: currentSpace,
onAdded: () {
context.read<SpaceBloc>().add(
SpaceEvent.createPage(
name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
index: 0,
),
);
context.read<SpaceBloc>().add(
SpaceEvent.expand(currentSpace, true),
);
},
onPressed: () => _showSpaceMenu(context),
),
_Pages(
key: ValueKey(currentSpace.id),
space: currentSpace,
),
],
);
},
);
}
void _showSpaceMenu(BuildContext context) {
showMobileBottomSheet(
context,
showDivider: false,
showHeader: true,
showDragHandle: true,
showCloseButton: true,
showDoneButton: true,
title: LocaleKeys.space_title.tr(),
backgroundColor: Theme.of(context).colorScheme.surface,
builder: (_) {
return BlocProvider.value(
value: context.read<SpaceBloc>(),
child: const Padding(
padding: EdgeInsets.symmetric(horizontal: 8.0),
child: MobileSpaceMenu(),
),
);
},
);
}
void _createNewPage() {
context.read<SpaceBloc>().add(
SpaceEvent.createPage(
name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
),
);
}
}
class _Pages extends StatelessWidget {
const _Pages({
super.key,
required this.space,
});
final ViewPB space;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) =>
ViewBloc(view: space)..add(const ViewEvent.initial()),
child: BlocBuilder<ViewBloc, ViewState>(
builder: (context, state) {
final spaceType = space.spacePermission == SpacePermission.publicToAll
? FolderSpaceType.public
: FolderSpaceType.private;
return Column(
children: state.view.childViews
.map(
(view) => MobileViewItem(
key: ValueKey(
'${space.id} ${view.id}',
),
spaceType: spaceType,
isFirstChild: view.id == state.view.childViews.first.id,
view: view,
level: 0,
leftPadding: HomeSpaceViewSizes.leftPadding,
isFeedback: false,
onSelected: context.pushView,
endActionPane: (context) {
final view = context.read<ViewBloc>().state.view;
return buildEndActionPane(
context,
[
MobilePaneActionType.more,
if (view.layout == ViewLayoutPB.Document)
MobilePaneActionType.add,
],
spaceType: spaceType,
needSpace: false,
);
},
),
)
.toList(),
);
},
),
);
}
}
@@ -0,0 +1,132 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@visibleForTesting
const Key mobileCreateNewPageButtonKey = Key('mobileCreateNewPageButtonKey');
class MobileSpaceHeader extends StatelessWidget {
const MobileSpaceHeader({
super.key,
required this.space,
required this.onPressed,
required this.onAdded,
required this.isExpanded,
});
final ViewPB space;
final VoidCallback onPressed;
final VoidCallback onAdded;
final bool isExpanded;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: onPressed,
child: SizedBox(
height: 48,
child: Row(
children: [
SpaceIcon(
dimension: 24,
space: space,
cornerRadius: 6.0,
),
const HSpace(8),
FlowyText.medium(
space.name,
lineHeight: 1.15,
fontSize: 16.0,
),
const HSpace(4.0),
const FlowySvg(
FlowySvgs.workspace_drop_down_menu_show_s,
),
const Spacer(),
GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: onAdded,
child: const FlowySvg(
FlowySvgs.m_space_add_s,
),
),
],
),
),
);
}
// Future<void> _onAction(SpaceMoreActionType type, dynamic data) async {
// switch (type) {
// case SpaceMoreActionType.rename:
// await _showRenameDialog();
// break;
// case SpaceMoreActionType.changeIcon:
// final (String icon, String iconColor) = data;
// context.read<SpaceBloc>().add(SpaceEvent.changeIcon(icon, iconColor));
// break;
// case SpaceMoreActionType.manage:
// _showManageSpaceDialog(context);
// break;
// case SpaceMoreActionType.addNewSpace:
// break;
// case SpaceMoreActionType.collapseAllPages:
// break;
// case SpaceMoreActionType.delete:
// _showDeleteSpaceDialog(context);
// break;
// case SpaceMoreActionType.divider:
// break;
// }
// }
// Future<void> _showRenameDialog() async {
// await NavigatorTextFieldDialog(
// title: LocaleKeys.space_rename.tr(),
// value: space.name,
// autoSelectAllText: true,
// onConfirm: (name, _) {
// context.read<SpaceBloc>().add(SpaceEvent.rename(space, name));
// },
// ).show(context);
// }
// void _showManageSpaceDialog(BuildContext context) {
// final spaceBloc = context.read<SpaceBloc>();
// showDialog(
// context: context,
// builder: (_) {
// return Dialog(
// shape: RoundedRectangleBorder(
// borderRadius: BorderRadius.circular(12.0),
// ),
// child: BlocProvider.value(
// value: spaceBloc,
// child: const ManageSpacePopup(),
// ),
// );
// },
// );
// }
// void _showDeleteSpaceDialog(BuildContext context) {
// final spaceBloc = context.read<SpaceBloc>();
// showDialog(
// context: context,
// builder: (_) {
// return Dialog(
// shape: RoundedRectangleBorder(
// borderRadius: BorderRadius.circular(12.0),
// ),
// child: BlocProvider.value(
// value: spaceBloc,
// child: const SizedBox(width: 440, child: DeleteSpacePopup()),
// ),
// );
// },
// );
// }
}
@@ -0,0 +1,128 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class MobileSpaceMenu extends StatelessWidget {
const MobileSpaceMenu({super.key});
@override
Widget build(BuildContext context) {
return BlocBuilder<SpaceBloc, SpaceState>(
builder: (context, state) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const VSpace(4.0),
for (final space in state.spaces)
SizedBox(
height: 52,
child: _SidebarSpaceMenuItem(
space: space,
isSelected: state.currentSpace?.id == space.id,
),
),
// const Padding(
// padding: EdgeInsets.symmetric(vertical: 8.0),
// child: Divider(
// height: 0.5,
// ),
// ),
// const SizedBox(
// height: 52,
// child: _CreateSpaceButton(),
// ),
],
);
},
);
}
}
class _SidebarSpaceMenuItem extends StatelessWidget {
const _SidebarSpaceMenuItem({
required this.space,
required this.isSelected,
});
final ViewPB space;
final bool isSelected;
@override
Widget build(BuildContext context) {
return FlowyButton(
text: Row(
children: [
FlowyText.medium(
space.name,
),
const HSpace(6.0),
if (space.spacePermission == SpacePermission.private)
const FlowySvg(
FlowySvgs.space_lock_s,
size: Size.square(12),
),
],
),
iconPadding: 10,
leftIcon: SpaceIcon(
dimension: 24,
space: space,
cornerRadius: 6.0,
),
leftIconSize: const Size.square(20),
rightIcon: isSelected
? const FlowySvg(
FlowySvgs.workspace_selected_s,
blendMode: null,
)
: null,
onTap: () {
context.read<SpaceBloc>().add(SpaceEvent.open(space));
Navigator.of(context).pop();
},
);
}
}
// class _CreateSpaceButton extends StatelessWidget {
// const _CreateSpaceButton();
// @override
// Widget build(BuildContext context) {
// return FlowyButton(
// text: FlowyText.regular(LocaleKeys.space_createNewSpace.tr()),
// iconPadding: 10,
// leftIcon: const FlowySvg(
// FlowySvgs.space_add_s,
// ),
// leftIconSize: const Size.square(20),
// onTap: () {
// PopoverContainer.of(context).close();
// _showCreateSpaceDialog(context);
// },
// );
// }
// void _showCreateSpaceDialog(BuildContext context) {
// final spaceBloc = context.read<SpaceBloc>();
// showDialog(
// context: context,
// builder: (_) {
// return Dialog(
// shape: RoundedRectangleBorder(
// borderRadius: BorderRadius.circular(12.0),
// ),
// child: BlocProvider.value(
// value: spaceBloc,
// child: const CreateSpacePopup(),
// ),
// );
// },
// );
// }
// }
@@ -21,8 +21,8 @@ class MobileSpaceTabBar extends StatelessWidget {
Widget build(BuildContext context) {
final baseStyle = Theme.of(context).textTheme.bodyMedium;
final labelStyle = baseStyle?.copyWith(
fontWeight: FontWeight.w500,
fontSize: 15.0,
fontWeight: FontWeight.w600,
fontSize: 16.0,
);
final unselectedLabelStyle = baseStyle?.copyWith(
fontWeight: FontWeight.w400,
@@ -1,15 +1,15 @@
import 'dart:ui';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/mobile_router.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/workspace/application/workspace/workspace_service.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
final PropertyValueNotifier<ViewLayoutPB?> createNewPageNotifier =
PropertyValueNotifier(null);
const _homeLabel = 'home';
const _addLabel = 'add';
const _notificationLabel = 'notification';
@@ -46,22 +46,40 @@ class MobileBottomNavigationBar extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isLightMode = Theme.of(context).isLightMode;
final backgroundColor = isLightMode
? Colors.white.withOpacity(0.95)
: const Color(0xFF23262B).withOpacity(0.95);
return Scaffold(
body: navigationShell,
bottomNavigationBar: Theme(
data: Theme.of(context).copyWith(
splashColor: Colors.transparent,
highlightColor: Colors.transparent,
),
child: BottomNavigationBar(
showSelectedLabels: false,
showUnselectedLabels: false,
enableFeedback: false,
type: BottomNavigationBarType.fixed,
elevation: 0,
items: _items,
currentIndex: navigationShell.currentIndex,
onTap: (int bottomBarIndex) => _onTap(context, bottomBarIndex),
extendBody: true,
bottomNavigationBar: ClipRRect(
child: BackdropFilter(
filter: ImageFilter.blur(
sigmaX: 3,
sigmaY: 3,
),
child: DecoratedBox(
decoration: BoxDecoration(
border: isLightMode
? Border(
top: BorderSide(color: Theme.of(context).dividerColor),
)
: null,
color: backgroundColor,
),
child: BottomNavigationBar(
showSelectedLabels: false,
showUnselectedLabels: false,
enableFeedback: false,
type: BottomNavigationBarType.fixed,
elevation: 0,
items: _items,
backgroundColor: Colors.transparent,
currentIndex: navigationShell.currentIndex,
onTap: (int bottomBarIndex) => _onTap(context, bottomBarIndex),
),
),
),
),
);
@@ -72,7 +90,7 @@ class MobileBottomNavigationBar extends StatelessWidget {
void _onTap(BuildContext context, int bottomBarIndex) {
if (_items[bottomBarIndex].label == _addLabel) {
// show an add dialog
_showCreatePageBottomSheet(context);
createNewPageNotifier.value = ViewLayoutPB.Document;
return;
}
// When navigating to a new branch, it's recommended to use the goBranch
@@ -87,40 +105,4 @@ class MobileBottomNavigationBar extends StatelessWidget {
initialLocation: bottomBarIndex == navigationShell.currentIndex,
);
}
void _showCreatePageBottomSheet(BuildContext context) {
showMobileBottomSheet(
context,
showHeader: true,
title: LocaleKeys.sideBar_addAPage.tr(),
showDragHandle: true,
showCloseButton: true,
useRootNavigator: true,
builder: (sheetContext) {
return AddNewPageWidgetBottomSheet(
view: ViewPB(),
onAction: (layout) async {
Navigator.of(sheetContext).pop();
final currentWorkspaceId =
await FolderEventReadCurrentWorkspace().send();
await currentWorkspaceId.fold((s) async {
final workspaceService = WorkspaceService(workspaceId: s.id);
final result = await workspaceService.createView(
name: LocaleKeys.menuAppHeader_defaultNewPageName.tr(),
viewSection: ViewSectionPB.Private,
layout: layout,
);
result.fold((s) {
context.pushView(s);
}, (e) {
Log.error('Failed to create new page: $e');
});
}, (e) {
Log.error('Failed to read current workspace: $e');
});
},
);
},
);
}
}
@@ -296,30 +296,29 @@ class _SingleMobileInnerViewItemState extends State<SingleMobileInnerViewItem> {
final icon = widget.view.icon.value.isNotEmpty
? FlowyText.emoji(
widget.view.icon.value,
fontSize: 20.0,
fontSize: 18.0,
)
: Opacity(
opacity: 0.7,
child: SizedBox.square(
dimension: 18.0,
child: widget.view.defaultIcon(),
),
child: widget.view.defaultIcon(),
);
return icon;
return SizedBox(width: 18.0, child: icon);
}
// > button or · button
// show > if the view is expandable.
// show · if the view can't contain child views.
Widget _buildLeftIcon() {
const rightPadding = 6.0;
if (context.read<ViewBloc>().state.view.childViews.isEmpty) {
return HSpace(widget.leftPadding);
return HSpace(widget.leftPadding + rightPadding);
}
return GestureDetector(
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.only(right: 6.0, top: 6.0, bottom: 6.0),
padding:
const EdgeInsets.only(right: rightPadding, top: 6.0, bottom: 6.0),
child: FlowySvg(
widget.isExpanded ? FlowySvgs.m_expand_s : FlowySvgs.m_collapse_s,
blendMode: null,
@@ -1,42 +1,131 @@
import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'dart:async';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_ai_message_bloc.freezed.dart';
class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
ChatAIMessageBloc({
required Message message,
dynamic message,
required this.chatId,
required this.questionId,
}) : super(ChatAIMessageState.initial(message)) {
if (state.stream != null) {
_subscription = state.stream!.listen((text) {
if (isClosed) {
return;
}
if (text.startsWith("data:")) {
add(ChatAIMessageEvent.newText(text.substring(5)));
} else if (text.startsWith("error:")) {
add(ChatAIMessageEvent.receiveError(text.substring(5)));
}
});
if (state.stream!.error != null) {
Future.delayed(const Duration(milliseconds: 300), () {
if (!isClosed) {
add(ChatAIMessageEvent.receiveError(state.stream!.error!));
}
});
}
}
on<ChatAIMessageEvent>(
(event, emit) async {
await event.when(
initial: () async {},
update: (userProfile, deviceId, states) {},
newText: (newText) {
emit(state.copyWith(text: state.text + newText, error: null));
},
receiveError: (error) {
emit(state.copyWith(error: error));
},
retry: () {
if (questionId is! Int64) {
Log.error("Question id is not Int64: $questionId");
return;
}
emit(
state.copyWith(
retryState: const LoadingState.loading(),
error: null,
),
);
final payload = ChatMessageIdPB(
chatId: chatId,
messageId: questionId,
);
ChatEventGetAnswerForQuestion(payload).send().then((result) {
if (!isClosed) {
result.fold(
(answer) {
add(ChatAIMessageEvent.retryResult(answer.content));
},
(err) {
Log.error("Failed to get answer: $err");
add(ChatAIMessageEvent.receiveError(err.toString()));
},
);
}
});
},
retryResult: (String text) {
emit(
state.copyWith(
text: text,
error: null,
retryState: const LoadingState.finish(),
),
);
},
);
},
);
}
@override
Future<void> close() {
_subscription?.cancel();
return super.close();
}
StreamSubscription<AnswerStreamElement>? _subscription;
final String chatId;
final Int64? questionId;
}
@freezed
class ChatAIMessageEvent with _$ChatAIMessageEvent {
const factory ChatAIMessageEvent.initial() = Initial;
const factory ChatAIMessageEvent.update(
UserProfilePB userProfile,
String deviceId,
DocumentAwarenessStatesPB states,
) = Update;
const factory ChatAIMessageEvent.newText(String text) = _NewText;
const factory ChatAIMessageEvent.receiveError(String error) = _ReceiveError;
const factory ChatAIMessageEvent.retry() = _Retry;
const factory ChatAIMessageEvent.retryResult(String text) = _RetryResult;
}
@freezed
class ChatAIMessageState with _$ChatAIMessageState {
const factory ChatAIMessageState({
required Message message,
AnswerStream? stream,
String? error,
required String text,
required LoadingState retryState,
}) = _ChatAIMessageState;
factory ChatAIMessageState.initial(Message message) =>
ChatAIMessageState(message: message);
factory ChatAIMessageState.initial(dynamic text) {
return ChatAIMessageState(
text: text is String ? text : "",
stream: text is AnswerStream ? text : null,
retryState: const LoadingState.finish(),
);
}
}
@@ -1,6 +1,12 @@
import 'dart:async';
import 'dart:collection';
import 'dart:ffi';
import 'dart:isolate';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
@@ -10,11 +16,11 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:nanoid/nanoid.dart';
import 'chat_message_listener.dart';
part 'chat_bloc.freezed.dart';
const canRetryKey = "canRetry";
const sendMessageErrorKey = "sendMessageError";
class ChatBloc extends Bloc<ChatEvent, ChatState> {
@@ -26,78 +32,31 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
super(
ChatState.initial(view, userProfile),
) {
_startListening();
_dispatch();
listener.start(
chatMessageCallback: _handleChatMessage,
lastUserSentMessageCallback: (message) {
if (!isClosed) {
add(ChatEvent.didSentUserMessage(message));
}
},
chatErrorMessageCallback: (err) {
if (!isClosed) {
Log.error("chat error: ${err.errorMessage}");
final metadata = OnetimeShotType.serverStreamError.toMap();
if (state.lastSentMessage != null) {
metadata[canRetryKey] = "true";
}
final error = CustomMessage(
metadata: metadata,
author: const User(id: "system"),
id: 'system',
);
add(ChatEvent.streaming([error]));
add(const ChatEvent.didFinishStreaming());
}
},
latestMessageCallback: (list) {
if (!isClosed) {
final messages = list.messages.map(_createChatMessage).toList();
add(ChatEvent.didLoadLatestMessages(messages));
}
},
prevMessageCallback: (list) {
if (!isClosed) {
final messages = list.messages.map(_createChatMessage).toList();
add(ChatEvent.didLoadPreviousMessages(messages, list.hasMore));
}
},
finishAnswerQuestionCallback: () {
if (!isClosed) {
add(const ChatEvent.didFinishStreaming());
if (state.lastSentMessage != null) {
final payload = ChatMessageIdPB(
chatId: chatId,
messageId: state.lastSentMessage!.messageId,
);
// When user message was sent to the server, we start gettting related question
ChatEventGetRelatedQuestion(payload).send().then((result) {
if (!isClosed) {
result.fold(
(list) {
add(
ChatEvent.didReceiveRelatedQuestion(list.items),
);
},
(err) {
Log.error("Failed to get related question: $err");
},
);
}
});
}
}
},
);
}
final ChatMessageListener listener;
final String chatId;
/// The last streaming message id
String lastStreamMessageId = '';
/// Using a temporary map to associate the real message ID with the last streaming message ID.
///
/// When a message is streaming, it does not have a real message ID. To maintain the relationship
/// between the real message ID and the last streaming message ID, we use this map to store the associations.
///
/// This map will be updated when receiving a message from the server and its author type
/// is 3 (AI response).
final HashMap<String, String> temporaryMessageIDMap = HashMap();
@override
Future<void> close() {
listener.stop();
Future<void> close() async {
if (state.answerStream != null) {
await state.answerStream?.dispose();
}
await listener.stop();
return super.close();
}
@@ -110,12 +69,25 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
chatId: state.view.id,
limit: Int64(10),
);
ChatEventLoadNextMessage(payload).send();
ChatEventLoadNextMessage(payload).send().then(
(result) {
result.fold((list) {
if (!isClosed) {
final messages =
list.messages.map(_createTextMessage).toList();
add(ChatEvent.didLoadLatestMessages(messages));
}
}, (err) {
Log.error("Failed to load messages: $err");
});
},
);
},
startLoadingPrevMessage: () async {
Int64? beforeMessageId;
if (state.messages.isNotEmpty) {
beforeMessageId = Int64.parseInt(state.messages.last.id);
final oldestMessage = _getOlderstMessage();
if (oldestMessage != null) {
beforeMessageId = Int64.parseInt(oldestMessage.id);
}
_loadPrevMessage(beforeMessageId);
emit(
@@ -126,8 +98,13 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
},
didLoadPreviousMessages: (List<Message> messages, bool hasMore) {
Log.debug("did load previous messages: ${messages.length}");
final uniqueMessages = {...state.messages, ...messages}.toList()
final onetimeMessages = _getOnetimeMessages();
final allMessages = _perminentMessages();
final uniqueMessages = {...allMessages, ...messages}.toList()
..sort((a, b) => b.id.compareTo(a.id));
uniqueMessages.insertAll(0, onetimeMessages);
emit(
state.copyWith(
messages: uniqueMessages,
@@ -137,8 +114,12 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
);
},
didLoadLatestMessages: (List<Message> messages) {
final uniqueMessages = {...state.messages, ...messages}.toList()
final onetimeMessages = _getOnetimeMessages();
final allMessages = _perminentMessages();
final uniqueMessages = {...allMessages, ...messages}.toList()
..sort((a, b) => b.id.compareTo(a.id));
uniqueMessages.insertAll(0, onetimeMessages);
emit(
state.copyWith(
messages: uniqueMessages,
@@ -146,55 +127,43 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
),
);
},
streaming: (List<Message> messages) {
streaming: (Message message) {
final allMessages = _perminentMessages();
allMessages.insertAll(0, messages);
emit(state.copyWith(messages: allMessages));
},
didFinishStreaming: () {
allMessages.insert(0, message);
emit(
state.copyWith(
answerQuestionStatus: const LoadingState.finish(),
messages: allMessages,
streamingStatus: const LoadingState.loading(),
),
);
},
sendMessage: (String message) async {
await _handleSentMessage(message, emit);
// Create a loading indicator
final loadingMessage =
_loadingMessage(state.userProfile.id.toString());
final allMessages = List<Message>.from(state.messages)
..insert(0, loadingMessage);
didFinishStreaming: () {
emit(
state.copyWith(streamingStatus: const LoadingState.finish()),
);
},
receveMessage: (Message message) {
final allMessages = _perminentMessages();
// remove message with the same id
allMessages.removeWhere((element) => element.id == message.id);
allMessages.insert(0, message);
emit(
state.copyWith(
messages: allMessages,
),
);
},
sendMessage: (String message) {
_startStreamingMessage(message, emit);
final allMessages = _perminentMessages();
emit(
state.copyWith(
lastSentMessage: null,
messages: allMessages,
answerQuestionStatus: const LoadingState.loading(),
relatedQuestions: [],
),
);
},
retryGenerate: () {
if (state.lastSentMessage == null) {
return;
}
final payload = ChatMessageIdPB(
chatId: chatId,
messageId: state.lastSentMessage!.messageId,
);
ChatEventGetAnswerForQuestion(payload).send().then((result) {
if (!isClosed) {
result.fold(
(answer) => _handleChatMessage(answer),
(err) {
Log.error("Failed to get answer: $err");
},
);
}
});
},
didReceiveRelatedQuestion: (List<RelatedQuestionPB> questions) {
final allMessages = _perminentMessages();
final message = CustomMessage(
@@ -224,11 +193,104 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
),
);
},
didUpdateAnswerStream: (AnswerStream stream) {
emit(state.copyWith(answerStream: stream));
},
stopStream: () async {
if (state.answerStream == null) {
return;
}
final payload = StopStreamPB(chatId: chatId);
await ChatEventStopStream(payload).send();
final allMessages = _perminentMessages();
if (state.streamingStatus != const LoadingState.finish()) {
// If the streaming is not started, remove the message from the list
if (!state.answerStream!.hasStarted) {
allMessages.removeWhere(
(element) => element.id == lastStreamMessageId,
);
lastStreamMessageId = "";
}
// when stop stream, we will set the answer stream to null. Which means the streaming
// is finished or canceled.
emit(
state.copyWith(
messages: allMessages,
answerStream: null,
streamingStatus: const LoadingState.finish(),
),
);
}
},
);
},
);
}
void _startListening() {
listener.start(
chatMessageCallback: (pb) {
if (!isClosed) {
// 3 mean message response from AI
if (pb.authorType == 3 && lastStreamMessageId.isNotEmpty) {
temporaryMessageIDMap[pb.messageId.toString()] =
lastStreamMessageId;
lastStreamMessageId = "";
}
final message = _createTextMessage(pb);
add(ChatEvent.receveMessage(message));
}
},
chatErrorMessageCallback: (err) {
if (!isClosed) {
Log.error("chat error: ${err.errorMessage}");
add(const ChatEvent.didFinishStreaming());
}
},
latestMessageCallback: (list) {
if (!isClosed) {
final messages = list.messages.map(_createTextMessage).toList();
add(ChatEvent.didLoadLatestMessages(messages));
}
},
prevMessageCallback: (list) {
if (!isClosed) {
final messages = list.messages.map(_createTextMessage).toList();
add(ChatEvent.didLoadPreviousMessages(messages, list.hasMore));
}
},
finishStreamingCallback: () {
if (!isClosed) {
add(const ChatEvent.didFinishStreaming());
// The answer strema will bet set to null after the streaming is finished or canceled.
// so if the answer stream is null, we will not get related question.
if (state.lastSentMessage != null && state.answerStream != null) {
final payload = ChatMessageIdPB(
chatId: chatId,
messageId: state.lastSentMessage!.messageId,
);
// When user message was sent to the server, we start gettting related question
ChatEventGetRelatedQuestion(payload).send().then((result) {
if (!isClosed) {
result.fold(
(list) {
add(ChatEvent.didReceiveRelatedQuestion(list.items));
},
(err) {
Log.error("Failed to get related question: $err");
},
);
}
});
}
}
},
);
}
// Returns the list of messages that are not include one-time messages.
List<Message> _perminentMessages() {
final allMessages = state.messages.where((element) {
@@ -238,6 +300,22 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
return allMessages;
}
List<Message> _getOnetimeMessages() {
final messages = state.messages.where((element) {
return (element.metadata?.containsKey(onetimeShotType) == true);
}).toList();
return messages;
}
Message? _getOlderstMessage() {
// get the last message that is not a one-time message
final message = state.messages.lastWhereOrNull((element) {
return !(element.metadata?.containsKey(onetimeShotType) == true);
});
return message;
}
void _loadPrevMessage(Int64? beforeMessageId) {
final payload = LoadPrevChatMessagePB(
chatId: state.view.id,
@@ -247,68 +325,92 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
ChatEventLoadPrevMessage(payload).send();
}
Future<void> _handleSentMessage(
Future<void> _startStreamingMessage(
String message,
Emitter<ChatState> emit,
) async {
final payload = SendChatPayloadPB(
if (state.answerStream != null) {
await state.answerStream?.dispose();
}
final answerStream = AnswerStream();
add(ChatEvent.didUpdateAnswerStream(answerStream));
final payload = StreamChatPayloadPB(
chatId: state.view.id,
message: message,
messageType: ChatMessageTypePB.User,
textStreamPort: Int64(answerStream.nativePort),
);
final result = await ChatEventSendMessage(payload).send();
// Stream message to the server
final result = await ChatEventStreamMessage(payload).send();
result.fold(
(_) {},
(ChatMessagePB question) {
if (!isClosed) {
add(ChatEvent.didSentUserMessage(question));
final questionMessageId = question.messageId;
final message = _createTextMessage(question);
add(ChatEvent.receveMessage(message));
final streamAnswer =
_createStreamMessage(answerStream, questionMessageId);
add(ChatEvent.streaming(streamAnswer));
}
},
(err) {
if (!isClosed) {
Log.error("Failed to send message: ${err.msg}");
final metadata = OnetimeShotType.invalidSendMesssage.toMap();
metadata[sendMessageErrorKey] = err.msg;
if (err.code != ErrorCode.Internal) {
metadata[sendMessageErrorKey] = err.msg;
}
final error = CustomMessage(
metadata: metadata,
author: const User(id: "system"),
id: 'system',
);
add(ChatEvent.streaming([error]));
add(ChatEvent.receveMessage(error));
}
},
);
}
void _handleChatMessage(ChatMessagePB pb) {
if (!isClosed) {
final message = _createChatMessage(pb);
final messages = pb.hasFollowing
? [_loadingMessage(0.toString()), message]
: [message];
add(ChatEvent.streaming(messages));
}
}
Message _createStreamMessage(AnswerStream stream, Int64 questionMessageId) {
final streamMessageId = nanoid();
lastStreamMessageId = streamMessageId;
Message _loadingMessage(String id) {
return CustomMessage(
author: User(id: id),
metadata: OnetimeShotType.loading.toMap(),
// fake id
id: nanoid(),
return TextMessage(
author: User(id: nanoid()),
metadata: {
"$AnswerStream": stream,
"question": questionMessageId,
"chatId": chatId,
},
id: streamMessageId,
createdAt: DateTime.now().millisecondsSinceEpoch,
text: '',
);
}
Message _createChatMessage(ChatMessagePB message) {
final messageId = message.messageId.toString();
Message _createTextMessage(ChatMessagePB message) {
String messageId = message.messageId.toString();
/// If the message id is in the temporary map, we will use the previous fake message id
if (temporaryMessageIDMap.containsKey(messageId)) {
messageId = temporaryMessageIDMap[messageId]!;
}
return TextMessage(
author: User(id: message.authorId),
id: messageId,
text: message.content,
createdAt: message.createdAt.toInt(),
repliedMessage: _getReplyMessage(state.messages, messageId),
createdAt: message.createdAt.toInt() * 1000,
);
}
Message? _getReplyMessage(List<Message?> messages, String messageId) {
return messages.firstWhereOrNull((element) => element?.id == messageId);
}
}
@freezed
@@ -322,15 +424,20 @@ class ChatEvent with _$ChatEvent {
) = _DidLoadPreviousMessages;
const factory ChatEvent.didLoadLatestMessages(List<Message> messages) =
_DidLoadMessages;
const factory ChatEvent.streaming(List<Message> messages) = _DidStreamMessage;
const factory ChatEvent.streaming(Message message) = _StreamingMessage;
const factory ChatEvent.receveMessage(Message message) = _ReceiveMessage;
const factory ChatEvent.didFinishStreaming() = _FinishStreamingMessage;
const factory ChatEvent.didReceiveRelatedQuestion(
List<RelatedQuestionPB> questions,
) = _DidReceiveRelatedQueston;
const factory ChatEvent.clearReleatedQuestion() = _ClearRelatedQuestion;
const factory ChatEvent.retryGenerate() = _RetryGenerate;
const factory ChatEvent.didSentUserMessage(ChatMessagePB message) =
_DidSendUserMessage;
const factory ChatEvent.didUpdateAnswerStream(
AnswerStream stream,
) = _DidUpdateAnswerStream;
const factory ChatEvent.stopStream() = _StopStream;
}
@freezed
@@ -347,13 +454,14 @@ class ChatState with _$ChatState {
required LoadingState loadingPreviousStatus,
// When sending a user message, the status will be set as loading.
// After the message is sent, the status will be set as finished.
required LoadingState answerQuestionStatus,
required LoadingState streamingStatus,
// Indicate whether there are more previous messages to load.
required bool hasMorePrevMessage,
// The related questions that are received after the user message is sent.
required List<RelatedQuestionPB> relatedQuestions,
// The last user message that is sent to the server.
ChatMessagePB? lastSentMessage,
AnswerStream? answerStream,
}) = _ChatState;
factory ChatState.initial(ViewPB view, UserProfilePB userProfile) =>
@@ -363,7 +471,7 @@ class ChatState with _$ChatState {
userProfile: userProfile,
initialLoadingStatus: const LoadingState.finish(),
loadingPreviousStatus: const LoadingState.finish(),
answerQuestionStatus: const LoadingState.finish(),
streamingStatus: const LoadingState.finish(),
hasMorePrevMessage: true,
relatedQuestions: [],
);
@@ -377,10 +485,8 @@ class LoadingState with _$LoadingState {
enum OnetimeShotType {
unknown,
loading,
serverStreamError,
relatedQuestion,
invalidSendMesssage
invalidSendMesssage,
}
const onetimeShotType = "OnetimeShotType";
@@ -388,10 +494,6 @@ const onetimeShotType = "OnetimeShotType";
extension OnetimeMessageTypeExtension on OnetimeShotType {
static OnetimeShotType fromString(String value) {
switch (value) {
case 'OnetimeShotType.loading':
return OnetimeShotType.loading;
case 'OnetimeShotType.serverStreamError':
return OnetimeShotType.serverStreamError;
case 'OnetimeShotType.relatedQuestion':
return OnetimeShotType.relatedQuestion;
case 'OnetimeShotType.invalidSendMesssage':
@@ -402,7 +504,7 @@ extension OnetimeMessageTypeExtension on OnetimeShotType {
}
}
Map<String, String> toMap() {
Map<String, dynamic> toMap() {
return {
onetimeShotType: toString(),
};
@@ -421,3 +523,43 @@ OnetimeShotType? onetimeMessageTypeFromMeta(Map<String, dynamic>? metadata) {
}
return null;
}
typedef AnswerStreamElement = String;
class AnswerStream {
AnswerStream() {
_port.handler = _controller.add;
_subscription = _controller.stream.listen(
(event) {
if (event.startsWith("data:")) {
_hasStarted = true;
} else if (event.startsWith("error:")) {
_error = event.substring(5);
}
},
);
}
final RawReceivePort _port = RawReceivePort();
final StreamController<AnswerStreamElement> _controller =
StreamController.broadcast();
late StreamSubscription<AnswerStreamElement> _subscription;
bool _hasStarted = false;
String? _error;
int get nativePort => _port.sendPort.nativePort;
bool get hasStarted => _hasStarted;
String? get error => _error;
Future<void> dispose() async {
await _controller.close();
await _subscription.cancel();
_port.close();
}
StreamSubscription<AnswerStreamElement> listen(
void Function(AnswerStreamElement event)? onData,
) {
return _controller.stream.listen(onData);
}
}
@@ -28,26 +28,23 @@ class ChatMessageListener {
ChatNotificationParser? _parser;
ChatMessageCallback? chatMessageCallback;
ChatMessageCallback? lastUserSentMessageCallback;
ChatErrorMessageCallback? chatErrorMessageCallback;
LatestMessageCallback? latestMessageCallback;
PrevMessageCallback? prevMessageCallback;
void Function()? finishAnswerQuestionCallback;
void Function()? finishStreamingCallback;
void start({
ChatMessageCallback? chatMessageCallback,
ChatErrorMessageCallback? chatErrorMessageCallback,
LatestMessageCallback? latestMessageCallback,
PrevMessageCallback? prevMessageCallback,
ChatMessageCallback? lastUserSentMessageCallback,
void Function()? finishAnswerQuestionCallback,
void Function()? finishStreamingCallback,
}) {
this.chatMessageCallback = chatMessageCallback;
this.chatErrorMessageCallback = chatErrorMessageCallback;
this.latestMessageCallback = latestMessageCallback;
this.prevMessageCallback = prevMessageCallback;
this.lastUserSentMessageCallback = lastUserSentMessageCallback;
this.finishAnswerQuestionCallback = finishAnswerQuestionCallback;
this.finishStreamingCallback = finishStreamingCallback;
}
void _callback(
@@ -59,9 +56,6 @@ class ChatMessageListener {
case ChatNotification.DidReceiveChatMessage:
chatMessageCallback?.call(ChatMessagePB.fromBuffer(r));
break;
case ChatNotification.LastUserSentMessage:
lastUserSentMessageCallback?.call(ChatMessagePB.fromBuffer(r));
break;
case ChatNotification.StreamChatMessageError:
chatErrorMessageCallback?.call(ChatMessageErrorPB.fromBuffer(r));
break;
@@ -71,8 +65,8 @@ class ChatMessageListener {
case ChatNotification.DidLoadPrevChatMessage:
prevMessageCallback?.call(ChatMessageListPB.fromBuffer(r));
break;
case ChatNotification.FinishAnswerQuestion:
finishAnswerQuestionCallback?.call();
case ChatNotification.FinishStreaming:
finishStreamingCallback?.call();
break;
default:
break;
@@ -1,103 +0,0 @@
import 'package:appflowy/plugins/ai_chat/application/chat_message_listener.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_related_question_bloc.freezed.dart';
class ChatRelatedMessageBloc
extends Bloc<ChatRelatedMessageEvent, ChatRelatedMessageState> {
ChatRelatedMessageBloc({
required String chatId,
}) : listener = ChatMessageListener(chatId: chatId),
super(ChatRelatedMessageState.initial()) {
on<ChatRelatedMessageEvent>(
(event, emit) async {
await event.when(
initial: () async {
listener.start(
lastUserSentMessageCallback: (message) {
if (!isClosed) {
add(ChatRelatedMessageEvent.updateLastSentMessage(message));
}
},
);
},
didReceiveRelatedQuestion: (List<RelatedQuestionPB> questions) {
Log.debug("Related questions: $questions");
emit(
state.copyWith(
relatedQuestions: questions,
),
);
},
updateLastSentMessage: (ChatMessagePB message) {
final payload =
ChatMessageIdPB(chatId: chatId, messageId: message.messageId);
ChatEventGetRelatedQuestion(payload).send().then((result) {
if (!isClosed) {
result.fold(
(list) {
add(
ChatRelatedMessageEvent.didReceiveRelatedQuestion(
list.items,
),
);
},
(err) {
Log.error("Failed to get related question: $err");
},
);
}
});
emit(
state.copyWith(
lastSentMessage: message,
relatedQuestions: [],
),
);
},
clear: () {
emit(
state.copyWith(
relatedQuestions: [],
),
);
},
);
},
);
}
final ChatMessageListener listener;
@override
Future<void> close() {
listener.stop();
return super.close();
}
}
@freezed
class ChatRelatedMessageEvent with _$ChatRelatedMessageEvent {
const factory ChatRelatedMessageEvent.initial() = Initial;
const factory ChatRelatedMessageEvent.updateLastSentMessage(
ChatMessagePB message,
) = _LastSentMessage;
const factory ChatRelatedMessageEvent.didReceiveRelatedQuestion(
List<RelatedQuestionPB> questions,
) = _RelatedQuestion;
const factory ChatRelatedMessageEvent.clear() = _Clear;
}
@freezed
class ChatRelatedMessageState with _$ChatRelatedMessageState {
const factory ChatRelatedMessageState({
ChatMessagePB? lastSentMessage,
@Default([]) List<RelatedQuestionPB> relatedQuestions,
}) = _ChatRelatedMessageState;
factory ChatRelatedMessageState.initial() => const ChatRelatedMessageState();
}
@@ -1,5 +1,7 @@
import 'package:appflowy_backend/protobuf/flowy-document/protobuf.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@@ -13,9 +15,23 @@ class ChatUserMessageBloc
}) : super(ChatUserMessageState.initial(message)) {
on<ChatUserMessageEvent>(
(event, emit) async {
await event.when(
initial: () async {},
update: (userProfile, deviceId, states) {},
event.when(
initial: () {
final payload =
WorkspaceMemberIdPB(uid: Int64.parseInt(message.author.id));
UserEventGetMemberInfo(payload).send().then((result) {
if (!isClosed) {
result.fold((member) {
add(ChatUserMessageEvent.didReceiveMemberInfo(member));
}, (err) {
Log.error("Error getting member info: $err");
});
}
});
},
didReceiveMemberInfo: (WorkspaceMemberPB memberInfo) {
emit(state.copyWith(member: memberInfo));
},
);
},
);
@@ -25,11 +41,9 @@ class ChatUserMessageBloc
@freezed
class ChatUserMessageEvent with _$ChatUserMessageEvent {
const factory ChatUserMessageEvent.initial() = Initial;
const factory ChatUserMessageEvent.update(
UserProfilePB userProfile,
String deviceId,
DocumentAwarenessStatesPB states,
) = Update;
const factory ChatUserMessageEvent.didReceiveMemberInfo(
WorkspaceMemberPB memberInfo,
) = _MemberInfo;
}
@freezed
@@ -1,9 +1,8 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_ai_message.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_streaming_error_message.dart';
import 'package:appflowy/plugins/ai_chat/presentation/ai_message_bubble.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_related_question.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_user_message.dart';
import 'package:appflowy/plugins/ai_chat/presentation/user_message_bubble.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
@@ -19,11 +18,12 @@ import 'package:flutter_chat_ui/flutter_chat_ui.dart' show Chat;
import 'package:flutter_chat_types/flutter_chat_types.dart' as types;
import 'presentation/chat_input.dart';
import 'presentation/chat_loading.dart';
import 'presentation/chat_popmenu.dart';
import 'presentation/chat_theme.dart';
import 'presentation/chat_user_invalid_message.dart';
import 'presentation/chat_welcome_page.dart';
import 'presentation/message/ai_text_message.dart';
import 'presentation/message/user_text_message.dart';
class AIChatUILayout {
static EdgeInsets get chatPadding =>
@@ -108,7 +108,6 @@ class _AIChatPageState extends State<AIChatPage> {
customBottomWidget: buildChatInput(blocContext),
user: _user,
theme: buildTheme(context),
customMessageBuilder: _customMessageBuilder,
onEndReached: () async {
if (state.hasMorePrevMessage &&
state.loadingPreviousStatus !=
@@ -138,6 +137,13 @@ class _AIChatPageState extends State<AIChatPage> {
},
),
messageWidthRatio: AIChatUILayout.messageWidthRatio,
textMessageBuilder: (
textMessage, {
required messageWidth,
required showName,
}) {
return _buildAITextMessage(blocContext, textMessage);
},
bubbleBuilder: (
child, {
required message,
@@ -149,46 +155,7 @@ class _AIChatPageState extends State<AIChatPage> {
child: child,
);
} else {
final messageType = onetimeMessageTypeFromMeta(
message.metadata,
);
if (messageType == OnetimeShotType.serverStreamError) {
return ChatStreamingError(
message: message,
onRetryPressed: () {
blocContext
.read<ChatBloc>()
.add(const ChatEvent.retryGenerate());
},
);
}
if (messageType == OnetimeShotType.invalidSendMesssage) {
return ChatInvalidUserMessage(
message: message,
);
}
if (messageType == OnetimeShotType.relatedQuestion) {
return RelatedQuestionList(
onQuestionSelected: (question) {
blocContext
.read<ChatBloc>()
.add(ChatEvent.sendMessage(question));
blocContext
.read<ChatBloc>()
.add(const ChatEvent.clearReleatedQuestion());
},
chatId: widget.view.id,
relatedQuestions: state.relatedQuestions,
);
}
return ChatAIMessageBubble(
message: message,
customMessageType: messageType,
child: child,
);
return _buildAIBubble(message, blocContext, state, child);
}
},
);
@@ -199,10 +166,67 @@ class _AIChatPageState extends State<AIChatPage> {
);
}
Widget _buildAITextMessage(BuildContext context, TextMessage message) {
final isAuthor = message.author.id == _user.id;
if (isAuthor) {
return ChatTextMessageWidget(
user: message.author,
messageUserId: message.id,
text: message.text,
);
} else {
final stream = message.metadata?["$AnswerStream"];
final questionId = message.metadata?["question"];
return ChatAITextMessageWidget(
user: message.author,
messageUserId: message.id,
text: stream is AnswerStream ? stream : message.text,
key: ValueKey(message.id),
questionId: questionId,
chatId: widget.view.id,
);
}
}
Widget _buildAIBubble(
Message message,
BuildContext blocContext,
ChatState state,
Widget child,
) {
final messageType = onetimeMessageTypeFromMeta(
message.metadata,
);
if (messageType == OnetimeShotType.invalidSendMesssage) {
return ChatInvalidUserMessage(
message: message,
);
}
if (messageType == OnetimeShotType.relatedQuestion) {
return RelatedQuestionList(
onQuestionSelected: (question) {
blocContext.read<ChatBloc>().add(ChatEvent.sendMessage(question));
blocContext
.read<ChatBloc>()
.add(const ChatEvent.clearReleatedQuestion());
},
chatId: widget.view.id,
relatedQuestions: state.relatedQuestions,
);
}
return ChatAIMessageBubble(
message: message,
customMessageType: messageType,
child: child,
);
}
Widget buildBubble(Message message, Widget child) {
final isAuthor = message.author.id == _user.id;
const borderRadius = BorderRadius.all(Radius.circular(6));
final childWithPadding = isAuthor
? Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16),
@@ -261,33 +285,25 @@ class _AIChatPageState extends State<AIChatPage> {
}
}
Widget _customMessageBuilder(
types.CustomMessage message, {
required int messageWidth,
}) {
// iteration custom message type
final messageType = onetimeMessageTypeFromMeta(message.metadata);
if (messageType == null) {
return const SizedBox.shrink();
}
switch (messageType) {
case OnetimeShotType.loading:
return const ChatAILoading();
default:
return const SizedBox.shrink();
}
}
Widget buildChatInput(BuildContext context) {
return ClipRect(
child: Padding(
padding: AIChatUILayout.safeAreaInsets(context),
child: Column(
children: [
ChatInput(
chatId: widget.view.id,
onSendPressed: (message) => onSendPressed(context, message.text),
BlocSelector<ChatBloc, ChatState, LoadingState>(
selector: (state) => state.streamingStatus,
builder: (context, state) {
return ChatInput(
chatId: widget.view.id,
onSendPressed: (message) =>
onSendPressed(context, message.text),
isStreaming: state != const LoadingState.finish(),
onStopStreaming: () {
context.read<ChatBloc>().add(const ChatEvent.stopStream());
},
);
},
),
const VSpace(6),
Opacity(
@@ -1,6 +1,5 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_avatar.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_input.dart';
@@ -13,7 +12,6 @@ import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart';
import 'package:styled_widget/styled_widget.dart';
@@ -35,30 +33,22 @@ class ChatAIMessageBubble extends StatelessWidget {
Widget build(BuildContext context) {
const padding = EdgeInsets.symmetric(horizontal: _leftPadding);
final childWithPadding = Padding(padding: padding, child: child);
final widget = isMobile
? _wrapPopMenu(childWithPadding)
: _wrapHover(childWithPadding);
return BlocProvider(
create: (context) => ChatAIMessageBloc(message: message),
child: BlocBuilder<ChatAIMessageBloc, ChatAIMessageState>(
builder: (context, state) {
final widget = isMobile
? _wrapPopMenu(childWithPadding)
: _wrapHover(childWithPadding);
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ChatBorderedCircleAvatar(
child: FlowySvg(
FlowySvgs.flowy_ai_chat_logo_s,
size: Size.square(24),
),
),
Expanded(child: widget),
],
);
},
),
return Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const ChatBorderedCircleAvatar(
child: FlowySvg(
FlowySvgs.flowy_ai_chat_logo_s,
size: Size.square(24),
),
),
Expanded(child: widget),
],
);
}
@@ -118,7 +108,7 @@ class _ChatAIMessageHoverState extends State<ChatAIMessageHover> {
borderRadius: Corners.s6Border,
),
child: Padding(
padding: const EdgeInsets.only(bottom: 40),
padding: const EdgeInsets.only(bottom: 30),
child: widget.child,
),
),
@@ -18,13 +18,17 @@ class ChatInput extends StatefulWidget {
required this.onSendPressed,
required this.chatId,
this.options = const InputOptions(),
required this.isStreaming,
required this.onStopStreaming,
});
final bool? isAttachmentUploading;
final VoidCallback? onAttachmentPressed;
final void Function(types.PartialText) onSendPressed;
final void Function() onStopStreaming;
final InputOptions options;
final String chatId;
final bool isStreaming;
@override
State<ChatInput> createState() => _ChatInputState();
@@ -68,26 +72,23 @@ class _ChatInputState extends State<ChatInput> {
void _handleSendButtonVisibilityModeChange() {
_textController.removeListener(_handleTextControllerChange);
if (widget.options.sendButtonVisibilityMode ==
SendButtonVisibilityMode.hidden) {
_sendButtonVisible = false;
} else if (widget.options.sendButtonVisibilityMode ==
SendButtonVisibilityMode.editing) {
_sendButtonVisible = _textController.text.trim() != '';
_textController.addListener(_handleTextControllerChange);
} else {
_sendButtonVisible = true;
}
_sendButtonVisible =
_textController.text.trim() != '' || widget.isStreaming;
_textController.addListener(_handleTextControllerChange);
}
void _handleSendPressed() {
final trimmedText = _textController.text.trim();
if (trimmedText != '') {
final partialText = types.PartialText(text: trimmedText);
widget.onSendPressed(partialText);
if (widget.isStreaming) {
widget.onStopStreaming();
} else {
final trimmedText = _textController.text.trim();
if (trimmedText != '') {
final partialText = types.PartialText(text: trimmedText);
widget.onSendPressed(partialText);
if (widget.options.inputClearMode == InputClearMode.always) {
_textController.clear();
if (widget.options.inputClearMode == InputClearMode.always) {
_textController.clear();
}
}
}
}
@@ -138,6 +139,7 @@ class _ChatInputState extends State<ChatInput> {
padding: textPadding,
child: TextField(
controller: _textController,
readOnly: widget.isStreaming,
focusNode: _inputFocusNode,
decoration: InputDecoration(
border: InputBorder.none,
@@ -153,7 +155,6 @@ class _ChatInputState extends State<ChatInput> {
autocorrect: widget.options.autocorrect,
autofocus: widget.options.autofocus,
enableSuggestions: widget.options.enableSuggestions,
spellCheckConfiguration: const SpellCheckConfiguration(),
keyboardType: widget.options.keyboardType,
textCapitalization: TextCapitalization.sentences,
maxLines: 10,
@@ -173,8 +174,14 @@ class _ChatInputState extends State<ChatInput> {
visible: _sendButtonVisible,
child: Padding(
padding: buttonPadding,
child: SendButton(
onPressed: _handleSendPressed,
child: AccessoryButton(
onSendPressed: () {
_handleSendPressed();
},
onStopStreaming: () {
widget.onStopStreaming();
},
isStreaming: widget.isStreaming,
),
),
),
@@ -184,10 +191,7 @@ class _ChatInputState extends State<ChatInput> {
@override
void didUpdateWidget(covariant ChatInput oldWidget) {
super.didUpdateWidget(oldWidget);
if (widget.options.sendButtonVisibilityMode !=
oldWidget.options.sendButtonVisibilityMode) {
_handleSendButtonVisibilityModeChange();
}
_handleSendButtonVisibilityModeChange();
}
@override
@@ -211,7 +215,6 @@ class InputOptions {
this.keyboardType = TextInputType.multiline,
this.onTextChanged,
this.onTextFieldTap,
this.sendButtonVisibilityMode = SendButtonVisibilityMode.editing,
this.textEditingController,
this.autocorrect = true,
this.autofocus = false,
@@ -231,11 +234,6 @@ class InputOptions {
/// Will be called on [TextField] tap.
final VoidCallback? onTextFieldTap;
/// Controls the visibility behavior of the [SendButton] based on the
/// [TextField] state inside the [ChatInput] widget.
/// Defaults to [SendButtonVisibilityMode.editing].
final SendButtonVisibilityMode sendButtonVisibilityMode;
/// Custom [TextEditingController]. If not provided, defaults to the
/// [InputTextFieldController], which extends [TextEditingController] and has
/// additional fatures like markdown support. If you want to keep additional
@@ -260,24 +258,46 @@ class InputOptions {
final isMobile = defaultTargetPlatform == TargetPlatform.android ||
defaultTargetPlatform == TargetPlatform.iOS;
class SendButton extends StatelessWidget {
const SendButton({required this.onPressed, super.key});
class AccessoryButton extends StatelessWidget {
const AccessoryButton({
required this.onSendPressed,
required this.onStopStreaming,
required this.isStreaming,
super.key,
});
final void Function() onPressed;
final void Function() onSendPressed;
final void Function() onStopStreaming;
final bool isStreaming;
@override
Widget build(BuildContext context) {
return FlowyIconButton(
width: 36,
fillColor: AFThemeExtension.of(context).lightGreyHover,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
radius: BorderRadius.circular(18),
icon: FlowySvg(
FlowySvgs.send_s,
size: const Size.square(24),
color: Theme.of(context).colorScheme.primary,
),
onPressed: onPressed,
);
if (isStreaming) {
return FlowyIconButton(
width: 36,
icon: FlowySvg(
FlowySvgs.ai_stream_stop_s,
size: const Size.square(28),
color: Theme.of(context).colorScheme.primary,
),
onPressed: onStopStreaming,
radius: BorderRadius.circular(18),
fillColor: AFThemeExtension.of(context).lightGreyHover,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
);
} else {
return FlowyIconButton(
width: 36,
fillColor: AFThemeExtension.of(context).lightGreyHover,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
radius: BorderRadius.circular(18),
icon: FlowySvg(
FlowySvgs.send_s,
size: const Size.square(24),
color: Theme.of(context).colorScheme.primary,
),
onPressed: onSendPressed,
);
}
}
}
@@ -53,15 +53,15 @@ class ContentPlaceholder extends StatelessWidget {
),
],
),
Container(
width: 140,
height: 16.0,
margin: const EdgeInsets.only(bottom: 8.0),
decoration: BoxDecoration(
color: AFThemeExtension.of(context).lightGreyHover,
borderRadius: BorderRadius.circular(4.0),
),
),
// Container(
// width: 140,
// height: 16.0,
// margin: const EdgeInsets.only(bottom: 8.0),
// decoration: BoxDecoration(
// color: AFThemeExtension.of(context).lightGreyHover,
// borderRadius: BorderRadius.circular(4.0),
// ),
// ),
],
),
);
@@ -1,47 +1,11 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_related_question_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-chat/entities.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class RelatedQuestionPage extends StatefulWidget {
const RelatedQuestionPage({
required this.chatId,
required this.onQuestionSelected,
super.key,
});
final String chatId;
final Function(String) onQuestionSelected;
@override
State<RelatedQuestionPage> createState() => _RelatedQuestionPageState();
}
class _RelatedQuestionPageState extends State<RelatedQuestionPage> {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => ChatRelatedMessageBloc(chatId: widget.chatId)
..add(
const ChatRelatedMessageEvent.initial(),
),
child: BlocBuilder<ChatRelatedMessageBloc, ChatRelatedMessageState>(
builder: (blocContext, state) {
return RelatedQuestionList(
chatId: widget.chatId,
onQuestionSelected: widget.onQuestionSelected,
relatedQuestions: state.relatedQuestions,
);
},
),
);
}
}
class RelatedQuestionList extends StatelessWidget {
const RelatedQuestionList({
@@ -0,0 +1,10 @@
import 'package:flutter/widgets.dart';
class StreamTextField extends StatelessWidget {
const StreamTextField({super.key});
@override
Widget build(BuildContext context) {
return const Placeholder();
}
}
@@ -1,84 +1,83 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.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_chat_types/flutter_chat_types.dart';
// import 'package:appflowy/generated/locale_keys.g.dart';
// import 'package:easy_localization/easy_localization.dart';
// import 'package:flowy_infra_ui/flowy_infra_ui.dart';
// import 'package:flutter/material.dart';
// import 'package:flutter_chat_types/flutter_chat_types.dart';
class ChatStreamingError extends StatelessWidget {
const ChatStreamingError({
required this.message,
required this.onRetryPressed,
super.key,
});
// class ChatStreamingError extends StatelessWidget {
// const ChatStreamingError({
// required this.message,
// required this.onRetryPressed,
// super.key,
// });
final void Function() onRetryPressed;
final Message message;
@override
Widget build(BuildContext context) {
final canRetry = message.metadata?[canRetryKey] != null;
// final void Function() onRetryPressed;
// final Message message;
// @override
// Widget build(BuildContext context) {
// final canRetry = message.metadata?[canRetryKey] != null;
if (canRetry) {
return Column(
children: [
const Divider(height: 4, thickness: 1),
const VSpace(16),
Center(
child: Column(
children: [
_aiUnvaliable(),
const VSpace(10),
_retryButton(),
],
),
),
],
);
} else {
return Center(
child: Column(
children: [
const Divider(height: 20, thickness: 1),
Padding(
padding: const EdgeInsets.all(8.0),
child: FlowyText(
LocaleKeys.chat_serverUnavailable.tr(),
fontSize: 14,
),
),
],
),
);
}
}
// if (canRetry) {
// return Column(
// children: [
// const Divider(height: 4, thickness: 1),
// const VSpace(16),
// Center(
// child: Column(
// children: [
// _aiUnvaliable(),
// const VSpace(10),
// _retryButton(),
// ],
// ),
// ),
// ],
// );
// } else {
// return Center(
// child: Column(
// children: [
// const Divider(height: 20, thickness: 1),
// Padding(
// padding: const EdgeInsets.all(8.0),
// child: FlowyText(
// LocaleKeys.chat_serverUnavailable.tr(),
// fontSize: 14,
// ),
// ),
// ],
// ),
// );
// }
// }
FlowyButton _retryButton() {
return FlowyButton(
radius: BorderRadius.circular(20),
useIntrinsicWidth: true,
text: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: FlowyText(
LocaleKeys.chat_regenerateAnswer.tr(),
fontSize: 14,
),
),
onTap: onRetryPressed,
iconPadding: 0,
leftIcon: const Icon(
Icons.refresh,
size: 20,
),
);
}
// FlowyButton _retryButton() {
// return FlowyButton(
// radius: BorderRadius.circular(20),
// useIntrinsicWidth: true,
// text: Padding(
// padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
// child: FlowyText(
// LocaleKeys.chat_regenerateAnswer.tr(),
// fontSize: 14,
// ),
// ),
// onTap: onRetryPressed,
// iconPadding: 0,
// leftIcon: const Icon(
// Icons.refresh,
// size: 20,
// ),
// );
// }
Padding _aiUnvaliable() {
return Padding(
padding: const EdgeInsets.all(8.0),
child: FlowyText(
LocaleKeys.chat_aiServerUnavailable.tr(),
fontSize: 14,
),
);
}
}
// Padding _aiUnvaliable() {
// return Padding(
// padding: const EdgeInsets.all(8.0),
// child: FlowyText(
// LocaleKeys.chat_aiServerUnavailable.tr(),
// fontSize: 14,
// ),
// );
// }
// }
@@ -20,29 +20,33 @@ class ChatWelcomePage extends StatelessWidget {
];
@override
Widget build(BuildContext context) {
return Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const FlowySvg(
FlowySvgs.flowy_ai_chat_logo_s,
size: Size.square(44),
),
const SizedBox(height: 40),
GridView.builder(
shrinkWrap: true,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: isMobile ? 2 : 4,
crossAxisSpacing: 6,
mainAxisSpacing: 6,
childAspectRatio: 16.0 / 9.0,
return AnimatedOpacity(
opacity: 1.0,
duration: const Duration(seconds: 3),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const FlowySvg(
FlowySvgs.flowy_ai_chat_logo_s,
size: Size.square(44),
),
itemCount: items.length,
itemBuilder: (context, index) => WelcomeQuestion(
question: items[index],
onSelected: onSelectedQuestion,
const SizedBox(height: 40),
GridView.builder(
shrinkWrap: true,
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: isMobile ? 2 : 4,
crossAxisSpacing: 6,
mainAxisSpacing: 6,
childAspectRatio: 16.0 / 9.0,
),
itemCount: items.length,
itemBuilder: (context, index) => WelcomeQuestion(
question: items[index],
onSelected: onSelectedQuestion,
),
),
),
],
],
),
);
}
}
@@ -0,0 +1,361 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_loading.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart';
import 'package:markdown_widget/markdown_widget.dart';
import 'selectable_highlight.dart';
class ChatAITextMessageWidget extends StatelessWidget {
const ChatAITextMessageWidget({
super.key,
required this.user,
required this.messageUserId,
required this.text,
required this.questionId,
required this.chatId,
});
final User user;
final String messageUserId;
final dynamic text;
final Int64? questionId;
final String chatId;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => ChatAIMessageBloc(
message: text,
chatId: chatId,
questionId: questionId,
)..add(const ChatAIMessageEvent.initial()),
child: BlocBuilder<ChatAIMessageBloc, ChatAIMessageState>(
builder: (context, state) {
if (state.error != null) {
return StreamingError(
onRetryPressed: () {
context.read<ChatAIMessageBloc>().add(
const ChatAIMessageEvent.retry(),
);
},
);
}
if (state.retryState == const LoadingState.loading()) {
return const ChatAILoading();
}
if (state.text.isEmpty) {
return const ChatAILoading();
} else {
return _textWidgetBuilder(user, context, state.text);
}
},
),
);
}
Widget _textWidgetBuilder(
User user,
BuildContext context,
String text,
) {
return MarkdownWidget(
data: text,
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
config: configFromContext(context),
);
}
MarkdownConfig configFromContext(BuildContext context) {
return MarkdownConfig(
configs: [
HrConfig(color: AFThemeExtension.of(context).textColor),
ChatH1Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 24,
fontWeight: FontWeight.bold,
height: 1.5,
),
dividerColor: AFThemeExtension.of(context).lightGreyHover,
),
ChatH2Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 20,
fontWeight: FontWeight.bold,
height: 1.5,
),
dividerColor: AFThemeExtension.of(context).lightGreyHover,
),
ChatH3Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 18,
fontWeight: FontWeight.bold,
height: 1.5,
),
dividerColor: AFThemeExtension.of(context).lightGreyHover,
),
H4Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 16,
fontWeight: FontWeight.bold,
height: 1.5,
),
),
H5Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 14,
fontWeight: FontWeight.bold,
height: 1.5,
),
),
H6Config(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 12,
fontWeight: FontWeight.bold,
height: 1.5,
),
),
PreConfig(
builder: (code, language) {
return ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 800,
),
child: ClipRRect(
borderRadius: const BorderRadius.all(Radius.circular(6.0)),
child: SelectableHighlightView(
code,
language: language,
theme: getHightlineTheme(context),
padding: const EdgeInsets.all(14),
textStyle: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 14,
fontWeight: FontWeight.bold,
height: 1.5,
),
),
),
);
},
),
PConfig(
textStyle: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
),
),
CodeConfig(
style: TextStyle(
color: AFThemeExtension.of(context).textColor,
fontSize: 16,
fontWeight: FontWeight.w500,
height: 1.5,
),
),
BlockquoteConfig(
sideColor: AFThemeExtension.of(context).lightGreyHover,
textColor: AFThemeExtension.of(context).textColor,
),
],
);
}
}
Map<String, TextStyle> getHightlineTheme(BuildContext context) {
return {
'root': TextStyle(
color: const Color(0xffabb2bf),
backgroundColor:
Theme.of(context).isLightMode ? Colors.white : Colors.black38,
),
'comment':
const TextStyle(color: Color(0xff5c6370), fontStyle: FontStyle.italic),
'quote':
const TextStyle(color: Color(0xff5c6370), fontStyle: FontStyle.italic),
'doctag': const TextStyle(color: Color(0xffc678dd)),
'keyword': const TextStyle(color: Color(0xffc678dd)),
'formula': const TextStyle(color: Color(0xffc678dd)),
'section': const TextStyle(color: Color(0xffe06c75)),
'name': const TextStyle(color: Color(0xffe06c75)),
'selector-tag': const TextStyle(color: Color(0xffe06c75)),
'deletion': const TextStyle(color: Color(0xffe06c75)),
'subst': const TextStyle(color: Color(0xffe06c75)),
'literal': const TextStyle(color: Color(0xff56b6c2)),
'string': const TextStyle(color: Color(0xff98c379)),
'regexp': const TextStyle(color: Color(0xff98c379)),
'addition': const TextStyle(color: Color(0xff98c379)),
'attribute': const TextStyle(color: Color(0xff98c379)),
'meta-string': const TextStyle(color: Color(0xff98c379)),
'built_in': const TextStyle(color: Color(0xffe6c07b)),
'attr': const TextStyle(color: Color(0xffd19a66)),
'variable': const TextStyle(color: Color(0xffd19a66)),
'template-variable': const TextStyle(color: Color(0xffd19a66)),
'type': const TextStyle(color: Color(0xffd19a66)),
'selector-class': const TextStyle(color: Color(0xffd19a66)),
'selector-attr': const TextStyle(color: Color(0xffd19a66)),
'selector-pseudo': const TextStyle(color: Color(0xffd19a66)),
'number': const TextStyle(color: Color(0xffd19a66)),
'symbol': const TextStyle(color: Color(0xff61aeee)),
'bullet': const TextStyle(color: Color(0xff61aeee)),
'link': const TextStyle(color: Color(0xff61aeee)),
'meta': const TextStyle(color: Color(0xff61aeee)),
'selector-id': const TextStyle(color: Color(0xff61aeee)),
'title': const TextStyle(color: Color(0xff61aeee)),
'emphasis': const TextStyle(fontStyle: FontStyle.italic),
'strong': const TextStyle(fontWeight: FontWeight.bold),
};
}
class ChatH1Config extends HeadingConfig {
const ChatH1Config({
this.style = const TextStyle(
fontSize: 32,
height: 40 / 32,
fontWeight: FontWeight.bold,
),
required this.dividerColor,
});
@override
final TextStyle style;
final Color dividerColor;
@override
String get tag => MarkdownTag.h1.name;
@override
HeadingDivider? get divider => HeadingDivider(
space: 10,
color: dividerColor,
height: 10,
);
}
///config class for h2
class ChatH2Config extends HeadingConfig {
const ChatH2Config({
this.style = const TextStyle(
fontSize: 24,
height: 30 / 24,
fontWeight: FontWeight.bold,
),
required this.dividerColor,
});
@override
final TextStyle style;
final Color dividerColor;
@override
String get tag => MarkdownTag.h2.name;
@override
HeadingDivider? get divider => HeadingDivider(
space: 10,
color: dividerColor,
height: 10,
);
}
class ChatH3Config extends HeadingConfig {
const ChatH3Config({
this.style = const TextStyle(
fontSize: 24,
height: 30 / 24,
fontWeight: FontWeight.bold,
),
required this.dividerColor,
});
@override
final TextStyle style;
final Color dividerColor;
@override
String get tag => MarkdownTag.h3.name;
@override
HeadingDivider? get divider => HeadingDivider(
space: 10,
color: dividerColor,
height: 10,
);
}
class StreamingError extends StatelessWidget {
const StreamingError({
required this.onRetryPressed,
super.key,
});
final void Function() onRetryPressed;
@override
Widget build(BuildContext context) {
return Column(
children: [
const Divider(height: 4, thickness: 1),
const VSpace(16),
Center(
child: Column(
children: [
_aiUnvaliable(),
const VSpace(10),
_retryButton(),
],
),
),
],
);
}
FlowyButton _retryButton() {
return FlowyButton(
radius: BorderRadius.circular(20),
useIntrinsicWidth: true,
text: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: FlowyText(
LocaleKeys.chat_regenerateAnswer.tr(),
fontSize: 14,
),
),
onTap: onRetryPressed,
iconPadding: 0,
leftIcon: const Icon(
Icons.refresh,
size: 20,
),
);
}
Padding _aiUnvaliable() {
return Padding(
padding: const EdgeInsets.all(8.0),
child: FlowyText(
LocaleKeys.chat_aiServerUnavailable.tr(),
fontSize: 14,
),
);
}
}
@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import 'package:highlight/highlight.dart';
/// Highlight Flutter Widget
class SelectableHighlightView extends StatelessWidget {
SelectableHighlightView(
String input, {
super.key,
this.language,
this.theme = const {},
this.padding,
this.textStyle,
int tabSize = 8,
}) : source = input.replaceAll('\t', ' ' * tabSize);
/// The original code to be highlighted
final String source;
/// Highlight language
///
/// It is recommended to give it a value for performance
///
/// [All available languages](https://github.com/pd4d10/highlight/tree/master/highlight/lib/languages)
final String? language;
/// Highlight theme
///
/// [All available themes](https://github.com/pd4d10/highlight/blob/master/flutter_highlight/lib/themes)
final Map<String, TextStyle> theme;
/// Padding
final EdgeInsetsGeometry? padding;
/// Text styles
///
/// Specify text styles such as font family and font size
final TextStyle? textStyle;
List<TextSpan> _convert(List<Node> nodes) {
final List<TextSpan> spans = [];
var currentSpans = spans;
final List<List<TextSpan>> stack = [];
// ignore: always_declare_return_types
traverse(Node node) {
if (node.value != null) {
currentSpans.add(
node.className == null
? TextSpan(text: node.value)
: TextSpan(text: node.value, style: theme[node.className!]),
);
} else if (node.children != null) {
final List<TextSpan> tmp = [];
currentSpans
.add(TextSpan(children: tmp, style: theme[node.className!]));
stack.add(currentSpans);
currentSpans = tmp;
for (final n in node.children!) {
traverse(n);
if (n == node.children!.last) {
currentSpans = stack.isEmpty ? spans : stack.removeLast();
}
}
}
}
for (final node in nodes) {
traverse(node);
}
return spans;
}
static const _rootKey = 'root';
static const _defaultBackgroundColor = Color(0xffffffff);
@override
Widget build(BuildContext context) {
return Container(
color: theme[_rootKey]?.backgroundColor ?? _defaultBackgroundColor,
padding: padding,
child: SelectableText.rich(
TextSpan(
style: textStyle,
children:
_convert(highlight.parse(source, language: language).nodes!),
),
),
);
}
}
@@ -0,0 +1,61 @@
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart';
class ChatTextMessageWidget extends StatelessWidget {
const ChatTextMessageWidget({
super.key,
required this.user,
required this.messageUserId,
required this.text,
});
final User user;
final String messageUserId;
final String text;
@override
Widget build(BuildContext context) {
return _textWidgetBuilder(user, context, text);
}
Widget _textWidgetBuilder(
User user,
BuildContext context,
String text,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextMessageText(
text: text,
),
],
);
}
}
/// Widget to reuse the markdown capabilities, e.g., for previews.
class TextMessageText extends StatelessWidget {
const TextMessageText({
super.key,
required this.text,
});
/// Text that is shown as markdown.
final String text;
@override
Widget build(BuildContext context) {
return FlowyText(
text,
fontSize: 16,
fontWeight: FontWeight.w500,
lineHeight: 1.5,
maxLines: null,
selectable: true,
color: AFThemeExtension.of(context).textColor,
);
}
}
@@ -29,7 +29,8 @@ class ChatUserMessageBubble extends StatelessWidget {
Theme.of(context).colorScheme.surfaceContainerHighest;
return BlocProvider(
create: (context) => ChatUserMessageBloc(message: message),
create: (context) => ChatUserMessageBloc(message: message)
..add(const ChatUserMessageEvent.initial()),
child: BlocBuilder<ChatUserMessageBloc, ChatUserMessageState>(
builder: (context, state) {
return Row(
@@ -0,0 +1,117 @@
import 'dart:async';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/application/field/field_info.dart';
import 'package:appflowy/util/time.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
part 'time_cell_bloc.freezed.dart';
class TimeCellBloc extends Bloc<TimeCellEvent, TimeCellState> {
TimeCellBloc({
required this.cellController,
}) : super(TimeCellState.initial(cellController)) {
_dispatch();
_startListening();
}
final TimeCellController cellController;
void Function()? _onCellChangedFn;
@override
Future<void> close() async {
if (_onCellChangedFn != null) {
cellController.removeListener(
onCellChanged: _onCellChangedFn!,
onFieldChanged: _onFieldChangedListener,
);
}
await cellController.dispose();
return super.close();
}
void _dispatch() {
on<TimeCellEvent>(
(event, emit) async {
await event.when(
didReceiveCellUpdate: (content) {
emit(
state.copyWith(
content:
content != null ? formatTime(content.time.toInt()) : "",
),
);
},
didUpdateField: (fieldInfo) {
final wrap = fieldInfo.wrapCellContent;
if (wrap != null) {
emit(state.copyWith(wrap: wrap));
}
},
updateCell: (text) async {
text = parseTime(text)?.toString() ?? text;
if (state.content != text) {
emit(state.copyWith(content: text));
await cellController.saveCellData(text);
// If the input content is "abc" that can't parsered as number
// then the data stored in the backend will be an empty string.
// So for every cell data that will be formatted in the backend.
// It needs to get the formatted data after saving.
add(
TimeCellEvent.didReceiveCellUpdate(
cellController.getCellData(),
),
);
}
},
);
},
);
}
void _startListening() {
_onCellChangedFn = cellController.addListener(
onCellChanged: (cellContent) {
if (!isClosed) {
add(TimeCellEvent.didReceiveCellUpdate(cellContent));
}
},
onFieldChanged: _onFieldChangedListener,
);
}
void _onFieldChangedListener(FieldInfo fieldInfo) {
if (!isClosed) {
add(TimeCellEvent.didUpdateField(fieldInfo));
}
}
}
@freezed
class TimeCellEvent with _$TimeCellEvent {
const factory TimeCellEvent.didReceiveCellUpdate(TimeCellDataPB? cell) =
_DidReceiveCellUpdate;
const factory TimeCellEvent.didUpdateField(FieldInfo fieldInfo) =
_DidUpdateField;
const factory TimeCellEvent.updateCell(String text) = _UpdateCell;
}
@freezed
class TimeCellState with _$TimeCellState {
const factory TimeCellState({
required String content,
required bool wrap,
}) = _TimeCellState;
factory TimeCellState.initial(TimeCellController cellController) {
final wrap = cellController.fieldInfo.wrapCellContent;
final cellData = cellController.getCellData();
return TimeCellState(
content: cellData != null ? formatTime(cellData.time.toInt()) : "",
wrap: wrap ?? true,
);
}
}
@@ -0,0 +1,111 @@
import 'dart:async';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/application/field/field_info.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'translate_cell_bloc.freezed.dart';
class TranslateCellBloc extends Bloc<TranslateCellEvent, TranslateCellState> {
TranslateCellBloc({
required this.cellController,
}) : super(TranslateCellState.initial(cellController)) {
_dispatch();
_startListening();
}
final TranslateCellController cellController;
void Function()? _onCellChangedFn;
@override
Future<void> close() async {
if (_onCellChangedFn != null) {
cellController.removeListener(
onCellChanged: _onCellChangedFn!,
onFieldChanged: _onFieldChangedListener,
);
}
await cellController.dispose();
return super.close();
}
void _dispatch() {
on<TranslateCellEvent>(
(event, emit) async {
await event.when(
didReceiveCellUpdate: (cellData) {
emit(
state.copyWith(content: cellData ?? ""),
);
},
didUpdateField: (fieldInfo) {
final wrap = fieldInfo.wrapCellContent;
if (wrap != null) {
emit(state.copyWith(wrap: wrap));
}
},
updateCell: (text) async {
if (state.content != text) {
emit(state.copyWith(content: text));
await cellController.saveCellData(text);
// If the input content is "abc" that can't parsered as number then the data stored in the backend will be an empty string.
// So for every cell data that will be formatted in the backend.
// It needs to get the formatted data after saving.
add(
TranslateCellEvent.didReceiveCellUpdate(
cellController.getCellData() ?? "",
),
);
}
},
);
},
);
}
void _startListening() {
_onCellChangedFn = cellController.addListener(
onCellChanged: (cellContent) {
if (!isClosed) {
add(
TranslateCellEvent.didReceiveCellUpdate(cellContent ?? ""),
);
}
},
onFieldChanged: _onFieldChangedListener,
);
}
void _onFieldChangedListener(FieldInfo fieldInfo) {
if (!isClosed) {
add(TranslateCellEvent.didUpdateField(fieldInfo));
}
}
}
@freezed
class TranslateCellEvent with _$TranslateCellEvent {
const factory TranslateCellEvent.didReceiveCellUpdate(String? cellContent) =
_DidReceiveCellUpdate;
const factory TranslateCellEvent.didUpdateField(FieldInfo fieldInfo) =
_DidUpdateField;
const factory TranslateCellEvent.updateCell(String text) = _UpdateCell;
}
@freezed
class TranslateCellState with _$TranslateCellState {
const factory TranslateCellState({
required String content,
required bool wrap,
}) = _TranslateCellState;
factory TranslateCellState.initial(TranslateCellController cellController) {
final wrap = cellController.fieldInfo.wrapCellContent;
return TranslateCellState(
content: cellController.getCellData() ?? "",
wrap: wrap ?? true,
);
}
}
@@ -0,0 +1,100 @@
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'translate_row_bloc.freezed.dart';
class TranslateRowBloc extends Bloc<TranslateRowEvent, TranslateRowState> {
TranslateRowBloc({
required this.viewId,
required this.rowId,
required this.fieldId,
}) : super(TranslateRowState.initial()) {
_dispatch();
}
final String viewId;
final String rowId;
final String fieldId;
void _dispatch() {
on<TranslateRowEvent>(
(event, emit) async {
event.when(
startTranslate: () {
final params = TranslateRowPB(
viewId: viewId,
rowId: rowId,
fieldId: fieldId,
);
emit(
state.copyWith(
loadingState: const LoadingState.loading(),
error: null,
),
);
DatabaseEventTranslateRow(params).send().then(
(result) => {
if (!isClosed)
add(TranslateRowEvent.finishTranslate(result)),
},
);
},
finishTranslate: (result) {
result.fold(
(s) => {
emit(
state.copyWith(
loadingState: const LoadingState.finish(),
error: null,
),
),
},
(err) => {
emit(
state.copyWith(
loadingState: const LoadingState.finish(),
error: err,
),
),
},
);
},
);
},
);
}
}
@freezed
class TranslateRowEvent with _$TranslateRowEvent {
const factory TranslateRowEvent.startTranslate() = _DidStartTranslate;
const factory TranslateRowEvent.finishTranslate(
FlowyResult<void, FlowyError> result,
) = _DidFinishTranslate;
}
@freezed
class TranslateRowState with _$TranslateRowState {
const factory TranslateRowState({
required LoadingState loadingState,
required FlowyError? error,
}) = _TranslateRowState;
factory TranslateRowState.initial() {
return const TranslateRowState(
loadingState: LoadingState.finish(),
error: null,
);
}
}
@freezed
class LoadingState with _$LoadingState {
const factory LoadingState.loading() = _Loading;
const factory LoadingState.finish() = _Finish;
}
@@ -16,6 +16,8 @@ typedef TimestampCellController = CellController<TimestampCellDataPB, String>;
typedef URLCellController = CellController<URLCellDataPB, String>;
typedef RelationCellController = CellController<RelationCellDataPB, String>;
typedef SummaryCellController = CellController<String, String>;
typedef TimeCellController = CellController<TimeCellDataPB, String>;
typedef TranslateCellController = CellController<String, String>;
CellController makeCellController(
DatabaseController databaseController,
@@ -120,7 +122,6 @@ CellController makeCellController(
),
cellDataPersistence: TextCellDataPersistence(),
);
case FieldType.Relation:
return RelationCellController(
viewId: viewId,
@@ -145,6 +146,30 @@ CellController makeCellController(
),
cellDataPersistence: TextCellDataPersistence(),
);
case FieldType.Time:
return TimeCellController(
viewId: viewId,
fieldController: fieldController,
cellContext: cellContext,
rowCache: rowCache,
cellDataLoader: CellDataLoader(
parser: TimeCellDataParser(),
reloadOnFieldChange: true,
),
cellDataPersistence: TextCellDataPersistence(),
);
case FieldType.Translate:
return TranslateCellController(
viewId: viewId,
fieldController: fieldController,
cellContext: cellContext,
rowCache: rowCache,
cellDataLoader: CellDataLoader(
parser: StringCellDataParser(),
reloadOnFieldChange: true,
),
cellDataPersistence: TextCellDataPersistence(),
);
}
throw UnimplementedError;
}
@@ -181,3 +181,18 @@ class RelationCellDataParser implements CellDataParser<RelationCellDataPB> {
}
}
}
class TimeCellDataParser implements CellDataParser<TimeCellDataPB> {
@override
TimeCellDataPB? parserData(List<int> data) {
if (data.isEmpty) {
return null;
}
try {
return TimeCellDataPB.fromBuffer(data);
} catch (e) {
Log.error("Failed to parse timer data: $e");
return null;
}
}
}
@@ -137,6 +137,8 @@ class FieldController {
List<FieldInfo> get fieldInfos => [..._fieldNotifier.fieldInfos];
List<FilterInfo> get filterInfos => [..._filterNotifier?.filters ?? []];
List<SortInfo> get sortInfos => [..._sortNotifier?.sorts ?? []];
List<GroupSettingPB> get groupSettings =>
_groupConfigurationByFieldId.entries.map((e) => e.value).toList();
FieldInfo? getField(String fieldId) {
return _fieldNotifier.fieldInfos
@@ -1,5 +1,6 @@
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:protobuf/protobuf.dart';
part 'field_info.freezed.dart';
@@ -63,6 +64,7 @@ class FieldInfo with _$FieldInfo {
case FieldType.SingleSelect:
case FieldType.Checklist:
case FieldType.URL:
case FieldType.Time:
return true;
default:
return false;
@@ -84,9 +86,19 @@ class FieldInfo with _$FieldInfo {
case FieldType.LastEditedTime:
case FieldType.CreatedTime:
case FieldType.Checklist:
case FieldType.Time:
return true;
default:
return false;
}
}
List<ProtobufEnum> get groupConditions {
switch (field.fieldType) {
case FieldType.DateTime:
return DateConditionPB.values;
default:
return [];
}
}
}
@@ -0,0 +1,82 @@
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/translate_entities.pb.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:protobuf/protobuf.dart';
part 'translate_type_option_bloc.freezed.dart';
class TranslateTypeOptionBloc
extends Bloc<TranslateTypeOptionEvent, TranslateTypeOptionState> {
TranslateTypeOptionBloc({required TranslateTypeOptionPB option})
: super(TranslateTypeOptionState.initial(option)) {
on<TranslateTypeOptionEvent>(
(event, emit) async {
event.when(
selectLanguage: (languageType) {
emit(
state.copyWith(
option: _updateLanguage(languageType),
language: languageTypeToLanguage(languageType),
),
);
},
);
},
);
}
TranslateTypeOptionPB _updateLanguage(TranslateLanguagePB languageType) {
state.option.freeze();
return state.option.rebuild((option) {
option.language = languageType;
});
}
}
@freezed
class TranslateTypeOptionEvent with _$TranslateTypeOptionEvent {
const factory TranslateTypeOptionEvent.selectLanguage(
TranslateLanguagePB languageType,
) = _SelectLanguage;
}
@freezed
class TranslateTypeOptionState with _$TranslateTypeOptionState {
const factory TranslateTypeOptionState({
required TranslateTypeOptionPB option,
required String language,
}) = _TranslateTypeOptionState;
factory TranslateTypeOptionState.initial(TranslateTypeOptionPB option) =>
TranslateTypeOptionState(
option: option,
language: languageTypeToLanguage(option.language),
);
}
String languageTypeToLanguage(TranslateLanguagePB langaugeType) {
switch (langaugeType) {
case TranslateLanguagePB.SimplifiedChinese:
return 'Simplified Chinese';
case TranslateLanguagePB.TraditionalChinese:
return 'Traditional Chinese';
case TranslateLanguagePB.English:
return 'English';
case TranslateLanguagePB.French:
return 'French';
case TranslateLanguagePB.German:
return 'German';
case TranslateLanguagePB.Spanish:
return 'Spanish';
case TranslateLanguagePB.Hindi:
return 'Hindi';
case TranslateLanguagePB.Portuguese:
return 'Portuguese';
case TranslateLanguagePB.StandardArabic:
return 'Standard Arabic';
default:
Log.error('Unknown language type: $langaugeType');
return 'English';
}
}
@@ -4,8 +4,6 @@ abstract class TypeOptionParser<T> {
T fromBuffer(List<int> buffer);
}
class NumberTypeOptionDataParser extends TypeOptionParser<NumberTypeOptionPB> {
@override
NumberTypeOptionPB fromBuffer(List<int> buffer) {
@@ -51,3 +49,11 @@ class RelationTypeOptionDataParser
return RelationTypeOptionPB.fromBuffer(buffer);
}
}
class TranslateTypeOptionDataParser
extends TypeOptionParser<TranslateTypeOptionPB> {
@override
TranslateTypeOptionPB fromBuffer(List<int> buffer) {
return TranslateTypeOptionPB.fromBuffer(buffer);
}
}
@@ -6,6 +6,7 @@ import 'package:appflowy/plugins/database/domain/group_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/board_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/group.pb.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@@ -22,6 +23,7 @@ class DatabaseGroupBloc extends Bloc<DatabaseGroupEvent, DatabaseGroupState> {
viewId,
databaseController.fieldController.fieldInfos,
databaseController.databaseLayoutSetting!.board,
databaseController.fieldController.groupSettings,
),
) {
_dispatch();
@@ -47,15 +49,24 @@ class DatabaseGroupBloc extends Bloc<DatabaseGroupEvent, DatabaseGroupState> {
on<DatabaseGroupEvent>(
(event, emit) async {
await event.when(
initial: () {
_startListening();
},
initial: () async => _startListening(),
didReceiveFieldUpdate: (fieldInfos) {
emit(state.copyWith(fieldInfos: fieldInfos));
emit(
state.copyWith(
fieldInfos: fieldInfos,
groupSettings:
_databaseController.fieldController.groupSettings,
),
);
},
setGroupByField: (String fieldId, FieldType fieldType) async {
setGroupByField: (
String fieldId,
FieldType fieldType, [
List<int>? settingContent,
]) async {
final result = await _groupBackendSvc.groupByField(
fieldId: fieldId,
settingContent: settingContent ?? [],
);
result.fold((l) => null, (err) => Log.error(err));
},
@@ -96,8 +107,9 @@ class DatabaseGroupEvent with _$DatabaseGroupEvent {
const factory DatabaseGroupEvent.initial() = _Initial;
const factory DatabaseGroupEvent.setGroupByField(
String fieldId,
FieldType fieldType,
) = _DatabaseGroupEvent;
FieldType fieldType, [
@Default([]) List<int> settingContent,
]) = _DatabaseGroupEvent;
const factory DatabaseGroupEvent.didReceiveFieldUpdate(
List<FieldInfo> fields,
) = _DidReceiveFieldUpdate;
@@ -112,16 +124,19 @@ class DatabaseGroupState with _$DatabaseGroupState {
required String viewId,
required List<FieldInfo> fieldInfos,
required BoardLayoutSettingPB layoutSettings,
required List<GroupSettingPB> groupSettings,
}) = _DatabaseGroupState;
factory DatabaseGroupState.initial(
String viewId,
List<FieldInfo> fieldInfos,
BoardLayoutSettingPB layoutSettings,
List<GroupSettingPB> groupSettings,
) =>
DatabaseGroupState(
viewId: viewId,
fieldInfos: fieldInfos,
layoutSettings: layoutSettings,
groupSettings: groupSettings,
);
}
@@ -16,8 +16,10 @@ import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:intl/intl.dart';
import 'package:protobuf/protobuf.dart' hide FieldInfo;
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:calendar_view/calendar_view.dart';
import '../../application/database_controller.dart';
import '../../application/field/field_controller.dart';
@@ -527,6 +529,9 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
return "No ${field.name}";
}
final groupSettings = databaseController.fieldController.groupSettings
.firstWhereOrNull((gs) => gs.fieldId == field.id);
switch (field.fieldType) {
case FieldType.SingleSelect:
final options =
@@ -547,33 +552,61 @@ class BoardBloc extends Bloc<BoardEvent, BoardState> {
case FieldType.URL:
return group.groupId;
case FieldType.DateTime:
// Assume DateCondition::Relative as there isn't an option for this
// right now.
final config = groupSettings?.content != null
? DateGroupConfigurationPB.fromBuffer(groupSettings!.content)
: DateGroupConfigurationPB();
final dateFormat = DateFormat("y/MM/dd");
try {
final targetDateTime = dateFormat.parseLoose(group.groupId);
final targetDateTimeDay = DateTime(
targetDateTime.year,
targetDateTime.month,
targetDateTime.day,
);
final now = DateTime.now();
final nowDay = DateTime(
now.year,
now.month,
now.day,
);
final diff = targetDateTimeDay.difference(nowDay).inDays;
return switch (diff) {
0 => "Today",
-1 => "Yesterday",
1 => "Tomorrow",
-7 => "Last 7 days",
2 => "Next 7 days",
-30 => "Last 30 days",
8 => "Next 30 days",
_ => DateFormat("MMM y").format(targetDateTimeDay)
};
switch (config.condition) {
case DateConditionPB.Day:
return DateFormat("MMM dd, y").format(targetDateTime);
case DateConditionPB.Week:
final beginningOfWeek = targetDateTime
.subtract(Duration(days: targetDateTime.weekday - 1));
final endOfWeek = targetDateTime.add(
Duration(days: DateTime.daysPerWeek - targetDateTime.weekday),
);
final beginningOfWeekFormat =
beginningOfWeek.year != endOfWeek.year
? "MMM dd y"
: "MMM dd";
final endOfWeekFormat = beginningOfWeek.month != endOfWeek.month
? "MMM dd y"
: "dd y";
return LocaleKeys.board_dateCondition_weekOf.tr(
args: [
DateFormat(beginningOfWeekFormat).format(beginningOfWeek),
DateFormat(endOfWeekFormat).format(endOfWeek),
],
);
case DateConditionPB.Month:
return DateFormat("MMM y").format(targetDateTime);
case DateConditionPB.Year:
return DateFormat("y").format(targetDateTime);
case DateConditionPB.Relative:
final targetDateTimeDay = DateTime(
targetDateTime.year,
targetDateTime.month,
targetDateTime.day,
);
final nowDay = DateTime.now().withoutTime;
final diff = targetDateTimeDay.difference(nowDay).inDays;
return switch (diff) {
0 => LocaleKeys.board_dateCondition_today.tr(),
-1 => LocaleKeys.board_dateCondition_yesterday.tr(),
1 => LocaleKeys.board_dateCondition_tomorrow.tr(),
-7 => LocaleKeys.board_dateCondition_lastSevenDays.tr(),
2 => LocaleKeys.board_dateCondition_nextSevenDays.tr(),
-30 => LocaleKeys.board_dateCondition_lastThirtyDays.tr(),
8 => LocaleKeys.board_dateCondition_nextThirtyDays.tr(),
_ => DateFormat("MMM y").format(targetDateTimeDay)
};
default:
return "";
}
} on FormatException {
return "";
}
@@ -14,7 +14,6 @@ import 'package:appflowy/plugins/database/tab_bar/tab_bar_view.dart';
import 'package:appflowy/plugins/database/widgets/card/card_bloc.dart';
import 'package:appflowy/plugins/database/widgets/cell/card_cell_style_maps/desktop_board_card_cell_style.dart';
import 'package:appflowy/plugins/database/widgets/row/row_detail.dart';
import 'package:appflowy/plugins/shared/callback_shortcuts.dart';
import 'package:appflowy/shared/conditional_listenable_builder.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
@@ -29,7 +28,6 @@ import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/material.dart' hide Card;
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/provider.dart';
import '../../widgets/card/card.dart';
import '../../widgets/cell/card_cell_builder.dart';
@@ -322,67 +320,64 @@ class _BoardContentState extends State<_BoardContent> {
},
),
],
child: Provider(
create: (context) => AFCallbackShortcutsProvider(),
child: FocusScope(
autofocus: true,
child: BoardShortcutContainer(
focusScope: widget.focusScope,
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: AppFlowyBoard(
boardScrollController: scrollManager,
scrollController: scrollController,
controller: context.read<BoardBloc>().boardController,
groupConstraints: const BoxConstraints.tightFor(width: 256),
config: config,
leading: HiddenGroupsColumn(margin: config.groupHeaderPadding),
trailing: context
.read<BoardBloc>()
.groupingFieldType
?.canCreateNewGroup ??
false
? BoardTrailing(scrollController: scrollController)
: const HSpace(40),
headerBuilder: (_, groupData) => BlocProvider<BoardBloc>.value(
value: context.read<BoardBloc>(),
child: BoardColumnHeader(
groupData: groupData,
margin: config.groupHeaderPadding,
),
child: FocusScope(
autofocus: true,
child: BoardShortcutContainer(
focusScope: widget.focusScope,
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: AppFlowyBoard(
boardScrollController: scrollManager,
scrollController: scrollController,
controller: context.read<BoardBloc>().boardController,
groupConstraints: const BoxConstraints.tightFor(width: 256),
config: config,
leading: HiddenGroupsColumn(margin: config.groupHeaderPadding),
trailing: context
.read<BoardBloc>()
.groupingFieldType
?.canCreateNewGroup ??
false
? BoardTrailing(scrollController: scrollController)
: const HSpace(40),
headerBuilder: (_, groupData) => BlocProvider<BoardBloc>.value(
value: context.read<BoardBloc>(),
child: BoardColumnHeader(
groupData: groupData,
margin: config.groupHeaderPadding,
),
footerBuilder: (_, groupData) => MultiBlocProvider(
providers: [
BlocProvider.value(
value: context.read<BoardBloc>(),
),
BlocProvider.value(
value: context.read<BoardActionsCubit>(),
),
],
child: BoardColumnFooter(
columnData: groupData,
boardConfig: config,
scrollManager: scrollManager,
),
footerBuilder: (_, groupData) => MultiBlocProvider(
providers: [
BlocProvider.value(
value: context.read<BoardBloc>(),
),
BlocProvider.value(
value: context.read<BoardActionsCubit>(),
),
],
child: BoardColumnFooter(
columnData: groupData,
boardConfig: config,
scrollManager: scrollManager,
),
cardBuilder: (_, column, columnItem) => MultiBlocProvider(
key: ValueKey("board_card_${column.id}_${columnItem.id}"),
providers: [
BlocProvider<BoardBloc>.value(
value: context.read<BoardBloc>(),
),
BlocProvider.value(
value: context.read<BoardActionsCubit>(),
),
],
child: _BoardCard(
afGroupData: column,
groupItem: columnItem as GroupItem,
boardConfig: config,
notifier: widget.focusScope,
cellBuilder: cellBuilder,
),
cardBuilder: (_, column, columnItem) => MultiBlocProvider(
key: ValueKey("board_card_${column.id}_${columnItem.id}"),
providers: [
BlocProvider<BoardBloc>.value(
value: context.read<BoardBloc>(),
),
BlocProvider.value(
value: context.read<BoardActionsCubit>(),
),
],
child: _BoardCard(
afGroupData: column,
groupItem: columnItem as GroupItem,
boardConfig: config,
notifier: widget.focusScope,
cellBuilder: cellBuilder,
),
),
),
@@ -469,7 +464,7 @@ class _BoardColumnFooterState extends State<BoardColumnFooter> {
const SingleActivator(LogicalKeyboardKey.keyE): nada,
const SingleActivator(LogicalKeyboardKey.keyN): nada,
const SingleActivator(LogicalKeyboardKey.delete): nada,
const SingleActivator(LogicalKeyboardKey.backspace): nada,
// const SingleActivator(LogicalKeyboardKey.backspace): nada,
const SingleActivator(LogicalKeyboardKey.enter): nada,
const SingleActivator(LogicalKeyboardKey.numpadEnter): nada,
const SingleActivator(LogicalKeyboardKey.comma): nada,
@@ -615,7 +610,7 @@ class _BoardCardState extends State<_BoardCard> {
const SingleActivator(LogicalKeyboardKey.keyE): nada,
const SingleActivator(LogicalKeyboardKey.keyN): nada,
const SingleActivator(LogicalKeyboardKey.delete): nada,
const SingleActivator(LogicalKeyboardKey.backspace): nada,
// const SingleActivator(LogicalKeyboardKey.backspace): nada,
const SingleActivator(LogicalKeyboardKey.enter): nada,
const SingleActivator(LogicalKeyboardKey.numpadEnter): nada,
const SingleActivator(LogicalKeyboardKey.comma): nada,
@@ -3,7 +3,6 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/board/application/board_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/header/field_type_extension.dart';
import 'package:appflowy/plugins/shared/callback_shortcuts.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_board/appflowy_board.dart';
@@ -32,7 +31,6 @@ class BoardColumnHeader extends StatefulWidget {
class _BoardColumnHeaderState extends State<BoardColumnHeader> {
final FocusNode _focusNode = FocusNode();
final FocusNode _keyboardListenerFocusNode = FocusNode();
late final AFCallbackShortcutsProvider _shortcutsProvider;
late final TextEditingController _controller =
TextEditingController.fromValue(
@@ -47,21 +45,15 @@ class _BoardColumnHeaderState extends State<BoardColumnHeader> {
@override
void initState() {
super.initState();
_shortcutsProvider = context.read<AFCallbackShortcutsProvider>();
_focusNode.addListener(() {
if (!_focusNode.hasFocus) {
_saveEdit();
}
});
_keyboardListenerFocusNode.addListener(() {
_shortcutsProvider.isShortcutsEnabled.value =
!_keyboardListenerFocusNode.hasFocus;
});
}
@override
void dispose() {
_shortcutsProvider.isShortcutsEnabled.value = true;
_focusNode.dispose();
_keyboardListenerFocusNode.dispose();
_controller.dispose();
@@ -39,14 +39,14 @@ class BoardFocusScope extends ChangeNotifier
notifyListeners();
}
void focusNext() {
bool focusNext() {
_deepCopy();
// if no card is focused, focus on the first card in the board
if (_focusedCards.isEmpty) {
_focusFirstCard();
notifyListeners();
return;
return true;
}
final lastFocusedCard = _focusedCards.last;
@@ -58,7 +58,7 @@ class BoardFocusScope extends ChangeNotifier
if (iterable == null || iterable.isEmpty) {
_focusFirstCard();
notifyListeners();
return;
return true;
}
if (iterable.length == 1) {
@@ -90,16 +90,18 @@ class BoardFocusScope extends ChangeNotifier
}
notifyListeners();
return true;
}
void focusPrevious() {
bool focusPrevious() {
_deepCopy();
// if no card is focused, focus on the last card in the board
if (_focusedCards.isEmpty) {
_focusLastCard();
notifyListeners();
return;
return true;
}
final lastFocusedCard = _focusedCards.last;
@@ -111,7 +113,7 @@ class BoardFocusScope extends ChangeNotifier
if (iterable == null || iterable.isEmpty) {
_focusLastCard();
notifyListeners();
return;
return true;
}
if (iterable.length == 1) {
@@ -143,16 +145,18 @@ class BoardFocusScope extends ChangeNotifier
}
notifyListeners();
return true;
}
void adjustRangeDown() {
bool adjustRangeDown() {
_deepCopy();
// if no card is focused, focus on the first card in the board
if (_focusedCards.isEmpty) {
_focusFirstCard();
notifyListeners();
return;
return true;
}
final firstFocusedCard = _focusedCards.first;
@@ -171,7 +175,7 @@ class BoardFocusScope extends ChangeNotifier
if (firstGroupIndex == -1 || lastGroupIndex == -1) {
_focusFirstCard();
notifyListeners();
return;
return true;
}
if (firstGroupIndex < lastGroupIndex) {
@@ -189,7 +193,7 @@ class BoardFocusScope extends ChangeNotifier
if (firstCardIndex == -1 || lastCardIndex == -1) {
_focusFirstCard();
notifyListeners();
return;
return true;
}
isExpand = firstCardIndex < lastCardIndex;
@@ -203,7 +207,7 @@ class BoardFocusScope extends ChangeNotifier
if (groupController == null) {
_focusFirstCard();
notifyListeners();
return;
return true;
}
final iterable = groupController.items
@@ -236,16 +240,17 @@ class BoardFocusScope extends ChangeNotifier
}
notifyListeners();
return true;
}
void adjustRangeUp() {
bool adjustRangeUp() {
_deepCopy();
// if no card is focused, focus on the first card in the board
if (_focusedCards.isEmpty) {
_focusLastCard();
notifyListeners();
return;
return true;
}
final firstFocusedCard = _focusedCards.first;
@@ -264,7 +269,7 @@ class BoardFocusScope extends ChangeNotifier
if (firstGroupIndex == -1 || lastGroupIndex == -1) {
_focusLastCard();
notifyListeners();
return;
return true;
}
if (firstGroupIndex < lastGroupIndex) {
@@ -282,7 +287,7 @@ class BoardFocusScope extends ChangeNotifier
if (firstCardIndex == -1 || lastCardIndex == -1) {
_focusLastCard();
notifyListeners();
return;
return true;
}
isExpand = firstCardIndex > lastCardIndex;
@@ -296,7 +301,7 @@ class BoardFocusScope extends ChangeNotifier
if (groupController == null) {
_focusLastCard();
notifyListeners();
return;
return true;
}
final iterable = groupController.items.reversed
@@ -329,12 +334,15 @@ class BoardFocusScope extends ChangeNotifier
}
notifyListeners();
return true;
}
void clear() {
bool clear() {
_deepCopy();
_focusedCards.clear();
notifyListeners();
return true;
}
void _focusFirstCard() {
@@ -1,8 +1,11 @@
import 'dart:io';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/board/application/board_actions_bloc.dart';
import 'package:appflowy/plugins/database/board/application/board_bloc.dart';
import 'package:appflowy/plugins/shared/callback_shortcuts.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -22,56 +25,7 @@ class BoardShortcutContainer extends StatelessWidget {
@override
Widget build(BuildContext context) {
return AFCallbackShortcuts(
canAcceptEvent: (_, __) =>
context.read<AFCallbackShortcutsProvider>().isShortcutsEnabled.value,
bindings: {
const SingleActivator(LogicalKeyboardKey.arrowUp):
focusScope.focusPrevious,
const SingleActivator(LogicalKeyboardKey.arrowDown):
focusScope.focusNext,
const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true):
focusScope.adjustRangeUp,
const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true):
focusScope.adjustRangeDown,
const SingleActivator(LogicalKeyboardKey.escape): focusScope.clear,
const SingleActivator(LogicalKeyboardKey.keyE): () {
if (focusScope.value.length != 1) {
return;
}
context
.read<BoardActionsCubit>()
.startEditingRow(focusScope.value.first);
},
const SingleActivator(LogicalKeyboardKey.keyN): () {
if (focusScope.value.length != 1) {
return;
}
context
.read<BoardActionsCubit>()
.startCreateBottomRow(focusScope.value.first.groupId);
focusScope.clear();
},
const SingleActivator(LogicalKeyboardKey.delete): () =>
_removeHandler(context),
const SingleActivator(LogicalKeyboardKey.backspace): () =>
_removeHandler(context),
SingleActivator(
LogicalKeyboardKey.arrowUp,
shift: true,
meta: Platform.isMacOS,
control: !Platform.isMacOS,
): () => _shiftCmdUpHandler(context),
const SingleActivator(LogicalKeyboardKey.enter): () =>
_enterHandler(context),
const SingleActivator(LogicalKeyboardKey.numpadEnter): () =>
_enterHandler(context),
const SingleActivator(LogicalKeyboardKey.enter, shift: true): () =>
_shitEnterHandler(context),
const SingleActivator(LogicalKeyboardKey.comma): () =>
_moveGroupToAdjacentGroup(context, true),
const SingleActivator(LogicalKeyboardKey.period): () =>
_moveGroupToAdjacentGroup(context, false),
},
bindings: _shortcutBindings(context),
child: FocusScope(
child: Focus(
child: Builder(
@@ -92,16 +46,75 @@ class BoardShortcutContainer extends StatelessWidget {
);
}
void _enterHandler(BuildContext context) {
Map<ShortcutActivator, AFBindingCallback> _shortcutBindings(
BuildContext context,
) {
return {
const SingleActivator(LogicalKeyboardKey.arrowUp):
focusScope.focusPrevious,
const SingleActivator(LogicalKeyboardKey.arrowDown): focusScope.focusNext,
const SingleActivator(LogicalKeyboardKey.arrowUp, shift: true):
focusScope.adjustRangeUp,
const SingleActivator(LogicalKeyboardKey.arrowDown, shift: true):
focusScope.adjustRangeDown,
const SingleActivator(LogicalKeyboardKey.escape): focusScope.clear,
const SingleActivator(LogicalKeyboardKey.delete): () =>
_removeHandler(context),
const SingleActivator(LogicalKeyboardKey.backspace): () =>
_removeHandler(context),
SingleActivator(
LogicalKeyboardKey.arrowUp,
shift: true,
meta: Platform.isMacOS,
control: !Platform.isMacOS,
): () => _shiftCmdUpHandler(context),
const SingleActivator(LogicalKeyboardKey.enter): () =>
_enterHandler(context),
const SingleActivator(LogicalKeyboardKey.numpadEnter): () =>
_enterHandler(context),
const SingleActivator(LogicalKeyboardKey.enter, shift: true): () =>
_shiftEnterHandler(context),
const SingleActivator(LogicalKeyboardKey.comma): () =>
_moveGroupToAdjacentGroup(context, true),
const SingleActivator(LogicalKeyboardKey.period): () =>
_moveGroupToAdjacentGroup(context, false),
const SingleActivator(LogicalKeyboardKey.keyE): () =>
_keyEHandler(context),
const SingleActivator(LogicalKeyboardKey.keyN): () =>
_keyNHandler(context),
};
}
bool _keyEHandler(BuildContext context) {
if (focusScope.value.length != 1) {
return;
return false;
}
context.read<BoardActionsCubit>().startEditingRow(focusScope.value.first);
return true;
}
bool _keyNHandler(BuildContext context) {
if (focusScope.value.length != 1) {
return false;
}
context
.read<BoardActionsCubit>()
.startCreateBottomRow(focusScope.value.first.groupId);
focusScope.clear();
return true;
}
bool _enterHandler(BuildContext context) {
if (focusScope.value.length != 1) {
return false;
}
context
.read<BoardActionsCubit>()
.openCardWithRowId(focusScope.value.first.rowId);
return true;
}
void _shitEnterHandler(BuildContext context) {
bool _shiftEnterHandler(BuildContext context) {
if (focusScope.value.isEmpty) {
context
.read<BoardActionsCubit>()
@@ -111,10 +124,13 @@ class BoardShortcutContainer extends StatelessWidget {
focusScope.value.first,
CreateBoardCardRelativePosition.after,
);
} else {
return false;
}
return true;
}
void _shiftCmdUpHandler(BuildContext context) {
bool _shiftCmdUpHandler(BuildContext context) {
if (focusScope.value.isEmpty) {
context
.read<BoardActionsCubit>()
@@ -124,19 +140,30 @@ class BoardShortcutContainer extends StatelessWidget {
focusScope.value.first,
CreateBoardCardRelativePosition.before,
);
} else {
return false;
}
return true;
}
void _removeHandler(BuildContext context) {
if (focusScope.value.isEmpty) {
return;
}
context.read<BoardBloc>().add(BoardEvent.deleteCards(focusScope.value));
}
void _moveGroupToAdjacentGroup(BuildContext context, bool toPrevious) {
bool _removeHandler(BuildContext context) {
if (focusScope.value.length != 1) {
return;
return false;
}
NavigatorOkCancelDialog(
message: LocaleKeys.grid_row_deleteCardPrompt.tr(),
onOkPressed: () {
context.read<BoardBloc>().add(BoardEvent.deleteCards(focusScope.value));
},
).show(context);
return true;
}
bool _moveGroupToAdjacentGroup(BuildContext context, bool toPrevious) {
if (focusScope.value.length != 1) {
return false;
}
context.read<BoardBloc>().add(
BoardEvent.moveGroupToAdjacentGroup(
@@ -145,5 +172,6 @@ class BoardShortcutContainer extends StatelessWidget {
),
);
focusScope.clear();
return true;
}
}
@@ -1,3 +1,5 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
@@ -5,12 +7,10 @@ import 'package:appflowy/plugins/database/application/setting/property_bloc.dart
import 'package:appflowy/plugins/database/calendar/application/calendar_setting_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.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/protobuf.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';
/// Widget that displays a list of settings that alters the appearance of the
@@ -325,7 +325,6 @@ Widget _toggleItem({
Toggle(
value: value,
onChanged: (value) => onToggle(!value),
style: ToggleStyle.big,
padding: EdgeInsets.zero,
),
],
@@ -202,6 +202,30 @@ class FilterBackendService {
);
}
Future<FlowyResult<void, FlowyError>> insertTimeFilter({
required String fieldId,
String? filterId,
required NumberFilterConditionPB condition,
String content = "",
}) {
final filter = TimeFilterPB()
..condition = condition
..content = content;
return filterId == null
? insertFilter(
fieldId: fieldId,
fieldType: FieldType.Time,
data: filter.writeToBuffer(),
)
: updateFilter(
filterId: filterId,
fieldId: fieldId,
fieldType: FieldType.Time,
data: filter.writeToBuffer(),
);
}
Future<FlowyResult<void, FlowyError>> insertFilter({
required String fieldId,
required FieldType fieldType,
@@ -1,5 +1,5 @@
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/group.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
@@ -10,10 +10,12 @@ class GroupBackendService {
Future<FlowyResult<void, FlowyError>> groupByField({
required String fieldId,
required List<int> settingContent,
}) {
final payload = GroupByFieldPayloadPB.create()
..viewId = viewId
..fieldId = fieldId;
..fieldId = fieldId
..settingContent = settingContent;
return DatabaseEventSetGroupByField(payload).send();
}
@@ -127,6 +127,11 @@ class GridCreateFilterBloc
fieldId: fieldId,
condition: NumberFilterConditionPB.Equal,
);
case FieldType.Time:
return _filterBackendSvc.insertTimeFilter(
fieldId: fieldId,
condition: NumberFilterConditionPB.Equal,
);
case FieldType.RichText:
return _filterBackendSvc.insertTextFilter(
fieldId: fieldId,
@@ -0,0 +1,111 @@
import 'dart:async';
import 'package:appflowy/plugins/database/domain/filter_listener.dart';
import 'package:appflowy/plugins/database/domain/filter_service.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/filter/filter_info.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'time_filter_editor_bloc.freezed.dart';
class TimeFilterEditorBloc
extends Bloc<TimeFilterEditorEvent, TimeFilterEditorState> {
TimeFilterEditorBloc({required this.filterInfo})
: _filterBackendSvc = FilterBackendService(viewId: filterInfo.viewId),
_listener = FilterListener(
viewId: filterInfo.viewId,
filterId: filterInfo.filter.id,
),
super(TimeFilterEditorState.initial(filterInfo)) {
_dispatch();
_startListening();
}
final FilterInfo filterInfo;
final FilterBackendService _filterBackendSvc;
final FilterListener _listener;
void _dispatch() {
on<TimeFilterEditorEvent>(
(event, emit) async {
event.when(
didReceiveFilter: (filter) {
final filterInfo = state.filterInfo.copyWith(filter: filter);
emit(
state.copyWith(
filterInfo: filterInfo,
filter: filterInfo.timeFilter()!,
),
);
},
updateCondition: (NumberFilterConditionPB condition) {
_filterBackendSvc.insertTimeFilter(
filterId: filterInfo.filter.id,
fieldId: filterInfo.fieldInfo.id,
condition: condition,
content: state.filter.content,
);
},
updateContent: (content) {
_filterBackendSvc.insertTimeFilter(
filterId: filterInfo.filter.id,
fieldId: filterInfo.fieldInfo.id,
condition: state.filter.condition,
content: content,
);
},
delete: () {
_filterBackendSvc.deleteFilter(
fieldId: filterInfo.fieldInfo.id,
filterId: filterInfo.filter.id,
);
},
);
},
);
}
void _startListening() {
_listener.start(
onUpdated: (filter) {
if (!isClosed) {
add(TimeFilterEditorEvent.didReceiveFilter(filter));
}
},
);
}
@override
Future<void> close() async {
await _listener.stop();
return super.close();
}
}
@freezed
class TimeFilterEditorEvent with _$TimeFilterEditorEvent {
const factory TimeFilterEditorEvent.didReceiveFilter(FilterPB filter) =
_DidReceiveFilter;
const factory TimeFilterEditorEvent.updateCondition(
NumberFilterConditionPB condition,
) = _UpdateCondition;
const factory TimeFilterEditorEvent.updateContent(String content) =
_UpdateContent;
const factory TimeFilterEditorEvent.delete() = _Delete;
}
@freezed
class TimeFilterEditorState with _$TimeFilterEditorState {
const factory TimeFilterEditorState({
required FilterInfo filterInfo,
required TimeFilterPB filter,
}) = _TimeFilterEditorState;
factory TimeFilterEditorState.initial(FilterInfo filterInfo) {
return TimeFilterEditorState(
filterInfo: filterInfo,
filter: filterInfo.timeFilter()!,
);
}
}
@@ -14,6 +14,7 @@ class GridSize {
static double get cellVPadding => 10 * scale;
static double get popoverItemHeight => 26 * scale;
static double get typeOptionSeparatorHeight => 4 * scale;
static double get newPropertyButtonWidth => 140 * scale;
static EdgeInsets get cellContentInsets => EdgeInsets.symmetric(
horizontal: GridSize.cellHPadding,
@@ -0,0 +1,227 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/grid/application/filter/time_filter_editor_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.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 '../condition_button.dart';
import '../disclosure_button.dart';
import '../filter_info.dart';
import 'choicechip.dart';
class TimeFilterChoiceChip extends StatefulWidget {
const TimeFilterChoiceChip({
super.key,
required this.filterInfo,
});
final FilterInfo filterInfo;
@override
State<TimeFilterChoiceChip> createState() => _TimeFilterChoiceChipState();
}
class _TimeFilterChoiceChipState extends State<TimeFilterChoiceChip> {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => TimeFilterEditorBloc(
filterInfo: widget.filterInfo,
),
child: BlocBuilder<TimeFilterEditorBloc, TimeFilterEditorState>(
builder: (context, state) {
return AppFlowyPopover(
constraints: BoxConstraints.loose(const Size(200, 100)),
direction: PopoverDirection.bottomWithCenterAligned,
popupBuilder: (_) {
return BlocProvider.value(
value: context.read<TimeFilterEditorBloc>(),
child: const TimeFilterEditor(),
);
},
child: ChoiceChipButton(
filterInfo: state.filterInfo,
),
);
},
),
);
}
}
class TimeFilterEditor extends StatefulWidget {
const TimeFilterEditor({super.key});
@override
State<TimeFilterEditor> createState() => _TimeFilterEditorState();
}
class _TimeFilterEditorState extends State<TimeFilterEditor> {
final popoverMutex = PopoverMutex();
@override
Widget build(BuildContext context) {
return BlocBuilder<TimeFilterEditorBloc, TimeFilterEditorState>(
builder: (context, state) {
final List<Widget> children = [
_buildFilterPanel(context, state),
if (state.filter.condition != NumberFilterConditionPB.NumberIsEmpty &&
state.filter.condition !=
NumberFilterConditionPB.NumberIsNotEmpty) ...[
const VSpace(4),
_buildFilterTimeField(context, state),
],
];
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 1),
child: IntrinsicHeight(child: Column(children: children)),
);
},
);
}
Widget _buildFilterPanel(
BuildContext context,
TimeFilterEditorState state,
) {
return SizedBox(
height: 20,
child: Row(
children: [
Expanded(
child: FlowyText(
state.filterInfo.fieldInfo.name,
overflow: TextOverflow.ellipsis,
),
),
const HSpace(4),
Expanded(
child: TimeFilterConditionPBList(
filterInfo: state.filterInfo,
popoverMutex: popoverMutex,
onCondition: (condition) {
context
.read<TimeFilterEditorBloc>()
.add(TimeFilterEditorEvent.updateCondition(condition));
},
),
),
const HSpace(4),
DisclosureButton(
popoverMutex: popoverMutex,
onAction: (action) {
switch (action) {
case FilterDisclosureAction.delete:
context
.read<TimeFilterEditorBloc>()
.add(const TimeFilterEditorEvent.delete());
break;
}
},
),
],
),
);
}
Widget _buildFilterTimeField(
BuildContext context,
TimeFilterEditorState state,
) {
return FlowyTextField(
text: state.filter.content,
hintText: LocaleKeys.grid_settings_typeAValue.tr(),
debounceDuration: const Duration(milliseconds: 300),
autoFocus: false,
onChanged: (text) {
context
.read<TimeFilterEditorBloc>()
.add(TimeFilterEditorEvent.updateContent(text));
},
);
}
}
class TimeFilterConditionPBList extends StatelessWidget {
const TimeFilterConditionPBList({
super.key,
required this.filterInfo,
required this.popoverMutex,
required this.onCondition,
});
final FilterInfo filterInfo;
final PopoverMutex popoverMutex;
final Function(NumberFilterConditionPB) onCondition;
@override
Widget build(BuildContext context) {
final timeFilter = filterInfo.timeFilter()!;
return PopoverActionList<ConditionWrapper>(
asBarrier: true,
mutex: popoverMutex,
direction: PopoverDirection.bottomWithCenterAligned,
actions: NumberFilterConditionPB.values
.map(
(action) => ConditionWrapper(
action,
timeFilter.condition == action,
),
)
.toList(),
buildChild: (controller) {
return ConditionButton(
conditionName: timeFilter.condition.filterName,
onTap: () => controller.show(),
);
},
onSelected: (action, controller) {
onCondition(action.inner);
controller.close();
},
);
}
}
class ConditionWrapper extends ActionCell {
ConditionWrapper(this.inner, this.isSelected);
final NumberFilterConditionPB inner;
final bool isSelected;
@override
Widget? rightIcon(Color iconColor) =>
isSelected ? const FlowySvg(FlowySvgs.check_s) : null;
@override
String get name => inner.filterName;
}
extension TimeFilterConditionPBExtension on NumberFilterConditionPB {
String get filterName {
return switch (this) {
NumberFilterConditionPB.Equal => LocaleKeys.grid_numberFilter_equal.tr(),
NumberFilterConditionPB.NotEqual =>
LocaleKeys.grid_numberFilter_notEqual.tr(),
NumberFilterConditionPB.LessThan =>
LocaleKeys.grid_numberFilter_lessThan.tr(),
NumberFilterConditionPB.LessThanOrEqualTo =>
LocaleKeys.grid_numberFilter_lessThanOrEqualTo.tr(),
NumberFilterConditionPB.GreaterThan =>
LocaleKeys.grid_numberFilter_greaterThan.tr(),
NumberFilterConditionPB.GreaterThanOrEqualTo =>
LocaleKeys.grid_numberFilter_greaterThanOrEqualTo.tr(),
NumberFilterConditionPB.NumberIsEmpty =>
LocaleKeys.grid_numberFilter_isEmpty.tr(),
NumberFilterConditionPB.NumberIsNotEmpty =>
LocaleKeys.grid_numberFilter_isNotEmpty.tr(),
_ => "",
};
}
}
@@ -60,4 +60,10 @@ class FilterInfo {
? NumberFilterPB.fromBuffer(filter.data.data)
: null;
}
TimeFilterPB? timeFilter() {
return filter.data.fieldType == FieldType.Time
? TimeFilterPB.fromBuffer(filter.data.data)
: null;
}
}
@@ -1,6 +1,7 @@
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:flutter/material.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'choicechip/checkbox.dart';
import 'choicechip/checklist/checklist.dart';
import 'choicechip/date.dart';
@@ -28,6 +29,8 @@ class FilterMenuItem extends StatelessWidget {
SelectOptionFilterChoicechip(filterInfo: filterInfo),
FieldType.URL => URLFilterChoiceChip(filterInfo: filterInfo),
FieldType.Checklist => ChecklistFilterChoicechip(filterInfo: filterInfo),
// FieldType.Time =>
// TimeFilterChoiceChip(filterInfo: filterInfo),
_ => const SizedBox(),
};
}
@@ -93,9 +93,12 @@ class _GridFieldCellState extends State<GridFieldCell> {
onFieldInserted: widget.onFieldInsertedOnEitherSide,
);
},
child: FieldCellButton(
field: widget.fieldInfo.field,
onTap: widget.onTap,
child: SizedBox(
height: 40,
child: FieldCellButton(
field: widget.fieldInfo.field,
onTap: widget.onTap,
),
),
);
@@ -217,6 +220,12 @@ class FieldCellButton extends StatelessWidget {
field.fieldType.svgData,
color: Theme.of(context).iconTheme.color,
),
rightIcon: field.fieldType.rightIcon != null
? FlowySvg(
field.fieldType.rightIcon!,
blendMode: null,
)
: null,
radius: radius,
text: FlowyText.medium(
field.name,
@@ -151,7 +151,10 @@ class _CellTrailing extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Container(
width: GridSize.trailHeaderPadding,
constraints: BoxConstraints(
maxWidth: GridSize.newPropertyButtonWidth,
minHeight: GridSize.headerHeight,
),
margin: EdgeInsets.only(right: GridSize.scrollBarSize + Insets.m),
decoration: BoxDecoration(
border: Border(
@@ -196,7 +196,10 @@ class _CreateFieldButtonState extends State<CreateFieldButton> {
@override
Widget build(BuildContext context) {
return Container(
width: 200,
constraints: BoxConstraints(
maxWidth: GridSize.newPropertyButtonWidth,
minHeight: GridSize.headerHeight,
),
decoration: _getDecoration(context),
child: FlowyButton(
margin: const EdgeInsets.symmetric(vertical: 14, horizontal: 12),
@@ -2,6 +2,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/row/row_service.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -52,7 +53,17 @@ class RowActionMenu extends StatelessWidget {
child: FlowyButton(
text: FlowyText.medium(action.text, overflow: TextOverflow.ellipsis),
onTap: () {
action.performAction(context, viewId, rowId);
if (action == RowAction.delete) {
NavigatorOkCancelDialog(
message: LocaleKeys.grid_row_deleteRowPrompt.tr(),
onOkPressed: () {
action.performAction(context, viewId, rowId);
},
).show(context);
} else {
action.performAction(context, viewId, rowId);
}
PopoverContainer.of(context).close();
},
leftIcon: icon,
@@ -1,18 +1,21 @@
import 'package:flutter/widgets.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/relation_card_cell.dart';
import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/timestamp_card_cell.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart';
import 'package:flutter/widgets.dart';
import 'card_cell_skeleton/card_cell.dart';
import 'card_cell_skeleton/checkbox_card_cell.dart';
import 'card_cell_skeleton/checklist_card_cell.dart';
import 'card_cell_skeleton/date_card_cell.dart';
import 'card_cell_skeleton/number_card_cell.dart';
import 'card_cell_skeleton/relation_card_cell.dart';
import 'card_cell_skeleton/select_option_card_cell.dart';
import 'card_cell_skeleton/summary_card_cell.dart';
import 'card_cell_skeleton/text_card_cell.dart';
import 'card_cell_skeleton/time_card_cell.dart';
import 'card_cell_skeleton/timestamp_card_cell.dart';
import 'card_cell_skeleton/translate_card_cell.dart';
import 'card_cell_skeleton/url_card_cell.dart';
typedef CardCellStyleMap = Map<FieldType, CardCellStyle>;
@@ -98,6 +101,18 @@ class CardCellBuilder {
databaseController: databaseController,
cellContext: cellContext,
),
FieldType.Time => TimeCardCell(
key: key,
style: isStyleOrNull(style),
databaseController: databaseController,
cellContext: cellContext,
),
FieldType.Translate => TranslateCardCell(
key: key,
style: isStyleOrNull(style),
databaseController: databaseController,
cellContext: cellContext,
),
_ => throw UnimplementedError,
};
}
@@ -0,0 +1,62 @@
import 'package:appflowy/plugins/database/application/cell/cell_controller.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/time_cell_bloc.dart';
import 'card_cell.dart';
class TimeCardCellStyle extends CardCellStyle {
const TimeCardCellStyle({
required super.padding,
required this.textStyle,
});
final TextStyle textStyle;
}
class TimeCardCell extends CardCell<TimeCardCellStyle> {
const TimeCardCell({
super.key,
required super.style,
required this.databaseController,
required this.cellContext,
});
final DatabaseController databaseController;
final CellContext cellContext;
@override
State<TimeCardCell> createState() => _TimeCellState();
}
class _TimeCellState extends State<TimeCardCell> {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) {
return TimeCellBloc(
cellController: makeCellController(
widget.databaseController,
widget.cellContext,
).as(),
);
},
child: BlocBuilder<TimeCellBloc, TimeCellState>(
buildWhen: (previous, current) => previous.content != current.content,
builder: (context, state) {
if (state.content.isEmpty) {
return const SizedBox.shrink();
}
return Container(
alignment: AlignmentDirectional.centerStart,
padding: widget.style.padding,
child: Text(state.content, style: widget.style.textStyle),
);
},
),
);
}
}
@@ -0,0 +1,62 @@
import 'package:appflowy/plugins/database/application/cell/bloc/translate_cell_bloc.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'card_cell.dart';
class TranslateCardCellStyle extends CardCellStyle {
const TranslateCardCellStyle({
required super.padding,
required this.textStyle,
});
final TextStyle textStyle;
}
class TranslateCardCell extends CardCell<TranslateCardCellStyle> {
const TranslateCardCell({
super.key,
required super.style,
required this.databaseController,
required this.cellContext,
});
final DatabaseController databaseController;
final CellContext cellContext;
@override
State<TranslateCardCell> createState() => _TranslateCellState();
}
class _TranslateCellState extends State<TranslateCardCell> {
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) {
return TranslateCellBloc(
cellController: makeCellController(
widget.databaseController,
widget.cellContext,
).as(),
);
},
child: BlocBuilder<TranslateCellBloc, TranslateCellState>(
buildWhen: (previous, current) => previous.content != current.content,
builder: (context, state) {
if (state.content.isEmpty) {
return const SizedBox.shrink();
}
return Container(
alignment: AlignmentDirectional.centerStart,
padding: widget.style.padding,
child: Text(state.content, style: widget.style.textStyle),
);
},
),
);
}
}
@@ -1,7 +1,9 @@
import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/summary_card_cell.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/summary_card_cell.dart';
import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/translate_card_cell.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import '../card_cell_builder.dart';
import '../card_cell_skeleton/checkbox_card_cell.dart';
import '../card_cell_skeleton/checklist_card_cell.dart';
@@ -84,5 +86,9 @@ CardCellStyleMap desktopCalendarCardCellStyleMap(BuildContext context) {
padding: padding,
textStyle: textStyle,
),
FieldType.Translate: TranslateCardCellStyle(
padding: padding,
textStyle: textStyle,
),
};
}
@@ -1,7 +1,9 @@
import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/summary_card_cell.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/summary_card_cell.dart';
import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/translate_card_cell.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import '../card_cell_builder.dart';
import '../card_cell_skeleton/checkbox_card_cell.dart';
import '../card_cell_skeleton/checklist_card_cell.dart';
@@ -10,6 +12,7 @@ import '../card_cell_skeleton/number_card_cell.dart';
import '../card_cell_skeleton/relation_card_cell.dart';
import '../card_cell_skeleton/select_option_card_cell.dart';
import '../card_cell_skeleton/text_card_cell.dart';
import '../card_cell_skeleton/time_card_cell.dart';
import '../card_cell_skeleton/timestamp_card_cell.dart';
import '../card_cell_skeleton/url_card_cell.dart';
@@ -84,5 +87,13 @@ CardCellStyleMap desktopBoardCardCellStyleMap(BuildContext context) {
padding: padding,
textStyle: textStyle,
),
FieldType.Time: TimeCardCellStyle(
padding: padding,
textStyle: textStyle,
),
FieldType.Translate: TranslateCardCellStyle(
padding: padding,
textStyle: textStyle,
),
};
}
@@ -1,7 +1,9 @@
import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/summary_card_cell.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/summary_card_cell.dart';
import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/translate_card_cell.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import '../card_cell_builder.dart';
import '../card_cell_skeleton/checkbox_card_cell.dart';
import '../card_cell_skeleton/checklist_card_cell.dart';
@@ -10,6 +12,7 @@ import '../card_cell_skeleton/number_card_cell.dart';
import '../card_cell_skeleton/relation_card_cell.dart';
import '../card_cell_skeleton/select_option_card_cell.dart';
import '../card_cell_skeleton/text_card_cell.dart';
import '../card_cell_skeleton/time_card_cell.dart';
import '../card_cell_skeleton/timestamp_card_cell.dart';
import '../card_cell_skeleton/url_card_cell.dart';
@@ -83,5 +86,13 @@ CardCellStyleMap mobileBoardCardCellStyleMap(BuildContext context) {
padding: padding,
textStyle: textStyle,
),
FieldType.Time: TimeCardCellStyle(
padding: padding,
textStyle: textStyle,
),
FieldType.Translate: TranslateCardCellStyle(
padding: padding,
textStyle: textStyle,
),
};
}
@@ -27,50 +27,58 @@ class DesktopGridSummaryCellSkin extends IEditableSummaryCellSkin {
onExit: (p) =>
Provider.of<SummaryMouseNotifier>(context, listen: false)
.onEnter = false,
child: Stack(
children: [
TextField(
controller: textEditingController,
enabled: false,
focusNode: focusNode,
onEditingComplete: () => focusNode.unfocus(),
onSubmitted: (_) => focusNode.unfocus(),
maxLines: null,
style: Theme.of(context).textTheme.bodyMedium,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
contentPadding: GridSize.cellContentInsets,
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
disabledBorder: InputBorder.none,
isDense: true,
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: GridSize.headerHeight,
),
child: Stack(
fit: StackFit.expand,
children: [
Center(
child: TextField(
controller: textEditingController,
enabled: false,
focusNode: focusNode,
onEditingComplete: () => focusNode.unfocus(),
onSubmitted: (_) => focusNode.unfocus(),
maxLines: null,
style: Theme.of(context).textTheme.bodyMedium,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
contentPadding: GridSize.cellContentInsets,
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
disabledBorder: InputBorder.none,
isDense: true,
),
),
),
),
Padding(
padding: EdgeInsets.symmetric(
horizontal: GridSize.cellVPadding,
),
child: Consumer<SummaryMouseNotifier>(
builder: (
BuildContext context,
SummaryMouseNotifier notifier,
Widget? child,
) {
if (notifier.onEnter) {
return SummaryCellAccessory(
viewId: bloc.cellController.viewId,
fieldId: bloc.cellController.fieldId,
rowId: bloc.cellController.rowId,
);
} else {
return const SizedBox.shrink();
}
},
),
).positioned(right: 0, bottom: 0),
],
Padding(
padding: EdgeInsets.symmetric(
horizontal: GridSize.cellVPadding,
),
child: Consumer<SummaryMouseNotifier>(
builder: (
BuildContext context,
SummaryMouseNotifier notifier,
Widget? child,
) {
if (notifier.onEnter) {
return SummaryCellAccessory(
viewId: bloc.cellController.viewId,
fieldId: bloc.cellController.fieldId,
rowId: bloc.cellController.rowId,
);
} else {
return const SizedBox.shrink();
}
},
),
).positioned(right: 0, bottom: 8),
],
),
),
);
},
@@ -0,0 +1,37 @@
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/time_cell_bloc.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../editable_cell_skeleton/time.dart';
class DesktopGridTimeCellSkin extends IEditableTimeCellSkin {
@override
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
TimeCellBloc bloc,
FocusNode focusNode,
TextEditingController textEditingController,
) {
return TextField(
controller: textEditingController,
focusNode: focusNode,
onEditingComplete: () => focusNode.unfocus(),
onSubmitted: (_) => focusNode.unfocus(),
maxLines: context.watch<TimeCellBloc>().state.wrap ? null : 1,
style: Theme.of(context).textTheme.bodyMedium,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
contentPadding: GridSize.cellContentInsets,
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
disabledBorder: InputBorder.none,
isDense: true,
),
);
}
}
@@ -0,0 +1,102 @@
import 'package:appflowy/plugins/database/application/cell/bloc/translate_cell_bloc.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/translate.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:styled_widget/styled_widget.dart';
class DesktopGridTranslateCellSkin extends IEditableTranslateCellSkin {
@override
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
TranslateCellBloc bloc,
FocusNode focusNode,
TextEditingController textEditingController,
) {
return ChangeNotifierProvider(
create: (_) => TranslateMouseNotifier(),
builder: (context, child) {
return MouseRegion(
cursor: SystemMouseCursors.click,
opaque: false,
onEnter: (p) =>
Provider.of<TranslateMouseNotifier>(context, listen: false)
.onEnter = true,
onExit: (p) =>
Provider.of<TranslateMouseNotifier>(context, listen: false)
.onEnter = false,
child: ConstrainedBox(
constraints: BoxConstraints(
minHeight: GridSize.headerHeight,
),
child: Stack(
fit: StackFit.expand,
children: [
Center(
child: TextField(
controller: textEditingController,
readOnly: true,
focusNode: focusNode,
onEditingComplete: () => focusNode.unfocus(),
onSubmitted: (_) => focusNode.unfocus(),
maxLines: null,
style: Theme.of(context).textTheme.bodyMedium,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
contentPadding: GridSize.cellContentInsets,
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
disabledBorder: InputBorder.none,
isDense: true,
),
),
),
Padding(
padding: EdgeInsets.symmetric(
horizontal: GridSize.cellVPadding,
),
child: Consumer<TranslateMouseNotifier>(
builder: (
BuildContext context,
TranslateMouseNotifier notifier,
Widget? child,
) {
if (notifier.onEnter) {
return TranslateCellAccessory(
viewId: bloc.cellController.viewId,
fieldId: bloc.cellController.fieldId,
rowId: bloc.cellController.rowId,
);
} else {
return const SizedBox.shrink();
}
},
),
).positioned(right: 0, bottom: 8),
],
),
),
);
},
);
}
}
class TranslateMouseNotifier extends ChangeNotifier {
TranslateMouseNotifier();
bool _onEnter = false;
set onEnter(bool value) {
if (_onEnter != value) {
_onEnter = value;
notifyListeners();
}
}
bool get onEnter => _onEnter;
}
@@ -1,9 +1,9 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_bloc.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_cell_editor.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_progress_bar.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -62,22 +62,24 @@ class _ChecklistItemsState extends State<ChecklistItems> {
if (showIncompleteOnly) {
tasks.removeWhere((task) => task.isSelected);
}
final children = tasks
.mapIndexed(
(index, task) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: ChecklistItem(
key: ValueKey(task.data.id),
task: task,
autofocus: widget.state.newTask && index == tasks.length - 1,
onSubmitted: index == tasks.length - 1
? () => widget.bloc
.add(const ChecklistCellEvent.createNewTask(""))
: null,
),
),
)
.toList();
// final children = tasks
// .mapIndexed(
// (index, task) => Padding(
// padding: const EdgeInsets.symmetric(vertical: 2.0),
// child: ChecklistItem(
// key: ValueKey('${task.data.id}$index'),
// task: task,
// autofocus: widget.state.newTask && index == tasks.length - 1,
// onSubmitted: () {
// if (index == tasks.length - 1) {
// // create a new task under the last task if the users press enter
// widget.bloc.add(const ChecklistCellEvent.createNewTask(''));
// }
// },
// ),
// ),
// )
// .toList();
return Align(
alignment: AlignmentDirectional.centerStart,
child: Column(
@@ -115,7 +117,7 @@ class _ChecklistItemsState extends State<ChecklistItems> {
),
),
const VSpace(2.0),
...children,
_ChecklistCellEditors(tasks: tasks),
ChecklistItemControl(cellNotifer: widget.cellContainerNotifier),
],
),
@@ -123,6 +125,41 @@ class _ChecklistItemsState extends State<ChecklistItems> {
}
}
class _ChecklistCellEditors extends StatelessWidget {
const _ChecklistCellEditors({
required this.tasks,
});
final List<ChecklistSelectOption> tasks;
@override
Widget build(BuildContext context) {
final bloc = context.read<ChecklistCellBloc>();
final state = bloc.state;
return Column(
mainAxisSize: MainAxisSize.min,
children: [
...tasks.mapIndexed(
(index, task) => Padding(
padding: const EdgeInsets.symmetric(vertical: 2.0),
child: ChecklistItem(
key: ValueKey('${task.data.id}$index'),
task: task,
autofocus: state.newTask && index == tasks.length - 1,
onSubmitted: () {
if (index == tasks.length - 1) {
// create a new task under the last task if the users press enter
bloc.add(const ChecklistCellEvent.createNewTask(''));
}
},
),
),
),
],
);
}
}
class ChecklistItemControl extends StatelessWidget {
const ChecklistItemControl({super.key, required this.cellNotifer});
@@ -3,6 +3,7 @@ import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/summary.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class DesktopRowDetailSummaryCellSkin extends IEditableSummaryCellSkin {
@override
@@ -13,41 +14,53 @@ class DesktopRowDetailSummaryCellSkin extends IEditableSummaryCellSkin {
FocusNode focusNode,
TextEditingController textEditingController,
) {
return Column(
children: [
TextField(
controller: textEditingController,
focusNode: focusNode,
onEditingComplete: () => focusNode.unfocus(),
onSubmitted: (_) => focusNode.unfocus(),
style: Theme.of(context).textTheme.bodyMedium,
textInputAction: TextInputAction.done,
maxLines: null,
minLines: 1,
decoration: InputDecoration(
contentPadding: GridSize.cellContentInsets,
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
disabledBorder: InputBorder.none,
isDense: true,
),
),
Row(
children: [
const Spacer(),
Padding(
padding: const EdgeInsets.all(8.0),
child: SummaryCellAccessory(
viewId: bloc.cellController.viewId,
fieldId: bloc.cellController.fieldId,
rowId: bloc.cellController.rowId,
),
return Padding(
padding: const EdgeInsets.all(8.0),
child: Stack(
children: [
TextField(
controller: textEditingController,
readOnly: true,
focusNode: focusNode,
onEditingComplete: () => focusNode.unfocus(),
onSubmitted: (_) => focusNode.unfocus(),
style: Theme.of(context).textTheme.bodyMedium,
textInputAction: TextInputAction.done,
maxLines: null,
minLines: 1,
decoration: InputDecoration(
contentPadding: GridSize.cellContentInsets,
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
disabledBorder: InputBorder.none,
isDense: true,
),
],
),
],
),
ChangeNotifierProvider.value(
value: cellContainerNotifier,
child: Selector<CellContainerNotifier, bool>(
selector: (_, notifier) => notifier.isHover,
builder: (context, isHover, child) {
return Visibility(
visible: isHover,
child: Row(
children: [
const Spacer(),
SummaryCellAccessory(
viewId: bloc.cellController.viewId,
fieldId: bloc.cellController.fieldId,
rowId: bloc.cellController.rowId,
),
],
),
);
},
),
),
],
),
);
}
}
@@ -0,0 +1,40 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/time_cell_bloc.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import '../editable_cell_skeleton/time.dart';
class DesktopRowDetailTimeCellSkin extends IEditableTimeCellSkin {
@override
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
TimeCellBloc bloc,
FocusNode focusNode,
TextEditingController textEditingController,
) {
return TextField(
controller: textEditingController,
focusNode: focusNode,
onEditingComplete: () => focusNode.unfocus(),
onSubmitted: (_) => focusNode.unfocus(),
style: Theme.of(context).textTheme.bodyMedium,
textInputAction: TextInputAction.done,
decoration: InputDecoration(
contentPadding: const EdgeInsets.symmetric(horizontal: 8, vertical: 9),
border: InputBorder.none,
focusedBorder: InputBorder.none,
enabledBorder: InputBorder.none,
errorBorder: InputBorder.none,
disabledBorder: InputBorder.none,
hintText: LocaleKeys.grid_row_textPlaceholder.tr(),
hintStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).hintColor,
),
isDense: true,
),
);
}
}
Loaded 100 of 707 files, more files were not shown because too many files have changed in this diff. Show more