diff --git a/i18n/en.json b/i18n/en.json index d61116f2a6..f3f1322045 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -983,6 +983,7 @@ "failed_to_load_assets": "Failed to load assets", "failed_to_load_folder": "Failed to load folder", "favorite": "Favorite", + "favorite_action_prompt": "{count} added to Favorites", "favorite_or_unfavorite_photo": "Favorite or unfavorite photo", "favorites": "Favorites", "favorites_page_no_favorites": "No favorite assets found", diff --git a/mobile/lib/constants/enums.dart b/mobile/lib/constants/enums.dart index 4999c48660..febc71032e 100644 --- a/mobile/lib/constants/enums.dart +++ b/mobile/lib/constants/enums.dart @@ -12,3 +12,5 @@ enum TextSearchType { enum AssetVisibilityEnum { timeline, hidden, archive, locked } enum SortUserBy { id } + +enum ActionSource { timeline, viewer } diff --git a/mobile/lib/domain/models/asset/base_asset.model.dart b/mobile/lib/domain/models/asset/base_asset.model.dart index 509998a109..9833f9a682 100644 --- a/mobile/lib/domain/models/asset/base_asset.model.dart +++ b/mobile/lib/domain/models/asset/base_asset.model.dart @@ -1,4 +1,4 @@ -part 'asset.model.dart'; +part 'remote_asset.model.dart'; part 'local_asset.model.dart'; enum AssetType { diff --git a/mobile/lib/domain/models/asset/asset.model.dart b/mobile/lib/domain/models/asset/remote_asset.model.dart similarity index 70% rename from mobile/lib/domain/models/asset/asset.model.dart rename to mobile/lib/domain/models/asset/remote_asset.model.dart index b0a0b0dfb9..608a03f2b2 100644 --- a/mobile/lib/domain/models/asset/asset.model.dart +++ b/mobile/lib/domain/models/asset/remote_asset.model.dart @@ -8,16 +8,18 @@ enum AssetVisibility { } // Model for an asset stored in the server -class Asset extends BaseAsset { +class RemoteAsset extends BaseAsset { final String id; final String? localId; final String? thumbHash; final AssetVisibility visibility; + final String ownerId; - const Asset({ + const RemoteAsset({ required this.id, this.localId, required super.name, + required this.ownerId, required super.checksum, required super.type, required super.createdAt, @@ -37,16 +39,17 @@ class Asset extends BaseAsset { @override String toString() { return '''Asset { - id: $id, - name: $name, - type: $type, - createdAt: $createdAt, - updatedAt: $updatedAt, - width: ${width ?? ""}, - height: ${height ?? ""}, - durationInSeconds: ${durationInSeconds ?? ""}, - localId: ${localId ?? ""}, - isFavorite: $isFavorite, + id: $id, + name: $name, + ownerId: $ownerId, + type: $type, + createdAt: $createdAt, + updatedAt: $updatedAt, + width: ${width ?? ""}, + height: ${height ?? ""}, + durationInSeconds: ${durationInSeconds ?? ""}, + localId: ${localId ?? ""}, + isFavorite: $isFavorite, thumbHash: ${thumbHash ?? ""}, visibility: $visibility, }'''; @@ -54,10 +57,11 @@ class Asset extends BaseAsset { @override bool operator ==(Object other) { - if (other is! Asset) return false; + if (other is! RemoteAsset) return false; if (identical(this, other)) return true; return super == other && id == other.id && + ownerId == other.ownerId && localId == other.localId && thumbHash == other.thumbHash && visibility == other.visibility; @@ -67,6 +71,7 @@ class Asset extends BaseAsset { int get hashCode => super.hashCode ^ id.hashCode ^ + ownerId.hashCode ^ localId.hashCode ^ thumbHash.hashCode ^ visibility.hashCode; diff --git a/mobile/lib/infrastructure/entities/remote_asset.entity.dart b/mobile/lib/infrastructure/entities/remote_asset.entity.dart index bfe08346dd..c08401356c 100644 --- a/mobile/lib/infrastructure/entities/remote_asset.entity.dart +++ b/mobile/lib/infrastructure/entities/remote_asset.entity.dart @@ -37,9 +37,10 @@ class RemoteAssetEntity extends Table } extension RemoteAssetEntityDataDomainEx on RemoteAssetEntityData { - Asset toDto() => Asset( + RemoteAsset toDto() => RemoteAsset( id: id, name: name, + ownerId: ownerId, checksum: checksum, type: type, createdAt: createdAt, diff --git a/mobile/lib/infrastructure/repositories/remote_asset.repository.dart b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart new file mode 100644 index 0000000000..ef347cfa75 --- /dev/null +++ b/mobile/lib/infrastructure/repositories/remote_asset.repository.dart @@ -0,0 +1,26 @@ +import 'package:drift/drift.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.dart'; +import 'package:immich_mobile/infrastructure/repositories/db.repository.dart'; +import 'package:immich_mobile/providers/infrastructure/db.provider.dart'; + +final remoteAssetRepositoryProvider = Provider( + (ref) => RemoteAssetRepository(ref.watch(driftProvider)), +); + +class RemoteAssetRepository extends DriftDatabaseRepository { + final Drift _db; + const RemoteAssetRepository(this._db) : super(_db); + + Future updateFavorite(List ids, bool isFavorite) { + return _db.batch((batch) async { + for (final id in ids) { + batch.update( + _db.remoteAssetEntity, + RemoteAssetEntityCompanion(isFavorite: Value(isFavorite)), + where: (e) => e.id.equals(id), + ); + } + }); + } +} diff --git a/mobile/lib/infrastructure/repositories/timeline.repository.dart b/mobile/lib/infrastructure/repositories/timeline.repository.dart index 18abac99de..fcd92cb30c 100644 --- a/mobile/lib/infrastructure/repositories/timeline.repository.dart +++ b/mobile/lib/infrastructure/repositories/timeline.repository.dart @@ -70,36 +70,38 @@ class DriftTimelineRepository extends DriftDatabaseRepository { return _db.mergedAssetDrift .mergedAsset(userIds, limit: Limit(count, offset)) .map( - (row) => row.remoteId != null - ? Asset( - id: row.remoteId!, - localId: row.localId, - name: row.name, - checksum: row.checksum, - type: row.type, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - thumbHash: row.thumbHash, - width: row.width, - height: row.height, - isFavorite: row.isFavorite, - durationInSeconds: row.durationInSeconds, - ) - : LocalAsset( - id: row.localId!, - remoteId: row.remoteId, - name: row.name, - checksum: row.checksum, - type: row.type, - createdAt: row.createdAt, - updatedAt: row.updatedAt, - width: row.width, - height: row.height, - isFavorite: row.isFavorite, - durationInSeconds: row.durationInSeconds, - ), - ) - .get(); + (row) { + return row.remoteId != null && row.ownerId != null + ? RemoteAsset( + id: row.remoteId!, + localId: row.localId, + name: row.name, + ownerId: row.ownerId!, + checksum: row.checksum, + type: row.type, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + thumbHash: row.thumbHash, + width: row.width, + height: row.height, + isFavorite: row.isFavorite, + durationInSeconds: row.durationInSeconds, + ) + : LocalAsset( + id: row.localId!, + remoteId: row.remoteId, + name: row.name, + checksum: row.checksum, + type: row.type, + createdAt: row.createdAt, + updatedAt: row.updatedAt, + width: row.width, + height: row.height, + isFavorite: row.isFavorite, + durationInSeconds: row.durationInSeconds, + ); + }, + ).get(); } Stream> watchLocalBucket( diff --git a/mobile/lib/presentation/widgets/action_buttons/favorite_action_button.widget.dart b/mobile/lib/presentation/widgets/action_buttons/favorite_action_button.widget.dart index 5c093c8fa0..5bf0566f20 100644 --- a/mobile/lib/presentation/widgets/action_buttons/favorite_action_button.widget.dart +++ b/mobile/lib/presentation/widgets/action_buttons/favorite_action_button.widget.dart @@ -1,16 +1,73 @@ import 'package:flutter/material.dart'; +import 'package:fluttertoast/fluttertoast.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; +import 'package:immich_mobile/domain/models/asset/base_asset.model.dart'; import 'package:immich_mobile/extensions/translate_extensions.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/base_action_button.widget.dart'; +import 'package:immich_mobile/providers/infrastructure/action.provider.dart'; +import 'package:immich_mobile/providers/timeline/multiselect.provider.dart'; +import 'package:immich_mobile/providers/user.provider.dart'; +import 'package:immich_mobile/widgets/common/immich_toast.dart'; class FavoriteActionButton extends ConsumerWidget { - const FavoriteActionButton({super.key}); + final ActionSource source; + + const FavoriteActionButton({super.key, required this.source}); + + onAction(BuildContext context, WidgetRef ref) { + switch (source) { + case ActionSource.timeline: + timelineAction(context, ref); + case ActionSource.viewer: + viewerAction(ref); + } + } + + void timelineAction(BuildContext context, WidgetRef ref) { + final user = ref.read(currentUserProvider); + if (user == null) { + return; + } + + final ids = ref + .read(multiSelectProvider.select((value) => value.selectedAssets)) + .whereType() + .where((asset) => asset.ownerId == user.id) + .map((asset) => asset.id) + .toList(); + + if (ids.isEmpty) { + return; + } + + ref.read(actionProvider.notifier).favorite(ids); + ref.read(multiSelectProvider.notifier).reset(); + + final toastMessage = 'favorite_action_prompt'.t( + context: context, + args: {'count': ids.length.toString()}, + ); + + if (context.mounted) { + ImmichToast.show( + context: context, + msg: toastMessage, + gravity: ToastGravity.BOTTOM, + ); + } + } + + void viewerAction(WidgetRef _) { + UnimplementedError("Viewer action for favorite is not implemented yet."); + } @override Widget build(BuildContext context, WidgetRef ref) { return BaseActionButton( iconData: Icons.favorite_border_rounded, label: "favorite".t(context: context), + onPressed: () => onAction(context, ref), ); } } diff --git a/mobile/lib/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart b/mobile/lib/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart index 0b5efc4bd1..d7677b77e8 100644 --- a/mobile/lib/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart +++ b/mobile/lib/presentation/widgets/bottom_app_bar/home_bottom_app_bar.widget.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/constants/enums.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/archive_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_action_button.widget.dart'; import 'package:immich_mobile/presentation/widgets/action_buttons/delete_local_action_button.widget.dart'; @@ -35,7 +36,7 @@ class HomeBottomAppBar extends ConsumerWidget { if (multiselect.hasRemote) ...[ const ShareLinkActionButton(), const ArchiveActionButton(), - const FavoriteActionButton(), + const FavoriteActionButton(source: ActionSource.timeline), const DownloadActionButton(), isTrashEnable ? const TrashActionButton() diff --git a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart index e9648ab06e..82aef61633 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail.widget.dart @@ -32,7 +32,7 @@ class Thumbnail extends StatelessWidget { ); } - if (asset is Asset) { + if (asset is RemoteAsset) { return RemoteThumbProvider( assetId: asset.id, height: size.height, @@ -45,7 +45,8 @@ class Thumbnail extends StatelessWidget { @override Widget build(BuildContext context) { - final thumbHash = asset is Asset ? (asset as Asset).thumbHash : null; + final thumbHash = + asset is RemoteAsset ? (asset as RemoteAsset).thumbHash : null; final provider = imageProvider(asset: asset, size: size); return OctoImage.fromSet( diff --git a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart index 7c2fbf4e21..ba02ea56e8 100644 --- a/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart +++ b/mobile/lib/presentation/widgets/images/thumbnail_tile.widget.dart @@ -30,9 +30,8 @@ class ThumbnailTile extends ConsumerWidget { ? context.primaryColor.darken(amount: 0.6) : context.primaryColor.lighten(amount: 0.8); - final isSelected = ref - .watch(multiSelectProvider.select((state) => state.selectedAssets)) - .contains(asset); + final multiselect = ref.watch(multiSelectProvider); + final isSelected = multiselect.selectedAssets.contains(asset); return Stack( children: [ diff --git a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart index 7cadff9ee2..4de9eaad38 100644 --- a/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart +++ b/mobile/lib/presentation/widgets/timeline/fixed/segment.model.dart @@ -185,7 +185,7 @@ class FixedSegment extends Segment { /// and prevents duplicate keys even when assets have the same name/timestamp String _generateUniqueKey(BaseAsset asset, int assetIndex) { // Try to get the most unique identifier based on asset type - if (asset is Asset) { + if (asset is RemoteAsset) { // For remote/merged assets, use the remote ID which is globally unique return 'asset_${asset.id}'; } else if (asset is LocalAsset) { diff --git a/mobile/lib/providers/infrastructure/action.provider.dart b/mobile/lib/providers/infrastructure/action.provider.dart new file mode 100644 index 0000000000..18b7378dd2 --- /dev/null +++ b/mobile/lib/providers/infrastructure/action.provider.dart @@ -0,0 +1,28 @@ +import 'package:immich_mobile/services/action.service.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +final actionProvider = NotifierProvider( + ActionNotifier.new, + dependencies: [ + actionServiceProvider, + ], +); + +class ActionNotifier extends Notifier { + late final ActionService _service; + + ActionNotifier() : super(); + + @override + void build() { + _service = ref.watch(actionServiceProvider); + } + + Future favorite(List ids) async { + await _service.favorite(ids); + } + + Future unFavorite(List ids) async { + await _service.unFavorite(ids); + } +} diff --git a/mobile/lib/providers/timeline/multiselect.provider.dart b/mobile/lib/providers/timeline/multiselect.provider.dart index 1a4c0e4c65..1bd0ae1565 100644 --- a/mobile/lib/providers/timeline/multiselect.provider.dart +++ b/mobile/lib/providers/timeline/multiselect.provider.dart @@ -13,9 +13,11 @@ final multiSelectProvider = class MultiSelectState { final Set selectedAssets; + final int lastUpdatedTime; const MultiSelectState({ required this.selectedAssets, + required this.lastUpdatedTime, }); bool get isEnabled => selectedAssets.isNotEmpty; @@ -30,25 +32,29 @@ class MultiSelectState { MultiSelectState copyWith({ Set? selectedAssets, + int? lastUpdatedTime, }) { return MultiSelectState( selectedAssets: selectedAssets ?? this.selectedAssets, + lastUpdatedTime: lastUpdatedTime ?? this.lastUpdatedTime, ); } @override - String toString() => 'MultiSelectState(selectedAssets: $selectedAssets)'; + String toString() => + 'MultiSelectState(selectedAssets: $selectedAssets, lastUpdatedTime: $lastUpdatedTime)'; @override bool operator ==(covariant MultiSelectState other) { if (identical(this, other)) return true; final listEquals = const DeepCollectionEquality().equals; - return listEquals(other.selectedAssets, selectedAssets); + return listEquals(other.selectedAssets, selectedAssets) && + other.lastUpdatedTime == lastUpdatedTime; } @override - int get hashCode => selectedAssets.hashCode; + int get hashCode => selectedAssets.hashCode ^ lastUpdatedTime.hashCode; } class MultiSelectNotifier extends Notifier { @@ -60,6 +66,7 @@ class MultiSelectNotifier extends Notifier { return const MultiSelectState( selectedAssets: {}, + lastUpdatedTime: 0, ); } @@ -97,6 +104,13 @@ class MultiSelectNotifier extends Notifier { ); } + void reset() { + state = MultiSelectState( + selectedAssets: {}, + lastUpdatedTime: DateTime.now().millisecondsSinceEpoch, + ); + } + /// Bucket bulk operations void selectBucket(int offset, int bucketCount) async { final assets = await _timelineService.loadAssets(offset, bucketCount); diff --git a/mobile/lib/repositories/asset_api.repository.dart b/mobile/lib/repositories/asset_api.repository.dart index 49dbf74304..923a214015 100644 --- a/mobile/lib/repositories/asset_api.repository.dart +++ b/mobile/lib/repositories/asset_api.repository.dart @@ -56,6 +56,15 @@ class AssetApiRepository extends ApiRepository { ); } + Future updateFavorite( + List ids, + bool isFavorite, + ) async { + return _api.updateAssets( + AssetBulkUpdateDto(ids: ids, isFavorite: isFavorite), + ); + } + _mapVisibility(AssetVisibilityEnum visibility) => switch (visibility) { AssetVisibilityEnum.timeline => AssetVisibility.timeline, AssetVisibilityEnum.hidden => AssetVisibility.hidden, diff --git a/mobile/lib/services/action.service.dart b/mobile/lib/services/action.service.dart new file mode 100644 index 0000000000..9affff9804 --- /dev/null +++ b/mobile/lib/services/action.service.dart @@ -0,0 +1,36 @@ +import 'package:flutter/foundation.dart'; +import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart'; +import 'package:immich_mobile/repositories/asset_api.repository.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; + +final actionServiceProvider = Provider( + (ref) => ActionService( + ref.watch(assetApiRepositoryProvider), + ref.watch(remoteAssetRepositoryProvider), + ), +); + +class ActionService { + final AssetApiRepository _assetApiRepository; + final RemoteAssetRepository _remoteAssetRepository; + + const ActionService(this._assetApiRepository, this._remoteAssetRepository); + + Future favorite(List remoteIds) async { + try { + await _assetApiRepository.updateFavorite(remoteIds, true); + await _remoteAssetRepository.updateFavorite(remoteIds, true); + } catch (e) { + debugPrint('Error favoriting assets: $e'); + } + } + + Future unFavorite(List remoteIds) async { + try { + await _assetApiRepository.updateFavorite(remoteIds, false); + await _remoteAssetRepository.updateFavorite(remoteIds, false); + } catch (e) { + debugPrint('Error unfavoriting assets: $e'); + } + } +}