1
0
mirror of https://github.com/immich-app/immich.git synced 2025-01-12 15:32:36 +02:00

feature(mobile): sync assets, albums & users to local database on device (#1759)

* feature(mobile): sync assets, albums & users to local database on device

* try to fix tests

* move DB sync operations to new SyncService

* clear db on user logout

* fix reason for endless loading timeline

* fix error when deleting album

* fix thumbnail of device albums

* add a few comments

* fix Hive box not open in album service when loading local assets

* adjust tests to int IDs

* fix bug: show all albums when Recent is selected

* update generated api

* reworked Recents album isAll handling

* guard against wrongly interleaved sync operations

* fix: timeline asset ordering (sort asset state by created at)

* fix: sort assets in albums by created at
This commit is contained in:
Fynn Petersen-Frey 2023-03-03 23:38:30 +01:00 committed by GitHub
parent 8f11529a75
commit 8708867c1c
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
61 changed files with 1723 additions and 892 deletions

View File

@ -142,6 +142,7 @@
"library_page_sharing": "Sharing", "library_page_sharing": "Sharing",
"library_page_sort_created": "Most recently created", "library_page_sort_created": "Most recently created",
"library_page_sort_title": "Album title", "library_page_sort_title": "Album title",
"library_page_device_albums": "Albums on Device",
"login_form_button_text": "Login", "login_form_button_text": "Login",
"login_form_email_hint": "youremail@email.com", "login_form_email_hint": "youremail@email.com",
"login_form_endpoint_hint": "http://your-server-ip:port/api", "login_form_endpoint_hint": "http://your-server-ip:port/api",

View File

@ -19,8 +19,12 @@ import 'package:immich_mobile/modules/onboarding/providers/gallery_permission.pr
import 'package:immich_mobile/modules/settings/providers/notification_permission.provider.dart'; import 'package:immich_mobile/modules/settings/providers/notification_permission.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/routing/tab_navigation_observer.dart'; import 'package:immich_mobile/routing/tab_navigation_observer.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/immich_logger_message.model.dart'; import 'package:immich_mobile/shared/models/immich_logger_message.model.dart';
import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/app_state.provider.dart'; import 'package:immich_mobile/shared/providers/app_state.provider.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart'; import 'package:immich_mobile/shared/providers/db.provider.dart';
@ -42,6 +46,7 @@ void main() async {
await initApp(); await initApp();
final db = await loadDb(); final db = await loadDb();
await migrateHiveToStoreIfNecessary(); await migrateHiveToStoreIfNecessary();
await migrateJsonCacheIfNecessary();
runApp(getMainWidget(db)); runApp(getMainWidget(db));
} }
@ -93,7 +98,13 @@ Future<void> initApp() async {
Future<Isar> loadDb() async { Future<Isar> loadDb() async {
final dir = await getApplicationDocumentsDirectory(); final dir = await getApplicationDocumentsDirectory();
Isar db = await Isar.open( Isar db = await Isar.open(
[StoreValueSchema], [
StoreValueSchema,
ExifInfoSchema,
AssetSchema,
AlbumSchema,
UserSchema,
],
directory: dir.path, directory: dir.path,
maxSizeMiB: 256, maxSizeMiB: 256,
); );

View File

@ -1,37 +1,43 @@
import 'package:collection/collection.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/album.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:isar/isar.dart';
class AlbumNotifier extends StateNotifier<List<Album>> { class AlbumNotifier extends StateNotifier<List<Album>> {
AlbumNotifier(this._albumService, this._albumCacheService) : super([]); AlbumNotifier(this._albumService, this._db) : super([]);
final AlbumService _albumService; final AlbumService _albumService;
final AlbumCacheService _albumCacheService; final Isar _db;
void _cacheState() {
_albumCacheService.put(state);
}
Future<void> getAllAlbums() async { Future<void> getAllAlbums() async {
if (await _albumCacheService.isValid() && state.isEmpty) { final User me = Store.get(StoreKey.currentUser);
final albums = await _albumCacheService.get(); List<Album> albums = await _db.albums
if (albums != null) { .filter()
state = albums; .owner((q) => q.isarIdEqualTo(me.isarId))
} .findAll();
} if (!const ListEquality().equals(albums, state)) {
state = albums;
final albums = await _albumService.getAlbums(isShared: false); }
await Future.wait([
if (albums != null) { _albumService.refreshDeviceAlbums(),
_albumService.refreshRemoteAlbums(isShared: false),
]);
albums = await _db.albums
.filter()
.owner((q) => q.isarIdEqualTo(me.isarId))
.findAll();
if (!const ListEquality().equals(albums, state)) {
state = albums; state = albums;
_cacheState();
} }
} }
void deleteAlbum(Album album) { Future<bool> deleteAlbum(Album album) async {
state = state.where((a) => a.id != album.id).toList(); state = state.where((a) => a.id != album.id).toList();
_cacheState(); return _albumService.deleteAlbum(album);
} }
Future<Album?> createAlbum( Future<Album?> createAlbum(
@ -39,20 +45,16 @@ class AlbumNotifier extends StateNotifier<List<Album>> {
Set<Asset> assets, Set<Asset> assets,
) async { ) async {
Album? album = await _albumService.createAlbum(albumTitle, assets, []); Album? album = await _albumService.createAlbum(albumTitle, assets, []);
if (album != null) { if (album != null) {
state = [...state, album]; state = [...state, album];
_cacheState();
return album;
} }
return null; return album;
} }
} }
final albumProvider = StateNotifierProvider<AlbumNotifier, List<Album>>((ref) { final albumProvider = StateNotifierProvider<AlbumNotifier, List<Album>>((ref) {
return AlbumNotifier( return AlbumNotifier(
ref.watch(albumServiceProvider), ref.watch(albumServiceProvider),
ref.watch(albumCacheServiceProvider), ref.watch(dbProvider),
); );
}); });

View File

@ -58,7 +58,7 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
); );
} }
void addNewAssets(List<Asset> assets) { void addNewAssets(Iterable<Asset> assets) {
state = state.copyWith( state = state.copyWith(
selectedNewAssetsForAlbum: { selectedNewAssetsForAlbum: {
...state.selectedNewAssetsForAlbum, ...state.selectedNewAssetsForAlbum,

View File

@ -1,21 +1,18 @@
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:isar/isar.dart';
class SharedAlbumNotifier extends StateNotifier<List<Album>> { class SharedAlbumNotifier extends StateNotifier<List<Album>> {
SharedAlbumNotifier(this._albumService, this._sharedAlbumCacheService) SharedAlbumNotifier(this._albumService, this._db) : super([]);
: super([]);
final AlbumService _albumService; final AlbumService _albumService;
final SharedAlbumCacheService _sharedAlbumCacheService; final Isar _db;
void _cacheState() {
_sharedAlbumCacheService.put(state);
}
Future<Album?> createSharedAlbum( Future<Album?> createSharedAlbum(
String albumName, String albumName,
@ -23,7 +20,7 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
Iterable<User> sharedUsers, Iterable<User> sharedUsers,
) async { ) async {
try { try {
var newAlbum = await _albumService.createAlbum( final Album? newAlbum = await _albumService.createAlbum(
albumName, albumName,
assets, assets,
sharedUsers, sharedUsers,
@ -31,61 +28,44 @@ class SharedAlbumNotifier extends StateNotifier<List<Album>> {
if (newAlbum != null) { if (newAlbum != null) {
state = [...state, newAlbum]; state = [...state, newAlbum];
_cacheState(); return newAlbum;
} }
return newAlbum;
} catch (e) { } catch (e) {
debugPrint("Error createSharedAlbum ${e.toString()}"); debugPrint("Error createSharedAlbum ${e.toString()}");
return null;
} }
return null;
} }
Future<void> getAllSharedAlbums() async { Future<void> getAllSharedAlbums() async {
if (await _sharedAlbumCacheService.isValid() && state.isEmpty) { var albums = await _db.albums.filter().sharedEqualTo(true).findAll();
final albums = await _sharedAlbumCacheService.get(); if (!const ListEquality().equals(albums, state)) {
if (albums != null) { state = albums;
state = albums;
}
} }
await _albumService.refreshRemoteAlbums(isShared: true);
List<Album>? sharedAlbums = await _albumService.getAlbums(isShared: true); albums = await _db.albums.filter().sharedEqualTo(true).findAll();
if (!const ListEquality().equals(albums, state)) {
if (sharedAlbums != null) { state = albums;
state = sharedAlbums;
_cacheState();
} }
} }
void deleteAlbum(Album album) { Future<bool> deleteAlbum(Album album) {
state = state.where((a) => a.id != album.id).toList(); state = state.where((a) => a.id != album.id).toList();
_cacheState(); return _albumService.deleteAlbum(album);
} }
Future<bool> leaveAlbum(Album album) async { Future<bool> leaveAlbum(Album album) async {
var res = await _albumService.leaveAlbum(album); var res = await _albumService.leaveAlbum(album);
if (res) { if (res) {
state = state.where((a) => a.id != album.id).toList(); await deleteAlbum(album);
_cacheState();
return true; return true;
} else { } else {
return false; return false;
} }
} }
Future<bool> removeAssetFromAlbum( Future<bool> removeAssetFromAlbum(Album album, Iterable<Asset> assets) {
Album album, return _albumService.removeAssetFromAlbum(album, assets);
Iterable<Asset> assets,
) async {
var res = await _albumService.removeAssetFromAlbum(album, assets);
if (res) {
return true;
} else {
return false;
}
} }
} }
@ -93,13 +73,15 @@ final sharedAlbumProvider =
StateNotifierProvider<SharedAlbumNotifier, List<Album>>((ref) { StateNotifierProvider<SharedAlbumNotifier, List<Album>>((ref) {
return SharedAlbumNotifier( return SharedAlbumNotifier(
ref.watch(albumServiceProvider), ref.watch(albumServiceProvider),
ref.watch(sharedAlbumCacheServiceProvider), ref.watch(dbProvider),
); );
}); });
final sharedAlbumDetailProvider = final sharedAlbumDetailProvider =
FutureProvider.autoDispose.family<Album?, String>((ref, albumId) async { FutureProvider.autoDispose.family<Album?, int>((ref, albumId) async {
final AlbumService sharedAlbumService = ref.watch(albumServiceProvider); final AlbumService sharedAlbumService = ref.watch(albumServiceProvider);
return await sharedAlbumService.getAlbumDetail(albumId); final Album? a = await sharedAlbumService.getAlbumDetail(albumId);
await a?.loadSortedAssets();
return a;
}); });

View File

@ -3,8 +3,8 @@ import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/services/user.service.dart'; import 'package:immich_mobile/shared/services/user.service.dart';
final suggestedSharedUsersProvider = final suggestedSharedUsersProvider =
FutureProvider.autoDispose<List<User>>((ref) async { FutureProvider.autoDispose<List<User>>((ref) {
UserService userService = ref.watch(userServiceProvider); UserService userService = ref.watch(userServiceProvider);
return await userService.getAllUsers(isAll: false) ?? []; return userService.getUsersInDb();
}); });

View File

@ -1,34 +1,129 @@
import 'dart:async'; import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/shared/services/sync.service.dart';
import 'package:immich_mobile/shared/services/user.service.dart';
import 'package:isar/isar.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
final albumServiceProvider = Provider( final albumServiceProvider = Provider(
(ref) => AlbumService( (ref) => AlbumService(
ref.watch(apiServiceProvider), ref.watch(apiServiceProvider),
ref.watch(userServiceProvider),
ref.watch(backgroundServiceProvider),
ref.watch(syncServiceProvider),
ref.watch(dbProvider),
), ),
); );
class AlbumService { class AlbumService {
final ApiService _apiService; final ApiService _apiService;
final UserService _userService;
final BackgroundService _backgroundService;
final SyncService _syncService;
final Isar _db;
Completer<bool> _localCompleter = Completer()..complete(false);
Completer<bool> _remoteCompleter = Completer()..complete(false);
AlbumService(this._apiService); AlbumService(
this._apiService,
this._userService,
this._backgroundService,
this._syncService,
this._db,
);
Future<List<Album>?> getAlbums({required bool isShared}) async { /// Checks all selected device albums for changes of albums and their assets
try { /// Updates the local database and returns `true` if there were any changes
final dto = await _apiService.albumApi Future<bool> refreshDeviceAlbums() async {
.getAllAlbums(shared: isShared ? isShared : null); if (!_localCompleter.isCompleted) {
return dto?.map(Album.remote).toList(); // guard against concurrent calls
} catch (e) { return _localCompleter.future;
debugPrint("Error getAllSharedAlbum ${e.toString()}");
return null;
} }
_localCompleter = Completer();
final Stopwatch sw = Stopwatch()..start();
bool changes = false;
try {
if (!await _backgroundService.hasAccess) {
return false;
}
final HiveBackupAlbums? infos =
(await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox))
.get(backupInfoKey);
if (infos == null) {
return false;
}
final List<AssetPathEntity> onDevice =
await PhotoManager.getAssetPathList(
hasAll: true,
filterOption: FilterOptionGroup(containsPathModified: true),
);
if (infos.excludedAlbumsIds.isNotEmpty) {
// remove all excluded albums
onDevice.removeWhere((e) => infos.excludedAlbumsIds.contains(e.id));
}
final hasAll = infos.selectedAlbumIds
.map((id) => onDevice.firstWhereOrNull((a) => a.id == id))
.whereNotNull()
.any((a) => a.isAll);
if (hasAll) {
// remove the virtual "Recents" album and keep and individual albums
onDevice.removeWhere((e) => e.isAll);
} else {
// keep only the explicitly selected albums
onDevice.removeWhere((e) => !infos.selectedAlbumIds.contains(e.id));
}
changes = await _syncService.syncLocalAlbumAssetsToDb(onDevice);
} finally {
_localCompleter.complete(changes);
}
debugPrint("refreshDeviceAlbums took ${sw.elapsedMilliseconds}ms");
return changes;
}
/// Checks remote albums (owned if `isShared` is false) for changes,
/// updates the local database and returns `true` if there were any changes
Future<bool> refreshRemoteAlbums({required bool isShared}) async {
if (!_remoteCompleter.isCompleted) {
// guard against concurrent calls
return _remoteCompleter.future;
}
_remoteCompleter = Completer();
final Stopwatch sw = Stopwatch()..start();
bool changes = false;
try {
await _userService.refreshUsers();
final List<AlbumResponseDto>? serverAlbums = await _apiService.albumApi
.getAllAlbums(shared: isShared ? true : null);
if (serverAlbums == null) {
return false;
}
changes = await _syncService.syncRemoteAlbumsToDb(
serverAlbums,
isShared: isShared,
loadDetails: (dto) async => dto.assetCount == dto.assets.length
? dto
: (await _apiService.albumApi.getAlbumInfo(dto.id)) ?? dto,
);
} finally {
_remoteCompleter.complete(changes);
}
debugPrint("refreshRemoteAlbums took ${sw.elapsedMilliseconds}ms");
return changes;
} }
Future<Album?> createAlbum( Future<Album?> createAlbum(
@ -37,56 +132,51 @@ class AlbumService {
Iterable<User> sharedUsers = const [], Iterable<User> sharedUsers = const [],
]) async { ]) async {
try { try {
final dto = await _apiService.albumApi.createAlbum( AlbumResponseDto? remote = await _apiService.albumApi.createAlbum(
CreateAlbumDto( CreateAlbumDto(
albumName: albumName, albumName: albumName,
assetIds: assets.map((asset) => asset.remoteId!).toList(), assetIds: assets.map((asset) => asset.remoteId!).toList(),
sharedWithUserIds: sharedUsers.map((e) => e.id).toList(), sharedWithUserIds: sharedUsers.map((e) => e.id).toList(),
), ),
); );
return dto != null ? Album.remote(dto) : null; if (remote != null) {
Album album = await Album.remote(remote);
await _db.writeTxn(() => _db.albums.store(album));
return album;
}
} catch (e) { } catch (e) {
debugPrint("Error createSharedAlbum ${e.toString()}"); debugPrint("Error createSharedAlbum ${e.toString()}");
return null;
} }
return null;
} }
/* /*
* Creates names like Untitled, Untitled (1), Untitled (2), ... * Creates names like Untitled, Untitled (1), Untitled (2), ...
*/ */
String _getNextAlbumName(List<Album>? albums) { Future<String> _getNextAlbumName() async {
const baseName = "Untitled"; const baseName = "Untitled";
for (int round = 0;; round++) {
final proposedName = "$baseName${round == 0 ? "" : " ($round)"}";
if (albums != null) { if (null ==
for (int round = 0; round < albums.length; round++) { await _db.albums.filter().nameEqualTo(proposedName).findFirst()) {
final proposedName = "$baseName${round == 0 ? "" : " ($round)"}"; return proposedName;
if (albums.where((a) => a.name == proposedName).isEmpty) {
return proposedName;
}
} }
} }
return baseName;
} }
Future<Album?> createAlbumWithGeneratedName( Future<Album?> createAlbumWithGeneratedName(
Iterable<Asset> assets, Iterable<Asset> assets,
) async { ) async {
return createAlbum( return createAlbum(
_getNextAlbumName(await getAlbums(isShared: false)), await _getNextAlbumName(),
assets, assets,
[], [],
); );
} }
Future<Album?> getAlbumDetail(String albumId) async { Future<Album?> getAlbumDetail(int albumId) {
try { return _db.albums.get(albumId);
final dto = await _apiService.albumApi.getAlbumInfo(albumId);
return dto != null ? Album.remote(dto) : null;
} catch (e) {
debugPrint('Error [getAlbumDetail] ${e.toString()}');
return null;
}
} }
Future<AddAssetsResponseDto?> addAdditionalAssetToAlbum( Future<AddAssetsResponseDto?> addAdditionalAssetToAlbum(
@ -98,6 +188,10 @@ class AlbumService {
album.remoteId!, album.remoteId!,
AddAssetsDto(assetIds: assets.map((asset) => asset.remoteId!).toList()), AddAssetsDto(assetIds: assets.map((asset) => asset.remoteId!).toList()),
); );
if (result != null && result.successfullyAdded > 0) {
album.assets.addAll(assets);
await _db.writeTxn(() => album.assets.save());
}
return result; return result;
} catch (e) { } catch (e) {
debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}"); debugPrint("Error addAdditionalAssetToAlbum ${e.toString()}");
@ -110,26 +204,53 @@ class AlbumService {
Album album, Album album,
) async { ) async {
try { try {
var result = await _apiService.albumApi.addUsersToAlbum( final result = await _apiService.albumApi.addUsersToAlbum(
album.remoteId!, album.remoteId!,
AddUsersDto(sharedUserIds: sharedUserIds), AddUsersDto(sharedUserIds: sharedUserIds),
); );
if (result != null) {
return result != null; album.sharedUsers
.addAll((await _db.users.getAllById(sharedUserIds)).cast());
await _db.writeTxn(() => album.sharedUsers.save());
return true;
}
} catch (e) { } catch (e) {
debugPrint("Error addAdditionalUserToAlbum ${e.toString()}"); debugPrint("Error addAdditionalUserToAlbum ${e.toString()}");
return false;
} }
return false;
} }
Future<bool> deleteAlbum(Album album) async { Future<bool> deleteAlbum(Album album) async {
try { try {
await _apiService.albumApi.deleteAlbum(album.remoteId!); final userId = Store.get<User>(StoreKey.currentUser)!.isarId;
if (album.owner.value?.isarId == userId) {
await _apiService.albumApi.deleteAlbum(album.remoteId!);
}
if (album.shared) {
final foreignAssets =
await album.assets.filter().not().ownerIdEqualTo(userId).findAll();
await _db.writeTxn(() => _db.albums.delete(album.id));
final List<Album> albums =
await _db.albums.filter().sharedEqualTo(true).findAll();
final List<Asset> existing = [];
for (Album a in albums) {
existing.addAll(
await a.assets.filter().not().ownerIdEqualTo(userId).findAll(),
);
}
final List<int> idsToRemove =
_syncService.sharedAssetsToRemove(foreignAssets, existing);
if (idsToRemove.isNotEmpty) {
await _db.writeTxn(() => _db.assets.deleteAll(idsToRemove));
}
} else {
await _db.writeTxn(() => _db.albums.delete(album.id));
}
return true; return true;
} catch (e) { } catch (e) {
debugPrint("Error deleteAlbum ${e.toString()}"); debugPrint("Error deleteAlbum ${e.toString()}");
return false;
} }
return false;
} }
Future<bool> leaveAlbum(Album album) async { Future<bool> leaveAlbum(Album album) async {
@ -153,6 +274,8 @@ class AlbumService {
assetIds: assets.map((e) => e.remoteId!).toList(growable: false), assetIds: assets.map((e) => e.remoteId!).toList(growable: false),
), ),
); );
album.assets.removeAll(assets);
await _db.writeTxn(() => album.assets.update(unlink: assets));
return true; return true;
} catch (e) { } catch (e) {
@ -173,6 +296,7 @@ class AlbumService {
), ),
); );
album.name = newAlbumTitle; album.name = newAlbumTitle;
await _db.writeTxn(() => _db.albums.put(album));
return true; return true;
} catch (e) { } catch (e) {

View File

@ -1,46 +1,23 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/services/json_cache.dart'; import 'package:immich_mobile/shared/services/json_cache.dart';
class BaseAlbumCacheService extends JsonCache<List<Album>> { @Deprecated("only kept to remove its files after migration")
BaseAlbumCacheService(super.cacheFileName); class _BaseAlbumCacheService extends JsonCache<List<Album>> {
_BaseAlbumCacheService(super.cacheFileName);
@override @override
void put(List<Album> data) { void put(List<Album> data) {}
putRawData(data.map((e) => e.toJson()).toList());
}
@override @override
Future<List<Album>?> get() async { Future<List<Album>?> get() => Future.value(null);
try {
final mapList = await readRawData() as List<dynamic>;
final responseData =
mapList.map((e) => Album.fromJson(e)).whereNotNull().toList();
return responseData;
} catch (e) {
await invalidate();
debugPrint(e.toString());
return null;
}
}
} }
class AlbumCacheService extends BaseAlbumCacheService { @Deprecated("only kept to remove its files after migration")
class AlbumCacheService extends _BaseAlbumCacheService {
AlbumCacheService() : super("album_cache"); AlbumCacheService() : super("album_cache");
} }
class SharedAlbumCacheService extends BaseAlbumCacheService { @Deprecated("only kept to remove its files after migration")
class SharedAlbumCacheService extends _BaseAlbumCacheService {
SharedAlbumCacheService() : super("shared_album_cache"); SharedAlbumCacheService() : super("shared_album_cache");
} }
final albumCacheServiceProvider = Provider(
(ref) => AlbumCacheService(),
);
final sharedAlbumCacheServiceProvider = Provider(
(ref) => SharedAlbumCacheService(),
);

View File

@ -25,7 +25,7 @@ class AddToAlbumBottomSheet extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final albums = ref.watch(albumProvider); final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
final albumService = ref.watch(albumServiceProvider); final albumService = ref.watch(albumServiceProvider);
final sharedAlbums = ref.watch(sharedAlbumProvider); final sharedAlbums = ref.watch(sharedAlbumProvider);

View File

@ -1,11 +1,7 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart';
import 'package:openapi/api.dart';
class AlbumThumbnailCard extends StatelessWidget { class AlbumThumbnailCard extends StatelessWidget {
final Function()? onTap; final Function()? onTap;
@ -20,7 +16,6 @@ class AlbumThumbnailCard extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
var box = Hive.box(userInfoBox);
var isDarkMode = Theme.of(context).brightness == Brightness.dark; var isDarkMode = Theme.of(context).brightness == Brightness.dark;
return LayoutBuilder( return LayoutBuilder(
builder: (context, constraints) { builder: (context, constraints) {
@ -42,21 +37,11 @@ class AlbumThumbnailCard extends StatelessWidget {
); );
} }
buildAlbumThumbnail() { buildAlbumThumbnail() => ImmichImage(
return CachedNetworkImage( album.thumbnail.value,
width: cardSize, width: cardSize,
height: cardSize, height: cardSize,
fit: BoxFit.cover, );
fadeInDuration: const Duration(milliseconds: 200),
imageUrl: getAlbumThumbnailUrl(
album,
type: ThumbnailFormat.JPEG,
),
httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
cacheKey:
getAlbumThumbNailCacheKey(album, type: ThumbnailFormat.JPEG),
);
}
return GestureDetector( return GestureDetector(
onTap: onTap, onTap: onTap,
@ -72,7 +57,7 @@ class AlbumThumbnailCard extends StatelessWidget {
height: cardSize, height: cardSize,
child: ClipRRect( child: ClipRRect(
borderRadius: BorderRadius.circular(20), borderRadius: BorderRadius.circular(20),
child: album.albumThumbnailAssetId == null child: album.thumbnail.value == null
? buildEmptyThumbnail() ? buildEmptyThumbnail()
: buildAlbumThumbnail(), : buildAlbumThumbnail(),
), ),

View File

@ -68,7 +68,7 @@ class AlbumThumbnailListTile extends StatelessWidget {
children: [ children: [
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: album.albumThumbnailAssetId == null child: album.thumbnail.value == null
? buildEmptyThumbnail() ? buildEmptyThumbnail()
: buildAlbumThumbnail(), : buildAlbumThumbnail(),
), ),

View File

@ -7,7 +7,6 @@ import 'package:immich_mobile/modules/album/providers/album.provider.dart';
import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart'; import 'package:immich_mobile/modules/album/providers/album_viewer.provider.dart';
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart'; import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart';
@ -35,19 +34,18 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
void onDeleteAlbumPressed() async { void onDeleteAlbumPressed() async {
ImmichLoadingOverlayController.appLoader.show(); ImmichLoadingOverlayController.appLoader.show();
bool isSuccess = await ref.watch(albumServiceProvider).deleteAlbum(album); final bool success;
if (album.shared) {
if (isSuccess) { success =
if (album.shared) { await ref.watch(sharedAlbumProvider.notifier).deleteAlbum(album);
ref.watch(sharedAlbumProvider.notifier).deleteAlbum(album); AutoRouter.of(context)
AutoRouter.of(context) .navigate(const TabControllerRoute(children: [SharingRoute()]));
.navigate(const TabControllerRoute(children: [SharingRoute()]));
} else {
ref.watch(albumProvider.notifier).deleteAlbum(album);
AutoRouter.of(context)
.navigate(const TabControllerRoute(children: [LibraryRoute()]));
}
} else { } else {
success = await ref.watch(albumProvider.notifier).deleteAlbum(album);
AutoRouter.of(context)
.navigate(const TabControllerRoute(children: [LibraryRoute()]));
}
if (!success) {
ImmichToast.show( ImmichToast.show(
context: context, context: context,
msg: "album_viewer_appbar_share_err_delete".tr(), msg: "album_viewer_appbar_share_err_delete".tr(),
@ -208,11 +206,12 @@ class AlbumViewerAppbar extends HookConsumerWidget with PreferredSizeWidget {
: null, : null,
centerTitle: false, centerTitle: false,
actions: [ actions: [
IconButton( if (album.isRemote)
splashRadius: 25, IconButton(
onPressed: buildBottomSheet, splashRadius: 25,
icon: const Icon(Icons.more_horiz_rounded), onPressed: buildBottomSheet,
), icon: const Icon(Icons.more_horiz_rounded),
),
], ],
); );
} }

View File

@ -2,7 +2,6 @@ import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart'; import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart'; import 'package:immich_mobile/modules/album/providers/asset_selection.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';
@ -22,7 +21,6 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
var deviceId = ref.watch(authenticationProvider).deviceId;
final selectedAssetsInAlbumViewer = final selectedAssetsInAlbumViewer =
ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer; ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer;
final isMultiSelectionEnable = final isMultiSelectionEnable =
@ -88,7 +86,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
bottom: 5, bottom: 5,
child: Icon( child: Icon(
asset.isRemote asset.isRemote
? (deviceId == asset.deviceId ? (asset.isLocal
? Icons.cloud_done_outlined ? Icons.cloud_done_outlined
: Icons.cloud_outlined) : Icons.cloud_outlined)
: Icons.cloud_off_outlined, : Icons.cloud_off_outlined,

View File

@ -25,7 +25,7 @@ import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegat
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart'; import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
class AlbumViewerPage extends HookConsumerWidget { class AlbumViewerPage extends HookConsumerWidget {
final String albumId; final int albumId;
const AlbumViewerPage({Key? key, required this.albumId}) : super(key: key); const AlbumViewerPage({Key? key, required this.albumId}) : super(key: key);
@ -101,7 +101,7 @@ class AlbumViewerPage extends HookConsumerWidget {
Widget buildTitle(Album album) { Widget buildTitle(Album album) {
return Padding( return Padding(
padding: const EdgeInsets.only(left: 8, right: 8, top: 16), padding: const EdgeInsets.only(left: 8, right: 8, top: 16),
child: userId == album.ownerId child: userId == album.ownerId && album.isRemote
? AlbumViewerEditableTitle( ? AlbumViewerEditableTitle(
album: album, album: album,
titleFocusNode: titleFocusNode, titleFocusNode: titleFocusNode,
@ -122,9 +122,10 @@ class AlbumViewerPage extends HookConsumerWidget {
Widget buildAlbumDateRange(Album album) { Widget buildAlbumDateRange(Album album) {
final DateTime startDate = album.assets.first.fileCreatedAt; final DateTime startDate = album.assets.first.fileCreatedAt;
final DateTime endDate = album.assets.last.fileCreatedAt; //Need default. final DateTime endDate = album.assets.last.fileCreatedAt; //Need default.
final String startDateText = final String startDateText = (startDate.year == endDate.year
(startDate.year == endDate.year ? DateFormat.MMMd() : DateFormat.yMMMd()) ? DateFormat.MMMd()
.format(startDate); : DateFormat.yMMMd())
.format(startDate);
final String endDateText = DateFormat.yMMMd().format(endDate); final String endDateText = DateFormat.yMMMd().format(endDate);
return Padding( return Padding(
@ -188,7 +189,7 @@ class AlbumViewerPage extends HookConsumerWidget {
final bool showStorageIndicator = final bool showStorageIndicator =
appSettingService.getSetting(AppSettingsEnum.storageIndicator); appSettingService.getSetting(AppSettingsEnum.storageIndicator);
if (album.assets.isNotEmpty) { if (album.sortedAssets.isNotEmpty) {
return SliverPadding( return SliverPadding(
padding: const EdgeInsets.only(top: 10.0), padding: const EdgeInsets.only(top: 10.0),
sliver: SliverGrid( sliver: SliverGrid(
@ -201,8 +202,8 @@ class AlbumViewerPage extends HookConsumerWidget {
delegate: SliverChildBuilderDelegate( delegate: SliverChildBuilderDelegate(
(BuildContext context, int index) { (BuildContext context, int index) {
return AlbumViewerThumbnail( return AlbumViewerThumbnail(
asset: album.assets[index], asset: album.sortedAssets[index],
assetList: album.assets, assetList: album.sortedAssets,
showStorageIndicator: showStorageIndicator, showStorageIndicator: showStorageIndicator,
); );
}, },
@ -267,17 +268,18 @@ class AlbumViewerPage extends HookConsumerWidget {
controller: scrollController, controller: scrollController,
slivers: [ slivers: [
buildHeader(album), buildHeader(album),
SliverPersistentHeader( if (album.isRemote)
pinned: true, SliverPersistentHeader(
delegate: ImmichSliverPersistentAppBarDelegate( pinned: true,
minHeight: 50, delegate: ImmichSliverPersistentAppBarDelegate(
maxHeight: 50, minHeight: 50,
child: Container( maxHeight: 50,
color: Theme.of(context).scaffoldBackgroundColor, child: Container(
child: buildControlButton(album), color: Theme.of(context).scaffoldBackgroundColor,
child: buildControlButton(album),
),
), ),
), ),
),
SliverSafeArea( SliverSafeArea(
sliver: buildImageGrid(album), sliver: buildImageGrid(album),
), ),

View File

@ -44,9 +44,13 @@ class LibraryPage extends HookConsumerWidget {
List<Album> sortedAlbums() { List<Album> sortedAlbums() {
if (selectedAlbumSortOrder.value == 0) { if (selectedAlbumSortOrder.value == 0) {
return albums.sortedBy((album) => album.createdAt).reversed.toList(); return albums
.where((a) => a.isRemote)
.sortedBy((album) => album.createdAt)
.reversed
.toList();
} }
return albums.sortedBy((album) => album.name); return albums.where((a) => a.isRemote).sortedBy((album) => album.name);
} }
Widget buildSortButton() { Widget buildSortButton() {
@ -194,6 +198,8 @@ class LibraryPage extends HookConsumerWidget {
final sorted = sortedAlbums(); final sorted = sortedAlbums();
final local = albums.where((a) => a.isLocal).toList();
return Scaffold( return Scaffold(
appBar: buildAppBar(), appBar: buildAppBar(),
body: CustomScrollView( body: CustomScrollView(
@ -270,6 +276,47 @@ class LibraryPage extends HookConsumerWidget {
), ),
), ),
), ),
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.only(
top: 12.0,
left: 12.0,
right: 12.0,
bottom: 20.0,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'library_page_device_albums',
style: TextStyle(fontWeight: FontWeight.bold),
).tr(),
],
),
),
),
SliverPadding(
padding: const EdgeInsets.all(12.0),
sliver: SliverGrid(
gridDelegate: const SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 250,
mainAxisSpacing: 12,
crossAxisSpacing: 12,
childAspectRatio: .7,
),
delegate: SliverChildBuilderDelegate(
childCount: local.length,
(context, index) => AlbumThumbnailCard(
album: local[index],
onTap: () => AutoRouter.of(context).push(
AlbumViewerRoute(
albumId: local[index].id,
),
),
),
),
),
),
], ],
), ),
); );

View File

@ -1,23 +1,19 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:easy_localization/easy_localization.dart'; import 'package:easy_localization/easy_localization.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart'; import 'package:immich_mobile/modules/album/providers/shared_album.provider.dart';
import 'package:immich_mobile/modules/album/ui/sharing_sliver_appbar.dart'; import 'package:immich_mobile/modules/album/ui/sharing_sliver_appbar.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/album.dart'; import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/utils/image_url_builder.dart'; import 'package:immich_mobile/shared/ui/immich_image.dart';
class SharingPage extends HookConsumerWidget { class SharingPage extends HookConsumerWidget {
const SharingPage({Key? key}) : super(key: key); const SharingPage({Key? key}) : super(key: key);
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
var box = Hive.box(userInfoBox);
final List<Album> sharedAlbums = ref.watch(sharedAlbumProvider); final List<Album> sharedAlbums = ref.watch(sharedAlbumProvider);
useEffect( useEffect(
@ -39,16 +35,10 @@ class SharingPage extends HookConsumerWidget {
const EdgeInsets.symmetric(vertical: 12, horizontal: 12), const EdgeInsets.symmetric(vertical: 12, horizontal: 12),
leading: ClipRRect( leading: ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(8),
child: CachedNetworkImage( child: ImmichImage(
album.thumbnail.value,
width: 60, width: 60,
height: 60, height: 60,
fit: BoxFit.cover,
imageUrl: getAlbumThumbnailUrl(album),
cacheKey: getAlbumThumbNailCacheKey(album),
httpHeaders: {
"Authorization": "Bearer ${box.get(accessTokenKey)}"
},
fadeInDuration: const Duration(milliseconds: 200),
), ),
), ),
title: Text( title: Text(

View File

@ -14,10 +14,14 @@ class ExifBottomSheet extends HookConsumerWidget {
const ExifBottomSheet({Key? key, required this.assetDetail}) const ExifBottomSheet({Key? key, required this.assetDetail})
: super(key: key); : super(key: key);
bool get showMap => assetDetail.latitude != null && assetDetail.longitude != null; bool get showMap =>
assetDetail.exifInfo?.latitude != null &&
assetDetail.exifInfo?.longitude != null;
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
final ExifInfo? exifInfo = assetDetail.exifInfo;
buildMap() { buildMap() {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(vertical: 16.0), padding: const EdgeInsets.symmetric(vertical: 16.0),
@ -33,8 +37,8 @@ class ExifBottomSheet extends HookConsumerWidget {
options: MapOptions( options: MapOptions(
interactiveFlags: InteractiveFlag.none, interactiveFlags: InteractiveFlag.none,
center: LatLng( center: LatLng(
assetDetail.latitude ?? 0, exifInfo?.latitude ?? 0,
assetDetail.longitude ?? 0, exifInfo?.longitude ?? 0,
), ),
zoom: 16.0, zoom: 16.0,
), ),
@ -55,8 +59,8 @@ class ExifBottomSheet extends HookConsumerWidget {
Marker( Marker(
anchorPos: AnchorPos.align(AnchorAlign.top), anchorPos: AnchorPos.align(AnchorAlign.top),
point: LatLng( point: LatLng(
assetDetail.latitude ?? 0, exifInfo?.latitude ?? 0,
assetDetail.longitude ?? 0, exifInfo?.longitude ?? 0,
), ),
builder: (ctx) => const Image( builder: (ctx) => const Image(
image: AssetImage('assets/location-pin.png'), image: AssetImage('assets/location-pin.png'),
@ -74,8 +78,6 @@ class ExifBottomSheet extends HookConsumerWidget {
final textColor = Theme.of(context).primaryColor; final textColor = Theme.of(context).primaryColor;
ExifInfo? exifInfo = assetDetail.exifInfo;
buildLocationText() { buildLocationText() {
return Text( return Text(
"${exifInfo?.city}, ${exifInfo?.state}", "${exifInfo?.city}, ${exifInfo?.state}",
@ -134,7 +136,7 @@ class ExifBottomSheet extends HookConsumerWidget {
exifInfo.state != null) exifInfo.state != null)
buildLocationText(), buildLocationText(),
Text( Text(
"${assetDetail.latitude!.toStringAsFixed(4)}, ${assetDetail.longitude!.toStringAsFixed(4)}", "${exifInfo!.latitude!.toStringAsFixed(4)}, ${exifInfo.longitude!.toStringAsFixed(4)}",
style: const TextStyle(fontSize: 12), style: const TextStyle(fontSize: 12),
) )
], ],

View File

@ -75,15 +75,11 @@ class GalleryViewerPage extends HookConsumerWidget {
ref.watch(favoriteProvider.notifier).toggleFavorite(asset); ref.watch(favoriteProvider.notifier).toggleFavorite(asset);
} }
getAssetExif() async { void getAssetExif() async {
if (assetList[indexOfAsset.value].isRemote) { assetDetail = assetList[indexOfAsset.value];
assetDetail = await ref assetDetail = await ref
.watch(assetServiceProvider) .watch(assetServiceProvider)
.getAssetById(assetList[indexOfAsset.value].id); .loadExif(assetList[indexOfAsset.value]);
} else {
// TODO local exif parsing?
assetDetail = assetList[indexOfAsset.value];
}
} }
/// Thumbnail image of a remote asset. Required asset.isRemote /// Thumbnail image of a remote asset. Required asset.isRemote

View File

@ -2,7 +2,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart';
class FavoriteSelectionNotifier extends StateNotifier<Set<String>> { class FavoriteSelectionNotifier extends StateNotifier<Set<int>> {
FavoriteSelectionNotifier(this.assetsState, this.assetNotifier) : super({}) { FavoriteSelectionNotifier(this.assetsState, this.assetNotifier) : super({}) {
state = assetsState.allAssets state = assetsState.allAssets
.where((asset) => asset.isFavorite) .where((asset) => asset.isFavorite)
@ -13,7 +13,7 @@ class FavoriteSelectionNotifier extends StateNotifier<Set<String>> {
final AssetsState assetsState; final AssetsState assetsState;
final AssetNotifier assetNotifier; final AssetNotifier assetNotifier;
void _setFavoriteForAssetId(String id, bool favorite) { void _setFavoriteForAssetId(int id, bool favorite) {
if (!favorite) { if (!favorite) {
state = state.difference({id}); state = state.difference({id});
} else { } else {
@ -21,7 +21,7 @@ class FavoriteSelectionNotifier extends StateNotifier<Set<String>> {
} }
} }
bool _isFavorite(String id) { bool _isFavorite(int id) {
return state.contains(id); return state.contains(id);
} }
@ -38,22 +38,22 @@ class FavoriteSelectionNotifier extends StateNotifier<Set<String>> {
Future<void> addToFavorites(Iterable<Asset> assets) { Future<void> addToFavorites(Iterable<Asset> assets) {
state = state.union(assets.map((a) => a.id).toSet()); state = state.union(assets.map((a) => a.id).toSet());
final futures = assets.map((a) => final futures = assets.map(
assetNotifier.toggleFavorite( (a) => assetNotifier.toggleFavorite(
a, a,
true, true,
), ),
); );
return Future.wait(futures); return Future.wait(futures);
} }
} }
final favoriteProvider = final favoriteProvider =
StateNotifierProvider<FavoriteSelectionNotifier, Set<String>>((ref) { StateNotifierProvider<FavoriteSelectionNotifier, Set<int>>((ref) {
return FavoriteSelectionNotifier( return FavoriteSelectionNotifier(
ref.watch(assetProvider), ref.watch(assetProvider),
ref.watch(assetProvider.notifier), ref.watch(assetProvider.notifier),
); );
}); });

View File

@ -23,7 +23,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
ItemPositionsListener.create(); ItemPositionsListener.create();
bool _scrolling = false; bool _scrolling = false;
final Set<String> _selectedAssets = HashSet(); final Set<int> _selectedAssets = HashSet();
Set<Asset> _getSelectedAssets() { Set<Asset> _getSelectedAssets() {
return _selectedAssets return _selectedAssets

View File

@ -3,7 +3,6 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart'; import 'package:immich_mobile/modules/favorite/providers/favorite_provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.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';
@ -32,8 +31,6 @@ class ThumbnailImage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
var deviceId = ref.watch(authenticationProvider).deviceId;
Widget buildSelectionIcon(Asset asset) { Widget buildSelectionIcon(Asset asset) {
if (isSelected) { if (isSelected) {
return Icon( return Icon(
@ -103,7 +100,7 @@ class ThumbnailImage extends HookConsumerWidget {
bottom: 5, bottom: 5,
child: Icon( child: Icon(
asset.isRemote asset.isRemote
? (deviceId == asset.deviceId ? (asset.isLocal
? Icons.cloud_done_outlined ? Icons.cloud_done_outlined
: Icons.cloud_outlined) : Icons.cloud_outlined)
: Icons.cloud_off_outlined, : Icons.cloud_off_outlined,

View File

@ -38,7 +38,7 @@ class HomePage extends HookConsumerWidget {
final selectionEnabledHook = useState(false); final selectionEnabledHook = useState(false);
final selection = useState(<Asset>{}); final selection = useState(<Asset>{});
final albums = ref.watch(albumProvider); final albums = ref.watch(albumProvider).where((a) => a.isRemote).toList();
final sharedAlbums = ref.watch(sharedAlbumProvider); final sharedAlbums = ref.watch(sharedAlbumProvider);
final albumService = ref.watch(albumServiceProvider); final albumService = ref.watch(albumServiceProvider);

View File

@ -3,15 +3,15 @@ import 'package:flutter/services.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/services/asset_cache.service.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart'; import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart'; import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart'; import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/shared/services/device_info.service.dart'; import 'package:immich_mobile/shared/services/device_info.service.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
class AuthenticationNotifier extends StateNotifier<AuthenticationState> { class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
@ -19,9 +19,6 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
this._deviceInfoService, this._deviceInfoService,
this._backupService, this._backupService,
this._apiService, this._apiService,
this._assetCacheService,
this._albumCacheService,
this._sharedAlbumCacheService,
) : super( ) : super(
AuthenticationState( AuthenticationState(
deviceId: "", deviceId: "",
@ -48,9 +45,6 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
final DeviceInfoService _deviceInfoService; final DeviceInfoService _deviceInfoService;
final BackupService _backupService; final BackupService _backupService;
final ApiService _apiService; final ApiService _apiService;
final AssetCacheService _assetCacheService;
final AlbumCacheService _albumCacheService;
final SharedAlbumCacheService _sharedAlbumCacheService;
Future<bool> login( Future<bool> login(
String email, String email,
@ -98,9 +92,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
Hive.box(userInfoBox).delete(accessTokenKey), Hive.box(userInfoBox).delete(accessTokenKey),
Store.delete(StoreKey.assetETag), Store.delete(StoreKey.assetETag),
Store.delete(StoreKey.userRemoteId), Store.delete(StoreKey.userRemoteId),
_assetCacheService.invalidate(), Store.delete(StoreKey.currentUser),
_albumCacheService.invalidate(),
_sharedAlbumCacheService.invalidate(),
Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).delete(savedLoginInfoKey) Hive.box<HiveSavedLoginInfo>(hiveLoginInfoBox).delete(savedLoginInfoKey)
]); ]);
@ -160,7 +152,10 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
var deviceInfo = await _deviceInfoService.getDeviceInfo(); var deviceInfo = await _deviceInfoService.getDeviceInfo();
userInfoHiveBox.put(deviceIdKey, deviceInfo["deviceId"]); userInfoHiveBox.put(deviceIdKey, deviceInfo["deviceId"]);
userInfoHiveBox.put(accessTokenKey, accessToken); userInfoHiveBox.put(accessTokenKey, accessToken);
Store.put(StoreKey.deviceId, deviceInfo["deviceId"]);
Store.put(StoreKey.deviceIdHash, fastHash(deviceInfo["deviceId"]));
Store.put(StoreKey.userRemoteId, userResponseDto.id); Store.put(StoreKey.userRemoteId, userResponseDto.id);
Store.put(StoreKey.currentUser, User.fromDto(userResponseDto));
state = state.copyWith( state = state.copyWith(
isAuthenticated: true, isAuthenticated: true,
@ -218,8 +213,5 @@ final authenticationProvider =
ref.watch(deviceInfoServiceProvider), ref.watch(deviceInfoServiceProvider),
ref.watch(backupServiceProvider), ref.watch(backupServiceProvider),
ref.watch(apiServiceProvider), ref.watch(apiServiceProvider),
ref.watch(assetCacheServiceProvider),
ref.watch(albumCacheServiceProvider),
ref.watch(sharedAlbumCacheServiceProvider),
); );
}); });

View File

@ -1,8 +1,5 @@
import 'dart:convert';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:openapi/api.dart';
class SearchResultPageState { class SearchResultPageState {
final bool isLoading; final bool isLoading;
@ -31,34 +28,6 @@ class SearchResultPageState {
); );
} }
Map<String, dynamic> toMap() {
return {
'isLoading': isLoading,
'isSuccess': isSuccess,
'isError': isError,
'searchResult': searchResult.map((x) => x.toJson()).toList(),
};
}
factory SearchResultPageState.fromMap(Map<String, dynamic> map) {
return SearchResultPageState(
isLoading: map['isLoading'] ?? false,
isSuccess: map['isSuccess'] ?? false,
isError: map['isError'] ?? false,
searchResult: List.from(
map['searchResult']
.map(AssetResponseDto.fromJson)
.where((e) => e != null)
.map(Asset.remote),
),
);
}
String toJson() => json.encode(toMap());
factory SearchResultPageState.fromJson(String source) =>
SearchResultPageState.fromMap(json.decode(source));
@override @override
String toString() { String toString() {
return 'SearchresultPageState(isLoading: $isLoading, isSuccess: $isSuccess, isError: $isError, searchResult: $searchResult)'; return 'SearchresultPageState(isLoading: $isLoading, isSuccess: $isSuccess, isError: $isError, searchResult: $searchResult)';

View File

@ -2,19 +2,23 @@ import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:isar/isar.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
final searchServiceProvider = Provider( final searchServiceProvider = Provider(
(ref) => SearchService( (ref) => SearchService(
ref.watch(apiServiceProvider), ref.watch(apiServiceProvider),
ref.watch(dbProvider),
), ),
); );
class SearchService { class SearchService {
final ApiService _apiService; final ApiService _apiService;
final Isar _db;
SearchService(this._apiService); SearchService(this._apiService, this._db);
Future<List<String>?> getUserSuggestedSearchTerms() async { Future<List<String>?> getUserSuggestedSearchTerms() async {
try { try {
@ -26,13 +30,15 @@ class SearchService {
} }
Future<List<Asset>?> searchAsset(String searchTerm) async { Future<List<Asset>?> searchAsset(String searchTerm) async {
// TODO search in local DB: 1. when offline, 2. to find local assets
try { try {
final List<AssetResponseDto>? results = await _apiService.assetApi final List<AssetResponseDto>? results = await _apiService.assetApi
.searchAsset(SearchAssetDto(searchTerm: searchTerm)); .searchAsset(SearchAssetDto(searchTerm: searchTerm));
if (results == null) { if (results == null) {
return null; return null;
} }
return results.map((e) => Asset.remote(e)).toList(); // TODO local DB might be out of date; add assets not yet in DB?
return _db.assets.getAllByRemoteId(results.map((e) => e.id));
} catch (e) { } catch (e) {
debugPrint("[ERROR] [searchAsset] ${e.toString()}"); debugPrint("[ERROR] [searchAsset] ${e.toString()}");
return null; return null;

View File

@ -698,7 +698,7 @@ class SelectUserForSharingRoute extends PageRouteInfo<void> {
class AlbumViewerRoute extends PageRouteInfo<AlbumViewerRouteArgs> { class AlbumViewerRoute extends PageRouteInfo<AlbumViewerRouteArgs> {
AlbumViewerRoute({ AlbumViewerRoute({
Key? key, Key? key,
required String albumId, required int albumId,
}) : super( }) : super(
AlbumViewerRoute.name, AlbumViewerRoute.name,
path: '/album-viewer-page', path: '/album-viewer-page',
@ -719,7 +719,7 @@ class AlbumViewerRouteArgs {
final Key? key; final Key? key;
final String albumId; final int albumId;
@override @override
String toString() { String toString() {

View File

@ -1,132 +1,153 @@
import 'package:flutter/cupertino.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/models/user.dart';
import 'package:isar/isar.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
part 'album.g.dart';
@Collection(inheritance: false)
class Album { class Album {
Album.remote(AlbumResponseDto dto) @protected
: remoteId = dto.id,
name = dto.albumName,
createdAt = DateTime.parse(dto.createdAt),
// TODO add modifiedAt to server
modifiedAt = DateTime.parse(dto.createdAt),
shared = dto.shared,
ownerId = dto.ownerId,
albumThumbnailAssetId = dto.albumThumbnailAssetId,
assetCount = dto.assetCount,
sharedUsers = dto.sharedUsers.map((e) => User.fromDto(e)).toList(),
assets = dto.assets.map(Asset.remote).toList();
Album({ Album({
this.remoteId, this.remoteId,
this.localId, this.localId,
required this.name, required this.name,
required this.ownerId,
required this.createdAt, required this.createdAt,
required this.modifiedAt, required this.modifiedAt,
required this.shared, required this.shared,
required this.assetCount,
this.albumThumbnailAssetId,
this.sharedUsers = const [],
this.assets = const [],
}); });
Id id = Isar.autoIncrement;
@Index(unique: false, replace: false, type: IndexType.hash)
String? remoteId; String? remoteId;
@Index(unique: false, replace: false, type: IndexType.hash)
String? localId; String? localId;
String name; String name;
String ownerId;
DateTime createdAt; DateTime createdAt;
DateTime modifiedAt; DateTime modifiedAt;
bool shared; bool shared;
String? albumThumbnailAssetId; final IsarLink<User> owner = IsarLink<User>();
int assetCount; final IsarLink<Asset> thumbnail = IsarLink<Asset>();
List<User> sharedUsers = const []; final IsarLinks<User> sharedUsers = IsarLinks<User>();
List<Asset> assets = const []; final IsarLinks<Asset> assets = IsarLinks<Asset>();
List<Asset> _sortedAssets = [];
@ignore
List<Asset> get sortedAssets => _sortedAssets;
@ignore
bool get isRemote => remoteId != null; bool get isRemote => remoteId != null;
@ignore
bool get isLocal => localId != null; bool get isLocal => localId != null;
String get id => isRemote ? remoteId! : localId!; @ignore
int get assetCount => assets.length;
@ignore
String? get ownerId => owner.value?.id;
Future<void> loadSortedAssets() async {
_sortedAssets = await assets.filter().sortByFileCreatedAt().findAll();
}
@override @override
bool operator ==(other) { bool operator ==(other) {
if (other is! Album) return false; if (other is! Album) return false;
return remoteId == other.remoteId && return id == other.id &&
remoteId == other.remoteId &&
localId == other.localId && localId == other.localId &&
name == other.name && name == other.name &&
createdAt == other.createdAt && createdAt == other.createdAt &&
modifiedAt == other.modifiedAt && modifiedAt == other.modifiedAt &&
shared == other.shared && shared == other.shared &&
ownerId == other.ownerId && owner.value == other.owner.value &&
albumThumbnailAssetId == other.albumThumbnailAssetId; thumbnail.value == other.thumbnail.value &&
sharedUsers.length == other.sharedUsers.length &&
assets.length == other.assets.length;
} }
@override @override
@ignore
int get hashCode => int get hashCode =>
id.hashCode ^
remoteId.hashCode ^ remoteId.hashCode ^
localId.hashCode ^ localId.hashCode ^
name.hashCode ^ name.hashCode ^
createdAt.hashCode ^ createdAt.hashCode ^
modifiedAt.hashCode ^ modifiedAt.hashCode ^
shared.hashCode ^ shared.hashCode ^
ownerId.hashCode ^ owner.value.hashCode ^
albumThumbnailAssetId.hashCode; thumbnail.value.hashCode ^
sharedUsers.length.hashCode ^
assets.length.hashCode;
Map<String, dynamic> toJson() { static Album local(AssetPathEntity ape) {
final json = <String, dynamic>{}; final Album a = Album(
json["remoteId"] = remoteId; name: ape.name,
json["localId"] = localId; createdAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(),
json["name"] = name; modifiedAt: ape.lastModified?.toUtc() ?? DateTime.now().toUtc(),
json["ownerId"] = ownerId; shared: false,
json["createdAt"] = createdAt.millisecondsSinceEpoch; );
json["modifiedAt"] = modifiedAt.millisecondsSinceEpoch; a.owner.value = Store.get(StoreKey.currentUser);
json["shared"] = shared; a.localId = ape.id;
json["albumThumbnailAssetId"] = albumThumbnailAssetId; return a;
json["assetCount"] = assetCount;
json["sharedUsers"] = sharedUsers;
json["assets"] = assets;
return json;
} }
static Album? fromJson(dynamic value) { static Future<Album> remote(AlbumResponseDto dto) async {
if (value is Map) { final Isar db = Isar.getInstance()!;
final json = value.cast<String, dynamic>(); final Album a = Album(
return Album( remoteId: dto.id,
remoteId: json["remoteId"], name: dto.albumName,
localId: json["localId"], createdAt: DateTime.parse(dto.createdAt),
name: json["name"], modifiedAt: DateTime.parse(dto.updatedAt),
ownerId: json["ownerId"], shared: dto.shared,
createdAt: DateTime.fromMillisecondsSinceEpoch( );
json["createdAt"], a.owner.value = await db.users.getById(dto.ownerId);
isUtc: true, if (dto.albumThumbnailAssetId != null) {
), a.thumbnail.value = await db.assets
modifiedAt: DateTime.fromMillisecondsSinceEpoch( .where()
json["modifiedAt"], .remoteIdEqualTo(dto.albumThumbnailAssetId)
isUtc: true, .findFirst();
),
shared: json["shared"],
albumThumbnailAssetId: json["albumThumbnailAssetId"],
assetCount: json["assetCount"],
sharedUsers: _listFromJson<User>(json["sharedUsers"], User.fromJson),
assets: _listFromJson<Asset>(json["assets"], Asset.fromJson),
);
} }
return null; if (dto.sharedUsers.isNotEmpty) {
final users = await db.users
.getAllById(dto.sharedUsers.map((e) => e.id).toList(growable: false));
a.sharedUsers.addAll(users.cast());
}
if (dto.assets.isNotEmpty) {
final assets =
await db.assets.getAllByRemoteId(dto.assets.map((e) => e.id));
a.assets.addAll(assets);
}
return a;
} }
} }
List<T> _listFromJson<T>( extension AssetsHelper on IsarCollection<Album> {
dynamic json, Future<void> store(Album a) async {
T? Function(dynamic) fromJson, await put(a);
) { await a.owner.save();
final result = <T>[]; await a.thumbnail.save();
if (json is List && json.isNotEmpty) { await a.sharedUsers.save();
for (final entry in json) { await a.assets.save();
final value = fromJson(entry);
if (value != null) {
result.add(value);
}
}
} }
return result; }
extension AssetPathEntityHelper on AssetPathEntity {
Future<List<Asset>> getAssets({
int start = 0,
int end = 0x7fffffffffffffff,
}) async {
final assetEntities = await getAssetListRange(start: start, end: end);
return assetEntities.map(Asset.local).toList();
}
}
extension AlbumResponseDtoHelper on AlbumResponseDto {
List<Asset> getAssets() => assets.map(Asset.remote).toList();
} }

Binary file not shown.

View File

@ -1,60 +1,65 @@
import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/shared/models/exif_info.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/utils/hash.dart';
import 'package:isar/isar.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/utils/builtin_extensions.dart'; import 'package:immich_mobile/utils/builtin_extensions.dart';
import 'package:path/path.dart' as p; import 'package:path/path.dart' as p;
part 'asset.g.dart';
/// Asset (online or local) /// Asset (online or local)
@Collection(inheritance: false)
class Asset { class Asset {
Asset.remote(AssetResponseDto remote) Asset.remote(AssetResponseDto remote)
: remoteId = remote.id, : remoteId = remote.id,
fileCreatedAt = DateTime.parse(remote.fileCreatedAt), isLocal = false,
fileModifiedAt = DateTime.parse(remote.fileModifiedAt), fileCreatedAt = DateTime.parse(remote.fileCreatedAt).toUtc(),
fileModifiedAt = DateTime.parse(remote.fileModifiedAt).toUtc(),
updatedAt = DateTime.parse(remote.updatedAt).toUtc(),
durationInSeconds = remote.duration.toDuration().inSeconds, durationInSeconds = remote.duration.toDuration().inSeconds,
fileName = p.basename(remote.originalPath), fileName = p.basename(remote.originalPath),
height = remote.exifInfo?.exifImageHeight?.toInt(), height = remote.exifInfo?.exifImageHeight?.toInt(),
width = remote.exifInfo?.exifImageWidth?.toInt(), width = remote.exifInfo?.exifImageWidth?.toInt(),
livePhotoVideoId = remote.livePhotoVideoId, livePhotoVideoId = remote.livePhotoVideoId,
deviceAssetId = remote.deviceAssetId, localId = remote.deviceAssetId,
deviceId = remote.deviceId, deviceId = fastHash(remote.deviceId),
ownerId = remote.ownerId, ownerId = fastHash(remote.ownerId),
latitude = remote.exifInfo?.latitude?.toDouble(),
longitude = remote.exifInfo?.longitude?.toDouble(),
exifInfo = exifInfo =
remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null, remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : null,
isFavorite = remote.isFavorite; isFavorite = remote.isFavorite;
Asset.local(AssetEntity local, String owner) Asset.local(AssetEntity local)
: localId = local.id, : localId = local.id,
latitude = local.latitude, isLocal = true,
longitude = local.longitude,
durationInSeconds = local.duration, durationInSeconds = local.duration,
height = local.height, height = local.height,
width = local.width, width = local.width,
fileName = local.title!, fileName = local.title!,
deviceAssetId = local.id, deviceId = Store.get(StoreKey.deviceIdHash),
deviceId = Hive.box(userInfoBox).get(deviceIdKey), ownerId = Store.get<User>(StoreKey.currentUser)!.isarId,
ownerId = owner,
fileModifiedAt = local.modifiedDateTime.toUtc(), fileModifiedAt = local.modifiedDateTime.toUtc(),
updatedAt = local.modifiedDateTime.toUtc(),
isFavorite = local.isFavorite, isFavorite = local.isFavorite,
fileCreatedAt = local.createDateTime.toUtc() { fileCreatedAt = local.createDateTime.toUtc() {
if (fileCreatedAt.year == 1970) { if (fileCreatedAt.year == 1970) {
fileCreatedAt = fileModifiedAt; fileCreatedAt = fileModifiedAt;
} }
if (local.latitude != null) {
exifInfo = ExifInfo(lat: local.latitude, long: local.longitude);
}
} }
Asset({ Asset({
this.localId,
this.remoteId, this.remoteId,
required this.deviceAssetId, required this.localId,
required this.deviceId, required this.deviceId,
required this.ownerId, required this.ownerId,
required this.fileCreatedAt, required this.fileCreatedAt,
required this.fileModifiedAt, required this.fileModifiedAt,
this.latitude, required this.updatedAt,
this.longitude,
required this.durationInSeconds, required this.durationInSeconds,
this.width, this.width,
this.height, this.height,
@ -62,21 +67,22 @@ class Asset {
this.livePhotoVideoId, this.livePhotoVideoId,
this.exifInfo, this.exifInfo,
required this.isFavorite, required this.isFavorite,
required this.isLocal,
}); });
@ignore
AssetEntity? _local; AssetEntity? _local;
@ignore
AssetEntity? get local { AssetEntity? get local {
if (isLocal && _local == null) { if (isLocal && _local == null) {
_local = AssetEntity( _local = AssetEntity(
id: localId!.toString(), id: localId.toString(),
typeInt: isImage ? 1 : 2, typeInt: isImage ? 1 : 2,
width: width!, width: width!,
height: height!, height: height!,
duration: durationInSeconds, duration: durationInSeconds,
createDateSecond: fileCreatedAt.millisecondsSinceEpoch ~/ 1000, createDateSecond: fileCreatedAt.millisecondsSinceEpoch ~/ 1000,
latitude: latitude,
longitude: longitude,
modifiedDateSecond: fileModifiedAt.millisecondsSinceEpoch ~/ 1000, modifiedDateSecond: fileModifiedAt.millisecondsSinceEpoch ~/ 1000,
title: fileName, title: fileName,
); );
@ -84,110 +90,136 @@ class Asset {
return _local; return _local;
} }
String? localId; Id id = Isar.autoIncrement;
@Index(unique: false, replace: false, type: IndexType.hash)
String? remoteId; String? remoteId;
String deviceAssetId; @Index(
unique: true,
replace: false,
type: IndexType.hash,
composite: [CompositeIndex('deviceId')],
)
String localId;
String deviceId; int deviceId;
String ownerId; int ownerId;
DateTime fileCreatedAt; DateTime fileCreatedAt;
DateTime fileModifiedAt; DateTime fileModifiedAt;
double? latitude; DateTime updatedAt;
double? longitude;
int durationInSeconds; int durationInSeconds;
int? width; short? width;
int? height; short? height;
String fileName; String fileName;
String? livePhotoVideoId; String? livePhotoVideoId;
ExifInfo? exifInfo;
bool isFavorite; bool isFavorite;
String get id => isLocal ? localId.toString() : remoteId!; bool isLocal;
@ignore
ExifInfo? exifInfo;
@ignore
bool get isInDb => id != Isar.autoIncrement;
@ignore
String get name => p.withoutExtension(fileName); String get name => p.withoutExtension(fileName);
@ignore
bool get isRemote => remoteId != null; bool get isRemote => remoteId != null;
bool get isLocal => localId != null; @ignore
bool get isImage => durationInSeconds == 0; bool get isImage => durationInSeconds == 0;
@ignore
Duration get duration => Duration(seconds: durationInSeconds); Duration get duration => Duration(seconds: durationInSeconds);
@override @override
bool operator ==(other) { bool operator ==(other) {
if (other is! Asset) return false; if (other is! Asset) return false;
return id == other.id && isLocal == other.isLocal; return id == other.id;
} }
@override @override
@ignore
int get hashCode => id.hashCode; int get hashCode => id.hashCode;
// methods below are only required for caching as JSON bool updateFromAssetEntity(AssetEntity ae) {
// TODO check more fields;
Map<String, dynamic> toJson() { // width and height are most important because local assets require these
final json = <String, dynamic>{}; final bool hasChanges =
json["localId"] = localId; isLocal == false || width != ae.width || height != ae.height;
json["remoteId"] = remoteId; if (hasChanges) {
json["deviceAssetId"] = deviceAssetId; isLocal = true;
json["deviceId"] = deviceId; width = ae.width;
json["ownerId"] = ownerId; height = ae.height;
json["fileCreatedAt"] = fileCreatedAt.millisecondsSinceEpoch;
json["fileModifiedAt"] = fileModifiedAt.millisecondsSinceEpoch;
json["latitude"] = latitude;
json["longitude"] = longitude;
json["durationInSeconds"] = durationInSeconds;
json["width"] = width;
json["height"] = height;
json["fileName"] = fileName;
json["livePhotoVideoId"] = livePhotoVideoId;
json["isFavorite"] = isFavorite;
if (exifInfo != null) {
json["exifInfo"] = exifInfo!.toJson();
} }
return json; return hasChanges;
} }
static Asset? fromJson(dynamic value) { Asset withUpdatesFromDto(AssetResponseDto dto) =>
if (value is Map) { Asset.remote(dto).updateFromDb(this);
final json = value.cast<String, dynamic>();
return Asset( Asset updateFromDb(Asset a) {
localId: json["localId"], assert(localId == a.localId);
remoteId: json["remoteId"], assert(deviceId == a.deviceId);
deviceAssetId: json["deviceAssetId"], id = a.id;
deviceId: json["deviceId"], isLocal |= a.isLocal;
ownerId: json["ownerId"], remoteId ??= a.remoteId;
fileCreatedAt: width ??= a.width;
DateTime.fromMillisecondsSinceEpoch(json["fileCreatedAt"], isUtc: true), height ??= a.height;
fileModifiedAt: DateTime.fromMillisecondsSinceEpoch( exifInfo ??= a.exifInfo;
json["fileModifiedAt"], exifInfo?.id = id;
isUtc: true, return this;
), }
latitude: json["latitude"],
longitude: json["longitude"], Future<void> put(Isar db) async {
durationInSeconds: json["durationInSeconds"], await db.assets.put(this);
width: json["width"], if (exifInfo != null) {
height: json["height"], exifInfo!.id = id;
fileName: json["fileName"], await db.exifInfos.put(exifInfo!);
livePhotoVideoId: json["livePhotoVideoId"],
exifInfo: ExifInfo.fromJson(json["exifInfo"]),
isFavorite: json["isFavorite"],
);
} }
return null; }
static int compareByDeviceIdLocalId(Asset a, Asset b) {
final int order = a.deviceId.compareTo(b.deviceId);
return order == 0 ? a.localId.compareTo(b.localId) : order;
}
static int compareById(Asset a, Asset b) => a.id.compareTo(b.id);
static int compareByLocalId(Asset a, Asset b) =>
a.localId.compareTo(b.localId);
}
extension AssetsHelper on IsarCollection<Asset> {
Future<int> deleteAllByRemoteId(Iterable<String> ids) =>
ids.isEmpty ? Future.value(0) : _remote(ids).deleteAll();
Future<int> deleteAllByLocalId(Iterable<String> ids) =>
ids.isEmpty ? Future.value(0) : _local(ids).deleteAll();
Future<List<Asset>> getAllByRemoteId(Iterable<String> ids) =>
ids.isEmpty ? Future.value([]) : _remote(ids).findAll();
Future<List<Asset>> getAllByLocalId(Iterable<String> ids) =>
ids.isEmpty ? Future.value([]) : _local(ids).findAll();
QueryBuilder<Asset, Asset, QAfterWhereClause> _remote(Iterable<String> ids) =>
where().anyOf(ids, (q, String e) => q.remoteIdEqualTo(e));
QueryBuilder<Asset, Asset, QAfterWhereClause> _local(Iterable<String> ids) {
return where().anyOf(
ids,
(q, String e) =>
q.localIdDeviceIdEqualTo(e, Store.get(StoreKey.deviceIdHash)),
);
} }
} }

Binary file not shown.

View File

@ -1,86 +1,93 @@
import 'package:isar/isar.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
import 'package:immich_mobile/utils/builtin_extensions.dart'; import 'package:immich_mobile/utils/builtin_extensions.dart';
part 'exif_info.g.dart';
/// Exif information 1:1 relation with Asset
@Collection(inheritance: false)
class ExifInfo { class ExifInfo {
Id? id;
int? fileSize; int? fileSize;
String? make; String? make;
String? model; String? model;
String? orientation; String? lens;
String? lensModel; float? f;
double? fNumber; float? mm;
double? focalLength; short? iso;
int? iso; float? exposureSeconds;
double? exposureTime; float? lat;
float? long;
String? city; String? city;
String? state; String? state;
String? country; String? country;
@ignore
String get exposureTime {
if (exposureSeconds == null) {
return "";
} else if (exposureSeconds! < 1) {
return "1/${(1.0 / exposureSeconds!).round()} s";
} else {
return "${exposureSeconds!.toStringAsFixed(1)} s";
}
}
@ignore
String get fNumber => f != null ? f!.toStringAsFixed(1) : "";
@ignore
String get focalLength => mm != null ? mm!.toStringAsFixed(1) : "";
@ignore
double? get latitude => lat;
@ignore
double? get longitude => long;
ExifInfo.fromDto(ExifResponseDto dto) ExifInfo.fromDto(ExifResponseDto dto)
: fileSize = dto.fileSizeInByte, : fileSize = dto.fileSizeInByte,
make = dto.make, make = dto.make,
model = dto.model, model = dto.model,
orientation = dto.orientation, lens = dto.lensModel,
lensModel = dto.lensModel, f = dto.fNumber?.toDouble(),
fNumber = dto.fNumber?.toDouble(), mm = dto.focalLength?.toDouble(),
focalLength = dto.focalLength?.toDouble(),
iso = dto.iso?.toInt(), iso = dto.iso?.toInt(),
exposureTime = dto.exposureTime?.toDouble(), exposureSeconds = _exposureTimeToSeconds(dto.exposureTime),
lat = dto.latitude?.toDouble(),
long = dto.longitude?.toDouble(),
city = dto.city, city = dto.city,
state = dto.state, state = dto.state,
country = dto.country; country = dto.country;
// stuff below is only required for caching as JSON ExifInfo({
ExifInfo(
this.fileSize, this.fileSize,
this.make, this.make,
this.model, this.model,
this.orientation, this.lens,
this.lensModel, this.f,
this.fNumber, this.mm,
this.focalLength,
this.iso, this.iso,
this.exposureTime, this.exposureSeconds,
this.lat,
this.long,
this.city, this.city,
this.state, this.state,
this.country, this.country,
); });
}
Map<String, dynamic> toJson() { double? _exposureTimeToSeconds(String? s) {
final json = <String, dynamic>{}; if (s == null) {
json["fileSize"] = fileSize;
json["make"] = make;
json["model"] = model;
json["orientation"] = orientation;
json["lensModel"] = lensModel;
json["fNumber"] = fNumber;
json["focalLength"] = focalLength;
json["iso"] = iso;
json["exposureTime"] = exposureTime;
json["city"] = city;
json["state"] = state;
json["country"] = country;
return json;
}
static ExifInfo? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return ExifInfo(
json["fileSize"],
json["make"],
json["model"],
json["orientation"],
json["lensModel"],
json["fNumber"],
json["focalLength"],
json["iso"],
json["exposureTime"],
json["city"],
json["state"],
json["country"],
);
}
return null; return null;
} }
double? value = double.tryParse(s);
if (value != null) {
return value;
}
final parts = s.split("/");
if (parts.length == 2) {
return parts[0].toDouble() / parts[1].toDouble();
}
return null;
} }

Binary file not shown.

View File

@ -1,3 +1,4 @@
import 'package:immich_mobile/shared/models/user.dart';
import 'package:isar/isar.dart'; import 'package:isar/isar.dart';
import 'dart:convert'; import 'dart:convert';
@ -25,26 +26,28 @@ class Store {
/// Returns the stored value for the given key, or the default value if null /// Returns the stored value for the given key, or the default value if null
static T? get<T>(StoreKey key, [T? defaultValue]) => static T? get<T>(StoreKey key, [T? defaultValue]) =>
_cache[key._id] ?? defaultValue; _cache[key.id] ?? defaultValue;
/// Stores the value synchronously in the cache and asynchronously in the DB /// Stores the value synchronously in the cache and asynchronously in the DB
static Future<void> put<T>(StoreKey key, T value) { static Future<void> put<T>(StoreKey key, T value) {
_cache[key._id] = value; _cache[key.id] = value;
return _db.writeTxn(() => _db.storeValues.put(StoreValue._of(value, key))); return _db.writeTxn(
() async => _db.storeValues.put(await StoreValue._of(value, key)),
);
} }
/// Removes the value synchronously from the cache and asynchronously from the DB /// Removes the value synchronously from the cache and asynchronously from the DB
static Future<void> delete(StoreKey key) { static Future<void> delete(StoreKey key) {
_cache[key._id] = null; _cache[key.id] = null;
return _db.writeTxn(() => _db.storeValues.delete(key._id)); return _db.writeTxn(() => _db.storeValues.delete(key.id));
} }
/// Fills the cache with the values from the DB /// Fills the cache with the values from the DB
static _populateCache() { static _populateCache() {
for (StoreKey key in StoreKey.values) { for (StoreKey key in StoreKey.values) {
final StoreValue? value = _db.storeValues.getSync(key._id); final StoreValue? value = _db.storeValues.getSync(key.id);
if (value != null) { if (value != null) {
_cache[key._id] = value._extract(key); _cache[key.id] = value._extract(key);
} }
} }
} }
@ -67,17 +70,22 @@ class StoreValue {
int? intValue; int? intValue;
String? strValue; String? strValue;
T? _extract<T>(StoreKey key) => key._isInt T? _extract<T>(StoreKey key) => key.isInt
? intValue ? (key.fromDb == null ? intValue : key.fromDb!.call(Store._db, intValue!))
: (key._fromJson != null : (key.fromJson != null
? key._fromJson!(json.decode(strValue!)) ? key.fromJson!(json.decode(strValue!))
: strValue); : strValue);
static StoreValue _of(dynamic value, StoreKey key) => StoreValue( static Future<StoreValue> _of(dynamic value, StoreKey key) async =>
key._id, StoreValue(
intValue: key._isInt ? value : null, key.id,
strValue: key._isInt intValue: key.isInt
? (key.toDb == null
? value
: await key.toDb!.call(Store._db, value))
: null,
strValue: key.isInt
? null ? null
: (key._fromJson == null ? value : json.encode(value.toJson())), : (key.fromJson == null ? value : json.encode(value.toJson())),
); );
} }
@ -86,11 +94,28 @@ class StoreValue {
enum StoreKey { enum StoreKey {
userRemoteId(0), userRemoteId(0),
assetETag(1), assetETag(1),
currentUser(2, isInt: true, fromDb: _getUser, toDb: _toUser),
deviceIdHash(3, isInt: true),
deviceId(4),
; ;
// ignore: unused_element const StoreKey(
const StoreKey(this._id, [this._isInt = false, this._fromJson]); this.id, {
final int _id; this.isInt = false,
final bool _isInt; this.fromDb,
final Function(dynamic)? _fromJson; this.toDb,
// ignore: unused_element
this.fromJson,
});
final int id;
final bool isInt;
final dynamic Function(Isar, int)? fromDb;
final Future<int> Function(Isar, dynamic)? toDb;
final Function(dynamic)? fromJson;
}
User? _getUser(Isar db, int i) => db.users.getSync(i);
Future<int> _toUser(Isar db, dynamic u) {
User user = (u as User);
return db.users.put(user);
} }

View File

@ -1,94 +1,63 @@
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/utils/hash.dart';
import 'package:isar/isar.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
part 'user.g.dart';
@Collection(inheritance: false)
class User { class User {
User({ User({
required this.id, required this.id,
required this.updatedAt,
required this.email, required this.email,
required this.firstName, required this.firstName,
required this.lastName, required this.lastName,
required this.profileImagePath,
required this.isAdmin, required this.isAdmin,
required this.oauthId,
}); });
Id get isarId => fastHash(id);
User.fromDto(UserResponseDto dto) User.fromDto(UserResponseDto dto)
: id = dto.id, : id = dto.id,
updatedAt = dto.updatedAt != null
? DateTime.parse(dto.updatedAt!).toUtc()
: DateTime.now().toUtc(),
email = dto.email, email = dto.email,
firstName = dto.firstName, firstName = dto.firstName,
lastName = dto.lastName, lastName = dto.lastName,
profileImagePath = dto.profileImagePath, isAdmin = dto.isAdmin;
isAdmin = dto.isAdmin,
oauthId = dto.oauthId;
@Index(unique: true, replace: false, type: IndexType.hash)
String id; String id;
DateTime updatedAt;
String email; String email;
String firstName; String firstName;
String lastName; String lastName;
String profileImagePath;
bool isAdmin; bool isAdmin;
String oauthId; @Backlink(to: 'owner')
final IsarLinks<Album> albums = IsarLinks<Album>();
@Backlink(to: 'sharedUsers')
final IsarLinks<Album> sharedAlbums = IsarLinks<Album>();
@override @override
bool operator ==(other) { bool operator ==(other) {
if (other is! User) return false; if (other is! User) return false;
return id == other.id && return id == other.id &&
updatedAt == other.updatedAt &&
email == other.email && email == other.email &&
firstName == other.firstName && firstName == other.firstName &&
lastName == other.lastName && lastName == other.lastName &&
profileImagePath == other.profileImagePath && isAdmin == other.isAdmin;
isAdmin == other.isAdmin &&
oauthId == other.oauthId;
} }
@override @override
@ignore
int get hashCode => int get hashCode =>
id.hashCode ^ id.hashCode ^
updatedAt.hashCode ^
email.hashCode ^ email.hashCode ^
firstName.hashCode ^ firstName.hashCode ^
lastName.hashCode ^ lastName.hashCode ^
profileImagePath.hashCode ^ isAdmin.hashCode;
isAdmin.hashCode ^
oauthId.hashCode;
UserResponseDto toDto() {
return UserResponseDto(
id: id,
email: email,
firstName: firstName,
lastName: lastName,
profileImagePath: profileImagePath,
createdAt: '',
isAdmin: isAdmin,
shouldChangePassword: false,
oauthId: oauthId,
);
}
Map<String, dynamic> toJson() {
final json = <String, dynamic>{};
json["id"] = id;
json["email"] = email;
json["firstName"] = firstName;
json["lastName"] = lastName;
json["profileImagePath"] = profileImagePath;
json["isAdmin"] = isAdmin;
json["oauthId"] = oauthId;
return json;
}
static User? fromJson(dynamic value) {
if (value is Map) {
final json = value.cast<String, dynamic>();
return User(
id: json["id"],
email: json["email"],
firstName: json["firstName"],
lastName: json["lastName"],
profileImagePath: json["profileImagePath"],
isAdmin: json["isAdmin"],
oauthId: json["oauthId"],
);
}
return null;
}
} }

Binary file not shown.

View File

@ -1,20 +1,19 @@
import 'dart:collection';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/modules/album/services/album.service.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/store.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/shared/services/asset.service.dart';
import 'package:immich_mobile/shared/services/asset_cache.service.dart';
import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.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/providers/app_settings.provider.dart';
import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/services/device_info.service.dart';
import 'package:collection/collection.dart'; import 'package:collection/collection.dart';
import 'package:immich_mobile/utils/tuple.dart';
import 'package:intl/intl.dart'; import 'package:intl/intl.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart'; import 'package:photo_manager/photo_manager.dart';
@ -50,50 +49,36 @@ class AssetsState {
} }
} }
class _CombineAssetsComputeParameters {
final Iterable<Asset> local;
final Iterable<Asset> remote;
final String deviceId;
_CombineAssetsComputeParameters(this.local, this.remote, this.deviceId);
}
class AssetNotifier extends StateNotifier<AssetsState> { class AssetNotifier extends StateNotifier<AssetsState> {
final AssetService _assetService; final AssetService _assetService;
final AssetCacheService _assetCacheService;
final AppSettingsService _settingsService; final AppSettingsService _settingsService;
final AlbumService _albumService;
final Isar _db;
final log = Logger('AssetNotifier'); final log = Logger('AssetNotifier');
final DeviceInfoService _deviceInfoService = DeviceInfoService();
bool _getAllAssetInProgress = false; bool _getAllAssetInProgress = false;
bool _deleteInProgress = false; bool _deleteInProgress = false;
AssetNotifier( AssetNotifier(
this._assetService, this._assetService,
this._assetCacheService,
this._settingsService, this._settingsService,
this._albumService,
this._db,
) : super(AssetsState.fromAssetList([])); ) : super(AssetsState.fromAssetList([]));
Future<void> _updateAssetsState( Future<void> _updateAssetsState(List<Asset> newAssetList) async {
List<Asset> newAssetList, {
bool cache = true,
}) async {
if (cache) {
_assetCacheService.put(newAssetList);
}
final layout = AssetGridLayoutParameters( final layout = AssetGridLayoutParameters(
_settingsService.getSetting(AppSettingsEnum.tilesPerRow), _settingsService.getSetting(AppSettingsEnum.tilesPerRow),
_settingsService.getSetting(AppSettingsEnum.dynamicLayout), _settingsService.getSetting(AppSettingsEnum.dynamicLayout),
GroupAssetsBy.values[_settingsService.getSetting(AppSettingsEnum.groupAssetsBy)], GroupAssetsBy
.values[_settingsService.getSetting(AppSettingsEnum.groupAssetsBy)],
); );
state = await AssetsState.fromAssetList(newAssetList) state = await AssetsState.fromAssetList(newAssetList)
.withRenderDataStructure(layout); .withRenderDataStructure(layout);
} }
// Just a little helper to trigger a rebuild of the state object // Just a little helper to trigger a rebuild of the state object
Future<void> rebuildAssetGridDataStructure() async { Future<void> rebuildAssetGridDataStructure() async {
await _updateAssetsState(state.allAssets, cache: false); await _updateAssetsState(state.allAssets);
} }
getAllAsset() async { getAllAsset() async {
@ -104,127 +89,102 @@ class AssetNotifier extends StateNotifier<AssetsState> {
final stopwatch = Stopwatch(); final stopwatch = Stopwatch();
try { try {
_getAllAssetInProgress = true; _getAllAssetInProgress = true;
bool isCacheValid = await _assetCacheService.isValid(); final User me = Store.get(StoreKey.currentUser);
final int cachedCount =
await _db.assets.filter().ownerIdEqualTo(me.isarId).count();
stopwatch.start(); stopwatch.start();
if (isCacheValid && state.allAssets.isEmpty) { if (cachedCount > 0 && cachedCount != state.allAssets.length) {
final List<Asset>? cachedData = await _assetCacheService.get(); await _updateAssetsState(await _getUserAssets(me.isarId));
if (cachedData == null) { log.info(
isCacheValid = false; "Reading assets ${state.allAssets.length} from DB: ${stopwatch.elapsedMilliseconds}ms",
log.warning("Cached asset data is invalid, fetching new data"); );
} else {
await _updateAssetsState(cachedData, cache: false);
log.info(
"Reading assets ${state.allAssets.length} from cache: ${stopwatch.elapsedMilliseconds}ms",
);
}
stopwatch.reset(); stopwatch.reset();
} }
final localTask = _assetService.getLocalAssets(urgent: !isCacheValid); final bool newRemote = await _assetService.refreshRemoteAssets();
final remoteTask = _assetService.getRemoteAssets( final bool newLocal = await _albumService.refreshDeviceAlbums();
etag: isCacheValid ? Store.get(StoreKey.assetETag) : null,
);
int remoteBegin = state.allAssets.indexWhere((a) => a.isRemote);
remoteBegin = remoteBegin == -1 ? state.allAssets.length : remoteBegin;
final List<Asset> currentLocal = state.allAssets.slice(0, remoteBegin);
final Pair<List<Asset>?, String?> remoteResult = await remoteTask;
List<Asset>? newRemote = remoteResult.first;
List<Asset>? newLocal = await localTask;
log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms"); log.info("Load assets: ${stopwatch.elapsedMilliseconds}ms");
stopwatch.reset(); stopwatch.reset();
if (newRemote == null && if (!newRemote && !newLocal) {
(newLocal == null || currentLocal.equals(newLocal))) {
log.info("state is already up-to-date"); log.info("state is already up-to-date");
return; return;
} }
newRemote ??= state.allAssets.slice(remoteBegin); stopwatch.reset();
newLocal ??= []; final assets = await _getUserAssets(me.isarId);
if (!const ListEquality().equals(assets, state.allAssets)) {
final combinedAssets = await _combineLocalAndRemoteAssets( log.info("setting new asset state");
local: newLocal, await _updateAssetsState(assets);
remote: newRemote, }
);
await _updateAssetsState(combinedAssets);
log.info("Combining assets: ${stopwatch.elapsedMilliseconds}ms");
Store.put(StoreKey.assetETag, remoteResult.second);
} finally { } finally {
_getAllAssetInProgress = false; _getAllAssetInProgress = false;
} }
} }
static Future<List<Asset>> _computeCombine( Future<List<Asset>> _getUserAssets(int userId) => _db.assets
_CombineAssetsComputeParameters data, .filter()
) async { .ownerIdEqualTo(userId)
var local = data.local; .sortByFileCreatedAtDesc()
var remote = data.remote; .findAll();
final deviceId = data.deviceId;
final List<Asset> assets = []; Future<void> clearAllAsset() {
if (remote.isNotEmpty && local.isNotEmpty) { state = AssetsState.empty();
final Set<String> existingIds = remote return _db.writeTxn(() async {
.where((e) => e.deviceId == deviceId) await _db.assets.clear();
.map((e) => e.deviceAssetId) await _db.exifInfos.clear();
.toSet(); await _db.albums.clear();
local = local.where((e) => !existingIds.contains(e.id)); });
}
assets.addAll(local);
// the order (first all local, then remote assets) is important!
assets.addAll(remote);
return assets;
} }
Future<List<Asset>> _combineLocalAndRemoteAssets({ Future<void> onNewAssetUploaded(Asset newAsset) async {
required Iterable<Asset> local,
required List<Asset> remote,
}) async {
final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
return await compute(
_computeCombine,
_CombineAssetsComputeParameters(local, remote, deviceId),
);
}
clearAllAsset() {
_updateAssetsState([]);
}
void onNewAssetUploaded(Asset newAsset) {
final int i = state.allAssets.indexWhere( final int i = state.allAssets.indexWhere(
(a) => (a) =>
a.isRemote || a.isRemote ||
(a.id == newAsset.deviceAssetId && a.deviceId == newAsset.deviceId), (a.localId == newAsset.localId && a.deviceId == newAsset.deviceId),
); );
if (i == -1 || state.allAssets[i].deviceAssetId != newAsset.deviceAssetId) { if (i == -1 ||
_updateAssetsState([...state.allAssets, newAsset]); state.allAssets[i].localId != newAsset.localId ||
state.allAssets[i].deviceId != newAsset.deviceId) {
await _updateAssetsState([...state.allAssets, newAsset]);
} else { } else {
// unify local/remote assets by replacing the
// local-only asset in the DB with a local&remote asset
final Asset? inDb = await _db.assets
.where()
.localIdDeviceIdEqualTo(newAsset.localId, newAsset.deviceId)
.findFirst();
if (inDb != null) {
newAsset.id = inDb.id;
newAsset.isLocal = inDb.isLocal;
}
// order is important to keep all local-only assets at the beginning! // order is important to keep all local-only assets at the beginning!
_updateAssetsState([ await _updateAssetsState([
...state.allAssets.slice(0, i), ...state.allAssets.slice(0, i),
...state.allAssets.slice(i + 1), ...state.allAssets.slice(i + 1),
newAsset, newAsset,
]); ]);
// TODO here is a place to unify local/remote assets by replacing the }
// local-only asset in the state with a local&remote asset try {
await _db.writeTxn(() => newAsset.put(_db));
} on IsarError catch (e) {
debugPrint(e.toString());
} }
} }
deleteAssets(Set<Asset> deleteAssets) async { Future<void> deleteAssets(Set<Asset> deleteAssets) async {
_deleteInProgress = true; _deleteInProgress = true;
try { try {
_updateAssetsState(
state.allAssets.whereNot(deleteAssets.contains).toList(),
);
final localDeleted = await _deleteLocalAssets(deleteAssets); final localDeleted = await _deleteLocalAssets(deleteAssets);
final remoteDeleted = await _deleteRemoteAssets(deleteAssets); final remoteDeleted = await _deleteRemoteAssets(deleteAssets);
final Set<String> deleted = HashSet(); if (localDeleted.isNotEmpty || remoteDeleted.isNotEmpty) {
deleted.addAll(localDeleted); final dbIds = deleteAssets.map((e) => e.id).toList();
deleted.addAll(remoteDeleted); await _db.writeTxn(() async {
if (deleted.isNotEmpty) { await _db.exifInfos.deleteAll(dbIds);
_updateAssetsState( await _db.assets.deleteAll(dbIds);
state.allAssets.where((a) => !deleted.contains(a.id)).toList(), });
);
} }
} finally { } finally {
_deleteInProgress = false; _deleteInProgress = false;
@ -232,16 +192,15 @@ class AssetNotifier extends StateNotifier<AssetsState> {
} }
Future<List<String>> _deleteLocalAssets(Set<Asset> assetsToDelete) async { Future<List<String>> _deleteLocalAssets(Set<Asset> assetsToDelete) async {
var deviceInfo = await _deviceInfoService.getDeviceInfo(); final int deviceId = Store.get(StoreKey.deviceIdHash);
var deviceId = deviceInfo["deviceId"];
final List<String> local = []; final List<String> local = [];
// Delete asset from device // Delete asset from device
for (final Asset asset in assetsToDelete) { for (final Asset asset in assetsToDelete) {
if (asset.isLocal) { if (asset.isLocal) {
local.add(asset.localId!); local.add(asset.localId);
} else if (asset.deviceId == deviceId) { } else if (asset.deviceId == deviceId) {
// Delete asset on device if it is still present // Delete asset on device if it is still present
var localAsset = await AssetEntity.fromId(asset.deviceAssetId); var localAsset = await AssetEntity.fromId(asset.localId);
if (localAsset != null) { if (localAsset != null) {
local.add(localAsset.id); local.add(localAsset.id);
} }
@ -249,7 +208,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
} }
if (local.isNotEmpty) { if (local.isNotEmpty) {
try { try {
return await PhotoManager.editor.deleteWithIds(local); await PhotoManager.editor.deleteWithIds(local);
} catch (e, stack) { } catch (e, stack) {
log.severe("Failed to delete asset from device", e, stack); log.severe("Failed to delete asset from device", e, stack);
} }
@ -289,8 +248,9 @@ class AssetNotifier extends StateNotifier<AssetsState> {
final assetProvider = StateNotifierProvider<AssetNotifier, AssetsState>((ref) { final assetProvider = StateNotifierProvider<AssetNotifier, AssetsState>((ref) {
return AssetNotifier( return AssetNotifier(
ref.watch(assetServiceProvider), ref.watch(assetServiceProvider),
ref.watch(assetCacheServiceProvider),
ref.watch(appSettingsServiceProvider), ref.watch(appSettingsServiceProvider),
ref.watch(albumServiceProvider),
ref.watch(dbProvider),
); );
}); });

View File

@ -28,9 +28,13 @@ class ApiService {
debugPrint("Cannot init ApiServer endpoint, userInfoBox not open yet."); debugPrint("Cannot init ApiServer endpoint, userInfoBox not open yet.");
} }
} }
String? _authToken;
setEndpoint(String endpoint) { setEndpoint(String endpoint) {
_apiClient = ApiClient(basePath: endpoint); _apiClient = ApiClient(basePath: endpoint);
if (_authToken != null) {
setAccessToken(_authToken!);
}
userApi = UserApi(_apiClient); userApi = UserApi(_apiClient);
authenticationApi = AuthenticationApi(_apiClient); authenticationApi = AuthenticationApi(_apiClient);
oAuthApi = OAuthApi(_apiClient); oAuthApi = OAuthApi(_apiClient);
@ -94,6 +98,9 @@ class ApiService {
} }
setAccessToken(String accessToken) { setAccessToken(String accessToken) {
_authToken = accessToken;
_apiClient.addDefaultHeader('Authorization', 'Bearer $accessToken'); _apiClient.addDefaultHeader('Authorization', 'Bearer $accessToken');
} }
ApiClient get apiClient => _apiClient;
} }

View File

@ -1,101 +1,84 @@
import 'dart:async'; import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/models/exif_info.dart';
import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/shared/services/sync.service.dart';
import 'package:immich_mobile/utils/openapi_extensions.dart'; import 'package:immich_mobile/utils/openapi_extensions.dart';
import 'package:immich_mobile/utils/tuple.dart'; import 'package:immich_mobile/utils/tuple.dart';
import 'package:isar/isar.dart';
import 'package:logging/logging.dart'; import 'package:logging/logging.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
final assetServiceProvider = Provider( final assetServiceProvider = Provider(
(ref) => AssetService( (ref) => AssetService(
ref.watch(apiServiceProvider), ref.watch(apiServiceProvider),
ref.watch(backupServiceProvider), ref.watch(syncServiceProvider),
ref.watch(backgroundServiceProvider), ref.watch(dbProvider),
), ),
); );
class AssetService { class AssetService {
final ApiService _apiService; final ApiService _apiService;
final BackupService _backupService; final SyncService _syncService;
final BackgroundService _backgroundService;
final log = Logger('AssetService'); final log = Logger('AssetService');
final Isar _db;
AssetService(this._apiService, this._backupService, this._backgroundService); AssetService(
this._apiService,
this._syncService,
this._db,
);
/// Checks the server for updated assets and updates the local database if
/// required. Returns `true` if there were any changes.
Future<bool> refreshRemoteAssets() async {
final Stopwatch sw = Stopwatch()..start();
final int numOwnedRemoteAssets = await _db.assets
.where()
.remoteIdIsNotNull()
.filter()
.ownerIdEqualTo(Store.get<User>(StoreKey.currentUser)!.isarId)
.count();
final List<AssetResponseDto>? dtos =
await _getRemoteAssets(hasCache: numOwnedRemoteAssets > 0);
if (dtos == null) {
debugPrint("refreshRemoteAssets fast took ${sw.elapsedMilliseconds}ms");
return false;
}
final bool changes = await _syncService
.syncRemoteAssetsToDb(dtos.map(Asset.remote).toList());
debugPrint("refreshRemoteAssets full took ${sw.elapsedMilliseconds}ms");
return changes;
}
/// Returns `null` if the server state did not change, else list of assets /// Returns `null` if the server state did not change, else list of assets
Future<Pair<List<Asset>?, String?>> getRemoteAssets({String? etag}) async { Future<List<AssetResponseDto>?> _getRemoteAssets({
required bool hasCache,
}) async {
try { try {
// temporary fix for race condition that the _apiService final etag = hasCache ? Store.get(StoreKey.assetETag) : null;
// get called before accessToken is set
var userInfoHiveBox = await Hive.openBox(userInfoBox);
var accessToken = userInfoHiveBox.get(accessTokenKey);
_apiService.setAccessToken(accessToken);
final Pair<List<AssetResponseDto>, String?>? remote = final Pair<List<AssetResponseDto>, String?>? remote =
await _apiService.assetApi.getAllAssetsWithETag(eTag: etag); await _apiService.assetApi.getAllAssetsWithETag(eTag: etag);
if (remote == null) { if (remote == null) {
return Pair(null, etag); return null;
} }
return Pair( if (remote.second != null && remote.second != etag) {
remote.first.map(Asset.remote).toList(growable: false), Store.put(StoreKey.assetETag, remote.second);
remote.second, }
); return remote.first;
} catch (e, stack) { } catch (e, stack) {
log.severe('Error while getting remote assets', e, stack); log.severe('Error while getting remote assets', e, stack);
debugPrint("[ERROR] [getRemoteAssets] $e"); return null;
return Pair(null, etag);
} }
} }
/// if [urgent] is `true`, do not block by waiting on the background service
/// to finish running. Returns `null` instead after a timeout.
Future<List<Asset>?> getLocalAssets({bool urgent = false}) async {
try {
final Future<bool> hasAccess = urgent
? _backgroundService.hasAccess
.timeout(const Duration(milliseconds: 250))
: _backgroundService.hasAccess;
if (!await hasAccess) {
throw Exception("Error [getAllAsset] failed to gain access");
}
final box = await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
final String userId = Store.get(StoreKey.userRemoteId);
if (backupAlbumInfo != null) {
return (await _backupService
.buildUploadCandidates(backupAlbumInfo.deepCopy()))
.map((e) => Asset.local(e, userId))
.toList(growable: false);
}
} catch (e, stackTrace) {
log.severe('Error while getting local assets', e, stackTrace);
debugPrint("Error [_getLocalAssets] ${e.toString()}");
}
return null;
}
Future<Asset?> getAssetById(String assetId) async {
try {
final dto = await _apiService.assetApi.getAssetById(assetId);
if (dto != null) {
return Asset.remote(dto);
}
} catch (e) {
debugPrint("Error [getAssetById] ${e.toString()}");
}
return null;
}
Future<List<DeleteAssetResponseDto>?> deleteAssets( Future<List<DeleteAssetResponseDto>?> deleteAssets(
Iterable<Asset> deleteAssets, Iterable<Asset> deleteAssets,
) async { ) async {
@ -114,6 +97,28 @@ class AssetService {
} }
} }
/// Loads the exif information from the database. If there is none, loads
/// the exif info from the server (remote assets only)
Future<Asset> loadExif(Asset a) async {
a.exifInfo ??= await _db.exifInfos.get(a.id);
if (a.exifInfo?.iso == null) {
if (a.isRemote) {
final dto = await _apiService.assetApi.getAssetById(a.remoteId!);
if (dto != null && dto.exifInfo != null) {
a = a.withUpdatesFromDto(dto);
if (a.isInDb) {
_db.writeTxn(() => a.put(_db));
} else {
debugPrint("[loadExif] parameter Asset is not from DB!");
}
}
} else {
// TODO implement local exif info parsing
}
}
return a;
}
Future<Asset?> updateAsset( Future<Asset?> updateAsset(
Asset asset, Asset asset,
UpdateAssetDto updateAssetDto, UpdateAssetDto updateAssetDto,

View File

@ -1,41 +1,13 @@
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/asset.dart'; import 'package:immich_mobile/shared/models/asset.dart';
import 'package:immich_mobile/shared/services/json_cache.dart'; import 'package:immich_mobile/shared/services/json_cache.dart';
@Deprecated("only kept to remove its files after migration")
class AssetCacheService extends JsonCache<List<Asset>> { class AssetCacheService extends JsonCache<List<Asset>> {
AssetCacheService() : super("asset_cache"); AssetCacheService() : super("asset_cache");
static Future<List<Map<String, dynamic>>> _computeSerialize( @override
List<Asset> assets, void put(List<Asset> data) {}
) async {
return assets.map((e) => e.toJson()).toList();
}
@override @override
void put(List<Asset> data) async { Future<List<Asset>?> get() => Future.value(null);
putRawData(await compute(_computeSerialize, data));
}
static Future<List<Asset>> _computeEncode(List<dynamic> data) async {
return data.map((e) => Asset.fromJson(e)).whereNotNull().toList();
}
@override
Future<List<Asset>?> get() async {
try {
final mapList = await readRawData() as List<dynamic>;
final responseData = await compute(_computeEncode, mapList);
return responseData;
} catch (e) {
debugPrint(e.toString());
await invalidate();
return null;
}
}
} }
final assetCacheServiceProvider = Provider(
(ref) => AssetCacheService(),
);

View File

@ -1,9 +1,8 @@
import 'dart:convert';
import 'dart:io'; import 'dart:io';
import 'package:flutter/foundation.dart';
import 'package:path_provider/path_provider.dart'; import 'package:path_provider/path_provider.dart';
@Deprecated("only kept to remove its files after migration")
abstract class JsonCache<T> { abstract class JsonCache<T> {
final String cacheFileName; final String cacheFileName;
@ -32,33 +31,6 @@ abstract class JsonCache<T> {
} }
} }
static Future<String> _computeEncodeJson(dynamic toEncode) async {
return json.encode(toEncode);
}
Future<void> putRawData(dynamic data) async {
final jsonString = await compute(_computeEncodeJson, data);
final file = await _getCacheFile();
if (!await file.exists()) {
await file.create();
}
await file.writeAsString(jsonString);
}
static Future<dynamic> _computeDecodeJson(String jsonString) async {
return json.decode(jsonString);
}
Future<dynamic> readRawData() async {
final file = await _getCacheFile();
final data = await file.readAsString();
return await compute(_computeDecodeJson, data);
}
void put(T data); void put(T data);
Future<T?> get(); Future<T?> get();
} }

View File

@ -0,0 +1,558 @@
import 'dart:async';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/shared/models/album.dart';
import 'package:immich_mobile/shared/models/asset.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/utils/async_mutex.dart';
import 'package:immich_mobile/utils/diff.dart';
import 'package:immich_mobile/utils/tuple.dart';
import 'package:isar/isar.dart';
import 'package:openapi/api.dart';
import 'package:photo_manager/photo_manager.dart';
final syncServiceProvider =
Provider((ref) => SyncService(ref.watch(dbProvider)));
class SyncService {
final Isar _db;
final AsyncMutex _lock = AsyncMutex();
SyncService(this._db);
// public methods:
/// Syncs users from the server to the local database
/// Returns `true`if there were any changes
Future<bool> syncUsersFromServer(List<User> users) async {
users.sortBy((u) => u.id);
final dbUsers = await _db.users.where().sortById().findAll();
final List<int> toDelete = [];
final List<User> toUpsert = [];
final changes = diffSortedListsSync(
users,
dbUsers,
compare: (User a, User b) => a.id.compareTo(b.id),
both: (User a, User b) {
if (a.updatedAt != b.updatedAt) {
toUpsert.add(a);
return true;
}
return false;
},
onlyFirst: (User a) => toUpsert.add(a),
onlySecond: (User b) => toDelete.add(b.isarId),
);
if (changes) {
await _db.writeTxn(() async {
await _db.users.deleteAll(toDelete);
await _db.users.putAll(toUpsert);
});
}
return changes;
}
/// Syncs remote assets owned by the logged-in user to the DB
/// Returns `true` if there were any changes
Future<bool> syncRemoteAssetsToDb(List<Asset> remote) =>
_lock.run(() => _syncRemoteAssetsToDb(remote));
/// Syncs remote albums to the database
/// returns `true` if there were any changes
Future<bool> syncRemoteAlbumsToDb(
List<AlbumResponseDto> remote, {
required bool isShared,
required FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
}) =>
_lock.run(() => _syncRemoteAlbumsToDb(remote, isShared, loadDetails));
/// Syncs all device albums and their assets to the database
/// Returns `true` if there were any changes
Future<bool> syncLocalAlbumAssetsToDb(List<AssetPathEntity> onDevice) =>
_lock.run(() => _syncLocalAlbumAssetsToDb(onDevice));
/// returns all Asset IDs that are not contained in the existing list
List<int> sharedAssetsToRemove(
List<Asset> deleteCandidates,
List<Asset> existing,
) {
if (deleteCandidates.isEmpty) {
return [];
}
deleteCandidates.sort(Asset.compareById);
existing.sort(Asset.compareById);
return _diffAssets(existing, deleteCandidates, compare: Asset.compareById)
.third
.map((e) => e.id)
.toList();
}
// private methods:
/// Syncs remote assets to the databas
/// returns `true` if there were any changes
Future<bool> _syncRemoteAssetsToDb(List<Asset> remote) async {
final User user = Store.get(StoreKey.currentUser);
final List<Asset> inDb = await _db.assets
.filter()
.ownerIdEqualTo(user.isarId)
.sortByDeviceId()
.thenByLocalId()
.findAll();
remote.sort(Asset.compareByDeviceIdLocalId);
final diff = _diffAssets(remote, inDb, remote: true);
if (diff.first.isEmpty && diff.second.isEmpty && diff.third.isEmpty) {
return false;
}
final idsToDelete = diff.third.map((e) => e.id).toList();
try {
await _db.writeTxn(() => _db.assets.deleteAll(idsToDelete));
await _upsertAssetsWithExif(diff.first + diff.second);
} on IsarError catch (e) {
debugPrint(e.toString());
}
return true;
}
/// Syncs remote albums to the database
/// returns `true` if there were any changes
Future<bool> _syncRemoteAlbumsToDb(
List<AlbumResponseDto> remote,
bool isShared,
FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
) async {
remote.sortBy((e) => e.id);
final baseQuery = _db.albums.where().remoteIdIsNotNull().filter();
final QueryBuilder<Album, Album, QAfterFilterCondition> query;
if (isShared) {
query = baseQuery.sharedEqualTo(true);
} else {
final User me = Store.get(StoreKey.currentUser);
query = baseQuery.owner((q) => q.isarIdEqualTo(me.isarId));
}
final List<Album> dbAlbums = await query.sortByRemoteId().findAll();
final List<Asset> toDelete = [];
final List<Asset> existing = [];
final bool changes = await diffSortedLists(
remote,
dbAlbums,
compare: (AlbumResponseDto a, Album b) => a.id.compareTo(b.remoteId!),
both: (AlbumResponseDto a, Album b) =>
_syncRemoteAlbum(a, b, toDelete, existing, loadDetails),
onlyFirst: (AlbumResponseDto a) =>
_addAlbumFromServer(a, existing, loadDetails),
onlySecond: (Album a) => _removeAlbumFromDb(a, toDelete),
);
if (isShared && toDelete.isNotEmpty) {
final List<int> idsToRemove = sharedAssetsToRemove(toDelete, existing);
if (idsToRemove.isNotEmpty) {
await _db.writeTxn(() => _db.assets.deleteAll(idsToRemove));
}
} else {
assert(toDelete.isEmpty);
}
return changes;
}
/// syncs albums from the server to the local database (does not support
/// syncing changes from local back to server)
/// accumulates
Future<bool> _syncRemoteAlbum(
AlbumResponseDto dto,
Album album,
List<Asset> deleteCandidates,
List<Asset> existing,
FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
) async {
if (!_hasAlbumResponseDtoChanged(dto, album)) {
return false;
}
dto = await loadDetails(dto);
if (dto.assetCount != dto.assets.length) {
return false;
}
final assetsInDb =
await album.assets.filter().sortByDeviceId().thenByLocalId().findAll();
final List<Asset> assetsOnRemote = dto.getAssets();
assetsOnRemote.sort(Asset.compareByDeviceIdLocalId);
final d = _diffAssets(assetsOnRemote, assetsInDb);
final List<Asset> toAdd = d.first, toUpdate = d.second, toUnlink = d.third;
// update shared users
final List<User> sharedUsers = album.sharedUsers.toList(growable: false);
sharedUsers.sort((a, b) => a.id.compareTo(b.id));
dto.sharedUsers.sort((a, b) => a.id.compareTo(b.id));
final List<String> userIdsToAdd = [];
final List<User> usersToUnlink = [];
diffSortedListsSync(
dto.sharedUsers,
sharedUsers,
compare: (UserResponseDto a, User b) => a.id.compareTo(b.id),
both: (a, b) => false,
onlyFirst: (UserResponseDto a) => userIdsToAdd.add(a.id),
onlySecond: (User a) => usersToUnlink.add(a),
);
// for shared album: put missing album assets into local DB
final resultPair = await _linkWithExistingFromDb(toAdd);
await _upsertAssetsWithExif(resultPair.second);
final assetsToLink = resultPair.first + resultPair.second;
final usersToLink = (await _db.users.getAllById(userIdsToAdd)).cast<User>();
album.name = dto.albumName;
album.shared = dto.shared;
album.modifiedAt = DateTime.parse(dto.updatedAt).toUtc();
if (album.thumbnail.value?.remoteId != dto.albumThumbnailAssetId) {
album.thumbnail.value = await _db.assets
.where()
.remoteIdEqualTo(dto.albumThumbnailAssetId)
.findFirst();
}
// write & commit all changes to DB
try {
await _db.writeTxn(() async {
await _db.assets.putAll(toUpdate);
await album.thumbnail.save();
await album.sharedUsers
.update(link: usersToLink, unlink: usersToUnlink);
await album.assets.update(link: assetsToLink, unlink: toUnlink.cast());
await _db.albums.put(album);
});
} on IsarError catch (e) {
debugPrint(e.toString());
}
if (album.shared || dto.shared) {
final userId = Store.get<User>(StoreKey.currentUser)!.isarId;
final foreign =
await album.assets.filter().not().ownerIdEqualTo(userId).findAll();
existing.addAll(foreign);
// delete assets in DB unless they belong to this user or part of some other shared album
deleteCandidates.addAll(toUnlink.where((a) => a.ownerId != userId));
}
return true;
}
/// Adds a remote album to the database while making sure to add any foreign
/// (shared) assets to the database beforehand
/// accumulates assets already existing in the database
Future<void> _addAlbumFromServer(
AlbumResponseDto dto,
List<Asset> existing,
FutureOr<AlbumResponseDto> Function(AlbumResponseDto) loadDetails,
) async {
if (dto.assetCount != dto.assets.length) {
dto = await loadDetails(dto);
}
if (dto.assetCount == dto.assets.length) {
// in case an album contains assets not yet present in local DB:
// put missing album assets into local DB
final result = await _linkWithExistingFromDb(dto.getAssets());
existing.addAll(result.first);
await _upsertAssetsWithExif(result.second);
final Album a = await Album.remote(dto);
await _db.writeTxn(() => _db.albums.store(a));
}
}
/// Accumulates all suitable album assets to the `deleteCandidates` and
/// removes the album from the database.
Future<void> _removeAlbumFromDb(
Album album,
List<Asset> deleteCandidates,
) async {
if (album.isLocal) {
// delete assets in DB unless they are remote or part of some other album
deleteCandidates.addAll(
await album.assets.filter().remoteIdIsNull().findAll(),
);
} else if (album.shared) {
final User user = Store.get(StoreKey.currentUser);
// delete assets in DB unless they belong to this user or are part of some other shared album
deleteCandidates.addAll(
await album.assets.filter().not().ownerIdEqualTo(user.isarId).findAll(),
);
}
final bool ok = await _db.writeTxn(() => _db.albums.delete(album.id));
assert(ok);
}
/// Syncs all device albums and their assets to the database
/// Returns `true` if there were any changes
Future<bool> _syncLocalAlbumAssetsToDb(List<AssetPathEntity> onDevice) async {
onDevice.sort((a, b) => a.id.compareTo(b.id));
final List<Album> inDb =
await _db.albums.where().localIdIsNotNull().sortByLocalId().findAll();
final List<Asset> deleteCandidates = [];
final List<Asset> existing = [];
final bool anyChanges = await diffSortedLists(
onDevice,
inDb,
compare: (AssetPathEntity a, Album b) => a.id.compareTo(b.localId!),
both: (AssetPathEntity ape, Album album) =>
_syncAlbumInDbAndOnDevice(ape, album, deleteCandidates, existing),
onlyFirst: (AssetPathEntity ape) => _addAlbumFromDevice(ape, existing),
onlySecond: (Album a) => _removeAlbumFromDb(a, deleteCandidates),
);
final pair = _handleAssetRemoval(deleteCandidates, existing);
if (pair.first.isNotEmpty || pair.second.isNotEmpty) {
await _db.writeTxn(() async {
await _db.assets.deleteAll(pair.first);
await _db.assets.putAll(pair.second);
});
}
return anyChanges;
}
/// Syncs the device album to the album in the database
/// returns `true` if there were any changes
/// Accumulates asset candidates to delete and those already existing in DB
Future<bool> _syncAlbumInDbAndOnDevice(
AssetPathEntity ape,
Album album,
List<Asset> deleteCandidates,
List<Asset> existing, [
bool forceRefresh = false,
]) async {
if (!forceRefresh && !await _hasAssetPathEntityChanged(ape, album)) {
return false;
}
if (!forceRefresh && await _syncDeviceAlbumFast(ape, album)) {
return true;
}
// general case, e.g. some assets have been deleted
final inDb = await album.assets.filter().sortByLocalId().findAll();
final List<Asset> onDevice = await ape.getAssets();
onDevice.sort(Asset.compareByLocalId);
final d = _diffAssets(onDevice, inDb, compare: Asset.compareByLocalId);
final List<Asset> toAdd = d.first, toUpdate = d.second, toDelete = d.third;
final result = await _linkWithExistingFromDb(toAdd);
deleteCandidates.addAll(toDelete);
existing.addAll(result.first);
album.name = ape.name;
album.modifiedAt = ape.lastModified!;
if (album.thumbnail.value != null &&
toDelete.contains(album.thumbnail.value)) {
album.thumbnail.value = null;
}
try {
await _db.writeTxn(() async {
await _db.assets.putAll(result.second);
await _db.assets.putAll(toUpdate);
await album.assets
.update(link: result.first + result.second, unlink: toDelete);
await _db.albums.put(album);
album.thumbnail.value ??= await album.assets.filter().findFirst();
await album.thumbnail.save();
});
} on IsarError catch (e) {
debugPrint(e.toString());
}
return true;
}
/// fast path for common case: only new assets were added to device album
/// returns `true` if successfull, else `false`
Future<bool> _syncDeviceAlbumFast(AssetPathEntity ape, Album album) async {
final int totalOnDevice = await ape.assetCountAsync;
final AssetPathEntity? modified = totalOnDevice > album.assetCount
? await ape.fetchPathProperties(
filterOptionGroup: FilterOptionGroup(
updateTimeCond: DateTimeCond(
min: album.modifiedAt.add(const Duration(seconds: 1)),
max: ape.lastModified!,
),
),
)
: null;
if (modified == null) {
return false;
}
final List<Asset> newAssets = await modified.getAssets();
if (totalOnDevice != album.assets.length + newAssets.length) {
return false;
}
album.modifiedAt = ape.lastModified!.toUtc();
final result = await _linkWithExistingFromDb(newAssets);
try {
await _db.writeTxn(() async {
await _db.assets.putAll(result.second);
await album.assets.update(link: result.first + result.second);
await _db.albums.put(album);
});
} on IsarError catch (e) {
debugPrint(e.toString());
}
return true;
}
/// Adds a new album from the device to the database and Accumulates all
/// assets already existing in the database to the list of `existing` assets
Future<void> _addAlbumFromDevice(
AssetPathEntity ape,
List<Asset> existing,
) async {
final Album a = Album.local(ape);
final result = await _linkWithExistingFromDb(await ape.getAssets());
await _upsertAssetsWithExif(result.second);
existing.addAll(result.first);
a.assets.addAll(result.first);
a.assets.addAll(result.second);
final thumb = result.first.firstOrNull ?? result.second.firstOrNull;
a.thumbnail.value = thumb;
try {
await _db.writeTxn(() => _db.albums.store(a));
} on IsarError catch (e) {
debugPrint(e.toString());
}
}
/// Returns a tuple (existing, updated)
Future<Pair<List<Asset>, List<Asset>>> _linkWithExistingFromDb(
List<Asset> assets,
) async {
if (assets.isEmpty) {
return const Pair([], []);
}
final List<Asset> inDb = await _db.assets
.where()
.anyOf(
assets,
(q, Asset e) => q.localIdDeviceIdEqualTo(e.localId, e.deviceId),
)
.sortByDeviceId()
.thenByLocalId()
.findAll();
assets.sort(Asset.compareByDeviceIdLocalId);
final List<Asset> existing = [], toUpsert = [];
diffSortedListsSync(
inDb,
assets,
compare: Asset.compareByDeviceIdLocalId,
both: (Asset a, Asset b) {
if ((a.isLocal || !b.isLocal) &&
(a.isRemote || !b.isRemote) &&
a.updatedAt == b.updatedAt) {
existing.add(a);
return false;
} else {
toUpsert.add(b.updateFromDb(a));
return true;
}
},
onlyFirst: (Asset a) => throw Exception("programming error"),
onlySecond: (Asset b) => toUpsert.add(b),
);
return Pair(existing, toUpsert);
}
/// Inserts or updates the assets in the database with their ExifInfo (if any)
Future<void> _upsertAssetsWithExif(List<Asset> assets) async {
if (assets.isEmpty) {
return;
}
final exifInfos = assets.map((e) => e.exifInfo).whereNotNull().toList();
try {
await _db.writeTxn(() async {
await _db.assets.putAll(assets);
for (final Asset added in assets) {
added.exifInfo?.id = added.id;
}
await _db.exifInfos.putAll(exifInfos);
});
} on IsarError catch (e) {
debugPrint(e.toString());
}
}
}
/// Returns a triple(toAdd, toUpdate, toRemove)
Triple<List<Asset>, List<Asset>, List<Asset>> _diffAssets(
List<Asset> assets,
List<Asset> inDb, {
bool? remote,
int Function(Asset, Asset) compare = Asset.compareByDeviceIdLocalId,
}) {
final List<Asset> toAdd = [];
final List<Asset> toUpdate = [];
final List<Asset> toRemove = [];
diffSortedListsSync(
inDb,
assets,
compare: compare,
both: (Asset a, Asset b) {
if (a.updatedAt.isBefore(b.updatedAt) ||
(!a.isLocal && b.isLocal) ||
(!a.isRemote && b.isRemote)) {
toUpdate.add(b.updateFromDb(a));
debugPrint("both");
return true;
}
return false;
},
onlyFirst: (Asset a) {
if (remote == true && a.isLocal) {
if (a.remoteId != null) {
a.remoteId = null;
toUpdate.add(a);
}
} else if (remote == false && a.isRemote) {
if (a.isLocal) {
a.isLocal = false;
toUpdate.add(a);
}
} else {
toRemove.add(a);
}
},
onlySecond: (Asset b) => toAdd.add(b),
);
return Triple(toAdd, toUpdate, toRemove);
}
/// returns a tuple (toDelete toUpdate) when assets are to be deleted
Pair<List<int>, List<Asset>> _handleAssetRemoval(
List<Asset> deleteCandidates,
List<Asset> existing,
) {
if (deleteCandidates.isEmpty) {
return const Pair([], []);
}
deleteCandidates.sort(Asset.compareById);
existing.sort(Asset.compareById);
final triple =
_diffAssets(existing, deleteCandidates, compare: Asset.compareById);
return Pair(triple.third.map((e) => e.id).toList(), triple.second);
}
/// returns `true` if the albums differ on the surface
Future<bool> _hasAssetPathEntityChanged(AssetPathEntity a, Album b) async {
return a.name != b.name ||
a.lastModified != b.modifiedAt ||
await a.assetCountAsync != b.assetCount;
}
/// returns `true` if the albums differ on the surface
bool _hasAlbumResponseDtoChanged(AlbumResponseDto dto, Album a) {
return dto.assetCount != a.assetCount ||
dto.albumName != a.name ||
dto.albumThumbnailAssetId != a.thumbnail.value?.remoteId ||
dto.shared != a.shared ||
DateTime.parse(dto.updatedAt).toUtc() != a.modifiedAt.toUtc();
}

View File

@ -3,24 +3,32 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:http/http.dart'; import 'package:http/http.dart';
import 'package:http_parser/http_parser.dart'; import 'package:http_parser/http_parser.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/models/user.dart'; import 'package:immich_mobile/shared/models/user.dart';
import 'package:immich_mobile/shared/providers/api.provider.dart'; import 'package:immich_mobile/shared/providers/api.provider.dart';
import 'package:immich_mobile/shared/providers/db.provider.dart';
import 'package:immich_mobile/shared/services/api.service.dart'; import 'package:immich_mobile/shared/services/api.service.dart';
import 'package:immich_mobile/shared/services/sync.service.dart';
import 'package:immich_mobile/utils/files_helper.dart'; import 'package:immich_mobile/utils/files_helper.dart';
import 'package:isar/isar.dart';
import 'package:openapi/api.dart'; import 'package:openapi/api.dart';
final userServiceProvider = Provider( final userServiceProvider = Provider(
(ref) => UserService( (ref) => UserService(
ref.watch(apiServiceProvider), ref.watch(apiServiceProvider),
ref.watch(dbProvider),
ref.watch(syncServiceProvider),
), ),
); );
class UserService { class UserService {
final ApiService _apiService; final ApiService _apiService;
final Isar _db;
final SyncService _syncService;
UserService(this._apiService); UserService(this._apiService, this._db, this._syncService);
Future<List<User>?> getAllUsers({required bool isAll}) async { Future<List<User>?> _getAllUsers({required bool isAll}) async {
try { try {
final dto = await _apiService.userApi.getAllUsers(isAll); final dto = await _apiService.userApi.getAllUsers(isAll);
return dto?.map(User.fromDto).toList(); return dto?.map(User.fromDto).toList();
@ -30,6 +38,14 @@ class UserService {
} }
} }
Future<List<User>> getUsersInDb({bool self = false}) async {
if (self) {
return _db.users.where().findAll();
}
final int userId = Store.get<User>(StoreKey.currentUser)!.isarId;
return _db.users.where().isarIdNotEqualTo(userId).findAll();
}
Future<CreateProfileImageResponseDto?> uploadProfileImage(XFile image) async { Future<CreateProfileImageResponseDto?> uploadProfileImage(XFile image) async {
try { try {
var mimeType = FileHelper.getMimeType(image.path); var mimeType = FileHelper.getMimeType(image.path);
@ -50,4 +66,12 @@ class UserService {
return null; return null;
} }
} }
Future<bool> refreshUsers() async {
final List<User>? users = await _getAllUsers(isAll: true);
if (users == null) {
return false;
}
return _syncService.syncUsersFromServer(users);
}
} }

View File

@ -0,0 +1,16 @@
import 'dart:async';
/// Async mutex to guarantee actions are performed sequentially and do not interleave
class AsyncMutex {
Future _running = Future.value(null);
/// Execute [operation] exclusively, after any currently running operations.
/// Returns a [Future] with the result of the [operation].
Future<T> run<T>(Future<T> Function() operation) {
final completer = Completer<T>();
_running.whenComplete(() {
completer.complete(Future<T>.sync(operation));
});
return _running = completer.future;
}
}

View File

@ -5,7 +5,11 @@ extension DurationExtension on String {
return Duration(hours: parts[0], minutes: parts[1], seconds: parts[2]); return Duration(hours: parts[0], minutes: parts[1], seconds: parts[2]);
} }
double? toDouble() { double toDouble() {
return double.tryParse(this); return double.parse(this);
}
int toInt() {
return int.parse(this);
} }
} }

View File

@ -0,0 +1,71 @@
import 'dart:async';
/// Efficiently compares two sorted lists in O(n), calling the given callback
/// for each item.
/// Return `true` if there are any differences found, else `false`
Future<bool> diffSortedLists<A, B>(
List<A> la,
List<B> lb, {
required int Function(A a, B b) compare,
required FutureOr<bool> Function(A a, B b) both,
required FutureOr<void> Function(A a) onlyFirst,
required FutureOr<void> Function(B b) onlySecond,
}) async {
bool diff = false;
int i = 0, j = 0;
for (; i < la.length && j < lb.length;) {
final int order = compare(la[i], lb[j]);
if (order == 0) {
diff |= await both(la[i++], lb[j++]);
} else if (order < 0) {
await onlyFirst(la[i++]);
diff = true;
} else if (order > 0) {
await onlySecond(lb[j++]);
diff = true;
}
}
diff |= i < la.length || j < lb.length;
for (; i < la.length; i++) {
await onlyFirst(la[i]);
}
for (; j < lb.length; j++) {
await onlySecond(lb[j]);
}
return diff;
}
/// Efficiently compares two sorted lists in O(n), calling the given callback
/// for each item.
/// Return `true` if there are any differences found, else `false`
bool diffSortedListsSync<A, B>(
List<A> la,
List<B> lb, {
required int Function(A a, B b) compare,
required bool Function(A a, B b) both,
required void Function(A a) onlyFirst,
required void Function(B b) onlySecond,
}) {
bool diff = false;
int i = 0, j = 0;
for (; i < la.length && j < lb.length;) {
final int order = compare(la[i], lb[j]);
if (order == 0) {
diff |= both(la[i++], lb[j++]);
} else if (order < 0) {
onlyFirst(la[i++]);
diff = true;
} else if (order > 0) {
onlySecond(lb[j++]);
diff = true;
}
}
diff |= i < la.length || j < lb.length;
for (; i < la.length; i++) {
onlyFirst(la[i]);
}
for (; j < lb.length; j++) {
onlySecond(lb[j]);
}
return diff;
}

View File

@ -0,0 +1,15 @@
/// FNV-1a 64bit hash algorithm optimized for Dart Strings
int fastHash(String string) {
var hash = 0xcbf29ce484222325;
var i = 0;
while (i < string.length) {
final codeUnit = string.codeUnitAt(i++);
hash ^= codeUnit >> 8;
hash *= 0x100000001b3;
hash ^= codeUnit & 0xFF;
hash *= 0x100000001b3;
}
return hash;
}

View File

@ -31,20 +31,20 @@ String getAlbumThumbnailUrl(
final Album album, { final Album album, {
ThumbnailFormat type = ThumbnailFormat.WEBP, ThumbnailFormat type = ThumbnailFormat.WEBP,
}) { }) {
if (album.albumThumbnailAssetId == null) { if (album.thumbnail.value?.remoteId == null) {
return ''; return '';
} }
return _getThumbnailUrl(album.albumThumbnailAssetId!, type: type); return _getThumbnailUrl(album.thumbnail.value!.remoteId!, type: type);
} }
String getAlbumThumbNailCacheKey( String getAlbumThumbNailCacheKey(
final Album album, { final Album album, {
ThumbnailFormat type = ThumbnailFormat.WEBP, ThumbnailFormat type = ThumbnailFormat.WEBP,
}) { }) {
if (album.albumThumbnailAssetId == null) { if (album.thumbnail.value?.remoteId == null) {
return ''; return '';
} }
return _getThumbnailCacheKey(album.albumThumbnailAssetId!, type); return _getThumbnailCacheKey(album.thumbnail.value!.remoteId!, type);
} }
String getImageUrl(final Asset asset) { String getImageUrl(final Asset asset) {

View File

@ -1,7 +1,11 @@
// ignore_for_file: deprecated_member_use_from_same_package
import 'package:flutter/cupertino.dart'; import 'package:flutter/cupertino.dart';
import 'package:hive/hive.dart'; import 'package:hive/hive.dart';
import 'package:immich_mobile/constants/hive_box.dart'; import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
import 'package:immich_mobile/shared/models/store.dart'; import 'package:immich_mobile/shared/models/store.dart';
import 'package:immich_mobile/shared/services/asset_cache.service.dart';
Future<void> migrateHiveToStoreIfNecessary() async { Future<void> migrateHiveToStoreIfNecessary() async {
try { try {
@ -22,3 +26,9 @@ _migrateSingleKey(Box box, String hiveKey, StoreKey key) async {
await box.delete(hiveKey); await box.delete(hiveKey);
} }
} }
Future<void> migrateJsonCacheIfNecessary() async {
await AlbumCacheService().invalidate();
await SharedAlbumCacheService().invalidate();
await AssetCacheService().invalidate();
}

View File

@ -6,3 +6,13 @@ class Pair<T1, T2> {
const Pair(this.first, this.second); const Pair(this.first, this.second);
} }
/// An immutable triple or 3-tuple
/// TODO replace with Record once Dart 2.19 is available
class Triple<T1, T2, T3> {
final T1 first;
final T2 second;
final T3 third;
const Triple(this.first, this.second, this.third);
}

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -13,14 +13,16 @@ void main() {
testAssets.add( testAssets.add(
Asset( Asset(
deviceAssetId: '$i', localId: '$i',
deviceId: '', deviceId: 1,
ownerId: '', ownerId: 1,
fileCreatedAt: date, fileCreatedAt: date,
fileModifiedAt: date, fileModifiedAt: date,
updatedAt: date,
durationInSeconds: 0, durationInSeconds: 0,
fileName: '', fileName: '',
isFavorite: false, isFavorite: false,
isLocal: false,
), ),
); );
} }

View File

@ -0,0 +1,50 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:immich_mobile/utils/diff.dart';
void main() {
final List<int> listA = [1, 2, 3, 4, 6];
final List<int> listB = [1, 3, 5, 7];
group('Test grouped', () {
test('test partial overlap', () async {
final List<int> onlyInA = [];
final List<int> onlyInB = [];
final List<int> inBoth = [];
final changes = await diffSortedLists(
listA,
listB,
compare: (int a, int b) => a.compareTo(b),
both: (int a, int b) {
inBoth.add(b);
return false;
},
onlyFirst: (int a) => onlyInA.add(a),
onlySecond: (int b) => onlyInB.add(b),
);
expect(changes, true);
expect(onlyInA, [2, 4, 6]);
expect(onlyInB, [5, 7]);
expect(inBoth, [1, 3]);
});
test('test partial overlap sync', () {
final List<int> onlyInA = [];
final List<int> onlyInB = [];
final List<int> inBoth = [];
final changes = diffSortedListsSync(
listA,
listB,
compare: (int a, int b) => a.compareTo(b),
both: (int a, int b) {
inBoth.add(b);
return false;
},
onlyFirst: (int a) => onlyInA.add(a),
onlySecond: (int b) => onlyInB.add(b),
);
expect(changes, true);
expect(onlyInA, [2, 4, 6]);
expect(onlyInB, [5, 7]);
expect(inBoth, [1, 3]);
});
});
}

View File

@ -12,75 +12,81 @@ import 'package:mockito/mockito.dart';
]) ])
import 'favorite_provider_test.mocks.dart'; import 'favorite_provider_test.mocks.dart';
Asset _getTestAsset(String id, bool favorite) { Asset _getTestAsset(int id, bool favorite) {
return Asset( final Asset a = Asset(
remoteId: id, remoteId: id.toString(),
deviceAssetId: '', localId: id.toString(),
deviceId: '', deviceId: 1,
ownerId: '', ownerId: 1,
fileCreatedAt: DateTime.now(), fileCreatedAt: DateTime.now(),
fileModifiedAt: DateTime.now(), fileModifiedAt: DateTime.now(),
updatedAt: DateTime.now(),
isLocal: false,
durationInSeconds: 0, durationInSeconds: 0,
fileName: '', fileName: '',
isFavorite: favorite, isFavorite: favorite,
); );
a.id = id;
return a;
} }
void main() { void main() {
group("Test favoriteProvider", () { group("Test favoriteProvider", () {
late MockAssetsState assetsState; late MockAssetsState assetsState;
late MockAssetNotifier assetNotifier; late MockAssetNotifier assetNotifier;
late ProviderContainer container; late ProviderContainer container;
late StateNotifierProvider<FavoriteSelectionNotifier, Set<String>> testFavoritesProvider; late StateNotifierProvider<FavoriteSelectionNotifier, Set<int>>
testFavoritesProvider;
setUp(() { setUp(
assetsState = MockAssetsState(); () {
assetNotifier = MockAssetNotifier(); assetsState = MockAssetsState();
container = ProviderContainer(); assetNotifier = MockAssetNotifier();
container = ProviderContainer();
testFavoritesProvider = testFavoritesProvider =
StateNotifierProvider<FavoriteSelectionNotifier, Set<String>>((ref) { StateNotifierProvider<FavoriteSelectionNotifier, Set<int>>((ref) {
return FavoriteSelectionNotifier( return FavoriteSelectionNotifier(
assetsState, assetsState,
assetNotifier, assetNotifier,
); );
}); });
},); },
);
test("Empty favorites provider", () { test("Empty favorites provider", () {
when(assetsState.allAssets).thenReturn([]); when(assetsState.allAssets).thenReturn([]);
expect(<String>{}, container.read(testFavoritesProvider)); expect(<int>{}, container.read(testFavoritesProvider));
}); });
test("Non-empty favorites provider", () { test("Non-empty favorites provider", () {
when(assetsState.allAssets).thenReturn([ when(assetsState.allAssets).thenReturn([
_getTestAsset("001", false), _getTestAsset(1, false),
_getTestAsset("002", true), _getTestAsset(2, true),
_getTestAsset("003", false), _getTestAsset(3, false),
_getTestAsset("004", false), _getTestAsset(4, false),
_getTestAsset("005", true), _getTestAsset(5, true),
]); ]);
expect(<String>{"002", "005"}, container.read(testFavoritesProvider)); expect(<int>{2, 5}, container.read(testFavoritesProvider));
}); });
test("Toggle favorite", () { test("Toggle favorite", () {
when(assetNotifier.toggleFavorite(null, false)) when(assetNotifier.toggleFavorite(null, false))
.thenAnswer((_) async => false); .thenAnswer((_) async => false);
final testAsset1 = _getTestAsset("001", false); final testAsset1 = _getTestAsset(1, false);
final testAsset2 = _getTestAsset("002", true); final testAsset2 = _getTestAsset(2, true);
when(assetsState.allAssets).thenReturn([testAsset1, testAsset2]); when(assetsState.allAssets).thenReturn([testAsset1, testAsset2]);
expect(<String>{"002"}, container.read(testFavoritesProvider)); expect(<int>{2}, container.read(testFavoritesProvider));
container.read(testFavoritesProvider.notifier).toggleFavorite(testAsset2); container.read(testFavoritesProvider.notifier).toggleFavorite(testAsset2);
expect(<String>{}, container.read(testFavoritesProvider)); expect(<int>{}, container.read(testFavoritesProvider));
container.read(testFavoritesProvider.notifier).toggleFavorite(testAsset1); container.read(testFavoritesProvider.notifier).toggleFavorite(testAsset1);
expect(<String>{"001"}, container.read(testFavoritesProvider)); expect(<int>{1}, container.read(testFavoritesProvider));
}); });
test("Add favorites", () { test("Add favorites", () {
@ -89,16 +95,16 @@ void main() {
when(assetsState.allAssets).thenReturn([]); when(assetsState.allAssets).thenReturn([]);
expect(<String>{}, container.read(testFavoritesProvider)); expect(<int>{}, container.read(testFavoritesProvider));
container.read(testFavoritesProvider.notifier).addToFavorites( container.read(testFavoritesProvider.notifier).addToFavorites(
[ [
_getTestAsset("001", false), _getTestAsset(1, false),
_getTestAsset("002", false), _getTestAsset(2, false),
], ],
); );
expect(<String>{"001", "002"}, container.read(testFavoritesProvider)); expect(<int>{1, 2}, container.read(testFavoritesProvider));
}); });
}); });
} }

View File

@ -187,7 +187,7 @@ class MockAssetNotifier extends _i1.Mock implements _i2.AssetNotifier {
returnValueForMissingStub: _i5.Future<void>.value(), returnValueForMissingStub: _i5.Future<void>.value(),
) as _i5.Future<void>); ) as _i5.Future<void>);
@override @override
void onNewAssetUploaded(_i4.Asset? newAsset) => super.noSuchMethod( Future<void> onNewAssetUploaded(_i4.Asset? newAsset) => super.noSuchMethod(
Invocation.method( Invocation.method(
#onNewAssetUploaded, #onNewAssetUploaded,
[newAsset], [newAsset],
@ -195,7 +195,7 @@ class MockAssetNotifier extends _i1.Mock implements _i2.AssetNotifier {
returnValueForMissingStub: null, returnValueForMissingStub: null,
); );
@override @override
dynamic deleteAssets(Set<_i4.Asset>? deleteAssets) => super.noSuchMethod( Future<void> deleteAssets(Set<_i4.Asset> deleteAssets) => super.noSuchMethod(
Invocation.method( Invocation.method(
#deleteAssets, #deleteAssets,
[deleteAssets], [deleteAssets],

View File

@ -101,6 +101,7 @@ describe('User', () => {
shouldChangePassword: true, shouldChangePassword: true,
profileImagePath: '', profileImagePath: '',
deletedAt: null, deletedAt: null,
updatedAt: expect.anything(),
oauthId: '', oauthId: '',
}, },
{ {
@ -113,6 +114,7 @@ describe('User', () => {
shouldChangePassword: true, shouldChangePassword: true,
profileImagePath: '', profileImagePath: '',
deletedAt: null, deletedAt: null,
updatedAt: expect.anything(),
oauthId: '', oauthId: '',
}, },
]), ]),

View File

@ -3583,6 +3583,9 @@
"format": "date-time", "format": "date-time",
"type": "string" "type": "string"
}, },
"updatedAt": {
"type": "string"
},
"oauthId": { "oauthId": {
"type": "string" "type": "string"
} }

View File

@ -10,6 +10,7 @@ export class UserResponseDto {
shouldChangePassword!: boolean; shouldChangePassword!: boolean;
isAdmin!: boolean; isAdmin!: boolean;
deletedAt?: Date; deletedAt?: Date;
updatedAt?: string;
oauthId!: string; oauthId!: string;
} }
@ -24,6 +25,7 @@ export function mapUser(entity: UserEntity): UserResponseDto {
shouldChangePassword: entity.shouldChangePassword, shouldChangePassword: entity.shouldChangePassword,
isAdmin: entity.isAdmin, isAdmin: entity.isAdmin,
deletedAt: entity.deletedAt, deletedAt: entity.deletedAt,
updatedAt: entity.updatedAt,
oauthId: entity.oauthId, oauthId: entity.oauthId,
}; };
} }

View File

@ -100,6 +100,7 @@ const adminUserResponse = Object.freeze({
shouldChangePassword: false, shouldChangePassword: false,
profileImagePath: '', profileImagePath: '',
createdAt: '2021-01-01', createdAt: '2021-01-01',
updatedAt: '2021-01-01',
}); });
describe(UserService.name, () => { describe(UserService.name, () => {
@ -162,6 +163,7 @@ describe(UserService.name, () => {
shouldChangePassword: false, shouldChangePassword: false,
profileImagePath: '', profileImagePath: '',
createdAt: '2021-01-01', createdAt: '2021-01-01',
updatedAt: '2021-01-01',
}, },
]); ]);
}); });

View File

@ -2406,6 +2406,12 @@ export interface UserResponseDto {
* @memberof UserResponseDto * @memberof UserResponseDto
*/ */
'deletedAt'?: string; 'deletedAt'?: string;
/**
*
* @type {string}
* @memberof UserResponseDto
*/
'updatedAt'?: string;
/** /**
* *
* @type {string} * @type {string}