feat: favorite action (#19623)

This commit is contained in:
Alex 2025-06-30 12:21:09 -05:00 committed by GitHub
parent fa5f30d9ca
commit 4c3fcdc745
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
16 changed files with 238 additions and 56 deletions

View File

@ -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",

View File

@ -12,3 +12,5 @@ enum TextSearchType {
enum AssetVisibilityEnum { timeline, hidden, archive, locked }
enum SortUserBy { id }
enum ActionSource { timeline, viewer }

View File

@ -1,4 +1,4 @@
part 'asset.model.dart';
part 'remote_asset.model.dart';
part 'local_asset.model.dart';
enum AssetType {

View File

@ -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 ?? "<NA>"},
height: ${height ?? "<NA>"},
durationInSeconds: ${durationInSeconds ?? "<NA>"},
localId: ${localId ?? "<NA>"},
isFavorite: $isFavorite,
id: $id,
name: $name,
ownerId: $ownerId,
type: $type,
createdAt: $createdAt,
updatedAt: $updatedAt,
width: ${width ?? "<NA>"},
height: ${height ?? "<NA>"},
durationInSeconds: ${durationInSeconds ?? "<NA>"},
localId: ${localId ?? "<NA>"},
isFavorite: $isFavorite,
thumbHash: ${thumbHash ?? "<NA>"},
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;

View File

@ -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,

View File

@ -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<RemoteAssetRepository>(
(ref) => RemoteAssetRepository(ref.watch(driftProvider)),
);
class RemoteAssetRepository extends DriftDatabaseRepository {
final Drift _db;
const RemoteAssetRepository(this._db) : super(_db);
Future<void> updateFavorite(List<String> 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),
);
}
});
}
}

View File

@ -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<List<Bucket>> watchLocalBucket(

View File

@ -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<RemoteAsset>()
.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),
);
}
}

View File

@ -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()

View File

@ -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(

View File

@ -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: [

View File

@ -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) {

View File

@ -0,0 +1,28 @@
import 'package:immich_mobile/services/action.service.dart';
import 'package:riverpod_annotation/riverpod_annotation.dart';
final actionProvider = NotifierProvider<ActionNotifier, void>(
ActionNotifier.new,
dependencies: [
actionServiceProvider,
],
);
class ActionNotifier extends Notifier<void> {
late final ActionService _service;
ActionNotifier() : super();
@override
void build() {
_service = ref.watch(actionServiceProvider);
}
Future<void> favorite(List<String> ids) async {
await _service.favorite(ids);
}
Future<void> unFavorite(List<String> ids) async {
await _service.unFavorite(ids);
}
}

View File

@ -13,9 +13,11 @@ final multiSelectProvider =
class MultiSelectState {
final Set<BaseAsset> 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<BaseAsset>? 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<MultiSelectState> {
@ -60,6 +66,7 @@ class MultiSelectNotifier extends Notifier<MultiSelectState> {
return const MultiSelectState(
selectedAssets: {},
lastUpdatedTime: 0,
);
}
@ -97,6 +104,13 @@ class MultiSelectNotifier extends Notifier<MultiSelectState> {
);
}
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);

View File

@ -56,6 +56,15 @@ class AssetApiRepository extends ApiRepository {
);
}
Future<void> updateFavorite(
List<String> 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,

View File

@ -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<ActionService>(
(ref) => ActionService(
ref.watch(assetApiRepositoryProvider),
ref.watch(remoteAssetRepositoryProvider),
),
);
class ActionService {
final AssetApiRepository _assetApiRepository;
final RemoteAssetRepository _remoteAssetRepository;
const ActionService(this._assetApiRepository, this._remoteAssetRepository);
Future<void> favorite(List<String> remoteIds) async {
try {
await _assetApiRepository.updateFavorite(remoteIds, true);
await _remoteAssetRepository.updateFavorite(remoteIds, true);
} catch (e) {
debugPrint('Error favoriting assets: $e');
}
}
Future<void> unFavorite(List<String> remoteIds) async {
try {
await _assetApiRepository.updateFavorite(remoteIds, false);
await _remoteAssetRepository.updateFavorite(remoteIds, false);
} catch (e) {
debugPrint('Error unfavoriting assets: $e');
}
}
}