diff --git a/lib/services/collections_service.dart b/lib/services/collections_service.dart index 21513030a..631b630e9 100644 --- a/lib/services/collections_service.dart +++ b/lib/services/collections_service.dart @@ -395,6 +395,29 @@ class CollectionsService { } } + Future trashEmptyCollection(Collection collection) async { + try { + // While trashing empty albums, we must pass keepFiles flag as True. + // The server will verify that the collection is actually empty before + // deleting the files. If keepFiles is set as False and the collection + // is not empty, then the files in the collections will be moved to trash. + await _enteDio.delete( + "/collections/v3/${collection.id}?keepFiles=True&collectionID=${collection.id}", + ); + final deletedCollection = collection.copyWith(isDeleted: true); + _collectionIDToCollections[collection.id] = deletedCollection; + unawaited(_db.insert([deletedCollection])); + } on DioError catch (e) { + if (e.response != null) { + debugPrint("Error " + e.response!.toString()); + } + rethrow; + } catch (e) { + _logger.severe('failed to trash empty collection', e); + rethrow; + } + } + Future _handleCollectionDeletion(Collection collection) async { await _filesDB.deleteCollection(collection.id); final deletedCollection = collection.copyWith(isDeleted: true); diff --git a/lib/ui/collections_gallery_widget.dart b/lib/ui/collections_gallery_widget.dart index 49cb718b3..861fe7fc2 100644 --- a/lib/ui/collections_gallery_widget.dart +++ b/lib/ui/collections_gallery_widget.dart @@ -1,5 +1,3 @@ - - import 'dart:async'; import 'package:collection/collection.dart'; @@ -22,6 +20,7 @@ import 'package:photos/ui/collections/remote_collections_grid_view_widget.dart'; import 'package:photos/ui/collections/section_title.dart'; import 'package:photos/ui/collections/trash_button_widget.dart'; import 'package:photos/ui/common/loading_widget.dart'; +import 'package:photos/ui/viewer/actions/delete_empty_albums.dart'; import 'package:photos/ui/viewer/gallery/empty_state.dart'; import 'package:photos/utils/local_settings.dart'; @@ -37,7 +36,8 @@ class _CollectionsGalleryWidgetState extends State with AutomaticKeepAliveClientMixin { final _logger = Logger((_CollectionsGalleryWidgetState).toString()); late StreamSubscription _localFilesSubscription; - late StreamSubscription _collectionUpdatesSubscription; + late StreamSubscription + _collectionUpdatesSubscription; late StreamSubscription _loggedOutEvent; AlbumSortKey? sortKey; String _loadReason = "init"; @@ -123,6 +123,8 @@ class _CollectionsGalleryWidgetState extends State Widget _getCollectionsGalleryWidget( List? collections, ) { + final bool showDeleteAlbumsButton = + collections!.where((c) => c.thumbnail == null).length >= 3; final TextStyle trashAndHiddenTextStyle = Theme.of(context) .textTheme .subtitle1! @@ -149,6 +151,12 @@ class _CollectionsGalleryWidgetState extends State _sortMenu(), ], ), + showDeleteAlbumsButton + ? const Padding( + padding: EdgeInsets.only(top: 2, left: 8.5, right: 48), + child: DeleteEmptyAlbums(), + ) + : const SizedBox.shrink(), const SizedBox(height: 12), Configuration.instance.hasConfiguredAccount() ? RemoteCollectionsGridViewWidget(collections) diff --git a/lib/ui/components/button_widget.dart b/lib/ui/components/button_widget.dart index 6fcaf4e32..0dd6d0ea6 100644 --- a/lib/ui/components/button_widget.dart +++ b/lib/ui/components/button_widget.dart @@ -60,6 +60,11 @@ class ButtonWidget extends StatelessWidget { ///This should be set to true if the alert which uses this button needs to ///return the Button's action. final bool isInAlert; + + /// progressStatus can be used to display information about the action + /// progress when ExecutionState is in Progress. + final ValueNotifier? progressStatus; + const ButtonWidget({ required this.buttonType, this.buttonSize = ButtonSize.large, @@ -72,6 +77,7 @@ class ButtonWidget extends StatelessWidget { this.isInAlert = false, this.iconColor, this.shouldSurfaceExecutionStates = true, + this.progressStatus, super.key, }); @@ -137,6 +143,7 @@ class ButtonWidget extends StatelessWidget { icon: icon, buttonAction: buttonAction, shouldSurfaceExecutionStates: shouldSurfaceExecutionStates, + progressStatus: progressStatus, ); } } @@ -152,6 +159,8 @@ class ButtonChildWidget extends StatefulWidget { final ButtonAction? buttonAction; final bool isInAlert; final bool shouldSurfaceExecutionStates; + final ValueNotifier? progressStatus; + const ButtonChildWidget({ required this.buttonStyle, required this.buttonType, @@ -159,6 +168,7 @@ class ButtonChildWidget extends StatefulWidget { required this.buttonSize, required this.isInAlert, required this.shouldSurfaceExecutionStates, + this.progressStatus, this.onTap, this.labelText, this.icon, @@ -177,14 +187,17 @@ class _ButtonChildWidgetState extends State { late TextStyle labelStyle; late Color checkIconColor; late Color loadingIconColor; + ValueNotifier? progressStatus; ///This is used to store the width of the button in idle state (small button) ///to be used as width for the button when the loading/succes states comes. double? widthOfButton; final _debouncer = Debouncer(const Duration(milliseconds: 300)); ExecutionState executionState = ExecutionState.idle; + @override void initState() { + progressStatus = widget.progressStatus; checkIconColor = widget.buttonStyle.checkIconColor ?? widget.buttonStyle.defaultIconColor; loadingIconColor = widget.buttonStyle.defaultIconColor; @@ -203,6 +216,7 @@ class _ButtonChildWidgetState extends State { iconColor = widget.buttonStyle.defaultIconColor; labelStyle = widget.buttonStyle.defaultLabelStyle; } + super.initState(); } @@ -315,6 +329,21 @@ class _ButtonChildWidgetState extends State { mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min, children: [ + progressStatus == null + ? const SizedBox.shrink() + : ValueListenableBuilder( + valueListenable: progressStatus!, + builder: ( + BuildContext context, + String value, + Widget? child, + ) { + return Text( + value, + style: lightTextTheme.smallBold, + ); + }, + ), EnteLoadingWidget( is20pts: true, color: loadingIconColor, diff --git a/lib/ui/viewer/actions/delete_empty_albums.dart b/lib/ui/viewer/actions/delete_empty_albums.dart new file mode 100644 index 000000000..79916fd80 --- /dev/null +++ b/lib/ui/viewer/actions/delete_empty_albums.dart @@ -0,0 +1,108 @@ +import 'package:flutter/material.dart'; +import 'package:photos/core/event_bus.dart'; +import 'package:photos/events/collection_updated_event.dart'; +import 'package:photos/models/file.dart'; +import 'package:photos/services/collections_service.dart'; +import 'package:photos/ui/components/action_sheet_widget.dart'; +import 'package:photos/ui/components/button_widget.dart'; +import 'package:photos/ui/components/models/button_type.dart'; + +class DeleteEmptyAlbums extends StatefulWidget { + const DeleteEmptyAlbums({Key? key}) : super(key: key); + + @override + State createState() => _DeleteEmptyAlbumsState(); +} + +class _DeleteEmptyAlbumsState extends State { + final ValueNotifier _deleteProgress = ValueNotifier(""); + bool _isCancelled = false; + + @override + void dispose() { + _deleteProgress.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Align( + alignment: Alignment.centerLeft, + child: ButtonWidget( + buttonSize: ButtonSize.small, + buttonType: ButtonType.secondary, + labelText: "Delete empty albums", + icon: Icons.delete_sweep_outlined, + shouldSurfaceExecutionStates: false, + onTap: () async { + await showActionSheet( + context: context, + isDismissible: false, + buttons: [ + ButtonWidget( + labelText: "Yes", + buttonType: ButtonType.neutral, + buttonSize: ButtonSize.large, + shouldStickToDarkTheme: true, + shouldSurfaceExecutionStates: true, + progressStatus: _deleteProgress, + onTap: () async { + await _deleteEmptyAlbums(); + if (!_isCancelled) { + Navigator.of(context, rootNavigator: true).pop(); + } + Bus.instance.fire( + CollectionUpdatedEvent( + 0, + [], + "empty_albums_deleted", + ), + ); + CollectionsService.instance.sync().ignore(); + _isCancelled = false; + }, + ), + ButtonWidget( + labelText: "Cancel", + buttonType: ButtonType.secondary, + buttonSize: ButtonSize.large, + shouldStickToDarkTheme: true, + onTap: () async { + _isCancelled = true; + Navigator.of(context, rootNavigator: true).pop(); + }, + ) + ], + title: "Delete empty albums", + body: + "This will delete all empty albums. This is useful when you want to reduce the clutter in your album list.", + actionSheetType: ActionSheetType.defaultActionSheet, + ); + }, + ), + ); + } + + Future _deleteEmptyAlbums() async { + final collections = + await CollectionsService.instance.getCollectionsWithThumbnails(); + collections.removeWhere((element) => element.thumbnail != null); + int failedCount = 0; + for (int i = 0; i < collections.length; i++) { + if (mounted && !_isCancelled) { + _deleteProgress.value = + "Deleting ${(i + 1).toString().padLeft(collections.length.toString().length, '0')}/ " + "${collections.length} "; + try { + await CollectionsService.instance + .trashEmptyCollection(collections[i].collection); + } catch (_) { + failedCount++; + } + } + } + if (failedCount > 0) { + debugPrint("Delete ops failed for $failedCount collections"); + } + } +} diff --git a/lib/ui/viewer/actions/file_selection_common_actions_widget.dart b/lib/ui/viewer/actions/file_selection_common_actions_widget.dart deleted file mode 100644 index 977c65378..000000000 --- a/lib/ui/viewer/actions/file_selection_common_actions_widget.dart +++ /dev/null @@ -1,19 +0,0 @@ -import 'package:flutter/cupertino.dart'; -import 'package:photos/models/gallery_type.dart'; -import 'package:photos/models/selected_files.dart'; - -class FileSelectionCommonActionWidget extends StatelessWidget { - final GalleryType type; - final SelectedFiles selectedFiles; - - const FileSelectionCommonActionWidget({ - super.key, - required this.type, - required this.selectedFiles, - }); - @override - Widget build(BuildContext context) { - // TODO: implement build - throw UnimplementedError(); - } -}