From 009c2ed402abda125e37dcdc1f508127d328adf7 Mon Sep 17 00:00:00 2001 From: Mathias Mogensen <42929161+Xazin@users.noreply.github.com> Date: Thu, 5 Sep 2024 06:03:58 +0200 Subject: [PATCH] feat: media type option (#6090) * feat: media type option * feat: rename files + more media types * feat: add basic style for cards on desktop * test: add media * chore: clippy * feat: row detail ux improvement * feat: show all media attachments as one count on card * chore: localization * fix: overlay issue + disable filter for option * test: add base integration test * test: add pumpAndSettle * chore: update collab revision * feat: mobile grid & board styling * fix: clippy * fix: grid reorder rows bug * fix: style discrepancy * chore: clippy * chore: code cleanup --- .../desktop/database/database_media_test.dart | 96 ++++ .../shared/database_test_op.dart | 9 + .../appflowy_flutter/ios/Runner/Info.plist | 2 + .../mobile_card_detail_screen.dart | 3 +- .../widgets/mobile_row_property_list.dart | 3 +- .../database/card/mobile_card_content.dart | 55 +- .../field/mobile_edit_field_screen.dart | 7 +- .../field/mobile_field_bottom_sheets.dart | 1 + .../field/mobile_quick_field_editor.dart | 7 +- .../cell/bloc/media_cell_bloc.dart | 220 ++++++++ .../cell/cell_controller_builder.dart | 13 + .../application/cell/cell_data_loader.dart | 16 + .../board/presentation/board_page.dart | 6 +- .../presentation/calendar_event_editor.dart | 2 +- .../database/domain/filter_service.dart | 24 + .../filter/filter_create_bloc.dart | 13 +- .../database/grid/presentation/grid_page.dart | 14 +- .../widgets/filter/create_filter_list.dart | 9 +- .../widgets/header/field_type_extension.dart | 7 + .../presentation/widgets/row/mobile_row.dart | 3 +- .../grid/presentation/widgets/row/row.dart | 4 +- .../plugins/database/widgets/card/card.dart | 38 +- .../widgets/cell/card_cell_builder.dart | 7 + .../card_cell_skeleton/media_card_cell.dart | 39 ++ .../card_cell_skeleton/text_card_cell.dart | 8 +- .../calendar_card_cell_style.dart | 9 +- .../desktop_board_card_cell_style.dart | 9 +- .../mobile_board_card_cell_style.dart | 5 + .../desktop_grid/desktop_grid_media_cell.dart | 237 ++++++++ .../desktop_row_detail_media_cell.dart | 533 ++++++++++++++++++ ...desktop_row_detail_select_option_cell.dart | 16 +- .../widgets/cell/editable_cell_builder.dart | 18 + .../cell/editable_cell_skeleton/media.dart | 106 ++++ .../mobile_row_detail_checkbox_cell.dart | 3 +- .../cell_editor/media_cell_editor.dart | 507 +++++++++++++++++ .../mobile_checklist_cell_editor.dart | 22 +- .../cell_editor/mobile_media_cell_editor.dart | 376 ++++++++++++ .../widgets/field/field_type_list.dart | 1 + .../field/type_option_editor/builder.dart | 2 + .../field/type_option_editor/media.dart | 19 + .../database/widgets/media_file_type_ext.dart | 28 + .../widgets/row/accessory/cell_accessory.dart | 7 + .../widgets/row/cells/cell_container.dart | 3 +- .../database/widgets/row/row_banner.dart | 3 +- .../database/widgets/row/row_property.dart | 6 +- .../presentation/database_document_title.dart | 3 +- .../document/application/document_bloc.dart | 3 +- .../application/document_service.dart | 24 +- .../lib/plugins/document/document_page.dart | 5 +- .../copy_and_paste/paste_from_image.dart | 2 +- .../file/file_block_component.dart | 9 +- .../editor_plugins/file/file_block_menu.dart | 19 +- .../editor_plugins/file/file_upload_menu.dart | 43 +- .../editor_plugins/file/file_util.dart | 140 ++++- .../image/image_placeholder.dart | 2 +- .../layouts/image_browser_layout.dart | 4 +- .../multi_image_placeholder.dart | 5 +- .../upload_image_menu/upload_image_menu.dart | 3 +- .../lib/shared/patterns/common_patterns.dart | 3 - .../shared/patterns/file_type_patterns.dart | 29 + .../lib/startup/tasks/file_storage_task.dart | 7 +- .../lib/startup/tasks/generate_router.dart | 5 +- .../lib/util/field_type_extension.dart | 3 + .../appflowy_flutter/lib/util/xfile_ext.dart | 109 ++++ .../interactive_image_toolbar.dart | 2 +- .../interactive_image_viewer.dart | 24 + .../lib/file_picker/file_picker_impl.dart | 11 +- .../lib/style_widget/icon_button.dart | 4 +- .../lib/widget/flowy_tooltip.dart | 2 - frontend/appflowy_flutter/pubspec.lock | 10 +- frontend/appflowy_flutter/pubspec.yaml | 1 + frontend/appflowy_tauri/src-tauri/Cargo.toml | 11 +- .../appflowy_web_app/src-tauri/Cargo.toml | 16 +- .../resources/flowy_icons/16x/download.svg | 1 + .../resources/flowy_icons/16x/ft_archive.svg | 1 + .../resources/flowy_icons/16x/ft_audio.svg | 1 + .../resources/flowy_icons/16x/ft_link.svg | 1 + .../resources/flowy_icons/16x/ft_text.svg | 1 + .../resources/flowy_icons/16x/ft_video.svg | 1 + frontend/resources/flowy_icons/16x/media.svg | 1 + frontend/resources/translations/en.json | 19 +- frontend/rust-lib/Cargo.toml | 7 +- .../src/database_event.rs | 6 + .../tests/database/local_test/test.rs | 2 + .../src/entities/field_entities.rs | 6 + .../entities/filter_entities/media_filter.rs | 49 ++ .../src/entities/filter_entities/mod.rs | 2 + .../src/entities/filter_entities/util.rs | 9 + .../flowy-database2/src/entities/macros.rs | 1 + .../src/entities/row_entities.rs | 11 + .../type_option_entities/media_entities.rs | 192 +++++++ .../src/entities/type_option_entities/mod.rs | 2 + .../select_option_entities.rs | 3 +- .../flowy-database2/src/event_handler.rs | 117 +++- .../rust-lib/flowy-database2/src/event_map.rs | 12 + .../src/services/cell/cell_operation.rs | 9 +- .../src/services/database/database_editor.rs | 94 ++- .../services/field/type_option_transform.rs | 7 +- .../media_type_option/media_file.rs | 57 ++ .../media_type_option/media_filter.rs | 31 + .../media_type_option/media_type_option.rs | 255 +++++++++ .../type_options/media_type_option/mod.rs | 7 + .../src/services/field/type_options/mod.rs | 2 + .../selection_type_option/select_option.rs | 1 + .../text_type_option/text_type_option.rs | 1 + .../field/type_options/type_option.rs | 17 +- .../field/type_options/type_option_cell.rs | 14 +- .../src/services/filter/entities.rs | 9 +- .../tests/database/cell_test/test.rs | 18 +- .../database/mock_data/board_mock_data.rs | 2 +- .../database/mock_data/grid_mock_data.rs | 14 +- .../tests/database/share_test/export_test.rs | 34 +- 112 files changed, 3823 insertions(+), 209 deletions(-) create mode 100644 frontend/appflowy_flutter/integration_test/desktop/database/database_media_test.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/media_cell_bloc.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/media_card_cell.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_media_cell.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_media_cell.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/media.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/media_cell_editor.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_media_cell_editor.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/media.dart create mode 100644 frontend/appflowy_flutter/lib/plugins/database/widgets/media_file_type_ext.dart create mode 100644 frontend/appflowy_flutter/lib/shared/patterns/file_type_patterns.dart create mode 100644 frontend/appflowy_flutter/lib/util/xfile_ext.dart create mode 100644 frontend/resources/flowy_icons/16x/download.svg create mode 100644 frontend/resources/flowy_icons/16x/ft_archive.svg create mode 100644 frontend/resources/flowy_icons/16x/ft_audio.svg create mode 100644 frontend/resources/flowy_icons/16x/ft_link.svg create mode 100644 frontend/resources/flowy_icons/16x/ft_text.svg create mode 100644 frontend/resources/flowy_icons/16x/ft_video.svg create mode 100644 frontend/resources/flowy_icons/16x/media.svg create mode 100644 frontend/rust-lib/flowy-database2/src/entities/filter_entities/media_filter.rs create mode 100644 frontend/rust-lib/flowy-database2/src/entities/type_option_entities/media_entities.rs create mode 100644 frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/media_file.rs create mode 100644 frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/media_filter.rs create mode 100644 frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/media_type_option.rs create mode 100644 frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/mod.rs diff --git a/frontend/appflowy_flutter/integration_test/desktop/database/database_media_test.dart b/frontend/appflowy_flutter/integration_test/desktop/database/database_media_test.dart new file mode 100644 index 0000000000000..ddbffa6ca743d --- /dev/null +++ b/frontend/appflowy_flutter/integration_test/desktop/database/database_media_test.dart @@ -0,0 +1,96 @@ +import 'dart:io'; + +import 'package:flutter/services.dart'; + +import 'package:appflowy/core/config/kv.dart'; +import 'package:appflowy/core/config/kv_keys.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/media_cell_editor.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-folder/protobuf.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:integration_test/integration_test.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +import '../../shared/database_test_op.dart'; +import '../../shared/mock/mock_file_picker.dart'; +import '../../shared/util.dart'; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + group('media type option in database', () { + testWidgets('add media field and add files', (tester) async { + await tester.initializeAppFlowy(); + await tester.tapAnonymousSignInButton(); + + await tester.createNewPageWithNameUnderParent(layout: ViewLayoutPB.Grid); + + // Invoke the field editor + await tester.tapGridFieldWithName('Type'); + await tester.tapEditFieldButton(); + + // Change to media type + await tester.tapSwitchFieldTypeButton(); + await tester.selectFieldType(FieldType.Media); + await tester.dismissFieldEditor(); + + // Open media cell editor + await tester.tapCellInGrid(rowIndex: 0, fieldType: FieldType.Media); + await tester.findMediaCellEditor(findsOneWidget); + + // Prepare files for upload from local + final firstImage = + await rootBundle.load('assets/test/images/sample.jpeg'); + final secondImage = + await rootBundle.load('assets/test/images/sample.gif'); + final tempDirectory = await getTemporaryDirectory(); + + final firstImagePath = p.join(tempDirectory.path, 'sample.jpeg'); + final firstFile = File(firstImagePath) + ..writeAsBytesSync(firstImage.buffer.asUint8List()); + + final secondImagePath = p.join(tempDirectory.path, 'sample.gif'); + final secondFile = File(secondImagePath) + ..writeAsBytesSync(secondImage.buffer.asUint8List()); + + mockPickFilePaths(paths: [firstImagePath]); + await getIt().set(KVKeys.kCloudType, '0'); + + // Click on add file button in the Media Cell Editor + await tester.tap(find.text(LocaleKeys.grid_media_addFileOrImage.tr())); + await tester.pumpAndSettle(); + + // Tap on the upload interaction + await tester.tapButtonWithName( + LocaleKeys.document_plugins_file_fileUploadHint.tr(), + ); + await tester.pumpAndSettle(); + + // Expect one file + expect(find.byType(RenderMedia), findsOneWidget); + + // Mock second file + mockPickFilePaths(paths: [secondImagePath]); + + // Click on add file button in the Media Cell Editor + await tester.tap(find.text(LocaleKeys.grid_media_addFileOrImage.tr())); + await tester.pumpAndSettle(); + + // Tap on the upload interaction + await tester.tapButtonWithName( + LocaleKeys.document_plugins_file_fileUploadHint.tr(), + ); + await tester.pumpAndSettle(); + + // Expect two files + expect(find.byType(RenderMedia), findsNWidgets(2)); + + // Remove the temp files + await Future.wait([firstFile.delete(), secondFile.delete()]); + }); + }); +} diff --git a/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart b/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart index 754e80342cffe..220673bd8aff0 100644 --- a/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart +++ b/frontend/appflowy_flutter/integration_test/shared/database_test_op.dart @@ -41,6 +41,7 @@ import 'package:appflowy/plugins/database/tab_bar/desktop/tab_bar_header.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/checkbox.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/checklist.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/date.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/media.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/number.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/select_option.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/text.dart'; @@ -50,6 +51,7 @@ import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_cell_edi import 'package:appflowy/plugins/database/widgets/cell_editor/checklist_progress_bar.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/date_editor.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/media_cell_editor.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/select_option_cell_editor.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/select_option_text_field.dart'; import 'package:appflowy/plugins/database/widgets/database_layout_ext.dart'; @@ -857,6 +859,11 @@ extension AppFlowyDatabaseTest on WidgetTester { expect(finder, matcher); } + Future findMediaCellEditor(dynamic matcher) async { + final finder = find.byType(MediaCellEditor); + expect(finder, matcher); + } + Future findSelectOptionEditor(dynamic matcher) async { final finder = find.byType(SelectOptionCellEditor); expect(finder, matcher); @@ -1580,6 +1587,8 @@ Finder finderForFieldType(FieldType fieldType) { return find.byType(EditableTextCell, skipOffstage: false); case FieldType.URL: return find.byType(EditableURLCell, skipOffstage: false); + case FieldType.Media: + return find.byType(EditableMediaCell, skipOffstage: false); default: throw Exception('Unknown field type: $fieldType'); } diff --git a/frontend/appflowy_flutter/ios/Runner/Info.plist b/frontend/appflowy_flutter/ios/Runner/Info.plist index ce3b8804d3c6e..e9e15609eedc9 100644 --- a/frontend/appflowy_flutter/ios/Runner/Info.plist +++ b/frontend/appflowy_flutter/ios/Runner/Info.plist @@ -67,5 +67,7 @@ UIViewControllerBasedStatusBarAppearance + UISupportsDocumentBrowser + diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart index 1708583453a59..4b080e540dda7 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/mobile_card_detail_screen.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; @@ -23,7 +25,6 @@ import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:fluttertoast/fluttertoast.dart'; import 'package:go_router/go_router.dart'; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/mobile_row_property_list.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/mobile_row_property_list.dart index 7c26879a4f420..95298daeff3ae 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/mobile_row_property_list.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/card_detail/widgets/mobile_row_property_list.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; @@ -6,7 +8,6 @@ import 'package:appflowy/plugins/database/grid/application/row/row_detail_bloc.d import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/util/field_type_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; class MobileRowPropertyList extends StatelessWidget { diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/mobile_card_content.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/mobile_card_content.dart index 48d8b2f097374..26978410051ef 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/card/mobile_card_content.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/card/mobile_card_content.dart @@ -1,9 +1,16 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/widgets/card/card.dart'; import 'package:appflowy/plugins/database/widgets/card/card_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell/card_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/cell/card_cell_style_maps/mobile_board_card_cell_style.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; -import 'package:flutter/material.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/widget/spacing.dart'; class MobileCardContent extends StatelessWidget { const MobileCardContent({ @@ -21,19 +28,47 @@ class MobileCardContent extends StatelessWidget { @override Widget build(BuildContext context) { + final attachmentCount = rowMeta.attachmentCount.toInt(); + return Padding( padding: styleConfiguration.cardPadding, child: Column( mainAxisSize: MainAxisSize.min, - children: cells.map( - (cellMeta) { - return cellBuilder.build( - cellContext: cellMeta.cellContext(), - styleMap: mobileBoardCardCellStyleMap(context), - hasNotes: !rowMeta.isDocumentEmpty, - ); - }, - ).toList(), + children: [ + ...cells.map( + (cellMeta) { + return cellBuilder.build( + cellContext: cellMeta.cellContext(), + styleMap: mobileBoardCardCellStyleMap(context), + hasNotes: !rowMeta.isDocumentEmpty, + ); + }, + ), + if (attachmentCount > 0) ...[ + const VSpace(4), + Padding( + padding: const EdgeInsets.only(left: 8), + child: Row( + children: [ + const FlowySvg( + FlowySvgs.media_s, + size: Size.square(12), + ), + const HSpace(6), + Flexible( + child: FlowyText.regular( + LocaleKeys.grid_media_attachmentsHint + .tr(args: ['$attachmentCount']), + fontSize: 12, + color: AFThemeExtension.of(context).secondaryTextColor, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + ], ), ); } diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_edit_field_screen.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_edit_field_screen.dart index d794817339bf4..a51e3561f8464 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_edit_field_screen.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_edit_field_screen.dart @@ -1,12 +1,13 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/base/app_bar/app_bar.dart'; import 'package:appflowy/mobile/presentation/database/field/mobile_full_field_editor.dart'; -import 'package:appflowy/plugins/database/domain/field_backend_service.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/plugins/database/domain/field_backend_service.dart'; import 'package:appflowy/plugins/database/domain/field_service.dart'; import 'package:appflowy/plugins/database/widgets/setting/field_visibility_extension.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; class MobileEditPropertyScreen extends StatefulWidget { @@ -49,7 +50,7 @@ class _MobileEditPropertyScreenState extends State { return PopScope( onPopInvoked: (didPop) { - if (didPop) { + if (!didPop) { context.pop(_fieldOptionValues); } }, diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_field_bottom_sheets.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_field_bottom_sheets.dart index fb1bda724d310..74c7a5a56b141 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_field_bottom_sheets.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_field_bottom_sheets.dart @@ -29,6 +29,7 @@ const mobileSupportedFieldTypes = [ FieldType.CreatedTime, FieldType.Checkbox, FieldType.Checklist, + FieldType.Media, // FieldType.Time, ]; diff --git a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_quick_field_editor.dart b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_quick_field_editor.dart index 54448057089fc..399f7632fde1b 100644 --- a/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_quick_field_editor.dart +++ b/frontend/appflowy_flutter/lib/mobile/presentation/database/field/mobile_quick_field_editor.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/database/card/card_detail/widgets/widgets.dart'; @@ -11,7 +13,6 @@ import 'package:appflowy/plugins/database/widgets/setting/field_visibility_exten import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:go_router/go_router.dart'; @@ -100,7 +101,7 @@ class _QuickEditFieldState extends State { context.pop(); }, ), - if (!widget.fieldInfo.isPrimary) + if (!widget.fieldInfo.isPrimary) ...[ FlowyOptionTile.text( showTopBorder: false, text: fieldVisibility.isVisibleState() @@ -116,7 +117,6 @@ class _QuickEditFieldState extends State { } }, ), - if (!widget.fieldInfo.isPrimary) FlowyOptionTile.text( showTopBorder: false, text: LocaleKeys.grid_field_insertLeft.tr(), @@ -133,6 +133,7 @@ class _QuickEditFieldState extends State { ); }, ), + ], FlowyOptionTile.text( showTopBorder: false, text: LocaleKeys.grid_field_insertRight.tr(), diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/media_cell_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/media_cell_bloc.dart new file mode 100644 index 0000000000000..9026fbee65d5d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/media_cell_bloc.dart @@ -0,0 +1,220 @@ +import 'dart:async'; + +import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; +import 'package:appflowy/plugins/database/application/field/field_info.dart'; +import 'package:appflowy/user/application/user_service.dart'; +import 'package:appflowy_backend/dispatch/dispatch.dart'; +import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/cell_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:flowy_infra/uuid.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; + +part 'media_cell_bloc.freezed.dart'; + +class MediaCellBloc extends Bloc { + MediaCellBloc({ + required this.cellController, + }) : super(MediaCellState.initial(cellController)) { + _dispatch(); + _startListening(); + } + + final MediaCellController cellController; + void Function()? _onCellChangedFn; + + String get databaseId => cellController.viewId; + String get rowId => cellController.rowId; + bool get wrapContent => cellController.fieldInfo.wrapCellContent ?? false; + + @override + Future close() async { + if (_onCellChangedFn != null) { + cellController.removeListener( + onCellChanged: _onCellChangedFn!, + onFieldChanged: _onFieldChangedListener, + ); + } + await cellController.dispose(); + return super.close(); + } + + void _dispatch() { + on( + (event, emit) async { + await event.when( + initial: () async { + // Fetch user profile + final userProfileResult = + await UserBackendService.getCurrentUserProfile(); + userProfileResult.fold( + (userProfile) => emit(state.copyWith(userProfile: userProfile)), + (l) => Log.error(l), + ); + }, + didUpdateCell: (files) { + emit(state.copyWith(files: files)); + }, + didUpdateField: (fieldName) { + emit(state.copyWith(fieldName: fieldName)); + }, + addFile: (url, name, uploadType, fileType) async { + final newFile = MediaFilePB( + id: uuid(), + url: url, + name: name, + uploadType: uploadType, + fileType: fileType, + ); + + final payload = MediaCellChangesetPB( + viewId: cellController.viewId, + cellId: CellIdPB( + viewId: cellController.viewId, + fieldId: cellController.fieldId, + rowId: cellController.rowId, + ), + insertedFiles: [newFile], + removedIds: [], + ); + + final result = await DatabaseEventUpdateMediaCell(payload).send(); + result.fold((l) => null, (err) => Log.error(err)); + }, + removeFile: (id) async { + final payload = MediaCellChangesetPB( + viewId: cellController.viewId, + cellId: CellIdPB( + viewId: cellController.viewId, + fieldId: cellController.fieldId, + rowId: cellController.rowId, + ), + insertedFiles: [], + removedIds: [id], + ); + + final result = await DatabaseEventUpdateMediaCell(payload).send(); + result.fold((l) => null, (err) => Log.error(err)); + }, + reorderFiles: (from, to) async { + final files = List.from(state.files); + if (from < to) { + to--; + } + + files.insert(to, files.removeAt(from)); + + // We emit the new state first to update the UI + emit(state.copyWith(files: files)); + + final payload = MediaCellChangesetPB( + viewId: cellController.viewId, + cellId: CellIdPB( + viewId: cellController.viewId, + fieldId: cellController.fieldId, + rowId: cellController.rowId, + ), + insertedFiles: files, + // In the backend we remove all files by id before we do inserts. + // So this will effectively reorder the files. + removedIds: files.map((file) => file.id).toList(), + ); + + final result = await DatabaseEventUpdateMediaCell(payload).send(); + result.fold((l) => null, (err) => Log.error(err)); + }, + renameFile: (fileId, name) async { + final payload = RenameMediaChangesetPB( + viewId: cellController.viewId, + cellId: CellIdPB( + viewId: cellController.viewId, + fieldId: cellController.fieldId, + rowId: cellController.rowId, + ), + fileId: fileId, + name: name, + ); + + final result = await DatabaseEventRenameMediaFile(payload).send(); + result.fold((l) => null, (err) => Log.error(err)); + }, + ); + }, + ); + } + + void _startListening() { + _onCellChangedFn = cellController.addListener( + onCellChanged: (cellData) { + if (!isClosed) { + add(MediaCellEvent.didUpdateCell(cellData?.files ?? const [])); + } + }, + onFieldChanged: _onFieldChangedListener, + ); + } + + void _onFieldChangedListener(FieldInfo fieldInfo) { + if (!isClosed) { + add(MediaCellEvent.didUpdateField(fieldInfo.name)); + } + } + + void renameFile(String fileId, String name) => + add(MediaCellEvent.renameFile(fileId: fileId, name: name)); + + void deleteFile(String fileId) => + add(MediaCellEvent.removeFile(fileId: fileId)); +} + +@freezed +class MediaCellEvent with _$MediaCellEvent { + const factory MediaCellEvent.initial() = _Initial; + + const factory MediaCellEvent.didUpdateCell(List files) = + _DidUpdateCell; + + const factory MediaCellEvent.didUpdateField(String fieldName) = + _DidUpdateField; + + const factory MediaCellEvent.addFile({ + required String url, + required String name, + required MediaUploadTypePB uploadType, + required MediaFileTypePB fileType, + }) = _AddFile; + + const factory MediaCellEvent.removeFile({ + required String fileId, + }) = _RemoveFile; + + const factory MediaCellEvent.reorderFiles({ + required int from, + required int to, + }) = _ReorderFiles; + + const factory MediaCellEvent.renameFile({ + required String fileId, + required String name, + }) = _RenameFile; +} + +@freezed +class MediaCellState with _$MediaCellState { + const factory MediaCellState({ + UserProfilePB? userProfile, + required String fieldName, + @Default([]) List files, + }) = _MediaCellState; + + factory MediaCellState.initial(MediaCellController cellController) { + final cellData = cellController.getCellData(); + + return MediaCellState( + fieldName: cellController.fieldInfo.field.name, + files: cellData?.files ?? const [], + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller_builder.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller_builder.dart index 50ef7ccb74cd0..afe05e8b70bf5 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller_builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_controller_builder.dart @@ -18,6 +18,7 @@ typedef RelationCellController = CellController; typedef SummaryCellController = CellController; typedef TimeCellController = CellController; typedef TranslateCellController = CellController; +typedef MediaCellController = CellController; CellController makeCellController( DatabaseController databaseController, @@ -170,6 +171,18 @@ CellController makeCellController( ), cellDataPersistence: TextCellDataPersistence(), ); + case FieldType.Media: + return MediaCellController( + viewId: viewId, + fieldController: fieldController, + cellContext: cellContext, + rowCache: rowCache, + cellDataLoader: CellDataLoader( + parser: MediaCellDataParser(), + reloadOnFieldChange: true, + ), + cellDataPersistence: TextCellDataPersistence(), + ); } throw UnimplementedError; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_loader.dart b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_loader.dart index 1c03239cde055..cfab4668ae1c8 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_loader.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/cell_data_loader.dart @@ -196,3 +196,19 @@ class TimeCellDataParser implements CellDataParser { } } } + +class MediaCellDataParser implements CellDataParser { + @override + MediaCellDataPB? parserData(List data) { + if (data.isEmpty) { + return null; + } + + try { + return MediaCellDataPB.fromBuffer(data); + } catch (e) { + Log.error("Failed to parse media cell data: $e"); + return null; + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart index 49c75173724b4..670eefb7f8564 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/board/presentation/board_page.dart @@ -1,5 +1,8 @@ import 'dart:io'; +import 'package:flutter/material.dart' hide Card; +import 'package:flutter/services.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/database/board/mobile_board_page.dart'; @@ -24,13 +27,12 @@ import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/widget/error_page.dart'; -import 'package:flutter/material.dart' hide Card; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../widgets/card/card.dart'; import '../../widgets/cell/card_cell_builder.dart'; import '../application/board_bloc.dart'; + import 'toolbar/board_setting_bar.dart'; import 'widgets/board_focus_scope.dart'; import 'widgets/board_hidden_groups.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_editor.dart index dfbf0d6bc0df5..8a57bea2bd530 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/calendar/presentation/calendar_event_editor.dart @@ -1,4 +1,3 @@ -import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; @@ -16,6 +15,7 @@ import 'package:appflowy/plugins/database/widgets/row/accessory/cell_accessory.d import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:appflowy/plugins/database/widgets/row/row_detail.dart'; import 'package:appflowy/util/field_type_extension.dart'; +import 'package:appflowy/workspace/application/view/view_bloc.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:collection/collection.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/database/domain/filter_service.dart b/frontend/appflowy_flutter/lib/plugins/database/domain/filter_service.dart index e618da5de96b3..7434c5e49724f 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/domain/filter_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/domain/filter_service.dart @@ -281,6 +281,30 @@ class FilterBackendService { ); } + Future> insertMediaFilter({ + required String fieldId, + String? filterId, + required MediaFilterConditionPB condition, + String content = "", + }) { + final filter = MediaFilterPB() + ..condition = condition + ..content = content; + + return filterId == null + ? insertFilter( + fieldId: fieldId, + fieldType: FieldType.Media, + data: filter.writeToBuffer(), + ) + : updateFilter( + filterId: filterId, + fieldId: fieldId, + fieldType: FieldType.Media, + data: filter.writeToBuffer(), + ); + } + Future> deleteFilter({ required String fieldId, required String filterId, diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_create_bloc.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_create_bloc.dart index a27b0bf00074d..b4a8dc72b77d3 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_create_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/application/filter/filter_create_bloc.dart @@ -3,13 +3,7 @@ import 'dart:async'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/domain/filter_service.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/checkbox_filter.pbenum.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/checklist_filter.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/date_filter.pbenum.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/number_filter.pb.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/select_option_filter.pbenum.dart'; -import 'package:appflowy_backend/protobuf/flowy-database2/text_filter.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart'; import 'package:appflowy_result/appflowy_result.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -149,6 +143,11 @@ class GridCreateFilterBloc fieldId: fieldId, condition: TextFilterConditionPB.TextContains, ); + case FieldType.Media: + return _filterBackendSvc.insertMediaFilter( + fieldId: fieldId, + condition: MediaFilterConditionPB.MediaIsNotEmpty, + ); default: throw UnimplementedError(); } diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart index 19c4e11483819..f2f1d8547f2db 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/grid_page.dart @@ -1,5 +1,7 @@ import 'dart:math'; +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/calculations_row.dart'; @@ -15,15 +17,16 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/scrolling/styled_scrollview.dart'; import 'package:flowy_infra_ui/widget/error_page.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:linked_scroll_controller/linked_scroll_controller.dart'; +import 'package:provider/provider.dart'; import '../../application/database_controller.dart'; import '../../application/row/row_controller.dart'; import '../../tab_bar/tab_bar_view.dart'; import '../../widgets/row/row_detail.dart'; import '../application/grid_bloc.dart'; + import 'grid_scroll.dart'; import 'layout/layout.dart'; import 'layout/sizes.dart'; @@ -352,9 +355,12 @@ class _GridRowsState extends State<_GridRows> { scrollController: widget.scrollController.verticalController, physics: const ClampingScrollPhysics(), buildDefaultDragHandles: false, - proxyDecorator: (child, index, animation) => Material( - color: Colors.white.withOpacity(.1), - child: Opacity(opacity: .5, child: child), + proxyDecorator: (child, _, __) => Provider.value( + value: context.read(), + child: Material( + color: Colors.white.withOpacity(.1), + child: Opacity(opacity: .5, child: child), + ), ), onReorder: (fromIndex, newIndex) { final toIndex = newIndex > fromIndex ? newIndex - 1 : newIndex; diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/create_filter_list.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/create_filter_list.dart index d28cc5c263d74..849a436e801a2 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/create_filter_list.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/filter/create_filter_list.dart @@ -78,12 +78,9 @@ class _GridCreateFilterListState extends State { child: ListView.separated( shrinkWrap: true, itemCount: cells.length, - itemBuilder: (BuildContext context, int index) { - return cells[index]; - }, - separatorBuilder: (BuildContext context, int index) { - return VSpace(GridSize.typeOptionSeparatorHeight); - }, + itemBuilder: (_, int index) => cells[index], + separatorBuilder: (_, __) => + VSpace(GridSize.typeOptionSeparatorHeight), ), ), ]; diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/field_type_extension.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/field_type_extension.dart index 3267c74ad7160..c4d446f2162be 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/field_type_extension.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/header/field_type_extension.dart @@ -22,3 +22,10 @@ extension FieldTypeListExtension on FieldType { _ => false, }; } + +extension RowDetailAccessoryExtension on FieldType { + bool get showRowDetailAccessory => switch (this) { + FieldType.Media => false, + _ => true, + }; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/mobile_row.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/mobile_row.dart index cca01bc036f3f..209c439ad164d 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/mobile_row.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/mobile_row.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; @@ -7,7 +9,6 @@ import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy/plugins/database/grid/application/row/row_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/mobile_cell_container.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../../layout/sizes.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/row.dart b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/row.dart index 19a5da2438f09..b803fbbd2867a 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/row.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/grid/presentation/widgets/row/row.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import "package:appflowy/generated/locale_keys.g.dart"; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; @@ -11,13 +13,13 @@ import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; import '../../../../widgets/row/accessory/cell_accessory.dart'; import '../../../../widgets/row/cells/cell_container.dart'; import '../../layout/sizes.dart'; + import 'action.dart'; class GridRow extends StatefulWidget { diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card.dart index bd6bce8dd463c..da09fbb63bc65 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/card/card.dart @@ -1,4 +1,7 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/database/card/card.dart'; import 'package:appflowy/plugins/database/application/field/field_controller.dart'; import 'package:appflowy/plugins/database/application/row/row_cache.dart'; @@ -8,14 +11,16 @@ import 'package:appflowy_backend/protobuf/flowy-database2/row_entities.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:collection/collection.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; -import 'card_bloc.dart'; import '../cell/card_cell_builder.dart'; import '../cell/card_cell_skeleton/card_cell.dart'; + +import 'card_bloc.dart'; import 'container/accessory.dart'; import 'container/card_container.dart'; @@ -196,11 +201,38 @@ class _CardContent extends StatelessWidget { @override Widget build(BuildContext context) { + final attachmentCount = rowMeta.attachmentCount.toInt(); final child = Padding( padding: styleConfiguration.cardPadding, child: Column( mainAxisSize: MainAxisSize.min, - children: _makeCells(context, rowMeta, cells), + children: [ + ..._makeCells(context, rowMeta, cells), + if (attachmentCount > 0) ...[ + const VSpace(2), + Padding( + padding: const EdgeInsets.only(left: 8), + child: Row( + children: [ + const FlowySvg( + FlowySvgs.media_s, + size: Size.square(12), + ), + const HSpace(4), + Flexible( + child: FlowyText.regular( + LocaleKeys.grid_media_attachmentsHint + .tr(args: ['$attachmentCount']), + fontSize: 11, + color: AFThemeExtension.of(context).secondaryTextColor, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ], + ], ), ); return styleConfiguration.hoverStyle == null diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_builder.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_builder.dart index aff11f6584cdd..d17c522de6a87 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_builder.dart @@ -8,6 +8,7 @@ import 'card_cell_skeleton/card_cell.dart'; import 'card_cell_skeleton/checkbox_card_cell.dart'; import 'card_cell_skeleton/checklist_card_cell.dart'; import 'card_cell_skeleton/date_card_cell.dart'; +import 'card_cell_skeleton/media_card_cell.dart'; import 'card_cell_skeleton/number_card_cell.dart'; import 'card_cell_skeleton/relation_card_cell.dart'; import 'card_cell_skeleton/select_option_card_cell.dart'; @@ -113,6 +114,12 @@ class CardCellBuilder { databaseController: databaseController, cellContext: cellContext, ), + FieldType.Media => MediaCardCell( + key: key, + style: isStyleOrNull(style), + databaseController: databaseController, + cellContext: cellContext, + ), _ => throw UnimplementedError, }; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/media_card_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/media_card_cell.dart new file mode 100644 index 0000000000000..f3569a57dc842 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/media_card_cell.dart @@ -0,0 +1,39 @@ +import 'package:flutter/widgets.dart'; + +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/card_cell.dart'; + +class MediaCardCellStyle extends CardCellStyle { + const MediaCardCellStyle({ + required super.padding, + required this.textStyle, + }); + + final TextStyle textStyle; +} + +// This is a placeholder for the MediaCardCell, it is not implemented +// as we use the [RowMetaPB.attachmentCount] to display cumulative attachments +// on a Card. +class MediaCardCell extends CardCell { + const MediaCardCell({ + super.key, + required super.style, + required this.databaseController, + required this.cellContext, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + + @override + State createState() => _MediaCellState(); +} + +class _MediaCellState extends State { + @override + Widget build(BuildContext context) { + return const SizedBox.shrink(); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/text_card_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/text_card_cell.dart index f5c1b3918b2f4..ab372506bb8d6 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/text_card_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_skeleton/text_card_cell.dart @@ -1,16 +1,18 @@ +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; -import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter/services.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../editable_cell_builder.dart'; + import 'card_cell.dart'; class TextCardCellStyle extends CardCellStyle { diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/calendar_card_cell_style.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/calendar_card_cell_style.dart index df7bb72f60a20..23a8a2451feb9 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/calendar_card_cell_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/calendar_card_cell_style.dart @@ -1,18 +1,19 @@ import 'package:flutter/material.dart'; -import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/summary_card_cell.dart'; -import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/translate_card_cell.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import '../card_cell_builder.dart'; import '../card_cell_skeleton/checkbox_card_cell.dart'; import '../card_cell_skeleton/checklist_card_cell.dart'; import '../card_cell_skeleton/date_card_cell.dart'; +import '../card_cell_skeleton/media_card_cell.dart'; import '../card_cell_skeleton/number_card_cell.dart'; import '../card_cell_skeleton/relation_card_cell.dart'; import '../card_cell_skeleton/select_option_card_cell.dart'; +import '../card_cell_skeleton/summary_card_cell.dart'; import '../card_cell_skeleton/text_card_cell.dart'; import '../card_cell_skeleton/timestamp_card_cell.dart'; +import '../card_cell_skeleton/translate_card_cell.dart'; import '../card_cell_skeleton/url_card_cell.dart'; CardCellStyleMap desktopCalendarCardCellStyleMap(BuildContext context) { @@ -90,5 +91,9 @@ CardCellStyleMap desktopCalendarCardCellStyleMap(BuildContext context) { padding: padding, textStyle: textStyle, ), + FieldType.Media: MediaCardCellStyle( + padding: padding, + textStyle: textStyle, + ), }; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/desktop_board_card_cell_style.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/desktop_board_card_cell_style.dart index 7fcb289c1d884..6d9f08fb5b2a5 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/desktop_board_card_cell_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/desktop_board_card_cell_style.dart @@ -1,19 +1,20 @@ import 'package:flutter/material.dart'; -import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/summary_card_cell.dart'; -import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/translate_card_cell.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import '../card_cell_builder.dart'; import '../card_cell_skeleton/checkbox_card_cell.dart'; import '../card_cell_skeleton/checklist_card_cell.dart'; import '../card_cell_skeleton/date_card_cell.dart'; +import '../card_cell_skeleton/media_card_cell.dart'; import '../card_cell_skeleton/number_card_cell.dart'; import '../card_cell_skeleton/relation_card_cell.dart'; import '../card_cell_skeleton/select_option_card_cell.dart'; +import '../card_cell_skeleton/summary_card_cell.dart'; import '../card_cell_skeleton/text_card_cell.dart'; import '../card_cell_skeleton/time_card_cell.dart'; import '../card_cell_skeleton/timestamp_card_cell.dart'; +import '../card_cell_skeleton/translate_card_cell.dart'; import '../card_cell_skeleton/url_card_cell.dart'; CardCellStyleMap desktopBoardCardCellStyleMap(BuildContext context) { @@ -95,5 +96,9 @@ CardCellStyleMap desktopBoardCardCellStyleMap(BuildContext context) { padding: padding, textStyle: textStyle, ), + FieldType.Media: MediaCardCellStyle( + padding: padding, + textStyle: textStyle, + ), }; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/mobile_board_card_cell_style.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/mobile_board_card_cell_style.dart index 3911678176210..93d98f013ee77 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/mobile_board_card_cell_style.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/card_cell_style_maps/mobile_board_card_cell_style.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; +import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/media_card_cell.dart'; import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/summary_card_cell.dart'; import 'package:appflowy/plugins/database/widgets/cell/card_cell_skeleton/translate_card_cell.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; @@ -94,5 +95,9 @@ CardCellStyleMap mobileBoardCardCellStyleMap(BuildContext context) { padding: padding, textStyle: textStyle, ), + FieldType.Media: MediaCardCellStyle( + padding: padding, + textStyle: textStyle, + ), }; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_media_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_media_cell.dart new file mode 100644 index 0000000000000..9c27546d753b5 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_media_cell.dart @@ -0,0 +1,237 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/bottom_sheet.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/media_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/media.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/media_cell_editor.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/mobile_media_cell_editor.dart'; +import 'package:appflowy/plugins/database/widgets/media_file_type_ext.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pb.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/platform_extension.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class GridMediaCellSkin extends IEditableMediaCellSkin { + const GridMediaCellSkin({this.isMobileRowDetail = false}); + + final bool isMobileRowDetail; + + @override + void dispose() {} + + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + PopoverController popoverController, + MediaCellBloc bloc, + ) { + final isMobile = PlatformExtension.isMobile; + + Widget child = BlocBuilder( + builder: (context, state) { + final filesToDisplay = state.files.take(4).toList(); + final extraCount = state.files.length - filesToDisplay.length; + + final wrapContent = context.read().wrapContent; + final children = [ + ...filesToDisplay.map((file) => _FilePreviewRender(file: file)), + if (extraCount > 0) _ExtraInfo(extraCount: extraCount), + ]; + + if (filesToDisplay.isEmpty && isMobile) { + children.add( + Padding( + padding: const EdgeInsets.symmetric(vertical: 4), + child: Text( + LocaleKeys.grid_row_textPlaceholder.tr(), + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontSize: 16, + color: Theme.of(context).hintColor, + ), + ), + ), + ); + } + + if (!isMobile && wrapContent) { + return Padding( + padding: const EdgeInsets.all(4), + child: SizedBox( + width: double.infinity, + child: Wrap( + runSpacing: 4, + children: children, + ), + ), + ); + } + + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 4), + child: SizedBox( + width: double.infinity, + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SeparatedRow( + separatorBuilder: () => const HSpace(6), + children: children, + ), + ), + ), + ); + }, + ); + + if (!isMobile) { + child = AppFlowyPopover( + controller: popoverController, + constraints: const BoxConstraints( + minWidth: 250, + maxWidth: 250, + maxHeight: 400, + ), + margin: EdgeInsets.zero, + triggerActions: PopoverTriggerFlags.none, + direction: PopoverDirection.bottomWithCenterAligned, + popupBuilder: (_) => BlocProvider.value( + value: context.read(), + child: const MediaCellEditor(), + ), + onClose: () => cellContainerNotifier.isFocus = false, + child: child, + ); + } else { + child = Align( + alignment: AlignmentDirectional.centerStart, + child: child, + ); + + if (isMobileRowDetail) { + child = Container( + decoration: BoxDecoration( + border: Border.fromBorderSide( + BorderSide(color: Theme.of(context).colorScheme.outline), + ), + borderRadius: const BorderRadius.all(Radius.circular(14)), + ), + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + alignment: AlignmentDirectional.centerStart, + child: child, + ); + } + + child = InkWell( + borderRadius: BorderRadius.circular(12), + onTap: () { + showMobileBottomSheet( + context, + builder: (_) => BlocProvider.value( + value: context.read(), + child: const MobileMediaCellEditor(), + ), + ); + }, + hoverColor: Colors.transparent, + child: child, + ); + } + + return BlocProvider.value( + value: bloc, + child: Builder(builder: (context) => child), + ); + } +} + +class _FilePreviewRender extends StatelessWidget { + const _FilePreviewRender({required this.file}); + + final MediaFilePB file; + + @override + Widget build(BuildContext context) { + Widget child; + if (file.fileType == MediaFileTypePB.Image) { + if (file.uploadType == MediaUploadTypePB.NetworkMedia) { + child = Image.network( + file.url, + height: 32, + width: 32, + fit: BoxFit.cover, + ); + } else if (file.uploadType == MediaUploadTypePB.LocalMedia) { + child = Image.file( + File(file.url), + height: 32, + width: 32, + fit: BoxFit.cover, + ); + } else { + // Cloud + child = FlowyNetworkImage( + url: file.url, + userProfilePB: context.read().state.userProfile, + height: 32, + width: 32, + ); + } + } else { + child = Container( + height: 32, + width: 32, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AFThemeExtension.of(context).greyHover, + ), + child: FlowySvg( + file.fileType.icon, + color: AFThemeExtension.of(context).textColor, + ), + ); + } + + return Container( + margin: const EdgeInsets.all(2), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + ), + child: child, + ); + } +} + +class _ExtraInfo extends StatelessWidget { + const _ExtraInfo({required this.extraCount}); + + final int extraCount; + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(2), + child: Container( + height: 32, + padding: const EdgeInsets.all(8), + decoration: BoxDecoration( + color: AFThemeExtension.of(context).greyHover, + borderRadius: BorderRadius.circular(4), + ), + child: FlowyText.regular( + LocaleKeys.grid_media_moreFilesHint.tr(args: ['$extraCount']), + lineHeight: 1, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_media_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_media_cell.dart new file mode 100644 index 0000000000000..9b9334e101e40 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_media_cell.dart @@ -0,0 +1,533 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/media_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/media.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/media_cell_editor.dart'; +import 'package:appflowy/plugins/database/widgets/media_file_type_ext.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_block_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy/util/theme_extension.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pb.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class DekstopRowDetailMediaCellSkin extends IEditableMediaCellSkin { + final mutex = PopoverMutex(); + + @override + void dispose() { + mutex.dispose(); + } + + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + PopoverController popoverController, + MediaCellBloc bloc, + ) { + return BlocProvider.value( + value: bloc, + child: Builder( + builder: (context) => BlocBuilder( + builder: (context, state) { + final filesToDisplay = state.files.take(4).toList(); + final extraCount = state.files.length - filesToDisplay.length; + + return SizedBox( + width: double.infinity, + child: LayoutBuilder( + builder: (context, constraints) { + if (state.files.isEmpty) { + return GestureDetector( + onTap: () => popoverController.show(), + child: AppFlowyPopover( + mutex: mutex, + controller: popoverController, + asBarrier: true, + constraints: const BoxConstraints( + minWidth: 250, + maxWidth: 250, + maxHeight: 400, + ), + offset: const Offset(0, 10), + margin: EdgeInsets.zero, + direction: PopoverDirection.bottomWithLeftAligned, + popupBuilder: (_) => BlocProvider.value( + value: context.read(), + child: const MediaCellEditor(), + ), + onClose: () => cellContainerNotifier.isFocus = false, + child: FlowyHover( + style: HoverStyle( + hoverColor: + AFThemeExtension.of(context).lightGreyHover, + ), + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 6, + ), + child: FlowyText.medium( + LocaleKeys.grid_row_textPlaceholder.tr(), + color: Theme.of(context).hintColor, + overflow: TextOverflow.ellipsis, + ), + ), + ), + ), + ); + } + + final size = constraints.maxWidth / 2 - 6; + return Wrap( + runSpacing: 12, + spacing: 12, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + AppFlowyPopover( + mutex: mutex, + controller: popoverController, + asBarrier: true, + constraints: const BoxConstraints( + minWidth: 250, + maxWidth: 250, + maxHeight: 400, + ), + offset: const Offset(0, 10), + margin: EdgeInsets.zero, + direction: PopoverDirection.bottomWithLeftAligned, + popupBuilder: (_) => BlocProvider.value( + value: context.read(), + child: const MediaCellEditor(), + ), + onClose: () => + cellContainerNotifier.isFocus = false, + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + ), + child: FlowyHover( + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 4, + ), + child: Row( + children: [ + const FlowySvg(FlowySvgs.edit_s), + const HSpace(4), + FlowyText.regular( + LocaleKeys.button_edit.tr(), + ), + ], + ), + ), + ), + ), + ), + ], + ), + ...filesToDisplay.map( + (file) => _FilePreviewRender( + key: ValueKey(file.id), + file: file, + size: size, + mutex: mutex, + ), + ), + if (extraCount > 0) + _ExtraInfo( + extraCount: extraCount, + controller: popoverController, + mutex: mutex, + cellContainerNotifier: cellContainerNotifier, + ), + ], + ); + }, + ), + ); + }, + ), + ), + ); + } +} + +class _FilePreviewRender extends StatefulWidget { + const _FilePreviewRender({ + super.key, + required this.file, + required this.size, + required this.mutex, + }); + + final MediaFilePB file; + final double size; + final PopoverMutex mutex; + + @override + State<_FilePreviewRender> createState() => _FilePreviewRenderState(); +} + +class _FilePreviewRenderState extends State<_FilePreviewRender> { + final controller = PopoverController(); + + @override + void dispose() { + controller.close(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + Widget child; + if (widget.file.fileType == MediaFileTypePB.Image) { + if (widget.file.uploadType == MediaUploadTypePB.NetworkMedia) { + child = Image.network( + widget.file.url, + fit: BoxFit.cover, + ); + } else if (widget.file.uploadType == MediaUploadTypePB.LocalMedia) { + child = Image.file( + File(widget.file.url), + fit: BoxFit.cover, + ); + } else { + // Cloud + child = FlowyNetworkImage( + url: widget.file.url, + userProfilePB: context.read().state.userProfile, + ); + } + } else { + child = DecoratedBox( + decoration: BoxDecoration(color: widget.file.fileType.color), + child: Center( + child: Container( + padding: const EdgeInsets.all(8), + child: FlowySvg( + widget.file.fileType.icon, + color: AFThemeExtension.of(context).strongText, + size: const Size.square(32), + ), + ), + ), + ); + } + + return Stack( + children: [ + DecoratedBox( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all(Corners.s6Radius), + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.15), + blurRadius: 2, + ), + ], + ), + child: Column( + children: [ + Container( + height: widget.size, + width: widget.size, + constraints: BoxConstraints( + maxHeight: widget.size < 150 ? 100 : 195, + minHeight: widget.size < 150 ? 100 : 195, + ), + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: AFThemeExtension.of(context).greyHover, + borderRadius: const BorderRadius.only( + topLeft: Corners.s6Radius, + topRight: Corners.s6Radius, + ), + ), + child: child, + ), + Container( + height: 28, + width: widget.size, + clipBehavior: Clip.antiAlias, + decoration: BoxDecoration( + color: Theme.of(context).isLightMode + ? Theme.of(context).cardColor + : AFThemeExtension.of(context).greyHover, + borderRadius: const BorderRadius.only( + bottomLeft: Corners.s6Radius, + bottomRight: Corners.s6Radius, + ), + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Center( + child: FlowyText.medium( + widget.file.name, + overflow: TextOverflow.ellipsis, + fontSize: 12, + color: AFThemeExtension.of(context).secondaryTextColor, + ), + ), + ), + ), + ], + ), + ), + Positioned(top: 5, right: 5, child: FileItemMenu(file: widget.file)), + ], + ); + } +} + +class FileItemMenu extends StatefulWidget { + const FileItemMenu({super.key, required this.file}); + + final MediaFilePB file; + + @override + State createState() => _FileItemMenuState(); +} + +class _FileItemMenuState extends State { + final popoverController = PopoverController(); + final nameController = TextEditingController(); + final errorMessage = ValueNotifier(null); + + @override + void initState() { + super.initState(); + nameController.text = widget.file.name; + } + + @override + void dispose() { + popoverController.close(); + errorMessage.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + controller: popoverController, + constraints: const BoxConstraints(maxWidth: 150), + direction: PopoverDirection.bottomWithRightAligned, + offset: const Offset(0, 5), + popupBuilder: (_) { + return SeparatedColumn( + separatorBuilder: () => const VSpace(4), + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.file.fileType == MediaFileTypePB.Image) ...[ + FlowyButton( + onTap: () { + popoverController.close(); + showDialog( + context: context, + builder: (_) => InteractiveImageViewer( + userProfile: + context.read().state.userProfile, + imageProvider: AFBlockImageProvider( + images: [ + ImageBlockData( + url: widget.file.url, + type: widget.file.uploadType.toCustomImageType(), + ), + ], + onDeleteImage: (_) => context + .read() + .deleteFile(widget.file.id), + ), + ), + ); + }, + leftIcon: FlowySvg( + FlowySvgs.full_view_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(18), + ), + text: FlowyText.regular( + LocaleKeys.settings_files_open.tr(), + color: AFThemeExtension.of(context).textColor, + ), + leftIconSize: const Size(18, 18), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + ), + ], + FlowyButton( + leftIcon: const FlowySvg(FlowySvgs.edit_s), + text: FlowyText.regular(LocaleKeys.grid_media_rename.tr()), + onTap: () { + popoverController.close(); + + nameController.text = widget.file.name; + nameController.selection = TextSelection( + baseOffset: 0, + extentOffset: nameController.text.length, + ); + + showCustomConfirmDialog( + context: context, + title: LocaleKeys.document_plugins_file_renameFile_title.tr(), + description: LocaleKeys + .document_plugins_file_renameFile_description + .tr(), + closeOnConfirm: false, + builder: (dialogContext) => FileRenameTextField( + nameController: nameController, + errorMessage: errorMessage, + onSubmitted: () => _saveName(context), + disposeController: false, + ), + confirmLabel: LocaleKeys.button_save.tr(), + onConfirm: () => _saveName(context), + ); + }, + ), + FlowyButton( + onTap: () async => downloadMediaFile( + context, + widget.file, + userProfile: context.read().state.userProfile, + ), + leftIcon: FlowySvg( + FlowySvgs.download_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(18), + ), + text: FlowyText.regular( + LocaleKeys.button_download.tr(), + color: AFThemeExtension.of(context).textColor, + ), + leftIconSize: const Size(18, 18), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + ), + FlowyButton( + onTap: () => context.read().add( + MediaCellEvent.removeFile( + fileId: widget.file.id, + ), + ), + leftIcon: FlowySvg( + FlowySvgs.delete_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(18), + ), + text: FlowyText.regular( + LocaleKeys.button_delete.tr(), + color: AFThemeExtension.of(context).textColor, + ), + leftIconSize: const Size(18, 18), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + ), + ], + ); + }, + child: DecoratedBox( + decoration: BoxDecoration( + color: Theme.of(context).cardColor, + borderRadius: const BorderRadius.all(Corners.s8Radius), + ), + child: Padding( + padding: const EdgeInsets.all(3), + child: FlowyIconButton( + width: 20, + radius: BorderRadius.circular(0), + icon: FlowySvg( + FlowySvgs.three_dots_s, + color: AFThemeExtension.of(context).textColor, + ), + ), + ), + ), + ); + } + + void _saveName(BuildContext context) { + final newName = nameController.text.trim(); + if (newName.isEmpty) { + return; + } + + context + .read() + .add(MediaCellEvent.renameFile(fileId: widget.file.id, name: newName)); + Navigator.of(context).pop(); + } +} + +class _ExtraInfo extends StatelessWidget { + const _ExtraInfo({ + required this.extraCount, + required this.controller, + required this.mutex, + required this.cellContainerNotifier, + }); + + final int extraCount; + final PopoverController controller; + final PopoverMutex mutex; + final CellContainerNotifier cellContainerNotifier; + + @override + Widget build(BuildContext context) { + return AppFlowyPopover( + key: const Key('extra_info'), + mutex: mutex, + controller: controller, + triggerActions: PopoverTriggerFlags.none, + constraints: const BoxConstraints( + minWidth: 250, + maxWidth: 250, + maxHeight: 400, + ), + margin: EdgeInsets.zero, + direction: PopoverDirection.bottomWithLeftAligned, + popupBuilder: (_) => BlocProvider.value( + value: context.read(), + child: const MediaCellEditor(), + ), + onClose: () => cellContainerNotifier.isFocus = false, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: controller.show, + child: FlowyHover( + resetHoverOnRebuild: false, + child: Container( + height: 38, + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: AFThemeExtension.of(context).greyHover, + borderRadius: BorderRadius.circular(4), + ), + child: FlowyText.regular( + LocaleKeys.grid_media_showMore.tr(args: ['$extraCount']), + lineHeight: 1, + ), + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_select_option_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_select_option_cell.dart index 31dc63abe4995..dff883db1f45b 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_select_option_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_select_option_cell.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/select_option_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/cell_editor/extension.dart'; @@ -7,7 +9,6 @@ import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_popover/appflowy_popover.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 '../editable_cell_skeleton/select_option.dart'; @@ -25,16 +26,13 @@ class DesktopRowDetailSelectOptionCellSkin controller: popoverController, constraints: const BoxConstraints.tightFor(width: 300), margin: EdgeInsets.zero, + triggerActions: PopoverTriggerFlags.none, direction: PopoverDirection.bottomWithLeftAligned, - popupBuilder: (BuildContext popoverContext) { - WidgetsBinding.instance.addPostFrameCallback((_) { - cellContainerNotifier.isFocus = true; - }); - return SelectOptionCellEditor( - cellController: bloc.cellController, - ); - }, onClose: () => cellContainerNotifier.isFocus = false, + onOpen: () => cellContainerNotifier.isFocus = true, + popupBuilder: (_) => SelectOptionCellEditor( + cellController: bloc.cellController, + ), child: BlocBuilder( builder: (context, state) { return Container( diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_builder.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_builder.dart index 155a6003ceb9e..bf9bc1ee170a0 100755 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_builder.dart @@ -3,6 +3,7 @@ import 'package:flutter/widgets.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/media.dart'; import 'package:appflowy/plugins/database/widgets/cell/editable_cell_skeleton/translate.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; @@ -134,6 +135,13 @@ class EditableCellBuilder { skin: IEditableTranslateCellSkin.fromStyle(style), key: key, ), + FieldType.Media => EditableMediaCell( + databaseController: databaseController, + cellContext: cellContext, + skin: IEditableMediaCellSkin.fromStyle(style), + style: style, + key: key, + ), _ => throw UnimplementedError(), }; } @@ -226,6 +234,13 @@ class EditableCellBuilder { skin: skinMap.timeSkin!, key: key, ), + FieldType.Media => EditableMediaCell( + databaseController: databaseController, + cellContext: cellContext, + skin: skinMap.mediaSkin!, + style: EditableCellStyle.desktopGrid, + key: key, + ), _ => throw UnimplementedError(), }; } @@ -382,6 +397,7 @@ class EditableCellSkinMap { this.urlSkin, this.relationSkin, this.timeSkin, + this.mediaSkin, }); final IEditableCheckboxCellSkin? checkboxSkin; @@ -394,6 +410,7 @@ class EditableCellSkinMap { final IEditableURLCellSkin? urlSkin; final IEditableRelationCellSkin? relationSkin; final IEditableTimeCellSkin? timeSkin; + final IEditableMediaCellSkin? mediaSkin; bool has(FieldType fieldType) { return switch (fieldType) { @@ -410,6 +427,7 @@ class EditableCellSkinMap { FieldType.RichText => textSkin != null, FieldType.URL => urlSkin != null, FieldType.Time => timeSkin != null, + FieldType.Media => mediaSkin != null, _ => throw UnimplementedError(), }; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/media.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/media.dart new file mode 100644 index 0000000000000..55adb853345d0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/media.dart @@ -0,0 +1,106 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/plugins/database/application/cell/bloc/media_cell_bloc.dart'; +import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; +import 'package:appflowy/plugins/database/application/database_controller.dart'; +import 'package:appflowy/plugins/database/widgets/cell/desktop_grid/desktop_grid_media_cell.dart'; +import 'package:appflowy/plugins/database/widgets/cell/desktop_row_detail/desktop_row_detail_media_cell.dart'; +import 'package:appflowy/plugins/database/widgets/cell/editable_cell_builder.dart'; +import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import '../../../application/cell/cell_controller_builder.dart'; + +abstract class IEditableMediaCellSkin { + const IEditableMediaCellSkin(); + + factory IEditableMediaCellSkin.fromStyle(EditableCellStyle style) { + return switch (style) { + EditableCellStyle.desktopGrid => const GridMediaCellSkin(), + EditableCellStyle.desktopRowDetail => DekstopRowDetailMediaCellSkin(), + EditableCellStyle.mobileGrid => const GridMediaCellSkin(), + EditableCellStyle.mobileRowDetail => + const GridMediaCellSkin(isMobileRowDetail: true), + }; + } + + bool autoShowPopover(EditableCellStyle style) => switch (style) { + EditableCellStyle.desktopGrid => true, + EditableCellStyle.desktopRowDetail => false, + EditableCellStyle.mobileGrid => false, + EditableCellStyle.mobileRowDetail => false, + }; + + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + PopoverController popoverController, + MediaCellBloc bloc, + ); + + void dispose(); +} + +class EditableMediaCell extends EditableCellWidget { + EditableMediaCell({ + super.key, + required this.databaseController, + required this.cellContext, + required this.skin, + required this.style, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + final IEditableMediaCellSkin skin; + final EditableCellStyle style; + + @override + GridEditableTextCell createState() => + _EditableMediaCellState(); +} + +class _EditableMediaCellState extends GridEditableTextCell { + final PopoverController popoverController = PopoverController(); + + late final cellBloc = MediaCellBloc( + cellController: makeCellController( + widget.databaseController, + widget.cellContext, + ).as(), + ); + + @override + void dispose() { + cellBloc.close(); + widget.skin.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocProvider.value( + value: cellBloc..add(const MediaCellEvent.initial()), + child: Builder( + builder: (context) => widget.skin.build( + context, + widget.cellContainerNotifier, + popoverController, + cellBloc, + ), + ), + ); + } + + @override + SingleListenerFocusNode focusNode = SingleListenerFocusNode(); + + @override + void onRequestFocus() => widget.skin.autoShowPopover(widget.style) + ? popoverController.show() + : null; + + @override + String? onCopy() => null; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_checkbox_cell.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_checkbox_cell.dart index 2e9e4b1a24987..279e79091386e 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_checkbox_cell.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/mobile_row_detail/mobile_row_detail_checkbox_cell.dart @@ -1,8 +1,9 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/checkbox_cell_bloc.dart'; import 'package:appflowy/plugins/database/widgets/row/cells/cell_container.dart'; import 'package:flowy_infra/theme_extension.dart'; -import 'package:flutter/material.dart'; import '../editable_cell_skeleton/checkbox.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/media_cell_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/media_cell_editor.dart new file mode 100644 index 0000000000000..d4995c7f3609e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/media_cell_editor.dart @@ -0,0 +1,507 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/media_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/media_file_type_ext.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_block_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/shared/appflowy_network_image.dart'; +import 'package:appflowy/util/xfile_ext.dart'; +import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pb.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:cross_file/cross_file.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flowy_infra_ui/style_widget/hover.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class MediaCellEditor extends StatefulWidget { + const MediaCellEditor({super.key}); + + @override + State createState() => _MediaCellEditorState(); +} + +class _MediaCellEditorState extends State { + final addFilePopoverController = PopoverController(); + final itemMutex = PopoverMutex(); + + @override + void dispose() { + addFilePopoverController.close(); + itemMutex.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return BlocBuilder( + builder: (context, state) { + return Padding( + padding: const EdgeInsets.all(4), + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (state.files.isNotEmpty) ...[ + ReorderableListView.builder( + shrinkWrap: true, + buildDefaultDragHandles: false, + itemBuilder: (_, index) => BlocProvider.value( + key: Key(state.files[index].id), + value: context.read(), + child: RenderMedia( + file: state.files[index], + index: index, + enableReordering: state.files.length > 1, + mutex: itemMutex, + ), + ), + itemCount: state.files.length, + onReorder: (from, to) => context + .read() + .add(MediaCellEvent.reorderFiles(from: from, to: to)), + proxyDecorator: (child, index, animation) => Material( + color: Colors.transparent, + child: SizeTransition( + sizeFactor: animation, + child: child, + ), + ), + ), + const Divider(height: 8), + ], + AppFlowyPopover( + controller: addFilePopoverController, + direction: PopoverDirection.bottomWithCenterAligned, + offset: const Offset(0, 10), + constraints: const BoxConstraints( + minWidth: 250, + maxWidth: 250, + ), + triggerActions: PopoverTriggerFlags.none, + popupBuilder: (popoverContext) => FileUploadMenu( + onInsertLocalFile: (file) async => insertLocalFile( + context, + file, + userProfile: + context.read().state.userProfile, + documentId: context.read().rowId, + onUploadSuccess: (path, isLocalMode) { + final mediaCellBloc = context.read(); + if (mediaCellBloc.isClosed) { + return; + } + + mediaCellBloc.add( + MediaCellEvent.addFile( + url: path, + name: file.name, + uploadType: isLocalMode + ? MediaUploadTypePB.LocalMedia + : MediaUploadTypePB.CloudMedia, + fileType: file.fileType.toMediaFileTypePB(), + ), + ); + + addFilePopoverController.close(); + }, + ), + onInsertNetworkFile: (url) { + if (url.isEmpty) return; + + final uri = Uri.tryParse(url); + if (uri == null) { + return; + } + + final fakeFile = XFile(uri.path); + MediaFileTypePB fileType = + fakeFile.fileType.toMediaFileTypePB(); + fileType = fileType == MediaFileTypePB.Other + ? MediaFileTypePB.Link + : fileType; + + String name = uri.pathSegments.isNotEmpty + ? uri.pathSegments.last + : ""; + if (name.isEmpty && uri.pathSegments.length > 1) { + name = uri.pathSegments[uri.pathSegments.length - 2]; + } else if (name.isEmpty) { + name = uri.host; + } + + context.read().add( + MediaCellEvent.addFile( + url: url, + name: name, + uploadType: MediaUploadTypePB.NetworkMedia, + fileType: fileType, + ), + ); + + addFilePopoverController.close(); + }, + ), + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: addFilePopoverController.show, + child: FlowyHover( + resetHoverOnRebuild: false, + child: Padding( + padding: const EdgeInsets.all(4.0), + child: Row( + children: [ + const FlowySvg( + FlowySvgs.add_s, + size: Size.square(18), + ), + const HSpace(8), + FlowyText( + LocaleKeys.grid_media_addFileOrImage.tr(), + lineHeight: 1.0, + ), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +extension ToCustomImageType on MediaUploadTypePB { + CustomImageType toCustomImageType() => switch (this) { + MediaUploadTypePB.NetworkMedia => CustomImageType.external, + MediaUploadTypePB.CloudMedia => CustomImageType.internal, + _ => CustomImageType.local, + }; +} + +@visibleForTesting +class RenderMedia extends StatefulWidget { + const RenderMedia({ + super.key, + required this.index, + required this.file, + required this.enableReordering, + required this.mutex, + }); + + final int index; + final MediaFilePB file; + final bool enableReordering; + final PopoverMutex mutex; + + @override + State createState() => _RenderMediaState(); +} + +class _RenderMediaState extends State { + bool isHovering = false; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (_) => setState(() => isHovering = true), + onExit: (_) => setState(() => isHovering = false), + cursor: SystemMouseCursors.click, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: DecoratedBox( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: isHovering + ? AFThemeExtension.of(context).greyHover + : Colors.transparent, + ), + child: Padding( + padding: const EdgeInsets.all(4), + child: Row( + children: [ + ReorderableDragStartListener( + index: widget.index, + enabled: widget.enableReordering, + child: const FlowySvg(FlowySvgs.drag_element_s), + ), + const HSpace(8), + if (widget.file.fileType == MediaFileTypePB.Image && + widget.file.uploadType == MediaUploadTypePB.CloudMedia) ...[ + Expanded( + child: _openInteractiveViewer( + context, + file: widget.file, + child: FlowyNetworkImage( + url: widget.file.url, + userProfilePB: + context.read().state.userProfile, + ), + ), + ), + ] else if (widget.file.fileType == MediaFileTypePB.Image) ...[ + Expanded( + child: _openInteractiveViewer( + context, + file: widget.file, + child: widget.file.uploadType == + MediaUploadTypePB.NetworkMedia + ? Image.network( + widget.file.url, + fit: BoxFit.cover, + alignment: Alignment.centerLeft, + ) + : Image.file( + File(widget.file.url), + fit: BoxFit.cover, + alignment: Alignment.centerLeft, + ), + ), + ), + ] else ...[ + Expanded( + child: GestureDetector( + onTap: () => afLaunchUrlString(widget.file.url), + child: Row( + children: [ + FlowySvg( + widget.file.fileType.icon, + color: AFThemeExtension.of(context).strongText, + size: const Size.square(18), + ), + const HSpace(8), + Flexible( + child: FlowyText( + widget.file.name, + overflow: TextOverflow.ellipsis, + ), + ), + ], + ), + ), + ), + ], + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: AppFlowyPopover( + mutex: widget.mutex, + asBarrier: true, + constraints: const BoxConstraints(maxWidth: 150), + direction: PopoverDirection.bottomWithRightAligned, + popupBuilder: (popoverContext) => BlocProvider.value( + value: context.read(), + child: MediaItemMenu( + file: widget.file, + closeContext: popoverContext, + ), + ), + child: FlowyIconButton( + width: 24, + icon: FlowySvg( + FlowySvgs.three_dots_s, + color: AFThemeExtension.of(context).textColor, + ), + ), + ), + ), + ], + ), + ), + ), + ), + ); + } + + Widget _openInteractiveViewer( + BuildContext context, { + required MediaFilePB file, + required Widget child, + }) => + GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () => openInteractiveViewerFromFile( + context, + file, + onDeleteImage: (_) => + context.read().deleteFile(file.id), + userProfile: context.read().state.userProfile, + ), + child: child, + ); +} + +class MediaItemMenu extends StatefulWidget { + const MediaItemMenu({ + super.key, + required this.file, + this.closeContext, + }); + + final MediaFilePB file; + final BuildContext? closeContext; + + @override + State createState() => _MediaItemMenuState(); +} + +class _MediaItemMenuState extends State { + late final nameController = TextEditingController(text: widget.file.name); + final errorMessage = ValueNotifier(null); + + BuildContext? renameContext; + + @override + void dispose() { + nameController.dispose(); + errorMessage.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SeparatedColumn( + separatorBuilder: () => const VSpace(4), + mainAxisSize: MainAxisSize.min, + children: [ + if (widget.file.fileType == MediaFileTypePB.Image) ...[ + FlowyButton( + onTap: () => showDialog( + context: widget.closeContext ?? context, + builder: (_) => InteractiveImageViewer( + userProfile: context.read().state.userProfile, + imageProvider: AFBlockImageProvider( + images: [ + ImageBlockData( + url: widget.file.url, + type: widget.file.uploadType.toCustomImageType(), + ), + ], + onDeleteImage: (_) => + context.read().deleteFile(widget.file.id), + ), + ), + ), + leftIcon: FlowySvg( + FlowySvgs.full_view_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(18), + ), + text: FlowyText.regular( + LocaleKeys.settings_files_open.tr(), + color: AFThemeExtension.of(context).textColor, + ), + leftIconSize: const Size(18, 18), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + ), + ], + FlowyButton( + leftIcon: FlowySvg( + FlowySvgs.edit_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(18), + ), + leftIconSize: const Size(18, 18), + text: FlowyText.regular( + LocaleKeys.grid_media_rename.tr(), + color: AFThemeExtension.of(context).textColor, + ), + onTap: () { + nameController.selection = TextSelection( + baseOffset: 0, + extentOffset: nameController.text.length, + ); + + showCustomConfirmDialog( + context: context, + title: LocaleKeys.document_plugins_file_renameFile_title.tr(), + description: + LocaleKeys.document_plugins_file_renameFile_description.tr(), + closeOnConfirm: false, + builder: (dialogContext) { + renameContext = dialogContext; + return FileRenameTextField( + nameController: nameController, + errorMessage: errorMessage, + onSubmitted: () => _saveName(context), + disposeController: false, + ); + }, + confirmLabel: LocaleKeys.button_save.tr(), + onConfirm: () => _saveName(context), + ); + }, + ), + FlowyButton( + onTap: () async => downloadMediaFile( + context, + widget.file, + userProfile: context.read().state.userProfile, + ), + leftIcon: FlowySvg( + FlowySvgs.download_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(18), + ), + text: FlowyText.regular( + LocaleKeys.button_download.tr(), + color: AFThemeExtension.of(context).textColor, + ), + leftIconSize: const Size(18, 18), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + ), + FlowyButton( + onTap: () => context.read().add( + MediaCellEvent.removeFile( + fileId: widget.file.id, + ), + ), + leftIcon: FlowySvg( + FlowySvgs.delete_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(18), + ), + text: FlowyText.regular( + LocaleKeys.button_delete.tr(), + color: AFThemeExtension.of(context).textColor, + ), + leftIconSize: const Size(18, 18), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + ), + ], + ); + } + + void _saveName(BuildContext context) { + if (nameController.text.isEmpty) { + errorMessage.value = + LocaleKeys.document_plugins_file_renameFile_nameEmptyError.tr(); + return; + } + + context.read().add( + MediaCellEvent.renameFile( + fileId: widget.file.id, + name: nameController.text, + ), + ); + + if (renameContext != null) { + Navigator.of(renameContext!).pop(); + } + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_checklist_cell_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_checklist_cell_editor.dart index fb8b11474c221..d1868d70ffbb6 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_checklist_cell_editor.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_checklist_cell_editor.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; @@ -8,14 +10,11 @@ import 'package:appflowy/plugins/database/application/cell/bloc/checklist_cell_b 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'; import 'package:go_router/go_router.dart'; class MobileChecklistCellEditScreen extends StatefulWidget { - const MobileChecklistCellEditScreen({ - super.key, - }); + const MobileChecklistCellEditScreen({super.key}); @override State createState() => @@ -48,21 +47,8 @@ class _MobileChecklistCellEditScreenState } Widget _buildHeader(BuildContext context) { - const iconWidth = 36.0; - const height = 44.0; return Stack( children: [ - Align( - alignment: Alignment.centerLeft, - child: FlowyIconButton( - icon: const FlowySvg( - FlowySvgs.close_s, - size: Size.square(iconWidth), - ), - width: iconWidth, - onPressed: () => context.pop(), - ), - ), SizedBox( height: 44.0, child: Align( @@ -72,7 +58,7 @@ class _MobileChecklistCellEditScreenState ), ), ), - ].map((e) => SizedBox(height: height, child: e)).toList(), + ], ); } } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_media_cell_editor.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_media_cell_editor.dart new file mode 100644 index 0000000000000..825de97d643d0 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/mobile_media_cell_editor.dart @@ -0,0 +1,376 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; +import 'package:appflowy/mobile/presentation/widgets/flowy_option_tile.dart'; +import 'package:appflowy/plugins/base/drag_handler.dart'; +import 'package:appflowy/plugins/database/application/cell/bloc/media_cell_bloc.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/media_cell_editor.dart'; +import 'package:appflowy/plugins/database/widgets/media_file_type_ext.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_util.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; +import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/image_render.dart'; +import 'package:appflowy/util/xfile_ext.dart'; +import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pb.dart'; +import 'package:cross_file/cross_file.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:go_router/go_router.dart'; + +import '../../../document/presentation/editor_plugins/openai/widgets/loading.dart'; + +class MobileMediaCellEditor extends StatelessWidget { + const MobileMediaCellEditor({super.key}); + + @override + Widget build(BuildContext context) { + return ConstrainedBox( + constraints: const BoxConstraints.tightFor(height: 420), + child: BlocProvider.value( + value: context.read(), + child: BlocBuilder( + builder: (context, state) => Column( + mainAxisSize: MainAxisSize.min, + children: [ + const DragHandle(), + SizedBox( + height: 44.0, + child: Align( + child: FlowyText.medium( + LocaleKeys.grid_field_mediaFieldName.tr(), + fontSize: 18, + ), + ), + ), + const Divider(height: 0.5), + Expanded( + child: SingleChildScrollView( + child: Column( + children: [ + Padding( + padding: const EdgeInsets.all(8), + child: FlowyButton( + margin: const EdgeInsets.all(12), + onTap: () => showMobileBottomSheet( + context, + title: LocaleKeys.grid_media_addFileMobile.tr(), + showHeader: true, + showCloseButton: true, + showDragHandle: true, + builder: (dialogContext) => Container( + margin: const EdgeInsets.only(top: 12), + constraints: const BoxConstraints( + maxHeight: 340, + minHeight: 80, + ), + child: FileUploadMenu( + onInsertLocalFile: (file) async { + dialogContext.pop(); + + await insertLocalFile( + context, + file, + userProfile: context + .read() + .state + .userProfile, + documentId: + context.read().rowId, + onUploadSuccess: (path, isLocalMode) { + final mediaCellBloc = + context.read(); + if (mediaCellBloc.isClosed) { + return; + } + + mediaCellBloc.add( + MediaCellEvent.addFile( + url: path, + name: file.name, + uploadType: isLocalMode + ? MediaUploadTypePB.LocalMedia + : MediaUploadTypePB.CloudMedia, + fileType: + file.fileType.toMediaFileTypePB(), + ), + ); + }, + ); + }, + onInsertNetworkFile: (url) async => + _onInsertNetworkFile( + url, + dialogContext, + context, + ), + ), + ), + ), + text: const Row( + children: [ + FlowySvg( + FlowySvgs.add_s, + size: Size.square(20), + ), + HSpace(8), + FlowyText('Add a file or image', fontSize: 15), + ], + ), + ), + ), + if (state.files.isNotEmpty) const Divider(height: .5), + ...state.files.map( + (file) => _FileItem(key: Key(file.id), file: file), + ), + ], + ), + ), + ), + ], + ), + ), + ), + ); + } + + Future _onInsertNetworkFile( + String url, + BuildContext dialogContext, + BuildContext context, + ) async { + dialogContext.pop(); + + if (url.isEmpty) return; + final uri = Uri.tryParse(url); + if (uri == null) { + return; + } + + final fakeFile = XFile(uri.path); + MediaFileTypePB fileType = fakeFile.fileType.toMediaFileTypePB(); + fileType = + fileType == MediaFileTypePB.Other ? MediaFileTypePB.Link : fileType; + + String name = uri.pathSegments.isNotEmpty ? uri.pathSegments.last : ""; + if (name.isEmpty && uri.pathSegments.length > 1) { + name = uri.pathSegments[uri.pathSegments.length - 2]; + } else if (name.isEmpty) { + name = uri.host; + } + + context.read().add( + MediaCellEvent.addFile( + url: url, + name: name, + uploadType: MediaUploadTypePB.NetworkMedia, + fileType: fileType, + ), + ); + } +} + +class _FileItem extends StatelessWidget { + const _FileItem({super.key, required this.file}); + + final MediaFilePB file; + + @override + Widget build(BuildContext context) { + return Column( + children: [ + ListTile( + title: Row( + children: [ + if (file.fileType != MediaFileTypePB.Image) ...[ + FlowySvg(file.fileType.icon, size: const Size.square(24)), + const HSpace(12), + Expanded( + child: FlowyText( + file.name, + overflow: TextOverflow.ellipsis, + ), + ), + ] else ...[ + Expanded( + child: Container( + alignment: Alignment.centerLeft, + constraints: const BoxConstraints(maxHeight: 125), + child: GestureDetector( + onTap: () => openInteractiveViewer(context), + child: ImageRender( + userProfile: + context.read().state.userProfile, + fit: BoxFit.fitHeight, + image: ImageBlockData( + url: file.url, + type: file.uploadType.toCustomImageType(), + ), + ), + ), + ), + ), + ], + FlowyIconButton( + width: 40, + icon: const FlowySvg( + FlowySvgs.three_dots_s, + size: Size.square(20), + ), + onPressed: () => showMobileBottomSheet( + context, + showDragHandle: true, + builder: (_) => BlocProvider.value( + value: context.read(), + child: _EditFileSheet(file: file), + ), + ), + ), + const HSpace(6), + ], + ), + ), + const Divider(height: .5), + ], + ); + } + + void openInteractiveViewer(BuildContext context) => + openInteractiveViewerFromFile( + context, + file, + onDeleteImage: (_) => context.read().deleteFile(file.id), + userProfile: context.read().state.userProfile, + ); +} + +class _EditFileSheet extends StatefulWidget { + const _EditFileSheet({required this.file}); + + final MediaFilePB file; + + @override + State<_EditFileSheet> createState() => __EditFileSheetState(); +} + +class __EditFileSheetState extends State<_EditFileSheet> { + late final controller = TextEditingController(text: widget.file.name); + Loading? loader; + + MediaFilePB get file => widget.file; + + @override + void dispose() { + controller.dispose(); + loader?.stop(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return SingleChildScrollView( + child: Column( + children: [ + const VSpace(16), + _FileTextField( + file: file, + controller: controller, + onChanged: (name) => + context.read().renameFile(file.id, name), + ), + const VSpace(20), + if (file.fileType == MediaFileTypePB.Image) + FlowyOptionTile.text( + text: LocaleKeys.grid_media_open.tr(), + leftIcon: const FlowySvg( + FlowySvgs.full_view_s, + size: Size.square(20), + ), + onTap: () => openInteractiveViewer(context), + ), + FlowyOptionTile.text( + text: file.fileType == MediaFileTypePB.Link + ? LocaleKeys.grid_media_open.tr() + : LocaleKeys.grid_media_download.tr(), + leftIcon: FlowySvg( + file.fileType == MediaFileTypePB.Link + ? FlowySvgs.m_link_m + : FlowySvgs.import_s, + size: const Size.square(20), + ), + onTap: () async => downloadMediaFile( + context, + widget.file, + userProfile: context.read().state.userProfile, + onDownloadBegin: () { + loader?.stop(); + loader = Loading(context); + loader?.start(); + }, + onDownloadEnd: () => loader?.stop(), + ), + ), + FlowyOptionTile.text( + text: LocaleKeys.grid_media_delete.tr(), + textColor: Theme.of(context).colorScheme.error, + leftIcon: FlowySvg( + FlowySvgs.trash_s, + size: const Size.square(20), + color: Theme.of(context).colorScheme.error, + ), + onTap: () { + context.pop(); + context.read().deleteFile(file.id); + }, + ), + ], + ), + ); + } + + void openInteractiveViewer(BuildContext context) => + openInteractiveViewerFromFile( + context, + file, + onDeleteImage: (_) => context.read().deleteFile(file.id), + userProfile: context.read().state.userProfile, + ); +} + +class _FileTextField extends StatelessWidget { + const _FileTextField({ + required this.file, + required this.controller, + required this.onChanged, + }); + + final MediaFilePB file; + final TextEditingController controller; + final void Function(String) onChanged; + + @override + Widget build(BuildContext context) { + return FlowyOptionTile.textField( + controller: controller, + textFieldPadding: const EdgeInsets.symmetric(horizontal: 12), + onTextChanged: onChanged, + leftIcon: Container( + height: 38, + width: 38, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(12), + color: file.fileType.color, + ), + child: Center( + child: FlowySvg( + file.fileType.icon, + size: const Size.square(22), + ), + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_type_list.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_type_list.dart index 69fe3635a81ba..bb56bfa83eaf4 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_type_list.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/field_type_list.dart @@ -24,6 +24,7 @@ const List _supportedFieldTypes = [ FieldType.Summary, // FieldType.Time, FieldType.Translate, + FieldType.Media, ]; class FieldTypeList extends StatelessWidget with FlowyOverlayDelegate { diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/builder.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/builder.dart index e4bcdd4911ef5..624f9f1fb2d76 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/builder.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/builder.dart @@ -2,6 +2,7 @@ import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:appflowy/plugins/database/widgets/field/type_option_editor/media.dart'; import 'package:appflowy/plugins/database/widgets/field/type_option_editor/translate.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; @@ -38,6 +39,7 @@ abstract class TypeOptionEditorFactory { FieldType.Summary => const SummaryTypeOptionEditorFactory(), FieldType.Time => const TimeTypeOptionEditorFactory(), FieldType.Translate => const TranslateTypeOptionEditorFactory(), + FieldType.Media => const MediaTypeOptionEditorFactory(), _ => throw UnimplementedError(), }; } diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/media.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/media.dart new file mode 100644 index 0000000000000..ecd1ba73fb173 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/field/type_option_editor/media.dart @@ -0,0 +1,19 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/plugins/database/widgets/field/type_option_editor/builder.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; + +class MediaTypeOptionEditorFactory implements TypeOptionEditorFactory { + const MediaTypeOptionEditorFactory(); + + @override + Widget? build({ + required BuildContext context, + required String viewId, + required FieldPB field, + required PopoverMutex popoverMutex, + required TypeOptionDataCallback onTypeOptionUpdated, + }) => + null; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/media_file_type_ext.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/media_file_type_ext.dart new file mode 100644 index 0000000000000..d4ae050dfa70d --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/media_file_type_ext.dart @@ -0,0 +1,28 @@ +import 'package:flutter/material.dart'; + +import 'package:appflowy/generated/flowy_svgs.g.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pb.dart'; + +extension FileTypeDisplay on MediaFileTypePB { + FlowySvgData get icon => switch (this) { + MediaFileTypePB.Image => FlowySvgs.image_s, + MediaFileTypePB.Link => FlowySvgs.ft_link_s, + MediaFileTypePB.Document => FlowySvgs.document_s, + MediaFileTypePB.Archive => FlowySvgs.ft_archive_s, + MediaFileTypePB.Video => FlowySvgs.ft_video_s, + MediaFileTypePB.Audio => FlowySvgs.ft_audio_s, + MediaFileTypePB.Text => FlowySvgs.ft_text_s, + _ => FlowySvgs.document_s, + }; + + Color get color => switch (this) { + MediaFileTypePB.Image => const Color(0xFF5465A1), + MediaFileTypePB.Link => const Color(0xFFA35F94), + MediaFileTypePB.Document => const Color(0xFFBAAC74), + MediaFileTypePB.Archive => const Color(0xFF40AAB8), + MediaFileTypePB.Video => const Color(0xFF5465A1), + MediaFileTypePB.Audio => const Color(0xFF5465A1), + MediaFileTypePB.Text => const Color(0xFF87B3A8), + _ => const Color(0xFF87B3A8), + }; +} diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/accessory/cell_accessory.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/accessory/cell_accessory.dart index febd6a6749144..437d125f53ec6 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/accessory/cell_accessory.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/accessory/cell_accessory.dart @@ -2,6 +2,7 @@ import 'package:flutter/material.dart'; import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/database/grid/presentation/widgets/header/field_type_extension.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/field_entities.pb.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/size.dart'; @@ -124,6 +125,12 @@ class _AccessoryHoverState extends State { @override Widget build(BuildContext context) { + // Some FieldType has built-in handling for more gestures + // and granular control, so we don't need to show the accessory. + if (!widget.fieldType.showRowDetailAccessory) { + return widget.child; + } + final List children = [ DecoratedBox( decoration: BoxDecoration( diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/cell_container.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/cell_container.dart index 22a9ce13811fe..7f0d850c209bb 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/cell_container.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/cells/cell_container.dart @@ -1,12 +1,13 @@ import 'package:flutter/material.dart'; + import 'package:provider/provider.dart'; import 'package:styled_widget/styled_widget.dart'; import '../../../grid/presentation/layout/sizes.dart'; import '../../../grid/presentation/widgets/row/row.dart'; +import '../../cell/editable_cell_builder.dart'; import '../accessory/cell_accessory.dart'; import '../accessory/cell_shortcuts.dart'; -import '../../cell/editable_cell_builder.dart'; class CellContainer extends StatelessWidget { const CellContainer({ diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_banner.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_banner.dart index 1fd5b8822e851..6ffca627169fa 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_banner.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_banner.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; @@ -13,7 +15,6 @@ import 'package:appflowy/workspace/presentation/settings/widgets/emoji_picker/em import 'package:appflowy_popover/appflowy_popover.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'; const _kBannerActionHeight = 40.0; diff --git a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_property.dart b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_property.dart index 6dd81fc501246..304e444761f9a 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_property.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/row/row_property.dart @@ -1,5 +1,8 @@ import 'dart:io'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/cell_controller.dart'; @@ -18,11 +21,10 @@ import 'package:collection/collection.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import '../cell/editable_cell_builder.dart'; + import 'accessory/cell_accessory.dart'; /// Display the row properties in a list. Only used in [RowDetailPage]. diff --git a/frontend/appflowy_flutter/lib/plugins/database_document/presentation/database_document_title.dart b/frontend/appflowy_flutter/lib/plugins/database_document/presentation/database_document_title.dart index d509aa2f25727..4005bfcfacbed 100644 --- a/frontend/appflowy_flutter/lib/plugins/database_document/presentation/database_document_title.dart +++ b/frontend/appflowy_flutter/lib/plugins/database_document/presentation/database_document_title.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart'; @@ -12,7 +14,6 @@ import 'package:appflowy_popover/appflowy_popover.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'; import 'database_document_title_bloc.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart index cc99dd60c5dfd..53b9084fabfc8 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart @@ -1,6 +1,8 @@ import 'dart:async'; import 'dart:convert'; +import 'package:flutter/foundation.dart'; + import 'package:appflowy/plugins/document/application/doc_sync_state_listener.dart'; import 'package:appflowy/plugins/document/application/document_awareness_metadata.dart'; import 'package:appflowy/plugins/document/application/document_collab_adapter.dart'; @@ -32,7 +34,6 @@ import 'package:appflowy_editor/appflowy_editor.dart' Position, paragraphNode; import 'package:appflowy_result/appflowy_result.dart'; -import 'package:flutter/foundation.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:freezed_annotation/freezed_annotation.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/application/document_service.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_service.dart index 5118620d98852..57b86ce2d48c9 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/application/document_service.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/application/document_service.dart @@ -117,17 +117,19 @@ class DocumentService { required String documentId, }) async { final workspace = await FolderEventReadCurrentWorkspace().send(); - return workspace.fold((l) async { - final payload = UploadFileParamsPB( - workspaceId: l.id, - localFilePath: localFilePath, - documentId: documentId, - ); - final result = await DocumentEventUploadFile(payload).send(); - return result; - }, (r) async { - return FlowyResult.failure(FlowyError(msg: 'Workspace not found')); - }); + return workspace.fold( + (l) async { + final payload = UploadFileParamsPB( + workspaceId: l.id, + localFilePath: localFilePath, + documentId: documentId, + ); + return DocumentEventUploadFile(payload).send(); + }, + (r) async { + return FlowyResult.failure(FlowyError(msg: 'Workspace not found')); + }, + ); } /// Download a file from the cloud storage. diff --git a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart index 1827a42bf6f00..de1a230ec7d4d 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/document_page.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/document_page.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/mobile/application/page_style/document_page_style_bloc.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/banner.dart'; @@ -12,7 +14,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/image/mult import 'package:appflowy/plugins/document/presentation/editor_plugins/plugins.dart'; import 'package:appflowy/plugins/document/presentation/editor_style.dart'; import 'package:appflowy/shared/flowy_error_page.dart'; -import 'package:appflowy/shared/patterns/common_patterns.dart'; +import 'package:appflowy/shared/patterns/file_type_patterns.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/action_navigation/action_navigation_bloc.dart'; import 'package:appflowy/workspace/application/action_navigation/navigation_action.dart'; @@ -22,7 +24,6 @@ import 'package:appflowy_backend/protobuf/flowy-folder/view.pb.dart'; import 'package:appflowy_editor/appflowy_editor.dart' hide Log; import 'package:cross_file/cross_file.dart'; import 'package:desktop_drop/desktop_drop.dart'; -import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:provider/provider.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart index 57ebe69fc6e0a..b779735252f57 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/copy_and_paste/paste_from_image.dart @@ -6,7 +6,7 @@ import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; -import 'package:appflowy/shared/patterns/common_patterns.dart'; +import 'package:appflowy/shared/patterns/file_type_patterns.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; import 'package:appflowy/workspace/presentation/widgets/dialogs.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart index 5b1b3aa979040..0e3a83ba7c501 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart @@ -10,6 +10,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/file/file_ import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:cross_file/cross_file.dart'; import 'package:desktop_drop/desktop_drop.dart'; import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; @@ -237,7 +238,7 @@ class FileBlockComponentState extends State }, onDragDone: (details) { if (dropManagerState.isDropEnabled) { - insertFileFromLocal(details.files.first.path); + insertFileFromLocal(details.files.first); } }, child: AppFlowyPopover( @@ -359,7 +360,8 @@ class FileBlockComponentState extends State } } - Future insertFileFromLocal(String path) async { + Future insertFileFromLocal(XFile file) async { + final path = file.path; final documentBloc = context.read(); final isLocalMode = documentBloc.isLocalMode; final urlType = isLocalMode ? FileUrlType.local : FileUrlType.cloud; @@ -382,12 +384,11 @@ class FileBlockComponentState extends State // Remove the file block from the drop state manager dropManagerState.remove(FileBlockKeys.type); - final name = Uri.tryParse(path)?.pathSegments.last ?? url; final transaction = editorState.transaction; transaction.updateNode(widget.node, { FileBlockKeys.url: url, FileBlockKeys.urlType: urlType.toIntValue(), - FileBlockKeys.name: name, + FileBlockKeys.name: file.name, FileBlockKeys.uploadedAt: DateTime.now().millisecondsSinceEpoch, }); await editorState.apply(transaction); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_menu.dart index 872f0d61d0636..133a9fb77adca 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_menu.dart @@ -79,8 +79,7 @@ class _FileBlockMenuState extends State { closeOnConfirm: false, builder: (context) { renameContext = context; - - return _RenameTextField( + return FileRenameTextField( nameController: nameController, errorMessage: errorMessage, onSubmitted: _saveName, @@ -146,22 +145,26 @@ class _FileBlockMenuState extends State { } } -class _RenameTextField extends StatefulWidget { - const _RenameTextField({ +class FileRenameTextField extends StatefulWidget { + const FileRenameTextField({ + super.key, required this.nameController, required this.errorMessage, required this.onSubmitted, + this.disposeController = true, }); final TextEditingController nameController; final ValueNotifier errorMessage; final VoidCallback onSubmitted; + final bool disposeController; + @override - State<_RenameTextField> createState() => _RenameTextFieldState(); + State createState() => _FileRenameTextFieldState(); } -class _RenameTextFieldState extends State<_RenameTextField> { +class _FileRenameTextFieldState extends State { @override void initState() { super.initState(); @@ -171,7 +174,9 @@ class _RenameTextFieldState extends State<_RenameTextField> { @override void dispose() { widget.errorMessage.removeListener(_setState); - widget.nameController.dispose(); + if (widget.disposeController) { + widget.nameController.dispose(); + } super.dispose(); } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart index 4baa8506fea21..e1762ba8260f4 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart @@ -4,6 +4,7 @@ import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:cross_file/cross_file.dart'; import 'package:desktop_drop/desktop_drop.dart'; import 'package:dotted_border/dotted_border.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -21,7 +22,7 @@ class FileUploadMenu extends StatefulWidget { required this.onInsertNetworkFile, }); - final void Function(String path) onInsertLocalFile; + final void Function(XFile file) onInsertLocalFile; final void Function(String url) onInsertNetworkFile; @override @@ -39,9 +40,7 @@ class _FileUploadMenuState extends State { mainAxisSize: MainAxisSize.min, children: [ TabBar( - onTap: (value) => setState(() { - currentTab = value; - }), + onTap: (value) => setState(() => currentTab = value), isScrollable: true, padding: EdgeInsets.zero, overlayColor: WidgetStatePropertyAll( @@ -61,9 +60,9 @@ class _FileUploadMenuState extends State { const Divider(height: 4), if (currentTab == 0) ...[ _FileUploadLocal( - onFilePicked: (path) { - if (path != null) { - widget.onInsertLocalFile(path); + onFilePicked: (file) { + if (file != null) { + widget.onInsertLocalFile(file); } }, ), @@ -98,7 +97,7 @@ class _Tab extends StatelessWidget { class _FileUploadLocal extends StatefulWidget { const _FileUploadLocal({required this.onFilePicked}); - final void Function(String?) onFilePicked; + final void Function(XFile?) onFilePicked; @override State<_FileUploadLocal> createState() => _FileUploadLocalState(); @@ -112,12 +111,34 @@ class _FileUploadLocalState extends State<_FileUploadLocal> { final constraints = PlatformExtension.isMobile ? const BoxConstraints(minHeight: 92) : null; + if (PlatformExtension.isMobile) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 12), + child: SizedBox( + height: 32, + width: 300, + child: FlowyButton( + backgroundColor: Theme.of(context).colorScheme.primary, + hoverColor: Theme.of(context).colorScheme.primary.withOpacity(0.9), + showDefaultBoxDecorationOnMobile: true, + margin: const EdgeInsets.all(5), + text: FlowyText( + LocaleKeys.document_plugins_file_uploadMobile.tr(), + textAlign: TextAlign.center, + color: Theme.of(context).colorScheme.onPrimary, + ), + onTap: () => _uploadFile(context), + ), + ), + ); + } + return Padding( padding: const EdgeInsets.all(4), child: DropTarget( onDragEntered: (_) => setState(() => isDragging = true), onDragExited: (_) => setState(() => isDragging = false), - onDragDone: (details) => widget.onFilePicked(details.files.first.path), + onDragDone: (details) => widget.onFilePicked(details.files.first), child: MouseRegion( cursor: SystemMouseCursors.click, child: GestureDetector( @@ -181,7 +202,9 @@ class _FileUploadLocalState extends State<_FileUploadLocal> { Future _uploadFile(BuildContext context) async { final result = await getIt().pickFiles(dialogTitle: ''); - widget.onFilePicked(result?.files.first.path); + final file = + result?.files.isNotEmpty ?? false ? result?.files.first.xFile : null; + widget.onFilePicked(file); } } diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart index 9832bc7fa99fc..5be1234e08564 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_util.dart @@ -1,13 +1,28 @@ +import 'dart:convert'; import 'dart:io'; +import 'package:flutter/material.dart'; + +import 'package:appflowy/core/helpers/url_launcher.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/application/document_service.dart'; +import 'package:appflowy/shared/custom_image_cache_manager.dart'; import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/xfile_ext.dart'; import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; +import 'package:appflowy/workspace/presentation/home/toast.dart'; import 'package:appflowy_backend/dispatch/error.dart'; import 'package:appflowy_backend/log.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pb.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/auth.pbenum.dart'; +import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; +import 'package:cross_file/cross_file.dart'; import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/file_picker/file_picker_impl.dart'; +import 'package:flowy_infra/platform_extension.dart'; import 'package:flowy_infra/uuid.dart'; +import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; +import 'package:http/http.dart' as http; import 'package:path/path.dart' as p; Future saveFileToLocalStorage(String localFilePath) async { @@ -36,8 +51,9 @@ Future saveFileToLocalStorage(String localFilePath) async { Future<(String? path, String? errorMessage)> saveFileToCloudStorage( String localFilePath, - String documentId, -) async { + String documentId, [ + bool isImage = false, +]) async { final documentService = DocumentService(); Log.debug("Uploading file from local path: $localFilePath"); final result = await documentService.uploadFile( @@ -46,7 +62,16 @@ Future<(String? path, String? errorMessage)> saveFileToCloudStorage( ); return result.fold( - (s) => (s.url, null), + (s) async { + if (isImage) { + await CustomImageCacheManager().putFile( + s.url, + File(localFilePath).readAsBytesSync(), + ); + } + + return (s.url, null); + }, (err) { final message = Platform.isIOS ? LocaleKeys.sideBar_storageLimitDialogTitleIOS.tr() @@ -58,3 +83,112 @@ Future<(String? path, String? errorMessage)> saveFileToCloudStorage( }, ); } + +/// Downloads a MediaFilePB +/// +/// On Mobile the file is fetched first using HTTP, and then saved using FilePicker. +/// On Desktop the files location is picked first using FilePicker, and then the file is saved. +/// +Future downloadMediaFile( + BuildContext context, + MediaFilePB file, { + VoidCallback? onDownloadBegin, + VoidCallback? onDownloadEnd, + UserProfilePB? userProfile, +}) async { + if ([ + MediaUploadTypePB.NetworkMedia, + MediaUploadTypePB.LocalMedia, + ].contains(file.uploadType)) { + /// When the file is a network file or a local file, we can directly open the file. + await afLaunchUrl(Uri.parse(file.url)); + } else { + if (userProfile == null) { + return showSnapBar( + context, + "Failed to download file, could not find user token", + ); + } + + final uri = Uri.parse(file.url); + final token = jsonDecode(userProfile.token)['access_token']; + + if (PlatformExtension.isMobile) { + onDownloadBegin?.call(); + + final response = + await http.get(uri, headers: {'Authorization': 'Bearer $token'}); + + if (response.statusCode == 200) { + final tempFile = File(uri.pathSegments.last); + await FilePicker().saveFile( + fileName: p.basename(tempFile.path), + bytes: response.bodyBytes, + ); + } else if (context.mounted) { + showSnapBar( + context, + LocaleKeys.document_plugins_image_imageDownloadFailed.tr(), + ); + } + + onDownloadEnd?.call(); + } else { + final savePath = await FilePicker().saveFile(fileName: file.name); + if (savePath == null) { + return; + } + + final response = + await http.get(uri, headers: {'Authorization': 'Bearer $token'}); + + if (response.statusCode == 200) { + final imgFile = File(savePath); + await imgFile.writeAsBytes(response.bodyBytes); + } else if (context.mounted) { + showSnapBar( + context, + LocaleKeys.document_plugins_image_imageDownloadFailed.tr(), + ); + } + } + } +} + +Future insertLocalFile( + BuildContext context, + XFile file, { + required String documentId, + UserProfilePB? userProfile, + void Function(String, bool)? onUploadSuccess, +}) async { + if (file.path.isEmpty) return; + + final fileType = file.fileType.toMediaFileTypePB(); + + // Check upload type + final isLocalMode = (userProfile?.authenticator ?? AuthenticatorPB.Local) == + AuthenticatorPB.Local; + + String? path; + String? errorMsg; + if (isLocalMode) { + path = await saveFileToLocalStorage(file.path); + } else { + (path, errorMsg) = await saveFileToCloudStorage( + file.path, + documentId, + fileType == MediaFileTypePB.Image, + ); + } + + if (errorMsg != null) { + return showSnackBarMessage(context, errorMsg); + } + + if (path == null) { + return; + } + + onUploadSuccess?.call(path, isLocalMode); +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_placeholder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_placeholder.dart index 08ad0a9ac988d..b01b5a6939d97 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_placeholder.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/image_placeholder.dart @@ -11,7 +11,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/image/comm import 'package:appflowy/plugins/document/presentation/editor_plugins/image/custom_image_block_component/custom_image_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart'; -import 'package:appflowy/shared/patterns/common_patterns.dart'; +import 'package:appflowy/shared/patterns/file_type_patterns.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart index fd9e1f51692a8..9f6b10cf3c725 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/image_browser_layout.dart @@ -10,7 +10,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/image/imag import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/layouts/multi_image_layouts.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart'; import 'package:appflowy/shared/appflowy_network_image.dart'; -import 'package:appflowy/shared/patterns/common_patterns.dart'; +import 'package:appflowy/shared/patterns/file_type_patterns.dart'; import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; @@ -231,7 +231,7 @@ class _ImageBrowserLayoutState extends State { mainAxisAlignment: MainAxisAlignment.center, children: [ const FlowySvg( - FlowySvgs.import_s, + FlowySvgs.download_s, size: Size.square(28), ), const HSpace(12), diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart index 4e1cfd1fcb358..57e8c1142f8da 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_placeholder.dart @@ -1,5 +1,7 @@ import 'dart:io'; +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/flowy_svgs.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/mobile/presentation/bottom_sheet/show_mobile_bottom_sheet.dart'; @@ -10,7 +12,7 @@ import 'package:appflowy/plugins/document/presentation/editor_plugins/image/comm import 'package:appflowy/plugins/document/presentation/editor_plugins/image/image_util.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/multi_image_block_component/multi_image_block_component.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart'; -import 'package:appflowy/shared/patterns/common_patterns.dart'; +import 'package:appflowy/shared/patterns/file_type_patterns.dart'; import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/workspace/application/settings/application_data_storage.dart'; import 'package:appflowy/workspace/presentation/home/toast.dart'; @@ -22,7 +24,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/uuid.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; -import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:http/http.dart'; import 'package:path/path.dart' as p; diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart index 6c4a8dcfd34d8..a81abf368bd12 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/image/upload_image_menu/upload_image_menu.dart @@ -1,3 +1,5 @@ +import 'package:flutter/material.dart'; + import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/header/cover_editor.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/unsplash_image_widget.dart'; @@ -9,7 +11,6 @@ import 'package:easy_localization/easy_localization.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; -import 'package:flutter/material.dart'; import 'widgets/embed_image_url_widget.dart'; diff --git a/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart b/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart index baa585a88a9fb..fb9cd9f226449 100644 --- a/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart +++ b/frontend/appflowy_flutter/lib/shared/patterns/common_patterns.dart @@ -13,9 +13,6 @@ const _imgUrlPattern = r'(https?:\/\/)([^\s(["<,>/]*)(\/)[^\s[",><]*(.png|.jpg|.gif|.webm)(\?[^\s[",><]*)?'; final imgUrlRegex = RegExp(_imgUrlPattern); -const _imgExtensionPattern = r'\.(gif|jpe?g|tiff?|png|webp|bmp)$'; -final imgExtensionRegex = RegExp(_imgExtensionPattern); - /// This pattern allows for both HTTP and HTTPS Scheme /// It allows for query parameters /// It only allows the following video extensions: diff --git a/frontend/appflowy_flutter/lib/shared/patterns/file_type_patterns.dart b/frontend/appflowy_flutter/lib/shared/patterns/file_type_patterns.dart new file mode 100644 index 0000000000000..418dd47f3d5b4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/shared/patterns/file_type_patterns.dart @@ -0,0 +1,29 @@ +/// This pattern matches a file extension that is an image. +/// +const _imgExtensionPattern = r'\.(gif|jpe?g|tiff?|png|webp|bmp)$'; +final imgExtensionRegex = RegExp(_imgExtensionPattern); + +/// This pattern matches a file extension that is a video. +/// +const _videoExtensionPattern = r'\.(mp4|mov|avi|webm|flv|m4v|mpeg|h264)$'; +final videoExtensionRegex = RegExp(_videoExtensionPattern); + +/// This pattern matches a file extension that is an audio. +/// +const _audioExtensionPattern = r'\.(mp3|wav|ogg|flac|aac|wma|alac|aiff)$'; +final audioExtensionRegex = RegExp(_audioExtensionPattern); + +/// This pattern matches a file extension that is a document. +/// +const _documentExtensionPattern = r'\.(pdf|doc|docx)$'; +final documentExtensionRegex = RegExp(_documentExtensionPattern); + +/// This pattern matches a file extension that is an archive. +/// +const _archiveExtensionPattern = r'\.(zip|tar|gz|7z|rar)$'; +final archiveExtensionRegex = RegExp(_archiveExtensionPattern); + +/// This pattern matches a file extension that is a text. +/// +const _textExtensionPattern = r'\.(txt|md|html|css|js|json|xml|csv)$'; +final textExtensionRegex = RegExp(_textExtensionPattern); diff --git a/frontend/appflowy_flutter/lib/startup/tasks/file_storage_task.dart b/frontend/appflowy_flutter/lib/startup/tasks/file_storage_task.dart index ee794ba9003bd..ffa331f9be2e9 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/file_storage_task.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/file_storage_task.dart @@ -3,12 +3,13 @@ import 'dart:convert'; import 'dart:ffi'; import 'dart:isolate'; +import 'package:flutter/foundation.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-storage/protobuf.dart'; import 'package:appflowy_result/appflowy_result.dart'; -import 'package:flutter/foundation.dart'; import 'package:fixnum/fixnum.dart'; import '../startup.dart'; @@ -20,9 +21,7 @@ class FileStorageTask extends LaunchTask { Future initialize(LaunchContext context) async { context.getIt.registerSingleton( FileStorageService(), - dispose: (service) async { - await service.dispose(); - }, + dispose: (service) async => service.dispose(), ); } diff --git a/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart index eebb8df1cde90..0c32a8f6ac3ad 100644 --- a/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart +++ b/frontend/appflowy_flutter/lib/startup/tasks/generate_router.dart @@ -1,5 +1,8 @@ import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; + import 'package:appflowy/mobile/presentation/chat/mobile_chat_screen.dart'; import 'package:appflowy/mobile/presentation/database/board/mobile_board_screen.dart'; import 'package:appflowy/mobile/presentation/database/card/card.dart'; @@ -32,8 +35,6 @@ import 'package:appflowy/workspace/presentation/settings/widgets/feature_flags/m import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:flowy_infra/time/duration.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:sheet/route.dart'; diff --git a/frontend/appflowy_flutter/lib/util/field_type_extension.dart b/frontend/appflowy_flutter/lib/util/field_type_extension.dart index 4ca63aa4aed0e..12d06da7b2387 100644 --- a/frontend/appflowy_flutter/lib/util/field_type_extension.dart +++ b/frontend/appflowy_flutter/lib/util/field_type_extension.dart @@ -24,6 +24,7 @@ extension FieldTypeExtension on FieldType { FieldType.Summary => LocaleKeys.grid_field_summaryFieldName.tr(), FieldType.Time => LocaleKeys.grid_field_timeFieldName.tr(), FieldType.Translate => LocaleKeys.grid_field_translateFieldName.tr(), + FieldType.Media => LocaleKeys.grid_field_mediaFieldName.tr(), _ => throw UnimplementedError(), }; @@ -42,6 +43,7 @@ extension FieldTypeExtension on FieldType { FieldType.Summary => FlowySvgs.ai_summary_s, FieldType.Time => FlowySvgs.timer_start_s, FieldType.Translate => FlowySvgs.ai_translate_s, + FieldType.Media => FlowySvgs.media_s, _ => throw UnimplementedError(), }; @@ -66,6 +68,7 @@ extension FieldTypeExtension on FieldType { FieldType.Summary => const Color(0xFFBECCFF), FieldType.Time => const Color(0xFFFDEDA7), FieldType.Translate => const Color(0xFFBECCFF), + FieldType.Media => const Color(0xFFFCBEBE), _ => throw UnimplementedError(), }; diff --git a/frontend/appflowy_flutter/lib/util/xfile_ext.dart b/frontend/appflowy_flutter/lib/util/xfile_ext.dart new file mode 100644 index 0000000000000..593ea337c1a3b --- /dev/null +++ b/frontend/appflowy_flutter/lib/util/xfile_ext.dart @@ -0,0 +1,109 @@ +import 'package:appflowy/shared/patterns/file_type_patterns.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pbenum.dart'; +import 'package:cross_file/cross_file.dart'; + +enum FileType { + other, + image, + link, + document, + archive, + video, + audio, + text; +} + +extension TypeRecognizer on XFile { + FileType get fileType { + // Prefer mime over using regexp as it is more reliable. + // Refer to Microsoft Documentation for common mime types: https://developer.mozilla.org/en-US/docs/Web/HTTP/Basics_of_HTTP/MIME_types/Common_types + if (mimeType?.isNotEmpty == true) { + if (mimeType!.contains('image')) { + return FileType.image; + } + if (mimeType!.contains('video')) { + return FileType.video; + } + if (mimeType!.contains('audio')) { + return FileType.audio; + } + if (mimeType!.contains('text')) { + return FileType.text; + } + if (mimeType!.contains('application')) { + if (mimeType!.contains('pdf') || + mimeType!.contains('doc') || + mimeType!.contains('docx')) { + return FileType.document; + } + if (mimeType!.contains('zip') || + mimeType!.contains('tar') || + mimeType!.contains('gz') || + mimeType!.contains('7z') || + // archive is used in eg. Java archives (jar) + mimeType!.contains('archive') || + mimeType!.contains('rar')) { + return FileType.archive; + } + if (mimeType!.contains('rtf')) { + return FileType.text; + } + } + + return FileType.other; + } + + // Check if the file is an image + if (imgExtensionRegex.hasMatch(path)) { + return FileType.image; + } + + // Check if the file is a video + if (videoExtensionRegex.hasMatch(path)) { + return FileType.video; + } + + // Check if the file is an audio + if (audioExtensionRegex.hasMatch(path)) { + return FileType.audio; + } + + // Check if the file is a document + if (documentExtensionRegex.hasMatch(path)) { + return FileType.document; + } + + // Check if the file is an archive + if (archiveExtensionRegex.hasMatch(path)) { + return FileType.archive; + } + + // Check if the file is a text + if (textExtensionRegex.hasMatch(path)) { + return FileType.text; + } + + return FileType.other; + } +} + +extension ToMediaFileTypePB on FileType { + MediaFileTypePB toMediaFileTypePB() { + switch (this) { + case FileType.image: + return MediaFileTypePB.Image; + case FileType.video: + return MediaFileTypePB.Video; + case FileType.audio: + return MediaFileTypePB.Audio; + case FileType.document: + return MediaFileTypePB.Document; + case FileType.archive: + return MediaFileTypePB.Archive; + case FileType.text: + return MediaFileTypePB.Text; + default: + return MediaFileTypePB.Other; + } + } +} diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart index 5c07d6d5b13d1..9897a0c55a1fa 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart @@ -177,7 +177,7 @@ class InteractiveImageToolbar extends StatelessWidget { ? currentImage.isLocal ? FlowySvgs.folder_m : FlowySvgs.m_aa_link_s - : FlowySvgs.import_s, + : FlowySvgs.download_s, onTap: () => _locateOrDownloadImage(context), ), ], diff --git a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart index 37960b3e7904e..7ea37e3ce2e32 100644 --- a/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart +++ b/frontend/appflowy_flutter/lib/workspace/presentation/widgets/image_viewer/interactive_image_viewer.dart @@ -1,10 +1,12 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:appflowy/plugins/database/widgets/cell_editor/media_cell_editor.dart'; import 'package:appflowy/plugins/document/application/document_bloc.dart'; import 'package:appflowy/plugins/document/presentation/editor_plugins/image/common.dart'; import 'package:appflowy/workspace/presentation/widgets/image_viewer/image_provider.dart'; import 'package:appflowy/workspace/presentation/widgets/image_viewer/interactive_image_toolbar.dart'; +import 'package:appflowy_backend/protobuf/flowy-database2/media_entities.pb.dart'; import 'package:appflowy_backend/protobuf/flowy-user/user_profile.pb.dart'; import 'package:provider/provider.dart'; @@ -187,3 +189,25 @@ class _InteractiveImageViewerState extends State { _onControllerChanged(); } } + +void openInteractiveViewerFromFile( + BuildContext context, + MediaFilePB file, { + required void Function(int) onDeleteImage, + UserProfilePB? userProfile, +}) => + showDialog( + context: context, + builder: (_) => InteractiveImageViewer( + userProfile: userProfile, + imageProvider: AFBlockImageProvider( + images: [ + ImageBlockData( + url: file.url, + type: file.uploadType.toCustomImageType(), + ), + ], + onDeleteImage: onDeleteImage, + ), + ), + ); diff --git a/frontend/appflowy_flutter/packages/flowy_infra/lib/file_picker/file_picker_impl.dart b/frontend/appflowy_flutter/packages/flowy_infra/lib/file_picker/file_picker_impl.dart index 2e4d082761392..1e6f6a99e29d6 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra/lib/file_picker/file_picker_impl.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra/lib/file_picker/file_picker_impl.dart @@ -1,5 +1,7 @@ -import 'package:flowy_infra/file_picker/file_picker_service.dart'; +import 'package:flutter/services.dart'; + import 'package:file_picker/file_picker.dart' as fp; +import 'package:flowy_infra/file_picker/file_picker_service.dart'; class FilePicker implements FilePickerService { @override @@ -35,6 +37,11 @@ class FilePicker implements FilePickerService { return FilePickerResult(result?.files ?? []); } + /// On Desktop it will return the path to which the file should be saved. + /// + /// On Mobile it will return the path to where the file has been saved, and will + /// automatically save it. The [bytes] parameter is required on Mobile. + /// @override Future saveFile({ String? dialogTitle, @@ -43,6 +50,7 @@ class FilePicker implements FilePickerService { FileType type = FileType.any, List? allowedExtensions, bool lockParentWindow = false, + Uint8List? bytes, }) async { final result = await fp.FilePicker.platform.saveFile( dialogTitle: dialogTitle, @@ -51,6 +59,7 @@ class FilePicker implements FilePickerService { type: type, allowedExtensions: allowedExtensions, lockParentWindow: lockParentWindow, + bytes: bytes, ); return result; diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart index b4a2553814e21..997038f5320c3 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/style_widget/icon_button.dart @@ -1,10 +1,11 @@ import 'dart:io'; +import 'package:flutter/material.dart'; + import 'package:flowy_infra/size.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flowy_svg/flowy_svg.dart'; -import 'package:flutter/material.dart'; class FlowyIconButton extends StatelessWidget { final double width; @@ -82,7 +83,6 @@ class FlowyIconButton extends StatelessWidget { preferBelow: preferBelow, message: tooltipMessage, richMessage: richTooltipText, - showDuration: Duration.zero, child: RawMaterialButton( clipBehavior: Clip.antiAlias, visualDensity: VisualDensity.compact, diff --git a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/flowy_tooltip.dart b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/flowy_tooltip.dart index da3f804c8da74..61ed4b1f181f8 100644 --- a/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/flowy_tooltip.dart +++ b/frontend/appflowy_flutter/packages/flowy_infra_ui/lib/widget/flowy_tooltip.dart @@ -8,7 +8,6 @@ class FlowyTooltip extends StatelessWidget { this.message, this.richMessage, this.preferBelow, - this.showDuration, this.margin, this.verticalOffset, this.child, @@ -17,7 +16,6 @@ class FlowyTooltip extends StatelessWidget { final String? message; final InlineSpan? richMessage; final bool? preferBelow; - final Duration? showDuration; final EdgeInsetsGeometry? margin; final Widget? child; final double? verticalOffset; diff --git a/frontend/appflowy_flutter/pubspec.lock b/frontend/appflowy_flutter/pubspec.lock index 89966137f4476..54a1cbee29aff 100644 --- a/frontend/appflowy_flutter/pubspec.lock +++ b/frontend/appflowy_flutter/pubspec.lock @@ -622,10 +622,10 @@ packages: dependency: transitive description: name: flex_seed_scheme - sha256: "6c595e545b0678e1fe17e8eec3d1fbca7237482da194fadc20ad8607dc7a7f3d" + sha256: cb5b7ec4ba525d9846d8992858a1c6cfc88f9466d96b8850e2a061aa5f682539 url: "https://pub.dev" source: hosted - version: "3.0.0" + version: "3.1.1" flowy_infra: dependency: "direct main" description: @@ -1284,13 +1284,13 @@ packages: source: hosted version: "1.12.0" mime: - dependency: transitive + dependency: "direct main" description: name: mime - sha256: "2e123074287cc9fd6c09de8336dae606d1ddb88d9ac47358826db698c176a1f2" + sha256: "801fd0b26f14a4a58ccb09d5892c3fbdeff209594300a542492cf13fba9d247a" url: "https://pub.dev" source: hosted - version: "1.0.5" + version: "1.0.6" mockito: dependency: transitive description: diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index 3e6e8f8ab5d45..09ffe383efb2c 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -153,6 +153,7 @@ dependencies: extended_text_library: ^12.0.0 sentry_flutter: ^8.7.0 sentry: ^8.8.0 + mime: ^1.0.6 dev_dependencies: flutter_lints: ^4.0.0 diff --git a/frontend/appflowy_tauri/src-tauri/Cargo.toml b/frontend/appflowy_tauri/src-tauri/Cargo.toml index 15ddcaaa95664..05c822cd962fd 100644 --- a/frontend/appflowy_tauri/src-tauri/Cargo.toml +++ b/frontend/appflowy_tauri/src-tauri/Cargo.toml @@ -20,7 +20,12 @@ bytes = "1.5.0" serde = "1.0" serde_json = "1.0.108" protobuf = { version = "2.28.0" } -diesel = { version = "2.1.0", features = ["sqlite", "chrono", "r2d2", "serde_json"] } +diesel = { version = "2.1.0", features = [ + "sqlite", + "chrono", + "r2d2", + "serde_json", +] } uuid = { version = "1.5.0", features = ["serde", "v4"] } serde_repr = "0.1" parking_lot = "0.12" @@ -70,9 +75,7 @@ tracing.workspace = true lib-dispatch = { path = "../../rust-lib/lib-dispatch", features = [ "use_serde", ] } -flowy-core = { path = "../../rust-lib/flowy-core", features = [ - "ts", -] } +flowy-core = { path = "../../rust-lib/flowy-core", features = ["ts"] } flowy-user = { path = "../../rust-lib/flowy-user", features = ["tauri_ts"] } flowy-config = { path = "../../rust-lib/flowy-config", features = ["tauri_ts"] } flowy-date = { path = "../../rust-lib/flowy-date", features = ["tauri_ts"] } diff --git a/frontend/appflowy_web_app/src-tauri/Cargo.toml b/frontend/appflowy_web_app/src-tauri/Cargo.toml index 2847fc59d7b2e..f34e2e3a7004d 100644 --- a/frontend/appflowy_web_app/src-tauri/Cargo.toml +++ b/frontend/appflowy_web_app/src-tauri/Cargo.toml @@ -20,7 +20,12 @@ bytes = "1.5.0" serde = "1.0" serde_json = "1.0.108" protobuf = { version = "2.28.0" } -diesel = { version = "2.1.0", features = ["sqlite", "chrono", "r2d2", "serde_json"] } +diesel = { version = "2.1.0", features = [ + "sqlite", + "chrono", + "r2d2", + "serde_json", +] } uuid = { version = "1.5.0", features = ["serde", "v4"] } serde_repr = "0.1" parking_lot = "0.12" @@ -69,9 +74,7 @@ tracing.workspace = true lib-dispatch = { path = "../../rust-lib/lib-dispatch", features = [ "use_serde", ] } -flowy-core = { path = "../../rust-lib/flowy-core", features = [ - "ts", -] } +flowy-core = { path = "../../rust-lib/flowy-core", features = ["ts"] } flowy-user = { path = "../../rust-lib/flowy-user", features = ["tauri_ts"] } flowy-config = { path = "../../rust-lib/flowy-config", features = ["tauri_ts"] } flowy-date = { path = "../../rust-lib/flowy-date", features = ["tauri_ts"] } @@ -89,9 +92,7 @@ flowy-document = { path = "../../rust-lib/flowy-document", features = [ flowy-notification = { path = "../../rust-lib/flowy-notification", features = [ "tauri_ts", ] } -flowy-ai = { path = "../../rust-lib/flowy-ai", features = [ - "tauri_ts", -] } +flowy-ai = { path = "../../rust-lib/flowy-ai", features = ["tauri_ts"] } uuid = "1.5.0" tauri-plugin-deep-link = "0.1.2" @@ -130,4 +131,3 @@ collab-user = { version = "0.2", git = "https://github.com/AppFlowy-IO/AppFlowy- # ⚠️⚠️⚠️️ appflowy-local-ai = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "6f064efe232268f8d396edbb4b84d57fbb640f13" } appflowy-plugin = { version = "0.1", git = "https://github.com/AppFlowy-IO/AppFlowy-LocalAI", rev = "6f064efe232268f8d396edbb4b84d57fbb640f13" } - diff --git a/frontend/resources/flowy_icons/16x/download.svg b/frontend/resources/flowy_icons/16x/download.svg new file mode 100644 index 0000000000000..c753fba27473c --- /dev/null +++ b/frontend/resources/flowy_icons/16x/download.svg @@ -0,0 +1 @@ +Download Streamline Icon: https://streamlinehq.com \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/ft_archive.svg b/frontend/resources/flowy_icons/16x/ft_archive.svg new file mode 100644 index 0000000000000..2cce4f3e42c22 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ft_archive.svg @@ -0,0 +1 @@ +Folder Closed Streamline Icon: https://streamlinehq.com \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/ft_audio.svg b/frontend/resources/flowy_icons/16x/ft_audio.svg new file mode 100644 index 0000000000000..6db8f1c5ffa53 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ft_audio.svg @@ -0,0 +1 @@ +File Audio Streamline Icon: https://streamlinehq.com \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/ft_link.svg b/frontend/resources/flowy_icons/16x/ft_link.svg new file mode 100644 index 0000000000000..8b836bfadb451 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ft_link.svg @@ -0,0 +1 @@ +Link Streamline Icon: https://streamlinehq.com \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/ft_text.svg b/frontend/resources/flowy_icons/16x/ft_text.svg new file mode 100644 index 0000000000000..8be97325f2692 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ft_text.svg @@ -0,0 +1 @@ +File Text Streamline Icon: https://streamlinehq.com \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/ft_video.svg b/frontend/resources/flowy_icons/16x/ft_video.svg new file mode 100644 index 0000000000000..7d15bf78b871f --- /dev/null +++ b/frontend/resources/flowy_icons/16x/ft_video.svg @@ -0,0 +1 @@ +Video Streamline Icon: https://streamlinehq.com \ No newline at end of file diff --git a/frontend/resources/flowy_icons/16x/media.svg b/frontend/resources/flowy_icons/16x/media.svg new file mode 100644 index 0000000000000..74356ae6dc588 --- /dev/null +++ b/frontend/resources/flowy_icons/16x/media.svg @@ -0,0 +1 @@ +Paperclip Streamline Icon: https://streamlinehq.com \ No newline at end of file diff --git a/frontend/resources/translations/en.json b/frontend/resources/translations/en.json index f90bfe9b4625c..533498be16323 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -1319,6 +1319,7 @@ "relationFieldName": "Relation", "summaryFieldName": "AI Summary", "timeFieldName": "Time", + "mediaFieldName": "Files & media", "translateFieldName": "AI Translate", "translateTo": "Translate to", "numberFormat": "Number format", @@ -1461,6 +1462,17 @@ "countEmptyShort": "EMPTY", "countNonEmpty": "Count not empty", "countNonEmptyShort": "FILLED" + }, + "media": { + "rename": "Rename", + "download": "Download", + "open": "Open", + "delete": "Delete", + "moreFilesHint": "+{} file(s)", + "showMore": "There are {} more file(s), click to view", + "addFileOrImage": "Add a file, image, or link", + "attachmentsHint": "{} attachment(s)", + "addFileMobile": "Add file" } }, "document": { @@ -1672,6 +1684,7 @@ "file": { "name": "File", "uploadTab": "Upload", + "uploadMobile": "Pick from files", "networkTab": "Embed link", "placeholderText": "Upload or embed a file", "placeholderDragging": "Drop the file to upload", @@ -1859,7 +1872,11 @@ "nextThirtyDays": "Next 30 days" }, "noGroup": "No group by property", - "noGroupDesc": "Board views require a property to group by in order to display" + "noGroupDesc": "Board views require a property to group by in order to display", + "media": { + "cardText": "{} {}", + "fallbackName": "files" + } }, "calendar": { "menuName": "Calendar", diff --git a/frontend/rust-lib/Cargo.toml b/frontend/rust-lib/Cargo.toml index a95156294094e..1cfefd94943ce 100644 --- a/frontend/rust-lib/Cargo.toml +++ b/frontend/rust-lib/Cargo.toml @@ -74,7 +74,12 @@ bytes = "1.5.0" serde_json = "1.0.108" serde = "1.0.194" protobuf = { version = "2.28.0" } -diesel = { version = "2.1.0", features = ["sqlite", "chrono", "r2d2", "serde_json"] } +diesel = { version = "2.1.0", features = [ + "sqlite", + "chrono", + "r2d2", + "serde_json", +] } uuid = { version = "1.5.0", features = ["serde", "v4", "v5"] } serde_repr = "0.1" futures = "0.3.29" diff --git a/frontend/rust-lib/event-integration-test/src/database_event.rs b/frontend/rust-lib/event-integration-test/src/database_event.rs index 041849bf872f7..b2e12aa67f80d 100644 --- a/frontend/rust-lib/event-integration-test/src/database_event.rs +++ b/frontend/rust-lib/event-integration-test/src/database_event.rs @@ -669,6 +669,12 @@ impl<'a> TestRowBuilder<'a> { time_field.id.clone() } + pub fn insert_media_cell(&mut self, media: String) -> String { + let media_field = self.field_with_type(&FieldType::Media); + self.cell_build.insert_text_cell(&media_field.id, media); + media_field.id.clone() + } + pub fn field_with_type(&self, field_type: &FieldType) -> Field { self .fields diff --git a/frontend/rust-lib/event-integration-test/tests/database/local_test/test.rs b/frontend/rust-lib/event-integration-test/tests/database/local_test/test.rs index 181a2470ebe71..ac3d30ddbc781 100644 --- a/frontend/rust-lib/event-integration-test/tests/database/local_test/test.rs +++ b/frontend/rust-lib/event-integration-test/tests/database/local_test/test.rs @@ -268,6 +268,7 @@ async fn update_row_meta_event_with_url_test() { icon_url: Some("icon_url".to_owned()), cover_url: None, is_document_empty: None, + attachment_count: None, }; let error = test.update_row_meta(changeset).await; assert!(error.is_none()); @@ -297,6 +298,7 @@ async fn update_row_meta_event_with_cover_test() { icon_url: Some("cover url".to_owned()), cover_url: None, is_document_empty: None, + attachment_count: None, }; let error = test.update_row_meta(changeset).await; assert!(error.is_none()); diff --git a/frontend/rust-lib/flowy-database2/src/entities/field_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/field_entities.rs index a2c35d32a84a9..b8d4dd6e6d0e2 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/field_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/field_entities.rs @@ -456,6 +456,7 @@ pub enum FieldType { Summary = 11, Translate = 12, Time = 13, + Media = 14, } impl Display for FieldType { @@ -498,6 +499,7 @@ impl FieldType { FieldType::Summary => "Summarize", FieldType::Translate => "Translate", FieldType::Time => "Time", + FieldType::Media => "Media", }; s.to_string() } @@ -558,6 +560,10 @@ impl FieldType { matches!(self, FieldType::Time) } + pub fn is_media(&self) -> bool { + matches!(self, FieldType::Media) + } + pub fn can_be_group(&self) -> bool { self.is_select_option() || self.is_checkbox() || self.is_url() } diff --git a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/media_filter.rs b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/media_filter.rs new file mode 100644 index 0000000000000..9cc9adc481643 --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/media_filter.rs @@ -0,0 +1,49 @@ +use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; +use flowy_error::ErrorCode; + +use crate::services::filter::ParseFilterData; + +#[derive(Eq, PartialEq, ProtoBuf, Debug, Default, Clone)] +pub struct MediaFilterPB { + #[pb(index = 1)] + pub condition: MediaFilterConditionPB, + + #[pb(index = 2)] + pub content: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, ProtoBuf_Enum)] +#[repr(u8)] +pub enum MediaFilterConditionPB { + #[default] + MediaIsEmpty = 0, + MediaIsNotEmpty = 1, +} + +impl std::convert::From for u32 { + fn from(value: MediaFilterConditionPB) -> Self { + value as u32 + } +} + +impl std::convert::TryFrom for MediaFilterConditionPB { + type Error = ErrorCode; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(MediaFilterConditionPB::MediaIsEmpty), + 1 => Ok(MediaFilterConditionPB::MediaIsNotEmpty), + _ => Err(ErrorCode::InvalidParams), + } + } +} + +impl ParseFilterData for MediaFilterPB { + fn parse(condition: u8, content: String) -> Self { + Self { + condition: MediaFilterConditionPB::try_from(condition) + .unwrap_or(MediaFilterConditionPB::MediaIsEmpty), + content, + } + } +} diff --git a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/mod.rs b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/mod.rs index a6a990a4580fa..0adb930020b07 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/mod.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/mod.rs @@ -2,6 +2,7 @@ mod checkbox_filter; mod checklist_filter; mod date_filter; mod filter_changeset; +mod media_filter; mod number_filter; mod relation_filter; mod select_option_filter; @@ -13,6 +14,7 @@ pub use checkbox_filter::*; pub use checklist_filter::*; pub use date_filter::*; pub use filter_changeset::*; +pub use media_filter::*; pub use number_filter::*; pub use relation_filter::*; pub use select_option_filter::*; diff --git a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/util.rs b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/util.rs index af1288506e560..b5e56dab774f4 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/filter_entities/util.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/filter_entities/util.rs @@ -14,6 +14,8 @@ use crate::entities::{ }; use crate::services::filter::{Filter, FilterChangeset, FilterInner}; +use super::MediaFilterPB; + #[derive(Debug, Default, Clone, ProtoBuf_Enum, Eq, PartialEq, Copy)] #[repr(u8)] pub enum FilterType { @@ -117,6 +119,10 @@ impl From<&Filter> for FilterPB { .cloned::() .unwrap() .try_into(), + FieldType::Media => condition_and_content + .cloned::() + .unwrap() + .try_into(), }; Self { @@ -170,6 +176,9 @@ impl TryFrom for FilterInner { FieldType::Translate => { BoxAny::new(TextFilterPB::try_from(bytes).map_err(|_| ErrorCode::ProtobufSerde)?) }, + FieldType::Media => { + BoxAny::new(MediaFilterPB::try_from(bytes).map_err(|_| ErrorCode::ProtobufSerde)?) + }, }; Ok(Self::Data { diff --git a/frontend/rust-lib/flowy-database2/src/entities/macros.rs b/frontend/rust-lib/flowy-database2/src/entities/macros.rs index 2d30eb15f079d..6fc6965bbcc08 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/macros.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/macros.rs @@ -18,6 +18,7 @@ macro_rules! impl_into_field_type { 11 => FieldType::Summary, 12 => FieldType::Translate, 13 => FieldType::Time, + 14 => FieldType::Media, _ => { tracing::error!("🔴Can't parse FieldType from value: {}", ty); FieldType::RichText diff --git a/frontend/rust-lib/flowy-database2/src/entities/row_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/row_entities.rs index c639016acd316..68b6628a0aef1 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/row_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/row_entities.rs @@ -63,6 +63,9 @@ pub struct RowMetaPB { #[pb(index = 4, one_of)] pub is_document_empty: Option, + + #[pb(index = 5, one_of)] + pub attachment_count: Option, } #[derive(Debug, Default, ProtoBuf)] @@ -78,6 +81,7 @@ impl From for RowMetaPB { document_id: None, icon: None, is_document_empty: None, + attachment_count: None, } } } @@ -89,6 +93,7 @@ impl From for RowMetaPB { document_id: None, icon: None, is_document_empty: None, + attachment_count: None, } } } @@ -100,6 +105,7 @@ impl From for RowMetaPB { document_id: Some(row_detail.document_id), icon: row_detail.meta.icon_url, is_document_empty: Some(row_detail.meta.is_document_empty), + attachment_count: Some(row_detail.meta.attachment_count), } } } @@ -121,6 +127,9 @@ pub struct UpdateRowMetaChangesetPB { #[pb(index = 5, one_of)] pub is_document_empty: Option, + + #[pb(index = 6, one_of)] + pub attachment_count: Option, } #[derive(Debug)] @@ -130,6 +139,7 @@ pub struct UpdateRowMetaParams { pub icon_url: Option, pub cover_url: Option, pub is_document_empty: Option, + pub attachment_count: Option, } impl TryInto for UpdateRowMetaChangesetPB { @@ -149,6 +159,7 @@ impl TryInto for UpdateRowMetaChangesetPB { icon_url: self.icon_url, cover_url: self.cover_url, is_document_empty: self.is_document_empty, + attachment_count: self.attachment_count, }) } } diff --git a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/media_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/media_entities.rs new file mode 100644 index 0000000000000..543a5a726cd7c --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/media_entities.rs @@ -0,0 +1,192 @@ +use flowy_derive::{ProtoBuf, ProtoBuf_Enum}; + +use crate::{ + entities::CellIdPB, + services::field::{MediaCellData, MediaFile, MediaFileType, MediaUploadType}, +}; + +#[derive(Debug, Clone, Default, ProtoBuf)] +pub struct MediaCellDataPB { + #[pb(index = 1)] + pub files: Vec, +} + +impl From for MediaCellDataPB { + fn from(data: MediaCellData) -> Self { + Self { + files: data.files.into_iter().map(Into::into).collect(), + } + } +} + +impl From for MediaCellData { + fn from(data: MediaCellDataPB) -> Self { + Self { + files: data.files.into_iter().map(Into::into).collect(), + } + } +} + +#[derive(Debug, Clone, Default, ProtoBuf)] +pub struct MediaTypeOptionPB { + #[pb(index = 1)] + pub files: Vec, +} + +#[derive(Debug, Clone, Default, ProtoBuf)] +pub struct MediaFilePB { + #[pb(index = 1)] + pub id: String, + + #[pb(index = 2)] + pub name: String, + + #[pb(index = 3)] + pub url: String, + + #[pb(index = 4)] + pub upload_type: MediaUploadTypePB, + + #[pb(index = 5)] + pub file_type: MediaFileTypePB, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, ProtoBuf_Enum)] +#[repr(u8)] +pub enum MediaUploadTypePB { + #[default] + LocalMedia = 0, + NetworkMedia = 1, + CloudMedia = 2, +} + +impl From for MediaUploadTypePB { + fn from(data: MediaUploadType) -> Self { + match data { + MediaUploadType::LocalMedia => MediaUploadTypePB::LocalMedia, + MediaUploadType::NetworkMedia => MediaUploadTypePB::NetworkMedia, + MediaUploadType::CloudMedia => MediaUploadTypePB::CloudMedia, + } + } +} + +impl From for MediaUploadType { + fn from(data: MediaUploadTypePB) -> Self { + match data { + MediaUploadTypePB::LocalMedia => MediaUploadType::LocalMedia, + MediaUploadTypePB::NetworkMedia => MediaUploadType::NetworkMedia, + MediaUploadTypePB::CloudMedia => MediaUploadType::CloudMedia, + } + } +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, ProtoBuf_Enum)] +#[repr(u8)] +pub enum MediaFileTypePB { + #[default] + Other = 0, + // Eg. jpg, png, gif, etc. + Image = 1, + // Eg. https://appflowy.io + Link = 2, + // Eg. pdf, doc, etc. + Document = 3, + // Eg. zip, rar, etc. + Archive = 4, + // Eg. mp4, avi, etc. + Video = 5, + // Eg. mp3, wav, etc. + Audio = 6, + // Eg. txt, csv, etc. + Text = 7, +} + +impl From for MediaFileTypePB { + fn from(data: MediaFileType) -> Self { + match data { + MediaFileType::Other => MediaFileTypePB::Other, + MediaFileType::Image => MediaFileTypePB::Image, + MediaFileType::Link => MediaFileTypePB::Link, + MediaFileType::Document => MediaFileTypePB::Document, + MediaFileType::Archive => MediaFileTypePB::Archive, + MediaFileType::Video => MediaFileTypePB::Video, + MediaFileType::Audio => MediaFileTypePB::Audio, + MediaFileType::Text => MediaFileTypePB::Text, + } + } +} + +impl From for MediaFileType { + fn from(data: MediaFileTypePB) -> Self { + match data { + MediaFileTypePB::Other => MediaFileType::Other, + MediaFileTypePB::Image => MediaFileType::Image, + MediaFileTypePB::Link => MediaFileType::Link, + MediaFileTypePB::Document => MediaFileType::Document, + MediaFileTypePB::Archive => MediaFileType::Archive, + MediaFileTypePB::Video => MediaFileType::Video, + MediaFileTypePB::Audio => MediaFileType::Audio, + MediaFileTypePB::Text => MediaFileType::Text, + } + } +} + +impl From for MediaFilePB { + fn from(data: MediaFile) -> Self { + Self { + id: data.id, + name: data.name, + url: data.url, + upload_type: data.upload_type.into(), + file_type: data.file_type.into(), + } + } +} + +impl From for MediaFile { + fn from(data: MediaFilePB) -> Self { + Self { + id: data.id, + name: data.name, + url: data.url, + upload_type: data.upload_type.into(), + file_type: data.file_type.into(), + } + } +} + +#[derive(Debug, Clone, Default, ProtoBuf)] +pub struct MediaCellChangesetPB { + #[pb(index = 1)] + pub view_id: String, + + #[pb(index = 2)] + pub cell_id: CellIdPB, + + #[pb(index = 3)] + pub inserted_files: Vec, + + #[pb(index = 4)] + pub removed_ids: Vec, +} + +#[derive(Debug, Clone, Default)] +pub struct MediaCellChangeset { + pub inserted_files: Vec, + pub removed_ids: Vec, +} + +#[derive(Debug, Clone, Default, ProtoBuf)] +pub struct RenameMediaChangesetPB { + #[pb(index = 1)] + pub view_id: String, + + #[pb(index = 2)] + pub cell_id: CellIdPB, + + #[pb(index = 3)] + pub file_id: String, + + #[pb(index = 4)] + pub name: String, +} diff --git a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/mod.rs b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/mod.rs index f92072eabd0bc..633f000e5394f 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/mod.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/mod.rs @@ -1,6 +1,7 @@ mod checkbox_entities; mod checklist_entities; mod date_entities; +mod media_entities; mod number_entities; mod relation_entities; mod select_option_entities; @@ -14,6 +15,7 @@ mod url_entities; pub use checkbox_entities::*; pub use checklist_entities::*; pub use date_entities::*; +pub use media_entities::*; pub use number_entities::*; pub use relation_entities::*; pub use select_option_entities::*; diff --git a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/select_option_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/select_option_entities.rs index ba8193d47b7e8..367cad385d6f6 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/select_option_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/select_option_entities.rs @@ -54,9 +54,8 @@ pub struct RepeatedSelectOptionPayload { pub items: Vec, } -#[derive(ProtoBuf_Enum, PartialEq, Eq, Debug, Clone)] +#[derive(ProtoBuf_Enum, PartialEq, Eq, Debug, Clone, Default)] #[repr(u8)] -#[derive(Default)] pub enum SelectOptionColorPB { #[default] Purple = 0, diff --git a/frontend/rust-lib/flowy-database2/src/event_handler.rs b/frontend/rust-lib/flowy-database2/src/event_handler.rs index 4c85e1502413d..031c409528e56 100644 --- a/frontend/rust-lib/flowy-database2/src/event_handler.rs +++ b/frontend/rust-lib/flowy-database2/src/event_handler.rs @@ -1,4 +1,4 @@ -use collab_database::rows::RowId; +use collab_database::rows::{Cell, RowId}; use lib_infra::box_any::BoxAny; use std::sync::{Arc, Weak}; use tokio::sync::oneshot; @@ -10,8 +10,8 @@ use lib_dispatch::prelude::{af_spawn, data_result_ok, AFPluginData, AFPluginStat use crate::entities::*; use crate::manager::DatabaseManager; use crate::services::field::{ - type_option_data_from_pb, ChecklistCellChangeset, DateCellChangeset, RelationCellChangeset, - SelectOptionCellChangeset, + type_option_data_from_pb, ChecklistCellChangeset, DateCellChangeset, MediaCellData, + RelationCellChangeset, SelectOptionCellChangeset, TypeOptionCellExt, }; use crate::services::group::GroupChangeset; use crate::services::share::csv::CSVFormat; @@ -1280,3 +1280,114 @@ pub(crate) async fn translate_row_handler( rx.await??; Ok(()) } + +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn update_media_cell_handler( + data: AFPluginData, + manager: AFPluginState>, +) -> FlowyResult<()> { + let manager = upgrade_manager(manager)?; + let params: MediaCellChangesetPB = data.into_inner(); + let cell_id: CellIdParams = params.cell_id.try_into()?; + let cell_changeset = MediaCellChangeset { + inserted_files: params.inserted_files.into_iter().map(Into::into).collect(), + removed_ids: params.removed_ids, + }; + + let database_editor = manager + .get_database_editor_with_view_id(&cell_id.view_id) + .await?; + + database_editor + .update_cell_with_changeset( + &cell_id.view_id, + &cell_id.row_id, + &cell_id.field_id, + BoxAny::new(cell_changeset), + ) + .await?; + Ok(()) +} + +/// We use a custom handler to rename the media file, as the ordering +/// of the files must be maintained while renaming a file. +/// +/// The changeset in [update_media_cell_handler] contains removals and inserts, +/// and if we were to remove and insert the file, it would mess up the ordering. +/// +#[tracing::instrument(level = "debug", skip_all, err)] +pub(crate) async fn rename_media_cell_file_handler( + data: AFPluginData, + manager: AFPluginState>, +) -> FlowyResult<()> { + let manager = upgrade_manager(manager)?; + let params: RenameMediaChangesetPB = data.into_inner(); + let cell_id: CellIdParams = params.cell_id.try_into()?; + + let database_editor = manager + .get_database_editor_with_view_id(&cell_id.view_id) + .await?; + + let cell = database_editor + .get_cell(&cell_id.field_id, &cell_id.row_id) + .await; + if cell.is_none() { + return Err(FlowyError::record_not_found()); + } + + let cell = cell.unwrap(); + let field = database_editor + .get_field(&cell_id.field_id) + .await + .ok_or_else(FlowyError::record_not_found)?; + let handler = TypeOptionCellExt::new(&field, None) + .get_type_option_cell_data_handler_with_field_type(FieldType::Media); + if handler.is_none() { + return Err( + FlowyError::internal().with_context("Error renaming media file: field type is not Media"), + ); + } + let handler = handler.unwrap(); + let data = handler + .handle_get_boxed_cell_data(&cell, &field) + .and_then(|cell_data| cell_data.unbox_or_none()) + .unwrap_or_else(MediaCellData::default); + + let file = data + .files + .iter() + .find(|file| file.id == params.file_id) + .ok_or_else(FlowyError::record_not_found)?; + + let new_file = file.rename(params.name); + let new_data = MediaCellData { + files: data + .files + .iter() + .map(|file| { + if file.id == params.file_id { + new_file.clone() + } else { + file.clone() + } + }) + .collect(), + }; + + let result = database_editor + .update_cell( + &cell_id.view_id, + &cell_id.row_id, + &cell_id.field_id, + Cell::from(&new_data), + ) + .await; + + if result.is_err() { + return Err( + FlowyError::internal().with_context("Error renaming media file: update cell failed"), + ); + } + + Ok(()) +} diff --git a/frontend/rust-lib/flowy-database2/src/event_map.rs b/frontend/rust-lib/flowy-database2/src/event_map.rs index ae3ecc649ab0a..5bd9cf6f33981 100644 --- a/frontend/rust-lib/flowy-database2/src/event_map.rs +++ b/frontend/rust-lib/flowy-database2/src/event_map.rs @@ -94,6 +94,9 @@ pub fn init(database_manager: Weak) -> AFPlugin { // AI .event(DatabaseEvent::SummarizeRow, summarize_row_handler) .event(DatabaseEvent::TranslateRow, translate_row_handler) + // Media + .event(DatabaseEvent::UpdateMediaCell, update_media_cell_handler) + .event(DatabaseEvent::RenameMediaFile, rename_media_cell_file_handler) } /// [DatabaseEvent] defines events that are used to interact with the Grid. You could check [this](https://appflowy.gitbook.io/docs/essential-documentation/contribute-to-appflowy/architecture/backend/protobuf) @@ -383,6 +386,15 @@ pub enum DatabaseEvent { #[event(input = "DatabaseViewRowIdPB")] InitRow = 176, + #[event(input = "DatabaseViewIdPB", output = "RepeatedRowMetaPB")] + GetAllRows = 177, + #[event(input = "DatabaseViewIdPB", output = "DatabaseExportDataPB")] ExportRawDatabaseData = 178, + + #[event(input = "MediaCellChangesetPB")] + UpdateMediaCell = 200, + + #[event(input = "RenameMediaChangesetPB")] + RenameMediaFile = 201, } diff --git a/frontend/rust-lib/flowy-database2/src/services/cell/cell_operation.rs b/frontend/rust-lib/flowy-database2/src/services/cell/cell_operation.rs index d1bae644ea7ab..dd608326814e2 100644 --- a/frontend/rust-lib/flowy-database2/src/services/cell/cell_operation.rs +++ b/frontend/rust-lib/flowy-database2/src/services/cell/cell_operation.rs @@ -219,7 +219,7 @@ impl<'a> CellBuilder<'a> { if let Some(field) = field_maps.get(&field_id) { let field_type = FieldType::from(field.field_type); match field_type { - FieldType::RichText => { + FieldType::RichText | FieldType::Translate | FieldType::Summary => { cells.insert(field_id, insert_text_cell(cell_str, field)); }, FieldType::Number | FieldType::Time => { @@ -259,11 +259,8 @@ impl<'a> CellBuilder<'a> { FieldType::Relation => { cells.insert(field_id, (&RelationCellData::from(cell_str)).into()); }, - FieldType::Summary => { - cells.insert(field_id, insert_text_cell(cell_str, field)); - }, - FieldType::Translate => { - cells.insert(field_id, insert_text_cell(cell_str, field)); + FieldType::Media => { + cells.insert(field_id, (&MediaCellData::from(cell_str)).into()); }, } } diff --git a/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs b/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs index a79651dae1588..d610dfd291be4 100644 --- a/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs +++ b/frontend/rust-lib/flowy-database2/src/services/database/database_editor.rs @@ -10,8 +10,9 @@ use crate::services::database_view::{ use crate::services::field::type_option_transform::transform_type_option; use crate::services::field::{ default_type_option_data_from_type, select_type_option_from_field, type_option_data_from_pb, - ChecklistCellChangeset, RelationTypeOption, SelectOptionCellChangeset, StringCellData, - TimestampCellData, TimestampCellDataWrapper, TypeOptionCellDataHandler, TypeOptionCellExt, + ChecklistCellChangeset, MediaCellData, RelationTypeOption, SelectOptionCellChangeset, + StringCellData, TimestampCellData, TimestampCellDataWrapper, TypeOptionCellDataHandler, + TypeOptionCellExt, }; use crate::services::field_settings::{default_field_settings_by_layout_map, FieldSettings}; use crate::services::filter::{Filter, FilterChangeset}; @@ -734,6 +735,7 @@ impl DatabaseEditor { document_id: Some(row_document_id), icon: row_meta.icon_url, is_document_empty: Some(row_meta.is_document_empty), + attachment_count: Some(row_meta.attachment_count), }) } else { warn!( @@ -777,7 +779,8 @@ impl DatabaseEditor { meta_update .insert_cover_if_not_none(changeset.cover_url) .insert_icon_if_not_none(changeset.icon_url) - .update_is_document_empty_if_not_none(changeset.is_document_empty); + .update_is_document_empty_if_not_none(changeset.is_document_empty) + .update_attachment_count_if_not_none(changeset.attachment_count); }) .await; @@ -947,12 +950,97 @@ impl DatabaseEditor { old_row: Option, ) { let option_row = self.get_row(view_id, row_id).await; + let field_type = self + .database + .read() + .await + .get_field(field_id) + .map(|field| field.field_type); + if let Some(row) = option_row { for view in self.database_views.editors().await { view .v_did_update_row(&old_row, &row, Some(field_id.to_owned())) .await; } + + if let Some(field_type) = field_type { + if FieldType::from(field_type) == FieldType::Media { + self + .did_update_attachments(view_id, row_id, field_id, old_row.clone()) + .await; + } + } + } + } + + async fn did_update_attachments( + &self, + view_id: &str, + row_id: &RowId, + field_id: &str, + old_row: Option, + ) { + let field = self.get_field(field_id).await; + if let Some(field) = field { + let handler = TypeOptionCellExt::new(&field, None) + .get_type_option_cell_data_handler_with_field_type(FieldType::Media); + if handler.is_none() { + return; + } + let handler = handler.unwrap(); + + let cell = self.get_cell(field_id, row_id).await; + let new_count = match cell { + Some(cell) => { + let data = handler + .handle_get_boxed_cell_data(&cell, &field) + .and_then(|cell_data| cell_data.unbox_or_none()) + .unwrap_or_else(MediaCellData::default); + + data.files.len() as i64 + }, + None => 0, + }; + + let old_cell = old_row.and_then(|row| row.cells.get(field_id).cloned()); + let old_count = match old_cell { + Some(old_cell) => { + let data = handler + .handle_get_boxed_cell_data(&old_cell, &field) + .and_then(|cell_data| cell_data.unbox_or_none()) + .unwrap_or_else(MediaCellData::default); + + data.files.len() as i64 + }, + None => 0, + }; + + if new_count != old_count { + let attachment_count = self + .get_row_meta(view_id, row_id) + .await + .and_then(|meta| meta.attachment_count); + + let new_attachment_count = match attachment_count { + Some(attachment_count) => attachment_count + new_count - old_count, + None => new_count, + }; + + self + .update_row_meta( + row_id, + UpdateRowMetaParams { + id: row_id.clone().into_inner(), + view_id: view_id.to_string(), + cover_url: None, + icon_url: None, + is_document_empty: None, + attachment_count: Some(new_attachment_count), + }, + ) + .await; + } } } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_option_transform.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_option_transform.rs index f273c784e8051..2b359983c4b62 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_option_transform.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_option_transform.rs @@ -2,8 +2,8 @@ use crate::entities::FieldType; use crate::services::field::summary_type_option::summary::SummarizationTypeOption; use crate::services::field::translate_type_option::translate::TranslateTypeOption; use crate::services::field::{ - CheckboxTypeOption, ChecklistTypeOption, DateTypeOption, MultiSelectTypeOption, NumberTypeOption, - RelationTypeOption, RichTextTypeOption, SingleSelectTypeOption, TimeTypeOption, + CheckboxTypeOption, ChecklistTypeOption, DateTypeOption, MediaTypeOption, MultiSelectTypeOption, + NumberTypeOption, RelationTypeOption, RichTextTypeOption, SingleSelectTypeOption, TimeTypeOption, TimestampTypeOption, TypeOptionTransform, URLTypeOption, }; use async_trait::async_trait; @@ -127,5 +127,8 @@ fn get_type_option_transform_handler( FieldType::Translate => { Box::new(TranslateTypeOption::from(type_option_data)) as Box }, + FieldType::Media => { + Box::new(MediaTypeOption::from(type_option_data)) as Box + }, } } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/media_file.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/media_file.rs new file mode 100644 index 0000000000000..affda5a98f096 --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/media_file.rs @@ -0,0 +1,57 @@ +use std::fmt::{Display, Formatter}; + +use serde::{Deserialize, Serialize}; + +#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct MediaFile { + pub id: String, + pub name: String, + pub url: String, + pub upload_type: MediaUploadType, + pub file_type: MediaFileType, +} + +impl MediaFile { + pub fn rename(&self, new_name: String) -> Self { + Self { + id: self.id.clone(), + name: new_name, + url: self.url.clone(), + upload_type: self.upload_type.clone(), + file_type: self.file_type.clone(), + } + } +} + +impl Display for MediaFile { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + write!( + f, + "MediaFile(id: {}, name: {}, url: {}, upload_type: {:?}, file_type: {:?})", + self.id, self.name, self.url, self.upload_type, self.file_type + ) + } +} + +#[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Default, Clone)] +#[repr(u8)] +pub enum MediaUploadType { + #[default] + LocalMedia = 0, + NetworkMedia = 1, + CloudMedia = 2, +} + +#[derive(PartialEq, Eq, Serialize, Deserialize, Debug, Default, Clone)] +#[repr(u8)] +pub enum MediaFileType { + #[default] + Other = 0, + Image = 1, + Link = 2, + Document = 3, + Archive = 4, + Video = 5, + Audio = 6, + Text = 7, +} diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/media_filter.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/media_filter.rs new file mode 100644 index 0000000000000..f5da10f4821d9 --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/media_filter.rs @@ -0,0 +1,31 @@ +use collab_database::{fields::Field, rows::Cell}; + +use crate::{ + entities::{MediaFilterConditionPB, MediaFilterPB}, + services::{cell::insert_text_cell, filter::PreFillCellsWithFilter}, +}; + +impl MediaFilterPB { + pub fn is_visible>(&self, cell_data: T) -> bool { + let cell_data = cell_data.as_ref().to_lowercase(); + match self.condition { + MediaFilterConditionPB::MediaIsEmpty => cell_data.is_empty(), + MediaFilterConditionPB::MediaIsNotEmpty => !cell_data.is_empty(), + } + } +} + +impl PreFillCellsWithFilter for MediaFilterPB { + fn get_compliant_cell(&self, field: &Field) -> (Option, bool) { + let text = match self.condition { + MediaFilterConditionPB::MediaIsNotEmpty if !self.content.is_empty() => { + Some(self.content.clone()) + }, + _ => None, + }; + + let open_after_create = matches!(self.condition, MediaFilterConditionPB::MediaIsNotEmpty); + + (text.map(|s| insert_text_cell(s, field)), open_after_create) + } +} diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/media_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/media_type_option.rs new file mode 100644 index 0000000000000..77a5e32ad949b --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/media_type_option.rs @@ -0,0 +1,255 @@ +use std::{cmp::Ordering, sync::Arc}; + +use collab::{preclude::Any, util::AnyMapExt}; +use collab_database::{ + fields::{Field, TypeOptionData, TypeOptionDataBuilder}, + rows::{new_cell_builder, Cell}, +}; +use flowy_error::FlowyResult; +use serde::{Deserialize, Serialize}; + +use crate::{ + entities::{FieldType, MediaCellChangeset, MediaCellDataPB, MediaFilterPB, MediaTypeOptionPB}, + services::{ + cell::{CellDataChangeset, CellDataDecoder}, + field::{ + default_order, StringCellData, TypeOption, TypeOptionCellData, TypeOptionCellDataCompare, + TypeOptionCellDataFilter, TypeOptionCellDataSerde, TypeOptionTransform, CELL_DATA, + }, + sort::SortCondition, + }, +}; + +use super::MediaFile; + +#[derive(Clone, Debug, Default, Serialize)] +pub struct MediaCellData { + pub files: Vec, +} + +impl From<&Cell> for MediaCellData { + fn from(cell: &Cell) -> Self { + let files = match cell.get(CELL_DATA) { + Some(Any::Array(array)) => array + .iter() + .flat_map(|item| { + if let Any::String(string) = item { + Some(serde_json::from_str::(string).unwrap_or_default()) + } else { + None + } + }) + .collect(), + _ => vec![], + }; + + Self { files } + } +} + +impl From<&MediaCellData> for Cell { + fn from(value: &MediaCellData) -> Self { + let data = Any::Array(Arc::from( + value + .files + .clone() + .into_iter() + .map(|file| Any::String(Arc::from(serde_json::to_string(&file).unwrap_or_default()))) + .collect::>(), + )); + + let mut cell = new_cell_builder(FieldType::Media); + cell.insert(CELL_DATA.into(), data); + cell + } +} + +impl From for MediaCellData { + fn from(s: String) -> Self { + if s.is_empty() { + return MediaCellData { files: vec![] }; + } + + let files = s + .split(", ") + .map(|file: &str| serde_json::from_str::(file).unwrap_or_default()) + .collect::>(); + + MediaCellData { files } + } +} + +impl TypeOptionCellData for MediaCellData { + fn is_cell_empty(&self) -> bool { + self.files.is_empty() + } +} + +impl ToString for MediaCellData { + fn to_string(&self) -> String { + self + .files + .iter() + .map(|file| file.to_string()) + .collect::>() + .join(", ") + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct MediaTypeOption { + #[serde(default)] + pub files: Vec, +} + +impl TypeOption for MediaTypeOption { + type CellData = MediaCellData; + type CellChangeset = MediaCellChangeset; + type CellProtobufType = MediaCellDataPB; + type CellFilter = MediaFilterPB; +} + +impl From for MediaTypeOption { + fn from(data: TypeOptionData) -> Self { + data + .get_as::("content") + .map(|s| serde_json::from_str::(&s).unwrap_or_default()) + .unwrap_or_default() + } +} + +impl From for TypeOptionData { + fn from(data: MediaTypeOption) -> Self { + let content = serde_json::to_string(&data).unwrap_or_default(); + TypeOptionDataBuilder::from([("content".into(), content.into())]) + } +} + +impl From for MediaTypeOptionPB { + fn from(value: MediaTypeOption) -> Self { + Self { + files: value.files.into_iter().map(Into::into).collect(), + } + } +} + +impl From for MediaTypeOption { + fn from(value: MediaTypeOptionPB) -> Self { + Self { + files: value.files.into_iter().map(Into::into).collect(), + } + } +} + +impl TypeOptionTransform for MediaTypeOption {} + +impl TypeOptionCellDataSerde for MediaTypeOption { + fn protobuf_encode( + &self, + cell_data: ::CellData, + ) -> ::CellProtobufType { + cell_data.into() + } + + fn parse_cell(&self, cell: &Cell) -> FlowyResult<::CellData> { + Ok(cell.into()) + } +} + +impl CellDataDecoder for MediaTypeOption { + fn decode_cell(&self, cell: &Cell) -> FlowyResult<::CellData> { + self.parse_cell(cell) + } + + fn decode_cell_with_transform( + &self, + _cell: &Cell, + from_field_type: FieldType, + _field: &Field, + ) -> Option<::CellData> { + match from_field_type { + FieldType::RichText + | FieldType::Number + | FieldType::DateTime + | FieldType::SingleSelect + | FieldType::MultiSelect + | FieldType::Checkbox + | FieldType::URL + | FieldType::Summary + | FieldType::Translate + | FieldType::Time + | FieldType::Checklist + | FieldType::LastEditedTime + | FieldType::CreatedTime + | FieldType::Relation + | FieldType::Media => None, + } + } + + fn stringify_cell_data(&self, cell_data: ::CellData) -> String { + cell_data.to_string() + } + + fn numeric_cell(&self, cell: &Cell) -> Option { + StringCellData::from(cell).0.parse::().ok() + } +} + +impl CellDataChangeset for MediaTypeOption { + fn apply_changeset( + &self, + changeset: ::CellChangeset, + cell: Option, + ) -> FlowyResult<(Cell, ::CellData)> { + if cell.is_none() { + let cell_data = MediaCellData { + files: changeset.inserted_files, + }; + return Ok(((&cell_data).into(), cell_data)); + } + + let cell_data: MediaCellData = MediaCellData::from(&cell.unwrap()); + let mut files = cell_data.files.clone(); + for removed_id in changeset.removed_ids.iter() { + if let Some(index) = files.iter().position(|file| file.id == removed_id.clone()) { + files.remove(index); + } + } + + for inserted in changeset.inserted_files.iter() { + if !files.iter().any(|file| file.id == inserted.id) { + files.push(inserted.clone()) + } + } + + let cell_data = MediaCellData { files }; + + Ok((Cell::from(&cell_data), cell_data)) + } +} + +impl TypeOptionCellDataFilter for MediaTypeOption { + fn apply_filter( + &self, + _filter: &::CellFilter, + _cell_data: &::CellData, + ) -> bool { + true + } +} + +impl TypeOptionCellDataCompare for MediaTypeOption { + fn apply_cmp( + &self, + cell_data: &::CellData, + other_cell_data: &::CellData, + _sort_condition: SortCondition, + ) -> Ordering { + match (cell_data.files.is_empty(), other_cell_data.is_cell_empty()) { + (true, true) => Ordering::Equal, + (true, false) => Ordering::Greater, + (false, true) => Ordering::Less, + (false, false) => default_order(), + } + } +} diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/mod.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/mod.rs new file mode 100644 index 0000000000000..5fae403f5335f --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/mod.rs @@ -0,0 +1,7 @@ +#![allow(clippy::module_inception)] +mod media_file; +mod media_filter; +mod media_type_option; + +pub use media_file::*; +pub use media_type_option::*; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/mod.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/mod.rs index a6515c9db4252..3464ae6cfeef7 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/mod.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/mod.rs @@ -1,6 +1,7 @@ pub mod checkbox_type_option; pub mod checklist_type_option; pub mod date_type_option; +pub mod media_type_option; pub mod number_type_option; pub mod relation_type_option; pub mod selection_type_option; @@ -17,6 +18,7 @@ mod util; pub use checkbox_type_option::*; pub use checklist_type_option::*; pub use date_type_option::*; +pub use media_type_option::*; pub use number_type_option::*; pub use relation_type_option::*; pub use selection_type_option::*; diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_option.rs index 4e6684202a73f..f57010c0a3122 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/selection_type_option/select_option.rs @@ -1,6 +1,7 @@ use crate::entities::SelectOptionCellDataPB; use crate::services::field::SelectOptionIds; use collab_database::entity::SelectOption; + #[derive(Debug)] pub struct SelectOptionCellData { pub select_options: Vec, diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_type_option.rs index d6e60cbcff93c..0566cdf641998 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/text_type_option/text_type_option.rs @@ -81,6 +81,7 @@ impl CellDataDecoder for RichTextTypeOption { | FieldType::URL | FieldType::Summary | FieldType::Translate + | FieldType::Media | FieldType::Time => Some(StringCellData::from(stringify_cell(cell, field))), FieldType::Checklist | FieldType::LastEditedTime diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option.rs index 7563e753c578e..bfeae5cab4dfc 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option.rs @@ -10,7 +10,7 @@ use std::fmt::Debug; use flowy_error::FlowyResult; use crate::entities::{ - CheckboxTypeOptionPB, ChecklistTypeOptionPB, DateTypeOptionPB, FieldType, + CheckboxTypeOptionPB, ChecklistTypeOptionPB, DateTypeOptionPB, FieldType, MediaTypeOptionPB, MultiSelectTypeOptionPB, NumberTypeOptionPB, RelationTypeOptionPB, RichTextTypeOptionPB, SingleSelectTypeOptionPB, SummarizationTypeOptionPB, TimeTypeOptionPB, TimestampTypeOptionPB, TranslateTypeOptionPB, URLTypeOptionPB, @@ -20,8 +20,9 @@ use crate::services::field::checklist_type_option::ChecklistTypeOption; use crate::services::field::summary_type_option::summary::SummarizationTypeOption; use crate::services::field::translate_type_option::translate::TranslateTypeOption; use crate::services::field::{ - CheckboxTypeOption, DateTypeOption, MultiSelectTypeOption, NumberTypeOption, RelationTypeOption, - RichTextTypeOption, SingleSelectTypeOption, TimeTypeOption, TimestampTypeOption, URLTypeOption, + CheckboxTypeOption, DateTypeOption, MediaTypeOption, MultiSelectTypeOption, NumberTypeOption, + RelationTypeOption, RichTextTypeOption, SingleSelectTypeOption, TimeTypeOption, + TimestampTypeOption, URLTypeOption, }; use crate::services::filter::{ParseFilterData, PreFillCellsWithFilter}; use crate::services::sort::SortCondition; @@ -197,6 +198,9 @@ pub fn type_option_data_from_pb>( FieldType::Translate => { TranslateTypeOptionPB::try_from(bytes).map(|pb| TranslateTypeOption::from(pb).into()) }, + FieldType::Media => { + MediaTypeOptionPB::try_from(bytes).map(|pb| MediaTypeOption::from(pb).into()) + }, } } @@ -274,6 +278,12 @@ pub fn type_option_to_pb(type_option: TypeOptionData, field_type: &FieldType) -> .try_into() .unwrap() }, + FieldType::Media => { + let media_type_option: MediaTypeOption = type_option.into(); + MediaTypeOptionPB::from(media_type_option) + .try_into() + .unwrap() + }, } } @@ -296,5 +306,6 @@ pub fn default_type_option_data_from_type(field_type: FieldType) -> TypeOptionDa FieldType::Summary => SummarizationTypeOption::default().into(), FieldType::Translate => TranslateTypeOption::default().into(), FieldType::Time => TimeTypeOption.into(), + FieldType::Media => MediaTypeOption::default().into(), } } diff --git a/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option_cell.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option_cell.rs index 0c7dc368586f6..99a29813addca 100644 --- a/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option_cell.rs +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/type_option_cell.rs @@ -14,8 +14,8 @@ use crate::services::cell::{CellCache, CellDataChangeset, CellDataDecoder, CellP use crate::services::field::summary_type_option::summary::SummarizationTypeOption; use crate::services::field::translate_type_option::translate::TranslateTypeOption; use crate::services::field::{ - CheckboxTypeOption, ChecklistTypeOption, DateTypeOption, MultiSelectTypeOption, NumberTypeOption, - RelationTypeOption, RichTextTypeOption, SingleSelectTypeOption, TimeTypeOption, + CheckboxTypeOption, ChecklistTypeOption, DateTypeOption, MediaTypeOption, MultiSelectTypeOption, + NumberTypeOption, RelationTypeOption, RichTextTypeOption, SingleSelectTypeOption, TimeTypeOption, TimestampTypeOption, TypeOption, TypeOptionCellData, TypeOptionCellDataCompare, TypeOptionCellDataFilter, TypeOptionCellDataSerde, TypeOptionTransform, URLTypeOption, }; @@ -496,6 +496,16 @@ impl<'a> TypeOptionCellExt<'a> { self.cell_data_cache.clone(), ) }), + FieldType::Media => self + .field + .get_type_option::(field_type) + .map(|type_option| { + TypeOptionCellDataHandlerImpl::new_with_boxed( + type_option, + field_type, + self.cell_data_cache.clone(), + ) + }), } } diff --git a/frontend/rust-lib/flowy-database2/src/services/filter/entities.rs b/frontend/rust-lib/flowy-database2/src/services/filter/entities.rs index 176ada802f5e0..4d861ee9e6bd6 100644 --- a/frontend/rust-lib/flowy-database2/src/services/filter/entities.rs +++ b/frontend/rust-lib/flowy-database2/src/services/filter/entities.rs @@ -14,8 +14,8 @@ use tracing::error; use crate::entities::{ CheckboxFilterPB, ChecklistFilterPB, DateFilterContent, DateFilterPB, FieldType, FilterType, - InsertedRowPB, NumberFilterPB, RelationFilterPB, SelectOptionFilterPB, TextFilterPB, - TimeFilterPB, + InsertedRowPB, MediaFilterPB, NumberFilterPB, RelationFilterPB, SelectOptionFilterPB, + TextFilterPB, TimeFilterPB, }; use crate::services::field::SelectOptionIds; @@ -287,6 +287,7 @@ impl FilterInner { FieldType::Summary => BoxAny::new(TextFilterPB::parse(condition as u8, content)), FieldType::Translate => BoxAny::new(TextFilterPB::parse(condition as u8, content)), FieldType::Time => BoxAny::new(TimeFilterPB::parse(condition as u8, content)), + FieldType::Media => BoxAny::new(MediaFilterPB::parse(condition as u8, content)), }; FilterInner::Data { @@ -388,6 +389,10 @@ impl<'a> From<&'a Filter> for FilterMap { let filter = condition_and_content.cloned::()?; (filter.condition as u8, filter.content) }, + FieldType::Media => { + let filter = condition_and_content.cloned::()?; + (filter.condition as u8, filter.content) + }, }; Some((condition, content)) }; diff --git a/frontend/rust-lib/flowy-database2/tests/database/cell_test/test.rs b/frontend/rust-lib/flowy-database2/tests/database/cell_test/test.rs index 67022a59f2add..0d5b3057e26fd 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/cell_test/test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/cell_test/test.rs @@ -1,10 +1,10 @@ use std::time::Duration; -use flowy_database2::entities::FieldType; +use flowy_database2::entities::{FieldType, MediaCellChangeset}; use flowy_database2::services::field::{ - ChecklistCellChangeset, DateCellChangeset, DateCellData, MultiSelectTypeOption, - RelationCellChangeset, SelectOptionCellChangeset, SingleSelectTypeOption, StringCellData, - TimeCellData, URLCellData, + ChecklistCellChangeset, DateCellChangeset, DateCellData, MediaFile, MediaFileType, + MediaUploadType, MultiSelectTypeOption, RelationCellChangeset, SelectOptionCellChangeset, + SingleSelectTypeOption, StringCellData, TimeCellData, URLCellData, }; use lib_infra::box_any::BoxAny; @@ -57,6 +57,16 @@ async fn grid_cell_update() { inserted_row_ids: vec!["abcdefabcdef".to_string().into()], ..Default::default() }), + FieldType::Media => BoxAny::new(MediaCellChangeset { + inserted_files: vec![MediaFile { + id: "abcdefghijk".to_string(), + name: "link".to_string(), + url: "https://www.appflowy.io".to_string(), + file_type: MediaFileType::Link, + upload_type: MediaUploadType::NetworkMedia, + }], + removed_ids: vec![], + }), _ => BoxAny::new("".to_string()), }; diff --git a/frontend/rust-lib/flowy-database2/tests/database/mock_data/board_mock_data.rs b/frontend/rust-lib/flowy-database2/tests/database/mock_data/board_mock_data.rs index c24f42225518e..ed507220708a5 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/mock_data/board_mock_data.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/mock_data/board_mock_data.rs @@ -141,7 +141,7 @@ pub fn make_test_board() -> DatabaseData { .build(); fields.push(time_field); }, - FieldType::Translate => {}, + FieldType::Translate | FieldType::Media => {}, } } diff --git a/frontend/rust-lib/flowy-database2/tests/database/mock_data/grid_mock_data.rs b/frontend/rust-lib/flowy-database2/tests/database/mock_data/grid_mock_data.rs index a8751262485fc..ac9b741a3deb6 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/mock_data/grid_mock_data.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/mock_data/grid_mock_data.rs @@ -9,9 +9,9 @@ use flowy_database2::entities::FieldType; use flowy_database2::services::field::summary_type_option::summary::SummarizationTypeOption; use flowy_database2::services::field::translate_type_option::translate::TranslateTypeOption; use flowy_database2::services::field::{ - ChecklistTypeOption, DateFormat, DateTypeOption, FieldBuilder, MultiSelectTypeOption, - NumberFormat, NumberTypeOption, RelationTypeOption, SingleSelectTypeOption, TimeFormat, - TimeTypeOption, TimestampTypeOption, + ChecklistTypeOption, DateFormat, DateTypeOption, FieldBuilder, MediaTypeOption, + MultiSelectTypeOption, NumberFormat, NumberTypeOption, RelationTypeOption, + SingleSelectTypeOption, TimeFormat, TimeTypeOption, TimestampTypeOption, }; use flowy_database2::services::field_settings::default_field_settings_for_fields; @@ -151,6 +151,14 @@ pub fn make_test_grid() -> DatabaseData { .build(); fields.push(translate_field); }, + FieldType::Media => { + let type_option = MediaTypeOption { files: vec![] }; + + let media_field = FieldBuilder::new(field_type, type_option) + .name("Media") + .build(); + fields.push(media_field); + }, } } diff --git a/frontend/rust-lib/flowy-database2/tests/database/share_test/export_test.rs b/frontend/rust-lib/flowy-database2/tests/database/share_test/export_test.rs index 791fc6e9ce9cc..72a85d1340b60 100644 --- a/frontend/rust-lib/flowy-database2/tests/database/share_test/export_test.rs +++ b/frontend/rust-lib/flowy-database2/tests/database/share_test/export_test.rs @@ -76,15 +76,16 @@ async fn export_and_then_import_meta_csv_test() { assert_eq!(s, "Google,Facebook"); } }, - FieldType::Checkbox => {}, - FieldType::URL => {}, - FieldType::Checklist => {}, - FieldType::LastEditedTime => {}, - FieldType::CreatedTime => {}, - FieldType::Relation => {}, - FieldType::Summary => {}, - FieldType::Time => {}, - FieldType::Translate => {}, + FieldType::Checkbox + | FieldType::URL + | FieldType::Checklist + | FieldType::LastEditedTime + | FieldType::CreatedTime + | FieldType::Relation + | FieldType::Summary + | FieldType::Time + | FieldType::Translate + | FieldType::Media => {}, } } else { panic!( @@ -163,13 +164,14 @@ async fn history_database_import_test() { assert_eq!(s, "AppFlowy website - https://www.appflowy.io"); } }, - FieldType::Checklist => {}, - FieldType::LastEditedTime => {}, - FieldType::CreatedTime => {}, - FieldType::Relation => {}, - FieldType::Summary => {}, - FieldType::Time => {}, - FieldType::Translate => {}, + FieldType::Checklist + | FieldType::LastEditedTime + | FieldType::CreatedTime + | FieldType::Relation + | FieldType::Summary + | FieldType::Time + | FieldType::Translate + | FieldType::Media => {}, } } else { panic!(