Compare commits

...
88 Commits
Author SHA1 Message Date
c3bfae866b chore: bump version 0.7.7 (#6951)
* feat: add same delete design in database (#6620)

* add same delete design in database

* fix: remove padding when widget is null or function is null

* fix(desktop): resize sidebar menu regression (#6897)

* fix: initial ai chat load (#6920)

* fix: unable to open local file using afLaunchUrl function (#6927)

* fix: unable to open local file using afLaunchUrl function

* chore: use the latest api to open the local file

* chore: use the latest api to open the local file

* chore: use the latest api to open the local file

* test: add local paht regex test

* fix(flutter): implement mention date transaction handler (#6933)

* fix: implement mention date transaction handler

* test: add integration tests

* chore: code cleanup

* chore: early return if null delta

* fix(flutter_desktop): clicking on empty space when editing a cell sho… (#6949)

* fix(flutter_desktop): clicking on empty space when editing a cell shouldn't close event card

* test: fix integration tests

* chore: bump version 0.7.7

* fix: hotfix issues for v0.7.7 (#6948)

* fix: include link preview block and file block in exported markdown

* test: include link preview block and file block in exported markdown

* chore: remove unused logs

* chore: update editor version

* fix: "+" menu should be close after pressing space

* test: cancel inline page reference menu by space

* chore: update editor version

* chore: remove unused logs

---------

Co-authored-by: Ahad Patel <69256193+Ahad-patel@users.noreply.github.com>
Co-authored-by: Richard Shiue <71320345+richardshiue@users.noreply.github.com>
2024-12-09 17:50:45 +08:00
Lucas.Xu d3c7d4b1d2 Revert "fix: hotfix issues for v0.7.7 (#6948)"
This reverts commit 8726df703e.
2024-12-09 16:21:12 +08:00
Lucas 8726df703e fix: hotfix issues for v0.7.7 (#6948)
* fix: include link preview block and file block in exported markdown

* test: include link preview block and file block in exported markdown

* chore: remove unused logs

* chore: update editor version

* fix: "+" menu should be close after pressing space

* test: cancel inline page reference menu by space

* chore: update editor version

* chore: remove unused logs
2024-12-09 16:20:36 +08:00
nathan 3fdd19f7a2 chore: do not pull object when object ids is empty 2024-12-09 15:17:11 +08:00
Lucas.Xu fb132cb4b8 chore: upgrade rust version to 1.80.1 2024-12-03 21:06:51 +08:00
Lucas e32c584b3b fix: remove extra padding in table on mobile (#6915) 2024-12-03 20:41:52 +08:00
Lucas 64e4416f54 chore: release version 0.7.6 (#6910)
* chore: update changelog

* feat: support displaying text color, background color and font item in table cell

* fix: integration test and unit test
2024-12-03 17:27:59 +08:00
Nathan.fooo ca195887e0 chore: bump collab (#6914)
* chore: bump collab

* chore: bump collab
2024-12-03 17:05:40 +08:00
Kilu.He f9a5458b94 feat: support simple table (#6911) 2024-12-03 15:33:51 +08:00
Lucas 244d072a65 fix: pressing enter on a collapsible list toggle adds an additional new line (#6909)
* fix: pressing Enter on a closed toggle list adds an additional new line

* test: pressing Enter on a closed toggle list adds an additional new line

* chore: upgrade appflowy_editor

* chore: rename table align
2024-12-03 13:56:15 +08:00
Lucas d9bc97e012 chore: remove rename dialog logic (#6906) 2024-12-02 20:01:40 +08:00
LucasandRichard Shiue e7491e5182 feat: simple table issues (#6871)
* fix: disable cut command in table cell

* feat: only keep the table cell content when coping text from table

* fix: focus on the first cell after inserting table

* test: focus on the first cell after inserting table

* feat: highlight the cell when editing

* test: highlight the cell when editing

* fix: creating a new row makes a cursor appear for a fraction of a second

* fix: add 4px between scroll bar and add row button

* chore: rename simple table components

* fix: select all in table cell block

* test: select all in table cell block

* feat: disable two-fingers resize in table cell

* feat: includ table when exporting markdown

* test: include table when exporting markdown

* feat: optimize add row button render logic

* chore: optimize hover button render logic

* fix: column button is not clickable

* fix: theme assertion

* feat: improve hovering logic

* fix: selection issue in table

* fix(flutter_desktop): popover conflicts on simple table

* feat: support table markdown import

* test: table cell isEditing test

* test: select all in table test

* fix: popover conflict in table action menu

* test: insert row, column, row/column in table

* test: delete row, column, row/column in table

* test: enable header column and header row in table

* test: duplicate/insert left/right/above/below in table

* chore: duplicate table in optin menu

* fix: integraion test

---------

Co-authored-by: Richard Shiue <71320345+richardshiue@users.noreply.github.com>
2024-12-02 17:50:32 +08:00
promto-c 550b8835c6 chore: update th-TH translations (#6898) 2024-12-02 14:42:40 +08:00
Mathias Mogensen 1851721d9a fix: tabs lr 0.7.6 (#6899)
* fix: tabs improvements launch review

* test: add tests for pin/unpin feature

* fix: failing test + cleanup
2024-12-02 00:52:36 +01:00
Mathias Mogensen 603d65a790 fix: mobile camera android permission (#6900) 2024-12-02 00:49:54 +01:00
Richard Shiue 0cba3f9e3f fix(mobile): lost initial scroll position on empty ai chat page (#6895) 2024-12-01 13:18:20 +08:00
promto-c 81960a7f05 chore(i18n): add th-TH translations (#6891)
* 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 🐦
2024-11-30 21:17:30 +08:00
Lucas 62c4a8c541 fix: unable to render code block in ai writer block (#6883)
* fix: unable to render code block in ai writer block

* feat: enter to generate ai result
2024-11-29 18:49:47 +08:00
Richard Shiueandnathan 510d8357ee fix: RwLock race condition during group controller instantiation (#6860)
* chore: code cleanup

* fix: view editor used during initialization

* fix: quick and dirty hack job

* test: add test

* chore: don't create separate mut var

* chore: docs

* fix: uninitialized group controllers

* chore: remove group

* chore: fix test

---------

Co-authored-by: nathan <nathan@appflowy.io>
2024-11-28 17:26:13 +08:00
Mathias Mogensen 4c6f6f14f3 fix: add camera permission for iOS (#6872) 2024-11-28 10:17:24 +01:00
Lucas 27af57289f chore: update version to 0.7.6 (#6870) 2024-11-28 14:04:22 +08:00
Lucas 17aa8c9036 feat: simple table redesign (#6831)
* feat: define the simple table block data strucuture

* fix: close keyboard when uploading file

* feat: integrate a simple table

* feat: support selectable mixin for table

* feat: render border for table

* feat: support add column/row

* feat: support add column and row

* feat: support add column and row button

* feat: support table border and cell border

* feat: use context to manage hovering status

* feat: add hover status for cell

* feat: add hover row action

* feat: support row/column hover action

* feat: support table more actions

* feat: support insert left/right/above/below actions

* feat: add reorder button

* feat: clear content in table

* feat: support header column and header row

* chore: refactor the table more actions

* feat: render border in cell

* feat: add align button

* feat: add align menu

* fix: unable to delete last column

* feat: support drag to resize

* feat: support resizing column and set limitation

* fix: unable to resize

* feat: support table cell align

* fix: type error

* feat: support table cell background color

* fix: enable header column/row bug

* feat: support table background color

* feat: support duplicate table without background color

* feat: support duplicate with column color

* feat: update column width

* feat: move insert operations to a new file

* fix: row more action position is not correct

* fix: delete row error

* feat: support highlight the border after selecting a column/row

* feat: support mapping the insertion color/align

* feat: support mapping the duplication color/align

* fix: delete column error

* feat: support duplication map

* feat: support duplicating column with color and align

* feat: support duplicating row with color and align

* chore: optimize the table_map_operations

* feat: support clear content / header / style operations

* chore: remove null value in table attributes

* fix: delete row issues

* fix: delete column issues

* fix: unable to clear content

* feat: support arrow key shortcuts

* feat: support tab shortcuts

* feat: support backspace key shortcuts

* feat: support table block selection

* feat: set text in header column / row to bold

* fix: flutter analyze

* chore: enable debug log in table

* test: simple table insert operation

* test: simple table delete operation

* test: simple table header operation

* test: simple table style operation - column width

* test: simple table style operation - background color and align

* test: simple table content operation - clear content

* test: simple table operations - insertion, deletion and duplication

* test: simple table operations - duplicate with background and align

* fix: hide the delete button if there is only one column or one row

* test: add integration test and i18n

* chore: update translations

* fix: drag visual issue

* fix: exclude the popup menu in table more actions

* feat: only show the add button when hovering on the edge
2024-11-28 13:58:00 +08:00
Mathias Mogensen c910bda534 fix: minor improvements to media interactions (#6867)
* fix: minor improvements to media interactions

* test: remove redundant test
2024-11-28 02:13:06 +01:00
Mathias Mogensen 8d01b96281 feat: pin tabs (#6869) 2024-11-28 01:20:47 +01:00
Mathias Mogensen 55f12d5358 feat: attachment mobile toolbar item (#6868) 2024-11-28 01:20:30 +01:00
Mathias Mogensen 018c146d72 fix: built in database height issue (#6866)
* fix: built in database height issue

* fix: use shrinkWrap to decide height

* fix: pageview for lazy rendering
2024-11-27 15:37:45 +01:00
Lucas 068ac0e992 feat: support markdown syntax in ai writer block (#6864)
* feat: support markdown syntax in ai writer block

* feat: support nested list

* test: add markdown text robot tests

* feat: support in-memory update in ai-writer block

* feat: support markdown in ask ai block

* feat: render ai result with appflowy_editor

* feat: support markdown syntax in ask ai block

* fix: selection after replace
2024-11-27 21:19:24 +08:00
Lucas b036129efb fix: multiple select menu display issue (#6861)
* chore: set the minimum windows size to 640

* feat: optimize text insertion

* fix: unable to scroll down in multiple select options
2024-11-27 09:56:28 +08:00
Richard Shiue b3c8eb151a chore: improve ai chat errors (#6851)
* chore: move margins to layout define

* chore: related question alignment

* chore: adjust vertical spacing around sources

* chore: scroll to bottom animation improvement

* chore: improve ai chat error handling
2024-11-25 18:45:59 +08:00
Lucas e86d584ea7 feat: support skipping in-memory update transaction (#6856)
* feat: support skipping in-memory update transaction

* fix: flutter analyze

* feat: add sentence mode

* test: support skipping in-memory update transaction

* test: add sentence mode

* test: add sentence mode (2)

* chore: set enableDocumentInternalLog to false

* fix: integration test

* fix: integration test
2024-11-25 17:55:15 +08:00
Richard Shiue e0226e54a5 chore: minor ai chat improvements (#6855)
* chore: animation alignment improvement

* fix: missing context in drop zone

* chore: increase minimum padding between chat and edge of screen

* chore: unify padding for ai message loading
2024-11-25 10:43:08 +08:00
Lucas bde1457524 feat: support click to create content inside empty toggle list (#6854)
* feat: support click to create content inside empty toggle list

* test: support click to create content inside empty toggle list

* fix: toggle list rtl issue

* chore: optimize cover title request node logic
2024-11-25 10:39:23 +08:00
Lucas 2ad2a79bd0 fix: macOS error 50 on uploading files (#6853) 2024-11-25 10:39:15 +08:00
Richard Shiue f013bb9d6e chore: re-add chat message animations (#6850)
* chore: re-add chat message animation

* chore: bump up message vertical padding
2024-11-24 22:43:45 +08:00
Nathan.fooo 1b4a723500 chore: only fetch billing info when current user is the workspace owner (#6847) 2024-11-23 13:25:42 +08:00
Nathan.fooo b5d2af3371 chore: parse chat response (#6843)
* chore: parse chat response

* chore: remove unrelated message

* chore: update readme
2024-11-23 09:48:24 +08:00
Nathan.fooo 4205a34f04 chore: delete upload task when upload size exceed limit (#6841)
* chore: delete upload tasks

* test: update

* chore: bump client api

* chore: fix test
2024-11-22 18:18:24 +08:00
Richard Shiue e86a9d697c chore: simplify chat user message bloc and widgets (#6836) 2024-11-20 13:13:38 +03:00
Richard Shiue f82dabcc75 chore: bump flutter chat ui version (#6835) 2024-11-20 15:47:35 +08:00
Mathias Mogensen 09717d92c5 chore: inno setup ignore version on .exe (#6745) 2024-11-20 10:03:57 +08:00
Lucas a0d8711d5c fix: close other tabs before switching workspace (#6830)
* fix: close other tabs before swtching workspace

* test: close other tabs before switching workspace

* chore: update release note

* test: close other tabs before switching workspace
2024-11-19 15:41:55 +08:00
Lucas c24b68481d feat: support creating subpage block in row detail page (#6824)
* feat: support creating subpage block in row detail page

* feat: hide the row page from sidebar

* test: support creating a sub-page block in row detail page

* fix: update drag block logic

* feat: support toggle heading in outline

* test: add toggle headings show in outline block test

* fix: unable to get focus when opening subpage from card
2024-11-19 09:58:04 +08:00
Richard Shiue df7fe9750d feat: wrap code block lines in AI chat (#6828) 2024-11-18 21:52:51 +08:00
Mathias Mogensen 9c22bb4fed fix: tab change speed + remove favorite option (#6819) 2024-11-18 17:11:39 +08:00
nathan 3803cf2506 chore: fix test 2024-11-18 16:47:45 +08:00
Nathan.fooo 51a11fbebd chore: fix upload file limit (#6825)
* chore: fix file upload limit

* chore: update test
2024-11-18 16:21:47 +08:00
Kilu.He cc7476c75b fix: add new row cover (#6826)
* fix: add new row cover

* fix: add default callout icon
2024-11-18 16:09:31 +08:00
Lucas 8a7cedd5b4 fix: cannot paste text into the title of the doc (#6821)
* fix: cannot paste text into the title of the doc

* test: add paste in title test
2024-11-18 14:44:02 +08:00
Lucas 7e528cf260 feat: support editing path name on mobile (#6798)
* feat: support editing path name on mobile

* chore: format code

* chore: update publish toast

* feat: optimize the toast for mobile more actions menu

* feat: optimize update path name logic

* test: add update path name test

* fix: integration test
2024-11-18 10:33:51 +08:00
Arya Pratap Singh 8a39ff0580 fix: turn into menu naming inconsistency issues (#6817) 2024-11-18 10:33:23 +08:00
Richard Shiue 710fbbdc08 fix: ai chat error message background color (#6810) 2024-11-17 16:04:43 +08:00
Richard Shiue 6c52896f99 chore(mobile): remove ai chat sample question autoscrolling behavior (#6793) 2024-11-17 15:36:16 +08:00
Kilu.He f526df98c7 fix: add template button to publish page (#6809) 2024-11-17 14:58:28 +08:00
Richard Shiue 521d74e082 chore: more ai ui fixes (#6799)
* chore: more ai ui fixes

* fix: messages arriving out of order

* chore: don't drop focus when clicking on sample questions
2024-11-15 17:31:51 +08:00
Lucas 225683562b fix: 0.7.4 launch review issues on mobile (#6795)
* feat: expand the hit test area for more button in space menu

* fix: contrast issue for the delte dialog text

* fix: stay in space menu after deleting a space

* fix: hide padding when space icon list is scrolled down

* feat: expand the hit test area for toggle button

* feat: remove open workspace success toast

* chore: update translations

* test: stay in space menu after deleting a space

* chore: enable android test

* fix: integration tests
2024-11-15 16:19:02 +08:00
Richard Shiue 9138787f86 chore: deprecate third party markdown (#6794) 2024-11-15 15:03:12 +08:00
Lucas 6ffb9e4d0f fix: launch 0.7.4 review issues on desktop (#6792)
* chore: enable document test runner

* fix: double menu showing in workspace menu

* feat: only display copy in ai message

* test: add test

* test: fix integration test

* fix: rust ci
2024-11-15 11:12:27 +08:00
Richard Shiue bced9327b1 feat: AI chat UI improvements (#6760)
* chore: fix typo

* chore: redundant check for cloud and use guard clause

* feat: improve ai prompt input ui

* feat: improve ai chat welcome page

* feat: improve loading status indicator

* feat: improve chat bubble avatars

* chore: clean up chat theme

* feat: improve sizing, padding and text styles

* feat: improve sugested follow up question style

* feat: improve error message style

* feat: improve mobile chat ui

* feat: add superman

* fix: cannot send new questions after 2000 character limit reached

* feat: ai response action bottom sheet

* chore: code cleanup

* feat: improve look of side panel

* chore: BIG ANNOUNCEMENT CITY

* fix: test session issues

* fix: launch review issues
2024-11-15 00:26:37 +08:00
Richard Shiue 8120656198 fix(mobile): selecting date from cupertino date widget resets time (#6791) 2024-11-15 00:26:17 +08:00
Richard ShiueandMathias Mogensen 941b7cf04c fix(flutter_desktop): document search (#6669)
* fix: double dispose on find menu

* fix: empty query not resetting search service

* fix: input focus getting lost after clicking button or pressing enter

* chore: remove unused focus node and text controller

* chore: bump appflowy editor

* chore: code cleanup

* chore: fix focus getting lost on submission

* fix: next match focuses on title after jumping

* chore: bump appflowy editor

* revert: unnecessary changes to FlowyFormTextInput

* fix: title requesting focus unexpectedly

* Update frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/header/cover_title.dart

Co-authored-by: Mathias Mogensen <42929161+Xazin@users.noreply.github.com>

* chore: merge conflicts

* chore: code cleanup

* test: add integration test

* fix: show replace menu icon color in dark mode

---------

Co-authored-by: Mathias Mogensen <42929161+Xazin@users.noreply.github.com>
2024-11-14 22:02:34 +08:00
Mathias Mogensen 1952ef0853 fix: tab menu and tabbar improvements (#6785)
* fix: tab menu and tabbar improvements

* chore: update appflowy_editor

* test: tab menu test

* test: fix test after refactor
2024-11-14 06:55:09 +01:00
Lucas d9f2d14e99 chore: bump version 0.7.4 (#6787) 2024-11-14 13:36:13 +08:00
Lucas 555d08e8ce feat: add toggle heading in plus menu on mobile (#6784)
* chore: add logs in space bloc

* feat: add toggle headings in plus menu

* test: add toggle heading block test

* test: toogle heading 1 block test

* test: add toggle heading selection test

* fix: toggle headings test

* chore: update new toggle heading icons
2024-11-14 13:34:24 +08:00
Mathias Mogensen 76009613fe fix: mobile file upload menu (#6786) 2024-11-14 06:10:03 +01:00
复予 651938a322 chore: update zh-CN translations (#6717)
* 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 🐦
2024-11-14 09:31:25 +08:00
Lucas b86011aa94 feat: optimize the font menu speed (#6780) 2024-11-14 09:19:35 +08:00
nathan cf240a392b chore: replace with appflowy cloud main branch id 2024-11-14 00:56:51 +08:00
Nathan.fooo 28530722bc chore: bump client api (#6781)
* chore: bump client api

* chore: fix member count serde error
2024-11-14 00:51:07 +08:00
ZackandLucas.Xu 3b304747f2 feat: last publish name (#6766)
* chore: wip update client api

* feat: add unpublished information

* feat: keep the last publish name

* test: add re-publish test

* test: add empty name test

* chore: update cloud version to 0.7.6

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2024-11-13 20:36:35 +08:00
Lucas cd3be696dc feat: optimize the workspace menu loading speed (#6758)
* feat: optimize the workspace menu loading speed

* chore: add workspace menu animation

* feat: optimize workspace menu loading speed on mobile

* test: workspace member count test

* fix: rust ci
2024-11-13 20:36:15 +08:00
Lucas 57933736ea feat: support embed webp (#6775)
* feat: support uploading webp

* feat: support jpeg and bmp image format
2024-11-13 17:16:48 +08:00
Lucas a46550c250 feat: support managing a space on mobile (#6764)
* feat: support duplicating and deleting a space on mobile

* feat: support editing a space on mobile

* feat: support renaming space

* feat: add more space icons

* fix: unable to rename space

* fix: code review

* chore: remove ingore import flag for testing

* test: add create space test

* chore: enable cloud test

* test: fix space test

* test: add rename/duplicate/delete tests
2024-11-13 11:10:15 +08:00
Lucas eed3f489c5 chore: update template icon (#6767) 2024-11-12 16:58:41 +08:00
Lucas d268f8c715 feat: integrate codemagic for testing ios/android (#6719)
* fix: pages overflow when selecting homepage

* feat: integrate codemagic

* Revert "fix: pages overflow when selecting homepage"

This reverts commit 156882a9a7.

* chore: try to fix build

* chore: remove flutter analyuze

* chore: launch ios simulator

* fix: flutter version

* fix: integration tests on mobile

* fix: mobile tests

* test: fix page style test

* chore: enable ios ci

* chore: update codemagic token

* chore: update app_id
2024-11-12 09:06:30 +08:00
Richard Shiue 3cd26cca35 fix(flutter_desktop): hidden kanban group name overflow (#6763) 2024-11-11 17:21:24 +08:00
Zack 6ad303583b feat: include member count in get_workspace API (#6747)
* feat: add member count for workspace

* fix: sqlite migration
2024-11-08 16:01:39 +08:00
Lucas e527a1843e chore: bump version 0.7.3 (#6743)
* chore: update changelog

* feat: add 'custom namespace' in plan description
2024-11-08 10:13:17 +08:00
Richard Shiue 3f8eb70ff3 chore: bump collab (#6746) 2024-11-08 02:33:28 +08:00
Richard ShiueandMathias Mogensen bd7976d005 fix: v0.7.3 additional launch review session issues (#6730)
* fix: focusedDay on is range toggle

* fix: attempt to fix date reminder

* chore: alignment issue on windows

* chore: use onFailure

* chore: default for set reminder

* chore: code style

* fix: add reminder bug

* fix: checklist item alignment

* fix: date cell reminders

* chore: regard cell datetime as correct

* fix: reminder creation date

* chore: bump collab that fixes reminders

* test: add date cell and reminder bloc test

* chore: bump collab

* chore: revert visual density change

* fix: submitting date time text field makes it flash

* fix: improve behavior of phantom checklist item

---------

Co-authored-by: Mathias Mogensen <mathias@appflowy.io>
2024-11-07 18:33:12 +08:00
Lucas 4e1532af3e fix: application window is not visible on Windows (#6740)
* fix: try to fix the windows not visible issue

* chore: update win32_window

* chore: adjust the new api
2024-11-07 15:55:38 +08:00
Mathias Mogensen 97999aee44 fix: block option interceptor (#6739)
* fix: use unique key in interceptor

* test: add block option interaction tests
2024-11-07 09:47:47 +08:00
Nathan.fooo 6785104c3a chore: send notification for file upload state (#6738)
* chore: send notification for file upload state

* chore: fix test
2024-11-07 00:00:54 +08:00
Mathias Mogensen 193c8242e2 fix: mobile media cleanup (#6733) 2024-11-06 08:03:20 +01:00
Kilu.He 59eda5e038 fix: modified invitation page text (#6734) 2024-11-06 13:36:27 +08:00
Nathan.fooo 3c8de8a52c chore: update upload file logs (#6735) 2024-11-06 11:18:40 +08:00
Zack d38a5f38e3 chore: update dep (#6727) 2024-11-06 09:12:16 +08:00
Lucas fe4f85a597 fix: align the namespace and toggle shortcuts (#6726)
* chore: update translations, url to URL

* fix: children disapper when using toggle shortcuts

* fix: add tooltip for namespace button

* test: add toggle shortcut test
2024-11-06 09:08:59 +08:00
Mathias Mogensen 0a1af4e61f fix: 0.7.3 improvements (#6731)
* fix: recheck workspace usage on workspace changed

* fix: rename insert page item
2024-11-06 00:50:27 +01:00
523 changed files with 24986 additions and 8562 deletions

No files matched your search

+179 -108
View File
@@ -1,126 +1,197 @@
# name: Android CI
name: Android CI
# on:
# push:
# branches:
# - "main"
# paths:
# - ".github/workflows/mobile_ci.yaml"
# - "frontend/**"
# - "!frontend/appflowy_tauri/**"
on:
push:
branches:
- "main"
paths:
- ".github/workflows/mobile_ci.yaml"
- "frontend/**"
- "!frontend/appflowy_tauri/**"
# pull_request:
# branches:
# - "main"
# paths:
# - ".github/workflows/mobile_ci.yaml"
# - "frontend/**"
# - "!frontend/appflowy_tauri/**"
pull_request:
branches:
- "main"
paths:
- ".github/workflows/mobile_ci.yaml"
- "frontend/**"
- "!frontend/appflowy_tauri/**"
# env:
# CARGO_TERM_COLOR: always
# FLUTTER_VERSION: "3.22.0"
# RUST_TOOLCHAIN: "1.77.2"
# CARGO_MAKE_VERSION: "0.36.6"
env:
CARGO_TERM_COLOR: always
FLUTTER_VERSION: "3.22.3"
RUST_TOOLCHAIN: "1.80.1"
CARGO_MAKE_VERSION: "0.37.18"
CLOUD_VERSION: 0.6.54-amd64
# concurrency:
# group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
# cancel-in-progress: true
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
cancel-in-progress: true
# jobs:
# build:
# if: github.event.pull_request.draft != true
# strategy:
# fail-fast: true
# matrix:
# os: [macos-14]
# runs-on: ${{ matrix.os }}
jobs:
build:
if: github.event.pull_request.draft != true
strategy:
fail-fast: true
matrix:
os: [ubuntu-latest]
runs-on: ${{ matrix.os }}
# steps:
# - name: Check storage space
# run: df -h
steps:
- name: Check storage space
run:
df -h
# # the following step is required to avoid running out of space
# - name: Maximize build space
# if: matrix.os == 'ubuntu-latest'
# run: |
# sudo rm -rf /usr/share/dotnet
# sudo rm -rf /opt/ghc
# sudo rm -rf "/usr/local/share/boost"
# sudo rm -rf "$AGENT_TOOLSDIRECTORY"
# sudo docker image prune --all --force
# sudo rm -rf /opt/hostedtoolcache/codeQL
# sudo rm -rf ${GITHUB_WORKSPACE}/.git
# sudo rm -rf $ANDROID_HOME/ndk
# the following step is required to avoid running out of space
- name: Maximize build space
if: matrix.os == 'ubuntu-latest'
run: |
sudo rm -rf /usr/share/dotnet
sudo rm -rf /opt/ghc
sudo rm -rf "/usr/local/share/boost"
sudo rm -rf "$AGENT_TOOLSDIRECTORY"
sudo docker image prune --all --force
sudo rm -rf /opt/hostedtoolcache/codeQL
sudo rm -rf ${GITHUB_WORKSPACE}/.git
# - name: Check storage space
# run: df -h
- name: Check storage space
run: df -h
# - name: Checkout source code
# uses: actions/checkout@v4
- name: Checkout appflowy cloud code
uses: actions/checkout@v4
with:
repository: AppFlowy-IO/AppFlowy-Cloud
path: AppFlowy-Cloud
# - uses: actions/setup-java@v4
# with:
# distribution: temurin
# java-version: 11
- name: Prepare appflowy cloud env
working-directory: AppFlowy-Cloud
run: |
# log level
cp deploy.env .env
sed -i 's|RUST_LOG=.*|RUST_LOG=trace|' .env
sed -i 's/GOTRUE_EXTERNAL_GOOGLE_ENABLED=.*/GOTRUE_EXTERNAL_GOOGLE_ENABLED=true/' .env
sed -i 's|GOTRUE_MAILER_AUTOCONFIRM=.*|GOTRUE_MAILER_AUTOCONFIRM=true|' .env
sed -i 's|API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://localhost|' .env
# - name: Install Rust toolchain
# id: rust_toolchain
# uses: actions-rs/toolchain@v1
# with:
# toolchain: ${{ env.RUST_TOOLCHAIN }}
# override: true
# profile: minimal
- name: Run Docker-Compose
working-directory: AppFlowy-Cloud
env:
APPFLOWY_CLOUD_VERSION: ${{ env.CLOUD_VERSION }}
APPFLOWY_HISTORY_VERSION: ${{ env.CLOUD_VERSION }}
APPFLOWY_WORKER_VERSION: ${{ env.CLOUD_VERSION }}
run: |
container_id=$(docker ps --filter name=appflowy-cloud-appflowy_cloud-1 -q)
if [ -z "$container_id" ]; then
echo "AppFlowy-Cloud container is not running. Pulling and starting the container..."
docker compose pull
docker compose up -d
echo "Waiting for the container to be ready..."
sleep 10
else
running_image=$(docker inspect --format='{{index .Config.Image}}' "$container_id")
if [ "$running_image" != "appflowy-cloud:$APPFLOWY_CLOUD_VERSION" ]; then
echo "AppFlowy-Cloud is running with an incorrect version. Restarting with the correct version..."
# Remove all containers if any exist
if [ "$(docker ps -aq)" ]; then
docker rm -f $(docker ps -aq)
else
echo "No containers to remove."
fi
# - name: Install flutter
# id: flutter
# uses: subosito/flutter-action@v2
# with:
# channel: "stable"
# flutter-version: ${{ env.FLUTTER_VERSION }}
# Remove all volumes if any exist
if [ "$(docker volume ls -q)" ]; then
docker volume rm $(docker volume ls -q)
else
echo "No volumes to remove."
fi
docker compose pull
docker compose up -d
echo "Waiting for the container to be ready..."
sleep 10
docker ps -a
docker compose logs
else
echo "AppFlowy-Cloud is running with the correct version."
fi
fi
# - uses: gradle/gradle-build-action@v3
# with:
# gradle-version: 7.4.2
- name: Checkout source code
uses: actions/checkout@v4
# - uses: davidB/rust-cargo-make@v1
# with:
# version: "0.36.6"
- uses: actions/setup-java@v4
with:
distribution: temurin
java-version: 11
# - name: Install prerequisites
# working-directory: frontend
# run: |
# rustup target install aarch64-linux-android
# rustup target install x86_64-linux-android
# cargo install --force duckscript_cli
# cargo install cargo-ndk
# if [ "$RUNNER_OS" == "Linux" ]; then
# sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub
# sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list
# sudo apt-get update
# sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev
# sudo apt-get install keybinder-3.0 libnotify-dev
# sudo apt-get install gcc-multilib
# elif [ "$RUNNER_OS" == "Windows" ]; then
# vcpkg integrate install
# elif [ "$RUNNER_OS" == "macOS" ]; then
# echo 'do nothing'
# fi
# cargo make appflowy-flutter-deps-tools
# shell: bash
- name: Install Rust toolchain
id: rust_toolchain
uses: actions-rs/toolchain@v1
with:
toolchain: ${{ env.RUST_TOOLCHAIN }}
override: true
profile: minimal
# - name: Build AppFlowy
# working-directory: frontend
# run: |
# cargo make --profile development-android appflowy-android-dev-ci
- name: Install flutter
id: flutter
uses: subosito/flutter-action@v2
with:
channel: "stable"
flutter-version: ${{ env.FLUTTER_VERSION }}
- uses: gradle/gradle-build-action@v3
with:
gradle-version: 8.10
# - name: Run integration tests
# # https://github.com/ReactiveCircus/android-emulator-runner
# uses: reactivecircus/android-emulator-runner@v2
# with:
# api-level: 32
# arch: arm64-v8a
# disk-size: 2048M
# working-directory: frontend/appflowy_flutter
# script: flutter test integration_test/runner.dart
- uses: davidB/rust-cargo-make@v1
with:
version: ${{ env.CARGO_MAKE_VERSION }}
- name: Install prerequisites
working-directory: frontend
run: |
rustup target install aarch64-linux-android
rustup target install x86_64-linux-android
rustup target add armv7-linux-androideabi
cargo install --force duckscript_cli
cargo install cargo-ndk
if [ "$RUNNER_OS" == "Linux" ]; then
sudo wget -qO /etc/apt/trusted.gpg.d/dart_linux_signing_key.asc https://dl-ssl.google.com/linux/linux_signing_key.pub
sudo wget -qO /etc/apt/sources.list.d/dart_stable.list https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list
sudo apt-get update
sudo apt-get install -y dart curl build-essential libssl-dev clang cmake ninja-build pkg-config libgtk-3-dev
sudo apt-get install keybinder-3.0 libnotify-dev
sudo apt-get install gcc-multilib
elif [ "$RUNNER_OS" == "Windows" ]; then
vcpkg integrate install
elif [ "$RUNNER_OS" == "macOS" ]; then
echo 'do nothing'
fi
cargo make appflowy-flutter-deps-tools
shell: bash
- name: Build AppFlowy
working-directory: frontend
run: |
cargo make --profile development-android appflowy-core-dev-android
cargo make --profile development-android code_generation
cd rust-lib
cargo clean
- name: Enable KVM group perms
run: |
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
sudo udevadm control --reload-rules
sudo udevadm trigger --name-match=kvm
- name: Run integration tests
# https://github.com/ReactiveCircus/android-emulator-runner
uses: reactivecircus/android-emulator-runner@v2
with:
api-level: 33
arch: x86_64
disk-size: 2048M
working-directory: frontend/appflowy_flutter
disable-animations: true
force-avd-creation: false
target: google_apis
script: flutter test integration_test/mobile/cloud/cloud_runner.dart
+1 -1
View File
@@ -346,7 +346,7 @@ jobs:
fail-fast: false
matrix:
os: [ubuntu-latest]
test_number: [1, 2, 3, 4, 5, 6, 7, 8]
test_number: [1, 2, 3, 4, 5, 6, 7, 8, 9]
include:
- os: ubuntu-latest
target: "x86_64-unknown-linux-gnu"
+22 -18
View File
@@ -20,9 +20,8 @@ on:
- "!frontend/appflowy_web_app/**"
env:
FLUTTER_VERSION: "3.22.0"
FLUTTER_VERSION: "3.22.3"
RUST_TOOLCHAIN: "1.80.1"
CLOUD_VERSION: 0.6.51
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -97,21 +96,26 @@ jobs:
cargo make --profile development-ios-arm64-sim appflowy-core-dev-ios
cargo make --profile development-ios-arm64-sim code_generation
# - uses: futureware-tech/simulator-action@v3
# id: simulator-action
# with:
# model: "iPhone 15"
# shutdown_after_job: false
- uses: futureware-tech/simulator-action@v3
id: simulator-action
with:
model: "iPhone 15"
shutdown_after_job: false
# - name: Run AppFlowy on simulator
# working-directory: frontend/appflowy_flutter
# run: |
# flutter run -d ${{ steps.simulator-action.outputs.udid }} &
# pid=$!
# sleep 500
# kill $pid
# continue-on-error: true
- name: Run AppFlowy on simulator
working-directory: frontend/appflowy_flutter
run: |
flutter run -d ${{ steps.simulator-action.outputs.udid }} &
pid=$!
sleep 500
kill $pid
continue-on-error: true
# - name: Run integration tests
# working-directory: frontend/appflowy_flutter
# run: flutter test integration_test/runner.dart -d ${{ steps.simulator-action.outputs.udid }}
# Integration tests
- name: Run integration tests
working-directory: frontend/appflowy_flutter
# The integration tests are flaky and sometimes fail with "Connection timed out":
# Don't block the CI. If the tests fail, the CI will still pass.
# Instead, we're using Code Magic to re-run the tests to check if they pass.
continue-on-error: true
run: flutter test integration_test/runner.dart -d ${{ steps.simulator-action.outputs.udid }}
+83
View File
@@ -0,0 +1,83 @@
name: Mobile-CI
on:
workflow_dispatch:
inputs:
branch:
description: "Branch to build"
required: true
default: "main"
workflow_id:
description: "Codemagic workflow ID"
required: true
default: "ios-workflow"
type: choice
options:
- ios-workflow
- android-workflow
env:
CODEMAGIC_API_TOKEN: ${{ secrets.CODEMAGIC_API_TOKEN }}
APP_ID: "6731d2f427e7c816080c3674"
jobs:
trigger-mobile-build:
runs-on: ubuntu-latest
steps:
- name: Trigger Codemagic Build
id: trigger_build
run: |
RESPONSE=$(curl -X POST \
--header "Content-Type: application/json" \
--header "x-auth-token: $CODEMAGIC_API_TOKEN" \
--data '{
"appId": "${{ env.APP_ID }}",
"workflowId": "${{ github.event.inputs.workflow_id }}",
"branch": "${{ github.event.inputs.branch }}"
}' \
https://api.codemagic.io/builds)
BUILD_ID=$(echo $RESPONSE | jq -r '.buildId')
echo "build_id=$BUILD_ID" >> $GITHUB_OUTPUT
echo "build_id=$BUILD_ID"
- name: Wait for build and check status
id: check_status
run: |
while true; do
curl -X GET \
--header "Content-Type: application/json" \
--header "x-auth-token: $CODEMAGIC_API_TOKEN" \
https://api.codemagic.io/builds/${{ steps.trigger_build.outputs.build_id }} > /tmp/response.json
RESPONSE_WITHOUT_COMMAND=$(cat /tmp/response.json | jq 'walk(if type == "object" and has("subactions") then .subactions |= map(del(.command)) else . end)')
STATUS=$(echo $RESPONSE_WITHOUT_COMMAND | jq -r '.build.status')
if [ "$STATUS" = "finished" ]; then
SUCCESS=$(echo $RESPONSE_WITHOUT_COMMAND | jq -r '.success')
BUILD_URL=$(echo $RESPONSE_WITHOUT_COMMAND | jq -r '.buildUrl')
echo "status=$STATUS" >> $GITHUB_OUTPUT
echo "success=$SUCCESS" >> $GITHUB_OUTPUT
echo "build_url=$BUILD_URL" >> $GITHUB_OUTPUT
break
elif [ "$STATUS" = "failed" ]; then
echo "status=failed" >> $GITHUB_OUTPUT
break
fi
sleep 60
done
- name: Slack Notification
uses: 8398a7/action-slack@v3
if: always()
with:
status: ${{ steps.check_status.outputs.success == 'true' && 'success' || 'failure' }}
fields: repo,message,commit,author,action,eventName,ref,workflow,job,took
text: |
Mobile CI Build Result
Branch: ${{ github.event.inputs.branch }}
Workflow: ${{ github.event.inputs.workflow_id }}
Build URL: ${{ steps.check_status.outputs.build_url }}
env:
SLACK_WEBHOOK_URL: ${{ secrets.RELEASE_SLACK_WEBHOOK }}
+1 -1
View File
@@ -7,7 +7,7 @@ on:
env:
FLUTTER_VERSION: "3.22.0"
RUST_TOOLCHAIN: "1.77.2"
RUST_TOOLCHAIN: "1.80.1"
jobs:
create-release:
+5 -6
View File
@@ -15,13 +15,11 @@ on:
- "main"
- "develop"
- "release/*"
paths:
- "frontend/rust-lib/**"
env:
CARGO_TERM_COLOR: always
CLOUD_VERSION: 0.6.51-amd64
RUST_TOOLCHAIN: "1.77.2"
CLOUD_VERSION: 0.7.6-amd64
RUST_TOOLCHAIN: "1.80.1"
jobs:
self-hosted-job:
@@ -43,6 +41,7 @@ jobs:
cp deploy.env .env
sed -i '' 's|RUST_LOG=.*|RUST_LOG=trace|' .env
sed -i '' 's|API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://localhost|' .env
sed -i '' 's|APPFLOWY_AI_OPENAI_API_KEY=.*|APPFLOWY_AI_OPENAI_API_KEY=${{ secrets.CI_OPENAI_API_KEY }}|' .env
- name: Ensure AppFlowy-Cloud is Running with Correct Version
working-directory: AppFlowy-Cloud
@@ -137,7 +136,7 @@ jobs:
run: |
cp deploy.env .env
sed -i 's|RUST_LOG=.*|RUST_LOG=trace|' .env
sed -i 's|GOTRUE_MAILER_AUTOCONFIRM=.*|GOTRUE_MAILER_AUTOCONFIRM=true|' .env
sed -i 's|GOTRUE_MAILER_AUTOCONFIRM=.*|GOTRUE_MAILER_AUTOCONFIRM=true|' .env
sed -i 's|API_EXTERNAL_URL=.*|API_EXTERNAL_URL=http://localhost|' .env
- name: Ensure AppFlowy-Cloud is Running with Correct Version
@@ -160,7 +159,7 @@ jobs:
else
echo "No volumes to remove."
fi
docker compose pull
docker compose up -d
echo "Waiting for the container to be ready..."
+1 -1
View File
@@ -11,7 +11,7 @@ on:
env:
CARGO_TERM_COLOR: always
FLUTTER_VERSION: "3.22.0"
RUST_TOOLCHAIN: "1.77.2"
RUST_TOOLCHAIN: "1.80.1"
jobs:
tests:
+2 -2
View File
@@ -11,7 +11,7 @@ on:
env:
NODE_VERSION: "18.16.0"
PNPM_VERSION: "8.5.0"
RUST_TOOLCHAIN: "1.77.2"
RUST_TOOLCHAIN: "1.80.1"
CARGO_MAKE_VERSION: "0.36.6"
CI: true
@@ -121,4 +121,4 @@ jobs:
with:
tauriScript: pnpm tauri
projectPath: frontend/appflowy_web_app
args: "--debug"
args: "--debug"
+3 -3
View File
@@ -7,7 +7,7 @@ on:
env:
NODE_VERSION: "18.16.0"
PNPM_VERSION: "8.5.0"
RUST_TOOLCHAIN: "1.77.2"
RUST_TOOLCHAIN: "1.80.1"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
@@ -19,7 +19,7 @@ jobs:
strategy:
fail-fast: false
matrix:
platform: [ ubuntu-20.04 ]
platform: [ubuntu-20.04]
runs-on: ${{ matrix.platform }}
@@ -108,4 +108,4 @@ jobs:
with:
tauriScript: pnpm tauri
projectPath: frontend/appflowy_tauri
args: "--debug"
args: "--debug"
+5 -6
View File
@@ -4,20 +4,19 @@ on:
workflow_dispatch:
inputs:
branch:
description: 'The branch to release'
description: "The branch to release"
required: true
default: 'main'
default: "main"
version:
description: 'The version to release'
description: "The version to release"
required: true
default: '0.0.0'
default: "0.0.0"
env:
NODE_VERSION: "18.16.0"
PNPM_VERSION: "8.5.0"
RUST_TOOLCHAIN: "1.77.2"
RUST_TOOLCHAIN: "1.80.1"
jobs:
publish-tauri:
permissions:
contents: write
+47
View File
@@ -1,4 +1,51 @@
# Release Notes
## Version 0.7.6 - 03/12/2024
### Bug Fixes
## Version 0.7.6 - 03/12/2024
### New Features
- Revamped the simple table UI
- Added support for capturing images from camera on mobile
### Bug Fixes
- Improved markdown rendering capabilities in AI writer
- Fixed an issue where pressing Enter on a collapsed toggle list would add an unnecessary new line
- Fixed an issue where creating a document from slash menu could insert content at incorrect position
## Version 0.7.5 - 25/11/2024
### Bug Fixes
- Improved chat response parsing
- Fixed toggle list icon direction for RTL mode
- Fixed cross blocks formatting not reflecting in float toolbar
- Fixed unable to click inside the toggle list to create a new paragraph
- Fixed open file error 50 on macOS
- Fixed upload file exceed limit error
## Version 0.7.4 - 19/11/2024
### New Features
- Support uploading WebP and BMP images
- Support managing workspaces on mobile
- Support adding toggle headings on mobile
- Improve the AI chat page UI
### Bug Fixes
- Optimized the workspace menu loading performance
- Optimized tab switching performance
- Fixed searching issues in Document page
## Version 0.7.3 - 07/11/2024
### New Features
- Enable custom URLs for published pages
- Support toggling headings
- Create a subpage by typing in the document
- Turn selected blocks into a subpage
- Add a manual date picker for the Date property
### Bug Fixes
- Fixed an issue where the workspace owner was unable to delete spaces created by others
- Fixed cursor height inconsistencies with text height
- Fixed editing issues in Kanban cards
- Fixed an issue preventing images or files from being dropped into empty paragraphs
## Version 0.7.2 - 22/10/2024
### New Features
- Copy link to block
+48 -17
View File
@@ -42,10 +42,12 @@ AppFlowy is the AI workspace where you achieve more without losing control of yo
## User Installation
- [Download AppFlowy Desktop (macOS, Windows, and Linux)](https://github.com/AppFlowy-IO/AppFlowy/releases)
- Other channels: [FlatHub](https://flathub.org/apps/io.appflowy.AppFlowy), [Snapcraft](https://snapcraft.io/appflowy), [Sourceforge](https://sourceforge.net/projects/appflowy/)
- Other
channels: [FlatHub](https://flathub.org/apps/io.appflowy.AppFlowy), [Snapcraft](https://snapcraft.io/appflowy), [Sourceforge](https://sourceforge.net/projects/appflowy/)
- Available on
- [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
- [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)
- [Source](https://docs.appflowy.io/docs/documentation/appflowy/from-source)
@@ -61,15 +63,18 @@ AppFlowy is the AI workspace where you achieve more without losing control of yo
## Getting Started with development
Please view the [documentation](https://docs.appflowy.io/docs/documentation/appflowy/from-source) for OS specific development instructions
Please view the [documentation](https://docs.appflowy.io/docs/documentation/appflowy/from-source) for OS specific
development instructions
## Roadmap
- [AppFlowy Roadmap ReadMe](https://docs.appflowy.io/docs/appflowy/roadmap)
- [AppFlowy Public Roadmap](https://github.com/orgs/AppFlowy-IO/projects/5/views/12)
If you'd like to propose a feature, submit a feature request [here](https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&template=feature_request.yaml&title=%5BFR%5D+) <br/>
If you'd like to report a bug, submit a bug report [here](https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&template=bug_report.yaml&title=%5BBug%5D+)
If you'd like to propose a feature, submit a feature
request [here](https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&template=feature_request.yaml&title=%5BFR%5D+) <br/>
If you'd like to report a bug, submit a bug
report [here](https://github.com/AppFlowy-IO/AppFlowy/issues/new?assignees=&labels=&template=bug_report.yaml&title=%5BBug%5D+)
## **Releases**
@@ -77,16 +82,24 @@ Please see the [changelog](https://www.appflowy.io/whatsnew) for more details ab
## Contributing
Contributions make the open-source community a fantastic place to learn, inspire, and create. Any contributions you make are **greatly appreciated**. Please look at [Contributing to AppFlowy](https://docs.appflowy.io/docs/documentation/software-contributions/contributing-to-appflowy) for details.
Contributions make the open-source community a fantastic place to learn, inspire, and create. Any contributions you make
are **greatly appreciated**. Please look
at [Contributing to AppFlowy](https://docs.appflowy.io/docs/documentation/software-contributions/contributing-to-appflowy)
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!
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.
## Translations 🌎🗺
[![translation badge](https://inlang.com/badge?url=github.com/AppFlowy-IO/AppFlowy)](https://inlang.com/editor/github.com/AppFlowy-IO/AppFlowy?ref=badge)
To add translations, you can manually edit the JSON translation files in `/frontend/resources/translations`, use the [inlang online editor](https://inlang.com/editor/github.com/AppFlowy-IO/AppFlowy), or run `npx inlang machine translate` to add missing translations.
To add translations, you can manually edit the JSON translation files in `/frontend/resources/translations`, use
the [inlang online editor](https://inlang.com/editor/github.com/AppFlowy-IO/AppFlowy), or
run `npx inlang machine translate` to add missing translations.
## Join the community to build AppFlowy together
@@ -96,16 +109,30 @@ To add translations, you can manually edit the JSON translation files in `/front
## Why Are We Building This?
Notion has been our favourite project and knowledge management tool in recent years because of its aesthetic appeal and functionality. Our team uses it daily, and we are on its paid plan. However, as we all know, Notion has its limitations. These include weak data security and poor compatibility with mobile devices. Likewise, alternative collaborative workplace management tools also have their constraints.
Notion has been our favourite project and knowledge management tool in recent years because of its aesthetic appeal and
functionality. Our team uses it daily, and we are on its paid plan. However, as we all know, Notion has its limitations.
These include weak data security and poor compatibility with mobile devices. Likewise, alternative collaborative
workplace management tools also have their constraints.
The limitations we encountered using these tools and our past work experience with collaborative productivity tools have led to our firm belief that there is a glass ceiling on what's possible for these tools in the future. This emanates from the fact that these tools will probably struggle to scale horizontally at some point and be forced to prioritize a proportion of customers whose needs differ from the rest. While decision-makers want a workplace OS, it is impossible to come up with a one-size fits all solution in such a fragmented market.
The limitations we encountered using these tools and our past work experience with collaborative productivity tools have
led to our firm belief that there is a glass ceiling on what's possible for these tools in the future. This emanates
from the fact that these tools will probably struggle to scale horizontally at some point and be forced to prioritize a
proportion of customers whose needs differ from the rest. While decision-makers want a workplace OS, it is impossible to
come up with a one-size fits all solution in such a fragmented market.
When a customer's evolving core needs are not satisfied, they either switch to another or build one from the ground up, in-house. Consequently, they either go under another ceiling or buy an expensive ticket to learn a hard lesson. This is a requirement for many resources and expertise, building a reliable and easy-to-use collaborative tool, not to mention the speed and native experience. The same may apply to individual users as well.
When a customer's evolving core needs are not satisfied, they either switch to another or build one from the ground up,
in-house. Consequently, they either go under another ceiling or buy an expensive ticket to learn a hard lesson. This is
a requirement for many resources and expertise, building a reliable and easy-to-use collaborative tool, not to mention
the speed and native experience. The same may apply to individual users as well.
All these restrictions necessitate our mission - to make it possible for anyone to create apps that suit their needs well.
All these restrictions necessitate our mission - to make it possible for anyone to create apps that suit their needs
well.
- To individuals, we would like to offer Notion's functionality, data security, and cross-platform native experience.
- To enterprises and hackers, AppFlowy is dedicated to offering building blocks and collaboration infra services to enable you to make apps on your own. Moreover, you have 100% control of your data. You can design and modify AppFlowy your way, with a single codebase written in Flutter and Rust supporting multiple platforms armed with long-term maintainability.
- To enterprises and hackers, AppFlowy is dedicated to offering building blocks and collaboration infra services to
enable you to make apps on your own. Moreover, you have 100% control of your data. You can design and modify AppFlowy
your way, with a single codebase written in Flutter and Rust supporting multiple platforms armed with long-term
maintainability.
We decided to achieve this mission by upholding the three most fundamental values:
@@ -113,16 +140,20 @@ We decided to achieve this mission by upholding the three most fundamental value
- Reliable native experience
- Community-driven extensibility
We do not claim to outperform Notion in terms of functionality and design, at least for now. Besides, our priority doesn't lie in more functionality at the moment. Instead, we would like to cultivate a community to democratize the knowledge and wheels of making complex workplace management tools while enabling people and businesses to create beautiful things on their own by equipping them with a versatile toolbox of building blocks.
We do not claim to outperform Notion in terms of functionality and design, at least for now. Besides, our priority
doesn't lie in more functionality at the moment. Instead, we would like to cultivate a community to democratize the
knowledge and wheels of making complex workplace management tools while enabling people and businesses to create
beautiful things on their own by equipping them with a versatile toolbox of building blocks.
## License
Distributed under the AGPLv3 License. See [`LICENSE.md`](https://github.com/AppFlowy-IO/AppFlowy/blob/main/LICENSE) for more information.
Distributed under the AGPLv3 License. See [`LICENSE.md`](https://github.com/AppFlowy-IO/AppFlowy/blob/main/LICENSE) for
more information.
## Acknowledgments
Special thanks to these amazing projects which help power AppFlowy.IO:
- [flutter-quill](https://github.com/singerdmx/flutter-quill)
- [cargo-make](https://github.com/sagiegurari/cargo-make)
- [contrib.rocks](https://contrib.rocks)
- [flutter_chat_ui](https://pub.dev/packages/flutter_chat_ui)
+47
View File
@@ -0,0 +1,47 @@
workflows:
ios-workflow:
name: iOS Workflow
instance_type: mac_mini_m2
max_build_duration: 30
environment:
flutter: 3.22.3
xcode: latest
cocoapods: default
scripts:
- name: Build Flutter
script: |
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y
source "$HOME/.cargo/env"
rustc --version
cargo --version
cd frontend
rustup target install aarch64-apple-ios-sim
cargo install --force cargo-make
cargo install --force duckscript_cli
cargo install --force cargo-lipo
cargo make appflowy-flutter-deps-tools
cargo make --profile development-ios-arm64-sim appflowy-core-dev-ios
cargo make --profile development-ios-arm64-sim code_generation
- name: iOS integration tests
script: |
cd frontend/appflowy_flutter
flutter emulators --launch apple_ios_simulator
flutter -d iPhone test integration_test/runner.dart
artifacts:
- build/ios/ipa/*.ipa
- /tmp/xcodebuild_logs/*.log
- flutter_drive.log
publishing:
email:
recipients:
- lucas.xu@appflowy.io
notify:
success: true
failure: true
+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.7.3"
APPFLOWY_VERSION = "0.7.7"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"
@@ -65,4 +65,5 @@
-->
<uses-permission android:name="android.permission.READ_MEDIA_AUDIO" />
<uses-permission android:name="android.permission.READ_MEDIA_VIDEO" />
<uses-permission android:name="android.permission.CAMERA" />
</manifest>
@@ -1,33 +1,10 @@
// ignore_for_file: unused_import
import 'dart:io';
import 'dart:ui';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy/workspace/presentation/settings/pages/account/account.dart';
import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart';
import 'package:appflowy/workspace/presentation/settings/shared/settings_body.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flowy_infra_ui/style_widget/text_field.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:intl/intl.dart';
import 'package:path/path.dart' as p;
import '../../../shared/dir.dart';
import '../../../shared/mock/mock_file_picker.dart';
import '../../../shared/util.dart';
import '../../board/board_hide_groups_test.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
@@ -0,0 +1,47 @@
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.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/constants.dart';
import '../../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('AI Writer:', () {
testWidgets('the ai writer transaction should only apply in memory',
(tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
const pageName = 'Document';
await tester.createNewPageInSpace(
spaceName: Constants.generalSpaceName,
layout: ViewLayoutPB.Document,
pageName: pageName,
);
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_aiWriter.tr(),
);
expect(find.byType(AIWriterBlockComponent), findsOneWidget);
// switch to another page
await tester.openPage(Constants.gettingStartedPageName);
// switch back to the page
await tester.openPage(pageName);
// expect the ai writer block is not in the document
expect(find.byType(AIWriterBlockComponent), findsNothing);
});
});
}
@@ -1,33 +1,17 @@
// ignore_for_file: unused_import
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/shared/share/constants.dart';
import 'package:appflowy/plugins/shared/share/publish_tab.dart';
import 'package:appflowy/plugins/shared/share/share_menu.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/shared/patterns/common_patterns.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.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';
import 'package:path/path.dart' as p;
import '../../../shared/constants.dart';
import '../../../shared/mock/mock_file_picker.dart';
import '../../../shared/util.dart';
import '../../../shared/workspace.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
@@ -122,9 +106,21 @@ void main() {
await tester.pumpUntilFound(errorToast2);
await tester.pumpUntilNotFound(errorToast2);
// rename with empty name
await tester.tap(inputField);
await tester.enterText(inputField, '');
await tester.tapButton(find.text(LocaleKeys.button_save.tr()));
await tester.pumpAndSettle();
// expect to see the toast with error message
final errorToast3 = find.text(
LocaleKeys.settings_sites_error_publishNameCannotBeEmpty.tr(),
);
await tester.pumpUntilFound(errorToast3);
await tester.pumpUntilNotFound(errorToast3);
// input the new path name
await tester.tap(inputField);
await tester.enterText(inputField, 'new-path-name');
// click save button
await tester.tapButton(find.text(LocaleKeys.button_save.tr()));
@@ -153,5 +149,72 @@ void main() {
isTrue,
);
});
testWidgets('re-publish the document', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
const pageName = 'Document';
await tester.createNewPageInSpace(
spaceName: Constants.generalSpaceName,
layout: ViewLayoutPB.Document,
pageName: pageName,
);
// open the publish menu
await tester.openPublishMenu();
// publish the document
final publishButton = find.byType(PublishButton);
await tester.tapButton(publishButton);
// rename the path name
final inputField = find.descendant(
of: find.byType(ShareMenu),
matching: find.byType(TextField),
);
// input the new path name
const newName = 'new-path-name';
await tester.enterText(inputField, newName);
// click save button
await tester.tapButton(find.text(LocaleKeys.button_save.tr()));
await tester.pumpAndSettle();
// expect to see the toast with success message
final successToast = find.text(
LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(),
);
await tester.pumpUntilNotFound(successToast);
// unpublish the document
final unpublishButton = find.byType(UnPublishButton);
await tester.tapButton(unpublishButton);
final unpublishSuccessToast = find.text(
LocaleKeys.publish_unpublishSuccessfully.tr(),
);
await tester.pumpUntilNotFound(unpublishSuccessToast);
// re-publish the document
await tester.tapButton(publishButton);
// expect to see the toast with success message
final rePublishSuccessToast = find.text(
LocaleKeys.publish_publishSuccessfully.tr(),
);
await tester.pumpUntilNotFound(rePublishSuccessToast);
// check the clipboard has the link
final content = await Clipboard.getData(Clipboard.kTextPlain);
expect(
content?.text?.contains(newName),
isTrue,
);
});
});
}
@@ -1,5 +1,6 @@
import 'package:integration_test/integration_test.dart';
import 'document_ai_writer_test.dart' as document_ai_writer_test;
import 'document_copy_link_to_block_test.dart'
as document_copy_link_to_block_test;
import 'document_option_actions_test.dart' as document_option_actions_test;
@@ -11,4 +12,5 @@ void main() {
document_option_actions_test.main();
document_copy_link_to_block_test.main();
document_publish_test.main();
document_ai_writer_test.main();
}
@@ -1,35 +1,13 @@
// ignore_for_file: unused_import
import 'dart:io';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
import 'package:universal_platform/universal_platform.dart';
import '../../../shared/constants.dart';
import '../../../shared/database_test_op.dart';
import '../../../shared/dir.dart';
import '../../../shared/emoji.dart';
import '../../../shared/mock/mock_file_picker.dart';
import '../../../shared/util.dart';
void main() {
@@ -1,21 +1,12 @@
// ignore_for_file: unused_import
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy/workspace/presentation/settings/pages/account/account.dart';
import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.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';
void main() {
@@ -1,24 +1,9 @@
// ignore_for_file: unused_import
import 'dart:io';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
import '../../../shared/dir.dart';
import '../../../shared/mock/mock_file_picker.dart';
import '../../../shared/util.dart';
void main() {
@@ -1,33 +1,11 @@
// ignore_for_file: unused_import
import 'dart:io';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy/workspace/presentation/settings/pages/account/account_user_profile.dart';
import 'package:appflowy/workspace/presentation/settings/pages/settings_account_view.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flowy_infra_ui/style_widget/text_field.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
import '../../../shared/database_test_op.dart';
import '../../../shared/dir.dart';
import '../../../shared/emoji.dart';
import '../../../shared/mock/mock_file_picker.dart';
import '../../../shared/util.dart';
import '../../board/board_hide_groups_test.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
@@ -1,21 +1,12 @@
// ignore_for_file: unused_import
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.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 '../../../shared/workspace.dart';
@@ -49,6 +40,10 @@ void main() {
await tester.changeWorkspaceIcon(icon);
await tester.changeWorkspaceName(name);
await tester.pumpUntilNotFound(
find.text(LocaleKeys.workspace_renameSuccess.tr()),
);
workspaceIcon = tester.widget<WorkspaceIcon>(
find.byType(WorkspaceIcon),
);
@@ -1,38 +1,19 @@
// ignore_for_file: unused_import
import 'dart:io';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
import '../../../shared/database_test_op.dart';
import '../../../shared/dir.dart';
import '../../../shared/emoji.dart';
import '../../../shared/mock/mock_file_picker.dart';
import '../../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('collaborative workspace: ', () {
group('collaborative workspace:', () {
// combine the create and delete workspace test to reduce the time
testWidgets('create a new workspace, open it and then delete it',
(tester) async {
@@ -93,5 +74,79 @@ void main() {
},
);
});
testWidgets('check the member count immediately after creating a workspace',
(tester) async {
// only run the test when the feature flag is on
if (!FeatureFlag.collaborativeWorkspace.isOn) {
return;
}
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
const name = 'AppFlowy.IO';
// the workspace will be opened after created
await tester.createCollaborativeWorkspace(name);
final loading = find.byType(Loading);
await tester.pumpUntilNotFound(loading);
await tester.openCollaborativeWorkspaceMenu();
// expect to see the member count
final memberCount = find.text('1 member');
expect(memberCount, findsNWidgets(2));
});
testWidgets('only display one menu item in the workspace menu',
(tester) async {
// only run the test when the feature flag is on
if (!FeatureFlag.collaborativeWorkspace.isOn) {
return;
}
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
const name = 'AppFlowy.IO';
// the workspace will be opened after created
await tester.createCollaborativeWorkspace(name);
final loading = find.byType(Loading);
await tester.pumpUntilNotFound(loading);
await tester.openCollaborativeWorkspaceMenu();
// hover on the workspace and click the more button
final workspaceItem = find.byWidgetPredicate(
(w) => w is WorkspaceMenuItem && w.workspace.name == name,
);
await tester.hoverOnWidget(
workspaceItem,
onHover: () async {
final moreButton = find.byWidgetPredicate(
(w) => w is WorkspaceMoreActionList && w.workspace.name == name,
);
expect(moreButton, findsOneWidget);
await tester.tapButton(moreButton);
// click it again
await tester.tapButton(moreButton);
// nothing should happen
expect(
find.text(LocaleKeys.button_rename.tr()),
findsOneWidget,
);
},
);
});
});
}
@@ -1,29 +1,17 @@
// ignore_for_file: unused_import
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/shared/share/constants.dart';
import 'package:appflowy/plugins/shared/share/share_menu.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/shared/patterns/common_patterns.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
import '../../../shared/constants.dart';
import '../../../shared/mock/mock_file_picker.dart';
import '../../../shared/util.dart';
import '../../../shared/workspace.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
@@ -0,0 +1,75 @@
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart';
import 'package:appflowy/workspace/presentation/home/tabs/flowy_tab.dart';
import 'package:appflowy/workspace/presentation/home/tabs/tabs_manager.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../../shared/constants.dart';
import '../../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Tabs', () {
testWidgets('close other tabs before opening a new workspace',
(tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
const name = 'AppFlowy.IO';
// the workspace will be opened after created
await tester.createCollaborativeWorkspace(name);
final loading = find.byType(Loading);
await tester.pumpUntilNotFound(loading);
// create new tabs in the workspace
expect(find.byType(FlowyTab), findsNothing);
const documentOneName = 'document one';
const documentTwoName = 'document two';
await tester.createNewPageInSpace(
spaceName: Constants.generalSpaceName,
layout: ViewLayoutPB.Document,
pageName: documentOneName,
);
await tester.createNewPageInSpace(
spaceName: Constants.generalSpaceName,
layout: ViewLayoutPB.Document,
pageName: documentTwoName,
);
/// Open second menu item in a new tab
await tester.openAppInNewTab(documentOneName, ViewLayoutPB.Document);
/// Open third menu item in a new tab
await tester.openAppInNewTab(documentTwoName, ViewLayoutPB.Document);
expect(
find.descendant(
of: find.byType(TabsManager),
matching: find.byType(FlowyTab),
),
findsNWidgets(2),
);
// switch to the another workspace
final Finder items = find.byType(WorkspaceMenuItem);
await tester.openCollaborativeWorkspaceMenu();
await tester.pumpUntilFound(items);
expect(items, findsNWidgets(2));
// open the first workspace
await tester.tap(items.first);
await tester.pumpUntilNotFound(loading);
expect(find.byType(FlowyTab), findsNothing);
});
});
}
@@ -1,35 +1,11 @@
// ignore_for_file: unused_import
import 'dart:io';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
import 'package:universal_platform/universal_platform.dart';
import '../../../shared/constants.dart';
import '../../../shared/database_test_op.dart';
import '../../../shared/dir.dart';
import '../../../shared/emoji.dart';
import '../../../shared/mock/mock_file_picker.dart';
import '../../../shared/util.dart';
import '../../../shared/workspace.dart';
@@ -1,46 +1,23 @@
// ignore_for_file: unused_import
import 'dart:io';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/openai/widgets/loading.dart';
import 'package:appflowy/plugins/document/presentation/editor_style.dart';
import 'package:appflowy/plugins/shared/share/publish_tab.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_actions.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_menu.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/sidebar_workspace.dart';
import 'package:appflowy/workspace/presentation/settings/pages/settings_workspace_view.dart';
import 'package:appflowy/workspace/presentation/settings/pages/sites/domain/domain_item.dart';
import 'package:appflowy/workspace/presentation/settings/pages/sites/domain/domain_more_action.dart';
import 'package:appflowy/workspace/presentation/settings/pages/sites/domain/domain_settings_dialog.dart';
import 'package:appflowy/workspace/presentation/settings/pages/sites/domain/home_page_menu.dart';
import 'package:appflowy/workspace/presentation/settings/pages/sites/published_page/published_view_item.dart';
import 'package:appflowy/workspace/presentation/settings/pages/sites/published_page/published_view_more_action.dart';
import 'package:appflowy/workspace/presentation/settings/pages/sites/published_page/published_view_settings_dialog.dart';
import 'package:appflowy/workspace/presentation/settings/shared/setting_list_tile.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
import 'package:appflowy/workspace/presentation/widgets/user_avatar.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
import '../../../shared/constants.dart';
import '../../../shared/database_test_op.dart';
import '../../../shared/dir.dart';
import '../../../shared/emoji.dart';
import '../../../shared/mock/mock_file_picker.dart';
import '../../../shared/util.dart';
void main() {
@@ -3,6 +3,7 @@ import 'package:integration_test/integration_test.dart';
import 'change_name_and_icon_test.dart' as change_name_and_icon_test;
import 'collaborative_workspace_test.dart' as collaborative_workspace_test;
import 'share_menu_test.dart' as share_menu_test;
import 'tabs_test.dart' as tabs_test;
import 'workspace_icon_test.dart' as workspace_icon_test;
import 'workspace_settings_test.dart' as workspace_settings_test;
@@ -14,4 +15,5 @@ void main() {
collaborative_workspace_test.main();
change_name_and_icon_test.main();
workspace_icon_test.main();
tabs_test.main();
}
@@ -301,6 +301,7 @@ void main() {
await tester.createOption(name: "qwer");
await tester.selectOption(name: "asdf");
await tester.dismissCellEditor();
await tester.dismissCellEditor();
await tester.tapDatabaseFilterButton();
await tester.tapCreateFilterByFieldType(FieldType.MultiSelect, "Tags");
@@ -332,6 +333,7 @@ void main() {
await tester.tapButton(finderForFieldType(FieldType.MultiSelect));
await tester.selectOption(name: "asdf");
await tester.dismissCellEditor();
await tester.dismissCellEditor();
tester.assertNumberOfEventsInCalendar(0);
@@ -4,12 +4,10 @@ import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/widgets/card/card.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/media_cell_editor.dart';
import 'package:appflowy/plugins/database/widgets/row/row_banner.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/shared/af_image.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pbenum.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
@@ -26,61 +24,6 @@ void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('database row cover', () {
testWidgets('add image to media field and check if cover is set (grid)',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
// Invoke the field editor
await tester.tapGridFieldWithName('Type');
await tester.tapEditFieldButton();
// Change to media type
await tester.tapSwitchFieldTypeButton();
await tester.selectFieldType(FieldType.Media);
await tester.dismissFieldEditor();
// Prepare file for upload from local
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');
// Open media cell editor
await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.Media);
await tester.findMediaCellEditor(findsOneWidget);
// Click on add file button in the Media Cell Editor
await tester.tap(find.text(LocaleKeys.grid_media_addFileOrImage.tr()));
await tester.pumpAndSettle();
// Tap on the upload interaction
await tester.tapFileUploadHint();
// Expect one file
expect(find.byType(RenderMedia), findsOneWidget);
// Close cell editor
await tester.dismissCellEditor();
// Open first row in row detail view
await tester.openFirstRowDetailPage();
await tester.pumpAndSettle();
// Expect a cover to be shown
expect(find.byType(RowCover), findsOneWidget);
// Remove the temp file
await Future.wait([file.delete()]);
});
testWidgets('add and remove cover from Row Detail Card', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
@@ -1,17 +1,17 @@
import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_cell_editor.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/header/desktop_field_cell.dart';
import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_checklist_cell.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_cell_editor.dart';
import 'package:appflowy/plugins/database/widgets/row/row_detail.dart';
import 'package:appflowy/plugins/document/document_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/header/emoji_icon_widget.dart';
import 'package:appflowy/util/field_type_extension.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.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/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -76,6 +76,24 @@ void main() {
// The number of emoji should be two. One in the row displayed in the grid
// one in the row detail page.
expect(emojiText, findsNWidgets(2));
// insert a sub page in database
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.pumpAndSettle();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_subPage_name.tr(),
offset: 100,
);
await tester.pumpAndSettle();
// the row detail page should be closed
final rowDetailPage = find.byType(RowDetailPage);
await tester.pumpUntilNotFound(rowDetailPage);
// expect to see a document page
final documentPage = find.byType(DocumentPage);
expect(documentPage, findsOneWidget);
});
testWidgets('remove emoji', (tester) async {
@@ -0,0 +1,47 @@
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Block option interaction tests', () {
testWidgets('has correct block selection on tap option button',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// We edit the document by entering some characters, to ensure the document has focus
await tester.editor.updateSelection(
Selection.collapsed(Position(path: [2])),
);
// Insert character 'a' three times - easy to identify
await tester.ime.insertText('aaa');
await tester.pumpAndSettle();
final editorState = tester.editor.getCurrentEditorState();
final node = editorState.getNodeAtPath([2]);
expect(node?.delta?.toPlainText(), startsWith('aaa'));
final multiSelection = Selection(
start: Position(path: [2], offset: 3),
end: Position(path: [4], offset: 40),
);
// Select multiple items
await tester.editor.updateSelection(multiSelection);
await tester.pumpAndSettle();
// Press the block option menu
await tester.editor.hoverAndClickOptionMenuButton([2]);
await tester.pumpAndSettle();
// Expect the selection to be Block type and not have changed
expect(editorState.selectionType, SelectionType.block);
expect(editorState.selection, multiSelection);
});
});
}
@@ -0,0 +1,144 @@
import 'dart:math';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.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';
import 'package:universal_platform/universal_platform.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
String generateRandomString(int len) {
final r = Random();
return String.fromCharCodes(
List.generate(len, (index) => r.nextInt(33) + 89),
);
}
testWidgets(
'document find menu test',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a new document
await tester.createNewPageWithNameUnderParent();
// tap editor to get focus
await tester.tapButton(find.byType(AppFlowyEditor));
// set clipboard data
final data = [
"123456\n",
...List.generate(100, (_) => "${generateRandomString(50)}\n"),
"1234567\n",
...List.generate(100, (_) => "${generateRandomString(50)}\n"),
"12345678\n",
...List.generate(100, (_) => "${generateRandomString(50)}\n"),
].join();
await getIt<ClipboardService>().setData(
ClipboardServiceData(
plainText: data,
),
);
// paste
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed:
UniversalPlatform.isLinux || UniversalPlatform.isWindows,
isMetaPressed: UniversalPlatform.isMacOS,
);
await tester.pumpAndSettle();
// go back to beginning of document
// FIXME: Cannot run Ctrl+F unless selection is on screen
await tester.editor
.updateSelection(Selection.collapsed(Position(path: [0])));
await tester.pumpAndSettle();
expect(find.byType(FindAndReplaceMenuWidget), findsNothing);
// press cmd/ctrl+F to display the find menu
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyF,
isControlPressed:
UniversalPlatform.isLinux || UniversalPlatform.isWindows,
isMetaPressed: UniversalPlatform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(FindAndReplaceMenuWidget), findsOneWidget);
final textField = find.descendant(
of: find.byType(FindAndReplaceMenuWidget),
matching: find.byType(TextField),
);
await tester.enterText(
textField,
"123456",
);
await tester.pumpAndSettle();
await tester.pumpAndSettle();
expect(
find.descendant(
of: find.byType(AppFlowyEditor),
matching: find.text("123456", findRichText: true),
),
findsOneWidget,
);
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
await tester.pumpAndSettle();
expect(
find.descendant(
of: find.byType(AppFlowyEditor),
matching: find.text("1234567", findRichText: true),
),
findsOneWidget,
);
await tester.showKeyboard(textField);
await tester.idle();
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
await tester.pumpAndSettle();
expect(
find.descendant(
of: find.byType(AppFlowyEditor),
matching: find.text("12345678", findRichText: true),
),
findsOneWidget,
);
// tap next button, go back to beginning of document
await tester.tapButton(
find.descendant(
of: find.byType(FindMenu),
matching: find.byFlowySvg(FlowySvgs.arrow_down_s),
),
);
expect(
find.descendant(
of: find.byType(AppFlowyEditor),
matching: find.text("123456", findRichText: true),
),
findsOneWidget,
);
},
);
}
@@ -2,12 +2,12 @@ import 'dart:io';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_page_block.dart';
import 'package:appflowy/plugins/inline_actions/inline_actions_menu.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
@@ -330,6 +330,23 @@ void main() {
expect(find.text("$_createdPageName (copy)"), findsNWidgets(2));
expect(find.text("$_createdPageName (copy) (copy)"), findsOneWidget);
});
testWidgets('Cancel inline page reference menu by space', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createOpenRenameDocumentUnderParent(name: _firstDocName);
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showPlusMenu();
// Cancel by space
await tester.simulateKeyEvent(
LogicalKeyboardKey.space,
);
await tester.pumpAndSettle();
expect(find.byType(InlineActionsMenu), findsNothing);
});
});
}
@@ -1,14 +1,13 @@
import 'dart:io';
import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/sub_page/sub_page_block_component.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_action_type.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.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';
@@ -1,13 +1,18 @@
import 'package:integration_test/integration_test.dart';
import 'document_block_option_test.dart' as document_block_option_test;
import 'document_find_menu_test.dart' as document_find_menu_test;
import 'document_inline_page_reference_test.dart'
as document_inline_page_reference_test;
import 'document_more_actions_test.dart' as document_more_actions_test;
import 'document_shortcuts_test.dart' as document_shortcuts_test;
import 'document_toolbar_test.dart' as document_toolbar_test;
import 'document_with_file_test.dart' as document_with_file_test;
import 'document_with_image_block_test.dart' as document_with_image_block_test;
import 'document_with_multi_image_block_test.dart'
as document_with_multi_image_block_test;
import 'document_with_simple_table_test.dart'
as document_with_simple_table_test;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
@@ -19,7 +24,8 @@ void main() {
document_more_actions_test.main();
document_with_file_test.main();
document_shortcuts_test.main();
// Disable subPage test temporarily, enable it in version 0.7.2
// document_sub_page_test.main();
document_block_option_test.main();
document_find_menu_test.main();
document_toolbar_test.main();
document_with_simple_table_test.main();
}
@@ -347,5 +347,27 @@ void main() {
await tester.pumpAndSettle();
});
testWidgets('paste text in title, check if the text is updated',
(tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent();
await Clipboard.setData(const ClipboardData(text: _testDocumentName));
final title = tester.editor.findDocumentTitle('');
await tester.tapButton(title);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isMetaPressed: UniversalPlatform.isMacOS,
isControlPressed: !UniversalPlatform.isMacOS,
);
await tester.pumpAndSettle();
final newTitle = tester.editor.findDocumentTitle(_testDocumentName);
expect(newTitle, findsOneWidget);
});
});
}
@@ -0,0 +1,50 @@
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('document toolbar:', () {
testWidgets('font family', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent();
await tester.editor.tapLineOfEditorAt(0);
const text = 'font family';
await tester.ime.insertText(text);
await tester.editor.updateSelection(
Selection.single(
path: [0],
startOffset: 0,
endOffset: text.length,
),
);
// tap the font family button
final fontFamilyButton = find.byKey(kFontFamilyToolbarItemKey);
await tester.tapButton(fontFamilyButton);
// expect to see the font family dropdown immediately
expect(find.byType(FontFamilyDropDown), findsOneWidget);
// click the font family 'Abel'
const abel = 'Abel';
await tester.tapButton(find.text(abel));
// check the text is updated to 'Abel'
final editorState = tester.editor.getCurrentEditorState();
expect(
editorState.getDeltaAttributeValueInSelection(
AppFlowyRichTextKeys.fontFamily,
),
abel,
);
});
});
}
@@ -1,14 +1,21 @@
import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mention/mention_date_block.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy/workspace/application/settings/date_time/date_format_ext.dart';
import 'package:appflowy/workspace/presentation/widgets/date_picker/desktop_date_picker.dart';
import 'package:appflowy/workspace/presentation/widgets/toggle/toggle.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:table_calendar/table_calendar.dart';
import '../../shared/util.dart';
@@ -18,7 +25,7 @@ void main() {
TestWidgetsFlutterBinding.ensureInitialized();
});
group('date or reminder block in document', () {
group('date or reminder block in document:', () {
testWidgets("insert date with time block", (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
@@ -121,5 +128,339 @@ void main() {
expect(find.text('@$formattedDate'), findsOneWidget);
expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsOneWidget);
});
testWidgets("copy, cut and paste a date mention", (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a new document
await tester.createNewPageWithNameUnderParent(
name: 'copy, cut and paste a date mention',
);
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_dateOrReminder.tr(),
);
final dateTimeSettings = DateTimeSettingsPB(
dateFormat: UserDateFormatPB.Friendly,
timeFormat: UserTimeFormatPB.TwentyFourHour,
);
final DateTime currentDateTime = DateTime.now();
final String formattedDate =
dateTimeSettings.dateFormat.formatDate(currentDateTime, false);
// get current date in editor
expect(find.byType(MentionDateBlock), findsOneWidget);
expect(find.text('@$formattedDate'), findsOneWidget);
// update selection and copy
await tester.editor.updateSelection(
Selection(
start: Position(path: [0]),
end: Position(path: [0], offset: 1),
),
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyC,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
// update selection and paste
await tester.editor.updateSelection(
Selection.collapsed(Position(path: [0], offset: 1)),
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(MentionDateBlock), findsNWidgets(2));
expect(find.text('@$formattedDate'), findsNWidgets(2));
// update selection and cut
await tester.editor.updateSelection(
Selection(
start: Position(path: [0], offset: 1),
end: Position(path: [0], offset: 2),
),
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyX,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(MentionDateBlock), findsOneWidget);
expect(find.text('@$formattedDate'), findsOneWidget);
// update selection and paste
await tester.editor.updateSelection(
Selection.collapsed(Position(path: [0], offset: 1)),
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(MentionDateBlock), findsNWidgets(2));
expect(find.text('@$formattedDate'), findsNWidgets(2));
});
testWidgets("copy, cut and paste a reminder mention", (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a new document
await tester.createNewPageWithNameUnderParent(
name: 'copy, cut and paste a reminder mention',
);
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_dateOrReminder.tr(),
);
// trigger popup
await tester.tapButton(find.byType(MentionDateBlock));
await tester.pumpAndSettle();
// set date to be fifteenth of the next month
await tester.tap(
find.descendant(
of: find.byType(DesktopAppFlowyDatePicker),
matching: find.byFlowySvg(FlowySvgs.arrow_right_s),
),
);
await tester.pumpAndSettle();
await tester.tap(
find.descendant(
of: find.byType(TableCalendar),
matching: find.text(15.toString()),
),
);
await tester.pumpAndSettle();
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
// add a reminder
await tester.tap(find.byType(MentionDateBlock));
await tester.pumpAndSettle();
await tester.tap(find.text(LocaleKeys.datePicker_reminderLabel.tr()));
await tester.pumpAndSettle();
await tester.tap(
find.textContaining(
LocaleKeys.datePicker_reminderOptions_oneDayBefore.tr(),
),
);
await tester.pumpAndSettle();
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
// verify
final dateTimeSettings = DateTimeSettingsPB(
dateFormat: UserDateFormatPB.Friendly,
timeFormat: UserTimeFormatPB.TwentyFourHour,
);
final now = DateTime.now();
final fifteenthOfNextMonth = DateTime(now.year, now.month + 1, 15);
final formattedDate =
dateTimeSettings.dateFormat.formatDate(fifteenthOfNextMonth, false);
expect(find.byType(MentionDateBlock), findsOneWidget);
expect(find.text('@$formattedDate'), findsOneWidget);
expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsOneWidget);
expect(getIt<ReminderBloc>().state.reminders.map((e) => e.id).length, 1);
// update selection and copy
await tester.editor.updateSelection(
Selection(
start: Position(path: [0]),
end: Position(path: [0], offset: 1),
),
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyC,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
// update selection and paste
await tester.editor.updateSelection(
Selection.collapsed(Position(path: [0], offset: 1)),
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(MentionDateBlock), findsNWidgets(2));
expect(find.text('@$formattedDate'), findsNWidgets(2));
expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsNWidgets(2));
expect(
getIt<ReminderBloc>().state.reminders.map((e) => e.id).toSet().length,
2,
);
// update selection and cut
await tester.editor.updateSelection(
Selection(
start: Position(path: [0], offset: 1),
end: Position(path: [0], offset: 2),
),
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyX,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(MentionDateBlock), findsOneWidget);
expect(find.text('@$formattedDate'), findsOneWidget);
expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsOneWidget);
expect(getIt<ReminderBloc>().state.reminders.map((e) => e.id).length, 1);
// update selection and paste
await tester.editor.updateSelection(
Selection.collapsed(Position(path: [0], offset: 1)),
);
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyV,
isControlPressed: Platform.isLinux || Platform.isWindows,
isMetaPressed: Platform.isMacOS,
);
await tester.pumpAndSettle();
expect(find.byType(MentionDateBlock), findsNWidgets(2));
expect(find.text('@$formattedDate'), findsNWidgets(2));
expect(find.byType(MentionDateBlock), findsNWidgets(2));
expect(find.text('@$formattedDate'), findsNWidgets(2));
expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsNWidgets(2));
expect(
getIt<ReminderBloc>().state.reminders.map((e) => e.id).toSet().length,
2,
);
});
testWidgets("delete, undo and redo a reminder mention", (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a new document
await tester.createNewPageWithNameUnderParent(
name: 'delete, undo and redo a reminder mention',
);
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_dateOrReminder.tr(),
);
// trigger popup
await tester.tapButton(find.byType(MentionDateBlock));
await tester.pumpAndSettle();
// set date to be fifteenth of the next month
await tester.tap(
find.descendant(
of: find.byType(DesktopAppFlowyDatePicker),
matching: find.byFlowySvg(FlowySvgs.arrow_right_s),
),
);
await tester.pumpAndSettle();
await tester.tap(
find.descendant(
of: find.byType(TableCalendar),
matching: find.text(15.toString()),
),
);
await tester.pumpAndSettle();
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
// add a reminder
await tester.tap(find.byType(MentionDateBlock));
await tester.pumpAndSettle();
await tester.tap(find.text(LocaleKeys.datePicker_reminderLabel.tr()));
await tester.pumpAndSettle();
await tester.tap(
find.textContaining(
LocaleKeys.datePicker_reminderOptions_oneDayBefore.tr(),
),
);
await tester.pumpAndSettle();
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
await tester.pumpAndSettle();
// verify
final dateTimeSettings = DateTimeSettingsPB(
dateFormat: UserDateFormatPB.Friendly,
timeFormat: UserTimeFormatPB.TwentyFourHour,
);
final now = DateTime.now();
final fifteenthOfNextMonth = DateTime(now.year, now.month + 1, 15);
final formattedDate =
dateTimeSettings.dateFormat.formatDate(fifteenthOfNextMonth, false);
expect(find.byType(MentionDateBlock), findsOneWidget);
expect(find.text('@$formattedDate'), findsOneWidget);
expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsOneWidget);
expect(getIt<ReminderBloc>().state.reminders.map((e) => e.id).length, 1);
// update selection and backspace to delete the mention
await tester.editor.updateSelection(
Selection.collapsed(Position(path: [0], offset: 1)),
);
await tester.simulateKeyEvent(LogicalKeyboardKey.backspace);
await tester.pumpAndSettle();
expect(find.byType(MentionDateBlock), findsNothing);
expect(find.text('@$formattedDate'), findsNothing);
expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsNothing);
expect(getIt<ReminderBloc>().state.reminders.isEmpty, isTrue);
// undo
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyZ,
isControlPressed: Platform.isWindows || Platform.isLinux,
isMetaPressed: Platform.isMacOS,
);
expect(find.byType(MentionDateBlock), findsOneWidget);
expect(find.text('@$formattedDate'), findsOneWidget);
expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsOneWidget);
expect(getIt<ReminderBloc>().state.reminders.map((e) => e.id).length, 1);
// redo
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyZ,
isControlPressed: Platform.isWindows || Platform.isLinux,
isMetaPressed: Platform.isMacOS,
isShiftPressed: true,
);
expect(find.byType(MentionDateBlock), findsNothing);
expect(find.text('@$formattedDate'), findsNothing);
expect(find.byFlowySvg(FlowySvgs.reminder_clock_s), findsNothing);
expect(getIt<ReminderBloc>().state.reminders.isEmpty, isTrue);
});
});
}
@@ -24,11 +24,70 @@ import 'package:run_with_network_images/run_with_network_images.dart';
import '../../shared/mock/mock_file_picker.dart';
import '../../shared/util.dart';
const _testImageUrls = [
'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',
'https://www.easygifanimator.net/images/samples/eglite.gif',
'https://people.math.sc.edu/Burkardt/data/bmp/snail.bmp',
'https://file-examples.com/storage/fe9566cb7d67345489a5a97/2017/10/file_example_JPG_100kB.jpg',
];
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
TestWidgetsFlutterBinding.ensureInitialized();
group('image block in document', () {
Future<void> testEmbedImage(WidgetTester tester, String url) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a new document
await tester.createNewPageWithNameUnderParent(
name: LocaleKeys.document_plugins_image_addAnImageDesktop.tr(),
);
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_image.tr(),
);
expect(find.byType(CustomImageBlockComponent), findsOneWidget);
expect(find.byType(ImagePlaceholder), findsOneWidget);
expect(
find.descendant(
of: find.byType(ImagePlaceholder),
matching: find.byType(AppFlowyPopover),
),
findsOneWidget,
);
expect(find.byType(UploadImageMenu), findsOneWidget);
await tester.tapButtonWithName(
LocaleKeys.document_imageBlock_embedLink_label.tr(),
);
await tester.enterText(
find.descendant(
of: find.byType(EmbedImageUrlWidget),
matching: find.byType(TextField),
),
url,
);
await tester.tapButton(
find.descendant(
of: find.byType(EmbedImageUrlWidget),
matching: find.text(
LocaleKeys.document_imageBlock_embedLink_label.tr(),
findRichText: true,
),
),
);
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], url);
}
testWidgets('insert an image from local file', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
@@ -79,59 +138,11 @@ void main() {
file.deleteSync();
});
testWidgets('insert an image from network', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a new document
await tester.createNewPageWithNameUnderParent(
name: LocaleKeys.document_plugins_image_addAnImageDesktop.tr(),
);
// tap the first line of the document
await tester.editor.tapLineOfEditorAt(0);
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_image.tr(),
);
expect(find.byType(CustomImageBlockComponent), findsOneWidget);
expect(find.byType(ImagePlaceholder), findsOneWidget);
expect(
find.descendant(
of: find.byType(ImagePlaceholder),
matching: find.byType(AppFlowyPopover),
),
findsOneWidget,
);
expect(find.byType(UploadImageMenu), findsOneWidget);
await tester.tapButtonWithName(
LocaleKeys.document_imageBlock_embedLink_label.tr(),
);
const url =
'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.enterText(
find.descendant(
of: find.byType(EmbedImageUrlWidget),
matching: find.byType(TextField),
),
url,
);
await tester.tapButton(
find.descendant(
of: find.byType(EmbedImageUrlWidget),
matching: find.text(
LocaleKeys.document_imageBlock_embedLink_label.tr(),
findRichText: true,
),
),
);
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], url);
});
for (final url in _testImageUrls) {
testWidgets('insert an image from network: $url', (tester) async {
await testEmbedImage(tester, url);
});
}
testWidgets('insert an image from unsplash', (tester) async {
await runWithNetworkImages(() async {
@@ -48,7 +48,7 @@ void main() {
await tester.editor.showSlashMenu();
await tester.editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_photoGallery.tr(),
offset: 100,
offset: 80,
);
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: 100,
offset: 80,
);
expect(find.byType(MultiImageBlockComponent), findsOneWidget);
expect(find.byType(MultiImagePlaceholder), findsOneWidget);
@@ -1,6 +1,7 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
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';
@@ -43,6 +44,9 @@ void main() {
* # Heading 1
* ## Heading 2
* ### Heading 3
* > # Heading 1
* > ## Heading 2
* > ### Heading 3
*/
await tester.editor.tapLineOfEditorAt(3);
@@ -53,7 +57,7 @@ void main() {
of: find.byType(OutlineBlockWidget),
matching: find.text(heading1),
),
findsOneWidget,
findsNWidgets(2),
);
// Heading 2 is prefixed with a bullet
@@ -62,7 +66,7 @@ void main() {
of: find.byType(OutlineBlockWidget),
matching: find.text(heading2),
),
findsOneWidget,
findsNWidgets(2),
);
// Heading 3 is prefixed with a dash
@@ -71,7 +75,7 @@ void main() {
of: find.byType(OutlineBlockWidget),
matching: find.text(heading3),
),
findsOneWidget,
findsNWidgets(2),
);
// update the Heading 1 to Heading 1Hello world
@@ -99,13 +103,16 @@ void main() {
* # Heading 1
* ## Heading 2
* ### Heading 3
* > # Heading 1
* > ## Heading 2
* > ### Heading 3
*/
await tester.editor.tapLineOfEditorAt(3);
await tester.editor.tapLineOfEditorAt(7);
await insertOutlineInDocument(tester);
// expect to find only the `heading1` widget under the [OutlineBlockWidget]
await hoverAndClickDepthOptionAction(tester, [3], 1);
await hoverAndClickDepthOptionAction(tester, [6], 1);
expect(
find.descendant(
of: find.byType(OutlineBlockWidget),
@@ -123,7 +130,7 @@ void main() {
//////
/// expect to find only the 'heading1' and 'heading2' under the [OutlineBlockWidget]
await hoverAndClickDepthOptionAction(tester, [3], 2);
await hoverAndClickDepthOptionAction(tester, [6], 2);
expect(
find.descendant(
of: find.byType(OutlineBlockWidget),
@@ -134,13 +141,13 @@ void main() {
//////
// expect to find all the headings under the [OutlineBlockWidget]
await hoverAndClickDepthOptionAction(tester, [3], 3);
await hoverAndClickDepthOptionAction(tester, [6], 3);
expect(
find.descendant(
of: find.byType(OutlineBlockWidget),
matching: find.text(heading1),
),
findsOneWidget,
findsNWidgets(2),
);
expect(
@@ -148,7 +155,7 @@ void main() {
of: find.byType(OutlineBlockWidget),
matching: find.text(heading2),
),
findsOneWidget,
findsNWidgets(2),
);
expect(
@@ -156,7 +163,7 @@ void main() {
of: find.byType(OutlineBlockWidget),
matching: find.text(heading3),
),
findsOneWidget,
findsNWidgets(2),
);
//////
});
@@ -186,7 +193,17 @@ Future<void> hoverAndClickDepthOptionAction(
Future<void> insertHeadingComponent(WidgetTester tester) async {
await tester.editor.tapLineOfEditorAt(0);
// # heading 1-3
await tester.ime.insertText('# $heading1\n');
await tester.ime.insertText('## $heading2\n');
await tester.ime.insertText('### $heading3\n');
// > # toggle heading 1-3
await tester.ime.insertText('> # $heading1\n');
await tester.simulateKeyEvent(LogicalKeyboardKey.backspace);
await tester.ime.insertText('> ## $heading2\n');
await tester.simulateKeyEvent(LogicalKeyboardKey.backspace);
await tester.ime.insertText('> ### $heading3\n');
await tester.simulateKeyEvent(LogicalKeyboardKey.backspace);
}
@@ -0,0 +1,392 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/_shared_widget.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/simple_table/simple_table.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
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:universal_platform/universal_platform.dart';
import '../../shared/util.dart';
const String heading1 = "Heading 1";
const String heading2 = "Heading 2";
const String heading3 = "Heading 3";
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('simple table block test:', () {
testWidgets('insert a simple table block', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
// validate the table is inserted
expect(find.byType(SimpleTableBlockWidget), findsOneWidget);
final editorState = tester.editor.getCurrentEditorState();
expect(
editorState.selection,
// table -> row -> cell -> paragraph
Selection.collapsed(Position(path: [0, 0, 0, 0])),
);
final firstCell = find.byType(SimpleTableCellBlockWidget).first;
expect(
tester
.state<SimpleTableCellBlockWidgetState>(firstCell)
.isEditingCellNotifier
.value,
isTrue,
);
});
testWidgets('select all in table cell', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
const cell1Content = 'Cell 1';
await tester.editor.tapLineOfEditorAt(0);
await tester.ime.insertText('New Table');
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
await tester.pumpAndSettle();
await tester.editor.tapLineOfEditorAt(1);
await tester.insertTableInDocument();
await tester.ime.insertText(cell1Content);
await tester.pumpAndSettle();
// Select all in the cell
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyA,
isControlPressed: !UniversalPlatform.isMacOS,
isMetaPressed: UniversalPlatform.isMacOS,
);
expect(
tester.editor.getCurrentEditorState().selection,
Selection(
start: Position(path: [1, 0, 0, 0]),
end: Position(path: [1, 0, 0, 0], offset: cell1Content.length),
),
);
// Press select all again, the selection should be the entire document
await tester.simulateKeyEvent(
LogicalKeyboardKey.keyA,
isControlPressed: !UniversalPlatform.isMacOS,
isMetaPressed: UniversalPlatform.isMacOS,
);
expect(
tester.editor.getCurrentEditorState().selection,
Selection(
start: Position(path: [0]),
end: Position(path: [1, 1, 1, 0]),
),
);
});
testWidgets('''
1. hover on the table
1.1 click the add row button
1.2 click the add column button
1.3 click the add row and column button
2. validate the table is updated
3. delete the last column
4. delete the last row
5. validate the table is updated
''', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
// add a new row
final row = find.byWidgetPredicate((w) {
return w is SimpleTableRowBlockWidget && w.node.rowIndex == 1;
});
await tester.hoverOnWidget(
row,
onHover: () async {
final addRowButton = find.byType(SimpleTableAddRowButton).first;
await tester.tap(addRowButton);
},
);
await tester.pumpAndSettle();
// add a new column
final column = find.byWidgetPredicate((w) {
return w is SimpleTableCellBlockWidget && w.node.columnIndex == 1;
}).first;
await tester.hoverOnWidget(
column,
onHover: () async {
final addColumnButton = find.byType(SimpleTableAddColumnButton).first;
await tester.tap(addColumnButton);
},
);
await tester.pumpAndSettle();
// add a new row and a new column
final row2 = find.byWidgetPredicate((w) {
return w is SimpleTableCellBlockWidget &&
w.node.rowIndex == 2 &&
w.node.columnIndex == 2;
}).first;
await tester.hoverOnWidget(
row2,
onHover: () async {
// click the add row and column button
final addRowAndColumnButton =
find.byType(SimpleTableAddColumnAndRowButton).first;
await tester.tap(addRowAndColumnButton);
},
);
await tester.pumpAndSettle();
final tableNode =
tester.editor.getCurrentEditorState().document.nodeAtPath([0])!;
expect(tableNode.columnLength, 4);
expect(tableNode.rowLength, 4);
// delete the last row
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.row,
index: tableNode.rowLength - 1,
action: SimpleTableMoreAction.delete,
);
await tester.pumpAndSettle();
expect(tableNode.rowLength, 3);
expect(tableNode.columnLength, 4);
// delete the last column
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.column,
index: tableNode.columnLength - 1,
action: SimpleTableMoreAction.delete,
);
await tester.pumpAndSettle();
expect(tableNode.columnLength, 3);
expect(tableNode.rowLength, 3);
});
testWidgets('enable header column and header row', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
// enable the header row
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.row,
index: 0,
action: SimpleTableMoreAction.enableHeaderRow,
);
await tester.pumpAndSettle();
// enable the header column
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.column,
index: 0,
action: SimpleTableMoreAction.enableHeaderColumn,
);
await tester.pumpAndSettle();
final tableNode =
tester.editor.getCurrentEditorState().document.nodeAtPath([0])!;
expect(tableNode.isHeaderColumnEnabled, isTrue);
expect(tableNode.isHeaderRowEnabled, isTrue);
// disable the header row
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.row,
index: 0,
action: SimpleTableMoreAction.enableHeaderRow,
);
await tester.pumpAndSettle();
expect(tableNode.isHeaderColumnEnabled, isTrue);
expect(tableNode.isHeaderRowEnabled, isFalse);
// disable the header column
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.column,
index: 0,
action: SimpleTableMoreAction.enableHeaderColumn,
);
await tester.pumpAndSettle();
expect(tableNode.isHeaderColumnEnabled, isFalse);
expect(tableNode.isHeaderRowEnabled, isFalse);
});
testWidgets('duplicate a column / row', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
// duplicate the row
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.row,
index: 0,
action: SimpleTableMoreAction.duplicate,
);
await tester.pumpAndSettle();
// duplicate the column
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.column,
index: 0,
action: SimpleTableMoreAction.duplicate,
);
await tester.pumpAndSettle();
final tableNode =
tester.editor.getCurrentEditorState().document.nodeAtPath([0])!;
expect(tableNode.columnLength, 3);
expect(tableNode.rowLength, 3);
});
testWidgets('insert left / insert right', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
// insert left
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.column,
index: 0,
action: SimpleTableMoreAction.insertLeft,
);
await tester.pumpAndSettle();
// insert right
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.column,
index: 0,
action: SimpleTableMoreAction.insertRight,
);
await tester.pumpAndSettle();
final tableNode =
tester.editor.getCurrentEditorState().document.nodeAtPath([0])!;
expect(tableNode.columnLength, 4);
expect(tableNode.rowLength, 2);
});
testWidgets('insert above / insert below', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(
name: 'simple_table_test',
);
await tester.editor.tapLineOfEditorAt(0);
await tester.insertTableInDocument();
// insert above
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.row,
index: 0,
action: SimpleTableMoreAction.insertAbove,
);
await tester.pumpAndSettle();
// insert below
await tester.clickMoreActionItemInTableMenu(
type: SimpleTableMoreActionType.row,
index: 0,
action: SimpleTableMoreAction.insertBelow,
);
await tester.pumpAndSettle();
final tableNode =
tester.editor.getCurrentEditorState().document.nodeAtPath([0])!;
expect(tableNode.rowLength, 4);
expect(tableNode.columnLength, 2);
});
});
}
extension on WidgetTester {
/// Insert a table in the document
Future<void> insertTableInDocument() async {
// open the actions menu and insert the outline block
await editor.showSlashMenu();
await editor.tapSlashMenuItemWithName(
LocaleKeys.document_slashMenu_name_table.tr(),
);
await pumpAndSettle();
}
Future<void> clickMoreActionItemInTableMenu({
required SimpleTableMoreActionType type,
required int index,
required SimpleTableMoreAction action,
}) async {
if (type == SimpleTableMoreActionType.row) {
final row = find.byWidgetPredicate((w) {
return w is SimpleTableRowBlockWidget && w.node.rowIndex == index;
});
await hoverOnWidget(
row,
onHover: () async {
final moreActionButton = find.byWidgetPredicate((w) {
return w is SimpleTableMoreActionMenu &&
w.type == SimpleTableMoreActionType.row &&
w.index == index;
});
await tapButton(moreActionButton);
await tapButton(find.text(action.name));
},
);
await pumpAndSettle();
} else if (type == SimpleTableMoreActionType.column) {
final column = find.byWidgetPredicate((w) {
return w is SimpleTableCellBlockWidget && w.node.columnIndex == index;
}).first;
await hoverOnWidget(
column,
onHover: () async {
final moreActionButton = find.byWidgetPredicate((w) {
return w is SimpleTableMoreActionMenu &&
w.type == SimpleTableMoreActionType.column &&
w.index == index;
});
await tapButton(moreActionButton);
await tapButton(find.text(action.name));
},
);
await pumpAndSettle();
}
await tapAt(Offset.zero);
}
}
@@ -1,7 +1,9 @@
import 'dart:io';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -263,5 +265,24 @@ void main() {
expect(node.attributes[ToggleListBlockKeys.level], 3);
expect(node.delta!.toPlainText(), 'Hello');
});
testWidgets('click the toggle list to create a new paragraph',
(tester) async {
await prepareToggleHeadingBlock(tester, '> # Hello');
final emptyHintText = find.text(
LocaleKeys.document_plugins_emptyToggleHeading.tr(
args: ['1'],
),
);
expect(emptyHintText, findsOneWidget);
await tester.tapButton(emptyHintText);
await tester.pumpAndSettle();
// check the new paragraph is created
final editorState = tester.editor.getCurrentEditorState();
final node = editorState.getNodeAtPath([0, 0])!;
expect(node.type, ParagraphBlockKeys.type);
});
});
}
@@ -1,5 +1,6 @@
import 'dart:io';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -87,7 +88,7 @@ void main() {
);
expect(
importedPageEditorState.getNodeAtPath([2])!.type,
TableBlockKeys.type,
SimpleTableBlockKeys.type,
);
});
});
@@ -1,17 +1,19 @@
import 'dart:io';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/home/tabs/flowy_tab.dart';
import 'package:appflowy/workspace/presentation/home/tabs/tabs_manager.dart';
import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.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:integration_test/integration_test.dart';
import '../../shared/base.dart';
import '../../shared/common_operations.dart';
import '../../shared/expectation.dart';
import '../../shared/keyboard.dart';
import '../../shared/util.dart';
const _documentName = 'First Doc';
const _documentTwoName = 'Second Doc';
@@ -20,17 +22,12 @@ void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('Tabs', () {
testWidgets('Open AppFlowy and open/navigate/close tabs', (tester) async {
testWidgets('open/navigate/close tabs', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
expect(
find.descendant(
of: find.byType(TabsManager),
matching: find.byType(TabBar),
),
findsNothing,
);
// No tabs rendered yet
expect(find.byType(FlowyTab), findsNothing);
await tester.createNewPageWithNameUnderParent(name: _documentName);
@@ -44,7 +41,7 @@ void main() {
expect(
find.descendant(
of: find.byType(TabBar),
of: find.byType(TabsManager),
matching: find.byType(FlowyTab),
),
findsNWidgets(3),
@@ -71,11 +68,267 @@ void main() {
expect(
find.descendant(
of: find.byType(TabBar),
of: find.byType(TabsManager),
matching: find.byType(FlowyTab),
),
findsNWidgets(2),
);
});
testWidgets('right click show tab menu, close others', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
expect(
find.descendant(
of: find.byType(TabsManager),
matching: find.byType(TabBar),
),
findsNothing,
);
await tester.createNewPageWithNameUnderParent(name: _documentName);
await tester.createNewPageWithNameUnderParent(name: _documentTwoName);
/// Open second menu item in a new tab
await tester.openAppInNewTab(gettingStarted, ViewLayoutPB.Document);
/// Open third menu item in a new tab
await tester.openAppInNewTab(_documentName, ViewLayoutPB.Document);
expect(
find.descendant(
of: find.byType(TabsManager),
matching: find.byType(FlowyTab),
),
findsNWidgets(3),
);
/// Right click on second tab
await tester.tap(
buttons: kSecondaryButton,
find.descendant(
of: find.byType(FlowyTab),
matching: find.text(gettingStarted),
),
);
await tester.pumpAndSettle();
expect(find.byType(TabMenu), findsOneWidget);
final firstTabFinder = find.descendant(
of: find.byType(FlowyTab),
matching: find.text(_documentTwoName),
);
final secondTabFinder = find.descendant(
of: find.byType(FlowyTab),
matching: find.text(gettingStarted),
);
final thirdTabFinder = find.descendant(
of: find.byType(FlowyTab),
matching: find.text(_documentName),
);
expect(firstTabFinder, findsOneWidget);
expect(secondTabFinder, findsOneWidget);
expect(thirdTabFinder, findsOneWidget);
// Close other tabs than the second item
await tester.tap(find.text(LocaleKeys.tabMenu_closeOthers.tr()));
await tester.pumpAndSettle();
// We expect to not find any tabs
expect(firstTabFinder, findsNothing);
expect(secondTabFinder, findsNothing);
expect(thirdTabFinder, findsNothing);
// Expect second tab to be current page (current page has breadcrumb, cover title,
// and in this case view name in sidebar)
expect(find.text(gettingStarted), findsNWidgets(3));
});
testWidgets('cannot close pinned tabs', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
expect(
find.descendant(
of: find.byType(TabsManager),
matching: find.byType(TabBar),
),
findsNothing,
);
await tester.createNewPageWithNameUnderParent(name: _documentName);
await tester.createNewPageWithNameUnderParent(name: _documentTwoName);
// Open second menu item in a new tab
await tester.openAppInNewTab(gettingStarted, ViewLayoutPB.Document);
// Open third menu item in a new tab
await tester.openAppInNewTab(_documentName, ViewLayoutPB.Document);
expect(
find.descendant(
of: find.byType(TabsManager),
matching: find.byType(FlowyTab),
),
findsNWidgets(3),
);
const firstTab = _documentTwoName;
const secondTab = gettingStarted;
const thirdTab = _documentName;
expect(tester.isTabAtIndex(firstTab, 0), isTrue);
expect(tester.isTabAtIndex(secondTab, 1), isTrue);
expect(tester.isTabAtIndex(thirdTab, 2), isTrue);
expect(tester.isTabPinned(gettingStarted), isFalse);
// Right click on second tab
await tester.openTabMenu(gettingStarted);
expect(find.byType(TabMenu), findsOneWidget);
// Pin second tab
await tester.tap(find.text(LocaleKeys.tabMenu_pinTab.tr()));
await tester.pumpAndSettle();
expect(tester.isTabPinned(gettingStarted), isTrue);
/// Right click on first unpinned tab (second tab)
await tester.openTabMenu(_documentTwoName);
// Close others
await tester.tap(find.text(LocaleKeys.tabMenu_closeOthers.tr()));
await tester.pumpAndSettle();
// We expect to find 2 tabs, the first pinned tab and the second tab
expect(find.byType(FlowyTab), findsNWidgets(2));
expect(tester.isTabAtIndex(gettingStarted, 0), isTrue);
expect(tester.isTabAtIndex(_documentTwoName, 1), isTrue);
});
testWidgets('pin/unpin tabs proper order', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
expect(
find.descendant(
of: find.byType(TabsManager),
matching: find.byType(TabBar),
),
findsNothing,
);
await tester.createNewPageWithNameUnderParent(name: _documentName);
await tester.createNewPageWithNameUnderParent(name: _documentTwoName);
// Open second menu item in a new tab
await tester.openAppInNewTab(gettingStarted, ViewLayoutPB.Document);
// Open third menu item in a new tab
await tester.openAppInNewTab(_documentName, ViewLayoutPB.Document);
expect(
find.descendant(
of: find.byType(TabsManager),
matching: find.byType(FlowyTab),
),
findsNWidgets(3),
);
const firstTabName = _documentTwoName;
const secondTabName = gettingStarted;
const thirdTabName = _documentName;
// Expect correct order
expect(tester.isTabAtIndex(firstTabName, 0), isTrue);
expect(tester.isTabAtIndex(secondTabName, 1), isTrue);
expect(tester.isTabAtIndex(thirdTabName, 2), isTrue);
// Pin second tab
await tester.openTabMenu(secondTabName);
await tester.tap(find.text(LocaleKeys.tabMenu_pinTab.tr()));
await tester.pumpAndSettle();
expect(tester.isTabPinned(secondTabName), isTrue);
// Expect correct order
expect(tester.isTabAtIndex(secondTabName, 0), isTrue);
expect(tester.isTabAtIndex(firstTabName, 1), isTrue);
expect(tester.isTabAtIndex(thirdTabName, 2), isTrue);
// Pin new second tab (first tab)
await tester.openTabMenu(firstTabName);
await tester.tap(find.text(LocaleKeys.tabMenu_pinTab.tr()));
await tester.pumpAndSettle();
expect(tester.isTabPinned(firstTabName), isTrue);
expect(tester.isTabPinned(secondTabName), isTrue);
expect(tester.isTabPinned(thirdTabName), isFalse);
expect(tester.isTabAtIndex(secondTabName, 0), isTrue);
expect(tester.isTabAtIndex(firstTabName, 1), isTrue);
expect(tester.isTabAtIndex(thirdTabName, 2), isTrue);
// Unpin second tab
await tester.openTabMenu(secondTabName);
await tester.tap(find.text(LocaleKeys.tabMenu_unpinTab.tr()));
await tester.pumpAndSettle();
expect(tester.isTabPinned(firstTabName), isTrue);
expect(tester.isTabPinned(secondTabName), isFalse);
expect(tester.isTabPinned(thirdTabName), isFalse);
expect(tester.isTabAtIndex(firstTabName, 0), isTrue);
expect(tester.isTabAtIndex(secondTabName, 1), isTrue);
expect(tester.isTabAtIndex(thirdTabName, 2), isTrue);
});
});
}
extension _TabsTester on WidgetTester {
bool isTabPinned(String tabName) {
final tabFinder = find.ancestor(
of: find.byWidgetPredicate(
(w) => w is ViewTabBarItem && w.view.name == tabName,
),
matching: find.byType(FlowyTab),
);
final FlowyTab tabWidget = widget(tabFinder);
return tabWidget.pageManager.isPinned;
}
bool isTabAtIndex(String tabName, int index) {
final tabFinder = find.ancestor(
of: find.byWidgetPredicate(
(w) => w is ViewTabBarItem && w.view.name == tabName,
),
matching: find.byType(FlowyTab),
);
final pluginId = (widget(tabFinder) as FlowyTab).pageManager.plugin.id;
final pluginIds = find
.byType(FlowyTab)
.evaluate()
.map((e) => (e.widget as FlowyTab).pageManager.plugin.id);
return pluginIds.elementAt(index) == pluginId;
}
Future<void> openTabMenu(String tabName) async {
await tap(
buttons: kSecondaryButton,
find.ancestor(
of: find.byWidgetPredicate(
(w) => w is ViewTabBarItem && w.view.name == tabName,
),
matching: find.byType(FlowyTab),
),
);
await pumpAndSettle();
}
}
@@ -4,7 +4,6 @@ import 'emoji_shortcut_test.dart' as emoji_shortcut_test;
import 'hotkeys_test.dart' as hotkeys_test;
import 'import_files_test.dart' as import_files_test;
import 'share_markdown_test.dart' as share_markdown_test;
import 'tabs_test.dart' as tabs_test;
import 'zoom_in_out_test.dart' as zoom_in_out_test;
void main() {
@@ -17,7 +16,6 @@ void main() {
emoji_shortcut_test.main();
share_markdown_test.main();
import_files_test.main();
tabs_test.main();
zoom_in_out_test.main();
// DON'T add more tests here.
}
@@ -0,0 +1,16 @@
import 'package:integration_test/integration_test.dart';
import 'desktop/uncategorized/tabs_test.dart' as tabs_test;
import 'desktop/first_test/first_test.dart' as first_test;
Future<void> main() async {
await runIntegration9OnDesktop();
}
Future<void> runIntegration9OnDesktop() async {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
first_test.main();
tabs_test.main();
}
@@ -1,9 +1,11 @@
import 'document/publish_test.dart' as publish_test;
import 'document/share_link_test.dart' as share_link_test;
import 'space/space_test.dart' as space_test;
import 'workspace/workspace_operations_test.dart' as workspace_operations_test;
Future<void> main() async {
workspace_operations_test.main();
share_link_test.main();
publish_test.main();
space_test.main();
}
@@ -1,47 +1,23 @@
// ignore_for_file: unused_import
import 'dart:io';
import 'dart:math';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart';
import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart';
import 'package:appflowy/mobile/presentation/home/home.dart';
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_layout.dart';
import 'package:appflowy/shared/patterns/common_patterns.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy/mobile/presentation/home/workspaces/create_workspace_menu.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.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';
import 'package:path/path.dart' as p;
import '../../../shared/constants.dart';
import '../../../shared/dir.dart';
import '../../../shared/mock/mock_file_picker.dart';
import '../../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('publish:', () {
testWidgets('publish document', (tester) async {
testWidgets('''
1. publish document
2. update path name
3. unpublish document
''', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
@@ -75,6 +51,51 @@ void main() {
findsOneWidget,
);
// update the path name
await tester.editor.clickMoreActionItemOnMobile(
LocaleKeys.shareAction_updatePathName.tr(),
);
const pathName1 = '???????????????';
const pathName2 = 'AppFlowy';
final textField = find.descendant(
of: find.byType(EditWorkspaceNameBottomSheet),
matching: find.byType(TextFormField),
);
await tester.enterText(textField, pathName1);
await tester.pumpAndSettle();
// wait 50ms to ensure the error message is shown
await tester.wait(50);
// click the confirm button
final confirmButton = find.text(LocaleKeys.button_confirm.tr());
await tester.tapButton(confirmButton);
// expect to see the update path name failed toast
final updatePathFailedText = find.text(
LocaleKeys.settings_sites_error_publishNameContainsInvalidCharacters
.tr(),
);
expect(updatePathFailedText, findsOneWidget);
// input the valid path name
await tester.enterText(textField, pathName2);
await tester.pumpAndSettle();
// click the confirm button
await tester.tapButton(confirmButton);
// wait 50ms to ensure the error message is shown
await tester.wait(50);
// expect to see the update path name success toast
final updatePathSuccessText = find.findTextInFlowyText(
LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(),
);
expect(updatePathSuccessText, findsOneWidget);
await tester.pumpUntilNotFound(updatePathSuccessText);
// unpublish the document
await tester.editor.clickMoreActionItemOnMobile(
LocaleKeys.shareAction_unPublish.tr(),
@@ -1,39 +1,12 @@
// ignore_for_file: unused_import
import 'dart:io';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart';
import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart';
import 'package:appflowy/mobile/presentation/home/home.dart';
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_layout.dart';
import 'package:appflowy/shared/patterns/common_patterns.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.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';
import 'package:path/path.dart' as p;
import '../../../shared/constants.dart';
import '../../../shared/dir.dart';
import '../../../shared/mock/mock_file_picker.dart';
import '../../../shared/util.dart';
void main() {
@@ -0,0 +1,287 @@
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/home/space/manage_space_widget.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/home/space/space_menu_bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/home/space/widgets.dart';
import 'package:appflowy/mobile/presentation/home/workspaces/create_workspace_menu.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/cupertino.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();
group('space operations:', () {
Future<void> openSpaceMenu(WidgetTester tester) async {
final spaceHeader = find.byType(MobileSpaceHeader);
await tester.tapButton(spaceHeader);
await tester.pumpUntilFound(find.byType(MobileSpaceMenu));
}
Future<void> openSpaceMenuMoreOptions(
WidgetTester tester,
ViewPB space,
) async {
final spaceMenuItemTrailing = find.byWidgetPredicate(
(w) => w is SpaceMenuItemTrailing && w.space.id == space.id,
);
final moreOptions = find.descendant(
of: spaceMenuItemTrailing,
matching: find.byWidgetPredicate(
(w) =>
w is FlowySvg &&
w.svg.path == FlowySvgs.workspace_three_dots_s.path,
),
);
await tester.tapButton(moreOptions);
await tester.pumpUntilFound(find.byType(SpaceMenuMoreOptions));
}
// combine the tests together to reduce the CI time
testWidgets('''
1. create a new space
2. update the space name
3. update the space permission
4. update the space icon
5. delete the space
''', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
// 1. create a new space
// click the space menu
await openSpaceMenu(tester);
// click the create a new space button
final createNewSpaceButton = find.text(
LocaleKeys.space_createNewSpace.tr(),
);
await tester.pumpUntilFound(createNewSpaceButton);
await tester.tapButton(createNewSpaceButton);
// input the new space name
final inputField = find.descendant(
of: find.byType(ManageSpaceWidget),
matching: find.byType(TextField),
);
const newSpaceName = 'AppFlowy';
await tester.enterText(inputField, newSpaceName);
await tester.pumpAndSettle();
// change the space permission to private
final permissionOption = find.byType(ManageSpacePermissionOption);
await tester.tapButton(permissionOption);
await tester.pumpAndSettle();
final privateOption = find.text(LocaleKeys.space_privatePermission.tr());
await tester.tapButton(privateOption);
await tester.pumpAndSettle();
// change the space icon color
final color = builtInSpaceColors[1];
final iconOption = find.descendant(
of: find.byType(ManageSpaceIconOption),
matching: find.byWidgetPredicate(
(w) => w is SpaceColorItem && w.color == color,
),
);
await tester.tapButton(iconOption);
await tester.pumpAndSettle();
// change the space icon
final icon = kIconGroups![0].icons[1];
final iconItem = find.descendant(
of: find.byType(ManageSpaceIconOption),
matching: find.byWidgetPredicate(
(w) => w is SpaceIconItem && w.icon == icon,
),
);
await tester.tapButton(iconItem);
await tester.pumpAndSettle();
// click the done button
final doneButton = find.descendant(
of: find.byWidgetPredicate(
(w) =>
w is BottomSheetHeader &&
w.title == LocaleKeys.space_createSpace.tr(),
),
matching: find.text(LocaleKeys.button_done.tr()),
);
await tester.tapButton(doneButton);
await tester.pumpAndSettle();
// wait 100ms for the space to be created
await tester.wait(100);
// verify the space is created
await openSpaceMenu(tester);
final spaceItems = find.byType(MobileSpaceMenuItem);
// expect to see 3 space items, 2 are built-in, 1 is the new space
expect(spaceItems, findsNWidgets(3));
// convert the space item to a widget
final spaceWidget =
tester.widgetList<MobileSpaceMenuItem>(spaceItems).last;
final space = spaceWidget.space;
expect(space.name, newSpaceName);
expect(space.spacePermission, SpacePermission.private);
expect(space.spaceIcon, icon.iconPath);
expect(space.spaceIconColor, color);
// open the SpaceMenuMoreOptions menu
await openSpaceMenuMoreOptions(tester, space);
// 2. rename the space name
final renameOption = find.text(LocaleKeys.button_rename.tr());
await tester.tapButton(renameOption);
await tester.pumpUntilFound(find.byType(EditWorkspaceNameBottomSheet));
// input the new space name
final renameInputField = find.descendant(
of: find.byType(EditWorkspaceNameBottomSheet),
matching: find.byType(TextField),
);
const renameSpaceName = 'HelloWorld';
await tester.enterText(renameInputField, renameSpaceName);
await tester.pumpAndSettle();
await tester.tapButton(find.text(LocaleKeys.button_confirm.tr()));
// click the done button
await tester.pumpAndSettle();
final renameSuccess = find.text(
LocaleKeys.space_success_renameSpace.tr(),
);
await tester.pumpUntilNotFound(renameSuccess);
// check the space name is updated
await openSpaceMenu(tester);
final renameSpaceItem = find.descendant(
of: find.byType(MobileSpaceMenuItem),
matching: find.text(renameSpaceName),
);
expect(renameSpaceItem, findsOneWidget);
// 3. manage the space
await openSpaceMenuMoreOptions(tester, space);
final manageOption = find.text(LocaleKeys.space_manage.tr());
await tester.tapButton(manageOption);
await tester.pumpUntilFound(find.byType(ManageSpaceWidget));
// 3.1 rename the space
final textField = find.descendant(
of: find.byType(ManageSpaceWidget),
matching: find.byType(TextField),
);
await tester.enterText(textField, 'AppFlowy');
await tester.pumpAndSettle();
// 3.2 change the permission
final permissionOption2 = find.byType(ManageSpacePermissionOption);
await tester.tapButton(permissionOption2);
await tester.pumpAndSettle();
final publicOption = find.text(LocaleKeys.space_publicPermission.tr());
await tester.tapButton(publicOption);
await tester.pumpAndSettle();
// 3.3 change the icon
// change the space icon color
final color2 = builtInSpaceColors[2];
final iconOption2 = find.descendant(
of: find.byType(ManageSpaceIconOption),
matching: find.byWidgetPredicate(
(w) => w is SpaceColorItem && w.color == color2,
),
);
await tester.tapButton(iconOption2);
await tester.pumpAndSettle();
// change the space icon
final icon2 = kIconGroups![0].icons[2];
final iconItem2 = find.descendant(
of: find.byType(ManageSpaceIconOption),
matching: find.byWidgetPredicate(
(w) => w is SpaceIconItem && w.icon == icon2,
),
);
await tester.tapButton(iconItem2);
await tester.pumpAndSettle();
// click the done button
final doneButton2 = find.descendant(
of: find.byWidgetPredicate(
(w) =>
w is BottomSheetHeader &&
w.title == LocaleKeys.space_manageSpace.tr(),
),
matching: find.text(LocaleKeys.button_done.tr()),
);
await tester.tapButton(doneButton2);
await tester.pumpAndSettle();
// check the space is updated
final spaceItems2 = find.byType(MobileSpaceMenuItem);
final spaceWidget2 =
tester.widgetList<MobileSpaceMenuItem>(spaceItems2).last;
final space2 = spaceWidget2.space;
expect(space2.name, 'AppFlowy');
expect(space2.spacePermission, SpacePermission.publicToAll);
expect(space2.spaceIcon, icon2.iconPath);
expect(space2.spaceIconColor, color2);
final manageSuccess = find.text(
LocaleKeys.space_success_updateSpace.tr(),
);
await tester.pumpUntilNotFound(manageSuccess);
// 4. duplicate the space
await openSpaceMenuMoreOptions(tester, space);
final duplicateOption = find.text(LocaleKeys.space_duplicate.tr());
await tester.tapButton(duplicateOption);
final duplicateSuccess = find.text(
LocaleKeys.space_success_duplicateSpace.tr(),
);
await tester.pumpUntilNotFound(duplicateSuccess);
// check the space is duplicated
await openSpaceMenu(tester);
final spaceItems3 = find.byType(MobileSpaceMenuItem);
expect(spaceItems3, findsNWidgets(4));
// 5. delete the space
await openSpaceMenuMoreOptions(tester, space);
final deleteOption = find.text(LocaleKeys.button_delete.tr());
await tester.tapButton(deleteOption);
final confirmDeleteButton = find.descendant(
of: find.byType(CupertinoDialogAction),
matching: find.text(LocaleKeys.button_delete.tr()),
);
await tester.tapButton(confirmDeleteButton);
final deleteSuccess = find.text(
LocaleKeys.space_success_deleteSpace.tr(),
);
await tester.pumpUntilNotFound(deleteSuccess);
// check the space is deleted
final spaceItems4 = find.byType(MobileSpaceMenuItem);
expect(spaceItems4, findsNWidgets(3));
});
});
}
@@ -1,37 +1,11 @@
// ignore_for_file: unused_import
import 'dart:io';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart';
import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart';
import 'package:appflowy/mobile/presentation/home/home.dart';
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_layout.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
import '../../../shared/constants.dart';
import '../../../shared/dir.dart';
import '../../../shared/mock/mock_file_picker.dart';
import '../../../shared/util.dart';
void main() {
@@ -1,6 +1,7 @@
import 'package:integration_test/integration_test.dart';
import 'page_style_test.dart' as page_style_test;
import 'plus_menu_test.dart' as plus_menu_test;
import 'title_test.dart' as title_test;
void main() {
@@ -9,4 +10,5 @@ void main() {
// Document integration tests
title_test.main();
page_style_test.main();
plus_menu_test.main();
}
@@ -1,42 +1,19 @@
// ignore_for_file: unused_import
import 'dart:io';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart';
import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart';
import 'package:appflowy/mobile/presentation/home/home.dart';
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_layout.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
import '../../shared/dir.dart';
import '../../shared/mock/mock_file_picker.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('document page style', () {
group('document page style:', () {
double getCurrentEditorFontSize() {
final editorPage = find
.byType(AppFlowyEditorPage)
@@ -57,11 +34,9 @@ void main() {
.single
.widget as AppFlowyEditorPage;
return editorPage.styleCustomizer
.style()
.textStyleConfiguration
.text
.height ??
PageStyleLineHeightLayout.normal.lineHeight;
.style()
.textStyleConfiguration
.lineHeight;
}
testWidgets('change font size in page style settings', (tester) async {
@@ -87,20 +62,24 @@ void main() {
await tester.openPage(gettingStarted);
// click the layout button
await tester.tapButton(find.byType(MobileViewPageLayoutButton));
var lineHeight = getCurrentEditorLineHeight();
expect(
getCurrentEditorLineHeight(),
lineHeight,
PageStyleLineHeightLayout.normal.lineHeight,
);
// change line height from normal to large
await tester.tapSvgButton(FlowySvgs.m_layout_large_s);
await tester.pumpAndSettle();
lineHeight = getCurrentEditorLineHeight();
expect(
getCurrentEditorLineHeight(),
lineHeight,
PageStyleLineHeightLayout.large.lineHeight,
);
// change line height from large to small
await tester.tapSvgButton(FlowySvgs.m_layout_small_s);
lineHeight = getCurrentEditorLineHeight();
expect(
getCurrentEditorLineHeight(),
lineHeight,
PageStyleLineHeightLayout.small.lineHeight,
);
});
@@ -0,0 +1,89 @@
import 'dart:async';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package: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();
group('document plus menu:', () {
testWidgets('add the toggle heading blocks via plus menu', (tester) async {
await tester.launchInAnonymousMode();
await tester.createNewDocumentOnMobile('toggle heading blocks');
final editorState = tester.editor.getCurrentEditorState();
// focus on the editor
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: [0])),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
// open the plus menu and select the toggle heading block
await tester.openPlusMenuAndClickButton(
LocaleKeys.document_slashMenu_name_toggleHeading1.tr(),
);
// check the block is inserted
final block1 = editorState.getNodeAtPath([0])!;
expect(block1.type, equals(ToggleListBlockKeys.type));
expect(block1.attributes[ToggleListBlockKeys.level], equals(1));
// click the expand button won't cancel the selection
await tester.tapButton(find.byIcon(Icons.arrow_right));
expect(
editorState.selection,
equals(Selection.collapsed(Position(path: [0]))),
);
// focus on the next line
unawaited(
editorState.updateSelectionWithReason(
Selection.collapsed(Position(path: [1])),
reason: SelectionUpdateReason.uiEvent,
),
);
await tester.pumpAndSettle();
// open the plus menu and select the toggle heading block
await tester.openPlusMenuAndClickButton(
LocaleKeys.document_slashMenu_name_toggleHeading2.tr(),
);
// check the block is inserted
final block2 = editorState.getNodeAtPath([1])!;
expect(block2.type, equals(ToggleListBlockKeys.type));
expect(block2.attributes[ToggleListBlockKeys.level], equals(2));
// focus on the next line
await tester.pumpAndSettle();
// open the plus menu and select the toggle heading block
await tester.openPlusMenuAndClickButton(
LocaleKeys.document_slashMenu_name_toggleHeading3.tr(),
);
// check the block is inserted
final block3 = editorState.getNodeAtPath([2])!;
expect(block3.type, equals(ToggleListBlockKeys.type));
expect(block3.attributes[ToggleListBlockKeys.level], equals(3));
// wait a few milliseconds to ensure the selection is updated
await Future.delayed(const Duration(milliseconds: 100));
// check the selection is collapsed
expect(
editorState.selection,
equals(Selection.collapsed(Position(path: [2]))),
);
});
});
}
@@ -1,39 +1,10 @@
// ignore_for_file: unused_import
import 'dart:io';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart';
import 'package:appflowy/mobile/presentation/base/app_bar/app_bar_actions.dart';
import 'package:appflowy/mobile/presentation/base/view_page/app_bar_buttons.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet_buttons.dart';
import 'package:appflowy/mobile/presentation/home/home.dart';
import 'package:appflowy/mobile/presentation/home/section_folder/mobile_home_section_folder_header.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/plugins/document/presentation/editor_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/cover/document_immersive_cover_bloc.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/page_style/_page_style_layout.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
import '../../shared/dir.dart';
import '../../shared/mock/mock_file_picker.dart';
import '../../shared/util.dart';
void main() {
@@ -1,47 +1,25 @@
// ignore_for_file: unused_import
import 'dart:io';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/home/home.dart';
import 'package:appflowy/mobile/presentation/home/section_folder/mobile_home_section_folder_header.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/plugins/document/document_page.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
import '../../shared/dir.dart';
import '../../shared/mock/mock_file_picker.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('create new page', () {
group('create new page in home page:', () {
testWidgets('create document', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.local,
);
await tester.launchInAnonymousMode();
// tap the create page button
final createPageButton = find.byWidgetPredicate(
(widget) =>
widget is FlowySvg &&
widget.svg.path == FlowySvgs.m_home_unselected_m.path,
widget.svg.path == FlowySvgs.m_home_add_m.path,
);
await tester.tapButton(createPageButton);
await tester.pumpAndSettle();
expect(find.byType(MobileDocumentScreen), findsOneWidget);
});
});
@@ -1,33 +1,15 @@
// ignore_for_file: unused_import
import 'dart:io';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/home/home.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/auth/af_cloud_mock_auth_service.dart';
import 'package:appflowy/user/application/auth/auth_service.dart';
import 'package:appflowy/user/presentation/screens/sign_in_screen/widgets/widgets.dart';
import 'package:appflowy/workspace/application/settings/prelude.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/setting_appflowy_cloud.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/uuid.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:path/path.dart' as p;
import '../../shared/dir.dart';
import '../../shared/mock/mock_file_picker.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('anonymous sign in on mobile', () {
group('anonymous sign in on mobile:', () {
testWidgets('anon user and then sign in', (tester) async {
await tester.initializeAppFlowy();
await tester.launchInAnonymousMode();
// expect to see the home page
expect(find.byType(MobileHomeScreen), findsOneWidget);
@@ -1,15 +1,20 @@
import 'package:appflowy_backend/log.dart';
import 'package:integration_test/integration_test.dart';
import 'mobile/document/page_style_test.dart' as page_style_test;
import 'mobile/document/document_test_runner.dart' as document_test_runner;
import 'mobile/home_page/create_new_page_test.dart' as create_new_page_test;
import 'mobile/sign_in/anonymous_sign_in_test.dart' as anonymous_sign_in_test;
Future<void> runIntegrationOnMobile() async {
Future<void> main() async {
Log.shared.disableLog = true;
await runIntegration1OnMobile();
}
Future<void> runIntegration1OnMobile() async {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
anonymous_sign_in_test.main();
create_new_page_test.main();
page_style_test.main();
document_test_runner.main();
}
@@ -8,7 +8,8 @@ import 'desktop_runner_5.dart';
import 'desktop_runner_6.dart';
import 'desktop_runner_7.dart';
import 'desktop_runner_8.dart';
import 'mobile_runner.dart';
import 'desktop_runner_9.dart';
import 'mobile_runner_1.dart';
/// The main task runner for all integration tests in AppFlowy.
///
@@ -27,8 +28,9 @@ Future<void> main() async {
await runIntegration6OnDesktop();
await runIntegration7OnDesktop();
await runIntegration8OnDesktop();
await runIntegration9OnDesktop();
} else if (Platform.isIOS || Platform.isAndroid) {
await runIntegrationOnMobile();
await runIntegration1OnMobile();
} else {
throw Exception('Unsupported platform');
}
@@ -4,8 +4,10 @@ import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/type_option_menu_item.dart';
import 'package:appflowy/mobile/presentation/presentation.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/emoji_picker_button.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/mobile_toolbar_v3/add_block_toolbar_item.dart';
import 'package:appflowy/plugins/shared/share/share_button.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/shared/text_field/text_filed_with_metric_lines.dart';
@@ -42,6 +44,7 @@ import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:universal_platform/universal_platform.dart';
import 'emoji.dart';
import 'util.dart';
@@ -787,6 +790,57 @@ extension CommonOperations on WidgetTester {
await tap(finder);
await pumpAndSettle(const Duration(seconds: 2));
}
/// Create a new document on mobile
Future<void> createNewDocumentOnMobile(String name) async {
final createPageButton = find.byKey(
BottomNavigationBarItemType.add.valueKey,
);
await tapButton(createPageButton);
expect(find.byType(MobileDocumentScreen), findsOneWidget);
final title = editor.findDocumentTitle('');
expect(title, findsOneWidget);
final textField = widget<TextField>(title);
expect(textField.focusNode!.hasFocus, isTrue);
// input new name and press done button
await enterText(title, name);
await testTextInput.receiveAction(TextInputAction.done);
await pumpAndSettle();
final newTitle = editor.findDocumentTitle(name);
expect(newTitle, findsOneWidget);
expect(textField.controller!.text, name);
}
/// Open the plus menu
Future<void> openPlusMenuAndClickButton(String buttonName) async {
assert(
UniversalPlatform.isMobile,
'This method is only supported on mobile platforms',
);
final plusMenuButton = find.byKey(addBlockToolbarItemKey);
final addMenuItem = find.byType(AddBlockMenu);
await tapButton(plusMenuButton);
await pumpUntilFound(addMenuItem);
final toggleHeading1 = find.byWidgetPredicate(
(widget) =>
widget is TypeOptionMenuItem && widget.value.text == buttonName,
);
final scrollable = find.ancestor(
of: find.byType(TypeOptionGridView),
matching: find.byType(Scrollable),
);
await scrollUntilVisible(
toggleHeading1,
100,
scrollable: scrollable,
);
await tapButton(toggleHeading1);
await pumpUntilNotFound(addMenuItem);
}
}
extension SettingsFinder on CommonFinders {
@@ -40,9 +40,13 @@ extension AppFlowyWorkspace on WidgetTester {
moreButton,
onHover: () async {
await tapButton(moreButton);
await tapButton(
find.findTextInFlowyText(LocaleKeys.button_rename.tr()),
// wait for the menu to open
final renameButton = find.findTextInFlowyText(
LocaleKeys.button_rename.tr(),
);
await pumpUntilFound(renameButton);
expect(renameButton, findsOneWidget);
await tapButton(renameButton);
final input = find.byType(TextFormField);
expect(input, findsOneWidget);
await enterText(input, name);
@@ -50,6 +50,8 @@
<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>
@@ -65,9 +67,9 @@
<string>UIInterfaceOrientationPortrait</string>
<string>UIInterfaceOrientationPortraitUpsideDown</string>
</array>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
<key>UISupportsDocumentBrowser</key>
<true/>
<key>UIViewControllerBasedStatusBarAppearance</key>
<false/>
</dict>
</plist>
@@ -49,6 +49,7 @@ class KVKeys {
/// {'SidebarFolderCategoryType.value': true}
static const String expandedFolders = 'expandedFolders';
/// @deprecated in version 0.7.6
/// The key for saving if showing the rename dialog when creating a new file
///
/// The value is a boolean string.
@@ -1,16 +1,24 @@
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'dart:io';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/shared/patterns/common_patterns.dart';
import 'package:appflowy/workspace/presentation/home/toast.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/services.dart';
import 'package:flutter/widgets.dart';
import 'package:open_filex/open_filex.dart';
import 'package:string_validator/string_validator.dart';
import 'package:url_launcher/url_launcher.dart' as launcher;
typedef OnFailureCallback = void Function(Uri uri);
Future<bool> afLaunchUrl(
/// Launch the uri
///
/// If the uri is a local file path, it will be opened with the OpenFilex.
/// Otherwise, it will be launched with the url_launcher.
Future<bool> afLaunchUri(
Uri uri, {
BuildContext? context,
OnFailureCallback? onFailure,
@@ -18,6 +26,18 @@ Future<bool> afLaunchUrl(
String? webOnlyWindowName,
bool addingHttpSchemeWhenFailed = false,
}) async {
final url = uri.toString();
final decodedUrl = Uri.decodeComponent(url);
// check if the uri is the local file path
if (localPathRegex.hasMatch(decodedUrl)) {
return _afLaunchLocalUri(
uri,
context: context,
onFailure: onFailure,
);
}
// try to launch the uri directly
bool result;
try {
@@ -32,7 +52,7 @@ Future<bool> afLaunchUrl(
}
// if the uri is not a valid url, try to launch it with http scheme
final url = uri.toString();
if (addingHttpSchemeWhenFailed &&
!result &&
!isURL(url, {'require_protocol': true})) {
@@ -54,9 +74,14 @@ Future<bool> afLaunchUrl(
return result;
}
/// Launch the url string
///
/// See [afLaunchUri] for more details.
Future<bool> afLaunchUrlString(
String url, {
bool addingHttpSchemeWhenFailed = false,
BuildContext? context,
OnFailureCallback? onFailure,
}) async {
final Uri uri;
try {
@@ -67,12 +92,56 @@ Future<bool> afLaunchUrlString(
}
// try to launch the uri directly
return afLaunchUrl(
return afLaunchUri(
uri,
addingHttpSchemeWhenFailed: addingHttpSchemeWhenFailed,
context: context,
onFailure: onFailure,
);
}
/// Launch the local uri
///
/// See [afLaunchUri] for more details.
Future<bool> _afLaunchLocalUri(
Uri uri, {
BuildContext? context,
OnFailureCallback? onFailure,
}) async {
final decodedUrl = Uri.decodeComponent(uri.toString());
// open the file with the OpenfileX
var result = await OpenFilex.open(decodedUrl);
if (result.type != ResultType.done) {
// For the file cant be opened, fallback to open the folder
final parentFolder = Directory(decodedUrl).parent.path;
result = await OpenFilex.open(parentFolder);
}
// show the toast if the file is not found
final message = switch (result.type) {
ResultType.done => LocaleKeys.openFileMessage_success.tr(),
ResultType.fileNotFound => LocaleKeys.openFileMessage_fileNotFound.tr(),
ResultType.noAppToOpen => LocaleKeys.openFileMessage_noAppToOpenFile.tr(),
ResultType.permissionDenied =>
LocaleKeys.openFileMessage_permissionDenied.tr(),
ResultType.error => LocaleKeys.failedToOpenUrl.tr(),
};
if (context != null && context.mounted) {
showToastNotification(
context,
message: message,
type: result.type == ResultType.done
? ToastificationType.success
: ToastificationType.error,
);
}
final openFileSuccess = result.type == ResultType.done;
if (!openFileSuccess && onFailure != null) {
onFailure(uri);
Log.error('Failed to open file: $result.message');
}
return openFileSuccess;
}
void _errorHandler(
Uri uri, {
BuildContext? context,
@@ -65,7 +65,10 @@ class _MobileViewPageState extends State<MobileViewPage> {
@override
void dispose() {
_appBarOpacity.dispose();
_scrollNotificationObserver?.removeListener(_onScrollNotification);
// there's no need to remove the listener, because the observer will be disposed when the widget is unmounted.
// inside the observer, the listener will be removed automatically.
// _scrollNotificationObserver?.removeListener(_onScrollNotification);
_scrollNotificationObserver = null;
super.dispose();
@@ -9,12 +9,14 @@ class TypeOptionMenuItemValue<T> {
required this.text,
required this.backgroundColor,
required this.onTap,
this.iconPadding,
});
final T value;
final FlowySvgData icon;
final String text;
final Color backgroundColor;
final EdgeInsets? iconPadding;
final void Function(BuildContext context, T value) onTap;
}
@@ -22,7 +24,7 @@ class TypeOptionMenu<T> extends StatelessWidget {
const TypeOptionMenu({
super.key,
required this.values,
this.width = 94,
this.width = 98,
this.iconWidth = 72,
this.scaleFactor = 1.0,
this.maxAxisSpacing = 18,
@@ -39,17 +41,18 @@ class TypeOptionMenu<T> extends StatelessWidget {
@override
Widget build(BuildContext context) {
return _GridView(
return TypeOptionGridView(
crossAxisCount: crossAxisCount,
mainAxisSpacing: maxAxisSpacing * scaleFactor,
itemWidth: width * scaleFactor,
children: values
.map(
(value) => _TypeOptionMenuItem<T>(
(value) => TypeOptionMenuItem<T>(
value: value,
width: width,
iconWidth: iconWidth,
scaleFactor: scaleFactor,
iconPadding: value.iconPadding,
),
)
.toList(),
@@ -57,18 +60,21 @@ class TypeOptionMenu<T> extends StatelessWidget {
}
}
class _TypeOptionMenuItem<T> extends StatelessWidget {
const _TypeOptionMenuItem({
class TypeOptionMenuItem<T> extends StatelessWidget {
const TypeOptionMenuItem({
super.key,
required this.value,
this.width = 94,
this.iconWidth = 72,
this.scaleFactor = 1.0,
this.iconPadding,
});
final TypeOptionMenuItemValue<T> value;
final double iconWidth;
final double width;
final double scaleFactor;
final EdgeInsets? iconPadding;
double get scaledIconWidth => iconWidth * scaleFactor;
double get scaledWidth => width * scaleFactor;
@@ -88,7 +94,8 @@ class _TypeOptionMenuItem<T> extends StatelessWidget {
borderRadius: BorderRadius.circular(24 * scaleFactor),
),
),
padding: EdgeInsets.all(21 * scaleFactor),
padding: EdgeInsets.all(21 * scaleFactor) +
(iconPadding ?? EdgeInsets.zero),
child: FlowySvg(
value.icon,
),
@@ -113,8 +120,9 @@ class _TypeOptionMenuItem<T> extends StatelessWidget {
}
}
class _GridView extends StatelessWidget {
const _GridView({
class TypeOptionGridView extends StatelessWidget {
const TypeOptionGridView({
super.key,
required this.children,
required this.crossAxisCount,
required this.mainAxisSpacing,
@@ -3,17 +3,24 @@ import 'dart:async';
import 'package:appflowy/core/helpers/url_launcher.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/home/workspaces/create_workspace_menu.dart';
import 'package:appflowy/plugins/document/presentation/editor_notification.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/shared/share/constants.dart';
import 'package:appflowy/plugins/shared/share/publish_name_generator.dart';
import 'package:appflowy/plugins/shared/share/share_bloc.dart';
import 'package:appflowy/shared/error_code/error_code_map.dart';
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/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/style_widget/text.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
@@ -23,83 +30,97 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
const MobileViewPageMoreBottomSheet({super.key, required this.view});
final ViewPB view;
@override
Widget build(BuildContext context) {
return BlocListener<ViewBloc, ViewState>(
listener: (context, state) {
if (state.successOrFailure.isSuccess && state.isDeleted) {
context.go('/home');
}
},
child: ViewPageBottomSheet(
view: view,
onAction: (action) async {
switch (action) {
case MobileViewBottomSheetBodyAction.duplicate:
context.read<ViewBloc>().add(const ViewEvent.duplicate());
context.pop();
break;
case MobileViewBottomSheetBodyAction.delete:
context.read<ViewBloc>().add(const ViewEvent.delete());
context.pop();
break;
case MobileViewBottomSheetBodyAction.addToFavorites:
case MobileViewBottomSheetBodyAction.removeFromFavorites:
context.read<FavoriteBloc>().add(FavoriteEvent.toggle(view));
context.pop();
break;
case MobileViewBottomSheetBodyAction.undo:
EditorNotification.undo().post();
context.pop();
break;
case MobileViewBottomSheetBodyAction.redo:
EditorNotification.redo().post();
context.pop();
break;
case MobileViewBottomSheetBodyAction.helpCenter:
// unimplemented
context.pop();
break;
case MobileViewBottomSheetBodyAction.publish:
await _publish(context);
if (context.mounted) {
context.pop();
}
break;
case MobileViewBottomSheetBodyAction.unpublish:
_unpublish(context);
context.pop();
break;
case MobileViewBottomSheetBodyAction.copyPublishLink:
_copyPublishLink(context);
context.pop();
break;
case MobileViewBottomSheetBodyAction.visitSite:
_visitPublishedSite(context);
context.pop();
break;
case MobileViewBottomSheetBodyAction.copyShareLink:
_copyShareLink(context);
context.pop();
break;
case MobileViewBottomSheetBodyAction.rename:
// no need to implement, rename is handled by the onRename callback.
throw UnimplementedError();
return BlocListener<ShareBloc, ShareState>(
listener: (context, state) => _showToast(context, state),
child: BlocListener<ViewBloc, ViewState>(
listener: (context, state) {
if (state.successOrFailure.isSuccess && state.isDeleted) {
context.go('/home');
}
},
onRename: (name) {
_onRename(context, name);
context.pop();
},
child: ViewPageBottomSheet(
view: view,
onAction: (action) async => _onAction(context, action),
onRename: (name) {
_onRename(context, name);
context.pop();
},
),
),
);
}
Future<void> _onAction(
BuildContext context,
MobileViewBottomSheetBodyAction action,
) async {
switch (action) {
case MobileViewBottomSheetBodyAction.duplicate:
_duplicate(context);
break;
case MobileViewBottomSheetBodyAction.delete:
context.read<ViewBloc>().add(const ViewEvent.delete());
context.pop();
break;
case MobileViewBottomSheetBodyAction.addToFavorites:
_addFavorite(context);
break;
case MobileViewBottomSheetBodyAction.removeFromFavorites:
_removeFavorite(context);
break;
case MobileViewBottomSheetBodyAction.undo:
EditorNotification.undo().post();
context.pop();
break;
case MobileViewBottomSheetBodyAction.redo:
EditorNotification.redo().post();
context.pop();
break;
case MobileViewBottomSheetBodyAction.helpCenter:
// unimplemented
context.pop();
break;
case MobileViewBottomSheetBodyAction.publish:
await _publish(context);
if (context.mounted) {
context.pop();
}
break;
case MobileViewBottomSheetBodyAction.unpublish:
_unpublish(context);
context.pop();
break;
case MobileViewBottomSheetBodyAction.copyPublishLink:
_copyPublishLink(context);
context.pop();
break;
case MobileViewBottomSheetBodyAction.visitSite:
_visitPublishedSite(context);
context.pop();
break;
case MobileViewBottomSheetBodyAction.copyShareLink:
_copyShareLink(context);
context.pop();
break;
case MobileViewBottomSheetBodyAction.updatePathName:
_updatePathName(context);
case MobileViewBottomSheetBodyAction.rename:
// no need to implement, rename is handled by the onRename callback.
throw UnimplementedError();
}
}
Future<void> _publish(BuildContext context) async {
final id = context.read<ShareBloc>().view.id;
final publishName = await generatePublishName(
id,
view.name,
final lastPublishName = context.read<ShareBloc>().state.pathName;
final publishName = lastPublishName.orDefault(
await generatePublishName(
id,
view.name,
),
);
if (context.mounted) {
context.read<ShareBloc>().add(
@@ -109,19 +130,44 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
[view.id],
),
);
showToastNotification(
context,
message: LocaleKeys.publish_publishSuccessfully.tr(),
);
}
}
void _duplicate(BuildContext context) {
context.read<ViewBloc>().add(const ViewEvent.duplicate());
context.pop();
showToastNotification(
context,
message: LocaleKeys.button_duplicateSuccessfully.tr(),
);
}
void _addFavorite(BuildContext context) {
_toggleFavorite(context);
showToastNotification(
context,
message: LocaleKeys.button_favoriteSuccessfully.tr(),
);
}
void _removeFavorite(BuildContext context) {
_toggleFavorite(context);
showToastNotification(
context,
message: LocaleKeys.button_unfavoriteSuccessfully.tr(),
);
}
void _toggleFavorite(BuildContext context) {
context.read<FavoriteBloc>().add(FavoriteEvent.toggle(view));
context.pop();
}
void _unpublish(BuildContext context) {
context.read<ShareBloc>().add(const ShareEvent.unPublish());
showToastNotification(
context,
message: LocaleKeys.publish_unpublishSuccessfully.tr(),
);
}
void _copyPublishLink(BuildContext context) {
@@ -143,7 +189,7 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
final url = context.read<ShareBloc>().state.url;
if (url.isNotEmpty) {
unawaited(
afLaunchUrl(
afLaunchUri(
Uri.parse(url),
mode: LaunchMode.externalApplication,
),
@@ -182,4 +228,112 @@ class MobileViewPageMoreBottomSheet extends StatelessWidget {
context.read<ViewBloc>().add(ViewEvent.rename(name));
}
}
void _updatePathName(BuildContext context) async {
final shareBloc = context.read<ShareBloc>();
final pathName = shareBloc.state.pathName;
await showMobileBottomSheet(
context,
showHeader: true,
title: LocaleKeys.shareAction_updatePathName.tr(),
showCloseButton: true,
showDragHandle: true,
showDivider: false,
padding: const EdgeInsets.symmetric(horizontal: 16),
builder: (bottomSheetContext) {
FlowyResult<void, FlowyError>? previousUpdatePathNameResult;
return EditWorkspaceNameBottomSheet(
type: EditWorkspaceNameType.edit,
workspaceName: pathName,
hintText: '',
validator: (value) => null,
validatorBuilder: (context) {
return BlocProvider.value(
value: shareBloc,
child: BlocBuilder<ShareBloc, ShareState>(
builder: (context, state) {
final updatePathNameResult = state.updatePathNameResult;
if (updatePathNameResult == null &&
previousUpdatePathNameResult == null) {
return const SizedBox.shrink();
}
if (updatePathNameResult != null) {
previousUpdatePathNameResult = updatePathNameResult;
}
final widget = previousUpdatePathNameResult?.fold(
(value) => const SizedBox.shrink(),
(error) => FlowyText(
error.code.publishErrorMessage.orDefault(
LocaleKeys.settings_sites_error_updatePathNameFailed
.tr(),
),
maxLines: 3,
fontSize: 12,
textAlign: TextAlign.left,
overflow: TextOverflow.ellipsis,
color: Theme.of(context).colorScheme.error,
),
) ??
const SizedBox.shrink();
return widget;
},
),
);
},
onSubmitted: (name) {
// rename the path name
Log.info('rename the path name, from: $pathName, to: $name');
shareBloc.add(ShareEvent.updatePathName(name));
},
);
},
);
shareBloc.add(const ShareEvent.clearPathNameResult());
}
void _showToast(BuildContext context, ShareState state) {
if (state.publishResult != null) {
state.publishResult!.fold(
(value) => showToastNotification(
context,
message: LocaleKeys.publish_publishSuccessfully.tr(),
),
(error) => showToastNotification(
context,
message: '${LocaleKeys.publish_publishFailed.tr()}: ${error.code}',
type: ToastificationType.error,
),
);
} else if (state.unpublishResult != null) {
state.unpublishResult!.fold(
(value) => showToastNotification(
context,
message: LocaleKeys.publish_unpublishSuccessfully.tr(),
),
(error) => showToastNotification(
context,
message: LocaleKeys.publish_unpublishFailed.tr(),
description: error.msg,
type: ToastificationType.error,
),
);
} else if (state.updatePathNameResult != null) {
state.updatePathNameResult!.onSuccess(
(value) {
showToastNotification(
context,
message:
LocaleKeys.settings_sites_success_updatePathNameSuccess.tr(),
);
context.pop();
},
);
}
}
}
@@ -24,6 +24,7 @@ enum MobileViewBottomSheetBodyAction {
copyPublishLink,
visitSite,
copyShareLink,
updatePathName,
}
typedef MobileViewBottomSheetBodyActionCallback = void Function(
@@ -164,6 +165,15 @@ class MobileViewBottomSheetBody extends StatelessWidget {
final isPublished = context.watch<ShareBloc>().state.isPublished;
if (isPublished) {
return [
MobileQuickActionButton(
text: LocaleKeys.shareAction_updatePathName.tr(),
icon: FlowySvgs.view_item_rename_s,
iconSize: const Size.square(18),
onTap: () => onAction(
MobileViewBottomSheetBodyAction.updatePathName,
),
),
_divider(),
MobileQuickActionButton(
text: LocaleKeys.shareAction_visitSite.tr(),
icon: FlowySvgs.m_visit_site_s,
@@ -47,6 +47,7 @@ Future<T?> showMobileBottomSheet<T>(
Color? barrierColor,
double? elevation,
bool showDoneButton = false,
void Function(BuildContext context)? onDone,
bool enableDraggableScrollable = false,
bool enableScrollable = false,
// this field is only used if showDragHandle is true
@@ -112,6 +113,7 @@ Future<T?> showMobileBottomSheet<T>(
showRemoveButton: showRemoveButton,
title: title,
onRemove: onRemove,
onDone: onDone,
),
);
@@ -208,6 +210,7 @@ class BottomSheetHeader extends StatelessWidget {
required this.title,
required this.showDoneButton,
this.onRemove,
this.onDone,
});
final bool showBackButton;
@@ -216,6 +219,7 @@ class BottomSheetHeader extends StatelessWidget {
final String title;
final bool showDoneButton;
final VoidCallback? onRemove;
final void Function(BuildContext context)? onDone;
@override
Widget build(BuildContext context) {
@@ -257,7 +261,13 @@ class BottomSheetHeader extends StatelessWidget {
Align(
alignment: Alignment.centerRight,
child: BottomSheetDoneButton(
onDone: () => Navigator.pop(context),
onDone: () {
if (onDone != null) {
onDone?.call(context);
} else {
Navigator.pop(context);
}
},
),
),
],
@@ -9,10 +9,10 @@ import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/database/card/card_detail/widgets/widgets.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/plugins/base/drag_handler.dart';
import 'package:appflowy/plugins/database/domain/field_service.dart';
import 'package:appflowy/plugins/database/application/field/type_option/number_format_bloc.dart';
import 'package:appflowy/plugins/database/widgets/field/type_option_editor/date/date_time_format.dart';
import 'package:appflowy/plugins/database/domain/field_service.dart';
import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart';
import 'package:appflowy/plugins/database/widgets/field/type_option_editor/date/date_time_format.dart';
import 'package:appflowy/util/field_type_extension.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:collection/collection.dart';
@@ -863,6 +863,8 @@ class _SelectOptionListState extends State<_SelectOptionList> {
return ListView(
shrinkWrap: true,
padding: EdgeInsets.zero,
// disable the inner scroll physics, so the outer ListView can scroll
physics: const NeverScrollableScrollPhysics(),
children: widget.selectOptions
.mapIndexed(
(index, option) => _SelectOptionTile(
@@ -279,16 +279,10 @@ class _HomePageState extends State<_HomePage> {
ToastificationType toastType = ToastificationType.success;
switch (actionType) {
case UserWorkspaceActionType.open:
message = result.fold(
(s) {
toastType = ToastificationType.success;
return LocaleKeys.workspace_openSuccess.tr();
},
(e) {
toastType = ToastificationType.error;
return '${LocaleKeys.workspace_openFailed.tr()}: ${e.msg}';
},
);
message = result.onFailure((e) {
toastType = ToastificationType.error;
return '${LocaleKeys.workspace_openFailed.tr()}: ${e.msg}';
});
break;
case UserWorkspaceActionType.delete:
message = result.fold(
@@ -0,0 +1,3 @@
class SpaceUIConstants {
static const itemHeight = 52.0;
}
@@ -0,0 +1,53 @@
import 'package:appflowy/shared/icon_emoji_picker/icon.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart' hide Icon;
import 'widgets.dart';
enum ManageSpaceType {
create,
edit,
}
class ManageSpaceWidget extends StatelessWidget {
const ManageSpaceWidget({
super.key,
required this.controller,
required this.permission,
required this.selectedColor,
required this.selectedIcon,
required this.type,
});
final TextEditingController controller;
final ValueNotifier<SpacePermission> permission;
final ValueNotifier<String> selectedColor;
final ValueNotifier<Icon?> selectedIcon;
final ManageSpaceType type;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
ManageSpaceNameOption(
controller: controller,
type: type,
),
ManageSpacePermissionOption(permission: permission),
ConstrainedBox(
constraints: const BoxConstraints(
maxHeight: 560,
),
child: ManageSpaceIconOption(
selectedColor: selectedColor,
selectedIcon: selectedIcon,
),
),
const VSpace(60),
],
);
}
}
@@ -17,7 +17,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class MobileSpace extends StatelessWidget {
const MobileSpace({super.key});
const MobileSpace({
super.key,
});
@override
Widget build(BuildContext context) {
@@ -63,6 +65,8 @@ class MobileSpace extends StatelessWidget {
useRootNavigator: true,
title: LocaleKeys.space_title.tr(),
backgroundColor: Theme.of(context).colorScheme.surface,
enableScrollable: true,
bottomSheetPadding: context.bottomSheetPadding(),
builder: (_) {
return BlocProvider.value(
value: context.read<SpaceBloc>(),
@@ -96,6 +100,7 @@ class MobileSpace extends StatelessWidget {
name: layout.defaultName,
layout: layout,
index: 0,
openAfterCreate: true,
),
);
context.read<SpaceBloc>().add(
@@ -1,50 +1,74 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/animated_gesture.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/home/space/space_menu_bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/home/workspaces/create_workspace_menu.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:appflowy/util/navigator_context_extension.dart';
import 'package:appflowy/util/string_extension.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_action_type.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.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';
import 'package:flutter/material.dart';
import 'package:flutter/material.dart' hide Icon;
import 'package:flutter_bloc/flutter_bloc.dart';
import 'constants.dart';
import 'manage_space_widget.dart';
class MobileSpaceMenu extends StatelessWidget {
const MobileSpaceMenu({super.key});
const MobileSpaceMenu({
super.key,
});
@override
Widget build(BuildContext context) {
return BlocBuilder<SpaceBloc, SpaceState>(
builder: (context, state) {
return Column(
mainAxisSize: MainAxisSize.min,
children: [
const VSpace(4.0),
for (final space in state.spaces)
SizedBox(
height: 52,
child: _SidebarSpaceMenuItem(
space: space,
isSelected: state.currentSpace?.id == space.id,
return SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const VSpace(4.0),
for (final space in state.spaces)
SizedBox(
height: SpaceUIConstants.itemHeight,
child: MobileSpaceMenuItem(
space: space,
isSelected: state.currentSpace?.id == space.id,
),
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 8.0),
child: Divider(
height: 0.5,
),
),
// const Padding(
// padding: EdgeInsets.symmetric(vertical: 8.0),
// child: Divider(
// height: 0.5,
// ),
// ),
// const SizedBox(
// height: 52,
// child: _CreateSpaceButton(),
// ),
],
const SizedBox(
height: SpaceUIConstants.itemHeight,
child: _CreateSpaceButton(),
),
],
),
);
},
);
}
}
class _SidebarSpaceMenuItem extends StatelessWidget {
const _SidebarSpaceMenuItem({
class MobileSpaceMenuItem extends StatelessWidget {
const MobileSpaceMenuItem({
super.key,
required this.space,
required this.isSelected,
});
@@ -79,12 +103,11 @@ class _SidebarSpaceMenuItem extends StatelessWidget {
cornerRadius: 6.0,
),
leftIconSize: const Size.square(24),
rightIcon: isSelected
? const FlowySvg(
FlowySvgs.m_blue_check_s,
blendMode: null,
)
: null,
rightIcon: SpaceMenuItemTrailing(
key: ValueKey('${space.id}_space_menu_item_trailing'),
space: space,
currentSpace: context.read<SpaceBloc>().state.currentSpace,
),
onTap: () {
context.read<SpaceBloc>().add(SpaceEvent.open(space));
Navigator.of(context).pop();
@@ -93,40 +116,385 @@ class _SidebarSpaceMenuItem extends StatelessWidget {
}
}
// class _CreateSpaceButton extends StatelessWidget {
// const _CreateSpaceButton();
class _CreateSpaceButton extends StatefulWidget {
const _CreateSpaceButton();
// @override
// Widget build(BuildContext context) {
// return FlowyButton(
// text: FlowyText.regular(LocaleKeys.space_createNewSpace.tr()),
// iconPadding: 10,
// leftIcon: const FlowySvg(
// FlowySvgs.space_add_s,
// ),
// leftIconSize: const Size.square(20),
// onTap: () {
// PopoverContainer.of(context).close();
// _showCreateSpaceDialog(context);
// },
// );
// }
@override
State<_CreateSpaceButton> createState() => _CreateSpaceButtonState();
}
// void _showCreateSpaceDialog(BuildContext context) {
// final spaceBloc = context.read<SpaceBloc>();
// showDialog(
// context: context,
// builder: (_) {
// return Dialog(
// shape: RoundedRectangleBorder(
// borderRadius: BorderRadius.circular(12.0),
// ),
// child: BlocProvider.value(
// value: spaceBloc,
// child: const CreateSpacePopup(),
// ),
// );
// },
// );
// }
// }
class _CreateSpaceButtonState extends State<_CreateSpaceButton> {
final controller = TextEditingController();
final permission = ValueNotifier<SpacePermission>(
SpacePermission.publicToAll,
);
final selectedColor = ValueNotifier<String>(
builtInSpaceColors.first,
);
final selectedIcon = ValueNotifier<Icon?>(
kIconGroups?.first.icons.first,
);
@override
void dispose() {
controller.dispose();
permission.dispose();
selectedColor.dispose();
selectedIcon.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return FlowyButton(
text: FlowyText.regular(LocaleKeys.space_createNewSpace.tr()),
iconPadding: 10,
leftIcon: const Padding(
padding: EdgeInsets.all(2.0),
child: FlowySvg(
FlowySvgs.space_add_s,
),
),
margin: const EdgeInsets.symmetric(horizontal: 12.0),
leftIconSize: const Size.square(24),
onTap: () => _showCreateSpaceDialog(context),
);
}
Future<void> _showCreateSpaceDialog(BuildContext context) async {
await showMobileBottomSheet(
context,
showHeader: true,
title: LocaleKeys.space_createSpace.tr(),
showCloseButton: true,
showDivider: false,
showDoneButton: true,
enableScrollable: true,
showDragHandle: true,
bottomSheetPadding: context.bottomSheetPadding(),
onDone: (bottomSheetContext) {
final iconPath = selectedIcon.value?.iconPath ?? '';
context.read<SpaceBloc>().add(
SpaceEvent.create(
name: controller.text.orDefault(
LocaleKeys.space_defaultSpaceName.tr(),
),
permission: permission.value,
iconColor: selectedColor.value,
icon: iconPath,
createNewPageByDefault: true,
openAfterCreate: false,
),
);
Navigator.pop(bottomSheetContext);
Navigator.pop(context);
Log.info(
'create space on mobile, name: ${controller.text}, permission: ${permission.value}, color: ${selectedColor.value}, icon: $iconPath',
);
},
padding: const EdgeInsets.symmetric(horizontal: 16),
builder: (bottomSheetContext) => ManageSpaceWidget(
controller: controller,
permission: permission,
selectedColor: selectedColor,
selectedIcon: selectedIcon,
type: ManageSpaceType.create,
),
);
_resetState();
}
void _resetState() {
controller.clear();
permission.value = SpacePermission.publicToAll;
selectedColor.value = builtInSpaceColors.first;
selectedIcon.value = kIconGroups?.first.icons.first;
}
}
class SpaceMenuItemTrailing extends StatefulWidget {
const SpaceMenuItemTrailing({
super.key,
required this.space,
this.currentSpace,
});
final ViewPB space;
final ViewPB? currentSpace;
@override
State<SpaceMenuItemTrailing> createState() => _SpaceMenuItemTrailingState();
}
class _SpaceMenuItemTrailingState extends State<SpaceMenuItemTrailing> {
final controller = TextEditingController();
final permission = ValueNotifier<SpacePermission>(
SpacePermission.publicToAll,
);
final selectedColor = ValueNotifier<String>(
builtInSpaceColors.first,
);
final selectedIcon = ValueNotifier<Icon?>(
kIconGroups?.first.icons.first,
);
@override
void dispose() {
controller.dispose();
permission.dispose();
selectedColor.dispose();
selectedIcon.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
const iconSize = Size.square(20);
return Row(
children: [
const HSpace(12.0),
// show the check icon if the space is the current space
if (widget.space.id == widget.currentSpace?.id)
const FlowySvg(
FlowySvgs.m_blue_check_s,
size: iconSize,
blendMode: null,
),
const HSpace(8.0),
// more options button
AnimatedGestureDetector(
onTapUp: () => _showMoreOptions(context),
child: const Padding(
padding: EdgeInsets.all(8.0),
child: FlowySvg(
FlowySvgs.workspace_three_dots_s,
size: iconSize,
),
),
),
],
);
}
void _showMoreOptions(BuildContext context) {
final actions = [
SpaceMoreActionType.rename,
SpaceMoreActionType.duplicate,
SpaceMoreActionType.manage,
SpaceMoreActionType.delete,
];
showMobileBottomSheet(
context,
showDragHandle: true,
showDivider: false,
useRootNavigator: true,
backgroundColor: Theme.of(context).colorScheme.surface,
builder: (bottomSheetContext) {
return SpaceMenuMoreOptions(
actions: actions,
onAction: (action) => _onActions(
context,
bottomSheetContext,
action,
),
);
},
);
}
void _onActions(
BuildContext context,
BuildContext bottomSheetContext,
SpaceMoreActionType action,
) {
Log.info('execute action in space menu bottom sheet: $action');
switch (action) {
case SpaceMoreActionType.rename:
_showRenameSpaceBottomSheet(context);
break;
case SpaceMoreActionType.duplicate:
_duplicateSpace(context, bottomSheetContext);
break;
case SpaceMoreActionType.manage:
_showManageSpaceBottomSheet(context);
break;
case SpaceMoreActionType.delete:
_deleteSpace(context, bottomSheetContext);
break;
default:
assert(false, 'Unsupported action: $action');
break;
}
}
void _duplicateSpace(BuildContext context, BuildContext bottomSheetContext) {
Log.info('duplicate the space: ${widget.space.name}');
context.read<SpaceBloc>().add(const SpaceEvent.duplicate());
showToastNotification(
context,
message: LocaleKeys.space_success_duplicateSpace.tr(),
);
Navigator.of(bottomSheetContext).pop();
Navigator.of(context).pop();
}
void _showRenameSpaceBottomSheet(BuildContext context) {
Navigator.of(context).pop();
showMobileBottomSheet(
context,
showHeader: true,
title: LocaleKeys.space_renameSpace.tr(),
showCloseButton: true,
showDragHandle: true,
showDivider: false,
padding: const EdgeInsets.symmetric(horizontal: 16),
builder: (bottomSheetContext) {
return EditWorkspaceNameBottomSheet(
type: EditWorkspaceNameType.edit,
workspaceName: widget.space.name,
hintText: LocaleKeys.space_spaceNamePlaceholder.tr(),
validator: (value) => null,
onSubmitted: (name) {
// rename the workspace
Log.info('rename the space, from: ${widget.space.name}, to: $name');
bottomSheetContext.popToHome();
context
.read<SpaceBloc>()
.add(SpaceEvent.rename(space: widget.space, name: name));
showToastNotification(
context,
message: LocaleKeys.space_success_renameSpace.tr(),
);
},
);
},
);
}
Future<void> _showManageSpaceBottomSheet(BuildContext context) async {
controller.text = widget.space.name;
permission.value = widget.space.spacePermission;
selectedColor.value =
widget.space.spaceIconColor ?? builtInSpaceColors.first;
selectedIcon.value = widget.space.spaceIcon?.icon;
await showMobileBottomSheet(
context,
showHeader: true,
title: LocaleKeys.space_manageSpace.tr(),
showCloseButton: true,
showDivider: false,
showDoneButton: true,
enableScrollable: true,
showDragHandle: true,
bottomSheetPadding: context.bottomSheetPadding(),
onDone: (bottomSheetContext) {
String iconName = '';
final icon = selectedIcon.value;
final iconGroup = icon?.iconGroup;
final iconId = icon?.name;
if (icon != null && iconGroup != null) {
iconName = '${iconGroup.name}/$iconId';
}
Log.info(
'update space on mobile, name: ${controller.text}, permission: ${permission.value}, color: ${selectedColor.value}, icon: $iconName',
);
context.read<SpaceBloc>().add(
SpaceEvent.update(
space: widget.space,
name: controller.text.orDefault(
LocaleKeys.space_defaultSpaceName.tr(),
),
permission: permission.value,
iconColor: selectedColor.value,
icon: iconName,
),
);
showToastNotification(
context,
message: LocaleKeys.space_success_updateSpace.tr(),
);
Navigator.pop(bottomSheetContext);
Navigator.pop(context);
},
padding: const EdgeInsets.symmetric(horizontal: 16),
builder: (bottomSheetContext) => ManageSpaceWidget(
controller: controller,
permission: permission,
selectedColor: selectedColor,
selectedIcon: selectedIcon,
type: ManageSpaceType.edit,
),
);
}
void _deleteSpace(
BuildContext context,
BuildContext bottomSheetContext,
) {
Navigator.of(bottomSheetContext).pop();
_showConfirmDialog(
context,
'${LocaleKeys.space_delete.tr()}: ${widget.space.name}',
LocaleKeys.space_deleteConfirmationDescription.tr(),
LocaleKeys.button_delete.tr(),
(_) async {
context.read<SpaceBloc>().add(SpaceEvent.delete(widget.space));
showToastNotification(
context,
message: LocaleKeys.space_success_deleteSpace.tr(),
);
Navigator.pop(context);
},
);
}
void _showConfirmDialog(
BuildContext context,
String title,
String content,
String rightButtonText,
void Function(BuildContext context)? onRightButtonPressed,
) {
showFlowyCupertinoConfirmDialog(
title: title,
content: FlowyText(
content,
fontSize: 14,
maxLines: 10,
),
leftButton: FlowyText(
LocaleKeys.button_cancel.tr(),
fontSize: 17.0,
figmaLineHeight: 24.0,
fontWeight: FontWeight.w500,
color: const Color(0xFF007AFF),
),
rightButton: FlowyText(
rightButtonText,
fontSize: 17.0,
figmaLineHeight: 24.0,
fontWeight: FontWeight.w400,
color: const Color(0xFFFE0220),
),
onRightButtonPressed: onRightButtonPressed,
);
}
}
@@ -0,0 +1,99 @@
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/presentation/home/menu/sidebar/space/space_action_type.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'constants.dart';
class SpaceMenuMoreOptions extends StatelessWidget {
const SpaceMenuMoreOptions({
super.key,
required this.onAction,
required this.actions,
});
final void Function(SpaceMoreActionType action) onAction;
final List<SpaceMoreActionType> actions;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: actions
.map(
(action) => _buildActionButton(context, action),
)
.toList(),
);
}
Widget _buildActionButton(
BuildContext context,
SpaceMoreActionType action,
) {
switch (action) {
case SpaceMoreActionType.rename:
return FlowyOptionTile.text(
text: LocaleKeys.button_rename.tr(),
height: SpaceUIConstants.itemHeight,
leftIcon: const FlowySvg(
FlowySvgs.view_item_rename_s,
size: Size.square(18),
),
showTopBorder: false,
showBottomBorder: false,
onTap: () => onAction(
SpaceMoreActionType.rename,
),
);
case SpaceMoreActionType.delete:
return FlowyOptionTile.text(
text: LocaleKeys.button_delete.tr(),
height: SpaceUIConstants.itemHeight,
textColor: Theme.of(context).colorScheme.error,
leftIcon: FlowySvg(
FlowySvgs.trash_s,
size: const Size.square(18),
color: Theme.of(context).colorScheme.error,
),
showTopBorder: false,
showBottomBorder: false,
onTap: () => onAction(
SpaceMoreActionType.delete,
),
);
case SpaceMoreActionType.manage:
return FlowyOptionTile.text(
text: LocaleKeys.space_manage.tr(),
height: SpaceUIConstants.itemHeight,
leftIcon: const FlowySvg(
FlowySvgs.settings_s,
size: Size.square(18),
),
showTopBorder: false,
showBottomBorder: false,
onTap: () => onAction(
SpaceMoreActionType.manage,
),
);
case SpaceMoreActionType.duplicate:
return FlowyOptionTile.text(
text: SpaceMoreActionType.duplicate.name,
height: SpaceUIConstants.itemHeight,
leftIcon: const FlowySvg(
FlowySvgs.duplicate_s,
size: Size.square(18),
),
showTopBorder: false,
showBottomBorder: false,
onTap: () => onAction(
SpaceMoreActionType.duplicate,
),
);
default:
return const SizedBox.shrink();
}
}
}
@@ -0,0 +1,53 @@
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/sidebar/space/space_bloc.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
class SpacePermissionBottomSheet extends StatelessWidget {
const SpacePermissionBottomSheet({
super.key,
required this.onAction,
required this.permission,
});
final SpacePermission permission;
final void Function(SpacePermission action) onAction;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
FlowyOptionTile.text(
text: LocaleKeys.space_publicPermission.tr(),
leftIcon: const FlowySvg(
FlowySvgs.space_permission_public_s,
),
trailing: permission == SpacePermission.publicToAll
? const FlowySvg(
FlowySvgs.m_blue_check_s,
blendMode: null,
)
: null,
onTap: () => onAction(SpacePermission.publicToAll),
),
FlowyOptionTile.text(
text: LocaleKeys.space_privatePermission.tr(),
showTopBorder: false,
leftIcon: const FlowySvg(
FlowySvgs.space_permission_private_s,
),
trailing: permission == SpacePermission.private
? const FlowySvg(
FlowySvgs.m_blue_check_s,
blendMode: null,
)
: null,
onTap: () => onAction(SpacePermission.private),
),
],
);
}
}
@@ -0,0 +1,389 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/animated_gesture.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/base/string_extension.dart';
import 'package:appflowy/shared/icon_emoji_picker/colors.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon.dart';
import 'package:appflowy/shared/icon_emoji_picker/icon_picker.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/space_icon_popup.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart' hide Icon;
import 'constants.dart';
import 'manage_space_widget.dart';
import 'space_permission_bottom_sheet.dart';
class ManageSpaceNameOption extends StatelessWidget {
const ManageSpaceNameOption({
super.key,
required this.controller,
required this.type,
});
final TextEditingController controller;
final ManageSpaceType type;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16, bottom: 4),
child: FlowyText(
LocaleKeys.space_spaceName.tr(),
fontSize: 14,
figmaLineHeight: 20.0,
fontWeight: FontWeight.w400,
color: Theme.of(context).hintColor,
),
),
FlowyOptionTile.textField(
controller: controller,
autofocus: type == ManageSpaceType.create ? true : false,
textFieldHintText: LocaleKeys.space_spaceNamePlaceholder.tr(),
),
const VSpace(16),
],
);
}
}
class ManageSpacePermissionOption extends StatelessWidget {
const ManageSpacePermissionOption({
super.key,
required this.permission,
});
final ValueNotifier<SpacePermission> permission;
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16, bottom: 4),
child: FlowyText(
LocaleKeys.space_permission.tr(),
fontSize: 14,
figmaLineHeight: 20.0,
fontWeight: FontWeight.w400,
color: Theme.of(context).hintColor,
),
),
ValueListenableBuilder(
valueListenable: permission,
builder: (context, value, child) => FlowyOptionTile.text(
height: SpaceUIConstants.itemHeight,
text: value.i18n,
leftIcon: FlowySvg(value.icon),
trailing: const FlowySvg(
FlowySvgs.arrow_right_s,
),
onTap: () {
showMobileBottomSheet(
context,
showHeader: true,
title: LocaleKeys.space_permission.tr(),
showCloseButton: true,
showDivider: false,
showDragHandle: true,
builder: (context) => SpacePermissionBottomSheet(
permission: value,
onAction: (value) {
permission.value = value;
Navigator.pop(context);
},
),
);
},
),
),
const VSpace(16),
],
);
}
}
class ManageSpaceIconOption extends StatefulWidget {
const ManageSpaceIconOption({
super.key,
required this.selectedColor,
required this.selectedIcon,
});
final ValueNotifier<String> selectedColor;
final ValueNotifier<Icon?> selectedIcon;
@override
State<ManageSpaceIconOption> createState() => _ManageSpaceIconOptionState();
}
class _ManageSpaceIconOptionState extends State<ManageSpaceIconOption> {
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
..._buildColorOption(context),
..._buildSpaceIconOption(context),
],
);
}
List<Widget> _buildColorOption(BuildContext context) {
return [
Padding(
padding: const EdgeInsets.only(left: 16, bottom: 4),
child: FlowyText(
LocaleKeys.space_mSpaceIconColor.tr(),
fontSize: 14,
figmaLineHeight: 20.0,
fontWeight: FontWeight.w400,
color: Theme.of(context).hintColor,
),
),
ValueListenableBuilder(
valueListenable: widget.selectedColor,
builder: (context, selectedColor, child) {
return FlowyOptionDecorateBox(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Row(
children: builtInSpaceColors.map((color) {
return SpaceColorItem(
color: color,
selectedColor: selectedColor,
onSelected: (color) => widget.selectedColor.value = color,
);
}).toList(),
),
),
),
);
},
),
const VSpace(16),
];
}
List<Widget> _buildSpaceIconOption(BuildContext context) {
return [
Padding(
padding: const EdgeInsets.only(left: 16, bottom: 4),
child: FlowyText(
LocaleKeys.space_mSpaceIcon.tr(),
fontSize: 14,
figmaLineHeight: 20.0,
fontWeight: FontWeight.w400,
color: Theme.of(context).hintColor,
),
),
Expanded(
child: SizedBox(
width: double.infinity,
child: ValueListenableBuilder(
valueListenable: widget.selectedColor,
builder: (context, selectedColor, child) {
return ValueListenableBuilder(
valueListenable: widget.selectedIcon,
builder: (context, selectedIcon, child) {
return FlowyOptionDecorateBox(
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
),
child: _buildIconGroups(
context,
selectedColor,
selectedIcon,
),
),
);
},
);
},
),
),
),
const VSpace(16),
];
}
Widget _buildIconGroups(
BuildContext context,
String selectedColor,
Icon? selectedIcon,
) {
final iconGroups = kIconGroups;
if (iconGroups == null) {
return const SizedBox.shrink();
}
return ListView.builder(
itemCount: iconGroups.length,
itemBuilder: (context, index) {
final iconGroup = iconGroups[index];
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const VSpace(12.0),
FlowyText(
iconGroup.displayName.capitalize(),
fontSize: 12,
figmaLineHeight: 18.0,
color: context.pickerTextColor,
),
const VSpace(4.0),
Center(
child: Wrap(
spacing: 10.0,
runSpacing: 8.0,
children: iconGroup.icons.map((icon) {
return SpaceIconItem(
icon: icon,
isSelected: selectedIcon?.name == icon.name,
selectedColor: selectedColor,
onSelectedIcon: (icon) => widget.selectedIcon.value = icon,
);
}).toList(),
),
),
const VSpace(12.0),
if (index == iconGroups.length - 1) ...[
const StreamlinePermit(),
],
],
);
},
);
}
}
class SpaceIconItem extends StatelessWidget {
const SpaceIconItem({
super.key,
required this.icon,
required this.onSelectedIcon,
required this.isSelected,
required this.selectedColor,
});
final Icon icon;
final void Function(Icon icon) onSelectedIcon;
final bool isSelected;
final String selectedColor;
@override
Widget build(BuildContext context) {
return AnimatedGestureDetector(
onTapUp: () => onSelectedIcon(icon),
child: Container(
width: 36,
height: 36,
decoration: isSelected
? BoxDecoration(
color: Color(int.parse(selectedColor)),
borderRadius: BorderRadius.circular(8.0),
)
: ShapeDecoration(
color: Colors.transparent,
shape: RoundedRectangleBorder(
side: const BorderSide(
width: 0.5,
color: Color(0x661F2329),
),
borderRadius: BorderRadius.circular(8),
),
),
child: Center(
child: FlowySvg.string(
icon.content,
size: const Size.square(18),
color: isSelected
? Theme.of(context).colorScheme.surface
: context.pickerIconColor,
opacity: isSelected ? 1.0 : 0.7,
),
),
),
);
}
}
class SpaceColorItem extends StatelessWidget {
const SpaceColorItem({
super.key,
required this.color,
required this.selectedColor,
required this.onSelected,
});
final String color;
final String selectedColor;
final void Function(String color) onSelected;
@override
Widget build(BuildContext context) {
final child = Center(
child: Container(
width: 28,
height: 28,
decoration: BoxDecoration(
color: Color(int.parse(color)),
borderRadius: BorderRadius.circular(14),
),
),
);
final decoration = color != selectedColor
? null
: ShapeDecoration(
color: Colors.transparent,
shape: RoundedRectangleBorder(
side: BorderSide(
width: 1.50,
color: Theme.of(context).colorScheme.primary,
),
borderRadius: BorderRadius.circular(21),
),
);
return AnimatedGestureDetector(
onTapUp: () => onSelected(color),
child: Container(
width: 36,
height: 36,
decoration: decoration,
child: child,
),
);
}
}
extension on SpacePermission {
String get i18n {
switch (this) {
case SpacePermission.publicToAll:
return LocaleKeys.space_publicPermission.tr();
case SpacePermission.private:
return LocaleKeys.space_privatePermission.tr();
}
}
FlowySvgData get icon {
switch (this) {
case SpacePermission.publicToAll:
return FlowySvgs.space_permission_public_s;
case SpacePermission.private:
return FlowySvgs.space_permission_private_s;
}
}
}
@@ -16,15 +16,18 @@ class FloatingAIEntry extends StatelessWidget {
scaleFactor: 0.99,
onTapUp: () => mobileCreateNewAIChatNotifier.value =
mobileCreateNewAIChatNotifier.value + 1,
child: DecoratedBox(
decoration: _buildShadowDecoration(context),
child: Container(
decoration: _buildWrapperDecoration(context),
height: 48,
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(left: 18),
child: _buildHintText(context),
child: Hero(
tag: "ai_chat_prompt",
child: DecoratedBox(
decoration: _buildShadowDecoration(context),
child: Container(
decoration: _buildWrapperDecoration(context),
height: 48,
alignment: Alignment.centerLeft,
child: Padding(
padding: const EdgeInsets.only(left: 18),
child: _buildHintText(context),
),
),
),
),
@@ -182,6 +182,7 @@ class _MobileSpaceTabState extends State<MobileSpaceTab>
SpaceEvent.createPage(
name: layout.defaultName,
layout: layout,
openAfterCreate: true,
),
);
} else if (layout == ViewLayoutPB.Document) {
@@ -32,6 +32,9 @@ class EditWorkspaceNameBottomSheet extends StatefulWidget {
required this.type,
required this.onSubmitted,
required this.workspaceName,
this.hintText,
this.validator,
this.validatorBuilder,
});
final EditWorkspaceNameType type;
@@ -40,6 +43,12 @@ class EditWorkspaceNameBottomSheet extends StatefulWidget {
// if the workspace name is not empty, it will be used as the initial value of the text field.
final String? workspaceName;
final String? hintText;
final String? Function(String?)? validator;
final WidgetBuilder? validatorBuilder;
@override
State<EditWorkspaceNameBottomSheet> createState() =>
_EditWorkspaceNameBottomSheetState();
@@ -68,6 +77,7 @@ class _EditWorkspaceNameBottomSheetState
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: <Widget>[
Form(
@@ -77,17 +87,24 @@ class _EditWorkspaceNameBottomSheetState
controller: _textFieldController,
keyboardType: TextInputType.text,
decoration: InputDecoration(
hintText: LocaleKeys.workspace_defaultName.tr(),
hintText:
widget.hintText ?? LocaleKeys.workspace_defaultName.tr(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return LocaleKeys.workspace_workspaceNameCannotBeEmpty.tr();
}
return null;
},
validator: widget.validator ??
(value) {
if (value == null || value.isEmpty) {
return LocaleKeys.workspace_workspaceNameCannotBeEmpty.tr();
}
return null;
},
onEditingComplete: _onSubmit,
),
),
if (widget.validatorBuilder != null) ...[
const VSpace(4),
widget.validatorBuilder!(context),
const VSpace(4),
],
const VSpace(16),
SizedBox(
width: double.infinity,
@@ -3,7 +3,7 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/animated_gesture.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/mobile/presentation/widgets/widgets.dart';
import 'package:appflowy/util/navigator_context_exntesion.dart';
import 'package:appflowy/util/navigator_context_extension.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/workspace/_sidebar_workspace_icon.dart';
@@ -234,6 +234,7 @@ class _WorkspaceMenuItemContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
final memberCount = workspace.memberCount.toInt();
return Padding(
padding: const EdgeInsets.only(left: 12),
child: Column(
@@ -247,10 +248,10 @@ class _WorkspaceMenuItemContent extends StatelessWidget {
overflow: TextOverflow.ellipsis,
),
FlowyText(
context.read<WorkspaceMemberBloc>().state.isLoading
memberCount == 0
? ''
: LocaleKeys.settings_appearance_members_membersCount.plural(
context.read<WorkspaceMemberBloc>().state.members.length,
memberCount,
),
fontSize: 10.0,
color: Theme.of(context).hintColor,
@@ -313,16 +314,18 @@ class _WorkspaceMenuItemTrailing extends StatelessWidget {
size: iconSize,
blendMode: null,
),
const HSpace(15.0),
const HSpace(8.0),
// more options button
AnimatedGestureDetector(
onTapUp: () => _showMoreOptions(context),
child: const FlowySvg(
FlowySvgs.workspace_three_dots_s,
size: iconSize,
child: const Padding(
padding: EdgeInsets.all(8.0),
child: FlowySvg(
FlowySvgs.workspace_three_dots_s,
size: iconSize,
),
),
),
const HSpace(8.0),
],
);
}
@@ -5,58 +5,52 @@ import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.da
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:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'chat_input_bloc.dart';
part 'ai_prompt_input_bloc.freezed.dart';
part 'chat_file_bloc.freezed.dart';
class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
AIPromptInputBloc()
: _listener = LocalLLMListener(),
super(AIPromptInputState.initial()) {
_dispatch();
_startListening();
_init();
}
class ChatFileBloc extends Bloc<ChatFileEvent, ChatFileState> {
ChatFileBloc()
: listener = LocalLLMListener(),
super(const ChatFileState()) {
listener.start(
stateCallback: (pluginState) {
if (!isClosed) {
add(ChatFileEvent.updatePluginState(pluginState));
}
},
chatStateCallback: (chatState) {
if (!isClosed) {
add(ChatFileEvent.updateChatState(chatState));
}
},
);
ChatInputFileMetadata consumeMetadata() {
final metadata = {
for (final file in state.uploadFiles) file.filePath: file,
};
if (metadata.isNotEmpty) {
add(const AIPromptInputEvent.clear());
}
return metadata;
}
final LocalLLMListener _listener;
@override
Future<void> close() async {
await _listener.stop();
return super.close();
}
void _dispatch() {
on<AIPromptInputEvent>(
(event, emit) {
event.when(
newFile: (String filePath, String fileName) {
final files = [...state.uploadFiles];
on<ChatFileEvent>(
(event, emit) async {
await event.when(
initial: () async {
final result = await AIEventGetLocalAIChatState().send();
result.fold(
(chatState) {
if (!isClosed) {
add(
ChatFileEvent.updateChatState(chatState),
);
}
},
(err) {
Log.error(err.toString());
},
);
},
newFile: (String filePath, String fileName) async {
final files = List<ChatFile>.from(state.uploadFiles);
final newFile = ChatFile.fromFilePath(filePath);
if (newFile != null) {
files.add(newFile);
emit(
state.copyWith(
uploadFiles: files,
),
);
emit(state.copyWith(uploadFiles: files));
}
},
updateChatState: (LocalAIChatPB chatState) {
@@ -76,8 +70,8 @@ class ChatFileBloc extends Bloc<ChatFileEvent, ChatFileState> {
fileEnabled && chatState.state == RunningStatePB.Running;
final aiType = chatState.state == RunningStatePB.Running
? const AIType.localAI()
: const AIType.appflowyAI();
? AIType.localAI
: AIType.appflowyAI;
emit(
state.copyWith(
@@ -107,48 +101,66 @@ class ChatFileBloc extends Bloc<ChatFileEvent, ChatFileState> {
);
}
ChatInputFileMetadata consumeMetaData() {
final metadata = state.uploadFiles.fold(
<String, ChatFile>{},
(map, file) => map..putIfAbsent(file.filePath, () => file),
void _startListening() {
_listener.start(
stateCallback: (pluginState) {
if (!isClosed) {
add(AIPromptInputEvent.updatePluginState(pluginState));
}
},
chatStateCallback: (chatState) {
if (!isClosed) {
add(AIPromptInputEvent.updateChatState(chatState));
}
},
);
if (metadata.isNotEmpty) {
add(const ChatFileEvent.clear());
}
return metadata;
}
final LocalLLMListener listener;
@override
Future<void> close() async {
await listener.stop();
return super.close();
void _init() {
AIEventGetLocalAIChatState().send().fold(
(chatState) {
if (!isClosed) {
add(AIPromptInputEvent.updateChatState(chatState));
}
},
Log.error,
);
}
}
@freezed
class ChatFileEvent with _$ChatFileEvent {
const factory ChatFileEvent.initial() = Initial;
const factory ChatFileEvent.newFile(String filePath, String fileName) =
class AIPromptInputEvent with _$AIPromptInputEvent {
const factory AIPromptInputEvent.newFile(String filePath, String fileName) =
_NewFile;
const factory ChatFileEvent.deleteFile(ChatFile file) = _DeleteFile;
const factory ChatFileEvent.clear() = _ClearFile;
const factory ChatFileEvent.updateChatState(LocalAIChatPB chatState) =
_UpdateChatState;
const factory ChatFileEvent.updatePluginState(
const factory AIPromptInputEvent.deleteFile(ChatFile file) = _DeleteFile;
const factory AIPromptInputEvent.clear() = _ClearFile;
const factory AIPromptInputEvent.updateChatState(
LocalAIChatPB chatState,
) = _UpdateChatState;
const factory AIPromptInputEvent.updatePluginState(
LocalAIPluginStatePB chatState,
) = _UpdatePluginState;
}
@freezed
class ChatFileState with _$ChatFileState {
const factory ChatFileState({
@Default(false) bool supportChatWithFile,
class AIPromptInputState with _$AIPromptInputState {
const factory AIPromptInputState({
required bool supportChatWithFile,
LocalAIChatPB? chatState,
@Default([]) List<ChatFile> uploadFiles,
@Default(AIType.appflowyAI()) AIType aiType,
}) = _ChatFileState;
required List<ChatFile> uploadFiles,
required AIType aiType,
}) = _AIPromptInputState;
factory AIPromptInputState.initial() => const AIPromptInputState(
supportChatWithFile: false,
uploadFiles: [],
aiType: AIType.appflowyAI,
);
}
enum AIType {
appflowyAI,
localAI;
bool get isLocalAI => this == localAI;
}
@@ -1,5 +1,3 @@
import 'dart:async';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
@@ -22,46 +20,29 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
}) : super(
ChatAIMessageState.initial(
message,
messageReferenceSource(refSourceJsonString),
parseMetadata(refSourceJsonString),
),
) {
if (state.stream != null) {
state.stream!.listen(
onData: (text) {
if (!isClosed) {
add(ChatAIMessageEvent.updateText(text));
}
},
onError: (error) {
if (!isClosed) {
add(ChatAIMessageEvent.receiveError(error.toString()));
}
},
onAIResponseLimit: () {
if (!isClosed) {
add(const ChatAIMessageEvent.onAIResponseLimit());
}
},
onMetadata: (sources) {
if (!isClosed) {
add(ChatAIMessageEvent.receiveSources(sources));
}
},
);
_dispatch();
if (state.stream!.error != null) {
Future.delayed(const Duration(milliseconds: 300), () {
if (!isClosed) {
add(ChatAIMessageEvent.receiveError(state.stream!.error!));
}
});
if (state.stream != null) {
_startListening();
if (state.stream!.aiLimitReached) {
add(const ChatAIMessageEvent.onAIResponseLimit());
} else if (state.stream!.error != null) {
add(ChatAIMessageEvent.receiveError(state.stream!.error!));
}
}
}
final String chatId;
final Int64? questionId;
void _dispatch() {
on<ChatAIMessageEvent>(
(event, emit) async {
await event.when(
initial: () async {},
(event, emit) {
event.when(
updateText: (newText) {
emit(
state.copyWith(
@@ -117,10 +98,12 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
),
);
},
receiveSources: (List<ChatMessageRefSource> sources) {
receiveMetadata: (metadata) {
Log.debug("AI Steps: ${metadata.progress?.step}");
emit(
state.copyWith(
sources: sources,
sources: metadata.sources,
progress: metadata.progress,
),
);
},
@@ -129,20 +112,41 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
);
}
final String chatId;
final Int64? questionId;
void _startListening() {
state.stream!.listen(
onData: (text) {
if (!isClosed) {
add(ChatAIMessageEvent.updateText(text));
}
},
onError: (error) {
if (!isClosed) {
add(ChatAIMessageEvent.receiveError(error.toString()));
}
},
onAIResponseLimit: () {
if (!isClosed) {
add(const ChatAIMessageEvent.onAIResponseLimit());
}
},
onMetadata: (metadata) {
if (!isClosed) {
add(ChatAIMessageEvent.receiveMetadata(metadata));
}
},
);
}
}
@freezed
class ChatAIMessageEvent with _$ChatAIMessageEvent {
const factory ChatAIMessageEvent.initial() = Initial;
const factory ChatAIMessageEvent.updateText(String text) = _UpdateText;
const factory ChatAIMessageEvent.receiveError(String error) = _ReceiveError;
const factory ChatAIMessageEvent.retry() = _Retry;
const factory ChatAIMessageEvent.retryResult(String text) = _RetryResult;
const factory ChatAIMessageEvent.onAIResponseLimit() = _OnAIResponseLimit;
const factory ChatAIMessageEvent.receiveSources(
List<ChatMessageRefSource> sources,
const factory ChatAIMessageEvent.receiveMetadata(
MetadataCollection metadata,
) = _ReceiveMetadata;
}
@@ -153,17 +157,19 @@ class ChatAIMessageState with _$ChatAIMessageState {
required String text,
required MessageState messageState,
required List<ChatMessageRefSource> sources,
required AIChatProgress? progress,
}) = _ChatAIMessageState;
factory ChatAIMessageState.initial(
dynamic text,
List<ChatMessageRefSource> sources,
MetadataCollection metadata,
) {
return ChatAIMessageState(
text: text is String ? text : "",
stream: text is AnswerStream ? text : null,
messageState: const MessageState.ready(),
sources: sources,
sources: metadata.sources,
progress: metadata.progress,
);
}
}
@@ -1,47 +1,50 @@
import 'dart:async';
import 'dart:collection';
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart';
import 'package:appflowy/util/int64_extension.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/code.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:collection/collection.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:nanoid/nanoid.dart';
import 'chat_entity.dart';
import 'chat_message_listener.dart';
import 'chat_message_service.dart';
import 'chat_message_stream.dart';
part 'chat_bloc.freezed.dart';
class ChatBloc extends Bloc<ChatEvent, ChatState> {
ChatBloc({
required ViewPB view,
required UserProfilePB userProfile,
}) : listener = ChatMessageListener(chatId: view.id),
chatId = view.id,
super(
ChatState.initial(view, userProfile),
) {
required this.chatId,
required this.userId,
}) : chatController = InMemoryChatController(),
listener = ChatMessageListener(chatId: chatId),
super(ChatState.initial()) {
_startListening();
_dispatch();
_init();
}
final ChatMessageListener listener;
final String chatId;
final String userId;
final ChatMessageListener listener;
final ChatController chatController;
/// The last streaming message id
String answerStreamMessageId = '';
String questionStreamMessageId = '';
ChatMessagePB? lastSentMessage;
/// Using a temporary map to associate the real message ID with the last streaming message ID.
///
/// When a message is streaming, it does not have a real message ID. To maintain the relationship
@@ -51,11 +54,24 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
/// is 3 (AI response).
final HashMap<String, String> temporaryMessageIDMap = HashMap();
bool isLoadingPreviousMessages = false;
bool hasMorePreviousMessages = true;
AnswerStream? answerStream;
int numSendMessage = 0;
/// a counter used to determine whether the initial loading state should be
/// set to finish. It should hit two before we emit: one for the local fetch
/// and another for the server fetch.
///
/// This is to work around a bug where if an ai chat that is not yet on the
/// user local storage is opened but has messages in the server, it will
/// remain stuck on the welcome screen until the user switches to another page
/// then come back.
int initialFetchCounter = 0;
@override
Future<void> close() async {
if (state.answerStream != null) {
await state.answerStream?.dispose();
}
await answerStream?.dispose();
await listener.stop();
return super.close();
}
@@ -64,187 +80,170 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
on<ChatEvent>(
(event, emit) async {
await event.when(
initialLoad: () {
final payload = LoadNextChatMessagePB(
chatId: state.view.id,
limit: Int64(10),
);
AIEventLoadNextMessage(payload).send().then(
(result) {
result.fold((list) {
if (!isClosed) {
final messages =
list.messages.map(_createTextMessage).toList();
add(ChatEvent.didLoadLatestMessages(messages));
}
}, (err) {
Log.error("Failed to load messages: $err");
});
},
);
},
// Loading messages
startLoadingPrevMessage: () async {
Int64? beforeMessageId;
final oldestMessage = _getOlderstMessage();
if (oldestMessage != null) {
try {
beforeMessageId = Int64.parseInt(oldestMessage.id);
} catch (e) {
Log.error(
"Failed to parse message id: $e, messaeg_id: ${oldestMessage.id}",
);
}
}
_loadPrevMessage(beforeMessageId);
emit(
state.copyWith(
loadingPreviousStatus: const ChatLoadingState.loading(),
),
);
},
didLoadPreviousMessages: (List<Message> messages, bool hasMore) {
Log.debug("did load previous messages: ${messages.length}");
final onetimeMessages = _getOnetimeMessages();
final allMessages = _perminentMessages();
final uniqueMessages = {...allMessages, ...messages}.toList()
..sort((a, b) => b.id.compareTo(a.id));
uniqueMessages.insertAll(0, onetimeMessages);
emit(
state.copyWith(
messages: uniqueMessages,
loadingPreviousStatus: const ChatLoadingState.finish(),
hasMorePrevMessage: hasMore,
),
);
},
didLoadLatestMessages: (List<Message> messages) {
final onetimeMessages = _getOnetimeMessages();
final allMessages = _perminentMessages();
final uniqueMessages = {...allMessages, ...messages}.toList()
..sort((a, b) => b.id.compareTo(a.id));
uniqueMessages.insertAll(0, onetimeMessages);
emit(
state.copyWith(
messages: uniqueMessages,
initialLoadingStatus: const ChatLoadingState.finish(),
),
);
},
// streaming message
finishAnswerStreaming: () {
emit(
state.copyWith(
streamingState: const StreamingState.done(),
acceptRelatedQuestion: true,
canSendMessage:
state.sendingState == const SendMessageState.done(),
),
);
},
didUpdateAnswerStream: (AnswerStream stream) {
emit(state.copyWith(answerStream: stream));
},
stopStream: () async {
if (state.answerStream == null) {
return;
didLoadLatestMessages: (List<Message> messages) async {
for (final message in messages) {
await chatController.insert(message, index: 0);
}
final payload = StopStreamPB(chatId: chatId);
await AIEventStopStream(payload).send();
final allMessages = _perminentMessages();
if (state.streamingState != const StreamingState.done()) {
// If the streaming is not started, remove the message from the list
if (!state.answerStream!.hasStarted) {
allMessages.removeWhere(
(element) => element.id == answerStreamMessageId,
);
answerStreamMessageId = "";
}
if (initialFetchCounter < 2) {
initialFetchCounter++;
}
// when stop stream, we will set the answer stream to null. Which means the streaming
// is finished or canceled.
if (state.loadingState.isLoading && initialFetchCounter >= 2) {
emit(
state.copyWith(
messages: allMessages,
answerStream: null,
streamingState: const StreamingState.done(),
),
state.copyWith(loadingState: const ChatLoadingState.finish()),
);
}
},
receveMessage: (Message message) {
final allMessages = _perminentMessages();
// remove message with the same id
allMessages.removeWhere((element) => element.id == message.id);
allMessages.insert(0, message);
loadPreviousMessages: () {
if (isLoadingPreviousMessages) {
return;
}
final oldestMessage = _getOldestMessage();
if (oldestMessage != null) {
final oldestMessageId = Int64.tryParseInt(oldestMessage.id);
if (oldestMessageId == null) {
Log.error("Failed to parse message_id: ${oldestMessage.id}");
return;
}
isLoadingPreviousMessages = true;
_loadPreviousMessages(oldestMessageId);
}
},
didLoadPreviousMessages: (messages, hasMore) {
Log.debug("did load previous messages: ${messages.length}");
for (final message in messages) {
chatController.insert(message, index: 0);
}
isLoadingPreviousMessages = false;
hasMorePreviousMessages = hasMore;
},
didFinishAnswerStream: () {
emit(
state.copyWith(
messages: allMessages,
),
state.copyWith(promptResponseState: PromptResponseState.ready),
);
},
startAnswerStreaming: (Message message) {
final allMessages = _perminentMessages();
allMessages.insert(0, message);
emit(
state.copyWith(
messages: allMessages,
streamingState: const StreamingState.streaming(),
canSendMessage: false,
),
);
},
sendMessage: (String message, Map<String, dynamic>? metadata) async {
unawaited(_startStreamingMessage(message, metadata, emit));
final allMessages = _perminentMessages();
emit(
state.copyWith(
lastSentMessage: null,
messages: allMessages,
relatedQuestions: [],
acceptRelatedQuestion: false,
sendingState: const SendMessageState.sending(),
canSendMessage: false,
),
);
},
finishSending: (ChatMessagePB message) {
emit(
state.copyWith(
lastSentMessage: message,
sendingState: const SendMessageState.done(),
canSendMessage:
state.streamingState == const StreamingState.done(),
),
);
},
// related question
didReceiveRelatedQuestion: (List<RelatedQuestionPB> questions) {
didReceiveRelatedQuestions: (List<String> questions) {
if (questions.isEmpty) {
return;
}
final allMessages = _perminentMessages();
final message = CustomMessage(
metadata: OnetimeShotType.relatedQuestion.toMap(),
final metadata = {
onetimeShotType: OnetimeShotType.relatedQuestion,
'questions': questions,
};
final createdAt = DateTime.now();
final message = TextMessage(
id: "related_question_$createdAt",
text: '',
metadata: metadata,
author: const User(id: systemUserId),
id: systemUserId,
createdAt: createdAt,
);
allMessages.insert(0, message);
chatController.insert(message);
},
receiveMessage: (Message message) {
final oldMessage = chatController.messages
.firstWhereOrNull((m) => m.id == message.id);
if (oldMessage == null) {
chatController.insert(message);
} else {
chatController.update(oldMessage, message);
}
},
sendMessage: (
String message,
Map<String, dynamic>? metadata,
) {
numSendMessage += 1;
final relatedQuestionMessages = chatController.messages
.where(
(message) =>
onetimeMessageTypeFromMeta(message.metadata) ==
OnetimeShotType.relatedQuestion,
)
.toList();
for (final message in relatedQuestionMessages) {
chatController.remove(message);
}
_startStreamingMessage(message, metadata);
lastSentMessage = null;
emit(
state.copyWith(
messages: allMessages,
relatedQuestions: questions,
promptResponseState: PromptResponseState.sendingQuestion,
),
);
},
clearReleatedQuestion: () {
finishSending: (ChatMessagePB message) {
lastSentMessage = message;
emit(
state.copyWith(
relatedQuestions: [],
promptResponseState: PromptResponseState.awaitingAnswer,
),
);
},
stopStream: () async {
if (answerStream == null) {
return;
}
// tell backend to stop
final payload = StopStreamPB(chatId: chatId);
await AIEventStopStream(payload).send();
// allow user input
emit(
state.copyWith(
promptResponseState: PromptResponseState.ready,
),
);
// no need to remove old message if stream has started already
if (answerStream!.hasStarted) {
return;
}
// remove the non-started message from the list
final message = chatController.messages.lastWhereOrNull(
(e) => e.id == answerStreamMessageId,
);
if (message != null) {
await chatController.remove(message);
}
// set answer stream to null
await answerStream?.dispose();
answerStream = null;
answerStreamMessageId = '';
},
startAnswerStreaming: (Message message) {
chatController.insert(message);
emit(
state.copyWith(
promptResponseState: PromptResponseState.streamingAnswer,
),
);
},
failedSending: () {
final lastMessage = chatController.messages.lastOrNull;
if (lastMessage != null) {
chatController.remove(lastMessage);
}
emit(
state.copyWith(
promptResponseState: PromptResponseState.ready,
),
);
},
@@ -256,29 +255,31 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
void _startListening() {
listener.start(
chatMessageCallback: (pb) {
if (!isClosed) {
// 3 mean message response from AI
if (pb.authorType == 3 && answerStreamMessageId.isNotEmpty) {
temporaryMessageIDMap[pb.messageId.toString()] =
answerStreamMessageId;
answerStreamMessageId = "";
}
// 1 mean message response from User
if (pb.authorType == 1 && questionStreamMessageId.isNotEmpty) {
temporaryMessageIDMap[pb.messageId.toString()] =
questionStreamMessageId;
questionStreamMessageId = "";
}
final message = _createTextMessage(pb);
add(ChatEvent.receveMessage(message));
if (isClosed) {
return;
}
// 3 mean message response from AI
if (pb.authorType == 3 && answerStreamMessageId.isNotEmpty) {
temporaryMessageIDMap[pb.messageId.toString()] =
answerStreamMessageId;
answerStreamMessageId = '';
}
// 1 mean message response from User
if (pb.authorType == 1 && questionStreamMessageId.isNotEmpty) {
temporaryMessageIDMap[pb.messageId.toString()] =
questionStreamMessageId;
questionStreamMessageId = '';
}
final message = _createTextMessage(pb);
add(ChatEvent.receiveMessage(message));
},
chatErrorMessageCallback: (err) {
if (!isClosed) {
Log.error("chat error: ${err.errorMessage}");
add(const ChatEvent.finishAnswerStreaming());
add(const ChatEvent.didFinishAnswerStream());
}
},
latestMessageCallback: (list) {
@@ -293,65 +294,74 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
add(ChatEvent.didLoadPreviousMessages(messages, list.hasMore));
}
},
finishStreamingCallback: () {
if (!isClosed) {
add(const ChatEvent.finishAnswerStreaming());
// The answer strema will bet set to null after the streaming is finished or canceled.
// so if the answer stream is null, we will not get related question.
if (state.lastSentMessage != null && state.answerStream != null) {
final payload = ChatMessageIdPB(
chatId: chatId,
messageId: state.lastSentMessage!.messageId,
);
// When user message was sent to the server, we start gettting related question
AIEventGetRelatedQuestion(payload).send().then((result) {
if (!isClosed) {
result.fold(
(list) {
if (state.acceptRelatedQuestion) {
add(ChatEvent.didReceiveRelatedQuestion(list.items));
}
},
(err) {
Log.error("Failed to get related question: $err");
},
);
}
});
}
finishStreamingCallback: () async {
if (isClosed) {
return;
}
add(const ChatEvent.didFinishAnswerStream());
// The answer stream will bet set to null after the streaming has
// finished, got cancelled, or errored. In this case, don't retrieve
// related questions.
if (answerStream == null || lastSentMessage == null) {
return;
}
final payload = ChatMessageIdPB(
chatId: chatId,
messageId: lastSentMessage!.messageId,
);
// when previous numSendMessage is not equal to current numSendMessage, it means that the user
// has sent a new message. So we don't need to get related questions.
final preNumSendMessage = numSendMessage;
await AIEventGetRelatedQuestion(payload).send().fold(
(list) {
if (!isClosed && preNumSendMessage == numSendMessage) {
add(
ChatEvent.didReceiveRelatedQuestions(
list.items.map((e) => e.content).toList(),
),
);
}
},
(err) => Log.error("Failed to get related questions: $err"),
);
},
);
}
// Returns the list of messages that are not include one-time messages.
List<Message> _perminentMessages() {
final allMessages = state.messages.where((element) {
return !(element.metadata?.containsKey(onetimeShotType) == true);
}).toList();
return allMessages;
void _init() async {
final payload = LoadNextChatMessagePB(
chatId: chatId,
limit: Int64(10),
);
await AIEventLoadNextMessage(payload).send().fold(
(list) {
if (!isClosed) {
final messages = list.messages.map(_createTextMessage).toList();
add(ChatEvent.didLoadLatestMessages(messages));
}
},
(err) => Log.error("Failed to load messages: $err"),
);
}
List<Message> _getOnetimeMessages() {
final messages = state.messages.where((element) {
return (element.metadata?.containsKey(onetimeShotType) == true);
}).toList();
return messages;
bool _isOneTimeMessage(Message message) {
return message.metadata != null &&
message.metadata!.containsKey(onetimeShotType);
}
Message? _getOlderstMessage() {
// get the last message that is not a one-time message
final message = state.messages.lastWhereOrNull((element) {
return !(element.metadata?.containsKey(onetimeShotType) == true);
});
return message;
/// get the last message that is not a one-time message
Message? _getOldestMessage() {
return chatController.messages
.firstWhereOrNull((message) => !_isOneTimeMessage(message));
}
void _loadPrevMessage(Int64? beforeMessageId) {
void _loadPreviousMessages(Int64? beforeMessageId) {
final payload = LoadPrevChatMessagePB(
chatId: state.view.id,
chatId: chatId,
limit: Int64(10),
beforeMessageId: beforeMessageId,
);
@@ -361,61 +371,60 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
Future<void> _startStreamingMessage(
String message,
Map<String, dynamic>? metadata,
Emitter<ChatState> emit,
) async {
if (state.answerStream != null) {
await state.answerStream?.dispose();
}
await answerStream?.dispose();
final answerStream = AnswerStream();
answerStream = AnswerStream();
final questionStream = QuestionStream();
add(ChatEvent.didUpdateAnswerStream(answerStream));
final payload = StreamChatPayloadPB(
chatId: state.view.id,
message: message,
messageType: ChatMessageTypePB.User,
questionStreamPort: Int64(questionStream.nativePort),
answerStreamPort: Int64(answerStream.nativePort),
metadata: await metadataPBFromMetadata(metadata),
);
// add a streaming question message
final questionStreamMessage = _createQuestionStreamMessage(
questionStream,
metadata,
);
add(ChatEvent.receveMessage(questionStreamMessage));
add(ChatEvent.receiveMessage(questionStreamMessage));
// Stream message to the server
final result = await AIEventStreamMessage(payload).send();
result.fold(
(ChatMessagePB question) {
final payload = StreamChatPayloadPB(
chatId: chatId,
message: message,
messageType: ChatMessageTypePB.User,
questionStreamPort: Int64(questionStream.nativePort),
answerStreamPort: Int64(answerStream!.nativePort),
metadata: await metadataPBFromMetadata(metadata),
);
// stream the question to the server
await AIEventStreamMessage(payload).send().fold(
(question) {
if (!isClosed) {
final streamAnswer = _createAnswerStreamMessage(
answerStream!,
question.messageId,
);
add(ChatEvent.finishSending(question));
// final message = _createTextMessage(question);
// add(ChatEvent.receveMessage(message));
final streamAnswer =
_createAnswerStreamMessage(answerStream, question.messageId);
add(ChatEvent.startAnswerStreaming(streamAnswer));
}
},
(err) {
if (!isClosed) {
Log.error("Failed to send message: ${err.msg}");
final metadata = OnetimeShotType.invalidSendMesssage.toMap();
if (err.code != ErrorCode.Internal) {
metadata[sendMessageErrorKey] = err.msg;
}
final error = CustomMessage(
final metadata = {
onetimeShotType: OnetimeShotType.error,
if (err.code != ErrorCode.Internal) errorMessageTextKey: err.msg,
};
final error = TextMessage(
text: '',
metadata: metadata,
author: const User(id: systemUserId),
id: systemUserId,
createdAt: DateTime.now(),
);
add(ChatEvent.receveMessage(error));
add(const ChatEvent.failedSending());
add(ChatEvent.receiveMessage(error));
}
},
);
@@ -425,19 +434,18 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
AnswerStream stream,
Int64 questionMessageId,
) {
final streamMessageId = (questionMessageId + 1).toString();
answerStreamMessageId = streamMessageId;
answerStreamMessageId = (questionMessageId + 1).toString();
return TextMessage(
id: answerStreamMessageId,
text: '',
author: User(id: "streamId:${nanoid()}"),
metadata: {
"$AnswerStream": stream,
messageQuestionIdKey: questionMessageId,
"chatId": chatId,
},
id: streamMessageId,
createdAt: DateTime.now().millisecondsSinceEpoch,
text: '',
createdAt: DateTime.now(),
);
}
@@ -446,23 +454,18 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
Map<String, dynamic>? sentMetadata,
) {
final now = DateTime.now();
final timestamp = now.millisecondsSinceEpoch;
questionStreamMessageId = timestamp.toString();
final Map<String, dynamic> metadata = {};
// if (sentMetadata != null) {
// metadata[messageMetadataJsonStringKey] = sentMetadata;
// }
questionStreamMessageId = (now.millisecondsSinceEpoch ~/ 1000).toString();
metadata["$QuestionStream"] = stream;
metadata["chatId"] = chatId;
metadata[messageChatFileListKey] =
chatFilesFromMessageMetadata(sentMetadata);
return TextMessage(
author: User(id: state.userProfile.id.toString()),
metadata: metadata,
author: User(id: userId),
metadata: {
"$QuestionStream": stream,
"chatId": chatId,
messageChatFileListKey: chatFilesFromMessageMetadata(sentMetadata),
},
id: questionStreamMessageId,
createdAt: DateTime.now().millisecondsSinceEpoch,
createdAt: now,
text: '',
);
}
@@ -479,7 +482,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
author: User(id: message.authorId),
id: messageId,
text: message.content,
createdAt: message.createdAt.toInt() * 1000,
createdAt: message.createdAt.toDateTime(),
metadata: {
messageRefSourceJsonStringKey: message.metadata,
},
@@ -489,8 +492,6 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
@freezed
class ChatEvent with _$ChatEvent {
const factory ChatEvent.initialLoad() = _InitialLoadMessage;
// send message
const factory ChatEvent.sendMessage({
required String message,
@@ -498,72 +499,42 @@ class ChatEvent with _$ChatEvent {
}) = _SendMessage;
const factory ChatEvent.finishSending(ChatMessagePB message) =
_FinishSendMessage;
const factory ChatEvent.failedSending() = _FailSendMessage;
// receive message
// streaming answer
const factory ChatEvent.startAnswerStreaming(Message message) =
_StartAnswerStreaming;
const factory ChatEvent.receveMessage(Message message) = _ReceiveMessage;
const factory ChatEvent.finishAnswerStreaming() = _FinishAnswerStreaming;
const factory ChatEvent.stopStream() = _StopStream;
const factory ChatEvent.didFinishAnswerStream() = _DidFinishAnswerStream;
// loading messages
const factory ChatEvent.startLoadingPrevMessage() = _StartLoadPrevMessage;
// receive message
const factory ChatEvent.receiveMessage(Message message) = _ReceiveMessage;
// loading messages
const factory ChatEvent.didLoadLatestMessages(List<Message> messages) =
_DidLoadMessages;
const factory ChatEvent.loadPreviousMessages() = _LoadPreviousMessages;
const factory ChatEvent.didLoadPreviousMessages(
List<Message> messages,
bool hasMore,
) = _DidLoadPreviousMessages;
const factory ChatEvent.didLoadLatestMessages(List<Message> messages) =
_DidLoadMessages;
// related questions
const factory ChatEvent.didReceiveRelatedQuestion(
List<RelatedQuestionPB> questions,
// related questions
const factory ChatEvent.didReceiveRelatedQuestions(
List<String> questions,
) = _DidReceiveRelatedQueston;
const factory ChatEvent.clearReleatedQuestion() = _ClearRelatedQuestion;
const factory ChatEvent.didUpdateAnswerStream(
AnswerStream stream,
) = _DidUpdateAnswerStream;
const factory ChatEvent.stopStream() = _StopStream;
}
@freezed
class ChatState with _$ChatState {
const factory ChatState({
required ViewPB view,
required List<Message> messages,
required UserProfilePB userProfile,
// When opening the chat, the initial loading status will be set as loading.
//After the initial loading is done, the status will be set as finished.
required ChatLoadingState initialLoadingStatus,
// When loading previous messages, the status will be set as loading.
// After the loading is done, the status will be set as finished.
required ChatLoadingState loadingPreviousStatus,
// When sending a user message, the status will be set as loading.
// After the message is sent, the status will be set as finished.
required StreamingState streamingState,
required SendMessageState sendingState,
// Indicate whether there are more previous messages to load.
required bool hasMorePrevMessage,
// The related questions that are received after the user message is sent.
required List<RelatedQuestionPB> relatedQuestions,
@Default(false) bool acceptRelatedQuestion,
// The last user message that is sent to the server.
ChatMessagePB? lastSentMessage,
AnswerStream? answerStream,
@Default(true) bool canSendMessage,
required ChatLoadingState loadingState,
required PromptResponseState promptResponseState,
}) = _ChatState;
factory ChatState.initial(ViewPB view, UserProfilePB userProfile) =>
ChatState(
view: view,
messages: [],
userProfile: userProfile,
initialLoadingStatus: const ChatLoadingState.finish(),
loadingPreviousStatus: const ChatLoadingState.finish(),
streamingState: const StreamingState.done(),
sendingState: const SendMessageState.done(),
hasMorePrevMessage: true,
relatedQuestions: [],
factory ChatState.initial() => const ChatState(
loadingState: ChatLoadingState.loading(),
promptResponseState: PromptResponseState.ready,
);
}
@@ -1,18 +1,15 @@
import 'dart:io';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pbenum.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:equatable/equatable.dart';
import 'package:flutter/material.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:path/path.dart' as path;
part 'chat_entity.g.dart';
part 'chat_entity.freezed.dart';
const sendMessageErrorKey = "sendMessageError";
const errorMessageTextKey = "errorMessageText";
const systemUserId = "system";
const aiResponseUserId = "0";
@@ -42,16 +39,25 @@ class ChatMessageRefSource {
Map<String, dynamic> toJson() => _$ChatMessageRefSourceToJson(this);
}
@freezed
class StreamingState with _$StreamingState {
const factory StreamingState.streaming() = _Streaming;
const factory StreamingState.done({FlowyError? error}) = _StreamDone;
@JsonSerializable()
class AIChatProgress {
AIChatProgress({
required this.step,
});
factory AIChatProgress.fromJson(Map<String, dynamic> json) =>
_$AIChatProgressFromJson(json);
final String step;
Map<String, dynamic> toJson() => _$AIChatProgressToJson(this);
}
@freezed
class SendMessageState with _$SendMessageState {
const factory SendMessageState.sending() = _Sending;
const factory SendMessageState.done({FlowyError? error}) = _SendDone;
enum PromptResponseState {
ready,
sendingQuestion,
awaitingAnswer,
streamingAnswer,
}
class ChatFile extends Equatable {
@@ -100,30 +106,6 @@ class ChatFile extends Equatable {
List<Object?> get props => [filePath];
}
extension ChatFileTypeExtension on ChatMessageMetaTypePB {
Widget get icon {
switch (this) {
case ChatMessageMetaTypePB.PDF:
return const FlowySvg(
FlowySvgs.file_pdf_s,
color: Color(0xff00BCF0),
);
case ChatMessageMetaTypePB.Txt:
return const FlowySvg(
FlowySvgs.file_txt_s,
color: Color(0xff00BCF0),
);
case ChatMessageMetaTypePB.Markdown:
return const FlowySvg(
FlowySvgs.file_md_s,
color: Color(0xff00BCF0),
);
default:
return const FlowySvg(FlowySvgs.file_unknown_s);
}
}
}
typedef ChatInputFileMetadata = Map<String, ChatFile>;
@freezed
@@ -138,45 +120,13 @@ extension ChatLoadingStateExtension on ChatLoadingState {
}
enum OnetimeShotType {
unknown,
sendingMessage,
relatedQuestion,
invalidSendMesssage,
error,
}
const onetimeShotType = "OnetimeShotType";
extension OnetimeMessageTypeExtension on OnetimeShotType {
static OnetimeShotType fromString(String value) {
switch (value) {
case 'OnetimeShotType.sendingMessage':
return OnetimeShotType.sendingMessage;
case 'OnetimeShotType.relatedQuestion':
return OnetimeShotType.relatedQuestion;
case 'OnetimeShotType.invalidSendMesssage':
return OnetimeShotType.invalidSendMesssage;
default:
Log.error('Unknown OnetimeShotType: $value');
return OnetimeShotType.unknown;
}
}
Map<String, dynamic> toMap() {
return {
onetimeShotType: toString(),
};
}
}
OnetimeShotType? onetimeMessageTypeFromMeta(Map<String, dynamic>? metadata) {
if (metadata == null) {
return null;
}
for (final entry in metadata.entries) {
if (entry.key == onetimeShotType) {
return OnetimeMessageTypeExtension.fromString(entry.value as String);
}
}
return null;
return metadata?[onetimeShotType];
}
@@ -1,87 +0,0 @@
import 'dart:async';
import 'package:appflowy/workspace/application/settings/ai/local_llm_listener.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:bloc/bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_input_bloc.freezed.dart';
class ChatInputStateBloc
extends Bloc<ChatInputStateEvent, ChatInputStateState> {
ChatInputStateBloc()
: listener = LocalLLMListener(),
super(const ChatInputStateState(aiType: _AppFlowyAI())) {
listener.start(
stateCallback: (pluginState) {
if (!isClosed) {
add(ChatInputStateEvent.updatePluginState(pluginState));
}
},
);
on<ChatInputStateEvent>(_handleEvent);
}
final LocalLLMListener listener;
@override
Future<void> close() async {
await listener.stop();
return super.close();
}
Future<void> _handleEvent(
ChatInputStateEvent event,
Emitter<ChatInputStateState> emit,
) async {
await event.when(
started: () async {
final result = await AIEventGetLocalAIPluginState().send();
result.fold(
(pluginState) {
if (!isClosed) {
add(
ChatInputStateEvent.updatePluginState(pluginState),
);
}
},
(err) {
Log.error(err.toString());
},
);
},
updatePluginState: (pluginState) {
if (pluginState.state == RunningStatePB.Running) {
emit(const ChatInputStateState(aiType: _LocalAI()));
} else {
emit(const ChatInputStateState(aiType: _AppFlowyAI()));
}
},
);
}
}
@freezed
class ChatInputStateEvent with _$ChatInputStateEvent {
const factory ChatInputStateEvent.started() = _Started;
const factory ChatInputStateEvent.updatePluginState(
LocalAIPluginStatePB pluginState,
) = _UpdatePluginState;
}
@freezed
class ChatInputStateState with _$ChatInputStateState {
const factory ChatInputStateState({required AIType aiType}) = _ChatInputState;
}
@freezed
class AIType with _$AIType {
const factory AIType.appflowyAI() = _AppFlowyAI;
const factory AIType.localAI() = _LocalAI;
}
extension AITypeX on AIType {
bool isLocalAI() => this is _LocalAI;
}
@@ -1,4 +1,3 @@
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@@ -7,14 +6,11 @@ part 'chat_input_file_bloc.freezed.dart';
class ChatInputFileBloc extends Bloc<ChatInputFileEvent, ChatInputFileState> {
ChatInputFileBloc({
// ignore: avoid_unused_constructor_parameters
required String chatId,
required this.file,
}) : super(const ChatInputFileState()) {
on<ChatInputFileEvent>(
(event, emit) async {
await event.when(
initial: () async {},
event.when(
updateUploadState: (UploadFileIndicator indicator) {
emit(state.copyWith(uploadFileIndicator: indicator));
},
@@ -28,7 +24,6 @@ class ChatInputFileBloc extends Bloc<ChatInputFileEvent, ChatInputFileState> {
@freezed
class ChatInputFileEvent with _$ChatInputFileEvent {
const factory ChatInputFileEvent.initial() = Initial;
const factory ChatInputFileEvent.updateUploadState(
UploadFileIndicator indicator,
) = _UpdateUploadState;
@@ -13,7 +13,6 @@ class ChatMemberBloc extends Bloc<ChatMemberEvent, ChatMemberState> {
on<ChatMemberEvent>(
(event, emit) async {
event.when(
initial: () {},
receiveMemberInfo: (String id, WorkspaceMemberPB memberInfo) {
final members = Map<String, ChatMember>.from(state.members);
members[id] = ChatMember(info: memberInfo);
@@ -51,7 +50,6 @@ class ChatMemberBloc extends Bloc<ChatMemberEvent, ChatMemberState> {
@freezed
class ChatMemberEvent with _$ChatMemberEvent {
const factory ChatMemberEvent.initial() = Initial;
const factory ChatMemberEvent.getMemberInfo(
String userId,
) = _GetMemberInfo;
@@ -7,11 +7,11 @@ import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-document/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:nanoid/nanoid.dart';
/// Indicate file source from appflowy document
const appflowySoruce = "appflowy";
const appflowySource = "appflowy";
List<ChatFile> fileListFromMessageMetadata(
Map<String, dynamic>? map,
@@ -66,78 +66,101 @@ ChatFile? chatFileFromMap(Map<String, dynamic>? map) {
return ChatFile.fromFilePath(filePath);
}
List<ChatMessageRefSource> messageReferenceSource(String? s) {
if (s == null || s.isEmpty || s == "null") {
return [];
class MetadataCollection {
MetadataCollection({
required this.sources,
this.progress,
});
final List<ChatMessageRefSource> sources;
final AIChatProgress? progress;
}
MetadataCollection parseMetadata(String? s) {
if (s == null || s.trim().isEmpty || s.toLowerCase() == "null") {
return MetadataCollection(sources: []);
}
final List<ChatMessageRefSource> metadata = [];
try {
final metadataJson = jsonDecode(s);
if (metadataJson == null) {
Log.warn("metadata is null");
return [];
}
// [{"id":null,"name":"The Five Dysfunctions of a Team.pdf","source":"/Users/weidongfu/Desktop/The Five Dysfunctions of a Team.pdf"}]
AIChatProgress? progress;
if (metadataJson is Map<String, dynamic>) {
if (metadataJson.isNotEmpty) {
metadata.add(ChatMessageRefSource.fromJson(metadataJson));
}
} else if (metadataJson is List) {
metadata.addAll(
metadataJson.map(
(e) => ChatMessageRefSource.fromJson(e as Map<String, dynamic>),
),
);
} else {
Log.error("Invalid metadata: $metadataJson");
try {
final dynamic decodedJson = jsonDecode(s);
if (decodedJson == null) {
return MetadataCollection(sources: []);
}
} catch (e) {
Log.error("Failed to parse metadata: $e");
void processMap(Map<String, dynamic> map) {
if (map.containsKey("step") && map["step"] != null) {
progress = AIChatProgress.fromJson(map);
} else if (map.containsKey("id") && map["id"] != null) {
metadata.add(ChatMessageRefSource.fromJson(map));
} else {
Log.info("Unsupported metadata format: $map");
}
}
if (decodedJson is Map<String, dynamic>) {
processMap(decodedJson);
} else if (decodedJson is List) {
for (final element in decodedJson) {
if (element is Map<String, dynamic>) {
processMap(element);
} else {
Log.error("Invalid metadata element: $element");
}
}
} else {
Log.error("Invalid metadata format: $decodedJson");
}
} catch (e, stacktrace) {
Log.error("Failed to parse metadata: $e, input: $s");
Log.debug(stacktrace.toString());
}
return metadata;
return MetadataCollection(sources: metadata, progress: progress);
}
Future<List<ChatMessageMetaPB>> metadataPBFromMetadata(
Map<String, dynamic>? map,
) async {
if (map == null) return [];
final List<ChatMessageMetaPB> metadata = [];
if (map != null) {
for (final entry in map.entries) {
if (entry.value is ViewActionPage) {
if (entry.value.page is ViewPB) {
final view = entry.value.page as ViewPB;
if (view.layout.isDocumentView) {
final payload = OpenDocumentPayloadPB(documentId: view.id);
final result = await DocumentEventGetDocumentText(payload).send();
result.fold((pb) {
metadata.add(
ChatMessageMetaPB(
id: view.id,
name: view.name,
data: pb.text,
dataType: ChatMessageMetaTypePB.Txt,
source: appflowySoruce,
),
);
}, (err) {
Log.error('Failed to get document text: $err');
});
}
}
} else if (entry.value is ChatFile) {
for (final value in map.values) {
switch (value) {
case ViewActionPage(view: final view) when view.layout.isDocumentView:
final payload = OpenDocumentPayloadPB(documentId: view.id);
await DocumentEventGetDocumentText(payload).send().fold(
(pb) {
metadata.add(
ChatMessageMetaPB(
id: view.id,
name: view.name,
data: pb.text,
dataType: ChatMessageMetaTypePB.Txt,
source: appflowySource,
),
);
},
(err) => Log.error('Failed to get document text: $err'),
);
break;
case ChatFile(
filePath: final filePath,
fileName: final fileName,
fileType: final fileType,
):
metadata.add(
ChatMessageMetaPB(
id: nanoid(8),
name: entry.value.fileName,
data: entry.value.filePath,
dataType: entry.value.fileType,
source: entry.value.filePath,
name: fileName,
data: filePath,
dataType: fileType,
source: filePath,
),
);
}
break;
}
}
@@ -2,7 +2,6 @@ import 'dart:async';
import 'dart:ffi';
import 'dart:isolate';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_message_service.dart';
class AnswerStream {
@@ -14,34 +13,25 @@ class AnswerStream {
_hasStarted = true;
final newText = event.substring(5);
_text += newText;
if (_onData != null) {
_onData!(_text);
}
_onData?.call(_text);
} else if (event.startsWith("error:")) {
_error = event.substring(5);
if (_onError != null) {
_onError!(_error!);
}
_onError?.call(_error!);
} else if (event.startsWith("metadata:")) {
if (_onMetadata != null) {
final s = event.substring(9);
_onMetadata!(messageReferenceSource(s));
_onMetadata!(parseMetadata(s));
}
} else if (event == "AI_RESPONSE_LIMIT") {
if (_onAIResponseLimit != null) {
_onAIResponseLimit!();
}
_aiLimitReached = true;
_onAIResponseLimit?.call();
}
},
onDone: () {
if (_onEnd != null) {
_onEnd!();
}
_onEnd?.call();
},
onError: (error) {
if (_onError != null) {
_onError!(error.toString());
}
_onError?.call(error.toString());
},
);
}
@@ -50,6 +40,7 @@ class AnswerStream {
final StreamController<String> _controller = StreamController.broadcast();
late StreamSubscription<String> _subscription;
bool _hasStarted = false;
bool _aiLimitReached = false;
String? _error;
String _text = "";
@@ -59,10 +50,11 @@ class AnswerStream {
void Function()? _onEnd;
void Function(String error)? _onError;
void Function()? _onAIResponseLimit;
void Function(List<ChatMessageRefSource> metadata)? _onMetadata;
void Function(MetadataCollection metadataCollection)? _onMetadata;
int get nativePort => _port.sendPort.nativePort;
bool get hasStarted => _hasStarted;
bool get aiLimitReached => _aiLimitReached;
String? get error => _error;
String get text => _text;
@@ -78,7 +70,7 @@ class AnswerStream {
void Function()? onEnd,
void Function(String error)? onError,
void Function()? onAIResponseLimit,
void Function(List<ChatMessageRefSource> metadata)? onMetadata,
void Function(MetadataCollection metadata)? onMetadata,
}) {
_onData = onData;
_onStart = onStart;
@@ -87,9 +79,7 @@ class AnswerStream {
_onAIResponseLimit = onAIResponseLimit;
_onMetadata = onMetadata;
if (_onStart != null) {
_onStart!();
}
_onStart?.call();
}
}
@@ -0,0 +1,75 @@
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_side_panel_bloc.freezed.dart';
class ChatSidePanelBloc extends Bloc<ChatSidePanelEvent, ChatSidePanelState> {
ChatSidePanelBloc({
required this.chatId,
}) : super(const ChatSidePanelState()) {
on<ChatSidePanelEvent>(
(event, emit) async {
await event.when(
selectedMetadata: (ChatMessageRefSource metadata) async {
emit(
state.copyWith(
metadata: metadata,
indicator: const ChatSidePanelIndicator.loading(),
),
);
await ViewBackendService.getView(metadata.id).fold(
(view) {
if (!isClosed) {
add(ChatSidePanelEvent.open(view));
}
},
(err) => Log.error("Failed to get view: $err"),
);
},
close: () {
emit(state.copyWith(metadata: null, isShowPanel: false));
},
open: (ViewPB view) {
emit(
state.copyWith(
indicator: ChatSidePanelIndicator.ready(view),
isShowPanel: true,
),
);
},
);
},
);
}
final String chatId;
}
@freezed
class ChatSidePanelEvent with _$ChatSidePanelEvent {
const factory ChatSidePanelEvent.selectedMetadata(
ChatMessageRefSource metadata,
) = _SelectedMetadata;
const factory ChatSidePanelEvent.close() = _Close;
const factory ChatSidePanelEvent.open(ViewPB view) = _Open;
}
@freezed
class ChatSidePanelState with _$ChatSidePanelState {
const factory ChatSidePanelState({
ChatMessageRefSource? metadata,
@Default(ChatSidePanelIndicator.loading()) ChatSidePanelIndicator indicator,
@Default(false) bool isShowPanel,
}) = _ChatSidePanelState;
}
@freezed
class ChatSidePanelIndicator with _$ChatSidePanelIndicator {
const factory ChatSidePanelIndicator.ready(ViewPB view) = _Ready;
const factory ChatSidePanelIndicator.loading() = _Loading;
}
@@ -1,85 +0,0 @@
import 'dart:async';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_side_pannel_bloc.freezed.dart';
const double kDefaultSidePannelWidth = 500;
class ChatSidePannelBloc
extends Bloc<ChatSidePannelEvent, ChatSidePannelState> {
ChatSidePannelBloc({
required this.chatId,
}) : super(const ChatSidePannelState()) {
on<ChatSidePannelEvent>(
(event, emit) async {
await event.when(
selectedMetadata: (ChatMessageRefSource metadata) async {
emit(
state.copyWith(
metadata: metadata,
indicator: const ChatSidePannelIndicator.loading(),
),
);
unawaited(
ViewBackendService.getView(metadata.id).then(
(result) {
result.fold((view) {
if (!isClosed) {
add(ChatSidePannelEvent.open(view));
}
}, (err) {
Log.error("Failed to get view: $err");
});
},
),
);
},
close: () {
emit(state.copyWith(metadata: null, isShowPannel: false));
},
open: (ViewPB view) {
emit(
state.copyWith(
indicator: ChatSidePannelIndicator.ready(view),
isShowPannel: true,
),
);
},
);
},
);
}
final String chatId;
}
@freezed
class ChatSidePannelEvent with _$ChatSidePannelEvent {
const factory ChatSidePannelEvent.selectedMetadata(
ChatMessageRefSource metadata,
) = _SelectedMetadata;
const factory ChatSidePannelEvent.close() = _Close;
const factory ChatSidePannelEvent.open(ViewPB view) = _Open;
}
@freezed
class ChatSidePannelState with _$ChatSidePannelState {
const factory ChatSidePannelState({
ChatMessageRefSource? metadata,
@Default(ChatSidePannelIndicator.loading())
ChatSidePannelIndicator indicator,
@Default(false) bool isShowPannel,
}) = _ChatSidePannelState;
}
@freezed
class ChatSidePannelIndicator with _$ChatSidePannelIndicator {
const factory ChatSidePannelIndicator.ready(ViewPB view) = _Ready;
const factory ChatSidePannelIndicator.loading() = _Loading;
}
@@ -8,76 +8,19 @@ part 'chat_user_message_bloc.freezed.dart';
class ChatUserMessageBloc
extends Bloc<ChatUserMessageEvent, ChatUserMessageState> {
ChatUserMessageBloc({
required dynamic message,
}) : super(
ChatUserMessageState.initial(
message,
),
) {
required this.questionStream,
required String text,
}) : super(ChatUserMessageState.initial(text)) {
_dispatch();
_startListening();
}
final QuestionStream? questionStream;
void _dispatch() {
on<ChatUserMessageEvent>(
(event, emit) {
event.when(
initial: () {
if (state.stream != null) {
if (!isClosed) {
add(ChatUserMessageEvent.updateText(state.stream!.text));
}
}
state.stream?.listen(
onData: (text) {
if (!isClosed) {
add(ChatUserMessageEvent.updateText(text));
}
},
onMessageId: (messageId) {
if (!isClosed) {
add(ChatUserMessageEvent.updateMessageId(messageId));
}
},
onError: (error) {
if (!isClosed) {
add(ChatUserMessageEvent.receiveError(error.toString()));
}
},
onFileIndexStart: (indexName) {
Log.debug("index start: $indexName");
},
onFileIndexEnd: (indexName) {
Log.info("index end: $indexName");
},
onFileIndexFail: (indexName) {
Log.debug("index fail: $indexName");
},
onIndexStart: () {
if (!isClosed) {
add(
const ChatUserMessageEvent.updateQuestionState(
QuestionMessageState.indexStart(),
),
);
}
},
onIndexEnd: () {
if (!isClosed) {
add(
const ChatUserMessageEvent.updateQuestionState(
QuestionMessageState.indexEnd(),
),
);
}
},
onDone: () {
if (!isClosed) {
add(
const ChatUserMessageEvent.updateQuestionState(
QuestionMessageState.finish(),
),
);
}
},
);
},
updateText: (String text) {
emit(state.copyWith(text: text));
},
@@ -92,11 +35,66 @@ class ChatUserMessageBloc
},
);
}
void _startListening() {
questionStream?.listen(
onData: (text) {
if (!isClosed) {
add(ChatUserMessageEvent.updateText(text));
}
},
onMessageId: (messageId) {
if (!isClosed) {
add(ChatUserMessageEvent.updateMessageId(messageId));
}
},
onError: (error) {
if (!isClosed) {
add(ChatUserMessageEvent.receiveError(error.toString()));
}
},
onFileIndexStart: (indexName) {
Log.debug("index start: $indexName");
},
onFileIndexEnd: (indexName) {
Log.info("index end: $indexName");
},
onFileIndexFail: (indexName) {
Log.debug("index fail: $indexName");
},
onIndexStart: () {
if (!isClosed) {
add(
const ChatUserMessageEvent.updateQuestionState(
QuestionMessageState.indexStart(),
),
);
}
},
onIndexEnd: () {
if (!isClosed) {
add(
const ChatUserMessageEvent.updateQuestionState(
QuestionMessageState.indexEnd(),
),
);
}
},
onDone: () {
if (!isClosed) {
add(
const ChatUserMessageEvent.updateQuestionState(
QuestionMessageState.finish(),
),
);
}
},
);
}
}
@freezed
class ChatUserMessageEvent with _$ChatUserMessageEvent {
const factory ChatUserMessageEvent.initial() = Initial;
const factory ChatUserMessageEvent.updateText(String text) = _UpdateText;
const factory ChatUserMessageEvent.updateQuestionState(
QuestionMessageState newState,
@@ -110,17 +108,14 @@ class ChatUserMessageEvent with _$ChatUserMessageEvent {
class ChatUserMessageState with _$ChatUserMessageState {
const factory ChatUserMessageState({
required String text,
QuestionStream? stream,
String? messageId,
@Default(QuestionMessageState.finish()) QuestionMessageState messageState,
required String? messageId,
required QuestionMessageState messageState,
}) = _ChatUserMessageState;
factory ChatUserMessageState.initial(
dynamic message,
) =>
ChatUserMessageState(
text: message is String ? message : "",
stream: message is QuestionStream ? message : null,
factory ChatUserMessageState.initial(String message) => ChatUserMessageState(
text: message,
messageId: null,
messageState: const QuestionMessageState.finish(),
);
}
@@ -1,60 +0,0 @@
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_types/flutter_chat_types.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'chat_message_service.dart';
part 'chat_user_message_bubble_bloc.freezed.dart';
class ChatUserMessageBubbleBloc
extends Bloc<ChatUserMessageBubbleEvent, ChatUserMessageBubbleState> {
ChatUserMessageBubbleBloc({
required Message message,
}) : super(
ChatUserMessageBubbleState.initial(
message,
_getFiles(message.metadata),
),
) {
on<ChatUserMessageBubbleEvent>(
(event, emit) async {
event.when(
initial: () {},
);
},
);
}
}
List<ChatFile> _getFiles(Map<String, dynamic>? metadata) {
if (metadata == null) {
return [];
}
final refSourceMetadata = metadata[messageRefSourceJsonStringKey] as String?;
final files = metadata[messageChatFileListKey] as List<ChatFile>?;
if (refSourceMetadata != null) {
return chatFilesFromMetadataString(refSourceMetadata);
}
return files ?? [];
}
@freezed
class ChatUserMessageBubbleEvent with _$ChatUserMessageBubbleEvent {
const factory ChatUserMessageBubbleEvent.initial() = Initial;
}
@freezed
class ChatUserMessageBubbleState with _$ChatUserMessageBubbleState {
const factory ChatUserMessageBubbleState({
required Message message,
required List<ChatFile> files,
}) = _ChatUserMessageBubbleState;
factory ChatUserMessageBubbleState.initial(
Message message,
List<ChatFile> files,
) =>
ChatUserMessageBubbleState(message: message, files: files);
}
@@ -2,6 +2,7 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/plugins/ai_chat/chat_page.dart';
import 'package:appflowy/plugins/util.dart';
import 'package:appflowy/startup/plugin/plugin.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view_info/view_info_bloc.dart';
import 'package:appflowy/workspace/presentation/home/home_stack.dart';
import 'package:appflowy/workspace/presentation/widgets/tab_bar_item.dart';
@@ -85,12 +86,16 @@ class AIChatPagePluginWidgetBuilder extends PluginWidgetBuilder
final ViewPluginNotifier notifier;
int? deletedViewIndex;
@override
String? get viewName => notifier.view.nameOrDefault;
@override
Widget get leftBarItem =>
ViewTitleBar(key: ValueKey(notifier.view.id), view: notifier.view);
@override
Widget tabBarItem(String pluginId) => ViewTabBarItem(view: notifier.view);
Widget tabBarItem(String pluginId, [bool shortForm = false]) =>
ViewTabBarItem(view: notifier.view, shortForm: shortForm);
@override
Widget buildWidget({
Loaded 100 of 523 files, more files were not shown because too many files have changed in this diff. Show more