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