mirror of
https://github.com/immich-app/immich.git
synced 2025-07-09 03:04:16 -04:00
feat: favorite action (#19623)
This commit is contained in:
parent
fa5f30d9ca
commit
4c3fcdc745
@ -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",
|
||||
|
@ -12,3 +12,5 @@ enum TextSearchType {
|
||||
enum AssetVisibilityEnum { timeline, hidden, archive, locked }
|
||||
|
||||
enum SortUserBy { id }
|
||||
|
||||
enum ActionSource { timeline, viewer }
|
||||
|
@ -1,4 +1,4 @@
|
||||
part 'asset.model.dart';
|
||||
part 'remote_asset.model.dart';
|
||||
part 'local_asset.model.dart';
|
||||
|
||||
enum AssetType {
|
||||
|
@ -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;
|
@ -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,
|
||||
|
@ -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),
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
@ -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(
|
||||
|
@ -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),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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()
|
||||
|
@ -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(
|
||||
|
@ -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: [
|
||||
|
@ -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) {
|
||||
|
28
mobile/lib/providers/infrastructure/action.provider.dart
Normal file
28
mobile/lib/providers/infrastructure/action.provider.dart
Normal 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);
|
||||
}
|
||||
}
|
@ -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);
|
||||
|
@ -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,
|
||||
|
36
mobile/lib/services/action.service.dart
Normal file
36
mobile/lib/services/action.service.dart
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user