diff --git a/mobile/lib/constants/hive_box.dart b/mobile/lib/constants/hive_box.dart index 0c3b04bd7e67e..d1449890906d0 100644 --- a/mobile/lib/constants/hive_box.dart +++ b/mobile/lib/constants/hive_box.dart @@ -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 diff --git a/mobile/lib/modules/album/ui/album_viewer_thumbnail.dart b/mobile/lib/modules/album/ui/album_viewer_thumbnail.dart index e34060def9a54..ff4062aae6807 100644 --- a/mobile/lib/modules/album/ui/album_viewer_thumbnail.dart +++ b/mobile/lib/modules/album/ui/album_viewer_thumbnail.dart @@ -85,9 +85,11 @@ class AlbumViewerThumbnail 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, ), diff --git a/mobile/lib/modules/album/ui/selection_thumbnail_image.dart b/mobile/lib/modules/album/ui/selection_thumbnail_image.dart index 25d8c01de0707..51cd2766c82ec 100644 --- a/mobile/lib/modules/album/ui/selection_thumbnail_image.dart +++ b/mobile/lib/modules/album/ui/selection_thumbnail_image.dart @@ -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, diff --git a/mobile/lib/modules/asset_viewer/providers/image_viewer_page_state.provider.dart b/mobile/lib/modules/asset_viewer/providers/image_viewer_page_state.provider.dart index 8bedafc762fb4..4cbe51b28a155 100644 --- a/mobile/lib/modules/asset_viewer/providers/image_viewer_page_state.provider.dart +++ b/mobile/lib/modules/asset_viewer/providers/image_viewer_page_state.provider.dart @@ -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 { final ImageViewerService _imageViewerService; @@ -20,7 +19,7 @@ class ImageViewerStateNotifier extends StateNotifier { ), ); - 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); diff --git a/mobile/lib/modules/asset_viewer/services/image_viewer.service.dart b/mobile/lib/modules/asset_viewer/services/image_viewer.service.dart index 0d2e72d60b36a..bd4cc8e310058 100644 --- a/mobile/lib/modules/asset_viewer/services/image_viewer.service.dart +++ b/mobile/lib/modules/asset_viewer/services/image_viewer.service.dart @@ -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 downloadAssetToDevice(AssetResponseDto asset) async { + Future 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; } diff --git a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart b/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart index bdeab2be329b0..7977742bb1986 100644 --- a/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart +++ b/mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart @@ -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,19 +111,18 @@ class ExifBottomSheet extends HookConsumerWidget { child: CustomDraggingHandle(), ), const SizedBox(height: 12), - if (exifInfo?.dateTimeOriginal != null) - Text( - DateFormat('date_format'.tr()).format( - exifInfo!.dateTimeOriginal!.toLocal(), - ), - style: const TextStyle( - fontWeight: FontWeight.bold, - fontSize: 14, - ), + Text( + DateFormat('date_format'.tr()).format( + assetDetail.createdAt.toLocal(), ), + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 14, + ), + ), // Location - if (assetDetail.latitude != null) + if (assetDetail.latitude != null && assetDetail.longitude != null) Padding( padding: const EdgeInsets.only(top: 32.0), child: Column( @@ -126,74 +135,67 @@ class ExifBottomSheet extends HookConsumerWidget { "exif_bottom_sheet_location", style: TextStyle(fontSize: 11, color: textColor), ).tr(), - if (assetDetail.latitude != null && - assetDetail.longitude != null) - buildMap(), + 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( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Divider( - thickness: 1, - color: Colors.grey[600], - ), - Padding( - padding: const EdgeInsets.only(bottom: 8.0), - child: Text( - "exif_bottom_sheet_details", - style: TextStyle(fontSize: 11, color: textColor), - ).tr(), + Padding( + padding: const EdgeInsets.only(top: 32.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Divider( + thickness: 1, + color: Colors.grey[600], + ), + Padding( + padding: const EdgeInsets.only(bottom: 8.0), + child: Text( + "exif_bottom_sheet_details", + style: TextStyle(fontSize: 11, color: textColor), + ).tr(), + ), + ListTile( + contentPadding: const EdgeInsets.all(0), + dense: true, + leading: const Icon(Icons.image), + title: Text( + assetDetail.fileName, + style: TextStyle( + fontWeight: FontWeight.bold, + color: textColor, + ), ), + subtitle: buildSizeText(assetDetail), + ), + if (exifInfo?.make != null) ListTile( contentPadding: const EdgeInsets.all(0), dense: true, - leading: const Icon(Icons.image), + leading: const Icon(Icons.camera), title: Text( - "${exifInfo.imageName!}${p.extension(assetDetail.remote!.originalPath)}", + "${exifInfo!.make} ${exifInfo.model}", style: TextStyle( - fontWeight: FontWeight.bold, color: textColor, + fontWeight: FontWeight.bold, ), ), - subtitle: exifInfo.exifImageHeight != null - ? Text( - "${exifInfo.exifImageHeight} x ${exifInfo.exifImageWidth} ${formatBytes(exifInfo.fileSizeInByte ?? 0)} ", - ) - : null, + subtitle: Text( + "ƒ/${exifInfo.fNumber} ${exifInfo.exposureTime} ${exifInfo.focalLength} mm ISO${exifInfo.iso} ", + ), ), - if (exifInfo.make != null) - ListTile( - contentPadding: const EdgeInsets.all(0), - dense: true, - leading: const Icon(Icons.camera), - title: Text( - "${exifInfo.make} ${exifInfo.model}", - style: TextStyle( - color: textColor, - fontWeight: FontWeight.bold, - ), - ), - subtitle: Text( - "ƒ/${exifInfo.fNumber} ${exifInfo.exposureTime} ${exifInfo.focalLength} mm ISO${exifInfo.iso} ", - ), - ), - ], - ), + ], ), + ), const SizedBox( height: 50, ), diff --git a/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart b/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart index 815f5b175dfd6..2ae0c131cc3ec 100644 --- a/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart +++ b/mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart @@ -43,7 +43,7 @@ class TopControlAppBar extends HookConsumerWidget { ), ), actions: [ - if (asset.remote?.livePhotoVideoId != null) + if (asset.livePhotoVideoId != null) IconButton( iconSize: iconSize, splashRadius: iconSize, @@ -104,18 +104,17 @@ class TopControlAppBar extends HookConsumerWidget { color: Colors.grey[200], ), ), - if (asset.isRemote) - IconButton( - iconSize: iconSize, - splashRadius: iconSize, - onPressed: () { - onMoreInfoPressed(); - }, - icon: Icon( - Icons.more_horiz_rounded, - color: Colors.grey[200], - ), + IconButton( + iconSize: iconSize, + splashRadius: iconSize, + onPressed: () { + onMoreInfoPressed(); + }, + icon: Icon( + Icons.more_horiz_rounded, + color: Colors.grey[200], ), + ), ], ); } diff --git a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart index 78d70b35fdcf5..44e2f4cea0fc6 100644 --- a/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart +++ b/mobile/lib/modules/asset_viewer/views/gallery_viewer.dart @@ -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!); } @@ -132,7 +134,7 @@ class GalleryViewerPage extends HookConsumerWidget { // Probably load WEBP either way precacheImage( remoteThumbnailImageProvider( - asset, + asset, api.ThumbnailFormat.WEBP, ), context, @@ -154,26 +156,23 @@ class GalleryViewerPage extends HookConsumerWidget { context, ); } - } } } void showInfo() { - if (assetList[indexOfAsset.value].isRemote) { - showModalBottomSheet( - shape: RoundedRectangleBorder( - borderRadius: BorderRadius.circular(15.0), - ), - barrierColor: Colors.transparent, - backgroundColor: Colors.transparent, - isScrollControlled: true, - context: context, - builder: (context) { - return ExifBottomSheet(assetDetail: assetDetail!); - }, - ); - } + showModalBottomSheet( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(15.0), + ), + barrierColor: Colors.transparent, + backgroundColor: Colors.transparent, + isScrollControlled: true, + context: context, + builder: (context) { + return ExifBottomSheet(assetDetail: assetDetail!); + }, + ); } void handleDelete(Asset deleteAsset) { @@ -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]), ), ), ); @@ -268,117 +269,132 @@ class GalleryViewerPage extends HookConsumerWidget { body: Stack( children: [ PhotoViewGallery.builder( - scaleStateChangedCallback: (state) { - isZoomed.value = state != PhotoViewScaleState.initial; - showAppBar.value = !isZoomed.value; - }, - pageController: controller, - scrollPhysics: isZoomed.value - ? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in - : (Platform.isIOS - ? const BouncingScrollPhysics() // Use bouncing physics for iOS - : const ClampingScrollPhysics() // Use heavy physics for Android - ), - itemCount: assetList.length, - scrollDirection: Axis.horizontal, - onPageChanged: (value) { - // Precache image - if (indexOfAsset.value < value) { - // Moving forwards, so precache the next asset - precacheNextImage(value + 1); - } else { - // Moving backwards, so precache previous asset - precacheNextImage(value - 1); - } - indexOfAsset.value = value; - HapticFeedback.selectionClick(); - }, - 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(),), - 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 }, - fit: BoxFit.contain, - fadeInDuration: const Duration(milliseconds: 0), - placeholder: (_, __) => webPThumbnail, - ); - } else { - return Image( - image: localThumbnailImageProvider(asset), - fit: BoxFit.contain, - ); - } - } : null, - builder: (context, index) { - getAssetExif(); - if (assetList[index].isImage && !isPlayingMotionVideo.value) { - // Show photo - final ImageProvider provider; - if (assetList[index].isLocal) { - provider = localImageProvider(assetList[index]); - } else { - if (isLoadOriginal.value) { - provider = originalImageProvider(assetList[index]); - } else { - provider = remoteThumbnailImageProvider( - assetList[index], - api.ThumbnailFormat.JPEG, - ); - } - } - return PhotoViewGalleryPageOptions( - onDragStart: (_, details, __) => localPosition = details.localPosition, - onDragUpdate: (_, details, __) => handleSwipeUpDown(details), - onTapDown: (_, __, ___) => showAppBar.value = !showAppBar.value, - imageProvider: provider, - heroAttributes: PhotoViewHeroAttributes(tag: assetList[index].id), - minScale: PhotoViewComputedScale.contained, - ); - } else { - return PhotoViewGalleryPageOptions.customChild( - onDragStart: (_, details, __) => localPosition = details.localPosition, - onDragUpdate: (_, details, __) => handleSwipeUpDown(details), - onTapDown: (_, __, ___) => showAppBar.value = !showAppBar.value, - heroAttributes: PhotoViewHeroAttributes(tag: assetList[index].id), - maxScale: 1.0, - minScale: 1.0, - child: SafeArea( - child: VideoViewerPage( - asset: assetList[index], - isMotionVideo: isPlayingMotionVideo.value, - onVideoEnded: () { - if (isPlayingMotionVideo.value) { - isPlayingMotionVideo.value = false; - } - }, - ), + scaleStateChangedCallback: (state) { + isZoomed.value = state != PhotoViewScaleState.initial; + showAppBar.value = !isZoomed.value; + }, + pageController: controller, + scrollPhysics: isZoomed.value + ? const NeverScrollableScrollPhysics() // Don't allow paging while scrolled in + : (Platform.isIOS + ? const BouncingScrollPhysics() // Use bouncing physics for iOS + : const ClampingScrollPhysics() // Use heavy physics for Android ), - ); - } - }, - ), - Positioned( - top: 0, - left: 0, - right: 0, - child: buildAppBar(), - ), - ], + itemCount: assetList.length, + scrollDirection: Axis.horizontal, + onPageChanged: (value) { + // Precache image + if (indexOfAsset.value < value) { + // Moving forwards, so precache the next asset + precacheNextImage(value + 1); + } else { + // Moving backwards, so precache previous asset + precacheNextImage(value - 1); + } + indexOfAsset.value = value; + HapticFeedback.selectionClick(); + }, + 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), + cacheKey: getThumbnailCacheKey(asset), + httpHeaders: {'Authorization': authToken}, + progressIndicatorBuilder: (_, __, ___) => const Center( + child: ImmichLoadingIndicator(), + ), + fadeInDuration: const Duration(milliseconds: 0), + fit: BoxFit.contain, + ); + + return CachedNetworkImage( + 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, + ); + } else { + return Image( + image: localThumbnailImageProvider(asset), + fit: BoxFit.contain, + ); + } + } + : null, + builder: (context, index) { + getAssetExif(); + if (assetList[index].isImage && !isPlayingMotionVideo.value) { + // Show photo + final ImageProvider provider; + if (assetList[index].isLocal) { + provider = localImageProvider(assetList[index]); + } else { + if (isLoadOriginal.value) { + provider = originalImageProvider(assetList[index]); + } else { + provider = remoteThumbnailImageProvider( + assetList[index], + api.ThumbnailFormat.JPEG, + ); + } + } + return PhotoViewGalleryPageOptions( + onDragStart: (_, details, __) => + localPosition = details.localPosition, + onDragUpdate: (_, details, __) => handleSwipeUpDown(details), + onTapDown: (_, __, ___) => + showAppBar.value = !showAppBar.value, + imageProvider: provider, + heroAttributes: + PhotoViewHeroAttributes(tag: assetList[index].id), + minScale: PhotoViewComputedScale.contained, + ); + } else { + return PhotoViewGalleryPageOptions.customChild( + onDragStart: (_, details, __) => + localPosition = details.localPosition, + onDragUpdate: (_, details, __) => handleSwipeUpDown(details), + onTapDown: (_, __, ___) => + showAppBar.value = !showAppBar.value, + heroAttributes: + PhotoViewHeroAttributes(tag: assetList[index].id), + maxScale: 1.0, + minScale: 1.0, + child: SafeArea( + child: VideoViewerPage( + asset: assetList[index], + isMotionVideo: isPlayingMotionVideo.value, + onVideoEnded: () { + if (isPlayingMotionVideo.value) { + isPlayingMotionVideo.value = false; + } + }, + ), + ), + ); + } + }, + ), + Positioned( + top: 0, + left: 0, + right: 0, + child: buildAppBar(), + ), + ], ), ); } } - diff --git a/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart b/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart index 85a4f62252007..adf2cfa3244d5 100644 --- a/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart +++ b/mobile/lib/modules/asset_viewer/views/video_viewer_page.dart @@ -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: [ diff --git a/mobile/lib/modules/backup/services/backup.service.dart b/mobile/lib/modules/backup/services/backup.service.dart index e1b551da9a08e..1c4a6c8c37769 100644 --- a/mobile/lib/modules/backup/services/backup.service.dart +++ b/mobile/lib/modules/backup/services/backup.service.dart @@ -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 selectedAlbums = diff --git a/mobile/lib/modules/home/models/get_all_asset_response.model.dart b/mobile/lib/modules/home/models/get_all_asset_response.model.dart deleted file mode 100644 index f02d830b2af8d..0000000000000 --- a/mobile/lib/modules/home/models/get_all_asset_response.model.dart +++ /dev/null @@ -1,76 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:openapi/api.dart'; - -class ImmichAssetGroupByDate { - final String date; - List assets; - ImmichAssetGroupByDate({ - required this.date, - required this.assets, - }); - - ImmichAssetGroupByDate copyWith({ - String? date, - List? 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 data; - final String nextPageKey; - GetAllAssetResponse({ - required this.count, - required this.data, - required this.nextPageKey, - }); - - GetAllAssetResponse copyWith({ - int? count, - List? 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; -} diff --git a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart index a6a08aadda84c..863631267e849 100644 --- a/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart +++ b/mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart @@ -24,7 +24,6 @@ class ImmichAssetGridState extends State { bool _scrolling = false; final Set _selectedAssets = HashSet(); - Set _getSelectedAssets() { return _selectedAssets .map((e) => widget.allAssets.firstWhereOrNull((a) => a.id == e)) @@ -103,7 +102,7 @@ class ImmichAssetGridState extends State { 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 { } } - Future onWillPop() async { if (widget.selectionActive && _selectedAssets.isNotEmpty) { _deselectAll(); @@ -234,8 +232,6 @@ class ImmichAssetGridState extends State { return true; } - - @override Widget build(BuildContext context) { return WillPopScope( diff --git a/mobile/lib/modules/login/providers/authentication.provider.dart b/mobile/lib/modules/login/providers/authentication.provider.dart index ed835952954b7..79416149a4298 100644 --- a/mobile/lib/modules/login/providers/authentication.provider.dart +++ b/mobile/lib/modules/login/providers/authentication.provider.dart @@ -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 { var deviceInfo = await _deviceInfoService.getDeviceInfo(); userInfoHiveBox.put(deviceIdKey, deviceInfo["deviceId"]); userInfoHiveBox.put(accessTokenKey, accessToken); + userInfoHiveBox.put(userIdKey, userResponseDto.id); state = state.copyWith( isAuthenticated: true, diff --git a/mobile/lib/modules/search/models/search_result_page_state.model.dart b/mobile/lib/modules/search/models/search_result_page_state.model.dart index 0bf045707c90d..75cd9e8ad16e4 100644 --- a/mobile/lib/modules/search/models/search_result_page_state.model.dart +++ b/mobile/lib/modules/search/models/search_result_page_state.model.dart @@ -45,9 +45,11 @@ class SearchResultPageState { isLoading: map['isLoading'] ?? false, isSuccess: map['isSuccess'] ?? false, isError: map['isError'] ?? false, - searchResult: List.from( + searchResult: List.from( map['searchResult'] - ?.map((x) => Asset.remote(AssetResponseDto.fromJson(x))), + .map(AssetResponseDto.fromJson) + .where((e) => e != null) + .map(Asset.remote), ), ); } diff --git a/mobile/lib/modules/search/providers/search_result_page.provider.dart b/mobile/lib/modules/search/providers/search_result_page.provider.dart index ba7f296e276b3..cbc7633999388 100644 --- a/mobile/lib/modules/search/providers/search_result_page.provider.dart +++ b/mobile/lib/modules/search/providers/search_result_page.provider.dart @@ -30,9 +30,7 @@ class SearchResultPageNotifier extends StateNotifier { isSuccess: false, ); - List? assets = (await _searchService.searchAsset(searchTerm)) - ?.map((e) => Asset.remote(e)) - .toList(); + List? assets = await _searchService.searchAsset(searchTerm); if (assets != null) { state = state.copyWith( diff --git a/mobile/lib/modules/search/services/search.service.dart b/mobile/lib/modules/search/services/search.service.dart index 721bfc20b8efa..8b1ea602c176b 100644 --- a/mobile/lib/modules/search/services/search.service.dart +++ b/mobile/lib/modules/search/services/search.service.dart @@ -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?> searchAsset(String searchTerm) async { + Future?> searchAsset(String searchTerm) async { try { - return await _apiService.assetApi + final List? 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 []; } } } diff --git a/mobile/lib/shared/models/asset.dart b/mobile/lib/shared/models/asset.dart index ca184750487fa..e3e8afbed7696 100644 --- a/mobile/lib/shared/models/asset.dart +++ b/mobile/lib/shared/models/asset.dart @@ -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 toJson() { final json = {}; - 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(); - final l = json["local"]; - if (l != null) { - return Asset.local(_assetEntityFromJson(l)); - } else { - return Asset.remote(AssetResponseDto.fromJson(json["remote"])); - } + 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"], + durationInSeconds: json["durationInSeconds"], + width: json["width"], + height: json["height"], + fileName: json["fileName"], + livePhotoVideoId: json["livePhotoVideoId"], + exifInfo: ExifInfo.fromJson(json["exifInfo"]), + ); } return null; } } - -Map _assetEntityToJson(AssetEntity a) { - final json = {}; - 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(); - 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; -} diff --git a/mobile/lib/shared/models/exif_info.dart b/mobile/lib/shared/models/exif_info.dart new file mode 100644 index 0000000000000..a14d1ef95cd1b --- /dev/null +++ b/mobile/lib/shared/models/exif_info.dart @@ -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 toJson() { + final json = {}; + 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(); + 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; + } +} diff --git a/mobile/lib/shared/providers/asset.provider.dart b/mobile/lib/shared/providers/asset.provider.dart index 5ae9251eb2024..4376cf36ab6d9 100644 --- a/mobile/lib/shared/providers/asset.provider.dart +++ b/mobile/lib/shared/providers/asset.provider.dart @@ -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>> _groupByDate() async { sortCompare(List assets) { assets.sortByCompare( (e) => e.createdAt, @@ -50,11 +50,11 @@ class AssetsState { return await compute(sortCompare, allAssets.toList()); } - static fromAssetList(List assets) { + static AssetsState fromAssetList(List assets) { return AssetsState(assets); } - static empty() { + static AssetsState empty() { return AssetsState([]); } } @@ -82,7 +82,10 @@ class AssetNotifier extends StateNotifier { this._settingsService, ) : super(AssetsState.fromAssetList([])); - _updateAssetsState(List newAssetList, {bool cache = true}) async { + Future _updateAssetsState( + List newAssetList, { + bool cache = true, + }) async { if (cache) { _assetCacheService.put(newAssetList); } @@ -101,20 +104,26 @@ class AssetNotifier extends StateNotifier { 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? 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 { _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 { ); 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 { // 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 { Future> _deleteRemoteAssets( Set assetsToDelete, ) async { - final Iterable remote = - assetsToDelete.where((e) => e.isRemote).map((e) => e.remote!); + final Iterable remote = assetsToDelete.where((e) => e.isRemote); final List deleteAssetResult = await _assetService.deleteAssets(remote) ?? []; return deleteAssetResult diff --git a/mobile/lib/shared/providers/websocket.provider.dart b/mobile/lib/shared/providers/websocket.provider.dart index 913f71ff9a401..93ffc205c8de2 100644 --- a/mobile/lib/shared/providers/websocket.provider.dart +++ b/mobile/lib/shared/providers/websocket.provider.dart @@ -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 { 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 { 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) { - ref.watch(assetProvider.notifier).onNewAssetUploaded(newAsset); - } - }); + _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); + } } } diff --git a/mobile/lib/modules/home/services/asset.service.dart b/mobile/lib/shared/services/asset.service.dart similarity index 90% rename from mobile/lib/modules/home/services/asset.service.dart rename to mobile/lib/shared/services/asset.service.dart index 9adad8b3dc40a..ca33c5fb8c47f 100644 --- a/mobile/lib/modules/home/services/asset.service.dart +++ b/mobile/lib/shared/services/asset.service.dart @@ -62,10 +62,11 @@ class AssetService { } final box = await Hive.openBox(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 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?> deleteAssets( - Iterable deleteAssets, + Iterable deleteAssets, ) async { try { final List payload = []; for (final asset in deleteAssets) { - payload.add(asset.id); + payload.add(asset.remoteId!); } return await _apiService.assetApi diff --git a/mobile/lib/modules/home/services/asset_cache.service.dart b/mobile/lib/shared/services/asset_cache.service.dart similarity index 93% rename from mobile/lib/modules/home/services/asset_cache.service.dart rename to mobile/lib/shared/services/asset_cache.service.dart index d7a6af5ccbdbd..fede6b7ca60c2 100644 --- a/mobile/lib/modules/home/services/asset_cache.service.dart +++ b/mobile/lib/shared/services/asset_cache.service.dart @@ -23,17 +23,15 @@ class AssetCacheService extends JsonCache> { } @override - Future> get() async { + Future?> get() async { try { final mapList = await readRawData() as List; - final responseData = await compute(_computeEncode, mapList); - return responseData; } catch (e) { debugPrint(e.toString()); - - return []; + await invalidate(); + return null; } } } diff --git a/mobile/lib/shared/services/json_cache.dart b/mobile/lib/shared/services/json_cache.dart index 34d2dbafd464e..d227660e77ec9 100644 --- a/mobile/lib/shared/services/json_cache.dart +++ b/mobile/lib/shared/services/json_cache.dart @@ -60,5 +60,5 @@ abstract class JsonCache { } void put(T data); - Future get(); + Future get(); } diff --git a/mobile/lib/shared/services/share.service.dart b/mobile/lib/shared/services/share.service.dart index 84d9674aded57..36d74ff9d8d06 100644 --- a/mobile/lib/shared/services/share.service.dart +++ b/mobile/lib/shared/services/share.service.dart @@ -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>((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 { diff --git a/mobile/lib/shared/ui/immich_image.dart b/mobile/lib/shared/ui/immich_image.dart index 56ec7cd915b18..c04654f72eac1 100644 --- a/mobile/lib/shared/ui/immich_image.dart +++ b/mobile/lib/shared/ui/immich_image.dart @@ -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 diff --git a/mobile/lib/utils/builtin_extensions.dart b/mobile/lib/utils/builtin_extensions.dart new file mode 100644 index 0000000000000..76555f21b004e --- /dev/null +++ b/mobile/lib/utils/builtin_extensions.dart @@ -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); + } +} diff --git a/mobile/lib/utils/image_url_builder.dart b/mobile/lib/utils/image_url_builder.dart index 5fb6408d27734..3ffe4b547a27d 100644 --- a/mobile/lib/utils/image_url_builder.dart +++ b/mobile/lib/utils/image_url_builder.dart @@ -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'; } diff --git a/mobile/test/asset_grid_data_structure_test.dart b/mobile/test/asset_grid_data_structure_test.dart index e363786dbac11..e957796de6065 100644 --- a/mobile/test/asset_grid_data_structure_test.dart +++ b/mobile/test/asset_grid_data_structure_test.dart @@ -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 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: '', - deviceId: '', - originalPath: '', - resizePath: '', - createdAt: date.toIso8601String(), - modifiedAt: date.toIso8601String(), - isFavorite: false, - mimeType: 'image/jpeg', - duration: '', - webpPath: '', - encodedVideoPath: '', - livePhotoVideoId: '', - ), + Asset( + deviceAssetId: '$i', + deviceId: '', + 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); });