diff --git a/frontend/appflowy_flutter/assets/images/Local Disk (C) - Shortcut.lnk b/frontend/appflowy_flutter/assets/images/Local Disk (C) - Shortcut.lnk deleted file mode 100644 index 52816016db..0000000000 Binary files a/frontend/appflowy_flutter/assets/images/Local Disk (C) - Shortcut.lnk and /dev/null differ diff --git a/frontend/appflowy_flutter/assets/images/folder.svg b/frontend/appflowy_flutter/assets/images/folder.svg new file mode 100644 index 0000000000..9c4d0dddb0 --- /dev/null +++ b/frontend/appflowy_flutter/assets/images/folder.svg @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/frontend/appflowy_flutter/assets/translations/en.json b/frontend/appflowy_flutter/assets/translations/en.json index fdd0531526..f19519d68c 100644 --- a/frontend/appflowy_flutter/assets/translations/en.json +++ b/frontend/appflowy_flutter/assets/translations/en.json @@ -362,7 +362,20 @@ "images": "Images", "abstract": "Abstract", "addCover": "Add Cover", - "addLocalImage": "Add local image" + "addLocalImage": "Add local image", + "invalidImageUrl": "Invalid image URL", + "failedToAddImageToGallery": "Failed to add image to gallery", + "enterImageUrl": "Enter image URL", + "add": "Add", + "back": "Back", + "saveToGallery": "Save to gallery", + "removeIcon": "Remove Icon", + "pasteImageUrl": "Paste image URL", + "or": "OR", + "pickFromFiles": "Pick from files", + "couldNotFetchImage": "Could not fetch image", + "imageSavingFailed": "Image Saving Failed", + "addIcon": "Add Icon" } } }, diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/cover/change_cover_popover.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/cover/change_cover_popover.dart index d7e9c45ef8..9aa338b0bc 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/cover/change_cover_popover.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/cover/change_cover_popover.dart @@ -2,21 +2,17 @@ import 'dart:io'; import 'dart:ui'; import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/plugins/cover/cover_image_picker.dart'; import 'package:appflowy/plugins/document/presentation/plugins/cover/cover_node_widget.dart'; -import 'package:appflowy/startup/startup.dart'; -import 'package:appflowy/util/file_picker/file_picker_service.dart'; -import 'package:appflowy/workspace/application/settings/settings_location_cubit.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart'; import 'package:easy_localization/easy_localization.dart'; -import 'package:file_picker/file_picker.dart' show FileType; import 'package:flowy_infra/size.dart'; import 'package:flowy_infra/theme_extension.dart'; import 'package:flowy_infra_ui/style_widget/icon_button.dart'; import 'package:flowy_infra_ui/style_widget/text.dart'; import 'package:flutter/material.dart'; import 'package:shared_preferences/shared_preferences.dart'; -import 'package:path/path.dart' as path; const String kLocalImagesKey = 'local_images'; @@ -76,6 +72,7 @@ class CoverColorPicker extends StatefulWidget { class _ChangeCoverPopoverState extends State { late Future>? fileImages; + bool isAddingImage = false; @override void initState() { @@ -88,26 +85,40 @@ class _ChangeCoverPopoverState extends State { return Padding( padding: const EdgeInsets.all(15), child: SingleChildScrollView( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - FlowyText.semibold(LocaleKeys.document_plugins_cover_colors.tr()), - const SizedBox(height: 10), - _buildColorPickerList(), - const SizedBox(height: 10), - FlowyText.semibold(LocaleKeys.document_plugins_cover_images.tr()), - const SizedBox(height: 10), - _buildFileImagePicker(), - const SizedBox(height: 10), - FlowyText.semibold(LocaleKeys.document_plugins_cover_abstract.tr()), - const SizedBox(height: 10), - _buildAbstractImagePicker(), - ], - ), + child: isAddingImage + ? CoverImagePicker( + onBackPressed: () => setState(() { + isAddingImage = false; + }), + onFileSubmit: (List path) { + setState(() { + isAddingImage = false; + }); + }) + : _buildCoverSelection(), ), ); } + Widget _buildCoverSelection() { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + FlowyText.semibold(LocaleKeys.document_plugins_cover_colors.tr()), + const SizedBox(height: 10), + _buildColorPickerList(), + const SizedBox(height: 10), + FlowyText.semibold(LocaleKeys.document_plugins_cover_images.tr()), + const SizedBox(height: 10), + _buildFileImagePicker(), + const SizedBox(height: 10), + FlowyText.semibold(LocaleKeys.document_plugins_cover_abstract.tr()), + const SizedBox(height: 10), + _buildAbstractImagePicker(), + ], + ); + } + Widget _buildAbstractImagePicker() { return GridView.builder( shrinkWrap: true, @@ -197,7 +208,9 @@ class _ChangeCoverPopoverState extends State { ), width: 20, onPressed: () { - _pickImages(); + setState(() { + isAddingImage = true; + }); }, ), ); @@ -249,36 +262,6 @@ class _ChangeCoverPopoverState extends State { prefs.setStringList(kLocalImagesKey, imageNames); return imageNames; } - - Future _pickImages() async { - SharedPreferences prefs = await SharedPreferences.getInstance(); - List imageNames = prefs.getStringList(kLocalImagesKey) ?? []; - FilePickerResult? result = await getIt().pickFiles( - dialogTitle: LocaleKeys.document_plugins_cover_addLocalImage.tr(), - allowMultiple: false, - type: FileType.image, - allowedExtensions: ['jpg', 'png', 'jpeg'], - ); - if (result != null && result.files.isNotEmpty) { - final path = result.files.first.path; - if (path != null) { - final directory = await _coverPath(); - final newPath = await File(path).copy( - '$directory/${path.split(path).last}}', - ); - imageNames.add(newPath.path); - } - } - await prefs.setStringList(kLocalImagesKey, imageNames); - setState(() {}); - } - - Future _coverPath() async { - final directory = await getIt().fetchLocation(); - return Directory(path.join(directory, 'covers')) - .create(recursive: true) - .then((value) => value.path); - } } class _CoverColorPickerState extends State { diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/cover/cover_image_picker.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/cover/cover_image_picker.dart new file mode 100644 index 0000000000..32f402cb83 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/cover/cover_image_picker.dart @@ -0,0 +1,254 @@ +import 'dart:io'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/plugins/document/presentation/plugins/cover/cover_image_picker_bloc.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra_ui/style_widget/snap_bar.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; + +import 'package:flowy_infra/image.dart'; +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flowy_infra_ui/style_widget/text_field.dart'; +import 'package:flowy_infra_ui/widget/rounded_button.dart'; +import 'package:flutter/material.dart'; + +class CoverImagePicker extends StatefulWidget { + final VoidCallback onBackPressed; + final Function(List paths) onFileSubmit; + + const CoverImagePicker( + {super.key, required this.onBackPressed, required this.onFileSubmit}); + + @override + State createState() => _CoverImagePickerState(); +} + +class _CoverImagePickerState extends State { + TextEditingController urlController = TextEditingController(); + bool get buttonDisabled => urlController.text.isEmpty; + + @override + void initState() { + super.initState(); + urlController.addListener(() { + setState(() {}); + }); + } + + _buildFilePickerWidget(BuildContext ctx) { + return Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + svgWidget( + "editor/add", + size: const Size(20, 20), + ), + const SizedBox( + width: 3, + ), + FlowyText( + LocaleKeys.document_plugins_cover_pasteImageUrl.tr(), + ), + ], + ), + const SizedBox( + height: 10, + ), + FlowyText( + LocaleKeys.document_plugins_cover_or.tr(), + color: Colors.grey, + ), + const SizedBox( + height: 10, + ), + FlowyButton( + onTap: () { + ctx.read().add(const PickFileImage()); + }, + useIntrinsicWidth: true, + leftIcon: svgWidget( + "file_icon", + size: const Size(25, 25), + ), + text: FlowyText( + LocaleKeys.document_plugins_cover_pickFromFiles.tr(), + ), + ), + ], + ); + } + + _buildImageDeleteButton(BuildContext ctx) { + return Positioned( + right: 10, + top: 10, + child: InkWell( + onTap: () { + ctx.read().add(const DeleteImage()); + }, + child: Container( + decoration: BoxDecoration( + shape: BoxShape.circle, + color: Theme.of(context).colorScheme.onPrimary), + child: svgWidget( + "editor/close", + size: const Size(20, 20), + ), + ), + ), + ); + } + + @override + Widget build(BuildContext context) { + return BlocProvider( + create: (context) => CoverImagePickerBloc() + ..add(const CoverImagePickerEvent.initialEvent()), + child: BlocListener( + listener: (context, state) { + if (state is NetworkImagePicked) { + state.successOrFail.isRight() + ? showSnapBar(context, + LocaleKeys.document_plugins_cover_invalidImageUrl.tr()) + : null; + } + if (state is Done) { + state.successOrFail.fold( + (l) => widget.onFileSubmit(l), + (r) => showSnapBar( + context, + LocaleKeys.document_plugins_cover_failedToAddImageToGallery + .tr())); + } + }, + child: BlocBuilder( + builder: (context, state) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + state is Loading + ? const SizedBox( + height: 180, + child: Center( + child: CircularProgressIndicator(), + ), + ) + : Stack( + children: [ + Container( + height: 180, + alignment: Alignment.center, + decoration: BoxDecoration( + color: + Theme.of(context).colorScheme.secondary, + borderRadius: Corners.s6Border, + image: state is Initial + ? null + : state is NetworkImagePicked + ? state.successOrFail.fold( + (path) => DecorationImage( + image: NetworkImage(path), + fit: BoxFit.cover), + (r) => null) + : state is FileImagePicked + ? DecorationImage( + image: FileImage( + File(state.path)), + fit: BoxFit.cover) + : null), + child: (state is Initial) + ? _buildFilePickerWidget(context) + : (state is NetworkImagePicked) + ? state.successOrFail.fold( + (l) => null, + (r) => _buildFilePickerWidget( + context, + ), + ) + : null), + (state is FileImagePicked) + ? _buildImageDeleteButton(context) + : (state is NetworkImagePicked) + ? state.successOrFail.fold( + (l) => _buildImageDeleteButton(context), + (r) => Container()) + : Container() + ], + ), + const SizedBox( + height: 10, + ), + Row( + children: [ + Expanded( + flex: 4, + child: FlowyTextField( + controller: urlController, + hintText: LocaleKeys + .document_plugins_cover_enterImageUrl + .tr(), + ), + ), + const SizedBox( + width: 5, + ), + Expanded( + flex: 1, + child: RoundedTextButton( + onPressed: () { + urlController.text.isNotEmpty + ? context + .read() + .add(UrlSubmit(urlController.text)) + : null; + }, + hoverColor: Colors.transparent, + fillColor: buttonDisabled + ? Colors.grey + : Theme.of(context).colorScheme.primary, + height: 36, + title: LocaleKeys.document_plugins_cover_add.tr(), + borderRadius: Corners.s8Border, + ), + ) + ], + ), + const SizedBox( + height: 10, + ), + Row( + mainAxisAlignment: MainAxisAlignment.end, + children: [ + FlowyTextButton( + LocaleKeys.document_plugins_cover_back.tr(), + hoverColor: Colors.transparent, + fillColor: Colors.transparent, + mainAxisAlignment: MainAxisAlignment.end, + onPressed: () => widget.onBackPressed(), + ), + FlowyTextButton( + LocaleKeys.document_plugins_cover_saveToGallery.tr(), + onPressed: () async { + context + .read() + .add(SaveToGallery(state)); + }, + hoverColor: Colors.transparent, + fillColor: Colors.transparent, + mainAxisAlignment: MainAxisAlignment.end, + fontColor: Theme.of(context).colorScheme.primary, + ), + ], + ) + ], + ); + }, + ), + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/cover/cover_image_picker_bloc.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/cover/cover_image_picker_bloc.dart new file mode 100644 index 0000000000..2389b3681c --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/cover/cover_image_picker_bloc.dart @@ -0,0 +1,196 @@ +import 'dart:io'; +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/startup/startup.dart'; +import 'package:appflowy/util/file_picker/file_picker_service.dart'; +import 'package:appflowy/workspace/application/settings/settings_location_cubit.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:file_picker/file_picker.dart' as fp; + +import 'package:appflowy_backend/protobuf/flowy-error/errors.pb.dart'; +import 'package:freezed_annotation/freezed_annotation.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:dartz/dartz.dart'; +import 'package:http/http.dart' as http; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:path/path.dart' as path; +import 'change_cover_popover.dart'; + +part 'cover_image_picker_bloc.freezed.dart'; + +class CoverImagePickerBloc + extends Bloc { + CoverImagePickerBloc() : super(const CoverImagePickerState.initial()) { + on( + (event, emit) async { + await event.map( + initialEvent: (InitialEvent initialEvent) { + emit(const CoverImagePickerState.initial()); + }, + urlSubmit: (UrlSubmit urlSubmit) async { + emit(const CoverImagePickerState.loading()); + final validateImage = await _validateUrl(urlSubmit.path); + if (validateImage) { + emit(CoverImagePickerState.networkImage(left(urlSubmit.path))); + } else { + emit( + CoverImagePickerState.networkImage( + right( + FlowyError( + msg: LocaleKeys.document_plugins_cover_couldNotFetchImage + .tr(), + ), + ), + ), + ); + } + }, + pickFileImage: (PickFileImage pickFileImage) async { + final imagePickerResults = await _pickImages(); + if (imagePickerResults != null) { + emit(CoverImagePickerState.fileImage(imagePickerResults)); + } else { + emit(const CoverImagePickerState.initial()); + } + }, + deleteImage: (DeleteImage deleteImage) { + emit(const CoverImagePickerState.initial()); + }, + saveToGallery: (SaveToGallery saveToGallery) async { + emit(const CoverImagePickerState.loading()); + final saveImage = await _saveToGallery(saveToGallery.previousState); + if (saveImage != null) { + emit(CoverImagePickerState.done(left(saveImage))); + } else { + emit( + CoverImagePickerState.done( + right( + FlowyError( + msg: LocaleKeys.document_plugins_cover_imageSavingFailed + .tr()), + ), + ), + ); + emit(const CoverImagePickerState.initial()); + } + }, + ); + }, + ); + } + + _saveToGallery(CoverImagePickerState state) async { + SharedPreferences prefs = await SharedPreferences.getInstance(); + List imagePaths = prefs.getStringList(kLocalImagesKey) ?? []; + final directory = await _coverPath(); + + if (state is FileImagePicked) { + try { + final path = state.path; + final newPath = '$directory/${path.split("\\").last}'; + final newFile = await File(path).copy(newPath); + imagePaths.add(newFile.path); + await prefs.setStringList(kLocalImagesKey, imagePaths); + return imagePaths; + } catch (e) { + return null; + } + } else if (state is NetworkImagePicked) { + try { + String? url = state.successOrFail.fold((path) => path, (r) => null); + if (url != null) { + final response = await http.get(Uri.parse(url)); + final newPath = + "$directory/IMG_$_timeStampString.${_getExtention(url)}"; + + final imageFile = File(newPath); + await imageFile.create(); + await imageFile.writeAsBytes(response.bodyBytes); + imagePaths.add(imageFile.absolute.path); + await prefs.setStringList(kLocalImagesKey, imagePaths); + return imagePaths; + } else { + return null; + } + } catch (e) { + return null; + } + } + } + + _pickImages() async { + FilePickerResult? result = await getIt().pickFiles( + dialogTitle: LocaleKeys.document_plugins_cover_addLocalImage.tr(), + allowMultiple: false, + type: fp.FileType.image, + allowedExtensions: ['jpg', 'png', 'jpeg'], + ); + if (result != null && result.files.isNotEmpty) { + final path = result.files.first.path; + if (path != null) { + return path; + } else { + return null; + } + } + return null; + } + + Future _coverPath() async { + final directory = await getIt().fetchLocation(); + return Directory(path.join(directory, 'covers')) + .create(recursive: true) + .then((value) => value.path); + } + + String get _timeStampString => + DateTime.now().millisecondsSinceEpoch.toString(); + + String? _getExtention(String path) => path.contains(".jpg") + ? "jpg" + : path.contains(".png") + ? "png" + : path.contains(".jpeg") + ? "jpeg" + : (path.contains("auto=format") && path.contains("unsplash")) + ? "jpeg" + : null; + + _validateUrl(String path) async { + if (_getExtention(path) != null) { + try { + final response = await http.get(Uri.parse(path)); + if (response.statusCode == 200) { + return true; + } else { + return false; + } + } catch (e) { + return false; + } + } else { + return false; + } + } +} + +@freezed +class CoverImagePickerEvent with _$CoverImagePickerEvent { + const factory CoverImagePickerEvent.urlSubmit(String path) = UrlSubmit; + const factory CoverImagePickerEvent.pickFileImage() = PickFileImage; + const factory CoverImagePickerEvent.deleteImage() = DeleteImage; + const factory CoverImagePickerEvent.saveToGallery( + CoverImagePickerState previousState) = SaveToGallery; + const factory CoverImagePickerEvent.initialEvent() = InitialEvent; +} + +@freezed +class CoverImagePickerState with _$CoverImagePickerState { + const factory CoverImagePickerState.initial() = Initial; + const factory CoverImagePickerState.loading() = Loading; + const factory CoverImagePickerState.networkImage( + Either successOrFail) = NetworkImagePicked; + const factory CoverImagePickerState.fileImage(String path) = FileImagePicked; + + const factory CoverImagePickerState.done( + Either, FlowyError> successOrFail) = Done; +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/cover/cover_node_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/cover/cover_node_widget.dart index 452b3356ab..67b083a774 100644 --- a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/cover/cover_node_widget.dart +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/cover/cover_node_widget.dart @@ -2,6 +2,9 @@ import 'dart:io'; import 'package:appflowy/generated/locale_keys.g.dart'; import 'package:appflowy/plugins/document/presentation/plugins/cover/change_cover_popover.dart'; +import 'package:appflowy/plugins/document/presentation/plugins/cover/emoji_popover.dart'; +import 'package:appflowy/plugins/document/presentation/plugins/cover/icon_widget.dart'; +import 'package:appflowy/workspace/presentation/widgets/emoji_picker/emoji_picker.dart'; import 'package:appflowy_editor/appflowy_editor.dart'; import 'package:appflowy_popover/appflowy_popover.dart'; import 'package:easy_localization/easy_localization.dart'; @@ -14,10 +17,10 @@ import 'package:flutter/material.dart'; const String kCoverType = 'cover'; const String kCoverSelectionTypeAttribute = 'cover_selection_type'; const String kCoverSelectionAttribute = 'cover_selection'; +const String kIconSelectionAttribute = 'selected_icon'; enum CoverSelectionType { initial, - color, file, asset; @@ -68,23 +71,16 @@ class _CoverImageNodeWidgetState extends State<_CoverImageNodeWidget> { widget.node.attributes[kCoverSelectionTypeAttribute], ); + PopoverController iconPopoverController = PopoverController(); @override Widget build(BuildContext context) { - if (selectionType == CoverSelectionType.initial) { - return _AddCoverButton( - onTap: () { - _insertCover(CoverSelectionType.asset, builtInAssetImages.first); - }, - ); - } else { - return _CoverImage( - editorState: widget.editorState, - node: widget.node, - onCoverChanged: (type, value) { - _insertCover(type, value); - }, - ); - } + return _CoverImage( + editorState: widget.editorState, + node: widget.node, + onCoverChanged: (type, value) { + _insertCover(type, value); + }, + ); } Future _insertCover(CoverSelectionType type, dynamic cover) async { @@ -92,14 +88,26 @@ class _CoverImageNodeWidgetState extends State<_CoverImageNodeWidget> { transaction.updateNode(widget.node, { kCoverSelectionTypeAttribute: type.toString(), kCoverSelectionAttribute: cover, + kIconSelectionAttribute: widget.node.attributes[kIconSelectionAttribute] }); return widget.editorState.apply(transaction); } } class _AddCoverButton extends StatefulWidget { + final Node node; + final EditorState editorState; + final bool hasIcon; + final CoverSelectionType selectionType; + + final PopoverController iconPopoverController; const _AddCoverButton({ required this.onTap, + required this.node, + required this.editorState, + required this.hasIcon, + required this.selectionType, + required this.iconPopoverController, }); final VoidCallback onTap; @@ -108,8 +116,16 @@ class _AddCoverButton extends StatefulWidget { State<_AddCoverButton> createState() => _AddCoverButtonState(); } +bool isPopoverOpen = false; + class _AddCoverButtonState extends State<_AddCoverButton> { bool isHidden = true; + PopoverMutex mutex = PopoverMutex(); + bool isPopoverOpen = false; + @override + void initState() { + super.initState(); + } @override Widget build(BuildContext context) { @@ -118,40 +134,118 @@ class _AddCoverButtonState extends State<_AddCoverButton> { setHidden(false); }, onExit: (event) { - setHidden(true); + setHidden(isPopoverOpen ? false : true); }, + opaque: false, child: Container( - height: 50.0, + height: widget.hasIcon ? 180 : 50.0, + alignment: Alignment.bottomLeft, width: double.infinity, padding: const EdgeInsets.only(top: 20, bottom: 5), - // color: Colors.red, child: isHidden - ? const SizedBox() + ? Container() : Row( mainAxisSize: MainAxisSize.min, mainAxisAlignment: MainAxisAlignment.start, children: [ // Add Cover Button. - FlowyButton( - leftIconSize: const Size.square(18), - onTap: widget.onTap, - useIntrinsicWidth: true, - leftIcon: svgWidget( - 'editor/image', - color: Theme.of(context).colorScheme.onSurface, - ), - text: FlowyText.regular( - LocaleKeys.document_plugins_cover_addCover.tr(), - ), - ) + widget.selectionType != CoverSelectionType.initial + ? Container() + : FlowyButton( + key: UniqueKey(), + leftIconSize: const Size.square(18), + onTap: widget.onTap, + useIntrinsicWidth: true, + leftIcon: svgWidget( + 'editor/image', + color: Theme.of(context).colorScheme.onSurface, + ), + text: FlowyText.regular( + LocaleKeys.document_plugins_cover_addCover.tr(), + ), + ), // Add Icon Button. - // ... + widget.hasIcon + ? FlowyButton( + leftIconSize: const Size.square(18), + onTap: () { + _removeIcon(); + }, + useIntrinsicWidth: true, + leftIcon: Icon( + Icons.emoji_emotions_outlined, + color: Theme.of(context).colorScheme.onSurface, + size: 18, + ), + text: FlowyText.regular(LocaleKeys + .document_plugins_cover_removeIcon + .tr()), + ) + : AppFlowyPopover( + mutex: mutex, + asBarrier: true, + onClose: () { + isPopoverOpen = false; + setHidden(true); + }, + offset: const Offset(120, 10), + controller: widget.iconPopoverController, + direction: PopoverDirection.bottomWithCenterAligned, + constraints: + BoxConstraints.loose(const Size(320, 380)), + margin: EdgeInsets.zero, + child: FlowyButton( + leftIconSize: const Size.square(18), + useIntrinsicWidth: true, + leftIcon: Icon(Icons.emoji_emotions_outlined, + color: Theme.of(context).colorScheme.onSurface, + size: 18), + text: FlowyText.regular( + LocaleKeys.document_plugins_cover_addIcon.tr()), + ), + popupBuilder: (BuildContext popoverContext) { + isPopoverOpen = true; + return EmojiPopover( + showRemoveButton: widget.hasIcon, + removeIcon: _removeIcon, + node: widget.node, + editorState: widget.editorState, + onEmojiChanged: (Emoji emoji) { + _insertIcon(emoji); + widget.iconPopoverController.close(); + }); + }, + ) ], ), ), ); } + Future _insertIcon(Emoji emoji) async { + final transaction = widget.editorState.transaction; + transaction.updateNode(widget.node, { + kCoverSelectionTypeAttribute: + widget.node.attributes[kCoverSelectionTypeAttribute], + kCoverSelectionAttribute: + widget.node.attributes[kCoverSelectionAttribute], + kIconSelectionAttribute: emoji.emoji, + }); + return widget.editorState.apply(transaction); + } + + Future _removeIcon() async { + final transaction = widget.editorState.transaction; + transaction.updateNode(widget.node, { + kIconSelectionAttribute: "", + kCoverSelectionTypeAttribute: + widget.node.attributes[kCoverSelectionTypeAttribute], + kCoverSelectionAttribute: + widget.node.attributes[kCoverSelectionAttribute], + }); + return widget.editorState.apply(transaction); + } + void setHidden(bool value) { if (isHidden == value) return; setState(() { @@ -173,7 +267,6 @@ class _CoverImage extends StatefulWidget { CoverSelectionType selectionType, dynamic selection, ) onCoverChanged; - @override State<_CoverImage> createState() => _CoverImageState(); } @@ -187,23 +280,112 @@ class _CoverImageState extends State<_CoverImage> { Color get color => Color(int.tryParse(widget.node.attributes[kCoverSelectionAttribute]) ?? 0xFFFFFFFF); - + bool get hasIcon => widget.node.attributes[kIconSelectionAttribute] == null + ? false + : widget.node.attributes[kIconSelectionAttribute].isNotEmpty; bool isOverlayButtonsHidden = true; + PopoverController iconPopoverController = PopoverController(); + bool get hasCover => + selectionType == CoverSelectionType.initial ? false : true; @override Widget build(BuildContext context) { return Stack( + alignment: Alignment.bottomLeft, children: [ - _buildCoverImage(context, widget.editorState), - _buildCoverOverlayButtons(context), + Container( + alignment: Alignment.topCenter, + height: !hasCover + ? 0 + : hasIcon + ? 320 + : 280, + child: _buildCoverImage(context, widget.editorState), + ), + hasIcon + ? Positioned( + bottom: !hasCover ? 30 : 10, + child: AppFlowyPopover( + offset: const Offset(100, 0), + controller: iconPopoverController, + direction: PopoverDirection.bottomWithCenterAligned, + constraints: BoxConstraints.loose(const Size(320, 380)), + margin: EdgeInsets.zero, + child: EmojiIconWidget( + emoji: widget.node.attributes[kIconSelectionAttribute], + onEmojiTapped: () { + iconPopoverController.show(); + }, + ), + popupBuilder: (BuildContext popoverContext) { + return EmojiPopover( + node: widget.node, + showRemoveButton: hasIcon, + removeIcon: _removeIcon, + editorState: widget.editorState, + onEmojiChanged: (Emoji emoji) { + _insertIcon(emoji); + iconPopoverController.close(); + }); + }, + ), + ) + : Container(), + hasIcon && selectionType != CoverSelectionType.initial + ? Container() + : _AddCoverButton( + onTap: () { + _insertCover( + CoverSelectionType.asset, builtInAssetImages.first); + }, + node: widget.node, + editorState: widget.editorState, + hasIcon: hasIcon, + selectionType: selectionType, + iconPopoverController: iconPopoverController, + ), ], ); } + Future _insertCover(CoverSelectionType type, dynamic cover) async { + final transaction = widget.editorState.transaction; + transaction.updateNode(widget.node, { + kCoverSelectionTypeAttribute: type.toString(), + kCoverSelectionAttribute: cover, + kIconSelectionAttribute: widget.node.attributes[kIconSelectionAttribute] + }); + return widget.editorState.apply(transaction); + } + + Future _insertIcon(Emoji emoji) async { + final transaction = widget.editorState.transaction; + transaction.updateNode(widget.node, { + kCoverSelectionTypeAttribute: + widget.node.attributes[kCoverSelectionTypeAttribute], + kCoverSelectionAttribute: + widget.node.attributes[kCoverSelectionAttribute], + kIconSelectionAttribute: emoji.emoji, + }); + return widget.editorState.apply(transaction); + } + + Future _removeIcon() async { + final transaction = widget.editorState.transaction; + transaction.updateNode(widget.node, { + kIconSelectionAttribute: "", + kCoverSelectionTypeAttribute: + widget.node.attributes[kCoverSelectionTypeAttribute], + kCoverSelectionAttribute: + widget.node.attributes[kCoverSelectionAttribute], + }); + return widget.editorState.apply(transaction); + } + Widget _buildCoverOverlayButtons(BuildContext context) { return Positioned( - bottom: 22, - right: 12, + bottom: 20, + right: 260, child: Row( mainAxisSize: MainAxisSize.min, children: [ @@ -253,7 +435,7 @@ class _CoverImageState extends State<_CoverImage> { Widget _buildCoverImage(BuildContext context, EditorState editorState) { final screenSize = MediaQuery.of(context).size; - const height = 200.0; + const height = 250.0; final Widget coverImage; switch (selectionType) { case CoverSelectionType.file: @@ -278,7 +460,7 @@ class _CoverImageState extends State<_CoverImage> { ); break; case CoverSelectionType.initial: - coverImage = const SizedBox(); // just an empty sizebox + coverImage = const SizedBox(); break; } //OverflowBox needs to be wraped by a widget with constraints(or from its parent) first,otherwise it will occur an erorr @@ -286,11 +468,16 @@ class _CoverImageState extends State<_CoverImage> { height: height, child: OverflowBox( maxWidth: screenSize.width, - child: Container( - padding: const EdgeInsets.only(bottom: 10), - height: double.infinity, - width: double.infinity, - child: coverImage, + child: Stack( + children: [ + Container( + padding: const EdgeInsets.only(bottom: 10), + height: double.infinity, + width: double.infinity, + child: coverImage, + ), + hasCover ? _buildCoverOverlayButtons(context) : const SizedBox() + ], ), ), ); diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/cover/emoji_popover.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/cover/emoji_popover.dart new file mode 100644 index 0000000000..9e4320d950 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/cover/emoji_popover.dart @@ -0,0 +1,92 @@ +import 'package:appflowy/generated/locale_keys.g.dart'; +import 'package:appflowy/workspace/presentation/widgets/emoji_picker/emoji_picker.dart'; +import 'package:appflowy/workspace/presentation/widgets/emoji_picker/src/default_emoji_picker_view.dart'; +import 'package:appflowy/workspace/presentation/widgets/emoji_picker/src/emoji_view_state.dart'; +import 'package:appflowy_editor/appflowy_editor.dart'; +import 'package:easy_localization/easy_localization.dart'; +import 'package:flowy_infra/image.dart'; +import 'package:flowy_infra_ui/style_widget/button.dart'; +import 'package:flowy_infra_ui/style_widget/text.dart'; +import 'package:flutter/material.dart'; + +class EmojiPopover extends StatefulWidget { + final EditorState editorState; + final Node node; + final void Function(Emoji emoji) onEmojiChanged; + final VoidCallback removeIcon; + final bool showRemoveButton; + + const EmojiPopover({ + super.key, + required this.editorState, + required this.node, + required this.onEmojiChanged, + required this.removeIcon, + required this.showRemoveButton, + }); + + @override + State createState() => _EmojiPopoverState(); +} + +class _EmojiPopoverState extends State { + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.all(15), + child: EmojiPicker( + onEmojiSelected: (category, emoji) { + widget.onEmojiChanged(emoji); + }, + customWidget: (Config config, EmojiViewState state) { + return Stack( + alignment: Alignment.topRight, + children: [ + Container( + padding: EdgeInsets.only(top: widget.showRemoveButton ? 25 : 0), + child: DefaultEmojiPickerView(config, state), + ), + _buildDeleteButtonIfNeed(), + ], + ); + }, + config: const Config( + columns: 8, + emojiSizeMax: 28, + bgColor: Colors.transparent, + iconColor: Colors.grey, + iconColorSelected: Color(0xff333333), + indicatorColor: Color(0xff333333), + progressIndicatorColor: Color(0xff333333), + buttonMode: ButtonMode.CUPERTINO, + initCategory: Category.RECENT, + ), + ), + ); + } + + Widget _buildDeleteButtonIfNeed() { + if (!widget.showRemoveButton) { + return const SizedBox(); + } + return FlowyButton( + onTap: () => widget.removeIcon(), + useIntrinsicWidth: true, + hoverColor: Theme.of(context).colorScheme.onPrimary, + text: Row( + mainAxisSize: MainAxisSize.min, + mainAxisAlignment: MainAxisAlignment.end, + children: [ + svgWidget("editor/delete"), + const SizedBox( + width: 5, + ), + FlowyText( + LocaleKeys.document_plugins_cover_removeIcon.tr(), + color: Colors.grey, + ), + ], + ), + ); + } +} diff --git a/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/cover/icon_widget.dart b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/cover/icon_widget.dart new file mode 100644 index 0000000000..107cc945c4 --- /dev/null +++ b/frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/cover/icon_widget.dart @@ -0,0 +1,61 @@ +import 'package:flowy_infra/size.dart'; +import 'package:flowy_infra_ui/flowy_infra_ui.dart'; +import 'package:flutter/material.dart'; + +class EmojiIconWidget extends StatefulWidget { + final String? emoji; + final void Function() onEmojiTapped; + + const EmojiIconWidget({ + super.key, + required this.emoji, + required this.onEmojiTapped, + }); + + @override + State createState() => _EmojiIconWidgetState(); +} + +class _EmojiIconWidgetState extends State { + bool hover = true; + + @override + Widget build(BuildContext context) { + return MouseRegion( + onEnter: (event) { + setHidden(false); + }, + onExit: (event) { + setHidden(true); + }, + child: Container( + height: 130, + width: 130, + margin: const EdgeInsets.only(top: 18), + decoration: BoxDecoration( + color: !hover + ? Theme.of(context).colorScheme.secondary + : Colors.transparent, + borderRadius: Corners.s8Border, + ), + alignment: Alignment.center, + child: Stack( + clipBehavior: Clip.none, + children: [ + FlowyText( + widget.emoji.toString(), + fontSize: 80, + ), + ], + ), + ), + ); + } + + void setHidden(bool value) { + if (hover == value) return; + setState(() { + hover = value; + }); + } +} diff --git a/frontend/appflowy_flutter/pubspec.yaml b/frontend/appflowy_flutter/pubspec.yaml index b96af94cd0..b79bcc5fbe 100644 --- a/frontend/appflowy_flutter/pubspec.yaml +++ b/frontend/appflowy_flutter/pubspec.yaml @@ -89,6 +89,7 @@ dependencies: google_fonts: ^3.0.1 file_picker: <=5.0.0 percent_indicator: ^4.0.1 + appflowy_editor_plugins: path: packages/appflowy_editor_plugins calendar_view: ^1.0.1