Browse Source

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
Fynn Petersen-Frey 2 years ago
parent
commit
0048662182
28 changed files with 617 additions and 495 deletions
  1. 1 0
      mobile/lib/constants/hive_box.dart
  2. 5 3
      mobile/lib/modules/album/ui/album_viewer_thumbnail.dart
  3. 1 1
      mobile/lib/modules/album/ui/selection_thumbnail_image.dart
  4. 1 2
      mobile/lib/modules/asset_viewer/providers/image_viewer_page_state.provider.dart
  5. 13 16
      mobile/lib/modules/asset_viewer/services/image_viewer.service.dart
  6. 61 59
      mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart
  7. 11 12
      mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart
  8. 152 136
      mobile/lib/modules/asset_viewer/views/gallery_viewer.dart
  9. 2 2
      mobile/lib/modules/asset_viewer/views/video_viewer_page.dart
  10. 3 0
      mobile/lib/modules/backup/services/backup.service.dart
  11. 0 76
      mobile/lib/modules/home/models/get_all_asset_response.model.dart
  12. 1 5
      mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart
  13. 2 1
      mobile/lib/modules/login/providers/authentication.provider.dart
  14. 4 2
      mobile/lib/modules/search/models/search_result_page_state.model.dart
  15. 1 3
      mobile/lib/modules/search/providers/search_result_page.provider.dart
  16. 8 3
      mobile/lib/modules/search/services/search.service.dart
  17. 142 90
      mobile/lib/shared/models/asset.dart
  18. 86 0
      mobile/lib/shared/models/exif_info.dart
  19. 28 20
      mobile/lib/shared/providers/asset.provider.dart
  20. 11 15
      mobile/lib/shared/providers/websocket.provider.dart
  21. 9 5
      mobile/lib/shared/services/asset.service.dart
  22. 3 5
      mobile/lib/shared/services/asset_cache.service.dart
  23. 1 1
      mobile/lib/shared/services/json_cache.dart
  24. 3 5
      mobile/lib/shared/services/share.service.dart
  25. 30 5
      mobile/lib/shared/ui/immich_image.dart
  26. 11 0
      mobile/lib/utils/builtin_extensions.dart
  27. 7 6
      mobile/lib/utils/image_url_builder.dart
  28. 20 22
      mobile/test/asset_grid_data_structure_test.dart

+ 1 - 0
mobile/lib/constants/hive_box.dart

@@ -5,6 +5,7 @@ const String deviceIdKey = 'immichBoxDeviceIdKey'; // Key 2
 const String isLoggedInKey = 'immichIsLoggedInKey'; // Key 3
 const String isLoggedInKey = 'immichIsLoggedInKey'; // Key 3
 const String serverEndpointKey = 'immichBoxServerEndpoint'; // Key 4
 const String serverEndpointKey = 'immichBoxServerEndpoint'; // Key 4
 const String assetEtagKey = 'immichAssetEtagKey'; // Key 5
 const String assetEtagKey = 'immichAssetEtagKey'; // Key 5
+const String userIdKey = 'immichUserIdKey'; // Key 6
 
 
 // Login Info
 // Login Info
 const String hiveLoginInfoBox = "immichLoginInfoBox"; // Box
 const String hiveLoginInfoBox = "immichLoginInfoBox"; // Box

+ 5 - 3
mobile/lib/modules/album/ui/album_viewer_thumbnail.dart

@@ -85,9 +85,11 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
         right: 10,
         right: 10,
         bottom: 5,
         bottom: 5,
         child: Icon(
         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,
           color: Colors.white,
           size: 18,
           size: 18,
         ),
         ),

+ 1 - 1
mobile/lib/modules/album/ui/selection_thumbnail_image.dart

@@ -121,7 +121,7 @@ class SelectionThumbnailImage extends HookConsumerWidget {
               child: Row(
               child: Row(
                 children: [
                 children: [
                   Text(
                   Text(
-                    asset.duration.substring(0, 7),
+                    asset.duration.toString().substring(0, 7),
                     style: const TextStyle(
                     style: const TextStyle(
                       color: Colors.white,
                       color: Colors.white,
                       fontSize: 10,
                       fontSize: 10,

+ 1 - 2
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/services/share.service.dart';
 import 'package:immich_mobile/shared/ui/immich_toast.dart';
 import 'package:immich_mobile/shared/ui/immich_toast.dart';
 import 'package:immich_mobile/shared/ui/share_dialog.dart';
 import 'package:immich_mobile/shared/ui/share_dialog.dart';
-import 'package:openapi/api.dart';
 
 
 class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
 class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
   final ImageViewerService _imageViewerService;
   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);
     state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.loading);
 
 
     bool isSuccess = await _imageViewerService.downloadAssetToDevice(asset);
     bool isSuccess = await _imageViewerService.downloadAssetToDevice(asset);

+ 13 - 16
mobile/lib/modules/asset_viewer/services/image_viewer.service.dart

@@ -2,10 +2,9 @@ import 'dart:io';
 
 
 import 'package:flutter/foundation.dart';
 import 'package:flutter/foundation.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/providers/api.provider.dart';
 import 'package:immich_mobile/shared/providers/api.provider.dart';
 import 'package:immich_mobile/shared/services/api.service.dart';
 import 'package:immich_mobile/shared/services/api.service.dart';
-import 'package:openapi/api.dart';
-import 'package:path/path.dart' as p;
 
 
 import 'package:photo_manager/photo_manager.dart';
 import 'package:photo_manager/photo_manager.dart';
 import 'package:path_provider/path_provider.dart';
 import 'package:path_provider/path_provider.dart';
@@ -18,14 +17,12 @@ class ImageViewerService {
 
 
   ImageViewerService(this._apiService);
   ImageViewerService(this._apiService);
 
 
-  Future<bool> downloadAssetToDevice(AssetResponseDto asset) async {
+  Future<bool> downloadAssetToDevice(Asset asset) async {
     try {
     try {
-      String fileName = p.basename(asset.originalPath);
-
       // Download LivePhotos image and motion part
       // 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(
         var imageResponse = await _apiService.assetApi.downloadFileWithHttpInfo(
-          asset.id,
+          asset.remoteId!,
         );
         );
 
 
         var motionReponse = await _apiService.assetApi.downloadFileWithHttpInfo(
         var motionReponse = await _apiService.assetApi.downloadFileWithHttpInfo(
@@ -43,28 +40,28 @@ class ImageViewerService {
         entity = await PhotoManager.editor.darwin.saveLivePhoto(
         entity = await PhotoManager.editor.darwin.saveLivePhoto(
           imageFile: imageFile,
           imageFile: imageFile,
           videoFile: videoFile,
           videoFile: videoFile,
-          title: p.basename(asset.originalPath),
+          title: asset.fileName,
         );
         );
 
 
         return entity != null;
         return entity != null;
       } else {
       } else {
-        var res = await _apiService.assetApi.downloadFileWithHttpInfo(
-          asset.id,
-        );
+        var res = await _apiService.assetApi
+            .downloadFileWithHttpInfo(asset.remoteId!);
 
 
         final AssetEntity? entity;
         final AssetEntity? entity;
 
 
-        if (asset.type == AssetTypeEnum.IMAGE) {
+        if (asset.isImage) {
           entity = await PhotoManager.editor.saveImage(
           entity = await PhotoManager.editor.saveImage(
             res.bodyBytes,
             res.bodyBytes,
-            title: p.basename(asset.originalPath),
+            title: asset.fileName,
           );
           );
         } else {
         } else {
           final tempDir = await getTemporaryDirectory();
           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);
           tempFile.writeAsBytesSync(res.bodyBytes);
-          entity =
-              await PhotoManager.editor.saveVideo(tempFile, title: fileName);
+          entity = await PhotoManager.editor
+              .saveVideo(tempFile, title: asset.fileName);
         }
         }
         return entity != null;
         return entity != null;
       }
       }

+ 61 - 59
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:flutter_map/flutter_map.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/models/exif_info.dart';
 import 'package:immich_mobile/shared/ui/drag_sheet.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:latlong2/latlong.dart';
 import 'package:immich_mobile/utils/bytes_units.dart';
 import 'package:immich_mobile/utils/bytes_units.dart';
 
 
@@ -68,7 +67,7 @@ class ExifBottomSheet extends HookConsumerWidget {
 
 
     final textColor = Theme.of(context).primaryColor;
     final textColor = Theme.of(context).primaryColor;
 
 
-    ExifResponseDto? exifInfo = assetDetail.remote?.exifInfo;
+    ExifInfo? exifInfo = assetDetail.exifInfo;
 
 
     buildLocationText() {
     buildLocationText() {
       return Text(
       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(
     return SingleChildScrollView(
       child: Card(
       child: Card(
         shape: const RoundedRectangleBorder(
         shape: const RoundedRectangleBorder(
@@ -101,19 +111,18 @@ class ExifBottomSheet extends HookConsumerWidget {
                 child: CustomDraggingHandle(),
                 child: CustomDraggingHandle(),
               ),
               ),
               const SizedBox(height: 12),
               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
               // Location
-              if (assetDetail.latitude != null)
+              if (assetDetail.latitude != null && assetDetail.longitude != null)
                 Padding(
                 Padding(
                   padding: const EdgeInsets.only(top: 32.0),
                   padding: const EdgeInsets.only(top: 32.0),
                   child: Column(
                   child: Column(
@@ -126,74 +135,67 @@ class ExifBottomSheet extends HookConsumerWidget {
                         "exif_bottom_sheet_location",
                         "exif_bottom_sheet_location",
                         style: TextStyle(fontSize: 11, color: textColor),
                         style: TextStyle(fontSize: 11, color: textColor),
                       ).tr(),
                       ).tr(),
-                      if (assetDetail.latitude != null &&
-                          assetDetail.longitude != null)
-                        buildMap(),
+                      buildMap(),
                       if (exifInfo != null &&
                       if (exifInfo != null &&
                           exifInfo.city != null &&
                           exifInfo.city != null &&
                           exifInfo.state != null)
                           exifInfo.state != null)
                         buildLocationText(),
                         buildLocationText(),
                       Text(
                       Text(
-                        "${assetDetail.latitude?.toStringAsFixed(4)}, ${assetDetail.longitude?.toStringAsFixed(4)}",
+                        "${assetDetail.latitude!.toStringAsFixed(4)}, ${assetDetail.longitude!.toStringAsFixed(4)}",
                         style: const TextStyle(fontSize: 12),
                         style: const TextStyle(fontSize: 12),
                       )
                       )
                     ],
                     ],
                   ),
                   ),
                 ),
                 ),
               // Detail
               // 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(
                       ListTile(
                         contentPadding: const EdgeInsets.all(0),
                         contentPadding: const EdgeInsets.all(0),
                         dense: true,
                         dense: true,
-                        leading: const Icon(Icons.image),
+                        leading: const Icon(Icons.camera),
                         title: Text(
                         title: Text(
-                          "${exifInfo.imageName!}${p.extension(assetDetail.remote!.originalPath)}",
+                          "${exifInfo!.make} ${exifInfo.model}",
                           style: TextStyle(
                           style: TextStyle(
-                            fontWeight: FontWeight.bold,
                             color: textColor,
                             color: textColor,
+                            fontWeight: FontWeight.bold,
                           ),
                           ),
                         ),
                         ),
-                        subtitle: exifInfo.exifImageHeight != null
-                            ? Text(
-                                "${exifInfo.exifImageHeight} x ${exifInfo.exifImageWidth}  ${formatBytes(exifInfo.fileSizeInByte ?? 0)} ",
-                              )
-                            : null,
-                      ),
-                      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} ",
-                          ),
+                        subtitle: Text(
+                          "ƒ/${exifInfo.fNumber}   ${exifInfo.exposureTime}   ${exifInfo.focalLength} mm   ISO${exifInfo.iso} ",
                         ),
                         ),
-                    ],
-                  ),
+                      ),
+                  ],
                 ),
                 ),
+              ),
               const SizedBox(
               const SizedBox(
                 height: 50,
                 height: 50,
               ),
               ),

+ 11 - 12
mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart

@@ -43,7 +43,7 @@ class TopControlAppBar extends HookConsumerWidget {
         ),
         ),
       ),
       ),
       actions: [
       actions: [
-        if (asset.remote?.livePhotoVideoId != null)
+        if (asset.livePhotoVideoId != null)
           IconButton(
           IconButton(
             iconSize: iconSize,
             iconSize: iconSize,
             splashRadius: iconSize,
             splashRadius: iconSize,
@@ -104,18 +104,17 @@ class TopControlAppBar extends HookConsumerWidget {
             color: Colors.grey[200],
             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],
           ),
           ),
+        ),
       ],
       ],
     );
     );
   }
   }

+ 152 - 136
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/exif_bottom_sheet.dart';
 import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.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/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/home/ui/delete_diaglog.dart';
 import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
 import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
 import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
 import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
@@ -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(
       return CachedNetworkImageProvider(
         getThumbnailUrl(
         getThumbnailUrl(
-          asset.remote!,
+          asset,
           type: type,
           type: type,
         ),
         ),
         cacheKey: getThumbnailCacheKey(
         cacheKey: getThumbnailCacheKey(
-          asset.remote!,
+          asset,
           type: type,
           type: type,
         ),
         ),
         headers: {"Authorization": authToken},
         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) {
     ImageProvider originalImageProvider(Asset asset) {
       return CachedNetworkImageProvider(
       return CachedNetworkImageProvider(
-        getImageUrl(asset.remote!),
-        cacheKey: getImageCacheKey(asset.remote!),
+        getImageUrl(asset),
+        cacheKey: getImageCacheKey(asset),
         headers: {"Authorization": authToken},
         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) {
     ImageProvider localThumbnailImageProvider(Asset asset) {
       return AssetEntityImageProvider(
       return AssetEntityImageProvider(
         asset.local!,
         asset.local!,
@@ -114,10 +117,9 @@ class GalleryViewerPage extends HookConsumerWidget {
           MediaQuery.of(context).size.height.floor(),
           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) {
     ImageProvider localImageProvider(Asset asset) {
       return AssetEntityImageProvider(asset.local!);
       return AssetEntityImageProvider(asset.local!);
     }
     }
@@ -132,7 +134,7 @@ class GalleryViewerPage extends HookConsumerWidget {
           // Probably load WEBP either way
           // Probably load WEBP either way
           precacheImage(
           precacheImage(
             remoteThumbnailImageProvider(
             remoteThumbnailImageProvider(
-              asset, 
+              asset,
               api.ThumbnailFormat.WEBP,
               api.ThumbnailFormat.WEBP,
             ),
             ),
             context,
             context,
@@ -154,26 +156,23 @@ class GalleryViewerPage extends HookConsumerWidget {
               context,
               context,
             );
             );
           }
           }
-
         }
         }
       }
       }
     }
     }
 
 
     void showInfo() {
     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) {
     void handleDelete(Asset deleteAsset) {
@@ -244,7 +243,7 @@ class GalleryViewerPage extends HookConsumerWidget {
                 ? null
                 ? null
                 : () {
                 : () {
                     ref.watch(imageViewerStateProvider.notifier).downloadAsset(
                     ref.watch(imageViewerStateProvider.notifier).downloadAsset(
-                          assetList[indexOfAsset.value].remote!,
+                          assetList[indexOfAsset.value],
                           context,
                           context,
                         );
                         );
                   },
                   },
@@ -256,8 +255,10 @@ class GalleryViewerPage extends HookConsumerWidget {
             onToggleMotionVideo: (() {
             onToggleMotionVideo: (() {
               isPlayingMotionVideo.value = !isPlayingMotionVideo.value;
               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(
       body: Stack(
         children: [
         children: [
           PhotoViewGallery.builder(
           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]);
+            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 {
               } else {
-                if (isLoadOriginal.value) {
-                  provider = originalImageProvider(assetList[index]);
+                // 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 {
                 } else {
-                  provider = remoteThumbnailImageProvider(
-                    assetList[index], 
-                    api.ThumbnailFormat.JPEG,
-                  );
+                  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;
-                      }
-                    },
+                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(),
-        ),
-      ],
+                );
+              }
+            },
+          ),
+          Positioned(
+            top: 0,
+            left: 0,
+            right: 0,
+            child: buildAppBar(),
+          ),
+        ],
       ),
       ),
     );
     );
   }
   }
 }
 }
-

+ 2 - 2
mobile/lib/modules/asset_viewer/views/video_viewer_page.dart

@@ -53,8 +53,8 @@ class VideoViewerPage extends HookConsumerWidget {
     final box = Hive.box(userInfoBox);
     final box = Hive.box(userInfoBox);
     final String jwtToken = box.get(accessTokenKey);
     final String jwtToken = box.get(accessTokenKey);
     final String videoUrl = isMotionVideo
     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(
     return Stack(
       children: [
       children: [

+ 3 - 0
mobile/lib/modules/backup/services/backup.service.dart

@@ -75,6 +75,9 @@ class BackupService {
     final filter = FilterOptionGroup(
     final filter = FilterOptionGroup(
       containsPathModified: true,
       containsPathModified: true,
       orders: [const OrderOption(type: OrderOptionType.updateDate)],
       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 now = DateTime.now();
     final List<AssetPathEntity?> selectedAlbums =
     final List<AssetPathEntity?> selectedAlbums =

+ 0 - 76
mobile/lib/modules/home/models/get_all_asset_response.model.dart

@@ -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;
-}

+ 1 - 5
mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart

@@ -24,7 +24,6 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
   bool _scrolling = false;
   bool _scrolling = false;
   final Set<String> _selectedAssets = HashSet();
   final Set<String> _selectedAssets = HashSet();
 
 
-
   Set<Asset> _getSelectedAssets() {
   Set<Asset> _getSelectedAssets() {
     return _selectedAssets
     return _selectedAssets
         .map((e) => widget.allAssets.firstWhereOrNull((a) => a.id == e))
         .map((e) => widget.allAssets.firstWhereOrNull((a) => a.id == e))
@@ -103,7 +102,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
     return Row(
     return Row(
       key: Key("asset-row-${row.assets.first.id}"),
       key: Key("asset-row-${row.assets.first.id}"),
       children: row.assets.map((Asset asset) {
       children: row.assets.map((Asset asset) {
-        bool last = asset == row.assets.last;
+        bool last = asset.id == row.assets.last.id;
 
 
         return Container(
         return Container(
           key: Key("asset-${asset.id}"),
           key: Key("asset-${asset.id}"),
@@ -224,7 +223,6 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
     }
     }
   }
   }
 
 
-
   Future<bool> onWillPop() async {
   Future<bool> onWillPop() async {
     if (widget.selectionActive && _selectedAssets.isNotEmpty) {
     if (widget.selectionActive && _selectedAssets.isNotEmpty) {
       _deselectAll();
       _deselectAll();
@@ -234,8 +232,6 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
     return true;
     return true;
   }
   }
 
 
-
-
   @override
   @override
   Widget build(BuildContext context) {
   Widget build(BuildContext context) {
     return WillPopScope(
     return WillPopScope(

+ 2 - 1
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:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/constants/hive_box.dart';
 import 'package:immich_mobile/constants/hive_box.dart';
 import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
 import 'package:immich_mobile/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/authentication_state.model.dart';
 import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
 import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
 import 'package:immich_mobile/modules/backup/services/backup.service.dart';
 import 'package:immich_mobile/modules/backup/services/backup.service.dart';
@@ -166,6 +166,7 @@ class AuthenticationNotifier extends StateNotifier<AuthenticationState> {
       var deviceInfo = await _deviceInfoService.getDeviceInfo();
       var deviceInfo = await _deviceInfoService.getDeviceInfo();
       userInfoHiveBox.put(deviceIdKey, deviceInfo["deviceId"]);
       userInfoHiveBox.put(deviceIdKey, deviceInfo["deviceId"]);
       userInfoHiveBox.put(accessTokenKey, accessToken);
       userInfoHiveBox.put(accessTokenKey, accessToken);
+      userInfoHiveBox.put(userIdKey, userResponseDto.id);
 
 
       state = state.copyWith(
       state = state.copyWith(
         isAuthenticated: true,
         isAuthenticated: true,

+ 4 - 2
mobile/lib/modules/search/models/search_result_page_state.model.dart

@@ -45,9 +45,11 @@ class SearchResultPageState {
       isLoading: map['isLoading'] ?? false,
       isLoading: map['isLoading'] ?? false,
       isSuccess: map['isSuccess'] ?? false,
       isSuccess: map['isSuccess'] ?? false,
       isError: map['isError'] ?? false,
       isError: map['isError'] ?? false,
-      searchResult: List<Asset>.from(
+      searchResult: List.from(
         map['searchResult']
         map['searchResult']
-            ?.map((x) => Asset.remote(AssetResponseDto.fromJson(x))),
+            .map(AssetResponseDto.fromJson)
+            .where((e) => e != null)
+            .map(Asset.remote),
       ),
       ),
     );
     );
   }
   }

+ 1 - 3
mobile/lib/modules/search/providers/search_result_page.provider.dart

@@ -30,9 +30,7 @@ class SearchResultPageNotifier extends StateNotifier<SearchResultPageState> {
       isSuccess: false,
       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) {
     if (assets != null) {
       state = state.copyWith(
       state = state.copyWith(

+ 8 - 3
mobile/lib/modules/search/services/search.service.dart

@@ -1,5 +1,6 @@
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/providers/api.provider.dart';
 import 'package:immich_mobile/shared/providers/api.provider.dart';
 import 'package:immich_mobile/shared/services/api.service.dart';
 import 'package:immich_mobile/shared/services/api.service.dart';
 import 'package:openapi/api.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 {
     try {
-      return await _apiService.assetApi
+      final List<AssetResponseDto>? results = await _apiService.assetApi
           .searchAsset(SearchAssetDto(searchTerm: searchTerm));
           .searchAsset(SearchAssetDto(searchTerm: searchTerm));
+      if (results == null) {
+        return null;
+      }
+      return results.map((e) => Asset.remote(e)).toList();
     } catch (e) {
     } catch (e) {
       debugPrint("[ERROR] [searchAsset] ${e.toString()}");
       debugPrint("[ERROR] [searchAsset] ${e.toString()}");
       return null;
       return null;
@@ -50,7 +55,7 @@ class SearchService {
       return await _apiService.assetApi.getCuratedObjects();
       return await _apiService.assetApi.getCuratedObjects();
     } catch (e) {
     } catch (e) {
       debugPrint("Error [getCuratedObjects] ${e.toString()}");
       debugPrint("Error [getCuratedObjects] ${e.toString()}");
-      throw [];
+      return [];
     }
     }
   }
   }
 }
 }

+ 142 - 90
mobile/lib/shared/models/asset.dart

@@ -1,62 +1,127 @@
 import 'package:hive/hive.dart';
 import 'package:hive/hive.dart';
 import 'package:immich_mobile/constants/hive_box.dart';
 import 'package:immich_mobile/constants/hive_box.dart';
+import 'package:immich_mobile/shared/models/exif_info.dart';
 import 'package:openapi/api.dart';
 import 'package:openapi/api.dart';
 import 'package:photo_manager/photo_manager.dart';
 import 'package:photo_manager/photo_manager.dart';
+import 'package:immich_mobile/utils/builtin_extensions.dart';
+import 'package:path/path.dart' as p;
 
 
 /// Asset (online or local)
 /// Asset (online or local)
 class Asset {
 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(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;
+    }
   }
   }
 
 
-  Asset.local(this.local) {
-    remote = null;
+  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,
+  });
+
+  AssetEntity? _local;
+
+  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;
   }
   }
 
 
-  late final AssetResponseDto? remote;
-  late final AssetEntity? local;
+  String? localId;
 
 
-  bool get isRemote => remote != null;
-  bool get isLocal => local != null;
+  String? remoteId;
 
 
-  String get deviceId =>
-      isRemote ? remote!.deviceId : Hive.box(userInfoBox).get(deviceIdKey);
+  String deviceAssetId;
 
 
-  String get deviceAssetId => isRemote ? remote!.deviceAssetId : local!.id;
+  String deviceId;
 
 
-  String get id => isLocal ? local!.id : remote!.id;
+  String ownerId;
 
 
-  double? get latitude =>
-      isLocal ? local!.latitude : remote!.exifInfo?.latitude?.toDouble();
+  DateTime createdAt;
 
 
-  double? get longitude =>
-      isLocal ? local!.longitude : remote!.exifInfo?.longitude?.toDouble();
+  DateTime modifiedAt;
 
 
-  DateTime get createdAt {
-    if (isLocal) {
-      if (local!.createDateTime.year == 1970) {
-        return local!.modifiedDateTime;
-      }
-      return local!.createDateTime;
-    } else {
-      return DateTime.parse(remote!.createdAt);
-    }
-  }
+  double? latitude;
 
 
-  bool get isImage => isLocal
-      ? local!.type == AssetType.image
-      : remote!.type == AssetTypeEnum.IMAGE;
+  double? longitude;
 
 
-  String get duration => isRemote
-      ? remote!.duration
-      : Duration(seconds: local!.duration).toString();
+  int durationInSeconds;
 
 
-  /// use only for tests
-  set createdAt(DateTime val) {
-    if (isRemote) {
-      remote!.createdAt = val.toIso8601String();
-    }
-  }
+  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
   @override
   bool operator ==(other) {
   bool operator ==(other) {
@@ -67,12 +132,26 @@ class Asset {
   @override
   @override
   int get hashCode => id.hashCode;
   int get hashCode => id.hashCode;
 
 
+  // methods below are only required for caching as JSON
+
   Map<String, dynamic> toJson() {
   Map<String, dynamic> toJson() {
     final json = <String, dynamic>{};
     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;
     return json;
   }
   }
@@ -80,55 +159,28 @@ class Asset {
   static Asset? fromJson(dynamic value) {
   static Asset? fromJson(dynamic value) {
     if (value is Map) {
     if (value is Map) {
       final json = value.cast<String, dynamic>();
       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 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;
     return null;
   }
   }
 }
 }
-
-Map<String, dynamic> _assetEntityToJson(AssetEntity a) {
-  final json = <String, dynamic>{};
-  json["id"] = a.id;
-  json["typeInt"] = a.typeInt;
-  json["width"] = a.width;
-  json["height"] = a.height;
-  json["duration"] = a.duration;
-  json["orientation"] = a.orientation;
-  json["isFavorite"] = a.isFavorite;
-  json["title"] = a.title;
-  json["createDateSecond"] = a.createDateSecond;
-  json["modifiedDateSecond"] = a.modifiedDateSecond;
-  json["latitude"] = a.latitude;
-  json["longitude"] = a.longitude;
-  json["mimeType"] = a.mimeType;
-  json["subtype"] = a.subtype;
-  return json;
-}
-
-AssetEntity? _assetEntityFromJson(dynamic value) {
-  if (value is Map) {
-    final json = value.cast<String, dynamic>();
-    return AssetEntity(
-      id: json["id"],
-      typeInt: json["typeInt"],
-      width: json["width"],
-      height: json["height"],
-      duration: json["duration"],
-      orientation: json["orientation"],
-      isFavorite: json["isFavorite"],
-      title: json["title"],
-      createDateSecond: json["createDateSecond"],
-      modifiedDateSecond: json["modifiedDateSecond"],
-      latitude: json["latitude"],
-      longitude: json["longitude"],
-      mimeType: json["mimeType"],
-      subtype: json["subtype"],
-    );
-  }
-  return null;
-}

+ 86 - 0
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<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;
+  }
+}

+ 28 - 20
mobile/lib/shared/providers/asset.provider.dart

@@ -4,8 +4,8 @@ import 'package:flutter/foundation.dart';
 import 'package:hive/hive.dart';
 import 'package:hive/hive.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/constants/hive_box.dart';
 import 'package:immich_mobile/constants/hive_box.dart';
-import 'package:immich_mobile/modules/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/home/ui/asset_grid/asset_grid_data_structure.dart';
 import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
 import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
 import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
 import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
@@ -36,7 +36,7 @@ class AssetsState {
     return AssetsState([...allAssets, ...toAdd]);
     return AssetsState([...allAssets, ...toAdd]);
   }
   }
 
 
-  _groupByDate() async {
+  Future<Map<String, List<Asset>>> _groupByDate() async {
     sortCompare(List<Asset> assets) {
     sortCompare(List<Asset> assets) {
       assets.sortByCompare<DateTime>(
       assets.sortByCompare<DateTime>(
         (e) => e.createdAt,
         (e) => e.createdAt,
@@ -50,11 +50,11 @@ class AssetsState {
     return await compute(sortCompare, allAssets.toList());
     return await compute(sortCompare, allAssets.toList());
   }
   }
 
 
-  static fromAssetList(List<Asset> assets) {
+  static AssetsState fromAssetList(List<Asset> assets) {
     return AssetsState(assets);
     return AssetsState(assets);
   }
   }
 
 
-  static empty() {
+  static AssetsState empty() {
     return AssetsState([]);
     return AssetsState([]);
   }
   }
 }
 }
@@ -82,7 +82,10 @@ class AssetNotifier extends StateNotifier<AssetsState> {
     this._settingsService,
     this._settingsService,
   ) : super(AssetsState.fromAssetList([]));
   ) : super(AssetsState.fromAssetList([]));
 
 
-  _updateAssetsState(List<Asset> newAssetList, {bool cache = true}) async {
+  Future<void> _updateAssetsState(
+    List<Asset> newAssetList, {
+    bool cache = true,
+  }) async {
     if (cache) {
     if (cache) {
       _assetCacheService.put(newAssetList);
       _assetCacheService.put(newAssetList);
     }
     }
@@ -101,20 +104,26 @@ class AssetNotifier extends StateNotifier<AssetsState> {
     final stopwatch = Stopwatch();
     final stopwatch = Stopwatch();
     try {
     try {
       _getAllAssetInProgress = true;
       _getAllAssetInProgress = true;
-      final bool isCacheValid = await _assetCacheService.isValid();
+      bool isCacheValid = await _assetCacheService.isValid();
       stopwatch.start();
       stopwatch.start();
       final Box box = Hive.box(userInfoBox);
       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 localTask = _assetService.getLocalAssets(urgent: !isCacheValid);
       final remoteTask = _assetService.getRemoteAssets(
       final remoteTask = _assetService.getRemoteAssets(
         etag: isCacheValid ? box.get(assetEtagKey) : null,
         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);
       int remoteBegin = state.allAssets.indexWhere((a) => a.isRemote);
       remoteBegin = remoteBegin == -1 ? state.allAssets.length : remoteBegin;
       remoteBegin = remoteBegin == -1 ? state.allAssets.length : remoteBegin;
@@ -184,7 +193,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
     _updateAssetsState([]);
     _updateAssetsState([]);
   }
   }
 
 
-  onNewAssetUploaded(AssetResponseDto newAsset) {
+  void onNewAssetUploaded(Asset newAsset) {
     final int i = state.allAssets.indexWhere(
     final int i = state.allAssets.indexWhere(
       (a) =>
       (a) =>
           a.isRemote ||
           a.isRemote ||
@@ -192,13 +201,13 @@ class AssetNotifier extends StateNotifier<AssetsState> {
     );
     );
 
 
     if (i == -1 || state.allAssets[i].deviceAssetId != newAsset.deviceAssetId) {
     if (i == -1 || state.allAssets[i].deviceAssetId != newAsset.deviceAssetId) {
-      _updateAssetsState([...state.allAssets, Asset.remote(newAsset)]);
+      _updateAssetsState([...state.allAssets, newAsset]);
     } else {
     } else {
       // order is important to keep all local-only assets at the beginning!
       // order is important to keep all local-only assets at the beginning!
       _updateAssetsState([
       _updateAssetsState([
         ...state.allAssets.slice(0, i),
         ...state.allAssets.slice(0, i),
         ...state.allAssets.slice(i + 1),
         ...state.allAssets.slice(i + 1),
-        Asset.remote(newAsset),
+        newAsset,
       ]);
       ]);
       // TODO here is a place to unify local/remote assets by replacing the
       // TODO here is a place to unify local/remote assets by replacing the
       // local-only asset in the state with a local&remote asset
       // local-only asset in the state with a local&remote asset
@@ -230,7 +239,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
     // Delete asset from device
     // Delete asset from device
     for (final Asset asset in assetsToDelete) {
     for (final Asset asset in assetsToDelete) {
       if (asset.isLocal) {
       if (asset.isLocal) {
-        local.add(asset.id);
+        local.add(asset.localId!);
       } else if (asset.deviceId == deviceId) {
       } else if (asset.deviceId == deviceId) {
         // Delete asset on device if it is still present
         // Delete asset on device if it is still present
         var localAsset = await AssetEntity.fromId(asset.deviceAssetId);
         var localAsset = await AssetEntity.fromId(asset.deviceAssetId);
@@ -252,8 +261,7 @@ class AssetNotifier extends StateNotifier<AssetsState> {
   Future<Iterable<String>> _deleteRemoteAssets(
   Future<Iterable<String>> _deleteRemoteAssets(
     Set<Asset> assetsToDelete,
     Set<Asset> assetsToDelete,
   ) async {
   ) 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 =
     final List<DeleteAssetResponseDto> deleteAssetResult =
         await _assetService.deleteAssets(remote) ?? [];
         await _assetService.deleteAssets(remote) ?? [];
     return deleteAssetResult
     return deleteAssetResult

+ 11 - 15
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:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/constants/hive_box.dart';
 import 'package:immich_mobile/constants/hive_box.dart';
 import 'package:immich_mobile/modules/login/providers/authentication.provider.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:immich_mobile/shared/providers/asset.provider.dart';
 import 'package:logging/logging.dart';
 import 'package:logging/logging.dart';
 import 'package:openapi/api.dart';
 import 'package:openapi/api.dart';
@@ -91,14 +92,7 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
           state = WebsocketState(isConnected: false, socket: null);
           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) {
       } catch (e) {
         debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
         debugPrint("[WEBSOCKET] Catch Websocket Error - ${e.toString()}");
       }
       }
@@ -122,14 +116,16 @@ class WebsocketNotifier extends StateNotifier<WebsocketState> {
 
 
   listenUploadEvent() {
   listenUploadEvent() {
     debugPrint("Start listening to event on_upload_success");
     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);
+    }
   }
   }
 }
 }
 
 

+ 9 - 5
mobile/lib/modules/home/services/asset.service.dart → mobile/lib/shared/services/asset.service.dart

@@ -62,10 +62,11 @@ class AssetService {
       }
       }
       final box = await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
       final box = await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
       final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
       final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
+      final String userId = Hive.box(userInfoBox).get(userIdKey);
       if (backupAlbumInfo != null) {
       if (backupAlbumInfo != null) {
         return (await _backupService
         return (await _backupService
                 .buildUploadCandidates(backupAlbumInfo.deepCopy()))
                 .buildUploadCandidates(backupAlbumInfo.deepCopy()))
-            .map(Asset.local)
+            .map((e) => Asset.local(e, userId))
             .toList(growable: false);
             .toList(growable: false);
       }
       }
     } catch (e) {
     } catch (e) {
@@ -76,21 +77,24 @@ class AssetService {
 
 
   Future<Asset?> getAssetById(String assetId) async {
   Future<Asset?> getAssetById(String assetId) async {
     try {
     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) {
     } catch (e) {
       debugPrint("Error [getAssetById]  ${e.toString()}");
       debugPrint("Error [getAssetById]  ${e.toString()}");
-      return null;
     }
     }
+    return null;
   }
   }
 
 
   Future<List<DeleteAssetResponseDto>?> deleteAssets(
   Future<List<DeleteAssetResponseDto>?> deleteAssets(
-    Iterable<AssetResponseDto> deleteAssets,
+    Iterable<Asset> deleteAssets,
   ) async {
   ) async {
     try {
     try {
       final List<String> payload = [];
       final List<String> payload = [];
 
 
       for (final asset in deleteAssets) {
       for (final asset in deleteAssets) {
-        payload.add(asset.id);
+        payload.add(asset.remoteId!);
       }
       }
 
 
       return await _apiService.assetApi
       return await _apiService.assetApi

+ 3 - 5
mobile/lib/modules/home/services/asset_cache.service.dart → mobile/lib/shared/services/asset_cache.service.dart

@@ -23,17 +23,15 @@ class AssetCacheService extends JsonCache<List<Asset>> {
   }
   }
 
 
   @override
   @override
-  Future<List<Asset>> get() async {
+  Future<List<Asset>?> get() async {
     try {
     try {
       final mapList = await readRawData() as List<dynamic>;
       final mapList = await readRawData() as List<dynamic>;
-
       final responseData = await compute(_computeEncode, mapList);
       final responseData = await compute(_computeEncode, mapList);
-
       return responseData;
       return responseData;
     } catch (e) {
     } catch (e) {
       debugPrint(e.toString());
       debugPrint(e.toString());
-
-      return [];
+      await invalidate();
+      return null;
     }
     }
   }
   }
 }
 }

+ 1 - 1
mobile/lib/shared/services/json_cache.dart

@@ -60,5 +60,5 @@ abstract class JsonCache<T> {
   }
   }
 
 
   void put(T data);
   void put(T data);
-  Future<T> get();
+  Future<T?> get();
 }
 }

+ 3 - 5
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:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/providers/api.provider.dart';
 import 'package:immich_mobile/shared/providers/api.provider.dart';
-import 'package:path/path.dart';
 import 'package:path_provider/path_provider.dart';
 import 'package:path_provider/path_provider.dart';
 import 'package:share_plus/share_plus.dart';
 import 'package:share_plus/share_plus.dart';
 import 'api.service.dart';
 import 'api.service.dart';
@@ -25,11 +24,10 @@ class ShareService {
     final downloadedXFiles = assets.map<Future<XFile>>((asset) async {
     final downloadedXFiles = assets.map<Future<XFile>>((asset) async {
       if (asset.isRemote) {
       if (asset.isRemote) {
         final tempDir = await getTemporaryDirectory();
         final tempDir = await getTemporaryDirectory();
-        final fileName = basename(asset.remote!.originalPath);
+        final fileName = asset.fileName;
         final tempFile = await File('${tempDir.path}/$fileName').create();
         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);
         tempFile.writeAsBytesSync(res.bodyBytes);
         return XFile(tempFile.path);
         return XFile(tempFile.path);
       } else {
       } else {

+ 30 - 5
mobile/lib/shared/ui/immich_image.dart

@@ -1,5 +1,6 @@
 import 'package:cached_network_image/cached_network_image.dart';
 import 'package:cached_network_image/cached_network_image.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/material.dart';
+import 'package:flutter/services.dart';
 import 'package:hive_flutter/hive_flutter.dart';
 import 'package:hive_flutter/hive_flutter.dart';
 import 'package:immich_mobile/constants/hive_box.dart';
 import 'package:immich_mobile/constants/hive_box.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
@@ -15,13 +16,28 @@ class ImmichImage extends StatelessWidget {
     this.useGrayBoxPlaceholder = false,
     this.useGrayBoxPlaceholder = false,
     super.key,
     super.key,
   });
   });
-  final Asset asset;
+  final Asset? asset;
   final bool useGrayBoxPlaceholder;
   final bool useGrayBoxPlaceholder;
   final double width;
   final double width;
   final double height;
   final double height;
 
 
   @override
   @override
   Widget build(BuildContext context) {
   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) {
     if (asset.isLocal) {
       return Image(
       return Image(
         image: AssetEntityImageProvider(
         image: AssetEntityImageProvider(
@@ -49,7 +65,16 @@ class ImmichImage extends StatelessWidget {
                 ));
                 ));
         },
         },
         errorBuilder: (context, error, stackTrace) {
         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(
           return Icon(
             Icons.image_not_supported_outlined,
             Icons.image_not_supported_outlined,
             color: Theme.of(context).primaryColor,
             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(
     return CachedNetworkImage(
       imageUrl: thumbnailRequestUrl,
       imageUrl: thumbnailRequestUrl,
       httpHeaders: {"Authorization": "Bearer $token"},
       httpHeaders: {"Authorization": "Bearer $token"},
-      cacheKey: getThumbnailCacheKey(asset.remote!),
+      cacheKey: getThumbnailCacheKey(asset),
       width: width,
       width: width,
       height: height,
       height: height,
       // keeping memCacheWidth, memCacheHeight, maxWidthDiskCache and
       // keeping memCacheWidth, memCacheHeight, maxWidthDiskCache and

+ 11 - 0
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);
+  }
+}

+ 7 - 6
mobile/lib/utils/image_url_builder.dart

@@ -1,17 +1,18 @@
 import 'package:hive/hive.dart';
 import 'package:hive/hive.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:openapi/api.dart';
 import 'package:openapi/api.dart';
 
 
 import '../constants/hive_box.dart';
 import '../constants/hive_box.dart';
 
 
 String getThumbnailUrl(
 String getThumbnailUrl(
-  final AssetResponseDto asset, {
+  final Asset asset, {
   ThumbnailFormat type = ThumbnailFormat.WEBP,
   ThumbnailFormat type = ThumbnailFormat.WEBP,
 }) {
 }) {
-  return _getThumbnailUrl(asset.id, type: type);
+  return _getThumbnailUrl(asset.remoteId!, type: type);
 }
 }
 
 
 String getThumbnailCacheKey(
 String getThumbnailCacheKey(
-  final AssetResponseDto asset, {
+  final Asset asset, {
   ThumbnailFormat type = ThumbnailFormat.WEBP,
   ThumbnailFormat type = ThumbnailFormat.WEBP,
 }) {
 }) {
   return _getThumbnailCacheKey(asset.id, type);
   return _getThumbnailCacheKey(asset.id, type);
@@ -45,12 +46,12 @@ String getAlbumThumbNailCacheKey(
   return _getThumbnailCacheKey(album.albumThumbnailAssetId!, type);
   return _getThumbnailCacheKey(album.albumThumbnailAssetId!, type);
 }
 }
 
 
-String getImageUrl(final AssetResponseDto asset) {
+String getImageUrl(final Asset asset) {
   final box = Hive.box(userInfoBox);
   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';
   return '${asset.id}_fullStage';
 }
 }
 
 

+ 20 - 22
mobile/test/asset_grid_data_structure_test.dart

@@ -1,7 +1,6 @@
 import 'package:flutter_test/flutter_test.dart';
 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/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/models/asset.dart';
-import 'package:openapi/api.dart';
 
 
 void main() {
 void main() {
   final List<Asset> testAssets = [];
   final List<Asset> testAssets = [];
@@ -13,24 +12,14 @@ void main() {
     DateTime date = DateTime(2022, month, day);
     DateTime date = DateTime(2022, month, day);
 
 
     testAssets.add(
     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
       // Day 1
       // 15 Assets => 5 Rows
       // 15 Assets => 5 Rows
       expect(renderList.elements.length, 18);
       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[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[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);
       expect(renderList.elements[11].date.month, 10);
     });
     });