mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	feat(mobile): show local assets (#905)
* introduce Asset as composition of AssetResponseDTO and AssetEntity * filter out duplicate assets (that are both local and remote, take only remote for now) * only allow remote images to be added to albums * introduce ImmichImage to render Asset using local or remote data * optimized deletion of local assets * local video file playback * allow multiple methods to wait on background service finished * skip local assets when adding to album from home screen * fix and optimize delete * show gray box placeholder for local assets * add comments * fix bug: duplicate assets in state after onNewAssetUploaded
This commit is contained in:
		
				
					committed by
					
						 GitHub
						GitHub
					
				
			
			
				
	
			
			
			
						parent
						
							99da181cfc
						
					
				
				
					commit
					1633af7af6
				
			| @@ -134,13 +134,13 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct | ||||
|     } | ||||
|  | ||||
|     private fun stopEngine(result: Result?) { | ||||
|         clearBackgroundNotification() | ||||
|         engine?.destroy() | ||||
|         engine = null | ||||
|         if (result != null) { | ||||
|             Log.d(TAG, "stopEngine result=${result}") | ||||
|             resolvableFuture.set(result) | ||||
|         } | ||||
|         engine?.destroy() | ||||
|         engine = null | ||||
|         clearBackgroundNotification() | ||||
|         waitOnSetForegroundAsync() | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -35,10 +35,12 @@ void main() async { | ||||
|   await Future.wait([ | ||||
|     Hive.openBox(userInfoBox), | ||||
|     Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox), | ||||
|     Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox), | ||||
|     Hive.openBox(hiveGithubReleaseInfoBox), | ||||
|     Hive.openBox(userSettingInfoBox), | ||||
|     Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox), | ||||
|     if (!Platform.isAndroid) Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox), | ||||
|     if (!Platform.isAndroid) | ||||
|       Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox), | ||||
|     if (!Platform.isAndroid) Hive.openBox(backgroundBackupInfoBox), | ||||
|     EasyLocalization.ensureInitialized(), | ||||
|   ]); | ||||
|  | ||||
| @@ -86,8 +88,8 @@ class ImmichAppState extends ConsumerState<ImmichApp> | ||||
|         var isAuthenticated = ref.watch(authenticationProvider).isAuthenticated; | ||||
|  | ||||
|         if (isAuthenticated) { | ||||
|           ref.read(backupProvider.notifier).resumeBackup(); | ||||
|           ref.read(backgroundServiceProvider).resumeServiceIfEnabled(); | ||||
|           ref.watch(backupProvider.notifier).resumeBackup(); | ||||
|           ref.watch(assetProvider.notifier).getAllAsset(); | ||||
|           ref.watch(serverInfoProvider.notifier).getServerVersion(); | ||||
|         } | ||||
|   | ||||
| @@ -1,10 +1,9 @@ | ||||
| import 'package:collection/collection.dart'; | ||||
|  | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
|  | ||||
| class AssetSelectionPageResult { | ||||
|   final Set<AssetResponseDto> selectedNewAsset; | ||||
|   final Set<AssetResponseDto> selectedAdditionalAsset; | ||||
|   final Set<Asset> selectedNewAsset; | ||||
|   final Set<Asset> selectedAdditionalAsset; | ||||
|   final bool isAlbumExist; | ||||
|  | ||||
|   AssetSelectionPageResult({ | ||||
| @@ -14,8 +13,8 @@ class AssetSelectionPageResult { | ||||
|   }); | ||||
|  | ||||
|   AssetSelectionPageResult copyWith({ | ||||
|     Set<AssetResponseDto>? selectedNewAsset, | ||||
|     Set<AssetResponseDto>? selectedAdditionalAsset, | ||||
|     Set<Asset>? selectedNewAsset, | ||||
|     Set<Asset>? selectedAdditionalAsset, | ||||
|     bool? isAlbumExist, | ||||
|   }) { | ||||
|     return AssetSelectionPageResult( | ||||
|   | ||||
| @@ -1,12 +1,11 @@ | ||||
| import 'package:collection/collection.dart'; | ||||
|  | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
|  | ||||
| class AssetSelectionState { | ||||
|   final Set<String> selectedMonths; | ||||
|   final Set<AssetResponseDto> selectedNewAssetsForAlbum; | ||||
|   final Set<AssetResponseDto> selectedAdditionalAssetsForAlbum; | ||||
|   final Set<AssetResponseDto> selectedAssetsInAlbumViewer; | ||||
|   final Set<Asset> selectedNewAssetsForAlbum; | ||||
|   final Set<Asset> selectedAdditionalAssetsForAlbum; | ||||
|   final Set<Asset> selectedAssetsInAlbumViewer; | ||||
|   final bool isMultiselectEnable; | ||||
|  | ||||
|   /// Indicate the asset selection page is navigated from existing album | ||||
| @@ -22,9 +21,9 @@ class AssetSelectionState { | ||||
|  | ||||
|   AssetSelectionState copyWith({ | ||||
|     Set<String>? selectedMonths, | ||||
|     Set<AssetResponseDto>? selectedNewAssetsForAlbum, | ||||
|     Set<AssetResponseDto>? selectedAdditionalAssetsForAlbum, | ||||
|     Set<AssetResponseDto>? selectedAssetsInAlbumViewer, | ||||
|     Set<Asset>? selectedNewAssetsForAlbum, | ||||
|     Set<Asset>? selectedAdditionalAssetsForAlbum, | ||||
|     Set<Asset>? selectedAssetsInAlbumViewer, | ||||
|     bool? isMultiselectEnable, | ||||
|     bool? isAlbumExist, | ||||
|   }) { | ||||
|   | ||||
| @@ -1,6 +1,7 @@ | ||||
| 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_cache.service.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
|  | ||||
| class AlbumNotifier extends StateNotifier<List<AlbumResponseDto>> { | ||||
| @@ -13,7 +14,6 @@ class AlbumNotifier extends StateNotifier<List<AlbumResponseDto>> { | ||||
|   } | ||||
|  | ||||
|   getAllAlbums() async { | ||||
|  | ||||
|     if (await _albumCacheService.isValid() && state.isEmpty) { | ||||
|       state = await _albumCacheService.get(); | ||||
|     } | ||||
| @@ -34,7 +34,7 @@ class AlbumNotifier extends StateNotifier<List<AlbumResponseDto>> { | ||||
|  | ||||
|   Future<AlbumResponseDto?> createAlbum( | ||||
|     String albumTitle, | ||||
|     Set<AssetResponseDto> assets, | ||||
|     Set<Asset> assets, | ||||
|   ) async { | ||||
|     AlbumResponseDto? album = | ||||
|         await _albumService.createAlbum(albumTitle, assets, []); | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/album/models/asset_selection_state.model.dart'; | ||||
|  | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
|  | ||||
| class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> { | ||||
|   AssetSelectionNotifier() | ||||
| @@ -22,15 +21,15 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> { | ||||
|  | ||||
|   void removeAssetsInMonth( | ||||
|     String removedMonth, | ||||
|     List<AssetResponseDto> assetsInMonth, | ||||
|     List<Asset> assetsInMonth, | ||||
|   ) { | ||||
|     Set<AssetResponseDto> currentAssetList = state.selectedNewAssetsForAlbum; | ||||
|     Set<Asset> currentAssetList = state.selectedNewAssetsForAlbum; | ||||
|     Set<String> currentMonthList = state.selectedMonths; | ||||
|  | ||||
|     currentMonthList | ||||
|         .removeWhere((selectedMonth) => selectedMonth == removedMonth); | ||||
|  | ||||
|     for (AssetResponseDto asset in assetsInMonth) { | ||||
|     for (Asset asset in assetsInMonth) { | ||||
|       currentAssetList.removeWhere((e) => e.id == asset.id); | ||||
|     } | ||||
|  | ||||
| @@ -40,7 +39,7 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> { | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void addAdditionalAssets(List<AssetResponseDto> assets) { | ||||
|   void addAdditionalAssets(List<Asset> assets) { | ||||
|     state = state.copyWith( | ||||
|       selectedAdditionalAssetsForAlbum: { | ||||
|         ...state.selectedAdditionalAssetsForAlbum, | ||||
| @@ -49,7 +48,7 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> { | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void addAllAssetsInMonth(String month, List<AssetResponseDto> assetsInMonth) { | ||||
|   void addAllAssetsInMonth(String month, List<Asset> assetsInMonth) { | ||||
|     state = state.copyWith( | ||||
|       selectedMonths: {...state.selectedMonths, month}, | ||||
|       selectedNewAssetsForAlbum: { | ||||
| @@ -59,7 +58,7 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> { | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void addNewAssets(List<AssetResponseDto> assets) { | ||||
|   void addNewAssets(List<Asset> assets) { | ||||
|     state = state.copyWith( | ||||
|       selectedNewAssetsForAlbum: { | ||||
|         ...state.selectedNewAssetsForAlbum, | ||||
| @@ -68,20 +67,20 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> { | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void removeSelectedNewAssets(List<AssetResponseDto> assets) { | ||||
|     Set<AssetResponseDto> currentList = state.selectedNewAssetsForAlbum; | ||||
|   void removeSelectedNewAssets(List<Asset> assets) { | ||||
|     Set<Asset> currentList = state.selectedNewAssetsForAlbum; | ||||
|  | ||||
|     for (AssetResponseDto asset in assets) { | ||||
|     for (Asset asset in assets) { | ||||
|       currentList.removeWhere((e) => e.id == asset.id); | ||||
|     } | ||||
|  | ||||
|     state = state.copyWith(selectedNewAssetsForAlbum: currentList); | ||||
|   } | ||||
|  | ||||
|   void removeSelectedAdditionalAssets(List<AssetResponseDto> assets) { | ||||
|     Set<AssetResponseDto> currentList = state.selectedAdditionalAssetsForAlbum; | ||||
|   void removeSelectedAdditionalAssets(List<Asset> assets) { | ||||
|     Set<Asset> currentList = state.selectedAdditionalAssetsForAlbum; | ||||
|  | ||||
|     for (AssetResponseDto asset in assets) { | ||||
|     for (Asset asset in assets) { | ||||
|       currentList.removeWhere((e) => e.id == asset.id); | ||||
|     } | ||||
|  | ||||
| @@ -109,7 +108,7 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> { | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void addAssetsInAlbumViewer(List<AssetResponseDto> assets) { | ||||
|   void addAssetsInAlbumViewer(List<Asset> assets) { | ||||
|     state = state.copyWith( | ||||
|       selectedAssetsInAlbumViewer: { | ||||
|         ...state.selectedAssetsInAlbumViewer, | ||||
| @@ -118,10 +117,10 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> { | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void removeAssetsInAlbumViewer(List<AssetResponseDto> assets) { | ||||
|     Set<AssetResponseDto> currentList = state.selectedAssetsInAlbumViewer; | ||||
|   void removeAssetsInAlbumViewer(List<Asset> assets) { | ||||
|     Set<Asset> currentList = state.selectedAssetsInAlbumViewer; | ||||
|  | ||||
|     for (AssetResponseDto asset in assets) { | ||||
|     for (Asset asset in assets) { | ||||
|       currentList.removeWhere((e) => e.id == asset.id); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -2,10 +2,12 @@ import 'package:flutter/material.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/album/services/album.service.dart'; | ||||
| import 'package:immich_mobile/modules/album/services/album_cache.service.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
|  | ||||
| class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> { | ||||
|   SharedAlbumNotifier(this._sharedAlbumService, this._sharedAlbumCacheService) : super([]); | ||||
|   SharedAlbumNotifier(this._sharedAlbumService, this._sharedAlbumCacheService) | ||||
|       : super([]); | ||||
|  | ||||
|   final AlbumService _sharedAlbumService; | ||||
|   final SharedAlbumCacheService _sharedAlbumCacheService; | ||||
| @@ -16,7 +18,7 @@ class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> { | ||||
|  | ||||
|   Future<AlbumResponseDto?> createSharedAlbum( | ||||
|     String albumName, | ||||
|     Set<AssetResponseDto> assets, | ||||
|     Set<Asset> assets, | ||||
|     List<String> sharedUserIds, | ||||
|   ) async { | ||||
|     try { | ||||
|   | ||||
| @@ -2,6 +2,7 @@ import 'dart:async'; | ||||
|  | ||||
| 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/providers/api.provider.dart'; | ||||
| import 'package:immich_mobile/shared/services/api.service.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
| @@ -29,7 +30,7 @@ class AlbumService { | ||||
|  | ||||
|   Future<AlbumResponseDto?> createAlbum( | ||||
|     String albumName, | ||||
|     Set<AssetResponseDto> assets, | ||||
|     Iterable<Asset> assets, | ||||
|     List<String> sharedUserIds, | ||||
|   ) async { | ||||
|     try { | ||||
| @@ -65,7 +66,7 @@ class AlbumService { | ||||
|   } | ||||
|  | ||||
|   Future<AlbumResponseDto?> createAlbumWithGeneratedName( | ||||
|     Set<AssetResponseDto> assets, | ||||
|     Iterable<Asset> assets, | ||||
|   ) async { | ||||
|     return createAlbum( | ||||
|         _getNextAlbumName(await getAlbums(isShared: false)), assets, []); | ||||
| @@ -81,7 +82,7 @@ class AlbumService { | ||||
|   } | ||||
|  | ||||
|   Future<AddAssetsResponseDto?> addAdditionalAssetToAlbum( | ||||
|     Set<AssetResponseDto> assets, | ||||
|     Iterable<Asset> assets, | ||||
|     String albumId, | ||||
|   ) async { | ||||
|     try { | ||||
|   | ||||
| @@ -1,18 +1,15 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:cached_network_image/cached_network_image.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:hive_flutter/hive_flutter.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/constants/hive_box.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/routing/router.dart'; | ||||
| import 'package:immich_mobile/utils/image_url_builder.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_image.dart'; | ||||
|  | ||||
| class AlbumViewerThumbnail extends HookConsumerWidget { | ||||
|   final AssetResponseDto asset; | ||||
|   final List<AssetResponseDto> assetList; | ||||
|   final Asset asset; | ||||
|   final List<Asset> assetList; | ||||
|   final bool showStorageIndicator; | ||||
|  | ||||
|   const AlbumViewerThumbnail({ | ||||
| @@ -24,8 +21,6 @@ class AlbumViewerThumbnail extends HookConsumerWidget { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     var box = Hive.box(userInfoBox); | ||||
|     var thumbnailRequestUrl = getThumbnailUrl(asset); | ||||
|     var deviceId = ref.watch(authenticationProvider).deviceId; | ||||
|     final selectedAssetsInAlbumViewer = | ||||
|         ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer; | ||||
| @@ -120,27 +115,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget { | ||||
|     _buildThumbnailImage() { | ||||
|       return Container( | ||||
|         decoration: BoxDecoration(border: drawBorderColor()), | ||||
|         child: CachedNetworkImage( | ||||
|           cacheKey: asset.id, | ||||
|           width: 300, | ||||
|           height: 300, | ||||
|           memCacheHeight: 200, | ||||
|           fit: BoxFit.cover, | ||||
|           imageUrl: thumbnailRequestUrl, | ||||
|           httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, | ||||
|           fadeInDuration: const Duration(milliseconds: 250), | ||||
|           progressIndicatorBuilder: (context, url, downloadProgress) => | ||||
|               Transform.scale( | ||||
|             scale: 0.2, | ||||
|             child: CircularProgressIndicator(value: downloadProgress.progress), | ||||
|           ), | ||||
|           errorWidget: (context, url, error) { | ||||
|             return Icon( | ||||
|               Icons.image_not_supported_outlined, | ||||
|               color: Theme.of(context).primaryColor, | ||||
|             ); | ||||
|           }, | ||||
|         ), | ||||
|         child: ImmichImage(asset, width: 300, height: 300), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
| @@ -167,7 +142,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget { | ||||
|         children: [ | ||||
|           _buildThumbnailImage(), | ||||
|           if (showStorageIndicator) _buildAssetStoreLocationIcon(), | ||||
|           if (asset.type != AssetTypeEnum.IMAGE) _buildVideoLabel(), | ||||
|           if (!asset.isImage) _buildVideoLabel(), | ||||
|           if (isMultiSelectionEnable) _buildAssetSelectionIcon(), | ||||
|         ], | ||||
|       ), | ||||
|   | ||||
| @@ -1,10 +1,10 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/album/ui/selection_thumbnail_image.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
|  | ||||
| class AssetGridByMonth extends HookConsumerWidget { | ||||
|   final List<AssetResponseDto> assetGroup; | ||||
|   final List<Asset> assetGroup; | ||||
|   const AssetGridByMonth({Key? key, required this.assetGroup}) | ||||
|       : super(key: key); | ||||
|   @override | ||||
|   | ||||
| @@ -2,11 +2,11 @@ import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
|  | ||||
| class MonthGroupTitle extends HookConsumerWidget { | ||||
|   final String month; | ||||
|   final List<AssetResponseDto> assetGroup; | ||||
|   final List<Asset> assetGroup; | ||||
|  | ||||
|   const MonthGroupTitle({ | ||||
|     Key? key, | ||||
|   | ||||
| @@ -1,29 +1,24 @@ | ||||
| import 'package:cached_network_image/cached_network_image.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:hive_flutter/hive_flutter.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/constants/hive_box.dart'; | ||||
| import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart'; | ||||
| import 'package:immich_mobile/utils/image_url_builder.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_image.dart'; | ||||
|  | ||||
| class SelectionThumbnailImage extends HookConsumerWidget { | ||||
|   final AssetResponseDto asset; | ||||
|   final Asset asset; | ||||
|  | ||||
|   const SelectionThumbnailImage({Key? key, required this.asset}) | ||||
|       : super(key: key); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     var box = Hive.box(userInfoBox); | ||||
|     var thumbnailRequestUrl = getThumbnailUrl(asset); | ||||
|     var selectedAsset = | ||||
|         ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum; | ||||
|     var newAssetsForAlbum = | ||||
|         ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum; | ||||
|     var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist; | ||||
|  | ||||
|     Widget _buildSelectionIcon(AssetResponseDto asset) { | ||||
|     Widget _buildSelectionIcon(Asset asset) { | ||||
|       var isSelected = selectedAsset.map((item) => item.id).contains(asset.id); | ||||
|       var isNewlySelected = | ||||
|           newAssetsForAlbum.map((item) => item.id).contains(asset.id); | ||||
| @@ -110,30 +105,7 @@ class SelectionThumbnailImage extends HookConsumerWidget { | ||||
|         children: [ | ||||
|           Container( | ||||
|             decoration: BoxDecoration(border: drawBorderColor()), | ||||
|             child: CachedNetworkImage( | ||||
|               cacheKey: asset.id, | ||||
|               width: 150, | ||||
|               height: 150, | ||||
|               memCacheHeight: asset.type == AssetTypeEnum.IMAGE ? 150 : 150, | ||||
|               fit: BoxFit.cover, | ||||
|               imageUrl: thumbnailRequestUrl, | ||||
|               httpHeaders: { | ||||
|                 "Authorization": "Bearer ${box.get(accessTokenKey)}" | ||||
|               }, | ||||
|               fadeInDuration: const Duration(milliseconds: 250), | ||||
|               progressIndicatorBuilder: (context, url, downloadProgress) => | ||||
|                   Transform.scale( | ||||
|                 scale: 0.2, | ||||
|                 child: | ||||
|                     CircularProgressIndicator(value: downloadProgress.progress), | ||||
|               ), | ||||
|               errorWidget: (context, url, error) { | ||||
|                 return Icon( | ||||
|                   Icons.image_not_supported_outlined, | ||||
|                   color: Theme.of(context).primaryColor, | ||||
|                 ); | ||||
|               }, | ||||
|             ), | ||||
|             child: ImmichImage(asset, width: 150, height: 150), | ||||
|           ), | ||||
|           Padding( | ||||
|             padding: const EdgeInsets.all(3.0), | ||||
| @@ -142,7 +114,7 @@ class SelectionThumbnailImage extends HookConsumerWidget { | ||||
|               child: _buildSelectionIcon(asset), | ||||
|             ), | ||||
|           ), | ||||
|           if (asset.type != AssetTypeEnum.IMAGE) | ||||
|           if (!asset.isImage) | ||||
|             Positioned( | ||||
|               bottom: 5, | ||||
|               right: 5, | ||||
|   | ||||
| @@ -1,49 +1,23 @@ | ||||
| import 'package:cached_network_image/cached_network_image.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:hive_flutter/hive_flutter.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/constants/hive_box.dart'; | ||||
| import 'package:immich_mobile/utils/image_url_builder.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_image.dart'; | ||||
|  | ||||
| class SharedAlbumThumbnailImage extends HookConsumerWidget { | ||||
|   final AssetResponseDto asset; | ||||
|   final Asset asset; | ||||
|  | ||||
|   const SharedAlbumThumbnailImage({Key? key, required this.asset}) | ||||
|       : super(key: key); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     var box = Hive.box(userInfoBox); | ||||
|  | ||||
|     return GestureDetector( | ||||
|       onTap: () { | ||||
|         // debugPrint("View ${asset.id}"); | ||||
|       }, | ||||
|       child: Stack( | ||||
|         children: [ | ||||
|           CachedNetworkImage( | ||||
|             cacheKey: asset.id, | ||||
|             width: 500, | ||||
|             height: 500, | ||||
|             memCacheHeight: 500, | ||||
|             fit: BoxFit.cover, | ||||
|             imageUrl: getThumbnailUrl(asset), | ||||
|             httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"}, | ||||
|             fadeInDuration: const Duration(milliseconds: 250), | ||||
|             progressIndicatorBuilder: (context, url, downloadProgress) => | ||||
|                 Transform.scale( | ||||
|               scale: 0.2, | ||||
|               child: | ||||
|                   CircularProgressIndicator(value: downloadProgress.progress), | ||||
|             ), | ||||
|             errorWidget: (context, url, error) { | ||||
|               return Icon( | ||||
|                 Icons.image_not_supported_outlined, | ||||
|                 color: Theme.of(context).primaryColor, | ||||
|               ); | ||||
|             }, | ||||
|           ), | ||||
|           ImmichImage(asset, width: 500, height: 500), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   | ||||
| @@ -16,6 +16,7 @@ import 'package:immich_mobile/modules/album/ui/album_viewer_thumbnail.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/routing/router.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart'; | ||||
| import 'package:immich_mobile/shared/views/immich_loading_overlay.dart'; | ||||
| @@ -38,9 +39,9 @@ class AlbumViewerPage extends HookConsumerWidget { | ||||
|     /// If they exist, add to selected asset state to show they are already selected. | ||||
|     void _onAddPhotosPressed(AlbumResponseDto albumInfo) async { | ||||
|       if (albumInfo.assets.isNotEmpty == true) { | ||||
|         ref | ||||
|             .watch(assetSelectionProvider.notifier) | ||||
|             .addNewAssets(albumInfo.assets.toList()); | ||||
|         ref.watch(assetSelectionProvider.notifier).addNewAssets( | ||||
|               albumInfo.assets.map((e) => Asset.remote(e)).toList(), | ||||
|             ); | ||||
|       } | ||||
|  | ||||
|       ref.watch(assetSelectionProvider.notifier).setIsAlbumExist(true); | ||||
| @@ -205,8 +206,9 @@ class AlbumViewerPage extends HookConsumerWidget { | ||||
|             delegate: SliverChildBuilderDelegate( | ||||
|               (BuildContext context, int index) { | ||||
|                 return AlbumViewerThumbnail( | ||||
|                   asset: albumInfo.assets[index], | ||||
|                   assetList: albumInfo.assets, | ||||
|                   asset: Asset.remote(albumInfo.assets[index]), | ||||
|                   assetList: | ||||
|                       albumInfo.assets.map((e) => Asset.remote(e)).toList(), | ||||
|                   showStorageIndicator: showStorageIndicator, | ||||
|                 ); | ||||
|               }, | ||||
|   | ||||
| @@ -166,7 +166,7 @@ class CreateAlbumPage extends HookConsumerWidget { | ||||
|                 return GestureDetector( | ||||
|                   onTap: _onBackgroundTapped, | ||||
|                   child: SharedAlbumThumbnailImage( | ||||
|                     asset: selectedAssets.toList()[index], | ||||
|                     asset: selectedAssets.elementAt(index), | ||||
|                   ), | ||||
|                 ); | ||||
|               }, | ||||
|   | ||||
| @@ -18,7 +18,6 @@ class SharingPage extends HookConsumerWidget { | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     var box = Hive.box(userInfoBox); | ||||
|     var thumbnailRequestUrl = '${box.get(serverEndpointKey)}/asset/thumbnail'; | ||||
|     final List<AlbumResponseDto> sharedAlbums = ref.watch(sharedAlbumProvider); | ||||
|  | ||||
|     useEffect( | ||||
|   | ||||
| @@ -1,9 +1,9 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:fluttertoast/fluttertoast.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart'; | ||||
| import 'package:immich_mobile/modules/asset_viewer/services/image_viewer.service.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/services/share.service.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_toast.dart'; | ||||
| import 'package:immich_mobile/shared/ui/share_dialog.dart'; | ||||
| @@ -47,7 +47,7 @@ class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> { | ||||
|     state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.idle); | ||||
|   } | ||||
|  | ||||
|   void shareAsset(AssetResponseDto asset, BuildContext context) async { | ||||
|   void shareAsset(Asset asset, BuildContext context) async { | ||||
|     showDialog( | ||||
|       context: context, | ||||
|       builder: (BuildContext buildContext) { | ||||
|   | ||||
| @@ -2,12 +2,13 @@ import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_map/flutter_map.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:path/path.dart' as p; | ||||
| import 'package:latlong2/latlong.dart'; | ||||
|  | ||||
| class ExifBottomSheet extends ConsumerWidget { | ||||
|   final AssetResponseDto assetDetail; | ||||
|   final Asset assetDetail; | ||||
|  | ||||
|   const ExifBottomSheet({Key? key, required this.assetDetail}) | ||||
|       : super(key: key); | ||||
| @@ -26,8 +27,8 @@ class ExifBottomSheet extends ConsumerWidget { | ||||
|           child: FlutterMap( | ||||
|             options: MapOptions( | ||||
|               center: LatLng( | ||||
|                 assetDetail.exifInfo?.latitude?.toDouble() ?? 0, | ||||
|                 assetDetail.exifInfo?.longitude?.toDouble() ?? 0, | ||||
|                 assetDetail.latitude ?? 0, | ||||
|                 assetDetail.longitude ?? 0, | ||||
|               ), | ||||
|               zoom: 16.0, | ||||
|             ), | ||||
| @@ -48,8 +49,8 @@ class ExifBottomSheet extends ConsumerWidget { | ||||
|                   Marker( | ||||
|                     anchorPos: AnchorPos.align(AnchorAlign.top), | ||||
|                     point: LatLng( | ||||
|                       assetDetail.exifInfo?.latitude?.toDouble() ?? 0, | ||||
|                       assetDetail.exifInfo?.longitude?.toDouble() ?? 0, | ||||
|                       assetDetail.latitude ?? 0, | ||||
|                       assetDetail.longitude ?? 0, | ||||
|                     ), | ||||
|                     builder: (ctx) => const Image( | ||||
|                       image: AssetImage('assets/location-pin.png'), | ||||
| @@ -63,9 +64,11 @@ class ExifBottomSheet extends ConsumerWidget { | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     ExifResponseDto? exifInfo = assetDetail.remote?.exifInfo; | ||||
|  | ||||
|     _buildLocationText() { | ||||
|       return Text( | ||||
|         "${assetDetail.exifInfo!.city}, ${assetDetail.exifInfo!.state}", | ||||
|         "${exifInfo?.city}, ${exifInfo?.state}", | ||||
|         style: TextStyle( | ||||
|           fontSize: 12, | ||||
|           color: Colors.grey[200], | ||||
| @@ -78,10 +81,10 @@ class ExifBottomSheet extends ConsumerWidget { | ||||
|       padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8), | ||||
|       child: ListView( | ||||
|         children: [ | ||||
|           if (assetDetail.exifInfo?.dateTimeOriginal != null) | ||||
|           if (exifInfo?.dateTimeOriginal != null) | ||||
|             Text( | ||||
|               DateFormat('date_format'.tr()).format( | ||||
|                 assetDetail.exifInfo!.dateTimeOriginal!.toLocal(), | ||||
|                 exifInfo!.dateTimeOriginal!.toLocal(), | ||||
|               ), | ||||
|               style: TextStyle( | ||||
|                 color: Colors.grey[400], | ||||
| @@ -101,7 +104,7 @@ class ExifBottomSheet extends ConsumerWidget { | ||||
|           ), | ||||
|  | ||||
|           // Location | ||||
|           if (assetDetail.exifInfo?.latitude != null) | ||||
|           if (assetDetail.latitude != null) | ||||
|             Padding( | ||||
|               padding: const EdgeInsets.only(top: 32.0), | ||||
|               child: Column( | ||||
| @@ -115,21 +118,22 @@ class ExifBottomSheet extends ConsumerWidget { | ||||
|                     "exif_bottom_sheet_location", | ||||
|                     style: TextStyle(fontSize: 11, color: Colors.grey[400]), | ||||
|                   ).tr(), | ||||
|                   if (assetDetail.exifInfo?.latitude != null && | ||||
|                       assetDetail.exifInfo?.longitude != null) | ||||
|                   if (assetDetail.latitude != null && | ||||
|                       assetDetail.longitude != null) | ||||
|                     _buildMap(), | ||||
|                   if (assetDetail.exifInfo?.city != null && | ||||
|                       assetDetail.exifInfo?.state != null) | ||||
|                   if (exifInfo != null && | ||||
|                       exifInfo.city != null && | ||||
|                       exifInfo.state != null) | ||||
|                     _buildLocationText(), | ||||
|                   Text( | ||||
|                     "${assetDetail.exifInfo?.latitude?.toStringAsFixed(4)}, ${assetDetail.exifInfo?.longitude?.toStringAsFixed(4)}", | ||||
|                     "${assetDetail.latitude?.toStringAsFixed(4)}, ${assetDetail.longitude?.toStringAsFixed(4)}", | ||||
|                     style: TextStyle(fontSize: 12, color: Colors.grey[400]), | ||||
|                   ) | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
|           // Detail | ||||
|           if (assetDetail.exifInfo != null) | ||||
|           if (exifInfo != null) | ||||
|             Padding( | ||||
|               padding: const EdgeInsets.only(top: 32.0), | ||||
|               child: Column( | ||||
| @@ -153,16 +157,16 @@ class ExifBottomSheet extends ConsumerWidget { | ||||
|                     iconColor: Colors.grey[300], | ||||
|                     leading: const Icon(Icons.image), | ||||
|                     title: Text( | ||||
|                       "${assetDetail.exifInfo?.imageName!}${p.extension(assetDetail.originalPath)}", | ||||
|                       "${exifInfo.imageName!}${p.extension(assetDetail.remote!.originalPath)}", | ||||
|                       style: const TextStyle(fontWeight: FontWeight.bold), | ||||
|                     ), | ||||
|                     subtitle: assetDetail.exifInfo?.exifImageHeight != null | ||||
|                     subtitle: exifInfo.exifImageHeight != null | ||||
|                         ? Text( | ||||
|                             "${assetDetail.exifInfo?.exifImageHeight} x ${assetDetail.exifInfo?.exifImageWidth}  ${assetDetail.exifInfo?.fileSizeInByte!}B ", | ||||
|                             "${exifInfo.exifImageHeight} x ${exifInfo.exifImageWidth}  ${exifInfo.fileSizeInByte!}B ", | ||||
|                           ) | ||||
|                         : null, | ||||
|                   ), | ||||
|                   if (assetDetail.exifInfo?.make != null) | ||||
|                   if (exifInfo.make != null) | ||||
|                     ListTile( | ||||
|                       contentPadding: const EdgeInsets.all(0), | ||||
|                       dense: true, | ||||
| @@ -170,11 +174,11 @@ class ExifBottomSheet extends ConsumerWidget { | ||||
|                       iconColor: Colors.grey[300], | ||||
|                       leading: const Icon(Icons.camera), | ||||
|                       title: Text( | ||||
|                         "${assetDetail.exifInfo?.make} ${assetDetail.exifInfo?.model}", | ||||
|                         "${exifInfo.make} ${exifInfo.model}", | ||||
|                         style: const TextStyle(fontWeight: FontWeight.bold), | ||||
|                       ), | ||||
|                       subtitle: Text( | ||||
|                         "ƒ/${assetDetail.exifInfo?.fNumber}   1/${(1 / (assetDetail.exifInfo?.exposureTime ?? 1)).toStringAsFixed(0)}   ${assetDetail.exifInfo?.focalLength}mm   ISO${assetDetail.exifInfo?.iso} ", | ||||
|                         "ƒ/${exifInfo.fNumber}   1/${(1 / (exifInfo.exposureTime ?? 1)).toStringAsFixed(0)}   ${exifInfo.focalLength}mm   ISO${exifInfo.iso} ", | ||||
|                       ), | ||||
|                     ), | ||||
|                 ], | ||||
|   | ||||
| @@ -1,17 +1,22 @@ | ||||
| import 'package:cached_network_image/cached_network_image.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/utils/image_url_builder.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:photo_manager/photo_manager.dart' | ||||
|     show AssetEntityImageProvider, ThumbnailSize; | ||||
| import 'package:photo_view/photo_view.dart'; | ||||
|  | ||||
| enum _RemoteImageStatus { empty, thumbnail, preview, full } | ||||
|  | ||||
| class _RemotePhotoViewState extends State<RemotePhotoView> { | ||||
|   late CachedNetworkImageProvider _imageProvider; | ||||
|   late ImageProvider _imageProvider; | ||||
|   _RemoteImageStatus _status = _RemoteImageStatus.empty; | ||||
|   bool _zoomedIn = false; | ||||
|  | ||||
|   late CachedNetworkImageProvider fullProvider; | ||||
|   late CachedNetworkImageProvider previewProvider; | ||||
|   late CachedNetworkImageProvider thumbnailProvider; | ||||
|   late ImageProvider _fullProvider; | ||||
|   late ImageProvider _previewProvider; | ||||
|   late ImageProvider _thumbnailProvider; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
| @@ -68,7 +73,7 @@ class _RemotePhotoViewState extends State<RemotePhotoView> { | ||||
|  | ||||
|   void _performStateTransition( | ||||
|     _RemoteImageStatus newStatus, | ||||
|     CachedNetworkImageProvider provider, | ||||
|     ImageProvider provider, | ||||
|   ) { | ||||
|     if (_status == newStatus) return; | ||||
|  | ||||
| @@ -90,40 +95,58 @@ class _RemotePhotoViewState extends State<RemotePhotoView> { | ||||
|   } | ||||
|  | ||||
|   void _loadImages() { | ||||
|     thumbnailProvider = _authorizedImageProvider( | ||||
|       widget.thumbnailUrl, | ||||
|       widget.cacheKey, | ||||
|     ); | ||||
|     _imageProvider = thumbnailProvider; | ||||
|     if (widget.asset.isLocal) { | ||||
|       _imageProvider = AssetEntityImageProvider( | ||||
|         widget.asset.local!, | ||||
|         isOriginal: false, | ||||
|         thumbnailSize: const ThumbnailSize.square(250), | ||||
|       ); | ||||
|       _fullProvider = AssetEntityImageProvider(widget.asset.local!); | ||||
|       _fullProvider.resolve(const ImageConfiguration()).addListener( | ||||
|         ImageStreamListener((ImageInfo image, _) { | ||||
|           _performStateTransition( | ||||
|             _RemoteImageStatus.full, | ||||
|             _fullProvider, | ||||
|           ); | ||||
|         }), | ||||
|       ); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     thumbnailProvider.resolve(const ImageConfiguration()).addListener( | ||||
|     _thumbnailProvider = _authorizedImageProvider( | ||||
|       getThumbnailUrl(widget.asset.remote!), | ||||
|       widget.asset.id, | ||||
|     ); | ||||
|     _imageProvider = _thumbnailProvider; | ||||
|  | ||||
|     _thumbnailProvider.resolve(const ImageConfiguration()).addListener( | ||||
|       ImageStreamListener((ImageInfo imageInfo, _) { | ||||
|         _performStateTransition( | ||||
|           _RemoteImageStatus.thumbnail, | ||||
|           thumbnailProvider, | ||||
|           _thumbnailProvider, | ||||
|         ); | ||||
|       }), | ||||
|     ); | ||||
|  | ||||
|     if (widget.previewUrl != null) { | ||||
|       previewProvider = _authorizedImageProvider( | ||||
|         widget.previewUrl!, | ||||
|         "${widget.cacheKey}_previewStage", | ||||
|     if (widget.threeStageLoading) { | ||||
|       _previewProvider = _authorizedImageProvider( | ||||
|         getThumbnailUrl(widget.asset.remote!, type: ThumbnailFormat.JPEG), | ||||
|         "${widget.asset.id}_previewStage", | ||||
|       ); | ||||
|       previewProvider.resolve(const ImageConfiguration()).addListener( | ||||
|       _previewProvider.resolve(const ImageConfiguration()).addListener( | ||||
|         ImageStreamListener((ImageInfo imageInfo, _) { | ||||
|           _performStateTransition(_RemoteImageStatus.preview, previewProvider); | ||||
|           _performStateTransition(_RemoteImageStatus.preview, _previewProvider); | ||||
|         }), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     fullProvider = _authorizedImageProvider( | ||||
|       widget.imageUrl, | ||||
|       "${widget.cacheKey}_fullStage", | ||||
|     _fullProvider = _authorizedImageProvider( | ||||
|       getImageUrl(widget.asset.remote!), | ||||
|       "${widget.asset.id}_fullStage", | ||||
|     ); | ||||
|     fullProvider.resolve(const ImageConfiguration()).addListener( | ||||
|     _fullProvider.resolve(const ImageConfiguration()).addListener( | ||||
|       ImageStreamListener((ImageInfo imageInfo, _) { | ||||
|         _performStateTransition(_RemoteImageStatus.full, fullProvider); | ||||
|         _performStateTransition(_RemoteImageStatus.full, _fullProvider); | ||||
|       }), | ||||
|     ); | ||||
|   } | ||||
| @@ -139,11 +162,11 @@ class _RemotePhotoViewState extends State<RemotePhotoView> { | ||||
|     super.dispose(); | ||||
|  | ||||
|     if (_status == _RemoteImageStatus.full) { | ||||
|       await fullProvider.evict(); | ||||
|       await _fullProvider.evict(); | ||||
|     } else if (_status == _RemoteImageStatus.preview) { | ||||
|       await previewProvider.evict(); | ||||
|       await _previewProvider.evict(); | ||||
|     } else if (_status == _RemoteImageStatus.thumbnail) { | ||||
|       await thumbnailProvider.evict(); | ||||
|       await _thumbnailProvider.evict(); | ||||
|     } | ||||
|  | ||||
|     await _imageProvider.evict(); | ||||
| @@ -153,23 +176,18 @@ class _RemotePhotoViewState extends State<RemotePhotoView> { | ||||
| class RemotePhotoView extends StatefulWidget { | ||||
|   const RemotePhotoView({ | ||||
|     Key? key, | ||||
|     required this.thumbnailUrl, | ||||
|     required this.imageUrl, | ||||
|     required this.asset, | ||||
|     required this.authToken, | ||||
|     required this.threeStageLoading, | ||||
|     required this.isZoomedFunction, | ||||
|     required this.isZoomedListener, | ||||
|     required this.onSwipeDown, | ||||
|     required this.onSwipeUp, | ||||
|     this.previewUrl, | ||||
|     required this.cacheKey, | ||||
|   }) : super(key: key); | ||||
|  | ||||
|   final String thumbnailUrl; | ||||
|   final String imageUrl; | ||||
|   final Asset asset; | ||||
|   final String authToken; | ||||
|   final String? previewUrl; | ||||
|   final String cacheKey; | ||||
|  | ||||
|   final bool threeStageLoading; | ||||
|   final void Function() onSwipeDown; | ||||
|   final void Function() onSwipeUp; | ||||
|   final void Function() isZoomedFunction; | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
|  | ||||
| class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget { | ||||
|   const TopControlAppBar({ | ||||
| @@ -13,9 +13,9 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget { | ||||
|     this.loading = false, | ||||
|   }) : super(key: key); | ||||
|  | ||||
|   final AssetResponseDto asset; | ||||
|   final Asset asset; | ||||
|   final Function onMoreInfoPressed; | ||||
|   final Function onDownloadPressed; | ||||
|   final VoidCallback? onDownloadPressed; | ||||
|   final Function onSharePressed; | ||||
|   final bool loading; | ||||
|  | ||||
| @@ -47,17 +47,16 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget { | ||||
|               child: const CircularProgressIndicator(strokeWidth: 2.0), | ||||
|             ), | ||||
|           ), | ||||
|         IconButton( | ||||
|           iconSize: iconSize, | ||||
|           splashRadius: iconSize, | ||||
|           onPressed: () { | ||||
|             onDownloadPressed(); | ||||
|           }, | ||||
|           icon: Icon( | ||||
|             Icons.cloud_download_rounded, | ||||
|             color: Colors.grey[200], | ||||
|         if (!asset.isLocal) | ||||
|           IconButton( | ||||
|             iconSize: iconSize, | ||||
|             splashRadius: iconSize, | ||||
|             onPressed: onDownloadPressed, | ||||
|             icon: Icon( | ||||
|               Icons.cloud_download_rounded, | ||||
|               color: Colors.grey[200], | ||||
|             ), | ||||
|           ), | ||||
|         ), | ||||
|         IconButton( | ||||
|           iconSize: iconSize, | ||||
|           splashRadius: iconSize, | ||||
| @@ -69,17 +68,18 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget { | ||||
|             color: Colors.grey[200], | ||||
|           ), | ||||
|         ), | ||||
|         IconButton( | ||||
|           iconSize: iconSize, | ||||
|           splashRadius: iconSize, | ||||
|           onPressed: () { | ||||
|             onMoreInfoPressed(); | ||||
|           }, | ||||
|           icon: Icon( | ||||
|             Icons.more_horiz_rounded, | ||||
|             color: Colors.grey[200], | ||||
|           ), | ||||
|         ) | ||||
|         if (asset.isRemote) | ||||
|           IconButton( | ||||
|             iconSize: iconSize, | ||||
|             splashRadius: iconSize, | ||||
|             onPressed: () { | ||||
|               onMoreInfoPressed(); | ||||
|             }, | ||||
|             icon: Icon( | ||||
|               Icons.more_horiz_rounded, | ||||
|               color: Colors.grey[200], | ||||
|             ), | ||||
|           ) | ||||
|       ], | ||||
|     ); | ||||
|   } | ||||
|   | ||||
| @@ -14,12 +14,12 @@ import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart' | ||||
| import 'package:immich_mobile/modules/home/services/asset.service.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:openapi/api.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
|  | ||||
| // ignore: must_be_immutable | ||||
| class GalleryViewerPage extends HookConsumerWidget { | ||||
|   late List<AssetResponseDto> assetList; | ||||
|   final AssetResponseDto asset; | ||||
|   late List<Asset> assetList; | ||||
|   final Asset asset; | ||||
|  | ||||
|   GalleryViewerPage({ | ||||
|     Key? key, | ||||
| @@ -27,7 +27,7 @@ class GalleryViewerPage extends HookConsumerWidget { | ||||
|     required this.asset, | ||||
|   }) : super(key: key); | ||||
|  | ||||
|   AssetResponseDto? assetDetail; | ||||
|   Asset? assetDetail; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
| @@ -37,8 +37,7 @@ class GalleryViewerPage extends HookConsumerWidget { | ||||
|     final loading = useState(false); | ||||
|     final isZoomed = useState<bool>(false); | ||||
|     ValueNotifier<bool> isZoomedListener = ValueNotifier<bool>(false); | ||||
|  | ||||
|     int indexOfAsset = assetList.indexOf(asset); | ||||
|     final indexOfAsset = useState(assetList.indexOf(asset)); | ||||
|  | ||||
|     PageController controller = | ||||
|         PageController(initialPage: assetList.indexOf(asset)); | ||||
| @@ -52,15 +51,15 @@ class GalleryViewerPage extends HookConsumerWidget { | ||||
|       [], | ||||
|     ); | ||||
|  | ||||
|     @override | ||||
|     initState(int index) { | ||||
|       indexOfAsset = index; | ||||
|     } | ||||
|  | ||||
|     getAssetExif() async { | ||||
|       assetDetail = await ref | ||||
|           .watch(assetServiceProvider) | ||||
|           .getAssetById(assetList[indexOfAsset].id); | ||||
|       if (assetList[indexOfAsset.value].isRemote) { | ||||
|         assetDetail = await ref | ||||
|             .watch(assetServiceProvider) | ||||
|             .getAssetById(assetList[indexOfAsset.value].id); | ||||
|       } else { | ||||
|         // TODO local exif parsing? | ||||
|         assetDetail = assetList[indexOfAsset.value]; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     void showInfo() { | ||||
| @@ -88,19 +87,20 @@ class GalleryViewerPage extends HookConsumerWidget { | ||||
|       backgroundColor: Colors.black, | ||||
|       appBar: TopControlAppBar( | ||||
|         loading: loading.value, | ||||
|         asset: assetList[indexOfAsset], | ||||
|         asset: assetList[indexOfAsset.value], | ||||
|         onMoreInfoPressed: () { | ||||
|           showInfo(); | ||||
|         }, | ||||
|         onDownloadPressed: () { | ||||
|           ref | ||||
|               .watch(imageViewerStateProvider.notifier) | ||||
|               .downloadAsset(assetList[indexOfAsset], context); | ||||
|         }, | ||||
|         onDownloadPressed: assetList[indexOfAsset.value].isLocal | ||||
|             ? null | ||||
|             : () { | ||||
|                 ref.watch(imageViewerStateProvider.notifier).downloadAsset( | ||||
|                     assetList[indexOfAsset.value].remote!, context); | ||||
|               }, | ||||
|         onSharePressed: () { | ||||
|           ref | ||||
|               .watch(imageViewerStateProvider.notifier) | ||||
|               .shareAsset(assetList[indexOfAsset], context); | ||||
|               .shareAsset(assetList[indexOfAsset.value], context); | ||||
|         }, | ||||
|       ), | ||||
|       body: SafeArea( | ||||
| @@ -113,14 +113,13 @@ class GalleryViewerPage extends HookConsumerWidget { | ||||
|           itemCount: assetList.length, | ||||
|           scrollDirection: Axis.horizontal, | ||||
|           onPageChanged: (value) { | ||||
|             indexOfAsset.value = value; | ||||
|             HapticFeedback.selectionClick(); | ||||
|           }, | ||||
|           itemBuilder: (context, index) { | ||||
|             initState(index); | ||||
|  | ||||
|             getAssetExif(); | ||||
|  | ||||
|             if (assetList[index].type == AssetTypeEnum.IMAGE) { | ||||
|             if (assetList[index].isImage) { | ||||
|               return ImageViewerPage( | ||||
|                 authToken: 'Bearer ${box.get(accessTokenKey)}', | ||||
|                 isZoomedFunction: isZoomedMethod, | ||||
| @@ -139,11 +138,7 @@ class GalleryViewerPage extends HookConsumerWidget { | ||||
|                 }, | ||||
|                 child: Hero( | ||||
|                   tag: assetList[index].id, | ||||
|                   child: VideoViewerPage( | ||||
|                     asset: assetList[index], | ||||
|                     videoUrl: | ||||
|                         '${box.get(serverEndpointKey)}/asset/file?aid=${assetList[index].deviceAssetId}&did=${assetList[index].deviceId}', | ||||
|                   ), | ||||
|                   child: VideoViewerPage(asset: assetList[index]), | ||||
|                 ), | ||||
|               ); | ||||
|             } | ||||
|   | ||||
| @@ -8,13 +8,12 @@ import 'package:immich_mobile/modules/asset_viewer/ui/download_loading_indicator | ||||
| import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart'; | ||||
| import 'package:immich_mobile/modules/asset_viewer/ui/remote_photo_view.dart'; | ||||
| import 'package:immich_mobile/modules/home/services/asset.service.dart'; | ||||
| import 'package:immich_mobile/utils/image_url_builder.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
|  | ||||
| // ignore: must_be_immutable | ||||
| class ImageViewerPage extends HookConsumerWidget { | ||||
|   final String heroTag; | ||||
|   final AssetResponseDto asset; | ||||
|   final Asset asset; | ||||
|   final String authToken; | ||||
|   final ValueNotifier<bool> isZoomedListener; | ||||
|   final void Function() isZoomedFunction; | ||||
| @@ -30,7 +29,7 @@ class ImageViewerPage extends HookConsumerWidget { | ||||
|     required this.threeStageLoading, | ||||
|   }) : super(key: key); | ||||
|  | ||||
|   AssetResponseDto? assetDetail; | ||||
|   Asset? assetDetail; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
| @@ -38,8 +37,13 @@ class ImageViewerPage extends HookConsumerWidget { | ||||
|         ref.watch(imageViewerStateProvider).downloadAssetStatus; | ||||
|  | ||||
|     getAssetExif() async { | ||||
|       assetDetail = | ||||
|           await ref.watch(assetServiceProvider).getAssetById(asset.id); | ||||
|       if (asset.isRemote) { | ||||
|         assetDetail = | ||||
|             await ref.watch(assetServiceProvider).getAssetById(asset.id); | ||||
|       } else { | ||||
|         // TODO local exif parsing? | ||||
|         assetDetail = asset; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     useEffect( | ||||
| @@ -68,17 +72,13 @@ class ImageViewerPage extends HookConsumerWidget { | ||||
|           child: Hero( | ||||
|             tag: heroTag, | ||||
|             child: RemotePhotoView( | ||||
|               thumbnailUrl: getThumbnailUrl(asset), | ||||
|               cacheKey: asset.id, | ||||
|               imageUrl: getImageUrl(asset), | ||||
|               previewUrl: threeStageLoading | ||||
|                   ? getThumbnailUrl(asset, type: ThumbnailFormat.JPEG) | ||||
|                   : null, | ||||
|               asset: asset, | ||||
|               authToken: authToken, | ||||
|               threeStageLoading: threeStageLoading, | ||||
|               isZoomedFunction: isZoomedFunction, | ||||
|               isZoomedListener: isZoomedListener, | ||||
|               onSwipeDown: () => AutoRouter.of(context).pop(), | ||||
|               onSwipeUp: () => showInfo(), | ||||
|               onSwipeUp: asset.isRemote ? showInfo : () {}, | ||||
|             ), | ||||
|           ), | ||||
|         ), | ||||
|   | ||||
| @@ -1,3 +1,5 @@ | ||||
| import 'dart:io'; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:hive/hive.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| @@ -6,24 +8,41 @@ import 'package:chewie/chewie.dart'; | ||||
| import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart'; | ||||
| import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart'; | ||||
| import 'package:immich_mobile/modules/asset_viewer/ui/download_loading_indicator.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:photo_manager/photo_manager.dart'; | ||||
| import 'package:video_player/video_player.dart'; | ||||
|  | ||||
| // ignore: must_be_immutable | ||||
| class VideoViewerPage extends HookConsumerWidget { | ||||
|   final String videoUrl; | ||||
|   final AssetResponseDto asset; | ||||
|   AssetResponseDto? assetDetail; | ||||
|   final Asset asset; | ||||
|  | ||||
|   VideoViewerPage({Key? key, required this.videoUrl, required this.asset}) | ||||
|       : super(key: key); | ||||
|   const VideoViewerPage({Key? key, required this.asset}) : super(key: key); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     if (asset.isLocal) { | ||||
|       final AsyncValue<File> videoFile = ref.watch(_fileFamily(asset.local!)); | ||||
|       return videoFile.when( | ||||
|         data: (data) => VideoThumbnailPlayer(file: data), | ||||
|         error: (error, stackTrace) => Icon( | ||||
|           Icons.image_not_supported_outlined, | ||||
|           color: Theme.of(context).primaryColor, | ||||
|         ), | ||||
|         loading: () => const Center( | ||||
|           child: SizedBox( | ||||
|             width: 75, | ||||
|             height: 75, | ||||
|             child: CircularProgressIndicator.adaptive(), | ||||
|           ), | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|     final downloadAssetStatus = | ||||
|         ref.watch(imageViewerStateProvider).downloadAssetStatus; | ||||
|  | ||||
|     String jwtToken = Hive.box(userInfoBox).get(accessTokenKey); | ||||
|     final box = Hive.box(userInfoBox); | ||||
|     final String jwtToken = box.get(accessTokenKey); | ||||
|     final String videoUrl = | ||||
|         '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}'; | ||||
|  | ||||
|     return Stack( | ||||
|       children: [ | ||||
| @@ -40,11 +59,21 @@ class VideoViewerPage extends HookConsumerWidget { | ||||
|   } | ||||
| } | ||||
|  | ||||
| class VideoThumbnailPlayer extends StatefulWidget { | ||||
|   final String url; | ||||
|   final String? jwtToken; | ||||
| final _fileFamily = | ||||
|     FutureProvider.family<File, AssetEntity>((ref, entity) async { | ||||
|   final file = await entity.file; | ||||
|   if (file == null) { | ||||
|     throw Exception(); | ||||
|   } | ||||
|   return file; | ||||
| }); | ||||
|  | ||||
|   const VideoThumbnailPlayer({Key? key, required this.url, this.jwtToken}) | ||||
| class VideoThumbnailPlayer extends StatefulWidget { | ||||
|   final String? url; | ||||
|   final String? jwtToken; | ||||
|   final File? file; | ||||
|  | ||||
|   const VideoThumbnailPlayer({Key? key, this.url, this.jwtToken, this.file}) | ||||
|       : super(key: key); | ||||
|  | ||||
|   @override | ||||
| @@ -63,10 +92,12 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> { | ||||
|  | ||||
|   Future<void> initializePlayer() async { | ||||
|     try { | ||||
|       videoPlayerController = VideoPlayerController.network( | ||||
|         widget.url, | ||||
|         httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"}, | ||||
|       ); | ||||
|       videoPlayerController = widget.file == null | ||||
|           ? VideoPlayerController.network( | ||||
|               widget.url!, | ||||
|               httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"}, | ||||
|             ) | ||||
|           : VideoPlayerController.file(widget.file!); | ||||
|  | ||||
|       await videoPlayerController.initialize(); | ||||
|       _createChewieController(); | ||||
|   | ||||
| @@ -50,6 +50,11 @@ class BackgroundService { | ||||
|       _Throttle(_updateProgress, notifyInterval); | ||||
|   late final _Throttle _throttledDetailNotify = | ||||
|       _Throttle(_updateDetailProgress, notifyInterval); | ||||
|   Completer<bool> _hasAccessCompleter = Completer(); | ||||
|   late Future<bool> _hasAccess = | ||||
|       Platform.isAndroid ? _hasAccessCompleter.future : Future.value(true); | ||||
|  | ||||
|   Future<bool> get hasAccess => _hasAccess; | ||||
|  | ||||
|   bool get isBackgroundInitialized { | ||||
|     return _isBackgroundInitialized; | ||||
| @@ -201,6 +206,15 @@ class BackgroundService { | ||||
|     if (!Platform.isAndroid) { | ||||
|       return true; | ||||
|     } | ||||
|     if (_hasLock) { | ||||
|       debugPrint("WARNING: [acquireLock] called more than once"); | ||||
|       return true; | ||||
|     } | ||||
|     if (_hasAccessCompleter.isCompleted) { | ||||
|       debugPrint("WARNING: [acquireLock] _hasAccessCompleter is completed"); | ||||
|       _hasAccessCompleter = Completer(); | ||||
|       _hasAccess = _hasAccessCompleter.future; | ||||
|     } | ||||
|     final int lockTime = Timeline.now; | ||||
|     _wantsLockTime = lockTime; | ||||
|     final ReceivePort rp = ReceivePort(_portNameLock); | ||||
| @@ -219,6 +233,7 @@ class BackgroundService { | ||||
|     } | ||||
|     _hasLock = true; | ||||
|     rp.listen(_heartbeatListener); | ||||
|     _hasAccessCompleter.complete(true); | ||||
|     return true; | ||||
|   } | ||||
|  | ||||
| @@ -271,6 +286,8 @@ class BackgroundService { | ||||
|     } | ||||
|     _wantsLockTime = 0; | ||||
|     if (_hasLock) { | ||||
|       _hasAccessCompleter = Completer(); | ||||
|       _hasAccess = _hasAccessCompleter.future; | ||||
|       IsolateNameServer.removePortNameMapping(_portNameLock); | ||||
|       _waitingIsolate?.send(true); | ||||
|       _waitingIsolate = null; | ||||
|   | ||||
| @@ -46,6 +46,17 @@ class HiveBackupAlbums { | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   /// Returns a deep copy to allow safe modification without changing the global | ||||
|   /// state of [HiveBackupAlbums] before actually saving the changes | ||||
|   HiveBackupAlbums deepCopy() { | ||||
|     return HiveBackupAlbums( | ||||
|       selectedAlbumIds: selectedAlbumIds.toList(), | ||||
|       excludedAlbumsIds: excludedAlbumsIds.toList(), | ||||
|       lastSelectedBackupTime: lastSelectedBackupTime.toList(), | ||||
|       lastExcludedBackupTime: lastExcludedBackupTime.toList(), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Map<String, dynamic> toMap() { | ||||
|     final result = <String, dynamic>{}; | ||||
|  | ||||
|   | ||||
| @@ -565,11 +565,16 @@ class BackupNotifier extends StateNotifier<BackUpState> { | ||||
|       state = state.copyWith(backupProgress: BackUpProgressEnum.inBackground); | ||||
|       final bool hasLock = await _backgroundService.acquireLock(); | ||||
|       if (!hasLock) { | ||||
|         debugPrint("WARNING [resumeBackup] failed to acquireLock"); | ||||
|         return; | ||||
|       } | ||||
|       Box<HiveBackupAlbums> box = | ||||
|           await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox); | ||||
|       HiveBackupAlbums? albums = box.get(backupInfoKey); | ||||
|       await Future.wait([ | ||||
|         Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox), | ||||
|         Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox), | ||||
|         Hive.openBox(backgroundBackupInfoBox), | ||||
|       ]); | ||||
|       final HiveBackupAlbums? albums = | ||||
|           Hive.box<HiveBackupAlbums>(hiveBackupInfoBox).get(backupInfoKey); | ||||
|       Set<AvailableAlbum> selectedAlbums = state.selectedBackupAlbums; | ||||
|       Set<AvailableAlbum> excludedAlbums = state.excludedBackupAlbums; | ||||
|       if (albums != null) { | ||||
| @@ -584,8 +589,7 @@ class BackupNotifier extends StateNotifier<BackUpState> { | ||||
|           albums.lastExcludedBackupTime, | ||||
|         ); | ||||
|       } | ||||
|       await Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox); | ||||
|       final Box backgroundBox = await Hive.openBox(backgroundBackupInfoBox); | ||||
|       final Box backgroundBox = Hive.box(backgroundBackupInfoBox); | ||||
|       state = state.copyWith( | ||||
|         backupProgress: previous, | ||||
|         selectedBackupAlbums: selectedAlbums, | ||||
|   | ||||
| @@ -1,34 +1,90 @@ | ||||
| import 'dart:async'; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:hive/hive.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/providers/api.provider.dart'; | ||||
| import 'package:immich_mobile/shared/services/api.service.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:photo_manager/src/types/entity.dart'; | ||||
|  | ||||
| final assetServiceProvider = Provider( | ||||
|   (ref) => AssetService( | ||||
|     ref.watch(apiServiceProvider), | ||||
|     ref.watch(backupServiceProvider), | ||||
|     ref.watch(backgroundServiceProvider), | ||||
|   ), | ||||
| ); | ||||
|  | ||||
| class AssetService { | ||||
|   final ApiService _apiService; | ||||
|   final BackupService _backupService; | ||||
|   final BackgroundService _backgroundService; | ||||
|  | ||||
|   AssetService(this._apiService); | ||||
|   AssetService(this._apiService, this._backupService, this._backgroundService); | ||||
|  | ||||
|   Future<List<AssetResponseDto>?> getAllAsset() async { | ||||
|   /// Returns all local, remote assets in that order | ||||
|   Future<List<Asset>> getAllAsset({bool urgent = false}) async { | ||||
|     final List<Asset> assets = []; | ||||
|     try { | ||||
|       return await _apiService.assetApi.getAllAssets(); | ||||
|       // not using `await` here to fetch local & remote assets concurrently | ||||
|       final Future<List<AssetResponseDto>?> remoteTask = | ||||
|           _apiService.assetApi.getAllAssets(); | ||||
|       final Iterable<AssetEntity> newLocalAssets; | ||||
|       final List<AssetEntity> localAssets = await _getLocalAssets(urgent); | ||||
|       final List<AssetResponseDto> remoteAssets = await remoteTask ?? []; | ||||
|       if (remoteAssets.isNotEmpty && localAssets.isNotEmpty) { | ||||
|         final String deviceId = Hive.box(userInfoBox).get(deviceIdKey); | ||||
|         final Set<String> existingIds = remoteAssets | ||||
|             .where((e) => e.deviceId == deviceId) | ||||
|             .map((e) => e.deviceAssetId) | ||||
|             .toSet(); | ||||
|         newLocalAssets = localAssets.where((e) => !existingIds.contains(e.id)); | ||||
|       } else { | ||||
|         newLocalAssets = localAssets; | ||||
|       } | ||||
|  | ||||
|       assets.addAll(newLocalAssets.map((e) => Asset.local(e))); | ||||
|       // the order (first all local, then remote assets) is important! | ||||
|       assets.addAll(remoteAssets.map((e) => Asset.remote(e))); | ||||
|     } catch (e) { | ||||
|       debugPrint("Error [getAllAsset]  ${e.toString()}"); | ||||
|       return null; | ||||
|     } | ||||
|     return assets; | ||||
|   } | ||||
|  | ||||
|   /// if [urgent] is `true`, do not block by waiting on the background service | ||||
|   /// to finish running. Returns an empty list instead after a timeout. | ||||
|   Future<List<AssetEntity>> _getLocalAssets(bool urgent) 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); | ||||
|  | ||||
|       return backupAlbumInfo != null | ||||
|           ? await _backupService | ||||
|               .buildUploadCandidates(backupAlbumInfo.deepCopy()) | ||||
|           : []; | ||||
|     } catch (e) { | ||||
|       debugPrint("Error [_getLocalAssets] ${e.toString()}"); | ||||
|       return []; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<AssetResponseDto?> getAssetById(String assetId) async { | ||||
|   Future<Asset?> getAssetById(String assetId) async { | ||||
|     try { | ||||
|       return await _apiService.assetApi.getAssetById(assetId); | ||||
|       return Asset.remote(await _apiService.assetApi.getAssetById(assetId)); | ||||
|     } catch (e) { | ||||
|       debugPrint("Error [getAssetById]  ${e.toString()}"); | ||||
|       return null; | ||||
| @@ -36,12 +92,12 @@ class AssetService { | ||||
|   } | ||||
|  | ||||
|   Future<List<DeleteAssetResponseDto>?> deleteAssets( | ||||
|     Set<AssetResponseDto> deleteAssets, | ||||
|     Iterable<AssetResponseDto> deleteAssets, | ||||
|   ) async { | ||||
|     try { | ||||
|       List<String> payload = []; | ||||
|       final List<String> payload = []; | ||||
|  | ||||
|       for (var asset in deleteAssets) { | ||||
|       for (final asset in deleteAssets) { | ||||
|         payload.add(asset.id); | ||||
|       } | ||||
|  | ||||
|   | ||||
| @@ -1,27 +1,24 @@ | ||||
| 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/services/json_cache.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
|  | ||||
|  | ||||
| class AssetCacheService extends JsonCache<List<AssetResponseDto>> { | ||||
| class AssetCacheService extends JsonCache<List<Asset>> { | ||||
|   AssetCacheService() : super("asset_cache"); | ||||
|  | ||||
|   @override | ||||
|   void put(List<AssetResponseDto> data) { | ||||
|   void put(List<Asset> data) { | ||||
|     putRawData(data.map((e) => e.toJson()).toList()); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   Future<List<AssetResponseDto>> get() async { | ||||
|   Future<List<Asset>> get() async { | ||||
|     try { | ||||
|       final mapList = await readRawData() as List<dynamic>; | ||||
|  | ||||
|       final responseData = mapList | ||||
|           .map((e) => AssetResponseDto.fromJson(e)) | ||||
|           .whereNotNull() | ||||
|           .toList(); | ||||
|       final responseData = | ||||
|           mapList.map((e) => Asset.fromJson(e)).whereNotNull().toList(); | ||||
|  | ||||
|       return responseData; | ||||
|     } catch (e) { | ||||
| @@ -33,5 +30,5 @@ class AssetCacheService extends JsonCache<List<AssetResponseDto>> { | ||||
| } | ||||
|  | ||||
| final assetCacheServiceProvider = Provider( | ||||
|       (ref) => AssetCacheService(), | ||||
|   (ref) => AssetCacheService(), | ||||
| ); | ||||
|   | ||||
| @@ -1,6 +1,6 @@ | ||||
| import 'dart:math'; | ||||
|  | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
|  | ||||
| enum RenderAssetGridElementType { | ||||
|   assetRow, | ||||
| @@ -9,7 +9,7 @@ enum RenderAssetGridElementType { | ||||
| } | ||||
|  | ||||
| class RenderAssetGridRow { | ||||
|   final List<AssetResponseDto> assets; | ||||
|   final List<Asset> assets; | ||||
|  | ||||
|   RenderAssetGridRow(this.assets); | ||||
| } | ||||
| @@ -19,7 +19,7 @@ class RenderAssetGridElement { | ||||
|   final RenderAssetGridRow? assetRow; | ||||
|   final String? title; | ||||
|   final DateTime date; | ||||
|   final List<AssetResponseDto>? relatedAssetList; | ||||
|   final List<Asset>? relatedAssetList; | ||||
|  | ||||
|   RenderAssetGridElement( | ||||
|     this.type, { | ||||
| @@ -31,13 +31,15 @@ class RenderAssetGridElement { | ||||
| } | ||||
|  | ||||
| List<RenderAssetGridElement> assetsToRenderList( | ||||
|     List<AssetResponseDto> assets, int assetsPerRow) { | ||||
|   List<Asset> assets, | ||||
|   int assetsPerRow, | ||||
| ) { | ||||
|   List<RenderAssetGridElement> elements = []; | ||||
|  | ||||
|   int cursor = 0; | ||||
|   while (cursor < assets.length) { | ||||
|     int rowElements = min(assets.length - cursor, assetsPerRow); | ||||
|     final date = DateTime.parse(assets[cursor].createdAt); | ||||
|     final date = assets[cursor].createdAt; | ||||
|  | ||||
|     final rowElement = RenderAssetGridElement( | ||||
|       RenderAssetGridElementType.assetRow, | ||||
| @@ -55,7 +57,9 @@ List<RenderAssetGridElement> assetsToRenderList( | ||||
| } | ||||
|  | ||||
| List<RenderAssetGridElement> assetGroupsToRenderList( | ||||
|     Map<String, List<AssetResponseDto>> assetGroups, int assetsPerRow) { | ||||
|   Map<String, List<Asset>> assetGroups, | ||||
|   int assetsPerRow, | ||||
| ) { | ||||
|   List<RenderAssetGridElement> elements = []; | ||||
|   DateTime? lastDate; | ||||
|  | ||||
| @@ -64,8 +68,11 @@ List<RenderAssetGridElement> assetGroupsToRenderList( | ||||
|  | ||||
|     if (lastDate == null || lastDate!.month != date.month) { | ||||
|       elements.add( | ||||
|         RenderAssetGridElement(RenderAssetGridElementType.monthTitle, | ||||
|             title: groupName, date: date), | ||||
|         RenderAssetGridElement( | ||||
|           RenderAssetGridElementType.monthTitle, | ||||
|           title: groupName, | ||||
|           date: date, | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -4,7 +4,7 @@ import 'package:collection/collection.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:scrollable_positioned_list/scrollable_positioned_list.dart'; | ||||
| import 'asset_grid_data_structure.dart'; | ||||
| import 'daily_title_text.dart'; | ||||
| @@ -13,7 +13,7 @@ import 'draggable_scrollbar_custom.dart'; | ||||
|  | ||||
| typedef ImmichAssetGridSelectionListener = void Function( | ||||
|   bool, | ||||
|   Set<AssetResponseDto>, | ||||
|   Set<Asset>, | ||||
| ); | ||||
|  | ||||
| class ImmichAssetGridState extends State<ImmichAssetGrid> { | ||||
| @@ -24,20 +24,20 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> { | ||||
|   bool _scrolling = false; | ||||
|   final Set<String> _selectedAssets = HashSet(); | ||||
|  | ||||
|   List<AssetResponseDto> get _assets { | ||||
|   List<Asset> get _assets { | ||||
|     return widget.renderList | ||||
|         .map((e) { | ||||
|           if (e.type == RenderAssetGridElementType.assetRow) { | ||||
|             return e.assetRow!.assets; | ||||
|           } else { | ||||
|             return List<AssetResponseDto>.empty(); | ||||
|             return List<Asset>.empty(); | ||||
|           } | ||||
|         }) | ||||
|         .flattened | ||||
|         .toList(); | ||||
|   } | ||||
|  | ||||
|   Set<AssetResponseDto> _getSelectedAssets() { | ||||
|   Set<Asset> _getSelectedAssets() { | ||||
|     return _selectedAssets | ||||
|         .map((e) => _assets.firstWhereOrNull((a) => a.id == e)) | ||||
|         .whereNotNull() | ||||
| @@ -48,7 +48,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> { | ||||
|     widget.listener?.call(selectionActive, _getSelectedAssets()); | ||||
|   } | ||||
|  | ||||
|   void _selectAssets(List<AssetResponseDto> assets) { | ||||
|   void _selectAssets(List<Asset> assets) { | ||||
|     setState(() { | ||||
|       for (var e in assets) { | ||||
|         _selectedAssets.add(e.id); | ||||
| @@ -57,7 +57,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> { | ||||
|     }); | ||||
|   } | ||||
|  | ||||
|   void _deselectAssets(List<AssetResponseDto> assets) { | ||||
|   void _deselectAssets(List<Asset> assets) { | ||||
|     setState(() { | ||||
|       for (var e in assets) { | ||||
|         _selectedAssets.remove(e.id); | ||||
| @@ -74,7 +74,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> { | ||||
|     _callSelectionListener(false); | ||||
|   } | ||||
|  | ||||
|   bool _allAssetsSelected(List<AssetResponseDto> assets) { | ||||
|   bool _allAssetsSelected(List<Asset> assets) { | ||||
|     return widget.selectionActive && | ||||
|         assets.firstWhereOrNull((e) => !_selectedAssets.contains(e.id)) == null; | ||||
|   } | ||||
| @@ -85,7 +85,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> { | ||||
|   } | ||||
|  | ||||
|   Widget _buildThumbnailOrPlaceholder( | ||||
|     AssetResponseDto asset, | ||||
|     Asset asset, | ||||
|     bool placeholder, | ||||
|   ) { | ||||
|     if (placeholder) { | ||||
| @@ -114,7 +114,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> { | ||||
|  | ||||
|     return Row( | ||||
|       key: Key("asset-row-${row.assets.first.id}"), | ||||
|       children: row.assets.map((AssetResponseDto asset) { | ||||
|       children: row.assets.map((Asset asset) { | ||||
|         bool last = asset == row.assets.last; | ||||
|  | ||||
|         return Container( | ||||
| @@ -134,7 +134,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> { | ||||
|   Widget _buildTitle( | ||||
|     BuildContext context, | ||||
|     String title, | ||||
|     List<AssetResponseDto> assets, | ||||
|     List<Asset> assets, | ||||
|   ) { | ||||
|     return DailyTitleText( | ||||
|       isoDate: title, | ||||
|   | ||||
| @@ -1,18 +1,15 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:cached_network_image/cached_network_image.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:hive_flutter/hive_flutter.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/constants/hive_box.dart'; | ||||
| import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; | ||||
| import 'package:immich_mobile/routing/router.dart'; | ||||
| import 'package:immich_mobile/utils/image_url_builder.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_image.dart'; | ||||
|  | ||||
| class ThumbnailImage extends HookConsumerWidget { | ||||
|   final AssetResponseDto asset; | ||||
|   final List<AssetResponseDto> assetList; | ||||
|   final Asset asset; | ||||
|   final List<Asset> assetList; | ||||
|   final bool showStorageIndicator; | ||||
|   final bool useGrayBoxPlaceholder; | ||||
|   final bool isSelected; | ||||
| @@ -34,12 +31,9 @@ class ThumbnailImage extends HookConsumerWidget { | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     var box = Hive.box(userInfoBox); | ||||
|     var thumbnailRequestUrl = getThumbnailUrl(asset); | ||||
|     var deviceId = ref.watch(authenticationProvider).deviceId; | ||||
|  | ||||
|  | ||||
|     Widget buildSelectionIcon(AssetResponseDto asset) { | ||||
|     Widget buildSelectionIcon(Asset asset) { | ||||
|       if (isSelected) { | ||||
|         return Icon( | ||||
|           Icons.check_circle, | ||||
| @@ -87,41 +81,11 @@ class ThumbnailImage extends HookConsumerWidget { | ||||
|                       ) | ||||
|                     : const Border(), | ||||
|               ), | ||||
|               child: CachedNetworkImage( | ||||
|                 cacheKey: 'thumbnail-image-${asset.id}', | ||||
|               child: ImmichImage( | ||||
|                 asset, | ||||
|                 width: 300, | ||||
|                 height: 300, | ||||
|                 memCacheHeight: 200, | ||||
|                 maxWidthDiskCache: 200, | ||||
|                 maxHeightDiskCache: 200, | ||||
|                 fit: BoxFit.cover, | ||||
|                 imageUrl: thumbnailRequestUrl, | ||||
|                 httpHeaders: { | ||||
|                   "Authorization": "Bearer ${box.get(accessTokenKey)}" | ||||
|                 }, | ||||
|                 fadeInDuration: const Duration(milliseconds: 250), | ||||
|                 progressIndicatorBuilder: (context, url, downloadProgress) { | ||||
|                   if (useGrayBoxPlaceholder) { | ||||
|                     return const DecoratedBox( | ||||
|                       decoration: BoxDecoration(color: Colors.grey), | ||||
|                     ); | ||||
|                   } | ||||
|                   return Transform.scale( | ||||
|                     scale: 0.2, | ||||
|                     child: CircularProgressIndicator( | ||||
|                       value: downloadProgress.progress, | ||||
|                     ), | ||||
|                   ); | ||||
|                 }, | ||||
|                 errorWidget: (context, url, error) { | ||||
|                   debugPrint("Error getting thumbnail $url = $error"); | ||||
|                   CachedNetworkImage.evictFromCache(thumbnailRequestUrl); | ||||
|  | ||||
|                   return Icon( | ||||
|                     Icons.image_not_supported_outlined, | ||||
|                     color: Theme.of(context).primaryColor, | ||||
|                   ); | ||||
|                 }, | ||||
|                 useGrayBoxPlaceholder: useGrayBoxPlaceholder, | ||||
|               ), | ||||
|             ), | ||||
|             if (multiselectEnabled) | ||||
| @@ -137,14 +101,16 @@ class ThumbnailImage extends HookConsumerWidget { | ||||
|                 right: 10, | ||||
|                 bottom: 5, | ||||
|                 child: Icon( | ||||
|                   (deviceId != asset.deviceId) | ||||
|                       ? Icons.cloud_done_outlined | ||||
|                       : Icons.photo_library_rounded, | ||||
|                   asset.isRemote | ||||
|                       ? (deviceId == asset.deviceId | ||||
|                           ? Icons.cloud_done_outlined | ||||
|                           : Icons.cloud_outlined) | ||||
|                       : Icons.cloud_off_outlined, | ||||
|                   color: Colors.white, | ||||
|                   size: 18, | ||||
|                 ), | ||||
|               ), | ||||
|             if (asset.type != AssetTypeEnum.IMAGE) | ||||
|             if (!asset.isImage) | ||||
|               Positioned( | ||||
|                 top: 5, | ||||
|                 right: 5, | ||||
|   | ||||
| @@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:easy_localization/easy_localization.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:fluttertoast/fluttertoast.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/album/providers/album.provider.dart'; | ||||
| import 'package:immich_mobile/modules/album/services/album.service.dart'; | ||||
| @@ -14,6 +15,7 @@ import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.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/routing/router.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/providers/asset.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/server_info.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/websocket.provider.dart'; | ||||
| @@ -31,7 +33,7 @@ class HomePage extends HookConsumerWidget { | ||||
|     final multiselectEnabled = ref.watch(multiselectProvider.notifier); | ||||
|     final selectionEnabledHook = useState(false); | ||||
|  | ||||
|     final selection = useState(<AssetResponseDto>{}); | ||||
|     final selection = useState(<Asset>{}); | ||||
|     final albums = ref.watch(albumProvider); | ||||
|     final albumService = ref.watch(albumServiceProvider); | ||||
|  | ||||
| @@ -60,7 +62,7 @@ class HomePage extends HookConsumerWidget { | ||||
|     Widget buildBody() { | ||||
|       void selectionListener( | ||||
|         bool multiselect, | ||||
|         Set<AssetResponseDto> selectedAssets, | ||||
|         Set<Asset> selectedAssets, | ||||
|       ) { | ||||
|         selectionEnabledHook.value = multiselect; | ||||
|         selection.value = selectedAssets; | ||||
| @@ -76,9 +78,27 @@ class HomePage extends HookConsumerWidget { | ||||
|         selectionEnabledHook.value = false; | ||||
|       } | ||||
|  | ||||
|       Iterable<Asset> remoteOnlySelection() { | ||||
|         final Set<Asset> assets = selection.value; | ||||
|         final bool onlyRemote = assets.every((e) => e.isRemote); | ||||
|         if (!onlyRemote) { | ||||
|           ImmichToast.show( | ||||
|             context: context, | ||||
|             msg: "Can not add local assets to albums yet, skipping", | ||||
|             gravity: ToastGravity.BOTTOM, | ||||
|           ); | ||||
|           return assets.where((a) => a.isRemote); | ||||
|         } | ||||
|         return assets; | ||||
|       } | ||||
|  | ||||
|       void onAddToAlbum(AlbumResponseDto album) async { | ||||
|         final Iterable<Asset> assets = remoteOnlySelection(); | ||||
|         if (assets.isEmpty) { | ||||
|           return; | ||||
|         } | ||||
|         final result = await albumService.addAdditionalAssetToAlbum( | ||||
|           selection.value, | ||||
|           assets, | ||||
|           album.id, | ||||
|         ); | ||||
|  | ||||
| @@ -103,6 +123,7 @@ class HomePage extends HookConsumerWidget { | ||||
|                   "added": result.successfullyAdded.toString(), | ||||
|                 }, | ||||
|               ), | ||||
|               toastType: ToastType.success, | ||||
|             ); | ||||
|           } | ||||
|  | ||||
| @@ -111,8 +132,11 @@ class HomePage extends HookConsumerWidget { | ||||
|       } | ||||
|  | ||||
|       void onCreateNewAlbum() async { | ||||
|         final result = | ||||
|             await albumService.createAlbumWithGeneratedName(selection.value); | ||||
|         final Iterable<Asset> assets = remoteOnlySelection(); | ||||
|         if (assets.isEmpty) { | ||||
|           return; | ||||
|         } | ||||
|         final result = await albumService.createAlbumWithGeneratedName(assets); | ||||
|  | ||||
|         if (result != null) { | ||||
|           ref.watch(albumProvider.notifier).getAllAlbums(); | ||||
|   | ||||
| @@ -1,13 +1,14 @@ | ||||
| import 'dart:convert'; | ||||
|  | ||||
| import 'package:collection/collection.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
|  | ||||
| class SearchResultPageState { | ||||
|   final bool isLoading; | ||||
|   final bool isSuccess; | ||||
|   final bool isError; | ||||
|   final List<AssetResponseDto> searchResult; | ||||
|   final List<Asset> searchResult; | ||||
|  | ||||
|   SearchResultPageState({ | ||||
|     required this.isLoading, | ||||
| @@ -20,7 +21,7 @@ class SearchResultPageState { | ||||
|     bool? isLoading, | ||||
|     bool? isSuccess, | ||||
|     bool? isError, | ||||
|     List<AssetResponseDto>? searchResult, | ||||
|     List<Asset>? searchResult, | ||||
|   }) { | ||||
|     return SearchResultPageState( | ||||
|       isLoading: isLoading ?? this.isLoading, | ||||
| @@ -44,8 +45,9 @@ class SearchResultPageState { | ||||
|       isLoading: map['isLoading'] ?? false, | ||||
|       isSuccess: map['isSuccess'] ?? false, | ||||
|       isError: map['isError'] ?? false, | ||||
|       searchResult: List<AssetResponseDto>.from( | ||||
|         map['searchResult']?.map((x) => AssetResponseDto.mapFromJson(x)), | ||||
|       searchResult: List<Asset>.from( | ||||
|         map['searchResult'] | ||||
|             ?.map((x) => Asset.remote(AssetResponseDto.fromJson(x))), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|   | ||||
| @@ -6,8 +6,8 @@ import 'package:immich_mobile/modules/search/models/search_result_page_state.mod | ||||
| import 'package:immich_mobile/modules/search/services/search.service.dart'; | ||||
| import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; | ||||
| import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:intl/intl.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
|  | ||||
| class SearchResultPageNotifier extends StateNotifier<SearchResultPageState> { | ||||
|   SearchResultPageNotifier(this._searchService) | ||||
| @@ -30,8 +30,9 @@ class SearchResultPageNotifier extends StateNotifier<SearchResultPageState> { | ||||
|       isSuccess: false, | ||||
|     ); | ||||
|  | ||||
|     List<AssetResponseDto>? assets = | ||||
|         await _searchService.searchAsset(searchTerm); | ||||
|     List<Asset>? assets = (await _searchService.searchAsset(searchTerm)) | ||||
|         ?.map((e) => Asset.remote(e)) | ||||
|         .toList(); | ||||
|  | ||||
|     if (assets != null) { | ||||
|       state = state.copyWith( | ||||
| @@ -61,12 +62,11 @@ final searchResultGroupByDateTimeProvider = StateProvider((ref) { | ||||
|   var assets = ref.watch(searchResultPageProvider).searchResult; | ||||
|  | ||||
|   assets.sortByCompare<DateTime>( | ||||
|     (e) => DateTime.parse(e.createdAt), | ||||
|     (e) => e.createdAt, | ||||
|     (a, b) => b.compareTo(a), | ||||
|   ); | ||||
|   return assets.groupListsBy( | ||||
|     (element) => DateFormat('y-MM-dd') | ||||
|         .format(DateTime.parse(element.createdAt).toLocal()), | ||||
|     (element) => DateFormat('y-MM-dd').format(element.createdAt.toLocal()), | ||||
|   ); | ||||
| }); | ||||
|  | ||||
|   | ||||
| @@ -22,6 +22,7 @@ import 'package:immich_mobile/modules/settings/views/settings_page.dart'; | ||||
| import 'package:immich_mobile/routing/auth_guard.dart'; | ||||
| import 'package:immich_mobile/modules/backup/views/backup_controller_page.dart'; | ||||
| import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/providers/api.provider.dart'; | ||||
| import 'package:immich_mobile/shared/services/api.service.dart'; | ||||
| import 'package:immich_mobile/shared/views/splash_screen.dart'; | ||||
|   | ||||
| @@ -65,8 +65,7 @@ class _$AppRouter extends RootStackRouter { | ||||
|       final args = routeData.argsAs<VideoViewerRouteArgs>(); | ||||
|       return MaterialPageX<dynamic>( | ||||
|           routeData: routeData, | ||||
|           child: VideoViewerPage( | ||||
|               key: args.key, videoUrl: args.videoUrl, asset: args.asset)); | ||||
|           child: VideoViewerPage(key: args.key, asset: args.asset)); | ||||
|     }, | ||||
|     BackupControllerRoute.name: (routeData) { | ||||
|       return MaterialPageX<dynamic>( | ||||
| @@ -258,9 +257,7 @@ class TabControllerRoute extends PageRouteInfo<void> { | ||||
| /// [GalleryViewerPage] | ||||
| class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> { | ||||
|   GalleryViewerRoute( | ||||
|       {Key? key, | ||||
|       required List<AssetResponseDto> assetList, | ||||
|       required AssetResponseDto asset}) | ||||
|       {Key? key, required List<Asset> assetList, required Asset asset}) | ||||
|       : super(GalleryViewerRoute.name, | ||||
|             path: '/gallery-viewer-page', | ||||
|             args: GalleryViewerRouteArgs( | ||||
| @@ -275,9 +272,9 @@ class GalleryViewerRouteArgs { | ||||
|  | ||||
|   final Key? key; | ||||
|  | ||||
|   final List<AssetResponseDto> assetList; | ||||
|   final List<Asset> assetList; | ||||
|  | ||||
|   final AssetResponseDto asset; | ||||
|   final Asset asset; | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
| @@ -291,7 +288,7 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> { | ||||
|   ImageViewerRoute( | ||||
|       {Key? key, | ||||
|       required String heroTag, | ||||
|       required AssetResponseDto asset, | ||||
|       required Asset asset, | ||||
|       required String authToken, | ||||
|       required void Function() isZoomedFunction, | ||||
|       required ValueNotifier<bool> isZoomedListener, | ||||
| @@ -324,7 +321,7 @@ class ImageViewerRouteArgs { | ||||
|  | ||||
|   final String heroTag; | ||||
|  | ||||
|   final AssetResponseDto asset; | ||||
|   final Asset asset; | ||||
|  | ||||
|   final String authToken; | ||||
|  | ||||
| @@ -343,29 +340,24 @@ class ImageViewerRouteArgs { | ||||
| /// generated route for | ||||
| /// [VideoViewerPage] | ||||
| class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> { | ||||
|   VideoViewerRoute( | ||||
|       {Key? key, required String videoUrl, required AssetResponseDto asset}) | ||||
|   VideoViewerRoute({Key? key, required Asset asset}) | ||||
|       : super(VideoViewerRoute.name, | ||||
|             path: '/video-viewer-page', | ||||
|             args: VideoViewerRouteArgs( | ||||
|                 key: key, videoUrl: videoUrl, asset: asset)); | ||||
|             args: VideoViewerRouteArgs(key: key, asset: asset)); | ||||
|  | ||||
|   static const String name = 'VideoViewerRoute'; | ||||
| } | ||||
|  | ||||
| class VideoViewerRouteArgs { | ||||
|   const VideoViewerRouteArgs( | ||||
|       {this.key, required this.videoUrl, required this.asset}); | ||||
|   const VideoViewerRouteArgs({this.key, required this.asset}); | ||||
|  | ||||
|   final Key? key; | ||||
|  | ||||
|   final String videoUrl; | ||||
|  | ||||
|   final AssetResponseDto asset; | ||||
|   final Asset asset; | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'VideoViewerRouteArgs{key: $key, videoUrl: $videoUrl, asset: $asset}'; | ||||
|     return 'VideoViewerRouteArgs{key: $key, asset: $asset}'; | ||||
|   } | ||||
| } | ||||
|  | ||||
|   | ||||
							
								
								
									
										117
									
								
								mobile/lib/shared/models/asset.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										117
									
								
								mobile/lib/shared/models/asset.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,117 @@ | ||||
| import 'package:hive/hive.dart'; | ||||
| import 'package:immich_mobile/constants/hive_box.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:photo_manager/photo_manager.dart'; | ||||
|  | ||||
| /// Asset (online or local) | ||||
| class Asset { | ||||
|   Asset.remote(this.remote) { | ||||
|     local = null; | ||||
|   } | ||||
|  | ||||
|   Asset.local(this.local) { | ||||
|     remote = null; | ||||
|   } | ||||
|  | ||||
|   late final AssetResponseDto? remote; | ||||
|   late final AssetEntity? local; | ||||
|  | ||||
|   bool get isRemote => remote != null; | ||||
|   bool get isLocal => local != null; | ||||
|  | ||||
|   String get deviceId => | ||||
|       isRemote ? remote!.deviceId : Hive.box(userInfoBox).get(deviceIdKey); | ||||
|  | ||||
|   String get deviceAssetId => isRemote ? remote!.deviceAssetId : local!.id; | ||||
|  | ||||
|   String get id => isLocal ? local!.id : remote!.id; | ||||
|  | ||||
|   double? get latitude => | ||||
|       isLocal ? local!.latitude : remote!.exifInfo?.latitude?.toDouble(); | ||||
|  | ||||
|   double? get longitude => | ||||
|       isLocal ? local!.longitude : remote!.exifInfo?.longitude?.toDouble(); | ||||
|  | ||||
|   DateTime get createdAt => | ||||
|       isLocal ? local!.createDateTime : DateTime.parse(remote!.createdAt); | ||||
|  | ||||
|   bool get isImage => isLocal | ||||
|       ? local!.type == AssetType.image | ||||
|       : remote!.type == AssetTypeEnum.IMAGE; | ||||
|  | ||||
|   String get duration => isRemote | ||||
|       ? remote!.duration | ||||
|       : Duration(seconds: local!.duration).toString(); | ||||
|  | ||||
|   /// use only for tests | ||||
|   set createdAt(DateTime val) { | ||||
|     if (isRemote) { | ||||
|       remote!.createdAt = val.toIso8601String(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
|     if (isLocal) { | ||||
|       json["local"] = _assetEntityToJson(local!); | ||||
|     } else { | ||||
|       json["remote"] = remote!.toJson(); | ||||
|     } | ||||
|     return json; | ||||
|   } | ||||
|  | ||||
|   static Asset? fromJson(dynamic value) { | ||||
|     if (value is Map) { | ||||
|       final json = value.cast<String, dynamic>(); | ||||
|       final l = json["local"]; | ||||
|       if (l != null) { | ||||
|         return Asset.local(_assetEntityFromJson(l)); | ||||
|       } else { | ||||
|         return Asset.remote(AssetResponseDto.fromJson(json["remote"])); | ||||
|       } | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
| } | ||||
|  | ||||
| Map<String, dynamic> _assetEntityToJson(AssetEntity a) { | ||||
|   final json = <String, dynamic>{}; | ||||
|   json["id"] = a.id; | ||||
|   json["typeInt"] = a.typeInt; | ||||
|   json["width"] = a.width; | ||||
|   json["height"] = a.height; | ||||
|   json["duration"] = a.duration; | ||||
|   json["orientation"] = a.orientation; | ||||
|   json["isFavorite"] = a.isFavorite; | ||||
|   json["title"] = a.title; | ||||
|   json["createDateSecond"] = a.createDateSecond; | ||||
|   json["modifiedDateSecond"] = a.modifiedDateSecond; | ||||
|   json["latitude"] = a.latitude; | ||||
|   json["longitude"] = a.longitude; | ||||
|   json["mimeType"] = a.mimeType; | ||||
|   json["subtype"] = a.subtype; | ||||
|   return json; | ||||
| } | ||||
|  | ||||
| AssetEntity? _assetEntityFromJson(dynamic value) { | ||||
|   if (value is Map) { | ||||
|     final json = value.cast<String, dynamic>(); | ||||
|     return AssetEntity( | ||||
|       id: json["id"], | ||||
|       typeInt: json["typeInt"], | ||||
|       width: json["width"], | ||||
|       height: json["height"], | ||||
|       duration: json["duration"], | ||||
|       orientation: json["orientation"], | ||||
|       isFavorite: json["isFavorite"], | ||||
|       title: json["title"], | ||||
|       createDateSecond: json["createDateSecond"], | ||||
|       modifiedDateSecond: json["modifiedDateSecond"], | ||||
|       latitude: json["latitude"], | ||||
|       longitude: json["longitude"], | ||||
|       mimeType: json["mimeType"], | ||||
|       subtype: json["subtype"], | ||||
|     ); | ||||
|   } | ||||
|   return null; | ||||
| } | ||||
| @@ -1,18 +1,23 @@ | ||||
| import 'dart:collection'; | ||||
|  | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/home/services/asset.service.dart'; | ||||
| import 'package:immich_mobile/modules/home/services/asset_cache.service.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:intl/intl.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:photo_manager/photo_manager.dart'; | ||||
|  | ||||
| class AssetNotifier extends StateNotifier<List<AssetResponseDto>> { | ||||
| class AssetNotifier extends StateNotifier<List<Asset>> { | ||||
|   final AssetService _assetService; | ||||
|   final AssetCacheService _assetCacheService; | ||||
|  | ||||
|   final DeviceInfoService _deviceInfoService = DeviceInfoService(); | ||||
|   bool _getAllAssetInProgress = false; | ||||
|   bool _deleteInProgress = false; | ||||
|  | ||||
|   AssetNotifier(this._assetService, this._assetCacheService) : super([]); | ||||
|  | ||||
| @@ -21,29 +26,38 @@ class AssetNotifier extends StateNotifier<List<AssetResponseDto>> { | ||||
|   } | ||||
|  | ||||
|   getAllAsset() async { | ||||
|     final stopwatch = Stopwatch(); | ||||
|  | ||||
|  | ||||
|     if (await _assetCacheService.isValid() && state.isEmpty) { | ||||
|       stopwatch.start(); | ||||
|       state = await _assetCacheService.get(); | ||||
|       debugPrint("Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms"); | ||||
|       stopwatch.reset(); | ||||
|     if (_getAllAssetInProgress || _deleteInProgress) { | ||||
|       // guard against multiple calls to this method while it's still working | ||||
|       return; | ||||
|     } | ||||
|     final stopwatch = Stopwatch(); | ||||
|     try { | ||||
|       _getAllAssetInProgress = true; | ||||
|  | ||||
|       final bool isCacheValid = await _assetCacheService.isValid(); | ||||
|       if (isCacheValid && state.isEmpty) { | ||||
|         stopwatch.start(); | ||||
|         state = await _assetCacheService.get(); | ||||
|         debugPrint( | ||||
|             "Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms"); | ||||
|         stopwatch.reset(); | ||||
|       } | ||||
|  | ||||
|       stopwatch.start(); | ||||
|       var allAssets = await _assetService.getAllAsset(urgent: !isCacheValid); | ||||
|       debugPrint("Query assets from API: ${stopwatch.elapsedMilliseconds}ms"); | ||||
|       stopwatch.reset(); | ||||
|  | ||||
|       state = allAssets; | ||||
|     } finally { | ||||
|       _getAllAssetInProgress = false; | ||||
|     } | ||||
|     debugPrint("[getAllAsset] setting new asset state"); | ||||
|  | ||||
|     stopwatch.start(); | ||||
|     var allAssets = await _assetService.getAllAsset(); | ||||
|     debugPrint("Query assets from API: ${stopwatch.elapsedMilliseconds}ms"); | ||||
|     _cacheState(); | ||||
|     debugPrint("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms"); | ||||
|     stopwatch.reset(); | ||||
|  | ||||
|     if (allAssets != null) { | ||||
|       state = allAssets; | ||||
|  | ||||
|       stopwatch.start(); | ||||
|       _cacheState(); | ||||
|       debugPrint("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms"); | ||||
|       stopwatch.reset(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   clearAllAsset() { | ||||
| @@ -52,80 +66,113 @@ class AssetNotifier extends StateNotifier<List<AssetResponseDto>> { | ||||
|   } | ||||
|  | ||||
|   onNewAssetUploaded(AssetResponseDto newAsset) { | ||||
|     state = [...state, newAsset]; | ||||
|     final int i = state.indexWhere( | ||||
|       (a) => | ||||
|           a.isRemote || | ||||
|           (a.id == newAsset.deviceAssetId && a.deviceId == newAsset.deviceId), | ||||
|     ); | ||||
|  | ||||
|     if (i == -1 || state[i].deviceAssetId != newAsset.deviceAssetId) { | ||||
|       state = [...state, Asset.remote(newAsset)]; | ||||
|     } else { | ||||
|       // order is important to keep all local-only assets at the beginning! | ||||
|       state = [ | ||||
|         ...state.slice(0, i), | ||||
|         ...state.slice(i + 1), | ||||
|         Asset.remote(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 | ||||
|     } | ||||
|     _cacheState(); | ||||
|   } | ||||
|  | ||||
|   deleteAssets(Set<AssetResponseDto> deleteAssets) async { | ||||
|   deleteAssets(Set<Asset> deleteAssets) async { | ||||
|     _deleteInProgress = true; | ||||
|     try { | ||||
|       final localDeleted = await _deleteLocalAssets(deleteAssets); | ||||
|       final remoteDeleted = await _deleteRemoteAssets(deleteAssets); | ||||
|       final Set<String> deleted = HashSet(); | ||||
|       deleted.addAll(localDeleted); | ||||
|       deleted.addAll(remoteDeleted); | ||||
|       if (deleted.isNotEmpty) { | ||||
|         state = state.where((a) => !deleted.contains(a.id)).toList(); | ||||
|         _cacheState(); | ||||
|       } | ||||
|     } finally { | ||||
|       _deleteInProgress = false; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<List<String>> _deleteLocalAssets(Set<Asset> assetsToDelete) async { | ||||
|     var deviceInfo = await _deviceInfoService.getDeviceInfo(); | ||||
|     var deviceId = deviceInfo["deviceId"]; | ||||
|     var deleteIdList = <String>[]; | ||||
|     final List<String> local = []; | ||||
|     // Delete asset from device | ||||
|     for (var asset in deleteAssets) { | ||||
|       // Delete asset on device if present | ||||
|       if (asset.deviceId == deviceId) { | ||||
|     for (final Asset asset in assetsToDelete) { | ||||
|       if (asset.isLocal) { | ||||
|         local.add(asset.id); | ||||
|       } else if (asset.deviceId == deviceId) { | ||||
|         // Delete asset on device if it is still present | ||||
|         var localAsset = await AssetEntity.fromId(asset.deviceAssetId); | ||||
|  | ||||
|         if (localAsset != null) { | ||||
|           deleteIdList.add(localAsset.id); | ||||
|           local.add(localAsset.id); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     try { | ||||
|       await PhotoManager.editor.deleteWithIds(deleteIdList); | ||||
|     } catch (e) { | ||||
|       debugPrint("Delete asset from device failed: $e"); | ||||
|     } | ||||
|  | ||||
|     // Delete asset on server | ||||
|     List<DeleteAssetResponseDto>? deleteAssetResult = | ||||
|         await _assetService.deleteAssets(deleteAssets); | ||||
|  | ||||
|     if (deleteAssetResult == null) { | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     for (var asset in deleteAssetResult) { | ||||
|       if (asset.status == DeleteAssetStatus.SUCCESS) { | ||||
|         state = | ||||
|             state.where((immichAsset) => immichAsset.id != asset.id).toList(); | ||||
|     if (local.isNotEmpty) { | ||||
|       try { | ||||
|         return await PhotoManager.editor.deleteWithIds(local); | ||||
|       } catch (e) { | ||||
|         debugPrint("Delete asset from device failed: $e"); | ||||
|       } | ||||
|     } | ||||
|     return []; | ||||
|   } | ||||
|  | ||||
|     _cacheState(); | ||||
|   Future<Iterable<String>> _deleteRemoteAssets( | ||||
|     Set<Asset> assetsToDelete, | ||||
|   ) async { | ||||
|     final Iterable<AssetResponseDto> remote = | ||||
|         assetsToDelete.where((e) => e.isRemote).map((e) => e.remote!); | ||||
|     final List<DeleteAssetResponseDto> deleteAssetResult = | ||||
|         await _assetService.deleteAssets(remote) ?? []; | ||||
|     return deleteAssetResult | ||||
|         .where((a) => a.status == DeleteAssetStatus.SUCCESS) | ||||
|         .map((a) => a.id); | ||||
|   } | ||||
| } | ||||
|  | ||||
| final assetProvider = | ||||
|     StateNotifierProvider<AssetNotifier, List<AssetResponseDto>>((ref) { | ||||
| final assetProvider = StateNotifierProvider<AssetNotifier, List<Asset>>((ref) { | ||||
|   return AssetNotifier( | ||||
|       ref.watch(assetServiceProvider), ref.watch(assetCacheServiceProvider)); | ||||
| }); | ||||
|  | ||||
| final assetGroupByDateTimeProvider = StateProvider((ref) { | ||||
|   var assets = ref.watch(assetProvider); | ||||
|   final assets = ref.watch(assetProvider).toList(); | ||||
|   // `toList()` ist needed to make a copy as to NOT sort the original list/state | ||||
|  | ||||
|   assets.sortByCompare<DateTime>( | ||||
|     (e) => DateTime.parse(e.createdAt), | ||||
|     (e) => e.createdAt, | ||||
|     (a, b) => b.compareTo(a), | ||||
|   ); | ||||
|   return assets.groupListsBy( | ||||
|     (element) => DateFormat('y-MM-dd') | ||||
|         .format(DateTime.parse(element.createdAt).toLocal()), | ||||
|     (element) => DateFormat('y-MM-dd').format(element.createdAt.toLocal()), | ||||
|   ); | ||||
| }); | ||||
|  | ||||
| final assetGroupByMonthYearProvider = StateProvider((ref) { | ||||
|   var assets = ref.watch(assetProvider); | ||||
|   // TODO: remove `where` once temporary workaround is no longer needed (to only | ||||
|   // allow remote assets to be added to album). Keep `toList()` as to NOT sort | ||||
|   // the original list/state | ||||
|   final assets = ref.watch(assetProvider).where((e) => e.isRemote).toList(); | ||||
|  | ||||
|   assets.sortByCompare<DateTime>( | ||||
|     (e) => DateTime.parse(e.createdAt), | ||||
|     (e) => e.createdAt, | ||||
|     (a, b) => b.compareTo(a), | ||||
|   ); | ||||
|  | ||||
|   return assets.groupListsBy( | ||||
|     (element) => DateFormat('MMMM, y') | ||||
|         .format(DateTime.parse(element.createdAt).toLocal()), | ||||
|     (element) => DateFormat('MMMM, y').format(element.createdAt.toLocal()), | ||||
|   ); | ||||
| }); | ||||
|   | ||||
| @@ -2,11 +2,11 @@ import 'dart:io'; | ||||
|  | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/providers/api.provider.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:path/path.dart'; | ||||
| import 'package:path_provider/path_provider.dart'; | ||||
| import 'package:share_plus/share_plus.dart'; | ||||
| import 'package:path/path.dart' as p; | ||||
| import 'api.service.dart'; | ||||
|  | ||||
| final shareServiceProvider = | ||||
| @@ -17,26 +17,28 @@ class ShareService { | ||||
|  | ||||
|   ShareService(this._apiService); | ||||
|  | ||||
|   Future<void> shareAsset(AssetResponseDto asset) async { | ||||
|   Future<void> shareAsset(Asset asset) async { | ||||
|     await shareAssets([asset]); | ||||
|   } | ||||
|  | ||||
|   Future<void> shareAssets(List<AssetResponseDto> assets) async { | ||||
|   Future<void> shareAssets(List<Asset> assets) async { | ||||
|     final downloadedFilePaths = assets.map((asset) async { | ||||
|       final res = await _apiService.assetApi.downloadFileWithHttpInfo( | ||||
|         asset.deviceAssetId, | ||||
|         asset.deviceId, | ||||
|         isThumb: false, | ||||
|         isWeb: false, | ||||
|       ); | ||||
|  | ||||
|       final fileName = p.basename(asset.originalPath); | ||||
|  | ||||
|       final tempDir = await getTemporaryDirectory(); | ||||
|       final tempFile = await File('${tempDir.path}/$fileName').create(); | ||||
|       tempFile.writeAsBytesSync(res.bodyBytes); | ||||
|  | ||||
|       return tempFile.path; | ||||
|       if (asset.isRemote) { | ||||
|         final tempDir = await getTemporaryDirectory(); | ||||
|         final fileName = basename(asset.remote!.originalPath); | ||||
|         final tempFile = await File('${tempDir.path}/$fileName').create(); | ||||
|         final res = await _apiService.assetApi.downloadFileWithHttpInfo( | ||||
|           asset.remote!.deviceAssetId, | ||||
|           asset.remote!.deviceId, | ||||
|           isThumb: false, | ||||
|           isWeb: false, | ||||
|         ); | ||||
|         tempFile.writeAsBytesSync(res.bodyBytes); | ||||
|         return tempFile.path; | ||||
|       } else { | ||||
|         File? f = await asset.local!.file; | ||||
|         return f!.path; | ||||
|       } | ||||
|     }); | ||||
|  | ||||
|     Share.shareFiles( | ||||
|   | ||||
							
								
								
									
										96
									
								
								mobile/lib/shared/ui/immich_image.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										96
									
								
								mobile/lib/shared/ui/immich_image.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,96 @@ | ||||
| import 'package:cached_network_image/cached_network_image.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:hive_flutter/hive_flutter.dart'; | ||||
| import 'package:immich_mobile/constants/hive_box.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/utils/image_url_builder.dart'; | ||||
| import 'package:photo_manager/photo_manager.dart'; | ||||
|  | ||||
| /// Renders an Asset using local data if available, else remote data | ||||
| class ImmichImage extends StatelessWidget { | ||||
|   const ImmichImage( | ||||
|     this.asset, { | ||||
|     required this.width, | ||||
|     required this.height, | ||||
|     this.useGrayBoxPlaceholder = false, | ||||
|     super.key, | ||||
|   }); | ||||
|   final Asset asset; | ||||
|   final bool useGrayBoxPlaceholder; | ||||
|   final double width; | ||||
|   final double height; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     if (asset.isLocal) { | ||||
|       return Image( | ||||
|         image: AssetEntityImageProvider( | ||||
|           asset.local!, | ||||
|           isOriginal: false, | ||||
|           thumbnailSize: const ThumbnailSize.square(250), // like server thumbs | ||||
|         ), | ||||
|         width: width, | ||||
|         height: height, | ||||
|         fit: BoxFit.cover, | ||||
|         frameBuilder: (context, child, frame, wasSynchronouslyLoaded) { | ||||
|           if (wasSynchronouslyLoaded || frame != null) { | ||||
|             return child; | ||||
|           } | ||||
|           return (useGrayBoxPlaceholder | ||||
|               ? const SizedBox.square( | ||||
|                   dimension: 250, | ||||
|                   child: DecoratedBox( | ||||
|                     decoration: BoxDecoration(color: Colors.grey), | ||||
|                   ), | ||||
|                 ) | ||||
|               : Transform.scale( | ||||
|                   scale: 0.2, | ||||
|                   child: const CircularProgressIndicator(), | ||||
|                 )); | ||||
|         }, | ||||
|         errorBuilder: (context, error, stackTrace) { | ||||
|           debugPrint("Error getting thumb for assetId=${asset.id}: $error"); | ||||
|           return Icon( | ||||
|             Icons.image_not_supported_outlined, | ||||
|             color: Theme.of(context).primaryColor, | ||||
|           ); | ||||
|         }, | ||||
|       ); | ||||
|     } | ||||
|     final String token = Hive.box(userInfoBox).get(accessTokenKey); | ||||
|     final String thumbnailRequestUrl = getThumbnailUrl(asset.remote!); | ||||
|     return CachedNetworkImage( | ||||
|       imageUrl: thumbnailRequestUrl, | ||||
|       httpHeaders: {"Authorization": "Bearer $token"}, | ||||
|       cacheKey: 'thumbnail-image-${asset.id}', | ||||
|       width: width, | ||||
|       height: height, | ||||
|       // keeping memCacheWidth, memCacheHeight, maxWidthDiskCache and | ||||
|       // maxHeightDiskCache = null allows to simply store the webp thumbnail | ||||
|       // from the server and use it for all rendered thumbnail sizes | ||||
|       fit: BoxFit.cover, | ||||
|       fadeInDuration: const Duration(milliseconds: 250), | ||||
|       progressIndicatorBuilder: (context, url, downloadProgress) { | ||||
|         if (useGrayBoxPlaceholder) { | ||||
|           return const DecoratedBox( | ||||
|             decoration: BoxDecoration(color: Colors.grey), | ||||
|           ); | ||||
|         } | ||||
|         return Transform.scale( | ||||
|           scale: 0.2, | ||||
|           child: CircularProgressIndicator( | ||||
|             value: downloadProgress.progress, | ||||
|           ), | ||||
|         ); | ||||
|       }, | ||||
|       errorWidget: (context, url, error) { | ||||
|         debugPrint("Error getting thumbnail $url = $error"); | ||||
|         CachedNetworkImage.evictFromCache(thumbnailRequestUrl); | ||||
|         return Icon( | ||||
|           Icons.image_not_supported_outlined, | ||||
|           color: Theme.of(context).primaryColor, | ||||
|         ); | ||||
|       }, | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -1,9 +1,10 @@ | ||||
| import 'package:flutter_test/flutter_test.dart'; | ||||
| import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
|  | ||||
| void main() { | ||||
|   final List<AssetResponseDto> testAssets = []; | ||||
|   final List<Asset> testAssets = []; | ||||
|  | ||||
|   for (int i = 0; i < 150; i++) { | ||||
|     int month = i ~/ 31; | ||||
| @@ -11,39 +12,43 @@ void main() { | ||||
|  | ||||
|     DateTime date = DateTime(2022, month, day); | ||||
|  | ||||
|     testAssets.add(AssetResponseDto( | ||||
|       type: AssetTypeEnum.IMAGE, | ||||
|       id: '$i', | ||||
|       deviceAssetId: '', | ||||
|       ownerId: '', | ||||
|       deviceId: '', | ||||
|       originalPath: '', | ||||
|       resizePath: '', | ||||
|       createdAt: date.toIso8601String(), | ||||
|       modifiedAt: date.toIso8601String(), | ||||
|       isFavorite: false, | ||||
|       mimeType: 'image/jpeg', | ||||
|       duration: '', | ||||
|       webpPath: '', | ||||
|       encodedVideoPath: '', | ||||
|     )); | ||||
|     testAssets.add( | ||||
|       Asset.remote( | ||||
|         AssetResponseDto( | ||||
|           type: AssetTypeEnum.IMAGE, | ||||
|           id: '$i', | ||||
|           deviceAssetId: '', | ||||
|           ownerId: '', | ||||
|           deviceId: '', | ||||
|           originalPath: '', | ||||
|           resizePath: '', | ||||
|           createdAt: date.toIso8601String(), | ||||
|           modifiedAt: date.toIso8601String(), | ||||
|           isFavorite: false, | ||||
|           mimeType: 'image/jpeg', | ||||
|           duration: '', | ||||
|           webpPath: '', | ||||
|           encodedVideoPath: '', | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   final Map<String, List<AssetResponseDto>> groups = { | ||||
|   final Map<String, List<Asset>> groups = { | ||||
|     '2022-01-05': testAssets.sublist(0, 5).map((e) { | ||||
|       e.createdAt = DateTime(2022, 1, 5).toIso8601String(); | ||||
|       e.createdAt = DateTime(2022, 1, 5); | ||||
|       return e; | ||||
|     }).toList(), | ||||
|     '2022-01-10': testAssets.sublist(5, 10).map((e) { | ||||
|       e.createdAt = DateTime(2022, 1, 10).toIso8601String(); | ||||
|       e.createdAt = DateTime(2022, 1, 10); | ||||
|       return e; | ||||
|     }).toList(), | ||||
|     '2022-02-17': testAssets.sublist(10, 15).map((e) { | ||||
|       e.createdAt = DateTime(2022, 2, 17).toIso8601String(); | ||||
|       e.createdAt = DateTime(2022, 2, 17); | ||||
|       return e; | ||||
|     }).toList(), | ||||
|     '2022-10-15': testAssets.sublist(15, 30).map((e) { | ||||
|       e.createdAt = DateTime(2022, 10, 15).toIso8601String(); | ||||
|       e.createdAt = DateTime(2022, 10, 15); | ||||
|       return e; | ||||
|     }).toList() | ||||
|   }; | ||||
|   | ||||
		Reference in New Issue
	
	Block a user