Compare commits

...
95 Commits
Author SHA1 Message Date
Richard ShiueandLucas.Xu 2dd7e5937f fix: incorrect popover position (#7452)
* fix: incorrect popover position

* fix: tests

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2025-03-04 20:20:26 +08:00
Lucas e9371029f3 chore: update changelog (#7451) 2025-03-04 17:17:57 +08:00
Richard Shiue bbec60ff02 fix(flutter_desktop): page name overflow in search (#7450) 2025-03-04 17:17:42 +08:00
Lucas 3bf4f080c5 feat: auto calculate the column width when resizing (#7448)
* feat: calculate the column width auto

* fix: ai writer table issue
2025-03-04 16:34:26 +08:00
Richard Shiue 637c043f5b fix: ai writer launch review 0.8.5 (#7445)
* fix: improve writing not working

* fix: show insert below and discard buttons even in overflow

* fix: incorrect predefined format initialization

* fix: generate image

* chore: multi-line related questions

* fix: add to undo history

* fix: disable keyboard service when using ai writer

* fix: disable drag nodes

* fix: strikethrough text after accepting

* fix: undo
2025-03-04 16:34:07 +08:00
MornandLucas 9eed993421 feat: refactor databse styles (#7405)
* feat: refactor databse styles

* feat: support compact mode for databse

* feat: support dynamic height for board

* fix: add reference icon for database view in document

* feat: support data sync for database node in document

* fix: add hover effect in compact mode switcher

* fix: title of document not align correctly with a large screen

* fix: some launch review issues

* fix: auto hide the Hidden Groups unless the user clicks it to show

* fix: testing error

* chore: update board version

* chore: update database menu buttons

* fix: some launch review issues

---------

Co-authored-by: Lucas <lucas.xu@appflowy.io>
2025-03-04 11:29:38 +08:00
Lucas aff720c1f1 fix: hide the improve writing button (#7443)
* fix: hide the improve writing button

* chore: update appflowy_editor version

* fix: simple column width

* Revert "fix: hide the improve writing button"

This reverts commit 815a28971c.
2025-03-04 11:29:24 +08:00
Lucas 655de30df5 fix: unable to delete the callout block when it's in the first line (#7442) 2025-03-03 16:40:41 +08:00
Richard Shiue fe6217bd82 feat: ai writer block (#7406)
* feat: ai writer block

* test: fix integration tests

* chore: add continue writing to slash menu

* chore: focus issues during insertion

* fix: explain button position

* fix: gesture detection

* fix: insert below

* fix: undo

* chore: improve writing toolbar item

* chore: pass predefined format when using quick commands

* fix: continue writing in an empty document or at the beginning of a document

* fix: don't allow selecting text not in content

* fix: related question not following predefined format
2025-03-03 13:35:51 +08:00
Morn eacd7b2503 fix: error display when showing SnackBar with dialog (#7440) 2025-03-03 13:15:50 +08:00
Lucas 2e17fb9dd3 fix: can't open the relation field in the linked database (#7441) 2025-03-03 13:14:29 +08:00
Morn 249543d64f fix: using [[ to create subpage with error text (#7434) 2025-03-03 11:22:40 +08:00
Lucas 8ebd490260 feat: support scrollable columns block (#7429)
* feat: support scrollable columns block

* fix: simple columns block issues on mobile

* feat: hide drag menu when resizing the columns block
2025-03-03 11:22:13 +08:00
Morn c0dfec8b34 fix: potential issues with displaying CircleAvatar (#7432) 2025-03-03 11:21:44 +08:00
Lucas 56a023c98a fix: the locked hint is not visible when there's a cover (#7439) 2025-03-03 11:20:58 +08:00
Lucas adcac881a7 fix: 0.8.5 launch review issues (#7430)
* chore: replace two columns with 2 columns

* fix: hide drag menu when the doc is locked

* feat: add placeholder when editing the paragraph

* fix: ingore tab shortcut in document title

* feat: forward the video block to link preview block
2025-03-03 09:57:36 +08:00
Lucas f73342d902 fix: auto updater should not block the launch process (#7427) 2025-02-28 15:18:21 +08:00
FakhriAzzouz 45b0233c21 chore: update Arabic translations (#7361)
* 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-02-27 13:14:48 +08:00
Lucas db349519cf chore: bump version 0.8.5 (#7421) 2025-02-27 13:13:49 +08:00
Lucas c760a1b1fe feat: support columns block in editor on desktop (#7402)
* feat: support columns block in editor

* feat: upgrade simple columns block

* fix: build error

* feat: add column width resizer

* fix: drag visual border

* fix: drag button position issue

* feat: add rule to check if the column is empty

* fix: flutter analyze

* feat: add document rules to delete the columns if its children are empty

* feat: support adding image in columns block

* feat: integrate block actions in columns block

* feat: support dragging to create a columns block

* feat: drag a block into an existing columns block

* feat: add delete columns and delete column rules

* feat: dragging the block to the left side of another block to create a columns block

* feat: support 2-4 columns block in slash menu

* chore: disable debug flag in columns block

* chore: update pubspec.yaml

* chore: update translations and icons

* fix: cloud integration test

* fix: integration test
2025-02-27 13:08:49 +08:00
MornandLucas c5fa9039b4 fix: add shortcut to create Inline Math Equation (#7401)
* fix: add shortcut to create Math Equation(#7331)

* chore: update code

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

---------

Co-authored-by: Lucas <lucas.xu@appflowy.io>
2025-02-26 10:06:28 +08:00
Richard Shiue 7eaafc52ce fix: adjust other user message alignment (#7414) 2025-02-25 10:35:53 +08:00
Richard Shiue 63239893ab chore(flutter): move other user message to end (#7413) 2025-02-24 15:14:26 +08:00
Annie e9a1a1ced0 chore: Update README.md (#7411)
change appflowy.io to appflowy.com wherever possible
2025-02-23 13:46:13 +08:00
LucasandMorn 6dc45c9830 feat: use flutter_distrubutor to build linux and macos packages (#7392)
* feat: use flutter_distrubutor to build linux packages

* feat: verify deb on Linux

* chore: update rpm deps

* chore: update codesign files

* chore: update rpm make_config.yaml

* chore: update release.yml

* chore: update release.yml

* chore: update feed url

* chore: rename AppFlowy to appflowy

* chore: update CHANGELOG.md (#7397)

* chore: create release path if not exist

* feat: support appimage

* Revert "feat: support appimage"

This reverts commit cb7dcf725c.

* fix: cp deb/rpm error

* feat: support appimage

* chore: add linux build script

* feat: add macos build script

* feat: update linux scripts

* chore: update linux scripts

* chore: update relesae script

* chore: update macos build scripts

* chore: rename macOS package name

* chore: add keychain in release.yaml

* chore: update macos build steps in release.yaml

* chore: update macos script desc

* chore: remove sudo

* feat: support tar.xz package type

* feat: support tar.xz package type

* chore: add fuse

---------

Co-authored-by: Morn <agedchen@gmail.com>
2025-02-21 17:39:13 +08:00
Morn 58f7659d55 fix: avatar displays error (#7403) 2025-02-21 14:28:24 +08:00
Morn fd12d3a0b0 chore: update CHANGELOG.md (#7397) 2025-02-18 16:48:33 +08:00
Lucas f25821e84d chore: update auto updater feed url (#7396) 2025-02-18 16:14:13 +08:00
Morn c0aa0e0509 fix: some launch review issues (#7379)
* fix: some launch review issues

* fix: some launch review issues

* fix: some launch review issues

* feat: add change button for icon uploader
2025-02-17 16:08:51 +08:00
Lucas 55fbb7522b feat: switch ai mode on mobile (#7391)
* fix: the default page name should be empty when creating

* feat: switch ai model on mobile
2025-02-17 16:08:38 +08:00
Li, John Gen 15b4d496fd chore: update translations (#7387)
* 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-02-17 15:23:11 +08:00
Lucas ad54ad0614 fix: the default page name should be empty when creating (#7389) 2025-02-17 15:13:25 +08:00
Lucas b18bbd0e82 fix: auto updater issue on Windows (#7385) 2025-02-17 09:56:30 +08:00
Lucas e028e45e93 fix: lock page issues (#7380)
* fix: unable to click the swith to lock/unlock page

* fix: add divider above delete button on mobile

* fix: enable lock/unlock page by tapping the lock icon

* chore: update translations

* fix: hide cursor when the page is locked

* fix: the inline databaes still can be edited if the document is locked

* fix: disable auto update checker

* chore: change my account to account & app
2025-02-14 17:02:25 +08:00
Lucas 0e7ac85f90 chore: add ai images label (#7376) 2025-02-13 14:56:16 +08:00
Lucas 8bb2541862 chore: upgrade version (#7374) 2025-02-13 13:57:37 +08:00
Lucas 133e61befd fix: clear background color of header row & header column (#7373) 2025-02-13 13:21:02 +08:00
Morn 9e98680861 feat: support slash menu on mobile (#7368)
* feat: support slash menu on mobile

* feat: support at menu on mobile

* feat: support plus menu on mobile
2025-02-13 12:45:56 +08:00
Khor Shu Heng b75fd673cd chore: update collab version (#7372) 2025-02-13 12:45:45 +08:00
Lucas 4a7e20b3a5 fix: save image should not copy the image (#7370)
* fix: save image should not copy the image

* fix: unable to scroll the table in AI Chat on mobile
2025-02-12 17:35:59 +08:00
MornandLucas.Xu bbe746c564 feat: support upload svg as icon (#7270)
* feat: support upload svg as icon

* feat: support upload icon by pasting a link

* feat: delete remote images when remove custon icons

* chore: add testing for pasting image link as custon icon

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2025-02-12 15:08:50 +08:00
Reagan lee 189faa4def chore: update translations (#7351)
* 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-02-12 15:08:01 +08:00
Lucas c1a8d89938 feat: lock page on mobile (#7366)
* feat: support lock button in view more actions

* feat: add lock page on mobile

* feat: disable actions in locked page

* feat: disable more actions in locked page

* feat: support locked grid on mobile

* feat: support locked board/calendar on mobile

* fix: exclude lock page button from AI Chat
2025-02-12 15:07:21 +08:00
Lucas 71ce9affbe feat: lock page (#7353)
* feat: lock page

* feat: add pageLockStatus bloc

* feat: add lock status and unlock status in title bar

* feat: add loading lock status

* feat: disable moveTo, delete, rename, updateIcon operations if the page is locked

* fix: lock toast issue

* feat: support locked database

* feat: support locked grid

* feat: support locked title

* feat: support locked board

* feat: support locked calendar
2025-02-12 09:49:36 +08:00
Morn 552dba5abe fix: support exporting more content to markdown (#7333)
* fix: support exporting to markdown with multiple images

* fix: support exporting to markdown with database

* fix: support exporting to markdown with date or reminder

* fix: support exporting to markdown with subpage and page reference

* chore: add some testing for markdown parser

* chore: add testing for exporting markdown with databse as csv
2025-02-11 21:46:02 +08:00
Richard Shiue 04e3246976 chore: rename predefined format enum variant (#7359) 2025-02-11 16:40:39 +08:00
Morn 5d73c3d194 fix: gallery not rendering in row page (#7349) 2025-02-11 14:03:49 +08:00
Richard Shiue 12d9a98831 chore: disable impeller on android (#7355) 2025-02-11 14:03:29 +08:00
Lucas 8f646a2843 feat: integrate version checker for Linux (#7346)
* feat: integrate auto_updater in macOS

* chore: update translations

* chore: bump auto_updater version

* feat: exclude linux platform in auto update task

* feat: support auto_updater on Linux

* chore: combine version checker and auto updater into same class
2025-02-10 15:07:53 +08:00
Lucas f53e9d6549 feat: integrate auto_updater for macOS (#7328)
* feat: integrate auto_updater in macOS

* chore: update translations

* chore: bump auto_updater version

* feat: exclude linux platform in auto update task

* chore: disable auto updater

* fix: integration tests

* fix: integration tests
2025-02-10 09:20:24 +08:00
Richard Shiue fc9c152553 fix(flutter_desktop): selection in AI chat going missing while scrolling (#7281) 2025-02-07 18:44:55 +08:00
Lucas 00cdee831d chore: upgrade to Flutter 3.27.4 (#7230) 2025-02-07 18:17:46 +08:00
Morn 17ae05a623 fix: pasting a link on iOS results in incorrect behavior (#7326) 2025-02-07 14:49:50 +08:00
hasanbeder 8b1a03713b feat(i18n): Add Turkish (tr-TR) language translation (#7329)
- Complete Turkish language translation for AppFlowy
- Covers all UI elements and user-facing strings
- Improves localization support for Turkish users
2025-02-07 09:58:15 +08:00
Richard Shiue e4b57033b4 fix: improve calendar event button icon color and add confirm dialog (#7330)
* fix: improve calendar event button icon color and add confirm dialog

* test: update test
2025-02-06 22:46:22 +08:00
Richard ShiueandNathan 62a6fb8913 chore: optional response format (#7204)
* chore: optional response format

* chore: bump client api

* chore: code cleanup

* chore: bump client api

---------

Co-authored-by: Nathan <nathan@appflowy.io>
2025-02-06 18:10:23 +08:00
FakhriAzzouz 0d89e22ed2 chore(i18n): update ar-SA translations (#7312)
* 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-02-06 13:54:16 +08:00
ArtemisOne ff2aae213c chore(i18n): update es-VE and ga-IE translations (#7320)
* 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-02-06 13:53:24 +08:00
Richard Shiue 43e64d8219 test: fix image integration test (#7323) 2025-02-05 19:45:21 +08:00
Richard Shiue 6823fe5d24 fix: row document images (#7322)
* fix: row document images

* fix: calendar

* chore: fallback to documentbloc
2025-02-05 16:40:03 +08:00
Lucas f683085618 fix: unable to upload file on Android devices (#7314) 2025-02-04 20:57:06 +08:00
Nathan.fooo 71a22dc466 chore: fix ai page user profile refresh (#7317) 2025-02-04 20:29:56 +08:00
Lucas eb508a3ec9 fix: editor stuck on image loading loop when uploading image in row document (#7313)
* fix: editor stuck on image loading loop when uploading image in row document

* test: editor stuck on image loading loop when uploading image in row document
2025-02-04 14:05:57 +08:00
Nathan.fooo aacd09d8e2 chore: Support new error code (#7311)
* chore: fetch model list

* chore: suppor new error code
2025-02-03 20:52:08 +08:00
Peter Jose 25a27dfa81 chore(i18n): update id-ID translations (#7290)
- Translate 'themeMode' label
- Adjust text 'fontFamily'
- Update 'layoutDirection' translation
- Update 'textDirection' translation
2025-02-03 10:41:05 +08:00
Mohammad Mahdi Momeni 36349778e3 chore(i18n): update fa translations (#7292) 2025-02-03 10:05:34 +08:00
Nathan.fooo 9271d42db5 chore: fetch model list (#7306)
* chore: fetch model list
2025-02-01 23:48:32 +08:00
Richard Shiue 90d6e98b51 fix(flutter_mobile): drop focus on tap outside (#7274) 2025-01-24 09:23:52 +08:00
Lucas dd6b285cd3 chore: update CHANGELOG.md (#7276) 2025-01-23 17:55:12 +08:00
FakhriAzzouz 8b0d5d76a2 New Arabic Translations (#7260)
* 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-01-23 14:02:32 +08:00
Morn 85b69bde15 chore: cancel button not visible in [Reset to Default] for shortcuts(#7171) (#7257) 2025-01-23 13:58:48 +08:00
Morn dd812d0501 chore: rename [font scale factor] to [scale factor] (#7267) 2025-01-23 13:58:09 +08:00
Richard Shiue a79f825ff1 chore: restrict uploadable image types (#7269)
* chore: restrict image format

* chore: code cleanup
2025-01-23 10:51:15 +08:00
Lucas 862e5629e3 fix: v0.8.2 launch review issues (#7266)
* fix: unable to copy table from Apple Notes

* fix: disable pasting a table into another table

* fix: ignore the delete shortcut if the next node is a simple table node
2025-01-22 22:46:03 +08:00
Richard Shiue c5a91e10df chore: support pasting image links that have no file extension (#7262)
* test: speed up copypasta tests

* chore: strip away markdown syntax if message is image only

* chore: paste image urls with no file extension

* test: add integration test

* test: group tests

* chore: apply code suggestions to 3 files
2025-01-22 22:13:18 +08:00
Lucas 0b0e10baa8 feat: turn -> into to → in document (#7256)
* chore: bump version 0.8.2

* feat: turn -> into to → in document

* Revert "chore: bump version 0.8.2"

This reverts commit 45efd4d7d7.

* test: add shortcut tests
2025-01-22 15:05:22 +08:00
Nathan ec18a3c443 chore: update docs 2025-01-22 09:47:06 +08:00
Nathan.fooo a0867ed688 chore: return ai image error (#7263) 2025-01-22 09:42:24 +08:00
Nathan.fooo cdf8e68ff2 Update plan desc (#7258)
* chore: update plan desc

* chore: update plan desc
2025-01-22 02:02:30 +08:00
Richard Shiue af92303e38 fix: launch review issues 0.8.2 (#7261) 2025-01-21 22:17:53 +08:00
Lucas 504041d75a chore: bump version 0.8.2 (#7255) 2025-01-21 13:31:40 +08:00
Lucas 06ab965413 chore: only enable document integrity check when enableDocumentInternalLog is on (#7251)
* chore: only enable document integrity check when enableDocumentInternalLog is on

* feat: copy divider and table from ChatGPT

* test: add copy from ChatGPT test

* feat: support copying link from keyboard clipboard
2025-01-21 13:29:31 +08:00
Morn 42bd4884fd feat: support custom image icon (#7236)
* feat: supporting upload custom image as icon

* feat: support custom image icon on mobile

* chore: clean code
2025-01-21 11:58:45 +08:00
Lucas 0c057243bc chore: bump client api (#7249)
* chore: bump client api

* chore: set default publish info to true
2025-01-21 09:18:25 +08:00
Lucas 8fdc6e9638 feat: support pasting table from notion, google docs and google sheet (#7247)
* feat: support pasting table from Notion

* test: add google docs / googles sheets table test

* fix: google docs test

* fix: paste table from notion test
2025-01-21 09:12:57 +08:00
Morn 187f7409ce feat: enable customizing icons for database views (#7187)
* feat: enable set icon for databse tab

* chore: resolve conflicts

* feat: enable changing dabtabse icon on mobile
2025-01-20 17:55:01 +08:00
Morn e3ce6e8b4b fix: use WindowSizeManager to zoom on mobile (#7215) 2025-01-20 17:54:26 +08:00
Richard Shiue cfe481759f chore: bump table column width when saving chat message to page (#7243)
* chore: bump table column width when saving chat message to page

* test: add unit test
2025-01-20 13:28:02 +08:00
Richard Shiue 6bcef33d05 chore: trim markdown text (#7242)
* chore: trim markdown text

* chore: trim before saving
2025-01-20 11:47:03 +08:00
Lucas aa8c9bad9f fix: remove app from homepage url (#7233)
* fix: remove app from namespace url

* fix: banner issue on mobile

* Revert "fix: remove app from namespace url"

This reverts commit 4bd5f3f590.

* fix: remove app from namespace url
2025-01-17 20:55:22 +08:00
Richard Shiue 1723886f3a feat: save messages as a new page (#7224) 2025-01-17 20:38:55 +08:00
FakhriAzzouz 63c7f7b6fe chore: update ar-SA translations (#7216)
* 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-01-17 10:08:09 +08:00
Lucas f35dfaf525 fix: improve the document diff function to prevent partial ordering issues (#7217)
* fix: improve the document diff function to prevent partial ordering issues

* fix: improve the document diff function to prevent partial ordering issues

* fix: nested block padding issues

* fix: improve the document diff function to prevent partial ordering issues

* chore: update editor version

* test: add no diff test and update text diff test

* test: delete and insert text diff with different id

* test: insert single text / delete single text tests

* test: multiple delete and update diff

* test: multiple insert and update diff

* chore: revert cargo changes

* chore: remove unused code

* chore: optimize the code logic
2025-01-16 21:01:23 +08:00
Richard Shiue eead2d20f5 fix: pass alwaysDistributeColumnWidths to simple table block (#7228) 2025-01-16 16:42:24 +08:00
Richard Shiue f73c2540c6 chore: reorganize code (#7206) 2025-01-15 19:57:47 +08:00
583 changed files with 21427 additions and 7727 deletions

No files matched your search

+1 -1
View File
@@ -18,7 +18,7 @@ on:
env:
CARGO_TERM_COLOR: always
FLUTTER_VERSION: "3.22.3"
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.81.0"
CARGO_MAKE_VERSION: "0.37.18"
CLOUD_VERSION: 0.6.54-amd64
+11 -11
View File
@@ -25,7 +25,7 @@ on:
env:
CARGO_TERM_COLOR: always
FLUTTER_VERSION: "3.22.2"
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.81.0"
CARGO_MAKE_VERSION: "0.37.18"
CLOUD_VERSION: 0.6.54-amd64
@@ -40,7 +40,7 @@ jobs:
strategy:
fail-fast: true
matrix:
os: [ ubuntu-latest ]
os: [ubuntu-latest]
include:
- os: ubuntu-latest
flutter_profile: development-linux-x86_64
@@ -74,7 +74,7 @@ jobs:
strategy:
fail-fast: true
matrix:
os: [ windows-latest ]
os: [windows-latest]
include:
- os: windows-latest
flutter_profile: development-windows-x86
@@ -101,7 +101,7 @@ jobs:
strategy:
fail-fast: true
matrix:
os: [ macos-latest ]
os: [macos-latest]
include:
- os: macos-latest
flutter_profile: development-mac-x86_64
@@ -123,12 +123,12 @@ jobs:
flutter_profile: ${{ matrix.flutter_profile }}
unit_test:
needs: [ prepare-linux ]
needs: [prepare-linux]
if: github.event.pull_request.draft != true
strategy:
fail-fast: false
matrix:
os: [ ubuntu-latest ]
os: [ubuntu-latest]
include:
- os: ubuntu-latest
flutter_profile: development-linux-x86_64
@@ -217,11 +217,11 @@ jobs:
shell: bash
cloud_integration_test:
needs: [ prepare-linux ]
needs: [prepare-linux]
strategy:
fail-fast: false
matrix:
os: [ ubuntu-latest ]
os: [ubuntu-latest]
include:
- os: ubuntu-latest
flutter_profile: development-linux-x86_64
@@ -340,13 +340,13 @@ jobs:
shell: bash
integration_test:
needs: [ prepare-linux ]
needs: [prepare-linux]
if: github.event.pull_request.draft != true
strategy:
fail-fast: false
matrix:
os: [ ubuntu-latest ]
test_number: [ 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
os: [ubuntu-latest]
test_number: [1, 2, 3, 4, 5, 6, 7, 8, 9]
include:
- os: ubuntu-latest
target: "x86_64-unknown-linux-gnu"
+1 -1
View File
@@ -18,7 +18,7 @@ on:
- "!frontend/appflowy_web_app/**"
env:
FLUTTER_VERSION: "3.22.3"
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.81.0"
concurrency:
+133 -121
View File
@@ -6,7 +6,7 @@ on:
- "*"
env:
FLUTTER_VERSION: "3.22.0"
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.81.0"
jobs:
@@ -128,9 +128,9 @@ jobs:
runs-on: ${{ matrix.job.os }}
needs: create-release
env:
MACOS_APP_RELEASE_PATH: frontend/appflowy_flutter/product/${{ github.ref_name }}/macos/Release
MACOS_APP_RELEASE_PATH: frontend/appflowy_flutter/build/${{ github.ref_name }}
MACOS_X86_ZIP_NAME: AppFlowy-${{ github.ref_name }}-macos-x86_64.zip
MACOS_DMG_NAME: AppFlowy-${{ github.ref_name }}-macos-x86_64
MACOS_DMG_NAME: AppFlowy-${{ github.ref_name }}-macos-x86_64.dmg
strategy:
fail-fast: false
matrix:
@@ -158,46 +158,22 @@ jobs:
- name: Install prerequisites
working-directory: frontend
run: |
brew install p7zip
cargo install --force --locked cargo-make
cargo install --force --locked duckscript_cli
- name: Import codesign certificate
uses: apple-actions/import-codesign-certs@v3
with:
p12-file-base64: ${{ secrets.MACOS_CERTIFICATE }}
p12-password: ${{ secrets.MACOS_CERTIFICATE_PWD }}
- name: Build AppFlowy
working-directory: frontend
run: |
flutter config --enable-macos-desktop
dart ./scripts/flutter_release_build/build_flowy.dart run . ${{ github.ref_name }}
- name: Codesign AppFlowy
run: |
echo ${{ secrets.MACOS_CERTIFICATE }} | base64 --decode > certificate.p12
security create-keychain -p action build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p action build.keychain
security import certificate.p12 -k build.keychain -P ${{ secrets.MACOS_CERTIFICATE_PWD }} -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k action build.keychain
/usr/bin/codesign --force --options runtime --deep --sign "${{ secrets.MACOS_CODESIGN_ID }}" "${{ env.MACOS_APP_RELEASE_PATH }}/AppFlowy.app" -v
- 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"
- 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
- name: Archive Asset
working-directory: ${{ env.MACOS_APP_RELEASE_PATH }}
run: zip --symlinks -qr ${{ env.MACOS_X86_ZIP_NAME }} AppFlowy.app
flutter pub global activate flutter_distributor
sh scripts/flutter_release_build/build_macos.sh --build_type dmg --build_arch x86_64 --version ${{ github.ref_name }} --apple-id ${{ secrets.MACOS_NOTARY_USER }} --team-id ${{ secrets.MACOS_TEAM_ID }} --password ${{ secrets.MACOS_NOTARY_PWD }}
- name: Upload Asset
uses: actions/upload-release-asset@v1
@@ -215,27 +191,27 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: ${{ env.MACOS_APP_RELEASE_PATH }}/${{ env.MACOS_DMG_NAME }}.dmg
asset_name: ${{ env.MACOS_DMG_NAME }}.dmg
asset_path: ${{ env.MACOS_APP_RELEASE_PATH }}/${{ env.MACOS_DMG_NAME }}
asset_name: ${{ env.MACOS_DMG_NAME }}
asset_content_type: application/octet-stream
build-for-macOS-universal:
build-for-macOS-arm64:
name: ${{ matrix.job.target }} (${{ matrix.job.os }}) [${{ matrix.job.extra-build-args }}]
runs-on: ${{ matrix.job.os }}
needs: create-release
env:
MACOS_APP_RELEASE_PATH: frontend/appflowy_flutter/product/${{ github.ref_name }}/macos/Release
MACOS_AARCH64_ZIP_NAME: AppFlowy-${{ github.ref_name }}-macos-universal.zip
MACOS_DMG_NAME: AppFlowy-${{ github.ref_name }}-macos-universal
MACOS_APP_RELEASE_PATH: frontend/appflowy_flutter/build/${{ github.ref_name }}
MACOS_AARCH64_ZIP_NAME: AppFlowy-${{ github.ref_name }}-macos-arm64.zip
MACOS_DMG_NAME: AppFlowy-${{ github.ref_name }}-macos-arm64.dmg
strategy:
fail-fast: false
matrix:
job:
- {
targets: "aarch64-apple-darwin,x86_64-apple-darwin",
os: macos-latest,
extra-build-args: "",
}
targets: "aarch64-apple-darwin",
os: macos-latest,
extra-build-args: "",
}
steps:
- name: Checkout source code
uses: actions/checkout@v4
@@ -256,46 +232,22 @@ jobs:
- name: Install prerequisites
working-directory: frontend
run: |
brew install p7zip
cargo install --force --locked cargo-make
cargo install --force --locked duckscript_cli
- name: Import codesign certificate
uses: apple-actions/import-codesign-certs@v3
with:
p12-file-base64: ${{ secrets.MACOS_CERTIFICATE }}
p12-password: ${{ secrets.MACOS_CERTIFICATE_PWD }}
- name: Build AppFlowy
working-directory: frontend
run: |
flutter config --enable-macos-desktop
sh scripts/flutter_release_build/build_universal_package_for_macos.sh ${{ github.ref_name }}
- name: Codesign AppFlowy
run: |
echo ${{ secrets.MACOS_CERTIFICATE }} | base64 --decode > certificate.p12
security create-keychain -p action build.keychain
security default-keychain -s build.keychain
security unlock-keychain -p action build.keychain
security import certificate.p12 -k build.keychain -P ${{ secrets.MACOS_CERTIFICATE_PWD }} -T /usr/bin/codesign
security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k action build.keychain
/usr/bin/codesign --force --options runtime --deep --sign "${{ secrets.MACOS_CODESIGN_ID }}" "${{ env.MACOS_APP_RELEASE_PATH }}/AppFlowy.app" -v
- 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"
- 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
- name: Archive Asset
working-directory: ${{ env.MACOS_APP_RELEASE_PATH }}
run: zip --symlinks -qr ${{ env.MACOS_AARCH64_ZIP_NAME }} AppFlowy.app
dart pub global activate flutter_distributor
sh scripts/flutter_release_build/build_macos.sh --build_type dmg --build_arch arm64 --version ${{ github.ref_name }} --apple-id ${{ secrets.MACOS_NOTARY_USER }} --team-id ${{ secrets.MACOS_TEAM_ID }} --password ${{ secrets.MACOS_NOTARY_PWD }}
- name: Upload Asset
uses: actions/upload-release-asset@v1
@@ -313,8 +265,82 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: ${{ env.MACOS_APP_RELEASE_PATH }}/${{ env.MACOS_DMG_NAME }}.dmg
asset_name: ${{ env.MACOS_DMG_NAME }}.dmg
asset_path: ${{ env.MACOS_APP_RELEASE_PATH }}/${{ env.MACOS_DMG_NAME }}
asset_name: ${{ env.MACOS_DMG_NAME }}
asset_content_type: application/octet-stream
build-for-macOS-universal:
name: ${{ matrix.job.target }} (${{ matrix.job.os }}) [${{ matrix.job.extra-build-args }}]
runs-on: ${{ matrix.job.os }}
needs: create-release
env:
MACOS_APP_RELEASE_PATH: frontend/appflowy_flutter/build/${{ github.ref_name }}
MACOS_AARCH64_ZIP_NAME: AppFlowy-${{ github.ref_name }}-macos-universal.zip
MACOS_DMG_NAME: AppFlowy-${{ github.ref_name }}-macos-universal.dmg
strategy:
fail-fast: false
matrix:
job:
- {
targets: "aarch64-apple-darwin,x86_64-apple-darwin",
os: macos-latest,
extra-build-args: "",
}
steps:
- name: Checkout source code
uses: actions/checkout@v4
- name: Install flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
targets: ${{ matrix.job.targets }}
components: rustfmt
- name: Install prerequisites
working-directory: frontend
run: |
brew install p7zip
cargo install --force --locked cargo-make
cargo install --force --locked duckscript_cli
- name: Import codesign certificate
uses: apple-actions/import-codesign-certs@v3
with:
p12-file-base64: ${{ secrets.MACOS_CERTIFICATE }}
p12-password: ${{ secrets.MACOS_CERTIFICATE_PWD }}
- name: Build AppFlowy
working-directory: frontend
run: |
flutter config --enable-macos-desktop
dart pub global activate flutter_distributor
sh scripts/flutter_release_build/build_macos.sh --build_type dmg --build_arch universal --version ${{ github.ref_name }} --apple-id ${{ secrets.MACOS_NOTARY_USER }} --team-id ${{ secrets.MACOS_TEAM_ID }} --password ${{ secrets.MACOS_NOTARY_PWD }}
- name: Upload Asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: ${{ env.MACOS_APP_RELEASE_PATH }}/${{ env.MACOS_AARCH64_ZIP_NAME }}
asset_name: ${{ env.MACOS_AARCH64_ZIP_NAME }}
asset_content_type: application/octet-stream
- name: Upload DMG Asset
uses: actions/upload-release-asset@v1
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: ${{ env.MACOS_APP_RELEASE_PATH }}/${{ env.MACOS_DMG_NAME }}
asset_name: ${{ env.MACOS_DMG_NAME }}
asset_content_type: application/octet-stream
build-for-linux:
@@ -322,26 +348,24 @@ jobs:
runs-on: ${{ matrix.job.os }}
needs: create-release
env:
LINUX_APP_RELEASE_PATH: frontend/appflowy_flutter/product/${{ github.ref_name }}/linux/Release
LINUX_ZIP_NAME: AppFlowy-${{ matrix.job.target }}-x86_64.tar.gz
LINUX_APP_RELEASE_PATH: frontend/appflowy_flutter/build/${{ github.ref_name }}
LINUX_PACKAGE_DEB_NAME: AppFlowy-${{ github.ref_name }}-linux-x86_64.deb
LINUX_PACKAGE_RPM_NAME: AppFlowy-${{ github.ref_name }}-linux-x86_64.rpm
LINUX_PACKAGE_TMP_RPM_NAME: AppFlowy-${{ github.ref_name }}-2.x86_64.rpm
LINUX_PACKAGE_TMP_APPIMAGE_NAME: AppFlowy-${{ github.ref_name }}-x86_64.AppImage
LINUX_PACKAGE_APPIMAGE_NAME: AppFlowy-${{ github.ref_name }}-linux-x86_64.AppImage
LINUX_PACKAGE_ZIP_NAME: AppFlowy-${{ github.ref_name }}-linux-x86_64.tar.gz
LINUX_PACKAGE_ZIP_NAME: AppFlowy-${{ github.ref_name }}-linux-x86_64.zip
LINUX_PACKAGE_TAR_NAME: AppFlowy-${{ github.ref_name }}-linux-x86_64.tar.gz
strategy:
fail-fast: false
matrix:
job:
- {
arch: x86_64,
target: x86_64-unknown-linux-gnu,
os: ubuntu-20.04,
extra-build-args: "",
flutter_profile: production-linux-x86_64,
}
arch: x86_64,
target: x86_64-unknown-linux-gnu,
os: ubuntu-22.04,
extra-build-args: "",
flutter_profile: production-linux-x86_64,
}
steps:
- name: Checkout source code
uses: actions/checkout@v4
@@ -364,14 +388,24 @@ jobs:
- name: Install prerequisites
working-directory: frontend
run: |
# Install dependencies
sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub
sudo apt-get update
sudo apt-get install -y build-essential libsqlite3-dev libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev
sudo apt-get install keybinder-3.0
sudo apt-get install -y alien libnotify-dev
sudo apt-get install -y build-essential libsqlite3-dev libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev keybinder-3.0 libnotify-dev
sudo apt install rpm patchelf locate
sudo add-apt-repository universe
sudo apt install libfuse2
# Install appimagetool
wget -O appimagetool "https://github.com/AppImage/AppImageKit/releases/download/continuous/appimagetool-x86_64.AppImage"
chmod +x appimagetool
mv appimagetool /usr/local/bin/
# Install cargo-make and duckscript
source $HOME/.cargo/env
cargo install --force --locked cargo-make
cargo install --force --locked duckscript_cli
rustup target add ${{ matrix.job.target }}
- name: Install gcc-aarch64-linux-gnu
@@ -384,30 +418,8 @@ jobs:
working-directory: frontend
run: |
flutter config --enable-linux-desktop
dart ./scripts/flutter_release_build/build_flowy.dart run . ${{ github.ref_name }}
- name: Archive Asset
working-directory: ${{ env.LINUX_APP_RELEASE_PATH }}
run: tar -czf ${{ env.LINUX_ZIP_NAME }} *
- name: Build Linux package (.deb)
working-directory: frontend
run: |
sh scripts/linux_distribution/deb/build_deb.sh appflowy_flutter/product/${{ github.ref_name }}/linux/Release ${{ github.ref_name }} ${{ env.LINUX_PACKAGE_DEB_NAME }}
- name: Build Linux package (.rpm)
working-directory: ${{ env.LINUX_APP_RELEASE_PATH }}
run: |
sudo alien -r ${{ env.LINUX_PACKAGE_DEB_NAME }}
cp -r ${{ env.LINUX_PACKAGE_TMP_RPM_NAME }} ${{ env.LINUX_PACKAGE_RPM_NAME }}
- name: Build Linux package (.AppImage)
working-directory: frontend
continue-on-error: true
run: |
sh scripts/linux_distribution/appimage/build_appimage.sh ${{ github.ref_name }}
cd ..
cp -r frontend/${{ env.LINUX_PACKAGE_TMP_APPIMAGE_NAME }} ${{ env.LINUX_APP_RELEASE_PATH }}/${{ env.LINUX_PACKAGE_APPIMAGE_NAME }}
dart pub global activate flutter_distributor
./scripts/flutter_release_build/build_linux.sh --build_type all --build_arch x86_64 --version ${{ github.ref_name }}
- name: Upload Asset
id: upload-release-asset
@@ -416,11 +428,11 @@ jobs:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
with:
upload_url: ${{ needs.create-release.outputs.upload_url }}
asset_path: ${{ env.LINUX_APP_RELEASE_PATH }}/${{ env.LINUX_ZIP_NAME }}
asset_path: ${{ env.LINUX_APP_RELEASE_PATH }}/${{ env.LINUX_PACKAGE_ZIP_NAME }}
asset_name: ${{ env.LINUX_PACKAGE_ZIP_NAME }}
asset_content_type: application/octet-stream
- name: Upload Debian package
- name: Upload DEB package
id: upload-release-asset-install-package-deb
uses: actions/upload-release-asset@v1
env:
+1 -1
View File
@@ -10,7 +10,7 @@ on:
env:
CARGO_TERM_COLOR: always
FLUTTER_VERSION: "3.22.0"
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.81.0"
jobs:
+34 -1
View File
@@ -1,6 +1,39 @@
# Release Notes
## Version 0.8.5 - 04/03/2025
### New Features
- Columns in Documents: Arrange content side by side using drag-and-drop or the slash menu
- AI Writers: New AI assistants in documents with response formatting options (list, table, text with images, image-only), follow-up questions, contextual memory, and more
- Compact Mode for Databases: Enable compact mode for grid and kanban views (full-page and inline) to increase information density, displaying more data per screen
### Bug Fixes
- Fixed an issue where callout blocks couldn’t be deleted when appearing as the first line in a document
- Fixed a bug preventing the relation field in databases from opening
- Fixed an issue where links in documents were unclickable on Linux
## Version 0.8.4 - 18/02/2025
### New Features
- Switch AI mode on mobile
- Support locking page
- Support uploading svg file as icon
- Support the slash, at, and plus menus on mobile
### Bug Fixes
- Gallery not rendering in row page
- Save image should not copy the image (mobile)
- Support exporting more content to markdown
## Version 0.8.2 - 23/01/2025
### New Features
- Customized database view icons
- Support for uploading images as custom icons
- Enabled selecting multiple AI messages to save into a document
- Added the ability to scale the app's display size on mobile
- Support for pasting image links without file extensions
### Bug Fixes
- Fixed an issue where pasting tables from other apps wasn't working
- Fixed homepage URL issues in Settings
- Fixed an issue where the 'Cancel' button was not visible on the Shortcuts page
## Version 0.8.1 - 14/01/2025
### New Features:
### New Features
- AI Chat Layout Options: Customize how AI responses appear with new layouts—List, Table, Image with Text, and Media Only
- DALL-E Integration: Generate stunning AI images from text prompts, now available in AI Chat
- Improved Desktop Search: Find what you need faster using keywords or by asking questions in natural language
+12 -14
View File
@@ -1,6 +1,6 @@
<h1 align="center" style="border-bottom: none">
<b>
<a href="https://www.appflowy.io">AppFlowy.IO</a><br>
<a href="https://www.appflowy.com">AppFlowy</a><br>
</b>
⭐️ The Open Source Alternative To Notion ⭐️ <br>
</h1>
@@ -18,18 +18,18 @@ AppFlowy is the AI workspace where you achieve more without losing control of yo
</p>
<p align="center">
<a href="https://www.appflowy.io"><b>Website</b></a> •
<a href="https://www.appflowy.com"><b>Website</b></a> •
<a href="https://forum.appflowy.io/"><b>Forum</b></a> •
<a href="https://discord.gg/9Q2xaN37tV"><b>Discord</b></a> •
<a href="https://www.reddit.com/r/AppFlowy"><b>Reddit</b></a> •
<a href="https://twitter.com/appflowy"><b>Twitter</b></a>
</p>
<p align="center"><img src="https://appflowy.io/_next/static/media/tasks.796c753e.png" alt="AppFlowy Kanban Board for To-dos" /></p>
<p align="center"><img src="https://appflowy.io/_next/static/media/Grid.9e30484b.png" alt="AppFlowy Databases for Tasks and Projects" /></p>
<p align="center"><img src="https://appflowy.io/_next/static/media/sites.a8d5b2b9.png" alt="AppFlowy Sites for Beautiful documentation" /></p>
<p align="center"><img src="https://appflowy.io/_next/static/media/ai.e1460982.png" alt="AppFlowy AI" /></p>
<p align="center"><img src="https://appflowy.io/_next/static/media/template.9ea13c3b.png" alt="AppFlowy Templates" /></p>
<p align="center"><img src="https://appflowy.com/_next/static/media/tasks.796c753e.png" alt="AppFlowy Kanban Board for To-dos" /></p>
<p align="center"><img src="https://appflowy.com/_next/static/media/Grid.9e30484b.png" alt="AppFlowy Databases for Tasks and Projects" /></p>
<p align="center"><img src="https://appflowy.com/_next/static/media/sites.a8d5b2b9.png" alt="AppFlowy Sites for Beautiful documentation" /></p>
<p align="center"><img src="https://appflowy.com/_next/static/media/ai.e1460982.png" alt="AppFlowy AI" /></p>
<p align="center"><img src="https://appflowy.com/_next/static/media/template.9ea13c3b.png" alt="AppFlowy Templates" /></p>
<br></br>
<p align="center" >
@@ -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://docs.appflowy.io/docs/guides/appflowy/self-hosting-appflowy)
- [Self-hosting AppFlowy](https://appflowy.com/docs/self-host-appflowy-overview)
- [Source](https://docs.appflowy.io/docs/documentation/appflowy/from-source)
## Built With
@@ -78,7 +78,7 @@ report [here](https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labe
## **Releases**
Please see the [changelog](https://www.appflowy.io/whatsnew) for more details about a given release.
Please see the [changelog](https://appflowy.com/what-is-new) for more details about a given release.
## Contributing
@@ -89,9 +89,7 @@ for details.
If your Pull Request is accepted as it fixes a bug, adds functionality, or makes AppFlowy's codebase significantly
easier to use or understand, **Congratulations!** If your administrative and managerial work behind the scenes sustains
the community, **Congratulations!** You are now an official contributor to AppFlowy. Get in touch with
us ([link](https://tally.so/r/mKP5z3)) to receive the very special Contributor T-shirt!
Proudly wear your T-shirt and show it to us by tagging [@appflowy](https://twitter.com/appflowy) on Twitter.
the community, **Congratulations!** You are now an official contributor to AppFlowy.
## Translations 🌎🗺
@@ -152,8 +150,8 @@ more information.
## Acknowledgments
Special thanks to these amazing projects which help power AppFlowy.IO:
Special thanks to these amazing projects which help power AppFlowy:
- [cargo-make](https://github.com/sagiegurari/cargo-make)
- [contrib.rocks](https://contrib.rocks)
- [flutter_chat_ui](https://pub.dev/packages/flutter_chat_ui)
- [flutter_chat_ui](https://pub.dev/packages/flutter_chat_ui)
+1 -1
View File
@@ -4,7 +4,7 @@ workflows:
instance_type: mac_mini_m2
max_build_duration: 30
environment:
flutter: 3.22.3
flutter: 3.27.4
xcode: latest
cocoapods: default
+123 -123
View File
@@ -1,125 +1,125 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
// This task only builds the Dart code of AppFlowy.
// It supports both the desktop and mobile version.
"name": "AF: Build Dart Only",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"env": {
"RUST_LOG": "debug",
},
// uncomment the following line to testing performance.
// "flutterMode": "profile",
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
// This task builds the Rust and Dart code of AppFlowy.
"name": "AF-desktop: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core",
"env": {
"RUST_LOG": "trace",
"RUST_BACKTRACE": "1"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
// This task builds will:
// - call the clean task,
// - rebuild all the generated Files (including freeze and language files)
// - rebuild the the Rust and Dart code of AppFlowy.
"name": "AF-desktop: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For iOS",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (iOS)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS-Simulator: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For iOS Simulator",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS-Simulator: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (iOS Simulator)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-Android: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For Android",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-Android: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (Android)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-desktop: Debug Rust",
"type": "lldb",
"request": "attach",
"pid": "${command:pickMyProcess}"
// To launch the application directly, use the following configuration:
// "request": "launch",
// "program": "[YOUR_APPLICATION_PATH]",
},
]
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
// This task only builds the Dart code of AppFlowy.
// It supports both the desktop and mobile version.
"name": "AF: Build Dart Only",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"env": {
"RUST_LOG": "debug",
},
// uncomment the following line to testing performance.
// "flutterMode": "profile",
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
// This task builds the Rust and Dart code of AppFlowy.
"name": "AF-desktop: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core",
"env": {
"RUST_LOG": "trace",
"RUST_BACKTRACE": "1"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
// This task builds will:
// - call the clean task,
// - rebuild all the generated Files (including freeze and language files)
// - rebuild the the Rust and Dart code of AppFlowy.
"name": "AF-desktop: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For iOS",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (iOS)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS-Simulator: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For iOS Simulator",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-iOS-Simulator: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (iOS Simulator)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-Android: Build All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Build Appflowy Core For Android",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-Android: Clean + Rebuild All",
"request": "launch",
"program": "./lib/main.dart",
"type": "dart",
"preLaunchTask": "AF: Clean + Rebuild All (Android)",
"env": {
"RUST_LOG": "trace"
},
"cwd": "${workspaceRoot}/appflowy_flutter"
},
{
"name": "AF-desktop: Debug Rust",
"type": "lldb",
"request": "attach",
"pid": "${command:pickMyProcess}"
// To launch the application directly, use the following configuration:
// "request": "launch",
// "program": "[YOUR_APPLICATION_PATH]",
},
]
}
+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.8.1"
APPFLOWY_VERSION = "0.8.5"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"
@@ -43,6 +43,8 @@
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java
-->
<meta-data android:name="flutterEmbedding" android:value="2" />
<meta-data android:name="io.flutter.embedding.android.EnableImpeller"
android:value="false" />
</application>
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M16 8C16 12.4183 12.4183 16 8 16C3.58172 16 0 12.4183 0 8C0 3.58172 3.58172 0 8 0C12.4183 0 16 3.58172 16 8ZM9.25 3.75C9.25 4.44036 8.69036 5 8 5C7.30964 5 6.75 4.44036 6.75 3.75C6.75 3.05964 7.30964 2.5 8 2.5C8.69036 2.5 9.25 3.05964 9.25 3.75ZM12 8H9.41901L11.2047 13H9.081L8 9.97321L6.91901 13H4.79528L6.581 8H4V6H12V8Z" fill="#000000"/>
</svg>

After

Width:  |  Height:  |  Size: 617 B

@@ -0,0 +1,35 @@
output: build/
releases:
- name: prod
jobs:
- name: release-prod-linux-deb
package:
platform: linux
target: deb
- name: release-prod-linux-rpm
package:
platform: linux
target: rpm
- name: release-prod-linux-appimage
package:
platform: linux
target: appimage
- name: release-prod-linux-zip
package:
platform: linux
target: zip
# Because the flutter_distribute plugin does not support the deep code-signing for macos, we need to manually sign the app.
# So we don't use the flutter_distribute plugin to distribute the macos app.
# - name: release-prod-macos-dmg
# package:
# platform: macos
# target: dmg
# - name: release-prod-macos-pkg
# package:
# platform: macos
# target: pkg
- name: release-prod-macos-zip
package:
platform: macos
target: zip
+26
View File
@@ -0,0 +1,26 @@
-----BEGIN PRIVATE KEY-----
MIIEXAIBADCCBDUGByqGSM44BAEwggQoAoICAQDlkozRmUnVH1MJFqOamAmUYu0Y
ruaTrrt6rCIZ0LFrfNnmHA4LOQEcXwBTTyn5sBmkPq+lb/rjmERKhmvl1rfo6q7t
J8mG4TWqSu0tOJQ6QxexnNW4yhzK/r9MS5MQus4Al+y2hQLaAMOUIOnaWIrC9OHy
7xyw+sVipECVKyQqipS4shGUSqbcN+ocQuTB+I0MtIjBii0DGSEY3pxQrfNWjHFL
7iTVKiTn3YOWPJQvv3FvEDrN+5xU5JZpD97ZhXaJpLUyOQaNvcPaOELPWcOSJwqH
Opf5b5N/VZ8SGbHNdxy9d5sSChBgtuAOihEhSp6SjFQ9eVHOf4NyJwSEMmi0gpdp
qm4ZQRJUnM2zIi0p9twR9FRYXzrxOs6yGCQEY+xFG93ShTLTj3zMrIyFqBsqEwFy
JiJWYWe/zp0V7UlLP+jXO9u9eghNmly7QVqD2P0qs/1V0jZFRuLWpsv4inau/qMZ
5EhGG4xCJZXfN1pkehy6e05/h+vs5anK3Wa/H8AtY6cK4CpzAanELvn3AH7VLbAh
Lswu6d5CV+DoFgxCWMzGBSdmCYU+2wRLaL8Q9TZHDR+pvQlunEFdfFoGES9WjBPh
AsVA6Mq22U8XSje9yHI3X9Eqe/7a+ajSgcGmB7oQ11+4xf5h2PtubRW/JL0KMjxC
xMTpq1md6Ndx/ptBUwIdAIOyiKb2YcTLWAOt+LAlRXMsY1+W4pTXJfV6RcMCggIA
Pxbd0HNj2O/aQhJxNZDMBIcx6+cZ+LKch7qLcaEpVqWHvDSnR2eOJJzWn0RoKK+V
uix/4T8texSQkWxAeFFdo6kyrR9XNL7hqEFFq8o9VpmvRzvG6h/bBgh3AHAQE3p/
8WrbK13IhnlWqd0MjFufSphm63o0gaWl95j+6KeUoKQnioetu9HiMtFKx0d/KYqT
QJg7hvR6VNCU2oShfXR3ce7RnUYwD37+djrUjUkoAZkZq2KoxBiKyeoSIeqAme19
tKcOs6b17mhALELuJ+NtDwlDunyiCDUYX9lTPijHwKeIFtBs38+OtRk3aIqmWTQd
bsCzAxp+kUMA5ESBME/RBNCSPHuDvtA3wfWvNbA5DXfZLwCgNSxhekq8XntIsRzf
J4v4uPzKFcVM3+sUUfSF04HHC9ol+PpLqXUyMnskiizqxFPq7H+6tyFZ7X2HiG6T
jcfVWthmv+JyfcABjVnk2qFH7GagENbdtYmfUox13LhE59Sh5chaJnCFtCDp8NCl
WgZnixCOFQ9EgTLaH6MovTvWpEgG2MfBCu5SMUHi2qSflorqpRFH+rA7NZSnyz3w
m7NB+fJSOP0IjEkOh7MafU6Z61oK9WY/Fc+F1zIENVv8PUc3p75y/4RAp4xzyKcT
ilaNC9U/3MRr3QmWwY7ejtZx6xdOxsvWBRDRSNbDdIkEHgIcfy0+ZHp+4MBcWSDv
uzWeM8QmNvbP+owM+H4F7A==
-----END PRIVATE KEY-----
+36
View File
@@ -0,0 +1,36 @@
-----BEGIN PUBLIC KEY-----
MIIGQzCCBDUGByqGSM44BAEwggQoAoICAQDlkozRmUnVH1MJFqOamAmUYu0YruaT
rrt6rCIZ0LFrfNnmHA4LOQEcXwBTTyn5sBmkPq+lb/rjmERKhmvl1rfo6q7tJ8mG
4TWqSu0tOJQ6QxexnNW4yhzK/r9MS5MQus4Al+y2hQLaAMOUIOnaWIrC9OHy7xyw
+sVipECVKyQqipS4shGUSqbcN+ocQuTB+I0MtIjBii0DGSEY3pxQrfNWjHFL7iTV
KiTn3YOWPJQvv3FvEDrN+5xU5JZpD97ZhXaJpLUyOQaNvcPaOELPWcOSJwqHOpf5
b5N/VZ8SGbHNdxy9d5sSChBgtuAOihEhSp6SjFQ9eVHOf4NyJwSEMmi0gpdpqm4Z
QRJUnM2zIi0p9twR9FRYXzrxOs6yGCQEY+xFG93ShTLTj3zMrIyFqBsqEwFyJiJW
YWe/zp0V7UlLP+jXO9u9eghNmly7QVqD2P0qs/1V0jZFRuLWpsv4inau/qMZ5EhG
G4xCJZXfN1pkehy6e05/h+vs5anK3Wa/H8AtY6cK4CpzAanELvn3AH7VLbAhLswu
6d5CV+DoFgxCWMzGBSdmCYU+2wRLaL8Q9TZHDR+pvQlunEFdfFoGES9WjBPhAsVA
6Mq22U8XSje9yHI3X9Eqe/7a+ajSgcGmB7oQ11+4xf5h2PtubRW/JL0KMjxCxMTp
q1md6Ndx/ptBUwIdAIOyiKb2YcTLWAOt+LAlRXMsY1+W4pTXJfV6RcMCggIAPxbd
0HNj2O/aQhJxNZDMBIcx6+cZ+LKch7qLcaEpVqWHvDSnR2eOJJzWn0RoKK+Vuix/
4T8texSQkWxAeFFdo6kyrR9XNL7hqEFFq8o9VpmvRzvG6h/bBgh3AHAQE3p/8Wrb
K13IhnlWqd0MjFufSphm63o0gaWl95j+6KeUoKQnioetu9HiMtFKx0d/KYqTQJg7
hvR6VNCU2oShfXR3ce7RnUYwD37+djrUjUkoAZkZq2KoxBiKyeoSIeqAme19tKcO
s6b17mhALELuJ+NtDwlDunyiCDUYX9lTPijHwKeIFtBs38+OtRk3aIqmWTQdbsCz
Axp+kUMA5ESBME/RBNCSPHuDvtA3wfWvNbA5DXfZLwCgNSxhekq8XntIsRzfJ4v4
uPzKFcVM3+sUUfSF04HHC9ol+PpLqXUyMnskiizqxFPq7H+6tyFZ7X2HiG6TjcfV
Wthmv+JyfcABjVnk2qFH7GagENbdtYmfUox13LhE59Sh5chaJnCFtCDp8NClWgZn
ixCOFQ9EgTLaH6MovTvWpEgG2MfBCu5SMUHi2qSflorqpRFH+rA7NZSnyz3wm7NB
+fJSOP0IjEkOh7MafU6Z61oK9WY/Fc+F1zIENVv8PUc3p75y/4RAp4xzyKcTilaN
C9U/3MRr3QmWwY7ejtZx6xdOxsvWBRDRSNbDdIkDggIGAAKCAgEAt1DHYZoeXY0r
vYXmxdNO6zfnbz1GGZHXpakzm9h4BrxPDP5J8DQ9ZeVVKg5+cU9AyMO3cZHp7wkx
k6IB+ZDUpqO1D3lWriRl2fI8cS4edI0fzpnW1nyhhFD4MbKmP+v27aH+DhZ4Up3y
GMmJTLmKiYx1EgZp7Sx77PBYDVMsKKd3h9+Hjp2YtUTfD2lleAmC+wcQGZiNtGw/
eKpsmUVnWrepOdntWTtCQi1OvfcHaF2QmgktCq+68hbDNYWaXmzVIiQqrdv/zzOG
hCFIrRGWemrxL0iFG4Pzc4UfOINsISQLcUxRuF6pQWPxF8O/mWKfzAeqWxmIujUM
EoSEuI3yQ8VjlYpW/8FSK7UhgnHBHOpCJWPWs/vQXAnaUR2PYyzuIzhVEhFs8YA8
iBIKnixIC2hu0YbEk3TBr/TRcbd7mDw9Mq7NT88xzdU13+Wh+4zhdX3rtBHYzBtI
7GaONGUNyY4h0duoyLpH6dxevaeKN6/bEdzYESjoE58QA88CpnAZGhJVphAba4cb
w6GTDhK3RlPWh6hRqJwLDILGtnJS3UKeBDRmKMqNuqmHqPjyAAvt9JBO8lzjoLgf
1cDsXHNWBVwA2jsX2CukNJPlY1Fa3MWhdaUXmy6QGMSisr1sptvBt1Phry8T2u+P
Y29SB4jvwqls268rP0cWqy4WXwlVwuc=
-----END PUBLIC KEY-----
@@ -23,24 +23,24 @@ void main() {
final expandFinder = find.byFlowySvg(FlowySvgs.hamburger_s_s);
// Is expanded by default
expect(collapseFinder, findsOneWidget);
expect(expandFinder, findsNothing);
// Collapse hidden groups
await tester.tap(collapseFinder);
await tester.pumpAndSettle();
// Is collapsed
expect(collapseFinder, findsNothing);
expect(expandFinder, findsOneWidget);
// Expand hidden groups
// Collapse hidden groups
await tester.tap(expandFinder);
await tester.pumpAndSettle();
// Is expanded
// Is collapsed
expect(collapseFinder, findsOneWidget);
expect(expandFinder, findsNothing);
// Expand hidden groups
await tester.tap(collapseFinder);
await tester.pumpAndSettle();
// Is expanded
expect(collapseFinder, findsNothing);
expect(expandFinder, findsOneWidget);
});
testWidgets('hide first group, and show it again', (tester) async {
@@ -48,6 +48,9 @@ void main() {
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Board);
final expandFinder = find.byFlowySvg(FlowySvgs.hamburger_s_s);
await tester.tapButton(expandFinder);
// Tap the options of the first group
final optionsFinder = find
.descendant(
@@ -1,5 +1,6 @@
import 'data_migration/data_migration_test_runner.dart'
as data_migration_test_runner;
import 'database/database_test_runner.dart' as database_test_runner;
import 'document/document_test_runner.dart' as document_test_runner;
import 'set_env.dart' as preset_af_cloud_env_test;
import 'sidebar/sidebar_icon_test.dart' as sidebar_icon_test;
@@ -28,4 +29,7 @@ Future<void> main() async {
sidebar_move_page_test.main();
sidebar_rename_untitled_test.main();
sidebar_icon_test.main();
// database
database_test_runner.main();
}
@@ -31,12 +31,6 @@ void main() {
await tester.enterUserName('local_user');
// Scroll to sign-in
await tester.scrollUntilVisible(
find.byType(AccountSignInOutButton),
100,
scrollable: find.findSettingsScrollable(),
);
await tester.tapButton(find.byType(AccountSignInOutButton));
// sign up with Google
@@ -0,0 +1,80 @@
import 'dart:io';
import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/image/resizeable_image.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
import 'package:appflowy_editor/appflowy_editor.dart'
hide UploadImageMenu, ResizableImage;
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import '../../../shared/constants.dart';
import '../../../shared/database_test_op.dart';
import '../../../shared/mock/mock_file_picker.dart';
import '../../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
// copy link to block
group('database image:', () {
testWidgets('insert image', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
// open the first row detail page and upload an image
await tester.createNewPageInSpace(
spaceName: Constants.generalSpaceName,
layout: ViewLayoutPB.Grid,
pageName: 'database image',
);
await tester.openFirstRowDetailPage();
// insert an image block
{
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_image.tr(),
);
}
// upload an image
{
final image = await rootBundle.load('assets/test/images/sample.jpeg');
final tempDirectory = await getTemporaryDirectory();
final imagePath = p.join(tempDirectory.path, 'sample.jpeg');
final file = File(imagePath)
..writeAsBytesSync(image.buffer.asUint8List());
mockPickFilePaths(
paths: [imagePath],
);
await getIt<KeyValueStorage>().set(KVKeys.kCloudType, '0');
await tester.tapButtonWithName(
LocaleKeys.document_imageBlock_upload_placeholder.tr(),
);
await tester.pumpAndSettle();
expect(find.byType(ResizableImage), findsOneWidget);
final node = tester.editor.getCurrentEditorState().getNodeAtPath([0])!;
expect(node.type, ImageBlockKeys.type);
expect(node.attributes[ImageBlockKeys.url], isNotEmpty);
// remove the temp file
file.deleteSync();
}
});
});
}
@@ -0,0 +1,9 @@
import 'package:integration_test/integration_test.dart';
import 'database_image_test.dart' as database_image_test;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
database_image_test.main();
}
@@ -33,7 +33,7 @@ void main() {
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_aiWriter.tr(),
);
expect(find.byType(AIWriterBlockComponent), findsOneWidget);
expect(find.byType(AiWriterBlockComponent), findsOneWidget);
// switch to another page
await tester.openPage(Constants.gettingStartedPageName);
@@ -41,7 +41,7 @@ void main() {
await tester.openPage(pageName);
// expect the ai writer block is not in the document
expect(find.byType(AIWriterBlockComponent), findsNothing);
expect(find.byType(AiWriterBlockComponent), findsNothing);
});
});
}
@@ -57,7 +57,7 @@ void main() {
// move the checkbox to the child of the block at path [9]
await tester.editor.dragBlock(
[10],
const Offset(80, -30),
const Offset(120, -20),
);
// wait for the move animation to complete
@@ -57,12 +57,6 @@ void main() {
await tester.openSettings();
await tester.openSettingsPage(SettingsPage.account);
// Scroll to sign-in
await tester.scrollUntilVisible(
find.byType(AccountSignInOutButton),
100,
scrollable: find.findSettingsScrollable(),
);
await tester.tapButton(find.byType(AccountSignInOutButton));
tester.expectToSeeGoogleLoginButton();
@@ -120,6 +120,10 @@ void main() {
widget is PublishedViewItem &&
widget.publishInfoView.view.name == pageName,
);
if (pageItem.evaluate().isEmpty) {
return;
}
expect(pageItem, findsOneWidget);
// comment it out because it's not allowed to update the namespace in free plan
@@ -257,6 +261,10 @@ More actions for published page:
widget is PublishedViewItem &&
widget.publishInfoView.view.name == pageName,
);
if (pageItem.evaluate().isEmpty) {
return;
}
expect(pageItem, findsOneWidget);
final copyLinkItem = find.text(LocaleKeys.shareAction_copyLink.tr());
@@ -0,0 +1,190 @@
import 'dart:convert';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/tab_bar/desktop/tab_bar_add_button.dart';
import 'package:appflowy/plugins/database/tab_bar/desktop/tab_bar_header.dart';
import 'package:appflowy/plugins/database/widgets/database_layout_ext.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/shared/sidebar_folder.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/setting_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/emoji.dart';
import '../../shared/util.dart';
void main() {
setUpAll(() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
RecentIcons.enable = false;
});
tearDownAll(() {
RecentIcons.enable = true;
});
testWidgets('change icon', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
final iconData = await tester.loadIcon();
const pageName = 'Database';
await tester.createNewPageWithNameUnderParent(
layout: ViewLayoutPB.Grid,
name: pageName,
);
/// create board
final addButton = find.byType(AddDatabaseViewButton);
await tester.tapButton(addButton);
await tester.tapButton(
find.text(
'${LocaleKeys.grid_createView.tr()} ${DatabaseLayoutPB.Board.layoutName}',
findRichText: true,
),
);
/// create calendar
await tester.tapButton(addButton);
await tester.tapButton(
find.text(
'${LocaleKeys.grid_createView.tr()} ${DatabaseLayoutPB.Calendar.layoutName}',
findRichText: true,
),
);
final databaseTabBarItem = find.byType(DatabaseTabBarItem);
expect(databaseTabBarItem, findsNWidgets(3));
final gridItem = databaseTabBarItem.first,
boardItem = databaseTabBarItem.at(1),
calendarItem = databaseTabBarItem.last;
/// change the icon of grid
/// the first tapping is to select specific item
/// the second tapping is to show the menu
await tester.tapButton(gridItem);
await tester.tapButton(gridItem);
/// change icon
await tester
.tapButton(find.text(LocaleKeys.disclosureAction_changeIcon.tr()));
await tester.tapIcon(iconData, enableColor: false);
final gridIcon = find.descendant(
of: gridItem,
matching: find.byType(RawEmojiIconWidget),
);
final gridIconWidget =
gridIcon.evaluate().first.widget as RawEmojiIconWidget;
final iconsData = IconsData.fromJson(jsonDecode(iconData.emoji));
final gridIconsData =
IconsData.fromJson(jsonDecode(gridIconWidget.emoji.emoji));
expect(gridIconsData.iconName, iconsData.iconName);
/// change the icon of board
await tester.tapButton(boardItem);
await tester.tapButton(boardItem);
await tester
.tapButton(find.text(LocaleKeys.disclosureAction_changeIcon.tr()));
await tester.tapIcon(iconData, enableColor: false);
final boardIcon = find.descendant(
of: boardItem,
matching: find.byType(RawEmojiIconWidget),
);
final boardIconWidget =
boardIcon.evaluate().first.widget as RawEmojiIconWidget;
final boardIconsData =
IconsData.fromJson(jsonDecode(boardIconWidget.emoji.emoji));
expect(boardIconsData.iconName, iconsData.iconName);
/// change the icon of calendar
await tester.tapButton(calendarItem);
await tester.tapButton(calendarItem);
await tester
.tapButton(find.text(LocaleKeys.disclosureAction_changeIcon.tr()));
await tester.tapIcon(iconData, enableColor: false);
final calendarIcon = find.descendant(
of: calendarItem,
matching: find.byType(RawEmojiIconWidget),
);
final calendarIconWidget =
calendarIcon.evaluate().first.widget as RawEmojiIconWidget;
final calendarIconsData =
IconsData.fromJson(jsonDecode(calendarIconWidget.emoji.emoji));
expect(calendarIconsData.iconName, iconsData.iconName);
});
testWidgets('change database icon from sidebar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
final iconData = await tester.loadIcon();
final icon = IconsData.fromJson(jsonDecode(iconData.emoji)), emoji = '😄';
const pageName = 'Database';
await tester.createNewPageWithNameUnderParent(
layout: ViewLayoutPB.Grid,
name: pageName,
);
final viewItem = find.descendant(
of: find.byType(SidebarFolder),
matching: find.byWidgetPredicate(
(w) => w is ViewItem && w.view.name == pageName,
),
);
/// change icon to emoji
await tester.tapButton(
find.descendant(
of: viewItem,
matching: find.byType(FlowySvg),
),
);
await tester.tapEmoji(emoji);
final iconWidget = find.descendant(
of: viewItem,
matching: find.byType(RawEmojiIconWidget),
);
expect(
(iconWidget.evaluate().first.widget as RawEmojiIconWidget).emoji.emoji,
emoji,
);
/// the icon will not be displayed in database item
Finder databaseIcon = find.descendant(
of: find.byType(DatabaseTabBarItem),
matching: find.byType(FlowySvg),
);
expect(
(databaseIcon.evaluate().first.widget as FlowySvg).svg,
FlowySvgs.icon_grid_s,
);
/// change emoji to icon
await tester.tapButton(iconWidget);
await tester.tapIcon(iconData);
expect(
(iconWidget.evaluate().first.widget as RawEmojiIconWidget).emoji.emoji,
iconData.emoji,
);
databaseIcon = find.descendant(
of: find.byType(DatabaseTabBarItem),
matching: find.byType(RawEmojiIconWidget),
);
final databaseIconWidget =
databaseIcon.evaluate().first.widget as RawEmojiIconWidget;
final databaseIconsData =
IconsData.fromJson(jsonDecode(databaseIconWidget.emoji.emoji));
expect(icon.svgString, databaseIconsData.svgString);
expect(icon.color, isNotEmpty);
expect(icon.color, databaseIconsData.color);
/// the icon in database item should not show the color
expect(databaseIconWidget.enableColor, false);
});
}
@@ -75,7 +75,7 @@ void main() {
[
LogicalKeyboardKey.control,
LogicalKeyboardKey.shift,
LogicalKeyboardKey.keyE,
LogicalKeyboardKey.keyC,
],
tester: tester,
withKeyUp: true,
@@ -19,7 +19,7 @@ import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('copy and paste in document', () {
group('copy and paste in document:', () {
testWidgets('paste multiple lines at the first line', (tester) async {
// mock the clipboard
const lines = 3;
@@ -172,305 +172,314 @@ void main() {
},
);
});
});
testWidgets('paste text on part of bullet list', (tester) async {
const plainText = 'test';
testWidgets('paste text on part of bullet list', (tester) async {
const plainText = 'test';
await tester.pasteContent(
plainText: plainText,
beforeTest: (editorState) async {
final transaction = editorState.transaction;
transaction.insertNodes(
[0],
[
Node(
type: BulletedListBlockKeys.type,
attributes: {
'delta': [
{"insert": "bullet list"},
],
},
),
],
);
// Set the selection to the second numbered list node (which has empty delta)
transaction.afterSelection = Selection(
start: Position(path: [0], offset: 7),
end: Position(path: [0], offset: 11),
);
await editorState.apply(transaction);
await tester.pumpAndSettle();
},
(editorState) {
final node = editorState.getNodeAtPath([0]);
expect(node?.delta?.toPlainText(), 'bullet test');
expect(node?.type, BulletedListBlockKeys.type);
},
);
});
testWidgets('paste image(png) from memory', (tester) async {
final image = await rootBundle.load('assets/test/images/sample.png');
final bytes = image.buffer.asUint8List();
await tester.pasteContent(image: ('png', bytes), (editorState) {
expect(editorState.document.root.children.length, 1);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ImageBlockKeys.type);
expect(node.attributes[ImageBlockKeys.url], isNotNull);
});
});
testWidgets('paste image(jpeg) from memory', (tester) async {
final image = await rootBundle.load('assets/test/images/sample.jpeg');
final bytes = image.buffer.asUint8List();
await tester.pasteContent(image: ('jpeg', bytes), (editorState) {
expect(editorState.document.root.children.length, 1);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ImageBlockKeys.type);
expect(node.attributes[ImageBlockKeys.url], isNotNull);
});
});
testWidgets('paste image(gif) from memory', (tester) async {
final image = await rootBundle.load('assets/test/images/sample.gif');
final bytes = image.buffer.asUint8List();
await tester.pasteContent(image: ('gif', bytes), (editorState) {
expect(editorState.document.root.children.length, 1);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ImageBlockKeys.type);
expect(node.attributes[ImageBlockKeys.url], isNotNull);
});
});
testWidgets(
'format the selected text to href when pasting url if available',
(tester) async {
const text = 'appflowy';
const url = 'https://appflowy.io';
await tester.pasteContent(
plainText: url,
plainText: plainText,
beforeTest: (editorState) async {
await tester.ime.insertText(text);
await tester.editor.updateSelection(
Selection.single(
path: [0],
startOffset: 0,
endOffset: text.length,
),
final transaction = editorState.transaction;
transaction.insertNodes(
[0],
[
Node(
type: BulletedListBlockKeys.type,
attributes: {
'delta': [
{"insert": "bullet list"},
],
},
),
],
);
},
(editorState) {
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ParagraphBlockKeys.type);
expect(node.delta!.toJson(), [
{
'insert': text,
'attributes': {'href': url},
}
]);
},
);
},
);
// https://github.com/AppFlowy-IO/AppFlowy/issues/3263
testWidgets(
'paste the image from clipboard when html and image are both available',
(tester) async {
const html =
'''<meta charset='utf-8'><img src="https://user-images.githubusercontent.com/9403740/262918875-603f4adb-58dd-49b5-8201-341d354935fd.png" alt="image"/>''';
final image = await rootBundle.load('assets/test/images/sample.png');
final bytes = image.buffer.asUint8List();
await tester.pasteContent(
html: html,
image: ('png', bytes),
(editorState) {
expect(editorState.document.root.children.length, 1);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ImageBlockKeys.type);
},
);
},
);
// Set the selection to the second numbered list node (which has empty delta)
transaction.afterSelection = Selection(
start: Position(path: [0], offset: 7),
end: Position(path: [0], offset: 11),
);
testWidgets('paste the html content contains section', (tester) async {
const html =
'''<meta charset='utf-8'><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgb(0, 160, 113);"><strong style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important;">AppFlowy</strong></span></section><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgb(0, 160, 113);"><strong style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important;">Hello World</strong></span></section>''';
await tester.pasteContent(html: html, (editorState) {
expect(editorState.document.root.children.length, 2);
final node1 = editorState.getNodeAtPath([0])!;
final node2 = editorState.getNodeAtPath([1])!;
expect(node1.type, ParagraphBlockKeys.type);
expect(node2.type, ParagraphBlockKeys.type);
});
});
testWidgets('paste the html from google translation', (tester) async {
const html =
'''<meta charset='utf-8'><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgb(0, 160, 113);"><strong style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important;"><font style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; vertical-align: inherit;"><font style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; vertical-align: inherit;">new force</font></font></strong></span></section><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgb(0, 160, 113);"><strong style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important;"><font style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; vertical-align: inherit;"><font style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; vertical-align: inherit;">Assessment focus: potential motivations, empathy</font></font></strong></span></section><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><br style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important;"></section><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><font style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-Line truncated
await tester.pasteContent(html: html, (editorState) {
expect(editorState.document.root.children.length, 8);
});
});
testWidgets(
'auto convert url to link preview block',
(tester) async {
const url = 'https://appflowy.io';
await tester.pasteContent(plainText: url, (editorState) async {
// the second one is the paragraph node
expect(editorState.document.root.children.length, 2);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, LinkPreviewBlockKeys.type);
expect(node.attributes[LinkPreviewBlockKeys.url], url);
});
// hover on the link preview block
// click the more button
// and select convert to link
await tester.hoverOnWidget(
find.byType(CustomLinkPreviewWidget),
onHover: () async {
final convertToLinkButton = find.byWidgetPredicate((widget) {
return widget is MenuBlockButton &&
widget.tooltip ==
LocaleKeys.document_plugins_urlPreview_convertToLink.tr();
});
expect(convertToLinkButton, findsOneWidget);
await tester.tap(convertToLinkButton);
await editorState.apply(transaction);
await tester.pumpAndSettle();
},
(editorState) {
final node = editorState.getNodeAtPath([0]);
expect(node?.delta?.toPlainText(), 'bullet test');
expect(node?.type, BulletedListBlockKeys.type);
},
);
});
await tester.pumpAndSettle();
final editorState = tester.editor.getCurrentEditorState();
final textNode = editorState.getNodeAtPath([0])!;
expect(textNode.type, ParagraphBlockKeys.type);
expect(textNode.delta!.toJson(), [
{
'insert': url,
'attributes': {'href': url},
}
]);
},
);
testWidgets(
'ctrl/cmd+z to undo the auto convert url to link preview block',
(tester) async {
const url = 'https://appflowy.io';
await tester.pasteContent(plainText: url, (editorState) async {
// the second one is the paragraph node
expect(editorState.document.root.children.length, 2);
testWidgets('paste image(png) from memory', (tester) async {
final image = await rootBundle.load('assets/test/images/sample.png');
final bytes = image.buffer.asUint8List();
await tester.pasteContent(image: ('png', bytes), (editorState) {
expect(editorState.document.root.children.length, 1);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, LinkPreviewBlockKeys.type);
expect(node.attributes[LinkPreviewBlockKeys.url], url);
expect(node.type, ImageBlockKeys.type);
expect(node.attributes[ImageBlockKeys.url], isNotNull);
});
await tester.editor.tapLineOfEditorAt(0);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyZ,
isControlPressed:
UniversalPlatform.isLinux || UniversalPlatform.isWindows,
isMetaPressed: UniversalPlatform.isMacOS,
);
await tester.pumpAndSettle();
final editorState = tester.editor.getCurrentEditorState();
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ParagraphBlockKeys.type);
expect(node.delta!.toJson(), [
{
'insert': url,
'attributes': {'href': url},
}
]);
},
);
testWidgets(
'paste the nodes start with non-delta node',
(tester) async {
await tester.pasteContent((_) {});
const text = 'Hello World';
final editorState = tester.editor.getCurrentEditorState();
final transaction = editorState.transaction;
// [image_block]
// [paragraph_block]
transaction.insertNodes([
0,
], [
customImageNode(url: ''),
paragraphNode(text: text),
]);
await editorState.apply(transaction);
await tester.pumpAndSettle();
await tester.editor.tapLineOfEditorAt(0);
// select all and copy
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyA,
isControlPressed:
UniversalPlatform.isLinux || UniversalPlatform.isWindows,
isMetaPressed: UniversalPlatform.isMacOS,
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyC,
isControlPressed:
UniversalPlatform.isLinux || UniversalPlatform.isWindows,
isMetaPressed: UniversalPlatform.isMacOS,
);
// put the cursor to the end of the paragraph block
await tester.editor.tapLineOfEditorAt(0);
// paste the content
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed:
UniversalPlatform.isLinux || UniversalPlatform.isWindows,
isMetaPressed: UniversalPlatform.isMacOS,
);
await tester.pumpAndSettle();
// expect the image and the paragraph block are inserted below the cursor
expect(editorState.getNodeAtPath([0])!.type, CustomImageBlockKeys.type);
expect(editorState.getNodeAtPath([1])!.type, ParagraphBlockKeys.type);
expect(editorState.getNodeAtPath([2])!.type, CustomImageBlockKeys.type);
expect(editorState.getNodeAtPath([3])!.type, ParagraphBlockKeys.type);
},
);
testWidgets('paste the url without protocol', (tester) async {
// paste the image that from local file
const plainText = '1.jpg';
final image = await rootBundle.load('assets/test/images/sample.jpeg');
final bytes = image.buffer.asUint8List();
await tester.pasteContent(plainText: plainText, image: ('jpeg', bytes),
(editorState) {
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ImageBlockKeys.type);
expect(node.attributes[ImageBlockKeys.url], isNotEmpty);
});
});
testWidgets('paste the image url', (tester) async {
const plainText = 'https://appflowy.io/1.jpg';
final image = await rootBundle.load('assets/test/images/sample.jpeg');
final bytes = image.buffer.asUint8List();
await tester.pasteContent(plainText: plainText, image: ('jpeg', bytes),
(editorState) {
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ImageBlockKeys.type);
expect(node.attributes[ImageBlockKeys.url], isNotEmpty);
testWidgets('paste image(jpeg) from memory', (tester) async {
final image = await rootBundle.load('assets/test/images/sample.jpeg');
final bytes = image.buffer.asUint8List();
await tester.pasteContent(image: ('jpeg', bytes), (editorState) {
expect(editorState.document.root.children.length, 1);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ImageBlockKeys.type);
expect(node.attributes[ImageBlockKeys.url], isNotNull);
});
});
});
const testMarkdownText = '''
testWidgets('paste image(gif) from memory', (tester) async {
final image = await rootBundle.load('assets/test/images/sample.gif');
final bytes = image.buffer.asUint8List();
await tester.pasteContent(image: ('gif', bytes), (editorState) {
expect(editorState.document.root.children.length, 1);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ImageBlockKeys.type);
expect(node.attributes[ImageBlockKeys.url], isNotNull);
});
});
testWidgets(
'format the selected text to href when pasting url if available',
(tester) async {
const text = 'appflowy';
const url = 'https://appflowy.io';
await tester.pasteContent(
plainText: url,
beforeTest: (editorState) async {
await tester.ime.insertText(text);
await tester.editor.updateSelection(
Selection.single(
path: [0],
startOffset: 0,
endOffset: text.length,
),
);
},
(editorState) {
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ParagraphBlockKeys.type);
expect(node.delta!.toJson(), [
{
'insert': text,
'attributes': {'href': url},
}
]);
},
);
},
);
// https://github.com/AppFlowy-IO/AppFlowy/issues/3263
testWidgets(
'paste the image from clipboard when html and image are both available',
(tester) async {
const html =
'''<meta charset='utf-8'><img src="https://user-images.githubusercontent.com/9403740/262918875-603f4adb-58dd-49b5-8201-341d354935fd.png" alt="image"/>''';
final image = await rootBundle.load('assets/test/images/sample.png');
final bytes = image.buffer.asUint8List();
await tester.pasteContent(
html: html,
image: ('png', bytes),
(editorState) {
expect(editorState.document.root.children.length, 1);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ImageBlockKeys.type);
},
);
},
);
testWidgets('paste the html content contains section', (tester) async {
const html =
'''<meta charset='utf-8'><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgb(0, 160, 113);"><strong style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important;">AppFlowy</strong></span></section><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgb(0, 160, 113);"><strong style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important;">Hello World</strong></span></section>''';
await tester.pasteContent(html: html, (editorState) {
expect(editorState.document.root.children.length, 2);
final node1 = editorState.getNodeAtPath([0])!;
final node2 = editorState.getNodeAtPath([1])!;
expect(node1.type, ParagraphBlockKeys.type);
expect(node2.type, ParagraphBlockKeys.type);
});
});
testWidgets('paste the html from google translation', (tester) async {
const html =
'''<meta charset='utf-8'><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgb(0, 160, 113);"><strong style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important;"><font style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; vertical-align: inherit;"><font style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; vertical-align: inherit;">new force</font></font></strong></span></section><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><span style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgb(0, 160, 113);"><strong style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important;"><font style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; vertical-align: inherit;"><font style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important; vertical-align: inherit;">Assessment focus: potential motivations, empathy</font></font></strong></span></section><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><br style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box !important; overflow-wrap: break-word !important;"></section><section style="margin: 0px 8px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: border-box; overflow-wrap: break-word !important; color: rgba(255, 255, 255, 0.6); font-family: system-ui, -apple-system, &quot;system-ui&quot;, &quot;Helvetica Neue&quot;, &quot;PingFang SC&quot;, &quot;Hiragino Sans GB&quot;, &quot;Microsoft YaHei UI&quot;, &quot;Microsoft YaHei&quot;, Arial, sans-serif; font-size: 15px; font-style: normal; font-variant-ligatures: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: 0.544px; orphans: 2; text-align: justify; text-indent: 0px; text-transform: none; widows: 2; word-spacing: 0px; -webkit-text-stroke-width: 0px; white-space: normal; background-color: rgb(25, 25, 25); text-decoration-thickness: initial; text-decoration-style: initial; text-decoration-color: initial;"><font style="margin: 0px; padding: 0px; outline: 0px; max-width: 100%; box-sizing: bordeLine truncated
await tester.pasteContent(html: html, (editorState) {
expect(editorState.document.root.children.length, 8);
});
});
testWidgets(
'auto convert url to link preview block',
(tester) async {
const url = 'https://appflowy.io';
await tester.pasteContent(plainText: url, (editorState) async {
// the second one is the paragraph node
expect(editorState.document.root.children.length, 2);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, LinkPreviewBlockKeys.type);
expect(node.attributes[LinkPreviewBlockKeys.url], url);
});
// hover on the link preview block
// click the more button
// and select convert to link
await tester.hoverOnWidget(
find.byType(CustomLinkPreviewWidget),
onHover: () async {
final convertToLinkButton = find.byWidgetPredicate((widget) {
return widget is MenuBlockButton &&
widget.tooltip ==
LocaleKeys.document_plugins_urlPreview_convertToLink.tr();
});
expect(convertToLinkButton, findsOneWidget);
await tester.tap(convertToLinkButton);
await tester.pumpAndSettle();
},
);
await tester.pumpAndSettle();
final editorState = tester.editor.getCurrentEditorState();
final textNode = editorState.getNodeAtPath([0])!;
expect(textNode.type, ParagraphBlockKeys.type);
expect(textNode.delta!.toJson(), [
{
'insert': url,
'attributes': {'href': url},
}
]);
},
);
testWidgets(
'ctrl/cmd+z to undo the auto convert url to link preview block',
(tester) async {
const url = 'https://appflowy.io';
await tester.pasteContent(plainText: url, (editorState) async {
// the second one is the paragraph node
expect(editorState.document.root.children.length, 2);
final node = editorState.getNodeAtPath([0])!;
expect(node.type, LinkPreviewBlockKeys.type);
expect(node.attributes[LinkPreviewBlockKeys.url], url);
});
await tester.editor.tapLineOfEditorAt(0);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyZ,
isControlPressed:
UniversalPlatform.isLinux || UniversalPlatform.isWindows,
isMetaPressed: UniversalPlatform.isMacOS,
);
await tester.pumpAndSettle();
final editorState = tester.editor.getCurrentEditorState();
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ParagraphBlockKeys.type);
expect(node.delta!.toJson(), [
{
'insert': url,
'attributes': {'href': url},
}
]);
},
);
testWidgets(
'paste the nodes start with non-delta node',
(tester) async {
await tester.pasteContent((_) {});
const text = 'Hello World';
final editorState = tester.editor.getCurrentEditorState();
final transaction = editorState.transaction;
// [image_block]
// [paragraph_block]
transaction.insertNodes([
0,
], [
customImageNode(url: ''),
paragraphNode(text: text),
]);
await editorState.apply(transaction);
await tester.pumpAndSettle();
await tester.editor.tapLineOfEditorAt(0);
// select all and copy
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyA,
isControlPressed:
UniversalPlatform.isLinux || UniversalPlatform.isWindows,
isMetaPressed: UniversalPlatform.isMacOS,
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyC,
isControlPressed:
UniversalPlatform.isLinux || UniversalPlatform.isWindows,
isMetaPressed: UniversalPlatform.isMacOS,
);
// put the cursor to the end of the paragraph block
await tester.editor.tapLineOfEditorAt(0);
// paste the content
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed:
UniversalPlatform.isLinux || UniversalPlatform.isWindows,
isMetaPressed: UniversalPlatform.isMacOS,
);
await tester.pumpAndSettle();
// expect the image and the paragraph block are inserted below the cursor
expect(editorState.getNodeAtPath([0])!.type, CustomImageBlockKeys.type);
expect(editorState.getNodeAtPath([1])!.type, ParagraphBlockKeys.type);
expect(editorState.getNodeAtPath([2])!.type, CustomImageBlockKeys.type);
expect(editorState.getNodeAtPath([3])!.type, ParagraphBlockKeys.type);
},
);
testWidgets('paste the url without protocol', (tester) async {
// paste the image that from local file
const plainText = '1.jpg';
final image = await rootBundle.load('assets/test/images/sample.jpeg');
final bytes = image.buffer.asUint8List();
await tester.pasteContent(plainText: plainText, image: ('jpeg', bytes),
(editorState) {
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ImageBlockKeys.type);
expect(node.attributes[ImageBlockKeys.url], isNotEmpty);
});
});
testWidgets('paste the image url', (tester) async {
const plainText = 'http://example.com/1.jpg';
final image = await rootBundle.load('assets/test/images/sample.jpeg');
final bytes = image.buffer.asUint8List();
await tester.pasteContent(plainText: plainText, image: ('jpeg', bytes),
(editorState) {
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ImageBlockKeys.type);
expect(node.attributes[ImageBlockKeys.url], isNotEmpty);
});
});
testWidgets('paste image url without extension', (tester) async {
const plainText =
'https://images.unsplash.com/photo-1469474968028-56623f02e42e?ixlib=rb-4.0.3&q=85&fm=jpg&crop=entropy&cs=srgb&dl=david-marcu-78A265wPiO4-unsplash.jpg&w=640';
await tester.pasteContent(plainText: plainText, (editorState) {
final node = editorState.getNodeAtPath([0])!;
expect(node.type, ImageBlockKeys.type);
expect(node.attributes[ImageBlockKeys.url], isNotEmpty);
});
});
const testMarkdownText = '''
# I'm h1
## I'm h2
### I'm h3
@@ -478,34 +487,35 @@ void main() {
##### I'm h5
###### I'm h6''';
testWidgets('paste markdowns', (tester) async {
await tester.pasteContent(
plainText: testMarkdownText,
(editorState) {
final children = editorState.document.root.children;
expect(children.length, 6);
for (int i = 1; i <= children.length; i++) {
final text = children[i - 1].delta!.toPlainText();
expect(text, 'I\'m h$i');
}
},
);
});
testWidgets('paste markdowns', (tester) async {
await tester.pasteContent(
plainText: testMarkdownText,
(editorState) {
final children = editorState.document.root.children;
expect(children.length, 6);
for (int i = 1; i <= children.length; i++) {
final text = children[i - 1].delta!.toPlainText();
expect(text, 'I\'m h$i');
}
},
);
});
testWidgets('paste markdowns as plain', (tester) async {
await tester.pasteContent(
plainText: testMarkdownText,
pasteAsPlain: true,
(editorState) {
final children = editorState.document.root.children;
expect(children.length, 6);
for (int i = 1; i <= children.length; i++) {
final text = children[i - 1].delta!.toPlainText();
final expectText = '${'#' * i} I\'m h$i';
expect(text, expectText);
}
},
);
testWidgets('paste markdowns as plain', (tester) async {
await tester.pasteContent(
plainText: testMarkdownText,
pasteAsPlain: true,
(editorState) {
final children = editorState.document.root.children;
expect(children.length, 6);
for (int i = 1; i <= children.length; i++) {
final text = children[i - 1].delta!.toPlainText();
final expectText = '${'#' * i} I\'m h$i';
expect(text, expectText);
}
},
);
});
});
}
@@ -523,7 +533,7 @@ extension on WidgetTester {
await tapAnonymousSignInButton();
// create a new document
await createNewPageWithNameUnderParent(name: 'Test Document');
await createNewPageWithNameUnderParent();
// tap the editor
await tapButton(find.byType(AppFlowyEditor));
@@ -546,7 +556,7 @@ extension on WidgetTester {
isShiftPressed: pasteAsPlain,
isMetaPressed: Platform.isMacOS,
);
await pumpAndSettle();
await pumpAndSettle(const Duration(milliseconds: 1000));
test(editor.getCurrentEditorState());
}
@@ -4,6 +4,7 @@ import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -163,5 +164,55 @@ void main() {
lessThan(5),
);
});
testWidgets('insert inline math equation by shortcut', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a new document
await tester.createNewPageWithNameUnderParent(
name: 'insert inline math equation by shortcut',
);
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
// insert a inline page
const formula = 'E = MC ^ 2';
await tester.ime.insertText(formula);
await tester.editor.updateSelection(
Selection.single(path: [0], startOffset: 0, endOffset: formula.length),
);
// mock key event
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyE,
isShiftPressed: true,
isControlPressed: true,
);
// expect to see the math equation block
final inlineMathEquation = find.byType(InlineMathEquation);
expect(inlineMathEquation, findsOneWidget);
await tester.editor.tapLineOfEditorAt(0);
const text = 'Hello World';
await tester.ime.insertText(text);
final inlineText = find.textContaining(text, findRichText: true);
expect(inlineText, findsOneWidget);
// the text should be in the same line with the math equation
final inlineMathEquationPosition = tester.getRect(inlineMathEquation);
final textPosition = tester.getRect(inlineText);
// allow 5px difference
expect(
(textPosition.top - inlineMathEquationPosition.top).abs(),
lessThan(5),
);
expect(
(textPosition.bottom - inlineMathEquationPosition.bottom).abs(),
lessThan(5),
);
});
});
}
@@ -48,7 +48,7 @@ void main() {
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_photoGallery.tr(),
offset: 80,
offset: 100,
);
expect(find.byType(MultiImageBlockComponent), findsOneWidget);
expect(find.byType(MultiImagePlaceholder), findsOneWidget);
@@ -146,7 +146,7 @@ void main() {
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_photoGallery.tr(),
offset: 80,
offset: 100,
);
expect(find.byType(MultiImageBlockComponent), findsOneWidget);
expect(find.byType(MultiImagePlaceholder), findsOneWidget);
@@ -5,6 +5,7 @@ import 'package:appflowy/shared/icon_emoji_picker/recent_icons.dart';
import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flowy_infra_ui/style_widget/text_field.dart';
import 'package:flowy_svg/flowy_svg.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -209,4 +210,137 @@ void main() {
);
}
});
testWidgets('Update page custom image icon in title bar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
/// prepare local image
final iconData = await tester.prepareImageIcon();
// create document, board, grid and calendar views
for (final value in ViewLayoutPB.values) {
if (value == ViewLayoutPB.Chat) {
continue;
}
await tester.createNewPageWithNameUnderParent(
name: value.name,
parentName: gettingStarted,
layout: value,
);
// update its icon
await tester.updatePageIconInTitleBarByName(
name: value.name,
layout: value,
icon: iconData,
);
tester.expectViewHasIcon(
value.name,
value,
iconData,
);
tester.expectViewTitleHasIcon(
value.name,
value,
iconData,
);
}
});
testWidgets('Update page custom svg icon in title bar', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
/// prepare local image
final iconData = await tester.prepareSvgIcon();
// create document, board, grid and calendar views
for (final value in ViewLayoutPB.values) {
if (value == ViewLayoutPB.Chat) {
continue;
}
await tester.createNewPageWithNameUnderParent(
name: value.name,
parentName: gettingStarted,
layout: value,
);
// update its icon
await tester.updatePageIconInTitleBarByName(
name: value.name,
layout: value,
icon: iconData,
);
tester.expectViewHasIcon(
value.name,
value,
iconData,
);
tester.expectViewTitleHasIcon(
value.name,
value,
iconData,
);
}
});
testWidgets('Update page custom svg icon in title bar by pasting a link',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
/// prepare local image
const testIconLink =
'https://beta.appflowy.cloud/api/file_storage/008e6f23-516b-4d8d-b1fe-2b75c51eee26/v1/blob/6bdf8dff%2D0e54%2D4d35%2D9981%2Dcde68bef1141/BGpLnRtb3AGBNgSJsceu70j83zevYKrMLzqsTIJcBeI=.svg';
/// create document, board, grid and calendar views
for (final value in ViewLayoutPB.values) {
if (value == ViewLayoutPB.Chat) {
continue;
}
await tester.createNewPageWithNameUnderParent(
name: value.name,
parentName: gettingStarted,
layout: value,
);
/// update its icon
await tester.updatePageIconInTitleBarByPasteALink(
name: value.name,
layout: value,
iconLink: testIconLink,
);
/// check if there is a svg in page
final pageName = tester.findPageName(
value.name,
layout: value,
);
final imageInPage = find.descendant(
of: pageName,
matching: find.byType(SvgPicture),
);
expect(imageInPage, findsOneWidget);
/// check if there is a svg in title
final imageInTitle = find.descendant(
of: find.byType(ViewTitleBar),
matching: find.byWidgetPredicate((w) {
if (w is! SvgPicture) return false;
final loader = w.bytesLoader;
if (loader is! SvgFileLoader) return false;
return loader.file.path.endsWith('.svg');
}),
);
expect(imageInTitle, findsOneWidget);
}
});
}
@@ -1,12 +1,16 @@
import 'dart:convert';
import 'dart:io';
import 'package:appflowy/plugins/shared/share/share_button.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
import 'package:archive/archive.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
import '../../shared/mock/mock_file_picker.dart';
import '../../shared/util.dart';
import '../document/document_with_database_test.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
@@ -18,7 +22,7 @@ void main() {
// mock the file picker
final path = await mockSaveFilePath(
p.join(context.applicationDataDirectory, 'test.md'),
p.join(context.applicationDataDirectory, 'test.zip'),
);
// click the share button and select markdown
await tester.tapShareButton();
@@ -28,10 +32,14 @@ void main() {
tester.expectToExportSuccess();
final file = File(path);
final isExist = file.existsSync();
expect(isExist, true);
final markdown = file.readAsStringSync();
expect(markdown, expectedMarkdown);
expect(file.existsSync(), true);
final archive = ZipDecoder().decodeBytes(file.readAsBytesSync());
for (final entry in archive) {
if (entry.isFile && entry.name.endsWith('.md')) {
final markdown = utf8.decode(entry.content);
expect(markdown, expectedMarkdown);
}
}
});
testWidgets(
@@ -57,7 +65,7 @@ void main() {
final path = await mockSaveFilePath(
p.join(
context.applicationDataDirectory,
'${shareButtonState.view.name}.md',
'${shareButtonState.view.name}.zip',
),
);
@@ -69,10 +77,44 @@ void main() {
tester.expectToExportSuccess();
final file = File(path);
final isExist = file.existsSync();
expect(isExist, true);
expect(file.existsSync(), true);
final archive = ZipDecoder().decodeBytes(file.readAsBytesSync());
for (final entry in archive) {
if (entry.isFile && entry.name.endsWith('.md')) {
final markdown = utf8.decode(entry.content);
expect(markdown, expectedMarkdown);
}
}
},
);
testWidgets('share the markdown with database', (tester) async {
final context = await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await insertLinkedDatabase(tester, ViewLayoutPB.Grid);
// mock the file picker
final path = await mockSaveFilePath(
p.join(context.applicationDataDirectory, 'test.zip'),
);
// click the share button and select markdown
await tester.tapShareButton();
await tester.tapMarkdownButton();
// expect to see the success dialog
tester.expectToExportSuccess();
final file = File(path);
expect(file.existsSync(), true);
final archive = ZipDecoder().decodeBytes(file.readAsBytesSync());
bool hasCsvFile = false;
for (final entry in archive) {
if (entry.isFile && entry.name.endsWith('.csv')) {
hasCsvFile = true;
}
}
expect(hasCsvFile, true);
});
});
}
@@ -1,9 +1,10 @@
import 'package:integration_test/integration_test.dart';
import 'desktop/uncategorized/tabs_test.dart' as tabs_test;
import 'desktop/database/database_icon_test.dart' as database_icon_test;
import 'desktop/first_test/first_test.dart' as first_test;
import 'desktop/uncategorized/code_block_language_selector_test.dart'
as code_language_selector;
import 'desktop/first_test/first_test.dart' as first_test;
import 'desktop/uncategorized/tabs_test.dart' as tabs_test;
Future<void> main() async {
await runIntegration9OnDesktop();
@@ -15,4 +16,5 @@ Future<void> runIntegration9OnDesktop() async {
first_test.main();
tabs_test.main();
code_language_selector.main();
database_icon_test.main();
}
@@ -0,0 +1,56 @@
import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart';
import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu_group.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
const title = 'Test At Menu';
group('at menu', () {
testWidgets('show at menu', (tester) async {
await tester.launchInAnonymousMode();
await tester.createPageAndShowAtMenu(title);
final menuWidget = find.byType(MobileInlineActionsMenu);
expect(menuWidget, findsOneWidget);
});
testWidgets('search by at menu', (tester) async {
await tester.launchInAnonymousMode();
await tester.createPageAndShowAtMenu(title);
const searchText = gettingStarted;
await tester.ime.insertText(searchText);
final actionWidgets = find.byType(MobileInlineActionsWidget);
expect(actionWidgets, findsNWidgets(2));
});
testWidgets('tap at menu', (tester) async {
await tester.launchInAnonymousMode();
await tester.createPageAndShowAtMenu(title);
const searchText = gettingStarted;
await tester.ime.insertText(searchText);
final actionWidgets = find.byType(MobileInlineActionsWidget);
await tester.tap(actionWidgets.last);
expect(find.byType(MentionPageBlock), findsOneWidget);
});
testWidgets('create subpage with at menu', (tester) async {
await tester.launchInAnonymousMode();
await tester.createNewDocumentOnMobile(title);
await tester.editor.tapLineOfEditorAt(0);
const subpageName = 'Subpage';
await tester.ime.insertText('[[$subpageName');
await tester.pumpAndSettle();
final actionWidgets = find.byType(MobileInlineActionsWidget);
await tester.tapButton(actionWidgets.first);
final firstNode =
tester.editor.getCurrentEditorState().getNodeAtPath([0]);
assert(firstNode != null);
expect(firstNode!.delta?.toPlainText().contains('['), false);
});
});
}
@@ -1,8 +1,11 @@
import 'package:integration_test/integration_test.dart';
import 'at_menu_test.dart' as at_menu;
import 'at_menu_test.dart' as at_menu_test;
import 'page_style_test.dart' as page_style_test;
import 'plus_menu_test.dart' as plus_menu_test;
import 'simple_table_test.dart' as simple_table_test;
import 'slash_menu_test.dart' as slash_menu;
import 'title_test.dart' as title_test;
import 'toolbar_test.dart' as toolbar_test;
@@ -13,6 +16,9 @@ void main() {
title_test.main();
page_style_test.main();
plus_menu_test.main();
at_menu_test.main();
simple_table_test.main();
toolbar_test.main();
slash_menu.main();
at_menu.main();
}
@@ -0,0 +1,104 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_icon.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/emoji.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('document title:', () {
testWidgets('update page custom image icon in title bar', (tester) async {
await tester.launchInAnonymousMode();
/// prepare local image
final iconData = await tester.prepareImageIcon();
/// create an empty page
await tester
.tapButton(find.byKey(BottomNavigationBarItemType.add.valueKey));
/// show Page style page
await tester.tapButton(find.byType(MobileViewPageLayoutButton));
final pageStyleIcon = find.byType(PageStyleIcon);
final iconInPageStyleIcon = find.descendant(
of: pageStyleIcon,
matching: find.byType(RawEmojiIconWidget),
);
expect(iconInPageStyleIcon, findsNothing);
/// show icon picker
await tester.tapButton(pageStyleIcon);
/// upload custom icon
await tester.pickImage(iconData);
/// check result
final documentPage = find.byType(MobileDocumentScreen);
final rawEmojiIconFinder = find
.descendant(
of: documentPage,
matching: find.byType(RawEmojiIconWidget),
)
.last;
final rawEmojiIconWidget =
rawEmojiIconFinder.evaluate().first.widget as RawEmojiIconWidget;
final iconDataInWidget = rawEmojiIconWidget.emoji;
expect(iconDataInWidget.type, FlowyIconType.custom);
final imageFinder =
find.descendant(of: rawEmojiIconFinder, matching: find.byType(Image));
expect(imageFinder, findsOneWidget);
});
testWidgets('update page custom svg icon in title bar', (tester) async {
await tester.launchInAnonymousMode();
/// prepare local image
final iconData = await tester.prepareSvgIcon();
/// create an empty page
await tester
.tapButton(find.byKey(BottomNavigationBarItemType.add.valueKey));
/// show Page style page
await tester.tapButton(find.byType(MobileViewPageLayoutButton));
final pageStyleIcon = find.byType(PageStyleIcon);
final iconInPageStyleIcon = find.descendant(
of: pageStyleIcon,
matching: find.byType(RawEmojiIconWidget),
);
expect(iconInPageStyleIcon, findsNothing);
/// show icon picker
await tester.tapButton(pageStyleIcon);
/// upload custom icon
await tester.pickImage(iconData);
/// check result
final documentPage = find.byType(MobileDocumentScreen);
final rawEmojiIconFinder = find
.descendant(
of: documentPage,
matching: find.byType(RawEmojiIconWidget),
)
.last;
final rawEmojiIconWidget =
rawEmojiIconFinder.evaluate().first.widget as RawEmojiIconWidget;
final iconDataInWidget = rawEmojiIconWidget.emoji;
expect(iconDataInWidget.type, FlowyIconType.custom);
final svgFinder = find.descendant(
of: rawEmojiIconFinder,
matching: find.byType(SvgPicture),
);
expect(svgFinder, findsOneWidget);
});
});
}
@@ -1,6 +1,9 @@
import 'dart:async';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu.dart';
import 'package:appflowy/mobile/presentation/inline_actions/mobile_inline_actions_menu_group.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -85,5 +88,32 @@ void main() {
equals(Selection.collapsed(Position(path: [2]))),
);
});
const title = 'Test Plus Menu';
testWidgets('show plus menu', (tester) async {
await tester.launchInAnonymousMode();
await tester.createPageAndShowPlusMenu(title);
final menuWidget = find.byType(MobileInlineActionsMenu);
expect(menuWidget, findsOneWidget);
});
testWidgets('search by plus menu', (tester) async {
await tester.launchInAnonymousMode();
await tester.createPageAndShowPlusMenu(title);
const searchText = gettingStarted;
await tester.ime.insertText(searchText);
final actionWidgets = find.byType(MobileInlineActionsWidget);
expect(actionWidgets, findsNWidgets(2));
});
testWidgets('tap plus menu', (tester) async {
await tester.launchInAnonymousMode();
await tester.createPageAndShowPlusMenu(title);
const searchText = gettingStarted;
await tester.ime.insertText(searchText);
final actionWidgets = find.byType(MobileInlineActionsWidget);
await tester.tap(actionWidgets.last);
expect(find.byType(MentionPageBlock), findsOneWidget);
});
});
}
@@ -0,0 +1,84 @@
import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_item.dart';
import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_item_widget.dart';
import 'package:appflowy/mobile/presentation/selection_menu/mobile_selection_menu_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/slash_menu/slash_menu_items/mobile_items.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
const title = 'Test Slash Menu';
group('slash menu', () {
testWidgets('show slash menu', (tester) async {
await tester.launchInAnonymousMode();
await tester.createPageAndShowSlashMenu(title);
final menuWidget = find.byType(MobileSelectionMenuWidget);
expect(menuWidget, findsOneWidget);
final items =
(menuWidget.evaluate().first.widget as MobileSelectionMenuWidget)
.items;
int i = 0;
for (final item in items) {
final localItem = mobileItems[i];
expect(item.name, localItem.name);
i++;
}
});
testWidgets('search by slash menu', (tester) async {
await tester.launchInAnonymousMode();
await tester.createPageAndShowSlashMenu(title);
const searchText = 'Heading';
await tester.ime.insertText(searchText);
final itemWidgets = find.byType(MobileSelectionMenuItemWidget);
int number = 0;
for (final item in mobileItems) {
if (item is MobileSelectionMenuItem) {
for (final childItem in item.children) {
if (childItem.name
.toLowerCase()
.contains(searchText.toLowerCase())) {
number++;
}
}
} else {
if (item.name.toLowerCase().contains(searchText.toLowerCase())) {
number++;
}
}
}
expect(itemWidgets, findsNWidgets(number));
});
testWidgets('tap to show submenu', (tester) async {
await tester.launchInAnonymousMode();
await tester.createNewDocumentOnMobile(title);
await tester.editor.tapLineOfEditorAt(0);
final listview = find.descendant(
of: find.byType(MobileSelectionMenuWidget),
matching: find.byType(ListView),
);
for (final item in mobileItems) {
if (item is! MobileSelectionMenuItem) continue;
await tester.editor.showSlashMenu();
await tester.scrollUntilVisible(
find.text(item.name),
50,
scrollable: listview,
duration: const Duration(milliseconds: 250),
);
await tester.tap(find.text(item.name));
final childrenLength = ((listview.evaluate().first.widget as ListView)
.childrenDelegate as SliverChildListDelegate)
.children
.length;
expect(childrenLength, item.children.length);
}
});
});
}
@@ -0,0 +1,48 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/home/setting/settings_popup_menu.dart';
import 'package:appflowy/workspace/presentation/home/hotkeys.dart';
import 'package:appflowy/workspace/presentation/widgets/more_view_actions/widgets/font_size_stepper.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
testWidgets('test for change scale factor', (tester) async {
await tester.launchInAnonymousMode();
/// tap [Setting] button
await tester.tapButton(find.byType(HomePageSettingsPopupMenu));
await tester
.tapButton(find.text(LocaleKeys.settings_popupMenuItem_settings.tr()));
/// tap [Font Scale Factor]
await tester.tapButton(
find.text(LocaleKeys.settings_appearance_fontScaleFactor.tr()),
);
/// drag slider
final slider = find.descendant(
of: find.byType(FontSizeStepper),
matching: find.byType(Slider),
);
await tester.slideToValue(slider, 0.8);
expect(appflowyScaleFactor, 0.8);
await tester.slideToValue(slider, 0.9);
expect(appflowyScaleFactor, 0.9);
await tester.slideToValue(slider, 1.0);
expect(appflowyScaleFactor, 1.0);
await tester.slideToValue(slider, 1.1);
expect(appflowyScaleFactor, 1.1);
await tester.slideToValue(slider, 1.2);
expect(appflowyScaleFactor, 1.2);
});
}
@@ -13,6 +13,7 @@ import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
@@ -235,6 +236,25 @@ extension AppFlowyTestBase on WidgetTester {
Future<void> wait(int milliseconds) async {
await pumpAndSettle(Duration(milliseconds: milliseconds));
}
Future<void> slideToValue(
Finder slider,
double value, {
double paddingOffset = 24.0,
}) async {
final sliderWidget = slider.evaluate().first.widget as Slider;
final range = sliderWidget.max - sliderWidget.min;
final initialRate = (value - sliderWidget.min) / range;
final totalWidth = getSize(slider).width - (2 * paddingOffset);
final zeroPoint = getTopLeft(slider) +
Offset(
paddingOffset + initialRate * totalWidth,
getSize(slider).height / 2,
);
final calculatedOffset = value * (totalWidth / 100);
await dragFrom(zeroPoint, Offset(calculatedOffset, 0));
await pumpAndSettle();
}
}
extension AppFlowyFinderTestBase on CommonFinders {
@@ -50,6 +50,8 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
import 'package:universal_platform/universal_platform.dart';
import 'emoji.dart';
@@ -615,7 +617,7 @@ extension CommonOperations on WidgetTester {
);
final distanceY = getCenter(to).dy - getCenter(from).dx;
await drag(from, Offset(0, distanceY));
await pumpAndSettle();
await pumpAndSettle(const Duration(seconds: 1));
}
// tap the button with [FlowySvgData]
@@ -671,10 +673,31 @@ extension CommonOperations on WidgetTester {
await tapEmoji(icon.emoji);
} else if (icon.type == FlowyIconType.icon) {
await tapIcon(icon);
} else if (icon.type == FlowyIconType.custom) {
await pickImage(icon);
}
await pumpAndSettle();
}
Future<void> updatePageIconInTitleBarByPasteALink({
required String name,
required ViewLayoutPB layout,
required String iconLink,
}) async {
await openPage(
name,
layout: layout,
);
final title = find.descendant(
of: find.byType(ViewTitleBar),
matching: find.text(name),
);
await tapButton(title);
await tapButton(find.byType(EmojiPickerButton));
await pasteImageLinkAsIcon(iconLink);
await pumpAndSettle();
}
Future<void> openNotificationHub({int tabIndex = 0}) async {
final finder = find.descendant(
of: find.byType(NotificationButton),
@@ -933,6 +956,45 @@ extension CommonOperations on WidgetTester {
),
);
}
Future<EmojiIconData> prepareImageIcon() async {
final imagePath = await rootBundle.load('assets/test/images/sample.jpeg');
final tempDirectory = await getTemporaryDirectory();
final localImagePath = p.join(tempDirectory.path, 'sample.jpeg');
final imageFile = File(localImagePath)
..writeAsBytesSync(imagePath.buffer.asUint8List());
return EmojiIconData.custom(imageFile.path);
}
Future<EmojiIconData> prepareSvgIcon() async {
final imagePath = await rootBundle.load('assets/test/images/sample.svg');
final tempDirectory = await getTemporaryDirectory();
final localImagePath = p.join(tempDirectory.path, 'sample.svg');
final imageFile = File(localImagePath)
..writeAsBytesSync(imagePath.buffer.asUint8List());
return EmojiIconData.custom(imageFile.path);
}
/// create new page and show slash menu
Future<void> createPageAndShowSlashMenu(String title) async {
await createNewDocumentOnMobile(title);
await editor.tapLineOfEditorAt(0);
await editor.showSlashMenu();
}
/// create new page and show at menu
Future<void> createPageAndShowAtMenu(String title) async {
await createNewDocumentOnMobile(title);
await editor.tapLineOfEditorAt(0);
await editor.showAtMenu();
}
/// create new page and show plus menu
Future<void> createPageAndShowPlusMenu(String title) async {
await createNewDocumentOnMobile(title);
await editor.tapLineOfEditorAt(0);
await editor.showPlusMenu();
}
}
extension SettingsFinder on CommonFinders {
@@ -1464,6 +1464,7 @@ extension AppFlowyDatabaseTest on WidgetTester {
);
await tapButton(button);
await tapButtonWithName(LocaleKeys.button_delete.tr());
}
Future<void> dragDropRescheduleCalendarEvent() async {
@@ -1,16 +1,24 @@
import 'dart:convert';
import 'dart:io';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_color_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_uploader.dart';
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart';
import 'package:desktop_drop/desktop_drop.dart';
import 'package:flowy_infra_ui/style_widget/primary_rounded_button.dart';
import 'package:flowy_svg/flowy_svg.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_emoji_mart/flutter_emoji_mart.dart';
import 'package:flutter_test/flutter_test.dart';
import 'base.dart';
import 'common_operations.dart';
extension EmojiTestExtension on WidgetTester {
Future<void> tapEmoji(String emoji) async {
@@ -21,7 +29,7 @@ extension EmojiTestExtension on WidgetTester {
await tapButton(emojiWidget);
}
Future<void> tapIcon(EmojiIconData icon) async {
Future<void> tapIcon(EmojiIconData icon, {bool enableColor = true}) async {
final iconsData = IconsData.fromJson(jsonDecode(icon.emoji));
final pickTab = find.byType(PickerTab);
expect(pickTab, findsOneWidget);
@@ -40,23 +48,97 @@ extension EmojiTestExtension on WidgetTester {
);
await tapButton(selectedSvg.first);
final colorPicker = find.byType(IconColorPicker);
expect(colorPicker, findsOneWidget);
final selectedColor = find.descendant(
of: colorPicker,
matching: find.byWidgetPredicate((w) {
if (w is Container) {
final d = w.decoration;
if (d is ShapeDecoration) {
if (d.color ==
Color(int.parse(iconsData.color ?? builtInSpaceColors.first))) {
return true;
if (enableColor) {
final colorPicker = find.byType(IconColorPicker);
expect(colorPicker, findsOneWidget);
final selectedColor = find.descendant(
of: colorPicker,
matching: find.byWidgetPredicate((w) {
if (w is Container) {
final d = w.decoration;
if (d is ShapeDecoration) {
if (d.color ==
Color(
int.parse(iconsData.color ?? builtInSpaceColors.first),
)) {
return true;
}
}
}
}
return false;
}),
return false;
}),
);
await tapButton(selectedColor);
}
}
Future<void> pickImage(EmojiIconData icon) async {
final pickTab = find.byType(PickerTab);
expect(pickTab, findsOneWidget);
await pumpAndSettle();
/// switch to custom tab
final iconTab = find.descendant(
of: pickTab,
matching: find.text(PickerTabType.custom.tr),
);
await tapButton(selectedColor);
expect(iconTab, findsOneWidget);
await tapButton(iconTab);
/// mock for dragging image
final dropTarget = find.descendant(
of: find.byType(IconUploader),
matching: find.byType(DropTarget),
);
expect(dropTarget, findsOneWidget);
final dropTargetWidget = dropTarget.evaluate().first.widget as DropTarget;
dropTargetWidget.onDragDone?.call(
DropDoneDetails(
files: [DropItemFile(icon.emoji)],
localPosition: Offset.zero,
globalPosition: Offset.zero,
),
);
await pumpAndSettle(const Duration(seconds: 3));
/// confirm to upload
final confirmButton = find.descendant(
of: find.byType(IconUploader),
matching: find.byType(PrimaryRoundedButton),
);
await tapButton(confirmButton);
}
Future<void> pasteImageLinkAsIcon(String link) async {
final pickTab = find.byType(PickerTab);
expect(pickTab, findsOneWidget);
await pumpAndSettle();
/// switch to custom tab
final iconTab = find.descendant(
of: pickTab,
matching: find.text(PickerTabType.custom.tr),
);
expect(iconTab, findsOneWidget);
await tapButton(iconTab);
// mock the clipboard
await getIt<ClipboardService>()
.setData(ClipboardServiceData(plainText: link));
// paste the link
await simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await pumpAndSettle(const Duration(seconds: 5));
/// confirm to upload
final confirmButton = find.descendant(
of: find.byType(IconUploader),
matching: find.byType(PrimaryRoundedButton),
);
await tapButton(confirmButton);
}
}
@@ -7,6 +7,8 @@ import 'package:appflowy/plugins/database/widgets/row/row_detail.dart';
import 'package:appflowy/plugins/document/presentation/banner.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/document_cover_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/shared/appflowy_network_image.dart';
import 'package:appflowy/shared/appflowy_network_svg.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
@@ -21,6 +23,7 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_svg/flowy_svg.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:string_validator/string_validator.dart';
import 'package:universal_platform/universal_platform.dart';
import 'util.dart';
@@ -249,6 +252,23 @@ extension Expectation on WidgetTester {
),
);
expect(icon, findsOneWidget);
} else if (type == FlowyIconType.custom) {
final isSvg = data.emoji.endsWith('.svg');
if (isURL(data.emoji)) {
final image = find.descendant(
of: pageName,
matching: isSvg
? find.byType(FlowyNetworkSvg)
: find.byType(FlowyNetworkImage),
);
expect(image, findsOneWidget);
} else {
final image = find.descendant(
of: pageName,
matching: isSvg ? find.byType(SvgPicture) : find.byType(Image),
);
expect(image, findsOneWidget);
}
}
}
@@ -273,6 +293,30 @@ extension Expectation on WidgetTester {
),
);
expect(icon, findsOneWidget);
} else if (type == FlowyIconType.custom) {
final isSvg = data.emoji.endsWith('.svg');
if (isURL(data.emoji)) {
final image = find.descendant(
of: find.byType(ViewTitleBar),
matching: isSvg
? find.byType(FlowyNetworkSvg)
: find.byType(FlowyNetworkImage),
);
expect(image, findsOneWidget);
} else {
final image = find.descendant(
of: find.byType(ViewTitleBar),
matching: isSvg
? find.byWidgetPredicate((w) {
if (w is! SvgPicture) return false;
final loader = w.bytesLoader;
if (loader is! SvgFileLoader) return false;
return loader.file.path.endsWith('.svg');
})
: find.byType(Image),
);
expect(image, findsOneWidget);
}
}
}
@@ -1,81 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:appflowy/ai/error.dart';
import 'package:appflowy/ai/openai_client.dart';
import 'package:appflowy/ai/text_completion.dart';
import 'package:http/http.dart' as http;
import 'package:mocktail/mocktail.dart';
class MyMockClient extends Mock implements http.Client {
@override
Future<http.StreamedResponse> send(http.BaseRequest request) async {
final requestType = request.method;
final requestUri = request.url;
if (requestType == 'POST' &&
requestUri == OpenAIRequestType.textCompletion.uri) {
final responseHeaders = <String, String>{
'content-type': 'text/event-stream',
};
final responseBody = Stream.fromIterable([
utf8.encode(
'{ "choices": [{"text": "Lorem ipsum dolor sit amet, consectetuer adipiscing elit. Aenean commodo ligula ", "index": 0, "logprobs": null, "finish_reason": null}]}',
),
utf8.encode('\n'),
utf8.encode('[DONE]'),
]);
// Return a mocked response with the expected data
return http.StreamedResponse(responseBody, 200, headers: responseHeaders);
}
// Return an error response for any other request
return http.StreamedResponse(const Stream.empty(), 404);
}
}
class MockOpenAIRepository extends HttpOpenAIRepository {
MockOpenAIRepository() : super(apiKey: 'dummyKey', client: MyMockClient());
@override
Future<void> getStreamedCompletions({
required String prompt,
required Future<void> Function() onStart,
required Future<void> Function(TextCompletionResponse response) onProcess,
required Future<void> Function() onEnd,
required void Function(AIError error) onError,
String? suffix,
int maxTokens = 2048,
double temperature = 0.3,
bool useAction = false,
}) async {
final request = http.Request('POST', OpenAIRequestType.textCompletion.uri);
final response = await client.send(request);
String previousSyntax = '';
if (response.statusCode == 200) {
await for (final chunk in response.stream
.transform(const Utf8Decoder())
.transform(const LineSplitter())) {
await onStart();
final data = chunk.trim().split('data: ');
if (data[0] != '[DONE]') {
final response = TextCompletionResponse.fromJson(
json.decode(data[0]),
);
if (response.choices.isNotEmpty) {
final text = response.choices.first.text;
if (text == previousSyntax && text == '\n') {
continue;
}
await onProcess(response);
previousSyntax = response.choices.first.text;
}
} else {
await onEnd();
}
}
}
}
}
+36 -29
View File
@@ -1,5 +1,5 @@
PODS:
- app_links (0.0.1):
- app_links (0.0.2):
- Flutter
- appflowy_backend (0.0.1):
- Flutter
@@ -66,6 +66,8 @@ PODS:
- permission_handler_apple (9.3.0):
- Flutter
- ReachabilitySwift (5.0.0)
- saver_gallery (0.0.1):
- Flutter
- SDWebImage (5.14.2):
- SDWebImage/Core (= 5.14.2)
- SDWebImage/Core (5.14.2)
@@ -79,7 +81,7 @@ PODS:
- shared_preferences_foundation (0.0.1):
- Flutter
- FlutterMacOS
- sqflite (0.0.3):
- sqflite_darwin (0.0.4):
- Flutter
- FlutterMacOS
- super_native_extensions (0.0.1):
@@ -90,6 +92,7 @@ PODS:
- Flutter
- webview_flutter_wkwebview (0.0.1):
- Flutter
- FlutterMacOS
DEPENDENCIES:
- app_links (from `.symlinks/plugins/app_links/ios`)
@@ -108,13 +111,14 @@ DEPENDENCIES:
- package_info_plus (from `.symlinks/plugins/package_info_plus/ios`)
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- saver_gallery (from `.symlinks/plugins/saver_gallery/ios`)
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite (from `.symlinks/plugins/sqflite/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
- super_native_extensions (from `.symlinks/plugins/super_native_extensions/ios`)
- url_launcher_ios (from `.symlinks/plugins/url_launcher_ios/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/ios`)
- webview_flutter_wkwebview (from `.symlinks/plugins/webview_flutter_wkwebview/darwin`)
SPEC REPOS:
trunk:
@@ -159,52 +163,55 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/path_provider_foundation/darwin"
permission_handler_apple:
:path: ".symlinks/plugins/permission_handler_apple/ios"
saver_gallery:
:path: ".symlinks/plugins/saver_gallery/ios"
sentry_flutter:
:path: ".symlinks/plugins/sentry_flutter/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
:path: ".symlinks/plugins/shared_preferences_foundation/darwin"
sqflite:
:path: ".symlinks/plugins/sqflite/darwin"
sqflite_darwin:
:path: ".symlinks/plugins/sqflite_darwin/darwin"
super_native_extensions:
:path: ".symlinks/plugins/super_native_extensions/ios"
url_launcher_ios:
:path: ".symlinks/plugins/url_launcher_ios/ios"
webview_flutter_wkwebview:
:path: ".symlinks/plugins/webview_flutter_wkwebview/ios"
:path: ".symlinks/plugins/webview_flutter_wkwebview/darwin"
SPEC CHECKSUMS:
app_links: e70ca16b4b0f88253b3b3660200d4a10b4ea9795
appflowy_backend: 144c20d8bfb298c4e10fa3fa6701a9f41bf98b88
connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d
device_info_plus: 97af1d7e84681a90d0693e63169a5d50e0839a0d
app_links: 3da4c36b46cac3bf24eb897f1a6ce80bda109874
appflowy_backend: 78f6a053f756e6bc29bcc5a2106cbe77b756e97a
connectivity_plus: 481668c94744c30c53b8895afb39159d1e619bdf
device_info_plus: 71ffc6ab7634ade6267c7a93088ed7e4f74e5896
DKImagePickerController: b512c28220a2b8ac7419f21c491fc8534b7601ac
DKPhotoGallery: fdfad5125a9fdda9cc57df834d49df790dbb4179
file_picker: 09aa5ec1ab24135ccd7a1621c46c84134bfd6655
flowy_infra_ui: 0455e1fa8c51885aa1437848e361e99419f34ebc
file_picker: 9b3292d7c8bc68c8a7bf8eb78f730e49c8efc517
flowy_infra_ui: 931b73a18b54a392ab6152eebe29a63a30751f53
Flutter: e0871f40cf51350855a761d2e70bf5af5b9b5de7
fluttertoast: e9a18c7be5413da53898f660530c56f35edfba9c
image_picker_ios: c560581cceedb403a6ff17f2f816d7fea1421fc1
integration_test: ce0a3ffa1de96d1a89ca0ac26fca7ea18a749ef4
irondash_engine_context: 3458bf979b90d616ffb8ae03a150bafe2e860cc9
keyboard_height_plugin: 43fa8bba20fd5c4fdeed5076466b8b9d43cc6b86
open_filex: 6e26e659846ec990262224a12ef1c528bb4edbe4
package_info_plus: c0502532a26c7662a62a356cebe2692ec5fe4ec4
path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46
permission_handler_apple: 9878588469a2b0d0fc1e048d9f43605f92e6cec2
fluttertoast: 76fea30fcf04176325f6864c87306927bd7d2038
image_picker_ios: 7fe1ff8e34c1790d6fff70a32484959f563a928a
integration_test: 4a889634ef21a45d28d50d622cf412dc6d9f586e
irondash_engine_context: 8e58ca8e0212ee9d1c7dc6a42121849986c88486
keyboard_height_plugin: ef70a8181b29f27670e9e2450814ca6b6dc05b05
open_filex: 432f3cd11432da3e39f47fcc0df2b1603854eff1
package_info_plus: af8e2ca6888548050f16fa2f1938db7b5a5df499
path_provider_foundation: 080d55be775b7414fd5a5ef3ac137b97b097e564
permission_handler_apple: 4ed2196e43d0651e8ff7ca3483a069d469701f2d
ReachabilitySwift: 985039c6f7b23a1da463388634119492ff86c825
saver_gallery: af2d0c762dafda254e0ad025ef0dabd6506cd490
SDWebImage: b9a731e1d6307f44ca703b3976d18c24ca561e84
Sentry: 1fe34e9c2cbba1e347623610d26db121dcb569f1
sentry_flutter: a39c2a2d67d5e5b9cb0b94a4985c76dd5b3fc737
share_plus: 8b6f8b3447e494cca5317c8c3073de39b3600d1f
shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78
sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec
super_native_extensions: 4916b3c627a9c7fffdc48a23a9eca0b1ac228fa7
sentry_flutter: e24b397f9a61fa5bbefd8279c3b2242ca86faa90
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
super_native_extensions: b763c02dc3a8fd078389f410bf15149179020cb4
SwiftyGif: 6c3eafd0ce693cad58bb63d2b2fb9bacb8552780
Toast: 91b396c56ee72a5790816f40d3a94dd357abc196
url_launcher_ios: 5334b05cef931de560670eeae103fd3e431ac3fe
webview_flutter_wkwebview: 2a23822e9039b7b1bc52e5add778e5d89ad488d1
url_launcher_ios: 694010445543906933d732453a59da0a173ae33d
webview_flutter_wkwebview: 44d4dee7d7056d5ad185d25b38404436d56c547c
PODFILE CHECKSUM: d0d9b4ff572d8695c38eb3f9b490f55cdfc57eca
@@ -1,7 +1,7 @@
import UIKit
import Flutter
@UIApplicationMain
@main
@objc class AppDelegate: FlutterAppDelegate {
override func application(
_ application: UIApplication,
+73 -70
View File
@@ -1,75 +1,78 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true/>
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
</array>
<key>CFBundleName</key>
<string>AppFlowy</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string></string>
<key>CFBundleURLSchemes</key>
<array>
<string>appflowy-flutter</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>FLTEnableImpeller</key>
<false/>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
<key>CADisableMinimumFrameDurationOnPhone</key>
<true />
<key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key>
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleLocalizations</key>
<array>
<string>en</string>
</array>
<key>CFBundleName</key>
<string>AppFlowy</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleShortVersionString</key>
<string>$(FLUTTER_BUILD_NAME)</string>
<key>CFBundleSignature</key>
<string>????</string>
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLName</key>
<string></string>
<key>CFBundleURLSchemes</key>
<array>
<string>appflowy-flutter</string>
</array>
</dict>
</array>
<key>CFBundleVersion</key>
<string>$(FLUTTER_BUILD_NUMBER)</string>
<key>FLTEnableImpeller</key>
<false />
<key>LSRequiresIPhoneOS</key>
<true />
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true />
</dict>
<key>NSPhotoLibraryUsageDescription</key>
<string>AppFlowy needs access to your photos to let you add images to your documents</string>
<key>NSPhotoLibraryAddUsageDescription</key>
<string>AppFlowy needs access to your photos to let you add images to your photo library</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true />
<key>NSCameraUsageDescription</key>
<string>AppFlowy needs access to your camera to let you add images to your documents from
camera</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UISupportsDocumentBrowser</key>
<true />
<key>UIViewControllerBasedStatusBarAppearance</key>
<false />
</dict>
<key>NSPhotoLibraryUsageDescription</key>
<string>AppFlowy needs access to your photos to let you add images to your documents</string>
<key>UIApplicationSupportsIndirectInputEvents</key>
<true/>
<key>NSCameraUsageDescription</key>
<string>AppFlowy needs access to your camera to let you add images to your documents from camera</string>
<key>UILaunchStoryboardName</key>
<string>LaunchScreen</string>
<key>UIMainStoryboardFile</key>
<string>Main</string>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationPortrait</string>
</array>
<key>UISupportedInterfaceOrientations~ipad</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UISupportsDocumentBrowser</key>
<true/>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>
</plist>
+16
View File
@@ -0,0 +1,16 @@
export 'service/ai_entities.dart';
export 'service/ai_prompt_input_bloc.dart';
export 'service/appflowy_ai_service.dart';
export 'service/error.dart';
export 'widgets/loading_indicator.dart';
export 'widgets/prompt_input/action_buttons.dart';
export 'widgets/prompt_input/desktop_prompt_text_field.dart';
export 'widgets/prompt_input/file_attachment_list.dart';
export 'widgets/prompt_input/layout_define.dart';
export 'widgets/prompt_input/mention_page_bottom_sheet.dart';
export 'widgets/prompt_input/mention_page_menu.dart';
export 'widgets/prompt_input/mentioned_page_text_span.dart';
export 'widgets/prompt_input/predefined_format_buttons.dart';
export 'widgets/prompt_input/select_sources_bottom_sheet.dart';
export 'widgets/prompt_input/select_sources_menu.dart';
export 'widgets/prompt_input/send_button.dart';
@@ -1,34 +0,0 @@
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'error.dart';
import 'text_completion.dart';
abstract class AIRepository {
Future<void> getStreamedCompletions({
required String prompt,
required Future<void> Function() onStart,
required Future<void> Function(TextCompletionResponse response) onProcess,
required Future<void> Function() onEnd,
required void Function(AIError error) onError,
String? suffix,
int maxTokens = 2048,
double temperature = 0.3,
bool useAction = false,
});
Future<void> streamCompletion({
String? objectId,
required String text,
required CompletionTypePB completionType,
required Future<void> Function() onStart,
required Future<void> Function(String text) onProcess,
required Future<void> Function() onEnd,
required void Function(AIError error) onError,
});
Future<FlowyResult<List<String>, AIError>> generateImage({
required String prompt,
int n = 1,
});
}
@@ -1,143 +0,0 @@
import 'dart:async';
import 'dart:ffi';
import 'dart:isolate';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/ask_ai_action.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:fixnum/fixnum.dart' as fixnum;
import 'ai_client.dart';
import 'error.dart';
import 'text_completion.dart';
class AppFlowyAIService implements AIRepository {
@override
Future<FlowyResult<List<String>, AIError>> generateImage({
required String prompt,
int n = 1,
}) {
throw UnimplementedError();
}
@override
Future<void> getStreamedCompletions({
required String prompt,
required Future<void> Function() onStart,
required Future<void> Function(TextCompletionResponse response) onProcess,
required Future<void> Function() onEnd,
required void Function(AIError error) onError,
String? suffix,
int maxTokens = 2048,
double temperature = 0.3,
bool useAction = false,
}) {
throw UnimplementedError();
}
@override
Future<CompletionStream> streamCompletion({
String? objectId,
required String text,
required CompletionTypePB completionType,
required Future<void> Function() onStart,
required Future<void> Function(String text) onProcess,
required Future<void> Function() onEnd,
required void Function(AIError error) onError,
}) async {
final stream = CompletionStream(
onStart,
onProcess,
onEnd,
onError,
);
final List<String> ragIds = [];
if (objectId != null) {
ragIds.add(objectId);
}
final payload = CompleteTextPB(
text: text,
completionType: completionType,
streamPort: fixnum.Int64(stream.nativePort),
objectId: objectId ?? "",
ragIds: ragIds,
);
// ignore: unawaited_futures
AIEventCompleteText(payload).send();
return stream;
}
}
CompletionTypePB completionTypeFromInt(AskAIAction action) {
switch (action) {
case AskAIAction.summarize:
return CompletionTypePB.MakeShorter;
case AskAIAction.fixSpelling:
return CompletionTypePB.SpellingAndGrammar;
case AskAIAction.improveWriting:
return CompletionTypePB.ImproveWriting;
case AskAIAction.makeItLonger:
return CompletionTypePB.MakeLonger;
}
}
class CompletionStream {
CompletionStream(
Future<void> Function() onStart,
Future<void> Function(String text) onProcess,
Future<void> Function() onEnd,
void Function(AIError error) onError,
) {
_port.handler = _controller.add;
_subscription = _controller.stream.listen(
(event) async {
if (event == "AI_RESPONSE_LIMIT") {
onError(
AIError(
message: LocaleKeys.sideBar_aiResponseLimit.tr(),
code: AIErrorCode.aiResponseLimitExceeded,
),
);
}
if (event.startsWith("start:")) {
await onStart();
}
if (event.startsWith("data:")) {
await onProcess(event.substring(5));
}
if (event.startsWith("finish:")) {
await onEnd();
}
if (event.startsWith("error:")) {
onError(AIError(message: event.substring(6)));
}
},
);
}
final RawReceivePort _port = RawReceivePort();
final StreamController<String> _controller = StreamController.broadcast();
late StreamSubscription<String> _subscription;
int get nativePort => _port.sendPort.nativePort;
Future<void> dispose() async {
await _controller.close();
await _subscription.cancel();
_port.close();
}
StreamSubscription<String> listen(
void Function(String event)? onData,
) {
return _controller.stream.listen(onData);
}
}
@@ -1,173 +0,0 @@
import 'dart:async';
import 'dart:convert';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbenum.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:http/http.dart' as http;
import 'ai_client.dart';
import 'error.dart';
import 'text_completion.dart';
enum OpenAIRequestType {
textCompletion,
textEdit,
imageGenerations;
Uri get uri {
switch (this) {
case OpenAIRequestType.textCompletion:
return Uri.parse('https://api.openai.com/v1/completions');
case OpenAIRequestType.textEdit:
return Uri.parse('https://api.openai.com/v1/chat/completions');
case OpenAIRequestType.imageGenerations:
return Uri.parse('https://api.openai.com/v1/images/generations');
}
}
}
class HttpOpenAIRepository implements AIRepository {
const HttpOpenAIRepository({
required this.client,
required this.apiKey,
});
final http.Client client;
final String apiKey;
Map<String, String> get headers => {
'Authorization': 'Bearer $apiKey',
'Content-Type': 'application/json',
};
@override
Future<void> getStreamedCompletions({
required String prompt,
required Future<void> Function() onStart,
required Future<void> Function(TextCompletionResponse response) onProcess,
required Future<void> Function() onEnd,
required void Function(AIError error) onError,
String? suffix,
int maxTokens = 2048,
double temperature = 0.3,
bool useAction = false,
}) async {
final parameters = {
'model': 'gpt-3.5-turbo-instruct',
'prompt': prompt,
'suffix': suffix,
'max_tokens': maxTokens,
'temperature': temperature,
'stream': true,
};
final request = http.Request('POST', OpenAIRequestType.textCompletion.uri);
request.headers.addAll(headers);
request.body = jsonEncode(parameters);
final response = await client.send(request);
// NEED TO REFACTOR.
// WHY OPENAI USE TWO LINES TO INDICATE THE START OF THE STREAMING RESPONSE?
// AND WHY OPENAI USE [DONE] TO INDICATE THE END OF THE STREAMING RESPONSE?
int syntax = 0;
var previousSyntax = '';
if (response.statusCode == 200) {
await for (final chunk in response.stream
.transform(const Utf8Decoder())
.transform(const LineSplitter())) {
syntax += 1;
if (!useAction) {
if (syntax == 3) {
await onStart();
continue;
} else if (syntax < 3) {
continue;
}
} else {
if (syntax == 2) {
await onStart();
continue;
} else if (syntax < 2) {
continue;
}
}
final data = chunk.trim().split('data: ');
if (data.length > 1) {
if (data[1] != '[DONE]') {
final response = TextCompletionResponse.fromJson(
json.decode(data[1]),
);
if (response.choices.isNotEmpty) {
final text = response.choices.first.text;
if (text == previousSyntax && text == '\n') {
continue;
}
await onProcess(response);
previousSyntax = response.choices.first.text;
}
} else {
await onEnd();
}
}
}
} else {
final body = await response.stream.bytesToString();
onError(
AIError.fromJson(json.decode(body)['error']),
);
}
return;
}
@override
Future<FlowyResult<List<String>, AIError>> generateImage({
required String prompt,
int n = 1,
}) async {
final parameters = {
'prompt': prompt,
'n': n,
'size': '512x512',
};
try {
final response = await client.post(
OpenAIRequestType.imageGenerations.uri,
headers: headers,
body: json.encode(parameters),
);
if (response.statusCode == 200) {
final data = json.decode(
utf8.decode(response.bodyBytes),
)['data'] as List;
final urls = data
.map((e) => e.values)
.expand((e) => e)
.map((e) => e.toString())
.toList();
return FlowyResult.success(urls);
} else {
return FlowyResult.failure(
AIError.fromJson(json.decode(response.body)['error']),
);
}
} catch (error) {
return FlowyResult.failure(AIError(message: error.toString()));
}
}
@override
Future<void> streamCompletion({
String? objectId,
required String text,
required CompletionTypePB completionType,
required Future<void> Function() onStart,
required Future<void> Function(String text) onProcess,
required Future<void> Function() onEnd,
required void Function(AIError error) onError,
}) {
throw UnimplementedError();
}
}
@@ -0,0 +1,85 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:equatable/equatable.dart';
class PredefinedFormat extends Equatable {
const PredefinedFormat({
required this.imageFormat,
required this.textFormat,
});
final ImageFormat imageFormat;
final TextFormat? textFormat;
PredefinedFormatPB toPB() {
return PredefinedFormatPB(
imageFormat: switch (imageFormat) {
ImageFormat.text => ResponseImageFormatPB.TextOnly,
ImageFormat.image => ResponseImageFormatPB.ImageOnly,
ImageFormat.textAndImage => ResponseImageFormatPB.TextAndImage,
},
textFormat: switch (textFormat) {
TextFormat.paragraph => ResponseTextFormatPB.Paragraph,
TextFormat.bulletList => ResponseTextFormatPB.BulletedList,
TextFormat.numberedList => ResponseTextFormatPB.NumberedList,
TextFormat.table => ResponseTextFormatPB.Table,
_ => null,
},
);
}
@override
List<Object?> get props => [imageFormat, textFormat];
}
enum ImageFormat {
text,
image,
textAndImage;
bool get hasText => this == text || this == textAndImage;
FlowySvgData get icon {
return switch (this) {
ImageFormat.text => FlowySvgs.ai_text_s,
ImageFormat.image => FlowySvgs.ai_image_s,
ImageFormat.textAndImage => FlowySvgs.ai_text_image_s,
};
}
String get i18n {
return switch (this) {
ImageFormat.text => LocaleKeys.chat_changeFormat_textOnly.tr(),
ImageFormat.image => LocaleKeys.chat_changeFormat_imageOnly.tr(),
ImageFormat.textAndImage =>
LocaleKeys.chat_changeFormat_textAndImage.tr(),
};
}
}
enum TextFormat {
paragraph,
bulletList,
numberedList,
table;
FlowySvgData get icon {
return switch (this) {
TextFormat.paragraph => FlowySvgs.ai_paragraph_s,
TextFormat.bulletList => FlowySvgs.ai_list_s,
TextFormat.numberedList => FlowySvgs.ai_number_list_s,
TextFormat.table => FlowySvgs.ai_table_s,
};
}
String get i18n {
return switch (this) {
TextFormat.paragraph => LocaleKeys.chat_changeFormat_text.tr(),
TextFormat.bulletList => LocaleKeys.chat_changeFormat_bullet.tr(),
TextFormat.numberedList => LocaleKeys.chat_changeFormat_number.tr(),
TextFormat.table => LocaleKeys.chat_changeFormat_table.tr(),
};
}
}
@@ -10,12 +10,15 @@ import 'package:appflowy_result/appflowy_result.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'ai_entities.dart';
part 'ai_prompt_input_bloc.freezed.dart';
class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
AIPromptInputBloc()
: _listener = LocalLLMListener(),
super(AIPromptInputState.initial()) {
AIPromptInputBloc({
required PredefinedFormat? predefinedFormat,
}) : _listener = LocalLLMListener(),
super(AIPromptInputState.initial(predefinedFormat)) {
_dispatch();
_startListening();
_init();
@@ -41,6 +44,7 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
final aiType = chatState.pluginState.state == RunningStatePB.Running
? AIType.localAI
: AIType.appflowyAI;
emit(
state.copyWith(
aiType: aiType,
@@ -65,6 +69,24 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
),
);
},
toggleShowPredefinedFormat: () {
final predefinedFormat =
!state.showPredefinedFormats && state.predefinedFormat == null
? PredefinedFormat(
imageFormat: ImageFormat.text,
textFormat: TextFormat.paragraph,
)
: null;
emit(
state.copyWith(
showPredefinedFormats: !state.showPredefinedFormats,
predefinedFormat: predefinedFormat,
),
);
},
updatePredefinedFormat: (format) {
emit(state.copyWith(predefinedFormat: format));
},
attachFile: (filePath, fileName) {
final newFile = ChatFile.fromFilePath(filePath);
if (newFile != null) {
@@ -152,6 +174,11 @@ class AIPromptInputEvent with _$AIPromptInputEvent {
const factory AIPromptInputEvent.updatePluginState(
LocalAIPluginStatePB chatState,
) = _UpdatePluginState;
const factory AIPromptInputEvent.toggleShowPredefinedFormat() =
_ToggleShowPredefinedFormat;
const factory AIPromptInputEvent.updatePredefinedFormat(
PredefinedFormat format,
) = _UpdatePredefinedFormat;
const factory AIPromptInputEvent.attachFile(
String filePath,
String fileName,
@@ -167,14 +194,19 @@ class AIPromptInputState with _$AIPromptInputState {
const factory AIPromptInputState({
required AIType aiType,
required bool supportChatWithFile,
required bool showPredefinedFormats,
required PredefinedFormat? predefinedFormat,
required LocalAIChatPB? chatState,
required List<ChatFile> attachedFiles,
required List<ViewPB> mentionedPages,
}) = _AIPromptInputState;
factory AIPromptInputState.initial() => const AIPromptInputState(
factory AIPromptInputState.initial(PredefinedFormat? format) =>
AIPromptInputState(
aiType: AIType.appflowyAI,
supportChatWithFile: false,
showPredefinedFormats: format != null,
predefinedFormat: format,
chatState: null,
attachedFiles: [],
mentionedPages: [],
@@ -0,0 +1,159 @@
import 'dart:async';
import 'dart:ffi';
import 'dart:isolate';
import 'package:appflowy/generated/locale_keys.g.dart';
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_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:fixnum/fixnum.dart' as fixnum;
import 'ai_entities.dart';
import 'error.dart';
abstract class AIRepository {
Future<void> streamCompletion({
String? objectId,
required String text,
PredefinedFormat? format,
required CompletionTypePB completionType,
required Future<void> Function() onStart,
required Future<void> Function(String text) onProcess,
required Future<void> Function() onEnd,
required void Function(AIError error) onError,
});
}
class AppFlowyAIService implements AIRepository {
@override
Future<(String, CompletionStream)?> streamCompletion({
String? objectId,
required String text,
PredefinedFormat? format,
List<String> sourceIds = const [],
required CompletionTypePB completionType,
required Future<void> Function() onStart,
required Future<void> Function(String text) onProcess,
required Future<void> Function() onEnd,
required void Function(AIError error) onError,
}) async {
final stream = AppFlowyCompletionStream(
onStart: onStart,
onProcess: onProcess,
onEnd: onEnd,
onError: onError,
);
final payload = CompleteTextPB(
text: text,
completionType: completionType,
format: format?.toPB(),
streamPort: fixnum.Int64(stream.nativePort),
objectId: objectId ?? '',
ragIds: [
if (objectId != null) objectId,
...sourceIds,
].unique(),
);
return AIEventCompleteText(payload).send().fold(
(task) => (task.taskId, stream),
(error) {
Log.error(error);
return null;
},
);
}
}
abstract class CompletionStream {
CompletionStream({
required this.onStart,
required this.onProcess,
required this.onEnd,
required this.onError,
});
final Future<void> Function() onStart;
final Future<void> Function(String text) onProcess;
final Future<void> Function() onEnd;
final void Function(AIError error) onError;
}
class AppFlowyCompletionStream extends CompletionStream {
AppFlowyCompletionStream({
required super.onStart,
required super.onProcess,
required super.onEnd,
required super.onError,
}) {
_startListening();
}
final RawReceivePort _port = RawReceivePort();
final StreamController<String> _controller = StreamController.broadcast();
late StreamSubscription<String> _subscription;
int get nativePort => _port.sendPort.nativePort;
void _startListening() {
_port.handler = _controller.add;
_subscription = _controller.stream.listen(
(event) async {
if (event == "AI_RESPONSE_LIMIT") {
onError(
AIError(
message: LocaleKeys.ai_textLimitReachedDescription.tr(),
code: AIErrorCode.aiResponseLimitExceeded,
),
);
}
if (event == "AI_IMAGE_RESPONSE_LIMIT") {
onError(
AIError(
message: LocaleKeys.ai_imageLimitReachedDescription.tr(),
code: AIErrorCode.aiImageResponseLimitExceeded,
),
);
}
if (event.startsWith("AI_MAX_REQUIRED:")) {
final msg = event.substring(16);
onError(
AIError(
message: msg,
code: AIErrorCode.other,
),
);
}
if (event.startsWith("start:")) {
await onStart();
}
if (event.startsWith("data:")) {
await onProcess(event.substring(5));
}
if (event.startsWith("finish:")) {
await onEnd();
}
if (event.startsWith("error:")) {
onError(
AIError(message: event.substring(6), code: AIErrorCode.other),
);
}
},
);
}
Future<void> dispose() async {
await _controller.close();
await _subscription.cancel();
_port.close();
}
}
@@ -7,7 +7,7 @@ part 'error.g.dart';
class AIError with _$AIError {
const factory AIError({
required String message,
@Default(AIErrorCode.other) AIErrorCode code,
required AIErrorCode code,
}) = _AIError;
factory AIError.fromJson(Map<String, Object?> json) =>
@@ -17,6 +17,8 @@ class AIError with _$AIError {
enum AIErrorCode {
@JsonValue('AIResponseLimitExceeded')
aiResponseLimitExceeded,
@JsonValue('AIImageResponseLimitExceeded')
aiImageResponseLimitExceeded,
@JsonValue('Other')
other,
}
@@ -1,27 +0,0 @@
import 'package:freezed_annotation/freezed_annotation.dart';
part 'text_completion.freezed.dart';
part 'text_completion.g.dart';
@freezed
class TextCompletionChoice with _$TextCompletionChoice {
factory TextCompletionChoice({
required String text,
required int index,
// ignore: invalid_annotation_target
@JsonKey(name: 'finish_reason') String? finishReason,
}) = _TextCompletionChoice;
factory TextCompletionChoice.fromJson(Map<String, Object?> json) =>
_$TextCompletionChoiceFromJson(json);
}
@freezed
class TextCompletionResponse with _$TextCompletionResponse {
const factory TextCompletionResponse({
required List<TextCompletionChoice> choices,
}) = _TextCompletionResponse;
factory TextCompletionResponse.fromJson(Map<String, Object?> json) =>
_$TextCompletionResponseFromJson(json);
}
@@ -0,0 +1,76 @@
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.dart';
/// An animated generating indicator for an AI response
class AILoadingIndicator extends StatelessWidget {
const AILoadingIndicator({
super.key,
this.text = "",
this.duration = const Duration(seconds: 1),
});
final String text;
final Duration duration;
@override
Widget build(BuildContext context) {
final slice = Duration(milliseconds: duration.inMilliseconds ~/ 5);
return SizedBox(
height: 20,
child: SeparatedRow(
separatorBuilder: () => const HSpace(4),
children: [
Padding(
padding: const EdgeInsetsDirectional.only(end: 4.0),
child: FlowyText(
text,
color: Theme.of(context).hintColor,
),
),
buildDot(const Color(0xFF9327FF))
.animate(onPlay: (controller) => controller.repeat())
.slideY(duration: slice, begin: 0, end: -1)
.then()
.slideY(begin: -1, end: 1)
.then()
.slideY(begin: 1, end: 0)
.then()
.slideY(duration: slice * 2, begin: 0, end: 0),
buildDot(const Color(0xFFFB006D))
.animate(onPlay: (controller) => controller.repeat())
.slideY(duration: slice, begin: 0, end: 0)
.then()
.slideY(begin: 0, end: -1)
.then()
.slideY(begin: -1, end: 1)
.then()
.slideY(begin: 1, end: 0)
.then()
.slideY(begin: 0, end: 0),
buildDot(const Color(0xFFFFCE00))
.animate(onPlay: (controller) => controller.repeat())
.slideY(duration: slice * 2, begin: 0, end: 0)
.then()
.slideY(duration: slice, begin: 0, end: -1)
.then()
.slideY(begin: -1, end: 1)
.then()
.slideY(begin: 1, end: 0),
],
),
);
}
Widget buildDot(Color color) {
return SizedBox.square(
dimension: 4,
child: DecoratedBox(
decoration: BoxDecoration(
color: color,
borderRadius: BorderRadius.circular(2),
),
),
);
}
}
@@ -6,9 +6,7 @@ import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/icon_button.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import '../layout_define.dart';
enum SendButtonState { enabled, streaming, disabled }
import 'layout_define.dart';
class PromptInputAttachmentButton extends StatelessWidget {
const PromptInputAttachmentButton({required this.onTap, super.key});
@@ -67,57 +65,3 @@ class PromptInputMentionButton extends StatelessWidget {
);
}
}
class PromptInputSendButton extends StatelessWidget {
const PromptInputSendButton({
super.key,
required this.buttonSize,
required this.iconSize,
required this.state,
required this.onSendPressed,
required this.onStopStreaming,
});
final double buttonSize;
final double iconSize;
final SendButtonState state;
final VoidCallback onSendPressed;
final VoidCallback onStopStreaming;
@override
Widget build(BuildContext context) {
return FlowyIconButton(
width: buttonSize,
icon: switch (state) {
SendButtonState.enabled => FlowySvg(
FlowySvgs.ai_send_filled_s,
size: Size.square(iconSize),
color: Theme.of(context).colorScheme.primary,
),
SendButtonState.disabled => FlowySvg(
FlowySvgs.ai_send_filled_s,
size: Size.square(iconSize),
color: Theme.of(context).disabledColor,
),
SendButtonState.streaming => FlowySvg(
FlowySvgs.ai_stop_filled_s,
size: Size.square(iconSize),
color: Theme.of(context).colorScheme.primary,
),
},
onPressed: () {
switch (state) {
case SendButtonState.enabled:
onSendPressed();
break;
case SendButtonState.streaming:
onStopStreaming();
break;
case SendButtonState.disabled:
break;
}
},
hoverColor: Colors.transparent,
);
}
}
@@ -1,7 +1,9 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/ai_prompt_input_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_control_cubit.dart';
import 'package:appflowy/plugins/ai_chat/presentation/layout_define.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:extended_text_field/extended_text_field.dart';
@@ -11,37 +13,30 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../application/chat_entity.dart';
import '../layout_define.dart';
import 'ai_prompt_buttons.dart';
import 'chat_input_file.dart';
import 'chat_input_span.dart';
import 'chat_mention_page_menu.dart';
import 'predefined_format_buttons.dart';
import 'select_sources_menu.dart';
class DesktopAIPromptInput extends StatefulWidget {
const DesktopAIPromptInput({
class DesktopPromptInput extends StatefulWidget {
const DesktopPromptInput({
super.key,
required this.chatId,
required this.isStreaming,
required this.onStopStreaming,
required this.onSubmitted,
required this.selectedSourcesNotifier,
required this.onUpdateSelectedSources,
this.hideDecoration = false,
});
final String chatId;
final bool isStreaming;
final void Function() onStopStreaming;
final void Function(String, PredefinedFormat?, Map<String, dynamic>)
onSubmitted;
final ValueNotifier<List<String>> selectedSourcesNotifier;
final void Function(List<String>) onUpdateSelectedSources;
final bool hideDecoration;
@override
State<DesktopAIPromptInput> createState() => _DesktopAIPromptInputState();
State<DesktopPromptInput> createState() => _DesktopPromptInputState();
}
class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
class _DesktopPromptInputState extends State<DesktopPromptInput> {
final textFieldKey = GlobalKey();
final layerLink = LayerLink();
final overlayController = OverlayPortalController();
@@ -49,12 +44,8 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
final focusNode = FocusNode();
final textController = TextEditingController();
bool showPredefinedFormatSection = true;
PredefinedFormat predefinedFormat = const PredefinedFormat(
imageFormat: ImageFormat.text,
textFormat: TextFormat.bulletList,
);
late SendButtonState sendButtonState;
bool isComposing = false;
@override
void initState() {
@@ -62,13 +53,15 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
textController.addListener(handleTextControllerChanged);
// refresh border color on focus change and hide menu when lost focus
focusNode.addListener(
() => setState(() {
if (!focusNode.hasFocus) {
cancelMentionPage();
() {
if (!widget.hideDecoration) {
setState(() {}); // refresh border color
}
}),
if (!focusNode.hasFocus) {
cancelMentionPage(); // hide menu when lost focus
}
},
);
updateSendButtonState();
@@ -110,23 +103,14 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
child: OverlayPortal(
controller: overlayController,
overlayChildBuilder: (context) {
return ChatMentionPageMenu(
anchor: ChatInputAnchor(textFieldKey, layerLink),
return PromptInputMentionPageMenu(
anchor: PromptInputAnchor(textFieldKey, layerLink),
textController: textController,
onPageSelected: handlePageSelected,
);
},
child: DecoratedBox(
decoration: BoxDecoration(
border: Border.all(
color: focusNode.hasFocus
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline,
width: focusNode.hasFocus ? 1.5 : 1.0,
strokeAlign: BorderSide.strokeAlignOutside,
),
borderRadius: DesktopAIPromptSizes.promptFrameRadius,
),
decoration: decoration(context),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -137,8 +121,7 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
DesktopAIPromptSizes.attachedFilesPreviewHeight,
),
child: TextFieldTapRegion(
child: ChatInputFile(
chatId: widget.chatId,
child: PromptInputFile(
onDeleted: (file) => context
.read<AIPromptInputBloc>()
.add(AIPromptInputEvent.removeFile(file)),
@@ -146,59 +129,63 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
),
),
const VSpace(4.0),
Stack(
children: [
Container(
constraints: getTextFieldConstraints(),
child: inputTextField(),
),
if (showPredefinedFormatSection)
Positioned.fill(
bottom: null,
child: TextFieldTapRegion(
child: Padding(
padding:
const EdgeInsetsDirectional.only(start: 8.0),
child: ChangeFormatBar(
predefinedFormat: predefinedFormat,
spacing: DesktopAIPromptSizes
.predefinedFormatBarButtonSpacing,
iconSize: DesktopAIPromptSizes
.predefinedFormatIconHeight,
buttonSize: DesktopAIPromptSizes
.predefinedFormatButtonHeight,
onSelectPredefinedFormat: (format) {
setState(() => predefinedFormat = format);
},
BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
builder: (context, state) {
return Stack(
children: [
ConstrainedBox(
constraints: getTextFieldConstraints(
state.showPredefinedFormats,
),
child: inputTextField(),
),
if (state.showPredefinedFormats)
Positioned.fill(
bottom: null,
child: TextFieldTapRegion(
child: Padding(
padding: const EdgeInsetsDirectional.only(
start: 8.0,
),
child: ChangeFormatBar(
predefinedFormat: state.predefinedFormat,
spacing: 4.0,
onSelectPredefinedFormat: (format) =>
context.read<AIPromptInputBloc>().add(
AIPromptInputEvent
.updatePredefinedFormat(
format,
),
),
),
),
),
),
Positioned.fill(
top: null,
child: TextFieldTapRegion(
child: _PromptBottomActions(
showPredefinedFormats:
state.showPredefinedFormats,
onTogglePredefinedFormatSection: () =>
context.read<AIPromptInputBloc>().add(
AIPromptInputEvent
.toggleShowPredefinedFormat(),
),
onStartMention: startMentionPageFromButton,
sendButtonState: sendButtonState,
onSendPressed: handleSend,
onStopStreaming: widget.onStopStreaming,
selectedSourcesNotifier:
widget.selectedSourcesNotifier,
onUpdateSelectedSources:
widget.onUpdateSelectedSources,
),
),
),
),
Positioned.fill(
top: null,
child: TextFieldTapRegion(
child: _PromptBottomActions(
textController: textController,
overlayController: overlayController,
focusNode: focusNode,
showPredefinedFormats: showPredefinedFormatSection,
predefinedFormat: predefinedFormat.imageFormat,
predefinedTextFormat: predefinedFormat.textFormat,
onTogglePredefinedFormatSection: () {
setState(() {
showPredefinedFormatSection =
!showPredefinedFormatSection;
});
},
sendButtonState: sendButtonState,
onSendPressed: handleSendPressed,
onStopStreaming: widget.onStopStreaming,
onUpdateSelectedSources:
widget.onUpdateSelectedSources,
),
),
),
],
],
);
},
),
],
),
@@ -208,16 +195,38 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
);
}
BoxConstraints getTextFieldConstraints() {
double minHeight = DesktopAIPromptSizes.textFieldMinHeight +
DesktopAIPromptSizes.actionBarHeight +
DesktopAIPromptSizes.actionBarPadding.vertical;
double maxHeight = 300;
if (showPredefinedFormatSection) {
minHeight += DesktopAIPromptSizes.predefinedFormatButtonHeight;
maxHeight += DesktopAIPromptSizes.predefinedFormatButtonHeight;
BoxDecoration decoration(BuildContext context) {
if (widget.hideDecoration) {
return BoxDecoration();
}
return BoxConstraints(minHeight: minHeight, maxHeight: maxHeight);
return BoxDecoration(
color: Theme.of(context).colorScheme.surface,
border: Border.all(
color: focusNode.hasFocus
? Theme.of(context).colorScheme.primary
: Theme.of(context).colorScheme.outline,
width: focusNode.hasFocus ? 1.5 : 1.0,
),
borderRadius: const BorderRadius.all(Radius.circular(12.0)),
);
}
void startMentionPageFromButton() {
if (overlayController.isShowing) {
return;
}
if (!focusNode.hasFocus) {
focusNode.requestFocus();
}
textController.text += '@';
WidgetsBinding.instance.addPostFrameCallback((_) {
if (context.mounted) {
context
.read<ChatInputControlCubit>()
.startSearching(textController.value);
overlayController.show();
}
});
}
void cancelMentionPage() {
@@ -237,7 +246,7 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
}
}
void handleSendPressed() {
void handleSend() {
if (widget.isStreaming) {
return;
}
@@ -252,21 +261,28 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
// get the attached files and mentioned pages
final metadata = context.read<AIPromptInputBloc>().consumeMetadata();
final bloc = context.read<AIPromptInputBloc>();
final showPredefinedFormats = bloc.state.showPredefinedFormats;
final predefinedFormat = bloc.state.predefinedFormat;
widget.onSubmitted(
trimmedText,
showPredefinedFormatSection ? predefinedFormat : null,
showPredefinedFormats ? predefinedFormat : null,
metadata,
);
}
void handleTextControllerChanged() {
if (!textController.value.composing.isCollapsed) {
setState(() {
// update whether send button is clickable
updateSendButtonState();
isComposing = !textController.value.composing.isCollapsed;
});
if (isComposing) {
return;
}
// update whether send button is clickable
setState(() => updateSendButtonState());
// handle text and selection changes ONLY when mentioning a page
// disable mention
@@ -323,6 +339,16 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
}
}
KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) {
if (event.character == '@') {
WidgetsBinding.instance.addPostFrameCallback((_) {
inputControlCubit.startSearching(textController.value);
overlayController.show();
});
}
return KeyEventResult.ignored;
}
void handlePageSelected(ViewPB view) {
final newText = textController.text.replaceRange(
inputControlCubit.filterStartPosition,
@@ -342,35 +368,69 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
}
Widget inputTextField() {
return Actions(
actions: buildActions(),
child: CompositedTransformTarget(
link: layerLink,
child: BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
builder: (context, state) {
return _PromptTextField(
key: textFieldKey,
cubit: inputControlCubit,
textController: textController,
textFieldFocusNode: focusNode,
showPredefinedFormatSection: showPredefinedFormatSection,
hintText: switch (state.aiType) {
AIType.appflowyAI => LocaleKeys.chat_inputMessageHint.tr(),
AIType.localAI => LocaleKeys.chat_inputLocalAIMessageHint.tr()
},
// onStartMentioningPage: () {
// WidgetsBinding.instance.addPostFrameCallback((_) {
// inputControlCubit.startSearching(textController.value);
// overlayController.show();
// });
// },
);
},
return Shortcuts(
shortcuts: buildShortcuts(),
child: Actions(
actions: buildActions(),
child: CompositedTransformTarget(
link: layerLink,
child: BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
builder: (context, state) {
return PromptInputTextField(
key: textFieldKey,
cubit: inputControlCubit,
textController: textController,
textFieldFocusNode: focusNode,
contentPadding:
calculateContentPadding(state.showPredefinedFormats),
hintText: switch (state.aiType) {
AIType.appflowyAI => LocaleKeys.chat_inputMessageHint.tr(),
AIType.localAI => LocaleKeys.chat_inputLocalAIMessageHint.tr()
},
);
},
),
),
),
);
}
BoxConstraints getTextFieldConstraints(bool showPredefinedFormats) {
double minHeight = DesktopAIPromptSizes.textFieldMinHeight +
DesktopAIPromptSizes.actionBarSendButtonSize +
DesktopAIChatSizes.inputActionBarMargin.vertical;
double maxHeight = 300;
if (showPredefinedFormats) {
minHeight += DesktopAIPromptSizes.predefinedFormatButtonHeight;
maxHeight += DesktopAIPromptSizes.predefinedFormatButtonHeight;
}
return BoxConstraints(minHeight: minHeight, maxHeight: maxHeight);
}
EdgeInsetsGeometry calculateContentPadding(bool showPredefinedFormats) {
final top = showPredefinedFormats
? DesktopAIPromptSizes.predefinedFormatButtonHeight
: 0.0;
final bottom = DesktopAIPromptSizes.actionBarSendButtonSize +
DesktopAIChatSizes.inputActionBarMargin.vertical;
return DesktopAIPromptSizes.textFieldContentPadding
.add(EdgeInsets.only(top: top, bottom: bottom));
}
Map<ShortcutActivator, Intent> buildShortcuts() {
if (isComposing) {
return const {};
}
return const {
SingleActivator(LogicalKeyboardKey.arrowUp): _FocusPreviousItemIntent(),
SingleActivator(LogicalKeyboardKey.arrowDown): _FocusNextItemIntent(),
SingleActivator(LogicalKeyboardKey.escape): _CancelMentionPageIntent(),
SingleActivator(LogicalKeyboardKey.enter): _SubmitOrMentionPageIntent(),
};
}
Map<Type, Action<Intent>> buildActions() {
return {
_FocusPreviousItemIntent: CallbackAction<_FocusPreviousItemIntent>(
@@ -404,7 +464,7 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
orElse: () {},
);
} else {
handleSendPressed();
handleSend();
}
return;
},
@@ -413,115 +473,6 @@ class _DesktopAIPromptInputState extends State<DesktopAIPromptInput> {
}
}
class _PromptTextField extends StatefulWidget {
const _PromptTextField({
super.key,
required this.cubit,
required this.textController,
required this.textFieldFocusNode,
this.showPredefinedFormatSection = false,
this.hintText = "",
// this.onStartMentioningPage,
});
final ChatInputControlCubit cubit;
final TextEditingController textController;
final FocusNode textFieldFocusNode;
final bool showPredefinedFormatSection;
final String hintText;
// final void Function()? onStartMentioningPage;
@override
State<_PromptTextField> createState() => _PromptTextFieldState();
}
class _PromptTextFieldState extends State<_PromptTextField> {
bool isComposing = false;
@override
void initState() {
super.initState();
// widget.textFieldFocusNode.onKeyEvent = handleKeyEvent;
widget.textController.addListener(onTextChanged);
}
@override
void dispose() {
// widget.textFieldFocusNode.onKeyEvent = null;
widget.textController.removeListener(onTextChanged);
super.dispose();
}
@override
Widget build(BuildContext context) {
return Shortcuts(
shortcuts: buildShortcuts(),
child: ExtendedTextField(
controller: widget.textController,
focusNode: widget.textFieldFocusNode,
decoration: InputDecoration(
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: calculateContentPadding(),
hintText: widget.hintText,
hintStyle: AIChatUILayout.inputHintTextStyle(context),
isCollapsed: true,
isDense: true,
),
keyboardType: TextInputType.multiline,
textCapitalization: TextCapitalization.sentences,
minLines: 1,
maxLines: null,
style: Theme.of(context).textTheme.bodyMedium,
specialTextSpanBuilder: ChatInputTextSpanBuilder(
inputControlCubit: widget.cubit,
specialTextStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
),
);
}
// KeyEventResult handleKeyEvent(FocusNode node, KeyEvent event) {
// if (event.character == '@') {
// widget.onStartMentioningPage();
// }
// return KeyEventResult.ignored;
// }
void onTextChanged() {
setState(
() => isComposing = !widget.textController.value.composing.isCollapsed,
);
}
EdgeInsetsGeometry calculateContentPadding() {
final top = widget.showPredefinedFormatSection
? DesktopAIPromptSizes.predefinedFormatButtonHeight
: 0.0;
const bottom = DesktopAIPromptSizes.actionBarHeight;
return DesktopAIPromptSizes.textFieldContentPadding
.add(EdgeInsets.only(top: top, bottom: bottom));
}
Map<ShortcutActivator, Intent> buildShortcuts() {
if (isComposing) {
return const {};
}
return const {
SingleActivator(LogicalKeyboardKey.arrowUp): _FocusPreviousItemIntent(),
SingleActivator(LogicalKeyboardKey.arrowDown): _FocusNextItemIntent(),
SingleActivator(LogicalKeyboardKey.escape): _CancelMentionPageIntent(),
SingleActivator(LogicalKeyboardKey.enter): _SubmitOrMentionPageIntent(),
};
}
}
class _SubmitOrMentionPageIntent extends Intent {
const _SubmitOrMentionPageIntent();
}
@@ -538,38 +489,87 @@ class _FocusNextItemIntent extends Intent {
const _FocusNextItemIntent();
}
class PromptInputTextField extends StatelessWidget {
const PromptInputTextField({
super.key,
required this.cubit,
required this.textController,
required this.textFieldFocusNode,
required this.contentPadding,
this.hintText = "",
});
final ChatInputControlCubit cubit;
final TextEditingController textController;
final FocusNode textFieldFocusNode;
final EdgeInsetsGeometry contentPadding;
final String hintText;
@override
Widget build(BuildContext context) {
return ExtendedTextField(
controller: textController,
focusNode: textFieldFocusNode,
decoration: InputDecoration(
border: InputBorder.none,
enabledBorder: InputBorder.none,
focusedBorder: InputBorder.none,
contentPadding: contentPadding,
hintText: hintText,
hintStyle: inputHintTextStyle(context),
isCollapsed: true,
isDense: true,
),
keyboardType: TextInputType.multiline,
textCapitalization: TextCapitalization.sentences,
minLines: 1,
maxLines: null,
style: Theme.of(context).textTheme.bodyMedium,
specialTextSpanBuilder: PromptInputTextSpanBuilder(
inputControlCubit: cubit,
specialTextStyle: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).colorScheme.primary,
fontWeight: FontWeight.w600,
),
),
);
}
TextStyle? inputHintTextStyle(BuildContext context) {
return Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Theme.of(context).isLightMode
? const Color(0xFFBDC2C8)
: const Color(0xFF3C3E51),
);
}
}
class _PromptBottomActions extends StatelessWidget {
const _PromptBottomActions({
required this.textController,
required this.overlayController,
required this.focusNode,
required this.sendButtonState,
required this.predefinedFormat,
required this.predefinedTextFormat,
required this.onTogglePredefinedFormatSection,
required this.showPredefinedFormats,
required this.onTogglePredefinedFormatSection,
required this.onStartMention,
required this.onSendPressed,
required this.onStopStreaming,
required this.selectedSourcesNotifier,
required this.onUpdateSelectedSources,
});
final TextEditingController textController;
final OverlayPortalController overlayController;
final FocusNode focusNode;
final bool showPredefinedFormats;
final ImageFormat predefinedFormat;
final TextFormat? predefinedTextFormat;
final void Function() onTogglePredefinedFormatSection;
final void Function() onStartMention;
final SendButtonState sendButtonState;
final void Function() onSendPressed;
final void Function() onStopStreaming;
final ValueNotifier<List<String>> selectedSourcesNotifier;
final void Function(List<String>) onUpdateSelectedSources;
@override
Widget build(BuildContext context) {
return Container(
height: DesktopAIPromptSizes.actionBarHeight,
margin: DesktopAIPromptSizes.actionBarPadding,
height: DesktopAIPromptSizes.actionBarSendButtonSize,
margin: DesktopAIChatSizes.inputActionBarMargin,
child: BlocBuilder<AIPromptInputBloc, AIPromptInputState>(
builder: (context, state) {
if (state.chatState == null) {
@@ -585,7 +585,7 @@ class _PromptBottomActions extends StatelessWidget {
if (state.aiType == AIType.appflowyAI) ...[
_selectSourcesButton(context),
const HSpace(
DesktopAIPromptSizes.actionBarButtonSpacing,
DesktopAIChatSizes.inputActionBarButtonSpacing,
),
],
// _mentionButton(context),
@@ -595,7 +595,7 @@ class _PromptBottomActions extends StatelessWidget {
if (state.supportChatWithFile) ...[
_attachmentButton(context),
const HSpace(
DesktopAIPromptSizes.actionBarButtonSpacing,
DesktopAIChatSizes.inputActionBarButtonSpacing,
),
],
_sendButton(),
@@ -609,8 +609,6 @@ class _PromptBottomActions extends StatelessWidget {
Widget _predefinedFormatButton() {
return PromptInputDesktopToggleFormatButton(
showFormatBar: showPredefinedFormats,
predefinedFormat: predefinedFormat,
predefinedTextFormat: predefinedTextFormat,
onTap: onTogglePredefinedFormatSection,
);
}
@@ -618,6 +616,7 @@ class _PromptBottomActions extends StatelessWidget {
Widget _selectSourcesButton(BuildContext context) {
return PromptInputDesktopSelectSourcesButton(
onUpdateSelectedSources: onUpdateSelectedSources,
selectedSourcesNotifier: selectedSourcesNotifier,
);
}
@@ -625,21 +624,7 @@ class _PromptBottomActions extends StatelessWidget {
// return PromptInputMentionButton(
// iconSize: DesktopAIPromptSizes.actionBarIconSize,
// buttonSize: DesktopAIPromptSizes.actionBarButtonSize,
// onTap: () {
// if (overlayController.isShowing) {
// return;
// }
// if (!focusNode.hasFocus) {
// focusNode.requestFocus();
// }
// textController.text += '@';
// Future.delayed(Duration.zero, () {
// context
// .read<ChatInputControlCubit>()
// .startSearching(textController.value);
// overlayController.show();
// });
// },
// onTap: onStartMention,
// );
// }
@@ -669,8 +654,6 @@ class _PromptBottomActions extends StatelessWidget {
Widget _sendButton() {
return PromptInputSendButton(
buttonSize: DesktopAIPromptSizes.actionBarHeight,
iconSize: DesktopAIPromptSizes.sendButtonSize,
state: sendButtonState,
onSendPressed: onSendPressed,
onStopStreaming: onStopStreaming,
@@ -1,5 +1,5 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/ai_chat/application/ai_prompt_input_bloc.dart';
import 'package:appflowy/ai/service/ai_prompt_input_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_input_file_bloc.dart';
import 'package:flowy_infra/theme_extension.dart';
@@ -8,16 +8,14 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:styled_widget/styled_widget.dart';
import '../layout_define.dart';
import 'layout_define.dart';
class ChatInputFile extends StatelessWidget {
const ChatInputFile({
class PromptInputFile extends StatelessWidget {
const PromptInputFile({
super.key,
required this.chatId,
required this.onDeleted,
});
final String chatId;
final void Function(ChatFile) onDeleted;
@override
@@ -37,7 +35,6 @@ class ChatInputFile extends StatelessWidget {
),
itemCount: files.length,
itemBuilder: (context, index) => ChatFilePreview(
chatId: chatId,
file: files[index],
onDeleted: () => onDeleted(files[index]),
),
@@ -49,13 +46,11 @@ class ChatInputFile extends StatelessWidget {
class ChatFilePreview extends StatefulWidget {
const ChatFilePreview({
required this.chatId,
required this.file,
required this.onDeleted,
super.key,
});
final String chatId;
final ChatFile file;
final VoidCallback onDeleted;
@@ -0,0 +1,42 @@
import 'package:flutter/widgets.dart';
class DesktopAIPromptSizes {
const DesktopAIPromptSizes._();
static const attachedFilesBarPadding =
EdgeInsets.only(left: 8.0, top: 8.0, right: 8.0);
static const attachedFilesPreviewHeight = 48.0;
static const attachedFilesPreviewSpacing = 12.0;
static const predefinedFormatButtonHeight = 28.0;
static const predefinedFormatIconHeight = 16.0;
static const textFieldMinHeight = 36.0;
static const textFieldContentPadding =
EdgeInsetsDirectional.fromSTEB(14.0, 8.0, 14.0, 8.0);
static const actionBarButtonSize = 28.0;
static const actionBarIconSize = 16.0;
static const actionBarSendButtonSize = 32.0;
static const actionBarSendButtonIconSize = 24.0;
}
class MobileAIPromptSizes {
const MobileAIPromptSizes._();
static const attachedFilesBarHeight = 68.0;
static const attachedFilesBarPadding =
EdgeInsets.only(top: 8.0, left: 8.0, right: 8.0, bottom: 4.0);
static const attachedFilesPreviewHeight = 56.0;
static const attachedFilesPreviewSpacing = 8.0;
static const predefinedFormatButtonHeight = 32.0;
static const predefinedFormatIconHeight = 20.0;
static const textFieldMinHeight = 32.0;
static const textFieldContentPadding =
EdgeInsets.symmetric(horizontal: 12.0, vertical: 8.0);
static const mentionIconSize = 20.0;
static const sendButtonSize = 32.0;
}
@@ -8,7 +8,7 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'chat_mention_page_menu.dart';
import 'mention_page_menu.dart';
Future<ViewPB?> showPageSelectorSheet(
BuildContext context, {
@@ -19,35 +19,39 @@ const double _noPageHeight = 20.0;
const double _fixedWidth = 360.0;
const double _maxHeight = 328.0;
class ChatInputAnchor {
ChatInputAnchor(this.anchorKey, this.layerLink);
class PromptInputAnchor {
PromptInputAnchor(this.anchorKey, this.layerLink);
final GlobalKey<State<StatefulWidget>> anchorKey;
final LayerLink layerLink;
}
class ChatMentionPageMenu extends StatefulWidget {
const ChatMentionPageMenu({
class PromptInputMentionPageMenu extends StatefulWidget {
const PromptInputMentionPageMenu({
super.key,
required this.anchor,
required this.textController,
required this.onPageSelected,
});
final ChatInputAnchor anchor;
final PromptInputAnchor anchor;
final TextEditingController textController;
final void Function(ViewPB view) onPageSelected;
@override
State<ChatMentionPageMenu> createState() => _ChatMentionPageMenuState();
State<PromptInputMentionPageMenu> createState() =>
_PromptInputMentionPageMenuState();
}
class _ChatMentionPageMenuState extends State<ChatMentionPageMenu> {
class _PromptInputMentionPageMenuState
extends State<PromptInputMentionPageMenu> {
@override
void initState() {
super.initState();
Future.delayed(Duration.zero, () {
context.read<ChatInputControlCubit>().refreshViews();
if (mounted) {
context.read<ChatInputControlCubit>().refreshViews();
}
});
}
@@ -92,7 +96,7 @@ class _ChatMentionPageMenuState extends State<ChatMentionPageMenu> {
],
),
child: TextFieldTapRegion(
child: ChatMentionPageList(
child: PromptInputMentionPageList(
onPageSelected: widget.onPageSelected,
),
),
@@ -143,8 +147,8 @@ class _ChatMentionPageMenuState extends State<ChatMentionPageMenu> {
}
}
class ChatMentionPageList extends StatefulWidget {
const ChatMentionPageList({
class PromptInputMentionPageList extends StatefulWidget {
const PromptInputMentionPageList({
super.key,
required this.onPageSelected,
});
@@ -152,10 +156,12 @@ class ChatMentionPageList extends StatefulWidget {
final void Function(ViewPB view) onPageSelected;
@override
State<ChatMentionPageList> createState() => _ChatMentionPageListState();
State<PromptInputMentionPageList> createState() =>
_PromptInputMentionPageListState();
}
class _ChatMentionPageListState extends State<ChatMentionPageList> {
class _PromptInputMentionPageListState
extends State<PromptInputMentionPageList> {
final autoScrollController = SimpleAutoScrollController(
suggestedRowHeight: _itemHeight,
beginGetter: (rect) => rect.top + 8.0,
@@ -5,8 +5,8 @@ import 'package:easy_localization/easy_localization.dart';
import 'package:extended_text_library/extended_text_library.dart';
import 'package:flutter/material.dart';
class ChatInputTextSpanBuilder extends SpecialTextSpanBuilder {
ChatInputTextSpanBuilder({
class PromptInputTextSpanBuilder extends SpecialTextSpanBuilder {
PromptInputTextSpanBuilder({
required this.inputControlCubit,
this.specialTextStyle,
});
@@ -1,25 +1,22 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:universal_platform/universal_platform.dart';
import '../layout_define.dart';
import '../../service/ai_entities.dart';
import 'layout_define.dart';
class PromptInputDesktopToggleFormatButton extends StatelessWidget {
const PromptInputDesktopToggleFormatButton({
super.key,
required this.showFormatBar,
required this.predefinedFormat,
required this.predefinedTextFormat,
required this.onTap,
});
final bool showFormatBar;
final ImageFormat predefinedFormat;
final TextFormat? predefinedTextFormat;
final VoidCallback onTap;
@override
@@ -49,15 +46,11 @@ class ChangeFormatBar extends StatelessWidget {
const ChangeFormatBar({
super.key,
required this.predefinedFormat,
required this.buttonSize,
required this.iconSize,
required this.spacing,
required this.onSelectPredefinedFormat,
});
final PredefinedFormat? predefinedFormat;
final double buttonSize;
final double iconSize;
final double spacing;
final void Function(PredefinedFormat) onSelectPredefinedFormat;
@@ -74,7 +67,7 @@ class ChangeFormatBar extends StatelessWidget {
_buildFormatButton(context, ImageFormat.image),
if (predefinedFormat?.imageFormat.hasText ?? true) ...[
_buildDivider(),
_buildTextFormatButton(context, TextFormat.auto),
_buildTextFormatButton(context, TextFormat.paragraph),
_buildTextFormatButton(context, TextFormat.bulletList),
_buildTextFormatButton(context, TextFormat.numberedList),
_buildTextFormatButton(context, TextFormat.table),
@@ -93,7 +86,8 @@ class ChangeFormatBar extends StatelessWidget {
return;
}
if (format.hasText) {
final textFormat = predefinedFormat?.textFormat ?? TextFormat.auto;
final textFormat =
predefinedFormat?.textFormat ?? TextFormat.paragraph;
onSelectPredefinedFormat(
PredefinedFormat(imageFormat: format, textFormat: textFormat),
);
@@ -106,15 +100,15 @@ class ChangeFormatBar extends StatelessWidget {
child: FlowyTooltip(
message: format.i18n,
child: SizedBox.square(
dimension: buttonSize,
dimension: _buttonSize,
child: FlowyHover(
isSelected: () => format == predefinedFormat?.imageFormat,
child: Center(
child: FlowySvg(
format.icon,
size: format == ImageFormat.textAndImage
? Size(21.0 / 16.0 * iconSize, iconSize)
: Size.square(iconSize),
? Size(21.0 / 16.0 * _iconSize, _iconSize)
: Size.square(_iconSize),
),
),
),
@@ -152,13 +146,13 @@ class ChangeFormatBar extends StatelessWidget {
child: FlowyTooltip(
message: format.i18n,
child: SizedBox.square(
dimension: buttonSize,
dimension: _buttonSize,
child: FlowyHover(
isSelected: () => format == predefinedFormat?.textFormat,
child: Center(
child: FlowySvg(
format.icon,
size: Size.square(iconSize),
size: Size.square(_iconSize),
),
),
),
@@ -166,6 +160,18 @@ class ChangeFormatBar extends StatelessWidget {
),
);
}
double get _buttonSize {
return UniversalPlatform.isMobile
? MobileAIPromptSizes.predefinedFormatButtonHeight
: DesktopAIPromptSizes.predefinedFormatButtonHeight;
}
double get _iconSize {
return UniversalPlatform.isMobile
? MobileAIPromptSizes.predefinedFormatIconHeight
: DesktopAIPromptSizes.predefinedFormatIconHeight;
}
}
class PromptInputMobileToggleFormatButton extends StatelessWidget {
@@ -2,7 +2,6 @@ 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/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
import 'package:appflowy/plugins/base/drag_handler.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
@@ -19,9 +18,11 @@ import 'select_sources_menu.dart';
class PromptInputMobileSelectSourcesButton extends StatefulWidget {
const PromptInputMobileSelectSourcesButton({
super.key,
required this.selectedSourcesNotifier,
required this.onUpdateSelectedSources,
});
final ValueNotifier<List<String>> selectedSourcesNotifier;
final void Function(List<String>) onUpdateSelectedSources;
@override
@@ -36,15 +37,15 @@ class _PromptInputMobileSelectSourcesButtonState
@override
void initState() {
super.initState();
widget.selectedSourcesNotifier.addListener(onSelectedSourcesChanged);
WidgetsBinding.instance.addPostFrameCallback((_) {
cubit.updateSelectedSources(
context.read<ChatBloc>().state.selectedSourceIds,
);
onSelectedSourcesChanged();
});
}
@override
void dispose() {
widget.selectedSourcesNotifier.removeListener(onSelectedSourcesChanged);
cubit.close();
super.dispose();
}
@@ -70,56 +71,49 @@ class _PromptInputMobileSelectSourcesButtonState
],
child: BlocBuilder<SpaceBloc, SpaceState>(
builder: (context, state) {
return BlocListener<ChatBloc, ChatState>(
listener: (context, state) {
cubit
..updateSelectedSources(state.selectedSourceIds)
..updateSelectedStatus();
},
child: FlowyButton(
margin: const EdgeInsetsDirectional.fromSTEB(4, 6, 2, 6),
expandText: false,
text: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowySvg(
FlowySvgs.ai_page_s,
color: Theme.of(context).iconTheme.color,
size: const Size.square(20.0),
),
FlowySvg(
FlowySvgs.ai_source_drop_down_s,
color: Theme.of(context).hintColor,
size: const Size.square(10),
),
],
),
onTap: () async {
context
.read<ChatSettingsCubit>()
.refreshSources(state.spaces, state.currentSpace);
await showMobileBottomSheet<void>(
context,
backgroundColor: Theme.of(context).colorScheme.surface,
maxChildSize: 0.98,
enableDraggableScrollable: true,
scrollableWidgetBuilder: (_, scrollController) {
return Expanded(
child: BlocProvider.value(
value: cubit,
child: _MobileSelectSourcesSheetBody(
scrollController: scrollController,
),
),
);
},
builder: (context) => const SizedBox.shrink(),
);
if (context.mounted) {
widget.onUpdateSelectedSources(cubit.selectedSourceIds);
}
},
return FlowyButton(
margin: const EdgeInsetsDirectional.fromSTEB(4, 6, 2, 6),
expandText: false,
text: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowySvg(
FlowySvgs.ai_page_s,
color: Theme.of(context).iconTheme.color,
size: const Size.square(20.0),
),
FlowySvg(
FlowySvgs.ai_source_drop_down_s,
color: Theme.of(context).hintColor,
size: const Size.square(10),
),
],
),
onTap: () async {
context
.read<ChatSettingsCubit>()
.refreshSources(state.spaces, state.currentSpace);
await showMobileBottomSheet<void>(
context,
backgroundColor: Theme.of(context).colorScheme.surface,
maxChildSize: 0.98,
enableDraggableScrollable: true,
scrollableWidgetBuilder: (_, scrollController) {
return Expanded(
child: BlocProvider.value(
value: cubit,
child: _MobileSelectSourcesSheetBody(
scrollController: scrollController,
),
),
);
},
builder: (context) => const SizedBox.shrink(),
);
if (context.mounted) {
widget.onUpdateSelectedSources(cubit.selectedSourceIds);
}
},
);
},
),
@@ -127,6 +121,12 @@ class _PromptInputMobileSelectSourcesButtonState
},
);
}
void onSelectedSourcesChanged() {
cubit
..updateSelectedSources(widget.selectedSourcesNotifier.value)
..updateSelectedStatus();
}
}
class _MobileSelectSourcesSheetBody extends StatefulWidget {
@@ -2,8 +2,8 @@ import 'dart:math';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.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';
@@ -18,15 +18,17 @@ import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../layout_define.dart';
import 'chat_mention_page_menu.dart';
import 'layout_define.dart';
import 'mention_page_menu.dart';
class PromptInputDesktopSelectSourcesButton extends StatefulWidget {
const PromptInputDesktopSelectSourcesButton({
super.key,
required this.selectedSourcesNotifier,
required this.onUpdateSelectedSources,
});
final ValueNotifier<List<String>> selectedSourcesNotifier;
final void Function(List<String>) onUpdateSelectedSources;
@override
@@ -42,15 +44,15 @@ class _PromptInputDesktopSelectSourcesButtonState
@override
void initState() {
super.initState();
widget.selectedSourcesNotifier.addListener(onSelectedSourcesChanged);
WidgetsBinding.instance.addPostFrameCallback((_) {
cubit.updateSelectedSources(
context.read<ChatBloc>().state.selectedSourceIds,
);
onSelectedSourcesChanged();
});
}
@override
void dispose() {
widget.selectedSourcesNotifier.removeListener(onSelectedSourcesChanged);
cubit.close();
super.dispose();
}
@@ -76,51 +78,53 @@ class _PromptInputDesktopSelectSourcesButtonState
],
child: BlocBuilder<SpaceBloc, SpaceState>(
builder: (context, state) {
return BlocListener<ChatBloc, ChatState>(
listener: (context, state) {
cubit
..updateSelectedSources(state.selectedSourceIds)
..updateSelectedStatus();
return AppFlowyPopover(
constraints: BoxConstraints.loose(const Size(320, 380)),
offset: const Offset(0.0, -10.0),
direction: PopoverDirection.topWithCenterAligned,
margin: EdgeInsets.zero,
controller: popoverController,
onOpen: () {
context
.read<ChatSettingsCubit>()
.refreshSources(state.spaces, state.currentSpace);
},
child: AppFlowyPopover(
constraints: BoxConstraints.loose(const Size(320, 380)),
offset: const Offset(0.0, -10.0),
direction: PopoverDirection.topWithCenterAligned,
margin: EdgeInsets.zero,
controller: popoverController,
onOpen: () {
context
.read<ChatSettingsCubit>()
.refreshSources(state.spaces, state.currentSpace);
},
onClose: () {
widget.onUpdateSelectedSources(cubit.selectedSourceIds);
context
.read<ChatSettingsCubit>()
.refreshSources(state.spaces, state.currentSpace);
},
popupBuilder: (_) {
return BlocProvider.value(
value: context.read<ChatSettingsCubit>(),
child: const _PopoverContent(),
);
},
child: _IndicatorButton(
onTap: () => popoverController.show(),
),
onClose: () {
widget.onUpdateSelectedSources(cubit.selectedSourceIds);
context
.read<ChatSettingsCubit>()
.refreshSources(state.spaces, state.currentSpace);
},
popupBuilder: (_) {
return BlocProvider.value(
value: context.read<ChatSettingsCubit>(),
child: const _PopoverContent(),
);
},
child: _IndicatorButton(
selectedSourcesNotifier: widget.selectedSourcesNotifier,
onTap: () => popoverController.show(),
),
);
},
),
);
}
void onSelectedSourcesChanged() {
cubit
..updateSelectedSources(widget.selectedSourcesNotifier.value)
..updateSelectedStatus();
}
}
class _IndicatorButton extends StatelessWidget {
const _IndicatorButton({
required this.selectedSourcesNotifier,
required this.onTap,
});
final ValueNotifier<List<String>> selectedSourcesNotifier;
final VoidCallback onTap;
@override
@@ -144,11 +148,19 @@ class _IndicatorButton extends StatelessWidget {
color: Theme.of(context).iconTheme.color,
),
const HSpace(2.0),
BlocBuilder<ChatBloc, ChatState>(
builder: (context, state) {
ValueListenableBuilder(
valueListenable: 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(
state.selectedSourceIds.length.toString(),
fontSize: 14,
label,
fontSize: 12,
figmaLineHeight: 16,
color: Theme.of(context).hintColor,
);
@@ -0,0 +1,70 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:universal_platform/universal_platform.dart';
import 'layout_define.dart';
enum SendButtonState { enabled, streaming, disabled }
class PromptInputSendButton extends StatelessWidget {
const PromptInputSendButton({
super.key,
required this.state,
required this.onSendPressed,
required this.onStopStreaming,
});
final SendButtonState state;
final VoidCallback onSendPressed;
final VoidCallback onStopStreaming;
@override
Widget build(BuildContext context) {
return FlowyIconButton(
width: _buttonSize,
icon: switch (state) {
SendButtonState.enabled => FlowySvg(
FlowySvgs.ai_send_filled_s,
size: Size.square(_iconSize),
color: Theme.of(context).colorScheme.primary,
),
SendButtonState.disabled => FlowySvg(
FlowySvgs.ai_send_filled_s,
size: Size.square(_iconSize),
color: Theme.of(context).disabledColor,
),
SendButtonState.streaming => FlowySvg(
FlowySvgs.ai_stop_filled_s,
size: Size.square(_iconSize),
color: Theme.of(context).colorScheme.primary,
),
},
onPressed: () {
switch (state) {
case SendButtonState.enabled:
onSendPressed();
break;
case SendButtonState.streaming:
onStopStreaming();
break;
case SendButtonState.disabled:
break;
}
},
hoverColor: Colors.transparent,
);
}
double get _buttonSize {
return UniversalPlatform.isMobile
? MobileAIPromptSizes.sendButtonSize
: DesktopAIPromptSizes.actionBarSendButtonSize;
}
double get _iconSize {
return UniversalPlatform.isMobile
? MobileAIPromptSizes.sendButtonSize
: DesktopAIPromptSizes.actionBarSendButtonIconSize;
}
}
@@ -115,4 +115,9 @@ class KVKeys {
///
/// The value is a json string of [RecentIcons]
static const String recentIcons = 'kRecentIcons';
/// The key for saving compact mode ids for node or databse view
///
/// The value is a json list of id
static const String compactModeIds = 'compactModeIds';
}
+2 -1
View File
@@ -180,7 +180,7 @@ Future<void> useLocalServer() async {
await _setAuthenticatorType(AuthenticatorType.local);
}
/// Use getIt<AppFlowyCloudSharedEnv>() to get the shared environment.
// Use getIt<AppFlowyCloudSharedEnv>() to get the shared environment.
class AppFlowyCloudSharedEnv {
AppFlowyCloudSharedEnv({
required AuthenticatorType authenticatorType,
@@ -247,6 +247,7 @@ Future<AppFlowyCloudConfiguration> configurationFromUri(
// In development mode, the app is configured to access the AppFlowy cloud server directly through specific ports.
// This setup bypasses the need for Nginx, meaning that the AppFlowy cloud should be running without an Nginx server
// in the development environment.
// If you modify following code, please update the corresponding documentation in the appflowy billing.
if (authenticatorType == AuthenticatorType.appflowyCloudDevelop) {
return AppFlowyCloudConfiguration(
base_url: "$baseUrl:8000",
@@ -502,11 +502,11 @@ class _AFDropdownMenuState<T> extends State<AFDropdownMenu<T>> {
// Simulate the focused state because the text field should always be focused
// during traversal. If the menu item has a custom foreground color, the "focused"
// color will also change to foregroundColor.withOpacity(0.12).
// color will also change to foregroundColor.withValues(alpha: 0.12).
effectiveStyle = entry.enabled && i == focusedIndex
? effectiveStyle.copyWith(
backgroundColor: WidgetStatePropertyAll<Color>(
focusedBackgroundColor.withOpacity(0.12),
focusedBackgroundColor.withValues(alpha: 0.12),
),
)
: effectiveStyle;
@@ -21,6 +21,7 @@ extension MobileRouter on BuildContext {
bool showMoreButton = true,
String? fixedTitle,
String? blockId,
List<String>? tabs,
}) async {
// set the current view before pushing the new view
getIt<MenuSharedState>().latestOpenView = view;
@@ -37,6 +38,9 @@ extension MobileRouter on BuildContext {
queryParameters[MobileDocumentScreen.viewBlockId] = blockId;
}
}
if (tabs != null) {
queryParameters[MobileDocumentScreen.viewSelectTabs] = tabs.join('-');
}
final uri = Uri(
path: view.routeName,
@@ -1,3 +1,4 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/base/mobile_view_page_bloc.dart';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
@@ -7,9 +8,11 @@ import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_state_container.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/plugins/document/presentation/document_collaborators.dart';
import 'package:appflowy/plugins/document/presentation/editor_notification.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
@@ -17,6 +20,9 @@ import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/user/user_workspace_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_lock_status_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy/workspace/presentation/widgets/view_title_bar.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@@ -34,6 +40,7 @@ class MobileViewPage extends StatefulWidget {
this.fixedTitle,
this.showMoreButton = true,
this.blockId,
this.tabs = const [PickerTabType.emoji, PickerTabType.icon],
});
/// view id
@@ -43,6 +50,7 @@ class MobileViewPage extends StatefulWidget {
final Map<String, dynamic>? arguments;
final bool showMoreButton;
final String? blockId;
final List<PickerTabType> tabs;
// only used in row page
final String? fixedTitle;
@@ -88,7 +96,7 @@ class _MobileViewPageState extends State<MobileViewPage> {
final body = _buildBody(context, state);
if (view == null) {
return _buildApp(context, null, body);
return SizedBox.shrink();
}
return MultiBlocProvider(
@@ -119,6 +127,11 @@ class _MobileViewPageState extends State<MobileViewPage> {
create: (_) => DocumentPageStyleBloc(view: view)
..add(const DocumentPageStyleEvent.initial()),
),
if (view.layout.isDocumentView || view.layout.isDatabaseView)
BlocProvider(
create: (_) => ViewLockStatusBloc(view: view)
..add(const ViewLockStatusEvent.initial()),
),
],
child: Builder(
builder: (context) {
@@ -149,6 +162,7 @@ class _MobileViewPageState extends State<MobileViewPage> {
title: title,
appBarOpacity: _appBarOpacity,
actions: actions,
view: view,
)
: FlowyAppBar(title: title, actions: actions);
final body = isDocument
@@ -193,6 +207,7 @@ class _MobileViewPageState extends State<MobileViewPage> {
data: {
MobileDocumentScreen.viewFixedTitle: widget.fixedTitle,
MobileDocumentScreen.viewBlockId: widget.blockId,
MobileDocumentScreen.viewSelectTabs: widget.tabs,
},
);
},
@@ -218,6 +233,8 @@ class _MobileViewPageState extends State<MobileViewPage> {
final isImmersiveMode =
context.read<MobileViewPageBloc>().state.isImmersiveMode;
final isLocked =
context.read<ViewLockStatusBloc?>()?.state.isLocked ?? false;
final actions = <Widget>[];
if (FeatureFlag.syncDocument.isOn) {
@@ -236,12 +253,13 @@ class _MobileViewPageState extends State<MobileViewPage> {
}
}
if (view.layout.isDocumentView) {
if (view.layout.isDocumentView && !isLocked) {
actions.addAll([
MobileViewPageLayoutButton(
view: view,
isImmersiveMode: isImmersiveMode,
appBarOpacity: _appBarOpacity,
tabs: widget.tabs,
),
]);
}
@@ -265,25 +283,139 @@ class _MobileViewPageState extends State<MobileViewPage> {
Widget _buildTitle(BuildContext context, ViewPB? view) {
final icon = view?.icon;
return Row(
mainAxisSize: MainAxisSize.min,
children: [
if (icon != null && icon.value.isNotEmpty) ...[
RawEmojiIconWidget(
emoji: icon.toEmojiIconData(),
emojiSize: 15,
return ValueListenableBuilder(
valueListenable: _appBarOpacity,
builder: (_, value, child) {
if (value < 0.99) {
return Padding(
padding: const EdgeInsets.only(left: 6.0),
child: _buildLockStatus(context, view),
);
}
final name =
widget.fixedTitle ?? view?.nameOrDefault ?? widget.title ?? '';
return Opacity(
opacity: value,
child: Row(
children: [
if (icon != null && icon.value.isNotEmpty) ...[
RawEmojiIconWidget(
emoji: icon.toEmojiIconData(),
emojiSize: 15,
),
const HSpace(4),
],
Flexible(
child: FlowyText.medium(
name,
fontSize: 15.0,
overflow: TextOverflow.ellipsis,
figmaLineHeight: 18.0,
),
),
const HSpace(4.0),
_buildLockStatusIcon(context, view),
],
),
const HSpace(4),
],
Expanded(
child: FlowyText.medium(
widget.fixedTitle ?? view?.name ?? widget.title ?? '',
fontSize: 15.0,
overflow: TextOverflow.ellipsis,
figmaLineHeight: 18.0,
),
),
],
);
},
);
}
Widget _buildLockStatus(BuildContext context, ViewPB? view) {
if (view == null || view.layout == ViewLayoutPB.Chat) {
return const SizedBox.shrink();
}
return BlocConsumer<ViewLockStatusBloc, ViewLockStatusState>(
listenWhen: (previous, current) =>
previous.isLoadingLockStatus == current.isLoadingLockStatus &&
current.isLoadingLockStatus == false,
listener: (context, state) {
if (state.isLocked) {
showToastNotification(
context,
message: LocaleKeys.lockPage_pageLockedToast.tr(),
);
EditorNotification.exitEditing().post();
}
},
builder: (context, state) {
if (state.isLocked) {
return LockedPageStatus();
} else if (!state.isLocked && state.lockCounter > 0) {
return ReLockedPageStatus();
}
return const SizedBox.shrink();
},
);
}
Widget _buildLockStatusIcon(BuildContext context, ViewPB? view) {
if (view == null || view.layout == ViewLayoutPB.Chat) {
return const SizedBox.shrink();
}
return BlocConsumer<ViewLockStatusBloc, ViewLockStatusState>(
listenWhen: (previous, current) =>
previous.isLoadingLockStatus == current.isLoadingLockStatus &&
current.isLoadingLockStatus == false,
listener: (context, state) {
if (state.isLocked) {
showToastNotification(
context,
message: LocaleKeys.lockPage_pageLockedToast.tr(),
);
}
},
builder: (context, state) {
if (state.isLocked) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
context.read<ViewLockStatusBloc>().add(
const ViewLockStatusEvent.unlock(),
);
},
child: Padding(
padding: const EdgeInsets.only(
top: 4.0,
right: 8,
bottom: 4.0,
),
child: FlowySvg(
FlowySvgs.lock_page_fill_s,
blendMode: null,
),
),
);
} else if (!state.isLocked && state.lockCounter > 0) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () {
context.read<ViewLockStatusBloc>().add(
const ViewLockStatusEvent.lock(),
);
},
child: Padding(
padding: const EdgeInsets.only(
top: 4.0,
right: 8,
bottom: 4.0,
),
child: FlowySvg(
FlowySvgs.unlock_page_s,
color: Color(0xFF8F959E),
blendMode: null,
),
),
);
}
return const SizedBox.shrink();
},
);
}
@@ -9,8 +9,10 @@ import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/plugins/document/presentation/editor_notification.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/page_style_bottom_sheet.dart';
import 'package:appflowy/plugins/shared/share/share_bloc.dart';
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
@@ -27,12 +29,13 @@ class MobileViewPageImmersiveAppBar extends StatelessWidget
required this.appBarOpacity,
required this.title,
required this.actions,
required this.view,
});
final ValueListenable appBarOpacity;
final Widget title;
final List<Widget> actions;
final ViewPB? view;
@override
final Size preferredSize;
@@ -42,9 +45,9 @@ class MobileViewPageImmersiveAppBar extends StatelessWidget
valueListenable: appBarOpacity,
builder: (_, opacity, __) => FlowyAppBar(
backgroundColor:
AppBarTheme.of(context).backgroundColor?.withOpacity(opacity),
AppBarTheme.of(context).backgroundColor?.withValues(alpha: opacity),
showDivider: false,
title: Opacity(opacity: opacity >= 0.99 ? 1.0 : 0, child: title),
title: _buildTitle(context, opacity: opacity),
leadingWidth: 44,
leading: Padding(
padding: const EdgeInsets.only(top: 4.0, bottom: 4.0, left: 12.0),
@@ -55,6 +58,13 @@ class MobileViewPageImmersiveAppBar extends StatelessWidget
);
}
Widget _buildTitle(
BuildContext context, {
required double opacity,
}) {
return title;
}
Widget _buildAppBarBackButton(BuildContext context) {
return AppBarButton(
padding: EdgeInsets.zero,
@@ -101,6 +111,12 @@ class MobileViewPageMoreButton extends StatelessWidget {
BlocProvider.value(value: context.read<FavoriteBloc>()),
BlocProvider.value(value: context.read<MobileViewPageBloc>()),
BlocProvider.value(value: context.read<ShareBloc>()),
BlocProvider(
create: (context) => ViewLockStatusBloc(view: view)
..add(
ViewLockStatusEvent.initial(),
),
),
],
child: MobileViewPageMoreBottomSheet(view: view),
),
@@ -123,9 +139,11 @@ class MobileViewPageLayoutButton extends StatelessWidget {
required this.view,
required this.isImmersiveMode,
required this.appBarOpacity,
required this.tabs,
});
final ViewPB view;
final List<PickerTabType> tabs;
final bool isImmersiveMode;
final ValueListenable appBarOpacity;
@@ -156,6 +174,7 @@ class MobileViewPageLayoutButton extends StatelessWidget {
],
child: PageStyleBottomSheet(
view: context.read<ViewBloc>().state.view,
tabs: tabs,
),
),
);
@@ -220,7 +239,7 @@ class _ImmersiveAppBarButton extends StatelessWidget {
child = DecoratedBox(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(dimension / 2.0),
color: Colors.black.withOpacity(0.2),
color: Colors.black.withValues(alpha: 0.2),
),
child: child,
);
@@ -14,6 +14,7 @@ import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/string_extension.dart';
import 'package:appflowy/workspace/application/favorite/favorite_bloc.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
@@ -43,7 +44,8 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
},
child: ViewPageBottomSheet(
view: view,
onAction: (action) async => _onAction(context, action),
onAction: (action, {arguments}) async =>
_onAction(context, action, arguments),
onRename: (name) {
_onRename(context, name);
context.pop();
@@ -56,6 +58,7 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
Future<void> _onAction(
BuildContext context,
MobileViewBottomSheetBodyAction action,
Map<String, dynamic>? arguments,
) async {
switch (action) {
case MobileViewBottomSheetBodyAction.duplicate:
@@ -107,12 +110,32 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
break;
case MobileViewBottomSheetBodyAction.updatePathName:
_updatePathName(context);
case MobileViewBottomSheetBodyAction.lockPage:
final isLocked =
arguments?[MobileViewBottomSheetBodyActionArguments.isLockedKey] ??
false;
await _lockPage(context, isLocked: isLocked);
// context.pop();
break;
case MobileViewBottomSheetBodyAction.rename:
// no need to implement, rename is handled by the onRename callback.
throw UnimplementedError();
}
}
Future<void> _lockPage(
BuildContext context, {
required bool isLocked,
}) async {
if (isLocked) {
context.read<ViewLockStatusBloc>().add(const ViewLockStatusEvent.lock());
} else {
context
.read<ViewLockStatusBloc>()
.add(const ViewLockStatusEvent.unlock());
}
}
Future<void> _publish(BuildContext context) async {
final id = context.read<ShareBloc>().view.id;
final lastPublishName = context.read<ShareBloc>().state.pathName;
@@ -1,8 +1,10 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
enum MobileViewItemBottomSheetBodyAction {
rename,
@@ -40,6 +42,8 @@ class MobileViewItemBottomSheetBody extends StatelessWidget {
BuildContext context,
MobileViewItemBottomSheetBodyAction action,
) {
final isLocked =
context.read<ViewLockStatusBloc?>()?.state.isLocked ?? false;
switch (action) {
case MobileViewItemBottomSheetBodyAction.rename:
return FlowyOptionTile.text(
@@ -49,6 +53,7 @@ class MobileViewItemBottomSheetBody extends StatelessWidget {
FlowySvgs.view_item_rename_s,
size: Size.square(18),
),
enable: !isLocked,
showTopBorder: false,
showBottomBorder: false,
onTap: () => onAction(
@@ -94,6 +99,7 @@ class MobileViewItemBottomSheetBody extends StatelessWidget {
size: const Size.square(18),
color: Theme.of(context).colorScheme.error,
),
enable: !isLocked,
showTopBorder: false,
showBottomBorder: false,
onTap: () => onAction(
@@ -4,9 +4,12 @@ import 'package:appflowy/mobile/application/base/mobile_view_page_bloc.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart';
import 'package:appflowy/plugins/shared/share/share_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/cupertino.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
@@ -25,11 +28,27 @@ enum MobileViewBottomSheetBodyAction {
visitSite,
copyShareLink,
updatePathName,
lockPage;
static const disableInLockedView = [
undo,
redo,
rename,
delete,
];
}
class MobileViewBottomSheetBodyActionArguments {
static const isLockedKey = 'is_locked';
}
typedef MobileViewBottomSheetBodyActionCallback = void Function(
MobileViewBottomSheetBodyAction action,
);
// for the [MobileViewBottomSheetBodyAction.lockPage] action,
// it will pass the [isLocked] value to the callback.
{
Map<String, dynamic>? arguments,
});
class ViewPageBottomSheet extends StatefulWidget {
const ViewPageBottomSheet({
@@ -56,7 +75,7 @@ class _ViewPageBottomSheetState extends State<ViewPageBottomSheet> {
case MobileBottomSheetType.view:
return MobileViewBottomSheetBody(
view: widget.view,
onAction: (action) {
onAction: (action, {arguments}) {
switch (action) {
case MobileViewBottomSheetBodyAction.rename:
setState(() {
@@ -64,7 +83,7 @@ class _ViewPageBottomSheetState extends State<ViewPageBottomSheet> {
});
break;
default:
widget.onAction(action);
widget.onAction(action, arguments: arguments);
}
},
);
@@ -93,6 +112,8 @@ class MobileViewBottomSheetBody extends StatelessWidget {
@override
Widget build(BuildContext context) {
final isFavorite = view.isFavorite;
final isLocked =
context.watch<ViewLockStatusBloc?>()?.state.isLocked ?? false;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
@@ -100,6 +121,7 @@ class MobileViewBottomSheetBody extends StatelessWidget {
text: LocaleKeys.button_rename.tr(),
icon: FlowySvgs.view_item_rename_s,
iconSize: const Size.square(18),
enable: !isLocked,
onTap: () => onAction(
MobileViewBottomSheetBodyAction.rename,
),
@@ -118,6 +140,28 @@ class MobileViewBottomSheetBody extends StatelessWidget {
),
),
_divider(),
if (view.layout.isDatabaseView || view.layout.isDocumentView) ...[
MobileQuickActionButton(
text: LocaleKeys.disclosureAction_lockPage.tr(),
icon: FlowySvgs.lock_page_s,
iconSize: const Size.square(18),
rightIconBuilder: (context) => _LockPageRightIconBuilder(
onAction: onAction,
),
onTap: () {
final isLocked =
context.read<ViewLockStatusBloc?>()?.state.isLocked ?? false;
onAction(
MobileViewBottomSheetBodyAction.lockPage,
arguments: {
MobileViewBottomSheetBodyActionArguments.isLockedKey:
!isLocked,
},
);
},
),
_divider(),
],
MobileQuickActionButton(
text: LocaleKeys.button_duplicate.tr(),
icon: FlowySvgs.duplicate_s,
@@ -138,12 +182,14 @@ class MobileViewBottomSheetBody extends StatelessWidget {
),
_divider(),
..._buildPublishActions(context),
_divider(),
MobileQuickActionButton(
text: LocaleKeys.button_delete.tr(),
textColor: Theme.of(context).colorScheme.error,
icon: FlowySvgs.trash_s,
iconColor: Theme.of(context).colorScheme.error,
iconSize: const Size.square(18),
enable: !isLocked,
onTap: () => onAction(
MobileViewBottomSheetBodyAction.delete,
),
@@ -206,3 +252,36 @@ class MobileViewBottomSheetBody extends StatelessWidget {
Widget _divider() => const MobileQuickActionDivider();
}
class _LockPageRightIconBuilder extends StatelessWidget {
const _LockPageRightIconBuilder({
required this.onAction,
});
final MobileViewBottomSheetBodyActionCallback onAction;
@override
Widget build(BuildContext context) {
final isLocked =
context.watch<ViewLockStatusBloc?>()?.state.isLocked ?? false;
return SizedBox(
width: 46,
height: 30,
child: FittedBox(
fit: BoxFit.fill,
child: CupertinoSwitch(
value: isLocked,
activeTrackColor: Theme.of(context).colorScheme.primary,
onChanged: (value) {
onAction(
MobileViewBottomSheetBodyAction.lockPage,
arguments: {
MobileViewBottomSheetBodyActionArguments.isLockedKey: value,
},
);
},
),
),
);
}
}
@@ -8,6 +8,7 @@ import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_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_lock_status_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -131,6 +132,11 @@ enum MobilePaneActionType {
BlocProvider.value(value: favoriteBloc),
if (recentViewsBloc != null)
BlocProvider.value(value: recentViewsBloc),
BlocProvider(
create: (_) =>
ViewLockStatusBloc(view: viewBloc.state.view)
..add(const ViewLockStatusEvent.initial()),
),
],
child: BlocBuilder<ViewBloc, ViewState>(
builder: (context, state) {
@@ -74,7 +74,7 @@ Future<T?> showMobileBottomSheet<T>(
backgroundColor ??= Theme.of(context).brightness == Brightness.light
? const Color(0xFFF7F8FB)
: const Color(0xFF23262B);
barrierColor ??= Colors.black.withOpacity(0.3);
barrierColor ??= Colors.black.withValues(alpha: 0.3);
return showModalBottomSheet<T>(
context: context,
@@ -329,7 +329,7 @@ class CupertinoSheetBottomRouteTransition extends StatelessWidget {
(Theme.of(context).brightness == Brightness.dark
? Colors.grey
: Colors.black)
.withOpacity(secondaryAnimation.value * 0.1),
.withValues(alpha: secondaryAnimation.value * 0.1),
BlendMode.srcOver,
),
child: child,
@@ -1,5 +1,3 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/database/board/board.dart';
@@ -13,12 +11,14 @@ import 'package:appflowy/plugins/database/widgets/cell/card_cell_style_maps/mobi
import 'package:appflowy/shared/flowy_error_page.dart';
import 'package:appflowy/util/field_type_extension.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
import 'package:appflowy/workspace/application/view/view_lock_status_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_board/appflowy_board.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
@@ -143,6 +143,8 @@ class _BoardContentState extends State<_BoardContent> {
return state.maybeMap(
orElse: () => const SizedBox.shrink(),
ready: (state) {
final isLocked =
context.watch<ViewLockStatusBloc?>()?.state.isLocked ?? false;
final showCreateGroupButton = context
.read<BoardBloc>()
.groupingFieldType
@@ -160,15 +162,20 @@ class _BoardContentState extends State<_BoardContent> {
padding: config.groupHeaderPadding,
)
: const HSpace(16),
trailing: showCreateGroupButton
trailing: showCreateGroupButton && !isLocked
? const MobileBoardTrailing()
: const HSpace(16),
headerBuilder: (_, groupData) => BlocProvider<BoardBloc>.value(
value: context.read<BoardBloc>(),
child: GroupCardHeader(
groupData: groupData,
),
),
headerBuilder: (_, groupData) {
final isLocked =
context.read<ViewLockStatusBloc?>()?.state.isLocked ??
false;
return IgnorePointer(
ignoring: isLocked,
child: GroupCardHeader(
groupData: groupData,
),
);
},
footerBuilder: _buildFooter,
cardBuilder: (_, column, columnItem) => _buildCard(
context: context,
@@ -184,34 +191,39 @@ class _BoardContentState extends State<_BoardContent> {
}
Widget _buildFooter(BuildContext context, AppFlowyGroupData columnData) {
final isLocked =
context.read<ViewLockStatusBloc?>()?.state.isLocked ?? false;
final style = Theme.of(context);
return SizedBox(
height: 42,
width: double.infinity,
child: TextButton.icon(
style: TextButton.styleFrom(
padding: const EdgeInsets.only(left: 8),
alignment: Alignment.centerLeft,
),
icon: FlowySvg(
FlowySvgs.add_m,
color: style.colorScheme.onSurface,
),
label: Text(
LocaleKeys.board_column_createNewCard.tr(),
style: style.textTheme.bodyMedium?.copyWith(
child: IgnorePointer(
ignoring: isLocked,
child: TextButton.icon(
style: TextButton.styleFrom(
padding: const EdgeInsets.only(left: 8),
alignment: Alignment.centerLeft,
),
icon: FlowySvg(
FlowySvgs.add_m,
color: style.colorScheme.onSurface,
),
),
onPressed: () => context.read<BoardBloc>().add(
BoardEvent.createRow(
columnData.id,
OrderObjectPositionTypePB.End,
null,
null,
),
label: Text(
LocaleKeys.board_column_createNewCard.tr(),
style: style.textTheme.bodyMedium?.copyWith(
color: style.colorScheme.onSurface,
),
),
onPressed: () => context.read<BoardBloc>().add(
BoardEvent.createRow(
columnData.id,
OrderObjectPositionTypePB.End,
null,
null,
),
),
),
),
);
}
@@ -231,6 +243,8 @@ class _BoardContentState extends State<_BoardContent> {
CardCellBuilder(databaseController: boardBloc.databaseController);
final groupItemId = groupItem.row.id + groupData.group.groupId;
final isLocked =
context.read<ViewLockStatusBloc?>()?.state.isLocked ?? false;
return Container(
key: ValueKey(groupItemId),
@@ -238,31 +252,34 @@ class _BoardContentState extends State<_BoardContent> {
decoration: _makeBoxDecoration(context),
child: BlocProvider.value(
value: boardBloc,
child: RowCard(
fieldController: boardBloc.fieldController,
rowMeta: rowMeta,
viewId: boardBloc.viewId,
rowCache: boardBloc.rowCache,
groupingFieldId: groupItem.fieldInfo.id,
isEditing: false,
cellBuilder: cellBuilder,
onTap: (context) {
context.push(
MobileRowDetailPage.routeName,
extra: {
MobileRowDetailPage.argRowId: rowMeta.id,
MobileRowDetailPage.argDatabaseController:
context.read<BoardBloc>().databaseController,
},
);
},
onStartEditing: () {},
onEndEditing: () {},
styleConfiguration: RowCardStyleConfiguration(
cellStyleMap: mobileBoardCardCellStyleMap(context),
showAccessory: false,
child: IgnorePointer(
ignoring: isLocked,
child: RowCard(
fieldController: boardBloc.fieldController,
rowMeta: rowMeta,
viewId: boardBloc.viewId,
rowCache: boardBloc.rowCache,
groupingFieldId: groupItem.fieldInfo.id,
isEditing: false,
cellBuilder: cellBuilder,
onTap: (context) {
context.push(
MobileRowDetailPage.routeName,
extra: {
MobileRowDetailPage.argRowId: rowMeta.id,
MobileRowDetailPage.argDatabaseController:
context.read<BoardBloc>().databaseController,
},
);
},
onStartEditing: () {},
onEndEditing: () {},
styleConfiguration: RowCardStyleConfiguration(
cellStyleMap: mobileBoardCardCellStyleMap(context),
showAccessory: false,
),
userProfile: boardBloc.userProfile,
),
userProfile: boardBloc.userProfile,
),
),
);
@@ -276,14 +293,20 @@ class _BoardContentState extends State<_BoardContent> {
border: themeMode == ThemeMode.light
? Border.fromBorderSide(
BorderSide(
color: Theme.of(context).colorScheme.outline.withOpacity(0.5),
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
),
)
: null,
boxShadow: themeMode == ThemeMode.light
? [
BoxShadow(
color: Theme.of(context).colorScheme.outline.withOpacity(0.5),
color: Theme.of(context)
.colorScheme
.outline
.withValues(alpha: 0.5),
blurRadius: 4,
offset: const Offset(0, 2),
),
@@ -1,5 +1,3 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart';
@@ -29,6 +27,7 @@ import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:fluttertoast/fluttertoast.dart';
import 'package:go_router/go_router.dart';
@@ -59,7 +58,9 @@ class _MobileRowDetailPageState extends State<MobileRowDetailPage> {
late final PageController _pageController;
String get viewId => widget.databaseController.viewId;
RowCache get rowCache => widget.databaseController.rowCache;
FieldController get fieldController =>
widget.databaseController.fieldController;
@@ -380,7 +381,9 @@ class MobileRowDetailPageContentState
late final EditableCellBuilder cellBuilder;
String get viewId => widget.databaseController.viewId;
RowCache get rowCache => widget.databaseController.rowCache;
FieldController get fieldController =>
widget.databaseController.fieldController;
ValueNotifier<String> primaryFieldId = ValueNotifier('');
@@ -542,6 +545,7 @@ class _TitleSkin extends IEditableTextCellSkin {
Widget build(
BuildContext context,
CellContainerNotifier cellContainerNotifier,
ValueNotifier<bool> compactModeNotifier,
TextCellBloc bloc,
FocusNode focusNode,
TextEditingController textEditingController,
@@ -8,6 +8,7 @@ import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/grid/presentation/layout/sizes.dart';
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
@@ -116,6 +117,7 @@ class _OpenRowPageButtonState extends State<OpenRowPageButton> {
addInRecent: false,
showMoreButton: false,
fixedTitle: fieldName,
tabs: [PickerTabType.emoji.name],
);
}
}
@@ -1,5 +1,3 @@
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart';
import 'package:appflowy/mobile/presentation/database/field/mobile_full_field_editor.dart';
@@ -8,6 +6,7 @@ import 'package:appflowy/plugins/database/domain/field_backend_service.dart';
import 'package:appflowy/plugins/database/domain/field_service.dart';
import 'package:appflowy/plugins/database/widgets/setting/field_visibility_extension.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
class MobileEditPropertyScreen extends StatefulWidget {
@@ -49,7 +48,7 @@ class _MobileEditPropertyScreenState extends State<MobileEditPropertyScreen> {
final fieldId = widget.field.id;
return PopScope(
onPopInvoked: (didPop) {
onPopInvokedWithResult: (didPop, _) {
if (!didPop) {
context.pop(_fieldOptionValues);
}
@@ -5,6 +5,8 @@ import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/plugins/database/application/tab_bar_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
@@ -163,9 +165,20 @@ class MobileDatabaseViewListButton extends StatelessWidget {
}
Widget _buildViewIconButton(BuildContext context, ViewPB view) {
final iconData = view.icon.toEmojiIconData();
Widget icon;
if (iconData.isEmpty || iconData.type != FlowyIconType.icon) {
icon = view.defaultIcon();
} else {
icon = RawEmojiIconWidget(
emoji: iconData,
emojiSize: 14.0,
enableColor: false,
);
}
return SizedBox.square(
dimension: 20.0,
child: view.defaultIcon(),
child: icon,
);
}
@@ -1,11 +1,16 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/show_transition_bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/flowy_mobile_quick_action_button.dart';
import 'package:appflowy/plugins/database/application/database_controller.dart';
import 'package:appflowy/shared/icon_emoji_picker/flowy_icon_emoji_picker.dart';
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
@@ -49,6 +54,45 @@ class MobileDatabaseViewQuickActions extends StatelessWidget {
}
}),
const MobileQuickActionDivider(),
_actionButton(
context,
_Action.changeIcon,
() {
showMobileBottomSheet(
context,
showDragHandle: true,
showDivider: false,
showHeader: true,
title: LocaleKeys.titleBar_pageIcon.tr(),
backgroundColor: AFThemeExtension.of(context).background,
enableDraggableScrollable: true,
minChildSize: 0.6,
initialChildSize: 0.61,
scrollableWidgetBuilder: (_, controller) {
return Expanded(
child: FlowyIconEmojiPicker(
tabs: const [PickerTabType.icon],
enableBackgroundColorSelection: false,
onSelectedEmoji: (r) {
ViewBackendService.updateViewIcon(
view: view,
viewIcon: r.data,
);
Navigator.pop(context);
},
),
);
},
builder: (_) => const SizedBox.shrink(),
).then((_) {
if (context.mounted) {
Navigator.pop(context);
}
});
},
!isInline,
),
const MobileQuickActionDivider(),
_actionButton(
context,
_Action.duplicate,
@@ -91,14 +135,16 @@ class MobileDatabaseViewQuickActions extends StatelessWidget {
enum _Action {
edit,
duplicate,
delete;
changeIcon,
delete,
duplicate;
String get label {
return switch (this) {
edit => LocaleKeys.grid_settings_editView.tr(),
duplicate => LocaleKeys.button_duplicate.tr(),
delete => LocaleKeys.button_delete.tr(),
changeIcon => LocaleKeys.disclosureAction_changeIcon.tr(),
};
}
@@ -107,6 +153,7 @@ enum _Action {
edit => FlowySvgs.view_item_rename_s,
duplicate => FlowySvgs.duplicate_s,
delete => FlowySvgs.trash_s,
changeIcon => FlowySvgs.change_icon_s,
};
}
@@ -1,4 +1,5 @@
import 'package:appflowy/mobile/presentation/base/mobile_view_page.dart';
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flutter/material.dart';
@@ -10,6 +11,7 @@ class MobileDocumentScreen extends StatelessWidget {
this.showMoreButton = true,
this.fixedTitle,
this.blockId,
this.tabs = const [PickerTabType.emoji, PickerTabType.icon],
});
/// view id
@@ -18,6 +20,7 @@ class MobileDocumentScreen extends StatelessWidget {
final bool showMoreButton;
final String? fixedTitle;
final String? blockId;
final List<PickerTabType> tabs;
static const routeName = '/docs';
static const viewId = 'id';
@@ -25,6 +28,7 @@ class MobileDocumentScreen extends StatelessWidget {
static const viewShowMoreButton = 'show_more_button';
static const viewFixedTitle = 'fixed_title';
static const viewBlockId = 'block_id';
static const viewSelectTabs = 'select_tabs';
@override
Widget build(BuildContext context) {
@@ -35,6 +39,7 @@ class MobileDocumentScreen extends StatelessWidget {
showMoreButton: showMoreButton,
fixedTitle: fixedTitle,
blockId: blockId,
tabs: tabs,
);
}
}
@@ -3,16 +3,19 @@ import 'package:appflowy/env/env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/mobile/presentation/setting/ai/ai_settings_group.dart';
import 'package:appflowy/mobile/presentation/setting/cloud/cloud_setting_group.dart';
import 'package:appflowy/mobile/presentation/setting/user_session_setting_group.dart';
import 'package:appflowy/mobile/presentation/setting/workspace/workspace_setting_group.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class MobileHomeSettingPage extends StatefulWidget {
const MobileHomeSettingPage({
@@ -70,29 +73,47 @@ class _MobileHomeSettingPageState extends State<MobileHomeSettingPage> {
Widget _buildSettingsWidget(UserProfilePB userProfile) {
// show the third-party sign in buttons if user logged in with local session and auth is enabled.
final showThirdPartyLogin =
final isLocalAuthEnabled =
userProfile.authenticator == AuthenticatorPB.Local && isAuthEnabled;
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
PersonalInfoSettingGroup(
userProfile: userProfile,
'';
return BlocProvider(
create: (context) => UserWorkspaceBloc(userProfile: userProfile)
..add(const UserWorkspaceEvent.initial()),
child: BlocBuilder<UserWorkspaceBloc, UserWorkspaceState>(
builder: (context, state) {
final currentWorkspaceId = state.currentWorkspace?.workspaceId ?? '';
return SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
children: [
PersonalInfoSettingGroup(
userProfile: userProfile,
),
const WorkspaceSettingGroup(),
const AppearanceSettingGroup(),
const LanguageSettingGroup(),
if (Env.enableCustomCloud) const CloudSettingGroup(),
if (isAuthEnabled)
AiSettingsGroup(
key: ValueKey(currentWorkspaceId),
userProfile: userProfile,
workspaceId: currentWorkspaceId,
currentWorkspaceMemberRole: state.currentWorkspace?.role,
),
const SupportSettingGroup(),
const AboutSettingGroup(),
UserSessionSettingGroup(
userProfile: userProfile,
showThirdPartyLogin: isLocalAuthEnabled,
),
const VSpace(20),
],
),
),
const WorkspaceSettingGroup(),
const AppearanceSettingGroup(),
const LanguageSettingGroup(),
if (Env.enableCustomCloud) const CloudSettingGroup(),
const SupportSettingGroup(),
const AboutSettingGroup(),
UserSessionSettingGroup(
userProfile: userProfile,
showThirdPartyLogin: showThirdPartyLogin,
),
const VSpace(20),
],
),
);
},
),
);
}
@@ -212,7 +212,7 @@ class _DeletedFilesListView extends StatelessWidget {
?.copyWith(color: theme.colorScheme.onSurface),
),
horizontalTitleGap: 0,
tileColor: theme.colorScheme.onSurface.withOpacity(0.1),
tileColor: theme.colorScheme.onSurface.withValues(alpha: 0.1),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
@@ -134,7 +134,8 @@ class _RecentCover extends StatelessWidget {
Widget build(BuildContext context) {
final placeholder = Container(
// random color, update it once we have a better placeholder
color: Theme.of(context).colorScheme.onSurfaceVariant.withOpacity(0.2),
color:
Theme.of(context).colorScheme.onSurfaceVariant.withValues(alpha: 0.2),
);
final value = this.value;
if (value == null) {
@@ -3,6 +3,7 @@ import 'package:appflowy/mobile/application/mobile_router.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/home/section_folder/mobile_home_section_folder_header.dart';
import 'package:appflowy/mobile/presentation/page_item/mobile_view_item.dart';
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/application/view/view_bloc.dart';
@@ -101,7 +102,14 @@ class _Pages extends StatelessWidget {
level: 0,
leftPadding: HomeSpaceViewSizes.leftPadding,
isFeedback: false,
onSelected: context.pushView,
onSelected: (v) => context.pushView(
v,
tabs: [
PickerTabType.emoji,
PickerTabType.icon,
PickerTabType.custom,
].map((e) => e.name).toList(),
),
endActionPane: (context) {
final view = context.read<ViewBloc>().state.view;
return buildEndActionPane(
@@ -11,6 +11,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emo
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/shared/appflowy_network_image.dart';
import 'package:appflowy/shared/flowy_gradient_colors.dart';
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
import 'package:appflowy/util/string_extension.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/settings/appearance/appearance_cubit.dart';
@@ -81,7 +82,14 @@ class MobileViewPage extends StatelessWidget {
spaceRatio: 4,
),
child: AnimatedGestureDetector(
onTapUp: () => context.pushView(view),
onTapUp: () => context.pushView(
view,
tabs: [
PickerTabType.emoji,
PickerTabType.icon,
PickerTabType.custom,
].map((e) => e.name).toList(),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -140,7 +148,8 @@ class MobileViewPage extends StatelessWidget {
final iconUrl = userProfile?.iconUrl;
if (iconUrl == null ||
iconUrl.isEmpty ||
view.createdBy != userProfile?.id) {
view.createdBy != userProfile?.id ||
!isURL(iconUrl)) {
return const SizedBox.shrink();
}
@@ -182,7 +191,7 @@ class MobileViewPage extends StatelessWidget {
WidgetSpan(
child: SizedBox(
width: 20,
child: EmojiIconWidget(
child: RawEmojiIconWidget(
emoji: icon,
emojiSize: 18.0,
),
@@ -206,7 +215,7 @@ class MobileViewPage extends StatelessWidget {
Widget _buildAuthor(BuildContext context, RecentViewState state) {
return FlowyText.regular(
// view.createdBy.toString(),
'Lucas',
'',
fontSize: 12.0,
color: Theme.of(context).hintColor,
overflow: TextOverflow.ellipsis,
@@ -216,7 +225,7 @@ class MobileViewPage extends StatelessWidget {
Widget _buildLastViewed(BuildContext context) {
final textColor = Theme.of(context).isLightMode
? const Color(0x7F171717)
: Colors.white.withOpacity(0.45);
: Colors.white.withValues(alpha: 0.45);
if (timestamp == null) {
return const SizedBox.shrink();
}
@@ -4,6 +4,7 @@ import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/home/space/mobile_space_header.dart';
import 'package:appflowy/mobile/presentation/home/space/mobile_space_menu.dart';
import 'package:appflowy/mobile/presentation/page_item/mobile_view_item.dart';
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
import 'package:appflowy/shared/list_extension.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
@@ -97,7 +98,7 @@ class MobileSpace extends StatelessWidget {
Navigator.of(sheetContext).pop();
context.read<SpaceBloc>().add(
SpaceEvent.createPage(
name: layout.defaultName,
name: '',
layout: layout,
index: 0,
openAfterCreate: true,
@@ -151,7 +152,14 @@ class _Pages extends StatelessWidget {
level: 0,
leftPadding: HomeSpaceViewSizes.leftPadding,
isFeedback: false,
onSelected: context.pushView,
onSelected: (v) => context.pushView(
v,
tabs: [
PickerTabType.emoji,
PickerTabType.icon,
PickerTabType.custom,
].map((e) => e.name).toList(),
),
endActionPane: (context) {
final view = context.read<ViewBloc>().state.view;
final actions = [
@@ -42,7 +42,7 @@ class FloatingAIEntry extends StatelessWidget {
blurRadius: 20,
spreadRadius: 1,
offset: const Offset(0, 4),
color: Colors.black.withOpacity(0.05),
color: Colors.black.withValues(alpha: 0.05),
),
],
);
@@ -51,8 +51,8 @@ class FloatingAIEntry extends StatelessWidget {
BoxDecoration _buildWrapperDecoration(BuildContext context) {
final outlineColor = Theme.of(context).colorScheme.outline;
final borderColor = Theme.of(context).isLightMode
? outlineColor.withOpacity(0.7)
: outlineColor.withOpacity(0.3);
? outlineColor.withValues(alpha: 0.7)
: outlineColor.withValues(alpha: 0.3);
return BoxDecoration(
borderRadius: BorderRadius.circular(30),
color: Theme.of(context).colorScheme.surface,
@@ -6,11 +6,11 @@ import 'package:appflowy/mobile/presentation/home/tab/_tab_bar.dart';
import 'package:appflowy/mobile/presentation/home/tab/space_order_bloc.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/mobile/presentation/setting/workspace/invite_members_screen.dart';
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
import 'package:appflowy/workspace/application/menu/sidebar_sections_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
@@ -72,7 +72,14 @@ class _MobileSpaceTabState extends State<MobileSpaceTab>
listener: (context, state) {
final lastCreatedPage = state.lastCreatedPage;
if (lastCreatedPage != null) {
context.pushView(lastCreatedPage);
context.pushView(
lastCreatedPage,
tabs: [
PickerTabType.emoji,
PickerTabType.icon,
PickerTabType.custom,
].map((e) => e.name).toList(),
);
}
},
),
@@ -82,7 +89,14 @@ class _MobileSpaceTabState extends State<MobileSpaceTab>
listener: (context, state) {
final lastCreatedPage = state.lastCreatedRootView;
if (lastCreatedPage != null) {
context.pushView(lastCreatedPage);
context.pushView(
lastCreatedPage,
tabs: [
PickerTabType.emoji,
PickerTabType.icon,
PickerTabType.custom,
].map((e) => e.name).toList(),
);
}
},
),
@@ -165,8 +179,6 @@ class _MobileSpaceTabState extends State<MobileSpaceTab>
);
case MobileSpaceTabType.favorites:
return MobileFavoriteSpace(userProfile: widget.userProfile);
default:
throw Exception('Unknown tab type: $tab');
}
}).toList();
}
@@ -180,7 +192,7 @@ class _MobileSpaceTabState extends State<MobileSpaceTab>
if (context.read<SpaceBloc>().state.spaces.isNotEmpty) {
context.read<SpaceBloc>().add(
SpaceEvent.createPage(
name: layout.defaultName,
name: '',
layout: layout,
openAfterCreate: true,
),
@@ -189,7 +201,7 @@ class _MobileSpaceTabState extends State<MobileSpaceTab>
// only support create document in section
context.read<SidebarSectionsBloc>().add(
SidebarSectionsEvent.createRootViewInSection(
name: layout.defaultName,
name: '',
index: 0,
viewSection: FolderSpaceType.public.toViewSectionPB,
),
@@ -139,7 +139,7 @@ class _CreateWorkspaceButton extends StatelessWidget {
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: const Color(0x01717171).withOpacity(0.12),
color: const Color(0x01717171).withValues(alpha: 0.12),
width: 0.8,
),
),
@@ -101,8 +101,6 @@ class WorkspaceMenuMoreOptions extends StatelessWidget {
WorkspaceMenuMoreOption.leave,
),
);
default:
return const Placeholder();
}
}
}
@@ -0,0 +1,253 @@
import 'dart:async';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'mobile_inline_actions_menu_group.dart';
extension _StartWithsSort on List<InlineActionsResult> {
void sortByStartsWithKeyword(String search) => sort(
(a, b) {
final aCount = a.startsWithKeywords
?.where(
(key) => search.toLowerCase().startsWith(key),
)
.length ??
0;
final bCount = b.startsWithKeywords
?.where(
(key) => search.toLowerCase().startsWith(key),
)
.length ??
0;
if (aCount > bCount) {
return -1;
} else if (bCount > aCount) {
return 1;
}
return 0;
},
);
}
const _invalidSearchesAmount = 10;
class MobileInlineActionsHandler extends StatefulWidget {
const MobileInlineActionsHandler({
super.key,
required this.results,
required this.editorState,
required this.menuService,
required this.onDismiss,
required this.style,
required this.service,
this.startCharAmount = 1,
this.startOffset = 0,
this.cancelBySpaceHandler,
});
final List<InlineActionsResult> results;
final EditorState editorState;
final InlineActionsMenuService menuService;
final VoidCallback onDismiss;
final InlineActionsMenuStyle style;
final int startCharAmount;
final InlineActionsService service;
final bool Function()? cancelBySpaceHandler;
final int startOffset;
@override
State<MobileInlineActionsHandler> createState() =>
_MobileInlineActionsHandlerState();
}
class _MobileInlineActionsHandlerState
extends State<MobileInlineActionsHandler> {
final _focusNode =
FocusNode(debugLabel: 'mobile_inline_actions_menu_handler');
late List<InlineActionsResult> results = widget.results;
int invalidCounter = 0;
late int startOffset;
String _search = '';
set search(String search) {
_search = search;
_doSearch();
}
Future<void> _doSearch() async {
final List<InlineActionsResult> newResults = [];
for (final handler in widget.service.handlers) {
final group = await handler.search(_search);
if (group.results.isNotEmpty) {
newResults.add(group);
}
}
invalidCounter = results.every((group) => group.results.isEmpty)
? invalidCounter + 1
: 0;
if (invalidCounter >= _invalidSearchesAmount) {
widget.onDismiss();
// Workaround to bring focus back to editor
await editorState.updateSelectionWithReason(editorState.selection);
return;
}
_resetSelection();
newResults.sortByStartsWithKeyword(_search);
setState(() => results = newResults);
}
void _resetSelection() {
_selectedGroup = 0;
_selectedIndex = 0;
}
int _selectedGroup = 0;
int _selectedIndex = 0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback(
(_) => _focusNode.requestFocus(),
);
startOffset = editorState.selection?.endIndex ?? 0;
keepEditorFocusNotifier.increase();
editorState.selectionNotifier.addListener(onSelectionChanged);
}
@override
void dispose() {
editorState.selectionNotifier.removeListener(onSelectionChanged);
_focusNode.dispose();
keepEditorFocusNotifier.decrease();
super.dispose();
}
@override
Widget build(BuildContext context) {
final width = editorState.renderBox!.size.width - 24 * 2;
return Focus(
focusNode: _focusNode,
child: Container(
constraints: BoxConstraints(
maxHeight: 192,
minWidth: width,
maxWidth: width,
),
margin: EdgeInsets.symmetric(horizontal: 24.0),
decoration: BoxDecoration(
color: widget.style.backgroundColor,
borderRadius: BorderRadius.circular(6.0),
boxShadow: [
BoxShadow(
blurRadius: 5,
spreadRadius: 1,
color: Colors.black.withValues(alpha: 0.1),
),
],
),
child: noResults
? SizedBox(
width: 150,
child: FlowyText.regular(
LocaleKeys.inlineActions_noResults.tr(),
),
)
: SingleChildScrollView(
physics: const ClampingScrollPhysics(),
child: Material(
color: Colors.transparent,
child: Padding(
padding: EdgeInsets.all(6.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: results
.where((g) => g.results.isNotEmpty)
.mapIndexed(
(index, group) => MobileInlineActionsGroup(
result: group,
editorState: editorState,
menuService: menuService,
style: widget.style,
onSelected: widget.onDismiss,
startOffset: startOffset - widget.startCharAmount,
endOffset:
_search.length + widget.startCharAmount,
isLastGroup: index == results.length - 1,
isGroupSelected: _selectedGroup == index,
selectedIndex: _selectedIndex,
onPreSelect: (int value) {
setState(() {
_selectedGroup = index;
_selectedIndex = value;
});
},
),
)
.toList(),
),
),
),
),
),
);
}
bool get noResults =>
results.isEmpty || results.every((e) => e.results.isEmpty);
int get groupLength => results.length;
int lengthOfGroup(int index) =>
results.length > index ? results[index].results.length : -1;
InlineActionsMenuItem handlerOf(int groupIndex, int handlerIndex) =>
results[groupIndex].results[handlerIndex];
EditorState get editorState => widget.editorState;
InlineActionsMenuService get menuService => widget.menuService;
void onSelectionChanged() {
final selection = editorState.selection;
if (selection == null) {
menuService.dismiss();
return;
}
if (!selection.isCollapsed) {
menuService.dismiss();
return;
}
final startOffset = widget.startOffset;
final endOffset = selection.end.offset;
if (endOffset < startOffset) {
menuService.dismiss();
return;
}
final node = editorState.getNodeAtPath(selection.start.path);
final text = node?.delta?.toPlainText() ?? '';
final search = text.substring(startOffset, endOffset);
this.search = search;
}
}
@@ -0,0 +1,151 @@
import 'dart:async';
import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_service.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/material.dart';
import 'mobile_inline_actions_handler.dart';
class MobileInlineActionsMenu extends InlineActionsMenuService {
MobileInlineActionsMenu({
required this.context,
required this.editorState,
required this.initialResults,
required this.style,
required this.service,
this.startCharAmount = 1,
this.cancelBySpaceHandler,
});
final BuildContext context;
final EditorState editorState;
final List<InlineActionsResult> initialResults;
final bool Function()? cancelBySpaceHandler;
final InlineActionsService service;
@override
final InlineActionsMenuStyle style;
final int startCharAmount;
OverlayEntry? _menuEntry;
@override
void dismiss() {
if (_menuEntry != null) {
editorState.service.keyboardService?.enable();
editorState.service.scrollService?.enable();
}
_menuEntry?.remove();
_menuEntry = null;
}
@override
Future<void> show() {
final completer = Completer<void>();
WidgetsBinding.instance.addPostFrameCallback((timeStamp) {
_show();
completer.complete();
});
return completer.future;
}
void _show() {
final selectionRects = editorState.selectionRects();
if (selectionRects.isEmpty) {
return;
}
const double menuHeight = 192.0;
const Offset menuOffset = Offset(0, 10);
final Offset editorOffset =
editorState.renderBox?.localToGlobal(Offset.zero) ?? Offset.zero;
final Size editorSize = editorState.renderBox!.size;
// Default to opening the overlay below
Alignment alignment = Alignment.topLeft;
final firstRect = selectionRects.first;
Offset offset = firstRect.bottomRight + menuOffset;
// Show above
if (offset.dy + menuHeight >= editorOffset.dy + editorSize.height) {
offset = firstRect.topRight - menuOffset;
alignment = Alignment.bottomLeft;
offset = Offset(
offset.dx,
MediaQuery.of(context).size.height - offset.dy,
);
}
final (left, top, right, bottom) = _getPosition(alignment, offset);
_menuEntry = OverlayEntry(
builder: (context) => SizedBox(
width: editorSize.width,
height: editorSize.height,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: dismiss,
child: Stack(
children: [
Positioned(
top: top,
bottom: bottom,
left: left,
right: right,
child: MobileInlineActionsHandler(
service: service,
results: initialResults,
editorState: editorState,
menuService: this,
onDismiss: dismiss,
style: style,
startCharAmount: startCharAmount,
cancelBySpaceHandler: cancelBySpaceHandler,
startOffset: editorState.selection?.start.offset ?? 0,
),
),
],
),
),
),
);
Overlay.of(context).insert(_menuEntry!);
editorState.service.keyboardService?.disable(showCursor: true);
editorState.service.scrollService?.disable();
}
(double? left, double? top, double? right, double? bottom) _getPosition(
Alignment alignment,
Offset offset,
) {
double? left, top, right, bottom;
switch (alignment) {
case Alignment.topLeft:
left = 0;
top = offset.dy;
break;
case Alignment.bottomLeft:
left = 0;
bottom = offset.dy;
break;
case Alignment.topRight:
right = offset.dx;
top = offset.dy;
break;
case Alignment.bottomRight:
right = offset.dx;
bottom = offset.dy;
break;
}
return (left, top, right, bottom);
}
}
@@ -0,0 +1,152 @@
import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_result.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:collection/collection.dart';
import 'package:flowy_infra_ui/style_widget/button.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
class MobileInlineActionsGroup extends StatelessWidget {
const MobileInlineActionsGroup({
super.key,
required this.result,
required this.editorState,
required this.menuService,
required this.style,
required this.onSelected,
required this.startOffset,
required this.endOffset,
required this.onPreSelect,
this.isLastGroup = false,
this.isGroupSelected = false,
this.selectedIndex = 0,
});
final InlineActionsResult result;
final EditorState editorState;
final InlineActionsMenuService menuService;
final InlineActionsMenuStyle style;
final VoidCallback onSelected;
final ValueChanged<int> onPreSelect;
final int startOffset;
final int endOffset;
final bool isLastGroup;
final bool isGroupSelected;
final int selectedIndex;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (result.title != null) ...[
SizedBox(
height: 36,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Align(
alignment: Alignment.centerLeft,
child: FlowyText.medium(
result.title!,
color: style.groupTextColor,
fontSize: 12,
),
),
),
),
],
...result.results.mapIndexed(
(index, item) => GestureDetector(
onTapDown: (e) {
onPreSelect.call(index);
},
child: MobileInlineActionsWidget(
item: item,
editorState: editorState,
menuService: menuService,
isSelected: isGroupSelected && index == selectedIndex,
style: style,
onSelected: onSelected,
startOffset: startOffset,
endOffset: endOffset,
),
),
),
],
);
}
}
class MobileInlineActionsWidget extends StatelessWidget {
const MobileInlineActionsWidget({
super.key,
required this.item,
required this.editorState,
required this.menuService,
required this.isSelected,
required this.style,
required this.onSelected,
required this.startOffset,
required this.endOffset,
});
final InlineActionsMenuItem item;
final EditorState editorState;
final InlineActionsMenuService menuService;
final bool isSelected;
final InlineActionsMenuStyle style;
final VoidCallback onSelected;
final int startOffset;
final int endOffset;
@override
Widget build(BuildContext context) {
final hasIcon = item.icon != null;
return Container(
height: 36,
decoration: BoxDecoration(
color: isSelected ? style.menuItemSelectedColor : null,
borderRadius: BorderRadius.circular(6.0),
),
child: FlowyButton(
expand: true,
isSelected: isSelected,
text: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12),
child: Align(
alignment: Alignment.centerLeft,
child: Row(
children: [
if (hasIcon) ...[
item.icon!.call(isSelected),
SizedBox(width: 12),
],
Flexible(
child: FlowyText.regular(
item.label,
figmaLineHeight: 18,
overflow: TextOverflow.ellipsis,
fontSize: 16,
color: style.menuItemSelectedTextColor,
),
),
],
),
),
),
onTap: () => _onPressed(context),
),
);
}
void _onPressed(BuildContext context) {
onSelected();
item.onSelected?.call(
context,
editorState,
menuService,
(startOffset, endOffset),
);
}
}
Loaded 100 of 583 files, more files were not shown because too many files have changed in this diff. Show more