mirror of
https://github.com/AppFlowy-IO/AppFlowy.git
synced 2024-08-30 18:12:39 +00:00
[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:
@ -366,6 +366,7 @@
|
|||||||
"changeCover": "Change Cover",
|
"changeCover": "Change Cover",
|
||||||
"colors": "Colors",
|
"colors": "Colors",
|
||||||
"images": "Images",
|
"images": "Images",
|
||||||
|
"clearAll": "Clear All",
|
||||||
"abstract": "Abstract",
|
"abstract": "Abstract",
|
||||||
"addCover": "Add Cover",
|
"addCover": "Add Cover",
|
||||||
"addLocalImage": "Add local image",
|
"addLocalImage": "Add local image",
|
||||||
|
@ -2,17 +2,20 @@ import 'dart:io';
|
|||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:appflowy/generated/locale_keys.g.dart';
|
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_image_picker.dart';
|
||||||
import 'package:appflowy/plugins/document/presentation/plugins/cover/cover_node_widget.dart';
|
import 'package:appflowy/plugins/document/presentation/plugins/cover/cover_node_widget.dart';
|
||||||
import 'package:appflowy_editor/appflowy_editor.dart';
|
import 'package:appflowy_editor/appflowy_editor.dart';
|
||||||
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
import 'package:appflowy_editor_plugins/appflowy_editor_plugins.dart';
|
||||||
import 'package:easy_localization/easy_localization.dart';
|
import 'package:easy_localization/easy_localization.dart';
|
||||||
|
import 'package:flowy_infra/image.dart';
|
||||||
import 'package:flowy_infra/size.dart';
|
import 'package:flowy_infra/size.dart';
|
||||||
import 'package:flowy_infra/theme_extension.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/icon_button.dart';
|
||||||
import 'package:flowy_infra_ui/style_widget/text.dart';
|
import 'package:flowy_infra_ui/style_widget/text.dart';
|
||||||
import 'package:flutter/material.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';
|
const String kLocalImagesKey = 'local_images';
|
||||||
|
|
||||||
@ -71,31 +74,35 @@ class CoverColorPicker extends StatefulWidget {
|
|||||||
}
|
}
|
||||||
|
|
||||||
class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
|
class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
|
||||||
late Future<List<String>>? fileImages;
|
|
||||||
bool isAddingImage = false;
|
bool isAddingImage = false;
|
||||||
|
|
||||||
@override
|
|
||||||
void initState() {
|
|
||||||
super.initState();
|
|
||||||
fileImages = _getPreviouslyPickedImagePaths();
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return BlocProvider(
|
||||||
padding: const EdgeInsets.all(15),
|
create: (context) => ChangeCoverPopoverBloc()
|
||||||
child: SingleChildScrollView(
|
..add(const ChangeCoverPopoverEvent.fetchPickedImagePaths()),
|
||||||
child: isAddingImage
|
child: BlocBuilder<ChangeCoverPopoverBloc, ChangeCoverPopoverState>(
|
||||||
? CoverImagePicker(
|
builder: (context, state) {
|
||||||
onBackPressed: () => setState(() {
|
return Padding(
|
||||||
isAddingImage = false;
|
padding: const EdgeInsets.all(15),
|
||||||
}),
|
child: SingleChildScrollView(
|
||||||
onFileSubmit: (List<String> path) {
|
child: isAddingImage
|
||||||
setState(() {
|
? CoverImagePicker(
|
||||||
isAddingImage = false;
|
onBackPressed: () => setState(() {
|
||||||
});
|
isAddingImage = false;
|
||||||
})
|
}),
|
||||||
: _buildCoverSelection(),
|
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),
|
const SizedBox(height: 10),
|
||||||
_buildColorPickerList(),
|
_buildColorPickerList(),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
FlowyText.semibold(
|
_buildImageHeader(),
|
||||||
LocaleKeys.document_plugins_cover_images.tr(),
|
|
||||||
color: Theme.of(context).colorScheme.tertiary,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
_buildFileImagePicker(),
|
_buildFileImagePicker(),
|
||||||
const SizedBox(height: 10),
|
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() {
|
Widget _buildAbstractImagePicker() {
|
||||||
return GridView.builder(
|
return GridView.builder(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
@ -182,71 +214,59 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildFileImagePicker() {
|
Widget _buildFileImagePicker() {
|
||||||
return FutureBuilder<List<String>>(
|
return BlocBuilder<ChangeCoverPopoverBloc, ChangeCoverPopoverState>(
|
||||||
future: _getPreviouslyPickedImagePaths(),
|
builder: (context, state) {
|
||||||
builder: (context, snapshot) {
|
if (state is Loaded) {
|
||||||
if (snapshot.hasData) {
|
List<String> images = state.imageNames;
|
||||||
List<String> images = snapshot.data!;
|
return GridView.builder(
|
||||||
return GridView.builder(
|
shrinkWrap: true,
|
||||||
shrinkWrap: true,
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
crossAxisCount: 3,
|
||||||
crossAxisCount: 3,
|
childAspectRatio: 1 / 0.65,
|
||||||
childAspectRatio: 1 / 0.65,
|
crossAxisSpacing: 7,
|
||||||
crossAxisSpacing: 7,
|
mainAxisSpacing: 7,
|
||||||
mainAxisSpacing: 7,
|
),
|
||||||
),
|
itemCount: images.length + 1,
|
||||||
itemCount: images.length + 1,
|
itemBuilder: (BuildContext ctx, index) {
|
||||||
itemBuilder: (BuildContext ctx, index) {
|
if (index == 0) {
|
||||||
if (index == 0) {
|
return Container(
|
||||||
return Container(
|
decoration: BoxDecoration(
|
||||||
decoration: BoxDecoration(
|
color:
|
||||||
color: Theme.of(context)
|
Theme.of(context).colorScheme.primary.withOpacity(0.15),
|
||||||
.colorScheme
|
border: Border.all(
|
||||||
.primary
|
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,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
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) {
|
List<ColorOption> _generateBackgroundColorOptions(EditorState editorState) {
|
||||||
@ -257,19 +277,75 @@ class _ChangeCoverPopoverState extends State<ChangeCoverPopover> {
|
|||||||
))
|
))
|
||||||
.toList();
|
.toList();
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<List<String>> _getPreviouslyPickedImagePaths() async {
|
class ImageGridItem extends StatefulWidget {
|
||||||
SharedPreferences prefs = await SharedPreferences.getInstance();
|
const ImageGridItem({
|
||||||
final imageNames = prefs.getStringList(kLocalImagesKey) ?? [];
|
Key? key,
|
||||||
final removeNames = [];
|
required this.onImageSelect,
|
||||||
for (final name in imageNames) {
|
required this.imagePath,
|
||||||
if (!File(name).existsSync()) {
|
}) : super(key: key);
|
||||||
removeNames.add(name);
|
|
||||||
}
|
final Function() onImageSelect;
|
||||||
}
|
final String imagePath;
|
||||||
imageNames.removeWhere((element) => removeNames.contains(element));
|
|
||||||
prefs.setStringList(kLocalImagesKey, imageNames);
|
@override
|
||||||
return imageNames;
|
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));
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -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;
|
||||||
|
}
|
Reference in New Issue
Block a user