Improvement for cover plugin #1928 (#1981)

* feat: added emoji and network image support

* fix: code cleanup and improvements

* fix: blank preview on invalid image save

* fix: flutter analyzer warnings

* fix: code refactor and bug fixes

* chore: removed unused imports

* chore: formate code

---------

Co-authored-by: ahmeduzair890 <ahmeduzair12123@gmail.com>
Co-authored-by: Lucas.Xu <lucas.xu@appflowy.io>
This commit is contained in:
Muhammad Rizwan 2023-03-15 10:22:48 +05:00 committed by GitHub
parent 7be7c2a7a0
commit 4c0168d7fa
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
10 changed files with 891 additions and 99 deletions

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?><!-- Uploaded to: SVG Repo, www.svgrepo.com, Generator: SVG Repo Mixer Tools -->
<svg width="800px" height="800px" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M3.5 15V6.9C3.5 6.05992 3.5 5.63988 3.66349 5.31901C3.8073 5.03677 4.03677 4.8073 4.31901 4.66349C4.63988 4.5 5.05992 4.5 5.9 4.5H8.47237C8.84808 4.5 9.03594 4.5 9.20646 4.55179C9.35741 4.59763 9.49785 4.6728 9.61972 4.77298C9.75739 4.88614 9.86159 5.04245 10.07 5.35507L10.93 6.64533C11.1384 6.95795 11.2426 7.11426 11.3803 7.22742C11.5022 7.3276 11.6426 7.40277 11.7935 7.44861C11.9641 7.5004 12.1519 7.5004 12.5276 7.5004H16.5004C16.965 7.5004 17.1973 7.5004 17.3879 7.55143C17.9058 7.69008 18.3103 8.09459 18.449 8.61248C18.5 8.80308 18.5 9.03539 18.5 9.5V9.5M10.5 13.5H16.5" stroke="#222222" stroke-linecap="round"/>
<path d="M4.5 18.5L16.7701 18.5004C17.3922 18.5004 17.7032 18.5004 17.9679 18.3963C18.2016 18.3044 18.4084 18.1553 18.5695 17.9626C18.752 17.7445 18.8503 17.4494 19.047 16.8593L20.4471 12.6592C20.8026 11.5927 20.9803 11.0595 20.8737 10.635C20.7804 10.2635 20.5485 9.94171 20.2255 9.7357C19.8566 9.50035 19.2945 9.50033 18.1703 9.5003L10.2299 9.50005C9.60784 9.50003 9.29681 9.50002 9.03216 9.60411C8.79846 9.69601 8.59157 9.84513 8.43047 10.0378C8.24804 10.2559 8.14968 10.551 7.95298 11.1411L5.7649 17.7057C5.60671 18.1803 5.16255 18.5004 4.66227 18.5004V18.5004C4.02037 18.5004 3.5 17.98 3.5 17.3381V14.5" stroke="#222222"/>
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

View File

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

View File

@ -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<ChangeCoverPopover> {
late Future<List<String>>? fileImages;
bool isAddingImage = false;
@override
void initState() {
@ -88,26 +85,40 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
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<String> 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<ChangeCoverPopover> {
),
width: 20,
onPressed: () {
_pickImages();
setState(() {
isAddingImage = true;
});
},
),
);
@ -249,36 +262,6 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
prefs.setStringList(kLocalImagesKey, imageNames);
return imageNames;
}
Future<void> _pickImages() async {
SharedPreferences prefs = await SharedPreferences.getInstance();
List<String> imageNames = prefs.getStringList(kLocalImagesKey) ?? [];
FilePickerResult? result = await getIt<FilePickerService>().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<String> _coverPath() async {
final directory = await getIt<SettingsLocationCubit>().fetchLocation();
return Directory(path.join(directory, 'covers'))
.create(recursive: true)
.then((value) => value.path);
}
}
class _CoverColorPickerState extends State<CoverColorPicker> {

View File

@ -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<String> paths) onFileSubmit;
const CoverImagePicker(
{super.key, required this.onBackPressed, required this.onFileSubmit});
@override
State<CoverImagePicker> createState() => _CoverImagePickerState();
}
class _CoverImagePickerState extends State<CoverImagePicker> {
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<CoverImagePickerBloc>().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<CoverImagePickerBloc>().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<CoverImagePickerBloc, CoverImagePickerState>(
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<CoverImagePickerBloc, CoverImagePickerState>(
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<CoverImagePickerBloc>()
.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<CoverImagePickerBloc>()
.add(SaveToGallery(state));
},
hoverColor: Colors.transparent,
fillColor: Colors.transparent,
mainAxisAlignment: MainAxisAlignment.end,
fontColor: Theme.of(context).colorScheme.primary,
),
],
)
],
);
},
),
),
);
}
}

View File

@ -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<CoverImagePickerEvent, CoverImagePickerState> {
CoverImagePickerBloc() : super(const CoverImagePickerState.initial()) {
on<CoverImagePickerEvent>(
(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<String> 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<FilePickerService>().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<String> _coverPath() async {
final directory = await getIt<SettingsLocationCubit>().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<String, FlowyError> successOrFail) = NetworkImagePicked;
const factory CoverImagePickerState.fileImage(String path) = FileImagePicked;
const factory CoverImagePickerState.done(
Either<List<String>, FlowyError> successOrFail) = Done;
}

View File

@ -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<void> _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<void> _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<void> _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<void> _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<void> _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<void> _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()
],
),
),
);

View File

@ -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<EmojiPopover> createState() => _EmojiPopoverState();
}
class _EmojiPopoverState extends State<EmojiPopover> {
@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,
),
],
),
);
}
}

View File

@ -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<EmojiIconWidget> createState() => _EmojiIconWidgetState();
}
class _EmojiIconWidgetState extends State<EmojiIconWidget> {
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;
});
}
}

View File

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