immich/mobile/lib/shared/providers/asset.provider.dart
Fynn Petersen-Frey e80d37bf8f
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>
2023-04-18 04:47:24 -05:00

300 lines
10 KiB
Dart

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';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/asset.service.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:collection/collection.dart';
import 'package:immich_mobile/shared/services/sync.service.dart';
import 'package:immich_mobile/utils/async_mutex.dart';
import 'package:immich_mobile/utils/db.dart';
import 'package:intl/intl.dart';
import 'package:isar/isar.dart';
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;
AssetsState(this.allAssets, {this.renderList});
Future<AssetsState> withRenderDataStructure(
AssetGridLayoutParameters layout,
) async {
return AssetsState(
allAssets,
renderList: await RenderList.fromAssets(
allAssets,
layout,
),
);
}
AssetsState withAdditionalAssets(List<Asset> toAdd) {
return AssetsState([...allAssets, ...toAdd]);
}
static AssetsState fromAssetList(List<Asset> assets) {
return AssetsState(assets);
}
static AssetsState empty() {
return AssetsState([]);
}
}
class AssetNotifier extends StateNotifier<AssetsState> {
final AssetService _assetService;
final AppSettingsService _settingsService;
final AlbumService _albumService;
final SyncService _syncService;
final Isar _db;
final log = Logger('AssetNotifier');
bool _getAllAssetInProgress = false;
bool _deleteInProgress = false;
final AsyncMutex _stateUpdateLock = AsyncMutex();
AssetNotifier(
this._assetService,
this._settingsService,
this._albumService,
this._syncService,
this._db,
) : super(AssetsState.fromAssetList([]));
Future<void> _updateAssetsState(List<Asset> newAssetList) async {
final layout = AssetGridLayoutParameters(
_settingsService.getSetting(AppSettingsEnum.tilesPerRow),
_settingsService.getSetting(AppSettingsEnum.dynamicLayout),
GroupAssetsBy
.values[_settingsService.getSetting(AppSettingsEnum.groupAssetsBy)],
);
state = await AssetsState.fromAssetList(newAssetList)
.withRenderDataStructure(layout);
}
// Just a little helper to trigger a rebuild of the state object
Future<void> rebuildAssetGridDataStructure() async {
await _updateAssetsState(state.allAssets);
}
Future<void> getAllAsset({bool clear = false}) async {
if (_getAllAssetInProgress || _deleteInProgress) {
// guard against multiple calls to this method while it's still working
return;
}
final stopwatch = Stopwatch()..start();
try {
_getAllAssetInProgress = true;
final User me = Store.get(StoreKey.currentUser);
if (clear) {
await clearAssetsAndAlbums(_db);
log.info("Manual refresh requested, cleared assets and albums from db");
} else if (_stateUpdateLock.enqueued <= 1) {
final int cachedCount = await _userAssetQuery(me.isarId).count();
if (cachedCount > 0 && cachedCount != state.allAssets.length) {
await _stateUpdateLock.run(
() async => _updateAssetsState(await _getUserAssets(me.isarId)),
);
log.info(
"Reading assets ${state.allAssets.length} from DB: ${stopwatch.elapsedMilliseconds}ms",
);
stopwatch.reset();
}
}
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 &&
!newLocal &&
state.allAssets.length == await _userAssetQuery(me.isarId).count()) {
log.info("state is already up-to-date");
return;
}
stopwatch.reset();
if (_stateUpdateLock.enqueued <= 1) {
_stateUpdateLock.run(() async {
final assets = await _getUserAssets(me.isarId);
if (!const ListEquality().equals(assets, state.allAssets)) {
log.info("setting new asset state");
await _updateAssetsState(assets);
}
});
}
} finally {
_getAllAssetInProgress = false;
}
}
Future<List<Asset>> _getUserAssets(int userId) =>
_userAssetQuery(userId).sortByFileCreatedAtDesc().findAll();
QueryBuilder<Asset, Asset, QAfterFilterCondition> _userAssetQuery(
int userId,
) =>
_db.assets.filter().ownerIdEqualTo(userId).isArchivedEqualTo(false);
Future<void> clearAllAsset() {
state = AssetsState.empty();
return clearAssetsAndAlbums(_db);
}
Future<void> onNewAssetUploaded(Asset newAsset) async {
final bool ok = await _syncService.syncNewAssetToDb(newAsset);
if (ok && _stateUpdateLock.enqueued <= 1) {
// run this sequentially if there is at most 1 other task waiting
await _stateUpdateLock.run(() async {
final userId = Store.get(StoreKey.currentUser).isarId;
final assets = await _getUserAssets(userId);
await _updateAssetsState(assets);
});
}
}
Future<void> deleteAssets(Set<Asset> deleteAssets) async {
_deleteInProgress = true;
try {
_updateAssetsState(
state.allAssets.whereNot(deleteAssets.contains).toList(),
);
final localDeleted = await _deleteLocalAssets(deleteAssets);
final remoteDeleted = await _deleteRemoteAssets(deleteAssets);
if (localDeleted.isNotEmpty || remoteDeleted.isNotEmpty) {
final dbIds = deleteAssets.map((e) => e.id).toList();
await _db.writeTxn(() async {
await _db.exifInfos.deleteAll(dbIds);
await _db.assets.deleteAll(dbIds);
});
}
} finally {
_deleteInProgress = false;
}
}
Future<List<String>> _deleteLocalAssets(Set<Asset> assetsToDelete) async {
final int deviceId = Store.get(StoreKey.deviceIdHash);
final List<String> local = [];
// Delete asset from device
for (final Asset asset in assetsToDelete) {
if (asset.isLocal) {
local.add(asset.localId);
} else if (asset.deviceId == deviceId) {
// Delete asset on device if it is still present
var localAsset = await AssetEntity.fromId(asset.localId);
if (localAsset != null) {
local.add(localAsset.id);
}
}
}
if (local.isNotEmpty) {
try {
await PhotoManager.editor.deleteWithIds(local);
} catch (e, stack) {
log.severe("Failed to delete asset from device", e, stack);
}
}
return [];
}
Future<Iterable<String>> _deleteRemoteAssets(
Set<Asset> assetsToDelete,
) async {
final Iterable<Asset> remote = assetsToDelete.where((e) => e.isRemote);
final List<DeleteAssetResponseDto> deleteAssetResult =
await _assetService.deleteAssets(remote) ?? [];
return deleteAssetResult
.where((a) => a.status == DeleteAssetStatus.SUCCESS)
.map((a) => a.id);
}
Future<bool> toggleFavorite(Asset asset, bool status) async {
final newAsset = await _assetService.changeFavoriteStatus(asset, status);
if (newAsset == null) {
log.severe("Change favorite status failed for asset ${asset.id}");
return asset.isFavorite;
}
final index = state.allAssets.indexWhere((a) => asset.id == a.id);
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) {
return AssetNotifier(
ref.watch(assetServiceProvider),
ref.watch(appSettingsServiceProvider),
ref.watch(albumServiceProvider),
ref.watch(syncServiceProvider),
ref.watch(dbProvider),
);
});
final assetGroupByMonthYearProvider = StateProvider((ref) {
// TODO: remove `where` once temporary workaround is no longer needed (to only
// allow remote assets to be added to album). Keep `toList()` as to NOT sort
// the original list/state
final assets =
ref.watch(assetProvider).allAssets.where((e) => e.isRemote).toList();
assets.sortByCompare<DateTime>(
(e) => e.fileCreatedAt,
(a, b) => b.compareTo(a),
);
return assets.groupListsBy(
(element) => DateFormat('MMMM, y').format(element.fileCreatedAt.toLocal()),
);
});