Browse Source

[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 <[email protected]>
GouravShDev 2 years ago
parent
commit
5ad00c041f

+ 1 - 0
frontend/appflowy_flutter/assets/translations/en.json

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

+ 175 - 99
frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/cover/change_cover_popover.dart

@@ -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));
+                },
+              ),
+            ),
+        ],
+      ),
+    );
   }
 }
 

+ 90 - 0
frontend/appflowy_flutter/lib/plugins/document/presentation/plugins/cover/change_cover_popover_bloc.dart

@@ -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;
+}