Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c41807e78f | ||
|
|
06fe7d60e2 | ||
|
|
75f0f08c78 | ||
|
|
8fcbf78714 | ||
|
|
837e754946 | ||
|
|
ad86e1f55a | ||
|
|
c78564ea79 | ||
|
|
29f8f0d317 | ||
|
|
4461c77992 | ||
|
|
dc5f463e84 | ||
|
|
a92e59c693 | ||
|
|
54206c446f | ||
|
|
d93929a3b6 | ||
|
|
af6d1f4701 | ||
|
|
73757dad6b | ||
|
|
ec1c5c40e2 | ||
|
|
95f3304cae | ||
|
|
8301c3f2c3 | ||
|
|
581a2b41a8 | ||
|
|
5e397c06d2 | ||
|
|
02dc7ab6b3 | ||
|
|
b4451bf54a | ||
|
|
47b77528ce | ||
|
|
e9f12832fb | ||
|
|
b0a02e6870 | ||
|
|
f5e2e4e915 | ||
|
|
fbb8f01e1a | ||
|
|
b085f4feec | ||
|
|
3cffb34364 | ||
|
|
ffef4a4e3b | ||
|
|
0b24f2cd37 | ||
|
|
d80d0285f9 | ||
|
|
c8acd0227a | ||
|
|
105f244a4b | ||
|
|
8a80ded187 | ||
|
|
49b56f0a6e | ||
|
|
c634e906ea | ||
|
|
03d35f8fe1 | ||
|
|
a7b795d3cc | ||
|
|
f4ae25f8d3 | ||
|
|
2e4e71f459 | ||
|
|
479fd45cab | ||
|
|
9cac7e5a91 | ||
|
|
bb4f860f37 | ||
|
|
fed9d43fc5 |
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -7,7 +7,7 @@ on:
|
||||
|
||||
env:
|
||||
FLUTTER_VERSION: "3.27.4"
|
||||
RUST_TOOLCHAIN: "1.84.0"
|
||||
RUST_TOOLCHAIN: "1.85.0"
|
||||
|
||||
jobs:
|
||||
create-release:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
+3
-2
@@ -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();
|
||||
|
||||
+2
-1
@@ -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;
|
||||
|
||||
+7
-4
@@ -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;
|
||||
},
|
||||
|
||||
+626
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
-346
@@ -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(
|
||||
|
||||
+21
-13
@@ -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(
|
||||
|
||||
+161
-86
@@ -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);
|
||||
|
||||
|
||||
+2
-2
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
+5
-2
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+159
@@ -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);
|
||||
}
|
||||
}
|
||||
+126
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+41
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
+141
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+55
@@ -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(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+95
@@ -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,
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+21
@@ -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(),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
+79
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
+74
@@ -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);
|
||||
});
|
||||
}
|
||||
}
|
||||
+81
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
+112
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
+33
@@ -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);
|
||||
}
|
||||
}
|
||||
+28
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
+12
@@ -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;
|
||||
}
|
||||
+67
@@ -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(),
|
||||
);
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+27
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+45
@@ -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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
+25
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+25
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+16
@@ -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;
|
||||
}
|
||||
|
||||
+3
-3
@@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
+2
-1
@@ -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);
|
||||
|
||||
+143
-14
@@ -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(),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
+172
@@ -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),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
+125
-120
@@ -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),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
+340
@@ -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,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
+78
-49
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
+37
-2
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -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';
|
||||
|
||||
+1
-1
@@ -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 {
|
||||
|
||||
+160
@@ -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;
|
||||
}
|
||||
|
||||
+80
-58
@@ -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
|
||||
}
|
||||
}
|
||||
+13
-4
@@ -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() {
|
||||
|
||||
+3
-3
@@ -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,
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
+33
-15
@@ -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) =>
|
||||
|
||||
+2
-2
@@ -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) {
|
||||
|
||||
+5
-1
@@ -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,
|
||||
|
||||
+1
@@ -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(
|
||||
|
||||
+100
-69
@@ -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(
|
||||
|
||||
+126
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
+9
-3
@@ -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,
|
||||
|
||||
+3
@@ -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)
|
||||
|
||||
+10
-6
@@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
+9
-4
@@ -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,
|
||||
|
||||
+23
-19
@@ -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 {
|
||||
|
||||
File renamed without changes.
Loaded 100 of 476 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user