Compare commits

...
83 Commits
Author SHA1 Message Date
Lucas 5cf3a365de chore: revert en-US i18n change (#8838) 2026-06-26 10:35:45 +08:00
Lucas 4af02cdc87 fix: add autofillHints support to AFTextField (#8594)
* chore: bump version to 0.11.4 and update changelog

* feat: add autofillHints support to AFTextField

* fix: remove autoFocus from font search input

* chore: fix code formatting
2026-03-20 14:02:00 +08:00
Annie bbe886fcdd Update README.md (#8327) 2025-11-16 21:57:57 +08:00
Rishi Ahuja f0f10b05f4 fix: prevent page flickering on rapid sidebar clicks (#8278)
* fix: prevent page flickering on rapid sidebar clicks

* refactor: improve deduplication to check both plugin and view IDs
2025-11-07 10:37:18 +08:00
Samuele Mazzei b5a1ccb4ee Update Italian Translations (#8253)
* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦
2025-11-06 15:22:45 +08:00
Stenrax 148f268923 chore: update translations with Fink 🐦 (#8320) 2025-11-06 15:16:46 +08:00
Tirthh 1ed3d5439b fix: enable password manager autofill for all password fields (#8295)
- Added autofillHints to password fields in sign-in, password setup, and password change screens
- Enables password managers (1Password, LastPass, Bitwarden, etc.) to detect and autofill password fields
- Minimal change: only added AutofillHints.password property to 8 password fields across 4 files
2025-11-06 15:16:21 +08:00
JradChaima1 2e00509ebb fix: set autoFocus true for font dropdown search input (#8294) 2025-11-06 15:04:58 +08:00
Lucas 41ca1dd8ee chore: bump version 0.9.9 (#8214) 2025-09-12 16:21:32 +08:00
AlphaSu1978 a447af334d chore: update zh-TW translations (#8128)
* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦
2025-08-20 10:52:32 +08:00
Matyáš Vaňhát 223126aff7 chore: update cs-CZ translations with Fink 🐦 (#8157) 2025-08-20 10:51:37 +08:00
Mo'ath Zaghdad 6654e0c6b3 chore: update ar-SA translations with Fink 🐦 (#8167) 2025-08-20 10:50:58 +08:00
Phenol_EV 1fbd37631a chore: update zh-CN translations (#8174)
Some Chinese translations have been revised to make them look more authentic.
2025-08-20 10:50:30 +08:00
Lucas 6b84c4b970 chore: upgrade AGP version to 8.1.0 (#8119)
* chore: upgrade AGP version to 8.1.0

* chore: update version info
2025-07-14 11:25:53 +08:00
Lucas 7599c1902d fix: prevent location from being accessed continuously on Windows (#8110) 2025-07-08 11:50:22 +08:00
YangFong 7b1a153749 chore: remove extra space in createdAt translation of zh-CN (#8094) 2025-07-03 09:49:30 +08:00
Richard Shiue d83faa4675 fix: inlang not working for translators (#8073) 2025-06-23 09:18:21 +08:00
Richard Shiue 460fd54182 chore: adjust toast component (#8060)
* chore: adjust toast component

* test: fix tests
2025-06-16 12:43:43 +08:00
Richard Shiue d4f9c71ec2 refactor: rename dialog (#8059)
* refactor: rename dialog

* test: fix test
2025-06-16 11:32:19 +08:00
Richard Shiue a480889c28 chore(mobile): add selected sources count indicator (#8056) 2025-06-13 09:51:59 +08:00
Richard Shiue 8d5019471b chore: add local ai learn more button (#8053) 2025-06-12 11:28:59 +08:00
Richard Shiue 08700d4837 chore: adjust padding for include time (#8052) 2025-06-12 11:28:32 +08:00
Morn 734bb25c0a fix: improve the link menu in read-only mode (#8048)
* fix: improve the link menu in read-only mode

* chore: change the tooltip of database fullscreen icon
2025-06-12 11:28:19 +08:00
Richard Shiue 197cc016ed fix: workspace name update not synced (#8054) 2025-06-11 18:22:06 +08:00
Lucas ca8b4f9384 fix: outline doesn't show when @dates are used in the headline (#8049)
* fix: outline doesn't show when @dates are used in the headline

* feat: add open app deeplink handler

* fix: database falling infinitely on mobile
2025-06-10 20:53:06 +08:00
Morn 8d0d968e9b fix: remove bold style of head mention node in notification center (#8034)
* fix: allow the whole embed link clickable

* fix: remove bold style of head mention node in notification center
2025-06-09 12:55:22 +08:00
Richard Shiue a080cffc1d chore: remove include time icon for created at/last modified type option (#8031) 2025-06-05 12:25:40 +08:00
Lucas 5eabaa7763 fix: update de/he translations (#8028) 2025-06-05 11:57:55 +08:00
Richard Shiue 730c291f47 chore: remove change and reset path settings (#8010)
* chore: remove change and reset path settings

* chore: show reset when custom

* chore: tooltip

* chore: reorganize code

* chore: reorganize code

* chore: use bloc instead and move logic to repository

* chore: listener

* chore: enable for cloud

* chore: add bloc test
2025-06-05 11:57:17 +08:00
Morn 800cefa9c0 fix: notification center mention node rendering issue within heading node (#8002) 2025-06-05 09:43:35 +08:00
Lucas dd2dbed8c8 fix: permission control doesn't work when opening the page at the first time (#8023)
* fix: permission control doesn't work when opening the page at the first time

* feat: enable tracing events cost time

* feat: leave shared page via ... menu

* fix: hide the create page button if user is guest

* feat: disable publish button if user is a guest

* feat: add shared user table

* feat: integrate sql into folder manager

* feat: integrate shared user notification

* fix: flutter analyze

* fix: revert local shared user data

* fix: disable share button on mobile if user is a guest

* feat: save the shared user data

* feat: integrate pro plan workflow into share menu

* chore: format code

* feat: disable share menu in local mode

* chore: format code
2025-06-05 09:42:31 +08:00
Lucas 11200a5b3d feat: enable shared section on mobile (#8020)
* feat: enable shared section on mobile

* feat: update mobile view item

* feat: shared with me section on mobile
2025-06-04 10:59:16 +08:00
Lucas 5598689dba feat: control access level in share menu (#8017)
* feat: use auto_confirm in share with user api

* feat: reduce share api calls

* feat: add team space section in title bar

* feat: only user with full access can invite guest/member

* feat: customize popup style

* feat: support section type

* feat: update access level based on public or private space

* test: add access level tests

* chore: update i18n

* chore: disable bloc talker

* fix: tests

* fix: general access test

* fix: view title bar test

* chore: i18n
2025-06-03 21:02:13 +08:00
Lucas 88a3e9eddd feat: implement section breadcrumb and new share menu design (#8012)
* feat: support secion breadcrumb

* chore: update layout

* feat: observe space permission change

* feat: new share menu design

* fix: widget test
2025-06-03 09:46:42 +08:00
Lucas d3566e303d feat: optimize share section and share tab logic (#8001)
* feat: optimize share section and share tab logic

* feat: new people with access section

* feat: new copy link widget

* fix: hide member page for guest user

* fix: hide sites page for guest user

* feat: support access level in document

* fix: can't edit the title when access level is read

* feat: guest role can't get page outside the flatten shared pages

* chore: refactor user workspace bloc

* feat: show guest role in workspace menu

* chore: update guest tag and section header

* chore: use readwrite as default permission

* fix: remove unused tests

* chore: clear share status

* fix: access level

* fix: menu color

* feat: support general access section

* feat: remove self access

* fix: tests

* fix: access level issues

* test: add workspace bloc test
2025-06-02 13:52:14 +08:00
Richard Shiue 9fb21f8dc2 refactor: workspace icon (#8006) 2025-05-30 17:20:51 +08:00
Lucas 289cf2526d feat: integrate guest role (#7967)
* fix: turn off share apis when fg is off

* chore: bump client-api version

* fix: use workspace id in turn into member api

* feat: hide the invite textfield when user role is guest

* fix: improve error handling for invitation failures in share tab

* fix: handle sharing disabled state in share tab

* chore: bump version

* feat: replace sidebar menu with AFMenu

* chore: add error code

* feat: shared with me

* fix: app scale issue

* chore: udpate error message

* feat: support more access levels

* chore: delegate the view item events

* feat: inject the callbacks into shared page list

* fix: flutter analyze

* feat: support rename / change icon in shared folder

* feat: added shared page actions button

* fix: tests

* chore: update version
2025-05-29 10:17:27 +08:00
Lucas c664b63003 chore: add retry in create dmg step (#7999) 2025-05-29 10:16:00 +08:00
Richard Shiue 37107669e4 chore: bring back animated theme (#7988) 2025-05-28 22:40:20 +08:00
Morn caf3512ed8 chore: update CHANGELOG.md (#7998)
* chore: update CHANGELOG.md

* chore: update CHANGELOG.md
2025-05-28 16:20:32 +08:00
Lucas 75f0f08c78 fix: checkbox icon not aligned with the first line (#7997) 2025-05-28 10:48:31 +08:00
Nathan.fooo 8fcbf78714 chore: index document even content is empty (#7996) 2025-05-28 10:11:45 +08:00
Morn 837e754946 fix: database padding issue with max document width (#7991)
* fix: adjust the bottom sheet menu drag area

* fix: database padding issue with max document width
2025-05-28 10:05:21 +08:00
Lucas ad86e1f55a feat: optimize chat animation (#7990)
* feat: optimize chat animation

* feat: optimize ai chat animation

* chore: move calculate min height logic to manager

* chore: remove unused code

* chore: adjust the animation on mobile
2025-05-27 20:06:07 +08:00
Lucas c78564ea79 fix: workspace name conflicts (#7992) 2025-05-27 20:04:04 +08:00
Richard Shiue 29f8f0d317 chore: adjust database custom prompt ui (#7989) 2025-05-27 19:15:36 +08:00
Richard Shiue 4461c77992 fix: make retry work in ai generated images (#7993) 2025-05-27 19:07:34 +08:00
Richard Shiue dc5f463e84 feat: add simple dropdown component (#7986)
* feat: add simple dropdown component

* chore: code style
2025-05-27 17:31:16 +08:00
Richard Shiue a92e59c693 fix: database view tooltip not displayed (#7987) 2025-05-27 16:38:07 +08:00
Morn 54206c446f fix: search LR issues (#7984)
* fix: some search LR issues

* fix: revamp search textfield hint text
2025-05-27 08:32:47 +08:00
Lucas d93929a3b6 fix: refocus editor after selecting image on Android (#7983)
* fix: refocus editor after selecting image on Android

* fix: toggle list issue

* fix: math equation markdown node parser
2025-05-27 08:32:21 +08:00
Nathan af6d1f4701 chore: fix ios simulator build 2025-05-26 17:38:51 +08:00
Nathan 73757dad6b chore: fix ios simulator build 2025-05-26 17:30:56 +08:00
Nathan ec1c5c40e2 chore: fix build 2025-05-26 16:46:00 +08:00
Nathan.fooo 95f3304cae chore: add constraint for importing csv (#7981) 2025-05-26 16:20:29 +08:00
Nathan.fooo 8301c3f2c3 chore: fix ios build (#7982) 2025-05-26 16:19:55 +08:00
Richard Shiue 581a2b41a8 chore: adjust custom prompt UI (#7966)
* chore: refetch view when reload

* chore: excessive padding

* chore: cursor for clear search input

* chore: move other to bottom of category list

* chore: padding on mobile

* chore: select sources background color

* chore: support single and multi select

* chore: adjust colors
2025-05-26 14:21:35 +08:00
Morn 5e397c06d2 fix: some search LR issues (#7971)
* fix: some LR issues

* fix: desktop textstyle issues

* fix: mobile textstyle issues

* fix: sometimes the path not showing

* fix: path displays error, open not exist page toast error

* fix: mobile search page focus issue

* fix: open page error

* fix: some UI issues
2025-05-26 14:21:23 +08:00
Richard Shiue 02dc7ab6b3 chore: add a tooltip for database tab bar items (#7970) 2025-05-23 14:25:45 +08:00
Richard Shiue b4451bf54a chore: use a different text action for title editing (#7968) 2025-05-22 18:30:30 +08:00
Richard Shiue 47b77528ce chore: adjust padding in chat (#7965) 2025-05-22 17:58:21 +08:00
Morn e9f12832fb fix: launch review 0.9.3 issues (#7964)
* fix: some LR issues

* fix: add max width for search result title

* fix: update the logic of refreshing chached views

* fix: padding issue

* fix: location of path is wrong

* fix: some UI color issues
2025-05-22 13:59:43 +08:00
Lucas b0a02e6870 feat: turn guest into member (#7958)
* feat: turn guest into member

* feat: handle share results

* fix: clear email textfield after inviting

* test: add turn into member test

* fix: double toasts show when sharing with guest
2025-05-22 11:54:12 +08:00
Richard Shiue f5e2e4e915 fix: clear cells not working (#7956)
* fix: clear cells not working

* chore: bump collab rev
2025-05-22 10:36:15 +08:00
Lucas fbb8f01e1a chore: turn off shared section feature flag (#7960) 2025-05-21 18:03:39 +08:00
Nathan.fooo b085f4feec chore: bump collab (#7959) 2025-05-21 16:20:19 +08:00
Morn 3cffb34364 feat: support mobile search AI Overview (#7932)
* feat: add arrow back button in search page

* feat: support mobile search AI Overview

* fix: add error toast fore the view no longer existed

* chore: add some tests

* fix: remove unuse code

* chore: i18n

* chore: improve mobile search UI

* fix: optimize some codes

* fix: some UI issues
2025-05-21 13:49:47 +08:00
Morn ffef4a4e3b fix: make the link align center while sitename is empty (#7955) 2025-05-21 13:38:57 +08:00
Morn 0b24f2cd37 fix: desktop Search UI improvement (#7950)
* fix: improve search recent list UI

* fix: complete desktop search preview UI improvement

* fix: hide search preview when the window is narrow

* fix: optimize some code
2025-05-21 13:38:03 +08:00
Nathan.fooo d80d0285f9 chore: enable index on mobile (#7954)
* chore: enable index on mobile

* chore: fix clippy
2025-05-21 12:05:27 +08:00
Lucas c8acd0227a feat: optimize shared section logic (#7957)
* chore: bump AppFlowy version to 1.0.0 and add sharedSection feature flag

* feat: conditionally display spaces in sidebar based on user role

* refactor: update user avatar implementation and enhance shared section functionality

- Removed unused colorHash from SharedUserWidget.
- Enabled polling in SharedSectionBloc for improved data updates.
- Disabled dragging for shared pages in SharedPagesList.
- Updated UserAvatar to use AFAvatar for consistency across components.
- Adjusted avatar sizes in various components to utilize AFAvatarSize enums.

* fix: typo

* refactor: enhance share tab functionality and error handling

* chore: i18n

* feat: turn guest into member

* fix: unit tests

* fix: unit tests

* chore: revert version changes
2025-05-21 10:39:13 +08:00
Lucas 105f244a4b feat: shared folder refactor (#7948)
* feat: get the shared folder

* feat: add shared_view sql

* feat: persist the shared view data

* feat: support notification

* fix: flutter compile

* feat: add logic, data and presentations

* feat: integrate client-api endpoints

* feat: replace upsert with replace_all for workspace shared views management

* refactor: streamline sharedPages retrieval with extension method

* feat: enhance SharedSidebarBloc with folder notification support and update shared pages management

* feat: implement SharedSection with Bloc integration and new UI components

* chore: update comments

* feat: introduce SharedSectionBloc for managing shared pages with polling and notification support

* refactor: replace AFAccessLevelPB with ShareAccessLevel in shared pages management and extend functionality with new extensions

* chore: rename folder

* feat: add unit tests for SharedSectionBloc and UI components in shared section

* fix: rust fmt

* chore: revert version changes

* fix: cargo clippy

* fix: unit test
2025-05-20 16:37:09 +08:00
Nathan.fooo 8a80ded187 chore: add chat message order test (#7953)
* chore: add chat message order test

* chore: clippy
2025-05-20 14:22:28 +08:00
Nathan.fooo 49b56f0a6e fix: stream key (#7952) 2025-05-20 11:48:29 +08:00
Richard Shiue c634e906ea chore: implement custom prompt usage metrics (#7923)
* chore: implement custom prompt usage metrics

* chore: bump client-api
2025-05-19 16:07:11 +08:00
Lucas 03d35f8fe1 feat: refactor share menu (#7935)
* chore: update client-api

* chore: integrate share endpoints

* feat: add share repository

* feat: add share events

* feat: integrate rust events

* chore: code refactor

* feat: add share logic

* feat: optimize share_with_user bloc

* feat: share with user widget

* feat: use AFPopover

* feat: add shared user list

* feat: fetch current user

* feat: add shared usert list

* feat: add mock share repo

* feat: update client-api

* feat: add people_with_access and general_access

* fix: flutter analyze

* feat: support share link

* feat: edit access level

* feat: invite guest

* feat: optimize share menu loading perf

* fix: unable to display the share link

* feat: update access role

* feat: support cached network image

* feat: add access level callback

* feat: remove guest access

* fix: popover exclusive issue

* fix: close edit access level popover after editing

* feat: use rust share apis

* chore: rename folder

* test: add widget tests

* test: share with user bloc test

* chore: downgrade to 0.9.3
2025-05-19 15:26:25 +08:00
Richard Shiue a7b795d3cc feat: allow users to map fields while importing prompts from database (#7938) 2025-05-19 10:40:04 +08:00
Richard Shiue f4ae25f8d3 fix: don't switch to space if on same space (#7939) 2025-05-16 18:24:44 +08:00
Richard Shiue 2e4e71f459 chore: code cleanup (#7940) 2025-05-16 17:23:50 +08:00
Lucas 479fd45cab feat: integrate color token v3 (#7936)
* feat: integrate color token v3

* chore: update existing color tokens
2025-05-15 18:03:16 +08:00
Lucas 9cac7e5a91 feat: add menu / avatar component (#7933)
* feat: support avatar in appflowy_ui package

* feat: add menu component

* feat: optimize section component

* feat: add menu example

* feat: add menu item selected status

* feat: support popover

* fix: analyze issue
2025-05-14 19:19:43 +08:00
Morn bb4f860f37 chore: update CHANGELOG.md (#7930) 2025-05-14 18:31:15 +08:00
Nathan.fooo fed9d43fc5 Upgrade Rust edition (Do not merge into 0.9.2 version) (#7925)
* chore: compile

* chore: bump rust edition

* chore: bump rust tool chain version

* chore: fix test

* chore: fmt

* chore: build target

* chore: clippy

* chore: fmt
2025-05-14 10:26:59 +08:00
665 changed files with 27684 additions and 6591 deletions

No files matched your search

+1 -1
View File
@@ -19,7 +19,7 @@ on:
env:
CARGO_TERM_COLOR: always
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.84.0"
RUST_TOOLCHAIN: "1.85.0"
CARGO_MAKE_VERSION: "0.37.18"
CLOUD_VERSION: 0.6.54-amd64
+1 -1
View File
@@ -26,7 +26,7 @@ on:
env:
CARGO_TERM_COLOR: always
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.84.0"
RUST_TOOLCHAIN: "1.85.0"
CARGO_MAKE_VERSION: "0.37.18"
CLOUD_VERSION: 0.9.49-amd64
+1 -1
View File
@@ -19,7 +19,7 @@ on:
env:
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.84.0"
RUST_TOOLCHAIN: "1.85.0"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+20 -13
View File
@@ -7,7 +7,7 @@ on:
env:
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.84.0"
RUST_TOOLCHAIN: "1.85.0"
jobs:
create-release:
@@ -180,17 +180,24 @@ jobs:
- name: Create macOS dmg
run: |
brew install create-dmg
create-dmg \
--volname ${{ env.MACOS_DMG_NAME }} \
--hide-extension "AppFlowy.app" \
--background frontend/scripts/dmg_assets/AppFlowyInstallerBackground.jpg \
--window-size 600 450 \
--icon-size 94 \
--icon "AppFlowy.app" 141 249 \
--app-drop-link 458 249 \
"${{ env.MACOS_APP_RELEASE_PATH }}/${{ env.MACOS_DMG_NAME }}.dmg" \
"${{ env.MACOS_APP_RELEASE_PATH }}/AppFlowy.app"
i=0
until [[ -e "${{ env.MACOS_APP_RELEASE_PATH }}/${{ env.MACOS_DMG_NAME }}.dmg" ]]; do
create-dmg \
--volname ${{ env.MACOS_DMG_NAME }} \
--hide-extension "AppFlowy.app" \
--background frontend/scripts/dmg_assets/AppFlowyInstallerBackground.jpg \
--window-size 600 450 \
--icon-size 94 \
--icon "AppFlowy.app" 141 249 \
--app-drop-link 458 249 \
"${{ env.MACOS_APP_RELEASE_PATH }}/${{ env.MACOS_DMG_NAME }}.dmg" \
"${{ env.MACOS_APP_RELEASE_PATH }}/AppFlowy.app" || true
if [[ $i -eq 10 ]]; then
echo 'Error: create-dmg did not succeed even after 10 tries.'
exit 1
fi
i=$((i+1))
done
- name: Notarize AppFlowy
run: |
xcrun notarytool submit ${{ env.MACOS_APP_RELEASE_PATH }}/${{ env.MACOS_DMG_NAME }}.dmg --apple-id ${{ secrets.MACOS_NOTARY_USER }} --team-id ${{ secrets.MACOS_TEAM_ID }} --password ${{ secrets.MACOS_NOTARY_PWD }} -v -f "json" --wait
@@ -233,7 +240,7 @@ jobs:
job:
- {
targets: "aarch64-apple-darwin,x86_64-apple-darwin",
os: macos-latest,
os: macos-14,
extra-build-args: "",
}
steps:
+1 -1
View File
@@ -19,7 +19,7 @@ on:
env:
CARGO_TERM_COLOR: always
CLOUD_VERSION: 0.9.49-amd64
RUST_TOOLCHAIN: "1.84.0"
RUST_TOOLCHAIN: "1.85.0"
jobs:
ubuntu-job:
+1 -1
View File
@@ -11,7 +11,7 @@ on:
env:
CARGO_TERM_COLOR: always
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.84.0"
RUST_TOOLCHAIN: "1.85.0"
jobs:
tests:
+352 -1
View File
@@ -1,4 +1,355 @@
# Release Notes
## Version 0.11.4 – 09/03/2026
### Desktop
#### New Features
- Notification System: In-app notification center to track mentions, reminders, and activity updates
- Revamped Slash Menu: Redesigned `/` command menu with improved layout and discoverability
- Editor Block Spacing: Optimize spacing between blocks in the document editor
#### Bug Fixes
- Fixed row document issues
- Fixed bulk delete not working correctly
- Fixed connection check causing unnecessary errors
### Mobile
#### New Features
- Notification System: In-app notification center to track mentions, reminders, and activity updates
- Revamped Slash Menu: Redesigned `/` command menu with improved layout and discoverability
- Editor Block Spacing: Optimize spacing between blocks in the document editor
#### Bug Fixes
- Fixed mobile block selection disappearing
- Fixed select-all behavior with Gboard on Android
- Fixed duplicate column names when creating board groups
## Version 0.11.3 – 25/02/2026
### Desktop
#### Bug Fixes
- Fixed the space dropdown menu not displaying correctly
- Fixed relation filter not applying correctly in advanced filter scenarios
- Fixed the disconnect banner not refreshing properly
### Mobile
#### Bug Fixes
- Fixed losing focus after typing the first character in a text cell
- Fixed FCM blocking the main app on startup
## Version 0.11.2 – 14/02/2026
### Desktop
#### New Features
- New Database View: Feed view
- AI Transcript & Meeting Blocks: Automatic speaker name guessing, referenced AI summaries, and meeting detection from Google Meet and Slack
- Google Drive Block: Embed and select Google Drive files via link or connected account
#### Bug Fixes
- File Block: Display upload progress during file uploads
- Image Upload: Improved upload indicator and instant image caching
- Unsplash Cover Image: Improved preview and keyboard navigation
### Mobile
#### New Features
- New Database View: Feed view
- Google Drive Block: Embed and select Google Drive files via link or connected account
#### Bug Fixes
- File Block: Display upload progress during file uploads
- Image Upload: Improved upload indicator and instant image caching
## Version 0.11.1 – 28/01/2026
### Desktop
#### New Features
- New Database Views: Gallery, List, and Chart views
- Grouping in a Grid (Table) view: Group by allows you to organize database entries into collapsible sections based on property values (e.g., Status, Priority)
- Multi-row actions: Allows for powerful multi-row (bulk) actions within databases to edit, delete, or duplicate all at once
- Database advanced filters: Useful when combining AND logic and OR logic in your filter
- Number property new formats: Comma separated and percent
- Export as PDF: Export a single page as .PDF
- Database Cover Images: Added support for cover images to enhance visual representation
- AI Transcript & Meeting Notes: Use the mention person type for task assignees in the action items of the AI summary
#### Bug Fixes
- Enhanced playback experience for video blocks
- Resolved issue with inline mention styles not matching block text styles
### Mobile
#### New Features
- New Database Views: Gallery, List, and Chart views
#### Bug Fixes
- Improved playback experience for video blocks
- Fixed issue with inline mention styles not matching block text styles
## Version 0.11.0 – 12/01/2026
### Desktop
#### Bug Fixes
- Fixed row detail page button style not following theme changes
- Fixed target limit not being removed when creating two-way relation field
- Improved speaker selector menu UI style in the transcript tab of AI meeting block
## Version 0.10.9 – 09/01/2026
### Desktop
#### New Features
- Database Rollup: A powerful property used to retrieve and aggregate information from items in a related database
- Database search: Search names of database pages, as well as property values (such as a status of "In progress") and content inside database pages
- AI Transcript with auto speaker identification: Upload an audio file to generate transcriptions with speaker labels. You can assign or change speaker names
- AI Meeting Notes with auto speaker identification: Real-time transcription during meetings with automatic speaker label detection after recording stops. You can assign or change speaker names
- Mermaid diagram support: Add diagrams directly in Code blocks with split, preview, and code display modes, plus an expandable view
- Top nav bar breadcrumb revamp: Refreshed design with improved layout
- Document and database cover reposition: Reposition cover images for the best display
#### Bug Fixes
- Fixed various database issues to improve stability and performance
### Mobile
#### New Features
- AI Transcript with auto speaker identification: Upload an audio file to generate transcriptions with speaker labels. You can assign or change speaker names
- Mermaid diagram support: Add diagrams directly in Code blocks with split, preview, and code display modes, plus an expandable view
#### Bug Fixes
- Fixed various database issues to improve stability and performance
## Version 0.10.8 – 22/12/2025
### Desktop
#### Bug Fixes
- Grid sort order preserved after edits — Editing a row no longer moves it to the bottom of a sorted Grid
- Delete banner for databases — Grid, Board, and Calendar now show restore/delete banner when deleted
- Timestamp field conversion — Converting Created time or Last modified to Text/Date no longer loses values
- Unicode string sorting — Sorting now handles accented characters and special symbols correctly
- Kanban filter fix — Filters now apply correctly to items added after view creation
### Mobile
#### Bug Fixes
- Grid sort order preserved after edits — Editing a row no longer moves it to the bottom of a sorted Grid
- Timestamp field conversion — Converting Created time or Last modified to Text/Date no longer loses values
- Unicode string sorting — Sorting now handles accented characters and special symbols correctly
- Kanban filter fix — Filters now apply correctly to items added after view creation
## Version 0.10.7 – 19/12/2025
### Desktop
#### New Features
- AI Meeting Notes Block: Supports real-time transcription on macOS and Windows. AI will turn everything into a clean, share-ready summary
- Database Two-way Relations: Automatically sync connected data between databases, ensuring that information added in one place instantly appears in the other
- Video Block: Upload or embed video files, then view and interact with them directly inside your document
- Audio Block: Upload or embed audio files, then view and interact with them directly inside your document
- Callout Block: Supports removing the callout icon
- Table of Contents Block: Supports collapsing/uncollapsing the content
#### Bug Fixes
- Fixed various issues related to database views, including sorting, filtering, counting, syncing, and layouts
- Fixed various issues related to the linked views of databases
- Fixed database issues related to date formats, file name display, view tabs, horizontal scroll, and Relation property performance
### Mobile
#### New Features
- Video Block: Upload or embed video files, then view and interact with them directly inside your document
- Audio Block: Upload or embed audio files, then view and interact with them directly inside your document
- Callout Block: Supports removing the callout icon
- Table of Contents Block: Supports collapsing/uncollapsing the content
#### Bug Fixes
- Fixed various issues related to database views, including sorting, filtering, counting, syncing, and layouts
- Fixed various issues related to the linked views of databases
- Fixed database issues related to date formats, file name display, view tabs, horizontal scroll, and Relation property performance
## Version 0.10.6 – 05/12/2025
### Desktop
#### Bug Fixes
- Fixed issues related to the duplicated databases
- Fixed an issue where the Chinese (Simplified) and Chinese (Traditional) language options were not being saved properly in the summary language of the AI meeting block
### Mobile
#### Bug Fixes
- Fixed issues related to the duplicated databases
## Version 0.10.5 – 02/12/2025
### Desktop
#### New Features
- AI Transcript Block: Turn audio and video recordings into ready-to-share notes with AI Transcribe. Easily use our rich-text editing features to refine both the AI transcription and summary. Perfect for work, school, or personal projects
- PDF Block: Upload or embed PDF files, then view and interact with them directly inside your document
- Improved Image Block: Supported new features such as captions, embedded external or page links, saving images, and replacing images with more polished visuals and interactions
- Self-hosted: Enabled custom namespaces, fixed issues with inviting members via link, and removed Pro plan banners that were intended for the managed cloud version
### Mobile
#### New Features
- PDF Block: Upload or embed PDF files, then view and interact with them directly inside your document
- Improved Image Block: Supported new features such as captions, embedded external or page links, saving images, and replacing images—with more polished visuals and interactions
- Self-hosted: Fixed issues with inviting members via link, and removed Pro plan banners that were intended for the managed cloud version
## Version 0.10.4 – 14/11/2025
### Desktop
#### New Features
- Revamped the Math Equation block with an easier-to-use UX and new features: multiline equations, more environment variables, inline math equation markdown support, and Ask AI
- Revamped the Toggle List block with an improved UI and bug fixes
- Revamped the right-click Context Menu with a more polished UI and new features: Paste as plain text and Ask AI
#### Bug Fixes
- Fixed issues related to the Math Equation and Toggle List blocks
- Fixed issues related to the right-click Context Menu
### Mobile
#### New Features
- Revamped the Math Equation and Toggle List blocks with improved UIs and new features
#### Bug Fixes
- Fixed issues related to the Math Equation and Toggle List blocks
## Version 0.10.3 – 31/10/2025
### Desktop
#### New Features
- Revamped the Code block with new features: pin language, caption, Ask AI, and Turn Text into Code
- Revamped the Callout block with an improved UI and quick styles
- Revamped the Outline block with a more polished UI and custom background colors
- Notification Settings: enable or disable mobile push notifications
#### Bug Fixes
- Fixed issues related to the Code and Callout blocks
### Mobile
#### New Features
- Supported mobile push notifications: you'll receive a push message when you're mentioned, invited to a page, or when page permissions change
- Revamped the Code, Callout, and Outline blocks with improved UIs and new features
## Version 0.10.2 – 24/10/2025
### Desktop
#### Bug Fixes
- Fixed inability to import Notion zip file
## Version 0.10.1 – 16/10/2025
### Desktop
#### Bug Fixes
- Fixed potential login token expired issue
- Fixed inability to create Vault workspace on self-hosted servers with commercial plans
- Fixed switching page rapidly caused the page flash
### Mobile
#### Bug Fixes
- Fixed potential login token expired issue
## Version 0.10.0 – 29/09/2025
### Desktop
#### New Features
- Person, a new database property, lets you assign a row to one or more collaborators
### Mobile
#### New Features
- Person, a new database property, lets you assign a row to one or more collaborators
- iOS Share to AppFlowy: quickly save a web page with optional notes to a target AppFlowy page or database
- Android Share to AppFlowy: quickly save a web page with optional notes to a target AppFlowy page or database
- A new Android widget: Quick Page Access
## Version 0.9.9 – 11/09/2025
### Desktop
#### New Features
- Workspace user profile: customize your avatar, about me, and profile card banner
### Mobile
#### New Features
- Workspace user profile: customize your avatar, about me, and profile card banner
- Two new Android widgets: Recent Pages and Favorites
- A new iOS widget: Quick Page Access
- iOS Share to AppFlowy: quickly save browser page links with optional notes to a target AppFlowy page
## Version 0.9.8 – 28/08/2025
### Desktop
#### New Features
- Bulk add collaborators with Can view / Can edit permissions via the Share menu
- Enable setting the start of the week to Monday to control default calendar layouts in the calendar view and date picker
- Sync date and time formats, language, and week start day across devices under the same user account
- Offer 10 premium Select-option colors to Pro Plan users
- Support managing pending members via Settings → Members
### Mobile
#### New Features
- Support iOS widgets for quick access to recent and favorite pages
- Offer 10 premium Select option colors to Pro Plan users
- Support managing pending members via Settings → Members
## Version 0.9.7 – 13/08/2025
### Desktop
#### New Features
- Mention or assign persons in documents via '@' or '/'
- Mentioned persons get notified about the mention via email
- More colors are available for database Select options and page covers
- Back up your AppFlowy workspace: Export your workspace as a ZIP file and import it back at any time
- GPT-5 is now available in AppFlowy
### Mobile
#### New Features
- Mention or assign persons in documents via '@' or '/'
- Mentioned persons get notified about the mention via email
- More colors are available for database Select options and page cover
#### Bug Fixes
- Fixed login page UI overflow issues on small screen devices
## Version 0.9.6 – 05/08/2025
### Bug Fixes
- Fixed some syncing issues
- Improved the Share menu
- Fixed some UI issues related to database filters and sorts
## Version 0.9.5 – 17/07/2025
### Desktop
#### New Features
- Vault Workspace: A new workspace type, private and offline. AI runs locally with no data transfer. Supports switching embedding models, chatting with files (PDF, Markdown, TXT). Includes RAG search with AI-generated overviews.
- Revamped color pickers in documents: Expanded palette with support for custom colors
### Mobile
#### New Features
- iOS In‑App Sign‑In: Sign in directly within the iOS app
- New colors: Improved text and background color options
Bug Fixes
- Add a network connection indicator
- Fix sync bugs and issues with WebSocket connections.
## Version 0.9.4 – 02/07/2025
### Desktop
#### New Features
- Private page sharing: Add members to private pages with Can View or Can Edit permissions
- Guest editor collaboration: Invite non-members (guest editors) to collaborate in real-time on your pages
- Shared with me: Browse all pages shared with you under the new Shared with me section
- New syncing protocol: Optimized for faster, more reliable multi-user and multi-device data sync
### Mobile
#### New Features
- Shared page collaboration: View and edit pages that have been shared with you on iOS and Android
- New syncing protocol: Optimized for faster, more reliable multi-user and multi-device data sync
## Version 0.9.3 - 28/05/2025
### Desktop
#### New Features
- Meet AppFlowy Workspace AI Search: Quickly find pages by searching titles, keywords, or asking natural-language questions
- AI Overviews: Ask natural questions and receive instant AI-generated summaries with source links, inspired by Google's AI Overviews
- Revamped Search Panel: A cleaner, smarter interface to help you search faster and more effectively
- Custom Prompts: Load a database page as the source for your own custom AI prompts
#### Bug Fixes
- Fixed misalignment in database view after setting maxDocumentWidth
- Centered embedded link when the site name is empty
- Fixed issue where row observer was not clearing as expected
- Fixed issue where workspace name reverted after being updated
- Aligned checkbox icon with the first line of text
### Mobile
#### New Features
- Meet AppFlowy Workspace AI Search: Quickly find pages by searching titles, keywords, or asking natural-language questions
- AI Overviews: Ask natural questions and receive instant AI-generated summaries with source links, inspired by Google's AI Overviews
- Revamped Search Tab: A redesigned interface that helps you find what you need more efficiently
#### Bug Fixes
- Fixed issue where font size reset after restarting the app
## Version 0.9.2 - 14/05/2025
### Desktop
#### New Features
- Supported AI Overview in Search to answer user queries based on their entire workspace
- Revamped the Search panel in the desktop app
- Enabled loading custom prompts from an AppFlowy database page
#### Bug Fixes
- Improved inserting emojis using the colon (:)
- Supported automatically filling the link name with the URL if the name is left empty
### Mobile
#### Bug Fixes
- Supported automatically filling the link name with the URL if the name is left empty
## Version 0.9.1 - 01/05/2025
### Desktop
#### New Features
@@ -201,7 +552,7 @@
- Fixed an error when opening files in the database in local mode
- Fixed arrow up/down navigation not working for selecting a language in Code Block
- Fixed an issue where deleting multiple blocks using the drag button on the document page didn’t work
## Version 0.7.7 - 09/12/2024
### Bug Fixes
- Fixed sidebar menu resize regression
+1 -1
View File
@@ -48,7 +48,7 @@ AppFlowy is the AI workspace where you achieve more without losing control of yo
- [App Store](https://apps.apple.com/app/appflowy/id6457261352): iPhone
- [Play Store](https://play.google.com/store/apps/details?id=io.appflowy.appflowy): Android 10 or above; ARMv7 is
not supported
- [Self-hosting AppFlowy](https://appflowy.com/docs/self-host-appflowy-overview)
- [Self-hosting AppFlowy](https://appflowy.com/docs/Step-by-step-Self-Hosting-Guide---From-Zero-to-Production)
- [Source](https://docs.appflowy.io/docs/documentation/appflowy/from-source)
## Built With
+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.9.2"
APPFLOWY_VERSION = "0.11.4"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"
@@ -32,7 +32,7 @@ if (keystorePropertiesFile.exists()) {
}
android {
compileSdkVersion 34
compileSdkVersion 35
ndkVersion "24.0.8215888"
compileOptions {
@@ -6,7 +6,7 @@ buildscript {
}
dependencies {
classpath 'com.android.tools.build:gradle:7.4.2'
classpath 'com.android.tools.build:gradle:8.1.0'
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
@@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip
distributionUrl=https\://services.gradle.org/distributions/gradle-8.1-all.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
@@ -8,7 +8,7 @@
"{""data"":""Juan"",""last_modified"":1726063423,""created_at"":1726063423,""field_type"":0}","{""created_at"":1726122510,""reminder_id"":"""",""include_time"":false,""is_range"":true,""last_modified"":1726122515,""data"":""1725604115"",""end_timestamp"":""1725776915"",""field_type"":2}","{""field_type"":1,""created_at"":1726063677,""last_modified"":1726063677,""data"":""93100""}","{""field_type"":1,""data"":""4.86"",""created_at"":1726062597,""last_modified"":1726062597}",,"{""last_modified"":1726062377,""field_type"":5,""data"":""Yes"",""created_at"":1726062377}","{""last_modified"":1726064412,""field_type"":7,""data"":""{\""options\"":[{\""id\"":\""tTDq\"",\""name\"":\""complete onboarding\"",\""color\"":\""Purple\""},{\""id\"":\""E8Ds\"",\""name\"":\""contact support\"",\""color\"":\""Purple\""},{\""id\"":\""RoGN\"",\""name\"":\""get started\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""tTDq\"",\""E8Ds\""]}"",""created_at"":1726064396}",,"{""created_at"":1726065278,""field_type"":3,""data"":""qnja"",""last_modified"":1726065278}","{""data"":""R9I7,yORP,1i4f"",""field_type"":4,""created_at"":1726105126,""last_modified"":1726105127}","{""data"":""1726122515"",""field_type"":8}","{""data"":""1726060541"",""field_type"":9}"
"{""data"":""Alex"",""created_at"":1726063432,""last_modified"":1726063432,""field_type"":0}","{""reminder_id"":"""",""data"":""1725292800"",""include_time"":true,""last_modified"":1726122448,""created_at"":1726122422,""is_range"":true,""end_timestamp"":""1725551940"",""field_type"":2}","{""field_type"":1,""last_modified"":1726063683,""created_at"":1726063683,""data"":""3560""}","{""created_at"":1726062561,""data"":""1.96"",""last_modified"":1726062561,""field_type"":1}","{""last_modified"":1726063952,""created_at"":1726063931,""data"":""al3x1343@protonmail.com"",""field_type"":6}","{""last_modified"":1726062375,""field_type"":5,""created_at"":1726062375,""data"":""Yes""}","{""data"":""{\""options\"":[{\""id\"":\""qNyr\"",\""name\"":\""finish reading book\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[]}"",""created_at"":1726064616,""last_modified"":1726064616,""field_type"":7}",,"{""data"":""qnja"",""created_at"":1726065272,""last_modified"":1726065272,""field_type"":3}","{""created_at"":1726105180,""last_modified"":1726105180,""field_type"":4,""data"":""R9I7,1i4f""}","{""field_type"":8,""data"":""1726122448""}","{""field_type"":9,""data"":""1726060541""}"
"{""last_modified"":1726063478,""created_at"":1726063436,""field_type"":0,""data"":""Alexander""}",,"{""field_type"":1,""last_modified"":1726063691,""created_at"":1726063691,""data"":""2073""}","{""field_type"":1,""data"":""0.5"",""last_modified"":1726062577,""created_at"":1726062577}","{""last_modified"":1726063991,""field_type"":6,""created_at"":1726063991,""data"":""alexandernotthedra@gmail.com""}","{""field_type"":5,""last_modified"":1726062378,""created_at"":1726062377,""data"":""No""}",,,"{""created_at"":1726065291,""data"":""GSf_"",""last_modified"":1726065291,""field_type"":3}","{""last_modified"":1726105142,""created_at"":1726105133,""data"":""SEUo"",""field_type"":4}","{""field_type"":8,""data"":""1726105142""}","{""field_type"":9,""data"":""1726060542""}"
"{""field_type"":0,""created_at"":1726063454,""last_modified"":1726063454,""data"":""George""}","{""created_at"":1726122467,""end_timestamp"":""1726468070"",""include_time"":false,""is_range"":true,""reminder_id"":"""",""field_type"":2,""data"":""1726295270"",""last_modified"":1726122470}",,,"{""field_type"":6,""data"":""george.aq@appflowy.io"",""last_modified"":1726064104,""created_at"":1726064016}","{""last_modified"":1726062376,""created_at"":1726062376,""field_type"":5,""data"":""Yes""}","{""data"":""{\""options\"":[{\""id\"":\""s_dQ\"",\""name\"":\""bug triage\"",\""color\"":\""Purple\""},{\""id\"":\""-Zfo\"",\""name\"":\""fix bugs\"",\""color\"":\""Purple\""},{\""id\"":\""wsDN\"",\""name\"":\""attend meetings\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""s_dQ\"",\""-Zfo\""]}"",""last_modified"":1726064468,""created_at"":1726064424,""field_type"":7}","{""data"":[""{\""id\"":\""8a77f84d-64e9-4e67-b902-fa23980459ec\"",\""name\"":\""BQdTmxpRI6f.png\"",\""url\"":\""https://file-examples.com/wp-content/storage/2017/10/file_example_PNG_500kB.png\"",\""upload_type\"":\""NetworkMedia\"",\""file_type\"":\""Image\""}""],""field_type"":14,""created_at"":1726122956,""last_modified"":1726122956}","{""field_type"":3,""data"":""qnja"",""created_at"":1726065313,""last_modified"":1726065313}","{""data"":""R9I7,yORP"",""field_type"":4,""last_modified"":1726105198,""created_at"":1726105187}","{""data"":""1726122956"",""field_type"":8}","{""data"":""1726060543"",""field_type"":9}"
"{""field_type"":0,""created_at"":1726063454,""last_modified"":1726063454,""data"":""George""}","{""created_at"":1726122467,""end_timestamp"":""1726468070"",""include_time"":false,""is_range"":true,""reminder_id"":"""",""field_type"":2,""data"":""1726295270"",""last_modified"":1726122470}",,,"{""field_type"":6,""data"":""george.aq@appflowy.io"",""last_modified"":1726064104,""created_at"":1726064016}","{""last_modified"":1726062376,""created_at"":1726062376,""field_type"":5,""data"":""Yes""}","{""data"":""{\""options\"":[{\""id\"":\""s_dQ\"",\""name\"":\""bug triage\"",\""color\"":\""Purple\""},{\""id\"":\""-Zfo\"",\""name\"":\""fix bugs\"",\""color\"":\""Purple\""},{\""id\"":\""wsDN\"",\""name\"":\""attend meetings\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""s_dQ\"",\""-Zfo\""]}"",""last_modified"":1726064468,""created_at"":1726064424,""field_type"":7}","{""data"":[""{\""id\"":\""8a77f84d-64e9-4e67-b902-fa23980459ec\"",\""name\"":\""BQdTmxpRI6f.png\"",\""url\"":\""https://samplelib.com/lib/preview/png/sample-blue-200x200.png\"",\""upload_type\"":\""NetworkMedia\"",\""file_type\"":\""Image\""}""],""field_type"":14,""created_at"":1726122956,""last_modified"":1726122956}","{""field_type"":3,""data"":""qnja"",""created_at"":1726065313,""last_modified"":1726065313}","{""data"":""R9I7,yORP"",""field_type"":4,""last_modified"":1726105198,""created_at"":1726105187}","{""data"":""1726122956"",""field_type"":8}","{""data"":""1726060543"",""field_type"":9}"
"{""field_type"":0,""last_modified"":1726063467,""data"":""Joanna"",""created_at"":1726063467}","{""include_time"":false,""end_timestamp"":""1727072893"",""is_range"":true,""last_modified"":1726122493,""created_at"":1726122483,""data"":""1726554493"",""field_type"":2,""reminder_id"":""""}","{""last_modified"":1726065463,""data"":""16470"",""field_type"":1,""created_at"":1726065463}","{""created_at"":1726062626,""field_type"":1,""last_modified"":1726062626,""data"":""-5.36""}","{""last_modified"":1726064069,""data"":""joannastrawberry29+hello@gmail.com"",""created_at"":1726064069,""field_type"":6}",,"{""field_type"":7,""created_at"":1726064444,""last_modified"":1726064460,""data"":""{\""options\"":[{\""id\"":\""ZxJz\"",\""name\"":\""post on Twitter\"",\""color\"":\""Purple\""},{\""id\"":\""upwi\"",\""name\"":\""watch Youtube videos\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""upwi\""]}""}",,"{""created_at"":1726065317,""last_modified"":1726065317,""field_type"":3,""data"":""qnja""}","{""field_type"":4,""last_modified"":1726105173,""data"":""uRAO,yORP"",""created_at"":1726105170}","{""data"":""1726122493"",""field_type"":8}","{""data"":""1726060545"",""field_type"":9}"
"{""last_modified"":1726063457,""created_at"":1726063457,""data"":""George"",""field_type"":0}","{""include_time"":true,""reminder_id"":"""",""field_type"":2,""is_range"":true,""created_at"":1726122521,""end_timestamp"":""1725829200"",""data"":""1725822900"",""last_modified"":1726122535}","{""last_modified"":1726065493,""field_type"":1,""data"":""9500"",""created_at"":1726065493}","{""last_modified"":1726062680,""created_at"":1726062680,""field_type"":1,""data"":""1.7""}","{""data"":""plgeorgebball@gmail.com"",""field_type"":6,""last_modified"":1726064087,""created_at"":1726064036}",,"{""last_modified"":1726064513,""data"":""{\""options\"":[{\""id\"":\""zy0x\"",\""name\"":\""game vs celtics\"",\""color\"":\""Purple\""},{\""id\"":\""WJsv\"",\""name\"":\""training\"",\""color\"":\""Purple\""},{\""id\"":\""w-f8\"",\""name\"":\""game vs spurs\"",\""color\"":\""Purple\""},{\""id\"":\""p1VQ\"",\""name\"":\""game vs knicks\"",\""color\"":\""Purple\""},{\""id\"":\""VjUA\"",\""name\"":\""recovery\"",\""color\"":\""Purple\""},{\""id\"":\""sQ8X\"",\""name\"":\""don't get injured\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[]}"",""created_at"":1726064486,""field_type"":7}",,"{""field_type"":3,""last_modified"":1726065310,""data"":""qnja"",""created_at"":1726065310}","{""created_at"":1726105205,""field_type"":4,""last_modified"":1726105249,""data"":""R9I7,1i4f,yORP,SEUo""}","{""data"":""1726122535"",""field_type"":8}","{""field_type"":9,""data"":""1726060546""}"
"{""data"":""Judy"",""created_at"":1726063475,""field_type"":0,""last_modified"":1726063487}","{""end_timestamp"":"""",""reminder_id"":"""",""data"":""1726640950"",""field_type"":2,""include_time"":false,""created_at"":1726122550,""last_modified"":1726122550,""is_range"":false}",,,"{""created_at"":1726063882,""field_type"":6,""last_modified"":1726064000,""data"":""judysmithjr@outlook.com""}","{""last_modified"":1726062712,""field_type"":5,""data"":""Yes"",""created_at"":1726062712}","{""created_at"":1726064549,""field_type"":7,""data"":""{\""options\"":[{\""id\"":\""j8cC\"",\""name\"":\""finish training\"",\""color\"":\""Purple\""},{\""id\"":\""SmSk\"",\""name\"":\""brainwash\"",\""color\"":\""Purple\""},{\""id\"":\""mnf5\"",\""name\"":\""welcome to ba sing se\"",\""color\"":\""Purple\""},{\""id\"":\""hcrj\"",\""name\"":\""don't mess up\"",\""color\"":\""Purple\""}],\""selected_option_ids\"":[\""j8cC\"",\""SmSk\"",\""mnf5\"",\""hcrj\""]}"",""last_modified"":1726064591}",,,"{""field_type"":4,""last_modified"":1726105152,""created_at"":1726105152,""data"":""R9I7""}","{""field_type"":8,""data"":""1726122550""}","{""field_type"":9,""data"":""1726060549""}"
@@ -1,10 +1,10 @@
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy/workspace/presentation/widgets/dialog_v2.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text_input.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -38,12 +38,12 @@ void main() {
);
await tester.pumpAndSettle();
expect(find.byType(NavigatorTextFieldDialog), findsOneWidget);
expect(find.byType(AFTextFieldDialog), findsOneWidget);
final textField = tester.widget<FlowyFormTextInput>(
final textField = tester.widget<AFTextField>(
find.descendant(
of: find.byType(NavigatorTextFieldDialog),
matching: find.byType(FlowyFormTextInput),
of: find.byType(AFTextFieldDialog),
matching: find.byType(AFTextField),
),
);
@@ -34,7 +34,7 @@ void main() {
var workspaceIcon = tester.widget<WorkspaceIcon>(
find.byType(WorkspaceIcon),
);
expect(workspaceIcon.workspace.icon, '');
expect(workspaceIcon.workspaceIcon, '');
await tester.openWorkspaceMenu();
await tester.changeWorkspaceIcon(icon);
@@ -47,8 +47,8 @@ void main() {
workspaceIcon = tester.widget<WorkspaceIcon>(
find.byType(WorkspaceIcon),
);
expect(workspaceIcon.workspace.icon, icon);
expect(find.findTextInFlowyText(name), findsOneWidget);
expect(workspaceIcon.workspaceIcon, icon);
expect(workspaceIcon.workspaceName, name);
});
testWidgets('verify the result again after relaunching', (tester) async {
@@ -69,7 +69,7 @@ void main() {
final workspaceIcon = tester.widget<WorkspaceIcon>(
find.byType(WorkspaceIcon),
);
expect(workspaceIcon.workspace.icon, icon);
expect(workspaceIcon.workspace.name, name);
expect(workspaceIcon.workspaceIcon, icon);
expect(workspaceIcon.workspaceName, name);
});
}
@@ -36,23 +36,20 @@ void main() {
final loading = find.byType(Loading);
await tester.pumpUntilNotFound(loading);
Finder success;
final Finder items = find.byType(WorkspaceMenuItem);
// delete the newly created workspace
await tester.openCollaborativeWorkspaceMenu();
await tester.pumpUntilFound(items);
final items = find.byType(WorkspaceMenuItem);
expect(items, findsNWidgets(2));
final lastWorkspace = items.last;
expect(
tester.widget<WorkspaceMenuItem>(items.last).workspace.name,
tester.widget<WorkspaceMenuItem>(lastWorkspace).workspace.name,
name,
);
final secondWorkspace = find.byType(WorkspaceMenuItem).last;
await tester.hoverOnWidget(
secondWorkspace,
lastWorkspace,
onHover: () async {
// click the more button
final moreButton = find.byType(WorkspaceMoreActionList);
@@ -68,10 +65,8 @@ void main() {
expect(confirm, findsOneWidget);
await tester.tapButton(find.text(LocaleKeys.button_ok.tr()));
// delete success
success = find.text(LocaleKeys.workspace_createSuccess.tr());
final success = find.text(LocaleKeys.workspace_createSuccess.tr());
await tester.pumpUntilFound(success);
expect(success, findsOneWidget);
await tester.pumpUntilNotFound(success);
},
);
});
@@ -100,7 +95,7 @@ void main() {
// expect to see the member count
final memberCount = find.text('1 member');
expect(memberCount, findsNWidgets(2));
expect(memberCount, findsAny);
});
testWidgets('workspace menu popover behavior test', (tester) async {
@@ -5,12 +5,13 @@ import 'collaborative_workspace_test.dart' as collaborative_workspace_test;
import 'share_menu_test.dart' as share_menu_test;
import 'tabs_test.dart' as tabs_test;
import 'workspace_icon_test.dart' as workspace_icon_test;
import 'workspace_settings_test.dart' as workspace_settings_test;
// import 'workspace_settings_test.dart' as workspace_settings_test;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
workspace_settings_test.main();
// Enable it after implementing the share feature
// workspace_settings_test.main();
share_menu_test.main();
collaborative_workspace_test.main();
change_name_and_icon_test.main();
@@ -168,6 +168,38 @@ void main() {
await tester.pumpAndSettle();
});
testWidgets('clear cells under field', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
await tester.scrollToRight(find.byType(GridPage));
// edit the data
await tester.editCell(
rowIndex: 0,
fieldType: FieldType.RichText,
input: 'Hello',
);
await tester.editCell(
rowIndex: 1,
fieldType: FieldType.RichText,
input: 'World',
);
expect(find.text('Hello'), findsOneWidget);
expect(find.text('World'), findsOneWidget);
// clear the cells
await tester.tapGridFieldWithName('Name');
await tester.tapClearCellsButton();
await tester.tapButtonWithName(LocaleKeys.button_confirm.tr());
expect(find.text('Hello'), findsNothing);
expect(find.text('World'), findsNothing);
});
testWidgets('create list of fields', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
@@ -7,7 +7,7 @@ import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('create and delete the document', () {
group('create and delete the document:', () {
testWidgets('create a new document when launching app in first time',
(tester) async {
await tester.initializeAppFlowy();
@@ -7,6 +7,7 @@ import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
@@ -521,8 +522,8 @@ extension _SubPageTestHelper on WidgetTester {
await hoverOnPageName(currentName, onHover: () async => pumpAndSettle());
await rightClickOnPageName(currentName);
await tapButtonWithName(ViewMoreActionType.rename.name);
await enterText(find.byType(TextFormField), newName);
await tapOKButton();
await enterText(find.byType(AFTextField), newName);
await tapButton(find.text(LocaleKeys.button_confirm.tr()));
await pumpAndSettle();
}
}
@@ -408,7 +408,8 @@ void main() {
expect(getNodeText(node), link);
});
testWidgets('insert link and clear link name and remove link', (tester) async {
testWidgets('insert link and clear link name and remove link',
(tester) async {
const text = 'edit link', link = 'https://test.appflowy.cloud';
await prepareForToolbar(tester, text);
@@ -56,6 +56,9 @@ void main() {
await tester.pumpAndSettle();
currentScaleFactor += 0.1;
currentScaleFactor = double.parse(
currentScaleFactor.toStringAsFixed(2),
);
final scaleFactor = await windowSizeManager.getScaleFactor();
expect(currentScaleFactor, appflowyScaleFactor);
@@ -1,6 +1,9 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/home/home.dart';
import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart';
import 'package:appflowy/mobile/presentation/search/mobile_search_ask_ai_entrance.dart';
import 'package:appflowy/mobile/presentation/search/mobile_search_page.dart';
import 'package:appflowy/mobile/presentation/search/mobile_search_result.dart';
import 'package:appflowy/mobile/presentation/search/mobile_search_textfield.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -56,4 +59,35 @@ void main() {
expect(cancelButton, findsNothing);
});
});
testWidgets('tap to search page and back to home', (tester) async {
await tester.launchInAnonymousMode();
/// go to search page
final searchButton = find.byFlowySvg(FlowySvgs.m_home_search_icon_m);
expect(find.byType(MobileSearchScreen), findsNothing);
await tester.tapButton(searchButton);
expect(find.byType(MobileSearchScreen), findsOneWidget);
/// back to home page
final backButton = find.byFlowySvg(FlowySvgs.search_page_arrow_left_m);
expect(backButton, findsOneWidget);
expect(find.byType(MobileHomeScreen), findsNothing);
await tester.tapButton(backButton);
expect(find.byType(MobileHomeScreen), findsOneWidget);
/// go to notification page
final notificationButton = find.byFlowySvg(FlowySvgs.m_home_notification_m);
expect(find.byType(MobileNotificationsScreenV2), findsNothing);
await tester.tapButton(notificationButton);
expect(find.byType(MobileNotificationsScreenV2), findsOneWidget);
/// go to search page
await tester.tapButton(searchButton);
expect(find.byType(MobileNotificationsScreenV2), findsNothing);
expect(find.byType(MobileSearchScreen), findsOneWidget);
await tester.tapButton(backButton);
expect(find.byType(MobileNotificationsScreenV2), findsOneWidget);
expect(find.byType(MobileSearchScreen), findsNothing);
});
}
@@ -38,11 +38,13 @@ import 'package:appflowy/workspace/presentation/notifications/widgets/notificati
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/dialog_v2.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';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/buttons/primary_button.dart';
import 'package:flutter/foundation.dart';
@@ -264,8 +266,8 @@ extension CommonOperations on WidgetTester {
/// Rename the page.
Future<void> renamePage(String name) async {
await tapRenamePageButton();
await enterText(find.byType(TextFormField), name);
await tapOKButton();
await enterText(find.byType(AFTextField), name);
await tapButton(find.text(LocaleKeys.button_confirm.tr()));
}
Future<void> tapTrashButton() async {
@@ -359,7 +361,7 @@ extension CommonOperations on WidgetTester {
);
final showRenameDialog = settingsOrFailure ?? false;
if (showRenameDialog) {
await tapOKButton();
await tapButton(find.text(LocaleKeys.button_confirm.tr()));
}
await pumpAndSettle();
@@ -736,8 +738,7 @@ extension CommonOperations on WidgetTester {
final workspace = find.byType(SidebarWorkspace);
expect(workspace, findsOneWidget);
await tapButton(workspace, pumpAndSettle: false);
await pump(const Duration(seconds: 5));
await tapButton(workspace, milliseconds: 5000);
}
Future<void> createCollaborativeWorkspace(String name) async {
@@ -752,22 +753,20 @@ extension CommonOperations on WidgetTester {
// click the create button
final createButton = find.byKey(createWorkspaceButtonKey);
expect(createButton, findsOneWidget);
await tapButton(createButton, pumpAndSettle: false);
await pump(const Duration(seconds: 5));
// see the create workspace dialog
final createWorkspaceDialog = find.byType(CreateWorkspaceDialog);
expect(createWorkspaceDialog, findsOneWidget);
await tapButton(createButton);
// input the workspace name
final workspaceNameInput = find.descendant(
of: createWorkspaceDialog,
of: find.byType(AFTextFieldDialog),
matching: find.byType(TextField),
);
await enterText(workspaceNameInput, name);
await pumpAndSettle();
await tapButtonWithName(LocaleKeys.button_ok.tr(), pumpAndSettle: false);
await pump(const Duration(seconds: 5));
await tapButton(
find.text(LocaleKeys.button_confirm.tr()),
milliseconds: 2000,
);
}
// For mobile platform to launch the app in anonymous mode
@@ -76,15 +76,16 @@ import 'package:appflowy/util/field_type_extension.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/clear_date_button.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/date_type_option_button.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/widgets/reminder_selector.dart';
import 'package:appflowy/workspace/presentation/widgets/dialog_v2.dart';
import 'package:appflowy/workspace/presentation/widgets/pop_up_action.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_board/appflowy_board.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:calendar_view/calendar_view.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/text_input.dart';
import 'package:flowy_infra_ui/widget/buttons/primary_button.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
@@ -836,6 +837,14 @@ extension AppFlowyDatabaseTest on WidgetTester {
await tapButton(button);
}
Future<void> tapClearCellsButton() async {
final field = find.byWidgetPredicate(
(widget) =>
widget is FieldActionCell && widget.action == FieldAction.clearData,
);
await tapButton(field);
}
Future<void> tapRowDetailPageRowActionButton() async =>
tapButton(find.byType(RowActionButton));
@@ -1638,18 +1647,13 @@ extension AppFlowyDatabaseTest on WidgetTester {
await enterText(
find.descendant(
of: find.byType(FlowyFormTextInput),
matching: find.byType(TextFormField),
of: find.byType(AFTextFieldDialog),
matching: find.byType(AFTextField),
),
name,
);
final field = find.byWidgetPredicate(
(widget) =>
widget is PrimaryTextButton &&
widget.label == LocaleKeys.button_ok.tr(),
);
await tapButton(field);
await tapButton(find.text(LocaleKeys.button_confirm.tr()));
}
Future<void> deleteDatebaseView(Finder linkedView) async {
@@ -98,10 +98,8 @@ extension Expectation on WidgetTester {
/// Expect to the markdown file export success dialog.
void expectToExportSuccess() {
final exportSuccess = find.byWidgetPredicate(
(widget) =>
widget is FlowyText &&
widget.text == LocaleKeys.settings_files_exportFileSuccess.tr(),
final exportSuccess = find.text(
LocaleKeys.settings_files_exportFileSuccess.tr(),
);
expect(exportSuccess, findsOneWidget);
}
@@ -84,6 +84,7 @@ class MockAIRepository extends Mock implements AppFlowyAIService {
String? objectId,
required String text,
PredefinedFormat? format,
String? promptId,
List<String> sourceIds = const [],
List<AiWriterRecord> history = const [],
required CompletionTypePB completionType,
@@ -4,8 +4,9 @@ import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sid
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart';
import 'package:appflowy/workspace/presentation/widgets/dialog_v2.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'util.dart';
@@ -31,26 +32,24 @@ extension AppFlowyWorkspace on WidgetTester {
}
Future<void> changeWorkspaceName(String name) async {
final moreButton = find.descendant(
of: find.byType(WorkspaceMenuItem),
matching: find.byType(WorkspaceMoreActionList),
);
expect(moreButton, findsOneWidget);
final menuItem = find.byType(WorkspaceMenuItem);
expect(menuItem, findsOneWidget);
await hoverOnWidget(
moreButton,
menuItem,
onHover: () async {
await tapButton(moreButton);
// wait for the menu to open
final renameButton = find.findTextInFlowyText(
LocaleKeys.button_rename.tr(),
await tapButton(
find.descendant(
of: menuItem,
matching: find.byType(WorkspaceMoreActionList),
),
);
await tapButton(find.text(LocaleKeys.button_rename.tr()));
final input = find.descendant(
of: find.byType(AFTextFieldDialog),
matching: find.byType(AFTextField),
);
await pumpUntilFound(renameButton);
expect(renameButton, findsOneWidget);
await tapButton(renameButton);
final input = find.byType(TextFormField);
expect(input, findsOneWidget);
await enterText(input, name);
await tapButton(find.text(LocaleKeys.button_ok.tr()));
await tapButton(find.text(LocaleKeys.button_confirm.tr()));
},
);
}
@@ -1,7 +1,11 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/shared/easy_localiation_service.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
@@ -112,75 +116,55 @@ enum TextFormat {
}
enum AiPromptCategory {
@JsonValue("other")
other,
@JsonValue("development")
development,
@JsonValue("writing")
writing,
@JsonValue("healthAndFitness")
healthAndFitness,
@JsonValue("business")
business,
@JsonValue("marketing")
marketing,
@JsonValue("travel")
travel,
@JsonValue("contentSeo")
contentSeo,
@JsonValue("emailMarketing")
emailMarketing,
@JsonValue("paidAds")
paidAds,
@JsonValue("prCommunication")
prCommunication,
@JsonValue("recruiting")
recruiting,
@JsonValue("sales")
sales,
@JsonValue("socialMedia")
socialMedia,
@JsonValue("strategy")
strategy,
@JsonValue("caseStudies")
caseStudies,
@JsonValue("salesCopy")
salesCopy,
@JsonValue("education")
education,
@JsonValue("work")
work,
@JsonValue("podcastProduction")
podcastProduction,
@JsonValue("copyWriting")
copyWriting,
@JsonValue("customerSuccess")
customerSuccess;
String get i18n {
String get i18n => token.tr();
String get token {
return switch (this) {
other => LocaleKeys.ai_customPrompt_others.tr(),
development => LocaleKeys.ai_customPrompt_development.tr(),
writing => LocaleKeys.ai_customPrompt_writing.tr(),
healthAndFitness => LocaleKeys.ai_customPrompt_healthAndFitness.tr(),
business => LocaleKeys.ai_customPrompt_business.tr(),
marketing => LocaleKeys.ai_customPrompt_marketing.tr(),
travel => LocaleKeys.ai_customPrompt_travel.tr(),
contentSeo => LocaleKeys.ai_customPrompt_contentSeo.tr(),
emailMarketing => LocaleKeys.ai_customPrompt_emailMarketing.tr(),
paidAds => LocaleKeys.ai_customPrompt_paidAds.tr(),
prCommunication => LocaleKeys.ai_customPrompt_prCommunication.tr(),
recruiting => LocaleKeys.ai_customPrompt_recruiting.tr(),
sales => LocaleKeys.ai_customPrompt_sales.tr(),
socialMedia => LocaleKeys.ai_customPrompt_socialMedia.tr(),
strategy => LocaleKeys.ai_customPrompt_strategy.tr(),
caseStudies => LocaleKeys.ai_customPrompt_caseStudies.tr(),
salesCopy => LocaleKeys.ai_customPrompt_salesCopy.tr(),
education => LocaleKeys.ai_customPrompt_education.tr(),
work => LocaleKeys.ai_customPrompt_work.tr(),
podcastProduction => LocaleKeys.ai_customPrompt_podcastProduction.tr(),
copyWriting => LocaleKeys.ai_customPrompt_copyWriting.tr(),
customerSuccess => LocaleKeys.ai_customPrompt_customerSuccess.tr(),
other => LocaleKeys.ai_customPrompt_others,
development => LocaleKeys.ai_customPrompt_development,
writing => LocaleKeys.ai_customPrompt_writing,
healthAndFitness => LocaleKeys.ai_customPrompt_healthAndFitness,
business => LocaleKeys.ai_customPrompt_business,
marketing => LocaleKeys.ai_customPrompt_marketing,
travel => LocaleKeys.ai_customPrompt_travel,
contentSeo => LocaleKeys.ai_customPrompt_contentSeo,
emailMarketing => LocaleKeys.ai_customPrompt_emailMarketing,
paidAds => LocaleKeys.ai_customPrompt_paidAds,
prCommunication => LocaleKeys.ai_customPrompt_prCommunication,
recruiting => LocaleKeys.ai_customPrompt_recruiting,
sales => LocaleKeys.ai_customPrompt_sales,
socialMedia => LocaleKeys.ai_customPrompt_socialMedia,
strategy => LocaleKeys.ai_customPrompt_strategy,
caseStudies => LocaleKeys.ai_customPrompt_caseStudies,
salesCopy => LocaleKeys.ai_customPrompt_salesCopy,
education => LocaleKeys.ai_customPrompt_education,
work => LocaleKeys.ai_customPrompt_work,
podcastProduction => LocaleKeys.ai_customPrompt_podcastProduction,
copyWriting => LocaleKeys.ai_customPrompt_copyWriting,
customerSuccess => LocaleKeys.ai_customPrompt_customerSuccess,
};
}
}
@@ -198,13 +182,28 @@ class AiPrompt extends Equatable {
});
factory AiPrompt.fromPB(CustomPromptPB pb) {
final map = _buildCategoryNameMap();
final categories = pb.category
.split(',')
.map((categoryName) => categoryName.trim())
.map(
(categoryName) {
final entry = map.entries.firstWhereOrNull(
(entry) =>
entry.value.$1 == categoryName ||
entry.value.$2 == categoryName,
);
return entry?.key ?? AiPromptCategory.other;
},
)
.toSet()
.toList();
return AiPrompt(
id: pb.id,
name: pb.name,
content: pb.content,
category: AiPromptCategory.values.firstWhere(
(e) => e.name == pb.category,
),
category: categories,
example: pb.example,
isFeatured: false,
isCustom: true,
@@ -219,11 +218,8 @@ class AiPrompt extends Equatable {
final String id;
final String name;
final String content;
@JsonKey(
unknownEnumValue: AiPromptCategory.other,
defaultValue: AiPromptCategory.other,
)
final AiPromptCategory category;
@JsonKey(fromJson: _categoryFromJson)
final List<AiPromptCategory> category;
@JsonKey(defaultValue: "")
final String example;
@JsonKey(defaultValue: false)
@@ -234,4 +230,157 @@ class AiPrompt extends Equatable {
@override
List<Object?> get props =>
[id, name, content, category, example, isFeatured, isCustom];
static Map<AiPromptCategory, (String, String)> _buildCategoryNameMap() {
final service = getIt<EasyLocalizationService>();
return {
for (final category in AiPromptCategory.values)
category: (
service.getFallbackTranslation(category.token),
service.getFallbackTranslation(category.token),
),
};
}
static List<AiPromptCategory> _categoryFromJson(dynamic json) {
if (json is String) {
return json
.split(',')
.map((categoryName) => categoryName.trim())
.map(
(categoryName) => $enumDecode(
_aiPromptCategoryEnumMap,
categoryName,
unknownValue: AiPromptCategory.other,
),
)
.toSet()
.toList();
}
return [AiPromptCategory.other];
}
}
const _aiPromptCategoryEnumMap = {
AiPromptCategory.other: 'other',
AiPromptCategory.development: 'development',
AiPromptCategory.writing: 'writing',
AiPromptCategory.healthAndFitness: 'healthAndFitness',
AiPromptCategory.business: 'business',
AiPromptCategory.marketing: 'marketing',
AiPromptCategory.travel: 'travel',
AiPromptCategory.contentSeo: 'contentSeo',
AiPromptCategory.emailMarketing: 'emailMarketing',
AiPromptCategory.paidAds: 'paidAds',
AiPromptCategory.prCommunication: 'prCommunication',
AiPromptCategory.recruiting: 'recruiting',
AiPromptCategory.sales: 'sales',
AiPromptCategory.socialMedia: 'socialMedia',
AiPromptCategory.strategy: 'strategy',
AiPromptCategory.caseStudies: 'caseStudies',
AiPromptCategory.salesCopy: 'salesCopy',
AiPromptCategory.education: 'education',
AiPromptCategory.work: 'work',
AiPromptCategory.podcastProduction: 'podcastProduction',
AiPromptCategory.copyWriting: 'copyWriting',
AiPromptCategory.customerSuccess: 'customerSuccess',
};
class CustomPromptDatabaseConfig extends Equatable {
const CustomPromptDatabaseConfig({
required this.view,
required this.titleFieldId,
required this.contentFieldId,
required this.exampleFieldId,
required this.categoryFieldId,
});
factory CustomPromptDatabaseConfig.fromAiPB(
CustomPromptDatabaseConfigurationPB pb,
ViewPB view,
) {
final config = CustomPromptDatabaseConfig(
view: view,
titleFieldId: pb.titleFieldId,
contentFieldId: pb.contentFieldId,
exampleFieldId: pb.hasExampleFieldId() ? pb.exampleFieldId : null,
categoryFieldId: pb.hasCategoryFieldId() ? pb.categoryFieldId : null,
);
return config;
}
factory CustomPromptDatabaseConfig.fromDbPB(
CustomPromptDatabaseConfigPB pb,
ViewPB view,
) {
final config = CustomPromptDatabaseConfig(
view: view,
titleFieldId: pb.titleFieldId,
contentFieldId: pb.contentFieldId,
exampleFieldId: pb.hasExampleFieldId() ? pb.exampleFieldId : null,
categoryFieldId: pb.hasCategoryFieldId() ? pb.categoryFieldId : null,
);
return config;
}
final ViewPB view;
final String titleFieldId;
final String contentFieldId;
final String? exampleFieldId;
final String? categoryFieldId;
@override
List<Object?> get props =>
[view.id, titleFieldId, contentFieldId, exampleFieldId, categoryFieldId];
CustomPromptDatabaseConfig copyWith({
ViewPB? view,
String? titleFieldId,
String? contentFieldId,
String? exampleFieldId,
String? categoryFieldId,
}) {
return CustomPromptDatabaseConfig(
view: view ?? this.view,
titleFieldId: titleFieldId ?? this.titleFieldId,
contentFieldId: contentFieldId ?? this.contentFieldId,
exampleFieldId: exampleFieldId ?? this.exampleFieldId,
categoryFieldId: categoryFieldId ?? this.categoryFieldId,
);
}
CustomPromptDatabaseConfigurationPB toAiPB() {
final payload = CustomPromptDatabaseConfigurationPB.create()
..viewId = view.id
..titleFieldId = titleFieldId
..contentFieldId = contentFieldId;
if (exampleFieldId != null) {
payload.exampleFieldId = exampleFieldId!;
}
if (categoryFieldId != null) {
payload.categoryFieldId = categoryFieldId!;
}
return payload;
}
CustomPromptDatabaseConfigPB toDbPB() {
final payload = CustomPromptDatabaseConfigPB.create()
..viewId = view.id
..titleFieldId = titleFieldId
..contentFieldId = contentFieldId;
if (exampleFieldId != null) {
payload.exampleFieldId = exampleFieldId!;
}
if (categoryFieldId != null) {
payload.categoryFieldId = categoryFieldId!;
}
return payload;
}
}
@@ -0,0 +1,161 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/plugins/database/domain/field_service.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:bloc/bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'ai_prompt_database_selector_cubit.freezed.dart';
class AiPromptDatabaseSelectorCubit
extends Cubit<AiPromptDatabaseSelectorState> {
AiPromptDatabaseSelectorCubit({
required CustomPromptDatabaseConfig? configuration,
}) : super(AiPromptDatabaseSelectorState.loading()) {
_init(configuration);
}
void _init(CustomPromptDatabaseConfig? config) async {
if (config == null) {
emit(AiPromptDatabaseSelectorState.empty());
return;
}
final fields = await _getFields(config.view.id);
if (fields == null) {
emit(AiPromptDatabaseSelectorState.empty());
return;
}
emit(
AiPromptDatabaseSelectorState.selected(
config: config,
fields: fields,
),
);
}
void selectDatabaseView(String viewId) async {
final configuration = await _testDatabase(viewId);
if (configuration == null) {
final stateCopy = state;
emit(AiPromptDatabaseSelectorState.invalidDatabase());
emit(stateCopy);
return;
}
final databaseView = await AiPromptSelectorCubit.getDatabaseView(viewId);
final fields = await _getFields(viewId);
if (databaseView == null || fields == null) {
final stateCopy = state;
emit(AiPromptDatabaseSelectorState.invalidDatabase());
emit(stateCopy);
return;
}
final config = CustomPromptDatabaseConfig.fromDbPB(
configuration,
databaseView,
);
emit(
AiPromptDatabaseSelectorState.selected(
config: config,
fields: fields,
),
);
}
void selectContentField(String fieldId) {
final state = this.state;
if (state is! _Selected) {
return;
}
final config = state.config.copyWith(
contentFieldId: fieldId,
);
emit(
AiPromptDatabaseSelectorState.selected(
config: config,
fields: state.fields,
),
);
}
void selectExampleField(String? fieldId) {
final state = this.state;
if (state is! _Selected) {
return;
}
final config = CustomPromptDatabaseConfig(
exampleFieldId: fieldId,
view: state.config.view,
titleFieldId: state.config.titleFieldId,
contentFieldId: state.config.contentFieldId,
categoryFieldId: state.config.categoryFieldId,
);
emit(
AiPromptDatabaseSelectorState.selected(
config: config,
fields: state.fields,
),
);
}
void selectCategoryField(String? fieldId) {
final state = this.state;
if (state is! _Selected) {
return;
}
final config = CustomPromptDatabaseConfig(
categoryFieldId: fieldId,
view: state.config.view,
titleFieldId: state.config.titleFieldId,
contentFieldId: state.config.contentFieldId,
exampleFieldId: state.config.exampleFieldId,
);
emit(
AiPromptDatabaseSelectorState.selected(
config: config,
fields: state.fields,
),
);
}
Future<List<FieldPB>?> _getFields(String viewId) {
return FieldBackendService.getFields(viewId: viewId).toNullable();
}
Future<CustomPromptDatabaseConfigPB?> _testDatabase(
String viewId,
) {
return DatabaseEventTestCustomPromptDatabaseConfiguration(
DatabaseViewIdPB(value: viewId),
).send().toNullable();
}
}
@freezed
class AiPromptDatabaseSelectorState with _$AiPromptDatabaseSelectorState {
const factory AiPromptDatabaseSelectorState.loading() = _Loading;
const factory AiPromptDatabaseSelectorState.empty() = _Empty;
const factory AiPromptDatabaseSelectorState.selected({
required CustomPromptDatabaseConfig config,
required List<FieldPB> fields,
}) = _Selected;
const factory AiPromptDatabaseSelectorState.invalidDatabase() =
_InvalidDatabase;
}
@@ -23,6 +23,8 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
final AIModelStateNotifier aiModelStateNotifier;
String? promptId;
@override
Future<void> close() async {
await aiModelStateNotifier.dispose();
@@ -88,7 +90,11 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
),
);
},
updatePromptId: (promptId) {
this.promptId = promptId;
},
clearMetadata: () {
promptId = null;
emit(
state.copyWith(
attachedFiles: [],
@@ -151,6 +157,8 @@ class AIPromptInputEvent with _$AIPromptInputEvent {
const factory AIPromptInputEvent.updateMentionedViews(List<ViewPB> views) =
_UpdateMentionedViews;
const factory AIPromptInputEvent.clearMetadata() = _ClearMetadata;
const factory AIPromptInputEvent.updatePromptId(String promptId) =
_UpdatePromptId;
}
@freezed
@@ -1,12 +1,16 @@
import 'dart:async';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../plugins/trash/application/trash_service.dart';
import 'ai_entities.dart';
import 'appflowy_ai_service.dart';
@@ -44,7 +48,7 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
isCustomPromptSectionSelected: false,
isFeaturedSectionSelected: true,
selectedPromptId: visiblePrompts.firstOrNull?.id,
customPromptDatabaseViewId: null,
databaseConfig: null,
isLoadingCustomPrompts: true,
selectedCategory: null,
favoritePrompts: [],
@@ -61,14 +65,29 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
readyState.copyWith(isLoadingCustomPrompts: true),
);
String? databaseViewId = readyState.customPromptDatabaseViewId;
if (databaseViewId == null) {
final result =
await AIEventGetCustomPromptDatabaseViewId().send().toNullable();
databaseViewId = result?.id;
CustomPromptDatabaseConfig? configuration = readyState.databaseConfig;
if (configuration == null) {
final configResult =
await AIEventGetCustomPromptDatabaseConfiguration()
.send()
.toNullable();
if (configResult != null) {
final view = await getDatabaseView(configResult.viewId);
if (view != null) {
configuration = CustomPromptDatabaseConfig.fromAiPB(
configResult,
view,
);
}
}
} else {
final view = await getDatabaseView(configuration.view.id);
if (view != null) {
configuration = configuration.copyWith(view: view);
}
}
if (databaseViewId == null) {
if (configuration == null) {
emit(
readyState.copyWith(isLoadingCustomPrompts: false),
);
@@ -78,7 +97,7 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
availablePrompts.removeWhere((prompt) => prompt.isCustom);
final customPrompts =
await _aiService.getDatabasePrompts(databaseViewId);
await _aiService.getDatabasePrompts(configuration.toDbPB());
if (customPrompts == null) {
final prompts = availablePrompts.where((prompt) => prompt.isFeatured);
@@ -92,7 +111,7 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
readyState.copyWith(
visiblePrompts: visiblePrompts.toList(),
selectedPromptId: selectedPromptId,
customPromptDatabaseViewId: databaseViewId,
databaseConfig: configuration,
isLoadingCustomPrompts: false,
isFeaturedSectionSelected: true,
isCustomPromptSectionSelected: false,
@@ -112,7 +131,7 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
emit(
readyState.copyWith(
visiblePrompts: visiblePrompts.toList(),
customPromptDatabaseViewId: databaseViewId,
databaseConfig: configuration,
isLoadingCustomPrompts: false,
selectedPromptId: selectedPromptId,
),
@@ -168,7 +187,8 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
ready: (readyState) {
final prompts = category == null
? availablePrompts
: availablePrompts.where((prompt) => prompt.category == category);
: availablePrompts
.where((prompt) => prompt.category.contains(category));
final visiblePrompts = _getFilteredPrompts(prompts);
final selectedPromptId = _getVisibleSelectedPrompt(
@@ -240,58 +260,48 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
);
}
void updateCustomPromptDatabaseViewId(String viewId) async {
final stateCopy = state;
final newState = state.maybeMap(
ready: (readyState) {
return readyState.customPromptDatabaseViewId == viewId
? null
: readyState.copyWith(isLoadingCustomPrompts: true);
},
orElse: () => null,
);
void updateCustomPromptDatabaseConfiguration(
CustomPromptDatabaseConfig configuration,
) async {
state.maybeMap(
ready: (readyState) async {
emit(
readyState.copyWith(isLoadingCustomPrompts: true),
);
if (newState == null) {
return;
}
emit(newState);
final customPrompts =
await _aiService.getDatabasePrompts(configuration.toDbPB());
await Future.delayed(const Duration(seconds: 1));
if (customPrompts == null) {
emit(AiPromptSelectorState.invalidDatabase());
emit(readyState);
return;
}
final customPrompts = await _aiService.getDatabasePrompts(viewId);
if (customPrompts == null) {
emit(AiPromptSelectorState.invalidDatabase());
emit(stateCopy);
availablePrompts
..removeWhere((prompt) => prompt.isCustom)
..addAll(customPrompts);
return;
}
await AIEventSetCustomPromptDatabaseConfiguration(
configuration.toAiPB(),
).send().onFailure(Log.error);
availablePrompts
..removeWhere((prompt) => prompt.isCustom)
..addAll(customPrompts);
await AIEventSetCustomPromptDatabaseViewId(
CustomPromptDatabaseViewIdPB()..id = viewId,
).send().onFailure(Log.error);
emit(
state.maybeMap(
ready: (readyState) {
final prompts = _getPromptsByCategory(readyState);
final visiblePrompts = _getFilteredPrompts(prompts);
final selectedPromptId = _getVisibleSelectedPrompt(
visiblePrompts,
readyState.selectedPromptId,
);
return readyState.copyWith(
final prompts = _getPromptsByCategory(readyState);
final visiblePrompts = _getFilteredPrompts(prompts);
final selectedPromptId = _getVisibleSelectedPrompt(
visiblePrompts,
readyState.selectedPromptId,
);
emit(
readyState.copyWith(
visiblePrompts: visiblePrompts.toList(),
selectedPromptId: selectedPromptId,
customPromptDatabaseViewId: viewId,
databaseConfig: configuration,
isLoadingCustomPrompts: false,
);
},
orElse: () => state,
),
),
);
},
orElse: () => {},
);
}
@@ -329,7 +339,7 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
Iterable<AiPrompt> _getPromptsByCategory(_AiPromptSelectorReadyState state) {
return availablePrompts.where((prompt) {
if (state.selectedCategory != null) {
return prompt.category == state.selectedCategory;
return prompt.category.contains(state.selectedCategory);
}
if (state.isFeaturedSectionSelected) {
return prompt.isFeatured;
@@ -352,6 +362,26 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
return visiblePrompts.firstOrNull?.id;
}
static Future<ViewPB?> getDatabaseView(String viewId) async {
final view = await ViewBackendService.getView(viewId).toNullable();
if (view != null) {
return view;
}
final trashViews = await TrashService().readTrash().toNullable();
final trashedItem =
trashViews?.items.firstWhereOrNull((element) => element.id == viewId);
if (trashedItem == null) {
return null;
}
return ViewPB()
..id = trashedItem.id
..name = trashedItem.name;
}
}
@freezed
@@ -371,7 +401,7 @@ class AiPromptSelectorState with _$AiPromptSelectorState {
required AiPromptCategory? selectedCategory,
required String? selectedPromptId,
required bool isLoadingCustomPrompts,
required String? customPromptDatabaseViewId,
required CustomPromptDatabaseConfig? databaseConfig,
}) = _AiPromptSelectorReadyState;
bool get isLoading => this is _AiPromptSelectorLoadingState;
@@ -8,7 +8,8 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operati
import 'package:appflowy/shared/list_extension.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'
hide CustomPromptDatabaseConfigurationPB;
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -28,6 +29,7 @@ abstract class AIRepository {
String? objectId,
required String text,
PredefinedFormat? format,
String? promptId,
List<String> sourceIds = const [],
List<AiWriterRecord> history = const [],
required CompletionTypePB completionType,
@@ -42,7 +44,9 @@ abstract class AIRepository {
Future<List<AiPrompt>> getBuiltInPrompts();
Future<List<AiPrompt>?> getDatabasePrompts(String databaseViewId);
Future<List<AiPrompt>?> getDatabasePrompts(
CustomPromptDatabaseConfigPB config,
);
void updateFavoritePrompts(List<String> promptIds);
}
@@ -53,6 +57,7 @@ class AppFlowyAIService implements AIRepository {
String? objectId,
required String text,
PredefinedFormat? format,
String? promptId,
List<String> sourceIds = const [],
List<AiWriterRecord> history = const [],
required CompletionTypePB completionType,
@@ -79,6 +84,7 @@ class AppFlowyAIService implements AIRepository {
text: text,
completionType: completionType,
format: format?.toPB(),
promptId: promptId,
streamPort: fixnum.Int64(stream.nativePort),
objectId: objectId ?? '',
ragIds: [
@@ -122,14 +128,11 @@ class AppFlowyAIService implements AIRepository {
@override
Future<List<AiPrompt>?> getDatabasePrompts(
String databaseViewId,
CustomPromptDatabaseConfigPB config,
) async {
return DatabaseEventGetDatabaseCustomPrompts(
DatabaseViewIdPB(value: databaseViewId),
).send().fold(
(databasePromptsPB) {
return databasePromptsPB.items.map(AiPrompt.fromPB).toList();
},
return DatabaseEventGetDatabaseCustomPrompts(config).send().fold(
(databasePromptsPB) =>
databasePromptsPB.items.map(AiPrompt.fromPB).toList(),
(err) {
Log.error(err);
return null;
@@ -68,7 +68,10 @@ class _AiPromptCategoryListState extends State<AiPromptCategoryList> {
static Iterable<AiPromptCategory> get sortedCategories {
final categories = [...AiPromptCategory.values];
categories.sort((a, b) => a.i18n.compareTo(b.i18n));
categories
..sort((a, b) => a.i18n.compareTo(b.i18n))
..remove(AiPromptCategory.other)
..add(AiPromptCategory.other);
return categories;
}
@@ -126,7 +129,7 @@ class AiPromptFeaturedSection extends StatelessWidget {
return theme.fillColorScheme.themeSelect;
}
if (isHovering) {
return theme.fillColorScheme.primaryAlpha5;
return theme.fillColorScheme.contentHover;
}
return Colors.transparent;
},
@@ -176,7 +179,7 @@ class AiPromptCustomPromptSection extends StatelessWidget {
return theme.fillColorScheme.themeSelect;
}
if (isHovering) {
return theme.fillColorScheme.primaryAlpha5;
return theme.fillColorScheme.contentHover;
}
return Colors.transparent;
},
@@ -236,7 +239,7 @@ class AiPromptCategoryItem extends StatelessWidget {
return theme.fillColorScheme.themeSelect;
}
if (isHovering) {
return theme.fillColorScheme.primaryAlpha5;
return theme.fillColorScheme.contentHover;
}
return Colors.transparent;
},
@@ -0,0 +1,626 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/ai/service/ai_prompt_database_selector_cubit.dart';
import 'package:appflowy/features/workspace/logic/workspace_bloc.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.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/view/view_item.dart';
import 'package:appflowy/workspace/presentation/widgets/dialog_v2.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:equatable/equatable.dart';
import 'package:expandable/expandable.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
Future<CustomPromptDatabaseConfig?> changeCustomPromptDatabaseConfig(
BuildContext context, {
CustomPromptDatabaseConfig? config,
}) async {
return showDialog<CustomPromptDatabaseConfig?>(
context: context,
builder: (_) {
return MultiBlocProvider(
providers: [
BlocProvider.value(
value: context.read<UserWorkspaceBloc>(),
),
BlocProvider(
create: (context) => AiPromptDatabaseSelectorCubit(
configuration: config,
),
),
],
child: const AiPromptDatabaseModal(),
);
},
);
}
class AiPromptDatabaseModal extends StatefulWidget {
const AiPromptDatabaseModal({
super.key,
});
@override
State<AiPromptDatabaseModal> createState() => _AiPromptDatabaseModalState();
}
class _AiPromptDatabaseModalState extends State<AiPromptDatabaseModal> {
final expandableController = ExpandableController(initialExpanded: false);
@override
void dispose() {
expandableController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return BlocListener<AiPromptDatabaseSelectorCubit,
AiPromptDatabaseSelectorState>(
listener: (context, state) {
state.maybeMap(
invalidDatabase: (_) {
showSimpleAFDialog(
context: context,
title: LocaleKeys.ai_customPrompt_invalidDatabase.tr(),
content: LocaleKeys.ai_customPrompt_invalidDatabaseHelp.tr(),
primaryAction: (
LocaleKeys.button_ok.tr(),
(context) {},
),
);
},
empty: (_) => expandableController.expanded = false,
selected: (_) => expandableController.expanded = true,
orElse: () {},
);
},
child: AFModal(
constraints: const BoxConstraints(
maxWidth: 450,
maxHeight: 400,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AFModalHeader(
leading: Text(
LocaleKeys.ai_customPrompt_configureDatabase.tr(),
style: theme.textStyle.heading4.prominent(
color: theme.textColorScheme.primary,
),
),
trailing: [
AFGhostButton.normal(
onTap: () => Navigator.of(context).pop(),
padding: EdgeInsets.all(theme.spacing.xs),
builder: (context, isHovering, disabled) {
return Center(
child: FlowySvg(
FlowySvgs.toast_close_s,
size: Size.square(20),
),
);
},
),
],
),
Flexible(
child: AFModalBody(
child: ExpandablePanel(
controller: expandableController,
theme: ExpandableThemeData(
tapBodyToCollapse: false,
hasIcon: false,
tapBodyToExpand: false,
tapHeaderToExpand: false,
),
header: const _Header(),
collapsed: const SizedBox.shrink(),
expanded: const _Expanded(),
),
),
),
AFModalFooter(
trailing: [
AFOutlinedButton.normal(
onTap: () => Navigator.of(context).pop(),
builder: (context, isHovering, disabled) {
return Text(
LocaleKeys.button_cancel.tr(),
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
);
},
),
AFFilledButton.primary(
onTap: () {
final config = context
.read<AiPromptDatabaseSelectorCubit>()
.state
.maybeMap(
selected: (state) => state.config,
orElse: () => null,
);
Navigator.of(context).pop(config);
},
builder: (context, isHovering, disabled) {
return Text(
LocaleKeys.button_done.tr(),
style: theme.textStyle.body.enhanced(
color: theme.textColorScheme.onFill,
),
);
},
),
],
),
],
),
),
);
}
}
class _Header extends StatefulWidget {
const _Header();
@override
State<_Header> createState() => _HeaderState();
}
class _HeaderState extends State<_Header> {
final popoverController = AFPopoverController();
@override
void dispose() {
popoverController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return BlocBuilder<AiPromptDatabaseSelectorCubit,
AiPromptDatabaseSelectorState>(
builder: (context, state) {
bool showNothing = false;
String? viewName;
state.maybeMap(
empty: (_) {
showNothing = false;
viewName = null;
},
selected: (selectedState) {
showNothing = false;
viewName = selectedState.config.view.nameOrDefault;
},
orElse: () {
showNothing = true;
viewName = null;
},
);
if (showNothing) {
return SizedBox.shrink();
}
return Padding(
padding: EdgeInsets.symmetric(
horizontal: theme.spacing.m,
vertical: theme.spacing.xl,
),
child: Row(
spacing: theme.spacing.s,
children: [
Expanded(
child: Text(
LocaleKeys.ai_customPrompt_selectDatabase.tr(),
style: theme.textStyle.body.standard(
color: theme.textColorScheme.secondary,
),
),
),
Expanded(
child: Center(
child: ViewSelector(
viewSelectorCubit: BlocProvider(
create: (context) => ViewSelectorCubit(
getIgnoreViewType: getIgnoreViewType,
),
),
child: BlocSelector<SpaceBloc, SpaceState,
(List<ViewPB>, ViewPB?)>(
selector: (state) => (state.spaces, state.currentSpace),
builder: (context, state) {
return AFPopover(
controller: popoverController,
decoration: BoxDecoration(
color: theme.surfaceColorScheme.primary,
borderRadius:
BorderRadius.circular(theme.borderRadius.l),
border: Border.all(
color: theme.borderColorScheme.primary,
),
boxShadow: theme.shadow.medium,
),
padding: EdgeInsets.zero,
anchor: AFAnchor(
childAlignment: Alignment.topCenter,
overlayAlignment: Alignment.bottomCenter,
offset: Offset(0, theme.spacing.xs),
),
popover: (context) {
return _PopoverContent(
onSelectViewItem: (item) {
context
.read<AiPromptDatabaseSelectorCubit>()
.selectDatabaseView(item.view.id);
popoverController.hide();
},
);
},
child: AFOutlinedButton.normal(
onTap: () {
context
.read<ViewSelectorCubit>()
.refreshSources(state.$1, state.$2);
popoverController.toggle();
},
builder: (context, isHovering, disabled) {
return Row(
mainAxisSize: MainAxisSize.min,
spacing: theme.spacing.s,
children: [
Flexible(
child: Text(
viewName ??
LocaleKeys
.ai_customPrompt_selectDatabase
.tr(),
style: theme.textStyle.body.enhanced(
color: theme.textColorScheme.primary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
FlowySvg(
FlowySvgs.toolbar_arrow_down_m,
color: theme.iconColorScheme.primary,
size: Size(12, 20),
),
],
);
},
),
);
},
),
),
),
),
],
),
);
},
);
}
IgnoreViewType getIgnoreViewType(ViewSelectorItem item) {
final layout = item.view.layout;
if (layout.isDatabaseView) {
return IgnoreViewType.none;
}
if (layout.isDocumentView) {
return hasDatabaseDescendent(item)
? IgnoreViewType.none
: IgnoreViewType.hide;
}
return IgnoreViewType.hide;
}
bool hasDatabaseDescendent(ViewSelectorItem viewSelectorItem) {
final layout = viewSelectorItem.view.layout;
if (layout == ViewLayoutPB.Chat) {
return false;
}
if (layout.isDatabaseView) {
return true;
}
// document may have children
return viewSelectorItem.children.any(
(child) => hasDatabaseDescendent(child),
);
}
}
class _Expanded extends StatelessWidget {
const _Expanded();
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return BlocBuilder<AiPromptDatabaseSelectorCubit,
AiPromptDatabaseSelectorState>(
builder: (context, state) {
return state.maybeMap(
orElse: () => SizedBox.shrink(),
selected: (selectedState) {
return Padding(
padding: EdgeInsets.all(theme.spacing.m),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: theme.spacing.m,
children: [
FieldSelector(
title: LocaleKeys.ai_customPrompt_title.tr(),
currentFieldId: selectedState.config.titleFieldId,
isDisabled: true,
fields: selectedState.fields,
onSelect: (id) {},
),
FieldSelector(
title: LocaleKeys.ai_customPrompt_content.tr(),
currentFieldId: selectedState.config.contentFieldId,
fields: selectedState.fields
.where((f) => f.fieldType == FieldType.RichText)
.toList(),
onSelect: (id) {
if (id != null) {
context
.read<AiPromptDatabaseSelectorCubit>()
.selectContentField(id);
}
},
),
FieldSelector(
title: LocaleKeys.ai_customPrompt_example.tr(),
currentFieldId: selectedState.config.exampleFieldId,
isOptional: true,
fields: selectedState.fields
.where((f) => f.fieldType == FieldType.RichText)
.toList(),
onSelect: (id) {
context
.read<AiPromptDatabaseSelectorCubit>()
.selectExampleField(id);
},
),
FieldSelector(
title: LocaleKeys.ai_customPrompt_category.tr(),
currentFieldId: selectedState.config.categoryFieldId,
isOptional: true,
fields: selectedState.fields
.where(
(f) =>
f.fieldType == FieldType.RichText ||
f.fieldType == FieldType.SingleSelect ||
f.fieldType == FieldType.MultiSelect,
)
.toList(),
onSelect: (id) {
context
.read<AiPromptDatabaseSelectorCubit>()
.selectCategoryField(id);
},
),
],
),
);
},
);
},
);
}
}
class _PopoverContent extends StatefulWidget {
const _PopoverContent({
required this.onSelectViewItem,
});
final void Function(ViewSelectorItem item) onSelectViewItem;
@override
State<_PopoverContent> createState() => _PopoverContentState();
}
class _PopoverContentState extends State<_PopoverContent> {
final focusNode = FocusNode();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
focusNode.requestFocus();
});
}
@override
void dispose() {
focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return ConstrainedBox(
constraints: const BoxConstraints.tightFor(
width: 300,
height: 400,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
VSpace(
theme.spacing.m,
),
Padding(
padding: EdgeInsets.symmetric(
horizontal: theme.spacing.m,
),
child: AFTextField(
focusNode: focusNode,
size: AFTextFieldSize.m,
hintText: LocaleKeys.search_label.tr(),
controller:
context.read<ViewSelectorCubit>().filterTextController,
),
),
VSpace(
theme.spacing.m,
),
AFDivider(),
Expanded(
child: BlocBuilder<ViewSelectorCubit, ViewSelectorState>(
builder: (context, state) {
return ListView(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
children: _buildVisibleSources(context, state).toList(),
);
},
),
),
],
),
);
}
Iterable<Widget> _buildVisibleSources(
BuildContext context,
ViewSelectorState state,
) {
return state.visibleSources.map(
(e) => ViewSelectorTreeItem(
key: ValueKey(
'custom_prompt_database_tree_item_${e.view.id}',
),
viewSelectorItem: e,
level: 0,
isDescendentOfSpace: e.view.isSpace,
isSelectedSection: false,
showCheckbox: false,
onSelected: (item) {
if (item.view.isDocument || item.view.isSpace) {
context.read<ViewSelectorCubit>().toggleIsExpanded(item, false);
return;
}
widget.onSelectViewItem(item);
},
height: 30.0,
),
);
}
}
class _FieldPBWrapper extends Equatable with AFDropDownMenuMixin {
const _FieldPBWrapper(this.field);
final FieldPB field;
@override
String get label => field.name;
@override
List<Object?> get props => [field.id];
}
class FieldSelector extends StatelessWidget {
const FieldSelector({
super.key,
required this.title,
required this.currentFieldId,
this.isDisabled = false,
this.isOptional = false,
this.fields = const [],
required this.onSelect,
});
final String title;
final String? currentFieldId;
final bool isDisabled;
final bool isOptional;
final List<FieldPB> fields;
final void Function(String? id)? onSelect;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final selectedField = fields.firstWhereOrNull(
(field) => field.id == currentFieldId,
);
return Row(
spacing: theme.spacing.s,
children: [
Expanded(
child: Text(
title,
style: theme.textStyle.body.standard(
color: theme.textColorScheme.secondary,
),
),
),
Expanded(
child: AFDropDownMenu<_FieldPBWrapper>(
isDisabled: isDisabled,
items: fields.map((field) => _FieldPBWrapper(field)).toList(),
selectedItems: [
if (selectedField != null) _FieldPBWrapper(selectedField),
],
clearIcon: selectedField == null ||
!fields.contains(selectedField) ||
!isOptional
? null
: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () {
onSelect?.call(null);
},
child: FlowySvg(
FlowySvgs.search_clear_m,
size: Size.square(16),
color: theme.iconColorScheme.tertiary,
),
),
),
onSelected: (value) {
if (value == null) {
return;
}
onSelect?.call(value.field.id);
},
dropdownIcon: FlowySvg(
FlowySvgs.toolbar_arrow_down_m,
color: theme.iconColorScheme.primary,
size: Size(12, 20),
),
),
),
],
);
}
}
@@ -1,346 +0,0 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/trash/application/trash_service.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class CustomPromptDatabaseSelector extends StatefulWidget {
const CustomPromptDatabaseSelector({
super.key,
this.databaseViewId,
this.isLoading = false,
this.popoverDirection = PopoverDirection.bottomWithCenterAligned,
required this.childBuilder,
});
final String? databaseViewId;
final bool isLoading;
final PopoverDirection popoverDirection;
final Widget Function(VoidCallback) childBuilder;
@override
State<CustomPromptDatabaseSelector> createState() =>
_CustomPromptDatabaseSelectorState();
}
class _CustomPromptDatabaseSelectorState
extends State<CustomPromptDatabaseSelector> {
final popoverController = PopoverController();
@override
Widget build(BuildContext context) {
return ViewSelector(
viewSelectorCubit: BlocProvider(
create: (context) => ViewSelectorCubit(
getIgnoreViewType: getIgnoreViewType,
),
),
child: BlocSelector<SpaceBloc, SpaceState, (List<ViewPB>, ViewPB?)>(
selector: (state) => (state.spaces, state.currentSpace),
builder: (context, state) {
return AppFlowyPopover(
controller: popoverController,
triggerActions: PopoverTriggerFlags.none,
margin: EdgeInsets.zero,
offset: const Offset(0, 4.0),
direction: widget.popoverDirection,
constraints: const BoxConstraints.tightFor(width: 300, height: 400),
popupBuilder: (_) {
return BlocProvider.value(
value: context.read<ViewSelectorCubit>(),
child: _PopoverContent(
onSelectViewItem: (item) {
final view = item.view;
if (view.layout.isDatabaseView) {
context
.read<AiPromptSelectorCubit>()
.updateCustomPromptDatabaseViewId(view.id);
popoverController.close();
} else if (!view.isSpace && view.layout.isDocumentView) {
context.read<ViewSelectorCubit>().toggleIsExpanded(
item,
false,
);
}
},
),
);
},
child: widget.childBuilder(
() {
if (!widget.isLoading) {
context
.read<ViewSelectorCubit>()
.refreshSources(state.$1, state.$2);
popoverController.show();
}
},
),
);
},
),
);
}
IgnoreViewType getIgnoreViewType(ViewSelectorItem item) {
final layout = item.view.layout;
if (layout.isDatabaseView) {
return IgnoreViewType.none;
}
if (layout.isDocumentView) {
return hasDatabaseDescendent(item)
? IgnoreViewType.none
: IgnoreViewType.hide;
}
return IgnoreViewType.hide;
}
bool hasDatabaseDescendent(ViewSelectorItem viewSelectorItem) {
final layout = viewSelectorItem.view.layout;
if (layout == ViewLayoutPB.Chat) {
return false;
}
if (layout.isDatabaseView) {
return true;
}
// document may have children
return viewSelectorItem.children.any(
(child) => hasDatabaseDescendent(child),
);
}
}
class AiPromptDatabaseSelectorButton extends StatefulWidget {
const AiPromptDatabaseSelectorButton({
super.key,
required this.selectedViewId,
required this.isLoading,
required this.onTap,
});
final String? selectedViewId;
final bool isLoading;
final VoidCallback onTap;
@override
State<AiPromptDatabaseSelectorButton> createState() =>
_AiPromptDatabaseSelectorButtonState();
}
class _AiPromptDatabaseSelectorButtonState
extends State<AiPromptDatabaseSelectorButton> {
late Future<ViewPB?> viewFuture;
@override
void initState() {
super.initState();
viewFuture = getView();
}
@override
void didUpdateWidget(covariant AiPromptDatabaseSelectorButton oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.selectedViewId != widget.selectedViewId) {
viewFuture = getView();
}
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return FutureBuilder<ViewPB?>(
future: viewFuture,
builder: (context, snapshot) {
String name = "";
if (snapshot.hasData &&
snapshot.connectionState == ConnectionState.done) {
name = snapshot.data!.nameOrDefault;
}
return Row(
spacing: theme.spacing.s,
children: [
Expanded(
child: Text(
"${LocaleKeys.ai_customPrompt_promptDatabase.tr()}: $name",
maxLines: 1,
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
overflow: TextOverflow.ellipsis,
),
),
ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 150,
),
child: AFOutlinedButton.normal(
onTap: widget.onTap,
builder: (context, isHovering, disabled) {
return Row(
spacing: theme.spacing.s,
mainAxisSize: MainAxisSize.min,
children: [
if (widget.isLoading) buildLoadingIndicator(theme),
Flexible(
child: Text(
widget.isLoading
? LocaleKeys.ai_customPrompt_loading.tr()
: LocaleKeys.button_change.tr(),
maxLines: 1,
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
overflow: TextOverflow.ellipsis,
),
),
],
);
},
),
),
],
);
},
);
}
Widget buildLoadingIndicator(AppFlowyThemeData theme) {
return SizedBox.square(
dimension: 20,
child: Padding(
padding: EdgeInsets.all(2.5),
child: CircularProgressIndicator(
color: theme.iconColorScheme.tertiary,
strokeWidth: 2.0,
),
),
);
}
Future<ViewPB?> getView() async {
if (widget.selectedViewId == null) {
return null;
}
final view =
await ViewBackendService.getView(widget.selectedViewId!).toNullable();
if (view != null) {
return view;
}
final trashViews = await TrashService().readTrash().toNullable();
final trashedItem = trashViews?.items
.firstWhereOrNull((element) => element.id == widget.selectedViewId);
if (trashedItem == null) {
return null;
}
return ViewPB()
..id = trashedItem.id
..name = trashedItem.name;
}
}
class _PopoverContent extends StatefulWidget {
const _PopoverContent({
required this.onSelectViewItem,
});
final void Function(ViewSelectorItem item) onSelectViewItem;
@override
State<_PopoverContent> createState() => _PopoverContentState();
}
class _PopoverContentState extends State<_PopoverContent> {
final focusNode = FocusNode();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
focusNode.requestFocus();
});
}
@override
void dispose() {
focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
VSpace(theme.spacing.m),
Padding(
padding: EdgeInsets.symmetric(
horizontal: theme.spacing.m,
),
child: AFTextField(
focusNode: focusNode,
size: AFTextFieldSize.m,
hintText: LocaleKeys.search_label.tr(),
controller: context.read<ViewSelectorCubit>().filterTextController,
),
),
VSpace(theme.spacing.m),
AFDivider(),
Expanded(
child: BlocBuilder<ViewSelectorCubit, ViewSelectorState>(
builder: (context, state) {
return ListView(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
children: _buildVisibleSources(context, state).toList(),
);
},
),
),
],
);
}
Iterable<Widget> _buildVisibleSources(
BuildContext context,
ViewSelectorState state,
) {
return state.visibleSources.map(
(e) => ViewSelectorTreeItem(
key: ValueKey(
'custom_prompt_database_tree_item_${e.view.id}',
),
viewSelectorItem: e,
level: 0,
isDescendentOfSpace: e.view.isSpace,
isSelectedSection: false,
showCheckbox: false,
onSelected: widget.onSelectViewItem,
height: 30.0,
),
);
}
}
@@ -1,4 +1,5 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/features/workspace/logic/workspace_bloc.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/user/prelude.dart';
@@ -46,6 +47,7 @@ class AiPromptModal extends StatelessWidget {
final theme = AppFlowyTheme.of(context);
return AFModal(
backgroundColor: theme.backgroundColorScheme.primary,
constraints: const BoxConstraints(
maxWidth: 1200,
maxHeight: 800,
@@ -102,7 +104,7 @@ class AiPromptModal extends StatelessWidget {
child: AiPromptCategoryList(),
),
if (readyState.isCustomPromptSectionSelected &&
readyState.customPromptDatabaseViewId == null)
readyState.databaseConfig == null)
const Expanded(
flex: 5,
child: Center(
@@ -1,10 +1,12 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'ai_prompt_database_selector.dart';
import 'ai_prompt_database_modal.dart';
class AiPromptOnboarding extends StatelessWidget {
const AiPromptOnboarding({super.key});
@@ -34,18 +36,24 @@ class AiPromptOnboarding extends StatelessWidget {
VSpace(
theme.spacing.xxl,
),
CustomPromptDatabaseSelector(
childBuilder: (onTap) => AFFilledButton.primary(
onTap: onTap,
builder: (context, isHovering, disabled) {
return Text(
LocaleKeys.ai_customPrompt_selectDatabase.tr(),
style: theme.textStyle.body.enhanced(
color: theme.textColorScheme.onFill,
),
);
},
),
AFFilledButton.primary(
onTap: () async {
final config = await changeCustomPromptDatabaseConfig(context);
if (config != null && context.mounted) {
context
.read<AiPromptSelectorCubit>()
.updateCustomPromptDatabaseConfiguration(config);
}
},
builder: (context, isHovering, disabled) {
return Text(
LocaleKeys.ai_customPrompt_selectDatabase.tr(),
style: theme.textStyle.body.enhanced(
color: theme.textColorScheme.onFill,
),
);
},
),
],
);
@@ -70,7 +70,7 @@ class AiPromptPreview extends StatelessWidget {
if (prompt.example.isNotEmpty) ...[
SelectionContainer.disabled(
child: Text(
LocaleKeys.ai_customPrompt_example.tr(),
LocaleKeys.ai_customPrompt_promptExample.tr(),
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.primary,
),
@@ -106,7 +106,7 @@ class _PromptContent extends StatelessWidget {
return Container(
padding: EdgeInsets.all(theme.spacing.l),
decoration: BoxDecoration(
color: theme.fillColorScheme.quaternary,
color: theme.surfaceContainerColorScheme.layer01,
borderRadius: BorderRadius.circular(theme.borderRadius.m),
),
child: Text.rich(
@@ -130,7 +130,7 @@ class _PromptContent extends StatelessWidget {
spans.add(
TextSpan(
text: part,
style: TextStyle(color: theme.textColorScheme.purple),
style: TextStyle(color: theme.textColorScheme.featured),
),
);
} else {
@@ -176,7 +176,7 @@ class _PromptExample extends StatelessWidget {
return Container(
padding: EdgeInsets.all(theme.spacing.l),
decoration: BoxDecoration(
color: theme.fillColorScheme.quaternary,
color: theme.surfaceContainerColorScheme.layer01,
borderRadius: BorderRadius.circular(theme.borderRadius.m),
),
child: AIMarkdownText(
@@ -4,6 +4,7 @@ import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:diffutil_dart/diffutil.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -11,7 +12,7 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'ai_prompt_database_selector.dart';
import 'ai_prompt_database_modal.dart';
const Duration _listItemAnimationDuration = Duration(milliseconds: 150);
@@ -57,89 +58,147 @@ class _AiPromptVisibleListState extends State<AiPromptVisibleList> {
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return BlocListener<AiPromptSelectorCubit, AiPromptSelectorState>(
listener: (context, state) {
state.maybeMap(
ready: (state) {
handleVisiblePromptListChanged(state.visiblePrompts);
return Column(
children: [
BlocConsumer<AiPromptSelectorCubit, AiPromptSelectorState>(
listener: (context, state) {
state.maybeMap(
ready: (state) {
handleVisiblePromptListChanged(state.visiblePrompts);
},
orElse: () {},
);
},
orElse: () {},
);
},
child: Column(
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: theme.spacing.l),
child: buildSearchField(context),
),
VSpace(
theme.spacing.s,
),
BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
buildWhen: (p, c) {
return p.maybeMap(
ready: (pr) => c.maybeMap(
ready: (cr) =>
pr.customPromptDatabaseViewId !=
cr.customPromptDatabaseViewId ||
pr.isLoadingCustomPrompts != cr.isLoadingCustomPrompts ||
pr.isCustomPromptSectionSelected !=
cr.isCustomPromptSectionSelected,
orElse: () => false,
),
orElse: () => true,
);
},
builder: (context, state) {
return state.maybeMap(
ready: (readyState) {
if (!readyState.isCustomPromptSectionSelected) {
return const SizedBox.shrink();
}
return Padding(
padding: EdgeInsets.only(
left: theme.spacing.l,
top: theme.spacing.s,
right: theme.spacing.l,
),
child: CustomPromptDatabaseSelector(
databaseViewId: readyState.customPromptDatabaseViewId,
isLoading: readyState.isLoadingCustomPrompts,
popoverDirection: PopoverDirection.bottomWithRightAligned,
childBuilder: (onTap) {
return AiPromptDatabaseSelectorButton(
selectedViewId: readyState.customPromptDatabaseViewId,
isLoading: readyState.isLoadingCustomPrompts,
onTap: onTap,
);
},
),
);
},
orElse: () => const SizedBox.shrink(),
);
},
),
Expanded(
child: TextFieldTapRegion(
groupId: "ai_prompt_category_list",
child: BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
builder: (context, state) {
return state.maybeMap(
ready: (readyState) {
if (readyState.visiblePrompts.isEmpty) {
return buildEmptyPrompts();
}
return buildPromptList();
},
orElse: () => const SizedBox.shrink(),
);
},
buildWhen: (p, c) {
return p.maybeMap(
ready: (pr) => c.maybeMap(
ready: (cr) =>
pr.databaseConfig?.view.id != cr.databaseConfig?.view.id ||
pr.isLoadingCustomPrompts != cr.isLoadingCustomPrompts ||
pr.isCustomPromptSectionSelected !=
cr.isCustomPromptSectionSelected,
orElse: () => false,
),
orElse: () => true,
);
},
builder: (context, state) {
return state.maybeMap(
ready: (readyState) {
if (!readyState.isCustomPromptSectionSelected) {
return const SizedBox.shrink();
}
return Container(
margin: EdgeInsets.only(
left: theme.spacing.l,
right: theme.spacing.l,
bottom: theme.spacing.l,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(theme.borderRadius.m),
color: theme.surfaceContainerColorScheme.layer01,
),
padding: EdgeInsets.all(theme.spacing.m),
child: Row(
children: [
Expanded(
child: Text.rich(
TextSpan(
children: [
TextSpan(
text:
"${LocaleKeys.ai_customPrompt_promptDatabase.tr()}: ",
style: TextStyle(fontWeight: FontWeight.w500),
),
TextSpan(
text: readyState
.databaseConfig?.view.nameOrDefault ??
"",
),
],
),
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 150,
),
child: AFOutlinedButton.normal(
builder: (context, isHovering, disabled) {
return Row(
spacing: theme.spacing.s,
mainAxisSize: MainAxisSize.min,
children: [
if (readyState.isLoadingCustomPrompts)
buildLoadingIndicator(theme),
Flexible(
child: Text(
readyState.isLoadingCustomPrompts
? LocaleKeys.ai_customPrompt_loading
.tr()
: LocaleKeys.button_change.tr(),
maxLines: 1,
style: theme.textStyle.body.enhanced(
color: theme.textColorScheme.primary,
),
overflow: TextOverflow.ellipsis,
),
),
],
);
},
onTap: () async {
final newConfig =
await changeCustomPromptDatabaseConfig(
context,
config: readyState.databaseConfig,
);
if (newConfig != null && context.mounted) {
context
.read<AiPromptSelectorCubit>()
.updateCustomPromptDatabaseConfiguration(
newConfig,
);
}
},
),
),
],
),
);
},
orElse: () => const SizedBox.shrink(),
);
},
),
Padding(
padding: EdgeInsets.symmetric(horizontal: theme.spacing.l),
child: buildSearchField(context),
),
Expanded(
child: TextFieldTapRegion(
groupId: "ai_prompt_category_list",
child: BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
builder: (context, state) {
return state.maybeMap(
ready: (readyState) {
if (readyState.visiblePrompts.isEmpty) {
return buildEmptyPrompts();
}
return buildPromptList();
},
orElse: () => const SizedBox.shrink(),
);
},
),
),
],
),
),
],
);
}
@@ -167,10 +226,13 @@ class _AiPromptVisibleListState extends State<AiPromptVisibleList> {
.read<AiPromptSelectorCubit>()
.filterTextController
.clear(),
child: FlowySvg(
FlowySvgs.toast_close_s,
color: theme.textColorScheme.secondary,
size: const Size.square(20),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: FlowySvg(
FlowySvgs.search_clear_m,
color: theme.iconColorScheme.tertiary,
size: const Size.square(20),
),
),
),
),
@@ -240,6 +302,19 @@ class _AiPromptVisibleListState extends State<AiPromptVisibleList> {
);
}
Widget buildLoadingIndicator(AppFlowyThemeData theme) {
return SizedBox.square(
dimension: 20,
child: Padding(
padding: EdgeInsets.all(2.5),
child: CircularProgressIndicator(
color: theme.iconColorScheme.tertiary,
strokeWidth: 2.0,
),
),
);
}
void handleVisiblePromptListChanged(
List<AiPrompt> newList,
) {
@@ -364,8 +439,8 @@ class _AiPromptListItemState extends State<_AiPromptListItem> {
? theme.borderColorScheme.themeThickHover
: theme.borderColorScheme.themeThick
: isHovering
? theme.borderColorScheme.greyTertiaryHover
: theme.borderColorScheme.greyTertiary,
? theme.borderColorScheme.primaryHover
: theme.borderColorScheme.primary,
),
),
child: Column(
@@ -15,6 +15,13 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'browse_prompts_button.dart';
typedef OnPromptInputSubmitted = void Function(
String input,
PredefinedFormat? predefinedFormat,
Map<String, dynamic> metadata,
String? promptId,
);
class DesktopPromptInput extends StatefulWidget {
const DesktopPromptInput({
super.key,
@@ -32,8 +39,7 @@ class DesktopPromptInput extends StatefulWidget {
final bool isStreaming;
final AiPromptInputTextEditingController textController;
final void Function() onStopStreaming;
final void Function(String, PredefinedFormat?, Map<String, dynamic>)
onSubmitted;
final OnPromptInputSubmitted onSubmitted;
final ValueNotifier<List<String>> selectedSourcesNotifier;
final void Function(List<String>) onUpdateSelectedSources;
final bool hideDecoration;
@@ -241,12 +247,14 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
final sources = (paletteState.askAISources ?? []).map((e) => e.id).toList();
final metadata =
context.read<AIPromptInputBloc?>()?.consumeMetadata() ?? {};
final promptState = context.read<AIPromptInputBloc?>()?.state;
final promptBloc = context.read<AIPromptInputBloc?>();
final promptId = promptBloc?.promptId;
final promptState = promptBloc?.state;
final predefinedFormat = promptState?.predefinedFormat;
if (sources.isNotEmpty) {
widget.onUpdateSelectedSources(sources);
}
widget.onSubmitted.call(query, predefinedFormat, metadata);
widget.onSubmitted.call(query, predefinedFormat, metadata, promptId ?? '');
}
void startMentionPageFromButton() {
@@ -308,6 +316,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
userInput,
showPredefinedFormats ? predefinedFormat : null,
metadata,
bloc.promptId,
);
}
@@ -526,9 +535,9 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
void handleOnSelectPrompt(AiPrompt prompt) {
final bloc = context.read<AIPromptInputBloc>();
bloc.add(
AIPromptInputEvent.updateMentionedViews([]),
);
bloc
..add(AIPromptInputEvent.updateMentionedViews([]))
..add(AIPromptInputEvent.updatePromptId(prompt.id));
final content = AiPromptInputTextEditingController.replace(prompt.content);
@@ -56,9 +56,9 @@ class AiPromptInputTextEditingController extends TextEditingController {
TextSpan(
text: restore(string),
style: theme.textStyle.body.standard().copyWith(
color: theme.textColorScheme.purple,
color: theme.textColorScheme.featured,
backgroundColor:
theme.fillColorScheme.purpleThick.withAlpha(51),
theme.fillColorScheme.featuredThick.withAlpha(51),
),
),
);
@@ -1,16 +1,18 @@
import 'dart:async';
import 'package:appflowy/ai/service/view_selector_cubit.dart';
import 'package:appflowy/features/workspace/logic/workspace_bloc.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/flowy_search_text_field.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/ai/service/view_selector_cubit.dart';
import 'package:appflowy/plugins/base/drag_handler.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -66,6 +68,8 @@ class _PromptInputMobileSelectSourcesButtonState
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return BlocBuilder<UserWorkspaceBloc, UserWorkspaceState>(
builder: (context, state) {
final userProfile = context.read<UserWorkspaceBloc>().state.userProfile;
@@ -96,6 +100,26 @@ class _PromptInputMobileSelectSourcesButtonState
color: Theme.of(context).iconTheme.color,
size: const Size.square(20.0),
),
const HSpace(2.0),
ValueListenableBuilder(
valueListenable: widget.selectedSourcesNotifier,
builder: (context, selectedSourceIds, _) {
final documentId =
context.read<DocumentBloc?>()?.documentId;
final label = documentId != null &&
selectedSourceIds.length == 1 &&
selectedSourceIds[0] == documentId
? LocaleKeys.chat_currentPage.tr()
: selectedSourceIds.length.toString();
return FlowyText(
label,
fontSize: 14,
figmaLineHeight: 20,
color: Theme.of(context).hintColor,
);
},
),
const HSpace(2.0),
FlowySvg(
FlowySvgs.ai_source_drop_down_s,
color: Theme.of(context).hintColor,
@@ -112,7 +136,7 @@ class _PromptInputMobileSelectSourcesButtonState
await showMobileBottomSheet<void>(
context,
backgroundColor: Theme.of(context).colorScheme.surface,
backgroundColor: theme.surfaceColorScheme.primary,
maxChildSize: 0.98,
enableDraggableScrollable: true,
scrollableWidgetBuilder: (_, scrollController) {
@@ -163,7 +187,7 @@ class _MobileSelectSourcesSheetBody extends StatelessWidget {
pinned: true,
delegate: _Header(
child: ColoredBox(
color: Theme.of(context).cardColor,
color: AppFlowyTheme.of(context).surfaceColorScheme.primary,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -197,6 +221,48 @@ class _MobileSelectSourcesSheetBody extends StatelessWidget {
),
),
),
BlocBuilder<ViewSelectorCubit, ViewSelectorState>(
builder: (context, state) {
return SliverList(
delegate: SliverChildBuilderDelegate(
childCount: state.selectedSources.length,
(context, index) {
final source = state.selectedSources.elementAt(index);
return ViewSelectorTreeItem(
key: ValueKey(
'selected_select_sources_tree_item_${source.view.id}',
),
viewSelectorItem: source,
level: 0,
isDescendentOfSpace: source.view.isSpace,
isSelectedSection: true,
onSelected: (item) {
context
.read<ViewSelectorCubit>()
.toggleSelectedStatus(item, true);
},
height: 40.0,
);
},
),
);
},
),
BlocBuilder<ViewSelectorCubit, ViewSelectorState>(
builder: (context, state) {
if (state.selectedSources.isNotEmpty &&
state.visibleSources.isNotEmpty) {
return SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: AFDivider(),
),
);
}
return const SliverToBoxAdapter();
},
),
BlocBuilder<ViewSelectorCubit, ViewSelectorState>(
builder: (context, state) {
return SliverList(
@@ -418,6 +418,8 @@ class ViewSelectorTreeItemInner extends StatelessWidget {
hoverColor: AFThemeExtension.of(context).lightGreyHover,
),
builder: (context, onHover) {
final theme = AppFlowyTheme.of(context);
final isSaveButtonVisible =
showSaveButton && !viewSelectorItem.view.isSpace;
final isAddButtonVisible = onAdd != null;
@@ -445,11 +447,12 @@ class ViewSelectorTreeItemInner extends StatelessWidget {
const HSpace(6.0),
// title
Expanded(
child: FlowyText(
child: Text(
viewSelectorItem.view.nameOrDefault,
overflow: TextOverflow.ellipsis,
fontSize: 14.0,
figmaLineHeight: 18.0,
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
),
),
if (onHover && (isSaveButtonVisible || isAddButtonVisible)) ...[
@@ -1,3 +1,4 @@
import 'package:appflowy/features/workspace/logic/workspace_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/user/prelude.dart';
import 'package:flutter/material.dart';
@@ -120,4 +120,9 @@ class KVKeys {
///
/// The value is a json list of id
static const String compactModeIds = 'compactModeIds';
/// v0.9.4: has the user clicked the upgrade to pro button
/// The value is a boolean string
static const String hasClickedUpgradeToProButton =
'hasClickedUpgradeToProButton';
}
@@ -0,0 +1,33 @@
import 'package:appflowy/features/share_tab/data/models/models.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
/// Abstract repository for managing view lock status.
///
/// For example, we're using rust events now, but we can still use the http api
/// for the future.
abstract class PageAccessLevelRepository {
/// Gets the current view from the backend.
Future<FlowyResult<ViewPB, FlowyError>> getView(String pageId);
/// Locks the view.
Future<FlowyResult<void, FlowyError>> lockView(String pageId);
/// Unlocks the view.
Future<FlowyResult<void, FlowyError>> unlockView(String pageId);
/// Gets the access level of the current user.
Future<FlowyResult<ShareAccessLevel, FlowyError>> getAccessLevel(
String pageId,
);
/// Gets the section type of the shared section.
Future<FlowyResult<SharedSectionType, FlowyError>> getSectionType(
String pageId,
);
/// Get current workspace
Future<FlowyResult<UserWorkspacePB, FlowyError>> getCurrentWorkspace();
}
@@ -0,0 +1,206 @@
import 'package:appflowy/features/page_access_level/data/repositories/page_access_level_repository.dart';
import 'package:appflowy/features/share_tab/data/models/models.dart';
import 'package:appflowy/features/util/extensions.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'
hide AFRolePB;
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:collection/collection.dart';
class RustPageAccessLevelRepositoryImpl implements PageAccessLevelRepository {
@override
Future<FlowyResult<ViewPB, FlowyError>> getView(String pageId) async {
final result = await ViewBackendService.getView(pageId);
return result.fold(
(view) {
Log.debug('get view(${view.id}) success');
return FlowyResult.success(view);
},
(error) {
Log.error('failed to get view, error: $error');
return FlowyResult.failure(error);
},
);
}
@override
Future<FlowyResult<void, FlowyError>> lockView(String pageId) async {
final result = await ViewBackendService.lockView(pageId);
return result.fold(
(_) {
Log.debug('lock view($pageId) success');
return FlowyResult.success(null);
},
(error) {
Log.error('failed to lock view, error: $error');
return FlowyResult.failure(error);
},
);
}
@override
Future<FlowyResult<void, FlowyError>> unlockView(String pageId) async {
final result = await ViewBackendService.unlockView(pageId);
return result.fold(
(_) {
Log.debug('unlock view($pageId) success');
return FlowyResult.success(null);
},
(error) {
Log.error('failed to unlock view, error: $error');
return FlowyResult.failure(error);
},
);
}
/// 1. local users have full access
/// 2. local workspace users have full access
/// 3. page creator has full access
/// 4. owner and members in public page have full access
/// 5. check the shared users list
@override
Future<FlowyResult<ShareAccessLevel, FlowyError>> getAccessLevel(
String pageId,
) async {
final userResult = await UserBackendService.getCurrentUserProfile();
final user = userResult.fold(
(s) => s,
(_) => null,
);
if (user == null) {
return FlowyResult.failure(
FlowyError(
code: ErrorCode.Internal,
msg: 'User not found',
),
);
}
if (user.userAuthType == AuthTypePB.Local) {
// Local user can always have full access.
return FlowyResult.success(ShareAccessLevel.fullAccess);
}
if (user.workspaceType == WorkspaceTypePB.LocalW) {
// Local workspace can always have full access.
return FlowyResult.success(ShareAccessLevel.fullAccess);
}
// If the user is the creator of the page, they can always have full access.
final viewResult = await getView(pageId);
final view = viewResult.fold(
(s) => s,
(_) => null,
);
if (view?.createdBy == user.id) {
return FlowyResult.success(ShareAccessLevel.fullAccess);
}
// If the page is public, the user can always have full access.
final workspaceResult = await getCurrentWorkspace();
final workspace = workspaceResult.fold(
(s) => s,
(_) => null,
);
if (workspace == null) {
return FlowyResult.failure(
FlowyError(
code: ErrorCode.Internal,
msg: 'Current workspace not found',
),
);
}
final sectionTypeResult = await getSectionType(pageId);
final sectionType = sectionTypeResult.fold(
(s) => s,
(_) => null,
);
if (sectionType == SharedSectionType.public &&
workspace.role != AFRolePB.Guest) {
return FlowyResult.success(ShareAccessLevel.fullAccess);
}
final email = user.email;
final request = GetSharedUsersPayloadPB(
viewId: pageId,
);
final result = await FolderEventGetSharedUsers(request).send();
return result.fold(
(success) {
final accessLevel = success.items
.firstWhereOrNull(
(item) => item.email == email,
)
?.accessLevel
.shareAccessLevel ??
ShareAccessLevel.readOnly;
Log.debug('current user access level: $accessLevel, in page: $pageId');
return FlowyResult.success(accessLevel);
},
(failure) {
Log.error(
'failed to get user access level: $failure, in page: $pageId',
);
// return the read access level if the user is not found
return FlowyResult.success(ShareAccessLevel.readOnly);
},
);
}
@override
Future<FlowyResult<SharedSectionType, FlowyError>> getSectionType(
String pageId,
) async {
final request = ViewIdPB(value: pageId);
final result = await FolderEventGetSharedViewSection(request).send();
return result.fold(
(success) {
final sectionType = success.section.sharedSectionType;
Log.debug('shared section type: $sectionType, in page: $pageId');
return FlowyResult.success(sectionType);
},
(failure) {
Log.error(
'failed to get shared section type: $failure, in page: $pageId',
);
return FlowyResult.failure(failure);
},
);
}
@override
Future<FlowyResult<UserWorkspacePB, FlowyError>> getCurrentWorkspace() async {
final result = await UserBackendService.getCurrentWorkspace();
final currentWorkspaceId = result.fold(
(s) => s.id,
(_) => null,
);
if (currentWorkspaceId == null) {
return FlowyResult.failure(
FlowyError(
code: ErrorCode.Internal,
msg: 'Current workspace not found',
),
);
}
final workspaceResult = await UserBackendService.getWorkspaceById(
currentWorkspaceId,
);
return workspaceResult;
}
}
@@ -0,0 +1,163 @@
import 'dart:async';
import 'package:appflowy/features/page_access_level/data/repositories/page_access_level_repository.dart';
import 'package:appflowy/features/page_access_level/data/repositories/rust_page_access_level_repository_impl.dart';
import 'package:appflowy/features/page_access_level/logic/page_access_level_event.dart';
import 'package:appflowy/features/page_access_level/logic/page_access_level_state.dart';
import 'package:appflowy/features/share_tab/data/models/models.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/workspace/application/view/view_listener.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:bloc/bloc.dart';
import 'package:protobuf/protobuf.dart';
export 'page_access_level_event.dart';
export 'page_access_level_state.dart';
class PageAccessLevelBloc
extends Bloc<PageAccessLevelEvent, PageAccessLevelState> {
PageAccessLevelBloc({
required this.view,
this.ignorePageAccessLevel = false,
PageAccessLevelRepository? repository,
}) : repository = repository ?? RustPageAccessLevelRepositoryImpl(),
listener = ViewListener(viewId: view.id),
super(PageAccessLevelState.initial(view)) {
on<PageAccessLevelInitialEvent>(_onInitial);
on<PageAccessLevelLockEvent>(_onLock);
on<PageAccessLevelUnlockEvent>(_onUnlock);
on<PageAccessLevelUpdateLockStatusEvent>(_onUpdateLockStatus);
on<PageAccessLevelUpdateSectionTypeEvent>(_onUpdateSectionType);
}
final ViewPB view;
// The repository to manage view lock status.
// If you need to test this bloc, you can add your own repository implementation.
final PageAccessLevelRepository repository;
// Used to listen for view updates.
late final ViewListener listener;
// should ignore the page access level
// in the row details page, we don't need to check the page access level
final bool ignorePageAccessLevel;
@override
Future<void> close() async {
await listener.stop();
return super.close();
}
Future<void> _onInitial(
PageAccessLevelInitialEvent event,
Emitter<PageAccessLevelState> emit,
) async {
// lock status
listener.start(
onViewUpdated: (view) async {
add(PageAccessLevelEvent.updateLockStatus(view.isLocked));
},
);
// section type
final sectionTypeResult = await repository.getSectionType(view.id);
final sectionType = sectionTypeResult.fold(
(sectionType) => sectionType,
(_) => SharedSectionType.public,
);
if (!FeatureFlag.sharedSection.isOn || ignorePageAccessLevel) {
emit(
state.copyWith(
view: view,
isLocked: view.isLocked,
isLoadingLockStatus: false,
accessLevel: ShareAccessLevel.fullAccess,
sectionType: sectionType,
),
);
return;
}
final result = await repository.getView(view.id);
final accessLevel = await repository.getAccessLevel(view.id);
final latestView = result.fold(
(view) => view,
(_) => view,
);
emit(
state.copyWith(
view: latestView,
isLocked: latestView.isLocked,
isLoadingLockStatus: false,
accessLevel: accessLevel.fold(
(accessLevel) => accessLevel,
(_) => ShareAccessLevel.readOnly,
),
sectionType: sectionType,
),
);
}
Future<void> _onLock(
PageAccessLevelLockEvent event,
Emitter<PageAccessLevelState> emit,
) async {
final result = await repository.lockView(view.id);
final isLocked = result.fold(
(_) => true,
(_) => false,
);
add(
PageAccessLevelEvent.updateLockStatus(
isLocked,
),
);
}
Future<void> _onUnlock(
PageAccessLevelUnlockEvent event,
Emitter<PageAccessLevelState> emit,
) async {
final result = await repository.unlockView(view.id);
final isLocked = result.fold(
(_) => false,
(_) => true,
);
add(
PageAccessLevelEvent.updateLockStatus(
isLocked,
lockCounter: state.lockCounter + 1,
),
);
}
void _onUpdateLockStatus(
PageAccessLevelUpdateLockStatusEvent event,
Emitter<PageAccessLevelState> emit,
) {
state.view.freeze();
final updatedView = state.view.rebuild(
(update) => update.isLocked = event.isLocked,
);
emit(
state.copyWith(
view: updatedView,
isLocked: event.isLocked,
lockCounter: event.lockCounter ?? state.lockCounter,
),
);
}
void _onUpdateSectionType(
PageAccessLevelUpdateSectionTypeEvent event,
Emitter<PageAccessLevelState> emit,
) {
emit(
state.copyWith(
sectionType: event.sectionType,
),
);
}
}
@@ -0,0 +1,55 @@
import 'package:appflowy/features/share_tab/data/models/models.dart';
/// Base class for all PageAccessLevel events
sealed class PageAccessLevelEvent {
const PageAccessLevelEvent();
/// Initialize the view lock status, it will create a view listener to listen for view updates.
/// Also, it will fetch the current view lock status from the repository.
const factory PageAccessLevelEvent.initial() = PageAccessLevelInitialEvent;
/// Lock the view.
const factory PageAccessLevelEvent.lock() = PageAccessLevelLockEvent;
/// Unlock the view.
const factory PageAccessLevelEvent.unlock() = PageAccessLevelUnlockEvent;
/// Update the lock status in the state.
const factory PageAccessLevelEvent.updateLockStatus(
bool isLocked, {
int? lockCounter,
}) = PageAccessLevelUpdateLockStatusEvent;
/// Update the section type in the state.
const factory PageAccessLevelEvent.updateSectionType(
SharedSectionType sectionType,
) = PageAccessLevelUpdateSectionTypeEvent;
}
class PageAccessLevelInitialEvent extends PageAccessLevelEvent {
const PageAccessLevelInitialEvent();
}
class PageAccessLevelLockEvent extends PageAccessLevelEvent {
const PageAccessLevelLockEvent();
}
class PageAccessLevelUnlockEvent extends PageAccessLevelEvent {
const PageAccessLevelUnlockEvent();
}
class PageAccessLevelUpdateLockStatusEvent extends PageAccessLevelEvent {
const PageAccessLevelUpdateLockStatusEvent(
this.isLocked, {
this.lockCounter,
});
final bool isLocked;
final int? lockCounter;
}
class PageAccessLevelUpdateSectionTypeEvent extends PageAccessLevelEvent {
const PageAccessLevelUpdateSectionTypeEvent(this.sectionType);
final SharedSectionType sectionType;
}
@@ -0,0 +1,97 @@
import 'package:appflowy/features/share_tab/data/models/models.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
class PageAccessLevelState {
factory PageAccessLevelState.initial(ViewPB view) => PageAccessLevelState(
view: view,
isLocked: false,
lockCounter: 0,
sectionType: SharedSectionType.public,
accessLevel: ShareAccessLevel.readOnly,
);
const PageAccessLevelState({
required this.view,
required this.isLocked,
required this.lockCounter,
required this.accessLevel,
required this.sectionType,
this.myRole,
this.isLoadingLockStatus = true,
});
final ViewPB view;
final bool isLocked;
final int lockCounter;
final bool isLoadingLockStatus;
final ShareAccessLevel accessLevel;
final SharedSectionType sectionType;
final ShareRole? myRole;
bool get isPublic => sectionType == SharedSectionType.public;
bool get isPrivate => sectionType == SharedSectionType.private;
bool get isShared => sectionType == SharedSectionType.shared;
bool get shouldHideSpace => myRole == ShareRole.guest;
bool get isEditable {
if (!FeatureFlag.sharedSection.isOn) {
return !isLocked;
}
return accessLevel != ShareAccessLevel.readOnly && !isLocked;
}
bool get isReadOnly => accessLevel == ShareAccessLevel.readOnly;
PageAccessLevelState copyWith({
ViewPB? view,
bool? isLocked,
int? lockCounter,
bool? isLoadingLockStatus,
ShareAccessLevel? accessLevel,
SharedSectionType? sectionType,
ShareRole? myRole,
}) {
return PageAccessLevelState(
view: view ?? this.view,
isLocked: isLocked ?? this.isLocked,
lockCounter: lockCounter ?? this.lockCounter,
isLoadingLockStatus: isLoadingLockStatus ?? this.isLoadingLockStatus,
accessLevel: accessLevel ?? this.accessLevel,
sectionType: sectionType ?? this.sectionType,
myRole: myRole ?? this.myRole,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is PageAccessLevelState &&
other.view == view &&
other.isLocked == isLocked &&
other.lockCounter == lockCounter &&
other.isLoadingLockStatus == isLoadingLockStatus &&
other.accessLevel == accessLevel &&
other.sectionType == sectionType &&
other.myRole == myRole;
}
@override
int get hashCode {
return Object.hash(
view,
isLocked,
lockCounter,
isLoadingLockStatus,
accessLevel,
sectionType,
myRole,
);
}
@override
String toString() {
return 'PageAccessLevelState(view: $view, isLocked: $isLocked, lockCounter: $lockCounter, isLoadingLockStatus: $isLoadingLockStatus, accessLevel: $accessLevel, sectionType: $sectionType, myRole: $myRole)';
}
}
@@ -0,0 +1,14 @@
import 'package:equatable/equatable.dart';
class UserDataLocation extends Equatable {
const UserDataLocation({
required this.path,
required this.isCustom,
});
final String path;
final bool isCustom;
@override
List<Object?> get props => [path, isCustom];
}
@@ -0,0 +1,61 @@
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/startup/tasks/rust_sdk.dart';
import 'package:appflowy/user/application/user_settings_service.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import '../models/user_data_location.dart';
import 'settings_repository.dart';
class RustSettingsRepositoryImpl implements SettingsRepository {
const RustSettingsRepositoryImpl();
final _userBackendService = const UserSettingsBackendService();
@override
Future<FlowyResult<UserDataLocation, FlowyError>>
getUserDataLocation() async {
final defaultDirectory = (await appFlowyApplicationDataDirectory()).path;
final result = await _userBackendService.getUserSetting();
return result.map(
(settings) {
final userDirectory = settings.userFolder;
return UserDataLocation(
path: userDirectory,
isCustom: userDirectory.contains(defaultDirectory),
);
},
);
}
@override
Future<FlowyResult<UserDataLocation, FlowyError>>
resetUserDataLocation() async {
final directory = await appFlowyApplicationDataDirectory();
await getIt<ApplicationDataStorage>().setPath(directory.path);
return FlowyResult.success(
UserDataLocation(
path: directory.path,
isCustom: false,
),
);
}
@override
Future<FlowyResult<UserDataLocation, FlowyError>> setCustomLocation(
String path,
) async {
final defaultDirectory = (await appFlowyApplicationDataDirectory()).path;
await getIt<ApplicationDataStorage>().setCustomPath(path);
return FlowyResult.success(
UserDataLocation(
path: path,
isCustom: path.contains(defaultDirectory),
),
);
}
}
@@ -0,0 +1,14 @@
import 'package:appflowy_backend/protobuf/flowy-error/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import '../models/user_data_location.dart';
abstract class SettingsRepository {
Future<FlowyResult<UserDataLocation, FlowyError>> getUserDataLocation();
Future<FlowyResult<UserDataLocation, FlowyError>> resetUserDataLocation();
Future<FlowyResult<UserDataLocation, FlowyError>> setCustomLocation(
String path,
);
}
@@ -0,0 +1,76 @@
import 'package:appflowy_result/appflowy_result.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../data/repositories/settings_repository.dart';
import 'data_location_event.dart';
import 'data_location_state.dart';
class DataLocationBloc extends Bloc<DataLocationEvent, DataLocationState> {
DataLocationBloc({
required SettingsRepository repository,
}) : _repository = repository,
super(DataLocationState.initial()) {
on<DataLocationInitial>(_onStarted);
on<DataLocationResetToDefault>(_onResetToDefault);
on<DataLocationSetCustomPath>(_onSetCustomPath);
on<DataLocationClearState>(_onClearState);
}
final SettingsRepository _repository;
Future<void> _onStarted(
DataLocationInitial event,
Emitter<DataLocationState> emit,
) async {
final userDataLocation =
await _repository.getUserDataLocation().toNullable();
emit(
DataLocationState(
userDataLocation: userDataLocation,
didResetToDefault: false,
),
);
}
Future<void> _onResetToDefault(
DataLocationResetToDefault event,
Emitter<DataLocationState> emit,
) async {
final defaultLocation =
await _repository.resetUserDataLocation().toNullable();
emit(
DataLocationState(
userDataLocation: defaultLocation,
didResetToDefault: true,
),
);
}
Future<void> _onClearState(
DataLocationClearState event,
Emitter<DataLocationState> emit,
) async {
emit(
state.copyWith(
didResetToDefault: false,
),
);
}
Future<void> _onSetCustomPath(
DataLocationSetCustomPath event,
Emitter<DataLocationState> emit,
) async {
final userDataLocation =
await _repository.setCustomLocation(event.path).toNullable();
emit(
state.copyWith(
userDataLocation: userDataLocation,
didResetToDefault: false,
),
);
}
}
@@ -0,0 +1,24 @@
sealed class DataLocationEvent {
const DataLocationEvent();
factory DataLocationEvent.initial() = DataLocationInitial;
factory DataLocationEvent.resetToDefault() = DataLocationResetToDefault;
factory DataLocationEvent.setCustomPath(String path) =
DataLocationSetCustomPath;
factory DataLocationEvent.clearState() = DataLocationClearState;
}
class DataLocationInitial extends DataLocationEvent {}
class DataLocationResetToDefault extends DataLocationEvent {}
class DataLocationSetCustomPath extends DataLocationEvent {
const DataLocationSetCustomPath(this.path);
final String path;
}
class DataLocationClearState extends DataLocationEvent {}
@@ -0,0 +1,29 @@
import 'package:equatable/equatable.dart';
import '../data/models/user_data_location.dart';
class DataLocationState extends Equatable {
const DataLocationState({
required this.userDataLocation,
required this.didResetToDefault,
});
factory DataLocationState.initial() =>
const DataLocationState(userDataLocation: null, didResetToDefault: false);
final UserDataLocation? userDataLocation;
final bool didResetToDefault;
@override
List<Object?> get props => [userDataLocation, didResetToDefault];
DataLocationState copyWith({
UserDataLocation? userDataLocation,
bool? didResetToDefault,
}) {
return DataLocationState(
userDataLocation: userDataLocation ?? this.userDataLocation,
didResetToDefault: didResetToDefault ?? this.didResetToDefault,
);
}
}
@@ -0,0 +1,6 @@
export 'logic/data_location_bloc.dart';
export 'logic/data_location_event.dart';
export 'logic/data_location_state.dart';
export 'data/models/user_data_location.dart';
export 'data/repositories/settings_repository.dart';
export 'data/repositories/rust_settings_repository_impl.dart';
@@ -0,0 +1,5 @@
export 'share_access_level.dart';
export 'share_popover_group_id.dart';
export 'share_role.dart';
export 'shared_user.dart';
export 'share_section_type.dart';
@@ -0,0 +1,57 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
/// The access level a user can have on a shared page.
enum ShareAccessLevel {
/// Can view the page only.
readOnly,
/// Can read and comment on the page.
readAndComment,
/// Can read and write to the page.
readAndWrite,
/// Full access (edit, share, remove, etc.) and can add new users.
fullAccess;
String get title {
switch (this) {
case ShareAccessLevel.readOnly:
return LocaleKeys.shareTab_accessLevel_view.tr();
case ShareAccessLevel.readAndComment:
return LocaleKeys.shareTab_accessLevel_comment.tr();
case ShareAccessLevel.readAndWrite:
return LocaleKeys.shareTab_accessLevel_edit.tr();
case ShareAccessLevel.fullAccess:
return LocaleKeys.shareTab_accessLevel_fullAccess.tr();
}
}
String get subtitle {
switch (this) {
case ShareAccessLevel.readOnly:
return LocaleKeys.shareTab_cantMakeChanges.tr();
case ShareAccessLevel.readAndComment:
return LocaleKeys.shareTab_canMakeAnyChanges.tr();
case ShareAccessLevel.readAndWrite:
return LocaleKeys.shareTab_canMakeAnyChanges.tr();
case ShareAccessLevel.fullAccess:
return LocaleKeys.shareTab_canMakeAnyChanges.tr();
}
}
FlowySvgData get icon {
switch (this) {
case ShareAccessLevel.readOnly:
return FlowySvgs.access_level_view_m;
case ShareAccessLevel.readAndComment:
return FlowySvgs.access_level_edit_m;
case ShareAccessLevel.readAndWrite:
return FlowySvgs.access_level_edit_m;
case ShareAccessLevel.fullAccess:
return FlowySvgs.access_level_edit_m;
}
}
}
@@ -0,0 +1 @@
class SharePopoverGroupId {}
@@ -0,0 +1,10 @@
enum ShareRole {
/// The owner of the shared page.
owner,
/// A guest on the shared page.
guest,
/// A member of the shared page.
member,
}
@@ -0,0 +1,11 @@
/// The type of the shared section.
///
/// - public: the shared section is public, anyone in the workspace can view/edit it.
/// - shared: the shared section is shared, anyone in the shared section can view/edit it.
/// - private: the shared section is private, only the users in the shared section can view/edit it.
enum SharedSectionType {
unknown,
public,
shared,
private;
}
@@ -0,0 +1,25 @@
class SharedGroup {
SharedGroup({
required this.id,
required this.name,
required this.icon,
});
final String id;
final String name;
final String icon;
SharedGroup copyWith({
String? id,
String? name,
String? icon,
}) {
return SharedGroup(
id: id ?? this.id,
name: name ?? this.name,
icon: icon ?? this.icon,
);
}
}
@@ -0,0 +1,47 @@
import 'package:appflowy/features/share_tab/data/models/share_access_level.dart';
import 'package:appflowy/features/share_tab/data/models/share_role.dart';
typedef SharedUsers = List<SharedUser>;
/// Represents a user with a role on a shared page.
class SharedUser {
SharedUser({
required this.email,
required this.name,
required this.role,
required this.accessLevel,
this.avatarUrl,
});
final String email;
/// The name of the user.
final String name;
/// The role of the user.
final ShareRole role;
/// The access level of the user.
final ShareAccessLevel accessLevel;
/// The avatar URL of the user.
///
/// if the avatar is not set, it will be the first letter of the name.
final String? avatarUrl;
SharedUser copyWith({
String? email,
String? name,
ShareRole? role,
ShareAccessLevel? accessLevel,
String? avatarUrl,
}) {
return SharedUser(
email: email ?? this.email,
name: name ?? this.name,
role: role ?? this.role,
accessLevel: accessLevel ?? this.accessLevel,
avatarUrl: avatarUrl ?? this.avatarUrl,
);
}
}
@@ -0,0 +1,198 @@
import 'dart:math';
import 'package:appflowy/features/share_tab/data/models/models.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'share_with_user_repository.dart';
// Move this file to test folder
class LocalShareWithUserRepositoryImpl extends ShareWithUserRepository {
LocalShareWithUserRepositoryImpl();
final SharedUsers _sharedUsers = [
// current user has full access
SharedUser(
email: 'lucas.xu@appflowy.io',
name: 'Lucas Xu - Long long long long long name',
accessLevel: ShareAccessLevel.readOnly,
role: ShareRole.guest,
avatarUrl: 'https://avatar.iran.liara.run/public',
),
// member user has read and write access
SharedUser(
email: 'vivian@appflowy.io',
name: 'Vivian Wang',
accessLevel: ShareAccessLevel.readAndWrite,
role: ShareRole.member,
avatarUrl: 'https://avatar.iran.liara.run/public/girl',
),
// member user has read access
SharedUser(
email: 'shuheng@appflowy.io',
name: 'Shuheng',
accessLevel: ShareAccessLevel.readOnly,
role: ShareRole.member,
avatarUrl: 'https://avatar.iran.liara.run/public/boy',
),
// guest user has read access
SharedUser(
email: 'guest_user_1@appflowy.io',
name: 'Read Only Guest',
accessLevel: ShareAccessLevel.readOnly,
role: ShareRole.guest,
avatarUrl: 'https://avatar.iran.liara.run/public/boy/10',
),
// guest user has read and write access
SharedUser(
email: 'guest_user_2@appflowy.io',
name: 'Read And Write Guest',
accessLevel: ShareAccessLevel.readAndWrite,
role: ShareRole.guest,
avatarUrl: 'https://avatar.iran.liara.run/public/boy/11',
),
// Others
SharedUser(
email: 'member_user_1@appflowy.io',
name: 'Member User 1',
accessLevel: ShareAccessLevel.readAndWrite,
role: ShareRole.member,
avatarUrl: 'https://avatar.iran.liara.run/public/boy/12',
),
SharedUser(
email: 'member_user_2@appflowy.io',
name: 'Member User 2',
accessLevel: ShareAccessLevel.readAndWrite,
role: ShareRole.member,
avatarUrl: 'https://avatar.iran.liara.run/public/boy/13',
),
];
final SharedUsers _availableSharedUsers = [
SharedUser(
email: 'guest_email@appflowy.io',
name: 'Guest',
accessLevel: ShareAccessLevel.readOnly,
role: ShareRole.guest,
avatarUrl: 'https://avatar.iran.liara.run/public/boy/28',
),
SharedUser(
email: 'richard@appflowy.io',
name: 'Richard',
accessLevel: ShareAccessLevel.readAndWrite,
role: ShareRole.member,
avatarUrl: 'https://avatar.iran.liara.run/public/boy/28',
),
];
@override
Future<FlowyResult<SharedUsers, FlowyError>> getSharedUsersInPage({
required String pageId,
}) async {
return FlowySuccess(_sharedUsers);
}
@override
Future<FlowyResult<void, FlowyError>> removeSharedUserFromPage({
required String pageId,
required List<String> emails,
}) async {
for (final email in emails) {
_sharedUsers.removeWhere((user) => user.email == email);
}
return FlowySuccess(null);
}
@override
Future<FlowyResult<void, FlowyError>> sharePageWithUser({
required String pageId,
required ShareAccessLevel accessLevel,
required List<String> emails,
}) async {
for (final email in emails) {
final index = _sharedUsers.indexWhere((user) => user.email == email);
if (index != -1) {
// Update access level if user exists
final user = _sharedUsers[index];
_sharedUsers[index] = SharedUser(
name: user.name,
email: user.email,
accessLevel: accessLevel,
role: user.role,
avatarUrl: user.avatarUrl,
);
} else {
// Add new user
_sharedUsers.add(
SharedUser(
name: email.split('@').first,
email: email,
accessLevel: accessLevel,
role: ShareRole.guest,
avatarUrl:
'https://avatar.iran.liara.run/public/${Random().nextInt(100)}',
),
);
}
}
return FlowySuccess(null);
}
@override
Future<FlowyResult<SharedUsers, FlowyError>> getAvailableSharedUsers({
required String pageId,
}) async {
return FlowySuccess([
..._sharedUsers,
..._availableSharedUsers,
]);
}
@override
Future<FlowyResult<void, FlowyError>> changeRole({
required String workspaceId,
required String email,
required ShareRole role,
}) async {
final index = _sharedUsers.indexWhere((user) => user.email == email);
if (index != -1) {
_sharedUsers[index] = _sharedUsers[index].copyWith(role: role);
}
return FlowySuccess(null);
}
@override
Future<FlowyResult<UserProfilePB, FlowyError>> getCurrentUserProfile() async {
// Simulate fetching current user profile
return FlowySuccess(
UserProfilePB()
..email = 'lucas.xu@appflowy.io'
..name = 'Lucas Xu',
);
}
@override
Future<FlowyResult<SharedSectionType, FlowyError>> getCurrentPageSectionType({
required String pageId,
}) async {
return FlowySuccess(SharedSectionType.private);
}
@override
Future<bool> getUpgradeToProButtonClicked({
required String workspaceId,
}) async {
return false;
}
@override
Future<void> setUpgradeToProButtonClicked({
required String workspaceId,
}) async {
return;
}
}
@@ -0,0 +1,191 @@
import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/features/share_tab/data/models/models.dart';
import 'package:appflowy/features/util/extensions.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:collection/collection.dart';
import 'share_with_user_repository.dart';
class RustShareWithUserRepositoryImpl extends ShareWithUserRepository {
RustShareWithUserRepositoryImpl();
@override
Future<FlowyResult<SharedUsers, FlowyError>> getSharedUsersInPage({
required String pageId,
}) async {
final request = GetSharedUsersPayloadPB(
viewId: pageId,
);
final result = await FolderEventGetSharedUsers(request).send();
return result.fold(
(success) {
Log.debug('get shared users success: $success');
return FlowySuccess(success.sharedUsers);
},
(failure) {
Log.error('get shared users failed: $failure');
return FlowyFailure(failure);
},
);
}
@override
Future<FlowyResult<void, FlowyError>> removeSharedUserFromPage({
required String pageId,
required List<String> emails,
}) async {
final request = RemoveUserFromSharedPagePayloadPB(
viewId: pageId,
emails: emails,
);
final result = await FolderEventRemoveUserFromSharedPage(request).send();
return result.fold(
(success) {
Log.debug('remove users($emails) from shared page($pageId)');
return FlowySuccess(success);
},
(failure) {
Log.error('remove users($emails) from shared page($pageId): $failure');
return FlowyFailure(failure);
},
);
}
@override
Future<FlowyResult<void, FlowyError>> sharePageWithUser({
required String pageId,
required ShareAccessLevel accessLevel,
required List<String> emails,
}) async {
final request = SharePageWithUserPayloadPB(
viewId: pageId,
emails: emails,
accessLevel: accessLevel.accessLevel,
autoConfirm: true,
);
final result = await FolderEventSharePageWithUser(request).send();
return result.fold(
(success) {
Log.debug(
'share page($pageId) with users($emails) with access level($accessLevel)',
);
return FlowySuccess(success);
},
(failure) {
Log.error(
'share page($pageId) with users($emails) with access level($accessLevel): $failure',
);
return FlowyFailure(failure);
},
);
}
@override
Future<FlowyResult<SharedUsers, FlowyError>> getAvailableSharedUsers({
required String pageId,
}) async {
return FlowySuccess([]);
}
@override
Future<FlowyResult<void, FlowyError>> changeRole({
required String workspaceId,
required String email,
required ShareRole role,
}) async {
final request = UpdateWorkspaceMemberPB(
workspaceId: workspaceId,
email: email,
role: role.userRole,
);
final result = await UserEventUpdateWorkspaceMember(request).send();
return result.fold(
(success) {
Log.debug(
'change role($role) for user($email) in workspaceId($workspaceId)',
);
return FlowySuccess(success);
},
(failure) {
Log.error(
'failed to change role($role) for user($email) in workspaceId($workspaceId)',
failure,
);
return FlowyFailure(failure);
},
);
}
@override
Future<FlowyResult<UserProfilePB, FlowyError>> getCurrentUserProfile() async {
final result = await UserEventGetUserProfile().send();
return result;
}
@override
Future<FlowyResult<SharedSectionType, FlowyError>> getCurrentPageSectionType({
required String pageId,
}) async {
final request = ViewIdPB.create()..value = pageId;
final result = await FolderEventGetViewAncestors(request).send();
final ancestors = result.fold(
(s) => s.items,
(f) => <ViewPB>[],
);
final space = ancestors.firstWhereOrNull((e) => e.isSpace);
if (space == null) {
return FlowySuccess(SharedSectionType.unknown);
}
final sectionType = switch (space.spacePermission) {
SpacePermission.publicToAll => SharedSectionType.public,
SpacePermission.private => SharedSectionType.private,
};
return FlowySuccess(sectionType);
}
@override
Future<bool> getUpgradeToProButtonClicked({
required String workspaceId,
}) async {
final result = await getIt<KeyValueStorage>().getWithFormat(
'${KVKeys.hasClickedUpgradeToProButton}_$workspaceId',
(value) => bool.parse(value),
);
if (result == null) {
return false;
}
return result;
}
@override
Future<void> setUpgradeToProButtonClicked({
required String workspaceId,
}) async {
await getIt<KeyValueStorage>().set(
'${KVKeys.hasClickedUpgradeToProButton}_$workspaceId',
'true',
);
}
}
@@ -0,0 +1,60 @@
import 'package:appflowy/features/share_tab/data/models/models.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
/// Abstract repository for sharing with users.
///
/// For example, we're using rust events now, but we can still use the http api
/// for the future.
abstract class ShareWithUserRepository {
/// Gets the list of users and their roles for a shared page.
Future<FlowyResult<SharedUsers, FlowyError>> getSharedUsersInPage({
required String pageId,
});
/// Gets the list of users that are available to be shared with.
Future<FlowyResult<SharedUsers, FlowyError>> getAvailableSharedUsers({
required String pageId,
});
/// Removes a user from a shared page.
Future<FlowyResult<void, FlowyError>> removeSharedUserFromPage({
required String pageId,
required List<String> emails,
});
/// Shares a page with a user, assigning a role.
///
/// If the user is already in the shared page, the access level will be updated.
Future<FlowyResult<void, FlowyError>> sharePageWithUser({
required String pageId,
required ShareAccessLevel accessLevel,
required List<String> emails,
});
/// Change the role of a user in a shared page.
Future<FlowyResult<void, FlowyError>> changeRole({
required String workspaceId,
required String email,
required ShareRole role,
});
/// Get current user profile.
Future<FlowyResult<UserProfilePB, FlowyError>> getCurrentUserProfile();
/// Get current page is in public section or private section.
Future<FlowyResult<SharedSectionType, FlowyError>> getCurrentPageSectionType({
required String pageId,
});
/// Get the upgrade to pro button has been clicked.
Future<bool> getUpgradeToProButtonClicked({
required String workspaceId,
});
/// Set the upgrade to pro button has been clicked.
Future<void> setUpgradeToProButtonClicked({
required String workspaceId,
});
}
@@ -0,0 +1,420 @@
import 'package:appflowy/core/notification/folder_notification.dart';
import 'package:appflowy/features/share_tab/data/models/models.dart';
import 'package:appflowy/features/share_tab/data/repositories/share_with_user_repository.dart';
import 'package:appflowy/features/share_tab/logic/share_tab_event.dart';
import 'package:appflowy/features/share_tab/logic/share_tab_state.dart';
import 'package:appflowy/features/util/extensions.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/shared/share/constants.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:bloc/bloc.dart';
export 'share_tab_event.dart';
export 'share_tab_state.dart';
class ShareTabBloc extends Bloc<ShareTabEvent, ShareTabState> {
ShareTabBloc({
required this.repository,
required this.pageId,
required this.workspaceId,
}) : super(ShareTabState.initial()) {
on<ShareTabEventInitialize>(_onInitial);
on<ShareTabEventLoadSharedUsers>(_onGetSharedUsers);
on<ShareTabEventInviteUsers>(_onShare);
on<ShareTabEventRemoveUsers>(_onRemove);
on<ShareTabEventUpdateUserAccessLevel>(_onUpdateAccessLevel);
on<ShareTabEventUpdateGeneralAccessLevel>(_onUpdateGeneralAccess);
on<ShareTabEventCopyShareLink>(_onCopyLink);
on<ShareTabEventSearchAvailableUsers>(_onSearchAvailableUsers);
on<ShareTabEventConvertToMember>(_onTurnIntoMember);
on<ShareTabEventClearState>(_onClearState);
on<ShareTabEventUpdateSharedUsers>(_onUpdateSharedUsers);
on<ShareTabEventUpgradeToProClicked>(_onUpgradeToProClicked);
}
final ShareWithUserRepository repository;
final String workspaceId;
final String pageId;
// Used to listen for shared view updates.
FolderNotificationListener? _folderNotificationListener;
@override
Future<void> close() async {
await _folderNotificationListener?.stop();
await super.close();
}
Future<void> _onInitial(
ShareTabEventInitialize event,
Emitter<ShareTabState> emit,
) async {
if (!FeatureFlag.sharedSection.isOn) {
emit(
state.copyWith(
errorMessage: 'Sharing is currently disabled.',
users: [],
isLoading: false,
),
);
return;
}
_initFolderNotificationListener();
final result = await repository.getCurrentUserProfile();
final currentUser = result.fold(
(user) => user,
(error) => null,
);
final sectionTypeResult = await repository.getCurrentPageSectionType(
pageId: pageId,
);
final sectionType = sectionTypeResult.fold(
(type) => type,
(error) => SharedSectionType.unknown,
);
final shareLink = ShareConstants.buildShareUrl(
workspaceId: workspaceId,
viewId: pageId,
);
final users = await _getSharedUsers();
final hasClickedUpgradeToPro =
await repository.getUpgradeToProButtonClicked(
workspaceId: workspaceId,
);
emit(
state.copyWith(
currentUser: currentUser,
shareLink: shareLink,
users: users,
sectionType: sectionType,
hasClickedUpgradeToPro: hasClickedUpgradeToPro,
),
);
}
Future<void> _onGetSharedUsers(
ShareTabEventLoadSharedUsers event,
Emitter<ShareTabState> emit,
) async {
if (!FeatureFlag.sharedSection.isOn) {
return;
}
emit(
state.copyWith(
errorMessage: '',
),
);
final result = await repository.getSharedUsersInPage(
pageId: pageId,
);
result.fold(
(users) => emit(
state.copyWith(
users: users,
initialResult: FlowySuccess(null),
),
),
(error) => emit(
state.copyWith(
errorMessage: error.msg,
initialResult: FlowyFailure(error),
),
),
);
}
Future<void> _onShare(
ShareTabEventInviteUsers event,
Emitter<ShareTabState> emit,
) async {
emit(
state.copyWith(
errorMessage: '',
),
);
final result = await repository.sharePageWithUser(
pageId: pageId,
accessLevel: event.accessLevel,
emails: event.emails,
);
await result.fold(
(_) async {
final users = await _getSharedUsers();
emit(
state.copyWith(
shareResult: FlowySuccess(null),
users: users,
),
);
},
(error) async {
emit(
state.copyWith(
errorMessage: error.msg,
shareResult: FlowyFailure(error),
),
);
},
);
}
Future<void> _onRemove(
ShareTabEventRemoveUsers event,
Emitter<ShareTabState> emit,
) async {
emit(
state.copyWith(
errorMessage: '',
),
);
final result = await repository.removeSharedUserFromPage(
pageId: pageId,
emails: event.emails,
);
await result.fold(
(_) async {
final users = await _getSharedUsers();
emit(
state.copyWith(
removeResult: FlowySuccess(null),
users: users,
),
);
},
(error) async {
emit(
state.copyWith(
isLoading: false,
removeResult: FlowyFailure(error),
),
);
},
);
}
Future<void> _onUpdateAccessLevel(
ShareTabEventUpdateUserAccessLevel event,
Emitter<ShareTabState> emit,
) async {
emit(
state.copyWith(),
);
final result = await repository.sharePageWithUser(
pageId: pageId,
accessLevel: event.accessLevel,
emails: [event.email],
);
await result.fold(
(_) async {
final users = await _getSharedUsers();
emit(
state.copyWith(
updateAccessLevelResult: FlowySuccess(null),
users: users,
),
);
},
(error) async {
emit(
state.copyWith(
errorMessage: error.msg,
isLoading: false,
),
);
},
);
}
void _onUpdateGeneralAccess(
ShareTabEventUpdateGeneralAccessLevel event,
Emitter<ShareTabState> emit,
) {
emit(
state.copyWith(
generalAccessRole: event.accessLevel,
),
);
}
void _onCopyLink(
ShareTabEventCopyShareLink event,
Emitter<ShareTabState> emit,
) {
getIt<ClipboardService>().setData(
ClipboardServiceData(
plainText: event.link,
),
);
emit(
state.copyWith(
linkCopied: true,
),
);
}
Future<void> _onSearchAvailableUsers(
ShareTabEventSearchAvailableUsers event,
Emitter<ShareTabState> emit,
) async {
emit(
state.copyWith(
errorMessage: '',
),
);
final result = await repository.getAvailableSharedUsers(pageId: pageId);
result.fold(
(users) {
// filter by email and name
final availableUsers = users.where((user) {
final query = event.query.toLowerCase();
return user.name.toLowerCase().contains(query) ||
user.email.toLowerCase().contains(query);
}).toList();
emit(
state.copyWith(
availableUsers: availableUsers,
),
);
},
(error) => emit(
state.copyWith(
errorMessage: error.msg,
availableUsers: [],
),
),
);
}
Future<void> _onTurnIntoMember(
ShareTabEventConvertToMember event,
Emitter<ShareTabState> emit,
) async {
emit(
state.copyWith(
errorMessage: '',
),
);
final result = await repository.changeRole(
workspaceId: workspaceId,
email: event.email,
role: ShareRole.member,
);
await result.fold(
(_) async {
final users = await _getSharedUsers();
emit(
state.copyWith(
turnIntoMemberResult: FlowySuccess(null),
users: users,
),
);
},
(error) async {
emit(
state.copyWith(
errorMessage: error.msg,
turnIntoMemberResult: FlowyFailure(error),
),
);
},
);
}
Future<SharedUsers> _getSharedUsers() async {
final shareResult = await repository.getSharedUsersInPage(
pageId: pageId,
);
return shareResult.fold(
(users) => users,
(error) => state.users,
);
}
void _onClearState(
ShareTabEventClearState event,
Emitter<ShareTabState> emit,
) {
emit(
state.copyWith(
errorMessage: '',
),
);
}
void _onUpdateSharedUsers(
ShareTabEventUpdateSharedUsers event,
Emitter<ShareTabState> emit,
) {
emit(
state.copyWith(
users: event.users,
),
);
}
Future<void> _onUpgradeToProClicked(
ShareTabEventUpgradeToProClicked event,
Emitter<ShareTabState> emit,
) async {
await repository.setUpgradeToProButtonClicked(
workspaceId: workspaceId,
);
emit(
state.copyWith(
hasClickedUpgradeToPro: true,
),
);
}
void _initFolderNotificationListener() {
_folderNotificationListener = FolderNotificationListener(
objectId: pageId,
handler: (notification, result) {
if (notification == FolderNotification.DidUpdateSharedUsers) {
final response = result.fold(
(payload) {
final repeatedSharedUsers =
RepeatedSharedUserPB.fromBuffer(payload);
return repeatedSharedUsers;
},
(error) => null,
);
Log.debug('update shared users: $response');
if (response != null) {
add(
ShareTabEvent.updateSharedUsers(
users: response.sharedUsers.reversed.toList(),
),
);
}
}
},
);
}
}
@@ -0,0 +1,154 @@
import 'package:appflowy/features/share_tab/data/models/models.dart';
sealed class ShareTabEvent {
const ShareTabEvent();
// Factory functions for creating events
factory ShareTabEvent.initialize() => const ShareTabEventInitialize();
factory ShareTabEvent.loadSharedUsers() =>
const ShareTabEventLoadSharedUsers();
factory ShareTabEvent.inviteUsers({
required List<String> emails,
required ShareAccessLevel accessLevel,
}) =>
ShareTabEventInviteUsers(emails: emails, accessLevel: accessLevel);
factory ShareTabEvent.removeUsers({
required List<String> emails,
}) =>
ShareTabEventRemoveUsers(emails: emails);
factory ShareTabEvent.updateUserAccessLevel({
required String email,
required ShareAccessLevel accessLevel,
}) =>
ShareTabEventUpdateUserAccessLevel(
email: email,
accessLevel: accessLevel,
);
factory ShareTabEvent.updateGeneralAccessLevel({
required ShareAccessLevel accessLevel,
}) =>
ShareTabEventUpdateGeneralAccessLevel(accessLevel: accessLevel);
factory ShareTabEvent.copyShareLink({
required String link,
}) =>
ShareTabEventCopyShareLink(link: link);
factory ShareTabEvent.searchAvailableUsers({
required String query,
}) =>
ShareTabEventSearchAvailableUsers(query: query);
factory ShareTabEvent.convertToMember({
required String email,
}) =>
ShareTabEventConvertToMember(email: email);
factory ShareTabEvent.clearState() => const ShareTabEventClearState();
factory ShareTabEvent.updateSharedUsers({
required SharedUsers users,
}) =>
ShareTabEventUpdateSharedUsers(users: users);
factory ShareTabEvent.upgradeToProClicked() =>
const ShareTabEventUpgradeToProClicked();
}
/// Initializes the share tab bloc.
class ShareTabEventInitialize extends ShareTabEvent {
const ShareTabEventInitialize();
}
/// Loads the shared users for the current page.
class ShareTabEventLoadSharedUsers extends ShareTabEvent {
const ShareTabEventLoadSharedUsers();
}
/// Invites users to the page with specified access level.
class ShareTabEventInviteUsers extends ShareTabEvent {
const ShareTabEventInviteUsers({
required this.emails,
required this.accessLevel,
});
final List<String> emails;
final ShareAccessLevel accessLevel;
}
/// Removes users from the shared page.
class ShareTabEventRemoveUsers extends ShareTabEvent {
const ShareTabEventRemoveUsers({
required this.emails,
});
final List<String> emails;
}
/// Updates the access level for a specific user.
class ShareTabEventUpdateUserAccessLevel extends ShareTabEvent {
const ShareTabEventUpdateUserAccessLevel({
required this.email,
required this.accessLevel,
});
final String email;
final ShareAccessLevel accessLevel;
}
/// Updates the general access level for all users.
class ShareTabEventUpdateGeneralAccessLevel extends ShareTabEvent {
const ShareTabEventUpdateGeneralAccessLevel({
required this.accessLevel,
});
final ShareAccessLevel accessLevel;
}
/// Copies the share link to the clipboard.
class ShareTabEventCopyShareLink extends ShareTabEvent {
const ShareTabEventCopyShareLink({
required this.link,
});
final String link;
}
/// Searches for available users by name or email.
class ShareTabEventSearchAvailableUsers extends ShareTabEvent {
const ShareTabEventSearchAvailableUsers({
required this.query,
});
final String query;
}
/// Converts a user into a member.
class ShareTabEventConvertToMember extends ShareTabEvent {
const ShareTabEventConvertToMember({
required this.email,
});
final String email;
}
class ShareTabEventClearState extends ShareTabEvent {
const ShareTabEventClearState();
}
class ShareTabEventUpdateSharedUsers extends ShareTabEvent {
const ShareTabEventUpdateSharedUsers({
required this.users,
});
final SharedUsers users;
}
class ShareTabEventUpgradeToProClicked extends ShareTabEvent {
const ShareTabEventUpgradeToProClicked();
}
@@ -0,0 +1,126 @@
import 'package:appflowy/features/share_tab/data/models/models.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
class ShareTabState {
factory ShareTabState.initial() => const ShareTabState();
const ShareTabState({
this.currentUser,
this.users = const [],
this.availableUsers = const [],
this.isLoading = false,
this.errorMessage = '',
this.shareLink = '',
this.generalAccessRole,
this.linkCopied = false,
this.sectionType = SharedSectionType.private,
this.initialResult,
this.shareResult,
this.removeResult,
this.updateAccessLevelResult,
this.turnIntoMemberResult,
this.hasClickedUpgradeToPro = false,
});
final UserProfilePB? currentUser;
final SharedUsers users;
final SharedUsers availableUsers;
final bool isLoading;
final String errorMessage;
final String shareLink;
final ShareAccessLevel? generalAccessRole;
final bool linkCopied;
final SharedSectionType sectionType;
final FlowyResult<void, FlowyError>? initialResult;
final FlowyResult<void, FlowyError>? shareResult;
final FlowyResult<void, FlowyError>? removeResult;
final FlowyResult<void, FlowyError>? updateAccessLevelResult;
final FlowyResult<void, FlowyError>? turnIntoMemberResult;
final bool hasClickedUpgradeToPro;
ShareTabState copyWith({
UserProfilePB? currentUser,
SharedUsers? users,
SharedUsers? availableUsers,
bool? isLoading,
String? errorMessage,
String? shareLink,
ShareAccessLevel? generalAccessRole,
bool? linkCopied,
SharedSectionType? sectionType,
FlowyResult<void, FlowyError>? initialResult,
FlowyResult<void, FlowyError>? shareResult,
FlowyResult<void, FlowyError>? removeResult,
FlowyResult<void, FlowyError>? updateAccessLevelResult,
FlowyResult<void, FlowyError>? turnIntoMemberResult,
bool? hasClickedUpgradeToPro,
}) {
return ShareTabState(
currentUser: currentUser ?? this.currentUser,
users: users ?? this.users,
availableUsers: availableUsers ?? this.availableUsers,
isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage ?? this.errorMessage,
shareLink: shareLink ?? this.shareLink,
generalAccessRole: generalAccessRole ?? this.generalAccessRole,
linkCopied: linkCopied ?? this.linkCopied,
sectionType: sectionType ?? this.sectionType,
initialResult: initialResult,
shareResult: shareResult,
removeResult: removeResult,
updateAccessLevelResult: updateAccessLevelResult,
turnIntoMemberResult: turnIntoMemberResult,
hasClickedUpgradeToPro:
hasClickedUpgradeToPro ?? this.hasClickedUpgradeToPro,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is ShareTabState &&
other.currentUser == currentUser &&
other.users == users &&
other.availableUsers == availableUsers &&
other.isLoading == isLoading &&
other.errorMessage == errorMessage &&
other.shareLink == shareLink &&
other.generalAccessRole == generalAccessRole &&
other.linkCopied == linkCopied &&
other.sectionType == sectionType &&
other.initialResult == initialResult &&
other.shareResult == shareResult &&
other.removeResult == removeResult &&
other.updateAccessLevelResult == updateAccessLevelResult &&
other.turnIntoMemberResult == turnIntoMemberResult &&
other.hasClickedUpgradeToPro == hasClickedUpgradeToPro;
}
@override
int get hashCode {
return Object.hash(
currentUser,
users,
availableUsers,
isLoading,
errorMessage,
shareLink,
generalAccessRole,
linkCopied,
sectionType,
initialResult,
shareResult,
removeResult,
updateAccessLevelResult,
turnIntoMemberResult,
hasClickedUpgradeToPro,
);
}
@override
String toString() {
return 'ShareTabState(currentUser: $currentUser, users: $users, availableUsers: $availableUsers, isLoading: $isLoading, errorMessage: $errorMessage, shareLink: $shareLink, generalAccessRole: $generalAccessRole, shareSectionType: $SharedSectionType, linkCopied: $linkCopied, initialResult: $initialResult, shareResult: $shareResult, removeResult: $removeResult, updateAccessLevelResult: $updateAccessLevelResult, turnIntoMemberResult: $turnIntoMemberResult, hasClickedUpgradeToPro: $hasClickedUpgradeToPro)';
}
}
@@ -0,0 +1,282 @@
import 'package:appflowy/features/share_tab/data/models/models.dart';
import 'package:appflowy/features/share_tab/data/models/shared_group.dart';
import 'package:appflowy/features/share_tab/logic/share_tab_bloc.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/copy_link_widget.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/general_access_section.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/people_with_access_section.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/share_with_user_widget.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/upgrade_to_pro_widget.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pbenum.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class ShareTab extends StatefulWidget {
const ShareTab({
super.key,
required this.workspaceId,
required this.pageId,
required this.workspaceName,
required this.workspaceIcon,
required this.isInProPlan,
required this.onUpgradeToPro,
});
final String workspaceId;
final String pageId;
// these 2 values should be provided by the share tab bloc
final String workspaceName;
final String workspaceIcon;
final bool isInProPlan;
final VoidCallback onUpgradeToPro;
@override
State<ShareTab> createState() => _ShareTabState();
}
class _ShareTabState extends State<ShareTab> {
final TextEditingController controller = TextEditingController();
late final ShareTabBloc shareTabBloc;
@override
void initState() {
super.initState();
shareTabBloc = context.read<ShareTabBloc>();
}
@override
void dispose() {
controller.dispose();
shareTabBloc.add(ShareTabEvent.clearState());
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return BlocConsumer<ShareTabBloc, ShareTabState>(
listener: (context, state) {
_onListenShareWithUserState(context, state);
},
builder: (context, state) {
if (state.isLoading) {
return const SizedBox.shrink();
}
final currentUser = state.currentUser;
final accessLevel = state.users
.firstWhereOrNull(
(user) => user.email == currentUser?.email,
)
?.accessLevel;
final isFullAccess = accessLevel == ShareAccessLevel.fullAccess;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// share page with user by email
// only user with full access can invite others
VSpace(theme.spacing.l),
ShareWithUserWidget(
controller: controller,
disabled: !isFullAccess,
onInvite: (emails) => _onSharePageWithUser(
context,
emails: emails,
accessLevel: ShareAccessLevel.readOnly,
),
),
if (!widget.isInProPlan && !state.hasClickedUpgradeToPro) ...[
UpgradeToProWidget(
onClose: () {
context.read<ShareTabBloc>().add(
ShareTabEvent.upgradeToProClicked(),
);
},
onUpgrade: widget.onUpgradeToPro,
),
],
// shared users
if (state.users.isNotEmpty) ...[
VSpace(theme.spacing.l),
PeopleWithAccessSection(
isInPublicPage: state.sectionType == SharedSectionType.public,
currentUserEmail: state.currentUser?.email ?? '',
users: state.users,
callbacks: _buildPeopleWithAccessSectionCallbacks(context),
),
],
// general access
if (state.sectionType == SharedSectionType.public) ...[
VSpace(theme.spacing.m),
GeneralAccessSection(
group: SharedGroup(
id: widget.workspaceId,
name: widget.workspaceName,
icon: widget.workspaceIcon,
),
),
],
// copy link
VSpace(theme.spacing.xl),
CopyLinkWidget(shareLink: state.shareLink),
VSpace(theme.spacing.m),
],
);
},
);
}
void _onSharePageWithUser(
BuildContext context, {
required List<String> emails,
required ShareAccessLevel accessLevel,
}) {
context.read<ShareTabBloc>().add(
ShareTabEvent.inviteUsers(emails: emails, accessLevel: accessLevel),
);
}
PeopleWithAccessSectionCallbacks _buildPeopleWithAccessSectionCallbacks(
BuildContext context,
) {
return PeopleWithAccessSectionCallbacks(
onSelectAccessLevel: (user, accessLevel) {
context.read<ShareTabBloc>().add(
ShareTabEvent.updateUserAccessLevel(
email: user.email,
accessLevel: accessLevel,
),
);
},
onTurnIntoMember: (user) {
context.read<ShareTabBloc>().add(
ShareTabEvent.convertToMember(email: user.email),
);
},
onRemoveAccess: (user) {
// show a dialog to confirm the action when removing self access
final theme = AppFlowyTheme.of(context);
final shareTabBloc = context.read<ShareTabBloc>();
final removingSelf =
user.email == shareTabBloc.state.currentUser?.email;
if (removingSelf) {
showConfirmDialog(
context: context,
title: 'Remove your own access',
titleStyle: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
description: '',
style: ConfirmPopupStyle.cancelAndOk,
confirmLabel: 'Remove',
onConfirm: (_) {
shareTabBloc.add(
ShareTabEvent.removeUsers(emails: [user.email]),
);
},
);
} else {
shareTabBloc.add(
ShareTabEvent.removeUsers(emails: [user.email]),
);
}
},
);
}
void _onListenShareWithUserState(
BuildContext context,
ShareTabState state,
) {
final shareResult = state.shareResult;
if (shareResult != null) {
shareResult.fold((success) {
// clear the controller to avoid showing the previous emails
controller.clear();
showToastNotification(
message: LocaleKeys.shareTab_invitationSent.tr(),
);
}, (error) {
String message;
switch (error.code) {
case ErrorCode.InvalidGuest:
message = LocaleKeys.shareTab_emailAlreadyInList.tr();
break;
case ErrorCode.FreePlanGuestLimitExceeded:
message = LocaleKeys.shareTab_upgradeToProToInviteGuests.tr();
break;
case ErrorCode.PaidPlanGuestLimitExceeded:
message = LocaleKeys.shareTab_maxGuestsReached.tr();
break;
default:
message = error.msg;
}
showToastNotification(
message: message,
type: ToastificationType.error,
);
});
}
final removeResult = state.removeResult;
if (removeResult != null) {
removeResult.fold((success) {
showToastNotification(
message: LocaleKeys.shareTab_removedGuestSuccessfully.tr(),
);
}, (error) {
showToastNotification(
message: error.msg,
type: ToastificationType.error,
);
});
}
final updateAccessLevelResult = state.updateAccessLevelResult;
if (updateAccessLevelResult != null) {
updateAccessLevelResult.fold((success) {
showToastNotification(
message: LocaleKeys.shareTab_updatedAccessLevelSuccessfully.tr(),
);
}, (error) {
showToastNotification(
message: error.msg,
type: ToastificationType.error,
);
});
}
final turnIntoMemberResult = state.turnIntoMemberResult;
if (turnIntoMemberResult != null) {
turnIntoMemberResult.fold((success) {
showToastNotification(
message: LocaleKeys.shareTab_turnedIntoMemberSuccessfully.tr(),
);
}, (error) {
showToastNotification(
message: error.msg,
type: ToastificationType.error,
);
});
}
}
}
@@ -0,0 +1,136 @@
import 'package:appflowy/features/share_tab/data/models/models.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
class AccessLevelListCallbacks {
const AccessLevelListCallbacks({
required this.onSelectAccessLevel,
required this.onTurnIntoMember,
required this.onRemoveAccess,
});
factory AccessLevelListCallbacks.none() {
return AccessLevelListCallbacks(
onSelectAccessLevel: (_) {},
onTurnIntoMember: () {},
onRemoveAccess: () {},
);
}
/// Callback when an access level is selected
final void Function(ShareAccessLevel accessLevel) onSelectAccessLevel;
/// Callback when the "Turn into Member" option is selected
final VoidCallback onTurnIntoMember;
/// Callback when the "Remove access" option is selected
final VoidCallback onRemoveAccess;
/// Copy
AccessLevelListCallbacks copyWith({
VoidCallback? onRemoveAccess,
VoidCallback? onTurnIntoMember,
void Function(ShareAccessLevel accessLevel)? onSelectAccessLevel,
}) {
return AccessLevelListCallbacks(
onRemoveAccess: onRemoveAccess ?? this.onRemoveAccess,
onTurnIntoMember: onTurnIntoMember ?? this.onTurnIntoMember,
onSelectAccessLevel: onSelectAccessLevel ?? this.onSelectAccessLevel,
);
}
}
enum AdditionalUserManagementOptions {
turnIntoMember,
removeAccess,
}
/// A widget that displays a list of access levels for sharing.
///
/// This widget is used in a popover to allow users to select different access levels
/// for shared content, as well as options to turn users into members or remove access.
class AccessLevelListWidget extends StatelessWidget {
const AccessLevelListWidget({
super.key,
required this.selectedAccessLevel,
required this.callbacks,
required this.supportedAccessLevels,
required this.additionalUserManagementOptions,
});
/// The currently selected access level
final ShareAccessLevel selectedAccessLevel;
/// Callbacks
final AccessLevelListCallbacks callbacks;
/// Supported access levels
final List<ShareAccessLevel> supportedAccessLevels;
/// Additional user management options
final List<AdditionalUserManagementOptions> additionalUserManagementOptions;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return AFMenu(
width: supportedAccessLevels.isNotEmpty ? 260 : 160,
children: [
// Display all available access level options
if (supportedAccessLevels.isNotEmpty) ...[
...supportedAccessLevels.map(
(accessLevel) => _buildAccessLevelItem(
context,
accessLevel: accessLevel,
onTap: () => callbacks.onSelectAccessLevel(accessLevel),
),
),
AFDivider(spacing: theme.spacing.m),
],
// Additional user management options
if (additionalUserManagementOptions
.contains(AdditionalUserManagementOptions.turnIntoMember))
AFTextMenuItem(
title: LocaleKeys.shareTab_turnIntoMember.tr(),
onTap: callbacks.onTurnIntoMember,
),
if (additionalUserManagementOptions
.contains(AdditionalUserManagementOptions.removeAccess))
AFTextMenuItem(
title: LocaleKeys.shareTab_removeAccess.tr(),
titleColor: theme.textColorScheme.error,
onTap: callbacks.onRemoveAccess,
),
],
);
}
Widget _buildAccessLevelItem(
BuildContext context, {
required ShareAccessLevel accessLevel,
required VoidCallback onTap,
}) {
return AFTextMenuItem(
title: accessLevel.title,
subtitle: accessLevel.subtitle,
showSelectedBackground: false,
selected: selectedAccessLevel == accessLevel,
leading: FlowySvg(
accessLevel.icon,
),
// Show a checkmark icon for the currently selected access level
trailing: selectedAccessLevel == accessLevel
? FlowySvg(
FlowySvgs.m_blue_check_s,
blendMode: null,
)
: null,
onTap: onTap,
);
}
}
@@ -0,0 +1,85 @@
import 'package:appflowy/features/share_tab/logic/share_tab_bloc.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class CopyLinkWidget extends StatelessWidget {
const CopyLinkWidget({
super.key,
required this.shareLink,
});
final String shareLink;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Container(
padding: EdgeInsets.symmetric(
vertical: theme.spacing.m,
horizontal: theme.spacing.l,
),
decoration: BoxDecoration(
color: theme.surfaceContainerColorScheme.layer01,
borderRadius: BorderRadius.circular(theme.spacing.m),
border: Border.all(
color: theme.borderColorScheme.primary,
),
),
child: Row(
children: [
FlowySvg(
FlowySvgs.toolbar_link_m,
),
HSpace(theme.spacing.m),
Expanded(
child: Text(
LocaleKeys.shareTab_peopleAboveCanAccessWithTheLink.tr(),
style: theme.textStyle.caption.standard(
color: theme.textColorScheme.primary,
),
overflow: TextOverflow.ellipsis,
),
),
AFOutlinedTextButton.normal(
text: LocaleKeys.shareTab_copyLink.tr(),
size: AFButtonSize.l,
padding: EdgeInsets.symmetric(
horizontal: theme.spacing.l,
vertical: theme.spacing.s,
),
backgroundColor: (context, isHovering, disabled) {
final theme = AppFlowyTheme.of(context);
if (disabled) {
return theme.fillColorScheme.content;
}
if (isHovering) {
return theme.fillColorScheme.contentHover;
}
return theme.surfaceColorScheme.layer02;
},
onTap: () {
context.read<ShareTabBloc>().add(
ShareTabEvent.copyShareLink(link: shareLink),
);
if (FlowyRunner.currentMode.isUnitTest) {
return;
}
showToastNotification(
message: LocaleKeys.shareTab_copiedLinkToClipboard.tr(),
);
},
),
],
),
);
}
}
@@ -0,0 +1,106 @@
import 'package:appflowy/features/share_tab/data/models/models.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/access_level_list_widget.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
class EditAccessLevelWidget extends StatefulWidget {
const EditAccessLevelWidget({
super.key,
required this.callbacks,
required this.selectedAccessLevel,
required this.supportedAccessLevels,
required this.additionalUserManagementOptions,
this.disabled = false,
});
/// Callbacks
final AccessLevelListCallbacks callbacks;
/// The currently selected access level
final ShareAccessLevel selectedAccessLevel;
/// Whether the widget is disabled
final bool disabled;
/// Supported access levels
final List<ShareAccessLevel> supportedAccessLevels;
/// Additional user management options
final List<AdditionalUserManagementOptions> additionalUserManagementOptions;
@override
State<EditAccessLevelWidget> createState() => _EditAccessLevelWidgetState();
}
class _EditAccessLevelWidgetState extends State<EditAccessLevelWidget> {
final popoverController = AFPopoverController();
@override
void dispose() {
popoverController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return AFPopover(
padding: EdgeInsets.zero,
decoration: BoxDecoration(), // the access level widget has a border
controller: popoverController,
popover: (_) {
return AccessLevelListWidget(
selectedAccessLevel: widget.selectedAccessLevel,
supportedAccessLevels: widget.supportedAccessLevels,
additionalUserManagementOptions:
widget.additionalUserManagementOptions,
callbacks: widget.callbacks.copyWith(
onSelectAccessLevel: (accessLevel) {
widget.callbacks.onSelectAccessLevel(accessLevel);
popoverController.hide();
},
onRemoveAccess: () {
widget.callbacks.onRemoveAccess();
popoverController.hide();
},
),
);
},
child: AFGhostButton.normal(
disabled: widget.disabled,
onTap: () {
popoverController.show();
},
padding: EdgeInsets.symmetric(
vertical: theme.spacing.s,
horizontal: theme.spacing.l,
),
builder: (context, isHovering, disabled) {
return Row(
children: [
Text(
widget.selectedAccessLevel.title,
style: theme.textStyle.body.standard(
color: disabled
? theme.textColorScheme.secondary
: theme.textColorScheme.primary,
),
),
HSpace(theme.spacing.xs),
FlowySvg(
FlowySvgs.arrow_down_s,
color: theme.textColorScheme.secondary,
),
],
);
},
),
);
}
}
@@ -0,0 +1,32 @@
import 'package:appflowy/features/share_tab/data/models/shared_group.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/shared_group_widget.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
class GeneralAccessSection extends StatelessWidget {
const GeneralAccessSection({
super.key,
required this.group,
});
final SharedGroup group;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return AFMenuSection(
title: LocaleKeys.shareTab_generalAccess.tr(),
padding: EdgeInsets.symmetric(
vertical: theme.spacing.xs,
horizontal: theme.spacing.m,
),
children: [
SharedGroupWidget(
group: group,
),
],
);
}
}
@@ -0,0 +1,34 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
class GuestTag extends StatelessWidget {
const GuestTag({super.key});
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Container(
padding: EdgeInsets.only(
left: theme.spacing.m,
right: theme.spacing.m,
bottom: 2,
),
decoration: BoxDecoration(
color: theme.fillColorScheme.warningLight,
borderRadius: BorderRadius.circular(theme.spacing.s),
),
child: Text(
LocaleKeys.shareTab_guest.tr(),
style: theme.textStyle.caption
.standard(
color: theme.textColorScheme.warning,
)
.copyWith(
height: 16.0 / 12.0,
),
),
);
}
}
@@ -0,0 +1,83 @@
import 'package:appflowy/features/share_tab/data/models/models.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/access_level_list_widget.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/shared_user_widget.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
class PeopleWithAccessSectionCallbacks {
const PeopleWithAccessSectionCallbacks({
required this.onRemoveAccess,
required this.onTurnIntoMember,
required this.onSelectAccessLevel,
});
factory PeopleWithAccessSectionCallbacks.none() {
return PeopleWithAccessSectionCallbacks(
onSelectAccessLevel: (_, __) {},
onTurnIntoMember: (_) {},
onRemoveAccess: (_) {},
);
}
/// Callback when an access level is selected
final void Function(SharedUser user, ShareAccessLevel accessLevel)
onSelectAccessLevel;
/// Callback when the "Turn into Member" option is selected
final void Function(SharedUser user) onTurnIntoMember;
/// Callback when the "Remove access" option is selected
final void Function(SharedUser user) onRemoveAccess;
}
class PeopleWithAccessSection extends StatelessWidget {
const PeopleWithAccessSection({
super.key,
required this.currentUserEmail,
required this.users,
required this.isInPublicPage,
this.callbacks,
});
final String currentUserEmail;
final SharedUsers users;
final bool isInPublicPage;
final PeopleWithAccessSectionCallbacks? callbacks;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final currentUser = users.firstWhereOrNull(
(user) => user.email == currentUserEmail,
);
return AFMenuSection(
title: 'People with access',
constraints: BoxConstraints(
maxHeight: 240,
),
padding: EdgeInsets.symmetric(
vertical: theme.spacing.xs,
horizontal: theme.spacing.m,
),
children: users.map((user) {
if (currentUser == null) {
return const SizedBox.shrink();
}
return SharedUserWidget(
user: user,
currentUser: currentUser,
isInPublicPage: isInPublicPage,
callbacks: AccessLevelListCallbacks(
onRemoveAccess: () => callbacks?.onRemoveAccess.call(user),
onTurnIntoMember: () => callbacks?.onTurnIntoMember.call(user),
onSelectAccessLevel: (accessLevel) =>
callbacks?.onSelectAccessLevel.call(user, accessLevel),
),
);
}).toList(),
);
}
}
@@ -0,0 +1,91 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:string_validator/string_validator.dart';
class ShareWithUserWidget extends StatefulWidget {
const ShareWithUserWidget({
super.key,
required this.onInvite,
this.controller,
this.disabled = false,
this.tooltip,
});
final TextEditingController? controller;
final void Function(List<String> emails) onInvite;
final bool disabled;
final String? tooltip;
@override
State<ShareWithUserWidget> createState() => _ShareWithUserWidgetState();
}
class _ShareWithUserWidgetState extends State<ShareWithUserWidget> {
late final TextEditingController effectiveController;
bool isButtonEnabled = false;
@override
void initState() {
super.initState();
effectiveController = widget.controller ?? TextEditingController();
effectiveController.addListener(_onTextChanged);
}
@override
void dispose() {
if (widget.controller == null) {
effectiveController.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final Widget child = Row(
children: [
Expanded(
child: AFTextField(
controller: effectiveController,
size: AFTextFieldSize.m,
hintText: LocaleKeys.shareTab_inviteByEmail.tr(),
),
),
HSpace(theme.spacing.s),
AFFilledTextButton.primary(
text: LocaleKeys.shareTab_invite.tr(),
disabled: !isButtonEnabled,
onTap: () {
widget.onInvite(effectiveController.text.trim().split(','));
},
),
],
);
if (widget.disabled) {
return FlowyTooltip(
message:
widget.tooltip ?? LocaleKeys.shareTab_onlyFullAccessCanInvite.tr(),
child: IgnorePointer(
child: child,
),
);
}
return child;
}
void _onTextChanged() {
setState(() {
final texts = effectiveController.text.trim().split(',');
isButtonEnabled = texts.isNotEmpty && texts.every(isEmail);
});
}
}
@@ -0,0 +1,97 @@
import 'package:appflowy/features/share_tab/data/models/share_access_level.dart';
import 'package:appflowy/features/share_tab/data/models/shared_group.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/access_level_list_widget.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/edit_access_level_widget.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
class SharedGroupWidget extends StatelessWidget {
const SharedGroupWidget({
super.key,
required this.group,
});
final SharedGroup group;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return AFMenuItem(
padding: EdgeInsets.symmetric(
vertical: theme.spacing.s,
horizontal: theme.spacing.m,
),
leading: _buildLeading(context),
title: _buildTitle(context),
subtitle: _buildSubtitle(context),
trailing: _buildTrailing(context),
onTap: () {},
);
}
Widget _buildLeading(BuildContext context) {
return WorkspaceIcon(
isEditable: false,
workspaceIcon: group.icon,
workspaceName: group.name,
iconSize: 32.0,
emojiSize: 24.0,
fontSize: 16.0,
onSelected: (r) {},
borderRadius: 8.0,
showBorder: false,
figmaLineHeight: 24.0,
);
}
Widget _buildTitle(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Row(
children: [
Flexible(
child: Text(
LocaleKeys.shareTab_anyoneAtWorkspace.tr(
namedArgs: {
'workspace': group.name,
},
),
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
overflow: TextOverflow.ellipsis,
),
),
// HSpace(theme.spacing.xs),
// FlowySvg(
// FlowySvgs.arrow_down_s,
// color: theme.textColorScheme.secondary,
// ),
],
);
}
Widget _buildSubtitle(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Text(
LocaleKeys.shareTab_anyoneInGroupWithLinkCanEdit.tr(),
textAlign: TextAlign.left,
style: theme.textStyle.caption.standard(
color: theme.textColorScheme.secondary,
),
);
}
Widget _buildTrailing(BuildContext context) {
return EditAccessLevelWidget(
disabled: true,
supportedAccessLevels: ShareAccessLevel.values,
selectedAccessLevel: ShareAccessLevel.readAndWrite,
callbacks: AccessLevelListCallbacks.none(),
additionalUserManagementOptions: [],
);
}
}
@@ -0,0 +1,171 @@
import 'package:appflowy/features/share_tab/data/models/models.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/access_level_list_widget.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/edit_access_level_widget.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/guest_tag.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/turn_into_member_widget.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
/// Widget to display a single shared user row as per the UI design, using AFMenuItem.
class SharedUserWidget extends StatelessWidget {
const SharedUserWidget({
super.key,
required this.user,
required this.currentUser,
required this.isInPublicPage,
this.callbacks,
});
final SharedUser user;
final SharedUser currentUser;
final AccessLevelListCallbacks? callbacks;
final bool isInPublicPage;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return AFMenuItem(
padding: EdgeInsets.symmetric(
vertical: theme.spacing.s,
horizontal: theme.spacing.m,
),
leading: AFAvatar(
name: user.name,
url: user.avatarUrl,
),
title: _buildTitle(context),
subtitle: _buildSubtitle(context),
trailing: _buildTrailing(context),
onTap: () {
// callbacks?.onSelectAccessLevel.call(user, user.accessLevel);
},
);
}
Widget _buildTitle(
BuildContext context,
) {
final theme = AppFlowyTheme.of(context);
final isCurrentUser = user.email == currentUser.email;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
user.name,
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
overflow: TextOverflow.ellipsis,
),
),
// if the user is the current user, show '(You)'
if (isCurrentUser) ...[
HSpace(theme.spacing.xs),
Flexible(
child: Text(
LocaleKeys.shareTab_you.tr(),
style: theme.textStyle.caption.standard(
color: theme.textColorScheme.secondary,
),
overflow: TextOverflow.ellipsis,
),
),
],
// if the user is a guest, show 'Guest'
if (user.role == ShareRole.guest) ...[
HSpace(theme.spacing.m),
const GuestTag(),
],
],
);
}
Widget _buildSubtitle(
BuildContext context,
) {
final theme = AppFlowyTheme.of(context);
return Text(
user.email,
style: theme.textStyle.caption.standard(
color: theme.textColorScheme.secondary,
),
);
}
Widget _buildTrailing(BuildContext context) {
final isCurrentUser = user.email == currentUser.email;
final theme = AppFlowyTheme.of(context);
final currentAccessLevel = currentUser.accessLevel;
Widget disabledAccessButton() => AFGhostTextButton.disabled(
text: user.accessLevel.title,
textStyle: theme.textStyle.body.standard(
color: theme.textColorScheme.secondary,
),
);
Widget editAccessWidget(List<ShareAccessLevel> supported) =>
EditAccessLevelWidget(
selectedAccessLevel: user.accessLevel,
supportedAccessLevels: supported,
additionalUserManagementOptions: [
AdditionalUserManagementOptions.removeAccess,
],
callbacks: callbacks ?? AccessLevelListCallbacks.none(),
);
// In public page, member/owner permissions are fixed
if (isInPublicPage &&
(user.role == ShareRole.member || user.role == ShareRole.owner)) {
return disabledAccessButton();
}
// Full access user can turn a guest into a member
if (user.role == ShareRole.guest &&
currentAccessLevel == ShareAccessLevel.fullAccess) {
return Row(
children: [
TurnIntoMemberWidget(
onTap: () => callbacks?.onTurnIntoMember.call(),
),
editAccessWidget([
ShareAccessLevel.readOnly,
ShareAccessLevel.readAndWrite,
]),
],
);
}
// Self-management
if (isCurrentUser) {
if (currentAccessLevel == ShareAccessLevel.readOnly ||
currentAccessLevel == ShareAccessLevel.readAndWrite) {
// Can only remove self
return editAccessWidget([]);
} else if (currentAccessLevel == ShareAccessLevel.fullAccess) {
// Full access user cannot change own access
return disabledAccessButton();
}
}
// Managing others
if (currentAccessLevel == ShareAccessLevel.readOnly ||
currentAccessLevel == ShareAccessLevel.readAndWrite) {
// Cannot change others' access
return disabledAccessButton();
} else {
// Full access user can manage others
final supportedAccessLevels = [
ShareAccessLevel.readOnly,
ShareAccessLevel.readAndWrite,
];
return editAccessWidget(supportedAccessLevels);
}
}
}
@@ -0,0 +1,30 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flutter/material.dart';
class TurnIntoMemberWidget extends StatelessWidget {
const TurnIntoMemberWidget({
super.key,
required this.onTap,
});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return FlowyTooltip(
message: LocaleKeys.shareTab_turnIntoMember.tr(),
child: AFGhostButton.normal(
onTap: onTap,
padding: EdgeInsets.all(theme.spacing.s),
builder: (context, isHovering, disabled) {
return FlowySvg(FlowySvgs.turn_into_member_m);
},
),
);
}
}
@@ -0,0 +1,88 @@
import 'package:appflowy/features/share_tab/logic/share_tab_bloc.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class UpgradeToProWidget extends StatelessWidget {
const UpgradeToProWidget({
super.key,
required this.onUpgrade,
required this.onClose,
});
final VoidCallback onClose;
final VoidCallback onUpgrade;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Container(
decoration: BoxDecoration(
color: Color(0x129327ff),
borderRadius: BorderRadius.circular(theme.borderRadius.m),
),
padding: EdgeInsets.symmetric(
vertical: theme.spacing.m,
horizontal: theme.spacing.l,
),
margin: EdgeInsets.only(
top: theme.spacing.l,
),
child: Row(
children: [
FlowySvg(
FlowySvgs.upgrade_pro_crown_m,
blendMode: null,
),
HSpace(
theme.spacing.m,
),
RichText(
text: TextSpan(
children: [
TextSpan(
text: LocaleKeys.shareTab_upgrade.tr(),
style: theme.textStyle.caption.standard().copyWith(
color: theme.textColorScheme.featured,
decoration: TextDecoration.underline,
),
recognizer: TapGestureRecognizer()
..onTap = () {
onUpgrade();
},
mouseCursor: SystemMouseCursors.click,
),
TextSpan(
text: LocaleKeys.shareTab_toProPlanToInviteGuests.tr(),
style: theme.textStyle.caption.standard().copyWith(
color: theme.textColorScheme.featured,
),
),
],
),
),
const Spacer(),
AFGhostButton.normal(
size: AFButtonSize.s,
padding: EdgeInsets.all(theme.spacing.xs),
onTap: () {
context
.read<ShareTabBloc>()
.add(ShareTabEvent.upgradeToProClicked());
onClose();
},
builder: (context, isHovering, disabled) => FlowySvg(
FlowySvgs.upgrade_to_pro_close_m,
size: const Size.square(20),
),
),
],
),
);
}
}
@@ -0,0 +1,39 @@
import 'package:appflowy/features/share_tab/data/models/share_access_level.dart';
import 'package:appflowy/features/shared_section/data/repositories/shared_pages_repository.dart';
import 'package:appflowy/features/shared_section/models/shared_page.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
// Move this file to test folder
class LocalSharedPagesRepositoryImpl implements SharedPagesRepository {
@override
Future<FlowyResult<SharedPages, FlowyError>> getSharedPages() async {
final pages = [
SharedPage(
view: ViewPB()
..id = '1'
..name = 'Welcome Page',
accessLevel: ShareAccessLevel.fullAccess,
),
SharedPage(
view: ViewPB()
..id = '2'
..name = 'Project Plan',
accessLevel: ShareAccessLevel.readAndWrite,
),
SharedPage(
view: ViewPB()
..id = '3'
..name = 'Readme',
accessLevel: ShareAccessLevel.readOnly,
),
];
return FlowyResult.success(pages);
}
@override
Future<FlowyResult<void, FlowyError>> leaveSharedPage(String pageId) async {
return FlowyResult.success(null);
}
}
@@ -0,0 +1,63 @@
import 'package:appflowy/features/shared_section/data/repositories/shared_pages_repository.dart';
import 'package:appflowy/features/shared_section/models/shared_page.dart';
import 'package:appflowy/features/util/extensions.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
class RustSharePagesRepositoryImpl implements SharedPagesRepository {
@override
Future<FlowyResult<SharedPages, FlowyError>> getSharedPages() async {
final result = await FolderEventGetSharedViews().send();
return result.fold(
(success) {
final sharedPages = success.sharedPages;
Log.debug('get shared pages success, len: ${sharedPages.length}');
return FlowyResult.success(sharedPages);
},
(error) {
Log.error('failed to get shared pages, error: $error');
return FlowyResult.failure(error);
},
);
}
@override
Future<FlowyResult<void, FlowyError>> leaveSharedPage(String pageId) async {
final user = await UserEventGetUserProfile().send();
final userEmail = user.fold(
(success) => success.email,
(error) => null,
);
if (userEmail == null) {
return FlowyResult.failure(FlowyError(msg: 'User email is null'));
}
final request = RemoveUserFromSharedPagePayloadPB(
viewId: pageId,
emails: [userEmail],
);
final result = await FolderEventRemoveUserFromSharedPage(request).send();
return result.fold(
(success) {
Log.debug('remove user($userEmail) from shared page($pageId)');
return FlowySuccess(success);
},
(failure) {
Log.error(
'remove user($userEmail) from shared page($pageId): $failure',
);
return FlowyFailure(failure);
},
);
}
}
@@ -0,0 +1,15 @@
import 'package:appflowy/features/shared_section/models/shared_page.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
/// Abstract repository for sharing pages with users.
///
/// For example, we're using rust events now, but we can still use the http api
/// for the future.
abstract class SharedPagesRepository {
/// Gets the list of users and their roles for a shared page.
Future<FlowyResult<SharedPages, FlowyError>> getSharedPages();
/// Removes a shared page from the repository.
Future<FlowyResult<void, FlowyError>> leaveSharedPage(String pageId);
}
@@ -0,0 +1,195 @@
import 'dart:async';
import 'package:appflowy/core/notification/folder_notification.dart';
import 'package:appflowy/features/shared_section/data/repositories/shared_pages_repository.dart';
import 'package:appflowy/features/shared_section/logic/shared_section_event.dart';
import 'package:appflowy/features/shared_section/logic/shared_section_state.dart';
import 'package:appflowy/features/util/extensions.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:bloc/bloc.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
export 'shared_section_event.dart';
export 'shared_section_state.dart';
class SharedSectionBloc extends Bloc<SharedSectionEvent, SharedSectionState> {
SharedSectionBloc({
required this.repository,
required this.workspaceId,
this.enablePolling = false,
this.pollingIntervalSeconds = 30,
}) : super(SharedSectionState.initial()) {
on<SharedSectionInitEvent>(_onInit);
on<SharedSectionRefreshEvent>(_onRefresh);
on<SharedSectionUpdateSharedPagesEvent>(_onUpdateSharedPages);
on<SharedSectionToggleExpandedEvent>(_onToggleExpanded);
on<SharedSectionLeaveSharedPageEvent>(_onLeaveSharedPage);
}
final String workspaceId;
// The repository to fetch the shared views.
// If you need to test this bloc, you can add your own repository implementation.
final SharedPagesRepository repository;
// Used to listen for shared view updates.
late final FolderNotificationListener _folderNotificationListener;
// Since the backend doesn't provide a way to listen for shared view updates (websocket with shared view updates is not implemented yet),
// we need to poll the shared views periodically.
final bool enablePolling;
// The interval of polling the shared views.
final int pollingIntervalSeconds;
Timer? _pollingTimer;
@override
Future<void> close() async {
await _folderNotificationListener.stop();
_pollingTimer?.cancel();
await super.close();
}
Future<void> _onInit(
SharedSectionInitEvent event,
Emitter<SharedSectionState> emit,
) async {
_initFolderNotificationListener();
_startPollingIfNeeded();
emit(
state.copyWith(
isLoading: true,
errorMessage: '',
),
);
final result = await repository.getSharedPages();
result.fold(
(pages) {
emit(
state.copyWith(
sharedPages: pages,
isLoading: false,
),
);
},
(error) {
emit(
state.copyWith(
errorMessage: error.msg,
isLoading: false,
),
);
},
);
}
Future<void> _onRefresh(
SharedSectionRefreshEvent event,
Emitter<SharedSectionState> emit,
) async {
final result = await repository.getSharedPages();
result.fold(
(pages) {
emit(
state.copyWith(
sharedPages: pages,
),
);
},
(error) {
emit(
state.copyWith(
errorMessage: error.msg,
),
);
},
);
}
void _onUpdateSharedPages(
SharedSectionUpdateSharedPagesEvent event,
Emitter<SharedSectionState> emit,
) {
emit(
state.copyWith(
sharedPages: event.sharedPages,
),
);
}
void _onToggleExpanded(
SharedSectionToggleExpandedEvent event,
Emitter<SharedSectionState> emit,
) {
emit(
state.copyWith(
isExpanded: !state.isExpanded,
),
);
}
void _initFolderNotificationListener() {
_folderNotificationListener = FolderNotificationListener(
objectId: workspaceId,
handler: (notification, result) {
if (notification == FolderNotification.DidUpdateSharedViews) {
final response = result.fold(
(payload) {
final repeatedSharedViews =
RepeatedSharedViewResponsePB.fromBuffer(payload);
return repeatedSharedViews;
},
(error) => null,
);
if (response != null) {
add(
SharedSectionEvent.updateSharedPages(
sharedPages: response.sharedPages,
),
);
}
}
},
);
}
void _onLeaveSharedPage(
SharedSectionLeaveSharedPageEvent event,
Emitter<SharedSectionState> emit,
) async {
final result = await repository.leaveSharedPage(event.pageId);
result.fold(
(success) {
add(
SharedSectionEvent.updateSharedPages(
sharedPages: state.sharedPages
..removeWhere(
(page) => page.view.id == event.pageId,
),
),
);
},
(error) {
emit(state.copyWith(errorMessage: error.msg));
},
);
}
void _startPollingIfNeeded() {
_pollingTimer?.cancel();
if (enablePolling && pollingIntervalSeconds > 0) {
_pollingTimer = Timer.periodic(
Duration(seconds: pollingIntervalSeconds),
(_) {
add(const SharedSectionEvent.refresh());
Log.debug('Polling shared views');
},
);
}
}
}
@@ -0,0 +1,55 @@
import 'package:appflowy/features/shared_section/models/shared_page.dart';
/// Base class for all SharedSection events
sealed class SharedSectionEvent {
const SharedSectionEvent();
/// Initialize, it will create a folder notification listener to listen for shared view updates.
/// Also, it will fetch the shared pages from the repository.
const factory SharedSectionEvent.init() = SharedSectionInitEvent;
/// Refresh, it will re-fetch the shared pages from the repository.
const factory SharedSectionEvent.refresh() = SharedSectionRefreshEvent;
/// Update the shared pages in the state.
const factory SharedSectionEvent.updateSharedPages({
required SharedPages sharedPages,
}) = SharedSectionUpdateSharedPagesEvent;
/// Toggle the expanded status of the shared section.
const factory SharedSectionEvent.toggleExpanded() =
SharedSectionToggleExpandedEvent;
/// Leave shared page.
const factory SharedSectionEvent.leaveSharedPage({
required String pageId,
}) = SharedSectionLeaveSharedPageEvent;
}
class SharedSectionInitEvent extends SharedSectionEvent {
const SharedSectionInitEvent();
}
class SharedSectionRefreshEvent extends SharedSectionEvent {
const SharedSectionRefreshEvent();
}
class SharedSectionUpdateSharedPagesEvent extends SharedSectionEvent {
const SharedSectionUpdateSharedPagesEvent({
required this.sharedPages,
});
final SharedPages sharedPages;
}
class SharedSectionToggleExpandedEvent extends SharedSectionEvent {
const SharedSectionToggleExpandedEvent();
}
class SharedSectionLeaveSharedPageEvent extends SharedSectionEvent {
const SharedSectionLeaveSharedPageEvent({
required this.pageId,
});
final String pageId;
}
@@ -0,0 +1,56 @@
import 'package:appflowy/features/shared_section/models/shared_page.dart';
class SharedSectionState {
factory SharedSectionState.initial() => const SharedSectionState();
const SharedSectionState({
this.sharedPages = const [],
this.isLoading = false,
this.errorMessage = '',
this.isExpanded = true,
});
final SharedPages sharedPages;
final bool isLoading;
final String errorMessage;
final bool isExpanded;
SharedSectionState copyWith({
SharedPages? sharedPages,
bool? isLoading,
String? errorMessage,
bool? isExpanded,
}) {
return SharedSectionState(
sharedPages: sharedPages ?? this.sharedPages,
isLoading: isLoading ?? this.isLoading,
errorMessage: errorMessage ?? this.errorMessage,
isExpanded: isExpanded ?? this.isExpanded,
);
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is SharedSectionState &&
other.sharedPages == sharedPages &&
other.isLoading == isLoading &&
other.errorMessage == errorMessage &&
other.isExpanded == isExpanded;
}
@override
int get hashCode {
return Object.hash(
sharedPages,
isLoading,
errorMessage,
isExpanded,
);
}
@override
String toString() {
return 'SharedSectionState(sharedPages: $sharedPages, isLoading: $isLoading, errorMessage: $errorMessage, isExpanded: $isExpanded)';
}
}
@@ -0,0 +1,14 @@
import 'package:appflowy/features/share_tab/data/models/share_access_level.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
typedef SharedPages = List<SharedPage>;
class SharedPage {
SharedPage({
required this.view,
required this.accessLevel,
});
final ViewPB view;
final ShareAccessLevel accessLevel;
}
@@ -0,0 +1,92 @@
import 'package:appflowy/features/shared_section/data/repositories/rust_shared_pages_repository_impl.dart';
import 'package:appflowy/features/shared_section/logic/shared_section_bloc.dart';
import 'package:appflowy/features/shared_section/presentation/widgets/m_shared_page_list.dart';
import 'package:appflowy/features/shared_section/presentation/widgets/m_shared_section_header.dart';
import 'package:appflowy/features/shared_section/presentation/widgets/refresh_button.dart';
import 'package:appflowy/features/shared_section/presentation/widgets/shared_section_empty.dart';
import 'package:appflowy/features/shared_section/presentation/widgets/shared_section_error.dart';
import 'package:appflowy/features/shared_section/presentation/widgets/shared_section_loading.dart';
import 'package:appflowy/mobile/application/mobile_router.dart';
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class MSharedSection extends StatelessWidget {
const MSharedSection({
super.key,
required this.workspaceId,
});
final String workspaceId;
@override
Widget build(BuildContext context) {
final repository = RustSharePagesRepositoryImpl();
return BlocProvider(
create: (_) => SharedSectionBloc(
workspaceId: workspaceId,
repository: repository,
enablePolling: true,
)..add(const SharedSectionInitEvent()),
child: BlocBuilder<SharedSectionBloc, SharedSectionState>(
builder: (context, state) {
if (state.isLoading) {
return const SharedSectionLoading();
}
if (state.errorMessage.isNotEmpty) {
return SharedSectionError(errorMessage: state.errorMessage);
}
// hide the shared section if there are no shared pages
if (state.sharedPages.isEmpty) {
return const SharedSectionEmpty();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const VSpace(HomeSpaceViewSizes.mVerticalPadding),
// Shared header
MSharedSectionHeader(),
Padding(
padding: const EdgeInsets.only(
left: HomeSpaceViewSizes.mHorizontalPadding,
),
child: MSharedPageList(
sharedPages: state.sharedPages,
onSelected: (view) {
context.pushView(
view,
tabs: [
PickerTabType.emoji,
PickerTabType.icon,
PickerTabType.custom,
].map((e) => e.name).toList(),
);
},
),
),
// Refresh button, for debugging only
if (kDebugMode)
RefreshSharedSectionButton(
onTap: () {
context.read<SharedSectionBloc>().add(
const SharedSectionEvent.refresh(),
);
},
),
],
);
},
),
);
}
}
@@ -0,0 +1,159 @@
import 'package:appflowy/features/shared_section/data/repositories/rust_shared_pages_repository_impl.dart';
import 'package:appflowy/features/shared_section/logic/shared_section_bloc.dart';
import 'package:appflowy/features/shared_section/presentation/widgets/refresh_button.dart';
import 'package:appflowy/features/shared_section/presentation/widgets/shared_page_list.dart';
import 'package:appflowy/features/shared_section/presentation/widgets/shared_section_error.dart';
import 'package:appflowy/features/shared_section/presentation/widgets/shared_section_header.dart';
import 'package:appflowy/features/shared_section/presentation/widgets/shared_section_loading.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart';
import 'package:appflowy/workspace/presentation/widgets/dialog_v2.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class SharedSection extends StatelessWidget {
const SharedSection({
super.key,
required this.workspaceId,
});
final String workspaceId;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final repository = RustSharePagesRepositoryImpl();
return BlocProvider(
create: (_) => SharedSectionBloc(
workspaceId: workspaceId,
repository: repository,
enablePolling: true,
)..add(const SharedSectionInitEvent()),
child: BlocBuilder<SharedSectionBloc, SharedSectionState>(
builder: (context, state) {
if (state.isLoading) {
return const SharedSectionLoading();
}
if (state.errorMessage.isNotEmpty) {
return SharedSectionError(errorMessage: state.errorMessage);
}
// hide the shared section if there are no shared pages
if (state.sharedPages.isEmpty) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Shared header
SharedSectionHeader(
onTap: () {
// expand or collapse the shared section
context.read<SharedSectionBloc>().add(
const SharedSectionToggleExpandedEvent(),
);
},
),
// Shared pages list
if (state.isExpanded)
SharedPageList(
sharedPages: state.sharedPages,
onSetEditing: (context, value) {
context.read<ViewBloc>().add(ViewEvent.setIsEditing(value));
},
onAction: (action, view, data) async {
switch (action) {
case ViewMoreActionType.favorite:
case ViewMoreActionType.unFavorite:
context
.read<FavoriteBloc>()
.add(FavoriteEvent.toggle(view));
break;
case ViewMoreActionType.openInNewTab:
context.read<TabsBloc>().openTab(view);
break;
case ViewMoreActionType.rename:
await showAFTextFieldDialog(
context: context,
title: LocaleKeys.disclosureAction_rename.tr(),
initialValue: view.nameOrDefault,
maxLength: 256,
onConfirm: (newValue) {
// can not use bloc here because it has been disposed.
ViewBackendService.updateView(
viewId: view.id,
name: newValue,
);
},
);
break;
case ViewMoreActionType.leaveSharedPage:
// show a dialog to confirm the action
await showConfirmDialog(
context: context,
title: 'Remove your own access',
titleStyle: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
description: '',
style: ConfirmPopupStyle.cancelAndOk,
confirmLabel: 'Remove',
onConfirm: (_) {
context.read<SharedSectionBloc>().add(
SharedSectionEvent.leaveSharedPage(
pageId: view.id,
),
);
},
);
break;
default:
// Other actions are not allowed for read-only access
break;
}
},
onSelected: (context, view) {
if (HardwareKeyboard.instance.isControlPressed) {
context.read<TabsBloc>().openTab(view);
}
context.read<TabsBloc>().openPlugin(view);
},
onTertiarySelected: (context, view) {
context.read<TabsBloc>().openTab(view);
},
),
// Refresh button, for debugging only
if (kDebugMode)
RefreshSharedSectionButton(
onTap: () {
context.read<SharedSectionBloc>().add(
const SharedSectionEvent.refresh(),
);
},
),
const VSpace(16.0),
],
);
},
),
);
}
}
@@ -0,0 +1,38 @@
import 'package:appflowy/features/shared_section/models/shared_page.dart';
import 'package:appflowy/mobile/presentation/page_item/mobile_view_item.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
import 'package:flutter/material.dart';
/// Shared pages on mobile
class MSharedPageList extends StatelessWidget {
const MSharedPageList({
super.key,
required this.sharedPages,
required this.onSelected,
});
final SharedPages sharedPages;
final ViewItemOnSelected onSelected;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: sharedPages.map((sharedPage) {
final view = sharedPage.view;
return MobileViewItem(
key: ValueKey(view.id),
spaceType: FolderSpaceType.public,
isFirstChild: view.id == sharedPages.first.view.id,
view: view,
level: 0,
isDraggable: false, // disable draggable for shared pages
leftPadding: HomeSpaceViewSizes.leftPadding,
isFeedback: false,
onSelected: onSelected,
);
}).toList(),
);
}
}
@@ -0,0 +1,38 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
class MSharedSectionHeader extends StatelessWidget {
const MSharedSectionHeader({
super.key,
});
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return SizedBox(
height: 48,
child: Row(
children: [
const HSpace(HomeSpaceViewSizes.mHorizontalPadding),
FlowySvg(
FlowySvgs.shared_with_me_m,
color: theme.badgeColorScheme.color13Thick2,
),
const HSpace(10.0),
FlowyText.medium(
LocaleKeys.shareSection_shared.tr(),
lineHeight: 1.15,
fontSize: 16.0,
),
const HSpace(HomeSpaceViewSizes.mHorizontalPadding),
],
),
);
}
}
@@ -0,0 +1,25 @@
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flutter/material.dart';
class RefreshSharedSectionButton extends StatelessWidget {
const RefreshSharedSectionButton({
super.key,
required this.onTap,
});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return AFTextMenuItem(
leading: Icon(
Icons.refresh,
size: 20,
color: theme.iconColorScheme.secondary,
),
title: 'Refresh',
onTap: onTap,
);
}
}
@@ -0,0 +1,158 @@
import 'package:appflowy/features/share_tab/data/models/share_access_level.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flutter/material.dart';
typedef SharedPageActionsButtonCallback = void Function(
ViewMoreActionType type,
ViewPB view,
dynamic data,
);
typedef SharedPageActionsButtonSetEditingCallback = void Function(
BuildContext context,
bool value,
);
class SharedPageActionsButton extends StatefulWidget {
const SharedPageActionsButton({
super.key,
required this.view,
required this.accessLevel,
required this.onAction,
required this.buildChild,
required this.onSetEditing,
this.showAtCursor = false,
});
final ViewPB view;
final ShareAccessLevel accessLevel;
final SharedPageActionsButtonCallback onAction;
final SharedPageActionsButtonSetEditingCallback onSetEditing;
final bool showAtCursor;
final Widget Function(AFPopoverController) buildChild;
@override
State<SharedPageActionsButton> createState() =>
_SharedPageActionsButtonState();
}
class _SharedPageActionsButtonState extends State<SharedPageActionsButton> {
AFPopoverController controller = AFPopoverController();
@override
void initState() {
super.initState();
controller.addListener(() {
if (!controller.isOpen) {
widget.onSetEditing(context, false);
}
});
}
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AFPopover(
controller: controller,
padding: EdgeInsets.zero,
decoration: BoxDecoration(), // the AFMenu has a border
anchor: const AFAnchorAuto(
offset: Offset(98, 8),
),
popover: (context) => AFMenu(
width: 240,
backgroundColor:
Theme.of(context).cardColor, // to compatible with the old design
children: _buildMenuItems(context),
),
child: widget.buildChild(controller),
);
}
List<Widget> _buildMenuItems(BuildContext context) {
final actionTypes = _buildActionTypes();
final menuItems = <Widget>[];
for (final actionType in actionTypes) {
if (actionType == ViewMoreActionType.divider) {
if (menuItems.isNotEmpty) {
menuItems.add(const AFDivider(spacing: 4));
}
} else {
menuItems.add(
AFTextMenuItem(
leading: FlowySvg(
actionType.leftIconSvg,
size: const Size.square(16),
color: actionType == ViewMoreActionType.delete
? Theme.of(context).colorScheme.error
: null,
),
title: actionType.name,
titleColor: actionType == ViewMoreActionType.delete
? Theme.of(context).colorScheme.error
: null,
trailing: actionType.rightIcon,
onTap: () {
widget.onAction(actionType, widget.view, null);
controller.hide();
},
),
);
}
}
return menuItems;
}
List<ViewMoreActionType> _buildActionTypes() {
final List<ViewMoreActionType> actionTypes = [];
// Always allow add to favorites and open in new tab
actionTypes.add(
widget.view.isFavorite
? ViewMoreActionType.unFavorite
: ViewMoreActionType.favorite,
);
actionTypes.add(
ViewMoreActionType.leaveSharedPage,
);
// Only show editable actions if access level allows it
if (widget.accessLevel != ShareAccessLevel.readOnly) {
actionTypes.addAll([
ViewMoreActionType.divider,
ViewMoreActionType.rename,
]);
// Chat doesn't change icon and duplicate
if (widget.view.layout != ViewLayoutPB.Chat) {
actionTypes.addAll([
ViewMoreActionType.changeIcon,
]);
}
if (widget.accessLevel == ShareAccessLevel.fullAccess) {
actionTypes.addAll([
ViewMoreActionType.delete,
]);
}
}
actionTypes.add(ViewMoreActionType.divider);
actionTypes.add(ViewMoreActionType.openInNewTab);
return actionTypes;
}
}
@@ -0,0 +1,82 @@
import 'package:appflowy/features/share_tab/data/models/share_access_level.dart';
import 'package:appflowy/features/shared_section/models/shared_page.dart';
import 'package:appflowy/features/shared_section/presentation/widgets/shared_page_actions_button.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.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';
/// Shared pages on desktop
class SharedPageList extends StatelessWidget {
const SharedPageList({
super.key,
required this.sharedPages,
required this.onAction,
required this.onSelected,
required this.onTertiarySelected,
required this.onSetEditing,
});
final SharedPages sharedPages;
final ViewItemOnSelected onSelected;
final ViewItemOnSelected onTertiarySelected;
final SharedPageActionsButtonCallback onAction;
final SharedPageActionsButtonSetEditingCallback onSetEditing;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: sharedPages.map((sharedPage) {
final view = sharedPage.view;
final accessLevel = sharedPage.accessLevel;
return ViewItem(
key: ValueKey(view.id),
spaceType: FolderSpaceType.public,
isFirstChild: view.id == sharedPages.first.view.id,
view: view,
level: 0,
isDraggable: false, // disable draggable for shared pages
leftPadding: HomeSpaceViewSizes.leftPadding,
isFeedback: false,
onSelected: onSelected,
onTertiarySelected: onTertiarySelected,
rightIconsBuilder: (context, view) => [
IntrinsicWidth(
child: _buildSharedPageMoreActionButton(
context,
view,
accessLevel,
),
),
const SizedBox(width: 4.0),
],
);
}).toList(),
);
}
Widget _buildSharedPageMoreActionButton(
BuildContext context,
ViewPB view,
ShareAccessLevel accessLevel,
) {
return SharedPageActionsButton(
view: view,
accessLevel: accessLevel,
onAction: onAction,
onSetEditing: onSetEditing,
buildChild: (controller) => FlowyIconButton(
width: 24,
icon: const FlowySvg(FlowySvgs.workspace_three_dots_s),
onPressed: () {
onSetEditing(context, true);
controller.show();
},
),
);
}
}
@@ -0,0 +1,41 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
class SharedSectionEmpty extends StatelessWidget {
const SharedSectionEmpty({super.key});
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
FlowySvg(
FlowySvgs.empty_shared_section_m,
color: theme.iconColorScheme.tertiary,
),
const VSpace(12),
Text(
'Nothing shared with you',
style: theme.textStyle.heading3.enhanced(
color: theme.textColorScheme.secondary,
),
textAlign: TextAlign.center,
),
const VSpace(4),
Text(
'Pages shared with you will show here',
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.tertiary,
),
textAlign: TextAlign.center,
),
const VSpace(kBottomNavigationBarHeight + 60.0),
],
),
);
}
}
@@ -0,0 +1,25 @@
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flutter/material.dart';
class SharedSectionError extends StatelessWidget {
const SharedSectionError({
super.key,
required this.errorMessage,
});
final String errorMessage;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
errorMessage,
style: theme.textStyle.body.enhanced(
color: theme.textColorScheme.warning,
),
),
);
}
}
@@ -0,0 +1,35 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
class SharedSectionHeader extends StatelessWidget {
const SharedSectionHeader({
super.key,
required this.onTap,
});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return AFGhostIconTextButton.primary(
text: LocaleKeys.shareSection_shared.tr(),
mainAxisAlignment: MainAxisAlignment.start,
size: AFButtonSize.l,
onTap: onTap,
// todo: ask the designer to provide the token.
padding: EdgeInsets.symmetric(
horizontal: 4,
vertical: 6,
),
borderRadius: theme.borderRadius.s,
iconBuilder: (context, isHover, disabled) => FlowySvg(
FlowySvgs.shared_with_me_m,
color: theme.badgeColorScheme.color13Thick2,
),
);
}
}
Loaded 100 of 665 files, more files were not shown because too many files have changed in this diff. Show more