mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	refactor(mobile): reworked Asset, store all required fields from local & remote (#1539)
replace usage of AssetResponseDto with Asset Add new class ExifInfo to store data from ExifResponseDto
This commit is contained in:
		
				
					committed by
					
						 GitHub
						GitHub
					
				
			
			
				
	
			
			
			
						parent
						
							7bd2455175
						
					
				
				
					commit
					0048662182
				
			| @@ -5,6 +5,7 @@ const String deviceIdKey = 'immichBoxDeviceIdKey'; // Key 2 | ||||
| const String isLoggedInKey = 'immichIsLoggedInKey'; // Key 3 | ||||
| const String serverEndpointKey = 'immichBoxServerEndpoint'; // Key 4 | ||||
| const String assetEtagKey = 'immichAssetEtagKey'; // Key 5 | ||||
| const String userIdKey = 'immichUserIdKey'; // Key 6 | ||||
|  | ||||
| // Login Info | ||||
| const String hiveLoginInfoBox = "immichLoginInfoBox"; // Box | ||||
|   | ||||
| @@ -85,9 +85,11 @@ class AlbumViewerThumbnail extends HookConsumerWidget { | ||||
|         right: 10, | ||||
|         bottom: 5, | ||||
|         child: Icon( | ||||
|           (deviceId != asset.deviceId) | ||||
|           asset.isRemote | ||||
|               ? (deviceId == asset.deviceId | ||||
|                   ? Icons.cloud_done_outlined | ||||
|               : Icons.photo_library_rounded, | ||||
|                   : Icons.cloud_outlined) | ||||
|               : Icons.cloud_off_outlined, | ||||
|           color: Colors.white, | ||||
|           size: 18, | ||||
|         ), | ||||
|   | ||||
| @@ -121,7 +121,7 @@ class SelectionThumbnailImage extends HookConsumerWidget { | ||||
|               child: Row( | ||||
|                 children: [ | ||||
|                   Text( | ||||
|                     asset.duration.substring(0, 7), | ||||
|                     asset.duration.toString().substring(0, 7), | ||||
|                     style: const TextStyle( | ||||
|                       color: Colors.white, | ||||
|                       fontSize: 10, | ||||
|   | ||||
| @@ -7,7 +7,6 @@ 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'; | ||||
| import 'package:openapi/api.dart'; | ||||
|  | ||||
| class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> { | ||||
|   final ImageViewerService _imageViewerService; | ||||
| @@ -20,7 +19,7 @@ class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> { | ||||
|           ), | ||||
|         ); | ||||
|  | ||||
|   void downloadAsset(AssetResponseDto asset, BuildContext context) async { | ||||
|   void downloadAsset(Asset asset, BuildContext context) async { | ||||
|     state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.loading); | ||||
|  | ||||
|     bool isSuccess = await _imageViewerService.downloadAssetToDevice(asset); | ||||
|   | ||||
| @@ -2,10 +2,9 @@ import 'dart:io'; | ||||
|  | ||||
| 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'; | ||||
| import 'package:path/path.dart' as p; | ||||
|  | ||||
| import 'package:photo_manager/photo_manager.dart'; | ||||
| import 'package:path_provider/path_provider.dart'; | ||||
| @@ -18,14 +17,12 @@ class ImageViewerService { | ||||
|  | ||||
|   ImageViewerService(this._apiService); | ||||
|  | ||||
|   Future<bool> downloadAssetToDevice(AssetResponseDto asset) async { | ||||
|   Future<bool> downloadAssetToDevice(Asset asset) async { | ||||
|     try { | ||||
|       String fileName = p.basename(asset.originalPath); | ||||
|  | ||||
|       // Download LivePhotos image and motion part | ||||
|       if (asset.type == AssetTypeEnum.IMAGE && asset.livePhotoVideoId != null) { | ||||
|       if (asset.isImage && asset.livePhotoVideoId != null) { | ||||
|         var imageResponse = await _apiService.assetApi.downloadFileWithHttpInfo( | ||||
|           asset.id, | ||||
|           asset.remoteId!, | ||||
|         ); | ||||
|  | ||||
|         var motionReponse = await _apiService.assetApi.downloadFileWithHttpInfo( | ||||
| @@ -43,28 +40,28 @@ class ImageViewerService { | ||||
|         entity = await PhotoManager.editor.darwin.saveLivePhoto( | ||||
|           imageFile: imageFile, | ||||
|           videoFile: videoFile, | ||||
|           title: p.basename(asset.originalPath), | ||||
|           title: asset.fileName, | ||||
|         ); | ||||
|  | ||||
|         return entity != null; | ||||
|       } else { | ||||
|         var res = await _apiService.assetApi.downloadFileWithHttpInfo( | ||||
|           asset.id, | ||||
|         ); | ||||
|         var res = await _apiService.assetApi | ||||
|             .downloadFileWithHttpInfo(asset.remoteId!); | ||||
|  | ||||
|         final AssetEntity? entity; | ||||
|  | ||||
|         if (asset.type == AssetTypeEnum.IMAGE) { | ||||
|         if (asset.isImage) { | ||||
|           entity = await PhotoManager.editor.saveImage( | ||||
|             res.bodyBytes, | ||||
|             title: p.basename(asset.originalPath), | ||||
|             title: asset.fileName, | ||||
|           ); | ||||
|         } else { | ||||
|           final tempDir = await getTemporaryDirectory(); | ||||
|           File tempFile = await File('${tempDir.path}/$fileName').create(); | ||||
|           File tempFile = | ||||
|               await File('${tempDir.path}/${asset.fileName}').create(); | ||||
|           tempFile.writeAsBytesSync(res.bodyBytes); | ||||
|           entity = | ||||
|               await PhotoManager.editor.saveVideo(tempFile, title: fileName); | ||||
|           entity = await PhotoManager.editor | ||||
|               .saveVideo(tempFile, title: asset.fileName); | ||||
|         } | ||||
|         return entity != null; | ||||
|       } | ||||
|   | ||||
| @@ -3,9 +3,8 @@ 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:immich_mobile/shared/models/exif_info.dart'; | ||||
| import 'package:immich_mobile/shared/ui/drag_sheet.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:path/path.dart' as p; | ||||
| import 'package:latlong2/latlong.dart'; | ||||
| import 'package:immich_mobile/utils/bytes_units.dart'; | ||||
|  | ||||
| @@ -68,7 +67,7 @@ class ExifBottomSheet extends HookConsumerWidget { | ||||
|  | ||||
|     final textColor = Theme.of(context).primaryColor; | ||||
|  | ||||
|     ExifResponseDto? exifInfo = assetDetail.remote?.exifInfo; | ||||
|     ExifInfo? exifInfo = assetDetail.exifInfo; | ||||
|  | ||||
|     buildLocationText() { | ||||
|       return Text( | ||||
| @@ -81,6 +80,17 @@ class ExifBottomSheet extends HookConsumerWidget { | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     buildSizeText(Asset a) { | ||||
|       String resolution = a.width != null && a.height != null | ||||
|           ? "${a.height} x ${a.width}  " | ||||
|           : ""; | ||||
|       String fileSize = a.exifInfo?.fileSize != null | ||||
|           ? formatBytes(a.exifInfo!.fileSize!) | ||||
|           : ""; | ||||
|       String text = resolution + fileSize; | ||||
|       return text.isEmpty ? null : Text(text); | ||||
|     } | ||||
|  | ||||
|     return SingleChildScrollView( | ||||
|       child: Card( | ||||
|         shape: const RoundedRectangleBorder( | ||||
| @@ -101,10 +111,9 @@ class ExifBottomSheet extends HookConsumerWidget { | ||||
|                 child: CustomDraggingHandle(), | ||||
|               ), | ||||
|               const SizedBox(height: 12), | ||||
|               if (exifInfo?.dateTimeOriginal != null) | ||||
|               Text( | ||||
|                 DateFormat('date_format'.tr()).format( | ||||
|                     exifInfo!.dateTimeOriginal!.toLocal(), | ||||
|                   assetDetail.createdAt.toLocal(), | ||||
|                 ), | ||||
|                 style: const TextStyle( | ||||
|                   fontWeight: FontWeight.bold, | ||||
| @@ -113,7 +122,7 @@ class ExifBottomSheet extends HookConsumerWidget { | ||||
|               ), | ||||
|  | ||||
|               // Location | ||||
|               if (assetDetail.latitude != null) | ||||
|               if (assetDetail.latitude != null && assetDetail.longitude != null) | ||||
|                 Padding( | ||||
|                   padding: const EdgeInsets.only(top: 32.0), | ||||
|                   child: Column( | ||||
| @@ -126,22 +135,19 @@ class ExifBottomSheet extends HookConsumerWidget { | ||||
|                         "exif_bottom_sheet_location", | ||||
|                         style: TextStyle(fontSize: 11, color: textColor), | ||||
|                       ).tr(), | ||||
|                       if (assetDetail.latitude != null && | ||||
|                           assetDetail.longitude != null) | ||||
|                       buildMap(), | ||||
|                       if (exifInfo != null && | ||||
|                           exifInfo.city != null && | ||||
|                           exifInfo.state != null) | ||||
|                         buildLocationText(), | ||||
|                       Text( | ||||
|                         "${assetDetail.latitude?.toStringAsFixed(4)}, ${assetDetail.longitude?.toStringAsFixed(4)}", | ||||
|                         "${assetDetail.latitude!.toStringAsFixed(4)}, ${assetDetail.longitude!.toStringAsFixed(4)}", | ||||
|                         style: const TextStyle(fontSize: 12), | ||||
|                       ) | ||||
|                     ], | ||||
|                   ), | ||||
|                 ), | ||||
|               // Detail | ||||
|               if (exifInfo != null) | ||||
|               Padding( | ||||
|                 padding: const EdgeInsets.only(top: 32.0), | ||||
|                 child: Column( | ||||
| @@ -163,25 +169,21 @@ class ExifBottomSheet extends HookConsumerWidget { | ||||
|                       dense: true, | ||||
|                       leading: const Icon(Icons.image), | ||||
|                       title: Text( | ||||
|                           "${exifInfo.imageName!}${p.extension(assetDetail.remote!.originalPath)}", | ||||
|                         assetDetail.fileName, | ||||
|                         style: TextStyle( | ||||
|                           fontWeight: FontWeight.bold, | ||||
|                           color: textColor, | ||||
|                         ), | ||||
|                       ), | ||||
|                         subtitle: exifInfo.exifImageHeight != null | ||||
|                             ? Text( | ||||
|                                 "${exifInfo.exifImageHeight} x ${exifInfo.exifImageWidth}  ${formatBytes(exifInfo.fileSizeInByte ?? 0)} ", | ||||
|                               ) | ||||
|                             : null, | ||||
|                       subtitle: buildSizeText(assetDetail), | ||||
|                     ), | ||||
|                       if (exifInfo.make != null) | ||||
|                     if (exifInfo?.make != null) | ||||
|                       ListTile( | ||||
|                         contentPadding: const EdgeInsets.all(0), | ||||
|                         dense: true, | ||||
|                         leading: const Icon(Icons.camera), | ||||
|                         title: Text( | ||||
|                             "${exifInfo.make} ${exifInfo.model}", | ||||
|                           "${exifInfo!.make} ${exifInfo.model}", | ||||
|                           style: TextStyle( | ||||
|                             color: textColor, | ||||
|                             fontWeight: FontWeight.bold, | ||||
|   | ||||
| @@ -43,7 +43,7 @@ class TopControlAppBar extends HookConsumerWidget { | ||||
|         ), | ||||
|       ), | ||||
|       actions: [ | ||||
|         if (asset.remote?.livePhotoVideoId != null) | ||||
|         if (asset.livePhotoVideoId != null) | ||||
|           IconButton( | ||||
|             iconSize: iconSize, | ||||
|             splashRadius: iconSize, | ||||
| @@ -104,7 +104,6 @@ class TopControlAppBar extends HookConsumerWidget { | ||||
|             color: Colors.grey[200], | ||||
|           ), | ||||
|         ), | ||||
|         if (asset.isRemote) | ||||
|         IconButton( | ||||
|           iconSize: iconSize, | ||||
|           splashRadius: iconSize, | ||||
|   | ||||
| @@ -13,7 +13,7 @@ import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_s | ||||
| import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart'; | ||||
| import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart'; | ||||
| 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/shared/services/asset.service.dart'; | ||||
| import 'package:immich_mobile/modules/home/ui/delete_diaglog.dart'; | ||||
| import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; | ||||
| import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; | ||||
| @@ -80,31 +80,34 @@ class GalleryViewerPage extends HookConsumerWidget { | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     /// Thumbnail image of a remote asset. Required asset.remote != null | ||||
|     ImageProvider remoteThumbnailImageProvider(Asset asset, api.ThumbnailFormat type) { | ||||
|     /// Thumbnail image of a remote asset. Required asset.isRemote | ||||
|     ImageProvider remoteThumbnailImageProvider( | ||||
|       Asset asset, | ||||
|       api.ThumbnailFormat type, | ||||
|     ) { | ||||
|       return CachedNetworkImageProvider( | ||||
|         getThumbnailUrl( | ||||
|           asset.remote!, | ||||
|           asset, | ||||
|           type: type, | ||||
|         ), | ||||
|         cacheKey: getThumbnailCacheKey( | ||||
|           asset.remote!, | ||||
|           asset, | ||||
|           type: type, | ||||
|         ), | ||||
|         headers: {"Authorization": authToken}, | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     /// Original (large) image of a remote asset. Required asset.remote != null | ||||
|     /// Original (large) image of a remote asset. Required asset.isRemote | ||||
|     ImageProvider originalImageProvider(Asset asset) { | ||||
|       return CachedNetworkImageProvider( | ||||
|         getImageUrl(asset.remote!), | ||||
|         cacheKey: getImageCacheKey(asset.remote!), | ||||
|         getImageUrl(asset), | ||||
|         cacheKey: getImageCacheKey(asset), | ||||
|         headers: {"Authorization": authToken}, | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     /// Thumbnail image of a local asset. Required asset.local != null | ||||
|     /// Thumbnail image of a local asset. Required asset.isLocal | ||||
|     ImageProvider localThumbnailImageProvider(Asset asset) { | ||||
|       return AssetEntityImageProvider( | ||||
|         asset.local!, | ||||
| @@ -114,10 +117,9 @@ class GalleryViewerPage extends HookConsumerWidget { | ||||
|           MediaQuery.of(context).size.height.floor(), | ||||
|         ), | ||||
|       ); | ||||
|  | ||||
|     } | ||||
|  | ||||
|     /// Original (large) image of a local asset. Required asset.local != null | ||||
|     /// Original (large) image of a local asset. Required asset.isLocal | ||||
|     ImageProvider localImageProvider(Asset asset) { | ||||
|       return AssetEntityImageProvider(asset.local!); | ||||
|     } | ||||
| @@ -154,13 +156,11 @@ class GalleryViewerPage extends HookConsumerWidget { | ||||
|               context, | ||||
|             ); | ||||
|           } | ||||
|  | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     void showInfo() { | ||||
|       if (assetList[indexOfAsset.value].isRemote) { | ||||
|       showModalBottomSheet( | ||||
|         shape: RoundedRectangleBorder( | ||||
|           borderRadius: BorderRadius.circular(15.0), | ||||
| @@ -174,7 +174,6 @@ class GalleryViewerPage extends HookConsumerWidget { | ||||
|         }, | ||||
|       ); | ||||
|     } | ||||
|     } | ||||
|  | ||||
|     void handleDelete(Asset deleteAsset) { | ||||
|       showDialog( | ||||
| @@ -244,7 +243,7 @@ class GalleryViewerPage extends HookConsumerWidget { | ||||
|                 ? null | ||||
|                 : () { | ||||
|                     ref.watch(imageViewerStateProvider.notifier).downloadAsset( | ||||
|                           assetList[indexOfAsset.value].remote!, | ||||
|                           assetList[indexOfAsset.value], | ||||
|                           context, | ||||
|                         ); | ||||
|                   }, | ||||
| @@ -256,8 +255,10 @@ class GalleryViewerPage extends HookConsumerWidget { | ||||
|             onToggleMotionVideo: (() { | ||||
|               isPlayingMotionVideo.value = !isPlayingMotionVideo.value; | ||||
|             }), | ||||
|             onDeletePressed: () => handleDelete((assetList[indexOfAsset.value])), | ||||
|             onAddToAlbumPressed: () => addToAlbum(assetList[indexOfAsset.value]), | ||||
|             onDeletePressed: () => | ||||
|                 handleDelete((assetList[indexOfAsset.value])), | ||||
|             onAddToAlbumPressed: () => | ||||
|                 addToAlbum(assetList[indexOfAsset.value]), | ||||
|           ), | ||||
|         ), | ||||
|       ); | ||||
| @@ -293,24 +294,33 @@ class GalleryViewerPage extends HookConsumerWidget { | ||||
|               indexOfAsset.value = value; | ||||
|               HapticFeedback.selectionClick(); | ||||
|             }, | ||||
|           loadingBuilder: isLoadPreview.value ? (context, event) { | ||||
|             loadingBuilder: isLoadPreview.value | ||||
|                 ? (context, event) { | ||||
|                     final asset = assetList[indexOfAsset.value]; | ||||
|                     if (!asset.isLocal) { | ||||
|                       // Use the WEBP Thumbnail as a placeholder for the JPEG thumbnail to acheive | ||||
|                       // Three-Stage Loading (WEBP -> JPEG -> Original) | ||||
|                       final webPThumbnail = CachedNetworkImage( | ||||
|                 imageUrl: getThumbnailUrl(asset.remote!, type: api.ThumbnailFormat.WEBP), | ||||
|                 cacheKey: getThumbnailCacheKey(asset.remote!, type: api.ThumbnailFormat.WEBP), | ||||
|                 httpHeaders: { 'Authorization': authToken }, | ||||
|                 progressIndicatorBuilder: (_, __, ___) => const Center(child: ImmichLoadingIndicator(),), | ||||
|                         imageUrl: getThumbnailUrl(asset), | ||||
|                         cacheKey: getThumbnailCacheKey(asset), | ||||
|                         httpHeaders: {'Authorization': authToken}, | ||||
|                         progressIndicatorBuilder: (_, __, ___) => const Center( | ||||
|                           child: ImmichLoadingIndicator(), | ||||
|                         ), | ||||
|                         fadeInDuration: const Duration(milliseconds: 0), | ||||
|                         fit: BoxFit.contain, | ||||
|                       ); | ||||
|  | ||||
|                       return CachedNetworkImage( | ||||
|                 imageUrl: getThumbnailUrl(asset.remote!, type: api.ThumbnailFormat.JPEG), | ||||
|                 cacheKey: getThumbnailCacheKey(asset.remote!, type: api.ThumbnailFormat.JPEG), | ||||
|                 httpHeaders: { 'Authorization': authToken }, | ||||
|                         imageUrl: getThumbnailUrl( | ||||
|                           asset, | ||||
|                           type: api.ThumbnailFormat.JPEG, | ||||
|                         ), | ||||
|                         cacheKey: getThumbnailCacheKey( | ||||
|                           asset, | ||||
|                           type: api.ThumbnailFormat.JPEG, | ||||
|                         ), | ||||
|                         httpHeaders: {'Authorization': authToken}, | ||||
|                         fit: BoxFit.contain, | ||||
|                         fadeInDuration: const Duration(milliseconds: 0), | ||||
|                         placeholder: (_, __) => webPThumbnail, | ||||
| @@ -321,7 +331,8 @@ class GalleryViewerPage extends HookConsumerWidget { | ||||
|                         fit: BoxFit.contain, | ||||
|                       ); | ||||
|                     } | ||||
|           } : null, | ||||
|                   } | ||||
|                 : null, | ||||
|             builder: (context, index) { | ||||
|               getAssetExif(); | ||||
|               if (assetList[index].isImage && !isPlayingMotionVideo.value) { | ||||
| @@ -340,19 +351,25 @@ class GalleryViewerPage extends HookConsumerWidget { | ||||
|                   } | ||||
|                 } | ||||
|                 return PhotoViewGalleryPageOptions( | ||||
|                 onDragStart: (_, details, __) => localPosition = details.localPosition, | ||||
|                   onDragStart: (_, details, __) => | ||||
|                       localPosition = details.localPosition, | ||||
|                   onDragUpdate: (_, details, __) => handleSwipeUpDown(details), | ||||
|                 onTapDown: (_, __, ___) => showAppBar.value = !showAppBar.value, | ||||
|                   onTapDown: (_, __, ___) => | ||||
|                       showAppBar.value = !showAppBar.value, | ||||
|                   imageProvider: provider, | ||||
|                 heroAttributes: PhotoViewHeroAttributes(tag: assetList[index].id), | ||||
|                   heroAttributes: | ||||
|                       PhotoViewHeroAttributes(tag: assetList[index].id), | ||||
|                   minScale: PhotoViewComputedScale.contained, | ||||
|                 ); | ||||
|               } else { | ||||
|                 return PhotoViewGalleryPageOptions.customChild( | ||||
|                 onDragStart: (_, details, __) => localPosition = details.localPosition, | ||||
|                   onDragStart: (_, details, __) => | ||||
|                       localPosition = details.localPosition, | ||||
|                   onDragUpdate: (_, details, __) => handleSwipeUpDown(details), | ||||
|                 onTapDown: (_, __, ___) => showAppBar.value = !showAppBar.value, | ||||
|                 heroAttributes: PhotoViewHeroAttributes(tag: assetList[index].id), | ||||
|                   onTapDown: (_, __, ___) => | ||||
|                       showAppBar.value = !showAppBar.value, | ||||
|                   heroAttributes: | ||||
|                       PhotoViewHeroAttributes(tag: assetList[index].id), | ||||
|                   maxScale: 1.0, | ||||
|                   minScale: 1.0, | ||||
|                   child: SafeArea( | ||||
| @@ -381,4 +398,3 @@ class GalleryViewerPage extends HookConsumerWidget { | ||||
|     ); | ||||
|   } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -53,8 +53,8 @@ class VideoViewerPage extends HookConsumerWidget { | ||||
|     final box = Hive.box(userInfoBox); | ||||
|     final String jwtToken = box.get(accessTokenKey); | ||||
|     final String videoUrl = isMotionVideo | ||||
|         ? '${box.get(serverEndpointKey)}/asset/file/${asset.remote?.livePhotoVideoId!}' | ||||
|         : '${box.get(serverEndpointKey)}/asset/file/${asset.id}'; | ||||
|         ? '${box.get(serverEndpointKey)}/asset/file/${asset.livePhotoVideoId}' | ||||
|         : '${box.get(serverEndpointKey)}/asset/file/${asset.remoteId}'; | ||||
|  | ||||
|     return Stack( | ||||
|       children: [ | ||||
|   | ||||
| @@ -75,6 +75,9 @@ class BackupService { | ||||
|     final filter = FilterOptionGroup( | ||||
|       containsPathModified: true, | ||||
|       orders: [const OrderOption(type: OrderOptionType.updateDate)], | ||||
|       // title is needed to create Assets | ||||
|       imageOption: const FilterOption(needTitle: true), | ||||
|       videoOption: const FilterOption(needTitle: true), | ||||
|     ); | ||||
|     final now = DateTime.now(); | ||||
|     final List<AssetPathEntity?> selectedAlbums = | ||||
|   | ||||
| @@ -1,76 +0,0 @@ | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
|  | ||||
| class ImmichAssetGroupByDate { | ||||
|   final String date; | ||||
|   List<AssetResponseDto> assets; | ||||
|   ImmichAssetGroupByDate({ | ||||
|     required this.date, | ||||
|     required this.assets, | ||||
|   }); | ||||
|  | ||||
|   ImmichAssetGroupByDate copyWith({ | ||||
|     String? date, | ||||
|     List<AssetResponseDto>? assets, | ||||
|   }) { | ||||
|     return ImmichAssetGroupByDate( | ||||
|       date: date ?? this.date, | ||||
|       assets: assets ?? this.assets, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String toString() => 'ImmichAssetGroupByDate(date: $date, assets: $assets)'; | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     if (identical(this, other)) return true; | ||||
|  | ||||
|     return other is ImmichAssetGroupByDate && | ||||
|         other.date == date && | ||||
|         listEquals(other.assets, assets); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   int get hashCode => date.hashCode ^ assets.hashCode; | ||||
| } | ||||
|  | ||||
| class GetAllAssetResponse { | ||||
|   final int count; | ||||
|   final List<ImmichAssetGroupByDate> data; | ||||
|   final String nextPageKey; | ||||
|   GetAllAssetResponse({ | ||||
|     required this.count, | ||||
|     required this.data, | ||||
|     required this.nextPageKey, | ||||
|   }); | ||||
|  | ||||
|   GetAllAssetResponse copyWith({ | ||||
|     int? count, | ||||
|     List<ImmichAssetGroupByDate>? data, | ||||
|     String? nextPageKey, | ||||
|   }) { | ||||
|     return GetAllAssetResponse( | ||||
|       count: count ?? this.count, | ||||
|       data: data ?? this.data, | ||||
|       nextPageKey: nextPageKey ?? this.nextPageKey, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String toString() => | ||||
|       'GetAllAssetResponse(count: $count, data: $data, nextPageKey: $nextPageKey)'; | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     if (identical(this, other)) return true; | ||||
|  | ||||
|     return other is GetAllAssetResponse && | ||||
|         other.count == count && | ||||
|         listEquals(other.data, data) && | ||||
|         other.nextPageKey == nextPageKey; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   int get hashCode => count.hashCode ^ data.hashCode ^ nextPageKey.hashCode; | ||||
| } | ||||
| @@ -24,7 +24,6 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> { | ||||
|   bool _scrolling = false; | ||||
|   final Set<String> _selectedAssets = HashSet(); | ||||
|  | ||||
|  | ||||
|   Set<Asset> _getSelectedAssets() { | ||||
|     return _selectedAssets | ||||
|         .map((e) => widget.allAssets.firstWhereOrNull((a) => a.id == e)) | ||||
| @@ -103,7 +102,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> { | ||||
|     return Row( | ||||
|       key: Key("asset-row-${row.assets.first.id}"), | ||||
|       children: row.assets.map((Asset asset) { | ||||
|         bool last = asset == row.assets.last; | ||||
|         bool last = asset.id == row.assets.last.id; | ||||
|  | ||||
|         return Container( | ||||
|           key: Key("asset-${asset.id}"), | ||||
| @@ -224,7 +223,6 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|  | ||||
|   Future<bool> onWillPop() async { | ||||
|     if (widget.selectionActive && _selectedAssets.isNotEmpty) { | ||||
|       _deselectAll(); | ||||
| @@ -234,8 +232,6 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> { | ||||
|     return true; | ||||
|   } | ||||
|  | ||||
|  | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return WillPopScope( | ||||
|   | ||||
| @@ -4,7 +4,7 @@ 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/album/services/album_cache.service.dart'; | ||||
| import 'package:immich_mobile/modules/home/services/asset_cache.service.dart'; | ||||
| import 'package:immich_mobile/shared/services/asset_cache.service.dart'; | ||||
| import 'package:immich_mobile/modules/login/models/authentication_state.model.dart'; | ||||
| import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart'; | ||||
| import 'package:immich_mobile/modules/backup/services/backup.service.dart'; | ||||
| @@ -166,6 +166,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> { | ||||
|       var deviceInfo = await _deviceInfoService.getDeviceInfo(); | ||||
|       userInfoHiveBox.put(deviceIdKey, deviceInfo["deviceId"]); | ||||
|       userInfoHiveBox.put(accessTokenKey, accessToken); | ||||
|       userInfoHiveBox.put(userIdKey, userResponseDto.id); | ||||
|  | ||||
|       state = state.copyWith( | ||||
|         isAuthenticated: true, | ||||
|   | ||||
| @@ -45,9 +45,11 @@ class SearchResultPageState { | ||||
|       isLoading: map['isLoading'] ?? false, | ||||
|       isSuccess: map['isSuccess'] ?? false, | ||||
|       isError: map['isError'] ?? false, | ||||
|       searchResult: List<Asset>.from( | ||||
|       searchResult: List.from( | ||||
|         map['searchResult'] | ||||
|             ?.map((x) => Asset.remote(AssetResponseDto.fromJson(x))), | ||||
|             .map(AssetResponseDto.fromJson) | ||||
|             .where((e) => e != null) | ||||
|             .map(Asset.remote), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|   | ||||
| @@ -30,9 +30,7 @@ class SearchResultPageNotifier extends StateNotifier<SearchResultPageState> { | ||||
|       isSuccess: false, | ||||
|     ); | ||||
|  | ||||
|     List<Asset>? assets = (await _searchService.searchAsset(searchTerm)) | ||||
|         ?.map((e) => Asset.remote(e)) | ||||
|         .toList(); | ||||
|     List<Asset>? assets = await _searchService.searchAsset(searchTerm); | ||||
|  | ||||
|     if (assets != null) { | ||||
|       state = state.copyWith( | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| 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:immich_mobile/shared/services/api.service.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
| @@ -24,10 +25,14 @@ class SearchService { | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<List<AssetResponseDto>?> searchAsset(String searchTerm) async { | ||||
|   Future<List<Asset>?> searchAsset(String searchTerm) async { | ||||
|     try { | ||||
|       return await _apiService.assetApi | ||||
|       final List<AssetResponseDto>? results = await _apiService.assetApi | ||||
|           .searchAsset(SearchAssetDto(searchTerm: searchTerm)); | ||||
|       if (results == null) { | ||||
|         return null; | ||||
|       } | ||||
|       return results.map((e) => Asset.remote(e)).toList(); | ||||
|     } catch (e) { | ||||
|       debugPrint("[ERROR] [searchAsset] ${e.toString()}"); | ||||
|       return null; | ||||
| @@ -50,7 +55,7 @@ class SearchService { | ||||
|       return await _apiService.assetApi.getCuratedObjects(); | ||||
|     } catch (e) { | ||||
|       debugPrint("Error [getCuratedObjects] ${e.toString()}"); | ||||
|       throw []; | ||||
|       return []; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|   | ||||
| @@ -1,63 +1,128 @@ | ||||
| import 'package:hive/hive.dart'; | ||||
| import 'package:immich_mobile/constants/hive_box.dart'; | ||||
| import 'package:immich_mobile/shared/models/exif_info.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:photo_manager/photo_manager.dart'; | ||||
| import 'package:immich_mobile/utils/builtin_extensions.dart'; | ||||
| import 'package:path/path.dart' as p; | ||||
|  | ||||
| /// Asset (online or local) | ||||
| class Asset { | ||||
|   Asset.remote(this.remote) { | ||||
|     local = null; | ||||
|   } | ||||
|   Asset.remote(AssetResponseDto remote) | ||||
|       : remoteId = remote.id, | ||||
|         createdAt = DateTime.parse(remote.createdAt), | ||||
|         modifiedAt = DateTime.parse(remote.modifiedAt), | ||||
|         durationInSeconds = remote.duration.toDuration().inSeconds, | ||||
|         fileName = p.basename(remote.originalPath), | ||||
|         height = remote.exifInfo?.exifImageHeight?.toInt(), | ||||
|         width = remote.exifInfo?.exifImageWidth?.toInt(), | ||||
|         livePhotoVideoId = remote.livePhotoVideoId, | ||||
|         deviceAssetId = remote.deviceAssetId, | ||||
|         deviceId = remote.deviceId, | ||||
|         ownerId = remote.ownerId, | ||||
|         latitude = remote.exifInfo?.latitude?.toDouble(), | ||||
|         longitude = remote.exifInfo?.longitude?.toDouble(), | ||||
|         exifInfo = | ||||
|             remote.exifInfo != null ? ExifInfo.fromDto(remote.exifInfo!) : 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 { | ||||
|     if (isLocal) { | ||||
|       if (local!.createDateTime.year == 1970) { | ||||
|         return local!.modifiedDateTime; | ||||
|       } | ||||
|       return local!.createDateTime; | ||||
|     } else { | ||||
|       return DateTime.parse(remote!.createdAt); | ||||
|   Asset.local(AssetEntity local, String owner) | ||||
|       : localId = local.id, | ||||
|         latitude = local.latitude, | ||||
|         longitude = local.longitude, | ||||
|         durationInSeconds = local.duration, | ||||
|         height = local.height, | ||||
|         width = local.width, | ||||
|         fileName = local.title!, | ||||
|         deviceAssetId = local.id, | ||||
|         deviceId = Hive.box(userInfoBox).get(deviceIdKey), | ||||
|         ownerId = owner, | ||||
|         modifiedAt = local.modifiedDateTime.toUtc(), | ||||
|         createdAt = local.createDateTime.toUtc() { | ||||
|     if (createdAt.year == 1970) { | ||||
|       createdAt = modifiedAt; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   bool get isImage => isLocal | ||||
|       ? local!.type == AssetType.image | ||||
|       : remote!.type == AssetTypeEnum.IMAGE; | ||||
|   Asset({ | ||||
|     this.localId, | ||||
|     this.remoteId, | ||||
|     required this.deviceAssetId, | ||||
|     required this.deviceId, | ||||
|     required this.ownerId, | ||||
|     required this.createdAt, | ||||
|     required this.modifiedAt, | ||||
|     this.latitude, | ||||
|     this.longitude, | ||||
|     required this.durationInSeconds, | ||||
|     this.width, | ||||
|     this.height, | ||||
|     required this.fileName, | ||||
|     this.livePhotoVideoId, | ||||
|     this.exifInfo, | ||||
|   }); | ||||
|  | ||||
|   String get duration => isRemote | ||||
|       ? remote!.duration | ||||
|       : Duration(seconds: local!.duration).toString(); | ||||
|   AssetEntity? _local; | ||||
|  | ||||
|   /// use only for tests | ||||
|   set createdAt(DateTime val) { | ||||
|     if (isRemote) { | ||||
|       remote!.createdAt = val.toIso8601String(); | ||||
|   AssetEntity? get local { | ||||
|     if (isLocal && _local == null) { | ||||
|       _local = AssetEntity( | ||||
|         id: localId!.toString(), | ||||
|         typeInt: isImage ? 1 : 2, | ||||
|         width: width!, | ||||
|         height: height!, | ||||
|         duration: durationInSeconds, | ||||
|         createDateSecond: createdAt.millisecondsSinceEpoch ~/ 1000, | ||||
|         latitude: latitude, | ||||
|         longitude: longitude, | ||||
|         modifiedDateSecond: modifiedAt.millisecondsSinceEpoch ~/ 1000, | ||||
|         title: fileName, | ||||
|       ); | ||||
|     } | ||||
|     return _local; | ||||
|   } | ||||
|  | ||||
|   String? localId; | ||||
|  | ||||
|   String? remoteId; | ||||
|  | ||||
|   String deviceAssetId; | ||||
|  | ||||
|   String deviceId; | ||||
|  | ||||
|   String ownerId; | ||||
|  | ||||
|   DateTime createdAt; | ||||
|  | ||||
|   DateTime modifiedAt; | ||||
|  | ||||
|   double? latitude; | ||||
|  | ||||
|   double? longitude; | ||||
|  | ||||
|   int durationInSeconds; | ||||
|  | ||||
|   int? width; | ||||
|  | ||||
|   int? height; | ||||
|  | ||||
|   String fileName; | ||||
|  | ||||
|   String? livePhotoVideoId; | ||||
|  | ||||
|   ExifInfo? exifInfo; | ||||
|  | ||||
|   String get id => isLocal ? localId.toString() : remoteId!; | ||||
|  | ||||
|   String get name => p.withoutExtension(fileName); | ||||
|  | ||||
|   bool get isRemote => remoteId != null; | ||||
|  | ||||
|   bool get isLocal => localId != null; | ||||
|  | ||||
|   bool get isImage => durationInSeconds == 0; | ||||
|  | ||||
|   Duration get duration => Duration(seconds: durationInSeconds); | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(other) { | ||||
|     if (other is! Asset) return false; | ||||
| @@ -67,12 +132,26 @@ class Asset { | ||||
|   @override | ||||
|   int get hashCode => id.hashCode; | ||||
|  | ||||
|   // methods below are only required for caching as JSON | ||||
|  | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
|     if (isLocal) { | ||||
|       json["local"] = _assetEntityToJson(local!); | ||||
|     } else { | ||||
|       json["remote"] = remote!.toJson(); | ||||
|     json["localId"] = localId; | ||||
|     json["remoteId"] = remoteId; | ||||
|     json["deviceAssetId"] = deviceAssetId; | ||||
|     json["deviceId"] = deviceId; | ||||
|     json["ownerId"] = ownerId; | ||||
|     json["createdAt"] = createdAt.millisecondsSinceEpoch; | ||||
|     json["modifiedAt"] = modifiedAt.millisecondsSinceEpoch; | ||||
|     json["latitude"] = latitude; | ||||
|     json["longitude"] = longitude; | ||||
|     json["durationInSeconds"] = durationInSeconds; | ||||
|     json["width"] = width; | ||||
|     json["height"] = height; | ||||
|     json["fileName"] = fileName; | ||||
|     json["livePhotoVideoId"] = livePhotoVideoId; | ||||
|     if (exifInfo != null) { | ||||
|       json["exifInfo"] = exifInfo!.toJson(); | ||||
|     } | ||||
|     return json; | ||||
|   } | ||||
| @@ -80,55 +159,28 @@ class Asset { | ||||
|   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"], | ||||
|       return Asset( | ||||
|         localId: json["localId"], | ||||
|         remoteId: json["remoteId"], | ||||
|         deviceAssetId: json["deviceAssetId"], | ||||
|         deviceId: json["deviceId"], | ||||
|         ownerId: json["ownerId"], | ||||
|         createdAt: | ||||
|             DateTime.fromMillisecondsSinceEpoch(json["createdAt"], isUtc: true), | ||||
|         modifiedAt: DateTime.fromMillisecondsSinceEpoch( | ||||
|           json["modifiedAt"], | ||||
|           isUtc: true, | ||||
|         ), | ||||
|         latitude: json["latitude"], | ||||
|         longitude: json["longitude"], | ||||
|       mimeType: json["mimeType"], | ||||
|       subtype: json["subtype"], | ||||
|         durationInSeconds: json["durationInSeconds"], | ||||
|         width: json["width"], | ||||
|         height: json["height"], | ||||
|         fileName: json["fileName"], | ||||
|         livePhotoVideoId: json["livePhotoVideoId"], | ||||
|         exifInfo: ExifInfo.fromJson(json["exifInfo"]), | ||||
|       ); | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
| } | ||||
|   | ||||
							
								
								
									
										86
									
								
								mobile/lib/shared/models/exif_info.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										86
									
								
								mobile/lib/shared/models/exif_info.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,86 @@ | ||||
| import 'package:openapi/api.dart'; | ||||
| import 'package:immich_mobile/utils/builtin_extensions.dart'; | ||||
|  | ||||
| class ExifInfo { | ||||
|   int? fileSize; | ||||
|   String? make; | ||||
|   String? model; | ||||
|   String? orientation; | ||||
|   String? lensModel; | ||||
|   double? fNumber; | ||||
|   double? focalLength; | ||||
|   int? iso; | ||||
|   double? exposureTime; | ||||
|   String? city; | ||||
|   String? state; | ||||
|   String? country; | ||||
|  | ||||
|   ExifInfo.fromDto(ExifResponseDto dto) | ||||
|       : fileSize = dto.fileSizeInByte, | ||||
|         make = dto.make, | ||||
|         model = dto.model, | ||||
|         orientation = dto.orientation, | ||||
|         lensModel = dto.lensModel, | ||||
|         fNumber = dto.fNumber?.toDouble(), | ||||
|         focalLength = dto.focalLength?.toDouble(), | ||||
|         iso = dto.iso?.toInt(), | ||||
|         exposureTime = dto.exposureTime?.toDouble(), | ||||
|         city = dto.city, | ||||
|         state = dto.state, | ||||
|         country = dto.country; | ||||
|  | ||||
|   // stuff below is only required for caching as JSON | ||||
|  | ||||
|   ExifInfo( | ||||
|     this.fileSize, | ||||
|     this.make, | ||||
|     this.model, | ||||
|     this.orientation, | ||||
|     this.lensModel, | ||||
|     this.fNumber, | ||||
|     this.focalLength, | ||||
|     this.iso, | ||||
|     this.exposureTime, | ||||
|     this.city, | ||||
|     this.state, | ||||
|     this.country, | ||||
|   ); | ||||
|  | ||||
|   Map<String, dynamic> toJson() { | ||||
|     final json = <String, dynamic>{}; | ||||
|     json["fileSize"] = fileSize; | ||||
|     json["make"] = make; | ||||
|     json["model"] = model; | ||||
|     json["orientation"] = orientation; | ||||
|     json["lensModel"] = lensModel; | ||||
|     json["fNumber"] = fNumber; | ||||
|     json["focalLength"] = focalLength; | ||||
|     json["iso"] = iso; | ||||
|     json["exposureTime"] = exposureTime; | ||||
|     json["city"] = city; | ||||
|     json["state"] = state; | ||||
|     json["country"] = country; | ||||
|     return json; | ||||
|   } | ||||
|  | ||||
|   static ExifInfo? fromJson(dynamic value) { | ||||
|     if (value is Map) { | ||||
|       final json = value.cast<String, dynamic>(); | ||||
|       return ExifInfo( | ||||
|         json["fileSize"], | ||||
|         json["make"], | ||||
|         json["model"], | ||||
|         json["orientation"], | ||||
|         json["lensModel"], | ||||
|         json["fNumber"], | ||||
|         json["focalLength"], | ||||
|         json["iso"], | ||||
|         json["exposureTime"], | ||||
|         json["city"], | ||||
|         json["state"], | ||||
|         json["country"], | ||||
|       ); | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
| } | ||||
| @@ -4,8 +4,8 @@ import 'package:flutter/foundation.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/home/services/asset.service.dart'; | ||||
| import 'package:immich_mobile/modules/home/services/asset_cache.service.dart'; | ||||
| import 'package:immich_mobile/shared/services/asset.service.dart'; | ||||
| import 'package:immich_mobile/shared/services/asset_cache.service.dart'; | ||||
| import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart'; | ||||
| import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart'; | ||||
| import 'package:immich_mobile/modules/settings/services/app_settings.service.dart'; | ||||
| @@ -36,7 +36,7 @@ class AssetsState { | ||||
|     return AssetsState([...allAssets, ...toAdd]); | ||||
|   } | ||||
|  | ||||
|   _groupByDate() async { | ||||
|   Future<Map<String, List<Asset>>> _groupByDate() async { | ||||
|     sortCompare(List<Asset> assets) { | ||||
|       assets.sortByCompare<DateTime>( | ||||
|         (e) => e.createdAt, | ||||
| @@ -50,11 +50,11 @@ class AssetsState { | ||||
|     return await compute(sortCompare, allAssets.toList()); | ||||
|   } | ||||
|  | ||||
|   static fromAssetList(List<Asset> assets) { | ||||
|   static AssetsState fromAssetList(List<Asset> assets) { | ||||
|     return AssetsState(assets); | ||||
|   } | ||||
|  | ||||
|   static empty() { | ||||
|   static AssetsState empty() { | ||||
|     return AssetsState([]); | ||||
|   } | ||||
| } | ||||
| @@ -82,7 +82,10 @@ class AssetNotifier extends StateNotifier<AssetsState> { | ||||
|     this._settingsService, | ||||
|   ) : super(AssetsState.fromAssetList([])); | ||||
|  | ||||
|   _updateAssetsState(List<Asset> newAssetList, {bool cache = true}) async { | ||||
|   Future<void> _updateAssetsState( | ||||
|     List<Asset> newAssetList, { | ||||
|     bool cache = true, | ||||
|   }) async { | ||||
|     if (cache) { | ||||
|       _assetCacheService.put(newAssetList); | ||||
|     } | ||||
| @@ -101,20 +104,26 @@ class AssetNotifier extends StateNotifier<AssetsState> { | ||||
|     final stopwatch = Stopwatch(); | ||||
|     try { | ||||
|       _getAllAssetInProgress = true; | ||||
|       final bool isCacheValid = await _assetCacheService.isValid(); | ||||
|       bool isCacheValid = await _assetCacheService.isValid(); | ||||
|       stopwatch.start(); | ||||
|       final Box box = Hive.box(userInfoBox); | ||||
|       if (isCacheValid && state.allAssets.isEmpty) { | ||||
|         final List<Asset>? cachedData = await _assetCacheService.get(); | ||||
|         if (cachedData == null) { | ||||
|           isCacheValid = false; | ||||
|           log.warning("Cached asset data is invalid, fetching new data"); | ||||
|         } else { | ||||
|           await _updateAssetsState(cachedData, cache: false); | ||||
|           log.info( | ||||
|             "Reading assets ${state.allAssets.length} from cache: ${stopwatch.elapsedMilliseconds}ms", | ||||
|           ); | ||||
|         } | ||||
|         stopwatch.reset(); | ||||
|       } | ||||
|       final localTask = _assetService.getLocalAssets(urgent: !isCacheValid); | ||||
|       final remoteTask = _assetService.getRemoteAssets( | ||||
|         etag: isCacheValid ? box.get(assetEtagKey) : null, | ||||
|       ); | ||||
|       if (isCacheValid && state.allAssets.isEmpty) { | ||||
|         await _updateAssetsState(await _assetCacheService.get(), cache: false); | ||||
|         log.info( | ||||
|           "Reading assets ${state.allAssets.length} from cache: ${stopwatch.elapsedMilliseconds}ms", | ||||
|         ); | ||||
|         stopwatch.reset(); | ||||
|       } | ||||
|  | ||||
|       int remoteBegin = state.allAssets.indexWhere((a) => a.isRemote); | ||||
|       remoteBegin = remoteBegin == -1 ? state.allAssets.length : remoteBegin; | ||||
| @@ -184,7 +193,7 @@ class AssetNotifier extends StateNotifier<AssetsState> { | ||||
|     _updateAssetsState([]); | ||||
|   } | ||||
|  | ||||
|   onNewAssetUploaded(AssetResponseDto newAsset) { | ||||
|   void onNewAssetUploaded(Asset newAsset) { | ||||
|     final int i = state.allAssets.indexWhere( | ||||
|       (a) => | ||||
|           a.isRemote || | ||||
| @@ -192,13 +201,13 @@ class AssetNotifier extends StateNotifier<AssetsState> { | ||||
|     ); | ||||
|  | ||||
|     if (i == -1 || state.allAssets[i].deviceAssetId != newAsset.deviceAssetId) { | ||||
|       _updateAssetsState([...state.allAssets, Asset.remote(newAsset)]); | ||||
|       _updateAssetsState([...state.allAssets, newAsset]); | ||||
|     } else { | ||||
|       // order is important to keep all local-only assets at the beginning! | ||||
|       _updateAssetsState([ | ||||
|         ...state.allAssets.slice(0, i), | ||||
|         ...state.allAssets.slice(i + 1), | ||||
|         Asset.remote(newAsset), | ||||
|         newAsset, | ||||
|       ]); | ||||
|       // TODO here is a place to unify local/remote assets by replacing the | ||||
|       // local-only asset in the state with a local&remote asset | ||||
| @@ -230,7 +239,7 @@ class AssetNotifier extends StateNotifier<AssetsState> { | ||||
|     // Delete asset from device | ||||
|     for (final Asset asset in assetsToDelete) { | ||||
|       if (asset.isLocal) { | ||||
|         local.add(asset.id); | ||||
|         local.add(asset.localId!); | ||||
|       } else if (asset.deviceId == deviceId) { | ||||
|         // Delete asset on device if it is still present | ||||
|         var localAsset = await AssetEntity.fromId(asset.deviceAssetId); | ||||
| @@ -252,8 +261,7 @@ class AssetNotifier extends StateNotifier<AssetsState> { | ||||
|   Future<Iterable<String>> _deleteRemoteAssets( | ||||
|     Set<Asset> assetsToDelete, | ||||
|   ) async { | ||||
|     final Iterable<AssetResponseDto> remote = | ||||
|         assetsToDelete.where((e) => e.isRemote).map((e) => e.remote!); | ||||
|     final Iterable<Asset> remote = assetsToDelete.where((e) => e.isRemote); | ||||
|     final List<DeleteAssetResponseDto> deleteAssetResult = | ||||
|         await _assetService.deleteAssets(remote) ?? []; | ||||
|     return deleteAssetResult | ||||
|   | ||||
| @@ -5,6 +5,7 @@ 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/login/providers/authentication.provider.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:immich_mobile/shared/providers/asset.provider.dart'; | ||||
| import 'package:logging/logging.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
| @@ -91,14 +92,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> { | ||||
|           state = WebsocketState(isConnected: false, socket: null); | ||||
|         }); | ||||
|  | ||||
|         socket.on('on_upload_success', (data) { | ||||
|           var jsonString = jsonDecode(data.toString()); | ||||
|           AssetResponseDto? newAsset = AssetResponseDto.fromJson(jsonString); | ||||
|  | ||||
|           if (newAsset != null) { | ||||
|             ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset); | ||||
|           } | ||||
|         }); | ||||
|         socket.on('on_upload_success', _handleOnUploadSuccess); | ||||
|       } catch (e) { | ||||
|         debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}"); | ||||
|       } | ||||
| @@ -122,14 +116,16 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> { | ||||
|  | ||||
|   listenUploadEvent() { | ||||
|     debugPrint("Start listening to event on_upload_success"); | ||||
|     state.socket?.on('on_upload_success', (data) { | ||||
|       var jsonString = jsonDecode(data.toString()); | ||||
|       AssetResponseDto? newAsset = AssetResponseDto.fromJson(jsonString); | ||||
|     state.socket?.on('on_upload_success', _handleOnUploadSuccess); | ||||
|   } | ||||
|  | ||||
|       if (newAsset != null) { | ||||
|   _handleOnUploadSuccess(dynamic data) { | ||||
|     final jsonString = jsonDecode(data.toString()); | ||||
|     final dto = AssetResponseDto.fromJson(jsonString); | ||||
|     if (dto != null) { | ||||
|       final newAsset = Asset.remote(dto); | ||||
|       ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset); | ||||
|     } | ||||
|     }); | ||||
|   } | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -62,10 +62,11 @@ class AssetService { | ||||
|       } | ||||
|       final box = await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox); | ||||
|       final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey); | ||||
|       final String userId = Hive.box(userInfoBox).get(userIdKey); | ||||
|       if (backupAlbumInfo != null) { | ||||
|         return (await _backupService | ||||
|                 .buildUploadCandidates(backupAlbumInfo.deepCopy())) | ||||
|             .map(Asset.local) | ||||
|             .map((e) => Asset.local(e, userId)) | ||||
|             .toList(growable: false); | ||||
|       } | ||||
|     } catch (e) { | ||||
| @@ -76,21 +77,24 @@ class AssetService { | ||||
| 
 | ||||
|   Future<Asset?> getAssetById(String assetId) async { | ||||
|     try { | ||||
|       return Asset.remote(await _apiService.assetApi.getAssetById(assetId)); | ||||
|       final dto = await _apiService.assetApi.getAssetById(assetId); | ||||
|       if (dto != null) { | ||||
|         return Asset.remote(dto); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       debugPrint("Error [getAssetById]  ${e.toString()}"); | ||||
|       return null; | ||||
|     } | ||||
|     return null; | ||||
|   } | ||||
| 
 | ||||
|   Future<List<DeleteAssetResponseDto>?> deleteAssets( | ||||
|     Iterable<AssetResponseDto> deleteAssets, | ||||
|     Iterable<Asset> deleteAssets, | ||||
|   ) async { | ||||
|     try { | ||||
|       final List<String> payload = []; | ||||
| 
 | ||||
|       for (final asset in deleteAssets) { | ||||
|         payload.add(asset.id); | ||||
|         payload.add(asset.remoteId!); | ||||
|       } | ||||
| 
 | ||||
|       return await _apiService.assetApi | ||||
| @@ -23,17 +23,15 @@ class AssetCacheService extends JsonCache<List<Asset>> { | ||||
|   } | ||||
| 
 | ||||
|   @override | ||||
|   Future<List<Asset>> get() async { | ||||
|   Future<List<Asset>?> get() async { | ||||
|     try { | ||||
|       final mapList = await readRawData() as List<dynamic>; | ||||
| 
 | ||||
|       final responseData = await compute(_computeEncode, mapList); | ||||
| 
 | ||||
|       return responseData; | ||||
|     } catch (e) { | ||||
|       debugPrint(e.toString()); | ||||
| 
 | ||||
|       return []; | ||||
|       await invalidate(); | ||||
|       return null; | ||||
|     } | ||||
|   } | ||||
| } | ||||
| @@ -60,5 +60,5 @@ abstract class JsonCache<T> { | ||||
|   } | ||||
|  | ||||
|   void put(T data); | ||||
|   Future<T> get(); | ||||
|   Future<T?> get(); | ||||
| } | ||||
|   | ||||
| @@ -4,7 +4,6 @@ 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:path/path.dart'; | ||||
| import 'package:path_provider/path_provider.dart'; | ||||
| import 'package:share_plus/share_plus.dart'; | ||||
| import 'api.service.dart'; | ||||
| @@ -25,11 +24,10 @@ class ShareService { | ||||
|     final downloadedXFiles = assets.map<Future<XFile>>((asset) async { | ||||
|       if (asset.isRemote) { | ||||
|         final tempDir = await getTemporaryDirectory(); | ||||
|         final fileName = basename(asset.remote!.originalPath); | ||||
|         final fileName = asset.fileName; | ||||
|         final tempFile = await File('${tempDir.path}/$fileName').create(); | ||||
|         final res = await _apiService.assetApi.downloadFileWithHttpInfo( | ||||
|           asset.remote!.id, | ||||
|         ); | ||||
|         final res = await _apiService.assetApi | ||||
|             .downloadFileWithHttpInfo(asset.remoteId!); | ||||
|         tempFile.writeAsBytesSync(res.bodyBytes); | ||||
|         return XFile(tempFile.path); | ||||
|       } else { | ||||
|   | ||||
| @@ -1,5 +1,6 @@ | ||||
| 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:immich_mobile/constants/hive_box.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| @@ -15,13 +16,28 @@ class ImmichImage extends StatelessWidget { | ||||
|     this.useGrayBoxPlaceholder = false, | ||||
|     super.key, | ||||
|   }); | ||||
|   final Asset asset; | ||||
|   final Asset? asset; | ||||
|   final bool useGrayBoxPlaceholder; | ||||
|   final double width; | ||||
|   final double height; | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     if (this.asset == null) { | ||||
|       return Container( | ||||
|         decoration: const BoxDecoration( | ||||
|           color: Colors.grey, | ||||
|         ), | ||||
|         child: SizedBox( | ||||
|           width: width, | ||||
|           height: height, | ||||
|           child: const Center( | ||||
|             child: Icon(Icons.no_photography), | ||||
|           ), | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|     final Asset asset = this.asset!; | ||||
|     if (asset.isLocal) { | ||||
|       return Image( | ||||
|         image: AssetEntityImageProvider( | ||||
| @@ -49,7 +65,16 @@ class ImmichImage extends StatelessWidget { | ||||
|                 )); | ||||
|         }, | ||||
|         errorBuilder: (context, error, stackTrace) { | ||||
|           debugPrint("Error getting thumb for assetId=${asset.id}: $error"); | ||||
|           if (error is PlatformException && | ||||
|               error.code == "The asset not found!") { | ||||
|             debugPrint( | ||||
|               "Asset ${asset.localId} does not exist anymore on device!", | ||||
|             ); | ||||
|           } else { | ||||
|             debugPrint( | ||||
|               "Error getting thumb for assetId=${asset.localId}: $error", | ||||
|             ); | ||||
|           } | ||||
|           return Icon( | ||||
|             Icons.image_not_supported_outlined, | ||||
|             color: Theme.of(context).primaryColor, | ||||
| @@ -57,12 +82,12 @@ class ImmichImage extends StatelessWidget { | ||||
|         }, | ||||
|       ); | ||||
|     } | ||||
|     final String token = Hive.box(userInfoBox).get(accessTokenKey); | ||||
|     final String thumbnailRequestUrl = getThumbnailUrl(asset.remote!); | ||||
|     final String? token = Hive.box(userInfoBox).get(accessTokenKey); | ||||
|     final String thumbnailRequestUrl = getThumbnailUrl(asset); | ||||
|     return CachedNetworkImage( | ||||
|       imageUrl: thumbnailRequestUrl, | ||||
|       httpHeaders: {"Authorization": "Bearer $token"}, | ||||
|       cacheKey: getThumbnailCacheKey(asset.remote!), | ||||
|       cacheKey: getThumbnailCacheKey(asset), | ||||
|       width: width, | ||||
|       height: height, | ||||
|       // keeping memCacheWidth, memCacheHeight, maxWidthDiskCache and | ||||
|   | ||||
							
								
								
									
										11
									
								
								mobile/lib/utils/builtin_extensions.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										11
									
								
								mobile/lib/utils/builtin_extensions.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,11 @@ | ||||
| extension DurationExtension on String { | ||||
|   Duration toDuration() { | ||||
|     final parts = | ||||
|         split(':').map((e) => double.parse(e).toInt()).toList(growable: false); | ||||
|     return Duration(hours: parts[0], minutes: parts[1], seconds: parts[2]); | ||||
|   } | ||||
|  | ||||
|   double? toDouble() { | ||||
|     return double.tryParse(this); | ||||
|   } | ||||
| } | ||||
| @@ -1,17 +1,18 @@ | ||||
| import 'package:hive/hive.dart'; | ||||
| import 'package:immich_mobile/shared/models/asset.dart'; | ||||
| import 'package:openapi/api.dart'; | ||||
|  | ||||
| import '../constants/hive_box.dart'; | ||||
|  | ||||
| String getThumbnailUrl( | ||||
|   final AssetResponseDto asset, { | ||||
|   final Asset asset, { | ||||
|   ThumbnailFormat type = ThumbnailFormat.WEBP, | ||||
| }) { | ||||
|   return _getThumbnailUrl(asset.id, type: type); | ||||
|   return _getThumbnailUrl(asset.remoteId!, type: type); | ||||
| } | ||||
|  | ||||
| String getThumbnailCacheKey( | ||||
|   final AssetResponseDto asset, { | ||||
|   final Asset asset, { | ||||
|   ThumbnailFormat type = ThumbnailFormat.WEBP, | ||||
| }) { | ||||
|   return _getThumbnailCacheKey(asset.id, type); | ||||
| @@ -45,12 +46,12 @@ String getAlbumThumbNailCacheKey( | ||||
|   return _getThumbnailCacheKey(album.albumThumbnailAssetId!, type); | ||||
| } | ||||
|  | ||||
| String getImageUrl(final AssetResponseDto asset) { | ||||
| String getImageUrl(final Asset asset) { | ||||
|   final box = Hive.box(userInfoBox); | ||||
|   return '${box.get(serverEndpointKey)}/asset/file/${asset.id}?isThumb=false'; | ||||
|   return '${box.get(serverEndpointKey)}/asset/file/${asset.remoteId}?isThumb=false'; | ||||
| } | ||||
|  | ||||
| String getImageCacheKey(final AssetResponseDto asset) { | ||||
| String getImageCacheKey(final Asset asset) { | ||||
|   return '${asset.id}_fullStage'; | ||||
| } | ||||
|  | ||||
|   | ||||
| @@ -1,7 +1,6 @@ | ||||
| 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<Asset> testAssets = []; | ||||
| @@ -13,24 +12,14 @@ void main() { | ||||
|     DateTime date = DateTime(2022, month, day); | ||||
|  | ||||
|     testAssets.add( | ||||
|       Asset.remote( | ||||
|         AssetResponseDto( | ||||
|           type: AssetTypeEnum.IMAGE, | ||||
|           id: '$i', | ||||
|           deviceAssetId: '', | ||||
|           ownerId: '', | ||||
|       Asset( | ||||
|         deviceAssetId: '$i', | ||||
|         deviceId: '', | ||||
|           originalPath: '', | ||||
|           resizePath: '', | ||||
|           createdAt: date.toIso8601String(), | ||||
|           modifiedAt: date.toIso8601String(), | ||||
|           isFavorite: false, | ||||
|           mimeType: 'image/jpeg', | ||||
|           duration: '', | ||||
|           webpPath: '', | ||||
|           encodedVideoPath: '', | ||||
|           livePhotoVideoId: '', | ||||
|         ), | ||||
|         ownerId: '', | ||||
|         createdAt: date, | ||||
|         modifiedAt: date, | ||||
|         durationInSeconds: 0, | ||||
|         fileName: '', | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| @@ -70,11 +59,20 @@ void main() { | ||||
|       // Day 1 | ||||
|       // 15 Assets => 5 Rows | ||||
|       expect(renderList.elements.length, 18); | ||||
|       expect(renderList.elements[0].type, RenderAssetGridElementType.monthTitle); | ||||
|       expect( | ||||
|         renderList.elements[0].type, | ||||
|         RenderAssetGridElementType.monthTitle, | ||||
|       ); | ||||
|       expect(renderList.elements[0].date.month, 1); | ||||
|       expect(renderList.elements[7].type, RenderAssetGridElementType.monthTitle); | ||||
|       expect( | ||||
|         renderList.elements[7].type, | ||||
|         RenderAssetGridElementType.monthTitle, | ||||
|       ); | ||||
|       expect(renderList.elements[7].date.month, 2); | ||||
|       expect(renderList.elements[11].type, RenderAssetGridElementType.monthTitle); | ||||
|       expect( | ||||
|         renderList.elements[11].type, | ||||
|         RenderAssetGridElementType.monthTitle, | ||||
|       ); | ||||
|       expect(renderList.elements[11].date.month, 10); | ||||
|     }); | ||||
|  | ||||
|   | ||||
		Reference in New Issue
	
	Block a user