Compare commits

..
Author SHA1 Message Date
Morn c41807e78f chore: update CHANGELOG.md (#7998)
* chore: update CHANGELOG.md

* chore: update CHANGELOG.md
2025-05-28 16:30:40 +08:00
Lucas.Xu 06fe7d60e2 fix: turn off share apis when fg is off 2025-05-28 14:09:57 +08:00
Lucas 75f0f08c78 fix: checkbox icon not aligned with the first line (#7997) 2025-05-28 10:48:31 +08:00
Nathan.fooo 8fcbf78714 chore: index document even content is empty (#7996) 2025-05-28 10:11:45 +08:00
Morn 837e754946 fix: database padding issue with max document width (#7991)
* fix: adjust the bottom sheet menu drag area

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

* feat: optimize ai chat animation

* chore: move calculate min height logic to manager

* chore: remove unused code

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

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

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

* fix: toggle list issue

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

* chore: excessive padding

* chore: cursor for clear search input

* chore: move other to bottom of category list

* chore: padding on mobile

* chore: select sources background color

* chore: support single and multi select

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

* fix: desktop textstyle issues

* fix: mobile textstyle issues

* fix: sometimes the path not showing

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

* fix: mobile search page focus issue

* fix: open page error

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

* fix: add max width for search result title

* fix: update the logic of refreshing chached views

* fix: padding issue

* fix: location of path is wrong

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

* feat: handle share results

* fix: clear email textfield after inviting

* test: add turn into member test

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

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

* feat: support mobile search AI Overview

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

* chore: add some tests

* fix: remove unuse code

* chore: i18n

* chore: improve mobile search UI

* fix: optimize some codes

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

* fix: complete desktop search preview UI improvement

* fix: hide search preview when the window is narrow

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

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

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

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

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

* fix: typo

* refactor: enhance share tab functionality and error handling

* chore: i18n

* feat: turn guest into member

* fix: unit tests

* fix: unit tests

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

* feat: add shared_view sql

* feat: persist the shared view data

* feat: support notification

* fix: flutter compile

* feat: add logic, data and presentations

* feat: integrate client-api endpoints

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

* refactor: streamline sharedPages retrieval with extension method

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

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

* chore: update comments

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

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

* chore: rename folder

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

* fix: rust fmt

* chore: revert version changes

* fix: cargo clippy

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

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

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

* chore: integrate share endpoints

* feat: add share repository

* feat: add share events

* feat: integrate rust events

* chore: code refactor

* feat: add share logic

* feat: optimize share_with_user bloc

* feat: share with user widget

* feat: use AFPopover

* feat: add shared user list

* feat: fetch current user

* feat: add shared usert list

* feat: add mock share repo

* feat: update client-api

* feat: add people_with_access and general_access

* fix: flutter analyze

* feat: support share link

* feat: edit access level

* feat: invite guest

* feat: optimize share menu loading perf

* fix: unable to display the share link

* feat: update access role

* feat: support cached network image

* feat: add access level callback

* feat: remove guest access

* fix: popover exclusive issue

* fix: close edit access level popover after editing

* feat: use rust share apis

* chore: rename folder

* test: add widget tests

* test: share with user bloc test

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

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

* feat: add menu component

* feat: optimize section component

* feat: add menu example

* feat: add menu item selected status

* feat: support popover

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

* chore: bump rust edition

* chore: bump rust tool chain version

* chore: fix test

* chore: fmt

* chore: build target

* chore: clippy

* chore: fmt
2025-05-14 10:26:59 +08:00
Richard Shiue 13fe4a2395 fix(mobile): initialize scale factor on startup (#7929)
* fix(mobile): initialize scale factor on startup

* fix: widget test
2025-05-13 21:50:37 +08:00
Lucas e8b8644716 chore: bump version 0.9.2 (#7926) 2025-05-13 14:11:32 +08:00
Morn 10472475ac fix: allow ':keyword' make mistakes (#7905) 2025-05-13 13:54:28 +08:00
Lucas 20e10e6bf2 chore: remove unused code (#7924) 2025-05-13 11:47:08 +08:00
Morn cf14a37dcf fix: using the shortcut to show the search panel will hide the 'Ask AI anything' button (#7916)
* fix: show search panel by shortcut will hide the 'Ask for AI' button

* fix: hovering on the search result will hide the border

* fix: improve the logic of updating CommandPalette value

* fix: UI issues

* fix: improve the behavior of search result preview

* chore: add integration test for desktop searching

* feat: enable click search result preview to open the page
2025-05-13 11:37:03 +08:00
Nathan.fooo a359313974 chore: select source when user is not anon (#7921) 2025-05-13 11:10:32 +08:00
Nathan 636794b9ba chore: event for fetch workspace plan 2025-05-13 00:13:57 +08:00
Nathan.fooo 7e5b243e40 chore: remove unused ai code (#7919) 2025-05-13 00:13:25 +08:00
Richard Shiue 5e99eeb167 chore: improve empty ai prompt list placeholder (#7917) 2025-05-12 16:53:06 +08:00
Morn 5029776685 fix: LR issues of desktop search (#7914)
* fix: update error view for search preview

* chore: add some logs for command palette

* fix: alignment issue
2025-05-12 14:12:30 +08:00
Morn 79009d2851 fix: the link name should be a required field (#7915) 2025-05-12 13:43:22 +08:00
NikitamceandRichard Shiue d153b3d9f1 chore(i18n): update ru-RU translations (#7835)
* chore: update translations with Fink 🐦

* Update ru-RU.json

* Update ru-RU.json

* chore: update translations with Fink 🐦

---------

Co-authored-by: Richard Shiue <71320345+richardshiue@users.noreply.github.com>
2025-05-12 12:17:58 +08:00
CrEagleOneandRichard Shiue f9aa3ff499 chore(i18n): update fr-FR translations 🐦 (#7832)
Co-authored-by: Richard Shiue <71320345+richardshiue@users.noreply.github.com>
2025-05-12 11:46:03 +08:00
Mathias Mogensen b4fbfaf2f8 fix: drop files duplicates (#7620) 2025-05-12 11:25:56 +08:00
FakhriAzzouz a026367f85 chore(i18n): add ar-SA translations (#7800)
* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦

* chore: update translations with Fink 🐦
2025-05-12 11:13:14 +08:00
Tom GoldenandRichard Shiue ef7063b8fd chore(i18n): add en-GB translations (#7705)
* feat: added en-GB translations

* feat: added en-GB translations

* chore: remove redundant argument

---------

Co-authored-by: Richard Shiue <71320345+richardshiue@users.noreply.github.com>
2025-05-12 11:10:11 +08:00
Richard Shiue 808bee5eb2 chore: adjust view selector logic (#7913)
* chore: adjust view selector logic

* chore: click on document to expand

* chore: code cleanup
2025-05-12 10:17:50 +08:00
Nathan 9ecae0a643 chore: ai templte 2025-05-11 20:16:12 +08:00
Nathan.fooo dfc80e6a5a chore: fetch subscription when start (#7910)
* chore: fetch subscription when start

* chore: fetch subscription if billing is enabled
2025-05-11 19:39:24 +08:00
Morn 014928cf19 feat: desktop Search UI revamp (#7895)
* feat: add ai search entrance

* chore: revamp search recent list

* feat: revamp preview widget

* feat: support click the 'Ask ai button'

* feat: support go to trash when there is no search results

* feat: support AI overview

* fix: typo issue

* fix: empty trash item name

* chore: replace some Dividers to AFDividers

* chore: improve some code logic

* chore: update some icon color

* fix: some UI issue

* chore: adjust some paddings

* fix: align issue for reference sources

* fix: change clear icon's hover effect

* fix: clear search field will not make it unfocus

* chore: make 'Ask AI' button scroll with list

* chore: change the size of search panel

* fix: change mobile search field cancel icon

* feat: support preview panel for recent pages

* fix: some UI issues

* fix: hover to show the reference sources

* fix: some padding issues
2025-05-11 16:45:44 +08:00
Richard Shiue 30f5692818 chore: adjust database custom prompt ui (#7902) 2025-05-11 16:45:28 +08:00
Nathan.foooandLucas.Xu 6e850ab8b2 refactor: extract event handler (#7908)
Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2025-05-11 16:44:59 +08:00
Khor Shu Heng 370b5714a1 chore: update collab version for row deserialization fix (#7907) 2025-05-11 14:08:32 +08:00
Nathan.fooo 2c8060cb1b refactor: chat bloc (#7904)
* refactor: chat bloc

* chore: clippy

* chore: make temp message id private
2025-05-10 22:07:47 +08:00
Nathan.fooo b4d48a63b9 chore: support multiple source search in chat (#7906)
* chore: support multiple source search in chat

* chore: clippy
2025-05-10 15:28:38 +08:00
Nathan.foooandLucas.Xu c986e414f4 Vault workspace suggested question (#7903)
* chore: return suggested question when ai say i don't know

* chore: new message type

* chore: implement uI

* chore: implement UI

* fix: assertion in local ai chat

* chore: implement UI

* chore: update prompt

* chore: update logs

* chore: add tests

* chore: message id

* fix: local ai page animation issue

* chore: remove debug log

* fix: remove repeated setState

* chore: add test

* chore: test

* fix: compile

---------

Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
2025-05-09 21:58:23 +08:00
Lucas c79ac3290e test: simple ai chat tests (#7901)
* fix: repeated setState in animation list

* test: add simple chat integration test

* chore: update chat list

* test: send messages without default messages

* fix: refresh space after moving page
2025-05-09 21:40:06 +08:00
Richard Shiue 7f7657edc8 test: add view selector tests (#7900) 2025-05-09 17:53:23 +08:00
Lucas d3ce59fe08 fix: v0.9.2 launch review issues (#7898)
* fix: remove empty paragraphs after inserting file block

* fix: chat page animation

* fix: remove opacity from locked page icon

* feat: open the space after opening the view

* fix: chat page loading issue

* fix: view title assertion

* fix: cover title issue

* fix: flutter analyze

* fix: list state issue

* fix: reverse animation list issue

* fix: integration test
2025-05-09 13:02:19 +08:00
Richard Shiue 0f89b6f690 fix: selected sources changes not being updated correctly (#7897) 2025-05-08 14:38:38 +08:00
Richard Shiue 680015ef66 feat: load custom prompts from a database (#7894)
* feat: load custom prompts from a database

* chore: code review

* chore: process trashed views and reduce widget rebuilds
2025-05-08 11:56:30 +08:00
Richard Shiue b048407b4e fix: select option logic (#7893)
* test: add select option tests

* fix: select option logic

* test: fix existing tests

* test: fix test logic
2025-05-08 09:13:03 +08:00
Nathan.fooo 95f9b07ef7 chore: use tanvity as fallback ai search (#7892)
* chore: use tanvity as fallback ai search

* chore: fix test
2025-05-08 00:17:02 +08:00
Richard Shiue b1d3553110 refactor: view selector (#7891)
* refactor: extract common view selection logic

* refactor: extract common component

* chore: make selected section selectable

* chore: max num of selected top-level pages
2025-05-07 21:10:23 +08:00
Lucas 38839b4255 feat: improve chat message animations (#7890)
* chore: upgrade editor version

* chore: only print the reminder refresh log when it contains new updates

* fix: google font family name issue

* chore: i18n

* feat: improve chat ai animation
2025-05-07 20:32:13 +08:00
606 changed files with 30159 additions and 9088 deletions

No files matched your search

+1 -1
View File
@@ -19,7 +19,7 @@ on:
env:
CARGO_TERM_COLOR: always
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.84.0"
RUST_TOOLCHAIN: "1.85.0"
CARGO_MAKE_VERSION: "0.37.18"
CLOUD_VERSION: 0.6.54-amd64
+1 -1
View File
@@ -26,7 +26,7 @@ on:
env:
CARGO_TERM_COLOR: always
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.84.0"
RUST_TOOLCHAIN: "1.85.0"
CARGO_MAKE_VERSION: "0.37.18"
CLOUD_VERSION: 0.9.49-amd64
+1 -1
View File
@@ -19,7 +19,7 @@ on:
env:
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.84.0"
RUST_TOOLCHAIN: "1.85.0"
concurrency:
group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }}
+1 -1
View File
@@ -7,7 +7,7 @@ on:
env:
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.84.0"
RUST_TOOLCHAIN: "1.85.0"
jobs:
create-release:
+1 -1
View File
@@ -19,7 +19,7 @@ on:
env:
CARGO_TERM_COLOR: always
CLOUD_VERSION: 0.9.49-amd64
RUST_TOOLCHAIN: "1.84.0"
RUST_TOOLCHAIN: "1.85.0"
jobs:
ubuntu-job:
+1 -1
View File
@@ -11,7 +11,7 @@ on:
env:
CARGO_TERM_COLOR: always
FLUTTER_VERSION: "3.27.4"
RUST_TOOLCHAIN: "1.84.0"
RUST_TOOLCHAIN: "1.85.0"
jobs:
tests:
+34
View File
@@ -1,4 +1,38 @@
# Release Notes
## Version 0.9.3 - 28/05/2025
### Desktop
#### New Features
- Meet AppFlowy Workspace AI Search: Quickly find pages by searching titles, keywords, or asking natural-language questions
- AI Overviews: Ask natural questions and receive instant AI-generated summaries with source links, inspired by Google's AI Overviews
- Revamped Search Panel: A cleaner, smarter interface to help you search faster and more effectively
- Custom Prompts: Load a database page as the source for your own custom AI prompts
#### Bug Fixes
- Fixed misalignment in database view after setting maxDocumentWidth
- Centered embedded link when the site name is empty
- Fixed issue where row observer was not clearing as expected
- Fixed issue where workspace name reverted after being updated
- Aligned checkbox icon with the first line of text
### Mobile
#### New Features
- Meet AppFlowy Workspace AI Search: Quickly find pages by searching titles, keywords, or asking natural-language questions
- AI Overviews: Ask natural questions and receive instant AI-generated summaries with source links, inspired by Google's AI Overviews
- Revamped Search Tab: A redesigned interface that helps you find what you need more efficiently
#### Bug Fixes
- Fixed issue where font size reset after restarting the app
## Version 0.9.2 - 14/05/2025
### Desktop
#### New Features
- Supported AI Overview in Search to answer user queries based on their entire workspace
- Revamped the Search panel in the desktop app
- Enabled loading custom prompts from an AppFlowy database page
#### Bug Fixes
- Improved inserting emojis using the colon (:)
- Supported automatically filling the link name with the URL if the name is left empty
### Mobile
#### Bug Fixes
- Supported automatically filling the link name with the URL if the name is left empty
## Version 0.9.1 - 01/05/2025
### Desktop
#### New Features
+1 -1
View File
@@ -26,7 +26,7 @@ CARGO_MAKE_EXTEND_WORKSPACE_MAKEFILE = true
CARGO_MAKE_CRATE_FS_NAME = "dart_ffi"
CARGO_MAKE_CRATE_NAME = "dart-ffi"
LIB_NAME = "dart_ffi"
APPFLOWY_VERSION = "0.9.1"
APPFLOWY_VERSION = "0.9.3"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"
@@ -0,0 +1,264 @@
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_animation_list_widget.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import '../../shared/ai_test_op.dart';
import '../../shared/util.dart';
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
group('chat page:', () {
testWidgets('send messages with default messages', (tester) async {
skipAIChatWelcomePage = true;
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a chat page
final pageName = 'Untitled';
await tester.createNewPageWithNameUnderParent(
name: pageName,
layout: ViewLayoutPB.Chat,
openAfterCreated: false,
);
await tester.pumpAndSettle(const Duration(milliseconds: 300));
final userId = '457037009907617792';
final user = User(id: userId, lastName: 'Lucas');
final aiUserId = '0';
final aiUser = User(id: aiUserId, lastName: 'AI');
await tester.loadDefaultMessages(
[
Message.text(
id: '1746776401',
text: 'How to use Kanban to manage tasks?',
author: user,
createdAt: DateTime.now().add(const Duration(seconds: 1)),
),
Message.text(
id: '1746776401_ans',
text:
'I couldn’t find any relevant information in the sources you selected. Please try asking a different question',
author: aiUser,
createdAt: DateTime.now().add(const Duration(seconds: 2)),
),
Message.text(
id: '1746776402',
text: 'How to use Kanban to manage tasks?',
author: user,
createdAt: DateTime.now().add(const Duration(seconds: 3)),
),
Message.text(
id: '1746776402_ans',
text:
'I couldn’t find any relevant information in the sources you selected. Please try asking a different question',
author: aiUser,
createdAt: DateTime.now().add(const Duration(seconds: 4)),
),
].reversed.toList(),
);
await tester.pumpAndSettle(Duration(seconds: 1));
// start chat
final int messageId = 1;
// send a message
await tester.sendUserMessage(
Message.text(
id: messageId.toString(),
text: 'How to use AppFlowy?',
author: user,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
// receive a message
await tester.receiveAIMessage(
Message.text(
id: '${messageId}_ans',
text: '''# How to Use AppFlowy
- Download and install AppFlowy from the official website (appflowy.io) or through app stores for your operating system (Windows, macOS, Linux, or mobile)
- Create an account or sign in when you first launch the app
- The main interface shows your workspace with a sidebar for navigation and a content area''',
author: aiUser,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
final chatBloc = tester.getCurrentChatBloc();
expect(chatBloc.chatController.messages.length, equals(6));
});
testWidgets('send messages without default messages', (tester) async {
skipAIChatWelcomePage = true;
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
// create a chat page
final pageName = 'Untitled';
await tester.createNewPageWithNameUnderParent(
name: pageName,
layout: ViewLayoutPB.Chat,
openAfterCreated: false,
);
await tester.pumpAndSettle(const Duration(milliseconds: 300));
final userId = '457037009907617792';
final user = User(id: userId, lastName: 'Lucas');
final aiUserId = '0';
final aiUser = User(id: aiUserId, lastName: 'AI');
// start chat
int messageId = 1;
// round 1
{
// send a message
await tester.sendUserMessage(
Message.text(
id: messageId.toString(),
text: 'How to use AppFlowy?',
author: user,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
// receive a message
await tester.receiveAIMessage(
Message.text(
id: '${messageId}_ans',
text: '''# How to Use AppFlowy
- Download and install AppFlowy from the official website (appflowy.io) or through app stores for your operating system (Windows, macOS, Linux, or mobile)
- Create an account or sign in when you first launch the app
- The main interface shows your workspace with a sidebar for navigation and a content area''',
author: aiUser,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
messageId++;
}
// round 2
{
// send a message
await tester.sendUserMessage(
Message.text(
id: messageId.toString(),
text: 'How to use AppFlowy?',
author: user,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
// receive a message
await tester.receiveAIMessage(
Message.text(
id: '${messageId}_ans',
text:
'I couldn’t find any relevant information in the sources you selected. Please try asking a different question',
author: aiUser,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
messageId++;
}
// round 3
{
// send a message
await tester.sendUserMessage(
Message.text(
id: messageId.toString(),
text: 'What document formatting options are available?',
author: user,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
// receive a message
await tester.receiveAIMessage(
Message.text(
id: '${messageId}_ans',
text:
'# AppFlowy Document Formatting\n- Basic formatting: Bold, italic, underline, strikethrough\n- Headings: 6 levels of headings for structuring content\n- Lists: Bullet points, numbered lists, and checklists\n- Code blocks: Format text as code with syntax highlighting\n- Tables: Create and format data tables\n- Embedded content: Add images, files, and other rich media',
author: aiUser,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
messageId++;
}
// round 4
{
// send a message
await tester.sendUserMessage(
Message.text(
id: messageId.toString(),
text: 'How do I export my data from AppFlowy?',
author: user,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
// receive a message
await tester.receiveAIMessage(
Message.text(
id: '${messageId}_ans',
text:
'# Exporting from AppFlowy\n- Export documents in multiple formats: Markdown, HTML, PDF\n- Export databases as CSV or Excel files\n- Batch export entire workspaces for backup\n- Use the export menu (three dots → Export) on any page\n- Exported files maintain most formatting and structure',
author: aiUser,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
messageId++;
}
// round 5
{
// send a message
await tester.sendUserMessage(
Message.text(
id: messageId.toString(),
text: 'Is there a mobile version of AppFlowy?',
author: user,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
// receive a message
await tester.receiveAIMessage(
Message.text(
id: '${messageId}_ans',
text:
'# AppFlowy on Mobile\n- Yes, AppFlowy is available for iOS and Android devices\n- Download from the App Store or Google Play Store\n- Mobile app includes core functionality: document editing, databases, and boards\n- Offline mode allows working without internet connection\n- Sync automatically when you reconnect\n- Responsive design adapts to different screen sizes',
author: aiUser,
createdAt: DateTime.now(),
),
);
await tester.pumpAndSettle(Duration(seconds: 1));
messageId++;
}
final chatBloc = tester.getCurrentChatBloc();
expect(chatBloc.chatController.messages.length, equals(10));
});
});
}
@@ -4,6 +4,7 @@ import 'database/database_test_runner.dart' as database_test_runner;
import 'document/document_test_runner.dart' as document_test_runner;
import 'set_env.dart' as preset_af_cloud_env_test;
import 'sidebar/sidebar_icon_test.dart' as sidebar_icon_test;
import 'sidebar/sidebar_search_test.dart' as sidebar_search_test;
import 'sidebar/sidebar_move_page_test.dart' as sidebar_move_page_test;
import 'sidebar/sidebar_rename_untitled_test.dart'
as sidebar_rename_untitled_test;
@@ -31,6 +32,7 @@ Future<void> main() async {
sidebar_move_page_test.main();
sidebar_rename_untitled_test.main();
sidebar_icon_test.main();
sidebar_search_test.main();
}
// database
@@ -0,0 +1,42 @@
import 'package:appflowy/ai/widgets/prompt_input/desktop_prompt_input.dart';
import 'package:appflowy/env/cloud_env.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_ask_ai_entrance.dart';
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_field.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() {
setUpAll(() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
});
testWidgets('Test for searching', (tester) async {
await tester.initializeAppFlowy(
cloudType: AuthenticatorType.appflowyCloudSelfHost,
);
await tester.tapGoogleLoginInButton();
await tester.expectToSeeHomePageWithGetStartedPage();
/// show searching page
final searchingButton = find.text(LocaleKeys.search_label.tr());
await tester.tapButton(searchingButton);
final askAIButton = find.byType(SearchAskAiEntrance);
expect(askAIButton, findsOneWidget);
/// searching for [gettingStarted]
final searchField = find.byType(SearchField);
final textFiled =
find.descendant(of: searchField, matching: find.byType(TextField));
await tester.enterText(textFiled, gettingStarted);
await tester.pumpAndSettle(Duration(seconds: 1));
/// tap ask AI button
await tester.tapButton(askAIButton);
expect(find.byType(DesktopPromptInput), findsOneWidget);
});
}
@@ -5,12 +5,13 @@ import 'collaborative_workspace_test.dart' as collaborative_workspace_test;
import 'share_menu_test.dart' as share_menu_test;
import 'tabs_test.dart' as tabs_test;
import 'workspace_icon_test.dart' as workspace_icon_test;
import 'workspace_settings_test.dart' as workspace_settings_test;
// import 'workspace_settings_test.dart' as workspace_settings_test;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
workspace_settings_test.main();
// Enable it after implementing the share feature
// workspace_settings_test.main();
share_menu_test.main();
collaborative_workspace_test.main();
change_name_and_icon_test.main();
@@ -1,7 +1,17 @@
import 'package:appflowy/workspace/presentation/command_palette/command_palette.dart';
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_ask_ai_entrance.dart';
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_result_cell.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/sidebar.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:integration_test/integration_test.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/command_palette/widgets/page_preview.dart';
import 'package:appflowy/workspace/presentation/command_palette/widgets/recent_views_list.dart';
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_field.dart';
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_results_list.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import '../../shared/util.dart';
void main() {
@@ -19,4 +29,70 @@ void main() {
expect(find.byType(CommandPaletteModal), findsNothing);
});
});
group('Search', () {
testWidgets('Test for searching', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(name: 'Switch To New Page');
await tester.pumpAndSettle();
/// tap getting started page
await tester.tapButton(
find.descendant(
of: find.byType(HomeSideBar),
matching: find.textContaining(gettingStarted),
),
);
/// show searching page
final searchingButton = find.text(LocaleKeys.search_label.tr());
await tester.tapButton(searchingButton);
final askAIButton = find.byType(SearchAskAiEntrance);
expect(askAIButton, findsNothing);
final recentList = find.byType(RecentViewsList);
expect(recentList, findsOneWidget);
/// there is [gettingStarted] in recent list
final gettingStartedRecentCell = find.descendant(
of: recentList,
matching: find.textContaining(gettingStarted),
);
expect(gettingStartedRecentCell, findsAtLeast(1));
/// hover to show preview
await tester.hoverOnWidget(gettingStartedRecentCell.first);
final pagePreview = find.byType(PagePreview);
expect(pagePreview, findsOneWidget);
final gettingStartedPreviewTitle = find.descendant(
of: pagePreview,
matching: find.textContaining(gettingStarted),
);
expect(gettingStartedPreviewTitle, findsOneWidget);
/// searching for [gettingStarted]
final searchField = find.byType(SearchField);
final textFiled =
find.descendant(of: searchField, matching: find.byType(TextField));
await tester.enterText(textFiled, gettingStarted);
await tester.pumpAndSettle(Duration(seconds: 1));
/// there is [gettingStarted] in result list
final resultList = find.byType(SearchResultList);
expect(resultList, findsOneWidget);
final resultCells = find.byType(SearchResultCell);
expect(resultCells, findsAtLeast(1));
/// hover to show preview
await tester.hoverOnWidget(resultCells.first);
expect(find.byType(PagePreview), findsOneWidget);
/// clear search content
final clearButton = find.byFlowySvg(FlowySvgs.search_clear_m);
await tester.tapButton(clearButton);
expect(find.byType(SearchResultList), findsNothing);
expect(find.byType(RecentViewsList), findsOneWidget);
});
});
}
@@ -168,6 +168,38 @@ void main() {
await tester.pumpAndSettle();
});
testWidgets('clear cells under field', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
await tester.scrollToRight(find.byType(GridPage));
// edit the data
await tester.editCell(
rowIndex: 0,
fieldType: FieldType.RichText,
input: 'Hello',
);
await tester.editCell(
rowIndex: 1,
fieldType: FieldType.RichText,
input: 'World',
);
expect(find.text('Hello'), findsOneWidget);
expect(find.text('World'), findsOneWidget);
// clear the cells
await tester.tapGridFieldWithName('Name');
await tester.tapClearCellsButton();
await tester.tapButtonWithName(LocaleKeys.button_confirm.tr());
expect(find.text('Hello'), findsNothing);
expect(find.text('World'), findsNothing);
});
testWidgets('create list of fields', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
@@ -366,5 +366,89 @@ void main() {
expect(getLinkFromNode(node), link);
expect(getNodeText(node), afterText);
});
testWidgets('insert link and clear link name', (tester) async {
const text = 'edit link', link = 'https://test.appflowy.cloud';
await prepareForToolbar(tester, text);
/// tap link button to show CreateLinkMenu
final linkButton = find.byFlowySvg(FlowySvgs.toolbar_link_m);
await tester.tapButton(linkButton);
/// search for page and select it
final textField = find.descendant(
of: find.byType(LinkCreateMenu),
matching: find.byType(TextFormField),
);
await tester.enterText(textField, link);
await tester.pumpAndSettle();
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
Node node = tester.editor.getNodeAtPath([0]);
expect(getLinkFromNode(node), link);
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
/// hover link
await tester.hoverOnWidget(find.byType(LinkHoverTrigger));
/// click edit button to show LinkEditMenu
final editButton = find.byFlowySvg(FlowySvgs.toolbar_link_edit_m);
await tester.tapButton(editButton);
final linkEditMenu = find.byType(LinkEditMenu);
expect(linkEditMenu, findsOneWidget);
/// clear the link name
final titleField = find.descendant(
of: linkEditMenu,
matching: find.byType(TextFormField),
);
await tester.enterText(titleField, '');
await tester.pumpAndSettle();
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
node = tester.editor.getNodeAtPath([0]);
expect(getNodeText(node), link);
});
testWidgets('insert link and clear link name and remove link',
(tester) async {
const text = 'edit link', link = 'https://test.appflowy.cloud';
await prepareForToolbar(tester, text);
/// tap link button to show CreateLinkMenu
final linkButton = find.byFlowySvg(FlowySvgs.toolbar_link_m);
await tester.tapButton(linkButton);
/// search for page and select it
final textField = find.descendant(
of: find.byType(LinkCreateMenu),
matching: find.byType(TextFormField),
);
await tester.enterText(textField, link);
await tester.pumpAndSettle();
await tester.simulateKeyEvent(LogicalKeyboardKey.enter);
Node node = tester.editor.getNodeAtPath([0]);
expect(getLinkFromNode(node), link);
await tester.simulateKeyEvent(LogicalKeyboardKey.escape);
/// hover link
await tester.hoverOnWidget(find.byType(LinkHoverTrigger));
/// click edit button to show LinkEditMenu
final editButton = find.byFlowySvg(FlowySvgs.toolbar_link_edit_m);
await tester.tapButton(editButton);
final linkEditMenu = find.byType(LinkEditMenu);
expect(linkEditMenu, findsOneWidget);
/// clear the link name
final titleField = find.descendant(
of: linkEditMenu,
matching: find.byType(TextFormField),
);
await tester.enterText(titleField, '');
await tester.pumpAndSettle();
await tester.tapButton(find.byFlowySvg(FlowySvgs.toolbar_link_unlink_m));
node = tester.editor.getNodeAtPath([0]);
expect(getNodeText(node), link);
expect(getLinkFromNode(node), null);
});
});
}
@@ -1,5 +1,6 @@
import 'package:integration_test/integration_test.dart';
import 'desktop/chat/chat_page_test.dart' as chat_page_test;
import 'desktop/database/database_icon_test.dart' as database_icon_test;
import 'desktop/first_test/first_test.dart' as first_test;
import 'desktop/uncategorized/code_block_language_selector_test.dart'
@@ -17,4 +18,5 @@ Future<void> runIntegration9OnDesktop() async {
tabs_test.main();
code_language_selector.main();
database_icon_test.main();
chat_page_test.main();
}
@@ -1,6 +1,9 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/home/home.dart';
import 'package:appflowy/mobile/presentation/notifications/mobile_notifications_screen.dart';
import 'package:appflowy/mobile/presentation/search/mobile_search_ask_ai_entrance.dart';
import 'package:appflowy/mobile/presentation/search/mobile_search_page.dart';
import 'package:appflowy/mobile/presentation/search/mobile_search_result.dart';
import 'package:appflowy/mobile/presentation/search/mobile_search_textfield.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -56,4 +59,35 @@ void main() {
expect(cancelButton, findsNothing);
});
});
testWidgets('tap to search page and back to home', (tester) async {
await tester.launchInAnonymousMode();
/// go to search page
final searchButton = find.byFlowySvg(FlowySvgs.m_home_search_icon_m);
expect(find.byType(MobileSearchScreen), findsNothing);
await tester.tapButton(searchButton);
expect(find.byType(MobileSearchScreen), findsOneWidget);
/// back to home page
final backButton = find.byFlowySvg(FlowySvgs.search_page_arrow_left_m);
expect(backButton, findsOneWidget);
expect(find.byType(MobileHomeScreen), findsNothing);
await tester.tapButton(backButton);
expect(find.byType(MobileHomeScreen), findsOneWidget);
/// go to notification page
final notificationButton = find.byFlowySvg(FlowySvgs.m_home_notification_m);
expect(find.byType(MobileNotificationsScreenV2), findsNothing);
await tester.tapButton(notificationButton);
expect(find.byType(MobileNotificationsScreenV2), findsOneWidget);
/// go to search page
await tester.tapButton(searchButton);
expect(find.byType(MobileNotificationsScreenV2), findsNothing);
expect(find.byType(MobileSearchScreen), findsOneWidget);
await tester.tapButton(backButton);
expect(find.byType(MobileNotificationsScreenV2), findsOneWidget);
expect(find.byType(MobileSearchScreen), findsNothing);
});
}
@@ -1,9 +1,14 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_page/chat_content_page.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/ai_writer_toolbar_item.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operations/ai_writer_entities.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:flutter_test/flutter_test.dart';
import '../../test/util.dart';
import 'util.dart';
extension AppFlowyAITest on WidgetTester {
@@ -38,4 +43,27 @@ extension AppFlowyAITest on WidgetTester {
testTextInput.enterText(text);
await pumpAndSettle(const Duration(milliseconds: 300));
}
ChatBloc getCurrentChatBloc() {
return element(find.byType(ChatContentPage)).read<ChatBloc>();
}
Future<void> loadDefaultMessages(List<Message> messages) async {
final chatBloc = getCurrentChatBloc();
chatBloc.add(ChatEvent.didLoadLatestMessages(messages));
await blocResponseFuture();
}
Future<void> sendUserMessage(Message message) async {
final chatBloc = getCurrentChatBloc();
// using received message to simulate the user message
chatBloc.add(ChatEvent.receiveMessage(message));
await blocResponseFuture();
}
Future<void> receiveAIMessage(Message message) async {
final chatBloc = getCurrentChatBloc();
chatBloc.add(ChatEvent.receiveMessage(message));
await blocResponseFuture();
}
}
@@ -1051,6 +1051,8 @@ extension ViewLayoutPBTest on ViewLayoutPB {
return LocaleKeys.document_menuName.tr();
case ViewLayoutPB.Calendar:
return LocaleKeys.calendar_menuName.tr();
case ViewLayoutPB.Chat:
return LocaleKeys.chat_newChat.tr();
default:
throw UnsupportedError('Unsupported layout: $this');
}
@@ -836,6 +836,14 @@ extension AppFlowyDatabaseTest on WidgetTester {
await tapButton(button);
}
Future<void> tapClearCellsButton() async {
final field = find.byWidgetPredicate(
(widget) =>
widget is FieldActionCell && widget.action == FieldAction.clearData,
);
await tapButton(field);
}
Future<void> tapRowDetailPageRowActionButton() async =>
tapButton(find.byType(RowActionButton));
@@ -84,6 +84,7 @@ class MockAIRepository extends Mock implements AppFlowyAIService {
String? objectId,
required String text,
PredefinedFormat? format,
String? promptId,
List<String> sourceIds = const [],
List<AiWriterRecord> history = const [],
required CompletionTypePB completionType,
@@ -71,11 +71,6 @@ PODS:
- SDWebImage (5.21.0):
- SDWebImage/Core (= 5.21.0)
- SDWebImage/Core (5.21.0)
- Sentry/HybridSDK (8.46.0)
- sentry_flutter (8.14.2):
- Flutter
- FlutterMacOS
- Sentry/HybridSDK (= 8.46.0)
- share_plus (0.0.1):
- Flutter
- shared_preferences_foundation (0.0.1):
@@ -112,7 +107,6 @@ DEPENDENCIES:
- path_provider_foundation (from `.symlinks/plugins/path_provider_foundation/darwin`)
- permission_handler_apple (from `.symlinks/plugins/permission_handler_apple/ios`)
- saver_gallery (from `.symlinks/plugins/saver_gallery/ios`)
- sentry_flutter (from `.symlinks/plugins/sentry_flutter/ios`)
- share_plus (from `.symlinks/plugins/share_plus/ios`)
- shared_preferences_foundation (from `.symlinks/plugins/shared_preferences_foundation/darwin`)
- sqflite_darwin (from `.symlinks/plugins/sqflite_darwin/darwin`)
@@ -126,7 +120,6 @@ SPEC REPOS:
- DKPhotoGallery
- ReachabilitySwift
- SDWebImage
- Sentry
- SwiftyGif
- Toast
@@ -165,8 +158,6 @@ EXTERNAL SOURCES:
:path: ".symlinks/plugins/permission_handler_apple/ios"
saver_gallery:
:path: ".symlinks/plugins/saver_gallery/ios"
sentry_flutter:
:path: ".symlinks/plugins/sentry_flutter/ios"
share_plus:
:path: ".symlinks/plugins/share_plus/ios"
shared_preferences_foundation:
@@ -202,8 +193,6 @@ SPEC CHECKSUMS:
ReachabilitySwift: 32793e867593cfc1177f5d16491e3a197d2fccda
saver_gallery: af2d0c762dafda254e0ad025ef0dabd6506cd490
SDWebImage: f84b0feeb08d2d11e6a9b843cb06d75ebf5b8868
Sentry: da60d980b197a46db0b35ea12cb8f39af48d8854
sentry_flutter: 27892878729f42701297c628eb90e7c6529f3684
share_plus: 50da8cb520a8f0f65671c6c6a99b3617ed10a58a
shared_preferences_foundation: 9e1978ff2562383bd5676f64ec4e9aa8fa06a6f7
sqflite_darwin: 20b2a3a3b70e43edae938624ce550a3cbf66a3d0
+2
View File
@@ -5,7 +5,9 @@ export 'service/appflowy_ai_service.dart';
export 'service/error.dart';
export 'service/ai_model_state_notifier.dart';
export 'service/select_model_bloc.dart';
export 'service/view_selector_cubit.dart';
export 'widgets/loading_indicator.dart';
export 'widgets/view_selector.dart';
export 'widgets/prompt_input/action_buttons.dart';
export 'widgets/prompt_input/desktop_prompt_input.dart';
export 'widgets/prompt_input/file_attachment_list.dart';
@@ -1,6 +1,11 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/shared/easy_localiation_service.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
@@ -14,11 +19,12 @@ class AIStreamEventPrefix {
static const start = 'start:';
static const finish = 'finish:';
static const comment = 'comment:';
static const aiResponseLimit = 'AI_RESPONSE_LIMIT';
static const aiImageResponseLimit = 'AI_IMAGE_RESPONSE_LIMIT';
static const aiMaxRequired = 'AI_MAX_REQUIRED:';
static const localAINotReady = 'LOCAL_AI_NOT_READY';
static const localAIDisabled = 'LOCAL_AI_DISABLED';
static const aiResponseLimit = 'ai_response_limit:';
static const aiImageResponseLimit = 'ai_image_response_limit:';
static const aiMaxRequired = 'ai_max_required:';
static const localAINotReady = 'local_ai_not_ready:';
static const localAIDisabled = 'local_ai_disabled:';
static const aiFollowUp = 'ai_follow_up:';
}
enum AiType {
@@ -110,75 +116,55 @@ enum TextFormat {
}
enum AiPromptCategory {
@JsonValue("other")
other,
@JsonValue("development")
development,
@JsonValue("writing")
writing,
@JsonValue("healthAndFitness")
healthAndFitness,
@JsonValue("business")
business,
@JsonValue("marketing")
marketing,
@JsonValue("travel")
travel,
@JsonValue("contentSeo")
contentSeo,
@JsonValue("emailMarketing")
emailMarketing,
@JsonValue("paidAds")
paidAds,
@JsonValue("prCommunication")
prCommunication,
@JsonValue("recruiting")
recruiting,
@JsonValue("sales")
sales,
@JsonValue("socialMedia")
socialMedia,
@JsonValue("strategy")
strategy,
@JsonValue("caseStudies")
caseStudies,
@JsonValue("salesCopy")
salesCopy,
@JsonValue("education")
education,
@JsonValue("work")
work,
@JsonValue("podcastProduction")
podcastProduction,
@JsonValue("copyWriting")
copyWriting,
@JsonValue("customerSuccess")
customerSuccess;
String get i18n {
String get i18n => token.tr();
String get token {
return switch (this) {
other => LocaleKeys.ai_customPrompt_others.tr(),
development => LocaleKeys.ai_customPrompt_development.tr(),
writing => LocaleKeys.ai_customPrompt_writing.tr(),
healthAndFitness => LocaleKeys.ai_customPrompt_healthAndFitness.tr(),
business => LocaleKeys.ai_customPrompt_business.tr(),
marketing => LocaleKeys.ai_customPrompt_marketing.tr(),
travel => LocaleKeys.ai_customPrompt_travel.tr(),
contentSeo => LocaleKeys.ai_customPrompt_contentSeo.tr(),
emailMarketing => LocaleKeys.ai_customPrompt_emailMarketing.tr(),
paidAds => LocaleKeys.ai_customPrompt_paidAds.tr(),
prCommunication => LocaleKeys.ai_customPrompt_prCommunication.tr(),
recruiting => LocaleKeys.ai_customPrompt_recruiting.tr(),
sales => LocaleKeys.ai_customPrompt_sales.tr(),
socialMedia => LocaleKeys.ai_customPrompt_socialMedia.tr(),
strategy => LocaleKeys.ai_customPrompt_strategy.tr(),
caseStudies => LocaleKeys.ai_customPrompt_caseStudies.tr(),
salesCopy => LocaleKeys.ai_customPrompt_salesCopy.tr(),
education => LocaleKeys.ai_customPrompt_education.tr(),
work => LocaleKeys.ai_customPrompt_work.tr(),
podcastProduction => LocaleKeys.ai_customPrompt_podcastProduction.tr(),
copyWriting => LocaleKeys.ai_customPrompt_copyWriting.tr(),
customerSuccess => LocaleKeys.ai_customPrompt_customerSuccess.tr(),
other => LocaleKeys.ai_customPrompt_others,
development => LocaleKeys.ai_customPrompt_development,
writing => LocaleKeys.ai_customPrompt_writing,
healthAndFitness => LocaleKeys.ai_customPrompt_healthAndFitness,
business => LocaleKeys.ai_customPrompt_business,
marketing => LocaleKeys.ai_customPrompt_marketing,
travel => LocaleKeys.ai_customPrompt_travel,
contentSeo => LocaleKeys.ai_customPrompt_contentSeo,
emailMarketing => LocaleKeys.ai_customPrompt_emailMarketing,
paidAds => LocaleKeys.ai_customPrompt_paidAds,
prCommunication => LocaleKeys.ai_customPrompt_prCommunication,
recruiting => LocaleKeys.ai_customPrompt_recruiting,
sales => LocaleKeys.ai_customPrompt_sales,
socialMedia => LocaleKeys.ai_customPrompt_socialMedia,
strategy => LocaleKeys.ai_customPrompt_strategy,
caseStudies => LocaleKeys.ai_customPrompt_caseStudies,
salesCopy => LocaleKeys.ai_customPrompt_salesCopy,
education => LocaleKeys.ai_customPrompt_education,
work => LocaleKeys.ai_customPrompt_work,
podcastProduction => LocaleKeys.ai_customPrompt_podcastProduction,
copyWriting => LocaleKeys.ai_customPrompt_copyWriting,
customerSuccess => LocaleKeys.ai_customPrompt_customerSuccess,
};
}
}
@@ -192,8 +178,38 @@ class AiPrompt extends Equatable {
required this.category,
required this.example,
required this.isFeatured,
required this.isCustom,
});
factory AiPrompt.fromPB(CustomPromptPB pb) {
final map = _buildCategoryNameMap();
final categories = pb.category
.split(',')
.map((categoryName) => categoryName.trim())
.map(
(categoryName) {
final entry = map.entries.firstWhereOrNull(
(entry) =>
entry.value.$1 == categoryName ||
entry.value.$2 == categoryName,
);
return entry?.key ?? AiPromptCategory.other;
},
)
.toSet()
.toList();
return AiPrompt(
id: pb.id,
name: pb.name,
content: pb.content,
category: categories,
example: pb.example,
isFeatured: false,
isCustom: true,
);
}
factory AiPrompt.fromJson(Map<String, dynamic> json) =>
_$AiPromptFromJson(json);
@@ -202,16 +218,169 @@ class AiPrompt extends Equatable {
final String id;
final String name;
final String content;
@JsonKey(
unknownEnumValue: AiPromptCategory.other,
defaultValue: AiPromptCategory.other,
)
final AiPromptCategory category;
@JsonKey(fromJson: _categoryFromJson)
final List<AiPromptCategory> category;
@JsonKey(defaultValue: "")
final String example;
@JsonKey(defaultValue: false)
final bool isFeatured;
@JsonKey(defaultValue: false)
final bool isCustom;
@override
List<Object?> get props => [id, name, content, category, example, isFeatured];
List<Object?> get props =>
[id, name, content, category, example, isFeatured, isCustom];
static Map<AiPromptCategory, (String, String)> _buildCategoryNameMap() {
final service = getIt<EasyLocalizationService>();
return {
for (final category in AiPromptCategory.values)
category: (
service.getFallbackTranslation(category.token),
service.getFallbackTranslation(category.token),
),
};
}
static List<AiPromptCategory> _categoryFromJson(dynamic json) {
if (json is String) {
return json
.split(',')
.map((categoryName) => categoryName.trim())
.map(
(categoryName) => $enumDecode(
_aiPromptCategoryEnumMap,
categoryName,
unknownValue: AiPromptCategory.other,
),
)
.toSet()
.toList();
}
return [AiPromptCategory.other];
}
}
const _aiPromptCategoryEnumMap = {
AiPromptCategory.other: 'other',
AiPromptCategory.development: 'development',
AiPromptCategory.writing: 'writing',
AiPromptCategory.healthAndFitness: 'healthAndFitness',
AiPromptCategory.business: 'business',
AiPromptCategory.marketing: 'marketing',
AiPromptCategory.travel: 'travel',
AiPromptCategory.contentSeo: 'contentSeo',
AiPromptCategory.emailMarketing: 'emailMarketing',
AiPromptCategory.paidAds: 'paidAds',
AiPromptCategory.prCommunication: 'prCommunication',
AiPromptCategory.recruiting: 'recruiting',
AiPromptCategory.sales: 'sales',
AiPromptCategory.socialMedia: 'socialMedia',
AiPromptCategory.strategy: 'strategy',
AiPromptCategory.caseStudies: 'caseStudies',
AiPromptCategory.salesCopy: 'salesCopy',
AiPromptCategory.education: 'education',
AiPromptCategory.work: 'work',
AiPromptCategory.podcastProduction: 'podcastProduction',
AiPromptCategory.copyWriting: 'copyWriting',
AiPromptCategory.customerSuccess: 'customerSuccess',
};
class CustomPromptDatabaseConfig extends Equatable {
const CustomPromptDatabaseConfig({
required this.view,
required this.titleFieldId,
required this.contentFieldId,
required this.exampleFieldId,
required this.categoryFieldId,
});
factory CustomPromptDatabaseConfig.fromAiPB(
CustomPromptDatabaseConfigurationPB pb,
ViewPB view,
) {
final config = CustomPromptDatabaseConfig(
view: view,
titleFieldId: pb.titleFieldId,
contentFieldId: pb.contentFieldId,
exampleFieldId: pb.hasExampleFieldId() ? pb.exampleFieldId : null,
categoryFieldId: pb.hasCategoryFieldId() ? pb.categoryFieldId : null,
);
return config;
}
factory CustomPromptDatabaseConfig.fromDbPB(
CustomPromptDatabaseConfigPB pb,
ViewPB view,
) {
final config = CustomPromptDatabaseConfig(
view: view,
titleFieldId: pb.titleFieldId,
contentFieldId: pb.contentFieldId,
exampleFieldId: pb.hasExampleFieldId() ? pb.exampleFieldId : null,
categoryFieldId: pb.hasCategoryFieldId() ? pb.categoryFieldId : null,
);
return config;
}
final ViewPB view;
final String titleFieldId;
final String contentFieldId;
final String? exampleFieldId;
final String? categoryFieldId;
@override
List<Object?> get props =>
[view.id, titleFieldId, contentFieldId, exampleFieldId, categoryFieldId];
CustomPromptDatabaseConfig copyWith({
ViewPB? view,
String? titleFieldId,
String? contentFieldId,
String? exampleFieldId,
String? categoryFieldId,
}) {
return CustomPromptDatabaseConfig(
view: view ?? this.view,
titleFieldId: titleFieldId ?? this.titleFieldId,
contentFieldId: contentFieldId ?? this.contentFieldId,
exampleFieldId: exampleFieldId ?? this.exampleFieldId,
categoryFieldId: categoryFieldId ?? this.categoryFieldId,
);
}
CustomPromptDatabaseConfigurationPB toAiPB() {
final payload = CustomPromptDatabaseConfigurationPB.create()
..viewId = view.id
..titleFieldId = titleFieldId
..contentFieldId = contentFieldId;
if (exampleFieldId != null) {
payload.exampleFieldId = exampleFieldId!;
}
if (categoryFieldId != null) {
payload.categoryFieldId = categoryFieldId!;
}
return payload;
}
CustomPromptDatabaseConfigPB toDbPB() {
final payload = CustomPromptDatabaseConfigPB.create()
..viewId = view.id
..titleFieldId = titleFieldId
..contentFieldId = contentFieldId;
if (exampleFieldId != null) {
payload.exampleFieldId = exampleFieldId!;
}
if (categoryFieldId != null) {
payload.categoryFieldId = categoryFieldId!;
}
return payload;
}
}
@@ -0,0 +1,161 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/plugins/database/domain/field_service.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:bloc/bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'ai_prompt_database_selector_cubit.freezed.dart';
class AiPromptDatabaseSelectorCubit
extends Cubit<AiPromptDatabaseSelectorState> {
AiPromptDatabaseSelectorCubit({
required CustomPromptDatabaseConfig? configuration,
}) : super(AiPromptDatabaseSelectorState.loading()) {
_init(configuration);
}
void _init(CustomPromptDatabaseConfig? config) async {
if (config == null) {
emit(AiPromptDatabaseSelectorState.empty());
return;
}
final fields = await _getFields(config.view.id);
if (fields == null) {
emit(AiPromptDatabaseSelectorState.empty());
return;
}
emit(
AiPromptDatabaseSelectorState.selected(
config: config,
fields: fields,
),
);
}
void selectDatabaseView(String viewId) async {
final configuration = await _testDatabase(viewId);
if (configuration == null) {
final stateCopy = state;
emit(AiPromptDatabaseSelectorState.invalidDatabase());
emit(stateCopy);
return;
}
final databaseView = await AiPromptSelectorCubit.getDatabaseView(viewId);
final fields = await _getFields(viewId);
if (databaseView == null || fields == null) {
final stateCopy = state;
emit(AiPromptDatabaseSelectorState.invalidDatabase());
emit(stateCopy);
return;
}
final config = CustomPromptDatabaseConfig.fromDbPB(
configuration,
databaseView,
);
emit(
AiPromptDatabaseSelectorState.selected(
config: config,
fields: fields,
),
);
}
void selectContentField(String fieldId) {
final state = this.state;
if (state is! _Selected) {
return;
}
final config = state.config.copyWith(
contentFieldId: fieldId,
);
emit(
AiPromptDatabaseSelectorState.selected(
config: config,
fields: state.fields,
),
);
}
void selectExampleField(String? fieldId) {
final state = this.state;
if (state is! _Selected) {
return;
}
final config = CustomPromptDatabaseConfig(
exampleFieldId: fieldId,
view: state.config.view,
titleFieldId: state.config.titleFieldId,
contentFieldId: state.config.contentFieldId,
categoryFieldId: state.config.categoryFieldId,
);
emit(
AiPromptDatabaseSelectorState.selected(
config: config,
fields: state.fields,
),
);
}
void selectCategoryField(String? fieldId) {
final state = this.state;
if (state is! _Selected) {
return;
}
final config = CustomPromptDatabaseConfig(
categoryFieldId: fieldId,
view: state.config.view,
titleFieldId: state.config.titleFieldId,
contentFieldId: state.config.contentFieldId,
exampleFieldId: state.config.exampleFieldId,
);
emit(
AiPromptDatabaseSelectorState.selected(
config: config,
fields: state.fields,
),
);
}
Future<List<FieldPB>?> _getFields(String viewId) {
return FieldBackendService.getFields(viewId: viewId).toNullable();
}
Future<CustomPromptDatabaseConfigPB?> _testDatabase(
String viewId,
) {
return DatabaseEventTestCustomPromptDatabaseConfiguration(
DatabaseViewIdPB(value: viewId),
).send().toNullable();
}
}
@freezed
class AiPromptDatabaseSelectorState with _$AiPromptDatabaseSelectorState {
const factory AiPromptDatabaseSelectorState.loading() = _Loading;
const factory AiPromptDatabaseSelectorState.empty() = _Empty;
const factory AiPromptDatabaseSelectorState.selected({
required CustomPromptDatabaseConfig config,
required List<FieldPB> fields,
}) = _Selected;
const factory AiPromptDatabaseSelectorState.invalidDatabase() =
_InvalidDatabase;
}
@@ -23,6 +23,8 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
final AIModelStateNotifier aiModelStateNotifier;
String? promptId;
@override
Future<void> close() async {
await aiModelStateNotifier.dispose();
@@ -88,7 +90,11 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
),
);
},
updatePromptId: (promptId) {
this.promptId = promptId;
},
clearMetadata: () {
promptId = null;
emit(
state.copyWith(
attachedFiles: [],
@@ -151,6 +157,8 @@ class AIPromptInputEvent with _$AIPromptInputEvent {
const factory AIPromptInputEvent.updateMentionedViews(List<ViewPB> views) =
_UpdateMentionedViews;
const factory AIPromptInputEvent.clearMetadata() = _ClearMetadata;
const factory AIPromptInputEvent.updatePromptId(String promptId) =
_UpdatePromptId;
}
@freezed
@@ -1,8 +1,16 @@
import 'dart:async';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../plugins/trash/application/trash_service.dart';
import 'ai_entities.dart';
import 'appflowy_ai_service.dart';
@@ -14,7 +22,7 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
}) : _aiService = aiService ?? AppFlowyAIService(),
super(AiPromptSelectorState.loading()) {
filterTextController.addListener(_filterTextChanged);
_loadPrompts();
_init();
}
final AppFlowyAIService _aiService;
@@ -27,33 +35,146 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
await super.close();
}
void _loadPrompts() async {
void _init() async {
availablePrompts.addAll(await _aiService.getBuiltInPrompts());
final featuredPrompts =
availablePrompts.where((prompt) => prompt.isFeatured);
final visiblePrompts = _getFilteredPrompts(featuredPrompts);
emit(
AiPromptSelectorState.ready(
visiblePrompts: visiblePrompts,
showCustomPrompts: false,
isFeaturedCategorySelected: true,
visiblePrompts: visiblePrompts.toList(),
isCustomPromptSectionSelected: false,
isFeaturedSectionSelected: true,
selectedPromptId: visiblePrompts.firstOrNull?.id,
databaseConfig: null,
isLoadingCustomPrompts: true,
selectedCategory: null,
favoritePrompts: [],
),
);
loadCustomPrompts();
}
void selectFeaturedCategory() {
void loadCustomPrompts() {
state.maybeMap(
ready: (readyState) async {
emit(
readyState.copyWith(isLoadingCustomPrompts: true),
);
CustomPromptDatabaseConfig? configuration = readyState.databaseConfig;
if (configuration == null) {
final configResult =
await AIEventGetCustomPromptDatabaseConfiguration()
.send()
.toNullable();
if (configResult != null) {
final view = await getDatabaseView(configResult.viewId);
if (view != null) {
configuration = CustomPromptDatabaseConfig.fromAiPB(
configResult,
view,
);
}
}
} else {
final view = await getDatabaseView(configuration.view.id);
if (view != null) {
configuration = configuration.copyWith(view: view);
}
}
if (configuration == null) {
emit(
readyState.copyWith(isLoadingCustomPrompts: false),
);
return;
}
availablePrompts.removeWhere((prompt) => prompt.isCustom);
final customPrompts =
await _aiService.getDatabasePrompts(configuration.toDbPB());
if (customPrompts == null) {
final prompts = availablePrompts.where((prompt) => prompt.isFeatured);
final visiblePrompts = _getFilteredPrompts(prompts);
final selectedPromptId = _getVisibleSelectedPrompt(
visiblePrompts,
readyState.selectedPromptId,
);
emit(
readyState.copyWith(
visiblePrompts: visiblePrompts.toList(),
selectedPromptId: selectedPromptId,
databaseConfig: configuration,
isLoadingCustomPrompts: false,
isFeaturedSectionSelected: true,
isCustomPromptSectionSelected: false,
selectedCategory: null,
),
);
} else {
availablePrompts.addAll(customPrompts);
final prompts = _getPromptsByCategory(readyState);
final visiblePrompts = _getFilteredPrompts(prompts);
final selectedPromptId = _getVisibleSelectedPrompt(
visiblePrompts,
readyState.selectedPromptId,
);
emit(
readyState.copyWith(
visiblePrompts: visiblePrompts.toList(),
databaseConfig: configuration,
isLoadingCustomPrompts: false,
selectedPromptId: selectedPromptId,
),
);
}
},
orElse: () {},
);
}
void selectCustomSection() {
state.maybeMap(
ready: (readyState) {
final prompts = availablePrompts.where((prompt) => prompt.isCustom);
final visiblePrompts = _getFilteredPrompts(prompts);
emit(
readyState.copyWith(
visiblePrompts: visiblePrompts.toList(),
selectedPromptId: visiblePrompts.firstOrNull?.id,
isCustomPromptSectionSelected: true,
isFeaturedSectionSelected: false,
selectedCategory: null,
),
);
},
orElse: () {},
);
}
void selectFeaturedSection() {
state.maybeMap(
ready: (readyState) {
final prompts = availablePrompts.where((prompt) => prompt.isFeatured);
final visiblePrompts = _getFilteredPrompts(prompts);
emit(
readyState.copyWith(
visiblePrompts: visiblePrompts,
isFeaturedCategorySelected: true,
visiblePrompts: visiblePrompts.toList(),
selectedPromptId: visiblePrompts.firstOrNull?.id,
isFeaturedSectionSelected: true,
isCustomPromptSectionSelected: false,
selectedCategory: null,
),
);
},
@@ -64,24 +185,24 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
void selectCategory(AiPromptCategory? category) {
state.maybeMap(
ready: (readyState) {
final unfilteredPrompts = category == null
final prompts = category == null
? availablePrompts
: availablePrompts.where((prompt) => prompt.category == category);
final prompts = _getFilteredPrompts(unfilteredPrompts);
: availablePrompts
.where((prompt) => prompt.category.contains(category));
final visiblePrompts = _getFilteredPrompts(prompts);
// Check if the selected prompt is still visible
String? selectedPromptId = prompts.firstOrNull?.id;
if (readyState.selectedPromptId != null &&
prompts.any((prompt) => prompt.id == readyState.selectedPromptId)) {
selectedPromptId = readyState.selectedPromptId;
}
final selectedPromptId = _getVisibleSelectedPrompt(
visiblePrompts,
readyState.selectedPromptId,
);
emit(
readyState.copyWith(
visiblePrompts: prompts,
isFeaturedCategorySelected: false,
visiblePrompts: visiblePrompts.toList(),
selectedCategory: category,
selectedPromptId: selectedPromptId,
isFeaturedSectionSelected: false,
isCustomPromptSectionSelected: false,
),
);
},
@@ -116,8 +237,6 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
emit(
readyState.copyWith(favoritePrompts: favoritePrompts),
);
// TODO(RS): Save the updated favorite prompts to local storage or database
},
orElse: () {},
);
@@ -130,7 +249,9 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
emit(
readyState.copyWith(
visiblePrompts: availablePrompts,
selectedPromptId: null,
isCustomPromptSectionSelected: false,
isFeaturedSectionSelected: true,
selectedPromptId: availablePrompts.firstOrNull?.id,
selectedCategory: null,
),
);
@@ -139,27 +260,74 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
);
}
void updateCustomPromptDatabaseConfiguration(
CustomPromptDatabaseConfig configuration,
) async {
state.maybeMap(
ready: (readyState) async {
emit(
readyState.copyWith(isLoadingCustomPrompts: true),
);
final customPrompts =
await _aiService.getDatabasePrompts(configuration.toDbPB());
if (customPrompts == null) {
emit(AiPromptSelectorState.invalidDatabase());
emit(readyState);
return;
}
availablePrompts
..removeWhere((prompt) => prompt.isCustom)
..addAll(customPrompts);
await AIEventSetCustomPromptDatabaseConfiguration(
configuration.toAiPB(),
).send().onFailure(Log.error);
final prompts = _getPromptsByCategory(readyState);
final visiblePrompts = _getFilteredPrompts(prompts);
final selectedPromptId = _getVisibleSelectedPrompt(
visiblePrompts,
readyState.selectedPromptId,
);
emit(
readyState.copyWith(
visiblePrompts: visiblePrompts.toList(),
selectedPromptId: selectedPromptId,
databaseConfig: configuration,
isLoadingCustomPrompts: false,
),
);
},
orElse: () => {},
);
}
void _filterTextChanged() {
state.maybeMap(
ready: (readyState) {
final unfilteredPrompts = readyState.selectedCategory == null
? readyState.isFeaturedCategorySelected
? availablePrompts.where((prompt) => prompt.isFeatured)
: availablePrompts
: availablePrompts.where(
(prompt) => prompt.category == readyState.selectedCategory,
);
final filteredPrompts = _getFilteredPrompts(unfilteredPrompts);
final prompts = _getPromptsByCategory(readyState);
final visiblePrompts = _getFilteredPrompts(prompts);
final selectedPromptId = _getVisibleSelectedPrompt(
visiblePrompts,
readyState.selectedPromptId,
);
emit(
readyState.copyWith(visiblePrompts: filteredPrompts),
readyState.copyWith(
visiblePrompts: visiblePrompts.toList(),
selectedPromptId: selectedPromptId,
),
);
},
orElse: () {},
);
}
List<AiPrompt> _getFilteredPrompts(Iterable<AiPrompt> prompts) {
Iterable<AiPrompt> _getFilteredPrompts(Iterable<AiPrompt> prompts) {
final filterText = filterTextController.value.text.trim().toLowerCase();
return prompts.where((prompt) {
@@ -167,6 +335,53 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
return content.contains(filterText);
}).toList();
}
Iterable<AiPrompt> _getPromptsByCategory(_AiPromptSelectorReadyState state) {
return availablePrompts.where((prompt) {
if (state.selectedCategory != null) {
return prompt.category.contains(state.selectedCategory);
}
if (state.isFeaturedSectionSelected) {
return prompt.isFeatured;
}
if (state.isCustomPromptSectionSelected) {
return prompt.isCustom;
}
return true;
});
}
String? _getVisibleSelectedPrompt(
Iterable<AiPrompt> visiblePrompts,
String? currentlySelectedPromptId,
) {
if (visiblePrompts
.any((prompt) => prompt.id == currentlySelectedPromptId)) {
return currentlySelectedPromptId;
}
return visiblePrompts.firstOrNull?.id;
}
static Future<ViewPB?> getDatabaseView(String viewId) async {
final view = await ViewBackendService.getView(viewId).toNullable();
if (view != null) {
return view;
}
final trashViews = await TrashService().readTrash().toNullable();
final trashedItem =
trashViews?.items.firstWhereOrNull((element) => element.id == viewId);
if (trashedItem == null) {
return null;
}
return ViewPB()
..id = trashedItem.id
..name = trashedItem.name;
}
}
@freezed
@@ -175,13 +390,18 @@ class AiPromptSelectorState with _$AiPromptSelectorState {
const factory AiPromptSelectorState.loading() = _AiPromptSelectorLoadingState;
const factory AiPromptSelectorState.invalidDatabase() =
_AiPromptSelectorErrorState;
const factory AiPromptSelectorState.ready({
required List<AiPrompt> visiblePrompts,
required List<String> favoritePrompts,
required bool showCustomPrompts,
required bool isFeaturedCategorySelected,
required bool isCustomPromptSectionSelected,
required bool isFeaturedSectionSelected,
required AiPromptCategory? selectedCategory,
required String? selectedPromptId,
required bool isLoadingCustomPrompts,
required CustomPromptDatabaseConfig? databaseConfig,
}) = _AiPromptSelectorReadyState;
bool get isLoading => this is _AiPromptSelectorLoadingState;
@@ -192,9 +412,4 @@ class AiPromptSelectorState with _$AiPromptSelectorState {
.firstWhereOrNull((prompt) => prompt.id == state.selectedPromptId),
orElse: () => null,
);
AiPromptCategory? get selectedCategory => maybeMap(
ready: (state) => state.selectedCategory,
orElse: () => null,
);
}
@@ -8,7 +8,9 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operati
import 'package:appflowy/shared/list_extension.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'
hide CustomPromptDatabaseConfigurationPB;
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:fixnum/fixnum.dart' as fixnum;
@@ -27,6 +29,7 @@ abstract class AIRepository {
String? objectId,
required String text,
PredefinedFormat? format,
String? promptId,
List<String> sourceIds = const [],
List<AiWriterRecord> history = const [],
required CompletionTypePB completionType,
@@ -41,6 +44,10 @@ abstract class AIRepository {
Future<List<AiPrompt>> getBuiltInPrompts();
Future<List<AiPrompt>?> getDatabasePrompts(
CustomPromptDatabaseConfigPB config,
);
void updateFavoritePrompts(List<String> promptIds);
}
@@ -50,6 +57,7 @@ class AppFlowyAIService implements AIRepository {
String? objectId,
required String text,
PredefinedFormat? format,
String? promptId,
List<String> sourceIds = const [],
List<AiWriterRecord> history = const [],
required CompletionTypePB completionType,
@@ -76,6 +84,7 @@ class AppFlowyAIService implements AIRepository {
text: text,
completionType: completionType,
format: format?.toPB(),
promptId: promptId,
streamPort: fixnum.Int64(stream.nativePort),
objectId: objectId ?? '',
ragIds: [
@@ -117,6 +126,20 @@ class AppFlowyAIService implements AIRepository {
return prompts;
}
@override
Future<List<AiPrompt>?> getDatabasePrompts(
CustomPromptDatabaseConfigPB config,
) async {
return DatabaseEventGetDatabaseCustomPrompts(config).send().fold(
(databasePromptsPB) =>
databasePromptsPB.items.map(AiPrompt.fromPB).toList(),
(err) {
Log.error(err);
return null;
},
);
}
@override
void updateFavoritePrompts(List<String> promptIds) {}
}
@@ -1,3 +1,4 @@
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
@@ -6,13 +7,12 @@ import 'package:appflowy_result/appflowy_result.dart';
import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'chat_select_sources_cubit.freezed.dart';
part 'view_selector_cubit.freezed.dart';
const int _kMaxSelectedParentPageCount = 3;
enum SourceSelectedStatus {
enum ViewSelectedStatus {
unselected,
selected,
partiallySelected;
@@ -22,45 +22,46 @@ enum SourceSelectedStatus {
bool get isPartiallySelected => this == partiallySelected;
}
class ChatSource {
ChatSource({
class ViewSelectorItem {
ViewSelectorItem({
required this.view,
required this.parentView,
required this.children,
required bool isExpanded,
required SourceSelectedStatus selectedStatus,
required IgnoreViewType ignoreStatus,
required ViewSelectedStatus selectedStatus,
required bool isDisabled,
}) : isExpandedNotifier = ValueNotifier(isExpanded),
selectedStatusNotifier = ValueNotifier(selectedStatus),
ignoreStatusNotifier = ValueNotifier(ignoreStatus);
isDisabledNotifier = ValueNotifier(isDisabled);
final ViewPB view;
final ViewPB? parentView;
final List<ChatSource> children;
final List<ViewSelectorItem> children;
final ValueNotifier<bool> isExpandedNotifier;
final ValueNotifier<SourceSelectedStatus> selectedStatusNotifier;
final ValueNotifier<IgnoreViewType> ignoreStatusNotifier;
final ValueNotifier<bool> isDisabledNotifier;
final ValueNotifier<ViewSelectedStatus> selectedStatusNotifier;
bool get isExpanded => isExpandedNotifier.value;
SourceSelectedStatus get selectedStatus => selectedStatusNotifier.value;
IgnoreViewType get ignoreStatus => ignoreStatusNotifier.value;
ViewSelectedStatus get selectedStatus => selectedStatusNotifier.value;
bool get isDisabled => isDisabledNotifier.value;
void toggleIsExpanded() {
isExpandedNotifier.value = !isExpanded;
isExpandedNotifier.value = !isExpandedNotifier.value;
}
ChatSource copy() {
return ChatSource(
ViewSelectorItem copy() {
return ViewSelectorItem(
view: view,
parentView: parentView,
children: children.map<ChatSource>((child) => child.copy()).toList(),
ignoreStatus: ignoreStatus,
isExpanded: isExpanded,
selectedStatus: selectedStatus,
children:
children.map<ViewSelectorItem>((child) => child.copy()).toList(),
isDisabled: isDisabledNotifier.value,
isExpanded: isExpandedNotifier.value,
selectedStatus: selectedStatusNotifier.value,
);
}
ChatSource? findChildBySourceId(String sourceId) {
ViewSelectorItem? findChildBySourceId(String sourceId) {
if (view.id == sourceId) {
return this;
}
@@ -73,20 +74,19 @@ class ChatSource {
return null;
}
void resetIgnoreViewTypeRecursive() {
ignoreStatusNotifier.value = view.layout.isDocumentView
? IgnoreViewType.none
: IgnoreViewType.disable;
void setIsDisabledRecursive(bool Function(ViewSelectorItem) newIsDisabled) {
isDisabledNotifier.value = newIsDisabled(this);
for (final child in children) {
child.resetIgnoreViewTypeRecursive();
child.setIsDisabledRecursive(newIsDisabled);
}
}
void updateIgnoreViewTypeRecursive(IgnoreViewType newIgnoreViewType) {
ignoreStatusNotifier.value = newIgnoreViewType;
void setIsSelectedStatusRecursive(ViewSelectedStatus selectedStatus) {
selectedStatusNotifier.value = selectedStatus;
for (final child in children) {
child.updateIgnoreViewTypeRecursive(newIgnoreViewType);
child.setIsSelectedStatusRecursive(selectedStatus);
}
}
@@ -96,36 +96,45 @@ class ChatSource {
}
isExpandedNotifier.dispose();
selectedStatusNotifier.dispose();
ignoreStatusNotifier.dispose();
isDisabledNotifier.dispose();
}
}
class ChatSettingsCubit extends Cubit<ChatSettingsState> {
ChatSettingsCubit({
this.hideDisabled = false,
}) : super(ChatSettingsState.initial());
class ViewSelectorCubit extends Cubit<ViewSelectorState> {
ViewSelectorCubit({
required this.getIgnoreViewType,
this.maxSelectedParentPageCount,
}) : super(ViewSelectorState.initial()) {
filterTextController.addListener(onFilterChanged);
}
final bool hideDisabled;
final IgnoreViewType Function(ViewSelectorItem) getIgnoreViewType;
final int? maxSelectedParentPageCount;
final List<String> selectedSourceIds = [];
final List<ChatSource> sources = [];
final List<ChatSource> selectedSources = [];
String filter = '';
final List<ViewSelectorItem> sources = [];
final List<ViewSelectorItem> selectedSources = [];
final filterTextController = TextEditingController();
void updateSelectedSources(List<String> newSelectedSourceIds) {
selectedSourceIds.clear();
selectedSourceIds.addAll(newSelectedSourceIds);
}
void refreshSources(List<ViewPB> spaceViews, ViewPB? currentSpace) async {
filter = "";
Future<void> refreshSources(
List<ViewPB> spaceViews,
ViewPB? currentSpace,
) async {
filterTextController.clear();
final newSources = await Future.wait(
spaceViews.map((view) => _recursiveBuild(view, null)),
);
for (final source in newSources) {
_restrictSelectionIfNecessary(source.children);
}
_setIsDisabledAndHideIfNecessary(newSources);
_restrictSelectionIfNecessary(newSources);
if (currentSpace != null) {
newSources
.firstWhereOrNull((e) => e.view.id == currentSpace.id)
@@ -155,29 +164,31 @@ class ChatSettingsCubit extends Cubit<ChatSettingsState> {
..addAll(selected.map((e) => e.copy()));
}
Future<ChatSource> _recursiveBuild(ViewPB view, ViewPB? parentView) async {
SourceSelectedStatus selectedStatus = SourceSelectedStatus.unselected;
Future<ViewSelectorItem> _recursiveBuild(
ViewPB view,
ViewPB? parentView,
) async {
ViewSelectedStatus selectedStatus = ViewSelectedStatus.unselected;
final isThisSourceSelected = selectedSourceIds.contains(view.id);
final childrenViews =
await ViewBackendService.getChildViews(viewId: view.id).toNullable();
final List<ViewPB>? childrenViews;
if (integrationMode().isTest) {
childrenViews = view.childViews;
} else {
childrenViews =
await ViewBackendService.getChildViews(viewId: view.id).toNullable();
}
int selectedCount = 0;
final children = <ChatSource>[];
final children = <ViewSelectorItem>[];
if (childrenViews != null) {
for (final childView in childrenViews) {
if (childView.layout == ViewLayoutPB.Chat) {
continue;
}
if (childView.layout != ViewLayoutPB.Document && hideDisabled) {
continue;
}
final childChatSource = await _recursiveBuild(childView, view);
if (childChatSource.selectedStatus.isSelected) {
final childItem = await _recursiveBuild(childView, view);
if (childItem.selectedStatus.isSelected) {
selectedCount++;
}
children.add(childChatSource);
children.add(childItem);
}
final areAllChildrenSelectedOrNoChildren =
@@ -186,47 +197,64 @@ class ChatSettingsCubit extends Cubit<ChatSettingsState> {
children.any((e) => !e.selectedStatus.isUnselected);
if (isThisSourceSelected && areAllChildrenSelectedOrNoChildren) {
selectedStatus = SourceSelectedStatus.selected;
selectedStatus = ViewSelectedStatus.selected;
} else if (isThisSourceSelected || isAnyChildNotUnselected) {
selectedStatus = SourceSelectedStatus.partiallySelected;
selectedStatus = ViewSelectedStatus.partiallySelected;
}
} else if (isThisSourceSelected) {
selectedStatus = SourceSelectedStatus.selected;
selectedStatus = ViewSelectedStatus.selected;
}
return ChatSource(
return ViewSelectorItem(
view: view,
parentView: parentView,
children: children,
ignoreStatus: view.layout.isDocumentView
? IgnoreViewType.none
: IgnoreViewType.disable,
isDisabled: false,
isExpanded: false,
selectedStatus: selectedStatus,
);
}
void _restrictSelectionIfNecessary(List<ChatSource> sources) {
void _setIsDisabledAndHideIfNecessary(
List<ViewSelectorItem> sources,
) {
sources.retainWhere((source) {
final ignoreViewType = getIgnoreViewType(source);
return ignoreViewType != IgnoreViewType.hide;
});
for (final source in sources) {
source.resetIgnoreViewTypeRecursive();
source.isDisabledNotifier.value =
getIgnoreViewType(source) == IgnoreViewType.disable;
_setIsDisabledAndHideIfNecessary(source.children);
}
}
void _restrictSelectionIfNecessary(List<ViewSelectorItem> sources) {
if (maxSelectedParentPageCount == null) {
return;
}
for (final source in sources) {
source.setIsDisabledRecursive((view) {
return getIgnoreViewType(view) == IgnoreViewType.disable;
});
}
if (sources.where((e) => !e.selectedStatus.isUnselected).length >=
_kMaxSelectedParentPageCount) {
maxSelectedParentPageCount!) {
sources
.where((e) => e.selectedStatus == SourceSelectedStatus.unselected)
.where((e) => e.selectedStatus == ViewSelectedStatus.unselected)
.forEach(
(e) => e.updateIgnoreViewTypeRecursive(IgnoreViewType.disable),
(e) => e.setIsDisabledRecursive((_) => true),
);
}
}
void updateFilter(String filter) {
this.filter = filter;
void onFilterChanged() {
for (final source in state.visibleSources) {
source.dispose();
}
if (sources.isEmpty) {
emit(ChatSettingsState.initial());
emit(ViewSelectorState.initial());
} else {
final selected =
selectedSources.map(_buildSearchResults).nonNulls.toList();
@@ -242,13 +270,13 @@ class ChatSettingsCubit extends Cubit<ChatSettingsState> {
}
/// traverse tree to build up search query
ChatSource? _buildSearchResults(ChatSource chatSource) {
final isVisible = chatSource.view.nameOrDefault
ViewSelectorItem? _buildSearchResults(ViewSelectorItem item) {
final isVisible = item.view.nameOrDefault
.toLowerCase()
.contains(filter.toLowerCase());
.contains(filterTextController.text.toLowerCase());
final childrenResults = <ChatSource>[];
for (final childSource in chatSource.children) {
final childrenResults = <ViewSelectorItem>[];
for (final childSource in item.children) {
final childResult = _buildSearchResults(childSource);
if (childResult != null) {
childrenResults.add(childResult);
@@ -256,48 +284,50 @@ class ChatSettingsCubit extends Cubit<ChatSettingsState> {
}
return isVisible || childrenResults.isNotEmpty
? ChatSource(
view: chatSource.view,
parentView: chatSource.parentView,
? ViewSelectorItem(
view: item.view,
parentView: item.parentView,
children: childrenResults,
ignoreStatus: chatSource.ignoreStatus,
isExpanded: chatSource.isExpanded,
selectedStatus: chatSource.selectedStatus,
isDisabled: item.isDisabled,
isExpanded: item.isExpanded,
selectedStatus: item.selectedStatus,
)
: null;
}
/// traverse tree to build up selected sources
Iterable<ChatSource> _buildSelectedSources(ChatSource chatSource) {
final children = <ChatSource>[];
Iterable<ViewSelectorItem> _buildSelectedSources(
ViewSelectorItem item,
) {
final children = <ViewSelectorItem>[];
for (final childSource in chatSource.children) {
for (final childSource in item.children) {
children.addAll(_buildSelectedSources(childSource));
}
return selectedSourceIds.contains(chatSource.view.id)
return selectedSourceIds.contains(item.view.id)
? [
ChatSource(
view: chatSource.view,
parentView: chatSource.parentView,
ViewSelectorItem(
view: item.view,
parentView: item.parentView,
children: children,
ignoreStatus: chatSource.ignoreStatus,
selectedStatus: chatSource.selectedStatus,
isDisabled: item.isDisabled,
selectedStatus: item.selectedStatus,
isExpanded: true,
),
]
: children;
}
void toggleSelectedStatus(ChatSource chatSource) {
if (chatSource.view.isSpace) {
void toggleSelectedStatus(ViewSelectorItem item, bool isSelectedSection) {
if (item.view.isSpace) {
return;
}
final allIds = _recursiveGetSourceIds(chatSource);
final allIds = _recursiveGetSourceIds(item);
if (chatSource.selectedStatus.isUnselected ||
chatSource.selectedStatus.isPartiallySelected &&
!chatSource.view.layout.isDocumentView) {
if (item.selectedStatus.isUnselected ||
item.selectedStatus.isPartiallySelected &&
!item.view.layout.isDocumentView) {
for (final id in allIds) {
if (!selectedSourceIds.contains(id)) {
selectedSourceIds.add(id);
@@ -311,13 +341,22 @@ class ChatSettingsCubit extends Cubit<ChatSettingsState> {
}
}
if (isSelectedSection) {
item.setIsSelectedStatusRecursive(
item.selectedStatus.isUnselected ||
item.selectedStatus.isPartiallySelected
? ViewSelectedStatus.selected
: ViewSelectedStatus.unselected,
);
}
updateSelectedStatus();
}
List<String> _recursiveGetSourceIds(ChatSource chatSource) {
List<String> _recursiveGetSourceIds(ViewSelectorItem item) {
return [
if (chatSource.view.layout.isDocumentView) chatSource.view.id,
for (final childSource in chatSource.children)
if (item.view.layout.isDocumentView) item.view.id,
for (final childSource in item.children)
..._recursiveGetSourceIds(childSource),
];
}
@@ -342,44 +381,42 @@ class ChatSettingsCubit extends Cubit<ChatSettingsState> {
);
}
SourceSelectedStatus _recursiveUpdateSelectedStatus(ChatSource chatSource) {
SourceSelectedStatus selectedStatus = SourceSelectedStatus.unselected;
ViewSelectedStatus _recursiveUpdateSelectedStatus(ViewSelectorItem item) {
ViewSelectedStatus selectedStatus = ViewSelectedStatus.unselected;
int selectedCount = 0;
for (final childSource in chatSource.children) {
for (final childSource in item.children) {
final childStatus = _recursiveUpdateSelectedStatus(childSource);
if (childStatus.isSelected) {
selectedCount++;
}
}
final isThisSourceSelected = selectedSourceIds.contains(chatSource.view.id);
final isThisSourceSelected = selectedSourceIds.contains(item.view.id);
final areAllChildrenSelectedOrNoChildren =
chatSource.children.length == selectedCount;
item.children.length == selectedCount;
final isAnyChildNotUnselected =
chatSource.children.any((e) => !e.selectedStatus.isUnselected);
item.children.any((e) => !e.selectedStatus.isUnselected);
if (isThisSourceSelected && areAllChildrenSelectedOrNoChildren) {
selectedStatus = SourceSelectedStatus.selected;
selectedStatus = ViewSelectedStatus.selected;
} else if (isThisSourceSelected || isAnyChildNotUnselected) {
selectedStatus = SourceSelectedStatus.partiallySelected;
selectedStatus = ViewSelectedStatus.partiallySelected;
}
chatSource.selectedStatusNotifier.value = selectedStatus;
item.selectedStatusNotifier.value = selectedStatus;
return selectedStatus;
}
void toggleIsExpanded(ChatSource chatSource, bool isSelectedSection) {
chatSource.toggleIsExpanded();
void toggleIsExpanded(ViewSelectorItem item, bool isSelectedSection) {
item.toggleIsExpanded();
if (isSelectedSection) {
for (final selectedSource in selectedSources) {
selectedSource
.findChildBySourceId(chatSource.view.id)
?.toggleIsExpanded();
selectedSource.findChildBySourceId(item.view.id)?.toggleIsExpanded();
}
} else {
for (final source in sources) {
final child = source.findChildBySourceId(chatSource.view.id);
final child = source.findChildBySourceId(item.view.id);
if (child != null) {
child.toggleIsExpanded();
break;
@@ -402,18 +439,19 @@ class ChatSettingsCubit extends Cubit<ChatSettingsState> {
for (final child in state.visibleSources) {
child.dispose();
}
filterTextController.dispose();
return super.close();
}
}
@freezed
class ChatSettingsState with _$ChatSettingsState {
const factory ChatSettingsState({
required List<ChatSource> visibleSources,
required List<ChatSource> selectedSources,
}) = _ChatSettingsState;
class ViewSelectorState with _$ViewSelectorState {
const factory ViewSelectorState({
required List<ViewSelectorItem> visibleSources,
required List<ViewSelectorItem> selectedSources,
}) = _ViewSelectorState;
factory ChatSettingsState.initial() => const ChatSettingsState(
factory ViewSelectorState.initial() => const ViewSelectorState(
visibleSources: [],
selectedSources: [],
);
@@ -8,11 +8,8 @@ import 'package:flutter_bloc/flutter_bloc.dart';
class AiPromptCategoryList extends StatefulWidget {
const AiPromptCategoryList({
super.key,
required this.padding,
});
final EdgeInsetsGeometry padding;
@override
State<AiPromptCategoryList> createState() => _AiPromptCategoryListState();
}
@@ -23,60 +20,62 @@ class _AiPromptCategoryListState extends State<AiPromptCategoryList> {
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Padding(
padding: EdgeInsets.all(
theme.spacing.l,
),
child: TextFieldTapRegion(
groupId: "ai_prompt_category_list",
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
spacing: theme.spacing.s,
children: [
_buildFeaturedCategory(context),
const AFDivider(),
Expanded(
child: ListView(
padding: EdgeInsets.zero,
children: [
_buildCategoryItem(context, null),
_buildCategoryItem(context, AiPromptCategory.development),
_buildCategoryItem(context, AiPromptCategory.writing),
_buildCategoryItem(context, AiPromptCategory.business),
_buildCategoryItem(context, AiPromptCategory.marketing),
_buildCategoryItem(
context,
AiPromptCategory.healthAndFitness,
),
_buildCategoryItem(context, AiPromptCategory.travel),
_buildCategoryItem(context, AiPromptCategory.contentSeo),
_buildCategoryItem(context, AiPromptCategory.emailMarketing),
_buildCategoryItem(context, AiPromptCategory.paidAds),
_buildCategoryItem(context, AiPromptCategory.prCommunication),
_buildCategoryItem(context, AiPromptCategory.recruiting),
_buildCategoryItem(context, AiPromptCategory.sales),
_buildCategoryItem(context, AiPromptCategory.socialMedia),
_buildCategoryItem(context, AiPromptCategory.strategy),
_buildCategoryItem(context, AiPromptCategory.caseStudies),
_buildCategoryItem(context, AiPromptCategory.salesCopy),
_buildCategoryItem(context, AiPromptCategory.education),
_buildCategoryItem(context, AiPromptCategory.work),
_buildCategoryItem(
context,
AiPromptCategory.podcastProduction,
),
_buildCategoryItem(context, AiPromptCategory.copyWriting),
_buildCategoryItem(context, AiPromptCategory.customerSuccess),
_buildCategoryItem(context, AiPromptCategory.other),
],
),
return TextFieldTapRegion(
groupId: "ai_prompt_category_list",
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Padding(
padding: EdgeInsets.only(
right: theme.spacing.l,
),
],
),
child: AiPromptFeaturedSection(),
),
Padding(
padding: EdgeInsets.only(
right: theme.spacing.l,
),
child: AiPromptCustomPromptSection(),
),
Padding(
padding: EdgeInsets.only(
top: theme.spacing.s,
right: theme.spacing.l,
),
child: AFDivider(),
),
Expanded(
child: ListView(
padding: EdgeInsets.only(
top: theme.spacing.s,
right: theme.spacing.l,
),
children: [
_buildCategoryItem(context, null),
...sortedCategories.map(
(category) => _buildCategoryItem(
context,
category,
),
),
],
),
),
],
),
);
}
static Iterable<AiPromptCategory> get sortedCategories {
final categories = [...AiPromptCategory.values];
categories
..sort((a, b) => a.i18n.compareTo(b.i18n))
..remove(AiPromptCategory.other)
..add(AiPromptCategory.other);
return categories;
}
Widget _buildCategoryItem(
BuildContext context,
AiPromptCategory? category,
@@ -88,18 +87,25 @@ class _AiPromptCategoryListState extends State<AiPromptCategoryList> {
},
);
}
}
Widget _buildFeaturedCategory(BuildContext context) {
class AiPromptFeaturedSection extends StatelessWidget {
const AiPromptFeaturedSection({
super.key,
});
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final isSelected = context.watch<AiPromptSelectorCubit>().state.maybeMap(
ready: (state) => state.isFeaturedCategorySelected,
ready: (state) => state.isFeaturedSectionSelected,
orElse: () => false,
);
return AFBaseButton(
onTap: () {
if (!isSelected) {
context.read<AiPromptSelectorCubit>().selectFeaturedCategory();
context.read<AiPromptSelectorCubit>().selectFeaturedSection();
}
},
builder: (context, isHovering, disabled) {
@@ -123,7 +129,7 @@ class _AiPromptCategoryListState extends State<AiPromptCategoryList> {
return theme.fillColorScheme.themeSelect;
}
if (isHovering) {
return theme.fillColorScheme.primaryAlpha5;
return theme.fillColorScheme.contentHover;
}
return Colors.transparent;
},
@@ -131,6 +137,61 @@ class _AiPromptCategoryListState extends State<AiPromptCategoryList> {
}
}
class AiPromptCustomPromptSection extends StatelessWidget {
const AiPromptCustomPromptSection({
super.key,
});
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
builder: (context, state) {
return state.maybeMap(
ready: (readyState) {
final isSelected = readyState.isCustomPromptSectionSelected;
return AFBaseButton(
onTap: () {
if (!isSelected) {
context.read<AiPromptSelectorCubit>().selectCustomSection();
}
},
builder: (context, isHovering, disabled) {
return Text(
LocaleKeys.ai_customPrompt_custom.tr(),
style: AppFlowyTheme.of(context).textStyle.body.standard(
color: theme.textColorScheme.primary,
),
overflow: TextOverflow.ellipsis,
);
},
borderRadius: theme.borderRadius.m,
padding: EdgeInsets.symmetric(
vertical: theme.spacing.s,
horizontal: theme.spacing.m,
),
borderColor: (context, isHovering, disabled, isFocused) =>
Colors.transparent,
backgroundColor: (context, isHovering, disabled) {
if (isSelected) {
return theme.fillColorScheme.themeSelect;
}
if (isHovering) {
return theme.fillColorScheme.contentHover;
}
return Colors.transparent;
},
);
},
orElse: () => const SizedBox.shrink(),
);
},
);
}
}
class AiPromptCategoryItem extends StatelessWidget {
const AiPromptCategoryItem({
super.key,
@@ -143,41 +204,47 @@ class AiPromptCategoryItem extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final isSelected = context.watch<AiPromptSelectorCubit>().state.maybeMap(
ready: (state) =>
!state.isFeaturedCategorySelected &&
state.selectedCategory == category,
return BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
builder: (context, state) {
final theme = AppFlowyTheme.of(context);
final isSelected = state.maybeMap(
ready: (state) {
return !state.isFeaturedSectionSelected &&
!state.isCustomPromptSectionSelected &&
state.selectedCategory == category;
},
orElse: () => false,
);
return AFBaseButton(
onTap: onSelect,
builder: (context, isHovering, disabled) {
return Text(
category?.i18n ?? LocaleKeys.ai_customPrompt_all.tr(),
style: AppFlowyTheme.of(context).textStyle.body.standard(
color: theme.textColorScheme.primary,
),
overflow: TextOverflow.ellipsis,
return AFBaseButton(
onTap: onSelect,
builder: (context, isHovering, disabled) {
return Text(
category?.i18n ?? LocaleKeys.ai_customPrompt_all.tr(),
style: AppFlowyTheme.of(context).textStyle.body.standard(
color: theme.textColorScheme.primary,
),
overflow: TextOverflow.ellipsis,
);
},
borderRadius: theme.borderRadius.m,
padding: EdgeInsets.symmetric(
vertical: theme.spacing.s,
horizontal: theme.spacing.m,
),
borderColor: (context, isHovering, disabled, isFocused) =>
Colors.transparent,
backgroundColor: (context, isHovering, disabled) {
if (isSelected) {
return theme.fillColorScheme.themeSelect;
}
if (isHovering) {
return theme.fillColorScheme.contentHover;
}
return Colors.transparent;
},
);
},
borderRadius: theme.borderRadius.m,
padding: EdgeInsets.symmetric(
vertical: theme.spacing.s,
horizontal: theme.spacing.m,
),
borderColor: (context, isHovering, disabled, isFocused) =>
Colors.transparent,
backgroundColor: (context, isHovering, disabled) {
if (isSelected) {
return theme.fillColorScheme.themeSelect;
}
if (isHovering) {
return theme.fillColorScheme.primaryAlpha5;
}
return Colors.transparent;
},
);
}
}
@@ -0,0 +1,626 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/ai/service/ai_prompt_database_selector_cubit.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy/workspace/presentation/widgets/dialog_v2.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:equatable/equatable.dart';
import 'package:expandable/expandable.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
Future<CustomPromptDatabaseConfig?> changeCustomPromptDatabaseConfig(
BuildContext context, {
CustomPromptDatabaseConfig? config,
}) async {
return showDialog<CustomPromptDatabaseConfig?>(
context: context,
builder: (_) {
return MultiBlocProvider(
providers: [
BlocProvider.value(
value: context.read<UserWorkspaceBloc>(),
),
BlocProvider(
create: (context) => AiPromptDatabaseSelectorCubit(
configuration: config,
),
),
],
child: const AiPromptDatabaseModal(),
);
},
);
}
class AiPromptDatabaseModal extends StatefulWidget {
const AiPromptDatabaseModal({
super.key,
});
@override
State<AiPromptDatabaseModal> createState() => _AiPromptDatabaseModalState();
}
class _AiPromptDatabaseModalState extends State<AiPromptDatabaseModal> {
final expandableController = ExpandableController(initialExpanded: false);
@override
void dispose() {
expandableController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return BlocListener<AiPromptDatabaseSelectorCubit,
AiPromptDatabaseSelectorState>(
listener: (context, state) {
state.maybeMap(
invalidDatabase: (_) {
showSimpleAFDialog(
context: context,
title: LocaleKeys.ai_customPrompt_invalidDatabase.tr(),
content: LocaleKeys.ai_customPrompt_invalidDatabaseHelp.tr(),
primaryAction: (
LocaleKeys.button_ok.tr(),
(context) {},
),
);
},
empty: (_) => expandableController.expanded = false,
selected: (_) => expandableController.expanded = true,
orElse: () {},
);
},
child: AFModal(
constraints: const BoxConstraints(
maxWidth: 450,
maxHeight: 400,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
AFModalHeader(
leading: Text(
LocaleKeys.ai_customPrompt_configureDatabase.tr(),
style: theme.textStyle.heading4.prominent(
color: theme.textColorScheme.primary,
),
),
trailing: [
AFGhostButton.normal(
onTap: () => Navigator.of(context).pop(),
padding: EdgeInsets.all(theme.spacing.xs),
builder: (context, isHovering, disabled) {
return Center(
child: FlowySvg(
FlowySvgs.toast_close_s,
size: Size.square(20),
),
);
},
),
],
),
Flexible(
child: AFModalBody(
child: ExpandablePanel(
controller: expandableController,
theme: ExpandableThemeData(
tapBodyToCollapse: false,
hasIcon: false,
tapBodyToExpand: false,
tapHeaderToExpand: false,
),
header: const _Header(),
collapsed: const SizedBox.shrink(),
expanded: const _Expanded(),
),
),
),
AFModalFooter(
trailing: [
AFOutlinedButton.normal(
onTap: () => Navigator.of(context).pop(),
builder: (context, isHovering, disabled) {
return Text(
LocaleKeys.button_cancel.tr(),
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
);
},
),
AFFilledButton.primary(
onTap: () {
final config = context
.read<AiPromptDatabaseSelectorCubit>()
.state
.maybeMap(
selected: (state) => state.config,
orElse: () => null,
);
Navigator.of(context).pop(config);
},
builder: (context, isHovering, disabled) {
return Text(
LocaleKeys.button_done.tr(),
style: theme.textStyle.body.enhanced(
color: theme.textColorScheme.onFill,
),
);
},
),
],
),
],
),
),
);
}
}
class _Header extends StatefulWidget {
const _Header();
@override
State<_Header> createState() => _HeaderState();
}
class _HeaderState extends State<_Header> {
final popoverController = AFPopoverController();
@override
void dispose() {
popoverController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return BlocBuilder<AiPromptDatabaseSelectorCubit,
AiPromptDatabaseSelectorState>(
builder: (context, state) {
bool showNothing = false;
String? viewName;
state.maybeMap(
empty: (_) {
showNothing = false;
viewName = null;
},
selected: (selectedState) {
showNothing = false;
viewName = selectedState.config.view.nameOrDefault;
},
orElse: () {
showNothing = true;
viewName = null;
},
);
if (showNothing) {
return SizedBox.shrink();
}
return Padding(
padding: EdgeInsets.symmetric(
horizontal: theme.spacing.m,
vertical: theme.spacing.xl,
),
child: Row(
spacing: theme.spacing.s,
children: [
Expanded(
child: Text(
LocaleKeys.ai_customPrompt_selectDatabase.tr(),
style: theme.textStyle.body.standard(
color: theme.textColorScheme.secondary,
),
),
),
Expanded(
child: Center(
child: ViewSelector(
viewSelectorCubit: BlocProvider(
create: (context) => ViewSelectorCubit(
getIgnoreViewType: getIgnoreViewType,
),
),
child: BlocSelector<SpaceBloc, SpaceState,
(List<ViewPB>, ViewPB?)>(
selector: (state) => (state.spaces, state.currentSpace),
builder: (context, state) {
return AFPopover(
controller: popoverController,
decoration: BoxDecoration(
color: theme.surfaceColorScheme.primary,
borderRadius:
BorderRadius.circular(theme.borderRadius.l),
border: Border.all(
color: theme.borderColorScheme.primary,
),
boxShadow: theme.shadow.medium,
),
padding: EdgeInsets.zero,
anchor: AFAnchor(
childAlignment: Alignment.topCenter,
overlayAlignment: Alignment.bottomCenter,
offset: Offset(0, theme.spacing.xs),
),
popover: (context) {
return _PopoverContent(
onSelectViewItem: (item) {
context
.read<AiPromptDatabaseSelectorCubit>()
.selectDatabaseView(item.view.id);
popoverController.hide();
},
);
},
child: AFOutlinedButton.normal(
onTap: () {
context
.read<ViewSelectorCubit>()
.refreshSources(state.$1, state.$2);
popoverController.toggle();
},
builder: (context, isHovering, disabled) {
return Row(
mainAxisSize: MainAxisSize.min,
spacing: theme.spacing.s,
children: [
Flexible(
child: Text(
viewName ??
LocaleKeys
.ai_customPrompt_selectDatabase
.tr(),
style: theme.textStyle.body.enhanced(
color: theme.textColorScheme.primary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
FlowySvg(
FlowySvgs.toolbar_arrow_down_m,
color: theme.iconColorScheme.primary,
size: Size(12, 20),
),
],
);
},
),
);
},
),
),
),
),
],
),
);
},
);
}
IgnoreViewType getIgnoreViewType(ViewSelectorItem item) {
final layout = item.view.layout;
if (layout.isDatabaseView) {
return IgnoreViewType.none;
}
if (layout.isDocumentView) {
return hasDatabaseDescendent(item)
? IgnoreViewType.none
: IgnoreViewType.hide;
}
return IgnoreViewType.hide;
}
bool hasDatabaseDescendent(ViewSelectorItem viewSelectorItem) {
final layout = viewSelectorItem.view.layout;
if (layout == ViewLayoutPB.Chat) {
return false;
}
if (layout.isDatabaseView) {
return true;
}
// document may have children
return viewSelectorItem.children.any(
(child) => hasDatabaseDescendent(child),
);
}
}
class _Expanded extends StatelessWidget {
const _Expanded();
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return BlocBuilder<AiPromptDatabaseSelectorCubit,
AiPromptDatabaseSelectorState>(
builder: (context, state) {
return state.maybeMap(
orElse: () => SizedBox.shrink(),
selected: (selectedState) {
return Padding(
padding: EdgeInsets.all(theme.spacing.m),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
spacing: theme.spacing.m,
children: [
FieldSelector(
title: LocaleKeys.ai_customPrompt_title.tr(),
currentFieldId: selectedState.config.titleFieldId,
isDisabled: true,
fields: selectedState.fields,
onSelect: (id) {},
),
FieldSelector(
title: LocaleKeys.ai_customPrompt_content.tr(),
currentFieldId: selectedState.config.contentFieldId,
fields: selectedState.fields
.where((f) => f.fieldType == FieldType.RichText)
.toList(),
onSelect: (id) {
if (id != null) {
context
.read<AiPromptDatabaseSelectorCubit>()
.selectContentField(id);
}
},
),
FieldSelector(
title: LocaleKeys.ai_customPrompt_example.tr(),
currentFieldId: selectedState.config.exampleFieldId,
isOptional: true,
fields: selectedState.fields
.where((f) => f.fieldType == FieldType.RichText)
.toList(),
onSelect: (id) {
context
.read<AiPromptDatabaseSelectorCubit>()
.selectExampleField(id);
},
),
FieldSelector(
title: LocaleKeys.ai_customPrompt_category.tr(),
currentFieldId: selectedState.config.categoryFieldId,
isOptional: true,
fields: selectedState.fields
.where(
(f) =>
f.fieldType == FieldType.RichText ||
f.fieldType == FieldType.SingleSelect ||
f.fieldType == FieldType.MultiSelect,
)
.toList(),
onSelect: (id) {
context
.read<AiPromptDatabaseSelectorCubit>()
.selectCategoryField(id);
},
),
],
),
);
},
);
},
);
}
}
class _PopoverContent extends StatefulWidget {
const _PopoverContent({
required this.onSelectViewItem,
});
final void Function(ViewSelectorItem item) onSelectViewItem;
@override
State<_PopoverContent> createState() => _PopoverContentState();
}
class _PopoverContentState extends State<_PopoverContent> {
final focusNode = FocusNode();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
focusNode.requestFocus();
});
}
@override
void dispose() {
focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return ConstrainedBox(
constraints: const BoxConstraints.tightFor(
width: 300,
height: 400,
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
VSpace(
theme.spacing.m,
),
Padding(
padding: EdgeInsets.symmetric(
horizontal: theme.spacing.m,
),
child: AFTextField(
focusNode: focusNode,
size: AFTextFieldSize.m,
hintText: LocaleKeys.search_label.tr(),
controller:
context.read<ViewSelectorCubit>().filterTextController,
),
),
VSpace(
theme.spacing.m,
),
AFDivider(),
Expanded(
child: BlocBuilder<ViewSelectorCubit, ViewSelectorState>(
builder: (context, state) {
return ListView(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
children: _buildVisibleSources(context, state).toList(),
);
},
),
),
],
),
);
}
Iterable<Widget> _buildVisibleSources(
BuildContext context,
ViewSelectorState state,
) {
return state.visibleSources.map(
(e) => ViewSelectorTreeItem(
key: ValueKey(
'custom_prompt_database_tree_item_${e.view.id}',
),
viewSelectorItem: e,
level: 0,
isDescendentOfSpace: e.view.isSpace,
isSelectedSection: false,
showCheckbox: false,
onSelected: (item) {
if (item.view.isDocument || item.view.isSpace) {
context.read<ViewSelectorCubit>().toggleIsExpanded(item, false);
return;
}
widget.onSelectViewItem(item);
},
height: 30.0,
),
);
}
}
class _FieldPBWrapper extends Equatable with AFDropDownMenuMixin {
const _FieldPBWrapper(this.field);
final FieldPB field;
@override
String get label => field.name;
@override
List<Object?> get props => [field.id];
}
class FieldSelector extends StatelessWidget {
const FieldSelector({
super.key,
required this.title,
required this.currentFieldId,
this.isDisabled = false,
this.isOptional = false,
this.fields = const [],
required this.onSelect,
});
final String title;
final String? currentFieldId;
final bool isDisabled;
final bool isOptional;
final List<FieldPB> fields;
final void Function(String? id)? onSelect;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final selectedField = fields.firstWhereOrNull(
(field) => field.id == currentFieldId,
);
return Row(
spacing: theme.spacing.s,
children: [
Expanded(
child: Text(
title,
style: theme.textStyle.body.standard(
color: theme.textColorScheme.secondary,
),
),
),
Expanded(
child: AFDropDownMenu<_FieldPBWrapper>(
isDisabled: isDisabled,
items: fields.map((field) => _FieldPBWrapper(field)).toList(),
selectedItems: [
if (selectedField != null) _FieldPBWrapper(selectedField),
],
clearIcon: selectedField == null ||
!fields.contains(selectedField) ||
!isOptional
? null
: MouseRegion(
cursor: SystemMouseCursors.click,
child: GestureDetector(
onTap: () {
onSelect?.call(null);
},
child: FlowySvg(
FlowySvgs.search_clear_m,
size: Size.square(16),
color: theme.iconColorScheme.tertiary,
),
),
),
onSelected: (value) {
if (value == null) {
return;
}
onSelect?.call(value.field.id);
},
dropdownIcon: FlowySvg(
FlowySvgs.toolbar_arrow_down_m,
color: theme.iconColorScheme.primary,
size: Size(12, 20),
),
),
),
],
);
}
}
@@ -1,6 +1,8 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/user/prelude.dart';
import 'package:appflowy/workspace/presentation/widgets/dialog_v2.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -8,6 +10,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'ai_prompt_category_list.dart';
import 'ai_prompt_onboarding.dart';
import 'ai_prompt_preview.dart';
import 'ai_prompt_visible_list.dart';
@@ -15,11 +18,20 @@ Future<AiPrompt?> showAiPromptModal(
BuildContext context, {
required AiPromptSelectorCubit aiPromptSelectorCubit,
}) async {
aiPromptSelectorCubit.loadCustomPrompts();
return showDialog<AiPrompt?>(
context: context,
builder: (context) {
return BlocProvider.value(
value: aiPromptSelectorCubit,
builder: (_) {
return MultiBlocProvider(
providers: [
BlocProvider.value(
value: aiPromptSelectorCubit,
),
BlocProvider.value(
value: context.read<UserWorkspaceBloc>(),
),
],
child: const AiPromptModal(),
);
},
@@ -34,96 +46,127 @@ class AiPromptModal extends StatelessWidget {
final theme = AppFlowyTheme.of(context);
return AFModal(
backgroundColor: theme.backgroundColorScheme.primary,
constraints: const BoxConstraints(
maxWidth: 1200,
maxHeight: 800,
),
child: Column(
children: [
AFModalHeader(
leading: Text(
LocaleKeys.ai_customPrompt_browsePrompts.tr(),
style: theme.textStyle.heading4.prominent(
color: theme.textColorScheme.primary,
child: BlocListener<AiPromptSelectorCubit, AiPromptSelectorState>(
listener: (context, state) {
state.maybeMap(
invalidDatabase: (_) {
showLoadPromptFailedDialog(context);
},
orElse: () {},
);
},
child: Column(
children: [
AFModalHeader(
leading: Text(
LocaleKeys.ai_customPrompt_browsePrompts.tr(),
style: theme.textStyle.heading4.prominent(
color: theme.textColorScheme.primary,
),
),
trailing: [
AFGhostButton.normal(
onTap: () => Navigator.of(context).pop(),
padding: EdgeInsets.all(theme.spacing.xs),
builder: (context, isHovering, disabled) {
return Center(
child: FlowySvg(
FlowySvgs.toast_close_s,
size: Size.square(20),
),
);
},
),
],
),
trailing: [
AFGhostButton.normal(
onTap: () => Navigator.of(context).pop(),
padding: EdgeInsets.all(theme.spacing.s),
builder: (context, isHovering, disabled) {
return Center(
child: FlowySvg(
FlowySvgs.toast_close_s,
size: Size.square(20),
),
);
},
),
],
),
Expanded(
child: AFModalBody(
child: BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
builder: (context, state) {
final theme = AppFlowyTheme.of(context);
return state.map(
loading: (_) {
return const Center(
child: CircularProgressIndicator(),
);
},
ready: (_) {
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Expanded(
child: AiPromptCategoryList(
padding: EdgeInsetsDirectional.only(
top: theme.spacing.l,
end: theme.spacing.l,
),
Expanded(
child: AFModalBody(
child:
BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
builder: (context, state) {
return state.maybeMap(
loading: (_) {
return const Center(
child: CircularProgressIndicator(),
);
},
ready: (readyState) {
return Row(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
const Expanded(
child: AiPromptCategoryList(),
),
),
Expanded(
flex: 2,
child: AiPromptVisibleList(),
),
Expanded(
flex: 3,
child: BlocBuilder<AiPromptSelectorCubit,
AiPromptSelectorState>(
builder: (context, state) {
final selectedPrompt = state.maybeMap(
ready: (state) {
return state.visiblePrompts
.firstWhereOrNull(
(prompt) =>
prompt.id == state.selectedPromptId,
if (readyState.isCustomPromptSectionSelected &&
readyState.databaseConfig == null)
const Expanded(
flex: 5,
child: Center(
child: AiPromptOnboarding(),
),
)
else ...[
const Expanded(
flex: 2,
child: AiPromptVisibleList(),
),
Expanded(
flex: 3,
child: BlocBuilder<AiPromptSelectorCubit,
AiPromptSelectorState>(
builder: (context, state) {
final selectedPrompt = state.maybeMap(
ready: (state) {
return state.visiblePrompts
.firstWhereOrNull(
(prompt) =>
prompt.id ==
state.selectedPromptId,
);
},
orElse: () => null,
);
if (selectedPrompt == null) {
return const SizedBox.shrink();
}
return AiPromptPreview(
prompt: selectedPrompt,
);
},
orElse: () => null,
);
if (selectedPrompt == null) {
return const SizedBox.shrink();
}
return AiPromptPreview(
prompt: selectedPrompt,
);
},
),
),
],
);
},
);
},
),
),
],
],
);
},
orElse: () => const SizedBox.shrink(),
);
},
),
),
),
),
],
],
),
),
);
}
}
void showLoadPromptFailedDialog(
BuildContext context,
) {
showSimpleAFDialog(
context: context,
title: LocaleKeys.ai_customPrompt_invalidDatabase.tr(),
content: LocaleKeys.ai_customPrompt_invalidDatabaseHelp.tr(),
primaryAction: (
LocaleKeys.button_ok.tr(),
(context) {},
),
);
}
@@ -0,0 +1,61 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'ai_prompt_database_modal.dart';
class AiPromptOnboarding extends StatelessWidget {
const AiPromptOnboarding({super.key});
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
LocaleKeys.ai_customPrompt_customPrompt.tr(),
style: theme.textStyle.heading3.standard(
color: theme.textColorScheme.primary,
),
),
VSpace(
theme.spacing.s,
),
Text(
LocaleKeys.ai_customPrompt_databasePrompts.tr(),
style: theme.textStyle.body.standard(
color: theme.textColorScheme.secondary,
),
),
VSpace(
theme.spacing.xxl,
),
AFFilledButton.primary(
onTap: () async {
final config = await changeCustomPromptDatabaseConfig(context);
if (config != null && context.mounted) {
context
.read<AiPromptSelectorCubit>()
.updateCustomPromptDatabaseConfiguration(config);
}
},
builder: (context, isHovering, disabled) {
return Text(
LocaleKeys.ai_customPrompt_selectDatabase.tr(),
style: theme.textStyle.body.enhanced(
color: theme.textColorScheme.onFill,
),
);
},
),
],
);
}
}
@@ -22,10 +22,8 @@ class AiPromptPreview extends StatelessWidget {
child: Column(
children: [
Padding(
padding: EdgeInsets.only(
top: theme.spacing.l,
left: theme.spacing.l,
right: theme.spacing.l,
padding: EdgeInsets.symmetric(
horizontal: theme.spacing.l,
),
child: SelectionContainer.disabled(
child: Row(
@@ -72,7 +70,7 @@ class AiPromptPreview extends StatelessWidget {
if (prompt.example.isNotEmpty) ...[
SelectionContainer.disabled(
child: Text(
LocaleKeys.ai_customPrompt_example.tr(),
LocaleKeys.ai_customPrompt_promptExample.tr(),
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.primary,
),
@@ -108,7 +106,7 @@ class _PromptContent extends StatelessWidget {
return Container(
padding: EdgeInsets.all(theme.spacing.l),
decoration: BoxDecoration(
color: theme.fillColorScheme.quaternary,
color: theme.surfaceContainerColorScheme.layer01,
borderRadius: BorderRadius.circular(theme.borderRadius.m),
),
child: Text.rich(
@@ -132,7 +130,7 @@ class _PromptContent extends StatelessWidget {
spans.add(
TextSpan(
text: part,
style: TextStyle(color: theme.textColorScheme.purple),
style: TextStyle(color: theme.textColorScheme.featured),
),
);
} else {
@@ -178,7 +176,7 @@ class _PromptExample extends StatelessWidget {
return Container(
padding: EdgeInsets.all(theme.spacing.l),
decoration: BoxDecoration(
color: theme.fillColorScheme.quaternary,
color: theme.surfaceContainerColorScheme.layer01,
borderRadius: BorderRadius.circular(theme.borderRadius.m),
),
child: AIMarkdownText(
@@ -4,13 +4,16 @@ import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:diffutil_dart/diffutil.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'ai_prompt_database_modal.dart';
const Duration _listItemAnimationDuration = Duration(milliseconds: 150);
class AiPromptVisibleList extends StatefulWidget {
@@ -35,12 +38,12 @@ class _AiPromptVisibleListState extends State<AiPromptVisibleList> {
super.initState();
cubit = context.read<AiPromptSelectorCubit>();
final textController = cubit.filterTextController;
filterIsEmpty = textController.text.isEmpty;
textController.addListener(handleFilterTextChanged);
final prompts = cubit.state.maybeMap(
ready: (value) => value.visiblePrompts,
orElse: () => <AiPrompt>[],
);
filterIsEmpty = textController.text.isEmpty;
textController.addListener(handleFilterTextChanged);
oldList.addAll(prompts);
}
@@ -53,56 +56,149 @@ class _AiPromptVisibleListState extends State<AiPromptVisibleList> {
@override
Widget build(BuildContext context) {
final spacing = AppFlowyTheme.of(context).spacing;
return BlocListener<AiPromptSelectorCubit, AiPromptSelectorState>(
listener: (context, state) {
final list = state.maybeMap(
ready: (state) => state.visiblePrompts,
orElse: () => <AiPrompt>[],
);
handleVisiblePromptListChanged(list);
},
child: Column(
children: [
VSpace(spacing.l),
Padding(
padding: EdgeInsets.symmetric(horizontal: spacing.l),
child: buildSearchField(context),
),
VSpace(spacing.s),
Expanded(
child: AnimatedList(
controller: scrollController,
padding: EdgeInsets.all(spacing.l),
key: listKey,
initialItemCount: oldList.length,
itemBuilder: (context, index, animation) {
final cubit = context.read<AiPromptSelectorCubit>();
final theme = AppFlowyTheme.of(context);
return cubit.state.maybeMap(
ready: (state) {
final prompt = state.visiblePrompts[index];
return Padding(
padding: EdgeInsets.only(
top: index == 0 ? 0 : spacing.s,
bottom: index == state.visiblePrompts.length - 1
? 0
: spacing.s,
return Column(
children: [
BlocConsumer<AiPromptSelectorCubit, AiPromptSelectorState>(
listener: (context, state) {
state.maybeMap(
ready: (state) {
handleVisiblePromptListChanged(state.visiblePrompts);
},
orElse: () {},
);
},
buildWhen: (p, c) {
return p.maybeMap(
ready: (pr) => c.maybeMap(
ready: (cr) =>
pr.databaseConfig?.view.id != cr.databaseConfig?.view.id ||
pr.isLoadingCustomPrompts != cr.isLoadingCustomPrompts ||
pr.isCustomPromptSectionSelected !=
cr.isCustomPromptSectionSelected,
orElse: () => false,
),
orElse: () => true,
);
},
builder: (context, state) {
return state.maybeMap(
ready: (readyState) {
if (!readyState.isCustomPromptSectionSelected) {
return const SizedBox.shrink();
}
return Container(
margin: EdgeInsets.only(
left: theme.spacing.l,
right: theme.spacing.l,
bottom: theme.spacing.l,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(theme.borderRadius.m),
color: theme.surfaceContainerColorScheme.layer01,
),
padding: EdgeInsets.all(theme.spacing.m),
child: Row(
children: [
Expanded(
child: Text.rich(
TextSpan(
children: [
TextSpan(
text:
"${LocaleKeys.ai_customPrompt_promptDatabase.tr()}: ",
style: TextStyle(fontWeight: FontWeight.w500),
),
TextSpan(
text: readyState
.databaseConfig?.view.nameOrDefault ??
"",
),
],
),
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
child: _AiPromptListItem(
animation: animation,
prompt: prompt,
isSelected: state.selectedPromptId == prompt.id,
ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 150,
),
child: AFOutlinedButton.normal(
builder: (context, isHovering, disabled) {
return Row(
spacing: theme.spacing.s,
mainAxisSize: MainAxisSize.min,
children: [
if (readyState.isLoadingCustomPrompts)
buildLoadingIndicator(theme),
Flexible(
child: Text(
readyState.isLoadingCustomPrompts
? LocaleKeys.ai_customPrompt_loading
.tr()
: LocaleKeys.button_change.tr(),
maxLines: 1,
style: theme.textStyle.body.enhanced(
color: theme.textColorScheme.primary,
),
overflow: TextOverflow.ellipsis,
),
),
],
);
},
onTap: () async {
final newConfig =
await changeCustomPromptDatabaseConfig(
context,
config: readyState.databaseConfig,
);
if (newConfig != null && context.mounted) {
context
.read<AiPromptSelectorCubit>()
.updateCustomPromptDatabaseConfiguration(
newConfig,
);
}
},
),
),
);
],
),
);
},
orElse: () => const SizedBox.shrink(),
);
},
),
Padding(
padding: EdgeInsets.symmetric(horizontal: theme.spacing.l),
child: buildSearchField(context),
),
Expanded(
child: TextFieldTapRegion(
groupId: "ai_prompt_category_list",
child: BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
builder: (context, state) {
return state.maybeMap(
ready: (readyState) {
if (readyState.visiblePrompts.isEmpty) {
return buildEmptyPrompts();
}
return buildPromptList();
},
orElse: () => const SizedBox.shrink(),
);
},
),
),
],
),
),
],
);
}
@@ -130,10 +226,13 @@ class _AiPromptVisibleListState extends State<AiPromptVisibleList> {
.read<AiPromptSelectorCubit>()
.filterTextController
.clear(),
child: FlowySvg(
FlowySvgs.toast_close_s,
color: theme.textColorScheme.secondary,
size: const Size.square(20),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: FlowySvg(
FlowySvgs.search_clear_m,
color: theme.iconColorScheme.tertiary,
size: const Size.square(20),
),
),
),
),
@@ -141,6 +240,81 @@ class _AiPromptVisibleListState extends State<AiPromptVisibleList> {
);
}
Widget buildEmptyPrompts() {
final theme = AppFlowyTheme.of(context);
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
FlowySvg(
FlowySvgs.m_home_search_icon_m,
color: theme.iconColorScheme.secondary,
size: Size.square(24),
),
VSpace(theme.spacing.m),
Text(
LocaleKeys.ai_customPrompt_noResults.tr(),
style: theme.textStyle.body
.standard(color: theme.textColorScheme.secondary),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
Widget buildPromptList() {
final theme = AppFlowyTheme.of(context);
return AnimatedList(
controller: scrollController,
padding: EdgeInsets.all(theme.spacing.l),
key: listKey,
initialItemCount: oldList.length,
itemBuilder: (context, index, animation) {
return BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
builder: (context, state) {
return state.maybeMap(
ready: (state) {
final prompt = state.visiblePrompts[index];
return Padding(
padding: EdgeInsets.only(
top: index == 0 ? 0 : theme.spacing.s,
bottom: index == state.visiblePrompts.length - 1
? 0
: theme.spacing.s,
),
child: _AiPromptListItem(
animation: animation,
prompt: prompt,
isSelected: state.selectedPromptId == prompt.id,
),
);
},
orElse: () => const SizedBox.shrink(),
);
},
);
},
);
}
Widget buildLoadingIndicator(AppFlowyThemeData theme) {
return SizedBox.square(
dimension: 20,
child: Padding(
padding: EdgeInsets.all(2.5),
child: CircularProgressIndicator(
color: theme.iconColorScheme.tertiary,
strokeWidth: 2.0,
),
),
);
}
void handleVisiblePromptListChanged(
List<AiPrompt> newList,
) {
@@ -258,16 +432,16 @@ class _AiPromptListItemState extends State<_AiPromptListItem> {
padding: EdgeInsets.all(theme.spacing.m),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(theme.borderRadius.m),
color: Colors.transparent,
border: Border.all(
color: widget.isSelected
? isHovering
? theme.borderColorScheme.themeThickHover
: theme.borderColorScheme.themeThick
: isHovering
? theme.borderColorScheme.greyTertiaryHover
: theme.borderColorScheme.greyTertiary,
? theme.borderColorScheme.primaryHover
: theme.borderColorScheme.primary,
),
color: theme.surfaceColorScheme.primary,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -4,6 +4,7 @@ import 'package:appflowy/plugins/ai_chat/application/chat_user_cubit.dart';
import 'package:appflowy/plugins/ai_chat/presentation/layout_define.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flowy_infra/file_picker/file_picker_service.dart';
@@ -14,6 +15,13 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'browse_prompts_button.dart';
typedef OnPromptInputSubmitted = void Function(
String input,
PredefinedFormat? predefinedFormat,
Map<String, dynamic> metadata,
String? promptId,
);
class DesktopPromptInput extends StatefulWidget {
const DesktopPromptInput({
super.key,
@@ -31,8 +39,7 @@ class DesktopPromptInput extends StatefulWidget {
final bool isStreaming;
final AiPromptInputTextEditingController textController;
final void Function() onStopStreaming;
final void Function(String, PredefinedFormat?, Map<String, dynamic>)
onSubmitted;
final OnPromptInputSubmitted onSubmitted;
final ValueNotifier<List<String>> selectedSourcesNotifier;
final void Function(List<String>) onUpdateSelectedSources;
final bool hideDecoration;
@@ -57,7 +64,6 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
@override
void initState() {
super.initState();
chatUserCubit.fetchUserProfile();
widget.textController.addListener(handleTextControllerChanged);
focusNode
@@ -77,6 +83,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
WidgetsBinding.instance.addPostFrameCallback((_) {
focusNode.requestFocus();
checkForAskingAI();
});
}
@@ -228,6 +235,28 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
);
}
void checkForAskingAI() {
final paletteBloc = context.read<CommandPaletteBloc?>(),
paletteState = paletteBloc?.state;
if (paletteBloc == null || paletteState == null) return;
final isAskingAI = paletteState.askAI;
if (!isAskingAI) return;
paletteBloc.add(CommandPaletteEvent.askedAI());
final query = paletteState.query ?? '';
if (query.isEmpty) return;
final sources = (paletteState.askAISources ?? []).map((e) => e.id).toList();
final metadata =
context.read<AIPromptInputBloc?>()?.consumeMetadata() ?? {};
final promptBloc = context.read<AIPromptInputBloc?>();
final promptId = promptBloc?.promptId;
final promptState = promptBloc?.state;
final predefinedFormat = promptState?.predefinedFormat;
if (sources.isNotEmpty) {
widget.onUpdateSelectedSources(sources);
}
widget.onSubmitted.call(query, predefinedFormat, metadata, promptId ?? '');
}
void startMentionPageFromButton() {
if (overlayController.isShowing) {
return;
@@ -287,6 +316,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
userInput,
showPredefinedFormats ? predefinedFormat : null,
metadata,
bloc.promptId,
);
}
@@ -505,9 +535,9 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
void handleOnSelectPrompt(AiPrompt prompt) {
final bloc = context.read<AIPromptInputBloc>();
bloc.add(
AIPromptInputEvent.updateMentionedViews([]),
);
bloc
..add(AIPromptInputEvent.updateMentionedViews([]))
..add(AIPromptInputEvent.updatePromptId(prompt.id));
final content = AiPromptInputTextEditingController.replace(prompt.content);
@@ -56,9 +56,9 @@ class AiPromptInputTextEditingController extends TextEditingController {
TextSpan(
text: restore(string),
style: theme.textStyle.body.standard().copyWith(
color: theme.textColorScheme.purple,
color: theme.textColorScheme.featured,
backgroundColor:
theme.fillColorScheme.purpleThick.withAlpha(51),
theme.fillColorScheme.featuredThick.withAlpha(51),
),
),
);
@@ -1,13 +1,17 @@
import 'dart:async';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/base/flowy_search_text_field.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
import 'package:appflowy/ai/service/view_selector_cubit.dart';
import 'package:appflowy/plugins/base/drag_handler.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -32,7 +36,18 @@ class PromptInputMobileSelectSourcesButton extends StatefulWidget {
class _PromptInputMobileSelectSourcesButtonState
extends State<PromptInputMobileSelectSourcesButton> {
late final cubit = ChatSettingsCubit();
late final cubit = ViewSelectorCubit(
maxSelectedParentPageCount: 3,
getIgnoreViewType: (item) {
if (item.view.isSpace) {
return IgnoreViewType.none;
}
if (item.view.layout != ViewLayoutPB.Document) {
return IgnoreViewType.hide;
}
return IgnoreViewType.none;
},
);
@override
void initState() {
@@ -52,6 +67,8 @@ class _PromptInputMobileSelectSourcesButtonState
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return BlocBuilder<UserWorkspaceBloc, UserWorkspaceState>(
builder: (context, state) {
final userProfile = context.read<UserWorkspaceBloc>().state.userProfile;
@@ -90,12 +107,15 @@ class _PromptInputMobileSelectSourcesButtonState
],
),
onTap: () async {
context
.read<ChatSettingsCubit>()
.refreshSources(state.spaces, state.currentSpace);
unawaited(
context
.read<ViewSelectorCubit>()
.refreshSources(state.spaces, state.currentSpace),
);
await showMobileBottomSheet<void>(
context,
backgroundColor: Theme.of(context).colorScheme.surface,
backgroundColor: theme.surfaceColorScheme.primary,
maxChildSize: 0.98,
enableDraggableScrollable: true,
scrollableWidgetBuilder: (_, scrollController) {
@@ -129,39 +149,24 @@ class _PromptInputMobileSelectSourcesButtonState
}
}
class _MobileSelectSourcesSheetBody extends StatefulWidget {
class _MobileSelectSourcesSheetBody extends StatelessWidget {
const _MobileSelectSourcesSheetBody({
required this.scrollController,
});
final ScrollController scrollController;
@override
State<_MobileSelectSourcesSheetBody> createState() =>
_MobileSelectSourcesSheetBodyState();
}
class _MobileSelectSourcesSheetBodyState
extends State<_MobileSelectSourcesSheetBody> {
final textController = TextEditingController();
@override
void dispose() {
textController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return CustomScrollView(
controller: widget.scrollController,
controller: scrollController,
shrinkWrap: true,
slivers: [
SliverPersistentHeader(
pinned: true,
delegate: _Header(
child: ColoredBox(
color: Theme.of(context).cardColor,
color: AppFlowyTheme.of(context).surfaceColorScheme.primary,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -183,10 +188,9 @@ class _MobileSelectSourcesSheetBodyState
child: SizedBox(
height: 44.0,
child: FlowySearchTextField(
controller: textController,
onChanged: (value) => context
.read<ChatSettingsCubit>()
.updateFilter(value),
controller: context
.read<ViewSelectorCubit>()
.filterTextController,
),
),
),
@@ -196,27 +200,25 @@ class _MobileSelectSourcesSheetBodyState
),
),
),
BlocBuilder<ChatSettingsCubit, ChatSettingsState>(
BlocBuilder<ViewSelectorCubit, ViewSelectorState>(
builder: (context, state) {
final sources = state.visibleSources
.where((e) => e.ignoreStatus != IgnoreViewType.hide);
return SliverList(
delegate: SliverChildBuilderDelegate(
childCount: sources.length,
childCount: state.visibleSources.length,
(context, index) {
final source = sources.elementAt(index);
return ChatSourceTreeItem(
final source = state.visibleSources.elementAt(index);
return ViewSelectorTreeItem(
key: ValueKey(
'visible_select_sources_tree_item_${source.view.id}',
),
chatSource: source,
viewSelectorItem: source,
level: 0,
isDescendentOfSpace: source.view.isSpace,
isSelectedSection: false,
onSelected: (chatSource) {
onSelected: (item) {
context
.read<ChatSettingsCubit>()
.toggleSelectedStatus(chatSource);
.read<ViewSelectorCubit>()
.toggleSelectedStatus(item, false);
},
height: 40.0,
);
@@ -2,15 +2,13 @@ import 'dart:math';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
import 'package:appflowy/plugins/document/application/document_bloc.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/sidebar/space/shared_widget.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@@ -18,6 +16,8 @@ import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import '../../service/view_selector_cubit.dart';
import '../view_selector.dart';
import 'layout_define.dart';
import 'mention_page_menu.dart';
@@ -38,7 +38,24 @@ class PromptInputDesktopSelectSourcesButton extends StatefulWidget {
class _PromptInputDesktopSelectSourcesButtonState
extends State<PromptInputDesktopSelectSourcesButton> {
late final cubit = ChatSettingsCubit();
late final cubit = ViewSelectorCubit(
maxSelectedParentPageCount: 3,
getIgnoreViewType: (item) {
final view = item.view;
if (view.isSpace) {
return IgnoreViewType.none;
}
if (view.layout == ViewLayoutPB.Chat) {
return IgnoreViewType.hide;
}
if (view.layout != ViewLayoutPB.Document) {
return IgnoreViewType.disable;
}
return IgnoreViewType.none;
},
);
final popoverController = PopoverController();
@override
@@ -59,23 +76,10 @@ class _PromptInputDesktopSelectSourcesButtonState
@override
Widget build(BuildContext context) {
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
final userProfile = userWorkspaceBloc.state.userProfile;
final workspaceId =
userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? '';
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => SpaceBloc(
userProfile: userProfile,
workspaceId: workspaceId,
)..add(const SpaceEvent.initial(openFirstPage: false)),
),
BlocProvider.value(
value: cubit,
),
],
return ViewSelector(
viewSelectorCubit: BlocProvider.value(
value: cubit,
),
child: BlocBuilder<SpaceBloc, SpaceState>(
builder: (context, state) {
return AppFlowyPopover(
@@ -86,18 +90,18 @@ class _PromptInputDesktopSelectSourcesButtonState
controller: popoverController,
onOpen: () {
context
.read<ChatSettingsCubit>()
.read<ViewSelectorCubit>()
.refreshSources(state.spaces, state.currentSpace);
},
onClose: () {
widget.onUpdateSelectedSources(cubit.selectedSourceIds);
context
.read<ChatSettingsCubit>()
.read<ViewSelectorCubit>()
.refreshSources(state.spaces, state.currentSpace);
},
popupBuilder: (_) {
return BlocProvider.value(
value: context.read<ChatSettingsCubit>(),
value: context.read<ViewSelectorCubit>(),
child: const _PopoverContent(),
);
},
@@ -186,20 +190,26 @@ class _PopoverContent extends StatelessWidget {
@override
Widget build(BuildContext context) {
return BlocBuilder<ChatSettingsCubit, ChatSettingsState>(
return BlocBuilder<ViewSelectorCubit, ViewSelectorState>(
builder: (context, state) {
final theme = AppFlowyTheme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(8, 12, 8, 8),
child: SpaceSearchField(
width: 600,
onSearch: (context, value) =>
context.read<ChatSettingsCubit>().updateFilter(value),
child: AFTextField(
size: AFTextFieldSize.m,
controller:
context.read<ViewSelectorCubit>().filterTextController,
hintText: LocaleKeys.search_label.tr(),
),
),
_buildDivider(),
AFDivider(
startIndent: theme.spacing.l,
endIndent: theme.spacing.l,
),
Flexible(
child: ListView(
shrinkWrap: true,
@@ -208,9 +218,10 @@ class _PopoverContent extends StatelessWidget {
..._buildSelectedSources(context, state),
if (state.selectedSources.isNotEmpty &&
state.visibleSources.isNotEmpty)
Padding(
padding: const EdgeInsets.symmetric(vertical: 4.0),
child: _buildDivider(),
AFDivider(
spacing: 4.0,
startIndent: theme.spacing.l,
endIndent: theme.spacing.l,
),
..._buildVisibleSources(context, state),
],
@@ -222,70 +233,53 @@ class _PopoverContent extends StatelessWidget {
);
}
Widget _buildDivider() {
return const Divider(
height: 1.0,
thickness: 1.0,
indent: 8.0,
endIndent: 8.0,
);
}
Iterable<Widget> _buildSelectedSources(
BuildContext context,
ChatSettingsState state,
ViewSelectorState state,
) {
return state.selectedSources
.where((e) => e.ignoreStatus != IgnoreViewType.hide)
.map(
(e) => ChatSourceTreeItem(
key: ValueKey(
'selected_select_sources_tree_item_${e.view.id}',
),
chatSource: e,
level: 0,
isDescendentOfSpace: e.view.isSpace,
isSelectedSection: true,
onSelected: (chatSource) {
context
.read<ChatSettingsCubit>()
.toggleSelectedStatus(chatSource);
},
height: 30.0,
),
);
return state.selectedSources.map(
(e) => ViewSelectorTreeItem(
key: ValueKey(
'selected_select_sources_tree_item_${e.view.id}',
),
viewSelectorItem: e,
level: 0,
isDescendentOfSpace: e.view.isSpace,
isSelectedSection: true,
onSelected: (item) {
context.read<ViewSelectorCubit>().toggleSelectedStatus(item, true);
},
height: 30.0,
),
);
}
Iterable<Widget> _buildVisibleSources(
BuildContext context,
ChatSettingsState state,
ViewSelectorState state,
) {
return state.visibleSources
.where((e) => e.ignoreStatus != IgnoreViewType.hide)
.map(
(e) => ChatSourceTreeItem(
key: ValueKey(
'visible_select_sources_tree_item_${e.view.id}',
),
chatSource: e,
level: 0,
isDescendentOfSpace: e.view.isSpace,
isSelectedSection: false,
onSelected: (chatSource) {
context
.read<ChatSettingsCubit>()
.toggleSelectedStatus(chatSource);
},
height: 30.0,
),
);
return state.visibleSources.map(
(e) => ViewSelectorTreeItem(
key: ValueKey(
'visible_select_sources_tree_item_${e.view.id}',
),
viewSelectorItem: e,
level: 0,
isDescendentOfSpace: e.view.isSpace,
isSelectedSection: false,
onSelected: (item) {
context.read<ViewSelectorCubit>().toggleSelectedStatus(item, false);
},
height: 30.0,
),
);
}
}
class ChatSourceTreeItem extends StatefulWidget {
const ChatSourceTreeItem({
class ViewSelectorTreeItem extends StatefulWidget {
const ViewSelectorTreeItem({
super.key,
required this.chatSource,
required this.viewSelectorItem,
required this.level,
required this.isDescendentOfSpace,
required this.isSelectedSection,
@@ -296,7 +290,7 @@ class ChatSourceTreeItem extends StatefulWidget {
this.showCheckbox = true,
});
final ChatSource chatSource;
final ViewSelectorItem viewSelectorItem;
/// nested level of the view item
final int level;
@@ -305,9 +299,9 @@ class ChatSourceTreeItem extends StatefulWidget {
final bool isSelectedSection;
final void Function(ChatSource chatSource) onSelected;
final void Function(ViewSelectorItem viewSelectorItem) onSelected;
final void Function(ChatSource chatSource)? onAdd;
final void Function(ViewSelectorItem viewSelectorItem)? onAdd;
final bool showSaveButton;
@@ -316,16 +310,16 @@ class ChatSourceTreeItem extends StatefulWidget {
final bool showCheckbox;
@override
State<ChatSourceTreeItem> createState() => _ChatSourceTreeItemState();
State<ViewSelectorTreeItem> createState() => _ViewSelectorTreeItemState();
}
class _ChatSourceTreeItemState extends State<ChatSourceTreeItem> {
class _ViewSelectorTreeItemState extends State<ViewSelectorTreeItem> {
@override
Widget build(BuildContext context) {
final child = SizedBox(
height: widget.height,
child: ChatSourceTreeItemInner(
chatSource: widget.chatSource,
child: ViewSelectorTreeItemInner(
viewSelectorItem: widget.viewSelectorItem,
level: widget.level,
isDescendentOfSpace: widget.isDescendentOfSpace,
isSelectedSection: widget.isSelectedSection,
@@ -336,33 +330,30 @@ class _ChatSourceTreeItemState extends State<ChatSourceTreeItem> {
),
);
final disabledEnabledChild =
widget.chatSource.ignoreStatus == IgnoreViewType.disable
? FlowyTooltip(
message: widget.showCheckbox
? switch (widget.chatSource.view.layout) {
ViewLayoutPB.Document =>
LocaleKeys.chat_sourcesLimitReached.tr(),
_ => LocaleKeys.chat_sourceUnsupported.tr(),
}
: "",
child: Opacity(
opacity: 0.5,
child: MouseRegion(
cursor: SystemMouseCursors.forbidden,
child: IgnorePointer(child: child),
),
),
)
: child;
final disabledEnabledChild = widget.viewSelectorItem.isDisabled
? FlowyTooltip(
message: widget.showCheckbox
? switch (widget.viewSelectorItem.view.layout) {
ViewLayoutPB.Document =>
LocaleKeys.chat_sourcesLimitReached.tr(),
_ => LocaleKeys.chat_sourceUnsupported.tr(),
}
: "",
child: Opacity(
opacity: 0.5,
child: MouseRegion(
cursor: SystemMouseCursors.forbidden,
child: IgnorePointer(child: child),
),
),
)
: child;
return ValueListenableBuilder(
valueListenable: widget.chatSource.isExpandedNotifier,
valueListenable: widget.viewSelectorItem.isExpandedNotifier,
builder: (context, isExpanded, child) {
// filter the child views that should be ignored
final childViews = widget.chatSource.children
.where((e) => e.ignoreStatus != IgnoreViewType.hide)
.toList();
final childViews = widget.viewSelectorItem.children;
if (!isExpanded || childViews.isEmpty) {
return disabledEnabledChild;
@@ -373,11 +364,11 @@ class _ChatSourceTreeItemState extends State<ChatSourceTreeItem> {
children: [
disabledEnabledChild,
...childViews.map(
(childSource) => ChatSourceTreeItem(
(childSource) => ViewSelectorTreeItem(
key: ValueKey(
'select_sources_tree_item_${childSource.view.id}',
),
chatSource: childSource,
viewSelectorItem: childSource,
level: widget.level + 1,
isDescendentOfSpace: widget.isDescendentOfSpace,
isSelectedSection: widget.isSelectedSection,
@@ -395,10 +386,10 @@ class _ChatSourceTreeItemState extends State<ChatSourceTreeItem> {
}
}
class ChatSourceTreeItemInner extends StatelessWidget {
const ChatSourceTreeItemInner({
class ViewSelectorTreeItemInner extends StatelessWidget {
const ViewSelectorTreeItemInner({
super.key,
required this.chatSource,
required this.viewSelectorItem,
required this.level,
required this.isDescendentOfSpace,
required this.isSelectedSection,
@@ -408,34 +399,29 @@ class ChatSourceTreeItemInner extends StatelessWidget {
this.onAdd,
});
final ChatSource chatSource;
final ViewSelectorItem viewSelectorItem;
final int level;
final bool isDescendentOfSpace;
final bool isSelectedSection;
final bool showCheckbox;
final bool showSaveButton;
final void Function(ChatSource)? onSelected;
final void Function(ChatSource)? onAdd;
final void Function(ViewSelectorItem)? onSelected;
final void Function(ViewSelectorItem)? onAdd;
@override
Widget build(BuildContext context) {
return GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {
if (!isSelectedSection) {
onSelected?.call(chatSource);
}
},
onTap: () => onSelected?.call(viewSelectorItem),
child: FlowyHover(
cursor: isSelectedSection ? SystemMouseCursors.basic : null,
style: HoverStyle(
hoverColor: isSelectedSection
? Colors.transparent
: AFThemeExtension.of(context).lightGreyHover,
hoverColor: AFThemeExtension.of(context).lightGreyHover,
),
builder: (context, onHover) {
final theme = AppFlowyTheme.of(context);
final isSaveButtonVisible =
showSaveButton && !chatSource.view.isSpace;
showSaveButton && !viewSelectorItem.view.isSpace;
final isAddButtonVisible = onAdd != null;
return Row(
children: [
@@ -443,29 +429,30 @@ class ChatSourceTreeItemInner extends StatelessWidget {
HSpace(max(20.0 * level - (isDescendentOfSpace ? 2 : 0), 0)),
// builds the >, ^ or · button
ToggleIsExpandedButton(
chatSource: chatSource,
viewSelectorItem: viewSelectorItem,
isSelectedSection: isSelectedSection,
),
const HSpace(2.0),
// checkbox
if (!chatSource.view.isSpace && showCheckbox) ...[
if (!viewSelectorItem.view.isSpace && showCheckbox) ...[
SourceSelectedStatusCheckbox(
chatSource: chatSource,
viewSelectorItem: viewSelectorItem,
),
const HSpace(4.0),
],
// icon
MentionViewIcon(
view: chatSource.view,
view: viewSelectorItem.view,
),
const HSpace(6.0),
// title
Expanded(
child: FlowyText(
chatSource.view.nameOrDefault,
child: Text(
viewSelectorItem.view.nameOrDefault,
overflow: TextOverflow.ellipsis,
fontSize: 14.0,
figmaLineHeight: 18.0,
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
),
),
if (onHover && (isSaveButtonVisible || isAddButtonVisible)) ...[
@@ -479,7 +466,7 @@ class ChatSourceTreeItemInner extends StatelessWidget {
size: const Size.square(16),
color: Theme.of(context).hintColor,
),
onPressed: () => onSelected?.call(chatSource),
onPressed: () => onSelected?.call(viewSelectorItem),
),
if (isSaveButtonVisible && isAddButtonVisible)
const HSpace(4.0),
@@ -492,7 +479,7 @@ class ChatSourceTreeItemInner extends StatelessWidget {
size: const Size.square(16),
color: Theme.of(context).hintColor,
),
onPressed: () => onAdd?.call(chatSource),
onPressed: () => onAdd?.call(viewSelectorItem),
),
const HSpace(4.0),
],
@@ -507,27 +494,30 @@ class ChatSourceTreeItemInner extends StatelessWidget {
class ToggleIsExpandedButton extends StatelessWidget {
const ToggleIsExpandedButton({
super.key,
required this.chatSource,
required this.viewSelectorItem,
required this.isSelectedSection,
});
final ChatSource chatSource;
final ViewSelectorItem viewSelectorItem;
final bool isSelectedSection;
@override
Widget build(BuildContext context) {
if (isReferencedDatabaseView(chatSource.view, chatSource.parentView)) {
if (isReferencedDatabaseView(
viewSelectorItem.view,
viewSelectorItem.parentView,
)) {
return const _DotIconWidget();
}
if (chatSource.children.isEmpty) {
if (viewSelectorItem.children.isEmpty) {
return const SizedBox.square(dimension: 16.0);
}
return FlowyHover(
child: GestureDetector(
child: ValueListenableBuilder(
valueListenable: chatSource.isExpandedNotifier,
valueListenable: viewSelectorItem.isExpandedNotifier,
builder: (context, value, _) => FlowySvg(
value
? FlowySvgs.view_item_expand_s
@@ -536,8 +526,8 @@ class ToggleIsExpandedButton extends StatelessWidget {
),
),
onTap: () => context
.read<ChatSettingsCubit>()
.toggleIsExpanded(chatSource, isSelectedSection),
.read<ViewSelectorCubit>()
.toggleIsExpanded(viewSelectorItem, isSelectedSection),
),
);
}
@@ -565,20 +555,20 @@ class _DotIconWidget extends StatelessWidget {
class SourceSelectedStatusCheckbox extends StatelessWidget {
const SourceSelectedStatusCheckbox({
super.key,
required this.chatSource,
required this.viewSelectorItem,
});
final ChatSource chatSource;
final ViewSelectorItem viewSelectorItem;
@override
Widget build(BuildContext context) {
return ValueListenableBuilder(
valueListenable: chatSource.selectedStatusNotifier,
valueListenable: viewSelectorItem.selectedStatusNotifier,
builder: (context, selectedStatus, _) => FlowySvg(
switch (selectedStatus) {
SourceSelectedStatus.unselected => FlowySvgs.uncheck_s,
SourceSelectedStatus.selected => FlowySvgs.check_filled_s,
SourceSelectedStatus.partiallySelected => FlowySvgs.check_partial_s,
ViewSelectedStatus.unselected => FlowySvgs.uncheck_s,
ViewSelectedStatus.selected => FlowySvgs.check_filled_s,
ViewSelectedStatus.partiallySelected => FlowySvgs.check_partial_s,
},
size: const Size.square(18.0),
blendMode: null,
@@ -0,0 +1,39 @@
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/user/prelude.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:provider/single_child_widget.dart';
class ViewSelector extends StatelessWidget {
const ViewSelector({
super.key,
required this.viewSelectorCubit,
required this.child,
});
final SingleChildWidget viewSelectorCubit;
final Widget child;
@override
Widget build(BuildContext context) {
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
final userProfile = userWorkspaceBloc.state.userProfile;
final workspaceId =
userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? '';
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) {
return SpaceBloc(
userProfile: userProfile,
workspaceId: workspaceId,
)..add(const SpaceEvent.initial(openFirstPage: false));
},
),
viewSelectorCubit,
],
child: child,
);
}
}
@@ -0,0 +1,4 @@
export 'share_access_level.dart';
export 'share_popover_group_id.dart';
export 'share_role.dart';
export 'shared_user.dart';
@@ -0,0 +1,30 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:easy_localization/easy_localization.dart';
/// The access level a user can have on a shared page.
enum ShareAccessLevel {
/// Can view the page only.
readOnly,
/// Can read and comment on the page.
readAndComment,
/// Can read and write to the page.
readAndWrite,
/// Full access (edit, share, remove, etc.) and can add new users.
fullAccess;
String get i18n {
switch (this) {
case ShareAccessLevel.readOnly:
return LocaleKeys.shareTab_accessLevel_view.tr();
case ShareAccessLevel.readAndComment:
return LocaleKeys.shareTab_accessLevel_comment.tr();
case ShareAccessLevel.readAndWrite:
return LocaleKeys.shareTab_accessLevel_edit.tr();
case ShareAccessLevel.fullAccess:
return LocaleKeys.shareTab_accessLevel_fullAccess.tr();
}
}
}
@@ -0,0 +1 @@
class SharePopoverGroupId {}
@@ -0,0 +1,10 @@
enum ShareRole {
/// The owner of the shared page.
owner,
/// A guest on the shared page.
guest,
/// A member of the shared page.
member,
}
@@ -0,0 +1,47 @@
import 'package:appflowy/features/share_tab/data/models/share_access_level.dart';
import 'package:appflowy/features/share_tab/data/models/share_role.dart';
typedef SharedUsers = List<SharedUser>;
/// Represents a user with a role on a shared page.
class SharedUser {
SharedUser({
required this.email,
required this.name,
required this.role,
required this.accessLevel,
this.avatarUrl,
});
final String email;
/// The name of the user.
final String name;
/// The role of the user.
final ShareRole role;
/// The access level of the user.
final ShareAccessLevel accessLevel;
/// The avatar URL of the user.
///
/// if the avatar is not set, it will be the first letter of the name.
final String? avatarUrl;
SharedUser copyWith({
String? email,
String? name,
ShareRole? role,
ShareAccessLevel? accessLevel,
String? avatarUrl,
}) {
return SharedUser(
email: email ?? this.email,
name: name ?? this.name,
role: role ?? this.role,
accessLevel: accessLevel ?? this.accessLevel,
avatarUrl: avatarUrl ?? this.avatarUrl,
);
}
}
@@ -0,0 +1,159 @@
import 'dart:math';
import 'package:appflowy/features/share_tab/data/models/models.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'share_with_user_repository.dart';
class LocalShareWithUserRepository extends ShareWithUserRepository {
LocalShareWithUserRepository();
final SharedUsers _sharedUsers = [
SharedUser(
email: 'lucas.xu@appflowy.io',
name: 'Lucas Xu',
accessLevel: ShareAccessLevel.readOnly,
role: ShareRole.guest,
avatarUrl: 'https://avatar.iran.liara.run/public',
),
SharedUser(
email: 'vivian@appflowy.io',
name: 'Vivian Wang',
accessLevel: ShareAccessLevel.readAndWrite,
role: ShareRole.member,
avatarUrl: 'https://avatar.iran.liara.run/public/girl',
),
SharedUser(
email: 'shuheng@appflowy.io',
name: 'Shuheng',
accessLevel: ShareAccessLevel.fullAccess,
role: ShareRole.owner,
avatarUrl: 'https://avatar.iran.liara.run/public/boy',
),
SharedUser(
email: 'guest_user_1@appflowy.io',
name: 'Guest User 1',
accessLevel: ShareAccessLevel.readOnly,
role: ShareRole.guest,
avatarUrl: 'https://avatar.iran.liara.run/public/boy/10',
),
SharedUser(
email: 'guest_user_2@appflowy.io',
name: 'Guest User 2',
accessLevel: ShareAccessLevel.readOnly,
role: ShareRole.owner,
avatarUrl: 'https://avatar.iran.liara.run/public/boy/11',
),
SharedUser(
email: 'member_user_1@appflowy.io',
name: 'Member User 1',
accessLevel: ShareAccessLevel.readAndWrite,
role: ShareRole.member,
avatarUrl: 'https://avatar.iran.liara.run/public/boy/12',
),
SharedUser(
email: 'member_user_2@appflowy.io',
name: 'Member User 2',
accessLevel: ShareAccessLevel.readAndWrite,
role: ShareRole.member,
avatarUrl: 'https://avatar.iran.liara.run/public/boy/13',
),
];
final SharedUsers _availableSharedUsers = [
SharedUser(
email: 'guest_email@appflowy.io',
name: 'Guest',
accessLevel: ShareAccessLevel.readOnly,
role: ShareRole.guest,
avatarUrl: 'https://avatar.iran.liara.run/public/boy/28',
),
SharedUser(
email: 'richard@appflowy.io',
name: 'Richard',
accessLevel: ShareAccessLevel.readAndWrite,
role: ShareRole.member,
avatarUrl: 'https://avatar.iran.liara.run/public/boy/28',
),
];
@override
Future<FlowyResult<SharedUsers, FlowyError>> getSharedUsersInPage({
required String pageId,
}) async {
return FlowySuccess(_sharedUsers);
}
@override
Future<FlowyResult<void, FlowyError>> removeSharedUserFromPage({
required String pageId,
required List<String> emails,
}) async {
for (final email in emails) {
_sharedUsers.removeWhere((user) => user.email == email);
}
return FlowySuccess(null);
}
@override
Future<FlowyResult<void, FlowyError>> sharePageWithUser({
required String pageId,
required ShareAccessLevel accessLevel,
required List<String> emails,
}) async {
for (final email in emails) {
final index = _sharedUsers.indexWhere((user) => user.email == email);
if (index != -1) {
// Update access level if user exists
final user = _sharedUsers[index];
_sharedUsers[index] = SharedUser(
name: user.name,
email: user.email,
accessLevel: accessLevel,
role: user.role,
avatarUrl: user.avatarUrl,
);
} else {
// Add new user
_sharedUsers.add(
SharedUser(
name: email.split('@').first,
email: email,
accessLevel: accessLevel,
role: ShareRole.guest,
avatarUrl:
'https://avatar.iran.liara.run/public/${Random().nextInt(100)}',
),
);
}
}
return FlowySuccess(null);
}
@override
Future<FlowyResult<SharedUsers, FlowyError>> getAvailableSharedUsers({
required String pageId,
}) async {
return FlowySuccess([
..._sharedUsers,
..._availableSharedUsers,
]);
}
@override
Future<FlowyResult<void, FlowyError>> changeRole({
required String pageId,
required String email,
required ShareRole role,
}) async {
final index = _sharedUsers.indexWhere((user) => user.email == email);
if (index != -1) {
_sharedUsers[index] = _sharedUsers[index].copyWith(role: role);
}
return FlowySuccess(null);
}
}
@@ -0,0 +1,126 @@
import 'package:appflowy/features/share_tab/data/models/models.dart';
import 'package:appflowy/features/util/extensions.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'share_with_user_repository.dart';
class RustShareWithUserRepository extends ShareWithUserRepository {
RustShareWithUserRepository();
@override
Future<FlowyResult<SharedUsers, FlowyError>> getSharedUsersInPage({
required String pageId,
}) async {
final request = GetSharedUsersPayloadPB(
viewId: pageId,
);
final result = await FolderEventGetSharedUsers(request).send();
return result.fold(
(success) {
Log.info('get shared users: $success');
return FlowySuccess(success.sharedUsers);
},
(failure) {
Log.error('getUsersInSharedPage: $failure');
return FlowyFailure(failure);
},
);
}
@override
Future<FlowyResult<void, FlowyError>> removeSharedUserFromPage({
required String pageId,
required List<String> emails,
}) async {
final request = RemoveUserFromSharedPagePayloadPB(
viewId: pageId,
emails: emails,
);
final result = await FolderEventRemoveUserFromSharedPage(request).send();
return result.fold(
(success) {
Log.info('remove users($emails) from shared page($pageId)');
return FlowySuccess(success);
},
(failure) {
Log.error('removeUserFromPage: $failure');
return FlowyFailure(failure);
},
);
}
@override
Future<FlowyResult<void, FlowyError>> sharePageWithUser({
required String pageId,
required ShareAccessLevel accessLevel,
required List<String> emails,
}) async {
final request = SharePageWithUserPayloadPB(
viewId: pageId,
emails: emails,
accessLevel: accessLevel.accessLevel,
);
final result = await FolderEventSharePageWithUser(request).send();
return result.fold(
(success) {
Log.info(
'share page($pageId) with users($emails) with access level($accessLevel)',
);
return FlowySuccess(success);
},
(failure) {
Log.error('sharePageWithUser: $failure');
return FlowyFailure(failure);
},
);
}
@override
Future<FlowyResult<SharedUsers, FlowyError>> getAvailableSharedUsers({
required String pageId,
}) async {
// TODO: Implement this
return FlowySuccess([]);
}
@override
Future<FlowyResult<void, FlowyError>> changeRole({
required String pageId,
required String email,
required ShareRole role,
}) async {
final request = UpdateWorkspaceMemberPB(
workspaceId: pageId,
email: email,
role: role.userRole,
);
final result = await UserEventUpdateWorkspaceMember(request).send();
return result.fold(
(success) {
Log.info('change role($role) for user($email) in page($pageId)');
return FlowySuccess(success);
},
(failure) {
Log.error(
'failed to change role($role) for user($email) in page($pageId)',
failure,
);
return FlowyFailure(failure);
},
);
}
}
@@ -0,0 +1,41 @@
import 'package:appflowy/features/share_tab/data/models/models.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
/// Abstract repository for sharing with users.
///
/// For example, we're using rust events now, but we can still use the http api
/// for the future.
abstract class ShareWithUserRepository {
/// Gets the list of users and their roles for a shared page.
Future<FlowyResult<SharedUsers, FlowyError>> getSharedUsersInPage({
required String pageId,
});
/// Gets the list of users that are available to be shared with.
Future<FlowyResult<SharedUsers, FlowyError>> getAvailableSharedUsers({
required String pageId,
});
/// Removes a user from a shared page.
Future<FlowyResult<void, FlowyError>> removeSharedUserFromPage({
required String pageId,
required List<String> emails,
});
/// Shares a page with a user, assigning a role.
///
/// If the user is already in the shared page, the access level will be updated.
Future<FlowyResult<void, FlowyError>> sharePageWithUser({
required String pageId,
required ShareAccessLevel accessLevel,
required List<String> emails,
});
/// Change the role of a user in a shared page.
Future<FlowyResult<void, FlowyError>> changeRole({
required String pageId,
required String email,
required ShareRole role,
});
}
@@ -0,0 +1,402 @@
import 'package:appflowy/features/share_tab/data/models/models.dart';
import 'package:appflowy/features/share_tab/data/repositories/share_with_user_repository.dart';
import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_paste/clipboard_service.dart';
import 'package:appflowy/plugins/shared/share/constants.dart';
import 'package:appflowy/shared/feature_flags.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:bloc/bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'share_with_user_bloc.freezed.dart';
class ShareWithUserBloc extends Bloc<ShareWithUserEvent, ShareWithUserState> {
ShareWithUserBloc({
required this.repository,
required this.pageId,
required this.workspaceId,
}) : super(ShareWithUserState.initial()) {
on<Initial>(_onInitial);
on<GetSharedUsers>(_onGetSharedUsers);
on<ShareWithUser>(_onShare);
on<RemoveUser>(_onRemove);
on<UpdateAccessLevel>(_onUpdateAccessLevel);
on<UpdateGeneralAccess>(_onUpdateGeneralAccess);
on<CopyLink>(_onCopyLink);
on<SearchAvailableUsers>(_onSearchAvailableUsers);
on<TurnIntoMember>(_onTurnIntoMember);
}
final ShareWithUserRepository repository;
final String workspaceId;
final String pageId;
Future<void> _onInitial(
Initial event,
Emitter<ShareWithUserState> emit,
) async {
if (!FeatureFlag.sharedSection.isOn) {
return;
}
final result = await UserBackendService.getCurrentUserProfile();
final currentUser = result.fold(
(user) => user,
(error) => null,
);
final shareLink = ShareConstants.buildShareUrl(
workspaceId: workspaceId,
viewId: pageId,
);
final users = await _getLatestSharedUsersOrCurrentUsers();
emit(
state.copyWith(
currentUser: currentUser,
shareLink: shareLink,
users: users,
),
);
}
Future<void> _onGetSharedUsers(
GetSharedUsers event,
Emitter<ShareWithUserState> emit,
) async {
if (!FeatureFlag.sharedSection.isOn) {
return;
}
emit(
state.copyWith(
errorMessage: '',
initialResult: null,
shareResult: null,
removeResult: null,
updateAccessLevelResult: null,
turnIntoMemberResult: null,
),
);
final result = await repository.getSharedUsersInPage(
pageId: pageId,
);
result.fold(
(users) => emit(
state.copyWith(
users: users,
initialResult: FlowySuccess(null),
),
),
(error) => emit(
state.copyWith(
errorMessage: error.msg,
initialResult: FlowyFailure(error),
),
),
);
}
Future<void> _onShare(
ShareWithUser event,
Emitter<ShareWithUserState> emit,
) async {
emit(
state.copyWith(
errorMessage: '',
shareResult: null,
turnIntoMemberResult: null,
removeResult: null,
updateAccessLevelResult: null,
),
);
final result = await repository.sharePageWithUser(
pageId: pageId,
accessLevel: event.accessLevel,
emails: event.emails,
);
await result.fold(
(_) async {
final users = await _getLatestSharedUsersOrCurrentUsers();
emit(
state.copyWith(
shareResult: FlowySuccess(null),
users: users,
),
);
},
(error) async {
emit(
state.copyWith(
errorMessage: error.msg,
shareResult: FlowyFailure(error),
),
);
},
);
}
Future<void> _onRemove(
RemoveUser event,
Emitter<ShareWithUserState> emit,
) async {
emit(
state.copyWith(
errorMessage: '',
removeResult: null,
shareResult: null,
updateAccessLevelResult: null,
turnIntoMemberResult: null,
),
);
final result = await repository.removeSharedUserFromPage(
pageId: pageId,
emails: event.emails,
);
await result.fold(
(_) async {
final users = await _getLatestSharedUsersOrCurrentUsers();
emit(
state.copyWith(
removeResult: FlowySuccess(null),
users: users,
),
);
},
(error) async {
emit(
state.copyWith(
isLoading: false,
removeResult: FlowyFailure(error),
),
);
},
);
}
Future<void> _onUpdateAccessLevel(
UpdateAccessLevel event,
Emitter<ShareWithUserState> emit,
) async {
emit(
state.copyWith(
updateAccessLevelResult: null,
),
);
final result = await repository.sharePageWithUser(
pageId: pageId,
accessLevel: event.accessLevel,
emails: [event.email],
);
await result.fold(
(_) async {
final users = await _getLatestSharedUsersOrCurrentUsers();
emit(
state.copyWith(
updateAccessLevelResult: FlowySuccess(null),
users: users,
),
);
},
(error) async {
emit(
state.copyWith(
errorMessage: error.msg,
isLoading: false,
),
);
},
);
}
void _onUpdateGeneralAccess(
UpdateGeneralAccess event,
Emitter<ShareWithUserState> emit,
) {
emit(
state.copyWith(
generalAccessRole: event.accessLevel,
),
);
}
void _onCopyLink(CopyLink event, Emitter<ShareWithUserState> emit) {
getIt<ClipboardService>().setData(
ClipboardServiceData(
plainText: event.link,
),
);
emit(
state.copyWith(
linkCopied: true,
),
);
}
Future<void> _onSearchAvailableUsers(
SearchAvailableUsers event,
Emitter<ShareWithUserState> emit,
) async {
emit(
state.copyWith(
errorMessage: '',
),
);
final result = await repository.getAvailableSharedUsers(pageId: pageId);
result.fold(
(users) {
// filter by email and name
final availableUsers = users.where((user) {
final query = event.query.toLowerCase();
return user.name.toLowerCase().contains(query) ||
user.email.toLowerCase().contains(query);
}).toList();
emit(
state.copyWith(
availableUsers: availableUsers,
),
);
},
(error) => emit(
state.copyWith(
errorMessage: error.msg,
availableUsers: [],
),
),
);
}
Future<void> _onTurnIntoMember(
TurnIntoMember event,
Emitter<ShareWithUserState> emit,
) async {
emit(
state.copyWith(
turnIntoMemberResult: null,
errorMessage: '',
removeResult: null,
shareResult: null,
updateAccessLevelResult: null,
),
);
final result = await repository.changeRole(
pageId: pageId,
email: event.email,
role: ShareRole.member,
);
await result.fold(
(_) async {
final users = await _getLatestSharedUsersOrCurrentUsers();
emit(
state.copyWith(
turnIntoMemberResult: FlowySuccess(null),
users: users,
),
);
},
(error) async {
emit(
state.copyWith(
errorMessage: error.msg,
turnIntoMemberResult: FlowyFailure(error),
),
);
},
);
}
Future<SharedUsers> _getLatestSharedUsersOrCurrentUsers() async {
final shareResult = await repository.getSharedUsersInPage(
pageId: pageId,
);
return shareResult.fold(
(users) => users,
(error) => state.users,
);
}
}
@freezed
class ShareWithUserEvent with _$ShareWithUserEvent {
/// Initializes the bloc.
const factory ShareWithUserEvent.init() = Initial;
/// Loads the shared users for the page.
const factory ShareWithUserEvent.getSharedUsers() = GetSharedUsers;
/// Invites the users to the page.
const factory ShareWithUserEvent.share({
required List<String> emails,
required ShareAccessLevel accessLevel,
}) = ShareWithUser;
/// Removes the users from the page.
const factory ShareWithUserEvent.remove({
required List<String> emails,
}) = RemoveUser;
/// Updates the access level of the user.
const factory ShareWithUserEvent.updateAccessLevel({
required String email,
required ShareAccessLevel accessLevel,
}) = UpdateAccessLevel;
/// Updates the general access role for all users.
const factory ShareWithUserEvent.updateGeneralAccess({
required ShareAccessLevel accessLevel,
}) = UpdateGeneralAccess;
/// Copies the link to the clipboard.
const factory ShareWithUserEvent.copyLink({
required String link,
}) = CopyLink;
/// Searches available users by name or email.
const factory ShareWithUserEvent.searchAvailableUsers({
required String query,
}) = SearchAvailableUsers;
/// Turns the user into a member.
const factory ShareWithUserEvent.turnIntoMember({
required String email,
}) = TurnIntoMember;
}
@freezed
class ShareWithUserState with _$ShareWithUserState {
const factory ShareWithUserState({
@Default(null) UserProfilePB? currentUser,
@Default([]) SharedUsers users,
@Default([]) SharedUsers availableUsers,
@Default(false) bool isLoading,
@Default('') String errorMessage,
@Default('') String shareLink,
ShareAccessLevel? generalAccessRole,
@Default(false) bool linkCopied,
@Default(null) FlowyResult<void, FlowyError>? initialResult,
@Default(null) FlowyResult<void, FlowyError>? shareResult,
@Default(null) FlowyResult<void, FlowyError>? removeResult,
@Default(null) FlowyResult<void, FlowyError>? updateAccessLevelResult,
@Default(null) FlowyResult<void, FlowyError>? turnIntoMemberResult,
}) = _ShareWithUserState;
factory ShareWithUserState.initial() => const ShareWithUserState();
}
@@ -0,0 +1,172 @@
import 'package:appflowy/features/share_tab/data/models/share_access_level.dart';
import 'package:appflowy/features/share_tab/logic/share_with_user_bloc.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/copy_link_widget.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/people_with_access_section.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/share_with_user_widget.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class ShareTab extends StatefulWidget {
const ShareTab({
super.key,
required this.workspaceId,
required this.pageId,
});
final String workspaceId;
final String pageId;
@override
State<ShareTab> createState() => _ShareTabState();
}
class _ShareTabState extends State<ShareTab> {
final TextEditingController controller = TextEditingController();
@override
void dispose() {
controller.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return BlocConsumer<ShareWithUserBloc, ShareWithUserState>(
listener: (context, state) {
_onListenShareWithUserState(context, state);
},
builder: (context, state) {
if (state.isLoading) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// share page with user by email
VSpace(theme.spacing.l),
ShareWithUserWidget(
controller: controller,
onInvite: (emails) => _onSharePageWithUser(
context,
emails: emails,
accessLevel: ShareAccessLevel.readOnly,
),
),
// shared users
if (state.users.isNotEmpty) ...[
VSpace(theme.spacing.l),
PeopleWithAccessSection(
currentUserEmail: state.currentUser?.email ?? '',
users: state.users,
callbacks: _buildPeopleWithAccessSectionCallbacks(context),
),
],
// general access
// enable it when the backend support general access features.
// VSpace(theme.spacing.m),
// GeneralAccessSection(),
// copy link
VSpace(theme.spacing.l),
const AFDivider(),
VSpace(theme.spacing.xl),
CopyLinkWidget(shareLink: state.shareLink),
VSpace(theme.spacing.m),
],
);
},
);
}
void _onSharePageWithUser(
BuildContext context, {
required List<String> emails,
required ShareAccessLevel accessLevel,
}) {
context.read<ShareWithUserBloc>().add(
ShareWithUserEvent.share(emails: emails, accessLevel: accessLevel),
);
}
PeopleWithAccessSectionCallbacks _buildPeopleWithAccessSectionCallbacks(
BuildContext context,
) {
return PeopleWithAccessSectionCallbacks(
onSelectAccessLevel: (user, accessLevel) {
// do nothing. the event doesn't support in the backend yet
},
onTurnIntoMember: (user) {
context.read<ShareWithUserBloc>().add(
ShareWithUserEvent.turnIntoMember(email: user.email),
);
},
onRemoveAccess: (user) {
context.read<ShareWithUserBloc>().add(
ShareWithUserEvent.remove(emails: [user.email]),
);
},
);
}
void _onListenShareWithUserState(
BuildContext context,
ShareWithUserState state,
) {
final shareResult = state.shareResult;
if (shareResult != null) {
shareResult.fold((success) {
// clear the controller to avoid showing the previous emails
controller.clear();
showToastNotification(
message: 'Invitation sent',
);
}, (error) {
// TODO: handle the limiation error
showToastNotification(
message: error.msg,
type: ToastificationType.error,
);
});
}
final removeResult = state.removeResult;
if (removeResult != null) {
removeResult.fold((success) {
showToastNotification(
message: 'Removed guest successfully',
);
}, (error) {
showToastNotification(
message: error.msg,
type: ToastificationType.error,
);
});
}
final updateAccessLevelResult = state.updateAccessLevelResult;
if (updateAccessLevelResult != null) {
updateAccessLevelResult.fold((success) {
showToastNotification(
message: 'Updated access level successfully',
);
}, (error) {
showToastNotification(
message: error.msg,
type: ToastificationType.error,
);
});
}
}
}
@@ -0,0 +1,141 @@
import 'package:appflowy/features/share_tab/data/models/models.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
class AccessLevelListCallbacks {
const AccessLevelListCallbacks({
required this.onSelectAccessLevel,
required this.onTurnIntoMember,
required this.onRemoveAccess,
});
factory AccessLevelListCallbacks.none() {
return AccessLevelListCallbacks(
onSelectAccessLevel: (_) {},
onTurnIntoMember: () {},
onRemoveAccess: () {},
);
}
/// Callback when an access level is selected
final void Function(ShareAccessLevel accessLevel) onSelectAccessLevel;
/// Callback when the "Turn into Member" option is selected
final VoidCallback onTurnIntoMember;
/// Callback when the "Remove access" option is selected
final VoidCallback onRemoveAccess;
/// Copy
AccessLevelListCallbacks copyWith({
VoidCallback? onRemoveAccess,
VoidCallback? onTurnIntoMember,
void Function(ShareAccessLevel accessLevel)? onSelectAccessLevel,
}) {
return AccessLevelListCallbacks(
onRemoveAccess: onRemoveAccess ?? this.onRemoveAccess,
onTurnIntoMember: onTurnIntoMember ?? this.onTurnIntoMember,
onSelectAccessLevel: onSelectAccessLevel ?? this.onSelectAccessLevel,
);
}
}
/// A widget that displays a list of access levels for sharing.
///
/// This widget is used in a popover to allow users to select different access levels
/// for shared content, as well as options to turn users into members or remove access.
class AccessLevelListWidget extends StatelessWidget {
const AccessLevelListWidget({
super.key,
this.enableAccessLevelSelection = false,
required this.selectedAccessLevel,
required this.callbacks,
});
/// Enable access level selection
final bool enableAccessLevelSelection;
/// The currently selected access level
final ShareAccessLevel selectedAccessLevel;
/// Callbacks
final AccessLevelListCallbacks callbacks;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return AFMenu(
width: enableAccessLevelSelection ? 240 : 160,
children: [
// Display all available access level options
if (enableAccessLevelSelection) ...[
_buildAccessLevelItem(
context,
accessLevel: ShareAccessLevel.fullAccess,
onTap: () =>
callbacks.onSelectAccessLevel(ShareAccessLevel.fullAccess),
),
_buildAccessLevelItem(
context,
accessLevel: ShareAccessLevel.readAndWrite,
onTap: () =>
callbacks.onSelectAccessLevel(ShareAccessLevel.readAndWrite),
),
_buildAccessLevelItem(
context,
accessLevel: ShareAccessLevel.readAndComment,
onTap: () =>
callbacks.onSelectAccessLevel(ShareAccessLevel.readAndComment),
),
_buildAccessLevelItem(
context,
accessLevel: ShareAccessLevel.readOnly,
onTap: () =>
callbacks.onSelectAccessLevel(ShareAccessLevel.readOnly),
),
AFDivider(spacing: theme.spacing.m),
],
// Additional user management options
AFTextMenuItem(
title: LocaleKeys.shareTab_turnIntoMember.tr(),
onTap: callbacks.onTurnIntoMember,
),
AFTextMenuItem(
title: LocaleKeys.shareTab_removeAccess.tr(),
titleColor: theme.textColorScheme.error,
onTap: callbacks.onRemoveAccess,
),
],
);
}
/// Builds an individual access level menu item
///
/// @param context The build context
/// @param accessLevel The access level to display
/// @param onTap Callback when this item is tapped
/// @return A menu item widget for the specified access level
Widget _buildAccessLevelItem(
BuildContext context, {
required ShareAccessLevel accessLevel,
required VoidCallback onTap,
}) {
return AFTextMenuItem(
title: accessLevel.i18n,
showSelectedBackground: false,
selected: selectedAccessLevel == accessLevel,
// Show a checkmark icon for the currently selected access level
trailing: selectedAccessLevel == accessLevel
? FlowySvg(
FlowySvgs.m_blue_check_s,
blendMode: null,
)
: null,
onTap: onTap,
);
}
}
@@ -0,0 +1,55 @@
import 'package:appflowy/features/share_tab/logic/share_with_user_bloc.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class CopyLinkWidget extends StatelessWidget {
const CopyLinkWidget({
super.key,
required this.shareLink,
});
final String shareLink;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Row(
children: [
Expanded(
child: AFTextField(
initialText: shareLink,
size: AFTextFieldSize.m,
readOnly: true,
),
),
HSpace(theme.spacing.s),
AFOutlinedIconTextButton.normal(
text: LocaleKeys.shareTab_copyLink.tr(),
size: AFButtonSize.l,
padding: EdgeInsets.symmetric(
horizontal: theme.spacing.l,
vertical: theme.spacing.s,
),
iconBuilder: (context, isHovering, disabled) => FlowySvg(
FlowySvgs.toolbar_link_m,
),
onTap: () {
context.read<ShareWithUserBloc>().add(
ShareWithUserEvent.copyLink(link: shareLink),
);
showToastNotification(
message: LocaleKeys.shareTab_copiedLinkToClipboard.tr(),
);
},
),
],
);
}
}
@@ -0,0 +1,95 @@
import 'package:appflowy/features/share_tab/data/models/models.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/access_level_list_widget.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
class EditAccessLevelWidget extends StatefulWidget {
const EditAccessLevelWidget({
super.key,
required this.callbacks,
required this.selectedAccessLevel,
this.disabled = false,
});
/// Callbacks
final AccessLevelListCallbacks callbacks;
/// The currently selected access level
final ShareAccessLevel selectedAccessLevel;
/// Whether the widget is disabled
final bool disabled;
@override
State<EditAccessLevelWidget> createState() => _EditAccessLevelWidgetState();
}
class _EditAccessLevelWidgetState extends State<EditAccessLevelWidget> {
final popoverController = AFPopoverController();
@override
void dispose() {
popoverController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return AFPopover(
padding: EdgeInsets.zero,
decoration: BoxDecoration(), // the access level widget has a border
controller: popoverController,
popover: (_) {
return AccessLevelListWidget(
selectedAccessLevel: widget.selectedAccessLevel,
callbacks: widget.callbacks.copyWith(
onSelectAccessLevel: (accessLevel) {
widget.callbacks.onSelectAccessLevel(accessLevel);
popoverController.hide();
},
onRemoveAccess: () {
widget.callbacks.onRemoveAccess();
popoverController.hide();
},
),
);
},
child: AFGhostButton.normal(
disabled: widget.disabled,
onTap: () {
popoverController.show();
},
padding: EdgeInsets.symmetric(
vertical: theme.spacing.s,
horizontal: theme.spacing.l,
),
builder: (context, isHovering, disabled) {
return Row(
children: [
Text(
widget.selectedAccessLevel.i18n,
style: theme.textStyle.body.standard(
color: disabled
? theme.textColorScheme.secondary
: theme.textColorScheme.primary,
),
),
HSpace(theme.spacing.xs),
FlowySvg(
FlowySvgs.arrow_down_s,
color: theme.textColorScheme.secondary,
),
],
);
},
),
);
}
}
@@ -0,0 +1,21 @@
import 'package:appflowy/features/share_tab/presentation/widgets/shared_group_widget.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flutter/material.dart';
class GeneralAccessSection extends StatelessWidget {
const GeneralAccessSection({super.key});
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return AFMenuSection(
title: 'General access',
padding: EdgeInsets.symmetric(
vertical: theme.spacing.s,
),
children: [
SharedGroupWidget(),
],
);
}
}
@@ -0,0 +1,79 @@
import 'package:appflowy/features/share_tab/data/models/models.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/access_level_list_widget.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/shared_user_widget.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
class PeopleWithAccessSectionCallbacks {
const PeopleWithAccessSectionCallbacks({
required this.onRemoveAccess,
required this.onTurnIntoMember,
required this.onSelectAccessLevel,
});
factory PeopleWithAccessSectionCallbacks.none() {
return PeopleWithAccessSectionCallbacks(
onSelectAccessLevel: (_, __) {},
onTurnIntoMember: (_) {},
onRemoveAccess: (_) {},
);
}
/// Callback when an access level is selected
final void Function(SharedUser user, ShareAccessLevel accessLevel)
onSelectAccessLevel;
/// Callback when the "Turn into Member" option is selected
final void Function(SharedUser user) onTurnIntoMember;
/// Callback when the "Remove access" option is selected
final void Function(SharedUser user) onRemoveAccess;
}
class PeopleWithAccessSection extends StatelessWidget {
const PeopleWithAccessSection({
super.key,
required this.currentUserEmail,
required this.users,
this.callbacks,
});
final String currentUserEmail;
final SharedUsers users;
final PeopleWithAccessSectionCallbacks? callbacks;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final currentUser = users.firstWhereOrNull(
(user) => user.email == currentUserEmail,
);
return AFMenuSection(
title: 'People with access',
constraints: BoxConstraints(
maxHeight: 240,
),
padding: EdgeInsets.symmetric(
vertical: theme.spacing.xs,
),
children: users.map((user) {
if (currentUser == null) {
return const SizedBox.shrink();
}
return SharedUserWidget(
user: user,
currentUser: currentUser,
callbacks: AccessLevelListCallbacks(
onRemoveAccess: () => callbacks?.onRemoveAccess.call(user),
onTurnIntoMember: () => callbacks?.onTurnIntoMember.call(user),
onSelectAccessLevel: (accessLevel) =>
callbacks?.onSelectAccessLevel.call(user, accessLevel),
),
);
}).toList(),
);
}
}
@@ -0,0 +1,74 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:string_validator/string_validator.dart';
class ShareWithUserWidget extends StatefulWidget {
const ShareWithUserWidget({
super.key,
required this.onInvite,
this.controller,
});
final void Function(List<String> emails) onInvite;
final TextEditingController? controller;
@override
State<ShareWithUserWidget> createState() => _ShareWithUserWidgetState();
}
class _ShareWithUserWidgetState extends State<ShareWithUserWidget> {
late final TextEditingController effectiveController;
bool isButtonEnabled = false;
@override
void initState() {
super.initState();
effectiveController = widget.controller ?? TextEditingController();
effectiveController.addListener(_onTextChanged);
}
@override
void dispose() {
if (widget.controller == null) {
effectiveController.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Row(
children: [
Expanded(
child: AFTextField(
controller: effectiveController,
size: AFTextFieldSize.m,
hintText: LocaleKeys.shareTab_inviteByEmail.tr(),
),
),
HSpace(theme.spacing.s),
AFFilledTextButton.primary(
text: LocaleKeys.shareTab_invite.tr(),
disabled: !isButtonEnabled,
onTap: () {
widget.onInvite(effectiveController.text.trim().split(','));
},
),
],
);
}
void _onTextChanged() {
setState(() {
final texts = effectiveController.text.trim().split(',');
isButtonEnabled = texts.isNotEmpty && texts.every(isEmail);
});
}
}
@@ -0,0 +1,81 @@
import 'package:appflowy/features/share_tab/data/models/share_access_level.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/access_level_list_widget.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/edit_access_level_widget.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
class SharedGroupWidget extends StatelessWidget {
const SharedGroupWidget({super.key});
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return AFMenuItem(
padding: EdgeInsets.symmetric(
vertical: theme.spacing.s,
),
leading: _buildLeading(context),
title: _buildTitle(context),
subtitle: _buildSubtitle(context),
trailing: _buildTrailing(context),
);
}
Widget _buildLeading(BuildContext context) {
return AFAvatar(
child: Padding(
padding: const EdgeInsets.all(3.0),
child: FlowySvg(
FlowySvgs.app_logo_s, // replace it with group avatar
blendMode: null,
),
),
);
}
Widget _buildTitle(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Row(
children: [
Flexible(
child: Text(
LocaleKeys.shareTab_anyoneAtWorkspace.tr(),
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
overflow: TextOverflow.ellipsis,
),
),
HSpace(theme.spacing.xs),
FlowySvg(
FlowySvgs.arrow_down_s,
color: theme.textColorScheme.secondary,
),
],
);
}
Widget _buildSubtitle(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Text(
LocaleKeys.shareTab_anyoneInGroupWithLinkCanEdit.tr(),
textAlign: TextAlign.left,
style: theme.textStyle.caption.standard(
color: theme.textColorScheme.secondary,
),
);
}
Widget _buildTrailing(BuildContext context) {
return EditAccessLevelWidget(
disabled: true,
selectedAccessLevel: ShareAccessLevel.fullAccess,
callbacks: AccessLevelListCallbacks.none(),
);
}
}
@@ -0,0 +1,112 @@
import 'package:appflowy/features/share_tab/data/models/models.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/access_level_list_widget.dart';
import 'package:appflowy/features/share_tab/presentation/widgets/edit_access_level_widget.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
/// Widget to display a single shared user row as per the UI design, using AFMenuItem.
class SharedUserWidget extends StatelessWidget {
const SharedUserWidget({
super.key,
required this.user,
required this.currentUser,
this.callbacks,
});
final SharedUser user;
final SharedUser currentUser;
final AccessLevelListCallbacks? callbacks;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return AFMenuItem(
padding: EdgeInsets.symmetric(
vertical: theme.spacing.s,
),
leading: AFAvatar(
name: user.name,
url: user.avatarUrl,
),
title: _buildTitle(context),
subtitle: _buildSubtitle(context),
trailing: _buildTrailing(context),
);
}
Widget _buildTitle(
BuildContext context,
) {
final theme = AppFlowyTheme.of(context);
final isCurrentUser = user.email == currentUser.email;
return Row(
children: [
Flexible(
child: Text(
user.name,
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
overflow: TextOverflow.ellipsis,
),
),
// if the user is the current user, show '(You)'
if (isCurrentUser) ...[
HSpace(theme.spacing.xs),
Text(
LocaleKeys.shareTab_you.tr(),
style: theme.textStyle.body.standard(
color: theme.textColorScheme.secondary,
),
),
],
// if the user is a guest, show 'Guest'
if (user.role == ShareRole.guest) ...[
HSpace(theme.spacing.xs),
Text(
LocaleKeys.shareTab_guest.tr(),
style: theme.textStyle.body.standard(
color: theme.textColorScheme.warning,
),
),
],
],
);
}
Widget _buildSubtitle(
BuildContext context,
) {
final theme = AppFlowyTheme.of(context);
return Text(
user.email,
style: theme.textStyle.caption.standard(
color: theme.textColorScheme.secondary,
),
);
}
Widget _buildTrailing(
BuildContext context,
) {
final isCurrentUser = user.email == currentUser.email;
final theme = AppFlowyTheme.of(context);
// The current guest user can't edit the access level of the other user
return isCurrentUser || currentUser.role == ShareRole.guest
? AFGhostTextButton.disabled(
text: user.accessLevel.i18n,
textStyle: theme.textStyle.body.standard(
color: theme.textColorScheme.secondary,
),
)
: EditAccessLevelWidget(
selectedAccessLevel: user.accessLevel,
callbacks: callbacks ?? AccessLevelListCallbacks.none(),
);
}
}
@@ -0,0 +1,33 @@
import 'package:appflowy/features/share_tab/data/models/share_access_level.dart';
import 'package:appflowy/features/shared_section/data/share_pages_repository.dart';
import 'package:appflowy/features/shared_section/models/shared_page.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
class LocalSharePagesRepository implements SharePagesRepository {
@override
Future<FlowyResult<SharedPages, FlowyError>> getSharedPages() async {
final pages = [
SharedPage(
view: ViewPB()
..id = '1'
..name = 'Welcome Page',
accessLevel: ShareAccessLevel.fullAccess,
),
SharedPage(
view: ViewPB()
..id = '2'
..name = 'Project Plan',
accessLevel: ShareAccessLevel.readAndWrite,
),
SharedPage(
view: ViewPB()
..id = '3'
..name = 'Readme',
accessLevel: ShareAccessLevel.readOnly,
),
];
return FlowyResult.success(pages);
}
}
@@ -0,0 +1,28 @@
import 'package:appflowy/features/shared_section/data/share_pages_repository.dart';
import 'package:appflowy/features/shared_section/models/shared_page.dart';
import 'package:appflowy/features/util/extensions.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
class RustSharePagesRepository implements SharePagesRepository {
@override
Future<FlowyResult<SharedPages, FlowyError>> getSharedPages() async {
final result = await FolderEventGetSharedViews().send();
return result.fold(
(success) {
final sharedPages = success.sharedPages;
Log.info('get shared pages success, len: ${sharedPages.length}');
return FlowyResult.success(sharedPages);
},
(error) {
Log.error('failed to get shared pages, error: $error');
return FlowyResult.failure(error);
},
);
}
}
@@ -0,0 +1,12 @@
import 'package:appflowy/features/shared_section/models/shared_page.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
/// Abstract repository for sharing pages with users.
///
/// For example, we're using rust events now, but we can still use the http api
/// for the future.
abstract class SharePagesRepository {
/// Gets the list of users and their roles for a shared page.
Future<FlowyResult<SharedPages, FlowyError>> getSharedPages();
}
@@ -0,0 +1,186 @@
import 'dart:async';
import 'package:appflowy/core/notification/folder_notification.dart';
import 'package:appflowy/features/shared_section/data/share_pages_repository.dart';
import 'package:appflowy/features/shared_section/models/shared_page.dart';
import 'package:appflowy/features/util/extensions.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:bloc/bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'shared_section_bloc.freezed.dart';
class SharedSectionBloc extends Bloc<SharedSectionEvent, SharedSectionState> {
SharedSectionBloc({
required this.repository,
required this.workspaceId,
this.enablePolling = false,
this.pollingIntervalSeconds = 30,
}) : super(SharedSectionState.initial()) {
on<_Init>(_onInit);
on<_Refresh>(_onRefresh);
on<_UpdateSharedPages>(_onUpdateSharedPages);
}
final String workspaceId;
// The repository to fetch the shared views.
// If you need to test this bloc, you can add your own repository implementation.
final SharePagesRepository repository;
// Used to listen for shared view updates.
late final FolderNotificationListener _folderNotificationListener;
// Since the backend doesn't provide a way to listen for shared view updates (websocket with shared view updates is not implemented yet),
// we need to poll the shared views periodically.
final bool enablePolling;
// The interval of polling the shared views.
final int pollingIntervalSeconds;
Timer? _pollingTimer;
@override
Future<void> close() async {
await _folderNotificationListener.stop();
_pollingTimer?.cancel();
await super.close();
}
Future<void> _onInit(
_Init event,
Emitter<SharedSectionState> emit,
) async {
_initFolderNotificationListener();
_startPollingIfNeeded();
emit(
state.copyWith(
isLoading: true,
errorMessage: '',
),
);
final result = await repository.getSharedPages();
result.fold(
(pages) {
emit(
state.copyWith(
sharedPages: pages,
isLoading: false,
),
);
},
(error) {
emit(
state.copyWith(
errorMessage: error.msg,
isLoading: false,
),
);
},
);
}
Future<void> _onRefresh(
_Refresh event,
Emitter<SharedSectionState> emit,
) async {
final result = await repository.getSharedPages();
result.fold(
(pages) {
emit(
state.copyWith(
sharedPages: pages,
),
);
},
(error) {
emit(
state.copyWith(
errorMessage: error.msg,
),
);
},
);
}
void _onUpdateSharedPages(
_UpdateSharedPages event,
Emitter<SharedSectionState> emit,
) {
emit(
state.copyWith(
sharedPages: event.sharedPages,
),
);
}
void _initFolderNotificationListener() {
_folderNotificationListener = FolderNotificationListener(
objectId: workspaceId,
handler: (notification, result) {
if (notification == FolderNotification.DidUpdateSharedViews) {
final response = result.fold(
(payload) {
final repeatedSharedViews =
RepeatedSharedViewResponsePB.fromBuffer(payload);
return repeatedSharedViews;
},
(error) => null,
);
if (response != null) {
add(
SharedSectionEvent.updateSharedPages(
sharedPages: response.sharedPages,
),
);
}
}
},
);
}
void _startPollingIfNeeded() {
_pollingTimer?.cancel();
if (enablePolling && pollingIntervalSeconds > 0) {
_pollingTimer = Timer.periodic(
Duration(seconds: pollingIntervalSeconds),
(_) {
add(const SharedSectionEvent.refresh());
Log.debug('Polling shared views');
},
);
}
}
}
@freezed
class SharedSectionEvent with _$SharedSectionEvent {
/// Initialize, it will create a folder notification listener to listen for shared view updates.
/// Also, it will fetch the shared pages from the repository.
const factory SharedSectionEvent.init() = _Init;
/// Refresh, it will re-fetch the shared pages from the repository.
const factory SharedSectionEvent.refresh() = _Refresh;
/// Update the shared pages in the state.
const factory SharedSectionEvent.updateSharedPages({
required SharedPages sharedPages,
}) = _UpdateSharedPages;
}
@freezed
class SharedSectionState with _$SharedSectionState {
const factory SharedSectionState({
@Default([]) SharedPages sharedPages,
@Default(false) bool isLoading,
@Default('') String errorMessage,
}) = _SharedSectionState;
const SharedSectionState._();
factory SharedSectionState.initial() => const SharedSectionState();
}
@@ -0,0 +1,14 @@
import 'package:appflowy/features/share_tab/data/models/share_access_level.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
typedef SharedPages = List<SharedPage>;
class SharedPage {
SharedPage({
required this.view,
required this.accessLevel,
});
final ViewPB view;
final ShareAccessLevel accessLevel;
}
@@ -0,0 +1,67 @@
import 'package:appflowy/features/shared_section/data/rust_share_pagers_repository.dart';
import 'package:appflowy/features/shared_section/logic/shared_section_bloc.dart';
import 'package:appflowy/features/shared_section/presentation/widgets/refresh_button.dart';
import 'package:appflowy/features/shared_section/presentation/widgets/shared_pages_list.dart';
import 'package:appflowy/features/shared_section/presentation/widgets/shared_section_error.dart';
import 'package:appflowy/features/shared_section/presentation/widgets/shared_section_header.dart';
import 'package:appflowy/features/shared_section/presentation/widgets/shared_section_loading.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class SharedSection extends StatelessWidget {
const SharedSection({
super.key,
required this.workspaceId,
});
final String workspaceId;
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (_) => SharedSectionBloc(
workspaceId: workspaceId,
repository: RustSharePagesRepository(),
enablePolling: true,
)..add(const SharedSectionEvent.init()),
child: BlocBuilder<SharedSectionBloc, SharedSectionState>(
builder: (context, state) {
if (state.isLoading) {
return const SharedSectionLoading();
}
if (state.errorMessage.isNotEmpty) {
return SharedSectionError(errorMessage: state.errorMessage);
}
// hide the shared section if there are no shared pages
if (state.sharedPages.isEmpty) {
return const SizedBox.shrink();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Shared header
const SharedSectionHeader(),
// Shared pages list
SharedPagesList(sharedPages: state.sharedPages),
// Refresh button, for debugging only
if (kDebugMode)
RefreshSharedSectionButton(
onTap: () {
context.read<SharedSectionBloc>().add(
const SharedSectionEvent.refresh(),
);
},
),
],
);
},
),
);
}
}
@@ -0,0 +1,27 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flutter/material.dart';
import 'package:easy_localization/easy_localization.dart';
class RefreshSharedSectionButton extends StatelessWidget {
const RefreshSharedSectionButton({
super.key,
required this.onTap,
});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return AFTextMenuItem(
leading: Icon(
Icons.refresh,
size: 20,
color: theme.iconColorScheme.secondary,
),
title: LocaleKeys.shareSection_refresh.tr(),
onTap: onTap,
);
}
}
@@ -0,0 +1,45 @@
import 'package:appflowy/features/shared_section/models/shared_page.dart';
import 'package:appflowy/workspace/application/sidebar/folder/folder_bloc.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/presentation/home/home_sizes.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class SharedPagesList extends StatelessWidget {
const SharedPagesList({
super.key,
required this.sharedPages,
});
final SharedPages sharedPages;
@override
Widget build(BuildContext context) {
return Column(
mainAxisSize: MainAxisSize.min,
children: sharedPages.map((sharedPage) {
final view = sharedPage.view;
return ViewItem(
key: ValueKey(view.id),
spaceType: FolderSpaceType.public,
isFirstChild: view.id == sharedPages.first.view.id,
view: view,
level: 0,
isDraggable: false, // disable draggable for shared pages
leftPadding: HomeSpaceViewSizes.leftPadding,
isFeedback: false,
onSelected: (context, view) {
if (HardwareKeyboard.instance.isControlPressed) {
context.read<TabsBloc>().openTab(view);
}
context.read<TabsBloc>().openPlugin(view);
},
onTertiarySelected: (context, view) =>
context.read<TabsBloc>().openTab(view),
);
}).toList(),
);
}
}
@@ -0,0 +1,25 @@
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flutter/material.dart';
class SharedSectionError extends StatelessWidget {
const SharedSectionError({
super.key,
required this.errorMessage,
});
final String errorMessage;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Padding(
padding: const EdgeInsets.all(16.0),
child: Text(
errorMessage,
style: theme.textStyle.body.enhanced(
color: theme.textColorScheme.warning,
),
),
);
}
}
@@ -0,0 +1,25 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
class SharedSectionHeader extends StatelessWidget {
const SharedSectionHeader({super.key});
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Padding(
padding: EdgeInsets.only(
left: theme.spacing.xs,
bottom: theme.spacing.s,
),
child: Text(
LocaleKeys.shareSection_shared.tr(),
style: theme.textStyle.caption.enhanced(
color: theme.textColorScheme.tertiary,
),
),
);
}
}
@@ -0,0 +1,16 @@
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flutter/material.dart';
class SharedSectionLoading extends StatelessWidget {
const SharedSectionLoading({super.key});
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Center(
child: CircularProgressIndicator(
color: theme.iconColorScheme.primary,
),
);
}
}
@@ -0,0 +1,111 @@
import 'package:appflowy/features/share_tab/data/models/models.dart';
import 'package:appflowy/features/shared_section/models/shared_page.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart' as folder;
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-user/workspace.pbenum.dart'
as user;
extension RepeatedSharedViewResponsePBExtension
on RepeatedSharedViewResponsePB {
SharedPages get sharedPages {
return sharedViews.map((e) => e.sharedPage).toList();
}
}
extension SharedViewPBExtension on SharedViewPB {
SharedPage get sharedPage {
return SharedPage(
view: view,
accessLevel: accessLevel.shareAccessLevel,
);
}
}
extension RepeatedSharedUserPBExtension on RepeatedSharedUserPB {
SharedUsers get sharedUsers {
return items.map((e) => e.sharedUser).toList();
}
}
extension SharedUserPBExtension on SharedUserPB {
SharedUser get sharedUser {
return SharedUser(
email: email,
name: name,
accessLevel: accessLevel.shareAccessLevel,
role: role.shareRole,
avatarUrl: avatarUrl,
);
}
}
extension AFAccessLevelPBExtension on AFAccessLevelPB {
ShareAccessLevel get shareAccessLevel {
switch (this) {
case AFAccessLevelPB.ReadOnly:
return ShareAccessLevel.readOnly;
case AFAccessLevelPB.ReadAndComment:
return ShareAccessLevel.readAndComment;
case AFAccessLevelPB.ReadAndWrite:
return ShareAccessLevel.readAndWrite;
case AFAccessLevelPB.FullAccess:
return ShareAccessLevel.fullAccess;
default:
throw Exception('Unknown share role: $this');
}
}
}
extension ShareAccessLevelExtension on ShareAccessLevel {
AFAccessLevelPB get accessLevel {
switch (this) {
case ShareAccessLevel.readOnly:
return AFAccessLevelPB.ReadOnly;
case ShareAccessLevel.readAndComment:
return AFAccessLevelPB.ReadAndComment;
case ShareAccessLevel.readAndWrite:
return AFAccessLevelPB.ReadAndWrite;
case ShareAccessLevel.fullAccess:
return AFAccessLevelPB.FullAccess;
}
}
}
extension AFRolePBExtension on AFRolePB {
ShareRole get shareRole {
switch (this) {
case AFRolePB.Guest:
return ShareRole.guest;
case AFRolePB.Member:
return ShareRole.member;
case AFRolePB.Owner:
return ShareRole.owner;
default:
throw Exception('Unknown share role: $this');
}
}
}
extension ShareRoleExtension on ShareRole {
user.AFRolePB get userRole {
switch (this) {
case ShareRole.guest:
return user.AFRolePB.Guest;
case ShareRole.member:
return user.AFRolePB.Member;
case ShareRole.owner:
return user.AFRolePB.Owner;
}
}
folder.AFRolePB get folderRole {
switch (this) {
case ShareRole.guest:
return folder.AFRolePB.Guest;
case ShareRole.member:
return folder.AFRolePB.Member;
case ShareRole.owner:
return folder.AFRolePB.Owner;
}
}
}
@@ -40,6 +40,7 @@ class MobileViewPage extends StatefulWidget {
this.fixedTitle,
this.showMoreButton = true,
this.blockId,
this.bodyPaddingTop = 0.0,
this.tabs = const [PickerTabType.emoji, PickerTabType.icon],
});
@@ -50,6 +51,7 @@ class MobileViewPage extends StatefulWidget {
final Map<String, dynamic>? arguments;
final bool showMoreButton;
final String? blockId;
final double bodyPaddingTop;
final List<PickerTabType> tabs;
// only used in row page
@@ -176,7 +178,10 @@ class _MobileViewPageState extends State<MobileViewPage> {
return Scaffold(
extendBodyBehindAppBar: isDocument,
appBar: appBar,
body: body,
body: Padding(
padding: EdgeInsets.only(top: widget.bodyPaddingTop),
child: body,
),
);
}
@@ -433,7 +438,8 @@ class _MobileViewPageState extends State<MobileViewPage> {
if (notification is ScrollUpdateNotification &&
defaultScrollNotificationPredicate(notification)) {
final ScrollMetrics metrics = notification.metrics;
double height = MediaQuery.of(context).padding.top;
double height =
MediaQuery.of(context).padding.top + widget.bodyPaddingTop;
if (defaultTargetPlatform == TargetPlatform.android) {
height += AppBarTheme.of(context).toolbarHeight ?? kToolbarHeight;
}
@@ -8,14 +8,14 @@ import 'package:appflowy/plugins/shared/share/constants.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
// ignore: implementation_imports
import 'package:appflowy_editor/src/editor/util/link_util.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
// ignore: implementation_imports
import 'package:appflowy_editor/src/editor/util/link_util.dart';
class MobileBottomSheetEditLinkWidget extends StatefulWidget {
const MobileBottomSheetEditLinkWidget({
@@ -410,7 +410,7 @@ class _MobileBottomSheetEditLinkWidgetState
BoxDecoration buildBorderDecoration() {
return BoxDecoration(
borderRadius: BorderRadius.circular(theme.borderRadius.l),
border: Border.all(color: theme.borderColorScheme.greyPrimary),
border: Border.all(color: theme.borderColorScheme.primary),
);
}
}
@@ -59,6 +59,7 @@ Future<T?> showMobileBottomSheet<T>(
double initialChildSize = 0.51,
double bottomSheetPadding = 0,
bool enablePadding = true,
WidgetBuilder? dragHandleBuilder,
}) async {
assert(
showHeader ||
@@ -102,7 +103,7 @@ Future<T?> showMobileBottomSheet<T>(
// ----- header area -----
if (showDragHandle) {
children.add(
const DragHandle(),
dragHandleBuilder?.call(context) ?? const DragHandle(),
);
}
@@ -109,7 +109,7 @@ class MobileSpaceMenuItem extends StatelessWidget {
currentSpace: context.read<SpaceBloc>().state.currentSpace,
),
onTap: () {
context.read<SpaceBloc>().add(SpaceEvent.open(space));
context.read<SpaceBloc>().add(SpaceEvent.open(space: space));
Navigator.of(context).pop();
},
);
@@ -19,6 +19,9 @@ import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:go_router/go_router.dart';
import 'home/mobile_home_page.dart';
import 'search/mobile_search_page.dart';
enum BottomNavigationBarActionType {
home,
notificationMultiSelect,
@@ -38,6 +41,14 @@ enum BottomNavigationBarItemType {
notification;
String get label => name;
String? get routeName {
return switch (this) {
home => MobileHomeScreen.routeName,
search => MobileSearchScreen.routeName,
notification => MobileNotificationsScreenV2.routeName,
add => null,
};
}
ValueKey get valueKey {
return ValueKey(label);
@@ -2,49 +2,78 @@ import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/home/tab/mobile_space_tab.dart';
import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'mobile_search_summary_cell.dart';
class MobileSearchAskAiEntrance extends StatelessWidget {
const MobileSearchAskAiEntrance({super.key, this.query});
final String? query;
const MobileSearchAskAiEntrance({super.key});
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final bloc = context.read<CommandPaletteBloc?>(), state = bloc?.state;
if (bloc == null || state == null) return _AskAIFor();
final generatingAIOverview = state.generatingAIOverview;
if (generatingAIOverview) return _AISearching();
final hasMockSummary = _mockSummary?.isNotEmpty ?? false,
hasSummaries = state.resultSummaries.isNotEmpty;
if (hasMockSummary || hasSummaries) return _AIOverview();
return _AskAIFor();
}
}
class _AskAIFor extends StatelessWidget {
const _AskAIFor();
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context),
spaceM = theme.spacing.m,
spaceL = theme.spacing.l;
return GestureDetector(
onTap: () {
context
.read<CommandPaletteBloc?>()
?.add(CommandPaletteEvent.gointToAskAI());
?.add(CommandPaletteEvent.goingToAskAI());
mobileCreateNewAIChatNotifier.value =
mobileCreateNewAIChatNotifier.value + 1;
},
behavior: HitTestBehavior.opaque,
child: Container(
height: 48,
margin: EdgeInsets.only(top: 16),
padding: EdgeInsets.fromLTRB(8, 12, 8, 12),
margin: EdgeInsets.only(top: spaceM),
padding: EdgeInsets.all(spaceL),
child: Row(
children: [
FlowySvg(
FlowySvgs.m_home_ai_chat_icon_m,
size: Size.square(24),
blendMode: null,
SizedBox.square(
dimension: 24,
child: Center(
child: FlowySvg(
FlowySvgs.m_home_ai_chat_icon_m,
size: Size.square(20),
blendMode: null,
),
),
),
HSpace(12),
buildText(theme),
HSpace(8),
buildText(context),
],
),
),
);
}
Widget buildText(AppFlowyThemeData theme) {
final queryText = query ?? '';
Widget buildText(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final bloc = context.read<CommandPaletteBloc?>();
final queryText = bloc?.state.query ?? '';
if (queryText.isEmpty) {
return Text(
LocaleKeys.search_askAIAnything.tr(),
@@ -74,3 +103,103 @@ class MobileSearchAskAiEntrance extends StatelessWidget {
);
}
}
class _AISearching extends StatelessWidget {
const _AISearching();
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context),
spaceM = theme.spacing.m,
spaceL = theme.spacing.l;
return Container(
margin: EdgeInsets.only(top: spaceM),
padding: EdgeInsets.all(spaceL),
child: SizedBox(
height: 24,
child: Row(
children: [
SizedBox.square(
dimension: 24,
child: Center(
child: FlowySvg(
FlowySvgs.m_home_ai_chat_icon_m,
size: Size.square(20),
blendMode: null,
),
),
),
HSpace(8),
Text(
LocaleKeys.search_searching.tr(),
style: theme.textStyle.heading4
.standard(color: theme.textColorScheme.secondary),
),
],
),
),
);
}
}
class _AIOverview extends StatelessWidget {
const _AIOverview();
@override
Widget build(BuildContext context) {
final bloc = context.read<CommandPaletteBloc?>(), state = bloc?.state;
final summaries = _mockSummary ?? state?.resultSummaries ?? [];
if (summaries.isEmpty) {
return const SizedBox.shrink();
}
final theme = AppFlowyTheme.of(context),
spaceM = theme.spacing.m,
spaceL = theme.spacing.l;
return Container(
margin: EdgeInsets.only(top: spaceM),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
VSpace(spaceM),
buildHeader(context),
VSpace(spaceL),
LayoutBuilder(
builder: (context, constrains) {
final summary = summaries.first;
return MobileSearchSummaryCell(
key: ValueKey(summary.content.trim()),
summary: summary,
maxWidth: constrains.maxWidth,
theme: AppFlowyTheme.of(context),
textStyle: theme.textStyle.heading4
.standard(color: theme.textColorScheme.primary)
.copyWith(height: 22 / 16),
);
},
),
],
),
);
}
Widget buildHeader(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Row(
children: [
FlowySvg(
FlowySvgs.ai_searching_icon_m,
size: Size.square(20),
blendMode: null,
),
HSpace(8),
Text(
LocaleKeys.commandPalette_aiOverview.tr(),
style: theme.textStyle.heading4
.enhanced(color: theme.textColorScheme.primary),
),
],
);
}
}
List<SearchSummaryPB>? _mockSummary;
@@ -1,7 +1,7 @@
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart';
import 'package:appflowy/workspace/application/command_palette/search_result_ext.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/command_palette/widgets/search_icon.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
@@ -13,18 +13,26 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'mobile_view_ancestors.dart';
class MobileSearchResultCell extends StatelessWidget {
const MobileSearchResultCell({super.key, required this.item, this.query});
const MobileSearchResultCell({
super.key,
required this.item,
this.query,
this.view,
});
final SearchResultItem item;
final ViewPB? view;
final String? query;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context),
textColor = theme.textColorScheme.primary;
final theme = AppFlowyTheme.of(context);
final commandPaletteState = context.read<CommandPaletteBloc>().state;
final displayName = item.displayName.isEmpty
? LocaleKeys.menuAppHeader_defaultNewPageName.tr()
: item.displayName;
final titleStyle = theme.textStyle.heading4
.standard(color: theme.textColorScheme.primary)
.copyWith(height: 24 / 16);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
child: Row(
@@ -32,7 +40,10 @@ class MobileSearchResultCell extends StatelessWidget {
children: [
SizedBox.square(
dimension: 24,
child: Center(child: buildIcon(theme)),
child: Center(
child: (view?.toSearchResultItem().icon ?? item.icon)
.buildIcon(context),
),
),
HSpace(12),
Flexible(
@@ -44,12 +55,10 @@ class MobileSearchResultCell extends StatelessWidget {
overflow: TextOverflow.ellipsis,
text: buildHighLightSpan(
content: displayName,
normal: theme.textStyle.heading4.standard(color: textColor),
highlight: theme.textStyle.heading4
.standard(color: textColor)
.copyWith(
backgroundColor: theme.fillColorScheme.themeSelect,
),
normal: titleStyle,
highlight: titleStyle.copyWith(
backgroundColor: theme.fillColorScheme.themeSelect,
),
),
),
buildPath(commandPaletteState, theme),
@@ -62,30 +71,24 @@ class MobileSearchResultCell extends StatelessWidget {
);
}
Widget buildIcon(AppFlowyThemeData theme) {
final icon = item.icon;
if (icon.ty == ResultIconTypePB.Emoji) {
return icon.getIcon(size: 20) ?? SizedBox.shrink();
} else {
return icon.getIcon(size: 20, iconColor: theme.iconColorScheme.primary) ??
SizedBox.shrink();
}
}
Widget buildPath(CommandPaletteState state, AppFlowyThemeData theme) {
return BlocProvider(
key: ValueKey(item.id),
create: (context) => ViewAncestorBloc(item.id),
child: BlocBuilder<ViewAncestorBloc, ViewAncestorState>(
builder: (context, state) {
final ancestors = state.ancestor.ancestors;
if(ancestors.isEmpty) return const SizedBox.shrink();
List<String> displayPath = ancestors.map((e) => e.name).toList();
if (ancestors.length > 2) {
displayPath = [ancestors.first.name, '...', ancestors.last.name];
}
return Text(
displayPath.join(' / '),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textStyle.body
.standard(color: theme.textColorScheme.secondary),
.standard(color: theme.textColorScheme.tertiary),
);
},
),
@@ -101,9 +104,9 @@ class MobileSearchResultCell extends StatelessWidget {
overflow: TextOverflow.ellipsis,
text: buildHighLightSpan(
content: item.content,
normal: theme.textStyle.heading4
normal: theme.textStyle.body
.standard(color: theme.textColorScheme.secondary),
highlight: theme.textStyle.heading4
highlight: theme.textStyle.body
.standard(color: theme.textColorScheme.primary)
.copyWith(
backgroundColor: theme.fillColorScheme.themeSelect,
@@ -0,0 +1,23 @@
import 'package:appflowy/workspace/application/command_palette/search_result_ext.dart';
import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flutter/material.dart';
extension MobileSearchIconItemExtension on ResultIconPB {
Widget? buildIcon(BuildContext context) {
final theme = AppFlowyTheme.of(context);
if (ty == ResultIconTypePB.Emoji) {
return SizedBox(
width: 20,
child: getIcon(size: 20) ?? SizedBox.shrink(),
);
} else {
return getIcon(
size: 20,
iconColor: theme.iconColorScheme.secondary,
) ??
SizedBox.shrink();
}
}
}
@@ -97,39 +97,43 @@ class _MobileSearchPageState extends State<MobileSearchPage> {
builder: (context, state) {
return SafeArea(
child: Scaffold(
body: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MobileSearchTextfield(
focusNode: focusNode,
hintText: enableShowAISearch
? LocaleKeys.search_searchOrAskAI.tr()
: LocaleKeys.search_label.tr(),
query: state.query ?? '',
onChanged: (value) =>
context.read<CommandPaletteBloc>().add(
CommandPaletteEvent.searchChanged(search: value),
),
),
if (enableShowAISearch)
MobileSearchAskAiEntrance(query: state.query),
Flexible(
child: NotificationListener(
child: MobileSearchResult(),
onNotification: (t) {
if (t is ScrollUpdateNotification) {
if (focusNode.hasFocus) {
focusNode.unfocus();
}
body: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MobileSearchTextfield(
focusNode: focusNode,
hintText: enableShowAISearch
? LocaleKeys.search_searchOrAskAI.tr()
: LocaleKeys.search_label.tr(),
query: state.query ?? '',
onChanged: (value) => context.read<CommandPaletteBloc>().add(
CommandPaletteEvent.searchChanged(search: value),
),
),
Flexible(
child: NotificationListener(
onNotification: (t) {
if (t is ScrollUpdateNotification) {
if (focusNode.hasFocus) {
focusNode.unfocus();
}
return true;
},
}
return true;
},
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Column(
children: [
if (enableShowAISearch) MobileSearchAskAiEntrance(),
MobileSearchResult(),
],
),
),
),
),
],
),
),
],
),
),
);
@@ -0,0 +1,172 @@
import 'package:appflowy/mobile/presentation/base/mobile_view_page.dart';
import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart';
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
import 'package:appflowy/startup/tasks/app_widget.dart';
import 'package:appflowy/workspace/application/command_palette/search_result_ext.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart' hide TextDirection;
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
class SearchSourceReferenceBottomSheet extends StatelessWidget {
const SearchSourceReferenceBottomSheet(this.sources, {super.key});
final List<SearchSourcePB> sources;
@override
Widget build(BuildContext context) {
return PageReferenceList(
sources: sources,
onTap: (id) async {
final theme = AppFlowyTheme.of(context);
final view = (await ViewBackendService.getView(id)).toNullable();
if (view == null) {
showToastNotification(
message: LocaleKeys.search_somethingWentWrong.tr(),
type: ToastificationType.error,
);
return;
}
await showMobileBottomSheet(
AppGlobals.rootNavKey.currentContext ?? context,
showDragHandle: true,
showDivider: false,
enableDraggableScrollable: true,
maxChildSize: 0.95,
minChildSize: 0.95,
initialChildSize: 0.95,
backgroundColor: theme.surfaceColorScheme.primary,
dragHandleBuilder: (_) => const _DragHandler(),
builder: (_) => SizedBox(
height: MediaQuery.of(context).size.height,
child: MobileViewPage(
id: id,
viewLayout: view.layout,
title: view.nameOrDefault,
tabs: PickerTabType.values,
bodyPaddingTop:
AppBarTheme.of(context).toolbarHeight ?? kToolbarHeight,
),
),
);
},
);
}
}
class PageReferenceList extends StatelessWidget {
const PageReferenceList({
super.key,
required this.sources,
required this.onTap,
});
final List<SearchSourcePB> sources;
final ValueChanged<String> onTap;
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(left: 16, top: 8),
child: Text(
LocaleKeys.commandPalette_aiOverviewSource.tr(),
style: theme.textStyle.body.enhanced(
color: theme.textColorScheme.secondary,
),
),
),
const VSpace(6),
ListView.builder(
shrinkWrap: true,
padding: EdgeInsets.fromLTRB(16, 0, 16, 8),
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
final source = sources[index];
final displayName = source.displayName.isEmpty
? LocaleKeys.menuAppHeader_defaultNewPageName.tr()
: source.displayName;
final sapceM = theme.spacing.m, spaceL = theme.spacing.l;
return FlowyButton(
onTap: () => onTap.call(source.id),
margin: EdgeInsets.zero,
text: Column(
mainAxisSize: MainAxisSize.min,
children: [
if (index != 0) AFDivider(),
Padding(
padding: EdgeInsets.symmetric(
vertical: spaceL,
horizontal: sapceM,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SizedBox.square(
dimension: 20,
child: Center(child: buildIcon(source.icon, theme)),
),
HSpace(12),
Flexible(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
displayName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.primary,
),
),
],
),
),
],
),
),
],
),
);
},
itemCount: sources.length,
),
],
);
}
Widget buildIcon(ResultIconPB icon, AppFlowyThemeData theme) {
if (icon.ty == ResultIconTypePB.Emoji) {
return icon.getIcon(size: 16, lineHeight: 20 / 16) ?? SizedBox.shrink();
} else {
return icon.getIcon(size: 20, iconColor: theme.iconColorScheme.primary) ??
SizedBox.shrink();
}
}
}
class _DragHandler extends StatelessWidget {
const _DragHandler();
@override
Widget build(BuildContext context) {
return Container(
height: 4,
width: 40,
margin: const EdgeInsets.symmetric(vertical: 12),
decoration: BoxDecoration(
color: Colors.grey.shade400,
borderRadius: BorderRadius.circular(2),
),
);
}
}
@@ -3,12 +3,10 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/application/mobile_router.dart';
import 'package:appflowy/shared/icon_emoji_picker/tab.dart';
import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart';
import 'package:appflowy/workspace/application/command_palette/search_result_list_bloc.dart';
import 'package:appflowy/workspace/application/recent/recent_views_bloc.dart';
import 'package:appflowy/workspace/application/view/view_service.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.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:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
@@ -36,9 +34,8 @@ class MobileSearchRecentList extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final commandPaletteState = context.read<CommandPaletteBloc>().state;
final theme = AppFlowyTheme.of(context);
final trashIdSet = commandPaletteState.trash.map((e) => e.id).toSet();
return BlocProvider(
create: (context) =>
@@ -49,31 +46,35 @@ class MobileSearchRecentList extends StatelessWidget {
.map((e) => e.item)
.where((e) => !trashIdSet.contains(e.id))
.toList();
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const VSpace(16),
Text(
LocaleKeys.sideBar_recent.tr(),
style: theme.textStyle.body
.enhanced(color: theme.textColorScheme.secondary),
),
const VSpace(4),
Column(
children: List.generate(recentViews.length, (index) {
final view = recentViews[index];
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _goToView(context, view),
child: MobileSearchResultCell(
item: view.toSearchResultItem(),
),
);
}),
),
],
),
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const VSpace(16),
Text(
LocaleKeys.sideBar_recent.tr(),
style: theme.textStyle.heading4
.enhanced(color: theme.textColorScheme.secondary)
.copyWith(
letterSpacing: 0.2,
height: 24 / 16,
),
),
const VSpace(4),
Column(
mainAxisSize: MainAxisSize.min,
children: List.generate(recentViews.length, (index) {
final view = recentViews[index];
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => _goToView(context, view),
child: MobileSearchResultCell(
item: view.toSearchResultItem(),
),
);
}),
),
],
);
},
),
@@ -81,108 +82,112 @@ class MobileSearchRecentList extends StatelessWidget {
}
}
class MobileSearchResultList extends StatefulWidget {
class MobileSearchResultList extends StatelessWidget {
const MobileSearchResultList({super.key});
@override
State<MobileSearchResultList> createState() => _MobileSearchResultListState();
}
class _MobileSearchResultListState extends State<MobileSearchResultList> {
late final SearchResultListBloc bloc;
@override
void initState() {
super.initState();
bloc = SearchResultListBloc();
}
@override
void dispose() {
bloc.close();
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = context.read<CommandPaletteBloc>().state,
theme = AppFlowyTheme.of(context);
final isSearching = state.searching,
items = state.combinedResponseItems.values.toList(),
hasData = items.isNotEmpty;
if (isSearching && !hasData) {
return Center(child: CircularProgressIndicator.adaptive());
} else if (!hasData) {
return buildNoResult(state.query ?? '');
final isSearching = state.searching || state.generatingAIOverview,
cachedViews = state.cachedViews;
List<SearchResultItem> displayItems =
state.combinedResponseItems.values.toList();
if (cachedViews.isNotEmpty) {
displayItems =
displayItems.where((item) => cachedViews[item.id] != null).toList();
}
return SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const VSpace(16),
Text(
LocaleKeys.commandPalette_bestMatches.tr(),
style: theme.textStyle.body
.enhanced(color: theme.textColorScheme.secondary),
),
const VSpace(4),
Column(
children: List.generate(items.length, (index) {
final item = items[index];
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () async {
final view = await ViewBackendService.getView(item.id)
.fold((s) => s, (s) => null);
if (view != null && context.mounted) {
await _goToView(context, view);
} else {
Log.error(
'tapping search result, view not found: ${item.id}',
);
}
},
child: MobileSearchResultCell(
item: item,
query: state.query,
),
);
}),
),
],
),
final hasData = displayItems.isNotEmpty;
if (isSearching && !hasData) {
return SizedBox(
height: 400,
child: Center(child: CircularProgressIndicator.adaptive()),
);
} else if (!hasData) {
return _NoResult();
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const VSpace(16),
Text(
LocaleKeys.commandPalette_bestMatches.tr(),
style: theme.textStyle.heading4
.enhanced(color: theme.textColorScheme.secondary)
.copyWith(
letterSpacing: 0.2,
height: 24 / 16,
),
),
const VSpace(4),
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemBuilder: (context, index) {
final item = displayItems[index];
final view = state.cachedViews[item.id];
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () async {
if (view != null && context.mounted) {
await _goToView(context, view);
} else {
showToastNotification(
message: LocaleKeys.search_pageNotExist.tr(),
type: ToastificationType.error,
);
Log.error(
'tapping search result, view not found: ${item.id}',
);
}
},
child: MobileSearchResultCell(
item: item,
view: view,
query: state.query,
),
);
},
separatorBuilder: (context, index) => AFDivider(),
itemCount: displayItems.length,
),
],
);
}
}
Widget buildNoResult(String query) {
final theme = AppFlowyTheme.of(context),
textColor = theme.textColorScheme.secondary;
class _NoResult extends StatelessWidget {
const _NoResult();
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
final textColor = theme.textColorScheme.secondary;
return Align(
alignment: Alignment.topCenter,
child: SizedBox(
height: 140,
child: Column(
children: [
const VSpace(48),
FlowySvg(
FlowySvgs.m_home_search_icon_m,
color: theme.iconColorScheme.secondary,
size: Size.square(24),
),
const VSpace(12),
Text(
LocaleKeys.search_noResultForSearching.tr(),
style: theme.textStyle.body.enhanced(color: textColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
textAlign: TextAlign.center,
LocaleKeys.search_noResultForSearchingHint.tr(),
style: theme.textStyle.caption.standard(color: textColor),
),
],
),
child: Column(
children: [
const VSpace(48),
FlowySvg(
FlowySvgs.m_home_search_icon_m,
color: theme.iconColorScheme.secondary,
size: Size.square(24),
),
const VSpace(12),
Text(
LocaleKeys.search_noResultForSearching.tr(),
style: theme.textStyle.heading4.enhanced(color: textColor),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
Text(
textAlign: TextAlign.center,
LocaleKeys.search_noResultForSearchingHint.tr(),
style: theme.textStyle.body.standard(color: textColor),
),
],
),
);
}
@@ -0,0 +1,340 @@
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/tab/mobile_space_tab.dart';
import 'package:appflowy/mobile/presentation/search/mobile_search_cell.dart';
import 'package:appflowy/startup/tasks/app_widget.dart';
import 'package:appflowy/workspace/application/command_palette/command_palette_bloc.dart';
import 'package:appflowy_backend/protobuf/flowy-search/result.pb.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart' hide TextDirection;
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'mobile_search_reference_bottom_sheet.dart';
class MobileSearchSummaryCell extends StatefulWidget {
const MobileSearchSummaryCell({
super.key,
required this.summary,
required this.maxWidth,
required this.theme,
required this.textStyle,
});
final SearchSummaryPB summary;
final double maxWidth;
final AppFlowyThemeData theme;
final TextStyle textStyle;
@override
State<MobileSearchSummaryCell> createState() =>
_MobileSearchSummaryCellState();
}
class _MobileSearchSummaryCellState extends State<MobileSearchSummaryCell> {
late TextPainter _painter;
late _TextInfo _textInfo = _TextInfo.normal(summary.content);
bool tappedShowMore = false;
final maxLines = 4;
SearchSummaryPB get summary => widget.summary;
double get maxWidth => widget.maxWidth;
AppFlowyThemeData get theme => widget.theme;
TextStyle get textStyle => widget.textStyle;
TextStyle get showMoreStyle =>
theme.textStyle.heading4.standard(color: theme.textColorScheme.secondary);
@override
void initState() {
super.initState();
refreshTextPainter();
}
@override
void didUpdateWidget(MobileSearchSummaryCell oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.maxWidth != maxWidth) {
refreshTextPainter();
}
}
@override
void dispose() {
_painter.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final showReference = summary.sources.isNotEmpty;
final query = summary.highlights.isEmpty
? context.read<CommandPaletteBloc?>()?.state.query
: summary.highlights;
return _textInfo.build(
context: context,
normal: textStyle,
more: showMoreStyle,
summury: summary,
query: query ?? '',
showMore: () {
setState(() {
tappedShowMore = true;
_textInfo = _TextInfo.normal(summary.content);
});
},
normalWidgetSpan: showReference
? WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: () => showPageReferences(context),
child: Padding(
padding: const EdgeInsets.fromLTRB(4, 4, 15, 4),
child: Container(
width: 21,
height: 15,
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.secondary,
borderRadius: BorderRadius.circular(6),
),
child: FlowySvg(
FlowySvgs.toolbar_link_m,
color: theme.iconColorScheme.primary,
size: Size.square(10),
),
),
),
),
)
: null,
overflowFadeCover: buildFadeCover(),
);
}
Widget buildFadeCover() {
final fillColor = Theme.of(context).scaffoldBackgroundColor;
return Container(
width: maxWidth,
height: 40,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
fillColor.withValues(alpha: 0),
fillColor.withValues(alpha: 0.6 * 255),
fillColor,
],
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
),
),
);
}
void refreshTextPainter() {
final content = summary.content;
if (!tappedShowMore) {
_painter = TextPainter(
text: TextSpan(text: content, style: textStyle),
textDirection: TextDirection.ltr,
maxLines: maxLines,
);
_painter.layout(maxWidth: maxWidth);
if (_painter.didExceedMaxLines) {
final lines = _painter.computeLineMetrics();
final lastLine = lines.last;
final offset = Offset(
lastLine.left + lastLine.width,
lines.map((e) => e.height).reduce((a, b) => a + b),
);
final range = _painter.getPositionForOffset(offset);
final text = content.substring(0, range.offset);
_textInfo = _TextInfo.overflow(text);
} else {
_textInfo = _TextInfo.normal(content);
}
}
}
void showPageReferences(BuildContext context) {
final theme = AppFlowyTheme.of(context);
showMobileBottomSheet(
AppGlobals.rootNavKey.currentContext ?? context,
showDragHandle: true,
showDivider: false,
enableDraggableScrollable: true,
backgroundColor: theme.surfaceColorScheme.primary,
builder: (_) => SearchSourceReferenceBottomSheet(summary.sources),
);
}
}
class _TextInfo {
_TextInfo({required this.text, required this.isOverflow});
_TextInfo.normal(this.text) : isOverflow = false;
_TextInfo.overflow(this.text) : isOverflow = true;
final String text;
final bool isOverflow;
Widget build({
required BuildContext context,
required TextStyle normal,
required TextStyle more,
required VoidCallback showMore,
required String query,
required SearchSummaryPB summury,
WidgetSpan? normalWidgetSpan,
Widget? overflowFadeCover,
}) {
final theme = AppFlowyTheme.of(context);
if (isOverflow) {
return GestureDetector(
behavior: HitTestBehavior.opaque,
onTap: showMore,
child: IgnorePointer(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
children: [
SelectionArea(
child: Text.rich(
_buildHighLightSpan(
content: text,
normal: normal,
query: query,
highlight: normal.copyWith(
backgroundColor: theme.fillColorScheme.themeSelect,
),
),
),
),
if (overflowFadeCover != null)
Positioned(
bottom: 0,
left: 0,
child: overflowFadeCover,
),
],
),
SizedBox(
height: 34,
child: Row(
children: [
Padding(
padding: const EdgeInsets.symmetric(vertical: 7),
child: FlowySvg(
FlowySvgs.arrow_down_s,
size: Size.square(20),
color: theme.iconColorScheme.secondary,
),
),
HSpace(8),
Text(LocaleKeys.search_showMore.tr(), style: more),
],
),
),
VSpace(16),
],
),
),
);
} else {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SelectionArea(
child: Text.rich(
TextSpan(
children: [
_buildHighLightSpan(
content: text,
normal: normal,
query: query,
highlight: normal.copyWith(
backgroundColor: theme.fillColorScheme.themeSelect,
),
),
if (normalWidgetSpan != null) normalWidgetSpan,
],
),
),
),
VSpace(16),
SizedBox(
width: 156,
height: 42,
child: AFOutlinedButton.normal(
borderRadius: 21,
padding: EdgeInsets.zero,
builder: (context, hovering, disabled) {
return Center(
child: SizedBox(
height: 22,
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
FlowySvg(
FlowySvgs.chat_ai_page_s,
size: Size.square(20),
color: theme.iconColorScheme.secondary,
),
HSpace(8),
Text(
LocaleKeys.commandPalette_aiAskFollowUp.tr(),
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.primary,
),
),
],
),
),
);
},
onTap: () {
context.read<CommandPaletteBloc?>()?.add(
CommandPaletteEvent.goingToAskAI(
sources: summury.sources,
),
);
mobileCreateNewAIChatNotifier.value =
mobileCreateNewAIChatNotifier.value + 1;
},
),
),
VSpace(16),
],
);
}
}
TextSpan _buildHighLightSpan({
required String content,
required TextStyle normal,
required TextStyle highlight,
String? query,
}) {
final queryText = (query ?? '').trim();
if (queryText.isEmpty) {
return TextSpan(text: content, style: normal);
}
final contents = content.splitIncludeSeparator(queryText);
return TextSpan(
children: List.generate(contents.length, (index) {
final content = contents[index];
final isHighlight = content.toLowerCase() == queryText.toLowerCase();
return TextSpan(
text: content,
style: isHighlight ? highlight : normal,
);
}),
);
}
}
@@ -1,9 +1,15 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/mobile/presentation/mobile_bottom_navigation_bar.dart';
import 'package:appflowy/shared/popup_menu/appflowy_popup_menu.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/startup/tasks/af_navigator_observer.dart';
import 'package:appflowy/user/application/reminder/reminder_bloc.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'mobile_search_page.dart';
class MobileSearchTextfield extends StatefulWidget {
const MobileSearchTextfield({
@@ -23,83 +29,97 @@ class MobileSearchTextfield extends StatefulWidget {
State<MobileSearchTextfield> createState() => _MobileSearchTextfieldState();
}
class _MobileSearchTextfieldState extends State<MobileSearchTextfield> {
class _MobileSearchTextfieldState extends State<MobileSearchTextfield>
with RouteAware {
late final TextEditingController controller;
final ValueNotifier<bool> hasFocusValueNotifier = ValueNotifier(true);
FocusNode get focusNode => widget.focusNode;
late String lastPage = bottomNavigationBarItemType.value ?? '';
String lastText = '';
@override
void initState() {
super.initState();
controller = TextEditingController(text: widget.query);
focusNode.addListener(onFocusChanged);
controller.addListener(() {
if (!mounted) return;
widget.onChanged?.call(controller.text);
if (lastText != controller.text) {
widget.onChanged?.call(controller.text);
lastText = controller.text;
}
});
bottomNavigationBarItemType.addListener(onBackOrLeave);
focusNode.requestFocus();
makeSureHasFocus();
getIt<AFNavigatorObserver>().addListener(onRoute);
}
@override
void dispose() {
controller.dispose();
focusNode.removeListener(onFocusChanged);
hasFocusValueNotifier.dispose();
bottomNavigationBarItemType.removeListener(onBackOrLeave);
getIt<AFNavigatorObserver>().removeListener(onRoute);
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return SizedBox(
return Container(
height: 42,
margin: EdgeInsets.symmetric(vertical: 8),
padding: EdgeInsets.only(left: 4, right: 16),
child: ValueListenableBuilder(
valueListenable: controller,
builder: (context, _, __) {
final hasText = controller.text.isNotEmpty;
return Row(
children: [
GestureDetector(
onTap: () {
if (lastPage.isEmpty) return;
// close the popup menu
closePopupMenu();
try {
BottomNavigationBarItemType label =
BottomNavigationBarItemType.values.byName(lastPage);
if (label == BottomNavigationBarItemType.search) {
label = BottomNavigationBarItemType.home;
}
if (label == BottomNavigationBarItemType.notification) {
getIt<ReminderBloc>().add(const ReminderEvent.refresh());
}
bottomNavigationBarItemType.value = label.label;
final routeName = label.routeName;
if (routeName != null) GoRouter.of(context).go(routeName);
} on ArgumentError {
Log.error(
'lastPage: [$lastPage] cannot be converted to BottomNavigationBarItemType',
);
}
},
child: SizedBox.square(
dimension: 40,
child: Center(
child: FlowySvg(
FlowySvgs.search_page_arrow_left_m,
size: Size.square(20),
color: theme.iconColorScheme.primary,
),
),
),
),
Expanded(
child: TextFormField(
autovalidateMode: AutovalidateMode.onUserInteraction,
autofocus: true,
focusNode: focusNode,
textAlign: TextAlign.left,
controller: controller,
style: TextStyle(
fontSize: 14,
height: 20 / 14,
fontWeight: FontWeight.w400,
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.primary,
),
decoration: buildInputDecoration(context),
),
),
ValueListenableBuilder(
valueListenable: hasFocusValueNotifier,
builder: (context, hasFocus, __) {
if (!hasFocus || !hasText) return SizedBox.shrink();
return GestureDetector(
onTap: () => focusNode.unfocus(),
behavior: HitTestBehavior.opaque,
child: Container(
height: 42,
padding: EdgeInsets.only(left: 8),
child: Align(
alignment: Alignment.centerLeft,
child: Text(
LocaleKeys.button_cancel.tr(),
style: theme.textStyle.body
.standard(color: theme.textColorScheme.action),
),
),
),
);
},
),
],
);
},
@@ -112,15 +132,12 @@ class _MobileSearchTextfieldState extends State<MobileSearchTextfield> {
final showCancelIcon = controller.text.isNotEmpty;
final border = OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(10.0)),
borderSide: BorderSide(color: theme.borderColorScheme.greyTertiary),
borderSide: BorderSide(color: theme.borderColorScheme.primary),
);
final enableBorder = border.copyWith(
borderSide: BorderSide(color: theme.borderColorScheme.themeThick),
);
final hintStyle = TextStyle(
fontSize: 14,
height: 20 / 14,
fontWeight: FontWeight.w400,
final hintStyle = theme.textStyle.heading4.standard(
color: theme.textColorScheme.tertiary,
);
return InputDecoration(
@@ -131,9 +148,9 @@ class _MobileSearchTextfieldState extends State<MobileSearchTextfield> {
border: border,
enabledBorder: border,
focusedBorder: enableBorder,
prefixIconConstraints: BoxConstraints.loose(Size(34, 40)),
prefixIconConstraints: BoxConstraints.loose(Size(38, 40)),
prefixIcon: Padding(
padding: const EdgeInsets.fromLTRB(8, 10, 4, 10),
padding: const EdgeInsets.fromLTRB(8, 10, 8, 10),
child: FlowySvg(
FlowySvgs.m_home_search_icon_m,
color: theme.iconColorScheme.secondary,
@@ -150,10 +167,9 @@ class _MobileSearchTextfieldState extends State<MobileSearchTextfield> {
child: Padding(
padding: const EdgeInsets.fromLTRB(4, 10, 8, 10),
child: FlowySvg(
FlowySvgs.clear_s,
blendMode: null,
FlowySvgs.search_clear_m,
color: theme.iconColorScheme.tertiary,
size: Size.square(20),
size: const Size.square(20),
),
),
)
@@ -161,9 +177,12 @@ class _MobileSearchTextfieldState extends State<MobileSearchTextfield> {
);
}
void onFocusChanged() {
if (!mounted) return;
hasFocusValueNotifier.value = focusNode.hasFocus;
Future<void> makeSureHasFocus() async {
if (!mounted || focusNode.hasFocus) return;
focusNode.requestFocus();
WidgetsBinding.instance.addPostFrameCallback((_) {
makeSureHasFocus();
});
}
void onBackOrLeave() {
@@ -173,6 +192,15 @@ class _MobileSearchTextfieldState extends State<MobileSearchTextfield> {
} else {
focusNode.unfocus();
controller.clear();
lastPage = label ?? '';
}
}
void onRoute(RouteInfo routeInfo) {
final oldName = routeInfo.oldRoute?.settings.name;
if (oldName != MobileSearchScreen.routeName) return;
if (routeInfo is PushRouterInfo) {
focusNode.unfocus();
}
}
}
@@ -1,4 +1,7 @@
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
@@ -18,6 +21,8 @@ class ViewAncestorBloc extends Bloc<ViewAncestorEvent, ViewAncestorState> {
(event, emit) async {
await event.map(
initial: (e) async {
emit(state.copyWith(isLoading: true));
final ancester = await _cache.getAncestor(
state.viewId,
onRefresh: (ancestor) {
@@ -26,7 +31,9 @@ class ViewAncestorBloc extends Bloc<ViewAncestorEvent, ViewAncestorState> {
}
},
);
emit(state.copyWith(ancestor: ancester, isLoading: false));
if (ancester != null) {
emit(state.copyWith(ancestor: ancester, isLoading: false));
}
},
);
},
@@ -53,3 +60,79 @@ class ViewAncestorState with _$ViewAncestorState {
ancestor: ViewAncestor.empty(),
);
}
extension ViewAncestorTextExtension on ViewAncestorState {
Widget buildPath(BuildContext context, {TextStyle? style}) {
final theme = AppFlowyTheme.of(context);
final ancestors = ancestor.ancestors;
final textStyle = style ??
theme.textStyle.caption.standard(color: theme.textColorScheme.tertiary);
final textHeight = (textStyle.fontSize ?? 0.0) * (textStyle.height ?? 1.0);
if (isLoading) return VSpace(textHeight);
return LayoutBuilder(
builder: (context, constrains) {
final List<String> displayPath = ancestors.map((e) => e.name).toList();
if (displayPath.isEmpty) return const SizedBox.shrink();
TextPainter textPainter =
_buildTextPainter(displayPath.join(' / '), textStyle);
textPainter.layout(maxWidth: constrains.maxWidth);
if (textPainter.didExceedMaxLines && displayPath.length > 2) {
displayPath.removeAt(displayPath.length - 2);
displayPath.insert(displayPath.length - 1, '...');
}
textPainter = _buildTextPainter(displayPath.join(' / '), textStyle);
textPainter.layout(maxWidth: constrains.maxWidth);
while (textPainter.didExceedMaxLines && displayPath.length > 3) {
displayPath.removeAt(displayPath.length - 2);
textPainter = _buildTextPainter(displayPath.join(' / '), textStyle);
textPainter.layout(maxWidth: constrains.maxWidth);
}
return Text(
displayPath.join(' / '),
style: textStyle,
maxLines: 2,
overflow: TextOverflow.ellipsis,
);
},
);
}
TextPainter _buildTextPainter(String text, TextStyle style) => TextPainter(
text: TextSpan(text: text, style: style),
maxLines: 1,
textDirection: TextDirection.ltr,
);
Widget buildOnelinePath(BuildContext context) {
final ancestors = ancestor.ancestors;
List<String> displayPath = ancestors.map((e) => e.name).toList();
if (ancestors.length > 2) {
displayPath = [ancestors.first.name, '...', ancestors.last.name];
}
final theme = AppFlowyTheme.of(context);
final style = theme.textStyle.caption
.standard(color: theme.textColorScheme.tertiary)
.copyWith(letterSpacing: 0.1);
return Row(
mainAxisSize: MainAxisSize.min,
children: [
HSpace(8),
Text(
'-',
style: style.copyWith(
color: theme.borderColorScheme.primaryHover,
),
),
HSpace(8),
Flexible(
child: Text(
displayPath.join(' / '),
style: style,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
);
}
}
@@ -11,7 +11,7 @@ class ViewAncestorCache {
final Map<String, ViewAncestor> _ancestors = {};
Future<ViewAncestor> getAncestor(
Future<ViewAncestor?> getAncestor(
String viewId, {
ValueChanged<ViewAncestor>? onRefresh,
}) async {
@@ -23,23 +23,26 @@ class ViewAncestorCache {
return _getAncestor(viewId);
}
Future<ViewAncestor> _getAncestor(
Future<ViewAncestor?> _getAncestor(
String viewId, {
ValueChanged<ViewAncestor>? onRefresh,
}) async {
final List<ViewPB> ancestors =
final List<ViewPB>? ancestors =
await ViewBackendService.getViewAncestors(viewId).fold(
(s) => s.items
.where((e) => e.parentViewId.isNotEmpty && e.id != viewId)
.toList(),
(f) => [],
(f) => null,
);
final newAncestors = ViewAncestor(
ancestors: ancestors.map((e) => ViewParent.fromViewPB(e)).toList(),
);
_ancestors[viewId] = newAncestors;
onRefresh?.call(newAncestors);
return newAncestors;
if (ancestors != null) {
final newAncestors = ViewAncestor(
ancestors: ancestors.map((e) => ViewParent.fromViewPB(e)).toList(),
);
_ancestors[viewId] = newAncestors;
onRefresh?.call(newAncestors);
return newAncestors;
}
return null;
}
}
@@ -5,7 +5,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_p
import 'package:appflowy/shared/af_role_pb_extension.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/inivitation/m_invite_member_by_email.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/invitation/m_invite_member_by_email.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
@@ -7,7 +7,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/copy_and_p
import 'package:appflowy/shared/af_role_pb_extension.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/user/application/user_service.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/inivitation/m_invite_member_by_link.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/invitation/m_invite_member_by_link.dart';
import 'package:appflowy/workspace/presentation/settings/widgets/members/workspace_member_bloc.dart';
import 'package:appflowy/workspace/presentation/widgets/dialogs.dart';
import 'package:appflowy_backend/log.dart';
@@ -11,5 +11,4 @@ export 'chat_message_service.dart';
export 'chat_message_stream.dart';
export 'chat_notification.dart';
export 'chat_select_message_bloc.dart';
export 'chat_select_sources_cubit.dart';
export 'chat_user_message_bloc.dart';
@@ -117,6 +117,14 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
),
);
});
on<_OnAIFollowUp>((event, emit) {
emit(
state.copyWith(
messageState: MessageState.aiFollowUp(event.followUpData),
),
);
});
}
void _initializeStreamListener() {
@@ -137,6 +145,9 @@ class ChatAIMessageBloc extends Bloc<ChatAIMessageEvent, ChatAIMessageState> {
},
onLocalAIInitializing: () =>
_safeAdd(const ChatAIMessageEvent.onLocalAIInitializing()),
onAIFollowUp: (data) {
_safeAdd(ChatAIMessageEvent.onAIFollowUp(data));
},
);
}
}
@@ -174,6 +185,9 @@ class ChatAIMessageEvent with _$ChatAIMessageEvent {
const factory ChatAIMessageEvent.receiveMetadata(
MetadataCollection metadata,
) = _ReceiveMetadata;
const factory ChatAIMessageEvent.onAIFollowUp(
AIFollowUpData followUpData,
) = _OnAIFollowUp;
}
@freezed
@@ -209,4 +223,6 @@ class MessageState with _$MessageState {
const factory MessageState.onInitializingLocalAI() = _LocalAIInitializing;
const factory MessageState.ready() = _Ready;
const factory MessageState.loading() = _Loading;
const factory MessageState.aiFollowUp(AIFollowUpData followUpData) =
_AIFollowUp;
}
@@ -1,8 +1,6 @@
import 'dart:async';
import 'dart:collection';
import 'package:appflowy/ai/ai.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';
@@ -15,264 +13,303 @@ import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.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_handler.dart';
import 'chat_message_listener.dart';
import 'chat_message_service.dart';
import 'chat_message_stream.dart';
import 'chat_settings_manager.dart';
import 'chat_stream_manager.dart';
part 'chat_bloc.freezed.dart';
/// Returns current Unix timestamp (seconds since epoch)
int timestamp() {
return DateTime.now().millisecondsSinceEpoch ~/ 1000;
}
class ChatBloc extends Bloc<ChatEvent, ChatState> {
ChatBloc({
required this.chatId,
required this.userId,
}) : chatController = InMemoryChatController(),
listener = ChatMessageListener(chatId: chatId),
selectedSourcesNotifier = ValueNotifier([]),
super(ChatState.initial()) {
// Initialize managers
_messageHandler = ChatMessageHandler(
chatId: chatId,
userId: userId,
chatController: chatController,
);
_streamManager = ChatStreamManager(chatId);
_settingsManager = ChatSettingsManager(chatId: chatId);
_startListening();
_dispatch();
_loadMessages();
_loadSetting();
_loadSettings();
}
final String chatId;
final String userId;
final ChatMessageListener listener;
final ValueNotifier<List<String>> selectedSourcesNotifier;
final ChatController chatController;
/// The last streaming message id
String answerStreamMessageId = '';
String questionStreamMessageId = '';
// Managers
late final ChatMessageHandler _messageHandler;
late final ChatStreamManager _streamManager;
late final ChatSettingsManager _settingsManager;
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
/// between the real message ID and the last streaming message ID, we use this map to store the associations.
///
/// This map will be updated when receiving a message from the server and its author type
/// is 3 (AI response).
final HashMap<String, String> temporaryMessageIDMap = HashMap();
bool isLoadingPreviousMessages = false;
bool hasMorePreviousMessages = true;
AnswerStream? answerStream;
bool isFetchingRelatedQuestions = false;
bool shouldFetchRelatedQuestions = false;
// Accessor for selected sources
ValueNotifier<List<String>> get selectedSourcesNotifier =>
_settingsManager.selectedSourcesNotifier;
@override
Future<void> close() async {
await answerStream?.dispose();
// Safely dispose all resources
await _streamManager.dispose();
await listener.stop();
final request = ViewIdPB(value: chatId);
unawaited(FolderEventCloseView(request).send());
selectedSourcesNotifier.dispose();
_settingsManager.dispose();
chatController.dispose();
return super.close();
}
void _dispatch() {
on<ChatEvent>(
(event, emit) async {
await event.when(
// Loading messages
didLoadLatestMessages: (List<Message> messages) async {
for (final message in messages) {
await chatController.insert(message, index: 0);
}
on<ChatEvent>((event, emit) async {
await event.when(
// Chat settings
didReceiveChatSettings: (settings) async =>
_handleChatSettings(settings),
updateSelectedSources: (selectedSourcesIds) async =>
_handleUpdateSources(selectedSourcesIds),
switch (state.loadingState) {
case LoadChatMessageStatus.loading
when chatController.messages.isEmpty:
emit(
state.copyWith(
loadingState: LoadChatMessageStatus.loadingRemote,
),
);
break;
case LoadChatMessageStatus.loading:
case LoadChatMessageStatus.loadingRemote:
emit(
state.copyWith(loadingState: LoadChatMessageStatus.ready),
);
break;
default:
break;
}
},
loadPreviousMessages: () {
if (isLoadingPreviousMessages) {
return;
}
// Message loading
didLoadLatestMessages: (messages) async =>
_handleLatestMessages(messages, emit),
loadPreviousMessages: () async => _loadPreviousMessagesIfNeeded(),
didLoadPreviousMessages: (messages, hasMore) async =>
_handlePreviousMessages(messages, hasMore),
final oldestMessage = _getOldestMessage();
// Message handling
receiveMessage: (message) async => _handleReceiveMessage(message),
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}");
// Sending messages
sendMessage: (message, format, metadata, promptId) async =>
_handleSendMessage(message, format, metadata, promptId, emit),
finishSending: () async => emit(
state.copyWith(
promptResponseState: PromptResponseState.streamingAnswer,
),
),
for (final message in messages) {
chatController.insert(message, index: 0);
}
// Stream control
stopStream: () async => _handleStopStream(emit),
failedSending: () async => _handleFailedSending(emit),
isLoadingPreviousMessages = false;
hasMorePreviousMessages = hasMore;
},
didFinishAnswerStream: () {
emit(
state.copyWith(promptResponseState: PromptResponseState.ready),
);
},
didReceiveRelatedQuestions: (List<String> questions) {
if (questions.isEmpty) {
return;
}
// Answer regeneration
regenerateAnswer: (id, format, model) async =>
_handleRegenerateAnswer(id, format, model, emit),
final metadata = {
onetimeShotType: OnetimeShotType.relatedQuestion,
'questions': questions,
};
// Streaming completion
didFinishAnswerStream: () async => emit(
state.copyWith(
promptResponseState: PromptResponseState.ready,
),
),
final createdAt = DateTime.now();
// Related questions
didReceiveRelatedQuestions: (questions) async =>
_handleRelatedQuestions(
questions,
emit,
),
final message = TextMessage(
id: "related_question_$createdAt",
text: '',
metadata: metadata,
author: const User(id: systemUserId),
createdAt: createdAt,
);
// Message management
deleteMessage: (message) async => chatController.remove(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,
PredefinedFormat? format,
Map<String, dynamic>? metadata,
) {
_clearErrorMessages(emit);
_clearRelatedQuestions();
_startStreamingMessage(message, format, metadata);
lastSentMessage = null;
// AI follow-up
onAIFollowUp: (followUpData) async {
shouldFetchRelatedQuestions =
followUpData.shouldGenerateRelatedQuestion;
},
);
});
}
isFetchingRelatedQuestions = false;
shouldFetchRelatedQuestions =
format == null || format.imageFormat.hasText;
// Chat settings handlers
void _handleChatSettings(ChatSettingsPB settings) {
_settingsManager.selectedSourcesNotifier.value = settings.ragIds;
}
emit(
state.copyWith(
promptResponseState: PromptResponseState.sendingQuestion,
),
);
},
finishSending: () {
emit(
state.copyWith(
promptResponseState: PromptResponseState.streamingAnswer,
),
);
},
stopStream: () async {
if (answerStream == null) {
return;
}
Future<void> _handleUpdateSources(List<String> selectedSourcesIds) async {
await _settingsManager.updateSelectedSources(selectedSourcesIds);
}
// tell backend to stop
final payload = StopStreamPB(chatId: chatId);
await AIEventStopStream(payload).send();
// Message loading handlers
Future<void> _handleLatestMessages(
List<Message> messages,
Emitter<ChatState> emit,
) async {
for (final message in messages) {
await chatController.insert(message, index: 0);
}
// allow user input
emit(
state.copyWith(
promptResponseState: PromptResponseState.ready,
),
);
// Check if emit is still valid after async operations
if (emit.isDone) {
return;
}
// no need to remove old message if stream has started already
if (answerStream!.hasStarted) {
return;
}
switch (state.loadingState) {
case LoadChatMessageStatus.loading when chatController.messages.isEmpty:
emit(state.copyWith(loadingState: LoadChatMessageStatus.loadingRemote));
break;
case LoadChatMessageStatus.loading:
case LoadChatMessageStatus.loadingRemote:
emit(state.copyWith(loadingState: LoadChatMessageStatus.ready));
break;
default:
break;
}
}
// remove the non-started message from the list
final message = chatController.messages.lastWhereOrNull(
(e) => e.id == answerStreamMessageId,
);
if (message != null) {
await chatController.remove(message);
}
void _handlePreviousMessages(List<Message> messages, bool hasMore) {
for (final message in messages) {
chatController.insert(message, index: 0);
}
// set answer stream to null
await answerStream?.dispose();
answerStream = null;
answerStreamMessageId = '';
},
failedSending: () {
final lastMessage = chatController.messages.lastOrNull;
if (lastMessage != null) {
chatController.remove(lastMessage);
}
emit(
state.copyWith(
promptResponseState: PromptResponseState.ready,
),
);
},
regenerateAnswer: (id, format, model) {
_clearRelatedQuestions();
_regenerateAnswer(id, format, model);
lastSentMessage = null;
isLoadingPreviousMessages = false;
hasMorePreviousMessages = hasMore;
}
isFetchingRelatedQuestions = false;
shouldFetchRelatedQuestions = false;
// Message handling
void _handleReceiveMessage(Message message) {
final oldMessage =
chatController.messages.firstWhereOrNull((m) => m.id == message.id);
if (oldMessage == null) {
chatController.insert(message);
} else {
chatController.update(oldMessage, message);
}
}
emit(
state.copyWith(
promptResponseState: PromptResponseState.sendingQuestion,
),
);
},
didReceiveChatSettings: (settings) {
selectedSourcesNotifier.value = settings.ragIds;
},
updateSelectedSources: (selectedSourcesIds) async {
selectedSourcesNotifier.value = [...selectedSourcesIds];
// Message sending handlers
void _handleSendMessage(
String message,
PredefinedFormat? format,
Map<String, dynamic>? metadata,
String? promptId,
Emitter<ChatState> emit,
) {
_messageHandler.clearErrorMessages();
emit(state.copyWith(clearErrorMessages: !state.clearErrorMessages));
final payload = UpdateChatSettingsPB(
chatId: ChatId(value: chatId),
ragIds: selectedSourcesIds,
);
await AIEventUpdateChatSettings(payload)
.send()
.onFailure(Log.error);
},
deleteMessage: (mesesage) async {
await chatController.remove(mesesage);
},
);
},
_messageHandler.clearRelatedQuestions();
_startStreamingMessage(message, format, metadata, promptId);
lastSentMessage = null;
isFetchingRelatedQuestions = false;
shouldFetchRelatedQuestions = format == null || format.imageFormat.hasText;
emit(
state.copyWith(
promptResponseState: PromptResponseState.sendingQuestion,
),
);
}
// Stream control handlers
Future<void> _handleStopStream(Emitter<ChatState> emit) async {
await _streamManager.stopStream();
// Allow user input
emit(state.copyWith(promptResponseState: PromptResponseState.ready));
// No need to remove old message if stream has started already
if (_streamManager.hasAnswerStreamStarted) {
return;
}
// Remove the non-started message from the list
final message = chatController.messages.lastWhereOrNull(
(e) => e.id == _messageHandler.answerStreamMessageId,
);
if (message != null) {
await chatController.remove(message);
}
await _streamManager.disposeAnswerStream();
}
void _handleFailedSending(Emitter<ChatState> emit) {
final lastMessage = chatController.messages.lastOrNull;
if (lastMessage != null) {
chatController.remove(lastMessage);
}
emit(state.copyWith(promptResponseState: PromptResponseState.ready));
}
// Answer regeneration handler
void _handleRegenerateAnswer(
String id,
PredefinedFormat? format,
AIModelPB? model,
Emitter<ChatState> emit,
) {
_messageHandler.clearRelatedQuestions();
_regenerateAnswer(id, format, model);
lastSentMessage = null;
isFetchingRelatedQuestions = false;
shouldFetchRelatedQuestions = false;
emit(
state.copyWith(
promptResponseState: PromptResponseState.sendingQuestion,
),
);
}
// Related questions handler
void _handleRelatedQuestions(
List<String> questions,
Emitter<ChatState> emit,
) {
if (questions.isEmpty) {
return;
}
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),
createdAt: createdAt,
);
chatController.insert(message);
emit(
state.copyWith(
promptResponseState: PromptResponseState.relatedQuestionsReady,
),
);
}
@@ -283,25 +320,8 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
return;
}
// 3 mean message response from AI
if (pb.authorType == 3 && answerStreamMessageId.isNotEmpty) {
temporaryMessageIDMap.putIfAbsent(
pb.messageId.toString(),
() => answerStreamMessageId,
);
answerStreamMessageId = '';
}
// 1 mean message response from User
if (pb.authorType == 1 && questionStreamMessageId.isNotEmpty) {
temporaryMessageIDMap.putIfAbsent(
pb.messageId.toString(),
() => questionStreamMessageId,
);
questionStreamMessageId = '';
}
final message = _createTextMessage(pb);
_messageHandler.processReceivedMessage(pb);
final message = _messageHandler.createTextMessage(pb);
add(ChatEvent.receiveMessage(message));
},
chatErrorMessageCallback: (err) {
@@ -312,13 +332,15 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
},
latestMessageCallback: (list) {
if (!isClosed) {
final messages = list.messages.map(_createTextMessage).toList();
final messages =
list.messages.map(_messageHandler.createTextMessage).toList();
add(ChatEvent.didLoadLatestMessages(messages));
}
},
prevMessageCallback: (list) {
if (!isClosed) {
final messages = list.messages.map(_createTextMessage).toList();
final messages =
list.messages.map(_messageHandler.createTextMessage).toList();
add(ChatEvent.didLoadPreviousMessages(messages, list.hasMore));
}
},
@@ -328,52 +350,55 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
}
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 ||
!shouldFetchRelatedQuestions) {
return;
}
final payload = ChatMessageIdPB(
chatId: chatId,
messageId: lastSentMessage!.messageId,
);
isFetchingRelatedQuestions = true;
await AIEventGetRelatedQuestion(payload).send().fold(
(list) {
// while fetching related questions, the user might enter a new
// question or regenerate a previous response. In such cases, don't
// display the relatedQuestions
if (!isClosed && isFetchingRelatedQuestions) {
add(
ChatEvent.didReceiveRelatedQuestions(
list.items.map((e) => e.content).toList(),
),
);
isFetchingRelatedQuestions = false;
}
},
(err) => Log.error("Failed to get related questions: $err"),
);
unawaited(_fetchRelatedQuestionsIfNeeded());
},
);
}
void _loadSetting() async {
// Split method to handle related questions
Future<void> _fetchRelatedQuestionsIfNeeded() async {
// Don't fetch related questions if conditions aren't met
if (_streamManager.answerStream == null ||
lastSentMessage == null ||
!shouldFetchRelatedQuestions) {
return;
}
final payload = ChatMessageIdPB(
chatId: chatId,
messageId: lastSentMessage!.messageId,
);
isFetchingRelatedQuestions = true;
await AIEventGetRelatedQuestion(payload).send().fold(
(list) {
// while fetching related questions, the user might enter a new
// question or regenerate a previous response. In such cases, don't
// display the relatedQuestions
if (!isClosed && isFetchingRelatedQuestions) {
add(
ChatEvent.didReceiveRelatedQuestions(
list.items.map((e) => e.content).toList(),
),
);
isFetchingRelatedQuestions = false;
}
},
(err) => Log.error("Failed to get related questions: $err"),
);
}
void _loadSettings() async {
final getChatSettingsPayload =
AIEventGetChatSettings(ChatId(value: chatId));
await getChatSettingsPayload.send().fold(
(settings) {
if (!isClosed) {
add(ChatEvent.didReceiveChatSettings(settings: settings));
}
},
Log.error,
(err) => Log.error("Failed to load chat settings: $err"),
);
}
@@ -382,10 +407,12 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
chatId: chatId,
limit: Int64(10),
);
await AIEventLoadNextMessage(loadMessagesPayload).send().fold(
(list) {
if (!isClosed) {
final messages = list.messages.map(_createTextMessage).toList();
final messages =
list.messages.map(_messageHandler.createTextMessage).toList();
add(ChatEvent.didLoadLatestMessages(messages));
}
},
@@ -393,15 +420,22 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
);
}
bool _isOneTimeMessage(Message message) {
return message.metadata != null &&
message.metadata!.containsKey(onetimeShotType);
}
void _loadPreviousMessagesIfNeeded() {
if (isLoadingPreviousMessages) {
return;
}
/// get the last message that is not a one-time message
Message? _getOldestMessage() {
return chatController.messages
.firstWhereOrNull((message) => !_isOneTimeMessage(message));
final oldestMessage = _messageHandler.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);
}
}
void _loadPreviousMessages(Int64? beforeMessageId) {
@@ -417,37 +451,25 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
String message,
PredefinedFormat? format,
Map<String, dynamic>? metadata,
String? promptId,
) async {
await answerStream?.dispose();
// Prepare streams
await _streamManager.prepareStreams();
answerStream = AnswerStream();
final questionStream = QuestionStream();
// add a streaming question message
final questionStreamMessage = _createQuestionStreamMessage(
questionStream,
// Create and add question message
final questionStreamMessage = _messageHandler.createQuestionStreamMessage(
_streamManager.questionStream!,
metadata,
);
add(ChatEvent.receiveMessage(questionStreamMessage));
final payload = StreamChatPayloadPB(
chatId: chatId,
message: message,
messageType: ChatMessageTypePB.User,
questionStreamPort: Int64(questionStream.nativePort),
answerStreamPort: Int64(answerStream!.nativePort),
//metadata: await metadataPBFromMetadata(metadata),
);
if (format != null) {
payload.format = format.toPB();
}
// stream the question to the server
await AIEventStreamMessage(payload).send().fold(
// Send stream request
await _streamManager.sendStreamRequest(message, format, promptId).fold(
(question) {
if (!isClosed) {
final streamAnswer = _createAnswerStreamMessage(
stream: answerStream!,
// Create and add answer stream message
final streamAnswer = _messageHandler.createAnswerStreamMessage(
stream: _streamManager.answerStream!,
questionMessageId: question.messageId,
fakeQuestionMessageId: questionStreamMessage.id,
);
@@ -481,143 +503,42 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
);
}
// Refactored method to handle answer regeneration
void _regenerateAnswer(
String answerMessageIdString,
PredefinedFormat? format,
AIModelPB? model,
) async {
final id = temporaryMessageIDMap.entries
.firstWhereOrNull((e) => e.value == answerMessageIdString)
?.key ??
answerMessageIdString;
final id = _messageHandler.getEffectiveMessageId(answerMessageIdString);
final answerMessageId = Int64.tryParseInt(id);
if (answerMessageId == null) {
return;
}
await answerStream?.dispose();
answerStream = AnswerStream();
final payload = RegenerateResponsePB(
chatId: chatId,
answerMessageId: answerMessageId,
answerStreamPort: Int64(answerStream!.nativePort),
);
if (format != null) {
payload.format = format.toPB();
}
if (model != null) {
payload.model = model;
}
await AIEventRegenerateResponse(payload).send().fold(
(success) {
await _streamManager.prepareStreams();
await _streamManager
.sendRegenerateRequest(
answerMessageId,
format,
model,
)
.fold(
(_) {
if (!isClosed) {
final streamAnswer = _createAnswerStreamMessage(
stream: answerStream!,
questionMessageId: answerMessageId - 1,
).copyWith(id: answerMessageIdString);
final streamAnswer = _messageHandler
.createAnswerStreamMessage(
stream: _streamManager.answerStream!,
questionMessageId: answerMessageId - 1,
)
.copyWith(id: answerMessageIdString);
add(ChatEvent.receiveMessage(streamAnswer));
add(const ChatEvent.finishSending());
}
},
(err) => Log.error("Failed to send message: ${err.msg}"),
(err) => Log.error("Failed to regenerate answer: ${err.msg}"),
);
}
Message _createAnswerStreamMessage({
required AnswerStream stream,
required Int64 questionMessageId,
String? fakeQuestionMessageId,
}) {
answerStreamMessageId = fakeQuestionMessageId == null
? (questionMessageId + 1).toString()
: "${fakeQuestionMessageId}_ans";
return TextMessage(
id: answerStreamMessageId,
text: '',
author: User(id: "streamId:${nanoid()}"),
metadata: {
"$AnswerStream": stream,
messageQuestionIdKey: questionMessageId,
"chatId": chatId,
},
createdAt: DateTime.now(),
);
}
Message _createQuestionStreamMessage(
QuestionStream stream,
Map<String, dynamic>? sentMetadata,
) {
final now = DateTime.now();
questionStreamMessageId = (now.millisecondsSinceEpoch ~/ 1000).toString();
return TextMessage(
author: User(id: userId),
metadata: {
"$QuestionStream": stream,
"chatId": chatId,
messageChatFileListKey: chatFilesFromMessageMetadata(sentMetadata),
},
id: questionStreamMessageId,
createdAt: now,
text: '',
);
}
Message _createTextMessage(ChatMessagePB message) {
String messageId = message.messageId.toString();
/// If the message id is in the temporary map, we will use the previous fake message id
if (temporaryMessageIDMap.containsKey(messageId)) {
messageId = temporaryMessageIDMap[messageId]!;
}
final metadata = message.metadata == 'null' ? '[]' : message.metadata;
return TextMessage(
author: User(id: message.authorId),
id: messageId,
text: message.content,
createdAt: message.createdAt.toDateTime(),
metadata: {
messageRefSourceJsonStringKey: metadata,
},
);
}
void _clearErrorMessages(Emitter<ChatState> emit) {
final errorMessages = chatController.messages
.where(
(message) =>
onetimeMessageTypeFromMeta(message.metadata) ==
OnetimeShotType.error,
)
.toList();
for (final message in errorMessages) {
chatController.remove(message);
}
emit(state.copyWith(clearErrorMessages: !state.clearErrorMessages));
}
void _clearRelatedQuestions() {
final relatedQuestionMessages = chatController.messages
.where(
(message) =>
onetimeMessageTypeFromMeta(message.metadata) ==
OnetimeShotType.relatedQuestion,
)
.toList();
for (final message in relatedQuestionMessages) {
chatController.remove(message);
}
}
}
@freezed
@@ -635,6 +556,7 @@ class ChatEvent with _$ChatEvent {
required String message,
PredefinedFormat? format,
Map<String, dynamic>? metadata,
String? promptId,
}) = _SendMessage;
const factory ChatEvent.finishSending() = _FinishSendMessage;
const factory ChatEvent.failedSending() = _FailSendMessage;
@@ -668,6 +590,9 @@ class ChatEvent with _$ChatEvent {
) = _DidReceiveRelatedQueston;
const factory ChatEvent.deleteMessage(Message message) = _DeleteMessage;
const factory ChatEvent.onAIFollowUp(AIFollowUpData followUpData) =
_OnAIFollowUp;
}
@freezed
@@ -58,6 +58,9 @@ enum PromptResponseState {
ready,
sendingQuestion,
streamingAnswer,
relatedQuestionsReady;
bool get isReady => this == ready || this == relatedQuestionsReady;
}
class ChatFile extends Equatable {
@@ -0,0 +1,171 @@
import 'dart:collection';
import 'package:appflowy/util/int64_extension.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:collection/collection.dart';
import 'package:fixnum/fixnum.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:nanoid/nanoid.dart';
import 'chat_entity.dart';
import 'chat_message_stream.dart';
/// Returns current Unix timestamp (seconds since epoch)
int timestamp() {
return DateTime.now().millisecondsSinceEpoch ~/ 1000;
}
/// Handles message creation and manipulation for the chat system
class ChatMessageHandler {
ChatMessageHandler({
required this.chatId,
required this.userId,
required this.chatController,
});
final String chatId;
final String userId;
final ChatController chatController;
/// Maps real message IDs to temporary streaming message IDs
final HashMap<String, String> _temporaryMessageIDMap = HashMap();
/// Gets the effective message ID from the temporary map
String getEffectiveMessageId(String messageId) {
return _temporaryMessageIDMap.entries
.firstWhereOrNull((entry) => entry.value == messageId)
?.key ??
messageId;
}
String answerStreamMessageId = '';
String questionStreamMessageId = '';
/// Create a message from ChatMessagePB object
Message createTextMessage(ChatMessagePB message) {
String messageId = message.messageId.toString();
/// If the message id is in the temporary map, we will use the previous fake message id
if (_temporaryMessageIDMap.containsKey(messageId)) {
messageId = _temporaryMessageIDMap[messageId]!;
}
final metadata = message.metadata == 'null' ? '[]' : message.metadata;
return TextMessage(
author: User(id: message.authorId),
id: messageId,
text: message.content,
createdAt: message.createdAt.toDateTime(),
metadata: {
messageRefSourceJsonStringKey: metadata,
},
);
}
/// Create a streaming answer message
Message createAnswerStreamMessage({
required AnswerStream stream,
required Int64 questionMessageId,
String? fakeQuestionMessageId,
}) {
answerStreamMessageId = fakeQuestionMessageId == null
? (questionMessageId + 1).toString()
: "${fakeQuestionMessageId}_ans";
return TextMessage(
id: answerStreamMessageId,
text: '',
author: User(id: "streamId:${nanoid()}"),
metadata: {
"$AnswerStream": stream,
messageQuestionIdKey: questionMessageId,
"chatId": chatId,
},
createdAt: DateTime.now(),
);
}
/// Create a streaming question message
Message createQuestionStreamMessage(
QuestionStream stream,
Map<String, dynamic>? sentMetadata,
) {
final now = DateTime.now();
questionStreamMessageId = timestamp().toString();
return TextMessage(
author: User(id: userId),
metadata: {
"$QuestionStream": stream,
"chatId": chatId,
if (sentMetadata != null)
messageChatFileListKey: sentMetadata[messageChatFileListKey],
},
id: questionStreamMessageId,
createdAt: now,
text: '',
);
}
/// Clear error messages from the chat
void clearErrorMessages() {
final errorMessages = chatController.messages
.where(
(message) =>
onetimeMessageTypeFromMeta(message.metadata) ==
OnetimeShotType.error,
)
.toList();
for (final message in errorMessages) {
chatController.remove(message);
}
}
/// Clear related questions from the chat
void clearRelatedQuestions() {
final relatedQuestionMessages = chatController.messages
.where(
(message) =>
onetimeMessageTypeFromMeta(message.metadata) ==
OnetimeShotType.relatedQuestion,
)
.toList();
for (final message in relatedQuestionMessages) {
chatController.remove(message);
}
}
/// Checks if a message is a one-time message
bool isOneTimeMessage(Message message) {
return message.metadata != null &&
message.metadata!.containsKey(onetimeShotType);
}
/// Get the oldest message that is not a one-time message
Message? getOldestMessage() {
return chatController.messages
.firstWhereOrNull((message) => !isOneTimeMessage(message));
}
/// Add a message to the temporary ID map when receiving from server
void processReceivedMessage(ChatMessagePB pb) {
// 3 means message response from AI
if (pb.authorType == 3 && answerStreamMessageId.isNotEmpty) {
_temporaryMessageIDMap.putIfAbsent(
pb.messageId.toString(),
() => answerStreamMessageId,
);
answerStreamMessageId = '';
}
// 1 means message response from User
if (pb.authorType == 1 && questionStreamMessageId.isNotEmpty) {
_temporaryMessageIDMap.putIfAbsent(
pb.messageId.toString(),
() => questionStreamMessageId,
);
questionStreamMessageId = '';
}
}
}
@@ -0,0 +1,160 @@
import 'dart:math';
import 'package:universal_platform/universal_platform.dart';
class MessageHeightConstants {
static const String answerSuffix = '_ans';
static const String withoutMinHeightSuffix = '_without_min_height';
// This offset comes from the chat input box height + navigation bar height
// It's used to calculate the minimum height for answer messages
//
// navigation bar height + last user message height
// + last AI message height + chat input box height = screen height
static const double defaultDesktopScreenOffset = 220.0;
static const double defaultMobileScreenOffset = 304.0;
static const double relatedQuestionOffset = 72.0;
}
class ChatMessageHeightManager {
factory ChatMessageHeightManager() => _instance;
ChatMessageHeightManager._();
static final ChatMessageHeightManager _instance =
ChatMessageHeightManager._();
final Map<String, double> _heightCache = <String, double>{};
double get defaultScreenOffset {
if (UniversalPlatform.isMobile) {
return MessageHeightConstants.defaultMobileScreenOffset;
}
return MessageHeightConstants.defaultDesktopScreenOffset;
}
/// Cache a message height
void cacheHeight({
required String messageId,
required double height,
}) {
if (messageId.isEmpty || height <= 0) {
assert(false, 'messageId or height is invalid');
return;
}
_heightCache[messageId] = height;
}
void cacheWithoutMinHeight({
required String messageId,
required double height,
}) {
if (messageId.isEmpty || height <= 0) {
assert(false, 'messageId or height is invalid');
return;
}
_heightCache[messageId + MessageHeightConstants.withoutMinHeightSuffix] =
height;
}
double? getCachedHeight({
required String messageId,
}) {
if (messageId.isEmpty) return null;
final height = _heightCache[messageId];
return height;
}
double? getCachedWithoutMinHeight({
required String messageId,
}) {
if (messageId.isEmpty) return null;
final height =
_heightCache[messageId + MessageHeightConstants.withoutMinHeightSuffix];
return height;
}
/// Calculate minimum height for AI answer messages
///
/// For the user message, we don't need to calculate the minimum height
double calculateMinHeight({
required String messageId,
required double screenHeight,
}) {
if (!isAnswerMessage(messageId)) return 0.0;
final originalMessageId = getOriginalMessageId(
messageId: messageId,
);
final cachedHeight = getCachedHeight(
messageId: originalMessageId,
);
if (cachedHeight == null) {
return 0.0;
}
final calculatedHeight = screenHeight - cachedHeight - defaultScreenOffset;
return max(calculatedHeight, 0.0);
}
/// Calculate minimum height for related question messages
///
/// For the user message, we don't need to calculate the minimum height
double calculateRelatedQuestionMinHeight({
required String messageId,
}) {
final cacheHeight = getCachedHeight(
messageId: messageId,
);
final cacheHeightWithoutMinHeight = getCachedWithoutMinHeight(
messageId: messageId,
);
double minHeight = 0;
if (cacheHeight != null && cacheHeightWithoutMinHeight != null) {
minHeight = cacheHeight -
cacheHeightWithoutMinHeight -
MessageHeightConstants.relatedQuestionOffset;
}
minHeight = max(minHeight, 0);
return minHeight;
}
bool isAnswerMessage(String messageId) {
return messageId.endsWith(MessageHeightConstants.answerSuffix);
}
/// Get the original message ID from an answer message ID
///
/// Answer message ID is like: "message_id_ans"
/// Original message ID is like: "message_id"
String getOriginalMessageId({
required String messageId,
}) {
if (!isAnswerMessage(messageId)) {
return messageId;
}
return messageId.replaceAll(MessageHeightConstants.answerSuffix, '');
}
void removeFromCache({
required String messageId,
}) {
if (messageId.isEmpty) return;
_heightCache.remove(messageId);
final answerMessageId = messageId + MessageHeightConstants.answerSuffix;
_heightCache.remove(answerMessageId);
}
void clearCache() {
_heightCache.clear();
}
}
@@ -1,9 +1,14 @@
import 'dart:async';
import 'dart:convert';
import 'dart:ffi';
import 'dart:isolate';
import 'package:appflowy/ai/service/ai_entities.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_message_service.dart';
import 'package:appflowy_backend/log.dart';
import 'package:json_annotation/json_annotation.dart';
part 'chat_message_stream.g.dart';
/// A stream that receives answer events from an isolate or external process.
/// It caches events that might occur before a listener is attached.
@@ -37,7 +42,7 @@ class AnswerStream {
void Function()? _onAIImageResponseLimit;
void Function(String message)? _onAIMaxRequired;
void Function(MetadataCollection metadata)? _onMetadata;
void Function(AIFollowUpData)? _onAIFollowUp;
// Caches for events that occur before listen() is called.
final List<String> _pendingAIMaxRequiredEvents = [];
bool _pendingLocalAINotReady = false;
@@ -88,6 +93,15 @@ class AnswerStream {
} else {
_pendingLocalAINotReady = true;
}
} else if (event.startsWith(AIStreamEventPrefix.aiFollowUp)) {
final s = event.substring(AIStreamEventPrefix.aiFollowUp.length);
try {
final dynamic jsonData = jsonDecode(s);
final data = AIFollowUpData.fromJson(jsonData);
_onAIFollowUp?.call(data);
} catch (e) {
Log.error('Error deserializing AIFollowUp data: $e\nRaw JSON: $s');
}
}
}
@@ -114,6 +128,7 @@ class AnswerStream {
void Function(String message)? onAIMaxRequired,
void Function(MetadataCollection metadata)? onMetadata,
void Function()? onLocalAIInitializing,
void Function(AIFollowUpData)? onAIFollowUp,
}) {
_onData = onData;
_onStart = onStart;
@@ -124,7 +139,7 @@ class AnswerStream {
_onAIMaxRequired = onAIMaxRequired;
_onMetadata = onMetadata;
_onLocalAIInitializing = onLocalAIInitializing;
_onAIFollowUp = onAIFollowUp;
// Flush pending AI_MAX_REQUIRED events.
if (_onAIMaxRequired != null && _pendingAIMaxRequiredEvents.isNotEmpty) {
for (final msg in _pendingAIMaxRequiredEvents) {
@@ -205,7 +220,6 @@ class QuestionStream {
void Function()? _onIndexStart;
void Function()? _onIndexEnd;
void Function()? _onDone;
int get nativePort => _port.sendPort.nativePort;
bool get hasStarted => _hasStarted;
String? get error => _error;
@@ -241,3 +255,18 @@ class QuestionStream {
_onDone = onDone;
}
}
@JsonSerializable()
class AIFollowUpData {
AIFollowUpData({
required this.shouldGenerateRelatedQuestion,
});
factory AIFollowUpData.fromJson(Map<String, dynamic> json) =>
_$AIFollowUpDataFromJson(json);
@JsonKey(name: 'should_generate_related_question')
final bool shouldGenerateRelatedQuestion;
Map<String, dynamic> toJson() => _$AIFollowUpDataToJson(this);
}
@@ -0,0 +1,48 @@
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/foundation.dart';
/// Manages settings for a chat
class ChatSettingsManager {
ChatSettingsManager({
required this.chatId,
}) : selectedSourcesNotifier = ValueNotifier([]);
final String chatId;
/// Notifies listeners when selected sources change
final ValueNotifier<List<String>> selectedSourcesNotifier;
/// Load settings from backend
Future<void> loadSettings() async {
final getChatSettingsPayload =
AIEventGetChatSettings(ChatId(value: chatId));
await getChatSettingsPayload.send().then((result) {
result.fold(
(settings) {
selectedSourcesNotifier.value = settings.ragIds;
},
(err) => Log.error("Failed to load chat settings: $err"),
);
});
}
/// Update selected sources
Future<void> updateSelectedSources(List<String> selectedSourcesIds) async {
selectedSourcesNotifier.value = [...selectedSourcesIds];
final payload = UpdateChatSettingsPB(
chatId: ChatId(value: chatId),
ragIds: selectedSourcesIds,
);
await AIEventUpdateChatSettings(payload).send().onFailure(Log.error);
}
/// Clean up resources
void dispose() {
selectedSourcesNotifier.dispose();
}
}
@@ -0,0 +1,126 @@
import 'dart:async';
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:fixnum/fixnum.dart';
import 'chat_message_stream.dart';
/// Manages chat streaming operations
class ChatStreamManager {
ChatStreamManager(this.chatId);
final String chatId;
AnswerStream? answerStream;
QuestionStream? questionStream;
/// Dispose of all streams
Future<void> dispose() async {
await answerStream?.dispose();
answerStream = null;
await questionStream?.dispose();
questionStream = null;
}
/// Prepare streams for a new message
Future<void> prepareStreams() async {
await dispose();
answerStream = AnswerStream();
questionStream = QuestionStream();
}
/// Build the payload for a streaming message
StreamChatPayloadPB buildStreamPayload(
String message,
PredefinedFormat? format,
String? promptId,
) {
final payload = StreamChatPayloadPB(
chatId: chatId,
message: message,
messageType: ChatMessageTypePB.User,
questionStreamPort: Int64(questionStream!.nativePort),
answerStreamPort: Int64(answerStream!.nativePort),
);
if (format != null) {
payload.format = format.toPB();
}
if (promptId != null) {
payload.promptId = promptId;
}
return payload;
}
/// Send a streaming message request to the server
Future<FlowyResult<ChatMessagePB, FlowyError>> sendStreamRequest(
String message,
PredefinedFormat? format,
String? promptId,
) async {
final payload = buildStreamPayload(message, format, promptId);
return AIEventStreamMessage(payload).send();
}
/// Build the payload for regenerating a response
RegenerateResponsePB buildRegeneratePayload(
Int64 answerMessageId,
PredefinedFormat? format,
AIModelPB? model,
) {
final payload = RegenerateResponsePB(
chatId: chatId,
answerMessageId: answerMessageId,
answerStreamPort: Int64(answerStream!.nativePort),
);
if (format != null) {
payload.format = format.toPB();
}
if (model != null) {
payload.model = model;
}
return payload;
}
/// Send a request to regenerate a response
Future<FlowyResult<dynamic, FlowyError>> sendRegenerateRequest(
Int64 answerMessageId,
PredefinedFormat? format,
AIModelPB? model,
) async {
final payload = buildRegeneratePayload(answerMessageId, format, model);
return AIEventRegenerateResponse(payload).send();
}
/// Stop the current streaming message
Future<void> stopStream() async {
if (answerStream == null) {
return;
}
final payload = StopStreamPB(chatId: chatId);
await AIEventStopStream(payload).send();
}
/// Check if the answer stream has started
bool get hasAnswerStreamStarted =>
answerStream != null && answerStream!.hasStarted;
Future<void> disposeAnswerStream() async {
if (answerStream == null) {
return;
}
await answerStream!.dispose();
answerStream = null;
}
}
@@ -8,7 +8,6 @@ import 'package:flutter/services.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'application/chat_bloc.dart';
import 'application/chat_entity.dart';
import 'application/chat_member_bloc.dart';
class AIChatPage extends StatelessWidget {
@@ -69,8 +68,7 @@ class AIChatPage extends StatelessWidget {
event.logicalKey == LogicalKeyboardKey.keyC &&
HardwareKeyboard.instance.isControlPressed) {
final chatBloc = context.read<ChatBloc>();
if (chatBloc.state.promptResponseState !=
PromptResponseState.ready) {
if (!chatBloc.state.promptResponseState.isReady) {
chatBloc.add(ChatEvent.stopStream());
return KeyEventResult.handled;
}
@@ -1,15 +1,19 @@
// ignore_for_file: implementation_imports
import 'dart:async';
import 'dart:math';
import 'package:appflowy/util/debounce.dart';
import 'package:appflowy_backend/log.dart';
import 'package:diffutil_dart/diffutil.dart' as diffutil;
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:flutter_chat_ui/src/scroll_to_bottom.dart';
import 'package:flutter_chat_ui/src/utils/message_list_diff.dart';
import 'package:provider/provider.dart';
import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
import '../application/chat_message_height_manager.dart';
import 'widgets/message_height_calculator.dart';
class ChatAnimatedList extends StatefulWidget {
const ChatAnimatedList({
@@ -36,34 +40,56 @@ class ChatAnimatedList extends StatefulWidget {
final double scrollBottomPadding;
@override
ChatAnimatedListState createState() => ChatAnimatedListState();
State<ChatAnimatedList> createState() => _ChatAnimatedListState();
}
class ChatAnimatedListState extends State<ChatAnimatedList>
class _ChatAnimatedListState extends State<ChatAnimatedList>
with SingleTickerProviderStateMixin {
final GlobalKey<SliverAnimatedListState> _listKey = GlobalKey();
late final ChatController _chatController = Provider.of<ChatController>(
late final ChatController chatController = Provider.of<ChatController>(
context,
listen: false,
);
late List<Message> _oldList;
late StreamSubscription<ChatOperation> _operationsSubscription;
late List<Message> oldList;
late StreamSubscription<ChatOperation> operationsSubscription;
late final AnimationController _scrollToBottomController;
late final Animation<double> _scrollToBottomAnimation;
Timer? _scrollToBottomShowTimer;
late final AnimationController scrollToBottomController;
late final Animation<double> scrollToBottomAnimation;
Timer? scrollToBottomShowTimer;
bool _userHasScrolled = false;
bool _isScrollingToBottom = false;
// String _lastInsertedMessageId = '';
final ScrollOffsetController scrollOffsetController =
ScrollOffsetController();
final ItemScrollController itemScrollController = ItemScrollController();
final ScrollOffsetListener scrollOffsetListener =
ScrollOffsetListener.create();
final ItemPositionsListener itemPositionsListener =
ItemPositionsListener.create();
int lastUserMessageIndex = 0;
bool isScrollingToBottom = false;
final loadPreviousMessagesDebounce = Debounce(
duration: const Duration(milliseconds: 200),
);
int initialScrollIndex = 0;
double initialAlignment = 1.0;
List<Message> messages = [];
final ChatMessageHeightManager heightManager = ChatMessageHeightManager();
@override
void initState() {
super.initState();
// TODO: Add assert for messages having same id
_oldList = List.from(_chatController.messages);
_operationsSubscription = _chatController.operationsStream.listen((event) {
oldList = List.from(chatController.messages);
operationsSubscription = chatController.operationsStream.listen((event) {
setState(() {
messages = chatController.messages;
});
switch (event.type) {
case ChatOperationType.insert:
assert(
@@ -74,8 +100,9 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
event.message != null,
'Message must be provided when inserting a message.',
);
_onInserted(event.index!, event.message!);
_oldList = List.from(_chatController.messages);
oldList = List.from(chatController.messages);
break;
case ChatOperationType.remove:
assert(
@@ -86,15 +113,16 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
event.message != null,
'Message must be provided when removing a message.',
);
_onRemoved(event.index!, event.message!);
_oldList = List.from(_chatController.messages);
oldList = List.from(chatController.messages);
break;
case ChatOperationType.set:
final newList = _chatController.messages;
final newList = chatController.messages;
final updates = diffutil
.calculateDiff<Message>(
MessageListDiff(_oldList, newList),
MessageListDiff(oldList, newList),
)
.getUpdatesWithData();
@@ -102,292 +130,220 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
_onDiffUpdate(updates.elementAt(i));
}
_oldList = List.from(newList);
oldList = List.from(newList);
break;
default:
break;
}
});
_scrollToBottomController = AnimationController(
messages = chatController.messages;
scrollToBottomController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_scrollToBottomAnimation = CurvedAnimation(
parent: _scrollToBottomController,
scrollToBottomAnimation = CurvedAnimation(
parent: scrollToBottomController,
curve: Curves.easeInOut,
);
widget.scrollController.addListener(_handleLoadPreviousMessages);
itemPositionsListener.itemPositions.addListener(() {
_handleToggleScrollToBottom();
});
WidgetsBinding.instance.addPostFrameCallback((_) {
itemPositionsListener.itemPositions.addListener(() {
_handleLoadPreviousMessages();
});
}
@override
void dispose() {
scrollToBottomShowTimer?.cancel();
scrollToBottomController.dispose();
operationsSubscription.cancel();
_clearMessageHeightCache();
super.dispose();
_scrollToBottomShowTimer?.cancel();
_scrollToBottomController.dispose();
_operationsSubscription.cancel();
widget.scrollController.removeListener(_handleLoadPreviousMessages);
}
@override
Widget build(BuildContext context) {
final builders = context.watch<Builders>();
final height = MediaQuery.of(context).size.height;
return NotificationListener<Notification>(
onNotification: (notification) {
if (notification is UserScrollNotification) {
// When user scrolls up, save it to `_userHasScrolled`
if (notification.direction == ScrollDirection.reverse) {
_userHasScrolled = true;
} else {
// When user overscolls to the bottom or stays idle at the bottom, set `_userHasScrolled` to false
if (notification.metrics.pixels ==
notification.metrics.maxScrollExtent) {
_userHasScrolled = false;
// A trick to avoid the first message being scrolled to the top
initialScrollIndex = messages.length;
initialAlignment = 1.0;
if (messages.length <= 2) {
initialScrollIndex = 0;
initialAlignment = 0.0;
}
final Widget child = Stack(
children: [
ScrollablePositionedList.builder(
scrollOffsetController: scrollOffsetController,
itemScrollController: itemScrollController,
initialScrollIndex: initialScrollIndex,
initialAlignment: initialAlignment,
scrollOffsetListener: scrollOffsetListener,
itemPositionsListener: itemPositionsListener,
physics: ClampingScrollPhysics(),
shrinkWrap: true,
// the extra item is a vertical padding.
itemCount: messages.length + 1,
itemBuilder: (context, index) {
if (index < 0 || index > messages.length) {
Log.error('[chat animation list] index out of range: $index');
return const SizedBox.shrink();
}
}
}
if (notification is ScrollUpdateNotification) {
_handleToggleScrollToBottom();
}
if (index == messages.length) {
return const SizedBox.shrink();
}
// Allow other listeners to get the notification
return false;
},
child: Stack(
children: [
CustomScrollView(
controller: widget.scrollController,
physics: ClampingScrollPhysics(),
slivers: <Widget>[
SliverPadding(
padding: EdgeInsets.only(
top: widget.bottomPadding ?? 0,
),
),
SliverAnimatedList(
key: _listKey,
initialItemCount: _chatController.messages.length,
itemBuilder: (
BuildContext context,
int index,
Animation<double> animation,
) {
final message = _chatController.messages[index];
final child = widget.itemBuilder(
context,
animation,
message,
);
return RepaintBoundary(
child: child,
);
},
),
SliverPadding(
padding: EdgeInsets.only(
bottom: max(0, height - widget.scrollBottomPadding),
),
),
],
),
builders.scrollToBottomBuilder?.call(
final message = messages[index];
return MessageHeightCalculator(
messageId: message.id,
onHeightMeasured: _cacheMessageHeight,
child: widget.itemBuilder(
context,
_scrollToBottomAnimation,
_handleScrollToBottom,
) ??
ScrollToBottom(
animation: _scrollToBottomAnimation,
onPressed: _handleScrollToBottom,
Tween<double>(begin: 1, end: 1).animate(
CurvedAnimation(
parent: scrollToBottomController,
curve: Curves.easeInOut,
),
),
message,
),
],
),
);
},
),
builders.scrollToBottomBuilder?.call(
context,
scrollToBottomAnimation,
_handleScrollToBottom,
) ??
ScrollToBottom(
animation: scrollToBottomAnimation,
onPressed: _handleScrollToBottom,
),
],
);
return child;
}
// void _subsequentScrollToEnd(Message data) async {
// final user = Provider.of<User>(context, listen: false);
// // We only want to scroll to the bottom if user has not scrolled up
// // or if the message is sent by the current user.
// if (data.id == _lastInsertedMessageId &&
// widget.scrollController.offset <
// widget.scrollController.position.maxScrollExtent &&
// (user.id != data.author.id && !_userHasScrolled)) {
// if (widget.scrollToEndAnimationDuration == Duration.zero) {
// widget.scrollController
// .jumpTo(widget.scrollController.position.maxScrollExtent);
// } else {
// await widget.scrollController.animateTo(
// widget.scrollController.position.maxScrollExtent,
// duration: widget.scrollToEndAnimationDuration,
// curve: Curves.linearToEaseOut,
// );
// }
// if (!widget.scrollController.hasClients || !mounted) return;
// // Because of the issue I have opened here https://github.com/flutter/flutter/issues/129768
// // we need an additional jump to the end. Sometimes Flutter
// // will not scroll to the very end. Sometimes it will not scroll to the
// // very end even with this, so this is something that needs to be
// // addressed by the Flutter team.
// //
// // Additionally here we have a check for the message id, because
// // if new message arrives in the meantime it will trigger another
// // scroll to the end animation, making this logic redundant.
// if (data.id == _lastInsertedMessageId &&
// widget.scrollController.offset <
// widget.scrollController.position.maxScrollExtent &&
// (user.id == data.author.id && _userHasScrolled)) {
// widget.scrollController
// .jumpTo(widget.scrollController.position.maxScrollExtent);
// }
// }
// }
void _scrollToTop(Message data) {
WidgetsBinding.instance.addPostFrameCallback(
(_) {
if (!widget.scrollController.hasClients || !mounted) return;
final user = Provider.of<User>(context, listen: false);
final lastMessage = _chatController.messages.last;
if (lastMessage.author.id == user.id) {
// scroll the current message to the top
widget.scrollController.animateTo(
widget.scrollController.position.maxScrollExtent,
duration: Durations.medium4,
curve: Curves.linearToEaseOut,
);
}
},
Future<void> _scrollLastUserMessageToTop() async {
final user = Provider.of<User>(context, listen: false);
final lastUserMessageIndex = messages.lastIndexWhere(
(message) => message.author.id == user.id,
);
}
void _handleScrollToBottom() {
_isScrollingToBottom = true;
_scrollToBottomController.reverse();
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!widget.scrollController.hasClients || !mounted) return;
final height = MediaQuery.of(context).size.height;
final paddingHeight = height - widget.scrollBottomPadding;
final offset =
widget.scrollController.position.maxScrollExtent - paddingHeight;
if (widget.scrollToEndAnimationDuration == Duration.zero) {
widget.scrollController.jumpTo(offset);
} else {
await widget.scrollController.animateTo(
offset,
duration: widget.scrollToEndAnimationDuration,
curve: Curves.linearToEaseOut,
);
}
if (!widget.scrollController.hasClients || !mounted) return;
_isScrollingToBottom = false;
});
}
void _handleToggleScrollToBottom() {
if (_isScrollingToBottom) {
// waiting for the ai answer message to be inserted
if (lastUserMessageIndex == -1 ||
lastUserMessageIndex + 1 >= messages.length) {
return;
}
final paddingHeight =
MediaQuery.of(context).size.height - widget.scrollBottomPadding;
_scrollToBottomShowTimer?.cancel();
if (widget.scrollController.offset <
widget.scrollController.position.maxScrollExtent - paddingHeight) {
_scrollToBottomShowTimer =
Timer(widget.scrollToBottomAppearanceDelay, () {
if (mounted) {
_scrollToBottomController.forward();
}
});
} else {
if (_scrollToBottomController.status != AnimationStatus.completed) {
_scrollToBottomController.stop();
}
_scrollToBottomController.reverse();
if (this.lastUserMessageIndex != lastUserMessageIndex) {
// scroll the current message to the top
await itemScrollController.scrollTo(
index: lastUserMessageIndex,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
}
this.lastUserMessageIndex = lastUserMessageIndex;
}
void _onInserted(final int position, final Message data) {
// There is a scroll notification listener the controls the
// `_userHasScrolled` variable.
//
// If for some reason `_userHasScrolled` is true and the user is not at the
// bottom of the list, set `_userHasScrolled` to false so that the scroll
// animation is triggered.
if (position == 0 &&
_userHasScrolled &&
widget.scrollController.offset <
widget.scrollController.position.maxScrollExtent) {
_userHasScrolled = false;
}
Future<void> _handleScrollToBottom() async {
isScrollingToBottom = true;
_listKey.currentState!.insertItem(
0,
duration: widget.insertAnimationDuration,
scrollToBottomShowTimer?.cancel();
await scrollToBottomController.reverse();
await itemScrollController.scrollTo(
index: messages.length + 1,
alignment: 1.0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeInOut,
);
// Used later to trigger scroll to end only for the last inserted message.
// _lastInsertedMessageId = data.id;
isScrollingToBottom = false;
}
if (position == _oldList.length) {
_scrollToTop(data);
// _scrollToEnd(data);
void _handleToggleScrollToBottom() {
if (isScrollingToBottom) {
return;
}
// get the max item
final sortedItems = itemPositionsListener.itemPositions.value.toList()
..sort((a, b) => a.index.compareTo(b.index));
final maxItem = sortedItems.lastOrNull;
if (maxItem == null) {
return;
}
if (maxItem.index > messages.length - 1 ||
(maxItem.index == messages.length - 1 &&
maxItem.itemTrailingEdge <= 1.01)) {
scrollToBottomShowTimer?.cancel();
scrollToBottomController.reverse();
return;
}
scrollToBottomShowTimer?.cancel();
scrollToBottomShowTimer = Timer(widget.scrollToBottomAppearanceDelay, () {
if (mounted) {
scrollToBottomController.forward();
}
});
}
void _handleLoadPreviousMessages() {
final sortedItems = itemPositionsListener.itemPositions.value.toList()
..sort((a, b) => a.index.compareTo(b.index));
final minItem = sortedItems.firstOrNull;
if (minItem == null || minItem.index > 0 || minItem.itemLeadingEdge < 0) {
return;
}
loadPreviousMessagesDebounce.call(
() {
widget.onLoadPreviousMessages?.call();
},
);
}
void _cacheMessageHeight(String messageId, double height) {
heightManager.cacheHeight(messageId: messageId, height: height);
}
void _clearMessageHeightCache() {
heightManager.clearCache();
}
Future<void> _onInserted(final int position, final Message data) async {
// scroll the last user message to the top if it's the last message
if (position == oldList.length) {
await _scrollLastUserMessageToTop();
}
}
void _onRemoved(final int position, final Message data) {
final visualPosition = max(_oldList.length - position - 1, 0);
_listKey.currentState!.removeItem(
visualPosition,
(context, animation) => widget.itemBuilder(
context,
animation,
data,
isRemoved: true,
),
duration: widget.removeAnimationDuration,
);
}
void _onChanged(int position, Message oldData, Message newData) {
_onRemoved(position, oldData);
_listKey.currentState!.insertItem(
max(_oldList.length - position - 1, 0),
duration: widget.insertAnimationDuration,
);
// Clean up cached height for removed message
heightManager.removeFromCache(messageId: data.id);
}
void _onDiffUpdate(diffutil.DataDiffUpdate<Message> update) {
update.when<void>(
insert: (pos, data) => _onInserted(max(_oldList.length - pos, 0), data),
remove: (pos, data) => _onRemoved(pos, data),
change: (pos, oldData, newData) => _onChanged(pos, oldData, newData),
move: (_, __, ___) => throw UnimplementedError('unused'),
);
}
void _handleLoadPreviousMessages() {
if (widget.scrollController.offset >=
widget.scrollController.position.maxScrollExtent) {
widget.onLoadPreviousMessages?.call();
}
// do nothing
}
}
@@ -207,12 +207,21 @@ class _MobileChatInputState extends State<MobileChatInput> {
if (!UniversalPlatform.isMobile) return;
final paletteBloc = context.read<CommandPaletteBloc?>(),
paletteState = paletteBloc?.state;
final isAskingAI = paletteState?.askAI ?? false;
if (paletteBloc == null || paletteState == null) return;
final isAskingAI = paletteState.askAI;
if (!isAskingAI) return;
final query = paletteState?.query ?? '';
paletteBloc.add(CommandPaletteEvent.askedAI());
final query = paletteState.query ?? '';
if (query.isEmpty) return;
onSubmitText(query);
paletteBloc?.add(CommandPaletteEvent.askedAI());
final sources = (paletteState.askAISources ?? []).map((e) => e.id).toList();
final metadata =
context.read<AIPromptInputBloc?>()?.consumeMetadata() ?? {};
final promptState = context.read<AIPromptInputBloc?>()?.state;
final predefinedFormat = promptState?.predefinedFormat;
if (sources.isNotEmpty) {
widget.onUpdateSelectedSources(sources);
}
widget.onSubmitted.call(query, predefinedFormat, metadata);
}
void handleTextControllerChanged() {
@@ -1,17 +1,17 @@
import 'dart:async';
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_edit_document_service.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_select_message_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_select_sources_cubit.dart';
import 'package:appflowy/plugins/document/application/prelude.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/tabs/tabs_bloc.dart';
import 'package:appflowy/workspace/application/user/prelude.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_result/appflowy_result.dart';
@@ -133,23 +133,22 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
@override
Widget build(BuildContext context) {
final userWorkspaceBloc = context.read<UserWorkspaceBloc>();
final userProfile = userWorkspaceBloc.state.userProfile;
final workspaceId =
userWorkspaceBloc.state.currentWorkspace?.workspaceId ?? '';
return ViewSelector(
viewSelectorCubit: BlocProvider(
create: (context) => ViewSelectorCubit(
getIgnoreViewType: (item) {
final view = item.view;
return MultiBlocProvider(
providers: [
BlocProvider(
create: (context) => SpaceBloc(
userProfile: userProfile,
workspaceId: workspaceId,
)..add(const SpaceEvent.initial(openFirstPage: false)),
if (view.isSpace) {
return IgnoreViewType.none;
}
if (view.layout != ViewLayoutPB.Document) {
return IgnoreViewType.hide;
}
return IgnoreViewType.none;
},
),
BlocProvider(
create: (context) => ChatSettingsCubit(hideDisabled: true),
),
],
),
child: BlocSelector<SpaceBloc, SpaceState, ViewPB?>(
selector: (state) => state.currentSpace,
builder: (context, spaceView) {
@@ -188,9 +187,11 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
await updateSelection(documentId);
} else {
if (spaceView != null) {
context
.read<ChatSettingsCubit>()
.refreshSources([spaceView], spaceView);
unawaited(
context
.read<ViewSelectorCubit>()
.refreshSources([spaceView], spaceView),
);
}
popoverController.show();
}
@@ -210,7 +211,7 @@ class _SaveToPageButtonState extends State<SaveToPageButton> {
Widget buildPopover(BuildContext context) {
return BlocProvider.value(
value: context.read<ChatSettingsCubit>(),
value: context.read<ViewSelectorCubit>(),
child: SaveToPagePopoverContent(
onAddToNewPage: (parentViewId) async {
await addMessageToNewPage(context, parentViewId);
Loaded 100 of 606 files, more files were not shown because too many files have changed in this diff. Show more