1
0
Fork 0
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:
shenlong 2024-01-06 03:02:16 +00:00 committed by GitHub
parent 56cde0438c
commit a233e176e5
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 182 additions and 64 deletions

View file

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

View file

@ -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;
}
}

View file

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

View file

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

View file

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

View file

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

Binary file not shown.

View file

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

View file

@ -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);
}
}

View file

@ -17,7 +17,7 @@ import 'package:latlong2/latlong.dart';
void handleShareAssets(
WidgetRef ref,
BuildContext context,
List<Asset> selection,
Iterable<Asset> selection,
) {
showDialog(
context: context,