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 0000000000..34b723f93b --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/application/cell/bloc/media_cell_bloc.dart @@ -0,0 +1,199 @@ +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(); + } + + final MediaCellController cellController; + void Function()? _onCellChangedFn; + + late UserProfilePB _userProfile; + + UserProfilePB get userProfile => _userProfile; + + String get databaseId => cellController.viewId; + + 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 { + _startListening(); + + // Fetch user profile + final userProfileResult = + await UserBackendService.getCurrentUserProfile(); + userProfileResult.fold( + (userProfile) => _userProfile = userProfile, + (l) => Log.error(l), + ); + + // Fetch the files from cellController + final data = cellController.getCellData(); + if (data != null) { + emit(state.copyWith(files: data.files)); + } + }, + 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)); + }, + ); + }, + ); + } + + 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)); + } + } +} + +@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; +} + +@freezed +class MediaCellState with _$MediaCellState { + const factory MediaCellState({ + required String fieldName, + @Default([]) List files, + }) = _MediaCellState; + + factory MediaCellState.initial(MediaCellController cellController) { + return MediaCellState(fieldName: cellController.fieldInfo.field.name); + } +} 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 50ef7ccb74..afe05e8b70 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 1c03239cde..cfab4668ae 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/application/field/field_info.dart b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_info.dart index bc5107f75c..d4b5afcd3b 100644 --- a/frontend/appflowy_flutter/lib/plugins/database/application/field/field_info.dart +++ b/frontend/appflowy_flutter/lib/plugins/database/application/field/field_info.dart @@ -65,6 +65,7 @@ class FieldInfo with _$FieldInfo { case FieldType.Checklist: case FieldType.URL: case FieldType.Time: + case FieldType.Media: return true; default: return false; 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 e618da5de9..7434c5e497 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 a27b0bf000..b4a8dc72b7 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 19c4e11483..cc585700bc 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,7 +17,6 @@ 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'; @@ -24,6 +25,7 @@ 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'; 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 36d4f24142..c141f28c36 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/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 0000000000..825369417e --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/desktop_grid/desktop_grid_media_cell.dart @@ -0,0 +1,114 @@ +import 'dart:io'; + +import 'package:flutter/widgets.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/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:flowy_infra/theme_extension.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +class DekstopGridMediaCellSkin extends IEditableMediaCellSkin { + @override + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + PopoverController popoverController, + MediaCellBloc bloc, + ) { + return 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: BlocBuilder( + builder: (context, state) { + final wrapContent = context.read().wrapContent; + if (wrapContent) { + return Padding( + padding: const EdgeInsets.all(4), + child: IntrinsicWidth( + child: Wrap( + spacing: 6, + runSpacing: 4, + children: state.files + .map((file) => _FilePreviewRender(file: file)) + .toList(), + ), + ), + ); + } + + return FlowyTooltip( + message: '${state.files.length} files - click to view', + child: Padding( + padding: const EdgeInsets.all(4), + child: IntrinsicWidth( + child: SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: SeparatedRow( + separatorBuilder: () => const HSpace(6), + children: state.files + .map((file) => _FilePreviewRender(file: file)) + .toList(), + ), + ), + ), + ), + ); + }, + ), + ); + } +} + +class _FilePreviewRender extends StatelessWidget { + const _FilePreviewRender({required this.file}); + + final MediaFilePB file; + + @override + Widget build(BuildContext context) { + if (file.fileType == MediaFileTypePB.Image) { + if (file.uploadType == MediaUploadTypePB.NetworkMedia) { + return Image.network(file.url, height: 32); + } else if (file.uploadType == MediaUploadTypePB.LocalMedia) { + return Image.file(File(file.url), height: 32); + } else { + // Cloud + return FlowyNetworkImage( + url: file.url, + userProfilePB: context.read().userProfile, + height: 32, + ); + } + } + + return Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 2), + decoration: BoxDecoration( + color: AFThemeExtension.of(context).greyHover, + borderRadius: BorderRadius.circular(4), + ), + child: FlowyText( + file.name, + overflow: TextOverflow.ellipsis, + ), + ); + } +} 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 155a6003ce..94606e896e 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,12 @@ class EditableCellBuilder { skin: IEditableTranslateCellSkin.fromStyle(style), key: key, ), + FieldType.Media => EditableMediaCell( + databaseController: databaseController, + cellContext: cellContext, + skin: IEditableMediaCellSkin.fromStyle(style), + key: key, + ), _ => throw UnimplementedError(), }; } @@ -226,6 +233,12 @@ class EditableCellBuilder { skin: skinMap.timeSkin!, key: key, ), + FieldType.Media => EditableMediaCell( + databaseController: databaseController, + cellContext: cellContext, + skin: skinMap.mediaSkin!, + key: key, + ), _ => throw UnimplementedError(), }; } @@ -382,6 +395,7 @@ class EditableCellSkinMap { this.urlSkin, this.relationSkin, this.timeSkin, + this.mediaSkin, }); final IEditableCheckboxCellSkin? checkboxSkin; @@ -394,6 +408,7 @@ class EditableCellSkinMap { final IEditableURLCellSkin? urlSkin; final IEditableRelationCellSkin? relationSkin; final IEditableTimeCellSkin? timeSkin; + final IEditableMediaCellSkin? mediaSkin; bool has(FieldType fieldType) { return switch (fieldType) { @@ -410,6 +425,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 0000000000..2922bf7959 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell/editable_cell_skeleton/media.dart @@ -0,0 +1,91 @@ +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/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 => DekstopGridMediaCellSkin(), + // TODO(Mathias): Implement the rest of the styles + EditableCellStyle.desktopRowDetail => DekstopGridMediaCellSkin(), + EditableCellStyle.mobileGrid => DekstopGridMediaCellSkin(), + EditableCellStyle.mobileRowDetail => DekstopGridMediaCellSkin(), + }; + } + + Widget build( + BuildContext context, + CellContainerNotifier cellContainerNotifier, + PopoverController popoverController, + MediaCellBloc bloc, + ); +} + +class EditableMediaCell extends EditableCellWidget { + EditableMediaCell({ + super.key, + required this.databaseController, + required this.cellContext, + required this.skin, + }); + + final DatabaseController databaseController; + final CellContext cellContext; + final IEditableMediaCellSkin skin; + + @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(); + 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() => popoverController.show(); + + @override + String? onCopy() => null; +} 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 0000000000..3581cd7108 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/database/widgets/cell_editor/media_cell_editor.dart @@ -0,0 +1,469 @@ +import 'dart:convert'; +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/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/shared/patterns/common_patterns.dart'; +import 'package:appflowy/workspace/presentation/home/toast.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_backend/protobuf/flowy-user/protobuf.dart'; +import 'package:appflowy_popover/appflowy_popover.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/file_picker/file_picker_impl.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:flowy_infra_ui/style_widget/snap_bar.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:http/http.dart' as http; +import 'package:path/path.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: (_, 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 { + if (file.path.isEmpty) return; + + final fileType = file.mimeType?.startsWith('image/') ?? + false || imgExtensionRegex.hasMatch(file.name) + ? MediaFileTypePB.Image + : MediaFileTypePB.Other; + + final mediaCellBloc = context.read(); + + // Check upload type + final userProfile = mediaCellBloc.userProfile; + final isLocalMode = + userProfile.authenticator == AuthenticatorPB.Local; + + String? path; + String? errorMsg; + if (isLocalMode) { + path = await saveFileToLocalStorage(file.path); + } else { + (path, errorMsg) = await saveFileToCloudStorage( + file.path, + mediaCellBloc.databaseId, + ); + } + + if (errorMsg != null) { + return showSnackBarMessage(context, errorMsg); + } + + if (mediaCellBloc.isClosed || path == null) { + return; + } + + mediaCellBloc.add( + MediaCellEvent.addFile( + url: path, + name: file.name, + uploadType: isLocalMode + ? MediaUploadTypePB.LocalMedia + : MediaUploadTypePB.CloudMedia, + fileType: fileType, + ), + ); + + addFilePopoverController.close(); + }, + onInsertNetworkFile: (url) { + if (url.isEmpty) return; + + final uri = Uri.tryParse(url); + if (uri == null) { + return showSnackBarMessage( + context, + 'Invalid URL - Please try again', + ); + } + + String name = uri.pathSegments.last; + if (name.isEmpty && uri.pathSegments.length > 1) { + name = uri.pathSegments[uri.pathSegments.length - 2]; + } + + context.read().add( + MediaCellEvent.addFile( + url: url, + name: name, + uploadType: MediaUploadTypePB.NetworkMedia, + fileType: MediaFileTypePB.Other, + ), + ); + + addFilePopoverController.close(); + }, + ), + child: GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: addFilePopoverController.show, + child: const FlowyHover( + resetHoverOnRebuild: false, + child: Padding( + padding: EdgeInsets.all(4.0), + child: Row( + children: [ + FlowySvg(FlowySvgs.add_s), + HSpace(8), + FlowyText('Add a file or image'), + ], + ), + ), + ), + ), + ), + ], + ), + ), + ); + }, + ); + } +} + +extension ToCustomImageType on MediaUploadTypePB { + CustomImageType toCustomImageType() => switch (this) { + MediaUploadTypePB.NetworkMedia => CustomImageType.external, + MediaUploadTypePB.CloudMedia => CustomImageType.internal, + _ => CustomImageType.local, + }; +} + +class _RenderMedia extends StatefulWidget { + const _RenderMedia({ + 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<_RenderMedia> createState() => __RenderMediaState(); +} + +class __RenderMediaState extends State<_RenderMedia> { + 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().userProfile, + height: 64, + ), + ), + ), + ] 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, + height: 64, + fit: BoxFit.contain, + alignment: Alignment.centerLeft, + ) + : Image.file( + File(widget.file.url), + height: 64, + fit: BoxFit.contain, + alignment: Alignment.centerLeft, + ), + ), + ), + ] else ...[ + Expanded( + child: GestureDetector( + onTap: () => afLaunchUrlString(widget.file.url), + 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: (_) => BlocProvider.value( + value: context.read(), + child: _MediaItemMenu(file: widget.file), + ), + 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, + }) { + return GestureDetector( + behavior: HitTestBehavior.translucent, + onTap: () { + showDialog( + context: context, + builder: (_) => InteractiveImageViewer( + userProfile: context.read().userProfile, + imageProvider: AFBlockImageProvider( + images: [ + ImageBlockData( + url: file.url, + type: file.uploadType.toCustomImageType(), + ), + ], + onDeleteImage: (_) => context + .read() + .add(MediaCellEvent.removeFile(fileId: file.id)), + ), + ), + ); + }, + child: child, + ); + } +} + +class _MediaItemMenu extends StatelessWidget { + const _MediaItemMenu({ + required this.file, + }); + + final MediaFilePB file; + + @override + Widget build(BuildContext context) { + return SeparatedColumn( + separatorBuilder: () => const VSpace(4), + mainAxisSize: MainAxisSize.min, + children: [ + if (file.fileType == MediaFileTypePB.Image) ...[ + FlowyButton( + onTap: () => showDialog( + context: context, + builder: (_) => InteractiveImageViewer( + userProfile: context.read().userProfile, + imageProvider: AFBlockImageProvider( + images: [ + ImageBlockData( + url: file.url, + type: file.uploadType.toCustomImageType(), + ), + ], + onDeleteImage: (_) => context + .read() + .add(MediaCellEvent.removeFile(fileId: 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( + onTap: () 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 { + final userProfile = context.read().userProfile; + final uri = Uri.parse(file.url); + final imgFile = File(uri.pathSegments.last); + final savePath = await FilePicker().saveFile( + fileName: basename(imgFile.path), + ); + + if (savePath != null) { + final uri = Uri.parse(file.url); + + final token = jsonDecode(userProfile.token)['access_token']; + 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(), + ); + } + } + } + }, + leftIcon: FlowySvg( + FlowySvgs.download_s, + color: Theme.of(context).iconTheme.color, + size: const Size.square(18), + ), + text: FlowyText.regular( + 'Download', + color: AFThemeExtension.of(context).textColor, + ), + leftIconSize: const Size(18, 18), + hoverColor: AFThemeExtension.of(context).lightGreyHover, + ), + FlowyButton( + onTap: () => context.read().add( + MediaCellEvent.removeFile( + fileId: 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, + ), + ], + ); + } +} 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 69fe3635a8..bb56bfa83e 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 e4bcdd4911..624f9f1fb2 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 0000000000..ecd1ba73fb --- /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/document/application/document_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/application/document_bloc.dart index cc99dd60c5..53b9084fab 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 5118620d98..57b86ce2d4 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/presentation/editor_plugins/file/file_block_component.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_block_component.dart index 5b1b3aa979..0e3a83ba7c 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_upload_menu.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/editor_plugins/file/file_upload_menu.dart index 4baa8506fe..d29f86de35 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 @@ -61,9 +62,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 +99,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(); @@ -117,7 +118,7 @@ class _FileUploadLocalState extends State<_FileUploadLocal> { 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 +182,7 @@ class _FileUploadLocalState extends State<_FileUploadLocal> { Future _uploadFile(BuildContext context) async { final result = await getIt().pickFiles(dialogTitle: ''); - widget.onFilePicked(result?.files.first.path); + widget.onFilePicked(result?.files.first.xFile); } } 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 fd9e1f5169..7f29ffd425 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 @@ -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/util/field_type_extension.dart b/frontend/appflowy_flutter/lib/util/field_type_extension.dart index 4ca63aa4ae..7244cecab5 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(), }; 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 5c07d6d5b1..9897a0c55a 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/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 b4a2553814..997038f532 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 da3f804c8d..61ed4b1f18 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 04e83c0bcc..4f1065fb47 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: diff --git a/frontend/resources/flowy_icons/16x/download.svg b/frontend/resources/flowy_icons/16x/download.svg new file mode 100644 index 0000000000..c753fba274 --- /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/media.svg b/frontend/resources/flowy_icons/16x/media.svg new file mode 100644 index 0000000000..74356ae6dc --- /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 2ff4a5a5f2..ed2bbcca57 100644 --- a/frontend/resources/translations/en.json +++ b/frontend/resources/translations/en.json @@ -1313,6 +1313,7 @@ "relationFieldName": "Relation", "summaryFieldName": "AI Summary", "timeFieldName": "Time", + "mediaFieldName": "Files & media", "translateFieldName": "AI Translate", "translateTo": "Translate to", "numberFormat": "Number format", @@ -2495,4 +2496,4 @@ "uploadFailedDescription": "The file upload failed", "uploadingDescription": "The file is being uploaded" } -} +} \ No newline at end of file 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 270ac7706d..d8e892fc51 100644 --- a/frontend/rust-lib/event-integration-test/src/database_event.rs +++ b/frontend/rust-lib/event-integration-test/src/database_event.rs @@ -668,6 +668,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/flowy-database2/src/entities/field_entities.rs b/frontend/rust-lib/flowy-database2/src/entities/field_entities.rs index 3263986db8..19ec70a093 100644 --- a/frontend/rust-lib/flowy-database2/src/entities/field_entities.rs +++ b/frontend/rust-lib/flowy-database2/src/entities/field_entities.rs @@ -451,6 +451,7 @@ pub enum FieldType { Summary = 11, Translate = 12, Time = 13, + Media = 14, } impl Display for FieldType { @@ -493,6 +494,7 @@ impl FieldType { FieldType::Summary => "Summarize", FieldType::Translate => "Translate", FieldType::Time => "Time", + FieldType::Media => "Media", }; s.to_string() } @@ -553,6 +555,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 0000000000..9cc9adc481 --- /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 a6a990a458..0adb930020 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 af1288506e..b5e56dab77 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 2d30eb15f0..6fc6965bbc 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/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 0000000000..5bffc4186d --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/entities/type_option_entities/media_entities.rs @@ -0,0 +1,152 @@ +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, + Image = 1, +} + +impl From for MediaFileTypePB { + fn from(data: MediaFileType) -> Self { + match data { + MediaFileType::Other => MediaFileTypePB::Other, + MediaFileType::Image => MediaFileTypePB::Image, + } + } +} + +impl From for MediaFileType { + fn from(data: MediaFileTypePB) -> Self { + match data { + MediaFileTypePB::Other => MediaFileType::Other, + MediaFileTypePB::Image => MediaFileType::Image, + } + } +} + +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, +} 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 f92072eabd..633f000e53 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 ba8193d47b..367cad385d 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 2a497e0e30..47358bcd8f 100644 --- a/frontend/rust-lib/flowy-database2/src/event_handler.rs +++ b/frontend/rust-lib/flowy-database2/src/event_handler.rs @@ -1261,3 +1261,31 @@ 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(()) +} diff --git a/frontend/rust-lib/flowy-database2/src/event_map.rs b/frontend/rust-lib/flowy-database2/src/event_map.rs index 5b0db9d9ed..24e0b67792 100644 --- a/frontend/rust-lib/flowy-database2/src/event_map.rs +++ b/frontend/rust-lib/flowy-database2/src/event_map.rs @@ -94,6 +94,8 @@ 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) } /// [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) @@ -385,4 +387,7 @@ pub enum DatabaseEvent { #[event(input = "DatabaseViewIdPB", output = "RepeatedRowMetaPB")] GetAllRows = 177, + + #[event(input = "MediaCellChangesetPB")] + UpdateMediaCell = 178, } 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 d1bae644ea..dd60832681 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/field/type_option_transform.rs b/frontend/rust-lib/flowy-database2/src/services/field/type_option_transform.rs index f273c784e8..2b359983c4 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 0000000000..5c058d77cf --- /dev/null +++ b/frontend/rust-lib/flowy-database2/src/services/field/type_options/media_type_option/media_file.rs @@ -0,0 +1,39 @@ +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 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, +} 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 0000000000..f5da10f482 --- /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 0000000000..77a5e32ad9 --- /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 0000000000..5fae403f53 --- /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 a6515c9db4..3464ae6cfe 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 4e6684202a..f57010c0a3 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 d6e60cbcff..0566cdf641 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 7563e753c5..bfeae5cab4 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 0c7dc36858..99a29813ad 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 176ada802f..4d861ee9e6 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/mock_data/board_mock_data.rs b/frontend/rust-lib/flowy-database2/tests/database/mock_data/board_mock_data.rs index c24f422255..ed50722070 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 a875126248..ac9b741a3d 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 791fc6e9ce..72a85d1340 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!(