Browse Source

feat(mobile): show local assets (#905)

* introduce Asset as composition of AssetResponseDTO and AssetEntity

* filter out duplicate assets (that are both local and remote, take only remote for now)

* only allow remote images to be added to albums

* introduce ImmichImage to render Asset using local or remote data

* optimized deletion of local assets

* local video file playback

* allow multiple methods to wait on background service finished

* skip local assets when adding to album from home screen

* fix and optimize delete

* show gray box placeholder for local assets

* add comments

* fix bug: duplicate assets in state after onNewAssetUploaded
Fynn Petersen-Frey 2 years ago
parent
commit
1633af7af6
41 changed files with 823 additions and 507 deletions
  1. 3 3
      mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt
  2. 5 3
      mobile/lib/main.dart
  3. 5 6
      mobile/lib/modules/album/models/asset_selection_page_result.model.dart
  4. 7 8
      mobile/lib/modules/album/models/asset_selection_state.model.dart
  5. 2 2
      mobile/lib/modules/album/providers/album.provider.dart
  6. 17 18
      mobile/lib/modules/album/providers/asset_selection.provider.dart
  7. 4 2
      mobile/lib/modules/album/providers/shared_album.provider.dart
  8. 4 3
      mobile/lib/modules/album/services/album.service.dart
  9. 6 31
      mobile/lib/modules/album/ui/album_viewer_thumbnail.dart
  10. 2 2
      mobile/lib/modules/album/ui/asset_grid_by_month.dart
  11. 2 2
      mobile/lib/modules/album/ui/month_group_title.dart
  12. 6 34
      mobile/lib/modules/album/ui/selection_thumbnail_image.dart
  13. 4 30
      mobile/lib/modules/album/ui/shared_album_thumbnail_image.dart
  14. 7 5
      mobile/lib/modules/album/views/album_viewer_page.dart
  15. 1 1
      mobile/lib/modules/album/views/create_album_page.dart
  16. 0 1
      mobile/lib/modules/album/views/sharing_page.dart
  17. 2 2
      mobile/lib/modules/asset_viewer/providers/image_viewer_page_state.provider.dart
  18. 25 21
      mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart
  19. 52 34
      mobile/lib/modules/asset_viewer/ui/remote_photo_view.dart
  20. 24 24
      mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart
  21. 24 29
      mobile/lib/modules/asset_viewer/views/gallery_viewer.dart
  22. 13 13
      mobile/lib/modules/asset_viewer/views/image_viewer_page.dart
  23. 45 14
      mobile/lib/modules/asset_viewer/views/video_viewer_page.dart
  24. 17 0
      mobile/lib/modules/backup/background_service/background.service.dart
  25. 11 0
      mobile/lib/modules/backup/models/hive_backup_albums.model.dart
  26. 9 5
      mobile/lib/modules/backup/providers/backup.provider.dart
  27. 65 9
      mobile/lib/modules/home/services/asset.service.dart
  28. 7 10
      mobile/lib/modules/home/services/asset_cache.service.dart
  29. 15 8
      mobile/lib/modules/home/ui/asset_grid/asset_grid_data_structure.dart
  30. 11 11
      mobile/lib/modules/home/ui/asset_grid/immich_asset_grid.dart
  31. 14 48
      mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart
  32. 29 5
      mobile/lib/modules/home/views/home_page.dart
  33. 6 4
      mobile/lib/modules/search/models/search_result_page_state.model.dart
  34. 6 6
      mobile/lib/modules/search/providers/search_result_page.provider.dart
  35. 1 0
      mobile/lib/routing/router.dart
  36. 11 19
      mobile/lib/routing/router.gr.dart
  37. 117 0
      mobile/lib/shared/models/asset.dart
  38. 101 54
      mobile/lib/shared/providers/asset.provider.dart
  39. 20 18
      mobile/lib/shared/services/share.service.dart
  40. 96 0
      mobile/lib/shared/ui/immich_image.dart
  41. 27 22
      mobile/test/asset_grid_data_structure_test.dart

+ 3 - 3
mobile/android/app/src/main/kotlin/com/example/mobile/BackupWorker.kt

@@ -134,13 +134,13 @@ class BackupWorker(ctx: Context, params: WorkerParameters) : ListenableWorker(ct
     }
 
     private fun stopEngine(result: Result?) {
+        clearBackgroundNotification()
+        engine?.destroy()
+        engine = null
         if (result != null) {
             Log.d(TAG, "stopEngine result=${result}")
             resolvableFuture.set(result)
         }
-        engine?.destroy()
-        engine = null
-        clearBackgroundNotification()
         waitOnSetForegroundAsync()
     }
 

+ 5 - 3
mobile/lib/main.dart

@@ -35,10 +35,12 @@ void main() async {
   await Future.wait([
     Hive.openBox(userInfoBox),
     Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox),
-    Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox),
     Hive.openBox(hiveGithubReleaseInfoBox),
     Hive.openBox(userSettingInfoBox),
-    Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox),
+    if (!Platform.isAndroid) Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox),
+    if (!Platform.isAndroid)
+      Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox),
+    if (!Platform.isAndroid) Hive.openBox(backgroundBackupInfoBox),
     EasyLocalization.ensureInitialized(),
   ]);
 
@@ -86,8 +88,8 @@ class ImmichAppState extends ConsumerState<ImmichApp>
         var isAuthenticated = ref.watch(authenticationProvider).isAuthenticated;
 
         if (isAuthenticated) {
+          ref.read(backupProvider.notifier).resumeBackup();
           ref.read(backgroundServiceProvider).resumeServiceIfEnabled();
-          ref.watch(backupProvider.notifier).resumeBackup();
           ref.watch(assetProvider.notifier).getAllAsset();
           ref.watch(serverInfoProvider.notifier).getServerVersion();
         }

+ 5 - 6
mobile/lib/modules/album/models/asset_selection_page_result.model.dart

@@ -1,10 +1,9 @@
 import 'package:collection/collection.dart';
-
-import 'package:openapi/api.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
 
 class AssetSelectionPageResult {
-  final Set<AssetResponseDto> selectedNewAsset;
-  final Set<AssetResponseDto> selectedAdditionalAsset;
+  final Set<Asset> selectedNewAsset;
+  final Set<Asset> selectedAdditionalAsset;
   final bool isAlbumExist;
 
   AssetSelectionPageResult({
@@ -14,8 +13,8 @@ class AssetSelectionPageResult {
   });
 
   AssetSelectionPageResult copyWith({
-    Set<AssetResponseDto>? selectedNewAsset,
-    Set<AssetResponseDto>? selectedAdditionalAsset,
+    Set<Asset>? selectedNewAsset,
+    Set<Asset>? selectedAdditionalAsset,
     bool? isAlbumExist,
   }) {
     return AssetSelectionPageResult(

+ 7 - 8
mobile/lib/modules/album/models/asset_selection_state.model.dart

@@ -1,12 +1,11 @@
 import 'package:collection/collection.dart';
-
-import 'package:openapi/api.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
 
 class AssetSelectionState {
   final Set<String> selectedMonths;
-  final Set<AssetResponseDto> selectedNewAssetsForAlbum;
-  final Set<AssetResponseDto> selectedAdditionalAssetsForAlbum;
-  final Set<AssetResponseDto> selectedAssetsInAlbumViewer;
+  final Set<Asset> selectedNewAssetsForAlbum;
+  final Set<Asset> selectedAdditionalAssetsForAlbum;
+  final Set<Asset> selectedAssetsInAlbumViewer;
   final bool isMultiselectEnable;
 
   /// Indicate the asset selection page is navigated from existing album
@@ -22,9 +21,9 @@ class AssetSelectionState {
 
   AssetSelectionState copyWith({
     Set<String>? selectedMonths,
-    Set<AssetResponseDto>? selectedNewAssetsForAlbum,
-    Set<AssetResponseDto>? selectedAdditionalAssetsForAlbum,
-    Set<AssetResponseDto>? selectedAssetsInAlbumViewer,
+    Set<Asset>? selectedNewAssetsForAlbum,
+    Set<Asset>? selectedAdditionalAssetsForAlbum,
+    Set<Asset>? selectedAssetsInAlbumViewer,
     bool? isMultiselectEnable,
     bool? isAlbumExist,
   }) {

+ 2 - 2
mobile/lib/modules/album/providers/album.provider.dart

@@ -1,6 +1,7 @@
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/album/services/album.service.dart';
 import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:openapi/api.dart';
 
 class AlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
@@ -13,7 +14,6 @@ class AlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
   }
 
   getAllAlbums() async {
-
     if (await _albumCacheService.isValid() && state.isEmpty) {
       state = await _albumCacheService.get();
     }
@@ -34,7 +34,7 @@ class AlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
 
   Future<AlbumResponseDto?> createAlbum(
     String albumTitle,
-    Set<AssetResponseDto> assets,
+    Set<Asset> assets,
   ) async {
     AlbumResponseDto? album =
         await _albumService.createAlbum(albumTitle, assets, []);

+ 17 - 18
mobile/lib/modules/album/providers/asset_selection.provider.dart

@@ -1,7 +1,6 @@
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/album/models/asset_selection_state.model.dart';
-
-import 'package:openapi/api.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
 
 class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
   AssetSelectionNotifier()
@@ -22,15 +21,15 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
 
   void removeAssetsInMonth(
     String removedMonth,
-    List<AssetResponseDto> assetsInMonth,
+    List<Asset> assetsInMonth,
   ) {
-    Set<AssetResponseDto> currentAssetList = state.selectedNewAssetsForAlbum;
+    Set<Asset> currentAssetList = state.selectedNewAssetsForAlbum;
     Set<String> currentMonthList = state.selectedMonths;
 
     currentMonthList
         .removeWhere((selectedMonth) => selectedMonth == removedMonth);
 
-    for (AssetResponseDto asset in assetsInMonth) {
+    for (Asset asset in assetsInMonth) {
       currentAssetList.removeWhere((e) => e.id == asset.id);
     }
 
@@ -40,7 +39,7 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
     );
   }
 
-  void addAdditionalAssets(List<AssetResponseDto> assets) {
+  void addAdditionalAssets(List<Asset> assets) {
     state = state.copyWith(
       selectedAdditionalAssetsForAlbum: {
         ...state.selectedAdditionalAssetsForAlbum,
@@ -49,7 +48,7 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
     );
   }
 
-  void addAllAssetsInMonth(String month, List<AssetResponseDto> assetsInMonth) {
+  void addAllAssetsInMonth(String month, List<Asset> assetsInMonth) {
     state = state.copyWith(
       selectedMonths: {...state.selectedMonths, month},
       selectedNewAssetsForAlbum: {
@@ -59,7 +58,7 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
     );
   }
 
-  void addNewAssets(List<AssetResponseDto> assets) {
+  void addNewAssets(List<Asset> assets) {
     state = state.copyWith(
       selectedNewAssetsForAlbum: {
         ...state.selectedNewAssetsForAlbum,
@@ -68,20 +67,20 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
     );
   }
 
-  void removeSelectedNewAssets(List<AssetResponseDto> assets) {
-    Set<AssetResponseDto> currentList = state.selectedNewAssetsForAlbum;
+  void removeSelectedNewAssets(List<Asset> assets) {
+    Set<Asset> currentList = state.selectedNewAssetsForAlbum;
 
-    for (AssetResponseDto asset in assets) {
+    for (Asset asset in assets) {
       currentList.removeWhere((e) => e.id == asset.id);
     }
 
     state = state.copyWith(selectedNewAssetsForAlbum: currentList);
   }
 
-  void removeSelectedAdditionalAssets(List<AssetResponseDto> assets) {
-    Set<AssetResponseDto> currentList = state.selectedAdditionalAssetsForAlbum;
+  void removeSelectedAdditionalAssets(List<Asset> assets) {
+    Set<Asset> currentList = state.selectedAdditionalAssetsForAlbum;
 
-    for (AssetResponseDto asset in assets) {
+    for (Asset asset in assets) {
       currentList.removeWhere((e) => e.id == asset.id);
     }
 
@@ -109,7 +108,7 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
     );
   }
 
-  void addAssetsInAlbumViewer(List<AssetResponseDto> assets) {
+  void addAssetsInAlbumViewer(List<Asset> assets) {
     state = state.copyWith(
       selectedAssetsInAlbumViewer: {
         ...state.selectedAssetsInAlbumViewer,
@@ -118,10 +117,10 @@ class AssetSelectionNotifier extends StateNotifier<AssetSelectionState> {
     );
   }
 
-  void removeAssetsInAlbumViewer(List<AssetResponseDto> assets) {
-    Set<AssetResponseDto> currentList = state.selectedAssetsInAlbumViewer;
+  void removeAssetsInAlbumViewer(List<Asset> assets) {
+    Set<Asset> currentList = state.selectedAssetsInAlbumViewer;
 
-    for (AssetResponseDto asset in assets) {
+    for (Asset asset in assets) {
       currentList.removeWhere((e) => e.id == asset.id);
     }
 

+ 4 - 2
mobile/lib/modules/album/providers/shared_album.provider.dart

@@ -2,10 +2,12 @@ import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/album/services/album.service.dart';
 import 'package:immich_mobile/modules/album/services/album_cache.service.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:openapi/api.dart';
 
 class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
-  SharedAlbumNotifier(this._sharedAlbumService, this._sharedAlbumCacheService) : super([]);
+  SharedAlbumNotifier(this._sharedAlbumService, this._sharedAlbumCacheService)
+      : super([]);
 
   final AlbumService _sharedAlbumService;
   final SharedAlbumCacheService _sharedAlbumCacheService;
@@ -16,7 +18,7 @@ class SharedAlbumNotifier extends StateNotifier<List<AlbumResponseDto>> {
 
   Future<AlbumResponseDto?> createSharedAlbum(
     String albumName,
-    Set<AssetResponseDto> assets,
+    Set<Asset> assets,
     List<String> sharedUserIds,
   ) async {
     try {

+ 4 - 3
mobile/lib/modules/album/services/album.service.dart

@@ -2,6 +2,7 @@ import 'dart:async';
 
 import 'package:flutter/foundation.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/providers/api.provider.dart';
 import 'package:immich_mobile/shared/services/api.service.dart';
 import 'package:openapi/api.dart';
@@ -29,7 +30,7 @@ class AlbumService {
 
   Future<AlbumResponseDto?> createAlbum(
     String albumName,
-    Set<AssetResponseDto> assets,
+    Iterable<Asset> assets,
     List<String> sharedUserIds,
   ) async {
     try {
@@ -65,7 +66,7 @@ class AlbumService {
   }
 
   Future<AlbumResponseDto?> createAlbumWithGeneratedName(
-    Set<AssetResponseDto> assets,
+    Iterable<Asset> assets,
   ) async {
     return createAlbum(
         _getNextAlbumName(await getAlbums(isShared: false)), assets, []);
@@ -81,7 +82,7 @@ class AlbumService {
   }
 
   Future<AddAssetsResponseDto?> addAdditionalAssetToAlbum(
-    Set<AssetResponseDto> assets,
+    Iterable<Asset> assets,
     String albumId,
   ) async {
     try {

+ 6 - 31
mobile/lib/modules/album/ui/album_viewer_thumbnail.dart

@@ -1,18 +1,15 @@
 import 'package:auto_route/auto_route.dart';
-import 'package:cached_network_image/cached_network_image.dart';
 import 'package:flutter/material.dart';
-import 'package:hive_flutter/hive_flutter.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/constants/hive_box.dart';
 import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
 import 'package:immich_mobile/routing/router.dart';
-import 'package:immich_mobile/utils/image_url_builder.dart';
-import 'package:openapi/api.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/ui/immich_image.dart';
 
 class AlbumViewerThumbnail extends HookConsumerWidget {
-  final AssetResponseDto asset;
-  final List<AssetResponseDto> assetList;
+  final Asset asset;
+  final List<Asset> assetList;
   final bool showStorageIndicator;
 
   const AlbumViewerThumbnail({
@@ -24,8 +21,6 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
-    var box = Hive.box(userInfoBox);
-    var thumbnailRequestUrl = getThumbnailUrl(asset);
     var deviceId = ref.watch(authenticationProvider).deviceId;
     final selectedAssetsInAlbumViewer =
         ref.watch(assetSelectionProvider).selectedAssetsInAlbumViewer;
@@ -120,27 +115,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
     _buildThumbnailImage() {
       return Container(
         decoration: BoxDecoration(border: drawBorderColor()),
-        child: CachedNetworkImage(
-          cacheKey: asset.id,
-          width: 300,
-          height: 300,
-          memCacheHeight: 200,
-          fit: BoxFit.cover,
-          imageUrl: thumbnailRequestUrl,
-          httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
-          fadeInDuration: const Duration(milliseconds: 250),
-          progressIndicatorBuilder: (context, url, downloadProgress) =>
-              Transform.scale(
-            scale: 0.2,
-            child: CircularProgressIndicator(value: downloadProgress.progress),
-          ),
-          errorWidget: (context, url, error) {
-            return Icon(
-              Icons.image_not_supported_outlined,
-              color: Theme.of(context).primaryColor,
-            );
-          },
-        ),
+        child: ImmichImage(asset, width: 300, height: 300),
       );
     }
 
@@ -167,7 +142,7 @@ class AlbumViewerThumbnail extends HookConsumerWidget {
         children: [
           _buildThumbnailImage(),
           if (showStorageIndicator) _buildAssetStoreLocationIcon(),
-          if (asset.type != AssetTypeEnum.IMAGE) _buildVideoLabel(),
+          if (!asset.isImage) _buildVideoLabel(),
           if (isMultiSelectionEnable) _buildAssetSelectionIcon(),
         ],
       ),

+ 2 - 2
mobile/lib/modules/album/ui/asset_grid_by_month.dart

@@ -1,10 +1,10 @@
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/album/ui/selection_thumbnail_image.dart';
-import 'package:openapi/api.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
 
 class AssetGridByMonth extends HookConsumerWidget {
-  final List<AssetResponseDto> assetGroup;
+  final List<Asset> assetGroup;
   const AssetGridByMonth({Key? key, required this.assetGroup})
       : super(key: key);
   @override

+ 2 - 2
mobile/lib/modules/album/ui/month_group_title.dart

@@ -2,11 +2,11 @@ import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
-import 'package:openapi/api.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
 
 class MonthGroupTitle extends HookConsumerWidget {
   final String month;
-  final List<AssetResponseDto> assetGroup;
+  final List<Asset> assetGroup;
 
   const MonthGroupTitle({
     Key? key,

+ 6 - 34
mobile/lib/modules/album/ui/selection_thumbnail_image.dart

@@ -1,29 +1,24 @@
-import 'package:cached_network_image/cached_network_image.dart';
 import 'package:flutter/material.dart';
-import 'package:hive_flutter/hive_flutter.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/constants/hive_box.dart';
 import 'package:immich_mobile/modules/album/providers/asset_selection.provider.dart';
-import 'package:immich_mobile/utils/image_url_builder.dart';
-import 'package:openapi/api.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/ui/immich_image.dart';
 
 class SelectionThumbnailImage extends HookConsumerWidget {
-  final AssetResponseDto asset;
+  final Asset asset;
 
   const SelectionThumbnailImage({Key? key, required this.asset})
       : super(key: key);
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
-    var box = Hive.box(userInfoBox);
-    var thumbnailRequestUrl = getThumbnailUrl(asset);
     var selectedAsset =
         ref.watch(assetSelectionProvider).selectedNewAssetsForAlbum;
     var newAssetsForAlbum =
         ref.watch(assetSelectionProvider).selectedAdditionalAssetsForAlbum;
     var isAlbumExist = ref.watch(assetSelectionProvider).isAlbumExist;
 
-    Widget _buildSelectionIcon(AssetResponseDto asset) {
+    Widget _buildSelectionIcon(Asset asset) {
       var isSelected = selectedAsset.map((item) => item.id).contains(asset.id);
       var isNewlySelected =
           newAssetsForAlbum.map((item) => item.id).contains(asset.id);
@@ -110,30 +105,7 @@ class SelectionThumbnailImage extends HookConsumerWidget {
         children: [
           Container(
             decoration: BoxDecoration(border: drawBorderColor()),
-            child: CachedNetworkImage(
-              cacheKey: asset.id,
-              width: 150,
-              height: 150,
-              memCacheHeight: asset.type == AssetTypeEnum.IMAGE ? 150 : 150,
-              fit: BoxFit.cover,
-              imageUrl: thumbnailRequestUrl,
-              httpHeaders: {
-                "Authorization": "Bearer ${box.get(accessTokenKey)}"
-              },
-              fadeInDuration: const Duration(milliseconds: 250),
-              progressIndicatorBuilder: (context, url, downloadProgress) =>
-                  Transform.scale(
-                scale: 0.2,
-                child:
-                    CircularProgressIndicator(value: downloadProgress.progress),
-              ),
-              errorWidget: (context, url, error) {
-                return Icon(
-                  Icons.image_not_supported_outlined,
-                  color: Theme.of(context).primaryColor,
-                );
-              },
-            ),
+            child: ImmichImage(asset, width: 150, height: 150),
           ),
           Padding(
             padding: const EdgeInsets.all(3.0),
@@ -142,7 +114,7 @@ class SelectionThumbnailImage extends HookConsumerWidget {
               child: _buildSelectionIcon(asset),
             ),
           ),
-          if (asset.type != AssetTypeEnum.IMAGE)
+          if (!asset.isImage)
             Positioned(
               bottom: 5,
               right: 5,

+ 4 - 30
mobile/lib/modules/album/ui/shared_album_thumbnail_image.dart

@@ -1,49 +1,23 @@
-import 'package:cached_network_image/cached_network_image.dart';
 import 'package:flutter/material.dart';
-import 'package:hive_flutter/hive_flutter.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/constants/hive_box.dart';
-import 'package:immich_mobile/utils/image_url_builder.dart';
-import 'package:openapi/api.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/ui/immich_image.dart';
 
 class SharedAlbumThumbnailImage extends HookConsumerWidget {
-  final AssetResponseDto asset;
+  final Asset asset;
 
   const SharedAlbumThumbnailImage({Key? key, required this.asset})
       : super(key: key);
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
-    var box = Hive.box(userInfoBox);
-
     return GestureDetector(
       onTap: () {
         // debugPrint("View ${asset.id}");
       },
       child: Stack(
         children: [
-          CachedNetworkImage(
-            cacheKey: asset.id,
-            width: 500,
-            height: 500,
-            memCacheHeight: 500,
-            fit: BoxFit.cover,
-            imageUrl: getThumbnailUrl(asset),
-            httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
-            fadeInDuration: const Duration(milliseconds: 250),
-            progressIndicatorBuilder: (context, url, downloadProgress) =>
-                Transform.scale(
-              scale: 0.2,
-              child:
-                  CircularProgressIndicator(value: downloadProgress.progress),
-            ),
-            errorWidget: (context, url, error) {
-              return Icon(
-                Icons.image_not_supported_outlined,
-                color: Theme.of(context).primaryColor,
-              );
-            },
-          ),
+          ImmichImage(asset, width: 500, height: 500),
         ],
       ),
     );

+ 7 - 5
mobile/lib/modules/album/views/album_viewer_page.dart

@@ -16,6 +16,7 @@ import 'package:immich_mobile/modules/album/ui/album_viewer_thumbnail.dart';
 import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
 import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
 import 'package:immich_mobile/routing/router.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 import 'package:immich_mobile/shared/ui/immich_sliver_persistent_app_bar_delegate.dart';
 import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
@@ -38,9 +39,9 @@ class AlbumViewerPage extends HookConsumerWidget {
     /// If they exist, add to selected asset state to show they are already selected.
     void _onAddPhotosPressed(AlbumResponseDto albumInfo) async {
       if (albumInfo.assets.isNotEmpty == true) {
-        ref
-            .watch(assetSelectionProvider.notifier)
-            .addNewAssets(albumInfo.assets.toList());
+        ref.watch(assetSelectionProvider.notifier).addNewAssets(
+              albumInfo.assets.map((e) => Asset.remote(e)).toList(),
+            );
       }
 
       ref.watch(assetSelectionProvider.notifier).setIsAlbumExist(true);
@@ -205,8 +206,9 @@ class AlbumViewerPage extends HookConsumerWidget {
             delegate: SliverChildBuilderDelegate(
               (BuildContext context, int index) {
                 return AlbumViewerThumbnail(
-                  asset: albumInfo.assets[index],
-                  assetList: albumInfo.assets,
+                  asset: Asset.remote(albumInfo.assets[index]),
+                  assetList:
+                      albumInfo.assets.map((e) => Asset.remote(e)).toList(),
                   showStorageIndicator: showStorageIndicator,
                 );
               },

+ 1 - 1
mobile/lib/modules/album/views/create_album_page.dart

@@ -166,7 +166,7 @@ class CreateAlbumPage extends HookConsumerWidget {
                 return GestureDetector(
                   onTap: _onBackgroundTapped,
                   child: SharedAlbumThumbnailImage(
-                    asset: selectedAssets.toList()[index],
+                    asset: selectedAssets.elementAt(index),
                   ),
                 );
               },

+ 0 - 1
mobile/lib/modules/album/views/sharing_page.dart

@@ -18,7 +18,6 @@ class SharingPage extends HookConsumerWidget {
   @override
   Widget build(BuildContext context, WidgetRef ref) {
     var box = Hive.box(userInfoBox);
-    var thumbnailRequestUrl = '${box.get(serverEndpointKey)}/asset/thumbnail';
     final List<AlbumResponseDto> sharedAlbums = ref.watch(sharedAlbumProvider);
 
     useEffect(

+ 2 - 2
mobile/lib/modules/asset_viewer/providers/image_viewer_page_state.provider.dart

@@ -1,9 +1,9 @@
-import 'package:auto_route/auto_route.dart';
 import 'package:flutter/material.dart';
 import 'package:fluttertoast/fluttertoast.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
 import 'package:immich_mobile/modules/asset_viewer/services/image_viewer.service.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/services/share.service.dart';
 import 'package:immich_mobile/shared/ui/immich_toast.dart';
 import 'package:immich_mobile/shared/ui/share_dialog.dart';
@@ -47,7 +47,7 @@ class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
     state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.idle);
   }
 
-  void shareAsset(AssetResponseDto asset, BuildContext context) async {
+  void shareAsset(Asset asset, BuildContext context) async {
     showDialog(
       context: context,
       builder: (BuildContext buildContext) {

+ 25 - 21
mobile/lib/modules/asset_viewer/ui/exif_bottom_sheet.dart

@@ -2,12 +2,13 @@ import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_map/flutter_map.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:openapi/api.dart';
 import 'package:path/path.dart' as p;
 import 'package:latlong2/latlong.dart';
 
 class ExifBottomSheet extends ConsumerWidget {
-  final AssetResponseDto assetDetail;
+  final Asset assetDetail;
 
   const ExifBottomSheet({Key? key, required this.assetDetail})
       : super(key: key);
@@ -26,8 +27,8 @@ class ExifBottomSheet extends ConsumerWidget {
           child: FlutterMap(
             options: MapOptions(
               center: LatLng(
-                assetDetail.exifInfo?.latitude?.toDouble() ?? 0,
-                assetDetail.exifInfo?.longitude?.toDouble() ?? 0,
+                assetDetail.latitude ?? 0,
+                assetDetail.longitude ?? 0,
               ),
               zoom: 16.0,
             ),
@@ -48,8 +49,8 @@ class ExifBottomSheet extends ConsumerWidget {
                   Marker(
                     anchorPos: AnchorPos.align(AnchorAlign.top),
                     point: LatLng(
-                      assetDetail.exifInfo?.latitude?.toDouble() ?? 0,
-                      assetDetail.exifInfo?.longitude?.toDouble() ?? 0,
+                      assetDetail.latitude ?? 0,
+                      assetDetail.longitude ?? 0,
                     ),
                     builder: (ctx) => const Image(
                       image: AssetImage('assets/location-pin.png'),
@@ -63,9 +64,11 @@ class ExifBottomSheet extends ConsumerWidget {
       );
     }
 
+    ExifResponseDto? exifInfo = assetDetail.remote?.exifInfo;
+
     _buildLocationText() {
       return Text(
-        "${assetDetail.exifInfo!.city}, ${assetDetail.exifInfo!.state}",
+        "${exifInfo?.city}, ${exifInfo?.state}",
         style: TextStyle(
           fontSize: 12,
           color: Colors.grey[200],
@@ -78,10 +81,10 @@ class ExifBottomSheet extends ConsumerWidget {
       padding: const EdgeInsets.symmetric(vertical: 16.0, horizontal: 8),
       child: ListView(
         children: [
-          if (assetDetail.exifInfo?.dateTimeOriginal != null)
+          if (exifInfo?.dateTimeOriginal != null)
             Text(
               DateFormat('date_format'.tr()).format(
-                assetDetail.exifInfo!.dateTimeOriginal!.toLocal(),
+                exifInfo!.dateTimeOriginal!.toLocal(),
               ),
               style: TextStyle(
                 color: Colors.grey[400],
@@ -101,7 +104,7 @@ class ExifBottomSheet extends ConsumerWidget {
           ),
 
           // Location
-          if (assetDetail.exifInfo?.latitude != null)
+          if (assetDetail.latitude != null)
             Padding(
               padding: const EdgeInsets.only(top: 32.0),
               child: Column(
@@ -115,21 +118,22 @@ class ExifBottomSheet extends ConsumerWidget {
                     "exif_bottom_sheet_location",
                     style: TextStyle(fontSize: 11, color: Colors.grey[400]),
                   ).tr(),
-                  if (assetDetail.exifInfo?.latitude != null &&
-                      assetDetail.exifInfo?.longitude != null)
+                  if (assetDetail.latitude != null &&
+                      assetDetail.longitude != null)
                     _buildMap(),
-                  if (assetDetail.exifInfo?.city != null &&
-                      assetDetail.exifInfo?.state != null)
+                  if (exifInfo != null &&
+                      exifInfo.city != null &&
+                      exifInfo.state != null)
                     _buildLocationText(),
                   Text(
-                    "${assetDetail.exifInfo?.latitude?.toStringAsFixed(4)}, ${assetDetail.exifInfo?.longitude?.toStringAsFixed(4)}",
+                    "${assetDetail.latitude?.toStringAsFixed(4)}, ${assetDetail.longitude?.toStringAsFixed(4)}",
                     style: TextStyle(fontSize: 12, color: Colors.grey[400]),
                   )
                 ],
               ),
             ),
           // Detail
-          if (assetDetail.exifInfo != null)
+          if (exifInfo != null)
             Padding(
               padding: const EdgeInsets.only(top: 32.0),
               child: Column(
@@ -153,16 +157,16 @@ class ExifBottomSheet extends ConsumerWidget {
                     iconColor: Colors.grey[300],
                     leading: const Icon(Icons.image),
                     title: Text(
-                      "${assetDetail.exifInfo?.imageName!}${p.extension(assetDetail.originalPath)}",
+                      "${exifInfo.imageName!}${p.extension(assetDetail.remote!.originalPath)}",
                       style: const TextStyle(fontWeight: FontWeight.bold),
                     ),
-                    subtitle: assetDetail.exifInfo?.exifImageHeight != null
+                    subtitle: exifInfo.exifImageHeight != null
                         ? Text(
-                            "${assetDetail.exifInfo?.exifImageHeight} x ${assetDetail.exifInfo?.exifImageWidth}  ${assetDetail.exifInfo?.fileSizeInByte!}B ",
+                            "${exifInfo.exifImageHeight} x ${exifInfo.exifImageWidth}  ${exifInfo.fileSizeInByte!}B ",
                           )
                         : null,
                   ),
-                  if (assetDetail.exifInfo?.make != null)
+                  if (exifInfo.make != null)
                     ListTile(
                       contentPadding: const EdgeInsets.all(0),
                       dense: true,
@@ -170,11 +174,11 @@ class ExifBottomSheet extends ConsumerWidget {
                       iconColor: Colors.grey[300],
                       leading: const Icon(Icons.camera),
                       title: Text(
-                        "${assetDetail.exifInfo?.make} ${assetDetail.exifInfo?.model}",
+                        "${exifInfo.make} ${exifInfo.model}",
                         style: const TextStyle(fontWeight: FontWeight.bold),
                       ),
                       subtitle: Text(
-                        "ƒ/${assetDetail.exifInfo?.fNumber}   1/${(1 / (assetDetail.exifInfo?.exposureTime ?? 1)).toStringAsFixed(0)}   ${assetDetail.exifInfo?.focalLength}mm   ISO${assetDetail.exifInfo?.iso} ",
+                        "ƒ/${exifInfo.fNumber}   1/${(1 / (exifInfo.exposureTime ?? 1)).toStringAsFixed(0)}   ${exifInfo.focalLength}mm   ISO${exifInfo.iso} ",
                       ),
                     ),
                 ],

+ 52 - 34
mobile/lib/modules/asset_viewer/ui/remote_photo_view.dart

@@ -1,17 +1,22 @@
 import 'package:cached_network_image/cached_network_image.dart';
 import 'package:flutter/material.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/utils/image_url_builder.dart';
+import 'package:openapi/api.dart';
+import 'package:photo_manager/photo_manager.dart'
+    show AssetEntityImageProvider, ThumbnailSize;
 import 'package:photo_view/photo_view.dart';
 
 enum _RemoteImageStatus { empty, thumbnail, preview, full }
 
 class _RemotePhotoViewState extends State<RemotePhotoView> {
-  late CachedNetworkImageProvider _imageProvider;
+  late ImageProvider _imageProvider;
   _RemoteImageStatus _status = _RemoteImageStatus.empty;
   bool _zoomedIn = false;
 
-  late CachedNetworkImageProvider fullProvider;
-  late CachedNetworkImageProvider previewProvider;
-  late CachedNetworkImageProvider thumbnailProvider;
+  late ImageProvider _fullProvider;
+  late ImageProvider _previewProvider;
+  late ImageProvider _thumbnailProvider;
 
   @override
   Widget build(BuildContext context) {
@@ -68,7 +73,7 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
 
   void _performStateTransition(
     _RemoteImageStatus newStatus,
-    CachedNetworkImageProvider provider,
+    ImageProvider provider,
   ) {
     if (_status == newStatus) return;
 
@@ -90,40 +95,58 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
   }
 
   void _loadImages() {
-    thumbnailProvider = _authorizedImageProvider(
-      widget.thumbnailUrl,
-      widget.cacheKey,
+    if (widget.asset.isLocal) {
+      _imageProvider = AssetEntityImageProvider(
+        widget.asset.local!,
+        isOriginal: false,
+        thumbnailSize: const ThumbnailSize.square(250),
+      );
+      _fullProvider = AssetEntityImageProvider(widget.asset.local!);
+      _fullProvider.resolve(const ImageConfiguration()).addListener(
+        ImageStreamListener((ImageInfo image, _) {
+          _performStateTransition(
+            _RemoteImageStatus.full,
+            _fullProvider,
+          );
+        }),
+      );
+      return;
+    }
+
+    _thumbnailProvider = _authorizedImageProvider(
+      getThumbnailUrl(widget.asset.remote!),
+      widget.asset.id,
     );
-    _imageProvider = thumbnailProvider;
+    _imageProvider = _thumbnailProvider;
 
-    thumbnailProvider.resolve(const ImageConfiguration()).addListener(
+    _thumbnailProvider.resolve(const ImageConfiguration()).addListener(
       ImageStreamListener((ImageInfo imageInfo, _) {
         _performStateTransition(
           _RemoteImageStatus.thumbnail,
-          thumbnailProvider,
+          _thumbnailProvider,
         );
       }),
     );
 
-    if (widget.previewUrl != null) {
-      previewProvider = _authorizedImageProvider(
-        widget.previewUrl!,
-        "${widget.cacheKey}_previewStage",
+    if (widget.threeStageLoading) {
+      _previewProvider = _authorizedImageProvider(
+        getThumbnailUrl(widget.asset.remote!, type: ThumbnailFormat.JPEG),
+        "${widget.asset.id}_previewStage",
       );
-      previewProvider.resolve(const ImageConfiguration()).addListener(
+      _previewProvider.resolve(const ImageConfiguration()).addListener(
         ImageStreamListener((ImageInfo imageInfo, _) {
-          _performStateTransition(_RemoteImageStatus.preview, previewProvider);
+          _performStateTransition(_RemoteImageStatus.preview, _previewProvider);
         }),
       );
     }
 
-    fullProvider = _authorizedImageProvider(
-      widget.imageUrl,
-      "${widget.cacheKey}_fullStage",
+    _fullProvider = _authorizedImageProvider(
+      getImageUrl(widget.asset.remote!),
+      "${widget.asset.id}_fullStage",
     );
-    fullProvider.resolve(const ImageConfiguration()).addListener(
+    _fullProvider.resolve(const ImageConfiguration()).addListener(
       ImageStreamListener((ImageInfo imageInfo, _) {
-        _performStateTransition(_RemoteImageStatus.full, fullProvider);
+        _performStateTransition(_RemoteImageStatus.full, _fullProvider);
       }),
     );
   }
@@ -139,11 +162,11 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
     super.dispose();
 
     if (_status == _RemoteImageStatus.full) {
-      await fullProvider.evict();
+      await _fullProvider.evict();
     } else if (_status == _RemoteImageStatus.preview) {
-      await previewProvider.evict();
+      await _previewProvider.evict();
     } else if (_status == _RemoteImageStatus.thumbnail) {
-      await thumbnailProvider.evict();
+      await _thumbnailProvider.evict();
     }
 
     await _imageProvider.evict();
@@ -153,23 +176,18 @@ class _RemotePhotoViewState extends State<RemotePhotoView> {
 class RemotePhotoView extends StatefulWidget {
   const RemotePhotoView({
     Key? key,
-    required this.thumbnailUrl,
-    required this.imageUrl,
+    required this.asset,
     required this.authToken,
+    required this.threeStageLoading,
     required this.isZoomedFunction,
     required this.isZoomedListener,
     required this.onSwipeDown,
     required this.onSwipeUp,
-    this.previewUrl,
-    required this.cacheKey,
   }) : super(key: key);
 
-  final String thumbnailUrl;
-  final String imageUrl;
+  final Asset asset;
   final String authToken;
-  final String? previewUrl;
-  final String cacheKey;
-
+  final bool threeStageLoading;
   final void Function() onSwipeDown;
   final void Function() onSwipeUp;
   final void Function() isZoomedFunction;

+ 24 - 24
mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart

@@ -1,7 +1,7 @@
 import 'package:auto_route/auto_route.dart';
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:openapi/api.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
 
 class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
   const TopControlAppBar({
@@ -13,9 +13,9 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
     this.loading = false,
   }) : super(key: key);
 
-  final AssetResponseDto asset;
+  final Asset asset;
   final Function onMoreInfoPressed;
-  final Function onDownloadPressed;
+  final VoidCallback? onDownloadPressed;
   final Function onSharePressed;
   final bool loading;
 
@@ -47,17 +47,16 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
               child: const CircularProgressIndicator(strokeWidth: 2.0),
             ),
           ),
-        IconButton(
-          iconSize: iconSize,
-          splashRadius: iconSize,
-          onPressed: () {
-            onDownloadPressed();
-          },
-          icon: Icon(
-            Icons.cloud_download_rounded,
-            color: Colors.grey[200],
+        if (!asset.isLocal)
+          IconButton(
+            iconSize: iconSize,
+            splashRadius: iconSize,
+            onPressed: onDownloadPressed,
+            icon: Icon(
+              Icons.cloud_download_rounded,
+              color: Colors.grey[200],
+            ),
           ),
-        ),
         IconButton(
           iconSize: iconSize,
           splashRadius: iconSize,
@@ -69,17 +68,18 @@ class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
             color: Colors.grey[200],
           ),
         ),
-        IconButton(
-          iconSize: iconSize,
-          splashRadius: iconSize,
-          onPressed: () {
-            onMoreInfoPressed();
-          },
-          icon: Icon(
-            Icons.more_horiz_rounded,
-            color: Colors.grey[200],
-          ),
-        )
+        if (asset.isRemote)
+          IconButton(
+            iconSize: iconSize,
+            splashRadius: iconSize,
+            onPressed: () {
+              onMoreInfoPressed();
+            },
+            icon: Icon(
+              Icons.more_horiz_rounded,
+              color: Colors.grey[200],
+            ),
+          )
       ],
     );
   }

+ 24 - 29
mobile/lib/modules/asset_viewer/views/gallery_viewer.dart

@@ -14,12 +14,12 @@ import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart'
 import 'package:immich_mobile/modules/home/services/asset.service.dart';
 import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
 import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
-import 'package:openapi/api.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
 
 // ignore: must_be_immutable
 class GalleryViewerPage extends HookConsumerWidget {
-  late List<AssetResponseDto> assetList;
-  final AssetResponseDto asset;
+  late List<Asset> assetList;
+  final Asset asset;
 
   GalleryViewerPage({
     Key? key,
@@ -27,7 +27,7 @@ class GalleryViewerPage extends HookConsumerWidget {
     required this.asset,
   }) : super(key: key);
 
-  AssetResponseDto? assetDetail;
+  Asset? assetDetail;
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
@@ -37,8 +37,7 @@ class GalleryViewerPage extends HookConsumerWidget {
     final loading = useState(false);
     final isZoomed = useState<bool>(false);
     ValueNotifier<bool> isZoomedListener = ValueNotifier<bool>(false);
-
-    int indexOfAsset = assetList.indexOf(asset);
+    final indexOfAsset = useState(assetList.indexOf(asset));
 
     PageController controller =
         PageController(initialPage: assetList.indexOf(asset));
@@ -52,15 +51,15 @@ class GalleryViewerPage extends HookConsumerWidget {
       [],
     );
 
-    @override
-    initState(int index) {
-      indexOfAsset = index;
-    }
-
     getAssetExif() async {
-      assetDetail = await ref
-          .watch(assetServiceProvider)
-          .getAssetById(assetList[indexOfAsset].id);
+      if (assetList[indexOfAsset.value].isRemote) {
+        assetDetail = await ref
+            .watch(assetServiceProvider)
+            .getAssetById(assetList[indexOfAsset.value].id);
+      } else {
+        // TODO local exif parsing?
+        assetDetail = assetList[indexOfAsset.value];
+      }
     }
 
     void showInfo() {
@@ -88,19 +87,20 @@ class GalleryViewerPage extends HookConsumerWidget {
       backgroundColor: Colors.black,
       appBar: TopControlAppBar(
         loading: loading.value,
-        asset: assetList[indexOfAsset],
+        asset: assetList[indexOfAsset.value],
         onMoreInfoPressed: () {
           showInfo();
         },
-        onDownloadPressed: () {
-          ref
-              .watch(imageViewerStateProvider.notifier)
-              .downloadAsset(assetList[indexOfAsset], context);
-        },
+        onDownloadPressed: assetList[indexOfAsset.value].isLocal
+            ? null
+            : () {
+                ref.watch(imageViewerStateProvider.notifier).downloadAsset(
+                    assetList[indexOfAsset.value].remote!, context);
+              },
         onSharePressed: () {
           ref
               .watch(imageViewerStateProvider.notifier)
-              .shareAsset(assetList[indexOfAsset], context);
+              .shareAsset(assetList[indexOfAsset.value], context);
         },
       ),
       body: SafeArea(
@@ -113,14 +113,13 @@ class GalleryViewerPage extends HookConsumerWidget {
           itemCount: assetList.length,
           scrollDirection: Axis.horizontal,
           onPageChanged: (value) {
+            indexOfAsset.value = value;
             HapticFeedback.selectionClick();
           },
           itemBuilder: (context, index) {
-            initState(index);
-
             getAssetExif();
 
-            if (assetList[index].type == AssetTypeEnum.IMAGE) {
+            if (assetList[index].isImage) {
               return ImageViewerPage(
                 authToken: 'Bearer ${box.get(accessTokenKey)}',
                 isZoomedFunction: isZoomedMethod,
@@ -139,11 +138,7 @@ class GalleryViewerPage extends HookConsumerWidget {
                 },
                 child: Hero(
                   tag: assetList[index].id,
-                  child: VideoViewerPage(
-                    asset: assetList[index],
-                    videoUrl:
-                        '${box.get(serverEndpointKey)}/asset/file?aid=${assetList[index].deviceAssetId}&did=${assetList[index].deviceId}',
-                  ),
+                  child: VideoViewerPage(asset: assetList[index]),
                 ),
               );
             }

+ 13 - 13
mobile/lib/modules/asset_viewer/views/image_viewer_page.dart

@@ -8,13 +8,12 @@ import 'package:immich_mobile/modules/asset_viewer/ui/download_loading_indicator
 import 'package:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
 import 'package:immich_mobile/modules/asset_viewer/ui/remote_photo_view.dart';
 import 'package:immich_mobile/modules/home/services/asset.service.dart';
-import 'package:immich_mobile/utils/image_url_builder.dart';
-import 'package:openapi/api.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
 
 // ignore: must_be_immutable
 class ImageViewerPage extends HookConsumerWidget {
   final String heroTag;
-  final AssetResponseDto asset;
+  final Asset asset;
   final String authToken;
   final ValueNotifier<bool> isZoomedListener;
   final void Function() isZoomedFunction;
@@ -30,7 +29,7 @@ class ImageViewerPage extends HookConsumerWidget {
     required this.threeStageLoading,
   }) : super(key: key);
 
-  AssetResponseDto? assetDetail;
+  Asset? assetDetail;
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
@@ -38,8 +37,13 @@ class ImageViewerPage extends HookConsumerWidget {
         ref.watch(imageViewerStateProvider).downloadAssetStatus;
 
     getAssetExif() async {
-      assetDetail =
-          await ref.watch(assetServiceProvider).getAssetById(asset.id);
+      if (asset.isRemote) {
+        assetDetail =
+            await ref.watch(assetServiceProvider).getAssetById(asset.id);
+      } else {
+        // TODO local exif parsing?
+        assetDetail = asset;
+      }
     }
 
     useEffect(
@@ -68,17 +72,13 @@ class ImageViewerPage extends HookConsumerWidget {
           child: Hero(
             tag: heroTag,
             child: RemotePhotoView(
-              thumbnailUrl: getThumbnailUrl(asset),
-              cacheKey: asset.id,
-              imageUrl: getImageUrl(asset),
-              previewUrl: threeStageLoading
-                  ? getThumbnailUrl(asset, type: ThumbnailFormat.JPEG)
-                  : null,
+              asset: asset,
               authToken: authToken,
+              threeStageLoading: threeStageLoading,
               isZoomedFunction: isZoomedFunction,
               isZoomedListener: isZoomedListener,
               onSwipeDown: () => AutoRouter.of(context).pop(),
-              onSwipeUp: () => showInfo(),
+              onSwipeUp: asset.isRemote ? showInfo : () {},
             ),
           ),
         ),

+ 45 - 14
mobile/lib/modules/asset_viewer/views/video_viewer_page.dart

@@ -1,3 +1,5 @@
+import 'dart:io';
+
 import 'package:flutter/material.dart';
 import 'package:hive/hive.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
@@ -6,24 +8,41 @@ import 'package:chewie/chewie.dart';
 import 'package:immich_mobile/modules/asset_viewer/models/image_viewer_page_state.model.dart';
 import 'package:immich_mobile/modules/asset_viewer/providers/image_viewer_page_state.provider.dart';
 import 'package:immich_mobile/modules/asset_viewer/ui/download_loading_indicator.dart';
-import 'package:openapi/api.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:photo_manager/photo_manager.dart';
 import 'package:video_player/video_player.dart';
 
 // ignore: must_be_immutable
 class VideoViewerPage extends HookConsumerWidget {
-  final String videoUrl;
-  final AssetResponseDto asset;
-  AssetResponseDto? assetDetail;
+  final Asset asset;
 
-  VideoViewerPage({Key? key, required this.videoUrl, required this.asset})
-      : super(key: key);
+  const VideoViewerPage({Key? key, required this.asset}) : super(key: key);
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
+    if (asset.isLocal) {
+      final AsyncValue<File> videoFile = ref.watch(_fileFamily(asset.local!));
+      return videoFile.when(
+        data: (data) => VideoThumbnailPlayer(file: data),
+        error: (error, stackTrace) => Icon(
+          Icons.image_not_supported_outlined,
+          color: Theme.of(context).primaryColor,
+        ),
+        loading: () => const Center(
+          child: SizedBox(
+            width: 75,
+            height: 75,
+            child: CircularProgressIndicator.adaptive(),
+          ),
+        ),
+      );
+    }
     final downloadAssetStatus =
         ref.watch(imageViewerStateProvider).downloadAssetStatus;
-
-    String jwtToken = Hive.box(userInfoBox).get(accessTokenKey);
+    final box = Hive.box(userInfoBox);
+    final String jwtToken = box.get(accessTokenKey);
+    final String videoUrl =
+        '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}';
 
     return Stack(
       children: [
@@ -40,11 +59,21 @@ class VideoViewerPage extends HookConsumerWidget {
   }
 }
 
+final _fileFamily =
+    FutureProvider.family<File, AssetEntity>((ref, entity) async {
+  final file = await entity.file;
+  if (file == null) {
+    throw Exception();
+  }
+  return file;
+});
+
 class VideoThumbnailPlayer extends StatefulWidget {
-  final String url;
+  final String? url;
   final String? jwtToken;
+  final File? file;
 
-  const VideoThumbnailPlayer({Key? key, required this.url, this.jwtToken})
+  const VideoThumbnailPlayer({Key? key, this.url, this.jwtToken, this.file})
       : super(key: key);
 
   @override
@@ -63,10 +92,12 @@ class _VideoThumbnailPlayerState extends State<VideoThumbnailPlayer> {
 
   Future<void> initializePlayer() async {
     try {
-      videoPlayerController = VideoPlayerController.network(
-        widget.url,
-        httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"},
-      );
+      videoPlayerController = widget.file == null
+          ? VideoPlayerController.network(
+              widget.url!,
+              httpHeaders: {"Authorization": "Bearer ${widget.jwtToken}"},
+            )
+          : VideoPlayerController.file(widget.file!);
 
       await videoPlayerController.initialize();
       _createChewieController();

+ 17 - 0
mobile/lib/modules/backup/background_service/background.service.dart

@@ -50,6 +50,11 @@ class BackgroundService {
       _Throttle(_updateProgress, notifyInterval);
   late final _Throttle _throttledDetailNotify =
       _Throttle(_updateDetailProgress, notifyInterval);
+  Completer<bool> _hasAccessCompleter = Completer();
+  late Future<bool> _hasAccess =
+      Platform.isAndroid ? _hasAccessCompleter.future : Future.value(true);
+
+  Future<bool> get hasAccess => _hasAccess;
 
   bool get isBackgroundInitialized {
     return _isBackgroundInitialized;
@@ -201,6 +206,15 @@ class BackgroundService {
     if (!Platform.isAndroid) {
       return true;
     }
+    if (_hasLock) {
+      debugPrint("WARNING: [acquireLock] called more than once");
+      return true;
+    }
+    if (_hasAccessCompleter.isCompleted) {
+      debugPrint("WARNING: [acquireLock] _hasAccessCompleter is completed");
+      _hasAccessCompleter = Completer();
+      _hasAccess = _hasAccessCompleter.future;
+    }
     final int lockTime = Timeline.now;
     _wantsLockTime = lockTime;
     final ReceivePort rp = ReceivePort(_portNameLock);
@@ -219,6 +233,7 @@ class BackgroundService {
     }
     _hasLock = true;
     rp.listen(_heartbeatListener);
+    _hasAccessCompleter.complete(true);
     return true;
   }
 
@@ -271,6 +286,8 @@ class BackgroundService {
     }
     _wantsLockTime = 0;
     if (_hasLock) {
+      _hasAccessCompleter = Completer();
+      _hasAccess = _hasAccessCompleter.future;
       IsolateNameServer.removePortNameMapping(_portNameLock);
       _waitingIsolate?.send(true);
       _waitingIsolate = null;

+ 11 - 0
mobile/lib/modules/backup/models/hive_backup_albums.model.dart

@@ -46,6 +46,17 @@ class HiveBackupAlbums {
     );
   }
 
+  /// Returns a deep copy to allow safe modification without changing the global
+  /// state of [HiveBackupAlbums] before actually saving the changes
+  HiveBackupAlbums deepCopy() {
+    return HiveBackupAlbums(
+      selectedAlbumIds: selectedAlbumIds.toList(),
+      excludedAlbumsIds: excludedAlbumsIds.toList(),
+      lastSelectedBackupTime: lastSelectedBackupTime.toList(),
+      lastExcludedBackupTime: lastExcludedBackupTime.toList(),
+    );
+  }
+
   Map<String, dynamic> toMap() {
     final result = <String, dynamic>{};
 

+ 9 - 5
mobile/lib/modules/backup/providers/backup.provider.dart

@@ -565,11 +565,16 @@ class BackupNotifier extends StateNotifier<BackUpState> {
       state = state.copyWith(backupProgress: BackUpProgressEnum.inBackground);
       final bool hasLock = await _backgroundService.acquireLock();
       if (!hasLock) {
+        debugPrint("WARNING [resumeBackup] failed to acquireLock");
         return;
       }
-      Box<HiveBackupAlbums> box =
-          await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
-      HiveBackupAlbums? albums = box.get(backupInfoKey);
+      await Future.wait([
+        Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox),
+        Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox),
+        Hive.openBox(backgroundBackupInfoBox),
+      ]);
+      final HiveBackupAlbums? albums =
+          Hive.box<HiveBackupAlbums>(hiveBackupInfoBox).get(backupInfoKey);
       Set<AvailableAlbum> selectedAlbums = state.selectedBackupAlbums;
       Set<AvailableAlbum> excludedAlbums = state.excludedBackupAlbums;
       if (albums != null) {
@@ -584,8 +589,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
           albums.lastExcludedBackupTime,
         );
       }
-      await Hive.openBox<HiveDuplicatedAssets>(duplicatedAssetsBox);
-      final Box backgroundBox = await Hive.openBox(backgroundBackupInfoBox);
+      final Box backgroundBox = Hive.box(backgroundBackupInfoBox);
       state = state.copyWith(
         backupProgress: previous,
         selectedBackupAlbums: selectedAlbums,

+ 65 - 9
mobile/lib/modules/home/services/asset.service.dart

@@ -1,34 +1,90 @@
 import 'dart:async';
 
 import 'package:flutter/material.dart';
+import 'package:hive/hive.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/constants/hive_box.dart';
+import 'package:immich_mobile/modules/backup/background_service/background.service.dart';
+import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
+import 'package:immich_mobile/modules/backup/services/backup.service.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/providers/api.provider.dart';
 import 'package:immich_mobile/shared/services/api.service.dart';
 import 'package:openapi/api.dart';
+import 'package:photo_manager/src/types/entity.dart';
 
 final assetServiceProvider = Provider(
   (ref) => AssetService(
     ref.watch(apiServiceProvider),
+    ref.watch(backupServiceProvider),
+    ref.watch(backgroundServiceProvider),
   ),
 );
 
 class AssetService {
   final ApiService _apiService;
+  final BackupService _backupService;
+  final BackgroundService _backgroundService;
 
-  AssetService(this._apiService);
+  AssetService(this._apiService, this._backupService, this._backgroundService);
 
-  Future<List<AssetResponseDto>?> getAllAsset() async {
+  /// Returns all local, remote assets in that order
+  Future<List<Asset>> getAllAsset({bool urgent = false}) async {
+    final List<Asset> assets = [];
     try {
-      return await _apiService.assetApi.getAllAssets();
+      // not using `await` here to fetch local & remote assets concurrently
+      final Future<List<AssetResponseDto>?> remoteTask =
+          _apiService.assetApi.getAllAssets();
+      final Iterable<AssetEntity> newLocalAssets;
+      final List<AssetEntity> localAssets = await _getLocalAssets(urgent);
+      final List<AssetResponseDto> remoteAssets = await remoteTask ?? [];
+      if (remoteAssets.isNotEmpty && localAssets.isNotEmpty) {
+        final String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
+        final Set<String> existingIds = remoteAssets
+            .where((e) => e.deviceId == deviceId)
+            .map((e) => e.deviceAssetId)
+            .toSet();
+        newLocalAssets = localAssets.where((e) => !existingIds.contains(e.id));
+      } else {
+        newLocalAssets = localAssets;
+      }
+
+      assets.addAll(newLocalAssets.map((e) => Asset.local(e)));
+      // the order (first all local, then remote assets) is important!
+      assets.addAll(remoteAssets.map((e) => Asset.remote(e)));
     } catch (e) {
       debugPrint("Error [getAllAsset]  ${e.toString()}");
-      return null;
+    }
+    return assets;
+  }
+
+  /// if [urgent] is `true`, do not block by waiting on the background service
+  /// to finish running. Returns an empty list instead after a timeout.
+  Future<List<AssetEntity>> _getLocalAssets(bool urgent) async {
+    try {
+      final Future<bool> hasAccess = urgent
+          ? _backgroundService.hasAccess
+              .timeout(const Duration(milliseconds: 250))
+          : _backgroundService.hasAccess;
+      if (!await hasAccess) {
+        throw Exception("Error [getAllAsset] failed to gain access");
+      }
+      final box = await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
+      final HiveBackupAlbums? backupAlbumInfo = box.get(backupInfoKey);
+
+      return backupAlbumInfo != null
+          ? await _backupService
+              .buildUploadCandidates(backupAlbumInfo.deepCopy())
+          : [];
+    } catch (e) {
+      debugPrint("Error [_getLocalAssets] ${e.toString()}");
+      return [];
     }
   }
 
-  Future<AssetResponseDto?> getAssetById(String assetId) async {
+  Future<Asset?> getAssetById(String assetId) async {
     try {
-      return await _apiService.assetApi.getAssetById(assetId);
+      return Asset.remote(await _apiService.assetApi.getAssetById(assetId));
     } catch (e) {
       debugPrint("Error [getAssetById]  ${e.toString()}");
       return null;
@@ -36,12 +92,12 @@ class AssetService {
   }
 
   Future<List<DeleteAssetResponseDto>?> deleteAssets(
-    Set<AssetResponseDto> deleteAssets,
+    Iterable<AssetResponseDto> deleteAssets,
   ) async {
     try {
-      List<String> payload = [];
+      final List<String> payload = [];
 
-      for (var asset in deleteAssets) {
+      for (final asset in deleteAssets) {
         payload.add(asset.id);
       }
 

+ 7 - 10
mobile/lib/modules/home/services/asset_cache.service.dart

@@ -1,27 +1,24 @@
 import 'package:collection/collection.dart';
 import 'package:flutter/foundation.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/services/json_cache.dart';
-import 'package:openapi/api.dart';
 
-
-class AssetCacheService extends JsonCache<List<AssetResponseDto>> {
+class AssetCacheService extends JsonCache<List<Asset>> {
   AssetCacheService() : super("asset_cache");
 
   @override
-  void put(List<AssetResponseDto> data) {
+  void put(List<Asset> data) {
     putRawData(data.map((e) => e.toJson()).toList());
   }
 
   @override
-  Future<List<AssetResponseDto>> get() async {
+  Future<List<Asset>> get() async {
     try {
       final mapList = await readRawData() as List<dynamic>;
 
-      final responseData = mapList
-          .map((e) => AssetResponseDto.fromJson(e))
-          .whereNotNull()
-          .toList();
+      final responseData =
+          mapList.map((e) => Asset.fromJson(e)).whereNotNull().toList();
 
       return responseData;
     } catch (e) {
@@ -33,5 +30,5 @@ class AssetCacheService extends JsonCache<List<AssetResponseDto>> {
 }
 
 final assetCacheServiceProvider = Provider(
-      (ref) => AssetCacheService(),
+  (ref) => AssetCacheService(),
 );

+ 15 - 8
mobile/lib/modules/home/ui/asset_grid/asset_grid_data_structure.dart

@@ -1,6 +1,6 @@
 import 'dart:math';
 
-import 'package:openapi/api.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
 
 enum RenderAssetGridElementType {
   assetRow,
@@ -9,7 +9,7 @@ enum RenderAssetGridElementType {
 }
 
 class RenderAssetGridRow {
-  final List<AssetResponseDto> assets;
+  final List<Asset> assets;
 
   RenderAssetGridRow(this.assets);
 }
@@ -19,7 +19,7 @@ class RenderAssetGridElement {
   final RenderAssetGridRow? assetRow;
   final String? title;
   final DateTime date;
-  final List<AssetResponseDto>? relatedAssetList;
+  final List<Asset>? relatedAssetList;
 
   RenderAssetGridElement(
     this.type, {
@@ -31,13 +31,15 @@ class RenderAssetGridElement {
 }
 
 List<RenderAssetGridElement> assetsToRenderList(
-    List<AssetResponseDto> assets, int assetsPerRow) {
+  List<Asset> assets,
+  int assetsPerRow,
+) {
   List<RenderAssetGridElement> elements = [];
 
   int cursor = 0;
   while (cursor < assets.length) {
     int rowElements = min(assets.length - cursor, assetsPerRow);
-    final date = DateTime.parse(assets[cursor].createdAt);
+    final date = assets[cursor].createdAt;
 
     final rowElement = RenderAssetGridElement(
       RenderAssetGridElementType.assetRow,
@@ -55,7 +57,9 @@ List<RenderAssetGridElement> assetsToRenderList(
 }
 
 List<RenderAssetGridElement> assetGroupsToRenderList(
-    Map<String, List<AssetResponseDto>> assetGroups, int assetsPerRow) {
+  Map<String, List<Asset>> assetGroups,
+  int assetsPerRow,
+) {
   List<RenderAssetGridElement> elements = [];
   DateTime? lastDate;
 
@@ -64,8 +68,11 @@ List<RenderAssetGridElement> assetGroupsToRenderList(
 
     if (lastDate == null || lastDate!.month != date.month) {
       elements.add(
-        RenderAssetGridElement(RenderAssetGridElementType.monthTitle,
-            title: groupName, date: date),
+        RenderAssetGridElement(
+          RenderAssetGridElementType.monthTitle,
+          title: groupName,
+          date: date,
+        ),
       );
     }
 

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

@@ -4,7 +4,7 @@ import 'package:collection/collection.dart';
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:immich_mobile/modules/home/ui/asset_grid/thumbnail_image.dart';
-import 'package:openapi/api.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:scrollable_positioned_list/scrollable_positioned_list.dart';
 import 'asset_grid_data_structure.dart';
 import 'daily_title_text.dart';
@@ -13,7 +13,7 @@ import 'draggable_scrollbar_custom.dart';
 
 typedef ImmichAssetGridSelectionListener = void Function(
   bool,
-  Set<AssetResponseDto>,
+  Set<Asset>,
 );
 
 class ImmichAssetGridState extends State<ImmichAssetGrid> {
@@ -24,20 +24,20 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
   bool _scrolling = false;
   final Set<String> _selectedAssets = HashSet();
 
-  List<AssetResponseDto> get _assets {
+  List<Asset> get _assets {
     return widget.renderList
         .map((e) {
           if (e.type == RenderAssetGridElementType.assetRow) {
             return e.assetRow!.assets;
           } else {
-            return List<AssetResponseDto>.empty();
+            return List<Asset>.empty();
           }
         })
         .flattened
         .toList();
   }
 
-  Set<AssetResponseDto> _getSelectedAssets() {
+  Set<Asset> _getSelectedAssets() {
     return _selectedAssets
         .map((e) => _assets.firstWhereOrNull((a) => a.id == e))
         .whereNotNull()
@@ -48,7 +48,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
     widget.listener?.call(selectionActive, _getSelectedAssets());
   }
 
-  void _selectAssets(List<AssetResponseDto> assets) {
+  void _selectAssets(List<Asset> assets) {
     setState(() {
       for (var e in assets) {
         _selectedAssets.add(e.id);
@@ -57,7 +57,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
     });
   }
 
-  void _deselectAssets(List<AssetResponseDto> assets) {
+  void _deselectAssets(List<Asset> assets) {
     setState(() {
       for (var e in assets) {
         _selectedAssets.remove(e.id);
@@ -74,7 +74,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
     _callSelectionListener(false);
   }
 
-  bool _allAssetsSelected(List<AssetResponseDto> assets) {
+  bool _allAssetsSelected(List<Asset> assets) {
     return widget.selectionActive &&
         assets.firstWhereOrNull((e) => !_selectedAssets.contains(e.id)) == null;
   }
@@ -85,7 +85,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
   }
 
   Widget _buildThumbnailOrPlaceholder(
-    AssetResponseDto asset,
+    Asset asset,
     bool placeholder,
   ) {
     if (placeholder) {
@@ -114,7 +114,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
 
     return Row(
       key: Key("asset-row-${row.assets.first.id}"),
-      children: row.assets.map((AssetResponseDto asset) {
+      children: row.assets.map((Asset asset) {
         bool last = asset == row.assets.last;
 
         return Container(
@@ -134,7 +134,7 @@ class ImmichAssetGridState extends State<ImmichAssetGrid> {
   Widget _buildTitle(
     BuildContext context,
     String title,
-    List<AssetResponseDto> assets,
+    List<Asset> assets,
   ) {
     return DailyTitleText(
       isoDate: title,

+ 14 - 48
mobile/lib/modules/home/ui/asset_grid/thumbnail_image.dart

@@ -1,18 +1,15 @@
 import 'package:auto_route/auto_route.dart';
-import 'package:cached_network_image/cached_network_image.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter/services.dart';
-import 'package:hive_flutter/hive_flutter.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
-import 'package:immich_mobile/constants/hive_box.dart';
 import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 import 'package:immich_mobile/routing/router.dart';
-import 'package:immich_mobile/utils/image_url_builder.dart';
-import 'package:openapi/api.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/shared/ui/immich_image.dart';
 
 class ThumbnailImage extends HookConsumerWidget {
-  final AssetResponseDto asset;
-  final List<AssetResponseDto> assetList;
+  final Asset asset;
+  final List<Asset> assetList;
   final bool showStorageIndicator;
   final bool useGrayBoxPlaceholder;
   final bool isSelected;
@@ -34,12 +31,9 @@ class ThumbnailImage extends HookConsumerWidget {
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
-    var box = Hive.box(userInfoBox);
-    var thumbnailRequestUrl = getThumbnailUrl(asset);
     var deviceId = ref.watch(authenticationProvider).deviceId;
 
-
-    Widget buildSelectionIcon(AssetResponseDto asset) {
+    Widget buildSelectionIcon(Asset asset) {
       if (isSelected) {
         return Icon(
           Icons.check_circle,
@@ -87,41 +81,11 @@ class ThumbnailImage extends HookConsumerWidget {
                       )
                     : const Border(),
               ),
-              child: CachedNetworkImage(
-                cacheKey: 'thumbnail-image-${asset.id}',
+              child: ImmichImage(
+                asset,
                 width: 300,
                 height: 300,
-                memCacheHeight: 200,
-                maxWidthDiskCache: 200,
-                maxHeightDiskCache: 200,
-                fit: BoxFit.cover,
-                imageUrl: thumbnailRequestUrl,
-                httpHeaders: {
-                  "Authorization": "Bearer ${box.get(accessTokenKey)}"
-                },
-                fadeInDuration: const Duration(milliseconds: 250),
-                progressIndicatorBuilder: (context, url, downloadProgress) {
-                  if (useGrayBoxPlaceholder) {
-                    return const DecoratedBox(
-                      decoration: BoxDecoration(color: Colors.grey),
-                    );
-                  }
-                  return Transform.scale(
-                    scale: 0.2,
-                    child: CircularProgressIndicator(
-                      value: downloadProgress.progress,
-                    ),
-                  );
-                },
-                errorWidget: (context, url, error) {
-                  debugPrint("Error getting thumbnail $url = $error");
-                  CachedNetworkImage.evictFromCache(thumbnailRequestUrl);
-
-                  return Icon(
-                    Icons.image_not_supported_outlined,
-                    color: Theme.of(context).primaryColor,
-                  );
-                },
+                useGrayBoxPlaceholder: useGrayBoxPlaceholder,
               ),
             ),
             if (multiselectEnabled)
@@ -137,14 +101,16 @@ class ThumbnailImage extends HookConsumerWidget {
                 right: 10,
                 bottom: 5,
                 child: Icon(
-                  (deviceId != asset.deviceId)
-                      ? Icons.cloud_done_outlined
-                      : Icons.photo_library_rounded,
+                  asset.isRemote
+                      ? (deviceId == asset.deviceId
+                          ? Icons.cloud_done_outlined
+                          : Icons.cloud_outlined)
+                      : Icons.cloud_off_outlined,
                   color: Colors.white,
                   size: 18,
                 ),
               ),
-            if (asset.type != AssetTypeEnum.IMAGE)
+            if (!asset.isImage)
               Positioned(
                 top: 5,
                 right: 5,

+ 29 - 5
mobile/lib/modules/home/views/home_page.dart

@@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
 import 'package:easy_localization/easy_localization.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:fluttertoast/fluttertoast.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/album/providers/album.provider.dart';
 import 'package:immich_mobile/modules/album/services/album.service.dart';
@@ -14,6 +15,7 @@ import 'package:immich_mobile/modules/home/ui/profile_drawer/profile_drawer.dart
 import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
 import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
 import 'package:immich_mobile/routing/router.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/providers/asset.provider.dart';
 import 'package:immich_mobile/shared/providers/server_info.provider.dart';
 import 'package:immich_mobile/shared/providers/websocket.provider.dart';
@@ -31,7 +33,7 @@ class HomePage extends HookConsumerWidget {
     final multiselectEnabled = ref.watch(multiselectProvider.notifier);
     final selectionEnabledHook = useState(false);
 
-    final selection = useState(<AssetResponseDto>{});
+    final selection = useState(<Asset>{});
     final albums = ref.watch(albumProvider);
     final albumService = ref.watch(albumServiceProvider);
 
@@ -60,7 +62,7 @@ class HomePage extends HookConsumerWidget {
     Widget buildBody() {
       void selectionListener(
         bool multiselect,
-        Set<AssetResponseDto> selectedAssets,
+        Set<Asset> selectedAssets,
       ) {
         selectionEnabledHook.value = multiselect;
         selection.value = selectedAssets;
@@ -76,9 +78,27 @@ class HomePage extends HookConsumerWidget {
         selectionEnabledHook.value = false;
       }
 
+      Iterable<Asset> remoteOnlySelection() {
+        final Set<Asset> assets = selection.value;
+        final bool onlyRemote = assets.every((e) => e.isRemote);
+        if (!onlyRemote) {
+          ImmichToast.show(
+            context: context,
+            msg: "Can not add local assets to albums yet, skipping",
+            gravity: ToastGravity.BOTTOM,
+          );
+          return assets.where((a) => a.isRemote);
+        }
+        return assets;
+      }
+
       void onAddToAlbum(AlbumResponseDto album) async {
+        final Iterable<Asset> assets = remoteOnlySelection();
+        if (assets.isEmpty) {
+          return;
+        }
         final result = await albumService.addAdditionalAssetToAlbum(
-          selection.value,
+          assets,
           album.id,
         );
 
@@ -103,6 +123,7 @@ class HomePage extends HookConsumerWidget {
                   "added": result.successfullyAdded.toString(),
                 },
               ),
+              toastType: ToastType.success,
             );
           }
 
@@ -111,8 +132,11 @@ class HomePage extends HookConsumerWidget {
       }
 
       void onCreateNewAlbum() async {
-        final result =
-            await albumService.createAlbumWithGeneratedName(selection.value);
+        final Iterable<Asset> assets = remoteOnlySelection();
+        if (assets.isEmpty) {
+          return;
+        }
+        final result = await albumService.createAlbumWithGeneratedName(assets);
 
         if (result != null) {
           ref.watch(albumProvider.notifier).getAllAlbums();

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

@@ -1,13 +1,14 @@
 import 'dart:convert';
 
 import 'package:collection/collection.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:openapi/api.dart';
 
 class SearchResultPageState {
   final bool isLoading;
   final bool isSuccess;
   final bool isError;
-  final List<AssetResponseDto> searchResult;
+  final List<Asset> searchResult;
 
   SearchResultPageState({
     required this.isLoading,
@@ -20,7 +21,7 @@ class SearchResultPageState {
     bool? isLoading,
     bool? isSuccess,
     bool? isError,
-    List<AssetResponseDto>? searchResult,
+    List<Asset>? searchResult,
   }) {
     return SearchResultPageState(
       isLoading: isLoading ?? this.isLoading,
@@ -44,8 +45,9 @@ class SearchResultPageState {
       isLoading: map['isLoading'] ?? false,
       isSuccess: map['isSuccess'] ?? false,
       isError: map['isError'] ?? false,
-      searchResult: List<AssetResponseDto>.from(
-        map['searchResult']?.map((x) => AssetResponseDto.mapFromJson(x)),
+      searchResult: List<Asset>.from(
+        map['searchResult']
+            ?.map((x) => Asset.remote(AssetResponseDto.fromJson(x))),
       ),
     );
   }

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

@@ -6,8 +6,8 @@ import 'package:immich_mobile/modules/search/models/search_result_page_state.mod
 import 'package:immich_mobile/modules/search/services/search.service.dart';
 import 'package:immich_mobile/modules/settings/providers/app_settings.provider.dart';
 import 'package:immich_mobile/modules/settings/services/app_settings.service.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:intl/intl.dart';
-import 'package:openapi/api.dart';
 
 class SearchResultPageNotifier extends StateNotifier<SearchResultPageState> {
   SearchResultPageNotifier(this._searchService)
@@ -30,8 +30,9 @@ class SearchResultPageNotifier extends StateNotifier<SearchResultPageState> {
       isSuccess: false,
     );
 
-    List<AssetResponseDto>? assets =
-        await _searchService.searchAsset(searchTerm);
+    List<Asset>? assets = (await _searchService.searchAsset(searchTerm))
+        ?.map((e) => Asset.remote(e))
+        .toList();
 
     if (assets != null) {
       state = state.copyWith(
@@ -61,12 +62,11 @@ final searchResultGroupByDateTimeProvider = StateProvider((ref) {
   var assets = ref.watch(searchResultPageProvider).searchResult;
 
   assets.sortByCompare<DateTime>(
-    (e) => DateTime.parse(e.createdAt),
+    (e) => e.createdAt,
     (a, b) => b.compareTo(a),
   );
   return assets.groupListsBy(
-    (element) => DateFormat('y-MM-dd')
-        .format(DateTime.parse(element.createdAt).toLocal()),
+    (element) => DateFormat('y-MM-dd').format(element.createdAt.toLocal()),
   );
 });
 

+ 1 - 0
mobile/lib/routing/router.dart

@@ -22,6 +22,7 @@ import 'package:immich_mobile/modules/settings/views/settings_page.dart';
 import 'package:immich_mobile/routing/auth_guard.dart';
 import 'package:immich_mobile/modules/backup/views/backup_controller_page.dart';
 import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/providers/api.provider.dart';
 import 'package:immich_mobile/shared/services/api.service.dart';
 import 'package:immich_mobile/shared/views/splash_screen.dart';

+ 11 - 19
mobile/lib/routing/router.gr.dart

@@ -65,8 +65,7 @@ class _$AppRouter extends RootStackRouter {
       final args = routeData.argsAs<VideoViewerRouteArgs>();
       return MaterialPageX<dynamic>(
           routeData: routeData,
-          child: VideoViewerPage(
-              key: args.key, videoUrl: args.videoUrl, asset: args.asset));
+          child: VideoViewerPage(key: args.key, asset: args.asset));
     },
     BackupControllerRoute.name: (routeData) {
       return MaterialPageX<dynamic>(
@@ -258,9 +257,7 @@ class TabControllerRoute extends PageRouteInfo<void> {
 /// [GalleryViewerPage]
 class GalleryViewerRoute extends PageRouteInfo<GalleryViewerRouteArgs> {
   GalleryViewerRoute(
-      {Key? key,
-      required List<AssetResponseDto> assetList,
-      required AssetResponseDto asset})
+      {Key? key, required List<Asset> assetList, required Asset asset})
       : super(GalleryViewerRoute.name,
             path: '/gallery-viewer-page',
             args: GalleryViewerRouteArgs(
@@ -275,9 +272,9 @@ class GalleryViewerRouteArgs {
 
   final Key? key;
 
-  final List<AssetResponseDto> assetList;
+  final List<Asset> assetList;
 
-  final AssetResponseDto asset;
+  final Asset asset;
 
   @override
   String toString() {
@@ -291,7 +288,7 @@ class ImageViewerRoute extends PageRouteInfo<ImageViewerRouteArgs> {
   ImageViewerRoute(
       {Key? key,
       required String heroTag,
-      required AssetResponseDto asset,
+      required Asset asset,
       required String authToken,
       required void Function() isZoomedFunction,
       required ValueNotifier<bool> isZoomedListener,
@@ -324,7 +321,7 @@ class ImageViewerRouteArgs {
 
   final String heroTag;
 
-  final AssetResponseDto asset;
+  final Asset asset;
 
   final String authToken;
 
@@ -343,29 +340,24 @@ class ImageViewerRouteArgs {
 /// generated route for
 /// [VideoViewerPage]
 class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
-  VideoViewerRoute(
-      {Key? key, required String videoUrl, required AssetResponseDto asset})
+  VideoViewerRoute({Key? key, required Asset asset})
       : super(VideoViewerRoute.name,
             path: '/video-viewer-page',
-            args: VideoViewerRouteArgs(
-                key: key, videoUrl: videoUrl, asset: asset));
+            args: VideoViewerRouteArgs(key: key, asset: asset));
 
   static const String name = 'VideoViewerRoute';
 }
 
 class VideoViewerRouteArgs {
-  const VideoViewerRouteArgs(
-      {this.key, required this.videoUrl, required this.asset});
+  const VideoViewerRouteArgs({this.key, required this.asset});
 
   final Key? key;
 
-  final String videoUrl;
-
-  final AssetResponseDto asset;
+  final Asset asset;
 
   @override
   String toString() {
-    return 'VideoViewerRouteArgs{key: $key, videoUrl: $videoUrl, asset: $asset}';
+    return 'VideoViewerRouteArgs{key: $key, asset: $asset}';
   }
 }
 

+ 117 - 0
mobile/lib/shared/models/asset.dart

@@ -0,0 +1,117 @@
+import 'package:hive/hive.dart';
+import 'package:immich_mobile/constants/hive_box.dart';
+import 'package:openapi/api.dart';
+import 'package:photo_manager/photo_manager.dart';
+
+/// Asset (online or local)
+class Asset {
+  Asset.remote(this.remote) {
+    local = null;
+  }
+
+  Asset.local(this.local) {
+    remote = null;
+  }
+
+  late final AssetResponseDto? remote;
+  late final AssetEntity? local;
+
+  bool get isRemote => remote != null;
+  bool get isLocal => local != null;
+
+  String get deviceId =>
+      isRemote ? remote!.deviceId : Hive.box(userInfoBox).get(deviceIdKey);
+
+  String get deviceAssetId => isRemote ? remote!.deviceAssetId : local!.id;
+
+  String get id => isLocal ? local!.id : remote!.id;
+
+  double? get latitude =>
+      isLocal ? local!.latitude : remote!.exifInfo?.latitude?.toDouble();
+
+  double? get longitude =>
+      isLocal ? local!.longitude : remote!.exifInfo?.longitude?.toDouble();
+
+  DateTime get createdAt =>
+      isLocal ? local!.createDateTime : DateTime.parse(remote!.createdAt);
+
+  bool get isImage => isLocal
+      ? local!.type == AssetType.image
+      : remote!.type == AssetTypeEnum.IMAGE;
+
+  String get duration => isRemote
+      ? remote!.duration
+      : Duration(seconds: local!.duration).toString();
+
+  /// use only for tests
+  set createdAt(DateTime val) {
+    if (isRemote) {
+      remote!.createdAt = val.toIso8601String();
+    }
+  }
+
+  Map<String, dynamic> toJson() {
+    final json = <String, dynamic>{};
+    if (isLocal) {
+      json["local"] = _assetEntityToJson(local!);
+    } else {
+      json["remote"] = remote!.toJson();
+    }
+    return json;
+  }
+
+  static Asset? fromJson(dynamic value) {
+    if (value is Map) {
+      final json = value.cast<String, dynamic>();
+      final l = json["local"];
+      if (l != null) {
+        return Asset.local(_assetEntityFromJson(l));
+      } else {
+        return Asset.remote(AssetResponseDto.fromJson(json["remote"]));
+      }
+    }
+    return null;
+  }
+}
+
+Map<String, dynamic> _assetEntityToJson(AssetEntity a) {
+  final json = <String, dynamic>{};
+  json["id"] = a.id;
+  json["typeInt"] = a.typeInt;
+  json["width"] = a.width;
+  json["height"] = a.height;
+  json["duration"] = a.duration;
+  json["orientation"] = a.orientation;
+  json["isFavorite"] = a.isFavorite;
+  json["title"] = a.title;
+  json["createDateSecond"] = a.createDateSecond;
+  json["modifiedDateSecond"] = a.modifiedDateSecond;
+  json["latitude"] = a.latitude;
+  json["longitude"] = a.longitude;
+  json["mimeType"] = a.mimeType;
+  json["subtype"] = a.subtype;
+  return json;
+}
+
+AssetEntity? _assetEntityFromJson(dynamic value) {
+  if (value is Map) {
+    final json = value.cast<String, dynamic>();
+    return AssetEntity(
+      id: json["id"],
+      typeInt: json["typeInt"],
+      width: json["width"],
+      height: json["height"],
+      duration: json["duration"],
+      orientation: json["orientation"],
+      isFavorite: json["isFavorite"],
+      title: json["title"],
+      createDateSecond: json["createDateSecond"],
+      modifiedDateSecond: json["modifiedDateSecond"],
+      latitude: json["latitude"],
+      longitude: json["longitude"],
+      mimeType: json["mimeType"],
+      subtype: json["subtype"],
+    );
+  }
+  return null;
+}

+ 101 - 54
mobile/lib/shared/providers/asset.provider.dart

@@ -1,18 +1,23 @@
+import 'dart:collection';
+
 import 'package:flutter/foundation.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/home/services/asset.service.dart';
 import 'package:immich_mobile/modules/home/services/asset_cache.service.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/services/device_info.service.dart';
 import 'package:collection/collection.dart';
 import 'package:intl/intl.dart';
 import 'package:openapi/api.dart';
 import 'package:photo_manager/photo_manager.dart';
 
-class AssetNotifier extends StateNotifier<List<AssetResponseDto>> {
+class AssetNotifier extends StateNotifier<List<Asset>> {
   final AssetService _assetService;
   final AssetCacheService _assetCacheService;
 
   final DeviceInfoService _deviceInfoService = DeviceInfoService();
+  bool _getAllAssetInProgress = false;
+  bool _deleteInProgress = false;
 
   AssetNotifier(this._assetService, this._assetCacheService) : super([]);
 
@@ -21,29 +26,38 @@ class AssetNotifier extends StateNotifier<List<AssetResponseDto>> {
   }
 
   getAllAsset() async {
+    if (_getAllAssetInProgress || _deleteInProgress) {
+      // guard against multiple calls to this method while it's still working
+      return;
+    }
     final stopwatch = Stopwatch();
+    try {
+      _getAllAssetInProgress = true;
+
+      final bool isCacheValid = await _assetCacheService.isValid();
+      if (isCacheValid && state.isEmpty) {
+        stopwatch.start();
+        state = await _assetCacheService.get();
+        debugPrint(
+            "Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms");
+        stopwatch.reset();
+      }
 
-
-    if (await _assetCacheService.isValid() && state.isEmpty) {
       stopwatch.start();
-      state = await _assetCacheService.get();
-      debugPrint("Reading assets from cache: ${stopwatch.elapsedMilliseconds}ms");
+      var allAssets = await _assetService.getAllAsset(urgent: !isCacheValid);
+      debugPrint("Query assets from API: ${stopwatch.elapsedMilliseconds}ms");
       stopwatch.reset();
+
+      state = allAssets;
+    } finally {
+      _getAllAssetInProgress = false;
     }
+    debugPrint("[getAllAsset] setting new asset state");
 
     stopwatch.start();
-    var allAssets = await _assetService.getAllAsset();
-    debugPrint("Query assets from API: ${stopwatch.elapsedMilliseconds}ms");
+    _cacheState();
+    debugPrint("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms");
     stopwatch.reset();
-
-    if (allAssets != null) {
-      state = allAssets;
-
-      stopwatch.start();
-      _cacheState();
-      debugPrint("Store assets in cache: ${stopwatch.elapsedMilliseconds}ms");
-      stopwatch.reset();
-    }
   }
 
   clearAllAsset() {
@@ -52,80 +66,113 @@ class AssetNotifier extends StateNotifier<List<AssetResponseDto>> {
   }
 
   onNewAssetUploaded(AssetResponseDto newAsset) {
-    state = [...state, newAsset];
+    final int i = state.indexWhere(
+      (a) =>
+          a.isRemote ||
+          (a.id == newAsset.deviceAssetId && a.deviceId == newAsset.deviceId),
+    );
+
+    if (i == -1 || state[i].deviceAssetId != newAsset.deviceAssetId) {
+      state = [...state, Asset.remote(newAsset)];
+    } else {
+      // order is important to keep all local-only assets at the beginning!
+      state = [
+        ...state.slice(0, i),
+        ...state.slice(i + 1),
+        Asset.remote(newAsset),
+      ];
+      // TODO here is a place to unify local/remote assets by replacing the
+      // local-only asset in the state with a local&remote asset
+    }
     _cacheState();
   }
 
-  deleteAssets(Set<AssetResponseDto> deleteAssets) async {
+  deleteAssets(Set<Asset> deleteAssets) async {
+    _deleteInProgress = true;
+    try {
+      final localDeleted = await _deleteLocalAssets(deleteAssets);
+      final remoteDeleted = await _deleteRemoteAssets(deleteAssets);
+      final Set<String> deleted = HashSet();
+      deleted.addAll(localDeleted);
+      deleted.addAll(remoteDeleted);
+      if (deleted.isNotEmpty) {
+        state = state.where((a) => !deleted.contains(a.id)).toList();
+        _cacheState();
+      }
+    } finally {
+      _deleteInProgress = false;
+    }
+  }
+
+  Future<List<String>> _deleteLocalAssets(Set<Asset> assetsToDelete) async {
     var deviceInfo = await _deviceInfoService.getDeviceInfo();
     var deviceId = deviceInfo["deviceId"];
-    var deleteIdList = <String>[];
+    final List<String> local = [];
     // Delete asset from device
-    for (var asset in deleteAssets) {
-      // Delete asset on device if present
-      if (asset.deviceId == deviceId) {
+    for (final Asset asset in assetsToDelete) {
+      if (asset.isLocal) {
+        local.add(asset.id);
+      } else if (asset.deviceId == deviceId) {
+        // Delete asset on device if it is still present
         var localAsset = await AssetEntity.fromId(asset.deviceAssetId);
-
         if (localAsset != null) {
-          deleteIdList.add(localAsset.id);
+          local.add(localAsset.id);
         }
       }
     }
-
-    try {
-      await PhotoManager.editor.deleteWithIds(deleteIdList);
-    } catch (e) {
-      debugPrint("Delete asset from device failed: $e");
-    }
-
-    // Delete asset on server
-    List<DeleteAssetResponseDto>? deleteAssetResult =
-        await _assetService.deleteAssets(deleteAssets);
-
-    if (deleteAssetResult == null) {
-      return;
-    }
-
-    for (var asset in deleteAssetResult) {
-      if (asset.status == DeleteAssetStatus.SUCCESS) {
-        state =
-            state.where((immichAsset) => immichAsset.id != asset.id).toList();
+    if (local.isNotEmpty) {
+      try {
+        return await PhotoManager.editor.deleteWithIds(local);
+      } catch (e) {
+        debugPrint("Delete asset from device failed: $e");
       }
     }
+    return [];
+  }
 
-    _cacheState();
+  Future<Iterable<String>> _deleteRemoteAssets(
+    Set<Asset> assetsToDelete,
+  ) async {
+    final Iterable<AssetResponseDto> remote =
+        assetsToDelete.where((e) => e.isRemote).map((e) => e.remote!);
+    final List<DeleteAssetResponseDto> deleteAssetResult =
+        await _assetService.deleteAssets(remote) ?? [];
+    return deleteAssetResult
+        .where((a) => a.status == DeleteAssetStatus.SUCCESS)
+        .map((a) => a.id);
   }
 }
 
-final assetProvider =
-    StateNotifierProvider<AssetNotifier, List<AssetResponseDto>>((ref) {
+final assetProvider = StateNotifierProvider<AssetNotifier, List<Asset>>((ref) {
   return AssetNotifier(
       ref.watch(assetServiceProvider), ref.watch(assetCacheServiceProvider));
 });
 
 final assetGroupByDateTimeProvider = StateProvider((ref) {
-  var assets = ref.watch(assetProvider);
+  final assets = ref.watch(assetProvider).toList();
+  // `toList()` ist needed to make a copy as to NOT sort the original list/state
 
   assets.sortByCompare<DateTime>(
-    (e) => DateTime.parse(e.createdAt),
+    (e) => e.createdAt,
     (a, b) => b.compareTo(a),
   );
   return assets.groupListsBy(
-    (element) => DateFormat('y-MM-dd')
-        .format(DateTime.parse(element.createdAt).toLocal()),
+    (element) => DateFormat('y-MM-dd').format(element.createdAt.toLocal()),
   );
 });
 
 final assetGroupByMonthYearProvider = StateProvider((ref) {
-  var assets = ref.watch(assetProvider);
+  // TODO: remove `where` once temporary workaround is no longer needed (to only
+  // allow remote assets to be added to album). Keep `toList()` as to NOT sort
+  // the original list/state
+  final assets = ref.watch(assetProvider).where((e) => e.isRemote).toList();
 
   assets.sortByCompare<DateTime>(
-    (e) => DateTime.parse(e.createdAt),
+    (e) => e.createdAt,
     (a, b) => b.compareTo(a),
   );
 
   return assets.groupListsBy(
-    (element) => DateFormat('MMMM, y')
-        .format(DateTime.parse(element.createdAt).toLocal()),
+    (element) => DateFormat('MMMM, y').format(element.createdAt.toLocal()),
   );
 });

+ 20 - 18
mobile/lib/shared/services/share.service.dart

@@ -2,11 +2,11 @@ import 'dart:io';
 
 import 'package:flutter/material.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:immich_mobile/shared/providers/api.provider.dart';
-import 'package:openapi/api.dart';
+import 'package:path/path.dart';
 import 'package:path_provider/path_provider.dart';
 import 'package:share_plus/share_plus.dart';
-import 'package:path/path.dart' as p;
 import 'api.service.dart';
 
 final shareServiceProvider =
@@ -17,26 +17,28 @@ class ShareService {
 
   ShareService(this._apiService);
 
-  Future<void> shareAsset(AssetResponseDto asset) async {
+  Future<void> shareAsset(Asset asset) async {
     await shareAssets([asset]);
   }
 
-  Future<void> shareAssets(List<AssetResponseDto> assets) async {
+  Future<void> shareAssets(List<Asset> assets) async {
     final downloadedFilePaths = assets.map((asset) async {
-      final res = await _apiService.assetApi.downloadFileWithHttpInfo(
-        asset.deviceAssetId,
-        asset.deviceId,
-        isThumb: false,
-        isWeb: false,
-      );
-
-      final fileName = p.basename(asset.originalPath);
-
-      final tempDir = await getTemporaryDirectory();
-      final tempFile = await File('${tempDir.path}/$fileName').create();
-      tempFile.writeAsBytesSync(res.bodyBytes);
-
-      return tempFile.path;
+      if (asset.isRemote) {
+        final tempDir = await getTemporaryDirectory();
+        final fileName = basename(asset.remote!.originalPath);
+        final tempFile = await File('${tempDir.path}/$fileName').create();
+        final res = await _apiService.assetApi.downloadFileWithHttpInfo(
+          asset.remote!.deviceAssetId,
+          asset.remote!.deviceId,
+          isThumb: false,
+          isWeb: false,
+        );
+        tempFile.writeAsBytesSync(res.bodyBytes);
+        return tempFile.path;
+      } else {
+        File? f = await asset.local!.file;
+        return f!.path;
+      }
     });
 
     Share.shareFiles(

+ 96 - 0
mobile/lib/shared/ui/immich_image.dart

@@ -0,0 +1,96 @@
+import 'package:cached_network_image/cached_network_image.dart';
+import 'package:flutter/material.dart';
+import 'package:hive_flutter/hive_flutter.dart';
+import 'package:immich_mobile/constants/hive_box.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
+import 'package:immich_mobile/utils/image_url_builder.dart';
+import 'package:photo_manager/photo_manager.dart';
+
+/// Renders an Asset using local data if available, else remote data
+class ImmichImage extends StatelessWidget {
+  const ImmichImage(
+    this.asset, {
+    required this.width,
+    required this.height,
+    this.useGrayBoxPlaceholder = false,
+    super.key,
+  });
+  final Asset asset;
+  final bool useGrayBoxPlaceholder;
+  final double width;
+  final double height;
+
+  @override
+  Widget build(BuildContext context) {
+    if (asset.isLocal) {
+      return Image(
+        image: AssetEntityImageProvider(
+          asset.local!,
+          isOriginal: false,
+          thumbnailSize: const ThumbnailSize.square(250), // like server thumbs
+        ),
+        width: width,
+        height: height,
+        fit: BoxFit.cover,
+        frameBuilder: (context, child, frame, wasSynchronouslyLoaded) {
+          if (wasSynchronouslyLoaded || frame != null) {
+            return child;
+          }
+          return (useGrayBoxPlaceholder
+              ? const SizedBox.square(
+                  dimension: 250,
+                  child: DecoratedBox(
+                    decoration: BoxDecoration(color: Colors.grey),
+                  ),
+                )
+              : Transform.scale(
+                  scale: 0.2,
+                  child: const CircularProgressIndicator(),
+                ));
+        },
+        errorBuilder: (context, error, stackTrace) {
+          debugPrint("Error getting thumb for assetId=${asset.id}: $error");
+          return Icon(
+            Icons.image_not_supported_outlined,
+            color: Theme.of(context).primaryColor,
+          );
+        },
+      );
+    }
+    final String token = Hive.box(userInfoBox).get(accessTokenKey);
+    final String thumbnailRequestUrl = getThumbnailUrl(asset.remote!);
+    return CachedNetworkImage(
+      imageUrl: thumbnailRequestUrl,
+      httpHeaders: {"Authorization": "Bearer $token"},
+      cacheKey: 'thumbnail-image-${asset.id}',
+      width: width,
+      height: height,
+      // keeping memCacheWidth, memCacheHeight, maxWidthDiskCache and
+      // maxHeightDiskCache = null allows to simply store the webp thumbnail
+      // from the server and use it for all rendered thumbnail sizes
+      fit: BoxFit.cover,
+      fadeInDuration: const Duration(milliseconds: 250),
+      progressIndicatorBuilder: (context, url, downloadProgress) {
+        if (useGrayBoxPlaceholder) {
+          return const DecoratedBox(
+            decoration: BoxDecoration(color: Colors.grey),
+          );
+        }
+        return Transform.scale(
+          scale: 0.2,
+          child: CircularProgressIndicator(
+            value: downloadProgress.progress,
+          ),
+        );
+      },
+      errorWidget: (context, url, error) {
+        debugPrint("Error getting thumbnail $url = $error");
+        CachedNetworkImage.evictFromCache(thumbnailRequestUrl);
+        return Icon(
+          Icons.image_not_supported_outlined,
+          color: Theme.of(context).primaryColor,
+        );
+      },
+    );
+  }
+}

+ 27 - 22
mobile/test/asset_grid_data_structure_test.dart

@@ -1,9 +1,10 @@
 import 'package:flutter_test/flutter_test.dart';
 import 'package:immich_mobile/modules/home/ui/asset_grid/asset_grid_data_structure.dart';
+import 'package:immich_mobile/shared/models/asset.dart';
 import 'package:openapi/api.dart';
 
 void main() {
-  final List<AssetResponseDto> testAssets = [];
+  final List<Asset> testAssets = [];
 
   for (int i = 0; i < 150; i++) {
     int month = i ~/ 31;
@@ -11,39 +12,43 @@ void main() {
 
     DateTime date = DateTime(2022, month, day);
 
-    testAssets.add(AssetResponseDto(
-      type: AssetTypeEnum.IMAGE,
-      id: '$i',
-      deviceAssetId: '',
-      ownerId: '',
-      deviceId: '',
-      originalPath: '',
-      resizePath: '',
-      createdAt: date.toIso8601String(),
-      modifiedAt: date.toIso8601String(),
-      isFavorite: false,
-      mimeType: 'image/jpeg',
-      duration: '',
-      webpPath: '',
-      encodedVideoPath: '',
-    ));
+    testAssets.add(
+      Asset.remote(
+        AssetResponseDto(
+          type: AssetTypeEnum.IMAGE,
+          id: '$i',
+          deviceAssetId: '',
+          ownerId: '',
+          deviceId: '',
+          originalPath: '',
+          resizePath: '',
+          createdAt: date.toIso8601String(),
+          modifiedAt: date.toIso8601String(),
+          isFavorite: false,
+          mimeType: 'image/jpeg',
+          duration: '',
+          webpPath: '',
+          encodedVideoPath: '',
+        ),
+      ),
+    );
   }
 
-  final Map<String, List<AssetResponseDto>> groups = {
+  final Map<String, List<Asset>> groups = {
     '2022-01-05': testAssets.sublist(0, 5).map((e) {
-      e.createdAt = DateTime(2022, 1, 5).toIso8601String();
+      e.createdAt = DateTime(2022, 1, 5);
       return e;
     }).toList(),
     '2022-01-10': testAssets.sublist(5, 10).map((e) {
-      e.createdAt = DateTime(2022, 1, 10).toIso8601String();
+      e.createdAt = DateTime(2022, 1, 10);
       return e;
     }).toList(),
     '2022-02-17': testAssets.sublist(10, 15).map((e) {
-      e.createdAt = DateTime(2022, 2, 17).toIso8601String();
+      e.createdAt = DateTime(2022, 2, 17);
       return e;
     }).toList(),
     '2022-10-15': testAssets.sublist(15, 30).map((e) {
-      e.createdAt = DateTime(2022, 10, 15).toIso8601String();
+      e.createdAt = DateTime(2022, 10, 15);
       return e;
     }).toList()
   };