mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00: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:
		
				
					committed by
					
						 GitHub
						GitHub
					
				
			
			
				
	
			
			
			
						parent
						
							8f11529a75
						
					
				
				
					commit
					8708867c1c
				
			| @@ -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", | ||||||
|   | |||||||
| @@ -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, | ||||||
|   ); |   ); | ||||||
|   | |||||||
| @@ -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), | ||||||
|   ); |   ); | ||||||
| }); | }); | ||||||
|   | |||||||
| @@ -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, | ||||||
|   | |||||||
| @@ -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; | ||||||
| }); | }); | ||||||
|   | |||||||
| @@ -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(); | ||||||
| }); | }); | ||||||
|   | |||||||
| @@ -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) { | ||||||
|   | |||||||
| @@ -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(), |  | ||||||
| ); |  | ||||||
|   | |||||||
| @@ -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); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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(), | ||||||
|                       ), |                       ), | ||||||
|   | |||||||
| @@ -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(), | ||||||
|             ), |             ), | ||||||
|   | |||||||
| @@ -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), | ||||||
|  |           ), | ||||||
|       ], |       ], | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|   | |||||||
| @@ -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, | ||||||
|   | |||||||
| @@ -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), | ||||||
|                 ), |                 ), | ||||||
|   | |||||||
| @@ -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, | ||||||
|  |                     ), | ||||||
|  |                   ), | ||||||
|  |                 ), | ||||||
|  |               ), | ||||||
|  |             ), | ||||||
|  |           ), | ||||||
|         ], |         ], | ||||||
|       ), |       ), | ||||||
|     ); |     ); | ||||||
|   | |||||||
| @@ -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( | ||||||
|   | |||||||
| @@ -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), | ||||||
|               ) |               ) | ||||||
|             ], |             ], | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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), | ||||||
|   ); |   ); | ||||||
| }); | }); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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 | ||||||
|   | |||||||
| @@ -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, | ||||||
|   | |||||||
| @@ -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); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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), |  | ||||||
|   ); |   ); | ||||||
| }); | }); | ||||||
|   | |||||||
| @@ -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)'; | ||||||
|   | |||||||
| @@ -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; | ||||||
|   | |||||||
| @@ -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() { | ||||||
|   | |||||||
| @@ -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(); | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										1391
									
								
								mobile/lib/shared/models/album.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1391
									
								
								mobile/lib/shared/models/album.g.dart
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -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)), | ||||||
|  |     ); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										2244
									
								
								mobile/lib/shared/models/asset.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2244
									
								
								mobile/lib/shared/models/asset.g.dart
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -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; | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										2304
									
								
								mobile/lib/shared/models/exif_info.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2304
									
								
								mobile/lib/shared/models/exif_info.g.dart
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -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); | ||||||
| } | } | ||||||
|   | |||||||
| @@ -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; |  | ||||||
|   } |  | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										1338
									
								
								mobile/lib/shared/models/user.g.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1338
									
								
								mobile/lib/shared/models/user.g.dart
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -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), | ||||||
|   ); |   ); | ||||||
| }); | }); | ||||||
|  |  | ||||||
|   | |||||||
| @@ -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; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -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, | ||||||
|   | |||||||
| @@ -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(), |  | ||||||
| ); |  | ||||||
|   | |||||||
| @@ -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(); | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										558
									
								
								mobile/lib/shared/services/sync.service.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										558
									
								
								mobile/lib/shared/services/sync.service.dart
									
									
									
									
									
										Normal 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(); | ||||||
|  | } | ||||||
| @@ -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); | ||||||
|  |   } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										16
									
								
								mobile/lib/utils/async_mutex.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										16
									
								
								mobile/lib/utils/async_mutex.dart
									
									
									
									
									
										Normal 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; | ||||||
|  |   } | ||||||
|  | } | ||||||
| @@ -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); | ||||||
|   } |   } | ||||||
| } | } | ||||||
|   | |||||||
							
								
								
									
										71
									
								
								mobile/lib/utils/diff.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										71
									
								
								mobile/lib/utils/diff.dart
									
									
									
									
									
										Normal 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; | ||||||
|  | } | ||||||
							
								
								
									
										15
									
								
								mobile/lib/utils/hash.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								mobile/lib/utils/hash.dart
									
									
									
									
									
										Normal 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; | ||||||
|  | } | ||||||
| @@ -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) { | ||||||
|   | |||||||
| @@ -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(); | ||||||
|  | } | ||||||
|   | |||||||
| @@ -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); | ||||||
|  | } | ||||||
|   | |||||||
							
								
								
									
										1
									
								
								mobile/openapi/doc/UserResponseDto.md
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										1
									
								
								mobile/openapi/doc/UserResponseDto.md
									
									
									
										generated
									
									
									
								
							| @@ -17,6 +17,7 @@ Name | Type | Description | Notes | |||||||
| **shouldChangePassword** | **bool** |  |  | **shouldChangePassword** | **bool** |  |  | ||||||
| **isAdmin** | **bool** |  |  | **isAdmin** | **bool** |  |  | ||||||
| **deletedAt** | [**DateTime**](DateTime.md) |  | [optional]  | **deletedAt** | [**DateTime**](DateTime.md) |  | [optional]  | ||||||
|  | **updatedAt** | **String** |  | [optional]  | ||||||
| **oauthId** | **String** |  |  | **oauthId** | **String** |  |  | ||||||
| 
 | 
 | ||||||
| [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) | [[Back to Model list]](../README.md#documentation-for-models) [[Back to API list]](../README.md#documentation-for-api-endpoints) [[Back to README]](../README.md) | ||||||
|   | |||||||
							
								
								
									
										19
									
								
								mobile/openapi/lib/model/user_response_dto.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										19
									
								
								mobile/openapi/lib/model/user_response_dto.dart
									
									
									
										generated
									
									
									
								
							| @@ -22,6 +22,7 @@ class UserResponseDto { | |||||||
|     required this.shouldChangePassword, |     required this.shouldChangePassword, | ||||||
|     required this.isAdmin, |     required this.isAdmin, | ||||||
|     this.deletedAt, |     this.deletedAt, | ||||||
|  |     this.updatedAt, | ||||||
|     required this.oauthId, |     required this.oauthId, | ||||||
|   }); |   }); | ||||||
| 
 | 
 | ||||||
| @@ -49,6 +50,14 @@ class UserResponseDto { | |||||||
|   /// |   /// | ||||||
|   DateTime? deletedAt; |   DateTime? deletedAt; | ||||||
| 
 | 
 | ||||||
|  |   /// | ||||||
|  |   /// Please note: This property should have been non-nullable! Since the specification file | ||||||
|  |   /// does not include a default value (using the "default:" property), however, the generated | ||||||
|  |   /// source code must fall back to having a nullable type. | ||||||
|  |   /// Consider adding a "default:" property in the specification file to hide this note. | ||||||
|  |   /// | ||||||
|  |   String? updatedAt; | ||||||
|  | 
 | ||||||
|   String oauthId; |   String oauthId; | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
| @@ -62,6 +71,7 @@ class UserResponseDto { | |||||||
|      other.shouldChangePassword == shouldChangePassword && |      other.shouldChangePassword == shouldChangePassword && | ||||||
|      other.isAdmin == isAdmin && |      other.isAdmin == isAdmin && | ||||||
|      other.deletedAt == deletedAt && |      other.deletedAt == deletedAt && | ||||||
|  |      other.updatedAt == updatedAt && | ||||||
|      other.oauthId == oauthId; |      other.oauthId == oauthId; | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
| @@ -76,10 +86,11 @@ class UserResponseDto { | |||||||
|     (shouldChangePassword.hashCode) + |     (shouldChangePassword.hashCode) + | ||||||
|     (isAdmin.hashCode) + |     (isAdmin.hashCode) + | ||||||
|     (deletedAt == null ? 0 : deletedAt!.hashCode) + |     (deletedAt == null ? 0 : deletedAt!.hashCode) + | ||||||
|  |     (updatedAt == null ? 0 : updatedAt!.hashCode) + | ||||||
|     (oauthId.hashCode); |     (oauthId.hashCode); | ||||||
| 
 | 
 | ||||||
|   @override |   @override | ||||||
|   String toString() => 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, deletedAt=$deletedAt, oauthId=$oauthId]'; |   String toString() => 'UserResponseDto[id=$id, email=$email, firstName=$firstName, lastName=$lastName, createdAt=$createdAt, profileImagePath=$profileImagePath, shouldChangePassword=$shouldChangePassword, isAdmin=$isAdmin, deletedAt=$deletedAt, updatedAt=$updatedAt, oauthId=$oauthId]'; | ||||||
| 
 | 
 | ||||||
|   Map<String, dynamic> toJson() { |   Map<String, dynamic> toJson() { | ||||||
|     final json = <String, dynamic>{}; |     final json = <String, dynamic>{}; | ||||||
| @@ -95,6 +106,11 @@ class UserResponseDto { | |||||||
|       json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String(); |       json[r'deletedAt'] = this.deletedAt!.toUtc().toIso8601String(); | ||||||
|     } else { |     } else { | ||||||
|       // json[r'deletedAt'] = null; |       // json[r'deletedAt'] = null; | ||||||
|  |     } | ||||||
|  |     if (this.updatedAt != null) { | ||||||
|  |       json[r'updatedAt'] = this.updatedAt; | ||||||
|  |     } else { | ||||||
|  |       // json[r'updatedAt'] = null; | ||||||
|     } |     } | ||||||
|       json[r'oauthId'] = this.oauthId; |       json[r'oauthId'] = this.oauthId; | ||||||
|     return json; |     return json; | ||||||
| @@ -128,6 +144,7 @@ class UserResponseDto { | |||||||
|         shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!, |         shouldChangePassword: mapValueOfType<bool>(json, r'shouldChangePassword')!, | ||||||
|         isAdmin: mapValueOfType<bool>(json, r'isAdmin')!, |         isAdmin: mapValueOfType<bool>(json, r'isAdmin')!, | ||||||
|         deletedAt: mapDateTime(json, r'deletedAt', ''), |         deletedAt: mapDateTime(json, r'deletedAt', ''), | ||||||
|  |         updatedAt: mapValueOfType<String>(json, r'updatedAt'), | ||||||
|         oauthId: mapValueOfType<String>(json, r'oauthId')!, |         oauthId: mapValueOfType<String>(json, r'oauthId')!, | ||||||
|       ); |       ); | ||||||
|     } |     } | ||||||
|   | |||||||
							
								
								
									
										5
									
								
								mobile/openapi/test/user_response_dto_test.dart
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										5
									
								
								mobile/openapi/test/user_response_dto_test.dart
									
									
									
										generated
									
									
									
								
							| @@ -61,6 +61,11 @@ void main() { | |||||||
|       // TODO |       // TODO | ||||||
|     }); |     }); | ||||||
| 
 | 
 | ||||||
|  |     // String updatedAt | ||||||
|  |     test('to test the property `updatedAt`', () async { | ||||||
|  |       // TODO | ||||||
|  |     }); | ||||||
|  | 
 | ||||||
|     // String oauthId |     // String oauthId | ||||||
|     test('to test the property `oauthId`', () async { |     test('to test the property `oauthId`', () async { | ||||||
|       // TODO |       // TODO | ||||||
|   | |||||||
| @@ -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, | ||||||
|       ), |       ), | ||||||
|     ); |     ); | ||||||
|   } |   } | ||||||
|   | |||||||
							
								
								
									
										50
									
								
								mobile/test/diff_test.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										50
									
								
								mobile/test/diff_test.dart
									
									
									
									
									
										Normal 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]); | ||||||
|  |     }); | ||||||
|  |   }); | ||||||
|  | } | ||||||
| @@ -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)); | ||||||
|     }); |     }); | ||||||
|   }); |   }); | ||||||
| } | } | ||||||
|   | |||||||
| @@ -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], | ||||||
|   | |||||||
| @@ -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: '', | ||||||
|             }, |             }, | ||||||
|           ]), |           ]), | ||||||
|   | |||||||
| @@ -3583,6 +3583,9 @@ | |||||||
|             "format": "date-time", |             "format": "date-time", | ||||||
|             "type": "string" |             "type": "string" | ||||||
|           }, |           }, | ||||||
|  |           "updatedAt": { | ||||||
|  |             "type": "string" | ||||||
|  |           }, | ||||||
|           "oauthId": { |           "oauthId": { | ||||||
|             "type": "string" |             "type": "string" | ||||||
|           } |           } | ||||||
|   | |||||||
| @@ -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, | ||||||
|   }; |   }; | ||||||
| } | } | ||||||
|   | |||||||
| @@ -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', | ||||||
|         }, |         }, | ||||||
|       ]); |       ]); | ||||||
|     }); |     }); | ||||||
|   | |||||||
							
								
								
									
										6
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										6
									
								
								web/src/api/open-api/api.ts
									
									
									
										generated
									
									
									
								
							| @@ -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} | ||||||
|   | |||||||
		Reference in New Issue
	
	Block a user