1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-17 01:06:46 +01:00

refactor(mobile): add AssetState and proper asset updating (#2270)

* refactor(mobile): add AssetState and proper asset updating

* generate files

---------

Co-authored-by: Fynn Petersen-Frey <zoodyy@users.noreply.github.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Fynn Petersen-Frey 2023-04-18 11:47:24 +02:00 committed by GitHub
parent b970a40b4e
commit e80d37bf8f
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
14 changed files with 254 additions and 96 deletions

View file

@ -6,6 +6,7 @@ import 'package:immich_mobile/modules/album/providers/asset_selection.provider.d
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart';
import 'package:immich_mobile/utils/storage_indicator.dart';
class AlbumViewerThumbnail extends HookConsumerWidget { class AlbumViewerThumbnail extends HookConsumerWidget {
final Asset asset; final Asset asset;
@ -85,11 +86,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
right: 10, right: 10,
bottom: 5, bottom: 5,
child: Icon( child: Icon(
asset.isRemote storageIcon(asset),
? (asset.isLocal
? Icons.cloud_done_outlined
: Icons.cloud_outlined)
: Icons.cloud_off_outlined,
color: Colors.white, color: Colors.white,
size: 18, size: 18,
), ),

View file

@ -30,7 +30,7 @@ class ArchiveSelectionNotifier extends StateNotifier<Set<int>> {
} }
Future<void> toggleArchive(Asset asset) async { Future<void> toggleArchive(Asset asset) async {
if (!asset.isRemote) return; if (asset.storage == AssetState.local) return;
_setArchiveForAssetId(asset.id, !_isArchive(asset.id)); _setArchiveForAssetId(asset.id, !_isArchive(asset.id));

View file

@ -65,42 +65,44 @@ class ArchivePage extends HookConsumerWidget {
} }
Widget buildBottomBar() { Widget buildBottomBar() {
return Align( return SafeArea(
alignment: Alignment.bottomCenter, child: Align(
child: SizedBox( alignment: Alignment.bottomCenter,
height: 64, child: SizedBox(
child: Card( height: 64,
child: Column( child: Card(
children: [ child: Column(
ListTile( children: [
shape: RoundedRectangleBorder( ListTile(
borderRadius: BorderRadius.circular(10), shape: RoundedRectangleBorder(
), borderRadius: BorderRadius.circular(10),
leading: const Icon( ),
Icons.unarchive_rounded, leading: const Icon(
), Icons.unarchive_rounded,
title: ),
const Text("Unarchive", style: TextStyle(fontSize: 14)), title:
onTap: () { const Text("Unarchive", style: TextStyle(fontSize: 14)),
if (selection.value.isNotEmpty) { onTap: () {
ref if (selection.value.isNotEmpty) {
.watch(assetProvider.notifier) ref
.toggleArchive(selection.value, false); .watch(assetProvider.notifier)
.toggleArchive(selection.value, false);
final assetOrAssets = final assetOrAssets =
selection.value.length > 1 ? 'assets' : 'asset'; selection.value.length > 1 ? 'assets' : 'asset';
ImmichToast.show( ImmichToast.show(
context: context, context: context,
msg: msg:
'Moved ${selection.value.length} $assetOrAssets to library', 'Moved ${selection.value.length} $assetOrAssets to library',
gravity: ToastGravity.CENTER, gravity: ToastGravity.CENTER,
); );
} }
selectionEnabledHook.value = false; selectionEnabledHook.value = false;
}, },
) )
], ],
),
), ),
), ),
), ),

View file

@ -26,8 +26,8 @@ class FavoriteSelectionNotifier extends StateNotifier<Set<int>> {
} }
Future<void> toggleFavorite(Asset asset) async { Future<void> toggleFavorite(Asset asset) async {
if (!asset.isRemote) return; // TODO support local favorite assets // TODO support local favorite assets
if (asset.storage == AssetState.local) return;
_setFavoriteForAssetId(asset.id, !_isFavorite(asset.id)); _setFavoriteForAssetId(asset.id, !_isFavorite(asset.id));
await assetNotifier.toggleFavorite( await assetNotifier.toggleFavorite(

View file

@ -6,6 +6,7 @@ import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart'
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/ui/immich_image.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart';
import 'package:immich_mobile/utils/storage_indicator.dart';
class ThumbnailImage extends HookConsumerWidget { class ThumbnailImage extends HookConsumerWidget {
final Asset asset; final Asset asset;
@ -124,11 +125,7 @@ class ThumbnailImage extends HookConsumerWidget {
right: 10, right: 10,
bottom: 5, bottom: 5,
child: Icon( child: Icon(
asset.isRemote storageIcon(asset),
? (asset.isLocal
? Icons.cloud_done_outlined
: Icons.cloud_outlined)
: Icons.cloud_off_outlined,
color: Colors.white, color: Colors.white,
size: 18, size: 18,
), ),

View file

@ -56,6 +56,7 @@ class Asset {
} }
Asset({ Asset({
this.id = Isar.autoIncrement,
this.remoteId, this.remoteId,
required this.localId, required this.localId,
required this.deviceId, required this.deviceId,
@ -133,6 +134,7 @@ class Asset {
bool isFavorite; bool isFavorite;
/// `true` if this [Asset] is present on the device
bool isLocal; bool isLocal;
bool isArchived; bool isArchived;
@ -146,12 +148,26 @@ class Asset {
@ignore @ignore
String get name => p.withoutExtension(fileName); String get name => p.withoutExtension(fileName);
/// `true` if this [Asset] is present on the server
@ignore @ignore
bool get isRemote => remoteId != null; bool get isRemote => remoteId != null;
@ignore @ignore
bool get isImage => type == AssetType.image; bool get isImage => type == AssetType.image;
@ignore
AssetState get storage {
if (isRemote && isLocal) {
return AssetState.merged;
} else if (isRemote) {
return AssetState.remote;
} else if (isLocal) {
return AssetState.local;
} else {
throw Exception("Asset has illegal state: $this");
}
}
@ignore @ignore
Duration get duration => Duration(seconds: durationInSeconds); Duration get duration => Duration(seconds: durationInSeconds);
@ -198,38 +214,113 @@ class Asset {
isLocal.hashCode ^ isLocal.hashCode ^
isArchived.hashCode; isArchived.hashCode;
bool updateFromAssetEntity(AssetEntity ae) { /// Returns `true` if this [Asset] can updated with values from parameter [a]
// TODO check more fields; bool canUpdate(Asset a) {
// width and height are most important because local assets require these assert(isInDb);
final bool hasChanges =
isLocal == false || width != ae.width || height != ae.height;
if (hasChanges) {
isLocal = true;
width = ae.width;
height = ae.height;
}
return hasChanges;
}
Asset withUpdatesFromDto(AssetResponseDto dto) =>
Asset.remote(dto).updateFromDb(this);
Asset updateFromDb(Asset a) {
assert(localId == a.localId); assert(localId == a.localId);
assert(deviceId == a.deviceId); assert(deviceId == a.deviceId);
id = a.id; assert(a.storage != AssetState.merged);
isLocal |= a.isLocal; return a.updatedAt.isAfter(updatedAt) ||
remoteId ??= a.remoteId; a.isRemote && !isRemote ||
width ??= a.width; a.isLocal && !isLocal ||
height ??= a.height; width == null && a.width != null ||
exifInfo ??= a.exifInfo; height == null && a.height != null ||
exifInfo?.id = id; exifInfo == null && a.exifInfo != null ||
if (!isRemote) { livePhotoVideoId == null && a.livePhotoVideoId != null ||
isArchived = a.isArchived; !isRemote && a.isRemote && isFavorite != a.isFavorite ||
} !isRemote && a.isRemote && isArchived != a.isArchived;
return this;
} }
/// Returns a new [Asset] with values from this and merged & updated with [a]
Asset updatedCopy(Asset a) {
assert(canUpdate(a));
if (a.updatedAt.isAfter(updatedAt)) {
// take most values from newer asset
// keep vales that can never be set by the asset not in DB
if (a.isRemote) {
return a._copyWith(
id: id,
isLocal: isLocal,
width: a.width ?? width,
height: a.height ?? height,
exifInfo: a.exifInfo?.copyWith(id: id) ?? exifInfo,
);
} else {
return a._copyWith(
id: id,
remoteId: remoteId,
livePhotoVideoId: livePhotoVideoId,
isFavorite: isFavorite,
isArchived: isArchived,
);
}
} else {
// fill in potentially missing values, i.e. merge assets
if (a.isRemote) {
// values from remote take precedence
return _copyWith(
remoteId: a.remoteId,
width: a.width,
height: a.height,
livePhotoVideoId: a.livePhotoVideoId,
// isFavorite + isArchived are not set by device-only assets
isFavorite: a.isFavorite,
isArchived: a.isArchived,
exifInfo: a.exifInfo?.copyWith(id: id) ?? exifInfo,
);
} else {
// add only missing values (and set isLocal to true)
return _copyWith(
isLocal: true,
width: width ?? a.width,
height: height ?? a.height,
exifInfo: exifInfo ?? a.exifInfo?.copyWith(id: id),
);
}
}
}
Asset _copyWith({
Id? id,
String? remoteId,
String? localId,
int? deviceId,
int? ownerId,
DateTime? fileCreatedAt,
DateTime? fileModifiedAt,
DateTime? updatedAt,
int? durationInSeconds,
AssetType? type,
short? width,
short? height,
String? fileName,
String? livePhotoVideoId,
bool? isFavorite,
bool? isLocal,
bool? isArchived,
ExifInfo? exifInfo,
}) =>
Asset(
id: id ?? this.id,
remoteId: remoteId ?? this.remoteId,
localId: localId ?? this.localId,
deviceId: deviceId ?? this.deviceId,
ownerId: ownerId ?? this.ownerId,
fileCreatedAt: fileCreatedAt ?? this.fileCreatedAt,
fileModifiedAt: fileModifiedAt ?? this.fileModifiedAt,
updatedAt: updatedAt ?? this.updatedAt,
durationInSeconds: durationInSeconds ?? this.durationInSeconds,
type: type ?? this.type,
width: width ?? this.width,
height: height ?? this.height,
fileName: fileName ?? this.fileName,
livePhotoVideoId: livePhotoVideoId ?? this.livePhotoVideoId,
isFavorite: isFavorite ?? this.isFavorite,
isLocal: isLocal ?? this.isLocal,
isArchived: isArchived ?? this.isArchived,
exifInfo: exifInfo ?? this.exifInfo,
);
Future<void> put(Isar db) async { Future<void> put(Isar db) async {
await db.assets.put(this); await db.assets.put(this);
if (exifInfo != null) { if (exifInfo != null) {
@ -311,6 +402,14 @@ extension AssetTypeEnumHelper on AssetTypeEnum {
} }
} }
/// Describes where the information of this asset came from:
/// only from the local device, only from the remote server or merged from both
enum AssetState {
local,
remote,
merged,
}
extension AssetsHelper on IsarCollection<Asset> { extension AssetsHelper on IsarCollection<Asset> {
Future<int> deleteAllByRemoteId(Iterable<String> ids) => Future<int> deleteAllByRemoteId(Iterable<String> ids) =>
ids.isEmpty ? Future.value(0) : _remote(ids).deleteAll(); ids.isEmpty ? Future.value(0) : _remote(ids).deleteAll();

View file

@ -63,6 +63,7 @@ class ExifInfo {
description = dto.description; description = dto.description;
ExifInfo({ ExifInfo({
this.id,
this.fileSize, this.fileSize,
this.make, this.make,
this.model, this.model,
@ -78,6 +79,41 @@ class ExifInfo {
this.country, this.country,
this.description, this.description,
}); });
ExifInfo copyWith({
Id? id,
int? fileSize,
String? make,
String? model,
String? lens,
float? f,
float? mm,
short? iso,
float? exposureSeconds,
float? lat,
float? long,
String? city,
String? state,
String? country,
String? description,
}) =>
ExifInfo(
id: id ?? this.id,
fileSize: fileSize ?? this.fileSize,
make: make ?? this.make,
model: model ?? this.model,
lens: lens ?? this.lens,
f: f ?? this.f,
mm: mm ?? this.mm,
iso: iso ?? this.iso,
exposureSeconds: exposureSeconds ?? this.exposureSeconds,
lat: lat ?? this.lat,
long: long ?? this.long,
city: city ?? this.city,
state: state ?? this.state,
country: country ?? this.country,
description: description ?? this.description,
);
} }
double? _exposureTimeToSeconds(String? s) { double? _exposureTimeToSeconds(String? s) {

View file

@ -102,8 +102,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
await clearAssetsAndAlbums(_db); await clearAssetsAndAlbums(_db);
log.info("Manual refresh requested, cleared assets and albums from db"); log.info("Manual refresh requested, cleared assets and albums from db");
} else if (_stateUpdateLock.enqueued <= 1) { } else if (_stateUpdateLock.enqueued <= 1) {
final int cachedCount = final int cachedCount = await _userAssetQuery(me.isarId).count();
await _db.assets.filter().ownerIdEqualTo(me.isarId).count();
if (cachedCount > 0 && cachedCount != state.allAssets.length) { if (cachedCount > 0 && cachedCount != state.allAssets.length) {
await _stateUpdateLock.run( await _stateUpdateLock.run(
() async => _updateAssetsState(await _getUserAssets(me.isarId)), () async => _updateAssetsState(await _getUserAssets(me.isarId)),
@ -121,8 +120,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
stopwatch.reset(); stopwatch.reset();
if (!newRemote && if (!newRemote &&
!newLocal && !newLocal &&
state.allAssets.length == state.allAssets.length == await _userAssetQuery(me.isarId).count()) {
await _db.assets.filter().ownerIdEqualTo(me.isarId).count()) {
log.info("state is already up-to-date"); log.info("state is already up-to-date");
return; return;
} }
@ -141,12 +139,13 @@ class AssetNotifier extends StateNotifier<AssetsState> {
} }
} }
Future<List<Asset>> _getUserAssets(int userId) => _db.assets Future<List<Asset>> _getUserAssets(int userId) =>
.filter() _userAssetQuery(userId).sortByFileCreatedAtDesc().findAll();
.ownerIdEqualTo(userId)
.isArchivedEqualTo(false) QueryBuilder<Asset, Asset, QAfterFilterCondition> _userAssetQuery(
.sortByFileCreatedAtDesc() int userId,
.findAll(); ) =>
_db.assets.filter().ownerIdEqualTo(userId).isArchivedEqualTo(false);
Future<void> clearAllAsset() { Future<void> clearAllAsset() {
state = AssetsState.empty(); state = AssetsState.empty();

View file

@ -101,7 +101,7 @@ class AssetService {
if (a.isRemote) { if (a.isRemote) {
final dto = await _apiService.assetApi.getAssetById(a.remoteId!); final dto = await _apiService.assetApi.getAssetById(a.remoteId!);
if (dto != null && dto.exifInfo != null) { if (dto != null && dto.exifInfo != null) {
a = a.withUpdatesFromDto(dto); a.exifInfo = Asset.remote(dto).exifInfo!.copyWith(id: a.id);
if (a.isInDb) { if (a.isInDb) {
_db.writeTxn(() => a.put(_db)); _db.writeTxn(() => a.put(_db));
} else { } else {
@ -122,7 +122,7 @@ class AssetService {
final dto = final dto =
await _apiService.assetApi.updateAsset(asset.remoteId!, updateAssetDto); await _apiService.assetApi.updateAsset(asset.remoteId!, updateAssetDto);
if (dto != null) { if (dto != null) {
final updated = Asset.remote(dto).updateFromDb(asset); final updated = asset.updatedCopy(Asset.remote(dto));
if (updated.isInDb) { if (updated.isInDb) {
await _db.writeTxn(() => updated.put(_db)); await _db.writeTxn(() => updated.put(_db));
} }

View file

@ -136,7 +136,7 @@ class SyncService {
if (match != null) { if (match != null) {
// unify local/remote assets by replacing the // unify local/remote assets by replacing the
// local-only asset in the DB with a local&remote asset // local-only asset in the DB with a local&remote asset
newAsset.updateFromDb(match); newAsset = match.updatedCopy(newAsset);
} }
try { try {
await _db.writeTxn(() => newAsset.put(_db)); await _db.writeTxn(() => newAsset.put(_db));
@ -581,12 +581,12 @@ class SyncService {
// client and server, thus never reaching "both" case below // client and server, thus never reaching "both" case below
compare: Asset.compareByOwnerDeviceLocalId, compare: Asset.compareByOwnerDeviceLocalId,
both: (Asset a, Asset b) { both: (Asset a, Asset b) {
if ((a.isLocal || !b.isLocal) && (a.isRemote || !b.isRemote)) { if (a.canUpdate(b)) {
toUpsert.add(a.updatedCopy(b));
return true;
} else {
existing.add(a); existing.add(a);
return false; return false;
} else {
toUpsert.add(b.updateFromDb(a));
return true;
} }
}, },
onlyFirst: (Asset a) => _log.finer( onlyFirst: (Asset a) => _log.finer(
@ -637,10 +637,8 @@ Triple<List<Asset>, List<Asset>, List<Asset>> _diffAssets(
assets, assets,
compare: compare, compare: compare,
both: (Asset a, Asset b) { both: (Asset a, Asset b) {
if (a.updatedAt.isBefore(b.updatedAt) || if (a.canUpdate(b)) {
(!a.isLocal && b.isLocal) || toUpdate.add(a.updatedCopy(b));
(!a.isRemote && b.isRemote)) {
toUpdate.add(b.updateFromDb(a));
return true; return true;
} }
return false; return false;

View file

@ -0,0 +1,14 @@
import 'package:flutter/material.dart';
import 'package:immich_mobile/shared/models/asset.dart';
/// Returns the suitable [IconData] to represent an [Asset]s storage location
IconData storageIcon(Asset asset) {
switch (asset.storage) {
case AssetState.local:
return Icons.cloud_off_outlined;
case AssetState.remote:
return Icons.cloud_outlined;
case AssetState.merged:
return Icons.cloud_done_outlined;
}
}

View file

@ -242,6 +242,22 @@ class MockAssetNotifier extends _i1.Mock implements _i2.AssetNotifier {
returnValueForMissingStub: _i5.Future<bool>.value(false), returnValueForMissingStub: _i5.Future<bool>.value(false),
) as _i5.Future<bool>); ) as _i5.Future<bool>);
@override @override
_i5.Future<void> toggleArchive(
Iterable<_i4.Asset>? assets,
bool? status,
) =>
(super.noSuchMethod(
Invocation.method(
#toggleArchive,
[
assets,
status,
],
),
returnValue: _i5.Future<void>.value(),
returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>);
@override
bool updateShouldNotify( bool updateShouldNotify(
_i2.AssetsState? old, _i2.AssetsState? old,
_i2.AssetsState? current, _i2.AssetsState? current,