[feat]: add image delete in document cover (#2111)

* feat: add image delete in document cover

* fix: amend according to review comments

* fix: add initCompleter.future before using prefs

* fix: show delete button on hover in CoverImageGrid

* feat: hover color on clear all and delete button

* Merge branch 'main' into feat/delete-cover-image

* fix: font color in clear all button in changecover

* chore: add Clear All button fill color

---------

Co-authored-by: Yijing Huang <hyj891204@gmail.com>
This commit is contained in:
GouravShDev 2023-04-03 08:42:24 +05:30 committed by GitHub
parent e8c6650d55
commit 5ad00c041f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
3 changed files with 266 additions and 99 deletions

View File

@ -366,6 +366,7 @@
"changeCover": "Change Cover",
"colors": "Colors",
"images": "Images",
"clearAll": "Clear All",
"abstract": "Abstract",
"addCover": "Add Cover",
"addLocalImage": "Add local image",

View File

@ -2,17 +2,20 @@ import 'dart:io';
import 'dart:ui';
import 'package:appflowy/generated/locale_keys.g.dart';
import 'package:appflowy/plugins/document/presentation/plugins/cover/change_cover_popover_bloc.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_editor/appflowy_editor.dart';
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flowy_infra/image.dart';
import 'package:flowy_infra/size.dart';
import 'package:flowy_infra/theme_extension.dart';
import 'package:flowy_infra_ui/style_widget/button.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:flutter_bloc/flutter_bloc.dart';
const String kLocalImagesKey = 'local_images';
@ -71,31 +74,35 @@ class CoverColorPicker extends StatefulWidget {
}
class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
late Future<List<String>>? fileImages;
bool isAddingImage = false;
@override
void initState() {
super.initState();
fileImages = _getPreviouslyPickedImagePaths();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(15),
child: SingleChildScrollView(
child: isAddingImage
? CoverImagePicker(
onBackPressed: () => setState(() {
isAddingImage = false;
}),
onFileSubmit: (List<String> path) {
setState(() {
isAddingImage = false;
});
})
: _buildCoverSelection(),
return BlocProvider(
create: (context) => ChangeCoverPopoverBloc()
..add(const ChangeCoverPopoverEvent.fetchPickedImagePaths()),
child: BlocBuilder<ChangeCoverPopoverBloc, ChangeCoverPopoverState>(
builder: (context, state) {
return Padding(
padding: const EdgeInsets.all(15),
child: SingleChildScrollView(
child: isAddingImage
? CoverImagePicker(
onBackPressed: () => setState(() {
isAddingImage = false;
}),
onFileSubmit: (List<String> path) {
context.read<ChangeCoverPopoverBloc>().add(
const ChangeCoverPopoverEvent
.fetchPickedImagePaths());
setState(() {
isAddingImage = false;
});
})
: _buildCoverSelection(),
),
);
},
),
);
}
@ -111,10 +118,7 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
const SizedBox(height: 10),
_buildColorPickerList(),
const SizedBox(height: 10),
FlowyText.semibold(
LocaleKeys.document_plugins_cover_images.tr(),
color: Theme.of(context).colorScheme.tertiary,
),
_buildImageHeader(),
const SizedBox(height: 10),
_buildFileImagePicker(),
const SizedBox(height: 10),
@ -128,6 +132,34 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
);
}
Widget _buildImageHeader() {
return BlocBuilder<ChangeCoverPopoverBloc, ChangeCoverPopoverState>(
builder: (context, state) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
FlowyText.semibold(
LocaleKeys.document_plugins_cover_images.tr(),
color: Theme.of(context).colorScheme.tertiary,
),
FlowyTextButton(
fillColor: Theme.of(context).cardColor,
hoverColor: Theme.of(context).colorScheme.secondaryContainer,
LocaleKeys.document_plugins_cover_clearAll.tr(),
fontColor: Theme.of(context).colorScheme.tertiary,
onPressed: () {
context
.read<ChangeCoverPopoverBloc>()
.add(const ChangeCoverPopoverEvent.clearAllImages());
},
mainAxisAlignment: MainAxisAlignment.end,
),
],
);
},
);
}
Widget _buildAbstractImagePicker() {
return GridView.builder(
shrinkWrap: true,
@ -182,71 +214,59 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
}
Widget _buildFileImagePicker() {
return FutureBuilder<List<String>>(
future: _getPreviouslyPickedImagePaths(),
builder: (context, snapshot) {
if (snapshot.hasData) {
List<String> images = snapshot.data!;
return GridView.builder(
shrinkWrap: true,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
childAspectRatio: 1 / 0.65,
crossAxisSpacing: 7,
mainAxisSpacing: 7,
),
itemCount: images.length + 1,
itemBuilder: (BuildContext ctx, index) {
if (index == 0) {
return Container(
decoration: BoxDecoration(
color: Theme.of(context)
.colorScheme
.primary
.withOpacity(0.15),
border: Border.all(
color: Theme.of(context).colorScheme.primary,
),
borderRadius: Corners.s8Border,
),
child: FlowyIconButton(
iconPadding: EdgeInsets.zero,
icon: Icon(
Icons.add,
color: Theme.of(context).colorScheme.primary,
),
width: 20,
onPressed: () {
setState(() {
isAddingImage = true;
});
},
),
);
}
return InkWell(
onTap: () {
widget.onCoverChanged(
CoverSelectionType.file,
images[index - 1],
);
},
child: Container(
decoration: BoxDecoration(
image: DecorationImage(
image: FileImage(File(images[index - 1])),
fit: BoxFit.cover,
),
borderRadius: Corners.s8Border,
),
return BlocBuilder<ChangeCoverPopoverBloc, ChangeCoverPopoverState>(
builder: (context, state) {
if (state is Loaded) {
List<String> images = state.imageNames;
return GridView.builder(
shrinkWrap: true,
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
childAspectRatio: 1 / 0.65,
crossAxisSpacing: 7,
mainAxisSpacing: 7,
),
itemCount: images.length + 1,
itemBuilder: (BuildContext ctx, index) {
if (index == 0) {
return Container(
decoration: BoxDecoration(
color:
Theme.of(context).colorScheme.primary.withOpacity(0.15),
border: Border.all(
color: Theme.of(context).colorScheme.primary,
),
borderRadius: Corners.s8Border,
),
child: FlowyIconButton(
iconPadding: EdgeInsets.zero,
icon: Icon(
Icons.add,
color: Theme.of(context).colorScheme.primary,
),
width: 20,
onPressed: () {
setState(() {
isAddingImage = true;
});
},
),
);
}
return ImageGridItem(
onImageSelect: () {
widget.onCoverChanged(
CoverSelectionType.file,
images[index - 1],
);
},
imagePath: images[index - 1],
);
} else {
return Container();
}
});
},
);
}
return Container();
});
}
List<ColorOption> _generateBackgroundColorOptions(EditorState editorState) {
@ -257,19 +277,75 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
))
.toList();
}
}
Future<List<String>> _getPreviouslyPickedImagePaths() async {
SharedPreferences prefs = await SharedPreferences.getInstance();
final imageNames = prefs.getStringList(kLocalImagesKey) ?? [];
final removeNames = [];
for (final name in imageNames) {
if (!File(name).existsSync()) {
removeNames.add(name);
}
}
imageNames.removeWhere((element) => removeNames.contains(element));
prefs.setStringList(kLocalImagesKey, imageNames);
return imageNames;
class ImageGridItem extends StatefulWidget {
const ImageGridItem({
Key? key,
required this.onImageSelect,
required this.imagePath,
}) : super(key: key);
final Function() onImageSelect;
final String imagePath;
@override
State<ImageGridItem> createState() => _ImageGridItemState();
}
class _ImageGridItemState extends State<ImageGridItem> {
bool showDeleteButton = false;
@override
Widget build(BuildContext context) {
return MouseRegion(
onEnter: (_) {
setState(() {
showDeleteButton = true;
});
},
onExit: (_) {
setState(() {
showDeleteButton = false;
});
},
child: Stack(
children: [
InkWell(
onTap: widget.onImageSelect,
child: Container(
decoration: BoxDecoration(
image: DecorationImage(
image: FileImage(File(widget.imagePath)),
fit: BoxFit.cover,
),
borderRadius: Corners.s8Border,
),
),
),
if (showDeleteButton)
Positioned(
right: 2,
top: 2,
child: FlowyIconButton(
fillColor:
Theme.of(context).colorScheme.surface.withOpacity(0.8),
hoverColor:
Theme.of(context).colorScheme.surface.withOpacity(0.8),
iconPadding: const EdgeInsets.all(5),
width: 28,
icon: svgWidget(
'editor/delete',
color: Theme.of(context).colorScheme.tertiary,
),
onPressed: () {
context.read<ChangeCoverPopoverBloc>().add(
ChangeCoverPopoverEvent.deleteImage(widget.imagePath));
},
),
),
],
),
);
}
}

View File

@ -0,0 +1,90 @@
import 'dart:async';
import 'dart:io';
import 'package:appflowy/plugins/document/presentation/plugins/cover/change_cover_popover.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:freezed_annotation/freezed_annotation.dart';
import 'package:shared_preferences/shared_preferences.dart';
part 'change_cover_popover_bloc.freezed.dart';
class ChangeCoverPopoverBloc
extends Bloc<ChangeCoverPopoverEvent, ChangeCoverPopoverState> {
late final SharedPreferences _prefs;
final _initCompleter = Completer<void>();
ChangeCoverPopoverBloc() : super(const ChangeCoverPopoverState.initial()) {
SharedPreferences.getInstance().then((prefs) {
_prefs = prefs;
_initCompleter.complete();
});
on<ChangeCoverPopoverEvent>((event, emit) async {
await event.map(
fetchPickedImagePaths:
(FetchPickedImagePaths fetchPickedImagePaths) async {
final imageNames = await _getPreviouslyPickedImagePaths();
emit(ChangeCoverPopoverState.loaded(imageNames));
},
deleteImage: (DeleteImage deleteImage) async {
final currentState = state;
if (currentState is Loaded) {
await _deleteImageInStorage(deleteImage.path);
final updateImageList = currentState.imageNames
.where((path) => path != deleteImage.path)
.toList();
await _updateImagePathsInStorage(updateImageList);
emit(Loaded(updateImageList));
}
},
clearAllImages: (ClearAllImages clearAllImages) async {
final currentState = state;
if (currentState is Loaded) {
for (final image in currentState.imageNames) {
await _deleteImageInStorage(image);
}
await _updateImagePathsInStorage([]);
emit(const Loaded([]));
}
},
);
});
}
Future<List<String>> _getPreviouslyPickedImagePaths() async {
await _initCompleter.future;
final imageNames = _prefs.getStringList(kLocalImagesKey) ?? [];
if (imageNames.isEmpty) {
return imageNames;
}
imageNames.removeWhere((name) => !File(name).existsSync());
return imageNames;
}
Future<void> _updateImagePathsInStorage(List<String> imagePaths) async {
await _initCompleter.future;
_prefs.setStringList(kLocalImagesKey, imagePaths);
return;
}
Future<void> _deleteImageInStorage(String path) async {
final imageFile = File(path);
await imageFile.delete();
}
}
@freezed
class ChangeCoverPopoverEvent with _$ChangeCoverPopoverEvent {
const factory ChangeCoverPopoverEvent.fetchPickedImagePaths() =
FetchPickedImagePaths;
const factory ChangeCoverPopoverEvent.deleteImage(String path) = DeleteImage;
const factory ChangeCoverPopoverEvent.clearAllImages() = ClearAllImages;
}
@freezed
class ChangeCoverPopoverState with _$ChangeCoverPopoverState {
const factory ChangeCoverPopoverState.initial() = Initial;
const factory ChangeCoverPopoverState.loading() = Loading;
const factory ChangeCoverPopoverState.loaded(
List<String> imageNames,
) = Loaded;
}