mirror of
https://github.com/immich-app/immich.git
synced 2024-12-29 15:11:58 +00:00
fix(mobile): handle readonly and offline assets (#5565)
* feat: add isReadOnly and isOffline fields to Asset collection * refactor: move asset iterable filters to extension * hide asset actions based on offline and readOnly fields * pr changes * chore: doc comments --------- Co-authored-by: shenlong-tanwen <139912620+shalong-tanwen@users.noreply.github.com> Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
parent
56cde0438c
commit
a233e176e5
10 changed files with 182 additions and 64 deletions
|
@ -189,6 +189,10 @@
|
|||
"home_page_building_timeline": "Building the timeline",
|
||||
"home_page_delete_err_partner": "Can not delete partner assets, skipping",
|
||||
"home_page_favorite_err_local": "Can not favorite local assets yet, skipping",
|
||||
"multiselect_grid_edit_gps_err_read_only": "Cannot edit location of read only asset(s), skipping",
|
||||
"multiselect_grid_edit_date_time_err_read_only": "Cannot edit date of read only asset(s), skipping",
|
||||
"asset_action_delete_err_read_only": "Cannot delete read only asset(s), skipping",
|
||||
"asset_action_share_err_offline": "Cannot fetch offline asset(s), skipping",
|
||||
"home_page_favorite_err_partner": "Can not favorite partner assets yet, skipping",
|
||||
"home_page_first_time_notice": "If this is your first time using the app, please make sure to choose backup album(s) so that the timeline can populate photos and videos in the album(s).",
|
||||
"home_page_share_err_local": "Can not share local assets via link, skipping",
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import 'dart:typed_data';
|
||||
|
||||
import 'package:collection/collection.dart';
|
||||
import 'package:immich_mobile/shared/models/asset.dart';
|
||||
import 'package:immich_mobile/shared/models/user.dart';
|
||||
|
||||
extension ListExtension<E> on List<E> {
|
||||
List<E> uniqueConsecutive({
|
||||
|
@ -39,3 +41,58 @@ extension IntListExtension on Iterable<int> {
|
|||
return list;
|
||||
}
|
||||
}
|
||||
|
||||
extension AssetListExtension on Iterable<Asset> {
|
||||
/// Returns the assets that are already available in the Immich server
|
||||
Iterable<Asset> remoteOnly({
|
||||
void Function()? errorCallback,
|
||||
}) {
|
||||
final bool onlyRemote = every((e) => e.isRemote);
|
||||
if (!onlyRemote) {
|
||||
if (errorCallback != null) errorCallback();
|
||||
return where((a) => a.isRemote);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/// Returns the assets that are owned by the user passed to the [owner] param
|
||||
/// If [owner] is null, an empty list is returned
|
||||
Iterable<Asset> ownedOnly(
|
||||
User? owner, {
|
||||
void Function()? errorCallback,
|
||||
}) {
|
||||
if (owner == null) return [];
|
||||
final userId = owner.isarId;
|
||||
final bool onlyOwned = every((e) => e.ownerId == userId);
|
||||
if (!onlyOwned) {
|
||||
if (errorCallback != null) errorCallback();
|
||||
return where((a) => a.ownerId == userId);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/// Returns the assets that are present on a file system which has write permission
|
||||
/// This filters out assets on readOnly external library to which we cannot perform any write operation
|
||||
Iterable<Asset> writableOnly({
|
||||
void Function()? errorCallback,
|
||||
}) {
|
||||
final bool onlyWritable = every((e) => !e.isReadOnly);
|
||||
if (!onlyWritable) {
|
||||
if (errorCallback != null) errorCallback();
|
||||
return where((a) => !a.isReadOnly);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
|
||||
/// Filters out offline assets and returns those that are still accessible by the Immich server
|
||||
Iterable<Asset> nonOfflineOnly({
|
||||
void Function()? errorCallback,
|
||||
}) {
|
||||
final bool onlyLive = every((e) => !e.isOffline);
|
||||
if (!onlyLive) {
|
||||
if (errorCallback != null) errorCallback();
|
||||
return where((a) => !a.isOffline);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -156,7 +156,7 @@ class ExifBottomSheet extends HookConsumerWidget {
|
|||
buildLocation() {
|
||||
// Guard no lat/lng
|
||||
if (!hasCoordinates()) {
|
||||
return asset.isRemote
|
||||
return asset.isRemote && !asset.isReadOnly
|
||||
? ListTile(
|
||||
minLeadingWidth: 0,
|
||||
contentPadding: const EdgeInsets.all(0),
|
||||
|
@ -194,7 +194,7 @@ class ExifBottomSheet extends HookConsumerWidget {
|
|||
fontWeight: FontWeight.w600,
|
||||
),
|
||||
).tr(),
|
||||
if (asset.isRemote)
|
||||
if (asset.isRemote && !asset.isReadOnly)
|
||||
IconButton(
|
||||
onPressed: () => handleEditLocation(
|
||||
ref,
|
||||
|
@ -251,7 +251,7 @@ class ExifBottomSheet extends HookConsumerWidget {
|
|||
fontSize: 14,
|
||||
),
|
||||
),
|
||||
if (asset.isRemote)
|
||||
if (asset.isRemote && !asset.isReadOnly)
|
||||
IconButton(
|
||||
onPressed: () => handleEditDateTime(
|
||||
ref,
|
||||
|
|
|
@ -166,7 +166,8 @@ class TopControlAppBar extends HookConsumerWidget {
|
|||
if (asset.isRemote && isOwner) buildFavoriteButton(a),
|
||||
if (asset.livePhotoVideoId != null) buildLivePhotoButton(),
|
||||
if (asset.isLocal && !asset.isRemote) buildUploadButton(),
|
||||
if (asset.isRemote && !asset.isLocal && isOwner) buildDownloadButton(),
|
||||
if (asset.isRemote && !asset.isLocal && !asset.isOffline && isOwner)
|
||||
buildDownloadButton(),
|
||||
if (asset.isRemote && (isOwner || isPartner)) buildAddToAlbumButtom(),
|
||||
if (album != null && album.shared) buildActivitiesButton(),
|
||||
buildMoreInfoButton(),
|
||||
|
|
|
@ -187,8 +187,8 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
|
||||
void showInfo() {
|
||||
showModalBottomSheet(
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(15.0),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(15.0)),
|
||||
),
|
||||
barrierColor: Colors.transparent,
|
||||
backgroundColor: Colors.transparent,
|
||||
|
@ -220,6 +220,16 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
}
|
||||
|
||||
void handleDelete(Asset deleteAsset) async {
|
||||
// Cannot delete readOnly / external assets. They are handled through library offline jobs
|
||||
if (asset().isReadOnly) {
|
||||
ImmichToast.show(
|
||||
durationInSecond: 1,
|
||||
context: context,
|
||||
msg: 'asset_action_delete_err_read_only'.tr(),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
return;
|
||||
}
|
||||
Future<bool> onDelete(bool force) async {
|
||||
final isDeleted = await ref.read(assetProvider.notifier).deleteAssets(
|
||||
{deleteAsset},
|
||||
|
@ -319,11 +329,20 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
}
|
||||
|
||||
shareAsset() {
|
||||
ref.watch(imageViewerStateProvider.notifier).shareAsset(asset(), context);
|
||||
if (asset().isOffline) {
|
||||
ImmichToast.show(
|
||||
durationInSecond: 1,
|
||||
context: context,
|
||||
msg: 'asset_action_share_err_offline'.tr(),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
return;
|
||||
}
|
||||
ref.read(imageViewerStateProvider.notifier).shareAsset(asset(), context);
|
||||
}
|
||||
|
||||
handleArchive(Asset asset) {
|
||||
ref.watch(assetProvider.notifier).toggleArchive([asset]);
|
||||
ref.read(assetProvider.notifier).toggleArchive([asset]);
|
||||
if (isParent) {
|
||||
context.popRoute();
|
||||
return;
|
||||
|
@ -346,6 +365,26 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
);
|
||||
}
|
||||
|
||||
handleDownload() {
|
||||
if (asset().isLocal) {
|
||||
return;
|
||||
}
|
||||
if (asset().isOffline) {
|
||||
ImmichToast.show(
|
||||
durationInSecond: 1,
|
||||
context: context,
|
||||
msg: 'asset_action_share_err_offline'.tr(),
|
||||
gravity: ToastGravity.BOTTOM,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
ref.read(imageViewerStateProvider.notifier).downloadAsset(
|
||||
asset(),
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
handleActivities() {
|
||||
if (album != null && album.shared && album.remoteId != null) {
|
||||
context.pushRoute(const ActivitiesRoute());
|
||||
|
@ -371,12 +410,11 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
asset().isLocal ? () => handleUpload(asset()) : null,
|
||||
onDownloadPressed: asset().isLocal
|
||||
? null
|
||||
: () => ref
|
||||
.watch(imageViewerStateProvider.notifier)
|
||||
.downloadAsset(
|
||||
asset(),
|
||||
context,
|
||||
),
|
||||
: () =>
|
||||
ref.read(imageViewerStateProvider.notifier).downloadAsset(
|
||||
asset(),
|
||||
context,
|
||||
),
|
||||
onToggleMotionVideo: (() {
|
||||
isPlayingMotionVideo.value = !isPlayingMotionVideo.value;
|
||||
}),
|
||||
|
@ -641,13 +679,7 @@ class GalleryViewerPage extends HookConsumerWidget {
|
|||
if (isOwner) (_) => handleArchive(asset()),
|
||||
if (isOwner && stack.isNotEmpty) (_) => showStackActionItems(),
|
||||
if (isOwner) (_) => handleDelete(asset()),
|
||||
if (!isOwner)
|
||||
(_) => asset().isLocal
|
||||
? null
|
||||
: ref.watch(imageViewerStateProvider.notifier).downloadAsset(
|
||||
asset(),
|
||||
context,
|
||||
),
|
||||
if (!isOwner) (_) => handleDownload(),
|
||||
];
|
||||
|
||||
return IgnorePointer(
|
||||
|
|
|
@ -32,6 +32,8 @@ class Asset {
|
|||
isFavorite = remote.isFavorite,
|
||||
isArchived = remote.isArchived,
|
||||
isTrashed = remote.isTrashed,
|
||||
isReadOnly = remote.isReadOnly,
|
||||
isOffline = remote.isOffline,
|
||||
stackParentId = remote.stackParentId,
|
||||
stackCount = remote.stackCount;
|
||||
|
||||
|
@ -49,6 +51,8 @@ class Asset {
|
|||
isFavorite = local.isFavorite,
|
||||
isArchived = false,
|
||||
isTrashed = false,
|
||||
isReadOnly = false,
|
||||
isOffline = false,
|
||||
stackCount = 0,
|
||||
fileCreatedAt = local.createDateTime {
|
||||
if (fileCreatedAt.year == 1970) {
|
||||
|
@ -77,11 +81,13 @@ class Asset {
|
|||
required this.fileName,
|
||||
this.livePhotoVideoId,
|
||||
this.exifInfo,
|
||||
required this.isFavorite,
|
||||
required this.isArchived,
|
||||
required this.isTrashed,
|
||||
this.isFavorite = false,
|
||||
this.isArchived = false,
|
||||
this.isTrashed = false,
|
||||
this.stackParentId,
|
||||
required this.stackCount,
|
||||
this.stackCount = 0,
|
||||
this.isReadOnly = false,
|
||||
this.isOffline = false,
|
||||
});
|
||||
|
||||
@ignore
|
||||
|
@ -148,6 +154,10 @@ class Asset {
|
|||
|
||||
bool isTrashed;
|
||||
|
||||
bool isReadOnly;
|
||||
|
||||
bool isOffline;
|
||||
|
||||
@ignore
|
||||
ExifInfo? exifInfo;
|
||||
|
||||
|
@ -256,6 +266,8 @@ class Asset {
|
|||
isFavorite != a.isFavorite ||
|
||||
isArchived != a.isArchived ||
|
||||
isTrashed != a.isTrashed ||
|
||||
isReadOnly != a.isReadOnly ||
|
||||
isOffline != a.isOffline ||
|
||||
a.exifInfo?.latitude != exifInfo?.latitude ||
|
||||
a.exifInfo?.longitude != exifInfo?.longitude ||
|
||||
// no local stack count or different count from remote
|
||||
|
@ -288,6 +300,7 @@ class Asset {
|
|||
exifInfo: exifInfo ?? a.exifInfo?.copyWith(id: id),
|
||||
);
|
||||
} else {
|
||||
// TODO: Revisit this and remove all bool field assignments
|
||||
return a._copyWith(
|
||||
id: id,
|
||||
remoteId: remoteId,
|
||||
|
@ -297,6 +310,8 @@ class Asset {
|
|||
isFavorite: isFavorite,
|
||||
isArchived: isArchived,
|
||||
isTrashed: isTrashed,
|
||||
isReadOnly: isReadOnly,
|
||||
isOffline: isOffline,
|
||||
);
|
||||
}
|
||||
} else {
|
||||
|
@ -314,6 +329,8 @@ class Asset {
|
|||
isFavorite: a.isFavorite,
|
||||
isArchived: a.isArchived,
|
||||
isTrashed: a.isTrashed,
|
||||
isReadOnly: a.isReadOnly,
|
||||
isOffline: a.isOffline,
|
||||
exifInfo: a.exifInfo?.copyWith(id: id) ?? exifInfo,
|
||||
);
|
||||
} else {
|
||||
|
@ -346,6 +363,8 @@ class Asset {
|
|||
bool? isFavorite,
|
||||
bool? isArchived,
|
||||
bool? isTrashed,
|
||||
bool? isReadOnly,
|
||||
bool? isOffline,
|
||||
ExifInfo? exifInfo,
|
||||
String? stackParentId,
|
||||
int? stackCount,
|
||||
|
@ -368,6 +387,8 @@ class Asset {
|
|||
isFavorite: isFavorite ?? this.isFavorite,
|
||||
isArchived: isArchived ?? this.isArchived,
|
||||
isTrashed: isTrashed ?? this.isTrashed,
|
||||
isReadOnly: isReadOnly ?? this.isReadOnly,
|
||||
isOffline: isOffline ?? this.isOffline,
|
||||
exifInfo: exifInfo ?? this.exifInfo,
|
||||
stackParentId: stackParentId ?? this.stackParentId,
|
||||
stackCount: stackCount ?? this.stackCount,
|
||||
|
@ -426,7 +447,9 @@ class Asset {
|
|||
"width": ${width ?? "N/A"},
|
||||
"height": ${height ?? "N/A"},
|
||||
"isArchived": $isArchived,
|
||||
"isTrashed": $isTrashed
|
||||
"isTrashed": $isTrashed,
|
||||
"isReadOnly": $isReadOnly,
|
||||
"isOffline": $isOffline,
|
||||
}""";
|
||||
}
|
||||
}
|
||||
|
|
BIN
mobile/lib/shared/models/asset.g.dart
generated
BIN
mobile/lib/shared/models/asset.g.dart
generated
Binary file not shown.
|
@ -7,6 +7,7 @@ import 'package:flutter/material.dart';
|
|||
import 'package:flutter_hooks/flutter_hooks.dart';
|
||||
import 'package:fluttertoast/fluttertoast.dart';
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
import 'package:immich_mobile/extensions/collection_extensions.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
|
||||
import 'package:immich_mobile/modules/album/services/album.service.dart';
|
||||
|
@ -108,52 +109,33 @@ class MultiselectGrid extends HookConsumerWidget {
|
|||
)
|
||||
: null;
|
||||
|
||||
Iterable<Asset> remoteOnly(
|
||||
Iterable<Asset> assets, {
|
||||
void Function()? errorCallback,
|
||||
}) {
|
||||
final bool onlyRemote = assets.every((e) => e.isRemote);
|
||||
if (!onlyRemote) {
|
||||
if (errorCallback != null) errorCallback();
|
||||
return assets.where((a) => a.isRemote);
|
||||
}
|
||||
return assets;
|
||||
}
|
||||
|
||||
Iterable<Asset> ownedOnly(
|
||||
Iterable<Asset> assets, {
|
||||
void Function()? errorCallback,
|
||||
}) {
|
||||
if (currentUser == null) return [];
|
||||
final userId = currentUser.isarId;
|
||||
final bool onlyOwned = assets.every((e) => e.ownerId == userId);
|
||||
if (!onlyOwned) {
|
||||
if (errorCallback != null) errorCallback();
|
||||
return assets.where((a) => a.ownerId == userId);
|
||||
}
|
||||
return assets;
|
||||
}
|
||||
|
||||
Iterable<Asset> ownedRemoteSelection({
|
||||
String? localErrorMessage,
|
||||
String? ownerErrorMessage,
|
||||
}) {
|
||||
final assets = selection.value;
|
||||
return remoteOnly(
|
||||
ownedOnly(assets, errorCallback: errorBuilder(ownerErrorMessage)),
|
||||
errorCallback: errorBuilder(localErrorMessage),
|
||||
);
|
||||
return assets
|
||||
.remoteOnly(errorCallback: errorBuilder(ownerErrorMessage))
|
||||
.ownedOnly(
|
||||
currentUser,
|
||||
errorCallback: errorBuilder(localErrorMessage),
|
||||
);
|
||||
}
|
||||
|
||||
Iterable<Asset> remoteSelection({String? errorMessage}) => remoteOnly(
|
||||
selection.value,
|
||||
Iterable<Asset> remoteSelection({String? errorMessage}) =>
|
||||
selection.value.remoteOnly(
|
||||
errorCallback: errorBuilder(errorMessage),
|
||||
);
|
||||
|
||||
void onShareAssets(bool shareLocal) {
|
||||
processing.value = true;
|
||||
if (shareLocal) {
|
||||
handleShareAssets(ref, context, selection.value.toList());
|
||||
// Share = Download + Send to OS specific share sheet
|
||||
// Filter offline assets since we cannot fetch their original file
|
||||
final liveAssets = selection.value.nonOfflineOnly(
|
||||
errorCallback: errorBuilder('asset_action_share_err_offline'.tr()),
|
||||
);
|
||||
handleShareAssets(ref, context, liveAssets);
|
||||
} else {
|
||||
final ids =
|
||||
remoteSelection(errorMessage: "home_page_share_err_local".tr())
|
||||
|
@ -199,10 +181,17 @@ class MultiselectGrid extends HookConsumerWidget {
|
|||
try {
|
||||
final trashEnabled =
|
||||
ref.read(serverInfoProvider.select((v) => v.serverFeatures.trash));
|
||||
final toDelete = ownedOnly(
|
||||
selection.value,
|
||||
errorCallback: errorBuilder('home_page_delete_err_partner'.tr()),
|
||||
).toList();
|
||||
final toDelete = selection.value
|
||||
.ownedOnly(
|
||||
currentUser,
|
||||
errorCallback: errorBuilder('home_page_delete_err_partner'.tr()),
|
||||
)
|
||||
// Cannot delete readOnly / external assets. They are handled through library offline jobs
|
||||
.writableOnly(
|
||||
errorCallback:
|
||||
errorBuilder('asset_action_delete_err_read_only'.tr()),
|
||||
)
|
||||
.toList();
|
||||
await ref
|
||||
.read(assetProvider.notifier)
|
||||
.deleteAssets(toDelete, force: !trashEnabled);
|
||||
|
@ -331,6 +320,11 @@ class MultiselectGrid extends HookConsumerWidget {
|
|||
final remoteAssets = ownedRemoteSelection(
|
||||
localErrorMessage: 'home_page_favorite_err_local'.tr(),
|
||||
ownerErrorMessage: 'home_page_favorite_err_partner'.tr(),
|
||||
).writableOnly(
|
||||
// Assume readOnly assets to be present in a read-only mount. So do not write sidecar
|
||||
errorCallback: errorBuilder(
|
||||
'multiselect_grid_edit_date_time_err_read_only'.tr(),
|
||||
),
|
||||
);
|
||||
if (remoteAssets.isNotEmpty) {
|
||||
handleEditDateTime(ref, context, remoteAssets.toList());
|
||||
|
@ -345,6 +339,11 @@ class MultiselectGrid extends HookConsumerWidget {
|
|||
final remoteAssets = ownedRemoteSelection(
|
||||
localErrorMessage: 'home_page_favorite_err_local'.tr(),
|
||||
ownerErrorMessage: 'home_page_favorite_err_partner'.tr(),
|
||||
).writableOnly(
|
||||
// Assume readOnly assets to be present in a read-only mount. So do not write sidecar
|
||||
errorCallback: errorBuilder(
|
||||
'multiselect_grid_edit_gps_err_read_only'.tr(),
|
||||
),
|
||||
);
|
||||
if (remoteAssets.isNotEmpty) {
|
||||
handleEditLocation(ref, context, remoteAssets.toList());
|
||||
|
|
|
@ -13,6 +13,8 @@ Future<void> migrateDatabaseIfNeeded(Isar db) async {
|
|||
await _migrateTo(db, 3);
|
||||
case 3:
|
||||
await _migrateTo(db, 4);
|
||||
case 4:
|
||||
await _migrateTo(db, 5);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -17,7 +17,7 @@ import 'package:latlong2/latlong.dart';
|
|||
void handleShareAssets(
|
||||
WidgetRef ref,
|
||||
BuildContext context,
|
||||
List<Asset> selection,
|
||||
Iterable<Asset> selection,
|
||||
) {
|
||||
showDialog(
|
||||
context: context,
|
||||
|
|
Loading…
Reference in a new issue