From 297316c7b85d3da3944f77534c573fe7bffbcdd0 Mon Sep 17 00:00:00 2001 From: shenlong <139912620+shenlong-tanwen@users.noreply.github.com> Date: Fri, 17 Jul 2026 01:19:30 +0530 Subject: [PATCH] refactor: owned action assets filter (#29382) Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> --- .../models/asset/remote_asset.model.dart | 4 + .../presentation/actions/favorite.action.dart | 20 +- mobile/lib/utils/asset_filter.dart | 22 ++ .../unit/factories/remote_asset_factory.dart | 11 +- mobile/test/unit/utils/asset_filter_test.dart | 200 ++++++++++++++++++ 5 files changed, 245 insertions(+), 12 deletions(-) create mode 100644 mobile/lib/utils/asset_filter.dart create mode 100644 mobile/test/unit/utils/asset_filter_test.dart diff --git a/mobile/lib/domain/models/asset/remote_asset.model.dart b/mobile/lib/domain/models/asset/remote_asset.model.dart index 3709627cfb..40e681d47d 100644 --- a/mobile/lib/domain/models/asset/remote_asset.model.dart +++ b/mobile/lib/domain/models/asset/remote_asset.model.dart @@ -70,6 +70,10 @@ class RemoteAsset extends BaseAsset { bool get isTrashed => deletedAt != null; + bool get isStacked => stackId != null; + + bool get isArchived => visibility == .archive; + @override String toString() { return '''Asset { diff --git a/mobile/lib/presentation/actions/favorite.action.dart b/mobile/lib/presentation/actions/favorite.action.dart index 33d4bb3b6c..a480e10a5f 100644 --- a/mobile/lib/presentation/actions/favorite.action.dart +++ b/mobile/lib/presentation/actions/favorite.action.dart @@ -3,25 +3,23 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/actions/action.dart'; import 'package:immich_mobile/providers/infrastructure/asset.provider.dart'; +import 'package:immich_mobile/utils/asset_filter.dart'; import 'package:immich_ui/immich_ui.dart'; class FavoriteAction extends AssetAction { - final bool shouldFavorite; + final bool favorite; - FavoriteAction({required super.assets}) : shouldFavorite = assets.any((asset) => !asset.isFavorite); + FavoriteAction({required super.assets}) : favorite = assets.any((asset) => !asset.isFavorite); @override - IconData get icon => shouldFavorite ? Icons.favorite_border_rounded : Icons.favorite_rounded; + IconData get icon => favorite ? Icons.favorite_border_rounded : Icons.favorite_rounded; @override - String label(ActionScope scope) => shouldFavorite ? scope.context.t.favorite : scope.context.t.unfavorite; + String label(ActionScope scope) => favorite ? scope.context.t.favorite : scope.context.t.unfavorite; @override - Iterable filter(ActionScope scope) => assets - .where( - (asset) => asset is RemoteAsset && asset.ownerId == scope.authUser.id && asset.isFavorite == !shouldFavorite, - ) - .cast(); + Iterable filter(ActionScope scope) => + AssetFilter(assets).owned(scope.authUser.id).favorite(isFavorite: !favorite); @override bool isVisible(ActionScope scope) => filter(scope).isNotEmpty; @@ -31,8 +29,8 @@ class FavoriteAction extends AssetAction { final ActionScope(:ref) = scope; final assets = filter(scope).map((asset) => asset.id).toList(growable: false); - await ref.read(assetServiceProvider).updateFavorite(assets, shouldFavorite); - final message = shouldFavorite + await ref.read(assetServiceProvider).updateFavorite(assets, favorite); + final message = favorite ? StaticTranslations.instance.favorite_action_prompt(count: assets.length) : StaticTranslations.instance.unfavorite_action_prompt(count: assets.length); snackbar.success(message); diff --git a/mobile/lib/utils/asset_filter.dart b/mobile/lib/utils/asset_filter.dart new file mode 100644 index 0000000000..34879fa778 --- /dev/null +++ b/mobile/lib/utils/asset_filter.dart @@ -0,0 +1,22 @@ +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; + +extension type const AssetFilter(Iterable assets) implements Iterable { + AssetFilter where(bool Function(T asset) test) => AssetFilter(assets.where(test)); + AssetFilter whereNot(bool Function(T asset) test) => AssetFilter(assets.where((asset) => !test(asset))); + + AssetFilter type(AssetType type) => where((asset) => asset.type == type); + AssetFilter favorite({bool isFavorite = true}) => where((asset) => asset.isFavorite == isFavorite); + + AssetFilter remote() => AssetFilter(assets.whereType()); + AssetFilter owned(String ownerId) => remote().where((asset) => asset.ownerId == ownerId); + AssetFilter visibility(AssetVisibility visibility) => + remote().where((asset) => asset.visibility == visibility); + AssetFilter notVisibility(AssetVisibility visibility) => + remote().where((asset) => asset.visibility != visibility); + AssetFilter archived({bool isArchived = true}) => + remote().where((asset) => asset.isArchived == isArchived); + AssetFilter stacked({bool isStacked = true}) => remote().where((asset) => asset.isStacked == isStacked); + + AssetFilter local() => AssetFilter(assets.whereType()); + AssetFilter backedUp() => local().where((asset) => asset.remoteAssetId != null); +} diff --git a/mobile/test/unit/factories/remote_asset_factory.dart b/mobile/test/unit/factories/remote_asset_factory.dart index 669eb3998a..3ab76ae15e 100644 --- a/mobile/test/unit/factories/remote_asset_factory.dart +++ b/mobile/test/unit/factories/remote_asset_factory.dart @@ -5,7 +5,14 @@ import '../../utils.dart'; class RemoteAssetFactory { const RemoteAssetFactory(); - static RemoteAsset create({String? id, String? name, String? ownerId, bool isFavorite = false}) { + static RemoteAsset create({ + String? id, + String? name, + String? ownerId, + bool isFavorite = false, + AssetVisibility visibility = AssetVisibility.timeline, + String? stackId, + }) { id = TestUtils.uuid(id); return RemoteAsset( @@ -17,6 +24,8 @@ class RemoteAssetFactory { createdAt: TestUtils.yesterday(), updatedAt: TestUtils.now(), isFavorite: isFavorite, + visibility: visibility, + stackId: stackId, isEdited: false, ); } diff --git a/mobile/test/unit/utils/asset_filter_test.dart b/mobile/test/unit/utils/asset_filter_test.dart new file mode 100644 index 0000000000..ceb28ba932 --- /dev/null +++ b/mobile/test/unit/utils/asset_filter_test.dart @@ -0,0 +1,200 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; +import 'package:immich_mobile/utils/asset_filter.dart'; + +import '../factories/local_asset_factory.dart'; +import '../factories/remote_asset_factory.dart'; + +void main() { + group('AssetFilter', () { + group('type promotion', () { + test('a bare filter retains every BaseAsset', () { + final remoteAsset = RemoteAssetFactory.create(); + final localAsset = LocalAssetFactory.create(); + + final AssetFilter filter = AssetFilter([remoteAsset, localAsset]); + + expect(filter.toList(), [remoteAsset, localAsset]); + }); + + test('remote keeps only remote assets', () { + final remoteAsset = RemoteAssetFactory.create(); + final localAsset = LocalAssetFactory.create(); + + final AssetFilter remoteOnly = AssetFilter([remoteAsset, localAsset]).remote(); + + expect(remoteOnly.toList(), [remoteAsset]); + }); + + test('local keeps only local assets', () { + final remoteAsset = RemoteAssetFactory.create(); + final localAsset = LocalAssetFactory.create(); + + final AssetFilter localOnly = AssetFilter([remoteAsset, localAsset]).local(); + + expect(localOnly.toList(), [localAsset]); + }); + + test('owned promotes to RemoteAsset and drops local assets', () { + final remoteAsset = RemoteAssetFactory.create(); + final localAsset = LocalAssetFactory.create(); + + final AssetFilter remoteOnly = AssetFilter([ + remoteAsset, + localAsset, + ]).owned(remoteAsset.ownerId); + + expect(remoteOnly.toList(), [remoteAsset]); + }); + + test('backedUp promotes to LocalAsset and drops remote assets', () { + final syncedPhoto = LocalAssetFactory.create().copyWith(remoteId: 'remote'); + final offlinePhoto = LocalAssetFactory.create(); + final remotePhoto = RemoteAssetFactory.create(); + + final AssetFilter syncedPhotos = AssetFilter([ + syncedPhoto, + offlinePhoto, + remotePhoto, + ]).backedUp(); + + expect(syncedPhotos.toList(), [syncedPhoto]); + }); + }); + + group('named filters', () { + test('owned keeps only assets of the given owner', () { + final asset1 = RemoteAssetFactory.create(); + final asset2 = RemoteAssetFactory.create(); + + final alexPhotos = AssetFilter([asset1, asset2]).owned(asset1.ownerId); + + expect(alexPhotos.toList(), [asset1]); + }); + + test('favorites keeps only favorite assets', () { + final asset1 = RemoteAssetFactory.create(isFavorite: true); + final asset2 = RemoteAssetFactory.create(ownerId: asset1.ownerId); + + final favorites = AssetFilter([asset1, asset2]).favorite(); + + expect(favorites.toList(), [asset1]); + }); + + test('type keeps only assets of the given type', () { + final image = RemoteAssetFactory.create(); + final video = RemoteAssetFactory.create(ownerId: image.ownerId).copyWith(type: .video); + + final videos = AssetFilter([image, video]).type(.video); + + expect(videos.toList(), [video]); + }); + + test('visibility keeps only assets with the given visibility', () { + final locked = RemoteAssetFactory.create(visibility: AssetVisibility.locked); + final onTimeline = RemoteAssetFactory.create(ownerId: locked.ownerId); + + final lockedPhotos = AssetFilter([locked, onTimeline]).visibility(.locked); + + expect(lockedPhotos.toList(), [locked]); + }); + + test('archived keeps only archived assets', () { + final archived = RemoteAssetFactory.create(visibility: AssetVisibility.archive); + final onTimeline = RemoteAssetFactory.create(ownerId: archived.ownerId); + + final archivedPhotos = AssetFilter([archived, onTimeline]).archived(); + + expect(archivedPhotos.toList(), [archived]); + }); + + test('stacked keeps only assets belonging to a stack', () { + final stacked = RemoteAssetFactory.create(stackId: 'stack'); + final loose = RemoteAssetFactory.create(ownerId: stacked.ownerId); + + final stackedPhotos = AssetFilter([stacked, loose]).stacked(); + + expect(stackedPhotos.toList(), [stacked]); + }); + }); + + group('inversion', () { + test('notArchived keeps every non-archived visibility', () { + final archived = RemoteAssetFactory.create(visibility: AssetVisibility.archive); + final onTimeline = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.timeline); + final hidden = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.hidden); + final locked = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.locked); + + final visiblePhotos = AssetFilter([archived, onTimeline, hidden, locked]).archived(isArchived: false); + + expect(visiblePhotos.toSet(), {onTimeline, hidden, locked}); + }); + + test('notVisibility keeps every asset not at the target visibility', () { + final archived = RemoteAssetFactory.create(visibility: AssetVisibility.archive); + final onTimeline = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.timeline); + final locked = RemoteAssetFactory.create(ownerId: archived.ownerId, visibility: AssetVisibility.locked); + + final toArchive = AssetFilter([archived, onTimeline, locked]).notVisibility(.archive); + + expect(toArchive.toSet(), {onTimeline, locked}); + }); + + test('notStacked keeps only assets without a stack', () { + final stacked = RemoteAssetFactory.create(stackId: 'stack'); + final loose = RemoteAssetFactory.create(ownerId: stacked.ownerId); + + final loosePhotos = AssetFilter([stacked, loose]).stacked(isStacked: false); + + expect(loosePhotos.toList(), [loose]); + }); + + test('whereNot inverts an arbitrary predicate', () { + final favorite = RemoteAssetFactory.create(isFavorite: true); + final regular = RemoteAssetFactory.create(ownerId: favorite.ownerId); + + final nonFavorites = AssetFilter([favorite, regular]).whereNot((asset) => asset.isFavorite); + + expect(nonFavorites.toList(), [regular]); + }); + + test('notFavorites keeps only non-favorite assets', () { + final favorite = RemoteAssetFactory.create(isFavorite: true); + final regular = RemoteAssetFactory.create(ownerId: favorite.ownerId); + + final nonFavorites = AssetFilter([favorite, regular]).favorite(isFavorite: false); + + expect(nonFavorites.toList(), [regular]); + }); + }); + + group('chaining', () { + test('combines predicates across owner, visibility and stack', () { + final asset = RemoteAssetFactory.create(); + final wrongOwner = RemoteAssetFactory.create(); + final archived = RemoteAssetFactory.create(ownerId: asset.ownerId, visibility: AssetVisibility.archive); + final stacked = RemoteAssetFactory.create(ownerId: asset.ownerId, stackId: 'stack-1'); + final localPhoto = LocalAssetFactory.create(); + + final result = AssetFilter([ + asset, + wrongOwner, + archived, + stacked, + localPhoto, + ]).owned(asset.ownerId).archived(isArchived: false).stacked(isStacked: false); + + expect(result.toList(), [asset]); + }); + + test('a base filter after a promotion retains the promoted type', () { + final favorite = RemoteAssetFactory.create(isFavorite: true); + final regular = RemoteAssetFactory.create(ownerId: favorite.ownerId); + + final AssetFilter result = AssetFilter([favorite, regular]).owned(favorite.ownerId).favorite(); + + expect(result.toList(), [favorite]); + }); + }); + }); +}