Przeglądaj źródła

Merge pull request #807 from ente-io/uncategorized

Neeraj Gupta 2 lat temu
rodzic
commit
98c26bd3ac

+ 13 - 0
lib/db/files_db.dart

@@ -610,6 +610,19 @@ class FilesDB {
     return FileLoadResult(files, files.length == limit);
   }
 
+  Future<List<File>> getAllFilesCollection(int collectionID) async {
+    final db = await instance.database;
+    const String whereClause = '$columnCollectionID = ?';
+    final List<Object> whereArgs = [collectionID];
+    final results = await db.query(
+      filesTable,
+      where: whereClause,
+      whereArgs: whereArgs,
+    );
+    final files = convertToFiles(results);
+    return files;
+  }
+
   Future<FileLoadResult> getFilesInCollections(
     List<int> collectionIDs,
     int startTime,

+ 5 - 0
lib/models/collection.dart

@@ -210,6 +210,11 @@ enum CollectionType {
   unknown,
 }
 
+extension CollectionTypeExtn on CollectionType {
+  bool get canDelete =>
+      this != CollectionType.favorites && this != CollectionType.uncategorized;
+}
+
 enum CollectionParticipantRole {
   unknown,
   viewer,

+ 36 - 0
lib/models/files_split.dart

@@ -0,0 +1,36 @@
+import 'package:photos/models/file.dart';
+
+class FilesSplit {
+  final List<File> pendingUploads;
+  final List<File> ownedByCurrentUser;
+  final List<File> ownedByOtherUsers;
+
+  FilesSplit({
+    required this.pendingUploads,
+    required this.ownedByCurrentUser,
+    required this.ownedByOtherUsers,
+  });
+
+  int get totalFileOwnedCount =>
+      pendingUploads.length + ownedByCurrentUser.length;
+
+  static FilesSplit split(Iterable<File> files, int currentUserID) {
+    final List<File> ownedByCurrentUser = [],
+        ownedByOtherUsers = [],
+        pendingUploads = [];
+    for (var f in files) {
+      if (f.ownerID == null || f.uploadedFileID == null) {
+        pendingUploads.add(f);
+      } else if (f.ownerID == currentUserID) {
+        ownedByCurrentUser.add(f);
+      } else {
+        ownedByOtherUsers.add(f);
+      }
+    }
+    return FilesSplit(
+      pendingUploads: pendingUploads,
+      ownedByCurrentUser: ownedByCurrentUser,
+      ownedByOtherUsers: ownedByOtherUsers,
+    );
+  }
+}

+ 10 - 0
lib/models/gallery_type.dart

@@ -1,6 +1,7 @@
 enum GalleryType {
   homepage,
   archive,
+  uncategorized,
   hidden,
   favorite,
   trash,
@@ -23,6 +24,7 @@ extension GalleyTypeExtension on GalleryType {
         return true;
 
       case GalleryType.hidden:
+      case GalleryType.uncategorized:
       case GalleryType.trash:
       case GalleryType.sharedCollection:
         return false;
@@ -32,6 +34,7 @@ extension GalleyTypeExtension on GalleryType {
   bool showMoveToAlbum() {
     switch (this) {
       case GalleryType.ownedCollection:
+      case GalleryType.uncategorized:
         return true;
 
       case GalleryType.hidden:
@@ -55,6 +58,7 @@ extension GalleyTypeExtension on GalleryType {
       case GalleryType.homepage:
       case GalleryType.favorite:
       case GalleryType.localFolder:
+      case GalleryType.uncategorized:
         return true;
       case GalleryType.trash:
       case GalleryType.archive:
@@ -70,6 +74,7 @@ extension GalleyTypeExtension on GalleryType {
       case GalleryType.searchResults:
       case GalleryType.homepage:
       case GalleryType.favorite:
+      case GalleryType.uncategorized:
       case GalleryType.archive:
       case GalleryType.hidden:
       case GalleryType.localFolder:
@@ -87,6 +92,7 @@ extension GalleyTypeExtension on GalleryType {
       case GalleryType.homepage:
       case GalleryType.favorite:
       case GalleryType.archive:
+      case GalleryType.uncategorized:
         return true;
       case GalleryType.hidden:
       case GalleryType.localFolder:
@@ -102,6 +108,7 @@ extension GalleyTypeExtension on GalleryType {
       case GalleryType.sharedCollection:
         return true;
       case GalleryType.hidden:
+      case GalleryType.uncategorized:
       case GalleryType.favorite:
       case GalleryType.searchResults:
       case GalleryType.homepage:
@@ -116,6 +123,7 @@ extension GalleyTypeExtension on GalleryType {
     switch (this) {
       case GalleryType.ownedCollection:
       case GalleryType.homepage:
+      case GalleryType.uncategorized:
         return true;
 
       case GalleryType.hidden:
@@ -139,6 +147,7 @@ extension GalleyTypeExtension on GalleryType {
       case GalleryType.homepage:
       case GalleryType.searchResults:
       case GalleryType.archive:
+      case GalleryType.uncategorized:
         return true;
 
       case GalleryType.hidden:
@@ -159,6 +168,7 @@ extension GalleyTypeExtension on GalleryType {
       case GalleryType.ownedCollection:
       case GalleryType.homepage:
       case GalleryType.searchResults:
+      case GalleryType.uncategorized:
         return true;
 
       case GalleryType.hidden:

+ 0 - 10
lib/models/key_attributes.dart

@@ -7,18 +7,8 @@ class KeyAttributes {
   final String publicKey;
   final String encryptedSecretKey;
   final String secretKeyDecryptionNonce;
-
-  // Note: For users who signed in before we started storing memLimit and
-  // optsLimit, these fields will be null. To update these values, they need to
-  // either log in again or client needs to fetch these values from server.
-  // (internal monologue: Hopefully, the mem/ops limit used to generate the
-  // key is same as it's stored on the server)
-  // https://github.com/ente-io/photos-app/commit/8cb7f885b343f2c796e4cc9ce1f7d70c9a13a003#diff-02f19d9ee0a60ee9674372d2c780da5d5284128dc9ea65dec6cdcddfc559ebb3
   final int? memLimit;
   final int? opsLimit;
-  // The recovery key attributes can be null for old users who haven't generated
-  // their recovery keys yet.
-  // https://github.com/ente-io/photos-app/commit/d7acc95855c62ecdf2a29c4102e648105e17bd8c#diff-02f19d9ee0a60ee9674372d2c780da5d5284128dc9ea65dec6cdcddfc559ebb3
   final String? masterKeyEncryptedWithRecoveryKey;
   final String? masterKeyDecryptionNonce;
   final String? recoveryKeyEncryptedWithMasterKey;

+ 0 - 16
lib/models/selected_file_breakup.dart

@@ -1,16 +0,0 @@
-import 'package:photos/models/file.dart';
-
-class SelectedFileSplit {
-  final List<File> pendingUploads;
-  final List<File> ownedByCurrentUser;
-  final List<File> ownedByOtherUsers;
-
-  SelectedFileSplit({
-    required this.pendingUploads,
-    required this.ownedByCurrentUser,
-    required this.ownedByOtherUsers,
-  });
-
-  int get totalFileOwnedCount =>
-      pendingUploads.length + ownedByCurrentUser.length;
-}

+ 0 - 21
lib/models/selected_files.dart

@@ -3,7 +3,6 @@ import 'package:flutter/foundation.dart';
 import 'package:photos/core/event_bus.dart';
 import 'package:photos/events/clear_selections_event.dart';
 import 'package:photos/models/file.dart';
-import 'package:photos/models/selected_file_breakup.dart';
 
 class SelectedFiles extends ChangeNotifier {
   final files = <File>{};
@@ -66,26 +65,6 @@ class SelectedFiles extends ChangeNotifier {
     return false;
   }
 
-  SelectedFileSplit split(int currentUseID) {
-    final List<File> ownedByCurrentUser = [],
-        ownedByOtherUsers = [],
-        pendingUploads = [];
-    for (var f in files) {
-      if (f.ownerID == null || f.uploadedFileID == null) {
-        pendingUploads.add(f);
-      } else if (f.ownerID == currentUseID) {
-        ownedByCurrentUser.add(f);
-      } else {
-        ownedByOtherUsers.add(f);
-      }
-    }
-    return SelectedFileSplit(
-      pendingUploads: pendingUploads,
-      ownedByCurrentUser: ownedByCurrentUser,
-      ownedByOtherUsers: ownedByOtherUsers,
-    );
-  }
-
   void clearAll() {
     Bus.instance.fire(ClearSelectionsEvent());
     lastSelections.addAll(files);

+ 39 - 29
lib/services/collections_service.dart

@@ -57,6 +57,7 @@ class CollectionsService {
   final _cachedUserIdToUser = <int, User>{};
   Collection? cachedDefaultHiddenCollection;
   Future<List<File>>? _cachedLatestFiles;
+  Collection? cachedUncategorizedCollection;
 
   CollectionsService._privateConstructor() {
     _db = CollectionsDB.instance;
@@ -360,33 +361,13 @@ class CollectionsService {
     }
   }
 
-  Future<void> trashCollection(
+  Future<void> trashNonEmptyCollection(
     Collection collection,
-    bool isEmptyCollection,
   ) async {
     try {
-      // Turn off automatic back-up for the on device folder only when the
-      // collection is non-empty. This is to handle the case when the existing
-      // files in the on-device folders where automatically uploaded in some
-      // other collection or from different device
-      if (!isEmptyCollection) {
-        final deviceCollections = await _filesDB.getDeviceCollections();
-        final Map<String, bool> deivcePathIDsToUnsync = Map.fromEntries(
-          deviceCollections
-              .where((e) => e.shouldBackup && e.collectionID == collection.id)
-              .map((e) => MapEntry(e.id, false)),
-        );
-
-        if (deivcePathIDsToUnsync.isNotEmpty) {
-          _logger.info(
-            'turning off backup status for folders $deivcePathIDsToUnsync',
-          );
-          await RemoteSyncService.instance
-              .updateDeviceFolderSyncStatus(deivcePathIDsToUnsync);
-        }
-      }
+      await _turnOffDeviceFolderSync(collection);
       await _enteDio.delete(
-        "/collections/v2/${collection.id}",
+        "/collections/v3/${collection.id}?keepFiles=False&collectionID=${collection.id}",
       );
       await _handleCollectionDeletion(collection);
     } catch (e) {
@@ -395,8 +376,33 @@ class CollectionsService {
     }
   }
 
-  Future<void> trashEmptyCollection(Collection collection) async {
+  Future<void> _turnOffDeviceFolderSync(Collection collection) async {
+    final deviceCollections = await _filesDB.getDeviceCollections();
+    final Map<String, bool> deivcePathIDsToUnsync = Map.fromEntries(
+      deviceCollections
+          .where((e) => e.shouldBackup && e.collectionID == collection.id)
+          .map((e) => MapEntry(e.id, false)),
+    );
+
+    if (deivcePathIDsToUnsync.isNotEmpty) {
+      _logger.info(
+        'turning off backup status for folders $deivcePathIDsToUnsync',
+      );
+      await RemoteSyncService.instance
+          .updateDeviceFolderSyncStatus(deivcePathIDsToUnsync);
+    }
+  }
+
+  Future<void> trashEmptyCollection(
+    Collection collection, {
+    //  during bulk deletion, this event is not fired to avoid quick refresh
+    //  of the collection gallery
+    bool isBulkDelete = false,
+  }) async {
     try {
+      if (!isBulkDelete) {
+        await _turnOffDeviceFolderSync(collection);
+      }
       // 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
@@ -404,9 +410,13 @@ class CollectionsService {
       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]));
+      if (isBulkDelete) {
+        final deletedCollection = collection.copyWith(isDeleted: true);
+        _collectionIDToCollections[collection.id] = deletedCollection;
+        unawaited(_db.insert([deletedCollection]));
+      } else {
+        await _handleCollectionDeletion(collection);
+      }
     } on DioError catch (e) {
       if (e.response != null) {
         debugPrint("Error " + e.response!.toString());
@@ -421,6 +431,7 @@ class CollectionsService {
   Future<void> _handleCollectionDeletion(Collection collection) async {
     await _filesDB.deleteCollection(collection.id);
     final deletedCollection = collection.copyWith(isDeleted: true);
+    unawaited(_db.insert([deletedCollection]));
     _collectionIDToCollections[collection.id] = deletedCollection;
     Bus.instance.fire(
       CollectionUpdatedEvent(
@@ -431,8 +442,7 @@ class CollectionsService {
       ),
     );
     sync().ignore();
-    unawaited(_db.insert([deletedCollection]));
-    unawaited(LocalSyncService.instance.syncAll());
+    LocalSyncService.instance.syncAll().ignore();
   }
 
   Uint8List getCollectionKey(int collectionID) {

+ 22 - 9
lib/services/favorites_service.dart

@@ -14,11 +14,14 @@ import 'package:photos/models/collection.dart';
 import 'package:photos/models/file.dart';
 import 'package:photos/services/collections_service.dart';
 import 'package:photos/services/remote_sync_service.dart';
+import 'package:photos/ui/actions/collection/collection_sharing_actions.dart';
 import 'package:photos/utils/crypto_util.dart';
 
 class FavoritesService {
   late Configuration _config;
+
   late CollectionsService _collectionsService;
+  late CollectionActions _collectionActions;
   late FilesDB _filesDB;
   int? _cachedFavoritesCollectionID;
   final Set<int> _cachedFavUploadedIDs = {};
@@ -26,9 +29,11 @@ class FavoritesService {
   late StreamSubscription<CollectionUpdatedEvent>
       _collectionUpdatesSubscription;
 
-  FavoritesService._privateConstructor() {
+  FavoritesService._privateConstructor() {}
+  Future<void> initFav() async {
     _config = Configuration.instance;
     _collectionsService = CollectionsService.instance;
+    _collectionActions = CollectionActions(_collectionsService);
     _filesDB = FilesDB.instance;
     _collectionUpdatesSubscription =
         Bus.instance.on<CollectionUpdatedEvent>().listen((event) {
@@ -46,8 +51,6 @@ class FavoritesService {
         }
       }
     });
-  }
-  Future<void> initFav() async {
     await _warmUpCache();
   }
 
@@ -120,7 +123,7 @@ class FavoritesService {
     }
   }
 
-  Future<void> addToFavorites(File file) async {
+  Future<void> addToFavorites(BuildContext context, File file) async {
     final collectionID = await _getOrCreateFavoriteCollectionID();
     final List<File> files = [file];
     if (file.uploadedFileID == null) {
@@ -134,7 +137,8 @@ class FavoritesService {
     RemoteSyncService.instance.sync(silently: true).ignore();
   }
 
-  Future<void> updateFavorites(List<File> files, bool favFlag) async {
+  Future<void> updateFavorites(
+      BuildContext context, List<File> files, bool favFlag) async {
     final int currentUserID = Configuration.instance.getUserID()!;
     if (files.any((f) => f.uploadedFileID == null)) {
       throw AssertionError("Can only favorite uploaded items");
@@ -146,18 +150,27 @@ class FavoritesService {
     if (favFlag) {
       await _collectionsService.addToCollection(collectionID, files);
     } else {
-      await _collectionsService.removeFromCollection(collectionID, files);
+      final Collection? favCollection = await _getFavoritesCollection();
+      await _collectionActions.moveFilesFromCurrentCollection(
+        context,
+        favCollection!,
+        files,
+      );
     }
     _updateFavoriteFilesCache(files, favFlag: favFlag);
   }
 
-  Future<void> removeFromFavorites(File file) async {
-    final collectionID = await _getOrCreateFavoriteCollectionID();
+  Future<void> removeFromFavorites(BuildContext context, File file) async {
     final fileID = file.uploadedFileID;
     if (fileID == null) {
       // Do nothing, ignore
     } else {
-      await _collectionsService.removeFromCollection(collectionID, [file]);
+      final Collection? favCollection = await _getFavoritesCollection();
+      await _collectionActions.moveFilesFromCurrentCollection(
+        context,
+        favCollection!,
+        [file],
+      );
     }
     _updateFavoriteFilesCache([file], favFlag: false);
   }

+ 39 - 0
lib/services/hidden_service.dart

@@ -41,6 +41,26 @@ extension HiddenService on CollectionsService {
     return cachedDefaultHiddenCollection!;
   }
 
+  // getUncategorizedCollection will return the uncategorized collection
+  // for the given user
+  Future<Collection> getUncategorizedCollection() async {
+    if (cachedUncategorizedCollection != null) {
+      return cachedUncategorizedCollection!;
+    }
+    final int userID = config.getUserID()!;
+    final Collection? matchedCollection =
+        collectionIDToCollections.values.firstWhereOrNull(
+      (element) =>
+          element.type == CollectionType.uncategorized &&
+          element.owner!.id == userID,
+    );
+    if (matchedCollection != null) {
+      cachedUncategorizedCollection = matchedCollection;
+      return cachedUncategorizedCollection!;
+    }
+    return _createUncategorizedCollection();
+  }
+
   Future<bool> hideFiles(
     BuildContext context,
     List<File> filesToHide, {
@@ -113,6 +133,25 @@ extension HiddenService on CollectionsService {
     return collectionFromServer;
   }
 
+  Future<Collection> _createUncategorizedCollection() async {
+    final key = CryptoUtil.generateKey();
+    final encKey = CryptoUtil.encryptSync(key, config.getKey()!);
+    final encName =
+        CryptoUtil.encryptSync(utf8.encode("Uncategorized") as Uint8List, key);
+    final collection = await createAndCacheCollection(
+      CreateRequest(
+        encryptedKey: Sodium.bin2base64(encKey.encryptedData!),
+        keyDecryptionNonce: Sodium.bin2base64(encKey.nonce!),
+        encryptedName: Sodium.bin2base64(encName.encryptedData!),
+        nameDecryptionNonce: Sodium.bin2base64(encName.nonce!),
+        type: CollectionType.uncategorized,
+        attributes: CollectionAttributes(),
+      ),
+    );
+    cachedUncategorizedCollection = collection;
+    return cachedUncategorizedCollection!;
+  }
+
   Future<CreateRequest> buildCollectionCreateRequest(
     String name, {
     required int visibility,

+ 3 - 1
lib/services/search_service.dart

@@ -6,6 +6,7 @@ import 'package:photos/data/months.dart';
 import 'package:photos/data/years.dart';
 import 'package:photos/db/files_db.dart';
 import 'package:photos/events/local_photos_updated_event.dart';
+import 'package:photos/models/collection.dart';
 import 'package:photos/models/collection_items.dart';
 import 'package:photos/models/file.dart';
 import 'package:photos/models/file_type.dart';
@@ -114,7 +115,8 @@ class SearchService {
         break;
       }
 
-      if (!c.collection.isHidden() &&
+      if (!c.collection.isHidden() && c.collection.type != CollectionType
+          .uncategorized &&
           c.collection.name!.toLowerCase().contains(
                 query.toLowerCase(),
               )) {

+ 53 - 1
lib/ui/actions/collection/collection_file_actions.dart

@@ -7,10 +7,61 @@ import 'package:photos/services/favorites_service.dart';
 import 'package:photos/theme/ente_theme.dart';
 import 'package:photos/ui/actions/collection/collection_sharing_actions.dart';
 import 'package:photos/ui/common/progress_dialog.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';
 import 'package:photos/utils/dialog_util.dart';
 import 'package:photos/utils/toast_util.dart';
 
 extension CollectionFileActions on CollectionActions {
+  Future<void> showRemoveFromCollectionSheetV2(
+    BuildContext bContext,
+    Collection collection,
+    SelectedFiles selectedFiles,
+  ) async {
+    final actionResult = await showActionSheet(
+      context: bContext,
+      buttons: [
+        ButtonWidget(
+          labelText: "Yes, remove",
+          buttonType: ButtonType.neutral,
+          buttonSize: ButtonSize.large,
+          shouldStickToDarkTheme: true,
+          isInAlert: true,
+          onTap: () async {
+            try {
+              await moveFilesFromCurrentCollection(
+                bContext,
+                collection,
+                selectedFiles.files,
+              );
+            } catch (e) {
+              logger.severe("Failed to move files", e);
+              rethrow;
+            }
+          },
+        ),
+        const ButtonWidget(
+          labelText: "Cancel",
+          buttonType: ButtonType.secondary,
+          buttonSize: ButtonSize.large,
+          buttonAction: ButtonAction.second,
+          shouldStickToDarkTheme: true,
+          isInAlert: true,
+        ),
+      ],
+      title: "Remove from album?",
+      body: "Selected items will be removed from this album. Items which are "
+          "only in this album will be moved to Uncategorized.",
+      actionSheetType: ActionSheetType.defaultActionSheet,
+    );
+    if (actionResult != null && actionResult == ButtonAction.error) {
+      showGenericErrorDialog(context: bContext);
+    } else {
+      selectedFiles.clearAll();
+    }
+  }
+
   Future<void> showRemoveFromCollectionSheet(
     BuildContext context,
     Collection collection,
@@ -120,7 +171,8 @@ extension CollectionFileActions on CollectionActions {
     await dialog.show();
 
     try {
-      await FavoritesService.instance.updateFavorites(files, markAsFavorite);
+      await FavoritesService.instance
+          .updateFavorites(context, files, markAsFavorite);
       return true;
     } catch (e, s) {
       logger.severe(e, s);

+ 246 - 7
lib/ui/actions/collection/collection_sharing_actions.dart

@@ -1,15 +1,19 @@
 import 'package:flutter/material.dart';
 import 'package:logging/logging.dart';
 import 'package:photos/core/configuration.dart';
+import 'package:photos/db/files_db.dart';
 import 'package:photos/ente_theme_data.dart';
 import 'package:photos/models/api/collection/create_request.dart';
 import 'package:photos/models/collection.dart';
 import 'package:photos/models/file.dart';
+import 'package:photos/models/files_split.dart';
 import 'package:photos/models/magic_metadata.dart';
 import 'package:photos/services/collections_service.dart';
 import 'package:photos/services/hidden_service.dart';
 import 'package:photos/services/user_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';
 import 'package:photos/ui/payment/subscription.dart';
 import 'package:photos/utils/date_time_util.dart';
 import 'package:photos/utils/dialog_util.dart';
@@ -30,15 +34,38 @@ class CollectionActions {
   ) async {
     // confirm if user wants to disable the url
     if (!enable) {
-      final choice = await showNewChoiceDialog(
-        context,
-        title: "Remove public link",
+      final ButtonAction? result = await showActionSheet(
+        context: context,
+        buttons: [
+          ButtonWidget(
+            buttonType: ButtonType.critical,
+            isInAlert: true,
+            shouldStickToDarkTheme: true,
+            buttonAction: ButtonAction.first,
+            shouldSurfaceExecutionStates: true,
+            labelText: "Yes, remove",
+            onTap: () async {
+              await CollectionsService.instance.disableShareUrl(collection);
+            },
+          ),
+          const ButtonWidget(
+            buttonType: ButtonType.secondary,
+            buttonAction: ButtonAction.second,
+            isInAlert: true,
+            shouldStickToDarkTheme: true,
+            labelText: "Cancel",
+          )
+        ],
+        title: "Remove public link?",
         body:
-            'This will remove the public link for accessing "${collection.name}"',
-        firstButtonLabel: "Yes, remove",
+            'This will remove the public link for accessing "${collection.name}".',
       );
-      if (choice != ButtonAction.first) {
-        return false;
+      if (result != null) {
+        if (result == ButtonAction.error) {
+          showGenericErrorDialog(context: context);
+        }
+        // return
+        return result == ButtonAction.first;
       }
     }
     final dialog = createProgressDialog(
@@ -239,6 +266,218 @@ class CollectionActions {
     }
   }
 
+  Future<bool> deleteCollectionSheet(
+    BuildContext bContext,
+    Collection collection,
+  ) async {
+    final currentUserID = Configuration.instance.getUserID()!;
+    if (collection.owner!.id != currentUserID) {
+      throw AssertionError("Can not delete album owned by others");
+    }
+    final actionResult = await showActionSheet(
+      context: bContext,
+      buttons: [
+        ButtonWidget(
+          labelText: "Keep Photos",
+          buttonType: ButtonType.neutral,
+          buttonSize: ButtonSize.large,
+          buttonAction: ButtonAction.first,
+          shouldStickToDarkTheme: true,
+          isInAlert: true,
+          onTap: () async {
+            try {
+              final List<File> files =
+                  await FilesDB.instance.getAllFilesCollection(collection.id);
+              await moveFilesFromCurrentCollection(bContext, collection, files);
+              // collection should be empty on server now
+              await collectionsService.trashEmptyCollection(collection);
+            } catch (e) {
+              logger.severe("Failed to keep photos and delete collection", e);
+              rethrow;
+            }
+          },
+        ),
+        ButtonWidget(
+          labelText: "Delete photos",
+          buttonType: ButtonType.critical,
+          buttonSize: ButtonSize.large,
+          buttonAction: ButtonAction.second,
+          shouldStickToDarkTheme: true,
+          isInAlert: true,
+          onTap: () async {
+            try {
+              await collectionsService.trashNonEmptyCollection(collection);
+            } catch (e) {
+              logger.severe("Failed to delete collection", e);
+              rethrow;
+            }
+          },
+        ),
+        const ButtonWidget(
+          labelText: "Cancel",
+          buttonType: ButtonType.secondary,
+          buttonSize: ButtonSize.large,
+          buttonAction: ButtonAction.third,
+          shouldStickToDarkTheme: true,
+          isInAlert: true,
+        ),
+      ],
+      title: "Delete album?",
+      body: "This album will be deleted. Do you also want to delete the "
+          "photos (and videos) that are present in this album?",
+      bodyHighlight: "They will be deleted from all albums.",
+      actionSheetType: ActionSheetType.defaultActionSheet,
+    );
+    if (actionResult != null && actionResult == ButtonAction.error) {
+      showGenericErrorDialog(context: bContext);
+      return false;
+    }
+    if (actionResult != null &&
+        (actionResult == ButtonAction.first ||
+            actionResult == ButtonAction.second)) {
+      return true;
+    }
+    return false;
+  }
+
+  /*
+  _moveFilesFromCurrentCollection removes the file from the current
+  collection. Based on the file and collection ownership, files will be
+  either moved to different collection (Case A). or will just get removed
+  from current collection (Case B).
+  -------------------------------
+  Case A: Files and collection belong to the same user. Such files
+  will be moved to a collection which belongs to the user and removed from
+  the current collection as part of move operation.
+  Note: Even files are present in the
+  destination collection, we need to make move API call on the server
+  so that the files are removed from current collection and are actually
+  moved to a collection owned by the user.
+  -------------------------------
+  Case B: Owner of files and collections are different. In such cases,
+  we will just remove (not move) the files from the given collection.
+  */
+  Future<void> moveFilesFromCurrentCollection(
+    BuildContext context,
+    Collection collection,
+    Iterable<File> files,
+  ) async {
+    final int currentUserID = Configuration.instance.getUserID()!;
+    final isCollectionOwner = collection.owner!.id == currentUserID;
+    if (!isCollectionOwner) {
+      // Todo: Support for removing own files from a collection owner by
+      // someone else will be added along with collaboration changes
+      showShortToast(context, "Only collection owner can remove");
+      return;
+    }
+    final FilesSplit split = FilesSplit.split(
+      files,
+      Configuration.instance.getUserID()!,
+    );
+    if (split.ownedByOtherUsers.isNotEmpty) {
+      //  Todo: Support for removing own files from a collection owner by
+      // someone else will be added along with collaboration changes
+      showShortToast(context, "Can only remove files owned by you");
+      return;
+    }
+
+    // pendingAssignMap keeps a track of files which are yet to be assigned to
+    // to destination collection.
+    final Map<int, File> pendingAssignMap = {};
+    // destCollectionToFilesMap contains the destination collection and
+    // files entry which needs to be moved in destination.
+    // After the end of mapping logic, the number of files entries in
+    // pendingAssignMap should be equal to files in destCollectionToFilesMap
+    final Map<int, List<File>> destCollectionToFilesMap = {};
+    final List<int> uploadedIDs = [];
+    for (File f in split.ownedByCurrentUser) {
+      if (f.uploadedFileID != null) {
+        pendingAssignMap[f.uploadedFileID!] = f;
+        uploadedIDs.add(f.uploadedFileID!);
+      }
+    }
+
+    final Map<int, List<File>> collectionToFilesMap =
+        await FilesDB.instance.getAllFilesGroupByCollectionID(uploadedIDs);
+
+    // Find and map the files from current collection to to entries in other
+    // collections. This mapping is done to avoid moving all the files to
+    // uncategorized during remove from album.
+    for (MapEntry<int, List<File>> entry in collectionToFilesMap.entries) {
+      if (!_isAutoMoveCandidate(collection.id, entry.key, currentUserID)) {
+        continue;
+      }
+      final targetCollection = collectionsService.getCollectionByID(entry.key)!;
+      // for each file which already exist in the destination collection
+      // add entries in the moveDestCollectionToFiles map
+      for (File file in entry.value) {
+        // Check if the uploaded file is still waiting to be mapped
+        if (pendingAssignMap.containsKey(file.uploadedFileID)) {
+          if (!destCollectionToFilesMap.containsKey(targetCollection.id)) {
+            destCollectionToFilesMap[targetCollection.id] = <File>[];
+          }
+          destCollectionToFilesMap[targetCollection.id]!
+              .add(pendingAssignMap[file.uploadedFileID!]!);
+          pendingAssignMap.remove(file.uploadedFileID);
+        }
+      }
+    }
+    // Move the remaining files to uncategorized collection
+    if (pendingAssignMap.isNotEmpty) {
+      final Collection uncategorizedCollection =
+          await collectionsService.getUncategorizedCollection();
+      final int toCollectionID = uncategorizedCollection.id;
+      for (MapEntry<int, File> entry in pendingAssignMap.entries) {
+        final file = entry.value;
+        if (pendingAssignMap.containsKey(file.uploadedFileID)) {
+          if (!destCollectionToFilesMap.containsKey(toCollectionID)) {
+            destCollectionToFilesMap[toCollectionID] = <File>[];
+          }
+          destCollectionToFilesMap[toCollectionID]!
+              .add(pendingAssignMap[file.uploadedFileID!]!);
+        }
+      }
+    }
+
+    // Verify that all files are mapped.
+    int mappedFilesCount = 0;
+    destCollectionToFilesMap.forEach((key, value) {
+      mappedFilesCount += value.length;
+    });
+    if (mappedFilesCount != uploadedIDs.length) {
+      throw AssertionError(
+        "Failed to map all files toMap: ${uploadedIDs.length} and mapped "
+        "$mappedFilesCount",
+      );
+    }
+
+    for (MapEntry<int, List<File>> entry in destCollectionToFilesMap.entries) {
+      await collectionsService.move(entry.key, collection.id, entry.value);
+    }
+  }
+
+  // This method returns true if the given destination collection is a good
+  // target to moving files during file remove or delete collection but keey
+  // photos action. Uncategorized or favorite type of collections are not
+  // good auto-move candidates. Uncategorized will be fall back for all files
+  // which could not be mapped to a potential target collection
+  bool _isAutoMoveCandidate(int fromCollectionID, toCollectionID, int userID) {
+    if (fromCollectionID == toCollectionID) {
+      return false;
+    }
+    final Collection? targetCollection =
+        collectionsService.getCollectionByID(toCollectionID);
+    // ignore non-cached collections, uncategorized and favorite
+    // collections and collections ignored by others
+    if (targetCollection == null ||
+        (CollectionType.uncategorized == targetCollection.type ||
+            targetCollection.type == CollectionType.favorites) ||
+        targetCollection.owner!.id != userID) {
+      return false;
+    }
+    return true;
+  }
+
   void _showUnSupportedAlert(BuildContext context) {
     final AlertDialog alert = AlertDialog(
       title: const Text("Sorry"),

+ 112 - 0
lib/ui/collections/uncat_collections_button_widget.dart

@@ -0,0 +1,112 @@
+import 'package:collection/collection.dart';
+import 'package:flutter/material.dart';
+import 'package:photos/db/files_db.dart';
+import 'package:photos/models/collection.dart';
+import 'package:photos/services/collections_service.dart';
+import 'package:photos/services/hidden_service.dart';
+import 'package:photos/ui/viewer/gallery/uncategorized_page.dart';
+import 'package:photos/utils/navigation_util.dart';
+
+class UnCatCollectionsButtonWidget extends StatelessWidget {
+  final TextStyle textStyle;
+
+  const UnCatCollectionsButtonWidget(
+    this.textStyle, {
+    Key? key,
+  }) : super(key: key);
+
+  @override
+  Widget build(BuildContext context) {
+    final Collection? collection = CollectionsService.instance
+        .getActiveCollections()
+        .firstWhereOrNull((e) => e.type == CollectionType.uncategorized);
+    if (collection == null) {
+      // create uncategorized collection if it's not already created
+      CollectionsService.instance.getUncategorizedCollection().ignore();
+    }
+    return OutlinedButton(
+      style: OutlinedButton.styleFrom(
+        backgroundColor: Theme.of(context).backgroundColor,
+        shape: RoundedRectangleBorder(
+          borderRadius: BorderRadius.circular(8),
+        ),
+        padding: const EdgeInsets.all(0),
+        side: BorderSide(
+          width: 0.5,
+          color: Theme.of(context).iconTheme.color!.withOpacity(0.24),
+        ),
+      ),
+      child: SizedBox(
+        height: 48,
+        width: double.infinity,
+        child: Padding(
+          padding: const EdgeInsets.symmetric(horizontal: 16),
+          child: Row(
+            mainAxisAlignment: MainAxisAlignment.spaceBetween,
+            children: [
+              Row(
+                children: [
+                  Icon(
+                    Icons.category_outlined,
+                    color: Theme.of(context).iconTheme.color,
+                  ),
+                  const Padding(padding: EdgeInsets.all(6)),
+                  FutureBuilder<int>(
+                    future: collection == null
+                        ? Future.value(0)
+                        : FilesDB.instance.collectionFileCount(collection.id),
+                    builder: (context, snapshot) {
+                      if (snapshot.hasData && snapshot.data! > 0) {
+                        return RichText(
+                          text: TextSpan(
+                            style: textStyle,
+                            children: [
+                              TextSpan(
+                                text: "Uncategorized",
+                                style: Theme.of(context).textTheme.subtitle1,
+                              ),
+                              const TextSpan(text: "  \u2022  "),
+                              TextSpan(
+                                text: snapshot.data.toString(),
+                              ),
+                              //need to query in db and bring this value
+                            ],
+                          ),
+                        );
+                      } else {
+                        return RichText(
+                          text: TextSpan(
+                            style: textStyle,
+                            children: [
+                              TextSpan(
+                                text: "Uncategorized",
+                                style: Theme.of(context).textTheme.subtitle1,
+                              ),
+                              //need to query in db and bring this value
+                            ],
+                          ),
+                        );
+                      }
+                    },
+                  ),
+                ],
+              ),
+              Icon(
+                Icons.chevron_right,
+                color: Theme.of(context).iconTheme.color,
+              ),
+            ],
+          ),
+        ),
+      ),
+      onPressed: () async {
+        if (collection != null) {
+          routeToPage(
+            context,
+            UnCategorizedPage(collection),
+          );
+        }
+      },
+    );
+  }
+}

+ 8 - 0
lib/ui/collections_gallery_widget.dart

@@ -19,6 +19,7 @@ import 'package:photos/ui/collections/hidden_collections_button_widget.dart';
 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/collections/uncat_collections_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';
@@ -83,10 +84,15 @@ class _CollectionsGalleryWidgetState extends State<CollectionsGalleryWidget>
   Future<List<CollectionWithThumbnail>> _getCollections() async {
     final List<CollectionWithThumbnail> collectionsWithThumbnail =
         await CollectionsService.instance.getCollectionsWithThumbnails();
+
+    // Remove uncategorized collection
+    collectionsWithThumbnail
+        .removeWhere((t) => t.collection.type == CollectionType.uncategorized);
     final ListMatch<CollectionWithThumbnail> favMathResult =
         collectionsWithThumbnail.splitMatch(
       (element) => element.collection.type == CollectionType.favorites,
     );
+
     // Hide fav collection if it's empty and not shared
     favMathResult.matched.removeWhere(
       (element) =>
@@ -168,6 +174,8 @@ class _CollectionsGalleryWidgetState extends State<CollectionsGalleryWidget>
               padding: const EdgeInsets.symmetric(horizontal: 16),
               child: Column(
                 children: [
+                  UnCatCollectionsButtonWidget(trashAndHiddenTextStyle),
+                  const SizedBox(height: 12),
                   ArchivedCollectionsButtonWidget(trashAndHiddenTextStyle),
                   const SizedBox(height: 12),
                   HiddenCollectionsButtonWidget(trashAndHiddenTextStyle),

+ 1 - 1
lib/ui/components/action_sheet_widget.dart

@@ -18,7 +18,7 @@ enum ActionSheetType {
 Future<ButtonAction?> showActionSheet({
   required BuildContext context,
   required List<ButtonWidget> buttons,
-  required ActionSheetType actionSheetType,
+  ActionSheetType actionSheetType = ActionSheetType.defaultActionSheet,
   bool enableDrag = true,
   bool isDismissible = true,
   bool isCheckIconGreen = false,

+ 3 - 5
lib/ui/components/button_widget.dart

@@ -438,11 +438,9 @@ class _ButtonChildWidgetState extends State<ButtonChildWidget> {
             executionState = ExecutionState.successful;
             Future.delayed(
                 Duration(
-                  seconds: widget.isInAlert
-                      ? 1
-                      : widget.shouldSurfaceExecutionStates
-                          ? 2
-                          : 0,
+                  seconds: widget.shouldSurfaceExecutionStates
+                      ? (widget.isInAlert ? 1 : 2)
+                      : 0,
                 ), () {
               widget.isInAlert
                   ? Navigator.of(context, rootNavigator: true)

+ 1 - 0
lib/ui/settings/backup_section_widget.dart

@@ -193,6 +193,7 @@ class BackupSectionWidgetState extends State<BackupSectionWidget> {
     final String countText = result.count.toString() +
         " duplicate file" +
         (result.count == 1 ? "" : "s");
+
     final DialogWidget dialog = choiceDialog(
       title: "✨ Success",
       body: "You have cleaned up " +

+ 9 - 3
lib/ui/viewer/actions/delete_empty_albums.dart

@@ -1,6 +1,7 @@
 import 'package:flutter/material.dart';
 import 'package:photos/core/event_bus.dart';
 import 'package:photos/events/collection_updated_event.dart';
+import 'package:photos/models/collection.dart';
 import 'package:photos/models/file.dart';
 import 'package:photos/services/collections_service.dart';
 import 'package:photos/ui/components/action_sheet_widget.dart';
@@ -86,7 +87,11 @@ class _DeleteEmptyAlbumsState extends State<DeleteEmptyAlbums> {
   Future<void> _deleteEmptyAlbums() async {
     final collections =
         await CollectionsService.instance.getCollectionsWithThumbnails();
-    collections.removeWhere((element) => element.thumbnail != null);
+    // remove collections which are not empty or can't be deleted
+    collections.removeWhere(
+      (element) =>
+          element.thumbnail != null || !element.collection.type.canDelete,
+    );
     int failedCount = 0;
     for (int i = 0; i < collections.length; i++) {
       if (mounted && !_isCancelled) {
@@ -94,8 +99,9 @@ class _DeleteEmptyAlbumsState extends State<DeleteEmptyAlbums> {
             "Deleting ${(i + 1).toString().padLeft(collections.length.toString().length, '0')} / "
             "${collections.length}";
         try {
-          await CollectionsService.instance
-              .trashEmptyCollection(collections[i].collection);
+          await CollectionsService.instance.trashEmptyCollection(
+              collections[i].collection,
+              isBulkDelete: true);
         } catch (_) {
           failedCount++;
         }

+ 6 - 5
lib/ui/viewer/actions/file_selection_actions_widget.dart

@@ -5,9 +5,10 @@ import 'package:page_transition/page_transition.dart';
 import 'package:photos/core/configuration.dart';
 import 'package:photos/models/collection.dart';
 import 'package:photos/models/device_collection.dart';
+import 'package:photos/models/file.dart';
+import 'package:photos/models/files_split.dart';
 import 'package:photos/models/gallery_type.dart';
 import 'package:photos/models/magic_metadata.dart';
-import 'package:photos/models/selected_file_breakup.dart';
 import 'package:photos/models/selected_files.dart';
 import 'package:photos/services/collections_service.dart';
 import 'package:photos/services/hidden_service.dart';
@@ -47,7 +48,7 @@ class FileSelectionActionWidget extends StatefulWidget {
 
 class _FileSelectionActionWidgetState extends State<FileSelectionActionWidget> {
   late int currentUserID;
-  late SelectedFileSplit split;
+  late FilesSplit split;
   late CollectionActions collectionActions;
 
   // _cachedCollectionForSharedLink is primarly used to avoid creating duplicate
@@ -58,7 +59,7 @@ class _FileSelectionActionWidgetState extends State<FileSelectionActionWidget> {
   @override
   void initState() {
     currentUserID = Configuration.instance.getUserID()!;
-    split = widget.selectedFiles.split(currentUserID);
+    split = FilesSplit.split(<File>[], currentUserID);
     widget.selectedFiles.addListener(_selectFileChangeListener);
     collectionActions = CollectionActions(CollectionsService.instance);
     super.initState();
@@ -74,7 +75,7 @@ class _FileSelectionActionWidgetState extends State<FileSelectionActionWidget> {
     if (_cachedCollectionForSharedLink != null) {
       _cachedCollectionForSharedLink = null;
     }
-    split = widget.selectedFiles.split(currentUserID);
+    split = FilesSplit.split(widget.selectedFiles.files, currentUserID);
     if (mounted) {
       setState(() => {});
     }
@@ -276,7 +277,7 @@ class _FileSelectionActionWidgetState extends State<FileSelectionActionWidget> {
       widget.selectedFiles
           .unSelectAll(split.ownedByOtherUsers.toSet(), skipNotify: true);
     }
-    await collectionActions.showRemoveFromCollectionSheet(
+    await collectionActions.showRemoveFromCollectionSheetV2(
       context,
       widget.collection!,
       widget.selectedFiles,

+ 2 - 2
lib/ui/viewer/file/fading_app_bar.dart

@@ -312,7 +312,7 @@ class FadingAppBarState extends State<FadingAppBar> {
             await dialog.show();
           }
           try {
-            await FavoritesService.instance.addToFavorites(file);
+            await FavoritesService.instance.addToFavorites(context, file);
           } catch (e, s) {
             _logger.severe(e, s);
             hasError = true;
@@ -324,7 +324,7 @@ class FadingAppBarState extends State<FadingAppBar> {
           }
         } else {
           try {
-            await FavoritesService.instance.removeFromFavorites(file);
+            await FavoritesService.instance.removeFromFavorites(context, file);
           } catch (e, s) {
             _logger.severe(e, s);
             hasError = true;

+ 4 - 1
lib/ui/viewer/file/thumbnail_widget.dart

@@ -241,7 +241,10 @@ class _ThumbnailWidgetState extends State<ThumbnailWidget> {
         _cacheAndRender(imageProvider);
       }
       ThumbnailInMemoryLruCache.put(
-          widget.file!, thumbData, thumbnailSmallSize);
+        widget.file!,
+        thumbData,
+        thumbnailSmallSize,
+      );
     }).catchError((e) {
       _logger.warning("Could not load image: ", e);
       _errorLoadingLocalThumbnail = true;

+ 5 - 2
lib/ui/viewer/gallery/collection_page.dart

@@ -3,6 +3,7 @@ import 'package:photos/core/event_bus.dart';
 import 'package:photos/db/files_db.dart';
 import 'package:photos/events/collection_updated_event.dart';
 import 'package:photos/events/files_updated_event.dart';
+import 'package:photos/models/collection.dart';
 import 'package:photos/models/collection_items.dart';
 import 'package:photos/models/file.dart';
 import 'package:photos/models/file_load_result.dart';
@@ -55,6 +56,8 @@ class _CollectionPageState extends State<CollectionPage> {
     if (widget.hasVerifiedLock == false && widget.c.collection.isHidden()) {
       return const EmptyState();
     }
+    final appBarTypeValue = widget.c.collection.type == CollectionType
+        .uncategorized ? GalleryType.uncategorized : widget.appBarType;
     final List<File>? initialFiles =
         widget.c.thumbnail != null ? [widget.c.thumbnail!] : null;
     final gallery = Gallery(
@@ -93,7 +96,7 @@ class _CollectionPageState extends State<CollectionPage> {
       appBar: PreferredSize(
         preferredSize: const Size.fromHeight(50.0),
         child: GalleryAppBarWidget(
-          widget.appBarType,
+          appBarTypeValue,
           widget.c.collection.name,
           _selectedFiles,
           collection: widget.c.collection,
@@ -104,7 +107,7 @@ class _CollectionPageState extends State<CollectionPage> {
         children: [
           gallery,
           FileSelectionOverlayBar(
-            widget.appBarType,
+            appBarTypeValue,
             _selectedFiles,
             collection: widget.c.collection,
           )

+ 10 - 5
lib/ui/viewer/gallery/gallery.dart

@@ -258,11 +258,16 @@ class _GalleryState extends State<Gallery> {
         return gallery;
       },
       labelTextBuilder: (int index) {
-        return getMonthAndYear(
-          DateTime.fromMicrosecondsSinceEpoch(
-            _collatedFiles[index][0].creationTime!,
-          ),
-        );
+        try {
+          return getMonthAndYear(
+            DateTime.fromMicrosecondsSinceEpoch(
+              _collatedFiles[index][0].creationTime!,
+            ),
+          );
+        } catch (e) {
+          _logger.severe("label text builder failed", e);
+          return "";
+        }
       },
       thumbBackgroundColor:
           Theme.of(context).colorScheme.galleryThumbBackgroundColor,

+ 26 - 27
lib/ui/viewer/gallery/gallery_app_bar_widget.dart

@@ -16,7 +16,7 @@ import 'package:photos/models/selected_files.dart';
 import 'package:photos/services/collections_service.dart';
 import 'package:photos/services/sync_service.dart';
 import 'package:photos/services/update_service.dart';
-import 'package:photos/ui/common/dialogs.dart';
+import 'package:photos/ui/actions/collection/collection_sharing_actions.dart';
 import 'package:photos/ui/common/rename_dialog.dart';
 import 'package:photos/ui/components/button_widget.dart';
 import 'package:photos/ui/components/dialog_widget.dart';
@@ -55,6 +55,7 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
   late StreamSubscription _userAuthEventSubscription;
   late Function() _selectedFilesListener;
   String? _appBarTitle;
+  late CollectionActions collectionActions;
   final GlobalKey shareButtonKey = GlobalKey();
 
   @override
@@ -62,6 +63,7 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
     _selectedFilesListener = () {
       setState(() {});
     };
+    collectionActions = CollectionActions(CollectionsService.instance);
     widget.selectedFiles.addListener(_selectedFilesListener);
     _userAuthEventSubscription =
         Bus.instance.on<SubscriptionPurchasedEvent>().listen((event) {
@@ -369,35 +371,32 @@ class _GalleryAppBarWidgetState extends State<GalleryAppBarWidget> {
             )
             ?.thumbnail ==
         null;
-    if (!isEmptyCollection) {
-      final result = await showChoiceDialog(
+    if (isEmptyCollection) {
+      final dialog = createProgressDialog(
         context,
-        "Delete album?",
-        "Files that are unique to this album "
-            "will be moved to trash, and this album will be deleted.",
-        firstAction: "Cancel",
-        secondAction: "Delete album",
-        secondActionColor: Colors.red,
+        "Please wait, deleting album",
       );
-      if (result != DialogUserChoice.secondChoice) {
-        return;
+      await dialog.show();
+      try {
+        await CollectionsService.instance
+            .trashEmptyCollection(widget.collection!);
+        await dialog.hide();
+        Navigator.of(context).pop();
+      } catch (e, s) {
+        _logger.severe("failed to trash collection", e, s);
+        await dialog.hide();
+        showGenericErrorDialog(context: context);
+      }
+    } else {
+      final bool result = await collectionActions.deleteCollectionSheet(
+        context,
+        widget.collection!,
+      );
+      if (result == true) {
+        Navigator.of(context).pop();
+      } else {
+        debugPrint("No pop");
       }
-    }
-    final dialog = createProgressDialog(
-      context,
-      "Please wait, deleting album",
-    );
-    await dialog.show();
-    try {
-      await CollectionsService.instance
-          .trashCollection(widget.collection!, isEmptyCollection);
-      showShortToast(context, "Successfully deleted album");
-      await dialog.hide();
-      Navigator.of(context).pop();
-    } catch (e, s) {
-      _logger.severe("failed to trash collection", e, s);
-      await dialog.hide();
-      showGenericErrorDialog(context: context);
     }
   }
 

+ 85 - 0
lib/ui/viewer/gallery/uncategorized_page.dart

@@ -0,0 +1,85 @@
+import 'package:flutter/material.dart';
+import 'package:photos/core/event_bus.dart';
+import 'package:photos/db/files_db.dart';
+import 'package:photos/events/collection_updated_event.dart';
+import 'package:photos/events/files_updated_event.dart';
+import 'package:photos/models/collection.dart';
+import 'package:photos/models/file_load_result.dart';
+import 'package:photos/models/gallery_type.dart';
+import 'package:photos/models/selected_files.dart';
+import 'package:photos/services/ignored_files_service.dart';
+import 'package:photos/ui/viewer/actions/file_selection_overlay_bar.dart';
+import 'package:photos/ui/viewer/gallery/gallery.dart';
+import 'package:photos/ui/viewer/gallery/gallery_app_bar_widget.dart';
+
+class UnCategorizedPage extends StatelessWidget {
+  final String tagPrefix;
+  final Collection collection;
+  final GalleryType appBarType;
+  final GalleryType overlayType;
+  final _selectedFiles = SelectedFiles();
+
+  UnCategorizedPage(
+    this.collection, {
+    this.tagPrefix = "Uncategorized_page",
+    this.appBarType = GalleryType.uncategorized,
+    this.overlayType = GalleryType.uncategorized,
+    Key? key,
+  }) : super(key: key);
+
+  @override
+  Widget build(Object context) {
+    final gallery = Gallery(
+      asyncLoader: (creationStartTime, creationEndTime, {limit, asc}) async {
+        final FileLoadResult result =
+            await FilesDB.instance.getFilesInCollection(
+          collection.id,
+          creationStartTime,
+          creationEndTime,
+          limit: limit,
+          asc: asc,
+        );
+        // hide ignored files from home page UI
+        final ignoredIDs = await IgnoredFilesService.instance.ignoredIDs;
+        result.files.removeWhere(
+          (f) =>
+              f.uploadedFileID == null &&
+              IgnoredFilesService.instance.shouldSkipUpload(ignoredIDs, f),
+        );
+        return result;
+      },
+      reloadEvent: Bus.instance
+          .on<CollectionUpdatedEvent>()
+          .where((event) => event.collectionID == collection.id),
+      removalEventTypes: const {
+        EventType.deletedFromRemote,
+        EventType.deletedFromEverywhere,
+        EventType.hide,
+      },
+      tagPrefix: tagPrefix,
+      selectedFiles: _selectedFiles,
+      initialFiles: null,
+      albumName: "Uncategorized",
+    );
+    return Scaffold(
+      appBar: PreferredSize(
+        preferredSize: const Size.fromHeight(50.0),
+        child: GalleryAppBarWidget(
+          appBarType,
+          "Uncategorized",
+          _selectedFiles,
+        ),
+      ),
+      body: Stack(
+        alignment: Alignment.bottomCenter,
+        children: [
+          gallery,
+          FileSelectionOverlayBar(
+            overlayType,
+            _selectedFiles,
+          ),
+        ],
+      ),
+    );
+  }
+}