pull/23992/merge
Peter Ombodi 2025-12-18 11:46:02 +02:00 committed by GitHub
commit 9a9c4cd9e9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
52 changed files with 10322 additions and 236 deletions

View File

@ -440,10 +440,14 @@
"advanced_settings_proxy_headers_title": "Custom proxy headers [EXPERIMENTAL]",
"advanced_settings_readonly_mode_subtitle": "Enables the read-only mode where the photos can be only viewed, things like selecting multiple images, sharing, casting, delete are all disabled. Enable/Disable read-only via user avatar from the main screen",
"advanced_settings_readonly_mode_title": "Read-only mode",
"advanced_settings_review_remote_deletions_subtitle": "Manually review cloud trash changes. Restorations are applied automatically.",
"advanced_settings_review_remote_deletions_title": "Review remote deletions",
"advanced_settings_self_signed_ssl_subtitle": "Skips SSL certificate verification for the server endpoint. Required for self-signed certificates.",
"advanced_settings_self_signed_ssl_title": "Allow self-signed SSL certificates [EXPERIMENTAL]",
"advanced_settings_sync_remote_deletions_subtitle": "Automatically delete or restore an asset on this device when that action is taken on the web",
"advanced_settings_sync_remote_deletions_title": "Sync remote deletions [EXPERIMENTAL]",
"advanced_settings_sync_remote_deletions_off_subtitle": "Cloud trash changes are ignored",
"advanced_settings_sync_remote_deletions_selector_title": "Sync remote deletions [EXPERIMENTAL]",
"advanced_settings_sync_remote_deletions_subtitle": "Automatically move assets to trash or restore them on this device when that action is taken on the web.",
"advanced_settings_sync_remote_deletions_title": "Auto sync",
"advanced_settings_tile_subtitle": "Advanced user's settings",
"advanced_settings_troubleshooting_subtitle": "Enable additional features for troubleshooting",
"advanced_settings_troubleshooting_title": "Troubleshooting",
@ -542,6 +546,12 @@
"asset_list_settings_title": "Photo Grid",
"asset_offline": "Asset Offline",
"asset_offline_description": "This external asset is no longer found on disk. Please contact your Immich administrator for help.",
"asset_out_of_sync_actions_title": "Pending trash decision",
"asset_out_of_sync_title": "Out-of-sync assets list",
"asset_out_of_sync_trash_confirmation_text": "Move selected assets to your device trash?",
"asset_out_of_sync_trash_confirmation_title": "Sync trash change",
"asset_out_of_sync_trash_subtitle": "Assets moved to the Immich cloud trash: choose to move them to local trash or keep them on this device.",
"asset_out_of_sync_trash_subtitle_result": "Nothing left to review — all decisions applied.",
"asset_restored_successfully": "Asset restored successfully",
"asset_skipped": "Skipped",
"asset_skipped_in_trash": "In trash",
@ -555,11 +565,13 @@
"assets_added_count": "Added {count, plural, one {# asset} other {# assets}}",
"assets_added_to_album_count": "Added {count, plural, one {# asset} other {# assets}} to the album",
"assets_added_to_albums_count": "Added {assetTotal, plural, one {# asset} other {# assets}} to {albumTotal, plural, one {# album} other {# albums}}",
"assets_allowed_to_moved_to_trash_count": "Allowed to move {count, plural, one {# asset} other {# assets}} to trash",
"assets_cannot_be_added_to_album_count": "{count, plural, one {Asset} other {Assets}} cannot be added to the album",
"assets_cannot_be_added_to_albums": "{count, plural, one {Asset} other {Assets}} cannot be added to any of the albums",
"assets_count": "{count, plural, one {# asset} other {# assets}}",
"assets_deleted_permanently": "{count} asset(s) deleted permanently",
"assets_deleted_permanently_from_server": "{count} asset(s) deleted permanently from the Immich server",
"assets_denied_to_moved_to_trash_count": "Allowed to keep {count, plural, one {# asset} other {# assets}} on device",
"assets_downloaded_failed": "{count, plural, one {Downloaded # file - {error} file failed} other {Downloaded # files - {error} files failed}}",
"assets_downloaded_successfully": "{count, plural, one {Downloaded # file successfully} other {Downloaded # files successfully}}",
"assets_moved_to_trash_count": "Moved {count, plural, one {# asset} other {# assets}} to trash",
@ -1249,6 +1261,8 @@
"jobs": "Jobs",
"keep": "Keep",
"keep_all": "Keep All",
"keep_in_trash": "Keep in trash",
"keep_on_device": "Keep on device",
"keep_this_delete_others": "Keep this, delete others",
"kept_this_deleted_others": "Kept this asset and deleted {count, plural, one {# asset} other {# assets}}",
"keyboard_shortcuts": "Keyboard shortcuts",
@ -1499,6 +1513,7 @@
"obtainium_configurator": "Obtainium Configurator",
"obtainium_configurator_instructions": "Use Obtainium to install and update the Android app directly from Immich GitHub's release. Create an API key and select a variant to create your Obtainium configuration link",
"ocr": "OCR",
"off": "Off",
"official_immich_resources": "Official Immich Resources",
"offline": "Offline",
"offset": "Offset",
@ -1758,6 +1773,7 @@
"retry_upload": "Retry upload",
"review_duplicates": "Review duplicates",
"review_large_files": "Review large files",
"review_out_of_sync_changes": "Review out-of-sync changes",
"role": "Role",
"role_editor": "Editor",
"role_viewer": "Viewer",

File diff suppressed because one or more lines are too long

View File

@ -17,6 +17,7 @@ sealed class BaseAsset {
final AssetType type;
final DateTime createdAt;
final DateTime updatedAt;
final DateTime? deletedAt;
final int? width;
final int? height;
final int? durationInSeconds;
@ -29,6 +30,7 @@ sealed class BaseAsset {
required this.type,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
this.width,
this.height,
this.durationInSeconds,
@ -67,6 +69,7 @@ sealed class BaseAsset {
type: $type,
createdAt: $createdAt,
updatedAt: $updatedAt,
deletedAt: $deletedAt,
width: ${width ?? "<NA>"},
height: ${height ?? "<NA>"},
durationInSeconds: ${durationInSeconds ?? "<NA>"},
@ -82,6 +85,7 @@ sealed class BaseAsset {
type == other.type &&
createdAt == other.createdAt &&
updatedAt == other.updatedAt &&
deletedAt == other.deletedAt &&
width == other.width &&
height == other.height &&
durationInSeconds == other.durationInSeconds &&
@ -96,6 +100,7 @@ sealed class BaseAsset {
type.hashCode ^
createdAt.hashCode ^
updatedAt.hashCode ^
deletedAt.hashCode ^
width.hashCode ^
height.hashCode ^
durationInSeconds.hashCode ^

View File

@ -17,6 +17,7 @@ class LocalAsset extends BaseAsset {
required super.type,
required super.createdAt,
required super.updatedAt,
super.deletedAt,
super.width,
super.height,
super.durationInSeconds,
@ -50,15 +51,16 @@ class LocalAsset extends BaseAsset {
type: $type,
createdAt: $createdAt,
updatedAt: $updatedAt,
deletedAt: $deletedAt,
width: ${width ?? "<NA>"},
height: ${height ?? "<NA>"},
durationInSeconds: ${durationInSeconds ?? "<NA>"},
remoteId: ${remoteId ?? "<NA>"}
isFavorite: $isFavorite,
orientation: $orientation,
adjustmentTime: $adjustmentTime,
latitude: ${latitude ?? "<NA>"},
longitude: ${longitude ?? "<NA>"},
orientation: $orientation,
adjustmentTime: $adjustmentTime,
latitude: ${latitude ?? "<NA>"},
longitude: ${longitude ?? "<NA>"},
}''';
}
@ -98,6 +100,7 @@ class LocalAsset extends BaseAsset {
int? durationInSeconds,
bool? isFavorite,
int? orientation,
DateTime? deletedAt,
DateTime? adjustmentTime,
double? latitude,
double? longitude,
@ -118,6 +121,7 @@ class LocalAsset extends BaseAsset {
adjustmentTime: adjustmentTime ?? this.adjustmentTime,
latitude: latitude ?? this.latitude,
longitude: longitude ?? this.longitude,
deletedAt: deletedAt ?? this.deletedAt,
);
}
}

View File

@ -20,6 +20,7 @@ class RemoteAsset extends BaseAsset {
required super.type,
required super.createdAt,
required super.updatedAt,
super.deletedAt,
super.width,
super.height,
super.durationInSeconds,
@ -51,6 +52,7 @@ class RemoteAsset extends BaseAsset {
type: $type,
createdAt: $createdAt,
updatedAt: $updatedAt,
deletedAt: ${deletedAt ?? "<NA>"},
width: ${width ?? "<NA>"},
height: ${height ?? "<NA>"},
durationInSeconds: ${durationInSeconds ?? "<NA>"},
@ -104,6 +106,7 @@ class RemoteAsset extends BaseAsset {
AssetVisibility? visibility,
String? livePhotoVideoId,
String? stackId,
DateTime? deletedAt,
}) {
return RemoteAsset(
id: id ?? this.id,
@ -122,6 +125,7 @@ class RemoteAsset extends BaseAsset {
visibility: visibility ?? this.visibility,
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
stackId: stackId ?? this.stackId,
deletedAt: deletedAt ?? this.deletedAt,
);
}
}

View File

@ -82,7 +82,8 @@ enum StoreKey<T> {
useWifiForUploadPhotos<bool>._(1005),
needBetaMigration<bool>._(1006),
// TODO: Remove this after patching open-api
shouldResetSync<bool>._(1007);
shouldResetSync<bool>._(1007),
reviewOutOfSyncChangesAndroid<bool>._(1008);
const StoreKey._(this.id);
final int id;

View File

@ -0,0 +1,33 @@
enum OutSyncType { trash, upload, etc }
class TrashSyncDecision {
final String checksum;
final bool? isSyncApproved;
const TrashSyncDecision({required this.checksum, this.isSyncApproved});
@override
String toString() {
return '''TrashSyncDecision {
checksum: $checksum,
isSyncApproved: $isSyncApproved,
}''';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
if (other is! TrashSyncDecision) return false;
return checksum == other.checksum && isSyncApproved == other.isSyncApproved;
}
@override
int get hashCode => checksum.hashCode ^ (isSyncApproved?.hashCode ?? 0);
TrashSyncDecision copyWith({String? checksum, bool? isSyncApproved}) {
return TrashSyncDecision(
checksum: checksum ?? this.checksum,
isSyncApproved: isSyncApproved ?? this.isSyncApproved,
);
}
}

View File

@ -9,6 +9,7 @@ import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/platform_extensions.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
@ -20,6 +21,7 @@ class LocalSyncService {
final DriftLocalAlbumRepository _localAlbumRepository;
final NativeSyncApi _nativeSyncApi;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final DriftTrashSyncRepository _trashSyncRepository;
final LocalFilesManagerRepository _localFilesManager;
final StorageRepository _storageRepository;
final Logger _log = Logger("DeviceSyncService");
@ -27,11 +29,13 @@ class LocalSyncService {
LocalSyncService({
required DriftLocalAlbumRepository localAlbumRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required DriftTrashSyncRepository trashSyncRepository,
required LocalFilesManagerRepository localFilesManager,
required StorageRepository storageRepository,
required NativeSyncApi nativeSyncApi,
}) : _localAlbumRepository = localAlbumRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository,
_trashSyncRepository = trashSyncRepository,
_localFilesManager = localFilesManager,
_storageRepository = storageRepository,
_nativeSyncApi = nativeSyncApi;
@ -39,7 +43,8 @@ class LocalSyncService {
Future<void> sync({bool full = false}) async {
final Stopwatch stopwatch = Stopwatch()..start();
try {
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false) ||
Store.get(StoreKey.reviewOutOfSyncChangesAndroid, false)) {
final hasPermission = await _localFilesManager.hasManageMediaPermission();
if (hasPermission) {
await _syncTrashedAssets();
@ -333,22 +338,34 @@ class LocalSyncService {
} else {
_log.info("syncTrashedAssets, No remote assets found for restoration");
}
final reviewMode = Store.get(StoreKey.reviewOutOfSyncChangesAndroid, false);
final localAssetsToTrash = await _trashedLocalAssetRepository.getToTrash();
if (localAssetsToTrash.isNotEmpty) {
final mediaUrls = await Future.wait(
localAssetsToTrash.values
.expand((e) => e)
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
);
_log.info("Moving to trash ${mediaUrls.join(", ")} assets");
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
if (result) {
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
if (reviewMode) {
final itemsToReview = localAssetsToTrash.values.flattened.where((la) => la.checksum?.isNotEmpty == true);
_log.info(
"Apply remote trash action to review for: ${itemsToReview.map((e) => 'id:${e.id}, name:${e.name}, deletedAt:${e.deletedAt}').join('|')}",
);
await _trashSyncRepository.upsertReviewCandidates(itemsToReview);
} else {
final mediaUrls = await Future.wait(
localAssetsToTrash.values
.expand((e) => e)
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
);
_log.info("Moving to trash ${mediaUrls.join(", ")} assets");
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
if (result) {
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
}
}
} else {
_log.info("syncTrashedAssets, No assets found in backup-enabled albums for move to trash");
}
if (reviewMode) {
final result = await _trashSyncRepository.deleteOutdated();
_log.info("syncTrashedAssets, outdated deleted: $result");
}
}
}

View File

@ -1,5 +1,6 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:immich_mobile/domain/models/store.model.dart';
import 'package:immich_mobile/domain/models/sync_event.model.dart';
import 'package:immich_mobile/entities/store.entity.dart';
@ -8,6 +9,7 @@ import 'package:immich_mobile/infrastructure/repositories/local_asset.repository
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:logging/logging.dart';
@ -20,6 +22,7 @@ class SyncStreamService {
final SyncStreamRepository _syncStreamRepository;
final DriftLocalAssetRepository _localAssetRepository;
final DriftTrashedLocalAssetRepository _trashedLocalAssetRepository;
final DriftTrashSyncRepository _trashSyncRepository;
final LocalFilesManagerRepository _localFilesManager;
final StorageRepository _storageRepository;
final bool Function()? _cancelChecker;
@ -29,6 +32,7 @@ class SyncStreamService {
required SyncStreamRepository syncStreamRepository,
required DriftLocalAssetRepository localAssetRepository,
required DriftTrashedLocalAssetRepository trashedLocalAssetRepository,
required DriftTrashSyncRepository trashSyncRepository,
required LocalFilesManagerRepository localFilesManager,
required StorageRepository storageRepository,
bool Function()? cancelChecker,
@ -36,6 +40,7 @@ class SyncStreamService {
_syncStreamRepository = syncStreamRepository,
_localAssetRepository = localAssetRepository,
_trashedLocalAssetRepository = trashedLocalAssetRepository,
_trashSyncRepository = trashSyncRepository,
_localFilesManager = localFilesManager,
_storageRepository = storageRepository,
_cancelChecker = cancelChecker;
@ -104,11 +109,21 @@ class SyncStreamService {
case SyncEntityType.assetV1:
final remoteSyncAssets = data.cast<SyncAssetV1>();
await _syncStreamRepository.updateAssetsV1(remoteSyncAssets);
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false)) {
if (CurrentPlatform.isAndroid && Store.get(StoreKey.manageLocalMediaAndroid, false) ||
Store.get(StoreKey.reviewOutOfSyncChangesAndroid, false)) {
final hasPermission = await _localFilesManager.hasManageMediaPermission();
if (hasPermission) {
await _handleRemoteTrashed(remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => e.checksum));
final reviewMode = Store.get(StoreKey.reviewOutOfSyncChangesAndroid, false);
final trashedAssetsMap = Map<String, DateTime>.fromEntries(
remoteSyncAssets.where((e) => e.deletedAt != null).map((e) => MapEntry(e.checksum, e.deletedAt!)),
);
await _handleRemoteTrashed(trashedAssetsMap, reviewMode);
await _applyRemoteRestoreToLocal();
if (reviewMode) {
await _trashSyncRepository.deleteOutdated();
final result = await _trashSyncRepository.deleteOutdated();
_logger.info("syncTrashedAssets, outdated deleted: $result");
}
} else {
_logger.warning("sync Trashed Assets cannot proceed because MANAGE_MEDIA permission is missing");
}
@ -243,24 +258,34 @@ class SyncStreamService {
}
}
Future<void> _handleRemoteTrashed(Iterable<String> checksums) async {
if (checksums.isEmpty) {
Future<void> _handleRemoteTrashed(Map<String, DateTime> trashedAssetsMap, bool reviewMode) async {
if (trashedAssetsMap.isEmpty) {
return Future.value();
} else {
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(checksums);
final localAssetsToTrash = await _localAssetRepository.getAssetsFromBackupAlbums(trashedAssetsMap);
if (localAssetsToTrash.isNotEmpty) {
final mediaUrls = await Future.wait(
localAssetsToTrash.values
.expand((e) => e)
.map((localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl())),
);
_logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
if (result) {
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
if (reviewMode) {
final itemsToReview = localAssetsToTrash.values.flattened.where((la) => la.checksum?.isNotEmpty == true);
_logger.info(
"Apply remote trash action to review for: ${itemsToReview.map((e) => 'id:${e.id}, name:${e.name}, deletedAt:${e.deletedAt}').join('*')}",
);
await _trashSyncRepository.upsertReviewCandidates(itemsToReview);
} else {
final mediaUrls = await Future.wait(
localAssetsToTrash.values
.expand((e) => e)
.map(
(localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl()),
),
);
_logger.info("Moving to trash ${mediaUrls.join(", ")} assets");
final result = await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
if (result) {
await _trashedLocalAssetRepository.trashLocalAsset(localAssetsToTrash);
}
}
} else {
_logger.info("No assets found in backup-enabled albums for assets: $checksums");
_logger.info("No assets found in backup-enabled albums for assets: $trashedAssetsMap");
}
}
}

View File

@ -35,6 +35,7 @@ enum TimelineOrigin {
search,
deepLink,
albumActivities,
syncTrash,
}
class TimelineFactory {
@ -65,6 +66,8 @@ class TimelineFactory {
TimelineService trash(String userId) => TimelineService(_timelineRepository.trash(userId, groupBy));
TimelineService toTrashSyncReview() => TimelineService(_timelineRepository.toTrashSyncReview(groupBy));
TimelineService archive(String userId) => TimelineService(_timelineRepository.archived(userId, groupBy));
TimelineService lockedFolder(String userId) => TimelineService(_timelineRepository.locked(userId, groupBy));
@ -93,6 +96,7 @@ class TimelineService {
StreamSubscription? _bucketSubscription;
int _totalAssets = 0;
int get totalAssets => _totalAssets;
TimelineService(TimelineQuery query)

View File

@ -0,0 +1,12 @@
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
class TrashSyncService {
final DriftTrashSyncRepository _trashSyncRepository;
const TrashSyncService({required DriftTrashSyncRepository trashSyncRepository})
: _trashSyncRepository = trashSyncRepository;
Stream<int> watchPendingApprovalCount() => _trashSyncRepository.watchPendingApprovalCount();
Stream<bool> watchIsApprovalPending(String checksum) => _trashSyncRepository.watchIsApprovalPending(checksum);
}

View File

@ -3,8 +3,9 @@ import 'stack.entity.dart';
import 'local_asset.entity.dart';
import 'local_album.entity.dart';
import 'local_album_asset.entity.dart';
import 'trash_sync.entity.dart';
mergedAsset:
mergedAsset:
SELECT
rae.id as remote_id,
(SELECT lae.id FROM local_asset_entity lae WHERE lae.checksum = rae.checksum LIMIT 1) as local_id,
@ -12,6 +13,7 @@ SELECT
rae."type",
rae.created_at as created_at,
rae.updated_at,
rae.deleted_at,
rae.width,
rae.height,
rae.duration_in_seconds,
@ -21,19 +23,31 @@ SELECT
rae.owner_id,
rae.live_photo_video_id,
0 as orientation,
rae.stack_id
rae.stack_id,
COALESCE(
(SELECT ts.is_sync_approved = 0 FROM trash_sync_entity ts WHERE ts.checksum = rae.checksum LIMIT 1),
FALSE
) AS sync_rejected
FROM
remote_asset_entity rae
LEFT JOIN
stack_entity se ON rae.stack_id = se.id
WHERE
rae.deleted_at IS NULL
(
rae.deleted_at IS NULL
OR EXISTS (
SELECT 1 FROM local_asset_entity lae WHERE lae.checksum = rae.checksum
)
)
AND rae.visibility = 0 -- timeline visibility
AND rae.owner_id IN :user_ids
AND (
rae.stack_id IS NULL
OR rae.id = se.primary_asset_id
)
AND NOT EXISTS (
SELECT 1 FROM trash_sync_entity ts WHERE ts.checksum = rae.checksum AND ts.is_sync_approved = 1
)
UNION ALL
@ -44,6 +58,7 @@ SELECT
lae."type",
lae.created_at as created_at,
lae.updated_at,
NULL as deleted_at,
lae.width,
lae.height,
lae.duration_in_seconds,
@ -53,7 +68,8 @@ SELECT
NULL as owner_id,
NULL as live_photo_video_id,
lae.orientation,
NULL as stack_id
NULL as stack_id,
false as sync_rejected
FROM
local_asset_entity lae
WHERE NOT EXISTS (
@ -88,13 +104,21 @@ FROM
LEFT JOIN
stack_entity se ON rae.stack_id = se.id
WHERE
rae.deleted_at IS NULL
(
rae.deleted_at IS NULL
OR EXISTS (
SELECT 1 FROM local_asset_entity lae WHERE lae.checksum = rae.checksum
)
)
AND rae.visibility = 0 -- timeline visibility
AND rae.owner_id in :user_ids
AND (
rae.stack_id IS NULL
OR rae.id = se.primary_asset_id
)
AND NOT EXISTS (
SELECT 1 FROM trash_sync_entity ts WHERE ts.checksum = rae.checksum AND ts.is_sync_approved = 1
)
UNION ALL
SELECT
lae.created_at

View File

@ -9,10 +9,12 @@ import 'package:immich_mobile/infrastructure/entities/remote_asset.entity.drift.
as i4;
import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'
as i5;
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart'
as i6;
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'
as i7;
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'
as i8;
class MergedAssetDrift extends i1.ModularAccessor {
MergedAssetDrift(i0.GeneratedDatabase db) : super(db);
@ -29,7 +31,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
);
$arrayStartIndex += generatedlimit.amountOfVariables;
return customSelect(
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}',
'SELECT rae.id AS remote_id, (SELECT lae.id FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum LIMIT 1) AS local_id, rae.name, rae.type, rae.created_at AS created_at, rae.updated_at, rae.deleted_at, rae.width, rae.height, rae.duration_in_seconds, rae.is_favorite, rae.thumb_hash, rae.checksum, rae.owner_id, rae.live_photo_video_id, 0 AS orientation, rae.stack_id, COALESCE((SELECT ts.is_sync_approved = 0 FROM trash_sync_entity AS ts WHERE ts.checksum = rae.checksum LIMIT 1), FALSE) AS sync_rejected FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE(rae.deleted_at IS NULL OR EXISTS (SELECT 1 AS _c0 FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum))AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)AND NOT EXISTS (SELECT 1 AS _c1 FROM trash_sync_entity AS ts WHERE ts.checksum = rae.checksum AND ts.is_sync_approved = 1) UNION ALL SELECT NULL AS remote_id, lae.id AS local_id, lae.name, lae.type, lae.created_at AS created_at, lae.updated_at, NULL AS deleted_at, lae.width, lae.height, lae.duration_in_seconds, lae.is_favorite, NULL AS thumb_hash, lae.checksum, NULL AS owner_id, NULL AS live_photo_video_id, lae.orientation, NULL AS stack_id, FALSE AS sync_rejected FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2) ORDER BY created_at DESC ${generatedlimit.sql}',
variables: [
for (var $ in userIds) i0.Variable<String>($),
...generatedlimit.introducedVariables,
@ -37,6 +39,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
readsFrom: {
remoteAssetEntity,
localAssetEntity,
trashSyncEntity,
stackEntity,
localAlbumAssetEntity,
localAlbumEntity,
@ -52,6 +55,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
),
createdAt: row.read<DateTime>('created_at'),
updatedAt: row.read<DateTime>('updated_at'),
deletedAt: row.readNullable<DateTime>('deleted_at'),
width: row.readNullable<int>('width'),
height: row.readNullable<int>('height'),
durationInSeconds: row.readNullable<int>('duration_in_seconds'),
@ -62,6 +66,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
livePhotoVideoId: row.readNullable<String>('live_photo_video_id'),
orientation: row.read<int>('orientation'),
stackId: row.readNullable<String>('stack_id'),
syncRejected: row.read<bool>('sync_rejected'),
),
);
}
@ -74,7 +79,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
final expandeduserIds = $expandVar($arrayStartIndex, userIds.length);
$arrayStartIndex += userIds.length;
return customSelect(
'SELECT COUNT(*) AS asset_count, CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', created_at, \'localtime\') END AS bucket_date FROM (SELECT rae.created_at FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE rae.deleted_at IS NULL AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)UNION ALL SELECT lae.created_at FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2)) GROUP BY bucket_date ORDER BY bucket_date DESC',
'SELECT COUNT(*) AS asset_count, CASE WHEN ?1 = 0 THEN STRFTIME(\'%Y-%m-%d\', created_at, \'localtime\') WHEN ?1 = 1 THEN STRFTIME(\'%Y-%m\', created_at, \'localtime\') END AS bucket_date FROM (SELECT rae.created_at FROM remote_asset_entity AS rae LEFT JOIN stack_entity AS se ON rae.stack_id = se.id WHERE(rae.deleted_at IS NULL OR EXISTS (SELECT 1 AS _c0 FROM local_asset_entity AS lae WHERE lae.checksum = rae.checksum))AND rae.visibility = 0 AND rae.owner_id IN ($expandeduserIds) AND(rae.stack_id IS NULL OR rae.id = se.primary_asset_id)AND NOT EXISTS (SELECT 1 AS _c1 FROM trash_sync_entity AS ts WHERE ts.checksum = rae.checksum AND ts.is_sync_approved = 1) UNION ALL SELECT lae.created_at FROM local_asset_entity AS lae WHERE NOT EXISTS (SELECT 1 FROM remote_asset_entity AS rae WHERE rae.checksum = lae.checksum AND rae.owner_id IN ($expandeduserIds)) AND EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 0) AND NOT EXISTS (SELECT 1 FROM local_album_asset_entity AS laa INNER JOIN local_album_entity AS la ON laa.album_id = la.id WHERE laa.asset_id = lae.id AND la.backup_selection = 2)) GROUP BY bucket_date ORDER BY bucket_date DESC',
variables: [
i0.Variable<int>(groupBy),
for (var $ in userIds) i0.Variable<String>($),
@ -83,6 +88,7 @@ class MergedAssetDrift extends i1.ModularAccessor {
remoteAssetEntity,
stackEntity,
localAssetEntity,
trashSyncEntity,
localAlbumAssetEntity,
localAlbumEntity,
},
@ -103,13 +109,16 @@ class MergedAssetDrift extends i1.ModularAccessor {
i3.$LocalAssetEntityTable get localAssetEntity => i1.ReadDatabaseContainer(
attachedDatabase,
).resultSet<i3.$LocalAssetEntityTable>('local_asset_entity');
i6.$LocalAlbumAssetEntityTable get localAlbumAssetEntity =>
i6.$TrashSyncEntityTable get trashSyncEntity => i1.ReadDatabaseContainer(
attachedDatabase,
).resultSet<i6.$TrashSyncEntityTable>('trash_sync_entity');
i7.$LocalAlbumAssetEntityTable get localAlbumAssetEntity =>
i1.ReadDatabaseContainer(
attachedDatabase,
).resultSet<i6.$LocalAlbumAssetEntityTable>('local_album_asset_entity');
i7.$LocalAlbumEntityTable get localAlbumEntity => i1.ReadDatabaseContainer(
).resultSet<i7.$LocalAlbumAssetEntityTable>('local_album_asset_entity');
i8.$LocalAlbumEntityTable get localAlbumEntity => i1.ReadDatabaseContainer(
attachedDatabase,
).resultSet<i7.$LocalAlbumEntityTable>('local_album_entity');
).resultSet<i8.$LocalAlbumEntityTable>('local_album_entity');
}
class MergedAssetResult {
@ -119,6 +128,7 @@ class MergedAssetResult {
final i2.AssetType type;
final DateTime createdAt;
final DateTime updatedAt;
final DateTime? deletedAt;
final int? width;
final int? height;
final int? durationInSeconds;
@ -129,6 +139,7 @@ class MergedAssetResult {
final String? livePhotoVideoId;
final int orientation;
final String? stackId;
final bool syncRejected;
MergedAssetResult({
this.remoteId,
this.localId,
@ -136,6 +147,7 @@ class MergedAssetResult {
required this.type,
required this.createdAt,
required this.updatedAt,
this.deletedAt,
this.width,
this.height,
this.durationInSeconds,
@ -146,6 +158,7 @@ class MergedAssetResult {
this.livePhotoVideoId,
required this.orientation,
this.stackId,
required this.syncRejected,
});
}

View File

@ -0,0 +1,24 @@
import 'package:drift/drift.dart';
import 'package:immich_mobile/domain/models/trash_sync.model.dart';
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart';
import 'package:immich_mobile/infrastructure/utils/drift_default.mixin.dart';
@TableIndex(name: 'idx_trash_sync_checksum', columns: {#checksum})
@TableIndex(name: 'idx_trash_sync_status', columns: {#isSyncApproved})
@TableIndex(name: 'idx_trash_sync_checksum_status', columns: {#checksum, #isSyncApproved})
class TrashSyncEntity extends Table with DriftDefaultsMixin {
const TrashSyncEntity();
TextColumn get checksum => text()();
BoolColumn get isSyncApproved => boolean().nullable()();
DateTimeColumn get updatedAt => dateTime().withDefault(currentDateAndTime)();
@override
Set<Column> get primaryKey => {checksum};
}
extension LocalAssetEntityDataDomainEx on TrashSyncEntityData {
TrashSyncDecision toDto() => TrashSyncDecision(checksum: checksum, isSyncApproved: isSyncApproved);
}

View File

@ -0,0 +1,458 @@
// dart format width=80
// ignore_for_file: type=lint
import 'package:drift/drift.dart' as i0;
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart'
as i1;
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.dart'
as i2;
import 'package:drift/src/runtime/query_builder/query_builder.dart' as i3;
typedef $$TrashSyncEntityTableCreateCompanionBuilder =
i1.TrashSyncEntityCompanion Function({
required String checksum,
i0.Value<bool?> isSyncApproved,
i0.Value<DateTime> updatedAt,
});
typedef $$TrashSyncEntityTableUpdateCompanionBuilder =
i1.TrashSyncEntityCompanion Function({
i0.Value<String> checksum,
i0.Value<bool?> isSyncApproved,
i0.Value<DateTime> updatedAt,
});
class $$TrashSyncEntityTableFilterComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$TrashSyncEntityTable> {
$$TrashSyncEntityTableFilterComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnFilters<String> get checksum => $composableBuilder(
column: $table.checksum,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<bool> get isSyncApproved => $composableBuilder(
column: $table.isSyncApproved,
builder: (column) => i0.ColumnFilters(column),
);
i0.ColumnFilters<DateTime> get updatedAt => $composableBuilder(
column: $table.updatedAt,
builder: (column) => i0.ColumnFilters(column),
);
}
class $$TrashSyncEntityTableOrderingComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$TrashSyncEntityTable> {
$$TrashSyncEntityTableOrderingComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.ColumnOrderings<String> get checksum => $composableBuilder(
column: $table.checksum,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<bool> get isSyncApproved => $composableBuilder(
column: $table.isSyncApproved,
builder: (column) => i0.ColumnOrderings(column),
);
i0.ColumnOrderings<DateTime> get updatedAt => $composableBuilder(
column: $table.updatedAt,
builder: (column) => i0.ColumnOrderings(column),
);
}
class $$TrashSyncEntityTableAnnotationComposer
extends i0.Composer<i0.GeneratedDatabase, i1.$TrashSyncEntityTable> {
$$TrashSyncEntityTableAnnotationComposer({
required super.$db,
required super.$table,
super.joinBuilder,
super.$addJoinBuilderToRootComposer,
super.$removeJoinBuilderFromRootComposer,
});
i0.GeneratedColumn<String> get checksum =>
$composableBuilder(column: $table.checksum, builder: (column) => column);
i0.GeneratedColumn<bool> get isSyncApproved => $composableBuilder(
column: $table.isSyncApproved,
builder: (column) => column,
);
i0.GeneratedColumn<DateTime> get updatedAt =>
$composableBuilder(column: $table.updatedAt, builder: (column) => column);
}
class $$TrashSyncEntityTableTableManager
extends
i0.RootTableManager<
i0.GeneratedDatabase,
i1.$TrashSyncEntityTable,
i1.TrashSyncEntityData,
i1.$$TrashSyncEntityTableFilterComposer,
i1.$$TrashSyncEntityTableOrderingComposer,
i1.$$TrashSyncEntityTableAnnotationComposer,
$$TrashSyncEntityTableCreateCompanionBuilder,
$$TrashSyncEntityTableUpdateCompanionBuilder,
(
i1.TrashSyncEntityData,
i0.BaseReferences<
i0.GeneratedDatabase,
i1.$TrashSyncEntityTable,
i1.TrashSyncEntityData
>,
),
i1.TrashSyncEntityData,
i0.PrefetchHooks Function()
> {
$$TrashSyncEntityTableTableManager(
i0.GeneratedDatabase db,
i1.$TrashSyncEntityTable table,
) : super(
i0.TableManagerState(
db: db,
table: table,
createFilteringComposer: () =>
i1.$$TrashSyncEntityTableFilterComposer($db: db, $table: table),
createOrderingComposer: () =>
i1.$$TrashSyncEntityTableOrderingComposer($db: db, $table: table),
createComputedFieldComposer: () => i1
.$$TrashSyncEntityTableAnnotationComposer($db: db, $table: table),
updateCompanionCallback:
({
i0.Value<String> checksum = const i0.Value.absent(),
i0.Value<bool?> isSyncApproved = const i0.Value.absent(),
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
}) => i1.TrashSyncEntityCompanion(
checksum: checksum,
isSyncApproved: isSyncApproved,
updatedAt: updatedAt,
),
createCompanionCallback:
({
required String checksum,
i0.Value<bool?> isSyncApproved = const i0.Value.absent(),
i0.Value<DateTime> updatedAt = const i0.Value.absent(),
}) => i1.TrashSyncEntityCompanion.insert(
checksum: checksum,
isSyncApproved: isSyncApproved,
updatedAt: updatedAt,
),
withReferenceMapper: (p0) => p0
.map((e) => (e.readTable(table), i0.BaseReferences(db, table, e)))
.toList(),
prefetchHooksCallback: null,
),
);
}
typedef $$TrashSyncEntityTableProcessedTableManager =
i0.ProcessedTableManager<
i0.GeneratedDatabase,
i1.$TrashSyncEntityTable,
i1.TrashSyncEntityData,
i1.$$TrashSyncEntityTableFilterComposer,
i1.$$TrashSyncEntityTableOrderingComposer,
i1.$$TrashSyncEntityTableAnnotationComposer,
$$TrashSyncEntityTableCreateCompanionBuilder,
$$TrashSyncEntityTableUpdateCompanionBuilder,
(
i1.TrashSyncEntityData,
i0.BaseReferences<
i0.GeneratedDatabase,
i1.$TrashSyncEntityTable,
i1.TrashSyncEntityData
>,
),
i1.TrashSyncEntityData,
i0.PrefetchHooks Function()
>;
i0.Index get idxTrashSyncChecksum => i0.Index(
'idx_trash_sync_checksum',
'CREATE INDEX idx_trash_sync_checksum ON trash_sync_entity (checksum)',
);
class $TrashSyncEntityTable extends i2.TrashSyncEntity
with i0.TableInfo<$TrashSyncEntityTable, i1.TrashSyncEntityData> {
@override
final i0.GeneratedDatabase attachedDatabase;
final String? _alias;
$TrashSyncEntityTable(this.attachedDatabase, [this._alias]);
static const i0.VerificationMeta _checksumMeta = const i0.VerificationMeta(
'checksum',
);
@override
late final i0.GeneratedColumn<String> checksum = i0.GeneratedColumn<String>(
'checksum',
aliasedName,
false,
type: i0.DriftSqlType.string,
requiredDuringInsert: true,
);
static const i0.VerificationMeta _isSyncApprovedMeta =
const i0.VerificationMeta('isSyncApproved');
@override
late final i0.GeneratedColumn<bool> isSyncApproved = i0.GeneratedColumn<bool>(
'is_sync_approved',
aliasedName,
true,
type: i0.DriftSqlType.bool,
requiredDuringInsert: false,
defaultConstraints: i0.GeneratedColumn.constraintIsAlways(
'CHECK ("is_sync_approved" IN (0, 1))',
),
);
static const i0.VerificationMeta _updatedAtMeta = const i0.VerificationMeta(
'updatedAt',
);
@override
late final i0.GeneratedColumn<DateTime> updatedAt =
i0.GeneratedColumn<DateTime>(
'updated_at',
aliasedName,
false,
type: i0.DriftSqlType.dateTime,
requiredDuringInsert: false,
defaultValue: i3.currentDateAndTime,
);
@override
List<i0.GeneratedColumn> get $columns => [
checksum,
isSyncApproved,
updatedAt,
];
@override
String get aliasedName => _alias ?? actualTableName;
@override
String get actualTableName => $name;
static const String $name = 'trash_sync_entity';
@override
i0.VerificationContext validateIntegrity(
i0.Insertable<i1.TrashSyncEntityData> instance, {
bool isInserting = false,
}) {
final context = i0.VerificationContext();
final data = instance.toColumns(true);
if (data.containsKey('checksum')) {
context.handle(
_checksumMeta,
checksum.isAcceptableOrUnknown(data['checksum']!, _checksumMeta),
);
} else if (isInserting) {
context.missing(_checksumMeta);
}
if (data.containsKey('is_sync_approved')) {
context.handle(
_isSyncApprovedMeta,
isSyncApproved.isAcceptableOrUnknown(
data['is_sync_approved']!,
_isSyncApprovedMeta,
),
);
}
if (data.containsKey('updated_at')) {
context.handle(
_updatedAtMeta,
updatedAt.isAcceptableOrUnknown(data['updated_at']!, _updatedAtMeta),
);
}
return context;
}
@override
Set<i0.GeneratedColumn> get $primaryKey => {checksum};
@override
i1.TrashSyncEntityData map(Map<String, dynamic> data, {String? tablePrefix}) {
final effectivePrefix = tablePrefix != null ? '$tablePrefix.' : '';
return i1.TrashSyncEntityData(
checksum: attachedDatabase.typeMapping.read(
i0.DriftSqlType.string,
data['${effectivePrefix}checksum'],
)!,
isSyncApproved: attachedDatabase.typeMapping.read(
i0.DriftSqlType.bool,
data['${effectivePrefix}is_sync_approved'],
),
updatedAt: attachedDatabase.typeMapping.read(
i0.DriftSqlType.dateTime,
data['${effectivePrefix}updated_at'],
)!,
);
}
@override
$TrashSyncEntityTable createAlias(String alias) {
return $TrashSyncEntityTable(attachedDatabase, alias);
}
@override
bool get withoutRowId => true;
@override
bool get isStrict => true;
}
class TrashSyncEntityData extends i0.DataClass
implements i0.Insertable<i1.TrashSyncEntityData> {
final String checksum;
final bool? isSyncApproved;
final DateTime updatedAt;
const TrashSyncEntityData({
required this.checksum,
this.isSyncApproved,
required this.updatedAt,
});
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
map['checksum'] = i0.Variable<String>(checksum);
if (!nullToAbsent || isSyncApproved != null) {
map['is_sync_approved'] = i0.Variable<bool>(isSyncApproved);
}
map['updated_at'] = i0.Variable<DateTime>(updatedAt);
return map;
}
factory TrashSyncEntityData.fromJson(
Map<String, dynamic> json, {
i0.ValueSerializer? serializer,
}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return TrashSyncEntityData(
checksum: serializer.fromJson<String>(json['checksum']),
isSyncApproved: serializer.fromJson<bool?>(json['isSyncApproved']),
updatedAt: serializer.fromJson<DateTime>(json['updatedAt']),
);
}
@override
Map<String, dynamic> toJson({i0.ValueSerializer? serializer}) {
serializer ??= i0.driftRuntimeOptions.defaultSerializer;
return <String, dynamic>{
'checksum': serializer.toJson<String>(checksum),
'isSyncApproved': serializer.toJson<bool?>(isSyncApproved),
'updatedAt': serializer.toJson<DateTime>(updatedAt),
};
}
i1.TrashSyncEntityData copyWith({
String? checksum,
i0.Value<bool?> isSyncApproved = const i0.Value.absent(),
DateTime? updatedAt,
}) => i1.TrashSyncEntityData(
checksum: checksum ?? this.checksum,
isSyncApproved: isSyncApproved.present
? isSyncApproved.value
: this.isSyncApproved,
updatedAt: updatedAt ?? this.updatedAt,
);
TrashSyncEntityData copyWithCompanion(i1.TrashSyncEntityCompanion data) {
return TrashSyncEntityData(
checksum: data.checksum.present ? data.checksum.value : this.checksum,
isSyncApproved: data.isSyncApproved.present
? data.isSyncApproved.value
: this.isSyncApproved,
updatedAt: data.updatedAt.present ? data.updatedAt.value : this.updatedAt,
);
}
@override
String toString() {
return (StringBuffer('TrashSyncEntityData(')
..write('checksum: $checksum, ')
..write('isSyncApproved: $isSyncApproved, ')
..write('updatedAt: $updatedAt')
..write(')'))
.toString();
}
@override
int get hashCode => Object.hash(checksum, isSyncApproved, updatedAt);
@override
bool operator ==(Object other) =>
identical(this, other) ||
(other is i1.TrashSyncEntityData &&
other.checksum == this.checksum &&
other.isSyncApproved == this.isSyncApproved &&
other.updatedAt == this.updatedAt);
}
class TrashSyncEntityCompanion
extends i0.UpdateCompanion<i1.TrashSyncEntityData> {
final i0.Value<String> checksum;
final i0.Value<bool?> isSyncApproved;
final i0.Value<DateTime> updatedAt;
const TrashSyncEntityCompanion({
this.checksum = const i0.Value.absent(),
this.isSyncApproved = const i0.Value.absent(),
this.updatedAt = const i0.Value.absent(),
});
TrashSyncEntityCompanion.insert({
required String checksum,
this.isSyncApproved = const i0.Value.absent(),
this.updatedAt = const i0.Value.absent(),
}) : checksum = i0.Value(checksum);
static i0.Insertable<i1.TrashSyncEntityData> custom({
i0.Expression<String>? checksum,
i0.Expression<bool>? isSyncApproved,
i0.Expression<DateTime>? updatedAt,
}) {
return i0.RawValuesInsertable({
if (checksum != null) 'checksum': checksum,
if (isSyncApproved != null) 'is_sync_approved': isSyncApproved,
if (updatedAt != null) 'updated_at': updatedAt,
});
}
i1.TrashSyncEntityCompanion copyWith({
i0.Value<String>? checksum,
i0.Value<bool?>? isSyncApproved,
i0.Value<DateTime>? updatedAt,
}) {
return i1.TrashSyncEntityCompanion(
checksum: checksum ?? this.checksum,
isSyncApproved: isSyncApproved ?? this.isSyncApproved,
updatedAt: updatedAt ?? this.updatedAt,
);
}
@override
Map<String, i0.Expression> toColumns(bool nullToAbsent) {
final map = <String, i0.Expression>{};
if (checksum.present) {
map['checksum'] = i0.Variable<String>(checksum.value);
}
if (isSyncApproved.present) {
map['is_sync_approved'] = i0.Variable<bool>(isSyncApproved.value);
}
if (updatedAt.present) {
map['updated_at'] = i0.Variable<DateTime>(updatedAt.value);
}
return map;
}
@override
String toString() {
return (StringBuffer('TrashSyncEntityCompanion(')
..write('checksum: $checksum, ')
..write('isSyncApproved: $isSyncApproved, ')
..write('updatedAt: $updatedAt')
..write(')'))
.toString();
}
}
i0.Index get idxTrashSyncStatus => i0.Index(
'idx_trash_sync_status',
'CREATE INDEX idx_trash_sync_status ON trash_sync_entity (is_sync_approved)',
);
i0.Index get idxTrashSyncChecksumStatus => i0.Index(
'idx_trash_sync_checksum_status',
'CREATE INDEX idx_trash_sync_checksum_status ON trash_sync_entity (checksum, is_sync_approved)',
);

View File

@ -95,7 +95,7 @@ class Drift extends $Drift implements IDatabaseRepository {
}
@override
int get schemaVersion => 14;
int get schemaVersion => 15;
@override
MigrationStrategy get migration => MigrationStrategy(
@ -190,6 +190,12 @@ class Drift extends $Drift implements IDatabaseRepository {
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.latitude);
await m.addColumn(v14.localAssetEntity, v14.localAssetEntity.longitude);
},
from14To15: (m, v15) async {
await m.create(v15.trashSyncEntity);
await m.createIndex(v15.idxTrashSyncChecksum);
await m.createIndex(v15.idxTrashSyncStatus);
await m.createIndex(v15.idxTrashSyncChecksumStatus);
},
),
);

View File

@ -9,39 +9,41 @@ import 'package:immich_mobile/infrastructure/entities/stack.entity.drift.dart'
as i3;
import 'package:immich_mobile/infrastructure/entities/local_asset.entity.drift.dart'
as i4;
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart'
as i5;
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'
import 'package:immich_mobile/infrastructure/entities/remote_album.entity.drift.dart'
as i6;
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'
import 'package:immich_mobile/infrastructure/entities/local_album.entity.drift.dart'
as i7;
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart'
import 'package:immich_mobile/infrastructure/entities/local_album_asset.entity.drift.dart'
as i8;
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart'
import 'package:immich_mobile/infrastructure/entities/auth_user.entity.drift.dart'
as i9;
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'
import 'package:immich_mobile/infrastructure/entities/user_metadata.entity.drift.dart'
as i10;
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'
import 'package:immich_mobile/infrastructure/entities/partner.entity.drift.dart'
as i11;
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart'
import 'package:immich_mobile/infrastructure/entities/exif.entity.drift.dart'
as i12;
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart'
import 'package:immich_mobile/infrastructure/entities/remote_album_asset.entity.drift.dart'
as i13;
import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart'
import 'package:immich_mobile/infrastructure/entities/remote_album_user.entity.drift.dart'
as i14;
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.dart'
import 'package:immich_mobile/infrastructure/entities/memory.entity.drift.dart'
as i15;
import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart'
import 'package:immich_mobile/infrastructure/entities/memory_asset.entity.drift.dart'
as i16;
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart'
import 'package:immich_mobile/infrastructure/entities/person.entity.drift.dart'
as i17;
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'
import 'package:immich_mobile/infrastructure/entities/asset_face.entity.drift.dart'
as i18;
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'
import 'package:immich_mobile/infrastructure/entities/store.entity.drift.dart'
as i19;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
import 'package:immich_mobile/infrastructure/entities/trashed_local_asset.entity.drift.dart'
as i20;
import 'package:drift/internal/modular.dart' as i21;
import 'package:immich_mobile/infrastructure/entities/merged_asset.drift.dart'
as i21;
import 'package:drift/internal/modular.dart' as i22;
abstract class $Drift extends i0.GeneratedDatabase {
$Drift(i0.QueryExecutor e) : super(e);
@ -52,38 +54,40 @@ abstract class $Drift extends i0.GeneratedDatabase {
late final i3.$StackEntityTable stackEntity = i3.$StackEntityTable(this);
late final i4.$LocalAssetEntityTable localAssetEntity = i4
.$LocalAssetEntityTable(this);
late final i5.$RemoteAlbumEntityTable remoteAlbumEntity = i5
late final i5.$TrashSyncEntityTable trashSyncEntity = i5
.$TrashSyncEntityTable(this);
late final i6.$RemoteAlbumEntityTable remoteAlbumEntity = i6
.$RemoteAlbumEntityTable(this);
late final i6.$LocalAlbumEntityTable localAlbumEntity = i6
late final i7.$LocalAlbumEntityTable localAlbumEntity = i7
.$LocalAlbumEntityTable(this);
late final i7.$LocalAlbumAssetEntityTable localAlbumAssetEntity = i7
late final i8.$LocalAlbumAssetEntityTable localAlbumAssetEntity = i8
.$LocalAlbumAssetEntityTable(this);
late final i8.$AuthUserEntityTable authUserEntity = i8.$AuthUserEntityTable(
late final i9.$AuthUserEntityTable authUserEntity = i9.$AuthUserEntityTable(
this,
);
late final i9.$UserMetadataEntityTable userMetadataEntity = i9
late final i10.$UserMetadataEntityTable userMetadataEntity = i10
.$UserMetadataEntityTable(this);
late final i10.$PartnerEntityTable partnerEntity = i10.$PartnerEntityTable(
late final i11.$PartnerEntityTable partnerEntity = i11.$PartnerEntityTable(
this,
);
late final i11.$RemoteExifEntityTable remoteExifEntity = i11
late final i12.$RemoteExifEntityTable remoteExifEntity = i12
.$RemoteExifEntityTable(this);
late final i12.$RemoteAlbumAssetEntityTable remoteAlbumAssetEntity = i12
late final i13.$RemoteAlbumAssetEntityTable remoteAlbumAssetEntity = i13
.$RemoteAlbumAssetEntityTable(this);
late final i13.$RemoteAlbumUserEntityTable remoteAlbumUserEntity = i13
late final i14.$RemoteAlbumUserEntityTable remoteAlbumUserEntity = i14
.$RemoteAlbumUserEntityTable(this);
late final i14.$MemoryEntityTable memoryEntity = i14.$MemoryEntityTable(this);
late final i15.$MemoryAssetEntityTable memoryAssetEntity = i15
late final i15.$MemoryEntityTable memoryEntity = i15.$MemoryEntityTable(this);
late final i16.$MemoryAssetEntityTable memoryAssetEntity = i16
.$MemoryAssetEntityTable(this);
late final i16.$PersonEntityTable personEntity = i16.$PersonEntityTable(this);
late final i17.$AssetFaceEntityTable assetFaceEntity = i17
late final i17.$PersonEntityTable personEntity = i17.$PersonEntityTable(this);
late final i18.$AssetFaceEntityTable assetFaceEntity = i18
.$AssetFaceEntityTable(this);
late final i18.$StoreEntityTable storeEntity = i18.$StoreEntityTable(this);
late final i19.$TrashedLocalAssetEntityTable trashedLocalAssetEntity = i19
late final i19.$StoreEntityTable storeEntity = i19.$StoreEntityTable(this);
late final i20.$TrashedLocalAssetEntityTable trashedLocalAssetEntity = i20
.$TrashedLocalAssetEntityTable(this);
i20.MergedAssetDrift get mergedAssetDrift => i21.ReadDatabaseContainer(
i21.MergedAssetDrift get mergedAssetDrift => i22.ReadDatabaseContainer(
this,
).accessor<i20.MergedAssetDrift>(i20.MergedAssetDrift.new);
).accessor<i21.MergedAssetDrift>(i21.MergedAssetDrift.new);
@override
Iterable<i0.TableInfo<i0.Table, Object?>> get allTables =>
allSchemaEntities.whereType<i0.TableInfo<i0.Table, Object?>>();
@ -93,9 +97,13 @@ abstract class $Drift extends i0.GeneratedDatabase {
remoteAssetEntity,
stackEntity,
localAssetEntity,
trashSyncEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
i5.idxTrashSyncChecksum,
i5.idxTrashSyncStatus,
i5.idxTrashSyncChecksumStatus,
i4.idxLocalAssetChecksum,
i2.idxRemoteAssetOwnerChecksum,
i2.uQRemoteAssetsOwnerChecksum,
@ -113,9 +121,9 @@ abstract class $Drift extends i0.GeneratedDatabase {
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
i11.idxLatLng,
i19.idxTrashedLocalAssetChecksum,
i19.idxTrashedLocalAssetAlbum,
i12.idxLatLng,
i20.idxTrashedLocalAssetChecksum,
i20.idxTrashedLocalAssetAlbum,
];
@override
i0.StreamQueryUpdateRules
@ -312,39 +320,41 @@ class $DriftManager {
i3.$$StackEntityTableTableManager(_db, _db.stackEntity);
i4.$$LocalAssetEntityTableTableManager get localAssetEntity =>
i4.$$LocalAssetEntityTableTableManager(_db, _db.localAssetEntity);
i5.$$RemoteAlbumEntityTableTableManager get remoteAlbumEntity =>
i5.$$RemoteAlbumEntityTableTableManager(_db, _db.remoteAlbumEntity);
i6.$$LocalAlbumEntityTableTableManager get localAlbumEntity =>
i6.$$LocalAlbumEntityTableTableManager(_db, _db.localAlbumEntity);
i7.$$LocalAlbumAssetEntityTableTableManager get localAlbumAssetEntity => i7
i5.$$TrashSyncEntityTableTableManager get trashSyncEntity =>
i5.$$TrashSyncEntityTableTableManager(_db, _db.trashSyncEntity);
i6.$$RemoteAlbumEntityTableTableManager get remoteAlbumEntity =>
i6.$$RemoteAlbumEntityTableTableManager(_db, _db.remoteAlbumEntity);
i7.$$LocalAlbumEntityTableTableManager get localAlbumEntity =>
i7.$$LocalAlbumEntityTableTableManager(_db, _db.localAlbumEntity);
i8.$$LocalAlbumAssetEntityTableTableManager get localAlbumAssetEntity => i8
.$$LocalAlbumAssetEntityTableTableManager(_db, _db.localAlbumAssetEntity);
i8.$$AuthUserEntityTableTableManager get authUserEntity =>
i8.$$AuthUserEntityTableTableManager(_db, _db.authUserEntity);
i9.$$UserMetadataEntityTableTableManager get userMetadataEntity =>
i9.$$UserMetadataEntityTableTableManager(_db, _db.userMetadataEntity);
i10.$$PartnerEntityTableTableManager get partnerEntity =>
i10.$$PartnerEntityTableTableManager(_db, _db.partnerEntity);
i11.$$RemoteExifEntityTableTableManager get remoteExifEntity =>
i11.$$RemoteExifEntityTableTableManager(_db, _db.remoteExifEntity);
i12.$$RemoteAlbumAssetEntityTableTableManager get remoteAlbumAssetEntity =>
i12.$$RemoteAlbumAssetEntityTableTableManager(
i9.$$AuthUserEntityTableTableManager get authUserEntity =>
i9.$$AuthUserEntityTableTableManager(_db, _db.authUserEntity);
i10.$$UserMetadataEntityTableTableManager get userMetadataEntity =>
i10.$$UserMetadataEntityTableTableManager(_db, _db.userMetadataEntity);
i11.$$PartnerEntityTableTableManager get partnerEntity =>
i11.$$PartnerEntityTableTableManager(_db, _db.partnerEntity);
i12.$$RemoteExifEntityTableTableManager get remoteExifEntity =>
i12.$$RemoteExifEntityTableTableManager(_db, _db.remoteExifEntity);
i13.$$RemoteAlbumAssetEntityTableTableManager get remoteAlbumAssetEntity =>
i13.$$RemoteAlbumAssetEntityTableTableManager(
_db,
_db.remoteAlbumAssetEntity,
);
i13.$$RemoteAlbumUserEntityTableTableManager get remoteAlbumUserEntity => i13
i14.$$RemoteAlbumUserEntityTableTableManager get remoteAlbumUserEntity => i14
.$$RemoteAlbumUserEntityTableTableManager(_db, _db.remoteAlbumUserEntity);
i14.$$MemoryEntityTableTableManager get memoryEntity =>
i14.$$MemoryEntityTableTableManager(_db, _db.memoryEntity);
i15.$$MemoryAssetEntityTableTableManager get memoryAssetEntity =>
i15.$$MemoryAssetEntityTableTableManager(_db, _db.memoryAssetEntity);
i16.$$PersonEntityTableTableManager get personEntity =>
i16.$$PersonEntityTableTableManager(_db, _db.personEntity);
i17.$$AssetFaceEntityTableTableManager get assetFaceEntity =>
i17.$$AssetFaceEntityTableTableManager(_db, _db.assetFaceEntity);
i18.$$StoreEntityTableTableManager get storeEntity =>
i18.$$StoreEntityTableTableManager(_db, _db.storeEntity);
i19.$$TrashedLocalAssetEntityTableTableManager get trashedLocalAssetEntity =>
i19.$$TrashedLocalAssetEntityTableTableManager(
i15.$$MemoryEntityTableTableManager get memoryEntity =>
i15.$$MemoryEntityTableTableManager(_db, _db.memoryEntity);
i16.$$MemoryAssetEntityTableTableManager get memoryAssetEntity =>
i16.$$MemoryAssetEntityTableTableManager(_db, _db.memoryAssetEntity);
i17.$$PersonEntityTableTableManager get personEntity =>
i17.$$PersonEntityTableTableManager(_db, _db.personEntity);
i18.$$AssetFaceEntityTableTableManager get assetFaceEntity =>
i18.$$AssetFaceEntityTableTableManager(_db, _db.assetFaceEntity);
i19.$$StoreEntityTableTableManager get storeEntity =>
i19.$$StoreEntityTableTableManager(_db, _db.storeEntity);
i20.$$TrashedLocalAssetEntityTableTableManager get trashedLocalAssetEntity =>
i20.$$TrashedLocalAssetEntityTableTableManager(
_db,
_db.trashedLocalAssetEntity,
);

View File

@ -5941,6 +5941,470 @@ i1.GeneratedColumn<DateTime> _column_96(String aliasedName) =>
true,
type: i1.DriftSqlType.dateTime,
);
final class Schema15 extends i0.VersionedSchema {
Schema15({required super.database}) : super(version: 15);
@override
late final List<i1.DatabaseSchemaEntity> entities = [
userEntity,
remoteAssetEntity,
stackEntity,
localAssetEntity,
trashSyncEntity,
remoteAlbumEntity,
localAlbumEntity,
localAlbumAssetEntity,
idxTrashSyncChecksum,
idxTrashSyncStatus,
idxTrashSyncChecksumStatus,
idxLocalAssetChecksum,
idxRemoteAssetOwnerChecksum,
uQRemoteAssetsOwnerChecksum,
uQRemoteAssetsOwnerLibraryChecksum,
idxRemoteAssetChecksum,
authUserEntity,
userMetadataEntity,
partnerEntity,
remoteExifEntity,
remoteAlbumAssetEntity,
remoteAlbumUserEntity,
memoryEntity,
memoryAssetEntity,
personEntity,
assetFaceEntity,
storeEntity,
trashedLocalAssetEntity,
idxLatLng,
idxTrashedLocalAssetChecksum,
idxTrashedLocalAssetAlbum,
];
late final Shape20 userEntity = Shape20(
source: i0.VersionedTable(
entityName: 'user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_84,
_column_85,
_column_91,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape17 remoteAssetEntity = Shape17(
source: i0.VersionedTable(
entityName: 'remote_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_13,
_column_14,
_column_15,
_column_16,
_column_17,
_column_18,
_column_19,
_column_20,
_column_21,
_column_86,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape3 stackEntity = Shape3(
source: i0.VersionedTable(
entityName: 'stack_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_0, _column_9, _column_5, _column_15, _column_75],
attachedDatabase: database,
),
alias: null,
);
late final Shape24 localAssetEntity = Shape24(
source: i0.VersionedTable(
entityName: 'local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_22,
_column_14,
_column_23,
_column_96,
_column_46,
_column_47,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape25 trashSyncEntity = Shape25(
source: i0.VersionedTable(
entityName: 'trash_sync_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(checksum)'],
columns: [_column_13, _column_97, _column_5],
attachedDatabase: database,
),
alias: null,
);
late final Shape9 remoteAlbumEntity = Shape9(
source: i0.VersionedTable(
entityName: 'remote_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_56,
_column_9,
_column_5,
_column_15,
_column_57,
_column_58,
_column_59,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape19 localAlbumEntity = Shape19(
source: i0.VersionedTable(
entityName: 'local_album_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_5,
_column_31,
_column_32,
_column_90,
_column_33,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape22 localAlbumAssetEntity = Shape22(
source: i0.VersionedTable(
entityName: 'local_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_34, _column_35, _column_33],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxTrashSyncChecksum = i1.Index(
'idx_trash_sync_checksum',
'CREATE INDEX idx_trash_sync_checksum ON trash_sync_entity (checksum)',
);
final i1.Index idxTrashSyncStatus = i1.Index(
'idx_trash_sync_status',
'CREATE INDEX idx_trash_sync_status ON trash_sync_entity (is_sync_approved)',
);
final i1.Index idxTrashSyncChecksumStatus = i1.Index(
'idx_trash_sync_checksum_status',
'CREATE INDEX idx_trash_sync_checksum_status ON trash_sync_entity (checksum, is_sync_approved)',
);
final i1.Index idxLocalAssetChecksum = i1.Index(
'idx_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_local_asset_checksum ON local_asset_entity (checksum)',
);
final i1.Index idxRemoteAssetOwnerChecksum = i1.Index(
'idx_remote_asset_owner_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_owner_checksum ON remote_asset_entity (owner_id, checksum)',
);
final i1.Index uQRemoteAssetsOwnerChecksum = i1.Index(
'UQ_remote_assets_owner_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_checksum ON remote_asset_entity (owner_id, checksum) WHERE(library_id IS NULL)',
);
final i1.Index uQRemoteAssetsOwnerLibraryChecksum = i1.Index(
'UQ_remote_assets_owner_library_checksum',
'CREATE UNIQUE INDEX IF NOT EXISTS UQ_remote_assets_owner_library_checksum ON remote_asset_entity (owner_id, library_id, checksum) WHERE(library_id IS NOT NULL)',
);
final i1.Index idxRemoteAssetChecksum = i1.Index(
'idx_remote_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_remote_asset_checksum ON remote_asset_entity (checksum)',
);
late final Shape21 authUserEntity = Shape21(
source: i0.VersionedTable(
entityName: 'auth_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_1,
_column_3,
_column_2,
_column_84,
_column_85,
_column_92,
_column_93,
_column_7,
_column_94,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape4 userMetadataEntity = Shape4(
source: i0.VersionedTable(
entityName: 'user_metadata_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(user_id, "key")'],
columns: [_column_25, _column_26, _column_27],
attachedDatabase: database,
),
alias: null,
);
late final Shape5 partnerEntity = Shape5(
source: i0.VersionedTable(
entityName: 'partner_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(shared_by_id, shared_with_id)'],
columns: [_column_28, _column_29, _column_30],
attachedDatabase: database,
),
alias: null,
);
late final Shape8 remoteExifEntity = Shape8(
source: i0.VersionedTable(
entityName: 'remote_exif_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id)'],
columns: [
_column_36,
_column_37,
_column_38,
_column_39,
_column_40,
_column_41,
_column_11,
_column_10,
_column_42,
_column_43,
_column_44,
_column_45,
_column_46,
_column_47,
_column_48,
_column_49,
_column_50,
_column_51,
_column_52,
_column_53,
_column_54,
_column_55,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape7 remoteAlbumAssetEntity = Shape7(
source: i0.VersionedTable(
entityName: 'remote_album_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, album_id)'],
columns: [_column_36, _column_60],
attachedDatabase: database,
),
alias: null,
);
late final Shape10 remoteAlbumUserEntity = Shape10(
source: i0.VersionedTable(
entityName: 'remote_album_user_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(album_id, user_id)'],
columns: [_column_60, _column_25, _column_61],
attachedDatabase: database,
),
alias: null,
);
late final Shape11 memoryEntity = Shape11(
source: i0.VersionedTable(
entityName: 'memory_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_18,
_column_15,
_column_8,
_column_62,
_column_63,
_column_64,
_column_65,
_column_66,
_column_67,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape12 memoryAssetEntity = Shape12(
source: i0.VersionedTable(
entityName: 'memory_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(asset_id, memory_id)'],
columns: [_column_36, _column_68],
attachedDatabase: database,
),
alias: null,
);
late final Shape14 personEntity = Shape14(
source: i0.VersionedTable(
entityName: 'person_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_9,
_column_5,
_column_15,
_column_1,
_column_69,
_column_71,
_column_72,
_column_73,
_column_74,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape15 assetFaceEntity = Shape15(
source: i0.VersionedTable(
entityName: 'asset_face_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [
_column_0,
_column_36,
_column_76,
_column_77,
_column_78,
_column_79,
_column_80,
_column_81,
_column_82,
_column_83,
],
attachedDatabase: database,
),
alias: null,
);
late final Shape18 storeEntity = Shape18(
source: i0.VersionedTable(
entityName: 'store_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id)'],
columns: [_column_87, _column_88, _column_89],
attachedDatabase: database,
),
alias: null,
);
late final Shape23 trashedLocalAssetEntity = Shape23(
source: i0.VersionedTable(
entityName: 'trashed_local_asset_entity',
withoutRowId: true,
isStrict: true,
tableConstraints: ['PRIMARY KEY(id, album_id)'],
columns: [
_column_1,
_column_8,
_column_9,
_column_5,
_column_10,
_column_11,
_column_12,
_column_0,
_column_95,
_column_22,
_column_14,
_column_23,
],
attachedDatabase: database,
),
alias: null,
);
final i1.Index idxLatLng = i1.Index(
'idx_lat_lng',
'CREATE INDEX IF NOT EXISTS idx_lat_lng ON remote_exif_entity (latitude, longitude)',
);
final i1.Index idxTrashedLocalAssetChecksum = i1.Index(
'idx_trashed_local_asset_checksum',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_checksum ON trashed_local_asset_entity (checksum)',
);
final i1.Index idxTrashedLocalAssetAlbum = i1.Index(
'idx_trashed_local_asset_album',
'CREATE INDEX IF NOT EXISTS idx_trashed_local_asset_album ON trashed_local_asset_entity (album_id)',
);
}
class Shape25 extends i0.VersionedTable {
Shape25({required super.source, required super.alias}) : super.aliased();
i1.GeneratedColumn<String> get checksum =>
columnsByName['checksum']! as i1.GeneratedColumn<String>;
i1.GeneratedColumn<bool> get isSyncApproved =>
columnsByName['is_sync_approved']! as i1.GeneratedColumn<bool>;
i1.GeneratedColumn<DateTime> get updatedAt =>
columnsByName['updated_at']! as i1.GeneratedColumn<DateTime>;
}
i1.GeneratedColumn<bool> _column_97(String aliasedName) =>
i1.GeneratedColumn<bool>(
'is_sync_approved',
aliasedName,
true,
type: i1.DriftSqlType.bool,
defaultConstraints: i1.GeneratedColumn.constraintIsAlways(
'CHECK ("is_sync_approved" IN (0, 1))',
),
);
i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema2 schema) from1To2,
required Future<void> Function(i1.Migrator m, Schema3 schema) from2To3,
@ -5955,6 +6419,7 @@ i0.MigrationStepWithVersion migrationSteps({
required Future<void> Function(i1.Migrator m, Schema12 schema) from11To12,
required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13,
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15,
}) {
return (currentVersion, database) async {
switch (currentVersion) {
@ -6023,6 +6488,11 @@ i0.MigrationStepWithVersion migrationSteps({
final migrator = i1.Migrator(database, schema);
await from13To14(migrator, schema);
return 14;
case 14:
final schema = Schema15(database: database);
final migrator = i1.Migrator(database, schema);
await from14To15(migrator, schema);
return 15;
default:
throw ArgumentError.value('Unknown migration from $currentVersion');
}
@ -6043,6 +6513,7 @@ i1.OnUpgrade stepByStep({
required Future<void> Function(i1.Migrator m, Schema12 schema) from11To12,
required Future<void> Function(i1.Migrator m, Schema13 schema) from12To13,
required Future<void> Function(i1.Migrator m, Schema14 schema) from13To14,
required Future<void> Function(i1.Migrator m, Schema15 schema) from14To15,
}) => i0.VersionedSchema.stepByStepHelper(
step: migrationSteps(
from1To2: from1To2,
@ -6058,5 +6529,6 @@ i1.OnUpgrade stepByStep({
from11To12: from11To12,
from12To13: from12To13,
from13To14: from13To14,
from14To15: from14To15,
),
);

View File

@ -99,14 +99,14 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
return query.map((localAlbum) => localAlbum.toDto()).get();
}
Future<Map<String, List<LocalAsset>>> getAssetsFromBackupAlbums(Iterable<String> checksums) async {
if (checksums.isEmpty) {
Future<Map<String, List<LocalAsset>>> getAssetsFromBackupAlbums(Map<String, DateTime> trashedAssetsMap) async {
if (trashedAssetsMap.isEmpty) {
return {};
}
final result = <String, List<LocalAsset>>{};
for (final slice in checksums.toSet().slices(kDriftMaxChunk)) {
for (final slice in trashedAssetsMap.keys.toSet().slices(kDriftMaxChunk)) {
final rows =
await (_db.select(_db.localAlbumAssetEntity).join([
innerJoin(_db.localAlbumEntity, _db.localAlbumAssetEntity.albumId.equalsExp(_db.localAlbumEntity.id)),
@ -120,10 +120,16 @@ class DriftLocalAssetRepository extends DriftDatabaseRepository {
for (final row in rows) {
final albumId = row.readTable(_db.localAlbumAssetEntity).albumId;
final assetData = row.readTable(_db.localAssetEntity);
final asset = assetData.toDto();
final asset = assetData.toDto().copyWith(deletedAt: trashedAssetsMap[assetData.checksum]);
(result[albumId] ??= <LocalAsset>[]).add(asset);
}
}
return result;
}
Future<List<LocalAsset>> getByChecksums(Iterable<String> checksums) {
if (checksums.isEmpty) return Future.value([]);
final query = _db.localAssetEntity.select()..where((lae) => lae.checksum.isIn(checksums));
return query.map((row) => row.toDto()).get();
}
}

View File

@ -258,4 +258,13 @@ class RemoteAssetRepository extends DriftDatabaseRepository {
Future<int> getCount() {
return _db.managers.remoteAssetEntity.count();
}
Future<List<RemoteAsset>> getByChecksums(Iterable<String> checksums, {bool? isTrashed}) {
if (checksums.isEmpty) return Future.value([]);
final query = _db.remoteAssetEntity.select()..where((rae) => rae.checksum.isIn(checksums));
if (isTrashed != null) {
query.where((rae) => isTrashed ? rae.deletedAt.isNotNull() : rae.deletedAt.isNull());
}
return query.map((row) => row.toDto()).get();
}
}

View File

@ -53,7 +53,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
return _db.mergedAssetDrift
.mergedAsset(userIds: userIds, limit: (_) => Limit(count, offset))
.map(
(row) => row.remoteId != null && row.ownerId != null
(row) => row.remoteId != null && row.ownerId != null && !row.syncRejected
? RemoteAsset(
id: row.remoteId!,
localId: row.localId,
@ -63,6 +63,7 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
type: row.type,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
deletedAt: row.deletedAt,
thumbHash: row.thumbHash,
width: row.width,
height: row.height,
@ -73,12 +74,13 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
)
: LocalAsset(
id: row.localId!,
remoteId: row.remoteId,
remoteId: row.syncRejected ? null : row.remoteId,
name: row.name,
checksum: row.checksum,
type: row.type,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
deletedAt: row.deletedAt,
width: row.width,
height: row.height,
isFavorite: row.isFavorite,
@ -277,6 +279,12 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
joinLocal: true,
);
TimelineQuery toTrashSyncReview(GroupAssetsBy groupBy) => (
bucketSource: () => _watchTrashSyncBucket(groupBy: groupBy),
assetSource: (offset, count) => _getToTrashSyncBucketAssets(offset: offset, count: count),
origin: TimelineOrigin.syncTrash,
);
TimelineQuery archived(String userId, GroupAssetsBy groupBy) => _remoteQueryBuilder(
filter: (row) =>
row.deletedAt.isNull() & row.ownerId.equals(userId) & row.visibility.equalsValue(AssetVisibility.archive),
@ -585,6 +593,56 @@ class DriftTimelineRepository extends DriftDatabaseRepository {
return query.map((row) => row.toDto()).get();
}
}
Stream<List<Bucket>> _watchTrashSyncBucket({GroupAssetsBy groupBy = GroupAssetsBy.day}) {
if (groupBy == GroupAssetsBy.none) {
// TODO: implement GroupAssetBy for place
throw UnsupportedError("GroupAssetsBy.none is not supported for watchPlaceBucket");
}
final assetCountExp = _db.remoteAssetEntity.id.count();
final dateExp = _db.remoteAssetEntity.createdAt.dateFmt(groupBy);
final pendingTrashChecksums = _db.trashSyncEntity.selectOnly()
..addColumns([_db.trashSyncEntity.checksum])
..where(_db.trashSyncEntity.isSyncApproved.isNull())
..groupBy([_db.trashSyncEntity.checksum]);
final query = _db.remoteAssetEntity.selectOnly()
..addColumns([assetCountExp, dateExp])
..where(
_db.remoteAssetEntity.deletedAt.isNotNull() &
_db.remoteAssetEntity.visibility.equalsValue(AssetVisibility.timeline) &
_db.remoteAssetEntity.checksum.isInQuery(pendingTrashChecksums),
)
..groupBy([dateExp])
..orderBy([OrderingTerm.desc(dateExp)]);
return query.map((row) {
final timeline = row.read(dateExp)!.truncateDate(groupBy);
final assetCount = row.read(assetCountExp)!;
return TimeBucket(date: timeline, assetCount: assetCount);
}).watch();
}
Future<List<BaseAsset>> _getToTrashSyncBucketAssets({required int offset, required int count}) {
final pendingTrashChecksums = _db.trashSyncEntity.selectOnly()
..addColumns([_db.trashSyncEntity.checksum])
..where(_db.trashSyncEntity.isSyncApproved.isNull())
..groupBy([_db.trashSyncEntity.checksum]);
final query = _db.remoteAssetEntity.select()
..where(
(tbl) =>
tbl.deletedAt.isNotNull() &
tbl.visibility.equalsValue(AssetVisibility.timeline) &
tbl.checksum.isInQuery(pendingTrashChecksums),
)
..orderBy([(tbl) => OrderingTerm.desc(tbl.createdAt)])
..limit(count, offset: offset);
return query.map((row) => row.toDto()).get();
}
}
List<Bucket> _generateBuckets(int count) {

View File

@ -0,0 +1,109 @@
import 'package:collection/collection.dart';
import 'package:drift/drift.dart';
import 'package:immich_mobile/constants/constants.dart';
import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/entities/trash_sync.entity.drift.dart';
import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
class DriftTrashSyncRepository extends DriftDatabaseRepository {
final Drift _db;
const DriftTrashSyncRepository(this._db) : super(_db);
Future<void> upsertReviewCandidates(Iterable<LocalAsset> itemsToReview) async {
if (itemsToReview.isEmpty) {
return Future.value();
}
final existingEntities = <TrashSyncEntityData>[];
final checksums = itemsToReview.map((e) => e.checksum).nonNulls;
for (final slice in checksums.slices(kDriftMaxChunk)) {
final sliceResult = await (_db.trashSyncEntity.select()..where((tbl) => tbl.checksum.isIn(slice))).get();
existingEntities.addAll(sliceResult);
}
final existingMap = {for (var e in existingEntities) e.checksum: e};
return _db.batch((batch) {
for (var item in itemsToReview) {
final existing = existingMap[item.checksum];
if (existing == null || (existing.isSyncApproved == false && item.deletedAt!.isAfter(existing.updatedAt))) {
batch.insert(
_db.trashSyncEntity,
TrashSyncEntityCompanion.insert(checksum: item.checksum!, updatedAt: Value(item.deletedAt!)),
onConflict: DoUpdate(
(_) => TrashSyncEntityCompanion.custom(
updatedAt: Variable(item.deletedAt),
isSyncApproved: const Variable(null),
),
),
);
}
}
});
}
Future<void> updateApproves(Iterable<String> checksums, bool isSyncApproved) {
if (checksums.isEmpty) {
return Future.value();
}
return _db.batch((batch) {
batch.update(
_db.trashSyncEntity,
TrashSyncEntityCompanion(isSyncApproved: Value(isSyncApproved)),
where: (tbl) => tbl.checksum.isIn(checksums),
);
});
}
Future<int> deleteOutdated() async {
final remoteAliveSelect = _db.selectOnly(_db.remoteAssetEntity)
..addColumns([_db.remoteAssetEntity.checksum])
..where(_db.remoteAssetEntity.deletedAt.isNull());
final localTrashedSelect = _db.selectOnly(_db.trashedLocalAssetEntity)
..addColumns([_db.trashedLocalAssetEntity.checksum]);
final query = _db.delete(_db.trashSyncEntity)
..where((row) => row.isSyncApproved.isNull() | row.isSyncApproved.equals(false))
..where((row) => row.checksum.isInQuery(remoteAliveSelect) | row.checksum.isInQuery(localTrashedSelect));
final deletedMatched = await query.go();
final localTrashedChecksums = _db.selectOnly(_db.trashedLocalAssetEntity)
..addColumns([_db.trashedLocalAssetEntity.checksum])
..where(_db.trashedLocalAssetEntity.checksum.isNotNull());
final localAssetChecksums = _db.selectOnly(_db.localAssetEntity)
..addColumns([_db.localAssetEntity.checksum])
..where(_db.localAssetEntity.checksum.isNotNull());
final orphanQuery = _db.delete(_db.trashSyncEntity)
..where(
(row) =>
(row.isSyncApproved.equals(false) & row.checksum.isNotInQuery(localAssetChecksums)) |
(row.isSyncApproved.equals(true) & row.checksum.isNotInQuery(localTrashedChecksums)),
);
final deletedOrphans = await orphanQuery.go();
return deletedMatched + deletedOrphans;
}
Stream<int> watchPendingApprovalCount() {
final countExpr = _db.trashSyncEntity.checksum.count(distinct: true);
final q = _db.selectOnly(_db.trashSyncEntity)
..addColumns([countExpr])
..where(_db.trashSyncEntity.isSyncApproved.isNull());
return q.watchSingle().map((row) => row.read(countExpr) ?? 0).distinct();
}
Stream<bool> watchIsApprovalPending(String checksum) {
final query = _db.selectOnly(_db.trashSyncEntity)
..addColumns([_db.trashSyncEntity.checksum])
..where((_db.trashSyncEntity.checksum.equals(checksum) & _db.trashSyncEntity.isSyncApproved.isNull()))
..limit(1);
return query.watchSingleOrNull().map((row) => row != null).distinct();
}
}

View File

@ -224,7 +224,8 @@ class DriftTrashedLocalAssetRepository extends DriftDatabaseRepository {
for (final row in rows) {
final albumId = row.readTable(_db.localAlbumAssetEntity).albumId;
final asset = row.readTable(_db.localAssetEntity).toDto();
final remoteDeletedAt = row.read(_db.remoteAssetEntity.deletedAt);
final asset = row.readTable(_db.localAssetEntity).toDto().copyWith(deletedAt: remoteDeletedAt);
(result[albumId] ??= <LocalAsset>[]).add(asset);
}

View File

@ -0,0 +1,81 @@
import 'package:auto_route/auto_route.dart';
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/bottom_sheet/trash_sync_bottom_bar.widget.dart';
import 'package:immich_mobile/presentation/widgets/timeline/timeline.widget.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/routing/router.dart';
@RoutePage()
class DriftTrashSyncReviewPage extends ConsumerWidget {
const DriftTrashSyncReviewPage({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
final router = context.router;
ref.listen(outOfSyncCountProvider, (previous, next) {
final prevCount = previous?.asData?.value ?? 0;
final nextCount = next.asData?.value;
if (prevCount > 0 && nextCount == 0) {
WidgetsBinding.instance.addPostFrameCallback((_) async {
await Future.delayed(const Duration(milliseconds: 1600));
if (router.current.name == DriftTrashSyncReviewRoute.name) {
await router.maybePop();
}
});
}
});
return ProviderScope(
overrides: [
timelineServiceProvider.overrideWith((ref) {
final user = ref.watch(currentUserProvider);
if (user == null) {
throw Exception('User must be logged in to access trash');
}
final timelineService = ref.watch(timelineFactoryProvider).toTrashSyncReview();
ref.onDispose(timelineService.dispose);
return timelineService;
}),
],
child: Timeline(
appBar: SliverAppBar(
title: Text('asset_out_of_sync_title'.tr()),
floating: true,
snap: true,
pinned: true,
centerTitle: true,
elevation: 0,
),
topSliverWidgetHeight: 24,
topSliverWidget: SliverPadding(
padding: const EdgeInsets.all(16.0),
sliver: SliverToBoxAdapter(
child: SizedBox(
height: 72.0,
child: Consumer(
builder: (context, ref, _) {
final outOfSyncCount = ref.watch(outOfSyncCountProvider).maybeWhen(data: (v) => v, orElse: () => 0);
return outOfSyncCount > 0
? const Text('asset_out_of_sync_trash_subtitle').tr()
: Center(
child: Text(
'asset_out_of_sync_trash_subtitle_result',
style: context.textTheme.bodyLarge,
).tr(),
);
},
),
),
),
),
bottomSheet: const TrashSyncBottomBar(),
),
);
}
}

View File

@ -0,0 +1,71 @@
import 'package:easy_localization/easy_localization.dart';
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/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.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/widgets/common/immich_toast.dart';
/// This deny move to trash action has the following behavior:
/// - Deny moving to the local trash those assets that are in the remote trash.
///
/// This action is used when the asset is selected in multi-selection mode in the trash page
class KeepOnDeviceActionButton extends ConsumerWidget {
final ActionSource source;
final bool isPreview;
const KeepOnDeviceActionButton({super.key, required this.source, required this.isPreview});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final actionNotifier = ref.read(actionProvider.notifier);
final multiSelectNotifier = ref.read(multiSelectProvider.notifier);
final result = await actionNotifier.resolveRemoteTrash(source, allow: false);
multiSelectNotifier.reset();
if (source == ActionSource.viewer) {
Future.delayed(Durations.extralong4, () {
EventStream.shared.emit(const ViewerReloadAssetEvent());
EventStream.shared.emit(const TimelineReloadEvent());
});
}
if (context.mounted) {
final successMessage = 'assets_denied_to_moved_to_trash_count'.t(
context: context,
args: {'count': result.count.toString()},
);
ImmichToast.show(
context: context,
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
const iconData = Icons.cloud_off_outlined;
return isPreview
? BaseActionButton(
maxWidth: 110.0,
iconData: iconData,
label: 'keep'.tr(),
onPressed: () => _onTap(context, ref),
)
: TextButton.icon(
icon: const Icon(iconData),
label: Text('keep_on_device'.tr(), style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold)),
onPressed: () => _onTap(context, ref),
);
}
}

View File

@ -0,0 +1,105 @@
import 'package:easy_localization/easy_localization.dart';
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/events.model.dart';
import 'package:immich_mobile/domain/utils/event_stream.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/providers/infrastructure/action.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
import 'package:immich_mobile/widgets/common/immich_toast.dart';
import 'base_action_button.widget.dart';
/// This move to trash action has the following behavior:
/// - Allows moving to the local trash those assets that are in the remote trash.
///
/// This action is used when the asset is selected in multi-selection mode in the review out-of-sync changes
class MoveToTrashActionButton extends ConsumerWidget {
final ActionSource source;
final bool isPreview;
const MoveToTrashActionButton({super.key, required this.source, required this.isPreview});
void _onTap(BuildContext context, WidgetRef ref) async {
if (!context.mounted) {
return;
}
final confirmed = await showDialog<bool>(
context: context,
builder: (BuildContext context) {
return AlertDialog(
title: Text('asset_out_of_sync_trash_confirmation_title'.tr()),
content: Column(
mainAxisSize: MainAxisSize.min,
children: [Text('asset_out_of_sync_trash_confirmation_text'.tr())],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: Text('cancel'.t(context: context)),
),
TextButton(
onPressed: () => Navigator.of(context).pop(true),
style: TextButton.styleFrom(foregroundColor: Theme.of(context).colorScheme.error),
child: Text('control_bottom_app_bar_trash_from_immich'.tr()),
),
],
);
},
);
if (confirmed != true) {
return;
}
final actionNotifier = ref.read(actionProvider.notifier);
final multiSelectNotifier = ref.read(multiSelectProvider.notifier);
final result = await actionNotifier.resolveRemoteTrash(source, allow: true);
multiSelectNotifier.reset();
if (source == ActionSource.viewer) {
Future.delayed(Durations.extralong4, () {
EventStream.shared.emit(const ViewerReloadAssetEvent());
EventStream.shared.emit(const TimelineReloadEvent());
});
}
if (context.mounted) {
final successMessage = 'assets_allowed_to_moved_to_trash_count'.t(
context: context,
args: {'count': result.count.toString()},
);
ImmichToast.show(
context: context,
msg: result.success ? successMessage : 'scaffold_body_error_occurred'.t(context: context),
gravity: ToastGravity.BOTTOM,
toastType: result.success ? ToastType.success : ToastType.error,
);
}
}
@override
Widget build(BuildContext context, WidgetRef ref) {
const iconData = Icons.delete_forever_outlined;
return isPreview
? BaseActionButton(
maxWidth: 100.0,
iconData: iconData,
label: 'delete'.tr(),
onPressed: () => _onTap(context, ref),
)
: TextButton.icon(
icon: Icon(iconData, color: Colors.red[400]),
label: Text(
'control_bottom_app_bar_trash_from_immich'.tr(),
style: TextStyle(fontSize: 14, color: Colors.red[400], fontWeight: FontWeight.bold),
),
onPressed: () => _onTap(context, ref),
);
}
}

View File

@ -457,7 +457,11 @@ class _AssetViewerState extends ConsumerState<AssetViewer> {
}
void _onAssetReloadEvent() async {
final index = pageController.page?.round() ?? 0;
int index = pageController.page?.round() ?? 0;
if (index == totalAssets && index > 0) {
--index;
await pageController.previousPage(duration: const Duration(milliseconds: 500), curve: Curves.easeIn);
}
final timelineService = ref.read(timelineServiceProvider);
final newAsset = await timelineService.getAssetAsync(index);

View File

@ -1,17 +1,23 @@
import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.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/domain/services/timeline.service.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/add_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';
import 'package:immich_mobile/presentation/widgets/action_buttons/edit_image_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/keep_on_device_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_trash_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/share_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/upload_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/add_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/asset_viewer/asset_viewer.state.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/widgets/asset_viewer/video_controls.dart';
@ -34,6 +40,10 @@ class ViewerBottomBar extends ConsumerWidget {
final showControls = ref.watch(assetViewerProvider.select((s) => s.showingControls));
final isInLockedView = ref.watch(inLockedViewProvider);
final timelineOrigin = ref.read(timelineServiceProvider).origin;
final isSyncTrashTimeline = timelineOrigin == TimelineOrigin.syncTrash;
final isWaitingForSyncApproval = ref.watch(isWaitingForSyncApprovalProvider(asset.checksum!)).value == true;
if (!showControls) {
opacity = 0;
}
@ -41,17 +51,23 @@ class ViewerBottomBar extends ConsumerWidget {
final originalTheme = context.themeData;
final actions = <Widget>[
const ShareActionButton(source: ActionSource.viewer),
if (isSyncTrashTimeline || isWaitingForSyncApproval) ...[
const Text('asset_out_of_sync_actions_title').tr(),
const KeepOnDeviceActionButton(source: ActionSource.viewer, isPreview: true),
const MoveToTrashActionButton(source: ActionSource.viewer, isPreview: true),
] else ...[
const ShareActionButton(source: ActionSource.viewer),
if (!isInLockedView) ...[
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
if (asset.type == AssetType.image) const EditImageActionButton(),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
if (!isInLockedView) ...[
if (asset.isLocalOnly) const UploadActionButton(source: ActionSource.viewer),
if (asset.type == AssetType.image) const EditImageActionButton(),
if (asset.hasRemote) AddActionButton(originalTheme: originalTheme),
if (isOwner) ...[
asset.isLocalOnly
? const DeleteLocalActionButton(source: ActionSource.viewer)
: const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
if (isOwner) ...[
asset.isLocalOnly
? const DeleteLocalActionButton(source: ActionSource.viewer)
: const DeleteActionButton(source: ActionSource.viewer, showConfirmation: true),
],
],
],
];

View File

@ -15,6 +15,7 @@ import 'package:immich_mobile/providers/activity.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@ -49,6 +50,8 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
final originalTheme = context.themeData;
final isWaitingForSyncApproval = ref.watch(isWaitingForSyncApprovalProvider(asset.checksum)).value == true;
final actions = <Widget>[
if (asset.isMotionPhoto) const MotionPhotoActionButton(iconOnly: true),
if (album != null && album.isActivityEnabled && album.isShared)
@ -59,9 +62,9 @@ class ViewerTopAppBar extends ConsumerWidget implements PreferredSizeWidget {
},
),
if (asset.hasRemote && isOwner && !asset.isFavorite)
if (asset.hasRemote && isOwner && !asset.isFavorite && !isWaitingForSyncApproval)
const FavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
if (asset.hasRemote && isOwner && asset.isFavorite)
if (asset.hasRemote && isOwner && asset.isFavorite && !isWaitingForSyncApproval)
const UnFavoriteActionButton(source: ActionSource.viewer, iconOnly: true),
ViewerKebabMenu(originalTheme: originalTheme),

View File

@ -9,6 +9,7 @@ import 'package:immich_mobile/providers/infrastructure/asset_viewer/current_asse
import 'package:immich_mobile/providers/infrastructure/current_album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/timeline.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/routes.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
@ -35,6 +36,7 @@ class ViewerKebabMenu extends ConsumerWidget {
final currentAlbum = ref.watch(currentRemoteAlbumProvider);
final isArchived = asset is RemoteAsset && asset.visibility == AssetVisibility.archive;
final advancedTroubleshooting = ref.watch(settingsProvider.notifier).get(Setting.advancedTroubleshooting);
final isWaitingForTrashApproval = ref.watch(isWaitingForSyncApprovalProvider(asset.checksum)).value == true;
final actionContext = ActionButtonContext(
asset: asset,
@ -49,6 +51,7 @@ class ViewerKebabMenu extends ConsumerWidget {
isCasting: isCasting,
timelineOrigin: timelineOrigin,
originalTheme: originalTheme,
isWaitingForTrashApproval: isWaitingForTrashApproval,
);
final menuChildren = ActionButtonBuilder.buildViewerKebabMenu(actionContext, context, ref);

View File

@ -0,0 +1,32 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/enums.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/keep_on_device_action_button.widget.dart';
import 'package:immich_mobile/presentation/widgets/action_buttons/move_to_trash_action_button.widget.dart';
class TrashSyncBottomBar extends ConsumerWidget {
const TrashSyncBottomBar({super.key});
@override
Widget build(BuildContext context, WidgetRef ref) {
return SafeArea(
child: Align(
alignment: Alignment.bottomCenter,
child: SizedBox(
height: 64,
child: Container(
color: context.themeData.canvasColor,
child: const Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
KeepOnDeviceActionButton(source: ActionSource.timeline, isPreview: false),
MoveToTrashActionButton(source: ActionSource.timeline, isPreview: false),
],
),
),
),
),
);
}
}

View File

@ -88,11 +88,15 @@ class ThumbnailTile extends ConsumerWidget {
child: _TileOverlayIcon(Icons.cloud_outlined),
),
),
AssetState.merged => const Align(
AssetState.merged => Align(
alignment: Alignment.bottomRight,
child: Padding(
padding: EdgeInsets.only(right: 10.0, bottom: 6.0),
child: _TileOverlayIcon(Icons.cloud_done_outlined),
padding: const EdgeInsets.only(right: 10.0, bottom: 6.0),
child: _TileOverlayIcon(
(asset as RemoteAsset).deletedAt != null
? Icons.sync_problem_rounded
: Icons.cloud_done_outlined,
),
),
),
},
@ -157,6 +161,7 @@ class _SelectionIndicator extends StatelessWidget {
class _VideoIndicator extends StatelessWidget {
final Duration duration;
const _VideoIndicator(this.duration);
@override

View File

@ -6,3 +6,8 @@ part 'app_settings.provider.g.dart';
@Riverpod(keepAlive: true)
AppSettingsService appSettingsService(Ref _) => const AppSettingsService();
final appSettingStreamProvider = StreamProvider.family.autoDispose<bool, AppSettingsEnum<bool>>((ref, setting) {
final service = ref.watch(appSettingsServiceProvider);
return service.watchSetting(setting);
});

View File

@ -421,6 +421,22 @@ class ActionNotifier extends Notifier<void> {
return ActionResult(count: assets.length, success: false, error: error.toString());
}
}
Future<ActionResult> resolveRemoteTrash(ActionSource source, {required bool allow}) async {
final remoteChecksums = _getAssets(source).map((a) => a.checksum).nonNulls;
_logger.info('resolveRemoteTrash, remoteChecksums: $remoteChecksums, allow: $allow');
try {
final result = await _service.resolveRemoteTrash(remoteChecksums, allow: allow);
return ActionResult(
count: remoteChecksums.length,
success: result,
error: result ? null : 'Failed to move assets to trash',
);
} catch (error, stack) {
_logger.severe('Failed to ${allow ? 'allow' : 'deny'} to move assets to trash', error, stack);
return ActionResult(count: remoteChecksums.length, success: false, error: error.toString());
}
}
}
extension on Iterable<RemoteAsset> {

View File

@ -11,6 +11,7 @@ import 'package:immich_mobile/providers/infrastructure/cancel.provider.dart';
import 'package:immich_mobile/providers/infrastructure/db.provider.dart';
import 'package:immich_mobile/providers/infrastructure/platform.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
final syncStreamServiceProvider = Provider(
@ -19,6 +20,7 @@ final syncStreamServiceProvider = Provider(
syncStreamRepository: ref.watch(syncStreamRepositoryProvider),
localAssetRepository: ref.watch(localAssetRepository),
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
trashSyncRepository: ref.watch(trashSyncRepositoryProvider),
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
storageRepository: ref.watch(storageRepositoryProvider),
cancelChecker: ref.watch(cancellationProvider),
@ -33,6 +35,7 @@ final localSyncServiceProvider = Provider(
(ref) => LocalSyncService(
localAlbumRepository: ref.watch(localAlbumRepository),
trashedLocalAssetRepository: ref.watch(trashedLocalAssetRepository),
trashSyncRepository: ref.watch(trashSyncRepositoryProvider),
localFilesManager: ref.watch(localFilesManagerRepositoryProvider),
storageRepository: ref.watch(storageRepositoryProvider),
nativeSyncApi: ref.watch(nativeSyncApiProvider),

View File

@ -1,12 +1,45 @@
import 'package:async/async.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/trash_sync.service.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/services/app_settings.service.dart';
import 'db.provider.dart';
typedef TrashedAssetsCount = ({int total, int hashed});
final trashSyncRepositoryProvider = Provider<DriftTrashSyncRepository>(
(ref) => DriftTrashSyncRepository(ref.watch(driftProvider)),
);
final trashedAssetsCountProvider = StreamProvider<TrashedAssetsCount>((ref) {
final repo = ref.watch(trashedLocalAssetRepository);
final total$ = repo.watchCount();
final hashed$ = repo.watchHashedCount();
return StreamZip<int>([total$, hashed$]).map((values) => (total: values[0], hashed: values[1]));
});
final trashSyncServiceProvider = Provider(
(ref) => TrashSyncService(trashSyncRepository: ref.watch(trashSyncRepositoryProvider)),
);
final outOfSyncCountProvider = StreamProvider<int>((ref) {
final enabledReviewMode = ref.watch(appSettingStreamProvider(AppSettingsEnum.reviewOutOfSyncChangesAndroid));
final service = ref.watch(trashSyncServiceProvider);
return enabledReviewMode.when(
data: (enabled) => enabled ? service.watchPendingApprovalCount() : Stream<int>.value(0),
loading: () => Stream<int>.value(0),
error: (_, __) => Stream<int>.value(0),
);
});
final isWaitingForSyncApprovalProvider = StreamProvider.family<bool, String?>((ref, checksum) {
final enabledReviewMode = ref.watch(appSettingStreamProvider(AppSettingsEnum.reviewOutOfSyncChangesAndroid));
final service = ref.watch(trashSyncServiceProvider);
return enabledReviewMode.when(
data: (enabled) => enabled && checksum != null ? service.watchIsApprovalPending(checksum) : Stream.value(false),
loading: () => Stream.value(false),
error: (_, __) => Stream.value(false),
);
});

View File

@ -104,6 +104,7 @@ import 'package:immich_mobile/presentation/pages/drift_recently_taken.page.dart'
import 'package:immich_mobile/presentation/pages/drift_remote_album.page.dart';
import 'package:immich_mobile/presentation/pages/drift_trash.page.dart';
import 'package:immich_mobile/presentation/pages/drift_user_selection.page.dart';
import 'package:immich_mobile/presentation/pages/drift_trash_sync_review.page.dart';
import 'package:immich_mobile/presentation/pages/drift_video.page.dart';
import 'package:immich_mobile/presentation/pages/editing/drift_crop.page.dart';
import 'package:immich_mobile/presentation/pages/editing/drift_edit.page.dart';
@ -309,6 +310,7 @@ class AppRouter extends RootStackRouter {
AutoRoute(page: DriftMemoryRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftFavoriteRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftTrashRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftTrashSyncReviewRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftArchiveRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: DriftLockedFolderRoute.page, guards: [_authGuard, _lockedGuard, _duplicateGuard]),
AutoRoute(page: DriftVideoRoute.page, guards: [_authGuard, _duplicateGuard]),

View File

@ -1488,6 +1488,22 @@ class DriftTrashRoute extends PageRouteInfo<void> {
);
}
/// generated route for
/// [DriftTrashSyncReviewPage]
class DriftTrashSyncReviewRoute extends PageRouteInfo<void> {
const DriftTrashSyncReviewRoute({List<PageRouteInfo>? children})
: super(DriftTrashSyncReviewRoute.name, initialChildren: children);
static const String name = 'DriftTrashSyncReviewRoute';
static PageInfo page = PageInfo(
name,
builder: (data) {
return const DriftTrashSyncReviewPage();
},
);
}
/// generated route for
/// [DriftUploadDetailPage]
class DriftUploadDetailRoute extends PageRouteInfo<void> {

View File

@ -8,16 +8,22 @@ import 'package:immich_mobile/domain/models/asset/base_asset.model.dart';
import 'package:immich_mobile/infrastructure/repositories/local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/remote_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/asset.provider.dart';
import 'package:immich_mobile/providers/infrastructure/storage.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/repositories/asset_api.repository.dart';
import 'package:immich_mobile/repositories/asset_media.repository.dart';
import 'package:immich_mobile/repositories/download.repository.dart';
import 'package:immich_mobile/repositories/drift_album_api_repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/utils/timezone.dart';
import 'package:immich_mobile/widgets/common/date_time_picker.dart';
import 'package:immich_mobile/widgets/common/location_picker.dart';
import 'package:logging/logging.dart';
import 'package:maplibre_gl/maplibre_gl.dart' as maplibre;
import 'package:riverpod_annotation/riverpod_annotation.dart';
@ -28,8 +34,12 @@ final actionServiceProvider = Provider<ActionService>(
ref.watch(localAssetRepository),
ref.watch(driftAlbumApiRepositoryProvider),
ref.watch(remoteAlbumRepository),
ref.watch(trashSyncRepositoryProvider),
ref.watch(assetMediaRepositoryProvider),
ref.watch(downloadRepositoryProvider),
ref.watch(storageRepositoryProvider),
ref.watch(localFilesManagerRepositoryProvider),
Logger('ActionService'),
),
);
@ -39,8 +49,12 @@ class ActionService {
final DriftLocalAssetRepository _localAssetRepository;
final DriftAlbumApiRepository _albumApiRepository;
final DriftRemoteAlbumRepository _remoteAlbumRepository;
final DriftTrashSyncRepository _trashSyncRepository;
final AssetMediaRepository _assetMediaRepository;
final DownloadRepository _downloadRepository;
final StorageRepository _storageRepository;
final LocalFilesManagerRepository _localFilesManager;
final Logger _logger;
const ActionService(
this._assetApiRepository,
@ -48,8 +62,12 @@ class ActionService {
this._localAssetRepository,
this._albumApiRepository,
this._remoteAlbumRepository,
this._trashSyncRepository,
this._assetMediaRepository,
this._downloadRepository,
this._storageRepository,
this._localFilesManager,
this._logger,
);
Future<void> shareLink(List<String> remoteIds, BuildContext context) async {
@ -242,4 +260,28 @@ class ActionService {
Future<List<bool>> downloadAll(List<RemoteAsset> assets) {
return _downloadRepository.downloadAllAssets(assets);
}
Future<bool> resolveRemoteTrash(Iterable<String> trashedChecksums, {required bool allow}) async {
if (trashedChecksums.isEmpty) {
return false;
}
await _trashSyncRepository.updateApproves(trashedChecksums, allow);
if (!allow) {
return true;
}
final localAssets = await _localAssetRepository.getByChecksums(trashedChecksums);
if (localAssets.isEmpty) {
return false;
}
final mediaUrls = await Future.wait(
localAssets.map(
(localAsset) => _storageRepository.getAssetEntityForAsset(localAsset).then((e) => e?.getMediaUrl()),
),
);
_logger.info("Moving assets to trash: ${mediaUrls.join(", ")}");
if (mediaUrls.isEmpty) {
return false;
}
return await _localFilesManager.moveToTrash(mediaUrls.nonNulls.toList());
}
}

View File

@ -30,6 +30,7 @@ enum AppSettingsEnum<T> {
selectedAlbumSortOrder<int>(StoreKey.selectedAlbumSortOrder, "selectedAlbumSortOrder", 2),
advancedTroubleshooting<bool>(StoreKey.advancedTroubleshooting, null, false),
manageLocalMediaAndroid<bool>(StoreKey.manageLocalMediaAndroid, null, false),
reviewOutOfSyncChangesAndroid<bool>(StoreKey.reviewOutOfSyncChangesAndroid, null, false),
logLevel<int>(StoreKey.logLevel, null, 5), // Level.INFO = 5
preferRemoteImage<bool>(StoreKey.preferRemoteImage, null, false),
loopVideo<bool>(StoreKey.loopVideo, "loopVideo", true),
@ -72,4 +73,11 @@ class AppSettingsService {
Future<void> setSetting<T>(AppSettingsEnum<T> setting, T value) {
return Store.put(setting.storeKey, value);
}
Stream<T> watchSetting<T>(AppSettingsEnum<T> setting) async* {
yield getSetting<T>(setting);
await for (final dynamic value in Store.watch(setting.storeKey)) {
yield (value as T?) ?? setting.defaultValue;
}
}
}

View File

@ -38,6 +38,7 @@ class ActionButtonContext {
final bool isStacked;
final RemoteAlbum? currentAlbum;
final bool advancedTroubleshooting;
final bool isWaitingForTrashApproval;
final ActionSource source;
final bool isCasting;
final TimelineOrigin timelineOrigin;
@ -52,6 +53,7 @@ class ActionButtonContext {
required this.isInLockedView,
required this.currentAlbum,
required this.advancedTroubleshooting,
required this.isWaitingForTrashApproval,
required this.source,
this.isCasting = false,
this.timelineOrigin = TimelineOrigin.main,
@ -92,7 +94,8 @@ enum ActionButtonType {
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote && //
!context.isArchived,
!context.isArchived &&
!context.isWaitingForTrashApproval,
ActionButtonType.unarchive =>
context.isOwner && //
!context.isInLockedView && //
@ -106,27 +109,31 @@ enum ActionButtonType {
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote && //
context.isTrashEnabled,
context.isTrashEnabled &&
!context.isWaitingForTrashApproval,
ActionButtonType.deletePermanent =>
context.isOwner && //
context.asset.hasRemote && //
!context.isTrashEnabled ||
context.isInLockedView,
context.isInLockedView && !context.isWaitingForTrashApproval,
ActionButtonType.delete =>
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote,
context.asset.hasRemote &&
!context.isWaitingForTrashApproval,
ActionButtonType.moveToLockFolder =>
context.isOwner && //
!context.isInLockedView && //
context.asset.hasRemote,
context.asset.hasRemote &&
!context.isWaitingForTrashApproval,
ActionButtonType.removeFromLockFolder =>
context.isOwner && //
context.isInLockedView && //
context.asset.hasRemote,
ActionButtonType.deleteLocal =>
!context.isInLockedView && //
context.asset.hasLocal,
context.asset.hasLocal &&
!context.isWaitingForTrashApproval,
ActionButtonType.upload =>
!context.isInLockedView && //
context.asset.storage == AssetState.local,
@ -154,6 +161,7 @@ enum ActionButtonType {
context.timelineOrigin != TimelineOrigin.lockedFolder &&
context.timelineOrigin != TimelineOrigin.archive &&
context.timelineOrigin != TimelineOrigin.localAlbum &&
context.timelineOrigin != TimelineOrigin.syncTrash &&
context.isOwner,
ActionButtonType.cast => context.isCasting || context.asset.hasRemote,
};

View File

@ -7,12 +7,14 @@ import 'package:flutter_hooks/flutter_hooks.dart' hide Store;
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/translate_extensions.dart';
import 'package:immich_mobile/models/backup/backup_state.model.dart';
import 'package:immich_mobile/providers/asset.provider.dart';
import 'package:immich_mobile/providers/auth.provider.dart';
import 'package:immich_mobile/providers/backup/backup.provider.dart';
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/locale_provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/providers/websocket.provider.dart';
@ -67,19 +69,24 @@ class ImmichAppBarDialog extends HookConsumerWidget {
);
}
buildActionButton(IconData icon, String text, Function() onTap, {Widget? trailing}) {
buildActionButton(IconData icon, String text, Function() onTap, {Widget? trailing, Color? btnColor}) {
return ListTile(
dense: true,
visualDensity: VisualDensity.standard,
contentPadding: const EdgeInsets.only(left: 30, right: 30),
minLeadingWidth: 40,
leading: SizedBox(child: Icon(icon, color: theme.textTheme.labelLarge?.color?.withAlpha(250), size: 20)),
leading: SizedBox(
child: Icon(icon, color: btnColor ?? theme.textTheme.labelLarge?.color?.withAlpha(250), size: 20),
),
title: Text(
text,
style: theme.textTheme.labelLarge?.copyWith(color: theme.textTheme.labelLarge?.color?.withAlpha(250)),
style: theme.textTheme.labelLarge?.copyWith(
color: btnColor ?? theme.textTheme.labelLarge?.color?.withAlpha(250),
),
).tr(),
onTap: onTap,
trailing: trailing,
iconColor: btnColor,
);
}
@ -87,6 +94,25 @@ class ImmichAppBarDialog extends HookConsumerWidget {
return buildActionButton(Icons.settings_outlined, "settings", () => context.pushRoute(const SettingsRoute()));
}
Widget buildOutOfSyncButton() {
return Consumer(
builder: (context, ref, _) {
final count = ref.watch(outOfSyncCountProvider).value ?? 0;
if (count == 0) {
return const SizedBox.shrink();
}
final btnColor = const Color.fromARGB(255, 243, 188, 106);
return buildActionButton(
Icons.warning_amber_rounded,
'review_out_of_sync_changes'.t(),
() => context.pushRoute(const DriftTrashSyncReviewRoute()),
trailing: Text('($count)', style: theme.textTheme.labelLarge?.copyWith(color: btnColor)),
btnColor: btnColor,
);
},
);
}
buildAppLogButton() {
return buildActionButton(
Icons.assignment_outlined,
@ -269,6 +295,7 @@ class ImmichAppBarDialog extends HookConsumerWidget {
const AppBarProfileInfoBox(),
buildStorageInformation(),
const AppBarServerInfo(),
buildOutOfSyncButton(),
if (Store.isBetaTimelineEnabled && isReadonlyModeEnabled) buildReadonlyMessage(),
buildAppLogButton(),
buildSettingButton(),

View File

@ -11,6 +11,7 @@ import 'package:immich_mobile/providers/backup/drift_backup.provider.dart';
import 'package:immich_mobile/providers/cast.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/infrastructure/setting.provider.dart';
import 'package:immich_mobile/providers/infrastructure/trash_sync.provider.dart';
import 'package:immich_mobile/providers/server_info.provider.dart';
import 'package:immich_mobile/providers/sync_status.provider.dart';
import 'package:immich_mobile/providers/timeline/multiselect.provider.dart';
@ -123,6 +124,7 @@ class _ProfileIndicator extends ConsumerWidget {
final serverInfoState = ref.watch(serverInfoProvider);
const widgetSize = 30.0;
final outOfSyncCount = ref.watch(outOfSyncCountProvider).maybeWhen(data: (count) => count, orElse: () => 0);
void toggleReadonlyMode() {
final isReadonlyModeEnabled = ref.watch(readonlyModeProvider);
@ -159,7 +161,7 @@ class _ProfileIndicator extends ConsumerWidget {
),
backgroundColor: Colors.transparent,
alignment: Alignment.bottomRight,
isLabelVisible: versionWarningPresent,
isLabelVisible: versionWarningPresent || outOfSyncCount > 0,
offset: const Offset(-2, -12),
child: user == null
? const Icon(Icons.face_outlined, size: widgetSize)

View File

@ -8,6 +8,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/domain/services/log.service.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/providers/app_settings.provider.dart';
import 'package:immich_mobile/providers/infrastructure/readonly_mode.provider.dart';
import 'package:immich_mobile/providers/user.provider.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
@ -18,8 +19,10 @@ import 'package:immich_mobile/widgets/settings/beta_timeline_list_tile.dart';
import 'package:immich_mobile/widgets/settings/custom_proxy_headers_settings/custom_proxy_headers_settings.dart';
import 'package:immich_mobile/widgets/settings/local_storage_settings.dart';
import 'package:immich_mobile/widgets/settings/settings_action_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_radio_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_slider_list_tile.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_page_scaffold.dart';
import 'package:immich_mobile/widgets/settings/settings_sub_title.dart';
import 'package:immich_mobile/widgets/settings/settings_switch_list_tile.dart';
import 'package:immich_mobile/widgets/settings/ssl_client_cert_settings.dart';
import 'package:logging/logging.dart';
@ -32,9 +35,7 @@ class AdvancedSettings extends HookConsumerWidget {
bool isLoggedIn = ref.read(currentUserProvider) != null;
final advancedTroubleshooting = useAppSettingsState(AppSettingsEnum.advancedTroubleshooting);
final manageLocalMediaAndroid = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
final isManageMediaSupported = useState(false);
final manageMediaAndroidPermission = useState(false);
final levelId = useAppSettingsState(AppSettingsEnum.logLevel);
final preferRemote = useAppSettingsState(AppSettingsEnum.preferRemoteImage);
final allowSelfSignedSSLCert = useAppSettingsState(AppSettingsEnum.allowSelfSignedSSLCert);
@ -58,11 +59,6 @@ class AdvancedSettings extends HookConsumerWidget {
useEffect(() {
() async {
isManageMediaSupported.value = await checkAndroidVersion();
if (isManageMediaSupported.value) {
manageMediaAndroidPermission.value = await ref
.read(localFilesManagerRepositoryProvider)
.hasManageMediaPermission();
}
}();
return null;
}, []);
@ -74,36 +70,7 @@ class AdvancedSettings extends HookConsumerWidget {
title: "advanced_settings_troubleshooting_title".tr(),
subtitle: "advanced_settings_troubleshooting_subtitle".tr(),
),
if (isManageMediaSupported.value)
Column(
children: [
SettingsSwitchListTile(
enabled: true,
valueNotifier: manageLocalMediaAndroid,
title: "advanced_settings_sync_remote_deletions_title".tr(),
subtitle: "advanced_settings_sync_remote_deletions_subtitle".tr(),
onChanged: (value) async {
if (value) {
final result = await ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
manageLocalMediaAndroid.value = result;
manageMediaAndroidPermission.value = result;
}
},
),
SettingsActionTile(
title: "manage_media_access_title".tr(),
statusText: manageMediaAndroidPermission.value ? "allowed".tr() : "not_allowed".tr(),
subtitle: "manage_media_access_rationale".tr(),
statusColor: manageLocalMediaAndroid.value && !manageMediaAndroidPermission.value
? const Color.fromARGB(255, 243, 188, 106)
: null,
onActionTap: () async {
final result = await ref.read(localFilesManagerRepositoryProvider).manageMediaPermission();
manageMediaAndroidPermission.value = result;
},
),
],
),
if (isManageMediaSupported.value) const _TrashSyncModeSelector(),
SettingsSliderListTile(
text: "advanced_settings_log_level_title".tr(namedArgs: {'level': logLevel}),
valueNotifier: levelId,
@ -158,3 +125,120 @@ class AdvancedSettings extends HookConsumerWidget {
return SettingsSubPageScaffold(settings: advancedSettings);
}
}
enum TrashSyncMode { none, auto, review }
final manageMediaPermissionProvider = FutureProvider<bool>((ref) async {
return ref.watch(localFilesManagerRepositoryProvider).hasManageMediaPermission();
});
class _TrashSyncModeSelector extends HookConsumerWidget {
const _TrashSyncModeSelector();
@override
Widget build(BuildContext context, WidgetRef ref) {
final autoSyncChanges = useAppSettingsState(AppSettingsEnum.manageLocalMediaAndroid);
final reviewOutOfSyncChanges = useAppSettingsState(AppSettingsEnum.reviewOutOfSyncChangesAndroid);
final manageMediaAndroidPermission = ref.watch(manageMediaPermissionProvider);
final manageMediaAndroidPermissionValue = manageMediaAndroidPermission.valueOrNull;
final selectedTrashSyncMode = autoSyncChanges.value
? TrashSyncMode.auto
: reviewOutOfSyncChanges.value
? TrashSyncMode.review
: TrashSyncMode.none;
Future<void> attemptToEnableSetting(AppSettingsEnum key) async {
final result = await ref.read(localFilesManagerRepositoryProvider).requestManageMediaPermission();
ref.invalidate(manageMediaPermissionProvider);
if (key == AppSettingsEnum.manageLocalMediaAndroid) {
autoSyncChanges.value = result;
if (result) {
reviewOutOfSyncChanges.value = false;
}
}
if (key == AppSettingsEnum.reviewOutOfSyncChangesAndroid) {
reviewOutOfSyncChanges.value = result;
if (result) {
autoSyncChanges.value = false;
}
}
ref.invalidate(appSettingsServiceProvider);
}
Future<void> handleTrashSyncModeChange(TrashSyncMode? mode) async {
if (mode == null) {
return;
}
switch (mode) {
case TrashSyncMode.none:
if (!autoSyncChanges.value && !reviewOutOfSyncChanges.value) {
break;
}
autoSyncChanges.value = false;
reviewOutOfSyncChanges.value = false;
ref.invalidate(appSettingsServiceProvider);
break;
case TrashSyncMode.auto:
if (autoSyncChanges.value) {
break;
}
await attemptToEnableSetting(AppSettingsEnum.manageLocalMediaAndroid);
break;
case TrashSyncMode.review:
if (reviewOutOfSyncChanges.value) {
break;
}
await attemptToEnableSetting(AppSettingsEnum.reviewOutOfSyncChangesAndroid);
break;
}
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SettingsSubTitle(title: "advanced_settings_sync_remote_deletions_selector_title".tr()),
SettingsRadioListTile(
groups: [
SettingsRadioGroup(
title: 'off'.tr(),
subtitle: 'advanced_settings_sync_remote_deletions_off_subtitle'.tr(),
value: TrashSyncMode.none,
),
SettingsRadioGroup(
title: 'advanced_settings_sync_remote_deletions_title'.tr(),
subtitle: 'advanced_settings_sync_remote_deletions_subtitle'.tr(),
value: TrashSyncMode.auto,
),
SettingsRadioGroup(
title: 'advanced_settings_review_remote_deletions_title'.tr(),
subtitle: 'advanced_settings_review_remote_deletions_subtitle'.tr(),
value: TrashSyncMode.review,
),
],
groupBy: selectedTrashSyncMode,
onRadioChanged: (mode) => handleTrashSyncModeChange(mode),
),
SettingsActionTile(
title: "manage_media_access_title".tr(),
statusText: manageMediaAndroidPermissionValue == null
? null
: manageMediaAndroidPermissionValue == true
? "allowed".tr()
: "not_allowed".tr(),
subtitle: "manage_media_access_rationale".tr(),
statusColor:
manageMediaAndroidPermissionValue == false && (autoSyncChanges.value || reviewOutOfSyncChanges.value)
? const Color.fromARGB(255, 243, 188, 106)
: null,
onActionTap: () async {
await ref.read(localFilesManagerRepositoryProvider).manageMediaPermission();
ref.invalidate(manageMediaPermissionProvider);
},
),
],
);
}
}

View File

@ -1,5 +1,6 @@
import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
@ -366,8 +367,10 @@ class _SyncStatsCounts extends ConsumerWidget {
),
),
// To be removed once the experimental feature is stable
if (CurrentPlatform.isAndroid &&
appSettingsService.getSetting<bool>(AppSettingsEnum.manageLocalMediaAndroid)) ...[
if ((kDebugMode || kProfileMode) &&
CurrentPlatform.isAndroid &&
(appSettingsService.getSetting<bool>(AppSettingsEnum.manageLocalMediaAndroid) ||
appSettingsService.getSetting<bool>(AppSettingsEnum.reviewOutOfSyncChangesAndroid))) ...[
_SectionHeaderText(text: "trash".t(context: context)),
Consumer(
builder: (context, ref, _) {

View File

@ -1,11 +1,13 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
class SettingsRadioGroup<T> {
final String title;
final String? subtitle;
final T value;
const SettingsRadioGroup({required this.title, required this.value});
const SettingsRadioGroup({required this.title, this.subtitle, required this.value});
}
class SettingsRadioListTile<T> extends StatelessWidget {
@ -28,6 +30,12 @@ class SettingsRadioListTile<T> extends StatelessWidget {
dense: true,
activeColor: context.primaryColor,
title: Text(g.title, style: context.textTheme.bodyLarge?.copyWith(fontWeight: FontWeight.w500)),
subtitle: g.subtitle != null
? Text(
g.subtitle!,
style: context.textTheme.bodyMedium?.copyWith(color: context.colorScheme.onSurfaceSecondary),
)
: null,
value: g.value,
controlAffinity: ListTileControlAffinity.trailing,
),

View File

@ -11,6 +11,7 @@ import 'package:immich_mobile/infrastructure/repositories/db.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/local_album.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/storage.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/platform/native_sync_api.g.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
@ -26,6 +27,7 @@ void main() {
late LocalSyncService sut;
late DriftLocalAlbumRepository mockLocalAlbumRepository;
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepository;
late DriftTrashSyncRepository mockTrashSyncRepo;
late LocalFilesManagerRepository mockLocalFilesManager;
late StorageRepository mockStorageRepository;
late MockNativeSyncApi mockNativeSyncApi;
@ -48,6 +50,7 @@ void main() {
setUp(() async {
mockLocalAlbumRepository = MockLocalAlbumRepository();
mockTrashedLocalAssetRepository = MockTrashedLocalAssetRepository();
mockTrashSyncRepo = MockTrashSyncRepository();
mockLocalFilesManager = MockLocalFilesManagerRepository();
mockStorageRepository = MockStorageRepository();
mockNativeSyncApi = MockNativeSyncApi();
@ -70,6 +73,7 @@ void main() {
localFilesManager: mockLocalFilesManager,
storageRepository: mockStorageRepository,
nativeSyncApi: mockNativeSyncApi,
trashSyncRepository: mockTrashSyncRepo,
);
await Store.put(StoreKey.manageLocalMediaAndroid, false);

View File

@ -16,6 +16,7 @@ import 'package:immich_mobile/infrastructure/repositories/storage.repository.dar
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/repositories/local_files_manager.repository.dart';
import 'package:mocktail/mocktail.dart';
@ -48,6 +49,7 @@ void main() {
late SyncApiRepository mockSyncApiRepo;
late DriftLocalAssetRepository mockLocalAssetRepo;
late DriftTrashedLocalAssetRepository mockTrashedLocalAssetRepo;
late DriftTrashSyncRepository mockTrashSyncRepo;
late LocalFilesManagerRepository mockLocalFilesManagerRepo;
late StorageRepository mockStorageRepo;
late Future<void> Function(List<SyncEvent>, Function(), Function()) handleEventsCallback;
@ -79,6 +81,7 @@ void main() {
mockLocalAssetRepo = MockLocalAssetRepository();
mockTrashedLocalAssetRepo = MockTrashedLocalAssetRepository();
mockLocalFilesManagerRepo = MockLocalFilesManagerRepository();
mockTrashSyncRepo = MockTrashSyncRepository();
mockStorageRepo = MockStorageRepository();
mockAbortCallbackWrapper = _MockAbortCallbackWrapper();
mockResetCallbackWrapper = _MockAbortCallbackWrapper();
@ -135,9 +138,10 @@ void main() {
trashedLocalAssetRepository: mockTrashedLocalAssetRepo,
localFilesManager: mockLocalFilesManagerRepo,
storageRepository: mockStorageRepo,
trashSyncRepository: mockTrashSyncRepo,
);
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((_) async => {});
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any<Map<String, DateTime>>())).thenAnswer((_) async => {});
when(() => mockTrashedLocalAssetRepo.trashLocalAsset(any())).thenAnswer((_) async {});
when(() => mockTrashedLocalAssetRepo.getToRestore()).thenAnswer((_) async => []);
when(() => mockTrashedLocalAssetRepo.applyRestoredAssets(any())).thenAnswer((_) async {});
@ -216,6 +220,7 @@ void main() {
localFilesManager: mockLocalFilesManagerRepo,
storageRepository: mockStorageRepo,
cancelChecker: cancellationChecker.call,
trashSyncRepository: mockTrashSyncRepo,
);
await sut.sync();
@ -255,6 +260,7 @@ void main() {
localFilesManager: mockLocalFilesManagerRepo,
storageRepository: mockStorageRepo,
cancelChecker: cancellationChecker.call,
trashSyncRepository: mockTrashSyncRepo,
);
await sut.sync();
@ -386,9 +392,16 @@ void main() {
'album-a': [localAsset],
'album-b': [mergedAsset],
};
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).thenAnswer((invocation) async {
final Iterable<String> requestedChecksums = invocation.positionalArguments.first as Iterable<String>;
expect(requestedChecksums.toSet(), equals({'checksum-local', 'checksum-merged', 'checksum-remote-only'}));
when(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any<Map<String, DateTime>>())).thenAnswer((invocation) async {
final Map<String, DateTime> trashedAssetsMap = invocation.positionalArguments.first as Map<String, DateTime>;
expect(
trashedAssetsMap,
equals({
localAsset.checksum!: DateTime(2025, 5, 1),
mergedAsset.checksum!: DateTime(2025, 5, 2),
'checksum-remote-only': DateTime(2025, 5, 3),
}),
);
return assetsByAlbum;
});
@ -445,7 +458,7 @@ void main() {
await simulateEvents(events);
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any())).called(1);
verify(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any<Map<String, DateTime>>())).called(1);
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
verifyNever(() => mockTrashedLocalAssetRepo.trashLocalAsset(any()));
});
@ -455,7 +468,7 @@ void main() {
await simulateEvents(events);
verifyNever(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any()));
verifyNever(() => mockLocalAssetRepo.getAssetsFromBackupAlbums(any<Map<String, DateTime>>()));
verifyNever(() => mockLocalFilesManagerRepo.moveToTrash(any()));
verify(() => mockSyncStreamRepo.deleteAssetsV1(any())).called(1);
});
@ -474,11 +487,7 @@ void main() {
});
final events = [
SyncStreamStub.assetModified(
id: 'remote-1',
checksum: 'checksum-trash',
ack: 'asset-remote-1-11',
),
SyncStreamStub.assetModified(id: 'remote-1', checksum: 'checksum-trash', ack: 'asset-remote-1-11'),
];
await simulateEvents(events);

View File

@ -17,6 +17,7 @@ import 'schema_v11.dart' as v11;
import 'schema_v12.dart' as v12;
import 'schema_v13.dart' as v13;
import 'schema_v14.dart' as v14;
import 'schema_v15.dart' as v15;
class GeneratedHelper implements SchemaInstantiationHelper {
@override
@ -50,10 +51,28 @@ class GeneratedHelper implements SchemaInstantiationHelper {
return v13.DatabaseAtV13(db);
case 14:
return v14.DatabaseAtV14(db);
case 15:
return v15.DatabaseAtV15(db);
default:
throw MissingSchemaException(version, versions);
}
}
static const versions = const [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14];
static const versions = const [
1,
2,
3,
4,
5,
6,
7,
8,
9,
10,
11,
12,
13,
14,
15,
];
}

File diff suppressed because it is too large Load Diff

View File

@ -9,6 +9,7 @@ import 'package:immich_mobile/infrastructure/repositories/storage.repository.dar
import 'package:immich_mobile/infrastructure/repositories/store.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_api.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/sync_stream.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trash_sync.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/trashed_local_asset.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user.repository.dart';
import 'package:immich_mobile/infrastructure/repositories/user_api.repository.dart';
@ -40,6 +41,8 @@ class MockRemoteAssetRepository extends Mock implements RemoteAssetRepository {}
class MockTrashedLocalAssetRepository extends Mock implements DriftTrashedLocalAssetRepository {}
class MockTrashSyncRepository extends Mock implements DriftTrashSyncRepository {}
class MockStorageRepository extends Mock implements StorageRepository {}
class MockDriftBackupRepository extends Mock implements DriftBackupRepository {}

View File

@ -83,6 +83,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@ -114,7 +115,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.share.shouldShow(context), isTrue);
@ -130,7 +132,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.share.shouldShow(context), isTrue);
@ -149,7 +152,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.shareLink.shouldShow(context), isTrue);
@ -166,7 +170,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.shareLink.shouldShow(context), isFalse);
@ -183,7 +188,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.shareLink.shouldShow(context), isFalse);
@ -202,7 +208,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.archive.shouldShow(context), isTrue);
@ -219,7 +226,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.archive.shouldShow(context), isFalse);
@ -236,7 +244,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.archive.shouldShow(context), isFalse);
@ -253,7 +262,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.archive.shouldShow(context), isFalse);
@ -270,7 +280,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.archive.shouldShow(context), isFalse);
@ -289,7 +300,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.unarchive.shouldShow(context), isTrue);
@ -306,7 +318,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.unarchive.shouldShow(context), isFalse);
@ -323,7 +336,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.unarchive.shouldShow(context), isFalse);
@ -342,7 +356,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.download.shouldShow(context), isTrue);
@ -359,7 +374,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.download.shouldShow(context), isFalse);
@ -376,7 +392,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.download.shouldShow(context), isFalse);
@ -395,7 +412,8 @@ void main() {
isStacked: false,
currentAlbum: null,
advancedTroubleshooting: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.similarPhotos.shouldShow(context), isTrue);
@ -412,7 +430,8 @@ void main() {
currentAlbum: null,
isStacked: false,
advancedTroubleshooting: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.similarPhotos.shouldShow(context), isFalse);
@ -431,7 +450,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.trash.shouldShow(context), isTrue);
@ -448,7 +468,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.trash.shouldShow(context), isFalse);
@ -467,7 +488,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.deletePermanent.shouldShow(context), isTrue);
@ -484,7 +506,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.deletePermanent.shouldShow(context), isFalse);
@ -503,7 +526,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.delete.shouldShow(context), isTrue);
@ -522,7 +546,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.moveToLockFolder.shouldShow(context), isTrue);
@ -541,7 +566,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.deleteLocal.shouldShow(context), isTrue);
@ -558,7 +584,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.deleteLocal.shouldShow(context), isFalse);
@ -574,7 +601,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.deleteLocal.shouldShow(context), isTrue);
@ -593,7 +621,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.upload.shouldShow(context), isTrue);
@ -612,7 +641,8 @@ void main() {
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.removeFromAlbum.shouldShow(context), isTrue);
@ -628,7 +658,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.removeFromAlbum.shouldShow(context), isFalse);
@ -647,7 +678,8 @@ void main() {
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.likeActivity.shouldShow(context), isTrue);
@ -664,7 +696,8 @@ void main() {
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.likeActivity.shouldShow(context), isFalse);
@ -681,7 +714,8 @@ void main() {
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.likeActivity.shouldShow(context), isFalse);
@ -697,7 +731,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.likeActivity.shouldShow(context), isFalse);
@ -715,7 +750,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: true,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.advancedInfo.shouldShow(context), isTrue);
@ -731,7 +767,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
expect(ActionButtonType.advancedInfo.shouldShow(context), isFalse);
@ -751,6 +788,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: true,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@ -768,6 +806,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@ -785,6 +824,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@ -807,6 +847,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
});
@ -826,7 +867,8 @@ void main() {
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>());
@ -840,7 +882,8 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>());
@ -855,7 +898,8 @@ void main() {
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: true,
source: ActionSource.timeline,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
final widget = buttonType.buildButton(contextWithAlbum);
expect(widget, isA<Widget>());
@ -879,6 +923,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@ -900,6 +945,7 @@ void main() {
currentAlbum: album,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@ -919,6 +965,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@ -939,6 +986,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);
@ -953,6 +1001,7 @@ void main() {
currentAlbum: null,
advancedTroubleshooting: false,
isStacked: false,
isWaitingForTrashApproval: false,
source: ActionSource.timeline,
);