Compare commits

...
45 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
476 changed files with 16418 additions and 4250 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.2"
APPFLOWY_VERSION = "0.9.3"
FLUTTER_DESKTOP_FEATURES = "dart"
PRODUCT_NAME = "AppFlowy"
MACOSX_DEPLOYMENT_TARGET = "11.0"
@@ -5,12 +5,13 @@ import 'collaborative_workspace_test.dart' as collaborative_workspace_test;
import 'share_menu_test.dart' as share_menu_test;
import 'tabs_test.dart' as tabs_test;
import 'workspace_icon_test.dart' as workspace_icon_test;
import 'workspace_settings_test.dart' as workspace_settings_test;
// import 'workspace_settings_test.dart' as workspace_settings_test;
void main() {
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
workspace_settings_test.main();
// Enable it after implementing the share feature
// workspace_settings_test.main();
share_menu_test.main();
collaborative_workspace_test.main();
change_name_and_icon_test.main();
@@ -168,6 +168,38 @@ void main() {
await tester.pumpAndSettle();
});
testWidgets('clear cells under field', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid);
await tester.scrollToRight(find.byType(GridPage));
// edit the data
await tester.editCell(
rowIndex: 0,
fieldType: FieldType.RichText,
input: 'Hello',
);
await tester.editCell(
rowIndex: 1,
fieldType: FieldType.RichText,
input: 'World',
);
expect(find.text('Hello'), findsOneWidget);
expect(find.text('World'), findsOneWidget);
// clear the cells
await tester.tapGridFieldWithName('Name');
await tester.tapClearCellsButton();
await tester.tapButtonWithName(LocaleKeys.button_confirm.tr());
expect(find.text('Hello'), findsNothing);
expect(find.text('World'), findsNothing);
});
testWidgets('create list of fields', (tester) async {
await tester.initializeAppFlowy();
await tester.tapAnonymousSignInButton();
@@ -408,7 +408,8 @@ void main() {
expect(getNodeText(node), link);
});
testWidgets('insert link and clear link name and remove link', (tester) async {
testWidgets('insert link and clear link name and remove link',
(tester) async {
const text = 'edit link', link = 'https://test.appflowy.cloud';
await prepareForToolbar(tester, text);
@@ -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);
});
}
@@ -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,
@@ -1,7 +1,11 @@
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/shared/easy_localiation_service.dart';
import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:equatable/equatable.dart';
import 'package:json_annotation/json_annotation.dart';
@@ -112,75 +116,55 @@ enum TextFormat {
}
enum AiPromptCategory {
@JsonValue("other")
other,
@JsonValue("development")
development,
@JsonValue("writing")
writing,
@JsonValue("healthAndFitness")
healthAndFitness,
@JsonValue("business")
business,
@JsonValue("marketing")
marketing,
@JsonValue("travel")
travel,
@JsonValue("contentSeo")
contentSeo,
@JsonValue("emailMarketing")
emailMarketing,
@JsonValue("paidAds")
paidAds,
@JsonValue("prCommunication")
prCommunication,
@JsonValue("recruiting")
recruiting,
@JsonValue("sales")
sales,
@JsonValue("socialMedia")
socialMedia,
@JsonValue("strategy")
strategy,
@JsonValue("caseStudies")
caseStudies,
@JsonValue("salesCopy")
salesCopy,
@JsonValue("education")
education,
@JsonValue("work")
work,
@JsonValue("podcastProduction")
podcastProduction,
@JsonValue("copyWriting")
copyWriting,
@JsonValue("customerSuccess")
customerSuccess;
String get i18n {
String get i18n => token.tr();
String get token {
return switch (this) {
other => LocaleKeys.ai_customPrompt_others.tr(),
development => LocaleKeys.ai_customPrompt_development.tr(),
writing => LocaleKeys.ai_customPrompt_writing.tr(),
healthAndFitness => LocaleKeys.ai_customPrompt_healthAndFitness.tr(),
business => LocaleKeys.ai_customPrompt_business.tr(),
marketing => LocaleKeys.ai_customPrompt_marketing.tr(),
travel => LocaleKeys.ai_customPrompt_travel.tr(),
contentSeo => LocaleKeys.ai_customPrompt_contentSeo.tr(),
emailMarketing => LocaleKeys.ai_customPrompt_emailMarketing.tr(),
paidAds => LocaleKeys.ai_customPrompt_paidAds.tr(),
prCommunication => LocaleKeys.ai_customPrompt_prCommunication.tr(),
recruiting => LocaleKeys.ai_customPrompt_recruiting.tr(),
sales => LocaleKeys.ai_customPrompt_sales.tr(),
socialMedia => LocaleKeys.ai_customPrompt_socialMedia.tr(),
strategy => LocaleKeys.ai_customPrompt_strategy.tr(),
caseStudies => LocaleKeys.ai_customPrompt_caseStudies.tr(),
salesCopy => LocaleKeys.ai_customPrompt_salesCopy.tr(),
education => LocaleKeys.ai_customPrompt_education.tr(),
work => LocaleKeys.ai_customPrompt_work.tr(),
podcastProduction => LocaleKeys.ai_customPrompt_podcastProduction.tr(),
copyWriting => LocaleKeys.ai_customPrompt_copyWriting.tr(),
customerSuccess => LocaleKeys.ai_customPrompt_customerSuccess.tr(),
other => LocaleKeys.ai_customPrompt_others,
development => LocaleKeys.ai_customPrompt_development,
writing => LocaleKeys.ai_customPrompt_writing,
healthAndFitness => LocaleKeys.ai_customPrompt_healthAndFitness,
business => LocaleKeys.ai_customPrompt_business,
marketing => LocaleKeys.ai_customPrompt_marketing,
travel => LocaleKeys.ai_customPrompt_travel,
contentSeo => LocaleKeys.ai_customPrompt_contentSeo,
emailMarketing => LocaleKeys.ai_customPrompt_emailMarketing,
paidAds => LocaleKeys.ai_customPrompt_paidAds,
prCommunication => LocaleKeys.ai_customPrompt_prCommunication,
recruiting => LocaleKeys.ai_customPrompt_recruiting,
sales => LocaleKeys.ai_customPrompt_sales,
socialMedia => LocaleKeys.ai_customPrompt_socialMedia,
strategy => LocaleKeys.ai_customPrompt_strategy,
caseStudies => LocaleKeys.ai_customPrompt_caseStudies,
salesCopy => LocaleKeys.ai_customPrompt_salesCopy,
education => LocaleKeys.ai_customPrompt_education,
work => LocaleKeys.ai_customPrompt_work,
podcastProduction => LocaleKeys.ai_customPrompt_podcastProduction,
copyWriting => LocaleKeys.ai_customPrompt_copyWriting,
customerSuccess => LocaleKeys.ai_customPrompt_customerSuccess,
};
}
}
@@ -198,13 +182,28 @@ class AiPrompt extends Equatable {
});
factory AiPrompt.fromPB(CustomPromptPB pb) {
final map = _buildCategoryNameMap();
final categories = pb.category
.split(',')
.map((categoryName) => categoryName.trim())
.map(
(categoryName) {
final entry = map.entries.firstWhereOrNull(
(entry) =>
entry.value.$1 == categoryName ||
entry.value.$2 == categoryName,
);
return entry?.key ?? AiPromptCategory.other;
},
)
.toSet()
.toList();
return AiPrompt(
id: pb.id,
name: pb.name,
content: pb.content,
category: AiPromptCategory.values.firstWhere(
(e) => e.name == pb.category,
),
category: categories,
example: pb.example,
isFeatured: false,
isCustom: true,
@@ -219,11 +218,8 @@ class AiPrompt extends Equatable {
final String id;
final String name;
final String content;
@JsonKey(
unknownEnumValue: AiPromptCategory.other,
defaultValue: AiPromptCategory.other,
)
final AiPromptCategory category;
@JsonKey(fromJson: _categoryFromJson)
final List<AiPromptCategory> category;
@JsonKey(defaultValue: "")
final String example;
@JsonKey(defaultValue: false)
@@ -234,4 +230,157 @@ class AiPrompt extends Equatable {
@override
List<Object?> get props =>
[id, name, content, category, example, isFeatured, isCustom];
static Map<AiPromptCategory, (String, String)> _buildCategoryNameMap() {
final service = getIt<EasyLocalizationService>();
return {
for (final category in AiPromptCategory.values)
category: (
service.getFallbackTranslation(category.token),
service.getFallbackTranslation(category.token),
),
};
}
static List<AiPromptCategory> _categoryFromJson(dynamic json) {
if (json is String) {
return json
.split(',')
.map((categoryName) => categoryName.trim())
.map(
(categoryName) => $enumDecode(
_aiPromptCategoryEnumMap,
categoryName,
unknownValue: AiPromptCategory.other,
),
)
.toSet()
.toList();
}
return [AiPromptCategory.other];
}
}
const _aiPromptCategoryEnumMap = {
AiPromptCategory.other: 'other',
AiPromptCategory.development: 'development',
AiPromptCategory.writing: 'writing',
AiPromptCategory.healthAndFitness: 'healthAndFitness',
AiPromptCategory.business: 'business',
AiPromptCategory.marketing: 'marketing',
AiPromptCategory.travel: 'travel',
AiPromptCategory.contentSeo: 'contentSeo',
AiPromptCategory.emailMarketing: 'emailMarketing',
AiPromptCategory.paidAds: 'paidAds',
AiPromptCategory.prCommunication: 'prCommunication',
AiPromptCategory.recruiting: 'recruiting',
AiPromptCategory.sales: 'sales',
AiPromptCategory.socialMedia: 'socialMedia',
AiPromptCategory.strategy: 'strategy',
AiPromptCategory.caseStudies: 'caseStudies',
AiPromptCategory.salesCopy: 'salesCopy',
AiPromptCategory.education: 'education',
AiPromptCategory.work: 'work',
AiPromptCategory.podcastProduction: 'podcastProduction',
AiPromptCategory.copyWriting: 'copyWriting',
AiPromptCategory.customerSuccess: 'customerSuccess',
};
class CustomPromptDatabaseConfig extends Equatable {
const CustomPromptDatabaseConfig({
required this.view,
required this.titleFieldId,
required this.contentFieldId,
required this.exampleFieldId,
required this.categoryFieldId,
});
factory CustomPromptDatabaseConfig.fromAiPB(
CustomPromptDatabaseConfigurationPB pb,
ViewPB view,
) {
final config = CustomPromptDatabaseConfig(
view: view,
titleFieldId: pb.titleFieldId,
contentFieldId: pb.contentFieldId,
exampleFieldId: pb.hasExampleFieldId() ? pb.exampleFieldId : null,
categoryFieldId: pb.hasCategoryFieldId() ? pb.categoryFieldId : null,
);
return config;
}
factory CustomPromptDatabaseConfig.fromDbPB(
CustomPromptDatabaseConfigPB pb,
ViewPB view,
) {
final config = CustomPromptDatabaseConfig(
view: view,
titleFieldId: pb.titleFieldId,
contentFieldId: pb.contentFieldId,
exampleFieldId: pb.hasExampleFieldId() ? pb.exampleFieldId : null,
categoryFieldId: pb.hasCategoryFieldId() ? pb.categoryFieldId : null,
);
return config;
}
final ViewPB view;
final String titleFieldId;
final String contentFieldId;
final String? exampleFieldId;
final String? categoryFieldId;
@override
List<Object?> get props =>
[view.id, titleFieldId, contentFieldId, exampleFieldId, categoryFieldId];
CustomPromptDatabaseConfig copyWith({
ViewPB? view,
String? titleFieldId,
String? contentFieldId,
String? exampleFieldId,
String? categoryFieldId,
}) {
return CustomPromptDatabaseConfig(
view: view ?? this.view,
titleFieldId: titleFieldId ?? this.titleFieldId,
contentFieldId: contentFieldId ?? this.contentFieldId,
exampleFieldId: exampleFieldId ?? this.exampleFieldId,
categoryFieldId: categoryFieldId ?? this.categoryFieldId,
);
}
CustomPromptDatabaseConfigurationPB toAiPB() {
final payload = CustomPromptDatabaseConfigurationPB.create()
..viewId = view.id
..titleFieldId = titleFieldId
..contentFieldId = contentFieldId;
if (exampleFieldId != null) {
payload.exampleFieldId = exampleFieldId!;
}
if (categoryFieldId != null) {
payload.categoryFieldId = categoryFieldId!;
}
return payload;
}
CustomPromptDatabaseConfigPB toDbPB() {
final payload = CustomPromptDatabaseConfigPB.create()
..viewId = view.id
..titleFieldId = titleFieldId
..contentFieldId = contentFieldId;
if (exampleFieldId != null) {
payload.exampleFieldId = exampleFieldId!;
}
if (categoryFieldId != null) {
payload.categoryFieldId = categoryFieldId!;
}
return payload;
}
}
@@ -0,0 +1,161 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/plugins/database/domain/field_service.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:bloc/bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
part 'ai_prompt_database_selector_cubit.freezed.dart';
class AiPromptDatabaseSelectorCubit
extends Cubit<AiPromptDatabaseSelectorState> {
AiPromptDatabaseSelectorCubit({
required CustomPromptDatabaseConfig? configuration,
}) : super(AiPromptDatabaseSelectorState.loading()) {
_init(configuration);
}
void _init(CustomPromptDatabaseConfig? config) async {
if (config == null) {
emit(AiPromptDatabaseSelectorState.empty());
return;
}
final fields = await _getFields(config.view.id);
if (fields == null) {
emit(AiPromptDatabaseSelectorState.empty());
return;
}
emit(
AiPromptDatabaseSelectorState.selected(
config: config,
fields: fields,
),
);
}
void selectDatabaseView(String viewId) async {
final configuration = await _testDatabase(viewId);
if (configuration == null) {
final stateCopy = state;
emit(AiPromptDatabaseSelectorState.invalidDatabase());
emit(stateCopy);
return;
}
final databaseView = await AiPromptSelectorCubit.getDatabaseView(viewId);
final fields = await _getFields(viewId);
if (databaseView == null || fields == null) {
final stateCopy = state;
emit(AiPromptDatabaseSelectorState.invalidDatabase());
emit(stateCopy);
return;
}
final config = CustomPromptDatabaseConfig.fromDbPB(
configuration,
databaseView,
);
emit(
AiPromptDatabaseSelectorState.selected(
config: config,
fields: fields,
),
);
}
void selectContentField(String fieldId) {
final state = this.state;
if (state is! _Selected) {
return;
}
final config = state.config.copyWith(
contentFieldId: fieldId,
);
emit(
AiPromptDatabaseSelectorState.selected(
config: config,
fields: state.fields,
),
);
}
void selectExampleField(String? fieldId) {
final state = this.state;
if (state is! _Selected) {
return;
}
final config = CustomPromptDatabaseConfig(
exampleFieldId: fieldId,
view: state.config.view,
titleFieldId: state.config.titleFieldId,
contentFieldId: state.config.contentFieldId,
categoryFieldId: state.config.categoryFieldId,
);
emit(
AiPromptDatabaseSelectorState.selected(
config: config,
fields: state.fields,
),
);
}
void selectCategoryField(String? fieldId) {
final state = this.state;
if (state is! _Selected) {
return;
}
final config = CustomPromptDatabaseConfig(
categoryFieldId: fieldId,
view: state.config.view,
titleFieldId: state.config.titleFieldId,
contentFieldId: state.config.contentFieldId,
exampleFieldId: state.config.exampleFieldId,
);
emit(
AiPromptDatabaseSelectorState.selected(
config: config,
fields: state.fields,
),
);
}
Future<List<FieldPB>?> _getFields(String viewId) {
return FieldBackendService.getFields(viewId: viewId).toNullable();
}
Future<CustomPromptDatabaseConfigPB?> _testDatabase(
String viewId,
) {
return DatabaseEventTestCustomPromptDatabaseConfiguration(
DatabaseViewIdPB(value: viewId),
).send().toNullable();
}
}
@freezed
class AiPromptDatabaseSelectorState with _$AiPromptDatabaseSelectorState {
const factory AiPromptDatabaseSelectorState.loading() = _Loading;
const factory AiPromptDatabaseSelectorState.empty() = _Empty;
const factory AiPromptDatabaseSelectorState.selected({
required CustomPromptDatabaseConfig config,
required List<FieldPB> fields,
}) = _Selected;
const factory AiPromptDatabaseSelectorState.invalidDatabase() =
_InvalidDatabase;
}
@@ -23,6 +23,8 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
final AIModelStateNotifier aiModelStateNotifier;
String? promptId;
@override
Future<void> close() async {
await aiModelStateNotifier.dispose();
@@ -88,7 +90,11 @@ class AIPromptInputBloc extends Bloc<AIPromptInputEvent, AIPromptInputState> {
),
);
},
updatePromptId: (promptId) {
this.promptId = promptId;
},
clearMetadata: () {
promptId = null;
emit(
state.copyWith(
attachedFiles: [],
@@ -151,6 +157,8 @@ class AIPromptInputEvent with _$AIPromptInputEvent {
const factory AIPromptInputEvent.updateMentionedViews(List<ViewPB> views) =
_UpdateMentionedViews;
const factory AIPromptInputEvent.clearMetadata() = _ClearMetadata;
const factory AIPromptInputEvent.updatePromptId(String promptId) =
_UpdatePromptId;
}
@freezed
@@ -1,12 +1,16 @@
import 'dart:async';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:bloc/bloc.dart';
import 'package:collection/collection.dart';
import 'package:flutter/widgets.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import '../../plugins/trash/application/trash_service.dart';
import 'ai_entities.dart';
import 'appflowy_ai_service.dart';
@@ -44,7 +48,7 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
isCustomPromptSectionSelected: false,
isFeaturedSectionSelected: true,
selectedPromptId: visiblePrompts.firstOrNull?.id,
customPromptDatabaseViewId: null,
databaseConfig: null,
isLoadingCustomPrompts: true,
selectedCategory: null,
favoritePrompts: [],
@@ -61,14 +65,29 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
readyState.copyWith(isLoadingCustomPrompts: true),
);
String? databaseViewId = readyState.customPromptDatabaseViewId;
if (databaseViewId == null) {
final result =
await AIEventGetCustomPromptDatabaseViewId().send().toNullable();
databaseViewId = result?.id;
CustomPromptDatabaseConfig? configuration = readyState.databaseConfig;
if (configuration == null) {
final configResult =
await AIEventGetCustomPromptDatabaseConfiguration()
.send()
.toNullable();
if (configResult != null) {
final view = await getDatabaseView(configResult.viewId);
if (view != null) {
configuration = CustomPromptDatabaseConfig.fromAiPB(
configResult,
view,
);
}
}
} else {
final view = await getDatabaseView(configuration.view.id);
if (view != null) {
configuration = configuration.copyWith(view: view);
}
}
if (databaseViewId == null) {
if (configuration == null) {
emit(
readyState.copyWith(isLoadingCustomPrompts: false),
);
@@ -78,7 +97,7 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
availablePrompts.removeWhere((prompt) => prompt.isCustom);
final customPrompts =
await _aiService.getDatabasePrompts(databaseViewId);
await _aiService.getDatabasePrompts(configuration.toDbPB());
if (customPrompts == null) {
final prompts = availablePrompts.where((prompt) => prompt.isFeatured);
@@ -92,7 +111,7 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
readyState.copyWith(
visiblePrompts: visiblePrompts.toList(),
selectedPromptId: selectedPromptId,
customPromptDatabaseViewId: databaseViewId,
databaseConfig: configuration,
isLoadingCustomPrompts: false,
isFeaturedSectionSelected: true,
isCustomPromptSectionSelected: false,
@@ -112,7 +131,7 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
emit(
readyState.copyWith(
visiblePrompts: visiblePrompts.toList(),
customPromptDatabaseViewId: databaseViewId,
databaseConfig: configuration,
isLoadingCustomPrompts: false,
selectedPromptId: selectedPromptId,
),
@@ -168,7 +187,8 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
ready: (readyState) {
final prompts = category == null
? availablePrompts
: availablePrompts.where((prompt) => prompt.category == category);
: availablePrompts
.where((prompt) => prompt.category.contains(category));
final visiblePrompts = _getFilteredPrompts(prompts);
final selectedPromptId = _getVisibleSelectedPrompt(
@@ -240,58 +260,48 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
);
}
void updateCustomPromptDatabaseViewId(String viewId) async {
final stateCopy = state;
final newState = state.maybeMap(
ready: (readyState) {
return readyState.customPromptDatabaseViewId == viewId
? null
: readyState.copyWith(isLoadingCustomPrompts: true);
},
orElse: () => null,
);
void updateCustomPromptDatabaseConfiguration(
CustomPromptDatabaseConfig configuration,
) async {
state.maybeMap(
ready: (readyState) async {
emit(
readyState.copyWith(isLoadingCustomPrompts: true),
);
if (newState == null) {
return;
}
emit(newState);
final customPrompts =
await _aiService.getDatabasePrompts(configuration.toDbPB());
await Future.delayed(const Duration(seconds: 1));
if (customPrompts == null) {
emit(AiPromptSelectorState.invalidDatabase());
emit(readyState);
return;
}
final customPrompts = await _aiService.getDatabasePrompts(viewId);
if (customPrompts == null) {
emit(AiPromptSelectorState.invalidDatabase());
emit(stateCopy);
availablePrompts
..removeWhere((prompt) => prompt.isCustom)
..addAll(customPrompts);
return;
}
await AIEventSetCustomPromptDatabaseConfiguration(
configuration.toAiPB(),
).send().onFailure(Log.error);
availablePrompts
..removeWhere((prompt) => prompt.isCustom)
..addAll(customPrompts);
await AIEventSetCustomPromptDatabaseViewId(
CustomPromptDatabaseViewIdPB()..id = viewId,
).send().onFailure(Log.error);
emit(
state.maybeMap(
ready: (readyState) {
final prompts = _getPromptsByCategory(readyState);
final visiblePrompts = _getFilteredPrompts(prompts);
final selectedPromptId = _getVisibleSelectedPrompt(
visiblePrompts,
readyState.selectedPromptId,
);
return readyState.copyWith(
final prompts = _getPromptsByCategory(readyState);
final visiblePrompts = _getFilteredPrompts(prompts);
final selectedPromptId = _getVisibleSelectedPrompt(
visiblePrompts,
readyState.selectedPromptId,
);
emit(
readyState.copyWith(
visiblePrompts: visiblePrompts.toList(),
selectedPromptId: selectedPromptId,
customPromptDatabaseViewId: viewId,
databaseConfig: configuration,
isLoadingCustomPrompts: false,
);
},
orElse: () => state,
),
),
);
},
orElse: () => {},
);
}
@@ -329,7 +339,7 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
Iterable<AiPrompt> _getPromptsByCategory(_AiPromptSelectorReadyState state) {
return availablePrompts.where((prompt) {
if (state.selectedCategory != null) {
return prompt.category == state.selectedCategory;
return prompt.category.contains(state.selectedCategory);
}
if (state.isFeaturedSectionSelected) {
return prompt.isFeatured;
@@ -352,6 +362,26 @@ class AiPromptSelectorCubit extends Cubit<AiPromptSelectorState> {
return visiblePrompts.firstOrNull?.id;
}
static Future<ViewPB?> getDatabaseView(String viewId) async {
final view = await ViewBackendService.getView(viewId).toNullable();
if (view != null) {
return view;
}
final trashViews = await TrashService().readTrash().toNullable();
final trashedItem =
trashViews?.items.firstWhereOrNull((element) => element.id == viewId);
if (trashedItem == null) {
return null;
}
return ViewPB()
..id = trashedItem.id
..name = trashedItem.name;
}
}
@freezed
@@ -371,7 +401,7 @@ class AiPromptSelectorState with _$AiPromptSelectorState {
required AiPromptCategory? selectedCategory,
required String? selectedPromptId,
required bool isLoadingCustomPrompts,
required String? customPromptDatabaseViewId,
required CustomPromptDatabaseConfig? databaseConfig,
}) = _AiPromptSelectorReadyState;
bool get isLoading => this is _AiPromptSelectorLoadingState;
@@ -8,7 +8,8 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/ai/operati
import 'package:appflowy/shared/list_extension.dart';
import 'package:appflowy_backend/dispatch/dispatch.dart';
import 'package:appflowy_backend/log.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/entities.pb.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart'
hide CustomPromptDatabaseConfigurationPB;
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -28,6 +29,7 @@ abstract class AIRepository {
String? objectId,
required String text,
PredefinedFormat? format,
String? promptId,
List<String> sourceIds = const [],
List<AiWriterRecord> history = const [],
required CompletionTypePB completionType,
@@ -42,7 +44,9 @@ abstract class AIRepository {
Future<List<AiPrompt>> getBuiltInPrompts();
Future<List<AiPrompt>?> getDatabasePrompts(String databaseViewId);
Future<List<AiPrompt>?> getDatabasePrompts(
CustomPromptDatabaseConfigPB config,
);
void updateFavoritePrompts(List<String> promptIds);
}
@@ -53,6 +57,7 @@ class AppFlowyAIService implements AIRepository {
String? objectId,
required String text,
PredefinedFormat? format,
String? promptId,
List<String> sourceIds = const [],
List<AiWriterRecord> history = const [],
required CompletionTypePB completionType,
@@ -79,6 +84,7 @@ class AppFlowyAIService implements AIRepository {
text: text,
completionType: completionType,
format: format?.toPB(),
promptId: promptId,
streamPort: fixnum.Int64(stream.nativePort),
objectId: objectId ?? '',
ragIds: [
@@ -122,14 +128,11 @@ class AppFlowyAIService implements AIRepository {
@override
Future<List<AiPrompt>?> getDatabasePrompts(
String databaseViewId,
CustomPromptDatabaseConfigPB config,
) async {
return DatabaseEventGetDatabaseCustomPrompts(
DatabaseViewIdPB(value: databaseViewId),
).send().fold(
(databasePromptsPB) {
return databasePromptsPB.items.map(AiPrompt.fromPB).toList();
},
return DatabaseEventGetDatabaseCustomPrompts(config).send().fold(
(databasePromptsPB) =>
databasePromptsPB.items.map(AiPrompt.fromPB).toList(),
(err) {
Log.error(err);
return null;
@@ -68,7 +68,10 @@ class _AiPromptCategoryListState extends State<AiPromptCategoryList> {
static Iterable<AiPromptCategory> get sortedCategories {
final categories = [...AiPromptCategory.values];
categories.sort((a, b) => a.i18n.compareTo(b.i18n));
categories
..sort((a, b) => a.i18n.compareTo(b.i18n))
..remove(AiPromptCategory.other)
..add(AiPromptCategory.other);
return categories;
}
@@ -126,7 +129,7 @@ class AiPromptFeaturedSection extends StatelessWidget {
return theme.fillColorScheme.themeSelect;
}
if (isHovering) {
return theme.fillColorScheme.primaryAlpha5;
return theme.fillColorScheme.contentHover;
}
return Colors.transparent;
},
@@ -176,7 +179,7 @@ class AiPromptCustomPromptSection extends StatelessWidget {
return theme.fillColorScheme.themeSelect;
}
if (isHovering) {
return theme.fillColorScheme.primaryAlpha5;
return theme.fillColorScheme.contentHover;
}
return Colors.transparent;
},
@@ -236,7 +239,7 @@ class AiPromptCategoryItem extends StatelessWidget {
return theme.fillColorScheme.themeSelect;
}
if (isHovering) {
return theme.fillColorScheme.primaryAlpha5;
return theme.fillColorScheme.contentHover;
}
return Colors.transparent;
},
@@ -0,0 +1,626 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/ai/service/ai_prompt_database_selector_cubit.dart';
import 'package:appflowy/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,346 +0,0 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/trash/application/trash_service.dart';
import 'package:appflowy/workspace/application/sidebar/space/space_bloc.dart';
import 'package:appflowy/workspace/application/view/prelude.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_result/appflowy_result.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:collection/collection.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
class CustomPromptDatabaseSelector extends StatefulWidget {
const CustomPromptDatabaseSelector({
super.key,
this.databaseViewId,
this.isLoading = false,
this.popoverDirection = PopoverDirection.bottomWithCenterAligned,
required this.childBuilder,
});
final String? databaseViewId;
final bool isLoading;
final PopoverDirection popoverDirection;
final Widget Function(VoidCallback) childBuilder;
@override
State<CustomPromptDatabaseSelector> createState() =>
_CustomPromptDatabaseSelectorState();
}
class _CustomPromptDatabaseSelectorState
extends State<CustomPromptDatabaseSelector> {
final popoverController = PopoverController();
@override
Widget build(BuildContext context) {
return ViewSelector(
viewSelectorCubit: BlocProvider(
create: (context) => ViewSelectorCubit(
getIgnoreViewType: getIgnoreViewType,
),
),
child: BlocSelector<SpaceBloc, SpaceState, (List<ViewPB>, ViewPB?)>(
selector: (state) => (state.spaces, state.currentSpace),
builder: (context, state) {
return AppFlowyPopover(
controller: popoverController,
triggerActions: PopoverTriggerFlags.none,
margin: EdgeInsets.zero,
offset: const Offset(0, 4.0),
direction: widget.popoverDirection,
constraints: const BoxConstraints.tightFor(width: 300, height: 400),
popupBuilder: (_) {
return BlocProvider.value(
value: context.read<ViewSelectorCubit>(),
child: _PopoverContent(
onSelectViewItem: (item) {
final view = item.view;
if (view.layout.isDatabaseView) {
context
.read<AiPromptSelectorCubit>()
.updateCustomPromptDatabaseViewId(view.id);
popoverController.close();
} else if (!view.isSpace && view.layout.isDocumentView) {
context.read<ViewSelectorCubit>().toggleIsExpanded(
item,
false,
);
}
},
),
);
},
child: widget.childBuilder(
() {
if (!widget.isLoading) {
context
.read<ViewSelectorCubit>()
.refreshSources(state.$1, state.$2);
popoverController.show();
}
},
),
);
},
),
);
}
IgnoreViewType getIgnoreViewType(ViewSelectorItem item) {
final layout = item.view.layout;
if (layout.isDatabaseView) {
return IgnoreViewType.none;
}
if (layout.isDocumentView) {
return hasDatabaseDescendent(item)
? IgnoreViewType.none
: IgnoreViewType.hide;
}
return IgnoreViewType.hide;
}
bool hasDatabaseDescendent(ViewSelectorItem viewSelectorItem) {
final layout = viewSelectorItem.view.layout;
if (layout == ViewLayoutPB.Chat) {
return false;
}
if (layout.isDatabaseView) {
return true;
}
// document may have children
return viewSelectorItem.children.any(
(child) => hasDatabaseDescendent(child),
);
}
}
class AiPromptDatabaseSelectorButton extends StatefulWidget {
const AiPromptDatabaseSelectorButton({
super.key,
required this.selectedViewId,
required this.isLoading,
required this.onTap,
});
final String? selectedViewId;
final bool isLoading;
final VoidCallback onTap;
@override
State<AiPromptDatabaseSelectorButton> createState() =>
_AiPromptDatabaseSelectorButtonState();
}
class _AiPromptDatabaseSelectorButtonState
extends State<AiPromptDatabaseSelectorButton> {
late Future<ViewPB?> viewFuture;
@override
void initState() {
super.initState();
viewFuture = getView();
}
@override
void didUpdateWidget(covariant AiPromptDatabaseSelectorButton oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.selectedViewId != widget.selectedViewId) {
viewFuture = getView();
}
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return FutureBuilder<ViewPB?>(
future: viewFuture,
builder: (context, snapshot) {
String name = "";
if (snapshot.hasData &&
snapshot.connectionState == ConnectionState.done) {
name = snapshot.data!.nameOrDefault;
}
return Row(
spacing: theme.spacing.s,
children: [
Expanded(
child: Text(
"${LocaleKeys.ai_customPrompt_promptDatabase.tr()}: $name",
maxLines: 1,
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
overflow: TextOverflow.ellipsis,
),
),
ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 150,
),
child: AFOutlinedButton.normal(
onTap: widget.onTap,
builder: (context, isHovering, disabled) {
return Row(
spacing: theme.spacing.s,
mainAxisSize: MainAxisSize.min,
children: [
if (widget.isLoading) buildLoadingIndicator(theme),
Flexible(
child: Text(
widget.isLoading
? LocaleKeys.ai_customPrompt_loading.tr()
: LocaleKeys.button_change.tr(),
maxLines: 1,
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
overflow: TextOverflow.ellipsis,
),
),
],
);
},
),
),
],
);
},
);
}
Widget buildLoadingIndicator(AppFlowyThemeData theme) {
return SizedBox.square(
dimension: 20,
child: Padding(
padding: EdgeInsets.all(2.5),
child: CircularProgressIndicator(
color: theme.iconColorScheme.tertiary,
strokeWidth: 2.0,
),
),
);
}
Future<ViewPB?> getView() async {
if (widget.selectedViewId == null) {
return null;
}
final view =
await ViewBackendService.getView(widget.selectedViewId!).toNullable();
if (view != null) {
return view;
}
final trashViews = await TrashService().readTrash().toNullable();
final trashedItem = trashViews?.items
.firstWhereOrNull((element) => element.id == widget.selectedViewId);
if (trashedItem == null) {
return null;
}
return ViewPB()
..id = trashedItem.id
..name = trashedItem.name;
}
}
class _PopoverContent extends StatefulWidget {
const _PopoverContent({
required this.onSelectViewItem,
});
final void Function(ViewSelectorItem item) onSelectViewItem;
@override
State<_PopoverContent> createState() => _PopoverContentState();
}
class _PopoverContentState extends State<_PopoverContent> {
final focusNode = FocusNode();
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
focusNode.requestFocus();
});
}
@override
void dispose() {
focusNode.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
VSpace(theme.spacing.m),
Padding(
padding: EdgeInsets.symmetric(
horizontal: theme.spacing.m,
),
child: AFTextField(
focusNode: focusNode,
size: AFTextFieldSize.m,
hintText: LocaleKeys.search_label.tr(),
controller: context.read<ViewSelectorCubit>().filterTextController,
),
),
VSpace(theme.spacing.m),
AFDivider(),
Expanded(
child: BlocBuilder<ViewSelectorCubit, ViewSelectorState>(
builder: (context, state) {
return ListView(
shrinkWrap: true,
padding: const EdgeInsets.fromLTRB(8, 4, 8, 12),
children: _buildVisibleSources(context, state).toList(),
);
},
),
),
],
);
}
Iterable<Widget> _buildVisibleSources(
BuildContext context,
ViewSelectorState state,
) {
return state.visibleSources.map(
(e) => ViewSelectorTreeItem(
key: ValueKey(
'custom_prompt_database_tree_item_${e.view.id}',
),
viewSelectorItem: e,
level: 0,
isDescendentOfSpace: e.view.isSpace,
isSelectedSection: false,
showCheckbox: false,
onSelected: widget.onSelectViewItem,
height: 30.0,
),
);
}
}
@@ -46,6 +46,7 @@ class AiPromptModal extends StatelessWidget {
final theme = AppFlowyTheme.of(context);
return AFModal(
backgroundColor: theme.backgroundColorScheme.primary,
constraints: const BoxConstraints(
maxWidth: 1200,
maxHeight: 800,
@@ -102,7 +103,7 @@ class AiPromptModal extends StatelessWidget {
child: AiPromptCategoryList(),
),
if (readyState.isCustomPromptSectionSelected &&
readyState.customPromptDatabaseViewId == null)
readyState.databaseConfig == null)
const Expanded(
flex: 5,
child: Center(
@@ -1,10 +1,12 @@
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'ai_prompt_database_selector.dart';
import 'ai_prompt_database_modal.dart';
class AiPromptOnboarding extends StatelessWidget {
const AiPromptOnboarding({super.key});
@@ -34,18 +36,24 @@ class AiPromptOnboarding extends StatelessWidget {
VSpace(
theme.spacing.xxl,
),
CustomPromptDatabaseSelector(
childBuilder: (onTap) => AFFilledButton.primary(
onTap: onTap,
builder: (context, isHovering, disabled) {
return Text(
LocaleKeys.ai_customPrompt_selectDatabase.tr(),
style: theme.textStyle.body.enhanced(
color: theme.textColorScheme.onFill,
),
);
},
),
AFFilledButton.primary(
onTap: () async {
final config = await changeCustomPromptDatabaseConfig(context);
if (config != null && context.mounted) {
context
.read<AiPromptSelectorCubit>()
.updateCustomPromptDatabaseConfiguration(config);
}
},
builder: (context, isHovering, disabled) {
return Text(
LocaleKeys.ai_customPrompt_selectDatabase.tr(),
style: theme.textStyle.body.enhanced(
color: theme.textColorScheme.onFill,
),
);
},
),
],
);
@@ -70,7 +70,7 @@ class AiPromptPreview extends StatelessWidget {
if (prompt.example.isNotEmpty) ...[
SelectionContainer.disabled(
child: Text(
LocaleKeys.ai_customPrompt_example.tr(),
LocaleKeys.ai_customPrompt_promptExample.tr(),
style: theme.textStyle.heading4.standard(
color: theme.textColorScheme.primary,
),
@@ -106,7 +106,7 @@ class _PromptContent extends StatelessWidget {
return Container(
padding: EdgeInsets.all(theme.spacing.l),
decoration: BoxDecoration(
color: theme.fillColorScheme.quaternary,
color: theme.surfaceContainerColorScheme.layer01,
borderRadius: BorderRadius.circular(theme.borderRadius.m),
),
child: Text.rich(
@@ -130,7 +130,7 @@ class _PromptContent extends StatelessWidget {
spans.add(
TextSpan(
text: part,
style: TextStyle(color: theme.textColorScheme.purple),
style: TextStyle(color: theme.textColorScheme.featured),
),
);
} else {
@@ -176,7 +176,7 @@ class _PromptExample extends StatelessWidget {
return Container(
padding: EdgeInsets.all(theme.spacing.l),
decoration: BoxDecoration(
color: theme.fillColorScheme.quaternary,
color: theme.surfaceContainerColorScheme.layer01,
borderRadius: BorderRadius.circular(theme.borderRadius.m),
),
child: AIMarkdownText(
@@ -4,6 +4,7 @@ import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/generated/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/util/theme_extension.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:diffutil_dart/diffutil.dart';
import 'package:easy_localization/easy_localization.dart';
@@ -11,7 +12,7 @@ import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'ai_prompt_database_selector.dart';
import 'ai_prompt_database_modal.dart';
const Duration _listItemAnimationDuration = Duration(milliseconds: 150);
@@ -57,89 +58,147 @@ class _AiPromptVisibleListState extends State<AiPromptVisibleList> {
Widget build(BuildContext context) {
final theme = AppFlowyTheme.of(context);
return BlocListener<AiPromptSelectorCubit, AiPromptSelectorState>(
listener: (context, state) {
state.maybeMap(
ready: (state) {
handleVisiblePromptListChanged(state.visiblePrompts);
return Column(
children: [
BlocConsumer<AiPromptSelectorCubit, AiPromptSelectorState>(
listener: (context, state) {
state.maybeMap(
ready: (state) {
handleVisiblePromptListChanged(state.visiblePrompts);
},
orElse: () {},
);
},
orElse: () {},
);
},
child: Column(
children: [
Padding(
padding: EdgeInsets.symmetric(horizontal: theme.spacing.l),
child: buildSearchField(context),
),
VSpace(
theme.spacing.s,
),
BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
buildWhen: (p, c) {
return p.maybeMap(
ready: (pr) => c.maybeMap(
ready: (cr) =>
pr.customPromptDatabaseViewId !=
cr.customPromptDatabaseViewId ||
pr.isLoadingCustomPrompts != cr.isLoadingCustomPrompts ||
pr.isCustomPromptSectionSelected !=
cr.isCustomPromptSectionSelected,
orElse: () => false,
),
orElse: () => true,
);
},
builder: (context, state) {
return state.maybeMap(
ready: (readyState) {
if (!readyState.isCustomPromptSectionSelected) {
return const SizedBox.shrink();
}
return Padding(
padding: EdgeInsets.only(
left: theme.spacing.l,
top: theme.spacing.s,
right: theme.spacing.l,
),
child: CustomPromptDatabaseSelector(
databaseViewId: readyState.customPromptDatabaseViewId,
isLoading: readyState.isLoadingCustomPrompts,
popoverDirection: PopoverDirection.bottomWithRightAligned,
childBuilder: (onTap) {
return AiPromptDatabaseSelectorButton(
selectedViewId: readyState.customPromptDatabaseViewId,
isLoading: readyState.isLoadingCustomPrompts,
onTap: onTap,
);
},
),
);
},
orElse: () => const SizedBox.shrink(),
);
},
),
Expanded(
child: TextFieldTapRegion(
groupId: "ai_prompt_category_list",
child: BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
builder: (context, state) {
return state.maybeMap(
ready: (readyState) {
if (readyState.visiblePrompts.isEmpty) {
return buildEmptyPrompts();
}
return buildPromptList();
},
orElse: () => const SizedBox.shrink(),
);
},
buildWhen: (p, c) {
return p.maybeMap(
ready: (pr) => c.maybeMap(
ready: (cr) =>
pr.databaseConfig?.view.id != cr.databaseConfig?.view.id ||
pr.isLoadingCustomPrompts != cr.isLoadingCustomPrompts ||
pr.isCustomPromptSectionSelected !=
cr.isCustomPromptSectionSelected,
orElse: () => false,
),
orElse: () => true,
);
},
builder: (context, state) {
return state.maybeMap(
ready: (readyState) {
if (!readyState.isCustomPromptSectionSelected) {
return const SizedBox.shrink();
}
return Container(
margin: EdgeInsets.only(
left: theme.spacing.l,
right: theme.spacing.l,
bottom: theme.spacing.l,
),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(theme.borderRadius.m),
color: theme.surfaceContainerColorScheme.layer01,
),
padding: EdgeInsets.all(theme.spacing.m),
child: Row(
children: [
Expanded(
child: Text.rich(
TextSpan(
children: [
TextSpan(
text:
"${LocaleKeys.ai_customPrompt_promptDatabase.tr()}: ",
style: TextStyle(fontWeight: FontWeight.w500),
),
TextSpan(
text: readyState
.databaseConfig?.view.nameOrDefault ??
"",
),
],
),
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
ConstrainedBox(
constraints: const BoxConstraints(
maxWidth: 150,
),
child: AFOutlinedButton.normal(
builder: (context, isHovering, disabled) {
return Row(
spacing: theme.spacing.s,
mainAxisSize: MainAxisSize.min,
children: [
if (readyState.isLoadingCustomPrompts)
buildLoadingIndicator(theme),
Flexible(
child: Text(
readyState.isLoadingCustomPrompts
? LocaleKeys.ai_customPrompt_loading
.tr()
: LocaleKeys.button_change.tr(),
maxLines: 1,
style: theme.textStyle.body.enhanced(
color: theme.textColorScheme.primary,
),
overflow: TextOverflow.ellipsis,
),
),
],
);
},
onTap: () async {
final newConfig =
await changeCustomPromptDatabaseConfig(
context,
config: readyState.databaseConfig,
);
if (newConfig != null && context.mounted) {
context
.read<AiPromptSelectorCubit>()
.updateCustomPromptDatabaseConfiguration(
newConfig,
);
}
},
),
),
],
),
);
},
orElse: () => const SizedBox.shrink(),
);
},
),
Padding(
padding: EdgeInsets.symmetric(horizontal: theme.spacing.l),
child: buildSearchField(context),
),
Expanded(
child: TextFieldTapRegion(
groupId: "ai_prompt_category_list",
child: BlocBuilder<AiPromptSelectorCubit, AiPromptSelectorState>(
builder: (context, state) {
return state.maybeMap(
ready: (readyState) {
if (readyState.visiblePrompts.isEmpty) {
return buildEmptyPrompts();
}
return buildPromptList();
},
orElse: () => const SizedBox.shrink(),
);
},
),
),
],
),
),
],
);
}
@@ -167,10 +226,13 @@ class _AiPromptVisibleListState extends State<AiPromptVisibleList> {
.read<AiPromptSelectorCubit>()
.filterTextController
.clear(),
child: FlowySvg(
FlowySvgs.toast_close_s,
color: theme.textColorScheme.secondary,
size: const Size.square(20),
child: MouseRegion(
cursor: SystemMouseCursors.click,
child: FlowySvg(
FlowySvgs.search_clear_m,
color: theme.iconColorScheme.tertiary,
size: const Size.square(20),
),
),
),
),
@@ -240,6 +302,19 @@ class _AiPromptVisibleListState extends State<AiPromptVisibleList> {
);
}
Widget buildLoadingIndicator(AppFlowyThemeData theme) {
return SizedBox.square(
dimension: 20,
child: Padding(
padding: EdgeInsets.all(2.5),
child: CircularProgressIndicator(
color: theme.iconColorScheme.tertiary,
strokeWidth: 2.0,
),
),
);
}
void handleVisiblePromptListChanged(
List<AiPrompt> newList,
) {
@@ -364,8 +439,8 @@ class _AiPromptListItemState extends State<_AiPromptListItem> {
? theme.borderColorScheme.themeThickHover
: theme.borderColorScheme.themeThick
: isHovering
? theme.borderColorScheme.greyTertiaryHover
: theme.borderColorScheme.greyTertiary,
? theme.borderColorScheme.primaryHover
: theme.borderColorScheme.primary,
),
),
child: Column(
@@ -15,6 +15,13 @@ import 'package:flutter_bloc/flutter_bloc.dart';
import 'browse_prompts_button.dart';
typedef OnPromptInputSubmitted = void Function(
String input,
PredefinedFormat? predefinedFormat,
Map<String, dynamic> metadata,
String? promptId,
);
class DesktopPromptInput extends StatefulWidget {
const DesktopPromptInput({
super.key,
@@ -32,8 +39,7 @@ class DesktopPromptInput extends StatefulWidget {
final bool isStreaming;
final AiPromptInputTextEditingController textController;
final void Function() onStopStreaming;
final void Function(String, PredefinedFormat?, Map<String, dynamic>)
onSubmitted;
final OnPromptInputSubmitted onSubmitted;
final ValueNotifier<List<String>> selectedSourcesNotifier;
final void Function(List<String>) onUpdateSelectedSources;
final bool hideDecoration;
@@ -241,12 +247,14 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
final sources = (paletteState.askAISources ?? []).map((e) => e.id).toList();
final metadata =
context.read<AIPromptInputBloc?>()?.consumeMetadata() ?? {};
final promptState = context.read<AIPromptInputBloc?>()?.state;
final promptBloc = context.read<AIPromptInputBloc?>();
final promptId = promptBloc?.promptId;
final promptState = promptBloc?.state;
final predefinedFormat = promptState?.predefinedFormat;
if (sources.isNotEmpty) {
widget.onUpdateSelectedSources(sources);
}
widget.onSubmitted.call(query, predefinedFormat, metadata);
widget.onSubmitted.call(query, predefinedFormat, metadata, promptId ?? '');
}
void startMentionPageFromButton() {
@@ -308,6 +316,7 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
userInput,
showPredefinedFormats ? predefinedFormat : null,
metadata,
bloc.promptId,
);
}
@@ -526,9 +535,9 @@ class _DesktopPromptInputState extends State<DesktopPromptInput> {
void handleOnSelectPrompt(AiPrompt prompt) {
final bloc = context.read<AIPromptInputBloc>();
bloc.add(
AIPromptInputEvent.updateMentionedViews([]),
);
bloc
..add(AIPromptInputEvent.updateMentionedViews([]))
..add(AIPromptInputEvent.updatePromptId(prompt.id));
final content = AiPromptInputTextEditingController.replace(prompt.content);
@@ -56,9 +56,9 @@ class AiPromptInputTextEditingController extends TextEditingController {
TextSpan(
text: restore(string),
style: theme.textStyle.body.standard().copyWith(
color: theme.textColorScheme.purple,
color: theme.textColorScheme.featured,
backgroundColor:
theme.fillColorScheme.purpleThick.withAlpha(51),
theme.fillColorScheme.featuredThick.withAlpha(51),
),
),
);
@@ -11,6 +11,7 @@ import 'package:appflowy/workspace/application/user/user_workspace_bloc.dart';
import 'package:appflowy/workspace/application/view/view_ext.dart';
import 'package:appflowy/workspace/presentation/home/menu/view/view_item.dart';
import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart';
import 'package:appflowy_ui/appflowy_ui.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart';
import 'package:flutter/material.dart';
@@ -66,6 +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;
@@ -112,7 +115,7 @@ class _PromptInputMobileSelectSourcesButtonState
await showMobileBottomSheet<void>(
context,
backgroundColor: Theme.of(context).colorScheme.surface,
backgroundColor: theme.surfaceColorScheme.primary,
maxChildSize: 0.98,
enableDraggableScrollable: true,
scrollableWidgetBuilder: (_, scrollController) {
@@ -163,7 +166,7 @@ class _MobileSelectSourcesSheetBody extends StatelessWidget {
pinned: true,
delegate: _Header(
child: ColoredBox(
color: Theme.of(context).cardColor,
color: AppFlowyTheme.of(context).surfaceColorScheme.primary,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -418,6 +418,8 @@ class ViewSelectorTreeItemInner extends StatelessWidget {
hoverColor: AFThemeExtension.of(context).lightGreyHover,
),
builder: (context, onHover) {
final theme = AppFlowyTheme.of(context);
final isSaveButtonVisible =
showSaveButton && !viewSelectorItem.view.isSpace;
final isAddButtonVisible = onAdd != null;
@@ -445,11 +447,12 @@ class ViewSelectorTreeItemInner extends StatelessWidget {
const HSpace(6.0),
// title
Expanded(
child: FlowyText(
child: Text(
viewSelectorItem.view.nameOrDefault,
overflow: TextOverflow.ellipsis,
fontSize: 14.0,
figmaLineHeight: 18.0,
style: theme.textStyle.body.standard(
color: theme.textColorScheme.primary,
),
),
),
if (onHover && (isSaveButtonVisible || isAddButtonVisible)) ...[
@@ -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,19 +2,42 @@ 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
@@ -25,26 +48,32 @@ class MobileSearchAskAiEntrance extends StatelessWidget {
},
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,
@@ -151,7 +168,7 @@ class _MobileSearchTextfieldState extends State<MobileSearchTextfield> {
padding: const EdgeInsets.fromLTRB(4, 10, 8, 10),
child: FlowySvg(
FlowySvgs.search_clear_m,
color: theme.iconColorScheme.secondary,
color: theme.iconColorScheme.tertiary,
size: const Size.square(20),
),
),
@@ -160,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() {
@@ -172,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();
}
}
}
@@ -31,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));
}
},
);
},
@@ -88,7 +90,7 @@ extension ViewAncestorTextExtension on ViewAncestorState {
return Text(
displayPath.join(' / '),
style: textStyle,
maxLines: 1,
maxLines: 2,
overflow: TextOverflow.ellipsis,
);
},
@@ -100,4 +102,37 @@ extension ViewAncestorTextExtension on ViewAncestorState {
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';
@@ -106,8 +106,8 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
receiveMessage: (message) async => _handleReceiveMessage(message),
// Sending messages
sendMessage: (message, format, metadata) async =>
_handleSendMessage(message, format, metadata, emit),
sendMessage: (message, format, metadata, promptId) async =>
_handleSendMessage(message, format, metadata, promptId, emit),
finishSending: () async => emit(
state.copyWith(
promptResponseState: PromptResponseState.streamingAnswer,
@@ -124,12 +124,17 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
// Streaming completion
didFinishAnswerStream: () async => emit(
state.copyWith(promptResponseState: PromptResponseState.ready),
state.copyWith(
promptResponseState: PromptResponseState.ready,
),
),
// Related questions
didReceiveRelatedQuestions: (questions) async =>
_handleRelatedQuestions(questions),
_handleRelatedQuestions(
questions,
emit,
),
// Message management
deleteMessage: (message) async => chatController.remove(message),
@@ -204,13 +209,14 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
String message,
PredefinedFormat? format,
Map<String, dynamic>? metadata,
String? promptId,
Emitter<ChatState> emit,
) {
_messageHandler.clearErrorMessages();
emit(state.copyWith(clearErrorMessages: !state.clearErrorMessages));
_messageHandler.clearRelatedQuestions();
_startStreamingMessage(message, format, metadata);
_startStreamingMessage(message, format, metadata, promptId);
lastSentMessage = null;
isFetchingRelatedQuestions = false;
@@ -276,7 +282,10 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
}
// Related questions handler
void _handleRelatedQuestions(List<String> questions) {
void _handleRelatedQuestions(
List<String> questions,
Emitter<ChatState> emit,
) {
if (questions.isEmpty) {
return;
}
@@ -296,6 +305,12 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
);
chatController.insert(message);
emit(
state.copyWith(
promptResponseState: PromptResponseState.relatedQuestionsReady,
),
);
}
void _startListening() {
@@ -436,6 +451,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
String message,
PredefinedFormat? format,
Map<String, dynamic>? metadata,
String? promptId,
) async {
// Prepare streams
await _streamManager.prepareStreams();
@@ -448,7 +464,7 @@ class ChatBloc extends Bloc<ChatEvent, ChatState> {
add(ChatEvent.receiveMessage(questionStreamMessage));
// Send stream request
await _streamManager.sendStreamRequest(message, format).fold(
await _streamManager.sendStreamRequest(message, format, promptId).fold(
(question) {
if (!isClosed) {
// Create and add answer stream message
@@ -540,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;
@@ -58,6 +58,9 @@ enum PromptResponseState {
ready,
sendingQuestion,
streamingAnswer,
relatedQuestionsReady;
bool get isReady => this == ready || this == relatedQuestionsReady;
}
class ChatFile extends Equatable {
@@ -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();
}
}
@@ -37,6 +37,7 @@ class ChatStreamManager {
StreamChatPayloadPB buildStreamPayload(
String message,
PredefinedFormat? format,
String? promptId,
) {
final payload = StreamChatPayloadPB(
chatId: chatId,
@@ -50,6 +51,10 @@ class ChatStreamManager {
payload.format = format.toPB();
}
if (promptId != null) {
payload.promptId = promptId;
}
return payload;
}
@@ -57,8 +62,9 @@ class ChatStreamManager {
Future<FlowyResult<ChatMessagePB, FlowyError>> sendStreamRequest(
String message,
PredefinedFormat? format,
String? promptId,
) async {
final payload = buildStreamPayload(message, format);
final payload = buildStreamPayload(message, format, promptId);
return AIEventStreamMessage(payload).send();
}
@@ -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;
}
@@ -5,7 +5,6 @@ import 'dart:async';
import 'package:appflowy/util/debounce.dart';
import 'package:appflowy_backend/log.dart';
import 'package:diffutil_dart/diffutil.dart' as diffutil;
import 'package:flowy_infra_ui/widget/spacing.dart';
import 'package:flutter/material.dart';
import 'package:flutter_chat_core/flutter_chat_core.dart';
import 'package:flutter_chat_ui/src/scroll_to_bottom.dart';
@@ -13,6 +12,9 @@ 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({
super.key,
@@ -38,21 +40,21 @@ 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 {
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;
final ScrollOffsetController scrollOffsetController =
ScrollOffsetController();
@@ -65,10 +67,10 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
final ItemPositionsListener itemPositionsListener =
ItemPositionsListener.create();
int _lastUserMessageIndex = 0;
bool _isScrollingToBottom = false;
int lastUserMessageIndex = 0;
bool isScrollingToBottom = false;
final _loadPreviousMessagesDebounce = Debounce(
final loadPreviousMessagesDebounce = Debounce(
duration: const Duration(milliseconds: 200),
);
@@ -76,15 +78,17 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
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;
messages = chatController.messages;
});
switch (event.type) {
case ChatOperationType.insert:
@@ -98,7 +102,7 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
);
_onInserted(event.index!, event.message!);
_oldList = List.from(_chatController.messages);
oldList = List.from(chatController.messages);
break;
case ChatOperationType.remove:
assert(
@@ -111,14 +115,14 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
);
_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();
@@ -126,22 +130,22 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
_onDiffUpdate(updates.elementAt(i));
}
_oldList = List.from(newList);
oldList = List.from(newList);
break;
default:
break;
}
});
messages = _chatController.messages;
messages = chatController.messages;
_scrollToBottomController = AnimationController(
scrollToBottomController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
);
_scrollToBottomAnimation = CurvedAnimation(
parent: _scrollToBottomController,
scrollToBottomAnimation = CurvedAnimation(
parent: scrollToBottomController,
curve: Curves.easeInOut,
);
@@ -152,15 +156,15 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
itemPositionsListener.itemPositions.addListener(() {
_handleLoadPreviousMessages();
});
// A trick to avoid the first message being scrolled to the top
}
@override
void dispose() {
_scrollToBottomShowTimer?.cancel();
_scrollToBottomController.dispose();
_operationsSubscription.cancel();
scrollToBottomShowTimer?.cancel();
scrollToBottomController.dispose();
operationsSubscription.cancel();
_clearMessageHeightCache();
super.dispose();
}
@@ -168,9 +172,8 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
@override
Widget build(BuildContext context) {
final builders = context.watch<Builders>();
final height = MediaQuery.of(context).size.height;
// A trick to avoid the first message being scrolled to the top
// A trick to avoid the first message being scrolled to the top
initialScrollIndex = messages.length;
initialAlignment = 1.0;
if (messages.length <= 2) {
@@ -198,29 +201,33 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
}
if (index == messages.length) {
return VSpace(height - 400);
return const SizedBox.shrink();
}
final message = messages[index];
return widget.itemBuilder(
context,
Tween<double>(begin: 1, end: 1).animate(
CurvedAnimation(
parent: _scrollToBottomController,
curve: Curves.easeInOut,
return MessageHeightCalculator(
messageId: message.id,
onHeightMeasured: _cacheMessageHeight,
child: widget.itemBuilder(
context,
Tween<double>(begin: 1, end: 1).animate(
CurvedAnimation(
parent: scrollToBottomController,
curve: Curves.easeInOut,
),
),
message,
),
message,
);
},
),
builders.scrollToBottomBuilder?.call(
context,
_scrollToBottomAnimation,
scrollToBottomAnimation,
_handleScrollToBottom,
) ??
ScrollToBottom(
animation: _scrollToBottomAnimation,
animation: scrollToBottomAnimation,
onPressed: _handleScrollToBottom,
),
],
@@ -235,11 +242,13 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
(message) => message.author.id == user.id,
);
if (lastUserMessageIndex == -1) {
// waiting for the ai answer message to be inserted
if (lastUserMessageIndex == -1 ||
lastUserMessageIndex + 1 >= messages.length) {
return;
}
if (_lastUserMessageIndex != lastUserMessageIndex) {
if (this.lastUserMessageIndex != lastUserMessageIndex) {
// scroll the current message to the top
await itemScrollController.scrollTo(
index: lastUserMessageIndex,
@@ -248,15 +257,15 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
);
}
_lastUserMessageIndex = lastUserMessageIndex;
this.lastUserMessageIndex = lastUserMessageIndex;
}
Future<void> _handleScrollToBottom() async {
_isScrollingToBottom = true;
isScrollingToBottom = true;
_scrollToBottomShowTimer?.cancel();
scrollToBottomShowTimer?.cancel();
await _scrollToBottomController.reverse();
await scrollToBottomController.reverse();
await itemScrollController.scrollTo(
index: messages.length + 1,
@@ -265,11 +274,11 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
curve: Curves.easeInOut,
);
_isScrollingToBottom = false;
isScrollingToBottom = false;
}
void _handleToggleScrollToBottom() {
if (_isScrollingToBottom) {
if (isScrollingToBottom) {
return;
}
@@ -285,15 +294,15 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
if (maxItem.index > messages.length - 1 ||
(maxItem.index == messages.length - 1 &&
maxItem.itemTrailingEdge <= 1.01)) {
_scrollToBottomShowTimer?.cancel();
_scrollToBottomController.reverse();
scrollToBottomShowTimer?.cancel();
scrollToBottomController.reverse();
return;
}
_scrollToBottomShowTimer?.cancel();
_scrollToBottomShowTimer = Timer(widget.scrollToBottomAppearanceDelay, () {
scrollToBottomShowTimer?.cancel();
scrollToBottomShowTimer = Timer(widget.scrollToBottomAppearanceDelay, () {
if (mounted) {
_scrollToBottomController.forward();
scrollToBottomController.forward();
}
});
}
@@ -307,21 +316,34 @@ class ChatAnimatedListState extends State<ChatAnimatedList>
return;
}
_loadPreviousMessagesDebounce.call(
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) {
if (position == oldList.length) {
await _scrollLastUserMessageToTop();
}
}
void _onRemoved(final int position, final Message data) {}
void _onRemoved(final int position, final Message data) {
// Clean up cached height for removed message
heightManager.removeFromCache(messageId: data.id);
}
void _onDiffUpdate(diffutil.DataDiffUpdate<Message> update) {}
void _onDiffUpdate(diffutil.DataDiffUpdate<Message> update) {
// 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() {
@@ -49,8 +49,7 @@ class _ChatFooterState extends State<ChatFooter> {
padding: AIChatUILayout.safeAreaInsets(context),
child: BlocSelector<ChatBloc, ChatState, bool>(
selector: (state) {
return state.promptResponseState ==
PromptResponseState.ready;
return state.promptResponseState.isReady;
},
builder: (context, canSendMessage) {
final chatBloc = context.read<ChatBloc>();
@@ -85,12 +84,13 @@ class _ChatFooterState extends State<ChatFooter> {
onStopStreaming: () {
chatBloc.add(const ChatEvent.stopStream());
},
onSubmitted: (text, format, metadata) {
onSubmitted: (text, format, metadata, promptId) {
chatBloc.add(
ChatEvent.sendMessage(
message: text,
format: format,
metadata: metadata,
promptId: promptId,
),
);
},
@@ -2,6 +2,7 @@ import 'dart:io';
import 'package:appflowy/ai/ai.dart';
import 'package:appflowy/plugins/ai_chat/application/ai_chat_prelude.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_message_height_manager.dart';
import 'package:appflowy/plugins/ai_chat/presentation/chat_related_question.dart';
import 'package:appflowy/plugins/ai_chat/presentation/message/ai_text_message.dart';
import 'package:appflowy/plugins/ai_chat/presentation/message/error_text_message.dart';
@@ -45,20 +46,35 @@ class TextMessageWidget extends StatelessWidget {
}
if (messageType == OnetimeShotType.relatedQuestion) {
return RelatedQuestionList(
relatedQuestions: message.metadata!['questions'],
onQuestionSelected: (question) {
final bloc = context.read<AIPromptInputBloc>();
final showPredefinedFormats = bloc.state.showPredefinedFormats;
final predefinedFormat = bloc.state.predefinedFormat;
final messages = context.read<ChatBloc>().chatController.messages;
final lastAIMessage = messages.lastWhere(
(e) =>
onetimeMessageTypeFromMeta(e.metadata) == null &&
(e.author.id == aiResponseUserId || e.author.id == systemUserId),
);
final minHeight =
ChatMessageHeightManager().calculateRelatedQuestionMinHeight(
messageId: lastAIMessage.id,
);
return Container(
constraints: BoxConstraints(
minHeight: minHeight,
),
child: RelatedQuestionList(
relatedQuestions: message.metadata!['questions'],
onQuestionSelected: (question) {
final bloc = context.read<AIPromptInputBloc>();
final showPredefinedFormats = bloc.state.showPredefinedFormats;
final predefinedFormat = bloc.state.predefinedFormat;
context.read<ChatBloc>().add(
ChatEvent.sendMessage(
message: question,
format: showPredefinedFormats ? predefinedFormat : null,
),
);
},
context.read<ChatBloc>().add(
ChatEvent.sendMessage(
message: question,
format: showPredefinedFormats ? predefinedFormat : null,
),
);
},
),
);
}
@@ -87,6 +103,8 @@ class TextMessageWidget extends StatelessWidget {
.where((e) => onetimeMessageTypeFromMeta(e.metadata) == null);
final isLastMessage =
messages.isEmpty ? false : messages.last.id == message.id;
final hasRelatedQuestions = state.promptResponseState ==
PromptResponseState.relatedQuestionsReady;
return ChatAIMessageWidget(
user: message.author,
messageUserId: message.id,
@@ -95,9 +113,9 @@ class TextMessageWidget extends StatelessWidget {
questionId: questionId,
chatId: view.id,
refSourceJsonString: refSourceJsonString,
isStreaming:
state.promptResponseState != PromptResponseState.ready,
isStreaming: !state.promptResponseState.isReady,
isLastMessage: isLastMessage,
hasRelatedQuestions: hasRelatedQuestions,
isSelectingMessages: isSelectingMessages,
enableAnimation: enableAnimation,
onSelectedMetadata: (metadata) =>
@@ -26,8 +26,8 @@ class RelatedQuestionList extends StatelessWidget {
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: relatedQuestions.length + 1,
padding:
const EdgeInsets.only(bottom: 8.0) + AIChatUILayout.messageMargin,
padding: const EdgeInsets.only(left: 4.0, bottom: 8.0) +
AIChatUILayout.messageMargin,
separatorBuilder: (context, index) => const VSpace(4.0),
itemBuilder: (context, index) {
if (index == 0) {
@@ -162,6 +162,7 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown>
// the editor is not editable in the chat
editable: false,
alwaysDistributeSimpleTableColumnWidths: UniversalPlatform.isDesktop,
customPadding: (node) => EdgeInsets.zero,
);
return IntrinsicHeight(
child: AppFlowyEditor(
@@ -186,9 +187,12 @@ class _AppFlowyEditorMarkdownState extends State<_AppFlowyEditorMarkdown>
}
if (!_animations.containsKey(node.id)) {
final duration = UniversalPlatform.isMobile
? const Duration(milliseconds: 800)
: const Duration(milliseconds: 1600);
final controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1600),
duration: duration,
);
final fade = Tween<double>(
begin: 0,
@@ -203,6 +203,7 @@ class _ChatAIMessageHoverState extends State<ChatAIMessageHover> {
showWhenUnlinked: false,
link: layerLink,
targetAnchor: Alignment.bottomLeft,
offset: const Offset(4.0, 0.0),
child: Align(
alignment: Alignment.topLeft,
child: MouseRegion(
@@ -3,7 +3,9 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_ai_message_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_bloc.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_entity.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_message_height_manager.dart';
import 'package:appflowy/plugins/ai_chat/application/chat_message_stream.dart';
import 'package:appflowy/plugins/ai_chat/presentation/widgets/message_height_calculator.dart';
import 'package:appflowy_backend/protobuf/flowy-ai/protobuf.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:fixnum/fixnum.dart';
@@ -42,6 +44,7 @@ class ChatAIMessageWidget extends StatelessWidget {
this.isStreaming = false,
this.isSelectingMessages = false,
this.enableAnimation = true,
this.hasRelatedQuestions = false,
});
final User user;
@@ -61,6 +64,7 @@ class ChatAIMessageWidget extends StatelessWidget {
final bool isLastMessage;
final bool isSelectingMessages;
final bool enableAnimation;
final bool hasRelatedQuestions;
@override
Widget build(BuildContext context) {
@@ -79,74 +83,98 @@ class ChatAIMessageWidget extends StatelessWidget {
final loadingText = blocState.progress?.step ??
LocaleKeys.chat_generatingResponse.tr();
return Padding(
padding: AIChatUILayout.messageMargin,
child: blocState.messageState.when(
loading: () => ChatAIMessageBubble(
message: message,
showActions: false,
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: AILoadingIndicator(text: loadingText),
),
),
ready: () {
return blocState.text.isEmpty
? _LoadingMessage(
message: message,
loadingText: loadingText,
)
: _NonEmptyMessage(
user: user,
messageUserId: messageUserId,
message: message,
stream: stream,
questionId: questionId,
chatId: chatId,
refSourceJsonString: refSourceJsonString,
onStopStream: onStopStream,
onSelectedMetadata: onSelectedMetadata,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
isLastMessage: isLastMessage,
isStreaming: isStreaming,
isSelectingMessages: isSelectingMessages,
enableAnimation: enableAnimation,
);
},
onError: (error) {
return ChatErrorMessageWidget(
errorMessage: LocaleKeys.chat_aiServerUnavailable.tr(),
);
},
onAIResponseLimit: () {
return ChatErrorMessageWidget(
errorMessage:
LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(),
);
},
onAIImageResponseLimit: () {
return ChatErrorMessageWidget(
errorMessage: LocaleKeys.sideBar_purchaseAIMax.tr(),
);
},
onAIMaxRequired: (message) {
return ChatErrorMessageWidget(
errorMessage: message,
);
},
onInitializingLocalAI: () {
onStopStream();
// Calculate minimum height only for the last AI answer message
double minHeight = 0;
if (isLastMessage && !hasRelatedQuestions) {
final screenHeight = MediaQuery.of(context).size.height;
minHeight = ChatMessageHeightManager().calculateMinHeight(
messageId: message.id,
screenHeight: screenHeight,
);
}
return ChatErrorMessageWidget(
errorMessage:
LocaleKeys.settings_aiPage_keys_localAIInitializing.tr(),
return Container(
alignment: Alignment.topLeft,
constraints: BoxConstraints(
minHeight: minHeight,
),
padding: AIChatUILayout.messageMargin,
child: MessageHeightCalculator(
messageId: message.id,
onHeightMeasured: (messageId, height) {
ChatMessageHeightManager().cacheWithoutMinHeight(
messageId: messageId,
height: height,
);
},
aiFollowUp: (followUpData) {
return const SizedBox.shrink();
},
child: blocState.messageState.when(
loading: () => ChatAIMessageBubble(
message: message,
showActions: false,
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: AILoadingIndicator(text: loadingText),
),
),
ready: () {
return blocState.text.isEmpty
? _LoadingMessage(
message: message,
loadingText: loadingText,
)
: _NonEmptyMessage(
user: user,
messageUserId: messageUserId,
message: message,
stream: stream,
questionId: questionId,
chatId: chatId,
refSourceJsonString: refSourceJsonString,
onStopStream: onStopStream,
onSelectedMetadata: onSelectedMetadata,
onRegenerate: onRegenerate,
onChangeFormat: onChangeFormat,
onChangeModel: onChangeModel,
isLastMessage: isLastMessage,
isStreaming: isStreaming,
isSelectingMessages: isSelectingMessages,
enableAnimation: enableAnimation,
);
},
onError: (error) {
return ChatErrorMessageWidget(
errorMessage: LocaleKeys.chat_aiServerUnavailable.tr(),
);
},
onAIResponseLimit: () {
return ChatErrorMessageWidget(
errorMessage:
LocaleKeys.sideBar_askOwnerToUpgradeToAIMax.tr(),
);
},
onAIImageResponseLimit: () {
return ChatErrorMessageWidget(
errorMessage: LocaleKeys.sideBar_purchaseAIMax.tr(),
);
},
onAIMaxRequired: (message) {
return ChatErrorMessageWidget(
errorMessage: message,
);
},
onInitializingLocalAI: () {
onStopStream();
return ChatErrorMessageWidget(
errorMessage: LocaleKeys
.settings_aiPage_keys_localAIInitializing
.tr(),
);
},
aiFollowUp: (followUpData) {
return const SizedBox.shrink();
},
),
),
);
},
@@ -188,7 +216,7 @@ class _LoadingMessage extends StatelessWidget {
message: message,
showActions: false,
child: Padding(
padding: const EdgeInsets.only(top: 8.0),
padding: EdgeInsetsDirectional.only(start: 4.0, top: 8.0),
child: AILoadingIndicator(text: loadingText),
),
);
@@ -248,9 +276,12 @@ class _NonEmptyMessage extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AIMarkdownText(
markdown: state.text,
withAnimation: enableAnimation && stream != null,
Padding(
padding: EdgeInsetsDirectional.only(start: 4.0),
child: AIMarkdownText(
markdown: state.text,
withAnimation: enableAnimation && stream != null,
),
),
if (state.sources.isNotEmpty)
SelectionContainer.disabled(
@@ -0,0 +1,126 @@
import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
/// Callback type for height measurement
typedef HeightMeasuredCallback = void Function(String messageId, double height);
/// Widget that measures and caches message heights with proper lifecycle management
class MessageHeightCalculator extends StatefulWidget {
const MessageHeightCalculator({
super.key,
required this.messageId,
required this.child,
this.onHeightMeasured,
});
final String messageId;
final Widget child;
final HeightMeasuredCallback? onHeightMeasured;
@override
State<MessageHeightCalculator> createState() =>
_MessageHeightCalculatorState();
}
class _MessageHeightCalculatorState extends State<MessageHeightCalculator>
with WidgetsBindingObserver {
final GlobalKey measureKey = GlobalKey();
double? lastMeasuredHeight;
bool isMeasuring = false;
int measurementAttempts = 0;
static const int maxMeasurementAttempts = 3;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
_scheduleMeasurement();
}
@override
void didUpdateWidget(MessageHeightCalculator oldWidget) {
super.didUpdateWidget(oldWidget);
if (oldWidget.messageId != widget.messageId) {
_resetMeasurement();
_scheduleMeasurement();
}
}
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
@override
void didChangeMetrics() {
if (mounted) {
_scheduleMeasurement();
}
}
@override
Widget build(BuildContext context) {
return KeyedSubtree(
key: measureKey,
child: widget.child,
);
}
void _resetMeasurement() {
lastMeasuredHeight = null;
isMeasuring = false;
measurementAttempts = 0;
}
void _scheduleMeasurement() {
if (isMeasuring || !mounted) return;
isMeasuring = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_measureHeight();
});
}
void _measureHeight() {
if (!mounted || measurementAttempts >= maxMeasurementAttempts) {
isMeasuring = false;
return;
}
measurementAttempts++;
try {
final renderBox =
measureKey.currentContext?.findRenderObject() as RenderBox?;
if (renderBox == null || !renderBox.hasSize) {
// Retry measurement in next frame if render box is not ready
if (measurementAttempts < maxMeasurementAttempts) {
SchedulerBinding.instance.addPostFrameCallback((_) {
if (mounted) _measureHeight();
});
} else {
isMeasuring = false;
}
return;
}
final height = renderBox.size.height;
if (lastMeasuredHeight == null ||
(height - (lastMeasuredHeight ?? 0)).abs() > 1.0) {
lastMeasuredHeight = height;
widget.onHeightMeasured?.call(widget.messageId, height);
}
isMeasuring = false;
} catch (e) {
isMeasuring = false;
}
}
}
@@ -46,7 +46,10 @@ class HiddenGroupsColumn extends StatelessWidget {
}
final isCollapsed = layoutSettings.collapseHiddenGroups;
final leftPadding = margin.left +
context.read<DatabasePluginWidgetBuilderSize>().horizontalPadding;
context.read<DatabasePluginWidgetBuilderSize>().paddingLeft;
final leftPaddingWithMaxDocWidth = context
.read<DatabasePluginWidgetBuilderSize>()
.paddingLeftWithMaxDocumentWidth;
return AnimatedSize(
alignment: AlignmentDirectional.topStart,
curve: Curves.easeOut,
@@ -55,14 +58,17 @@ class HiddenGroupsColumn extends StatelessWidget {
? SizedBox(
height: 50,
child: Padding(
padding: const EdgeInsets.only(left: 80, right: 8),
padding: EdgeInsets.only(
left: 80 + leftPaddingWithMaxDocWidth,
right: 8,
),
child: Center(
child: _collapseExpandIcon(context, isCollapsed),
),
),
)
: Container(
width: 274,
width: 274 + leftPaddingWithMaxDocWidth,
padding: EdgeInsets.only(
left: leftPadding,
right: margin.right + 4,
@@ -221,6 +221,8 @@ class _CalendarPageState extends State<CalendarPage> {
return LayoutBuilder(
// must specify MonthView width for useAvailableVerticalSpace to work properly
builder: (context, constraints) {
final paddingLeft =
context.read<DatabasePluginWidgetBuilderSize>().paddingLeft;
EdgeInsets padding = UniversalPlatform.isMobile
? CalendarSize.contentInsetsMobile
: CalendarSize.contentInsets +
@@ -230,6 +232,7 @@ class _CalendarPageState extends State<CalendarPage> {
if (horizontalPadding == 0) {
padding = padding.copyWith(left: 0, right: 0);
}
padding = padding.copyWith(left: paddingLeft + padding.left);
return Padding(
padding: padding,
child: ScrollConfiguration(
@@ -145,6 +145,17 @@ class FieldBackendService {
return DatabaseEventUpdateFieldTypeOption(payload).send();
}
static Future<FlowyResult<List<FieldPB>, FlowyError>> getFields({
required String viewId,
}) {
final payload = GetFieldPayloadPB.create()..viewId = viewId;
return DatabaseEventGetFields(payload).send().fold(
(repeated) => FlowySuccess(repeated.items),
(error) => FlowyFailure(error),
);
}
/// Returns the primary field of the view.
static Future<FlowyResult<FieldPB, FlowyError>> getPrimaryField({
required String viewId,
@@ -409,6 +409,10 @@ class _GridRowsState extends State<_GridRows> {
@override
Widget build(BuildContext context) {
final paddingLeft = context
.read<DatabasePluginWidgetBuilderSize?>()
?.paddingLeftWithMaxDocumentWidth ??
0.0;
Widget child;
if (widget.shrinkWrap) {
child = Scrollbar(
@@ -420,9 +424,10 @@ class _GridRowsState extends State<_GridRows> {
constraints: BoxConstraints(
maxWidth: GridLayout.headerWidth(
context
.read<DatabasePluginWidgetBuilderSize>()
.horizontalPadding *
3,
.read<DatabasePluginWidgetBuilderSize>()
.horizontalPadding *
3 +
paddingLeft,
context.read<GridBloc>().state.fields,
),
),
@@ -459,13 +464,16 @@ class _GridRowsState extends State<_GridRows> {
Widget _shrinkWrapRenderList(BuildContext context) {
final state = context.read<GridBloc>().state;
final horizontalPadding =
context.read<DatabasePluginWidgetBuilderSize?>()?.horizontalPadding ??
0.0;
final databaseSize = context.read<DatabasePluginWidgetBuilderSize?>();
return ListView(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: EdgeInsets.symmetric(horizontal: horizontalPadding),
padding: EdgeInsets.fromLTRB(
databaseSize?.paddingLeft ?? 0.0,
0,
databaseSize?.horizontalPadding ?? 0.0,
0,
),
children: [
widget.shrinkWrap
? _reorderableListView(state)
@@ -94,12 +94,16 @@ class _GridFieldCellState extends State<GridFieldCell> {
onFieldInserted: widget.onFieldInsertedOnEitherSide,
);
},
child: SizedBox(
height: GridSize.headerHeight,
child: FieldCellButton(
field: widget.fieldInfo.field,
onTap: widget.onTap,
margin: const EdgeInsetsDirectional.fromSTEB(12, 9, 10, 9),
child: FlowyTooltip(
message: widget.fieldInfo.name,
preferBelow: false,
child: SizedBox(
height: GridSize.headerHeight,
child: FieldCellButton(
field: widget.fieldInfo.field,
onTap: widget.onTap,
margin: const EdgeInsetsDirectional.fromSTEB(12, 9, 10, 9),
),
),
),
);
@@ -39,9 +39,9 @@ class _GridHeaderSliverAdaptorState extends State<GridHeaderSliverAdaptor> {
Widget build(BuildContext context) {
final fieldController =
context.read<GridBloc>().databaseController.fieldController;
final horizontalPadding =
context.read<DatabasePluginWidgetBuilderSize?>()?.horizontalPadding ??
0.0;
final databaseSize = context.read<DatabasePluginWidgetBuilderSize?>();
final horizontalPadding = databaseSize?.horizontalPadding ?? 0.0;
final paddingLeft = databaseSize?.paddingLeftWithMaxDocumentWidth ?? 0.0;
return BlocProvider(
create: (context) {
return GridHeaderBloc(
@@ -54,7 +54,12 @@ class _GridHeaderSliverAdaptorState extends State<GridHeaderSliverAdaptor> {
controller: widget.anchorScrollController,
child: Padding(
padding: widget.shrinkWrap
? EdgeInsets.symmetric(horizontal: horizontalPadding)
? EdgeInsets.fromLTRB(
horizontalPadding + paddingLeft,
0,
horizontalPadding,
0,
)
: EdgeInsets.zero,
child: _GridHeader(
viewId: widget.viewId,
@@ -292,25 +292,29 @@ class _TabBarItemButtonState extends State<TabBarItemButton> {
);
},
child: IntrinsicWidth(
child: FlowyButton(
radius: Corners.s6Border,
hoverColor: AFThemeExtension.of(context).greyHover,
onTap: () {
if (widget.isSelected) menuController.show();
widget.onTap.call();
},
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
onSecondaryTap: () {
menuController.show();
},
leftIcon: _buildViewIcon(),
text: FlowyText(
widget.view.nameOrDefault,
lineHeight: 1.0,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
color: color,
fontWeight: widget.isSelected ? FontWeight.w500 : FontWeight.w400,
child: FlowyTooltip(
message: widget.view.nameOrDefault,
preferBelow: false,
child: FlowyButton(
radius: Corners.s6Border,
hoverColor: AFThemeExtension.of(context).greyHover,
onTap: () {
if (widget.isSelected) menuController.show();
widget.onTap.call();
},
margin: const EdgeInsets.symmetric(horizontal: 12, vertical: 5),
onSecondaryTap: () {
menuController.show();
},
leftIcon: _buildViewIcon(),
text: FlowyText(
widget.view.nameOrDefault,
lineHeight: 1.0,
textAlign: TextAlign.center,
overflow: TextOverflow.ellipsis,
color: color,
fontWeight: widget.isSelected ? FontWeight.w500 : FontWeight.w400,
),
),
),
),
@@ -1,5 +1,6 @@
import 'dart:async';
import 'dart:convert';
import 'dart:math';
import 'package:appflowy/core/config/kv.dart';
import 'package:appflowy/core/config/kv_keys.dart';
@@ -132,96 +133,115 @@ class _DatabaseTabBarViewState extends State<DatabaseTabBarView> {
@override
Widget build(BuildContext context) {
if (!initialed) return Center(child: CircularProgressIndicator());
return MultiBlocProvider(
providers: [
BlocProvider<DatabaseTabBarBloc>(
create: (_) => DatabaseTabBarBloc(
view: widget.view,
compactModeId: compactModeId,
enableCompactMode: enableCompactMode,
)..add(const DatabaseTabBarEvent.initial()),
),
BlocProvider<ViewBloc>(
create: (_) => ViewBloc(view: widget.view)
..add(
const ViewEvent.initial(),
return LayoutBuilder(
builder: (context, constraints) {
final maxWidth = constraints.maxWidth;
final editorState = context.read<EditorState?>();
final maxDocWidth = editorState?.editorStyle.maxWidth ?? maxWidth;
final paddingLeft = max(0, maxWidth - maxDocWidth) / 2;
return MultiBlocProvider(
providers: [
BlocProvider<DatabaseTabBarBloc>(
create: (_) => DatabaseTabBarBloc(
view: widget.view,
compactModeId: compactModeId,
enableCompactMode: enableCompactMode,
)..add(const DatabaseTabBarEvent.initial()),
),
),
],
child: BlocBuilder<DatabaseTabBarBloc, DatabaseTabBarState>(
builder: (innerContext, state) {
final layout = state.tabBars[state.selectedIndex].layout;
final isCalendar = layout == ViewLayoutPB.Calendar;
final horizontalPadding =
context.read<DatabasePluginWidgetBuilderSize>().horizontalPadding;
final showActionWrapper = widget.showActions &&
widget.actionBuilder != null &&
widget.node != null;
final Widget child = Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (UniversalPlatform.isMobile) const VSpace(12),
ValueListenableBuilder<bool>(
valueListenable: state
.tabBarControllerByViewId[state.parentView.id]!
.controller
.isLoading,
builder: (_, value, ___) {
if (value) {
return const SizedBox.shrink();
}
Widget child = UniversalPlatform.isDesktop
? const TabBarHeader()
: const MobileTabBarHeader();
if (innerContext.watch<ViewBloc>().state.view.isLocked) {
child = IgnorePointer(
child: child,
);
}
if (showActionWrapper) {
child = BlockComponentActionWrapper(
node: widget.node!,
actionBuilder: widget.actionBuilder!,
child: Padding(
padding: EdgeInsets.only(right: horizontalPadding),
child: child,
),
);
}
if (UniversalPlatform.isDesktop) {
child = Container(
padding: EdgeInsets.symmetric(
horizontal: horizontalPadding,
),
child: child,
);
}
return child;
},
),
pageSettingBarExtensionFromState(context, state),
wrapContent(
layout: layout,
child: Padding(
padding:
(isCalendar && widget.shrinkWrap || showActionWrapper)
? EdgeInsets.only(left: 42 - horizontalPadding)
: EdgeInsets.zero,
child: pageContentFromState(context, state),
BlocProvider<ViewBloc>(
create: (_) => ViewBloc(view: widget.view)
..add(
const ViewEvent.initial(),
),
),
],
);
),
],
child: BlocBuilder<DatabaseTabBarBloc, DatabaseTabBarState>(
builder: (innerContext, state) {
final layout = state.tabBars[state.selectedIndex].layout;
final isCalendar = layout == ViewLayoutPB.Calendar;
final databseBuilderSize =
context.read<DatabasePluginWidgetBuilderSize>();
final horizontalPadding = databseBuilderSize.horizontalPadding;
final showActionWrapper = widget.showActions &&
widget.actionBuilder != null &&
widget.node != null;
final Widget child = Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
if (UniversalPlatform.isMobile) const VSpace(12),
ValueListenableBuilder<bool>(
valueListenable: state
.tabBarControllerByViewId[state.parentView.id]!
.controller
.isLoading,
builder: (_, value, ___) {
if (value) {
return const SizedBox.shrink();
}
return child;
},
),
Widget child = UniversalPlatform.isDesktop
? const TabBarHeader()
: const MobileTabBarHeader();
if (innerContext.watch<ViewBloc>().state.view.isLocked) {
child = IgnorePointer(
child: child,
);
}
if (showActionWrapper) {
child = BlockComponentActionWrapper(
node: widget.node!,
actionBuilder: widget.actionBuilder!,
child: Padding(
padding: EdgeInsets.only(right: horizontalPadding),
child: child,
),
);
}
if (UniversalPlatform.isDesktop) {
child = Container(
padding: EdgeInsets.fromLTRB(
horizontalPadding + paddingLeft,
0,
horizontalPadding,
0,
),
child: child,
);
}
return child;
},
),
pageSettingBarExtensionFromState(context, state),
wrapContent(
layout: layout,
child: Padding(
padding:
(isCalendar && widget.shrinkWrap || showActionWrapper)
? EdgeInsets.only(left: 42 - horizontalPadding)
: EdgeInsets.zero,
child: Provider(
create: (_) => DatabasePluginWidgetBuilderSize(
horizontalPadding: horizontalPadding,
paddingLeftWithMaxDocumentWidth: paddingLeft,
verticalPadding: databseBuilderSize.verticalPadding,
),
child: pageContentFromState(context, state),
),
),
),
],
);
return child;
},
),
);
},
);
}
@@ -365,10 +385,14 @@ class DatabasePluginWidgetBuilderSize {
const DatabasePluginWidgetBuilderSize({
required this.horizontalPadding,
this.verticalPadding = 16.0,
this.paddingLeftWithMaxDocumentWidth = 0.0,
});
final double horizontalPadding;
final double verticalPadding;
final double paddingLeftWithMaxDocumentWidth;
double get paddingLeft => paddingLeftWithMaxDocumentWidth + horizontalPadding;
}
class DatabasePluginWidgetBuilder extends PluginWidgetBuilder {
Loaded 100 of 476 files, more files were not shown because too many files have changed in this diff. Show more