feat(mobile): Archive feature on mobile (#2258)

* update asset to include isArchive property

* Not display archived assets on timeline

* replace share button to archive button

* Added archive page

* Add bottom nav bar

* clean up homepage

* remove deadcode

* improve on sync is archive

* show archive asset correctly

* better merge condition

* Added back renderList to re-rendering don't jump around

* Better way to handle showing archive assets

* complete ArchiveSelectionNotifier

* toggle archive

* remove deadcode

* fix unit tests

* update assets in DB when changing assets

* update asset state to reflect archived status

* allow to archive assets via multi-select from timeline

* fixed logic

* Add options to bulk unarchive

* regenerate api

* Change position of toast message

---------

Co-authored-by: Fynn Petersen-Frey <zoodyy@users.noreply.github.com>
This commit is contained in:
Alex 2023-04-17 00:02:07 -05:00 committed by GitHub
parent 635eee9e5e
commit 2e5cd986dd
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 523 additions and 114 deletions

View file

@ -29,7 +29,8 @@ class Asset {
ownerId = fastHash(remote.ownerId),
exifInfo =
remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null,
isFavorite = remote.isFavorite;
isFavorite = remote.isFavorite,
isArchived = remote.isArchived;
Asset.local(AssetEntity local)
: localId = local.id,
@ -44,6 +45,7 @@ class Asset {
fileModifiedAt = local.modifiedDateTime,
updatedAt = local.modifiedDateTime,
isFavorite = local.isFavorite,
isArchived = false,
fileCreatedAt = local.createDateTime {
if (fileCreatedAt.year == 1970) {
fileCreatedAt = fileModifiedAt;
@ -70,6 +72,7 @@ class Asset {
this.exifInfo,
required this.isFavorite,
required this.isLocal,
required this.isArchived,
});
@ignore
@ -132,6 +135,8 @@ class Asset {
bool isLocal;
bool isArchived;
@ignore
ExifInfo? exifInfo;
@ -168,7 +173,8 @@ class Asset {
fileName == other.fileName &&
livePhotoVideoId == other.livePhotoVideoId &&
isFavorite == other.isFavorite &&
isLocal == other.isLocal;
isLocal == other.isLocal &&
isArchived == other.isArchived;
}
@override
@ -189,7 +195,8 @@ class Asset {
fileName.hashCode ^
livePhotoVideoId.hashCode ^
isFavorite.hashCode ^
isLocal.hashCode;
isLocal.hashCode ^
isArchived.hashCode;
bool updateFromAssetEntity(AssetEntity ae) {
// TODO check more fields;
@ -217,6 +224,9 @@ class Asset {
height ??= a.height;
exifInfo ??= a.exifInfo;
exifInfo?.id = id;
if (!isRemote) {
isArchived = a.isArchived;
}
return this;
}
@ -271,7 +281,8 @@ class Asset {
"isFavorite": $isFavorite,
"isLocal": $isLocal,
"width": ${width ?? "N/A"},
"height": ${height ?? "N/A"}
"height": ${height ?? "N/A"},
"isArchived": $isArchived
}""";
}
}

View file

@ -47,49 +47,54 @@ const AssetSchema = CollectionSchema(
name: r'height',
type: IsarType.int,
),
r'isFavorite': PropertySchema(
r'isArchived': PropertySchema(
id: 6,
name: r'isArchived',
type: IsarType.bool,
),
r'isFavorite': PropertySchema(
id: 7,
name: r'isFavorite',
type: IsarType.bool,
),
r'isLocal': PropertySchema(
id: 7,
id: 8,
name: r'isLocal',
type: IsarType.bool,
),
r'livePhotoVideoId': PropertySchema(
id: 8,
id: 9,
name: r'livePhotoVideoId',
type: IsarType.string,
),
r'localId': PropertySchema(
id: 9,
id: 10,
name: r'localId',
type: IsarType.string,
),
r'ownerId': PropertySchema(
id: 10,
id: 11,
name: r'ownerId',
type: IsarType.long,
),
r'remoteId': PropertySchema(
id: 11,
id: 12,
name: r'remoteId',
type: IsarType.string,
),
r'type': PropertySchema(
id: 12,
id: 13,
name: r'type',
type: IsarType.byte,
enumMap: _AssettypeEnumValueMap,
),
r'updatedAt': PropertySchema(
id: 13,
id: 14,
name: r'updatedAt',
type: IsarType.dateTime,
),
r'width': PropertySchema(
id: 14,
id: 15,
name: r'width',
type: IsarType.int,
)
@ -175,15 +180,16 @@ void _assetSerialize(
writer.writeDateTime(offsets[3], object.fileModifiedAt);
writer.writeString(offsets[4], object.fileName);
writer.writeInt(offsets[5], object.height);
writer.writeBool(offsets[6], object.isFavorite);
writer.writeBool(offsets[7], object.isLocal);
writer.writeString(offsets[8], object.livePhotoVideoId);
writer.writeString(offsets[9], object.localId);
writer.writeLong(offsets[10], object.ownerId);
writer.writeString(offsets[11], object.remoteId);
writer.writeByte(offsets[12], object.type.index);
writer.writeDateTime(offsets[13], object.updatedAt);
writer.writeInt(offsets[14], object.width);
writer.writeBool(offsets[6], object.isArchived);
writer.writeBool(offsets[7], object.isFavorite);
writer.writeBool(offsets[8], object.isLocal);
writer.writeString(offsets[9], object.livePhotoVideoId);
writer.writeString(offsets[10], object.localId);
writer.writeLong(offsets[11], object.ownerId);
writer.writeString(offsets[12], object.remoteId);
writer.writeByte(offsets[13], object.type.index);
writer.writeDateTime(offsets[14], object.updatedAt);
writer.writeInt(offsets[15], object.width);
}
Asset _assetDeserialize(
@ -199,16 +205,17 @@ Asset _assetDeserialize(
fileModifiedAt: reader.readDateTime(offsets[3]),
fileName: reader.readString(offsets[4]),
height: reader.readIntOrNull(offsets[5]),
isFavorite: reader.readBool(offsets[6]),
isLocal: reader.readBool(offsets[7]),
livePhotoVideoId: reader.readStringOrNull(offsets[8]),
localId: reader.readString(offsets[9]),
ownerId: reader.readLong(offsets[10]),
remoteId: reader.readStringOrNull(offsets[11]),
type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[12])] ??
isArchived: reader.readBool(offsets[6]),
isFavorite: reader.readBool(offsets[7]),
isLocal: reader.readBool(offsets[8]),
livePhotoVideoId: reader.readStringOrNull(offsets[9]),
localId: reader.readString(offsets[10]),
ownerId: reader.readLong(offsets[11]),
remoteId: reader.readStringOrNull(offsets[12]),
type: _AssettypeValueEnumMap[reader.readByteOrNull(offsets[13])] ??
AssetType.other,
updatedAt: reader.readDateTime(offsets[13]),
width: reader.readIntOrNull(offsets[14]),
updatedAt: reader.readDateTime(offsets[14]),
width: reader.readIntOrNull(offsets[15]),
);
object.id = id;
return object;
@ -238,19 +245,21 @@ P _assetDeserializeProp<P>(
case 7:
return (reader.readBool(offset)) as P;
case 8:
return (reader.readStringOrNull(offset)) as P;
return (reader.readBool(offset)) as P;
case 9:
return (reader.readString(offset)) as P;
case 10:
return (reader.readLong(offset)) as P;
case 11:
return (reader.readStringOrNull(offset)) as P;
case 10:
return (reader.readString(offset)) as P;
case 11:
return (reader.readLong(offset)) as P;
case 12:
return (reader.readStringOrNull(offset)) as P;
case 13:
return (_AssettypeValueEnumMap[reader.readByteOrNull(offset)] ??
AssetType.other) as P;
case 13:
return (reader.readDateTime(offset)) as P;
case 14:
return (reader.readDateTime(offset)) as P;
case 15:
return (reader.readIntOrNull(offset)) as P;
default:
throw IsarError('Unknown property with id $propertyId');
@ -1024,6 +1033,16 @@ extension AssetQueryFilter on QueryBuilder<Asset, Asset, QFilterCondition> {
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> isArchivedEqualTo(
bool value) {
return QueryBuilder.apply(this, (query) {
return query.addFilterCondition(FilterCondition.equalTo(
property: r'isArchived',
value: value,
));
});
}
QueryBuilder<Asset, Asset, QAfterFilterCondition> isFavoriteEqualTo(
bool value) {
return QueryBuilder.apply(this, (query) {
@ -1771,6 +1790,18 @@ extension AssetQuerySortBy on QueryBuilder<Asset, Asset, QSortBy> {
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> sortByIsArchived() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'isArchived', Sort.asc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> sortByIsArchivedDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'isArchived', Sort.desc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> sortByIsFavorite() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'isFavorite', Sort.asc);
@ -1965,6 +1996,18 @@ extension AssetQuerySortThenBy on QueryBuilder<Asset, Asset, QSortThenBy> {
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> thenByIsArchived() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'isArchived', Sort.asc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> thenByIsArchivedDesc() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'isArchived', Sort.desc);
});
}
QueryBuilder<Asset, Asset, QAfterSortBy> thenByIsFavorite() {
return QueryBuilder.apply(this, (query) {
return query.addSortBy(r'isFavorite', Sort.asc);
@ -2112,6 +2155,12 @@ extension AssetQueryWhereDistinct on QueryBuilder<Asset, Asset, QDistinct> {
});
}
QueryBuilder<Asset, Asset, QDistinct> distinctByIsArchived() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'isArchived');
});
}
QueryBuilder<Asset, Asset, QDistinct> distinctByIsFavorite() {
return QueryBuilder.apply(this, (query) {
return query.addDistinctBy(r'isFavorite');
@ -2214,6 +2263,12 @@ extension AssetQueryProperty on QueryBuilder<Asset, Asset, QQueryProperty> {
});
}
QueryBuilder<Asset, bool, QQueryOperations> isArchivedProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'isArchived');
});
}
QueryBuilder<Asset, bool, QQueryOperations> isFavoriteProperty() {
return QueryBuilder.apply(this, (query) {
return query.addPropertyName(r'isFavorite');

View file

@ -1,3 +1,4 @@
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
@ -19,6 +20,8 @@ import 'package:logging/logging.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
/// State does not contain archived assets.
/// Use database provider if you want to access the isArchived assets
class AssetsState {
final List<Asset> allAssets;
final RenderList? renderList;
@ -76,6 +79,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
GroupAssetsBy
.values[_settingsService.getSetting(AppSettingsEnum.groupAssetsBy)],
);
state = await AssetsState.fromAssetList(newAssetList)
.withRenderDataStructure(layout);
}
@ -112,6 +116,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
}
final bool newRemote = await _assetService.refreshRemoteAssets();
final bool newLocal = await _albumService.refreshDeviceAlbums();
debugPrint("newRemote: $newRemote, newLocal: $newLocal");
log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset();
if (!newRemote &&
@ -139,6 +144,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
Future<List<Asset>> _getUserAssets(int userId) => _db.assets
.filter()
.ownerIdEqualTo(userId)
.isArchivedEqualTo(false)
.sortByFileCreatedAtDesc()
.findAll();
@ -224,13 +230,46 @@ class AssetNotifier extends StateNotifier<AssetsState> {
}
final index = state.allAssets.indexWhere((a) => asset.id == a.id);
if (index > 0) {
if (index != -1) {
state.allAssets[index] = newAsset;
_updateAssetsState(state.allAssets);
}
return newAsset.isFavorite;
}
Future<void> toggleArchive(Iterable<Asset> assets, bool status) async {
final newAssets = await Future.wait(
assets.map((a) => _assetService.changeArchiveStatus(a, status)),
);
int i = 0;
bool unArchived = false;
for (Asset oldAsset in assets) {
final newAsset = newAssets[i++];
if (newAsset == null) {
log.severe("Change archive status failed for asset ${oldAsset.id}");
continue;
}
final index = state.allAssets.indexWhere((a) => oldAsset.id == a.id);
if (newAsset.isArchived) {
// remove from state
if (index != -1) {
state.allAssets.removeAt(index);
}
} else {
// add to state is difficult because the list is sorted
unArchived = true;
}
}
if (unArchived) {
final User me = Store.get(StoreKey.currentUser);
await _stateUpdateLock.run(
() async => _updateAssetsState(await _getUserAssets(me.isarId)),
);
} else {
_updateAssetsState(state.allAssets);
}
}
}
final assetProvider = StateNotifierProvider<AssetNotifier, AssetsState>((ref) {

View file

@ -121,10 +121,21 @@ class AssetService {
) async {
final dto =
await _apiService.assetApi.updateAsset(asset.remoteId!, updateAssetDto);
return dto == null ? null : Asset.remote(dto);
if (dto != null) {
final updated = Asset.remote(dto).updateFromDb(asset);
if (updated.isInDb) {
await _db.writeTxn(() => updated.put(_db));
}
return updated;
}
return null;
}
Future<Asset?> changeFavoriteStatus(Asset asset, bool isFavorite) {
return updateAsset(asset, UpdateAssetDto(isFavorite: isFavorite));
}
Future<Asset?> changeArchiveStatus(Asset asset, bool isArchive) {
return updateAsset(asset, UpdateAssetDto(isArchived: isArchive));
}
}