feat: media type option

This commit is contained in:
Mathias Mogensen 2024-08-27 16:42:39 +02:00
parent b77fdb8424
commit 4793d29de2
54 changed files with 1687 additions and 80 deletions

View File

@ -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<MediaCellEvent, MediaCellState> {
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<void> close() async {
if (_onCellChangedFn != null) {
cellController.removeListener(
onCellChanged: _onCellChangedFn!,
onFieldChanged: _onFieldChangedListener,
);
}
await cellController.dispose();
return super.close();
}
void _dispatch() {
on<MediaCellEvent>(
(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<MediaFilePB>.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<MediaFilePB> 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<MediaFilePB> files,
}) = _MediaCellState;
factory MediaCellState.initial(MediaCellController cellController) {
return MediaCellState(fieldName: cellController.fieldInfo.field.name);
}
}

View File

@ -18,6 +18,7 @@ typedef RelationCellController = CellController<RelationCellDataPB, String>;
typedef SummaryCellController = CellController<String, String>; typedef SummaryCellController = CellController<String, String>;
typedef TimeCellController = CellController<TimeCellDataPB, String>; typedef TimeCellController = CellController<TimeCellDataPB, String>;
typedef TranslateCellController = CellController<String, String>; typedef TranslateCellController = CellController<String, String>;
typedef MediaCellController = CellController<MediaCellDataPB, String>;
CellController makeCellController( CellController makeCellController(
DatabaseController databaseController, DatabaseController databaseController,
@ -170,6 +171,18 @@ CellController makeCellController(
), ),
cellDataPersistence: TextCellDataPersistence(), 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; throw UnimplementedError;
} }

View File

@ -196,3 +196,19 @@ class TimeCellDataParser implements CellDataParser<TimeCellDataPB> {
} }
} }
} }
class MediaCellDataParser implements CellDataParser<MediaCellDataPB> {
@override
MediaCellDataPB? parserData(List<int> data) {
if (data.isEmpty) {
return null;
}
try {
return MediaCellDataPB.fromBuffer(data);
} catch (e) {
Log.error("Failed to parse media cell data: $e");
return null;
}
}
}

View File

@ -65,6 +65,7 @@ class FieldInfo with _$FieldInfo {
case FieldType.Checklist: case FieldType.Checklist:
case FieldType.URL: case FieldType.URL:
case FieldType.Time: case FieldType.Time:
case FieldType.Media:
return true; return true;
default: default:
return false; return false;

View File

@ -281,6 +281,30 @@ class FilterBackendService {
); );
} }
Future<FlowyResult<void, FlowyError>> 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<FlowyResult<void, FlowyError>> deleteFilter({ Future<FlowyResult<void, FlowyError>> deleteFilter({
required String fieldId, required String fieldId,
required String filterId, required String filterId,

View File

@ -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_controller.dart';
import 'package:appflowy/plugins/database/application/field/field_info.dart'; import 'package:appflowy/plugins/database/application/field/field_info.dart';
import 'package:appflowy/plugins/database/domain/filter_service.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/protobuf.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-error/errors.pbserver.dart'; import 'package:appflowy_backend/protobuf/flowy-error/errors.pbserver.dart';
import 'package:appflowy_result/appflowy_result.dart'; import 'package:appflowy_result/appflowy_result.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
@ -149,6 +143,11 @@ class GridCreateFilterBloc
fieldId: fieldId, fieldId: fieldId,
condition: TextFilterConditionPB.TextContains, condition: TextFilterConditionPB.TextContains,
); );
case FieldType.Media:
return _filterBackendSvc.insertMediaFilter(
fieldId: fieldId,
condition: MediaFilterConditionPB.MediaIsNotEmpty,
);
default: default:
throw UnimplementedError(); throw UnimplementedError();
} }

View File

@ -1,5 +1,7 @@
import 'dart:math'; import 'dart:math';
import 'package:flutter/material.dart';
import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/database/application/row/row_service.dart'; import 'package:appflowy/plugins/database/application/row/row_service.dart';
import 'package:appflowy/plugins/database/grid/presentation/widgets/calculations/calculations_row.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/flowy_infra_ui.dart';
import 'package:flowy_infra_ui/style_widget/scrolling/styled_scrollview.dart'; import 'package:flowy_infra_ui/style_widget/scrolling/styled_scrollview.dart';
import 'package:flowy_infra_ui/widget/error_page.dart'; import 'package:flowy_infra_ui/widget/error_page.dart';
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:linked_scroll_controller/linked_scroll_controller.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 '../../tab_bar/tab_bar_view.dart';
import '../../widgets/row/row_detail.dart'; import '../../widgets/row/row_detail.dart';
import '../application/grid_bloc.dart'; import '../application/grid_bloc.dart';
import 'grid_scroll.dart'; import 'grid_scroll.dart';
import 'layout/layout.dart'; import 'layout/layout.dart';
import 'layout/sizes.dart'; import 'layout/sizes.dart';

View File

@ -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/flowy_svgs.g.dart';
import 'package:appflowy/generated/locale_keys.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.dart';
import 'package:appflowy/plugins/database/application/cell/cell_controller_builder.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/database_controller.dart';
import 'package:appflowy/plugins/database/application/cell/bloc/text_cell_bloc.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.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 'package:flutter_bloc/flutter_bloc.dart';
import '../editable_cell_builder.dart'; import '../editable_cell_builder.dart';
import 'card_cell.dart'; import 'card_cell.dart';
class TextCardCellStyle extends CardCellStyle { class TextCardCellStyle extends CardCellStyle {

View File

@ -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<MediaCellBloc>(),
child: const MediaCellEditor(),
),
onClose: () => cellContainerNotifier.isFocus = false,
child: BlocBuilder<MediaCellBloc, MediaCellState>(
builder: (context, state) {
final wrapContent = context.read<MediaCellBloc>().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<MediaCellBloc>().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,
),
);
}
}

View File

@ -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/cell/cell_controller.dart';
import 'package:appflowy/plugins/database/application/database_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/plugins/database/widgets/cell/editable_cell_skeleton/translate.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
@ -134,6 +135,12 @@ class EditableCellBuilder {
skin: IEditableTranslateCellSkin.fromStyle(style), skin: IEditableTranslateCellSkin.fromStyle(style),
key: key, key: key,
), ),
FieldType.Media => EditableMediaCell(
databaseController: databaseController,
cellContext: cellContext,
skin: IEditableMediaCellSkin.fromStyle(style),
key: key,
),
_ => throw UnimplementedError(), _ => throw UnimplementedError(),
}; };
} }
@ -226,6 +233,12 @@ class EditableCellBuilder {
skin: skinMap.timeSkin!, skin: skinMap.timeSkin!,
key: key, key: key,
), ),
FieldType.Media => EditableMediaCell(
databaseController: databaseController,
cellContext: cellContext,
skin: skinMap.mediaSkin!,
key: key,
),
_ => throw UnimplementedError(), _ => throw UnimplementedError(),
}; };
} }
@ -382,6 +395,7 @@ class EditableCellSkinMap {
this.urlSkin, this.urlSkin,
this.relationSkin, this.relationSkin,
this.timeSkin, this.timeSkin,
this.mediaSkin,
}); });
final IEditableCheckboxCellSkin? checkboxSkin; final IEditableCheckboxCellSkin? checkboxSkin;
@ -394,6 +408,7 @@ class EditableCellSkinMap {
final IEditableURLCellSkin? urlSkin; final IEditableURLCellSkin? urlSkin;
final IEditableRelationCellSkin? relationSkin; final IEditableRelationCellSkin? relationSkin;
final IEditableTimeCellSkin? timeSkin; final IEditableTimeCellSkin? timeSkin;
final IEditableMediaCellSkin? mediaSkin;
bool has(FieldType fieldType) { bool has(FieldType fieldType) {
return switch (fieldType) { return switch (fieldType) {
@ -410,6 +425,7 @@ class EditableCellSkinMap {
FieldType.RichText => textSkin != null, FieldType.RichText => textSkin != null,
FieldType.URL => urlSkin != null, FieldType.URL => urlSkin != null,
FieldType.Time => timeSkin != null, FieldType.Time => timeSkin != null,
FieldType.Media => mediaSkin != null,
_ => throw UnimplementedError(), _ => throw UnimplementedError(),
}; };
} }

View File

@ -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<EditableMediaCell> createState() =>
_EditableMediaCellState();
}
class _EditableMediaCellState extends GridEditableTextCell<EditableMediaCell> {
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;
}

View File

@ -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<MediaCellEditor> createState() => _MediaCellEditorState();
}
class _MediaCellEditorState extends State<MediaCellEditor> {
final addFilePopoverController = PopoverController();
final itemMutex = PopoverMutex();
@override
void dispose() {
addFilePopoverController.close();
itemMutex.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return BlocBuilder<MediaCellBloc, MediaCellState>(
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<MediaCellBloc>(),
child: _RenderMedia(
file: state.files[index],
index: index,
enableReordering: state.files.length > 1,
mutex: itemMutex,
),
),
itemCount: state.files.length,
onReorder: (from, to) => context
.read<MediaCellBloc>()
.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<MediaCellBloc>();
// 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<MediaCellBloc>().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<MediaCellBloc>().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<MediaCellBloc>(),
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<MediaCellBloc>().userProfile,
imageProvider: AFBlockImageProvider(
images: [
ImageBlockData(
url: file.url,
type: file.uploadType.toCustomImageType(),
),
],
onDeleteImage: (_) => context
.read<MediaCellBloc>()
.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<MediaCellBloc>().userProfile,
imageProvider: AFBlockImageProvider(
images: [
ImageBlockData(
url: file.url,
type: file.uploadType.toCustomImageType(),
),
],
onDeleteImage: (_) => context
.read<MediaCellBloc>()
.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<MediaCellBloc>().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<MediaCellBloc>().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,
),
],
);
}
}

View File

@ -24,6 +24,7 @@ const List<FieldType> _supportedFieldTypes = [
FieldType.Summary, FieldType.Summary,
// FieldType.Time, // FieldType.Time,
FieldType.Translate, FieldType.Translate,
FieldType.Media,
]; ];
class FieldTypeList extends StatelessWidget with FlowyOverlayDelegate { class FieldTypeList extends StatelessWidget with FlowyOverlayDelegate {

View File

@ -2,6 +2,7 @@ import 'dart:typed_data';
import 'package:flutter/material.dart'; 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/plugins/database/widgets/field/type_option_editor/translate.dart';
import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart'; import 'package:appflowy_backend/protobuf/flowy-database2/protobuf.dart';
import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:appflowy_popover/appflowy_popover.dart';
@ -38,6 +39,7 @@ abstract class TypeOptionEditorFactory {
FieldType.Summary => const SummaryTypeOptionEditorFactory(), FieldType.Summary => const SummaryTypeOptionEditorFactory(),
FieldType.Time => const TimeTypeOptionEditorFactory(), FieldType.Time => const TimeTypeOptionEditorFactory(),
FieldType.Translate => const TranslateTypeOptionEditorFactory(), FieldType.Translate => const TranslateTypeOptionEditorFactory(),
FieldType.Media => const MediaTypeOptionEditorFactory(),
_ => throw UnimplementedError(), _ => throw UnimplementedError(),
}; };
} }

View File

@ -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;
}

View File

@ -1,6 +1,8 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert'; 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/doc_sync_state_listener.dart';
import 'package:appflowy/plugins/document/application/document_awareness_metadata.dart'; import 'package:appflowy/plugins/document/application/document_awareness_metadata.dart';
import 'package:appflowy/plugins/document/application/document_collab_adapter.dart'; import 'package:appflowy/plugins/document/application/document_collab_adapter.dart';
@ -32,7 +34,6 @@ import 'package:appflowy_editor/appflowy_editor.dart'
Position, Position,
paragraphNode; paragraphNode;
import 'package:appflowy_result/appflowy_result.dart'; import 'package:appflowy_result/appflowy_result.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart'; import 'package:freezed_annotation/freezed_annotation.dart';

View File

@ -117,17 +117,19 @@ class DocumentService {
required String documentId, required String documentId,
}) async { }) async {
final workspace = await FolderEventReadCurrentWorkspace().send(); final workspace = await FolderEventReadCurrentWorkspace().send();
return workspace.fold((l) async { return workspace.fold(
(l) async {
final payload = UploadFileParamsPB( final payload = UploadFileParamsPB(
workspaceId: l.id, workspaceId: l.id,
localFilePath: localFilePath, localFilePath: localFilePath,
documentId: documentId, documentId: documentId,
); );
final result = await DocumentEventUploadFile(payload).send(); return DocumentEventUploadFile(payload).send();
return result; },
}, (r) async { (r) async {
return FlowyResult.failure(FlowyError(msg: 'Workspace not found')); return FlowyResult.failure(FlowyError(msg: 'Workspace not found'));
}); },
);
} }
/// Download a file from the cloud storage. /// Download a file from the cloud storage.

View File

@ -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/workspace/presentation/home/toast.dart';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:appflowy_popover/appflowy_popover.dart';
import 'package:cross_file/cross_file.dart';
import 'package:desktop_drop/desktop_drop.dart'; import 'package:desktop_drop/desktop_drop.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra_ui/flowy_infra_ui.dart'; import 'package:flowy_infra_ui/flowy_infra_ui.dart';
@ -237,7 +238,7 @@ class FileBlockComponentState extends State<FileBlockComponent>
}, },
onDragDone: (details) { onDragDone: (details) {
if (dropManagerState.isDropEnabled) { if (dropManagerState.isDropEnabled) {
insertFileFromLocal(details.files.first.path); insertFileFromLocal(details.files.first);
} }
}, },
child: AppFlowyPopover( child: AppFlowyPopover(
@ -359,7 +360,8 @@ class FileBlockComponentState extends State<FileBlockComponent>
} }
} }
Future<void> insertFileFromLocal(String path) async { Future<void> insertFileFromLocal(XFile file) async {
final path = file.path;
final documentBloc = context.read<DocumentBloc>(); final documentBloc = context.read<DocumentBloc>();
final isLocalMode = documentBloc.isLocalMode; final isLocalMode = documentBloc.isLocalMode;
final urlType = isLocalMode ? FileUrlType.local : FileUrlType.cloud; final urlType = isLocalMode ? FileUrlType.local : FileUrlType.cloud;
@ -382,12 +384,11 @@ class FileBlockComponentState extends State<FileBlockComponent>
// Remove the file block from the drop state manager // Remove the file block from the drop state manager
dropManagerState.remove(FileBlockKeys.type); dropManagerState.remove(FileBlockKeys.type);
final name = Uri.tryParse(path)?.pathSegments.last ?? url;
final transaction = editorState.transaction; final transaction = editorState.transaction;
transaction.updateNode(widget.node, { transaction.updateNode(widget.node, {
FileBlockKeys.url: url, FileBlockKeys.url: url,
FileBlockKeys.urlType: urlType.toIntValue(), FileBlockKeys.urlType: urlType.toIntValue(),
FileBlockKeys.name: name, FileBlockKeys.name: file.name,
FileBlockKeys.uploadedAt: DateTime.now().millisecondsSinceEpoch, FileBlockKeys.uploadedAt: DateTime.now().millisecondsSinceEpoch,
}); });
await editorState.apply(transaction); await editorState.apply(transaction);

View File

@ -4,6 +4,7 @@ import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/shared/patterns/common_patterns.dart'; import 'package:appflowy/shared/patterns/common_patterns.dart';
import 'package:appflowy/startup/startup.dart'; import 'package:appflowy/startup/startup.dart';
import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor/appflowy_editor.dart';
import 'package:cross_file/cross_file.dart';
import 'package:desktop_drop/desktop_drop.dart'; import 'package:desktop_drop/desktop_drop.dart';
import 'package:dotted_border/dotted_border.dart'; import 'package:dotted_border/dotted_border.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
@ -21,7 +22,7 @@ class FileUploadMenu extends StatefulWidget {
required this.onInsertNetworkFile, required this.onInsertNetworkFile,
}); });
final void Function(String path) onInsertLocalFile; final void Function(XFile file) onInsertLocalFile;
final void Function(String url) onInsertNetworkFile; final void Function(String url) onInsertNetworkFile;
@override @override
@ -61,9 +62,9 @@ class _FileUploadMenuState extends State<FileUploadMenu> {
const Divider(height: 4), const Divider(height: 4),
if (currentTab == 0) ...[ if (currentTab == 0) ...[
_FileUploadLocal( _FileUploadLocal(
onFilePicked: (path) { onFilePicked: (file) {
if (path != null) { if (file != null) {
widget.onInsertLocalFile(path); widget.onInsertLocalFile(file);
} }
}, },
), ),
@ -98,7 +99,7 @@ class _Tab extends StatelessWidget {
class _FileUploadLocal extends StatefulWidget { class _FileUploadLocal extends StatefulWidget {
const _FileUploadLocal({required this.onFilePicked}); const _FileUploadLocal({required this.onFilePicked});
final void Function(String?) onFilePicked; final void Function(XFile?) onFilePicked;
@override @override
State<_FileUploadLocal> createState() => _FileUploadLocalState(); State<_FileUploadLocal> createState() => _FileUploadLocalState();
@ -117,7 +118,7 @@ class _FileUploadLocalState extends State<_FileUploadLocal> {
child: DropTarget( child: DropTarget(
onDragEntered: (_) => setState(() => isDragging = true), onDragEntered: (_) => setState(() => isDragging = true),
onDragExited: (_) => setState(() => isDragging = false), onDragExited: (_) => setState(() => isDragging = false),
onDragDone: (details) => widget.onFilePicked(details.files.first.path), onDragDone: (details) => widget.onFilePicked(details.files.first),
child: MouseRegion( child: MouseRegion(
cursor: SystemMouseCursors.click, cursor: SystemMouseCursors.click,
child: GestureDetector( child: GestureDetector(
@ -181,7 +182,7 @@ class _FileUploadLocalState extends State<_FileUploadLocal> {
Future<void> _uploadFile(BuildContext context) async { Future<void> _uploadFile(BuildContext context) async {
final result = await getIt<FilePickerService>().pickFiles(dialogTitle: ''); final result = await getIt<FilePickerService>().pickFiles(dialogTitle: '');
widget.onFilePicked(result?.files.first.path); widget.onFilePicked(result?.files.first.xFile);
} }
} }

View File

@ -231,7 +231,7 @@ class _ImageBrowserLayoutState extends State<ImageBrowserLayout> {
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
const FlowySvg( const FlowySvg(
FlowySvgs.import_s, FlowySvgs.download_s,
size: Size.square(28), size: Size.square(28),
), ),
const HSpace(12), const HSpace(12),

View File

@ -24,6 +24,7 @@ extension FieldTypeExtension on FieldType {
FieldType.Summary => LocaleKeys.grid_field_summaryFieldName.tr(), FieldType.Summary => LocaleKeys.grid_field_summaryFieldName.tr(),
FieldType.Time => LocaleKeys.grid_field_timeFieldName.tr(), FieldType.Time => LocaleKeys.grid_field_timeFieldName.tr(),
FieldType.Translate => LocaleKeys.grid_field_translateFieldName.tr(), FieldType.Translate => LocaleKeys.grid_field_translateFieldName.tr(),
FieldType.Media => LocaleKeys.grid_field_mediaFieldName.tr(),
_ => throw UnimplementedError(), _ => throw UnimplementedError(),
}; };
@ -42,6 +43,7 @@ extension FieldTypeExtension on FieldType {
FieldType.Summary => FlowySvgs.ai_summary_s, FieldType.Summary => FlowySvgs.ai_summary_s,
FieldType.Time => FlowySvgs.timer_start_s, FieldType.Time => FlowySvgs.timer_start_s,
FieldType.Translate => FlowySvgs.ai_translate_s, FieldType.Translate => FlowySvgs.ai_translate_s,
FieldType.Media => FlowySvgs.media_s,
_ => throw UnimplementedError(), _ => throw UnimplementedError(),
}; };

View File

@ -177,7 +177,7 @@ class InteractiveImageToolbar extends StatelessWidget {
? currentImage.isLocal ? currentImage.isLocal
? FlowySvgs.folder_m ? FlowySvgs.folder_m
: FlowySvgs.m_aa_link_s : FlowySvgs.m_aa_link_s
: FlowySvgs.import_s, : FlowySvgs.download_s,
onTap: () => _locateOrDownloadImage(context), onTap: () => _locateOrDownloadImage(context),
), ),
], ],

View File

@ -1,10 +1,11 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/size.dart';
import 'package:flowy_infra_ui/style_widget/hover.dart'; import 'package:flowy_infra_ui/style_widget/hover.dart';
import 'package:flowy_infra_ui/widget/flowy_tooltip.dart'; import 'package:flowy_infra_ui/widget/flowy_tooltip.dart';
import 'package:flowy_svg/flowy_svg.dart'; import 'package:flowy_svg/flowy_svg.dart';
import 'package:flutter/material.dart';
class FlowyIconButton extends StatelessWidget { class FlowyIconButton extends StatelessWidget {
final double width; final double width;
@ -82,7 +83,6 @@ class FlowyIconButton extends StatelessWidget {
preferBelow: preferBelow, preferBelow: preferBelow,
message: tooltipMessage, message: tooltipMessage,
richMessage: richTooltipText, richMessage: richTooltipText,
showDuration: Duration.zero,
child: RawMaterialButton( child: RawMaterialButton(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
visualDensity: VisualDensity.compact, visualDensity: VisualDensity.compact,

View File

@ -8,7 +8,6 @@ class FlowyTooltip extends StatelessWidget {
this.message, this.message,
this.richMessage, this.richMessage,
this.preferBelow, this.preferBelow,
this.showDuration,
this.margin, this.margin,
this.verticalOffset, this.verticalOffset,
this.child, this.child,
@ -17,7 +16,6 @@ class FlowyTooltip extends StatelessWidget {
final String? message; final String? message;
final InlineSpan? richMessage; final InlineSpan? richMessage;
final bool? preferBelow; final bool? preferBelow;
final Duration? showDuration;
final EdgeInsetsGeometry? margin; final EdgeInsetsGeometry? margin;
final Widget? child; final Widget? child;
final double? verticalOffset; final double? verticalOffset;

View File

@ -622,10 +622,10 @@ packages:
dependency: transitive dependency: transitive
description: description:
name: flex_seed_scheme name: flex_seed_scheme
sha256: "6c595e545b0678e1fe17e8eec3d1fbca7237482da194fadc20ad8607dc7a7f3d" sha256: cb5b7ec4ba525d9846d8992858a1c6cfc88f9466d96b8850e2a061aa5f682539
url: "https://pub.dev" url: "https://pub.dev"
source: hosted source: hosted
version: "3.0.0" version: "3.1.1"
flowy_infra: flowy_infra:
dependency: "direct main" dependency: "direct main"
description: description:

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-0.5 -0.5 16 16" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" id="Download--Streamline-Lucide.svg" height="16" width="16"><desc>Download Streamline Icon: https://streamlinehq.com</desc><path d="M13.125 9.375v2.5a1.25 1.25 0 0 1 -1.25 1.25H3.125a1.25 1.25 0 0 1 -1.25 -1.25v-2.5" stroke-width="1"></path><path d="m4.375 6.25 3.125 3.125 3.125 -3.125" stroke-width="1"></path><path d="m7.5 9.375 0 -7.5" stroke-width="1"></path></svg>

After

Width:  |  Height:  |  Size: 512 B

View File

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="-0.5 -0.5 16 16" fill="none" stroke="#000000" stroke-linecap="round" stroke-linejoin="round" id="Paperclip--Streamline-Lucide.svg" height="16" width="16"><desc>Paperclip Streamline Icon: https://streamlinehq.com</desc><path d="m13.4 6.90625 -5.7437499999999995 5.7437499999999995a3.75 3.75 0 0 1 -5.30625 -5.30625l5.35625 -5.35625A2.5 2.5 0 1 1 11.25 5.525l-5.36875 5.35625a1.25 1.25 0 0 1 -1.76875 -1.76875l5.30625 -5.300000000000001" stroke-width="1"></path></svg>

After

Width:  |  Height:  |  Size: 515 B

View File

@ -1313,6 +1313,7 @@
"relationFieldName": "Relation", "relationFieldName": "Relation",
"summaryFieldName": "AI Summary", "summaryFieldName": "AI Summary",
"timeFieldName": "Time", "timeFieldName": "Time",
"mediaFieldName": "Files & media",
"translateFieldName": "AI Translate", "translateFieldName": "AI Translate",
"translateTo": "Translate to", "translateTo": "Translate to",
"numberFormat": "Number format", "numberFormat": "Number format",

View File

@ -668,6 +668,12 @@ impl<'a> TestRowBuilder<'a> {
time_field.id.clone() 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 { pub fn field_with_type(&self, field_type: &FieldType) -> Field {
self self
.fields .fields

View File

@ -451,6 +451,7 @@ pub enum FieldType {
Summary = 11, Summary = 11,
Translate = 12, Translate = 12,
Time = 13, Time = 13,
Media = 14,
} }
impl Display for FieldType { impl Display for FieldType {
@ -493,6 +494,7 @@ impl FieldType {
FieldType::Summary => "Summarize", FieldType::Summary => "Summarize",
FieldType::Translate => "Translate", FieldType::Translate => "Translate",
FieldType::Time => "Time", FieldType::Time => "Time",
FieldType::Media => "Media",
}; };
s.to_string() s.to_string()
} }
@ -553,6 +555,10 @@ impl FieldType {
matches!(self, FieldType::Time) matches!(self, FieldType::Time)
} }
pub fn is_media(&self) -> bool {
matches!(self, FieldType::Media)
}
pub fn can_be_group(&self) -> bool { pub fn can_be_group(&self) -> bool {
self.is_select_option() || self.is_checkbox() || self.is_url() self.is_select_option() || self.is_checkbox() || self.is_url()
} }

View File

@ -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<MediaFilterConditionPB> for u32 {
fn from(value: MediaFilterConditionPB) -> Self {
value as u32
}
}
impl std::convert::TryFrom<u8> for MediaFilterConditionPB {
type Error = ErrorCode;
fn try_from(value: u8) -> Result<Self, Self::Error> {
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,
}
}
}

View File

@ -2,6 +2,7 @@ mod checkbox_filter;
mod checklist_filter; mod checklist_filter;
mod date_filter; mod date_filter;
mod filter_changeset; mod filter_changeset;
mod media_filter;
mod number_filter; mod number_filter;
mod relation_filter; mod relation_filter;
mod select_option_filter; mod select_option_filter;
@ -13,6 +14,7 @@ pub use checkbox_filter::*;
pub use checklist_filter::*; pub use checklist_filter::*;
pub use date_filter::*; pub use date_filter::*;
pub use filter_changeset::*; pub use filter_changeset::*;
pub use media_filter::*;
pub use number_filter::*; pub use number_filter::*;
pub use relation_filter::*; pub use relation_filter::*;
pub use select_option_filter::*; pub use select_option_filter::*;

View File

@ -14,6 +14,8 @@ use crate::entities::{
}; };
use crate::services::filter::{Filter, FilterChangeset, FilterInner}; use crate::services::filter::{Filter, FilterChangeset, FilterInner};
use super::MediaFilterPB;
#[derive(Debug, Default, Clone, ProtoBuf_Enum, Eq, PartialEq, Copy)] #[derive(Debug, Default, Clone, ProtoBuf_Enum, Eq, PartialEq, Copy)]
#[repr(u8)] #[repr(u8)]
pub enum FilterType { pub enum FilterType {
@ -117,6 +119,10 @@ impl From<&Filter> for FilterPB {
.cloned::<TextFilterPB>() .cloned::<TextFilterPB>()
.unwrap() .unwrap()
.try_into(), .try_into(),
FieldType::Media => condition_and_content
.cloned::<MediaFilterPB>()
.unwrap()
.try_into(),
}; };
Self { Self {
@ -170,6 +176,9 @@ impl TryFrom<FilterDataPB> for FilterInner {
FieldType::Translate => { FieldType::Translate => {
BoxAny::new(TextFilterPB::try_from(bytes).map_err(|_| ErrorCode::ProtobufSerde)?) 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 { Ok(Self::Data {

View File

@ -18,6 +18,7 @@ macro_rules! impl_into_field_type {
11 => FieldType::Summary, 11 => FieldType::Summary,
12 => FieldType::Translate, 12 => FieldType::Translate,
13 => FieldType::Time, 13 => FieldType::Time,
14 => FieldType::Media,
_ => { _ => {
tracing::error!("🔴Can't parse FieldType from value: {}", ty); tracing::error!("🔴Can't parse FieldType from value: {}", ty);
FieldType::RichText FieldType::RichText

View File

@ -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<MediaFilePB>,
}
impl From<MediaCellData> for MediaCellDataPB {
fn from(data: MediaCellData) -> Self {
Self {
files: data.files.into_iter().map(Into::into).collect(),
}
}
}
impl From<MediaCellDataPB> 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<MediaFilePB>,
}
#[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<MediaUploadType> for MediaUploadTypePB {
fn from(data: MediaUploadType) -> Self {
match data {
MediaUploadType::LocalMedia => MediaUploadTypePB::LocalMedia,
MediaUploadType::NetworkMedia => MediaUploadTypePB::NetworkMedia,
MediaUploadType::CloudMedia => MediaUploadTypePB::CloudMedia,
}
}
}
impl From<MediaUploadTypePB> 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<MediaFileType> for MediaFileTypePB {
fn from(data: MediaFileType) -> Self {
match data {
MediaFileType::Other => MediaFileTypePB::Other,
MediaFileType::Image => MediaFileTypePB::Image,
}
}
}
impl From<MediaFileTypePB> for MediaFileType {
fn from(data: MediaFileTypePB) -> Self {
match data {
MediaFileTypePB::Other => MediaFileType::Other,
MediaFileTypePB::Image => MediaFileType::Image,
}
}
}
impl From<MediaFile> 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<MediaFilePB> 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<MediaFilePB>,
#[pb(index = 4)]
pub removed_ids: Vec<String>,
}
#[derive(Debug, Clone, Default)]
pub struct MediaCellChangeset {
pub inserted_files: Vec<MediaFile>,
pub removed_ids: Vec<String>,
}

View File

@ -1,6 +1,7 @@
mod checkbox_entities; mod checkbox_entities;
mod checklist_entities; mod checklist_entities;
mod date_entities; mod date_entities;
mod media_entities;
mod number_entities; mod number_entities;
mod relation_entities; mod relation_entities;
mod select_option_entities; mod select_option_entities;
@ -14,6 +15,7 @@ mod url_entities;
pub use checkbox_entities::*; pub use checkbox_entities::*;
pub use checklist_entities::*; pub use checklist_entities::*;
pub use date_entities::*; pub use date_entities::*;
pub use media_entities::*;
pub use number_entities::*; pub use number_entities::*;
pub use relation_entities::*; pub use relation_entities::*;
pub use select_option_entities::*; pub use select_option_entities::*;

View File

@ -54,9 +54,8 @@ pub struct RepeatedSelectOptionPayload {
pub items: Vec<SelectOptionPB>, pub items: Vec<SelectOptionPB>,
} }
#[derive(ProtoBuf_Enum, PartialEq, Eq, Debug, Clone)] #[derive(ProtoBuf_Enum, PartialEq, Eq, Debug, Clone, Default)]
#[repr(u8)] #[repr(u8)]
#[derive(Default)]
pub enum SelectOptionColorPB { pub enum SelectOptionColorPB {
#[default] #[default]
Purple = 0, Purple = 0,

View File

@ -1261,3 +1261,31 @@ pub(crate) async fn translate_row_handler(
rx.await??; rx.await??;
Ok(()) Ok(())
} }
#[tracing::instrument(level = "debug", skip_all, err)]
pub(crate) async fn update_media_cell_handler(
data: AFPluginData<MediaCellChangesetPB>,
manager: AFPluginState<Weak<DatabaseManager>>,
) -> 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(())
}

View File

@ -94,6 +94,8 @@ pub fn init(database_manager: Weak<DatabaseManager>) -> AFPlugin {
// AI // AI
.event(DatabaseEvent::SummarizeRow, summarize_row_handler) .event(DatabaseEvent::SummarizeRow, summarize_row_handler)
.event(DatabaseEvent::TranslateRow, translate_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) /// [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")] #[event(input = "DatabaseViewIdPB", output = "RepeatedRowMetaPB")]
GetAllRows = 177, GetAllRows = 177,
#[event(input = "MediaCellChangesetPB")]
UpdateMediaCell = 178,
} }

View File

@ -219,7 +219,7 @@ impl<'a> CellBuilder<'a> {
if let Some(field) = field_maps.get(&field_id) { if let Some(field) = field_maps.get(&field_id) {
let field_type = FieldType::from(field.field_type); let field_type = FieldType::from(field.field_type);
match field_type { match field_type {
FieldType::RichText => { FieldType::RichText | FieldType::Translate | FieldType::Summary => {
cells.insert(field_id, insert_text_cell(cell_str, field)); cells.insert(field_id, insert_text_cell(cell_str, field));
}, },
FieldType::Number | FieldType::Time => { FieldType::Number | FieldType::Time => {
@ -259,11 +259,8 @@ impl<'a> CellBuilder<'a> {
FieldType::Relation => { FieldType::Relation => {
cells.insert(field_id, (&RelationCellData::from(cell_str)).into()); cells.insert(field_id, (&RelationCellData::from(cell_str)).into());
}, },
FieldType::Summary => { FieldType::Media => {
cells.insert(field_id, insert_text_cell(cell_str, field)); cells.insert(field_id, (&MediaCellData::from(cell_str)).into());
},
FieldType::Translate => {
cells.insert(field_id, insert_text_cell(cell_str, field));
}, },
} }
} }

View File

@ -2,8 +2,8 @@ use crate::entities::FieldType;
use crate::services::field::summary_type_option::summary::SummarizationTypeOption; use crate::services::field::summary_type_option::summary::SummarizationTypeOption;
use crate::services::field::translate_type_option::translate::TranslateTypeOption; use crate::services::field::translate_type_option::translate::TranslateTypeOption;
use crate::services::field::{ use crate::services::field::{
CheckboxTypeOption, ChecklistTypeOption, DateTypeOption, MultiSelectTypeOption, NumberTypeOption, CheckboxTypeOption, ChecklistTypeOption, DateTypeOption, MediaTypeOption, MultiSelectTypeOption,
RelationTypeOption, RichTextTypeOption, SingleSelectTypeOption, TimeTypeOption, NumberTypeOption, RelationTypeOption, RichTextTypeOption, SingleSelectTypeOption, TimeTypeOption,
TimestampTypeOption, TypeOptionTransform, URLTypeOption, TimestampTypeOption, TypeOptionTransform, URLTypeOption,
}; };
use async_trait::async_trait; use async_trait::async_trait;
@ -127,5 +127,8 @@ fn get_type_option_transform_handler(
FieldType::Translate => { FieldType::Translate => {
Box::new(TranslateTypeOption::from(type_option_data)) as Box<dyn TypeOptionTransformHandler> Box::new(TranslateTypeOption::from(type_option_data)) as Box<dyn TypeOptionTransformHandler>
}, },
FieldType::Media => {
Box::new(MediaTypeOption::from(type_option_data)) as Box<dyn TypeOptionTransformHandler>
},
} }
} }

View File

@ -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,
}

View File

@ -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<T: AsRef<str>>(&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<Cell>, 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)
}
}

View File

@ -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<MediaFile>,
}
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::<MediaFile>(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::<Vec<_>>(),
));
let mut cell = new_cell_builder(FieldType::Media);
cell.insert(CELL_DATA.into(), data);
cell
}
}
impl From<String> 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::<MediaFile>(file).unwrap_or_default())
.collect::<Vec<_>>();
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::<Vec<_>>()
.join(", ")
}
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct MediaTypeOption {
#[serde(default)]
pub files: Vec<MediaFile>,
}
impl TypeOption for MediaTypeOption {
type CellData = MediaCellData;
type CellChangeset = MediaCellChangeset;
type CellProtobufType = MediaCellDataPB;
type CellFilter = MediaFilterPB;
}
impl From<TypeOptionData> for MediaTypeOption {
fn from(data: TypeOptionData) -> Self {
data
.get_as::<String>("content")
.map(|s| serde_json::from_str::<MediaTypeOption>(&s).unwrap_or_default())
.unwrap_or_default()
}
}
impl From<MediaTypeOption> 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<MediaTypeOption> for MediaTypeOptionPB {
fn from(value: MediaTypeOption) -> Self {
Self {
files: value.files.into_iter().map(Into::into).collect(),
}
}
}
impl From<MediaTypeOptionPB> 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: <Self as TypeOption>::CellData,
) -> <Self as TypeOption>::CellProtobufType {
cell_data.into()
}
fn parse_cell(&self, cell: &Cell) -> FlowyResult<<Self as TypeOption>::CellData> {
Ok(cell.into())
}
}
impl CellDataDecoder for MediaTypeOption {
fn decode_cell(&self, cell: &Cell) -> FlowyResult<<Self as TypeOption>::CellData> {
self.parse_cell(cell)
}
fn decode_cell_with_transform(
&self,
_cell: &Cell,
from_field_type: FieldType,
_field: &Field,
) -> Option<<Self as TypeOption>::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: <Self as TypeOption>::CellData) -> String {
cell_data.to_string()
}
fn numeric_cell(&self, cell: &Cell) -> Option<f64> {
StringCellData::from(cell).0.parse::<f64>().ok()
}
}
impl CellDataChangeset for MediaTypeOption {
fn apply_changeset(
&self,
changeset: <Self as TypeOption>::CellChangeset,
cell: Option<Cell>,
) -> FlowyResult<(Cell, <Self as TypeOption>::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: &<Self as TypeOption>::CellFilter,
_cell_data: &<Self as TypeOption>::CellData,
) -> bool {
true
}
}
impl TypeOptionCellDataCompare for MediaTypeOption {
fn apply_cmp(
&self,
cell_data: &<Self as TypeOption>::CellData,
other_cell_data: &<Self as TypeOption>::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(),
}
}
}

View File

@ -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::*;

View File

@ -1,6 +1,7 @@
pub mod checkbox_type_option; pub mod checkbox_type_option;
pub mod checklist_type_option; pub mod checklist_type_option;
pub mod date_type_option; pub mod date_type_option;
pub mod media_type_option;
pub mod number_type_option; pub mod number_type_option;
pub mod relation_type_option; pub mod relation_type_option;
pub mod selection_type_option; pub mod selection_type_option;
@ -17,6 +18,7 @@ mod util;
pub use checkbox_type_option::*; pub use checkbox_type_option::*;
pub use checklist_type_option::*; pub use checklist_type_option::*;
pub use date_type_option::*; pub use date_type_option::*;
pub use media_type_option::*;
pub use number_type_option::*; pub use number_type_option::*;
pub use relation_type_option::*; pub use relation_type_option::*;
pub use selection_type_option::*; pub use selection_type_option::*;

View File

@ -1,6 +1,7 @@
use crate::entities::SelectOptionCellDataPB; use crate::entities::SelectOptionCellDataPB;
use crate::services::field::SelectOptionIds; use crate::services::field::SelectOptionIds;
use collab_database::entity::SelectOption; use collab_database::entity::SelectOption;
#[derive(Debug)] #[derive(Debug)]
pub struct SelectOptionCellData { pub struct SelectOptionCellData {
pub select_options: Vec<SelectOption>, pub select_options: Vec<SelectOption>,

View File

@ -81,6 +81,7 @@ impl CellDataDecoder for RichTextTypeOption {
| FieldType::URL | FieldType::URL
| FieldType::Summary | FieldType::Summary
| FieldType::Translate | FieldType::Translate
| FieldType::Media
| FieldType::Time => Some(StringCellData::from(stringify_cell(cell, field))), | FieldType::Time => Some(StringCellData::from(stringify_cell(cell, field))),
FieldType::Checklist FieldType::Checklist
| FieldType::LastEditedTime | FieldType::LastEditedTime

View File

@ -10,7 +10,7 @@ use std::fmt::Debug;
use flowy_error::FlowyResult; use flowy_error::FlowyResult;
use crate::entities::{ use crate::entities::{
CheckboxTypeOptionPB, ChecklistTypeOptionPB, DateTypeOptionPB, FieldType, CheckboxTypeOptionPB, ChecklistTypeOptionPB, DateTypeOptionPB, FieldType, MediaTypeOptionPB,
MultiSelectTypeOptionPB, NumberTypeOptionPB, RelationTypeOptionPB, RichTextTypeOptionPB, MultiSelectTypeOptionPB, NumberTypeOptionPB, RelationTypeOptionPB, RichTextTypeOptionPB,
SingleSelectTypeOptionPB, SummarizationTypeOptionPB, TimeTypeOptionPB, TimestampTypeOptionPB, SingleSelectTypeOptionPB, SummarizationTypeOptionPB, TimeTypeOptionPB, TimestampTypeOptionPB,
TranslateTypeOptionPB, URLTypeOptionPB, 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::summary_type_option::summary::SummarizationTypeOption;
use crate::services::field::translate_type_option::translate::TranslateTypeOption; use crate::services::field::translate_type_option::translate::TranslateTypeOption;
use crate::services::field::{ use crate::services::field::{
CheckboxTypeOption, DateTypeOption, MultiSelectTypeOption, NumberTypeOption, RelationTypeOption, CheckboxTypeOption, DateTypeOption, MediaTypeOption, MultiSelectTypeOption, NumberTypeOption,
RichTextTypeOption, SingleSelectTypeOption, TimeTypeOption, TimestampTypeOption, URLTypeOption, RelationTypeOption, RichTextTypeOption, SingleSelectTypeOption, TimeTypeOption,
TimestampTypeOption, URLTypeOption,
}; };
use crate::services::filter::{ParseFilterData, PreFillCellsWithFilter}; use crate::services::filter::{ParseFilterData, PreFillCellsWithFilter};
use crate::services::sort::SortCondition; use crate::services::sort::SortCondition;
@ -197,6 +198,9 @@ pub fn type_option_data_from_pb<T: Into<Bytes>>(
FieldType::Translate => { FieldType::Translate => {
TranslateTypeOptionPB::try_from(bytes).map(|pb| TranslateTypeOption::from(pb).into()) 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() .try_into()
.unwrap() .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::Summary => SummarizationTypeOption::default().into(),
FieldType::Translate => TranslateTypeOption::default().into(), FieldType::Translate => TranslateTypeOption::default().into(),
FieldType::Time => TimeTypeOption.into(), FieldType::Time => TimeTypeOption.into(),
FieldType::Media => MediaTypeOption::default().into(),
} }
} }

View File

@ -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::summary_type_option::summary::SummarizationTypeOption;
use crate::services::field::translate_type_option::translate::TranslateTypeOption; use crate::services::field::translate_type_option::translate::TranslateTypeOption;
use crate::services::field::{ use crate::services::field::{
CheckboxTypeOption, ChecklistTypeOption, DateTypeOption, MultiSelectTypeOption, NumberTypeOption, CheckboxTypeOption, ChecklistTypeOption, DateTypeOption, MediaTypeOption, MultiSelectTypeOption,
RelationTypeOption, RichTextTypeOption, SingleSelectTypeOption, TimeTypeOption, NumberTypeOption, RelationTypeOption, RichTextTypeOption, SingleSelectTypeOption, TimeTypeOption,
TimestampTypeOption, TypeOption, TypeOptionCellData, TypeOptionCellDataCompare, TimestampTypeOption, TypeOption, TypeOptionCellData, TypeOptionCellDataCompare,
TypeOptionCellDataFilter, TypeOptionCellDataSerde, TypeOptionTransform, URLTypeOption, TypeOptionCellDataFilter, TypeOptionCellDataSerde, TypeOptionTransform, URLTypeOption,
}; };
@ -496,6 +496,16 @@ impl<'a> TypeOptionCellExt<'a> {
self.cell_data_cache.clone(), self.cell_data_cache.clone(),
) )
}), }),
FieldType::Media => self
.field
.get_type_option::<MediaTypeOption>(field_type)
.map(|type_option| {
TypeOptionCellDataHandlerImpl::new_with_boxed(
type_option,
field_type,
self.cell_data_cache.clone(),
)
}),
} }
} }

View File

@ -14,8 +14,8 @@ use tracing::error;
use crate::entities::{ use crate::entities::{
CheckboxFilterPB, ChecklistFilterPB, DateFilterContent, DateFilterPB, FieldType, FilterType, CheckboxFilterPB, ChecklistFilterPB, DateFilterContent, DateFilterPB, FieldType, FilterType,
InsertedRowPB, NumberFilterPB, RelationFilterPB, SelectOptionFilterPB, TextFilterPB, InsertedRowPB, MediaFilterPB, NumberFilterPB, RelationFilterPB, SelectOptionFilterPB,
TimeFilterPB, TextFilterPB, TimeFilterPB,
}; };
use crate::services::field::SelectOptionIds; use crate::services::field::SelectOptionIds;
@ -287,6 +287,7 @@ impl FilterInner {
FieldType::Summary => BoxAny::new(TextFilterPB::parse(condition as u8, content)), FieldType::Summary => BoxAny::new(TextFilterPB::parse(condition as u8, content)),
FieldType::Translate => 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::Time => BoxAny::new(TimeFilterPB::parse(condition as u8, content)),
FieldType::Media => BoxAny::new(MediaFilterPB::parse(condition as u8, content)),
}; };
FilterInner::Data { FilterInner::Data {
@ -388,6 +389,10 @@ impl<'a> From<&'a Filter> for FilterMap {
let filter = condition_and_content.cloned::<TextFilterPB>()?; let filter = condition_and_content.cloned::<TextFilterPB>()?;
(filter.condition as u8, filter.content) (filter.condition as u8, filter.content)
}, },
FieldType::Media => {
let filter = condition_and_content.cloned::<MediaFilterPB>()?;
(filter.condition as u8, filter.content)
},
}; };
Some((condition, content)) Some((condition, content))
}; };

View File

@ -141,7 +141,7 @@ pub fn make_test_board() -> DatabaseData {
.build(); .build();
fields.push(time_field); fields.push(time_field);
}, },
FieldType::Translate => {}, FieldType::Translate | FieldType::Media => {},
} }
} }

View File

@ -9,9 +9,9 @@ use flowy_database2::entities::FieldType;
use flowy_database2::services::field::summary_type_option::summary::SummarizationTypeOption; 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::translate_type_option::translate::TranslateTypeOption;
use flowy_database2::services::field::{ use flowy_database2::services::field::{
ChecklistTypeOption, DateFormat, DateTypeOption, FieldBuilder, MultiSelectTypeOption, ChecklistTypeOption, DateFormat, DateTypeOption, FieldBuilder, MediaTypeOption,
NumberFormat, NumberTypeOption, RelationTypeOption, SingleSelectTypeOption, TimeFormat, MultiSelectTypeOption, NumberFormat, NumberTypeOption, RelationTypeOption,
TimeTypeOption, TimestampTypeOption, SingleSelectTypeOption, TimeFormat, TimeTypeOption, TimestampTypeOption,
}; };
use flowy_database2::services::field_settings::default_field_settings_for_fields; use flowy_database2::services::field_settings::default_field_settings_for_fields;
@ -151,6 +151,14 @@ pub fn make_test_grid() -> DatabaseData {
.build(); .build();
fields.push(translate_field); 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);
},
} }
} }

View File

@ -76,15 +76,16 @@ async fn export_and_then_import_meta_csv_test() {
assert_eq!(s, "Google,Facebook"); assert_eq!(s, "Google,Facebook");
} }
}, },
FieldType::Checkbox => {}, FieldType::Checkbox
FieldType::URL => {}, | FieldType::URL
FieldType::Checklist => {}, | FieldType::Checklist
FieldType::LastEditedTime => {}, | FieldType::LastEditedTime
FieldType::CreatedTime => {}, | FieldType::CreatedTime
FieldType::Relation => {}, | FieldType::Relation
FieldType::Summary => {}, | FieldType::Summary
FieldType::Time => {}, | FieldType::Time
FieldType::Translate => {}, | FieldType::Translate
| FieldType::Media => {},
} }
} else { } else {
panic!( panic!(
@ -163,13 +164,14 @@ async fn history_database_import_test() {
assert_eq!(s, "AppFlowy website - https://www.appflowy.io"); assert_eq!(s, "AppFlowy website - https://www.appflowy.io");
} }
}, },
FieldType::Checklist => {}, FieldType::Checklist
FieldType::LastEditedTime => {}, | FieldType::LastEditedTime
FieldType::CreatedTime => {}, | FieldType::CreatedTime
FieldType::Relation => {}, | FieldType::Relation
FieldType::Summary => {}, | FieldType::Summary
FieldType::Time => {}, | FieldType::Time
FieldType::Translate => {}, | FieldType::Translate
| FieldType::Media => {},
} }
} else { } else {
panic!( panic!(