Browse Source

Download asset to local and error fixing (#100)

* Update photo_manager pub package
* Added download endpoint for assets
* Successfully save a photo to the local device's gallery
* Save save a video to the local device's gallery
* Fixed #97
* Added download loading indicator
* Refactor and increase the font size for curated search thumbnail images
* Reposition loading animation on the search result page
Alex 3 năm trước cách đây
mục cha
commit
90ef64efa3
34 tập tin đã thay đổi với 538 bổ sung257 xóa
  1. 4 1
      Makefile
  2. 2 1
      docker/docker-compose.yml
  3. 1 0
      microservices/src/image-classifier/image-classifier.service.ts
  4. 1 0
      microservices/src/object-detection/object-detection.service.ts
  5. 1 1
      mobile/android/app/build.gradle
  6. 3 0
      mobile/android/app/src/main/AndroidManifest.xml
  7. 2 2
      mobile/ios/Podfile.lock
  8. 69 63
      mobile/ios/Runner/Info.plist
  9. 1 0
      mobile/lib/main.dart
  10. 17 11
      mobile/lib/modules/asset_viewer/models/image_viewer_page_state.model.dart
  11. 6 0
      mobile/lib/modules/asset_viewer/models/request_download_asset_info.model.dart
  12. 0 0
      mobile/lib/modules/asset_viewer/models/store_model_here.txt
  13. 32 10
      mobile/lib/modules/asset_viewer/providers/image_viewer_page_state.provider.dart
  14. 50 0
      mobile/lib/modules/asset_viewer/services/image_viewer.service.dart
  15. 0 0
      mobile/lib/modules/asset_viewer/services/store_services_here.txt
  16. 24 0
      mobile/lib/modules/asset_viewer/ui/download_loading_indicator.dart
  17. 9 4
      mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart
  18. 67 51
      mobile/lib/modules/asset_viewer/views/image_viewer_page.dart
  19. 54 15
      mobile/lib/modules/asset_viewer/views/video_viewer_page.dart
  20. 2 2
      mobile/lib/modules/home/ui/thumbnail_image.dart
  21. 4 3
      mobile/lib/modules/login/ui/login_form.dart
  22. 67 0
      mobile/lib/modules/search/ui/thumbnail_with_info.dart
  23. 7 70
      mobile/lib/modules/search/views/search_page.dart
  24. 5 1
      mobile/lib/modules/search/views/search_result_page.dart
  25. 1 1
      mobile/lib/routing/router.dart
  26. 11 5
      mobile/lib/routing/router.gr.dart
  27. 19 3
      mobile/lib/shared/models/immich_asset.model.dart
  28. 1 1
      mobile/lib/shared/services/backup.service.dart
  29. 25 4
      mobile/lib/shared/services/network.service.dart
  30. 16 4
      mobile/lib/shared/ui/immich_toast.dart
  31. 8 1
      mobile/pubspec.lock
  32. 3 3
      mobile/pubspec.yaml
  33. 9 0
      server/src/api-v1/asset/asset.controller.ts
  34. 17 0
      server/src/api-v1/asset/asset.service.ts

+ 4 - 1
Makefile

@@ -8,4 +8,7 @@ dev-scale:
 	docker-compose -f ./docker/docker-compose.dev.yml up --build -V  --scale immich_server=3 --remove-orphans 
 
 prod:
-	docker-compose -f ./docker/docker-compose.yml up --build -V --remove-orphans
+	docker-compose -f ./docker/docker-compose.yml up --build -V --remove-orphans
+
+prod-scale:
+	docker-compose -f ./docker/docker-compose.yml up --build -V --scale immich_server=3 --scale immich_microservices=3 --remove-orphans

+ 2 - 1
docker/docker-compose.yml

@@ -22,6 +22,7 @@ services:
       - database
     networks:
       - immich_network
+    restart: unless-stopped
 
   immich_microservices:
     image: immich-microservices:1.4.0
@@ -43,7 +44,7 @@ services:
       - database
     networks:
       - immich_network
-
+    restart: unless-stopped
 
   redis:
     container_name: immich_redis

+ 1 - 0
microservices/src/image-classifier/image-classifier.service.ts

@@ -39,6 +39,7 @@ export class ImageClassifierService {
           }
         }
 
+        tf.dispose(decodedImage);
         return tags;
       }
     } catch (e) {

+ 1 - 0
microservices/src/object-detection/object-detection.service.ts

@@ -29,6 +29,7 @@ export class ObjectDetectionService {
           }
         }
 
+        tf.dispose(decodedImage);
         return [...tags];
       }
     } catch (e) {

+ 1 - 1
mobile/android/app/build.gradle

@@ -51,7 +51,7 @@ android {
     defaultConfig {
         // TODO: Specify your own unique Application ID (https://developer.android.com/studio/build/application-id.html).
         applicationId "app.alextran.immich"
-        minSdkVersion 20
+        minSdkVersion 21
         targetSdkVersion flutter.targetSdkVersion
         versionCode flutterVersionCode.toInteger()
         versionName flutterVersionName

+ 3 - 0
mobile/android/app/src/main/AndroidManifest.xml

@@ -20,4 +20,7 @@
   </application>
   <uses-permission android:name="android.permission.INTERNET" />
   <uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
+  <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" />
+  <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" />
+  <uses-permission android:name="android.permission.ACCESS_MEDIA_LOCATION" />
 </manifest>

+ 2 - 2
mobile/ios/Podfile.lock

@@ -13,7 +13,7 @@ PODS:
     - Flutter
   - path_provider_ios (0.0.1):
     - Flutter
-  - photo_manager (1.0.0):
+  - photo_manager (2.0.0):
     - Flutter
     - FlutterMacOS
   - SAMKeychain (1.5.3)
@@ -70,7 +70,7 @@ SPEC CHECKSUMS:
   FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a
   package_info_plus: 6c92f08e1f853dc01228d6f553146438dafcd14e
   path_provider_ios: 7d7ce634493af4477d156294792024ec3485acd5
-  photo_manager: 84fa94fbeb82e607333ea9a13c43b58e0903a463
+  photo_manager: 4f6810b7dfc4feb03b461ac1a70dacf91fba7604
   SAMKeychain: 483e1c9f32984d50ca961e26818a534283b4cd5c
   sqflite: 6d358c025f5b867b29ed92fc697fd34924e11904
   Toast: 91b396c56ee72a5790816f40d3a94dd357abc196

+ 69 - 63
mobile/ios/Runner/Info.plist

@@ -1,66 +1,72 @@
 <?xml version="1.0" encoding="UTF-8"?>
 <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
 <plist version="1.0">
-<dict>
-	<key>CFBundleDevelopmentRegion</key>
-	<string>$(DEVELOPMENT_LANGUAGE)</string>
-	<key>CFBundleDisplayName</key>
-	<string>Immich</string>
-	<key>CFBundleExecutable</key>
-	<string>$(EXECUTABLE_NAME)</string>
-	<key>CFBundleIdentifier</key>
-	<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
-	<key>CFBundleInfoDictionaryVersion</key>
-	<string>6.0</string>
-	<key>CFBundleName</key>
-	<string>immich_mobile</string>
-	<key>CFBundlePackageType</key>
-	<string>APPL</string>
-	<key>CFBundleShortVersionString</key>
-	<string>$(FLUTTER_BUILD_NAME)</string>
-	<key>CFBundleSignature</key>
-	<string>????</string>
-	<key>CFBundleVersion</key>
-	<string>2</string>
-	<key>LSRequiresIPhoneOS</key>
-	<true/>
-	<key>MGLMapboxMetricsEnabledSettingShownInApp</key>
-	<true/>
-	<key>NSAppTransportSecurity</key>
-	<dict>
-		<key>NSAllowsArbitraryLoads</key>
-		<true/>
-	</dict>
-	<key>NSLocationAlwaysUsageDescription</key>
-	<string>Enable location setting to show position of assets on map</string>
-	<key>NSLocationWhenInUseUsageDescription</key>
-	<string>Enable location setting to show position of assets on map</string>
-	<key>NSPhotoLibraryUsageDescription</key>
-	<string>We need to manage backup your photos album</string>
-	<key>UILaunchStoryboardName</key>
-	<string>LaunchScreen</string>
-	<key>UIMainStoryboardFile</key>
-	<string>Main</string>
-	<key>UISupportedInterfaceOrientations</key>
-	<array>
-		<string>UIInterfaceOrientationPortrait</string>
-		<string>UIInterfaceOrientationLandscapeLeft</string>
-		<string>UIInterfaceOrientationLandscapeRight</string>
-	</array>
-	<key>UISupportedInterfaceOrientations~ipad</key>
-	<array>
-		<string>UIInterfaceOrientationPortrait</string>
-		<string>UIInterfaceOrientationPortraitUpsideDown</string>
-		<string>UIInterfaceOrientationLandscapeLeft</string>
-		<string>UIInterfaceOrientationLandscapeRight</string>
-	</array>
-	<key>UIUserInterfaceStyle</key>
-	<string>Light</string>
-	<key>UIViewControllerBasedStatusBarAppearance</key>
-	<true/>
-	<key>io.flutter.embedded_views_preview</key>
-	<true/>
-	<key>ITSAppUsesNonExemptEncryption</key>
-	<false/>
-</dict>
-</plist>
+  <dict>
+    <key>CFBundleDevelopmentRegion</key>
+    <string>$(DEVELOPMENT_LANGUAGE)</string>
+    <key>CFBundleDisplayName</key>
+    <string>Immich</string>
+    <key>CFBundleExecutable</key>
+    <string>$(EXECUTABLE_NAME)</string>
+    <key>CFBundleIdentifier</key>
+    <string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+    <key>CFBundleInfoDictionaryVersion</key>
+    <string>6.0</string>
+    <key>CFBundleName</key>
+    <string>immich_mobile</string>
+    <key>CFBundlePackageType</key>
+    <string>APPL</string>
+    <key>CFBundleShortVersionString</key>
+    <string>$(FLUTTER_BUILD_NAME)</string>
+    <key>CFBundleSignature</key>
+    <string>????</string>
+    <key>CFBundleVersion</key>
+    <string>2</string>
+    <key>LSRequiresIPhoneOS</key>
+    <true />
+    <key>MGLMapboxMetricsEnabledSettingShownInApp</key>
+    <true />
+    <key>NSAppTransportSecurity</key>
+    <dict>
+      <key>NSAllowsArbitraryLoads</key>
+      <true />
+    </dict>
+    <key>NSLocationAlwaysUsageDescription</key>
+    <string>Enable location setting to show position of assets on map</string>
+
+    <key>NSLocationWhenInUseUsageDescription</key>
+    <string>Enable location setting to show position of assets on map</string>
+
+    <key>NSPhotoLibraryUsageDescription</key>
+    <string>We need to manage backup your photos album</string>
+
+    <key>NSPhotoLibraryAddUsageDescription</key>
+    <string>We need to manage backup your photos album</string>
+
+    <key>UILaunchStoryboardName</key>
+    <string>LaunchScreen</string>
+    <key>UIMainStoryboardFile</key>
+    <string>Main</string>
+    <key>UISupportedInterfaceOrientations</key>
+    <array>
+      <string>UIInterfaceOrientationPortrait</string>
+      <string>UIInterfaceOrientationLandscapeLeft</string>
+      <string>UIInterfaceOrientationLandscapeRight</string>
+    </array>
+    <key>UISupportedInterfaceOrientations~ipad</key>
+    <array>
+      <string>UIInterfaceOrientationPortrait</string>
+      <string>UIInterfaceOrientationPortraitUpsideDown</string>
+      <string>UIInterfaceOrientationLandscapeLeft</string>
+      <string>UIInterfaceOrientationLandscapeRight</string>
+    </array>
+    <key>UIUserInterfaceStyle</key>
+    <string>Light</string>
+    <key>UIViewControllerBasedStatusBarAppearance</key>
+    <true />
+    <key>io.flutter.embedded_views_preview</key>
+    <true />
+    <key>ITSAppUsesNonExemptEncryption</key>
+    <false />
+  </dict>
+</plist>

+ 1 - 0
mobile/lib/main.dart

@@ -97,6 +97,7 @@ class _ImmichAppState extends ConsumerState<ImmichApp> with WidgetsBindingObserv
         textTheme: GoogleFonts.workSansTextTheme(
           Theme.of(context).textTheme.apply(fontSizeFactor: 1.0),
         ),
+        snackBarTheme: SnackBarThemeData(contentTextStyle: TextStyle(fontFamily: GoogleFonts.workSans().fontFamily)),
         scaffoldBackgroundColor: const Color(0xFFf6f8fe),
         appBarTheme: const AppBarTheme(
           backgroundColor: Colors.white,

+ 17 - 11
mobile/lib/modules/asset_viewer/models/image_viewer_page_state.model.dart

@@ -1,28 +1,34 @@
 import 'dart:convert';
 
+enum DownloadAssetStatus { idle, loading, success, error }
+
 class ImageViewerPageState {
-  final bool isBottomSheetEnable;
+  // enum
+  final DownloadAssetStatus downloadAssetStatus;
+
   ImageViewerPageState({
-    required this.isBottomSheetEnable,
+    required this.downloadAssetStatus,
   });
 
   ImageViewerPageState copyWith({
-    bool? isBottomSheetEnable,
+    DownloadAssetStatus? downloadAssetStatus,
   }) {
     return ImageViewerPageState(
-      isBottomSheetEnable: isBottomSheetEnable ?? this.isBottomSheetEnable,
+      downloadAssetStatus: downloadAssetStatus ?? this.downloadAssetStatus,
     );
   }
 
   Map<String, dynamic> toMap() {
-    return {
-      'isBottomSheetEnable': isBottomSheetEnable,
-    };
+    final result = <String, dynamic>{};
+
+    result.addAll({'downloadAssetStatus': downloadAssetStatus.index});
+
+    return result;
   }
 
   factory ImageViewerPageState.fromMap(Map<String, dynamic> map) {
     return ImageViewerPageState(
-      isBottomSheetEnable: map['isBottomSheetEnable'] ?? false,
+      downloadAssetStatus: DownloadAssetStatus.values[map['downloadAssetStatus'] ?? 0],
     );
   }
 
@@ -31,15 +37,15 @@ class ImageViewerPageState {
   factory ImageViewerPageState.fromJson(String source) => ImageViewerPageState.fromMap(json.decode(source));
 
   @override
-  String toString() => 'ImageViewerPageState(isBottomSheetEnable: $isBottomSheetEnable)';
+  String toString() => 'ImageViewerPageState(downloadAssetStatus: $downloadAssetStatus)';
 
   @override
   bool operator ==(Object other) {
     if (identical(this, other)) return true;
 
-    return other is ImageViewerPageState && other.isBottomSheetEnable == isBottomSheetEnable;
+    return other is ImageViewerPageState && other.downloadAssetStatus == downloadAssetStatus;
   }
 
   @override
-  int get hashCode => isBottomSheetEnable.hashCode;
+  int get hashCode => downloadAssetStatus.hashCode;
 }

+ 6 - 0
mobile/lib/modules/asset_viewer/models/request_download_asset_info.model.dart

@@ -0,0 +1,6 @@
+class RequestDownloadAssetInfo {
+  final String assetId;
+  final String deviceId;
+
+  RequestDownloadAssetInfo(this.assetId, this.deviceId);
+}

+ 0 - 0
mobile/lib/modules/asset_viewer/models/store_model_here.txt


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

@@ -1,21 +1,43 @@
+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/home/models/home_page_state.model.dart';
+import 'package:immich_mobile/modules/asset_viewer/services/image_viewer.service.dart';
 import 'package:immich_mobile/shared/models/immich_asset.model.dart';
+import 'package:immich_mobile/shared/ui/immich_toast.dart';
 
-class ImageViewerPageStateNotifier extends StateNotifier<ImageViewerPageState> {
-  ImageViewerPageStateNotifier() : super(ImageViewerPageState(isBottomSheetEnable: false));
+class ImageViewerStateNotifier extends StateNotifier<ImageViewerPageState> {
+  final ImageViewerService _imageViewerService = ImageViewerService();
 
-  void toggleBottomSheet() {
-    bool isBottomSheetEnable = state.isBottomSheetEnable;
+  ImageViewerStateNotifier() : super(ImageViewerPageState(downloadAssetStatus: DownloadAssetStatus.idle));
 
-    if (isBottomSheetEnable) {
-      state.copyWith(isBottomSheetEnable: false);
+  void downloadAsset(ImmichAsset asset, BuildContext context) async {
+    state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.loading);
+
+    bool isSuccess = await _imageViewerService.downloadAssetToDevice(asset);
+
+    if (isSuccess) {
+      state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.success);
+
+      ImmichToast.show(
+        context: context,
+        msg: "Download Success",
+        toastType: ToastType.success,
+        gravity: ToastGravity.BOTTOM,
+      );
     } else {
-      state.copyWith(isBottomSheetEnable: true);
+      state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.error);
+      ImmichToast.show(
+        context: context,
+        msg: "Download Error",
+        toastType: ToastType.error,
+        gravity: ToastGravity.BOTTOM,
+      );
     }
+
+    state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.idle);
   }
 }
 
-final homePageStateProvider = StateNotifierProvider<ImageViewerPageStateNotifier, ImageViewerPageState>(
-    ((ref) => ImageViewerPageStateNotifier()));
+final imageViewerStateProvider =
+    StateNotifierProvider<ImageViewerStateNotifier, ImageViewerPageState>(((ref) => ImageViewerStateNotifier()));

+ 50 - 0
mobile/lib/modules/asset_viewer/services/image_viewer.service.dart

@@ -0,0 +1,50 @@
+import 'dart:io';
+
+import 'package:flutter/foundation.dart';
+import 'package:hive_flutter/hive_flutter.dart';
+import 'package:immich_mobile/constants/hive_box.dart';
+import 'package:immich_mobile/shared/models/immich_asset.model.dart';
+import 'package:path/path.dart' as p;
+import 'package:http/http.dart' as http;
+
+import 'package:photo_manager/photo_manager.dart';
+import 'package:path_provider/path_provider.dart';
+
+class ImageViewerService {
+  Future<bool> downloadAssetToDevice(ImmichAsset asset) async {
+    try {
+      String fileName = p.basename(asset.originalPath);
+      var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
+      Uri filePath =
+          Uri.parse("$savedEndpoint/asset/download?aid=${asset.deviceAssetId}&did=${asset.deviceId}&isThumb=false");
+
+      var res = await http.get(
+        filePath,
+        headers: {"Authorization": "Bearer ${Hive.box(userInfoBox).get(accessTokenKey)}"},
+      );
+
+      final AssetEntity? entity;
+
+      if (asset.type == 'IMAGE') {
+        entity = await PhotoManager.editor.saveImage(
+          res.bodyBytes,
+          title: p.basename(asset.originalPath),
+        );
+      } else {
+        final tempDir = await getTemporaryDirectory();
+        File tempFile = await File('${tempDir.path}/$fileName').create();
+        tempFile.writeAsBytesSync(res.bodyBytes);
+        entity = await PhotoManager.editor.saveVideo(tempFile, title: fileName);
+      }
+
+      if (entity != null) {
+        return true;
+      }
+    } catch (e) {
+      debugPrint("Error saving file $e");
+      return false;
+    }
+
+    return false;
+  }
+}

+ 0 - 0
mobile/lib/modules/asset_viewer/services/store_services_here.txt


+ 24 - 0
mobile/lib/modules/asset_viewer/ui/download_loading_indicator.dart

@@ -0,0 +1,24 @@
+import 'package:flutter/material.dart';
+import 'package:flutter_spinkit/flutter_spinkit.dart';
+
+class DownloadLoadingIndicator extends StatelessWidget {
+  const DownloadLoadingIndicator({
+    Key? key,
+  }) : super(key: key);
+
+  @override
+  Widget build(BuildContext context) {
+    return Container(
+      height: 60,
+      width: 60,
+      decoration: BoxDecoration(
+        color: Theme.of(context).primaryColor,
+        borderRadius: BorderRadius.circular(10),
+      ),
+      child: const SpinKitDancingSquare(
+        color: Colors.white,
+        size: 30.0,
+      ),
+    );
+  }
+}

+ 9 - 4
mobile/lib/modules/asset_viewer/ui/top_control_app_bar.dart

@@ -1,14 +1,19 @@
 import 'package:auto_route/auto_route.dart';
 import 'package:flutter/material.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/shared/models/immich_asset.model.dart';
 
-class TopControlAppBar extends StatelessWidget with PreferredSizeWidget {
-  const TopControlAppBar({Key? key, required this.asset, required this.onMoreInfoPressed}) : super(key: key);
+class TopControlAppBar extends ConsumerWidget with PreferredSizeWidget {
+  const TopControlAppBar(
+      {Key? key, required this.asset, required this.onMoreInfoPressed, required this.onDownloadPressed})
+      : super(key: key);
 
   final ImmichAsset asset;
   final Function onMoreInfoPressed;
+  final Function onDownloadPressed;
+
   @override
-  Widget build(BuildContext context) {
+  Widget build(BuildContext context, WidgetRef ref) {
     double iconSize = 18.0;
 
     return AppBar(
@@ -29,7 +34,7 @@ class TopControlAppBar extends StatelessWidget with PreferredSizeWidget {
           iconSize: iconSize,
           splashRadius: iconSize,
           onPressed: () {
-            print("download");
+            onDownloadPressed();
           },
           icon: const Icon(Icons.cloud_download_rounded),
         ),

+ 67 - 51
mobile/lib/modules/asset_viewer/views/image_viewer_page.dart

@@ -4,6 +4,9 @@ import 'package:flutter_hooks/flutter_hooks.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/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:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
 import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
 import 'package:immich_mobile/modules/home/services/asset.service.dart';
@@ -25,6 +28,7 @@ class ImageViewerPage extends HookConsumerWidget {
 
   @override
   Widget build(BuildContext context, WidgetRef ref) {
+    final downloadAssetStatus = ref.watch(imageViewerStateProvider).downloadAssetStatus;
     var box = Hive.box(userInfoBox);
 
     getAssetExif() async {
@@ -42,65 +46,77 @@ class ImageViewerPage extends HookConsumerWidget {
         asset: asset,
         onMoreInfoPressed: () {
           showModalBottomSheet(
-              backgroundColor: Colors.black,
-              barrierColor: Colors.transparent,
-              isScrollControlled: false,
-              context: context,
-              builder: (context) {
-                return ExifBottomSheet(assetDetail: assetDetail!);
-              });
+            backgroundColor: Colors.black,
+            barrierColor: Colors.transparent,
+            isScrollControlled: false,
+            context: context,
+            builder: (context) {
+              return ExifBottomSheet(assetDetail: assetDetail!);
+            },
+          );
+        },
+        onDownloadPressed: () {
+          ref.watch(imageViewerStateProvider.notifier).downloadAsset(asset, context);
         },
       ),
       body: SafeArea(
-        child: Center(
-          child: Hero(
-            tag: heroTag,
-            child: CachedNetworkImage(
-              fit: BoxFit.cover,
-              imageUrl: imageUrl,
-              httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
-              fadeInDuration: const Duration(milliseconds: 250),
-              errorWidget: (context, url, error) => ConstrainedBox(
-                constraints: const BoxConstraints(maxWidth: 300),
-                child: Wrap(
-                  spacing: 32,
-                  runSpacing: 32,
-                  alignment: WrapAlignment.center,
-                  children: [
-                    const Text(
-                      "Failed To Render Image - Possibly Corrupted Data",
-                      textAlign: TextAlign.center,
-                      style: TextStyle(fontSize: 16, color: Colors.white),
+        child: Stack(
+          children: [
+            Center(
+              child: Hero(
+                tag: heroTag,
+                child: CachedNetworkImage(
+                  fit: BoxFit.cover,
+                  imageUrl: imageUrl,
+                  httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
+                  fadeInDuration: const Duration(milliseconds: 250),
+                  errorWidget: (context, url, error) => ConstrainedBox(
+                    constraints: const BoxConstraints(maxWidth: 300),
+                    child: Wrap(
+                      spacing: 32,
+                      runSpacing: 32,
+                      alignment: WrapAlignment.center,
+                      children: [
+                        const Text(
+                          "Failed To Render Image - Possibly Corrupted Data",
+                          textAlign: TextAlign.center,
+                          style: TextStyle(fontSize: 16, color: Colors.white),
+                        ),
+                        SingleChildScrollView(
+                          child: Text(
+                            error.toString(),
+                            textAlign: TextAlign.center,
+                            style: TextStyle(fontSize: 12, color: Colors.grey[400]),
+                          ),
+                        ),
+                      ],
                     ),
-                    SingleChildScrollView(
-                      child: Text(
-                        error.toString(),
-                        textAlign: TextAlign.center,
-                        style: TextStyle(fontSize: 12, color: Colors.grey[400]),
+                  ),
+                  placeholder: (context, url) {
+                    return CachedNetworkImage(
+                      cacheKey: thumbnailUrl,
+                      fit: BoxFit.cover,
+                      imageUrl: thumbnailUrl,
+                      httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
+                      placeholderFadeInDuration: const Duration(milliseconds: 0),
+                      progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
+                        scale: 0.2,
+                        child: CircularProgressIndicator(value: downloadProgress.progress),
                       ),
-                    ),
-                  ],
+                      errorWidget: (context, url, error) => Icon(
+                        Icons.error,
+                        color: Colors.grey[300],
+                      ),
+                    );
+                  },
                 ),
               ),
-              placeholder: (context, url) {
-                return CachedNetworkImage(
-                  cacheKey: thumbnailUrl,
-                  fit: BoxFit.cover,
-                  imageUrl: thumbnailUrl,
-                  httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
-                  placeholderFadeInDuration: const Duration(milliseconds: 0),
-                  progressIndicatorBuilder: (context, url, downloadProgress) => Transform.scale(
-                    scale: 0.2,
-                    child: CircularProgressIndicator(value: downloadProgress.progress),
-                  ),
-                  errorWidget: (context, url, error) => Icon(
-                    Icons.error,
-                    color: Colors.grey[300],
-                  ),
-                );
-              },
             ),
-          ),
+            if (downloadAssetStatus == DownloadAssetStatus.loading)
+              const Center(
+                child: DownloadLoadingIndicator(),
+              ),
+          ],
         ),
       ),
     );

+ 54 - 15
mobile/lib/shared/views/video_viewer_page.dart → mobile/lib/modules/asset_viewer/views/video_viewer_page.dart

@@ -1,35 +1,74 @@
-import 'package:auto_route/auto_route.dart';
 import 'package:flutter/material.dart';
-import 'package:flutter/services.dart';
+import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hive/hive.dart';
+import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/constants/hive_box.dart';
 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:immich_mobile/modules/asset_viewer/ui/exif_bottom_sheet.dart';
+import 'package:immich_mobile/modules/asset_viewer/ui/top_control_app_bar.dart';
+import 'package:immich_mobile/modules/home/services/asset.service.dart';
+import 'package:immich_mobile/shared/models/immich_asset.model.dart';
+import 'package:immich_mobile/shared/models/immich_asset_with_exif.model.dart';
 import 'package:video_player/video_player.dart';
 
-class VideoViewerPage extends StatelessWidget {
+// ignore: must_be_immutable
+class VideoViewerPage extends HookConsumerWidget {
   final String videoUrl;
+  final ImmichAsset asset;
+  ImmichAssetWithExif? assetDetail;
+  final AssetService _assetService = AssetService();
 
-  const VideoViewerPage({Key? key, required this.videoUrl}) : super(key: key);
+  VideoViewerPage({Key? key, required this.videoUrl, required this.asset}) : super(key: key);
 
   @override
-  Widget build(BuildContext context) {
+  Widget build(BuildContext context, WidgetRef ref) {
+    final downloadAssetStatus = ref.watch(imageViewerStateProvider).downloadAssetStatus;
+
     String jwtToken = Hive.box(userInfoBox).get(accessTokenKey);
 
+    getAssetExif() async {
+      assetDetail = await _assetService.getAssetById(asset.id);
+    }
+
+    useEffect(() {
+      getAssetExif();
+      return null;
+    }, []);
+
     return Scaffold(
       backgroundColor: Colors.black,
-      appBar: AppBar(
-        systemOverlayStyle: SystemUiOverlayStyle.light,
-        backgroundColor: Colors.black,
-        leading: IconButton(
-            onPressed: () {
-              AutoRouter.of(context).pop();
+      appBar: TopControlAppBar(
+        asset: asset,
+        onMoreInfoPressed: () {
+          showModalBottomSheet(
+            backgroundColor: Colors.black,
+            barrierColor: Colors.transparent,
+            isScrollControlled: false,
+            context: context,
+            builder: (context) {
+              return ExifBottomSheet(assetDetail: assetDetail!);
             },
-            icon: const Icon(Icons.arrow_back_ios)),
+          );
+        },
+        onDownloadPressed: () {
+          ref.watch(imageViewerStateProvider.notifier).downloadAsset(asset, context);
+        },
       ),
       body: SafeArea(
-        child: VideoThumbnailPlayer(
-          url: videoUrl,
-          jwtToken: jwtToken,
+        child: Stack(
+          children: [
+            VideoThumbnailPlayer(
+              url: videoUrl,
+              jwtToken: jwtToken,
+            ),
+            if (downloadAssetStatus == DownloadAssetStatus.loading)
+              const Center(
+                child: DownloadLoadingIndicator(),
+              ),
+          ],
         ),
       ),
     );

+ 2 - 2
mobile/lib/modules/home/ui/thumbnail_image.dart

@@ -65,8 +65,8 @@ class ThumbnailImage extends HookConsumerWidget {
           } else {
             AutoRouter.of(context).push(
               VideoViewerRoute(
-                videoUrl: '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}',
-              ),
+                  videoUrl: '${box.get(serverEndpointKey)}/asset/file?aid=${asset.deviceAssetId}&did=${asset.deviceId}',
+                  asset: asset),
             );
           }
         }

+ 4 - 3
mobile/lib/modules/login/ui/login_form.dart

@@ -128,9 +128,10 @@ class LoginButton extends ConsumerWidget {
             AutoRouter.of(context).pushNamed("/tab-controller-page");
           } else {
             ImmichToast.show(
-                context: context,
-                msg: "Error logging you in, check server url, email and password!",
-                toastType: ToastType.error);
+              context: context,
+              msg: "Error logging you in, check server url, email and password!",
+              toastType: ToastType.error,
+            );
           }
         },
         child: const Text("Login"));

+ 67 - 0
mobile/lib/modules/search/ui/thumbnail_with_info.dart

@@ -0,0 +1,67 @@
+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/utils/capitalize_first_letter.dart';
+
+class ThumbnailWithInfo extends StatelessWidget {
+  const ThumbnailWithInfo({Key? key, required this.textInfo, required this.imageUrl, required this.onTap})
+      : super(key: key);
+
+  final String textInfo;
+  final String imageUrl;
+  final Function onTap;
+
+  @override
+  Widget build(BuildContext context) {
+    var box = Hive.box(userInfoBox);
+
+    return GestureDetector(
+      onTap: () {
+        onTap();
+      },
+      child: Padding(
+        padding: const EdgeInsets.only(right: 8.0),
+        child: SizedBox(
+          width: MediaQuery.of(context).size.width / 2,
+          child: Stack(
+            alignment: Alignment.bottomCenter,
+            children: [
+              Container(
+                foregroundDecoration: BoxDecoration(
+                  borderRadius: BorderRadius.circular(10),
+                  color: Colors.black26,
+                ),
+                child: ClipRRect(
+                  borderRadius: BorderRadius.circular(10),
+                  child: CachedNetworkImage(
+                    width: 250,
+                    height: 250,
+                    fit: BoxFit.cover,
+                    imageUrl: imageUrl,
+                    httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
+                  ),
+                ),
+              ),
+              Positioned(
+                bottom: 8,
+                left: 10,
+                child: SizedBox(
+                  width: MediaQuery.of(context).size.width / 3,
+                  child: Text(
+                    textInfo.capitalizeFirstLetter(),
+                    style: const TextStyle(
+                      color: Colors.white,
+                      fontWeight: FontWeight.bold,
+                      fontSize: 16,
+                    ),
+                  ),
+                ),
+              ),
+            ],
+          ),
+        ),
+      ),
+    );
+  }
+}

+ 7 - 70
mobile/lib/modules/search/views/search_page.dart

@@ -1,5 +1,4 @@
 import 'package:auto_route/auto_route.dart';
-import 'package:cached_network_image/cached_network_image.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
 import 'package:hive_flutter/hive_flutter.dart';
@@ -10,6 +9,7 @@ import 'package:immich_mobile/modules/search/models/curated_object.model.dart';
 import 'package:immich_mobile/modules/search/providers/search_page_state.provider.dart';
 import 'package:immich_mobile/modules/search/ui/search_bar.dart';
 import 'package:immich_mobile/modules/search/ui/search_suggestion_list.dart';
+import 'package:immich_mobile/modules/search/ui/thumbnail_with_info.dart';
 import 'package:immich_mobile/routing/router.dart';
 import 'package:immich_mobile/utils/capitalize_first_letter.dart';
 
@@ -40,12 +40,12 @@ class SearchPage extends HookConsumerWidget {
 
     _buildPlaces() {
       return curatedLocation.when(
-        loading: () => const CircularProgressIndicator(),
+        loading: () => const SizedBox(width: 60, height: 60, child: CircularProgressIndicator.adaptive()),
         error: (err, stack) => Text('Error: $err'),
         data: (curatedLocations) {
           return curatedLocations.isNotEmpty
               ? SizedBox(
-                  height: MediaQuery.of(context).size.width / 3,
+                  height: MediaQuery.of(context).size.width / 2,
                   child: ListView.builder(
                     padding: const EdgeInsets.only(left: 16),
                     scrollDirection: Axis.horizontal,
@@ -66,7 +66,7 @@ class SearchPage extends HookConsumerWidget {
                   ),
                 )
               : SizedBox(
-                  height: MediaQuery.of(context).size.width / 3,
+                  height: MediaQuery.of(context).size.width / 2,
                   child: ListView.builder(
                     padding: const EdgeInsets.only(left: 16),
                     scrollDirection: Axis.horizontal,
@@ -87,12 +87,12 @@ class SearchPage extends HookConsumerWidget {
 
     _buildThings() {
       return curatedObjects.when(
-        loading: () => const CircularProgressIndicator(),
+        loading: () => const SizedBox(width: 60, height: 60, child: CircularProgressIndicator.adaptive()),
         error: (err, stack) => Text('Error: $err'),
         data: (objects) {
           return objects.isNotEmpty
               ? SizedBox(
-                  height: MediaQuery.of(context).size.width / 3,
+                  height: MediaQuery.of(context).size.width / 2,
                   child: ListView.builder(
                     padding: const EdgeInsets.only(left: 16),
                     scrollDirection: Axis.horizontal,
@@ -114,7 +114,7 @@ class SearchPage extends HookConsumerWidget {
                   ),
                 )
               : SizedBox(
-                  height: MediaQuery.of(context).size.width / 3,
+                  height: MediaQuery.of(context).size.width / 2,
                   child: ListView.builder(
                     padding: const EdgeInsets.only(left: 16),
                     scrollDirection: Axis.horizontal,
@@ -172,66 +172,3 @@ class SearchPage extends HookConsumerWidget {
     );
   }
 }
-
-class ThumbnailWithInfo extends StatelessWidget {
-  const ThumbnailWithInfo({Key? key, required this.textInfo, required this.imageUrl, required this.onTap})
-      : super(key: key);
-
-  final String textInfo;
-  final String imageUrl;
-  final Function onTap;
-
-  @override
-  Widget build(BuildContext context) {
-    var box = Hive.box(userInfoBox);
-
-    return GestureDetector(
-      onTap: () {
-        onTap();
-      },
-      child: Padding(
-        padding: const EdgeInsets.only(right: 8.0),
-        child: SizedBox(
-          width: MediaQuery.of(context).size.width / 3,
-          height: MediaQuery.of(context).size.width / 3,
-          child: Stack(
-            alignment: Alignment.bottomCenter,
-            children: [
-              Container(
-                foregroundDecoration: BoxDecoration(
-                  borderRadius: BorderRadius.circular(10),
-                  color: Colors.black26,
-                ),
-                child: ClipRRect(
-                  borderRadius: BorderRadius.circular(10),
-                  child: CachedNetworkImage(
-                    width: 150,
-                    height: 150,
-                    fit: BoxFit.cover,
-                    imageUrl: imageUrl,
-                    httpHeaders: {"Authorization": "Bearer ${box.get(accessTokenKey)}"},
-                  ),
-                ),
-              ),
-              Positioned(
-                bottom: 8,
-                left: 10,
-                child: SizedBox(
-                  width: MediaQuery.of(context).size.width / 3,
-                  child: Text(
-                    textInfo.capitalizeFirstLetter(),
-                    style: const TextStyle(
-                      color: Colors.white,
-                      fontWeight: FontWeight.bold,
-                      fontSize: 12,
-                    ),
-                  ),
-                ),
-              ),
-            ],
-          ),
-        ),
-      ),
-    );
-  }
-}

+ 5 - 1
mobile/lib/modules/search/views/search_result_page.dart

@@ -1,6 +1,7 @@
 import 'package:auto_route/auto_route.dart';
 import 'package:flutter/material.dart';
 import 'package:flutter_hooks/flutter_hooks.dart';
+import 'package:flutter_spinkit/flutter_spinkit.dart';
 import 'package:hooks_riverpod/hooks_riverpod.dart';
 import 'package:immich_mobile/modules/home/ui/daily_title_text.dart';
 import 'package:immich_mobile/modules/home/ui/draggable_scrollbar.dart';
@@ -107,7 +108,10 @@ class SearchResultPage extends HookConsumerWidget {
       }
 
       if (searchResultPageState.isLoading) {
-        return const CircularProgressIndicator.adaptive();
+        return Center(
+            child: SpinKitDancingSquare(
+          color: Theme.of(context).primaryColor,
+        ));
       }
 
       if (searchResultPageState.isSuccess) {

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

@@ -9,7 +9,7 @@ import 'package:immich_mobile/shared/models/immich_asset.model.dart';
 import 'package:immich_mobile/shared/views/backup_controller_page.dart';
 import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
 import 'package:immich_mobile/shared/views/tab_controller_page.dart';
-import 'package:immich_mobile/shared/views/video_viewer_page.dart';
+import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
 
 part 'router.gr.dart';
 

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

@@ -44,7 +44,8 @@ class _$AppRouter extends RootStackRouter {
       final args = routeData.argsAs<VideoViewerRouteArgs>();
       return MaterialPageX<dynamic>(
           routeData: routeData,
-          child: VideoViewerPage(key: args.key, videoUrl: args.videoUrl));
+          child: VideoViewerPage(
+              key: args.key, videoUrl: args.videoUrl, asset: args.asset));
     },
     BackupControllerRoute.name: (routeData) {
       return MaterialPageX<dynamic>(
@@ -163,24 +164,29 @@ class ImageViewerRouteArgs {
 /// generated route for
 /// [VideoViewerPage]
 class VideoViewerRoute extends PageRouteInfo<VideoViewerRouteArgs> {
-  VideoViewerRoute({Key? key, required String videoUrl})
+  VideoViewerRoute(
+      {Key? key, required String videoUrl, required ImmichAsset asset})
       : super(VideoViewerRoute.name,
             path: '/video-viewer-page',
-            args: VideoViewerRouteArgs(key: key, videoUrl: videoUrl));
+            args: VideoViewerRouteArgs(
+                key: key, videoUrl: videoUrl, asset: asset));
 
   static const String name = 'VideoViewerRoute';
 }
 
 class VideoViewerRouteArgs {
-  const VideoViewerRouteArgs({this.key, required this.videoUrl});
+  const VideoViewerRouteArgs(
+      {this.key, required this.videoUrl, required this.asset});
 
   final Key? key;
 
   final String videoUrl;
 
+  final ImmichAsset asset;
+
   @override
   String toString() {
-    return 'VideoViewerRouteArgs{key: $key, videoUrl: $videoUrl}';
+    return 'VideoViewerRouteArgs{key: $key, videoUrl: $videoUrl, asset: $asset}';
   }
 }
 

+ 19 - 3
mobile/lib/shared/models/immich_asset.model.dart

@@ -10,6 +10,8 @@ class ImmichAsset {
   final String modifiedAt;
   final bool isFavorite;
   final String? duration;
+  final String originalPath;
+  final String resizePath;
 
   ImmichAsset({
     required this.id,
@@ -21,6 +23,8 @@ class ImmichAsset {
     required this.modifiedAt,
     required this.isFavorite,
     this.duration,
+    required this.originalPath,
+    required this.resizePath,
   });
 
   ImmichAsset copyWith({
@@ -33,6 +37,8 @@ class ImmichAsset {
     String? modifiedAt,
     bool? isFavorite,
     String? duration,
+    String? originalPath,
+    String? resizePath,
   }) {
     return ImmichAsset(
       id: id ?? this.id,
@@ -44,6 +50,8 @@ class ImmichAsset {
       modifiedAt: modifiedAt ?? this.modifiedAt,
       isFavorite: isFavorite ?? this.isFavorite,
       duration: duration ?? this.duration,
+      originalPath: originalPath ?? this.originalPath,
+      resizePath: resizePath ?? this.resizePath,
     );
   }
 
@@ -58,6 +66,8 @@ class ImmichAsset {
       'modifiedAt': modifiedAt,
       'isFavorite': isFavorite,
       'duration': duration,
+      'originalPath': originalPath,
+      'resizePath': resizePath,
     };
   }
 
@@ -72,6 +82,8 @@ class ImmichAsset {
       modifiedAt: map['modifiedAt'] ?? '',
       isFavorite: map['isFavorite'] ?? false,
       duration: map['duration'],
+      originalPath: map['originalPath'] ?? '',
+      resizePath: map['resizePath'] ?? '',
     );
   }
 
@@ -81,7 +93,7 @@ class ImmichAsset {
 
   @override
   String toString() {
-    return 'ImmichAsset(id: $id, deviceAssetId: $deviceAssetId, userId: $userId, deviceId: $deviceId, type: $type, createdAt: $createdAt, modifiedAt: $modifiedAt, isFavorite: $isFavorite, duration: $duration)';
+    return 'ImmichAsset(id: $id, deviceAssetId: $deviceAssetId, userId: $userId, deviceId: $deviceId, type: $type, createdAt: $createdAt, modifiedAt: $modifiedAt, isFavorite: $isFavorite, duration: $duration, originalPath: $originalPath, resizePath: $resizePath)';
   }
 
   @override
@@ -97,7 +109,9 @@ class ImmichAsset {
         other.createdAt == createdAt &&
         other.modifiedAt == modifiedAt &&
         other.isFavorite == isFavorite &&
-        other.duration == duration;
+        other.duration == duration &&
+        other.originalPath == originalPath &&
+        other.resizePath == resizePath;
   }
 
   @override
@@ -110,6 +124,8 @@ class ImmichAsset {
         createdAt.hashCode ^
         modifiedAt.hashCode ^
         isFavorite.hashCode ^
-        duration.hashCode;
+        duration.hashCode ^
+        originalPath.hashCode ^
+        resizePath.hashCode;
   }
 }

+ 1 - 1
mobile/lib/shared/services/backup.service.dart

@@ -73,7 +73,7 @@ class BackupService {
           });
 
           // Build thumbnail multipart data
-          var thumbnailData = await entity.thumbDataWithSize(1280, 720);
+          var thumbnailData = await entity.thumbnailDataWithSize(const ThumbnailSize(720, 1280));
           if (thumbnailData != null) {
             thumbnailUploadData = MultipartFile.fromBytes(
               List.from(thumbnailData),

+ 25 - 4
mobile/lib/shared/services/network.service.dart

@@ -1,3 +1,4 @@
+import 'dart:async';
 import 'dart:convert';
 
 import 'package:dio/dio.dart';
@@ -25,16 +26,36 @@ class NetworkService {
     }
   }
 
-  Future<dynamic> getRequest({required String url}) async {
+  Future<dynamic> getRequest({required String url, bool isByteResponse = false, bool isStreamReponse = false}) async {
     try {
       var dio = Dio();
       dio.interceptors.add(AuthenticatedRequestInterceptor());
 
       var savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
-      Response res = await dio.get('$savedEndpoint/$url');
 
-      if (res.statusCode == 200) {
-        return res;
+      if (isByteResponse) {
+        Response<List<int>> res = await dio.get<List<int>>(
+          '$savedEndpoint/$url',
+          options: Options(responseType: ResponseType.bytes),
+        );
+
+        if (res.statusCode == 200) {
+          return res;
+        }
+      } else if (isStreamReponse) {
+        Response<ResponseBody> res = await dio.get<ResponseBody>(
+          '$savedEndpoint/$url',
+          options: Options(responseType: ResponseType.stream),
+        );
+
+        if (res.statusCode == 200) {
+          return res;
+        }
+      } else {
+        Response res = await dio.get('$savedEndpoint/$url');
+        if (res.statusCode == 200) {
+          return res;
+        }
       }
     } on DioError catch (e) {
       debugPrint("DioError: ${e.response}");

+ 16 - 4
mobile/lib/shared/ui/immich_toast.dart

@@ -8,12 +8,24 @@ class ImmichToast {
     required BuildContext context,
     required String msg,
     ToastType toastType = ToastType.info,
+    ToastGravity gravity = ToastGravity.TOP,
   }) {
     FToast fToast;
 
     fToast = FToast();
     fToast.init(context);
 
+    _getColor(ToastType type, BuildContext context) {
+      switch (type) {
+        case ToastType.info:
+          return Theme.of(context).primaryColor;
+        case ToastType.success:
+          return const Color.fromARGB(255, 78, 140, 124);
+        case ToastType.error:
+          return const Color.fromARGB(255, 220, 48, 85);
+      }
+    }
+
     fToast.showToast(
       child: Container(
         padding: const EdgeInsets.symmetric(horizontal: 24.0, vertical: 12.0),
@@ -36,8 +48,8 @@ class ImmichToast {
                 : Container(),
             (toastType == ToastType.success)
                 ? const Icon(
-                    Icons.check,
-                    color: Color.fromARGB(255, 104, 248, 140),
+                    Icons.check_circle_rounded,
+                    color: Color.fromARGB(255, 78, 140, 124),
                   )
                 : Container(),
             (toastType == ToastType.error)
@@ -53,7 +65,7 @@ class ImmichToast {
               child: Text(
                 msg,
                 style: TextStyle(
-                  color: Theme.of(context).primaryColor,
+                  color: _getColor(toastType, context),
                   fontWeight: FontWeight.bold,
                   fontSize: 15,
                 ),
@@ -62,7 +74,7 @@ class ImmichToast {
           ],
         ),
       ),
-      gravity: ToastGravity.TOP,
+      gravity: gravity,
       toastDuration: const Duration(seconds: 2),
     );
   }

+ 8 - 1
mobile/pubspec.lock

@@ -328,6 +328,13 @@ packages:
       url: "https://pub.dartlang.org"
     source: hosted
     version: "2.0.0-dev.0"
+  flutter_spinkit:
+    dependency: "direct main"
+    description:
+      name: flutter_spinkit
+      url: "https://pub.dartlang.org"
+    source: hosted
+    version: "5.1.0"
   flutter_test:
     dependency: "direct dev"
     description: flutter
@@ -680,7 +687,7 @@ packages:
       name: photo_manager
       url: "https://pub.dartlang.org"
     source: hosted
-    version: "1.3.10"
+    version: "2.0.6"
   photo_view:
     dependency: "direct main"
     description:

+ 3 - 3
mobile/pubspec.yaml

@@ -11,7 +11,7 @@ dependencies:
   flutter:
     sdk: flutter
   cupertino_icons: ^1.0.2
-  photo_manager: ^1.3.10
+  photo_manager: ^2.0.6
   flutter_hooks: ^0.18.0
   hooks_riverpod: ^2.0.0-dev.0
   hive:
@@ -33,11 +33,11 @@ dependencies:
   badges: ^2.0.2
   photo_view: ^0.13.0
   socket_io_client: ^2.0.0-beta.4-nullsafety.0
-  # mapbox_gl: ^0.15.0
   flutter_map: ^0.14.0
   flutter_udid: ^2.0.0
   package_info_plus: ^1.4.0
-
+  flutter_spinkit: ^5.1.0
+  
 dev_dependencies:
   flutter_test:
     sdk: flutter

+ 9 - 0
server/src/api-v1/asset/asset.controller.ts

@@ -76,6 +76,15 @@ export class AssetController {
     return 'ok';
   }
 
+  @Get('/download')
+  async downloadFile(
+    @GetAuthUser() authUser: AuthUserDto,
+    @Response({ passthrough: true }) res: Res,
+    @Query(ValidationPipe) query: ServeFileDto,
+  ) {
+    return this.assetService.downloadFile(authUser, query, res);
+  }
+
   @Get('/file')
   async serveFile(
     @Headers() headers,

+ 17 - 0
server/src/api-v1/asset/asset.service.ts

@@ -13,6 +13,7 @@ import { Response as Res } from 'express';
 import { promisify } from 'util';
 import { DeleteAssetDto } from './dto/delete-asset.dto';
 import { SearchAssetDto } from './dto/search-asset.dto';
+import path from 'path';
 
 const fileInfo = promisify(stat);
 
@@ -146,10 +147,26 @@ export class AssetService {
     });
   }
 
+  public async downloadFile(authUser: AuthUserDto, query: ServeFileDto, res: Res) {
+    let file = null;
+    const asset = await this.findOne(authUser, query.did, query.aid);
+
+    if (query.isThumb === 'false' || !query.isThumb) {
+      file = createReadStream(asset.originalPath);
+    } else {
+      file = createReadStream(asset.resizePath);
+    }
+
+    return new StreamableFile(file);
+  }
+
   public async serveFile(authUser: AuthUserDto, query: ServeFileDto, res: Res, headers: any) {
     let file = null;
     const asset = await this.findOne(authUser, query.did, query.aid);
 
+    if (!asset) {
+      throw new BadRequestException('Asset does not exist');
+    }
     // Handle Sending Images
     if (asset.type == AssetType.IMAGE || query.isThumb == 'true') {
       res.set({