mirror of
				https://github.com/immich-app/immich.git
				synced 2025-11-04 03:27:09 -05:00 
			
		
		
		
	feat(mobile): enhance download operations (#12973)
* add packages * create download task * show progress * save video and image * show progress info * live photo wip * download and link live photos * Update list of assets * wip * correct progress * add state to download * revert unncessary change * repository pattern * translation * remove unused code * update method call from repository * remove unused variable * handle multiple livephotos download * remove logging statement * lint * not removing all records
This commit is contained in:
		
							parent
							
								
									2bcd27e166
								
							
						
					
					
						commit
						fa9bb8074c
					
				@ -588,5 +588,16 @@
 | 
				
			|||||||
  "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
 | 
					  "version_announcement_overlay_title": "New Server Version Available \uD83C\uDF89",
 | 
				
			||||||
  "viewer_remove_from_stack": "Remove from Stack",
 | 
					  "viewer_remove_from_stack": "Remove from Stack",
 | 
				
			||||||
  "viewer_stack_use_as_main_asset": "Use as Main Asset",
 | 
					  "viewer_stack_use_as_main_asset": "Use as Main Asset",
 | 
				
			||||||
  "viewer_unstack": "Un-Stack"
 | 
					  "viewer_unstack": "Un-Stack",
 | 
				
			||||||
 | 
					  "downloading_media": "Downloading media",
 | 
				
			||||||
 | 
					  "download_finished": "Download finished",
 | 
				
			||||||
 | 
					  "download_filename": "file: {}",
 | 
				
			||||||
 | 
					  "downloading": "Downloading...",
 | 
				
			||||||
 | 
					  "download_complete": "Download complete",
 | 
				
			||||||
 | 
					  "download_failed": "Download failed",
 | 
				
			||||||
 | 
					  "download_canceled": "Download canceled",
 | 
				
			||||||
 | 
					  "download_paused": "Download paused",
 | 
				
			||||||
 | 
					  "download_enqueue": "Download enqueued",
 | 
				
			||||||
 | 
					  "download_notfound": "Download not found",
 | 
				
			||||||
 | 
					  "download_waiting_to_retry": "Waiting to retry"
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
@ -1,4 +1,6 @@
 | 
				
			|||||||
PODS:
 | 
					PODS:
 | 
				
			||||||
 | 
					  - background_downloader (0.0.1):
 | 
				
			||||||
 | 
					    - Flutter
 | 
				
			||||||
  - connectivity_plus (0.0.1):
 | 
					  - connectivity_plus (0.0.1):
 | 
				
			||||||
    - Flutter
 | 
					    - Flutter
 | 
				
			||||||
    - ReachabilitySwift
 | 
					    - ReachabilitySwift
 | 
				
			||||||
@ -99,6 +101,7 @@ PODS:
 | 
				
			|||||||
    - Flutter
 | 
					    - Flutter
 | 
				
			||||||
 | 
					
 | 
				
			||||||
DEPENDENCIES:
 | 
					DEPENDENCIES:
 | 
				
			||||||
 | 
					  - background_downloader (from `.symlinks/plugins/background_downloader/ios`)
 | 
				
			||||||
  - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
 | 
					  - connectivity_plus (from `.symlinks/plugins/connectivity_plus/ios`)
 | 
				
			||||||
  - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
 | 
					  - device_info_plus (from `.symlinks/plugins/device_info_plus/ios`)
 | 
				
			||||||
  - file_picker (from `.symlinks/plugins/file_picker/ios`)
 | 
					  - file_picker (from `.symlinks/plugins/file_picker/ios`)
 | 
				
			||||||
@ -137,6 +140,8 @@ SPEC REPOS:
 | 
				
			|||||||
    - Toast
 | 
					    - Toast
 | 
				
			||||||
 | 
					
 | 
				
			||||||
EXTERNAL SOURCES:
 | 
					EXTERNAL SOURCES:
 | 
				
			||||||
 | 
					  background_downloader:
 | 
				
			||||||
 | 
					    :path: ".symlinks/plugins/background_downloader/ios"
 | 
				
			||||||
  connectivity_plus:
 | 
					  connectivity_plus:
 | 
				
			||||||
    :path: ".symlinks/plugins/connectivity_plus/ios"
 | 
					    :path: ".symlinks/plugins/connectivity_plus/ios"
 | 
				
			||||||
  device_info_plus:
 | 
					  device_info_plus:
 | 
				
			||||||
@ -189,6 +194,7 @@ EXTERNAL SOURCES:
 | 
				
			|||||||
    :path: ".symlinks/plugins/wakelock_plus/ios"
 | 
					    :path: ".symlinks/plugins/wakelock_plus/ios"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
SPEC CHECKSUMS:
 | 
					SPEC CHECKSUMS:
 | 
				
			||||||
 | 
					  background_downloader: 9f788ffc5de45acf87d6380e91ca0841066c18cf
 | 
				
			||||||
  connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d
 | 
					  connectivity_plus: bf0076dd84a130856aa636df1c71ccaff908fa1d
 | 
				
			||||||
  device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6
 | 
					  device_info_plus: c6fb39579d0f423935b0c9ce7ee2f44b71b9fce6
 | 
				
			||||||
  DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
 | 
					  DKImagePickerController: 946cec48c7873164274ecc4624d19e3da4c1ef3c
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										14
									
								
								mobile/lib/interfaces/download.interface.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										14
									
								
								mobile/lib/interfaces/download.interface.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,14 @@
 | 
				
			|||||||
 | 
					import 'package:background_downloader/background_downloader.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					abstract interface class IDownloadRepository {
 | 
				
			||||||
 | 
					  void Function(TaskStatusUpdate)? onImageDownloadStatus;
 | 
				
			||||||
 | 
					  void Function(TaskStatusUpdate)? onVideoDownloadStatus;
 | 
				
			||||||
 | 
					  void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus;
 | 
				
			||||||
 | 
					  void Function(TaskProgressUpdate)? onTaskProgress;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<List<TaskRecord>> getLiveVideoTasks();
 | 
				
			||||||
 | 
					  Future<bool> download(DownloadTask task);
 | 
				
			||||||
 | 
					  Future<bool> cancel(String id);
 | 
				
			||||||
 | 
					  Future<void> deleteAllTrackingRecords();
 | 
				
			||||||
 | 
					  Future<void> deleteRecordsWithIds(List<String> id);
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -1,6 +1,7 @@
 | 
				
			|||||||
import 'dart:async';
 | 
					import 'dart:async';
 | 
				
			||||||
import 'dart:io';
 | 
					import 'dart:io';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:background_downloader/background_downloader.dart';
 | 
				
			||||||
import 'package:device_info_plus/device_info_plus.dart';
 | 
					import 'package:device_info_plus/device_info_plus.dart';
 | 
				
			||||||
import 'package:easy_localization/easy_localization.dart';
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
import 'package:flutter/foundation.dart';
 | 
					import 'package:flutter/foundation.dart';
 | 
				
			||||||
@ -9,6 +10,7 @@ import 'package:flutter/services.dart';
 | 
				
			|||||||
import 'package:flutter_displaymode/flutter_displaymode.dart';
 | 
					import 'package:flutter_displaymode/flutter_displaymode.dart';
 | 
				
			||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
					import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
				
			||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
 | 
					import 'package:immich_mobile/extensions/build_context_extensions.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/utils/download.dart';
 | 
				
			||||||
import 'package:timezone/data/latest.dart';
 | 
					import 'package:timezone/data/latest.dart';
 | 
				
			||||||
import 'package:immich_mobile/constants/locales.dart';
 | 
					import 'package:immich_mobile/constants/locales.dart';
 | 
				
			||||||
import 'package:immich_mobile/services/background.service.dart';
 | 
					import 'package:immich_mobile/services/background.service.dart';
 | 
				
			||||||
@ -72,7 +74,6 @@ Future<void> initApp() async {
 | 
				
			|||||||
  var log = Logger("ImmichErrorLogger");
 | 
					  var log = Logger("ImmichErrorLogger");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  FlutterError.onError = (details) {
 | 
					  FlutterError.onError = (details) {
 | 
				
			||||||
    debugPrint("FlutterError - Catch all: $details");
 | 
					 | 
				
			||||||
    FlutterError.presentError(details);
 | 
					    FlutterError.presentError(details);
 | 
				
			||||||
    log.severe(
 | 
					    log.severe(
 | 
				
			||||||
      'FlutterError - Catch all',
 | 
					      'FlutterError - Catch all',
 | 
				
			||||||
@ -82,11 +83,29 @@ Future<void> initApp() async {
 | 
				
			|||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  PlatformDispatcher.instance.onError = (error, stack) {
 | 
					  PlatformDispatcher.instance.onError = (error, stack) {
 | 
				
			||||||
 | 
					    debugPrint("FlutterError - Catch all: $error");
 | 
				
			||||||
    log.severe('PlatformDispatcher - Catch all', error, stack);
 | 
					    log.severe('PlatformDispatcher - Catch all', error, stack);
 | 
				
			||||||
    return true;
 | 
					    return true;
 | 
				
			||||||
  };
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  initializeTimeZones();
 | 
					  initializeTimeZones();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  FileDownloader().configureNotification(
 | 
				
			||||||
 | 
					    running: TaskNotification(
 | 
				
			||||||
 | 
					      'downloading_media'.tr(),
 | 
				
			||||||
 | 
					      'file: {filename}',
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
 | 
					    complete: TaskNotification(
 | 
				
			||||||
 | 
					      'download_finished'.tr(),
 | 
				
			||||||
 | 
					      'file: {filename}',
 | 
				
			||||||
 | 
					    ),
 | 
				
			||||||
 | 
					    progressBar: true,
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  FileDownloader().trackTasksInGroup(
 | 
				
			||||||
 | 
					    downloadGroupLivePhoto,
 | 
				
			||||||
 | 
					    markDownloadedComplete: false,
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Future<Isar> loadDb() async {
 | 
					Future<Isar> loadDb() async {
 | 
				
			||||||
@ -188,8 +207,8 @@ class ImmichAppState extends ConsumerState<ImmichApp>
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
  @override
 | 
					  @override
 | 
				
			||||||
  Widget build(BuildContext context) {
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
    var router = ref.watch(appRouterProvider);
 | 
					    final router = ref.watch(appRouterProvider);
 | 
				
			||||||
    var immichTheme = ref.watch(immichThemeProvider);
 | 
					    final immichTheme = ref.watch(immichThemeProvider);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return MaterialApp(
 | 
					    return MaterialApp(
 | 
				
			||||||
      localizationsDelegates: context.localizationDelegates,
 | 
					      localizationsDelegates: context.localizationDelegates,
 | 
				
			||||||
 | 
				
			|||||||
@ -1,55 +0,0 @@
 | 
				
			|||||||
import 'dart:convert';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
enum DownloadAssetStatus { idle, loading, success, error }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class AssetViewerPageState {
 | 
					 | 
				
			||||||
  // enum
 | 
					 | 
				
			||||||
  final DownloadAssetStatus downloadAssetStatus;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  AssetViewerPageState({
 | 
					 | 
				
			||||||
    required this.downloadAssetStatus,
 | 
					 | 
				
			||||||
  });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  AssetViewerPageState copyWith({
 | 
					 | 
				
			||||||
    DownloadAssetStatus? downloadAssetStatus,
 | 
					 | 
				
			||||||
  }) {
 | 
					 | 
				
			||||||
    return AssetViewerPageState(
 | 
					 | 
				
			||||||
      downloadAssetStatus: downloadAssetStatus ?? this.downloadAssetStatus,
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  Map<String, dynamic> toMap() {
 | 
					 | 
				
			||||||
    final result = <String, dynamic>{};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    result.addAll({'downloadAssetStatus': downloadAssetStatus.index});
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return result;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  factory AssetViewerPageState.fromMap(Map<String, dynamic> map) {
 | 
					 | 
				
			||||||
    return AssetViewerPageState(
 | 
					 | 
				
			||||||
      downloadAssetStatus:
 | 
					 | 
				
			||||||
          DownloadAssetStatus.values[map['downloadAssetStatus'] ?? 0],
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  String toJson() => json.encode(toMap());
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  factory AssetViewerPageState.fromJson(String source) =>
 | 
					 | 
				
			||||||
      AssetViewerPageState.fromMap(json.decode(source));
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  @override
 | 
					 | 
				
			||||||
  String toString() =>
 | 
					 | 
				
			||||||
      'ImageViewerPageState(downloadAssetStatus: $downloadAssetStatus)';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  @override
 | 
					 | 
				
			||||||
  bool operator ==(Object other) {
 | 
					 | 
				
			||||||
    if (identical(this, other)) return true;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return other is AssetViewerPageState &&
 | 
					 | 
				
			||||||
        other.downloadAssetStatus == downloadAssetStatus;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  @override
 | 
					 | 
				
			||||||
  int get hashCode => downloadAssetStatus.hashCode;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
							
								
								
									
										109
									
								
								mobile/lib/models/download/download_state.model.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										109
									
								
								mobile/lib/models/download/download_state.model.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,109 @@
 | 
				
			|||||||
 | 
					// ignore_for_file: public_member_api_docs, sort_constructors_first
 | 
				
			||||||
 | 
					import 'dart:convert';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:background_downloader/background_downloader.dart';
 | 
				
			||||||
 | 
					import 'package:collection/collection.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class DownloadInfo {
 | 
				
			||||||
 | 
					  final String fileName;
 | 
				
			||||||
 | 
					  final double progress;
 | 
				
			||||||
 | 
					  // enum
 | 
				
			||||||
 | 
					  final TaskStatus status;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  DownloadInfo({
 | 
				
			||||||
 | 
					    required this.fileName,
 | 
				
			||||||
 | 
					    required this.progress,
 | 
				
			||||||
 | 
					    required this.status,
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  DownloadInfo copyWith({
 | 
				
			||||||
 | 
					    String? fileName,
 | 
				
			||||||
 | 
					    double? progress,
 | 
				
			||||||
 | 
					    TaskStatus? status,
 | 
				
			||||||
 | 
					  }) {
 | 
				
			||||||
 | 
					    return DownloadInfo(
 | 
				
			||||||
 | 
					      fileName: fileName ?? this.fileName,
 | 
				
			||||||
 | 
					      progress: progress ?? this.progress,
 | 
				
			||||||
 | 
					      status: status ?? this.status,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Map<String, dynamic> toMap() {
 | 
				
			||||||
 | 
					    return <String, dynamic>{
 | 
				
			||||||
 | 
					      'fileName': fileName,
 | 
				
			||||||
 | 
					      'progress': progress,
 | 
				
			||||||
 | 
					      'status': status.index,
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  factory DownloadInfo.fromMap(Map<String, dynamic> map) {
 | 
				
			||||||
 | 
					    return DownloadInfo(
 | 
				
			||||||
 | 
					      fileName: map['fileName'] as String,
 | 
				
			||||||
 | 
					      progress: map['progress'] as double,
 | 
				
			||||||
 | 
					      status: TaskStatus.values[map['status'] as int],
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  String toJson() => json.encode(toMap());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  factory DownloadInfo.fromJson(String source) =>
 | 
				
			||||||
 | 
					      DownloadInfo.fromMap(json.decode(source) as Map<String, dynamic>);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  String toString() =>
 | 
				
			||||||
 | 
					      'DownloadInfo(fileName: $fileName, progress: $progress, status: $status)';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  bool operator ==(covariant DownloadInfo other) {
 | 
				
			||||||
 | 
					    if (identical(this, other)) return true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return other.fileName == fileName &&
 | 
				
			||||||
 | 
					        other.progress == progress &&
 | 
				
			||||||
 | 
					        other.status == status;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  int get hashCode => fileName.hashCode ^ progress.hashCode ^ status.hashCode;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class DownloadState {
 | 
				
			||||||
 | 
					  // enum
 | 
				
			||||||
 | 
					  final TaskStatus downloadStatus;
 | 
				
			||||||
 | 
					  final Map<String, DownloadInfo> taskProgress;
 | 
				
			||||||
 | 
					  final bool showProgress;
 | 
				
			||||||
 | 
					  DownloadState({
 | 
				
			||||||
 | 
					    required this.downloadStatus,
 | 
				
			||||||
 | 
					    required this.taskProgress,
 | 
				
			||||||
 | 
					    required this.showProgress,
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  DownloadState copyWith({
 | 
				
			||||||
 | 
					    TaskStatus? downloadStatus,
 | 
				
			||||||
 | 
					    Map<String, DownloadInfo>? taskProgress,
 | 
				
			||||||
 | 
					    bool? showProgress,
 | 
				
			||||||
 | 
					  }) {
 | 
				
			||||||
 | 
					    return DownloadState(
 | 
				
			||||||
 | 
					      downloadStatus: downloadStatus ?? this.downloadStatus,
 | 
				
			||||||
 | 
					      taskProgress: taskProgress ?? this.taskProgress,
 | 
				
			||||||
 | 
					      showProgress: showProgress ?? this.showProgress,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  String toString() =>
 | 
				
			||||||
 | 
					      'DownloadState(downloadStatus: $downloadStatus, taskProgress: $taskProgress, showProgress: $showProgress)';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  bool operator ==(covariant DownloadState other) {
 | 
				
			||||||
 | 
					    if (identical(this, other)) return true;
 | 
				
			||||||
 | 
					    final mapEquals = const DeepCollectionEquality().equals;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return other.downloadStatus == downloadStatus &&
 | 
				
			||||||
 | 
					        mapEquals(other.taskProgress, taskProgress) &&
 | 
				
			||||||
 | 
					        other.showProgress == showProgress;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  int get hashCode =>
 | 
				
			||||||
 | 
					      downloadStatus.hashCode ^ taskProgress.hashCode ^ showProgress.hashCode;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										60
									
								
								mobile/lib/models/download/livephotos_medatada.model.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								mobile/lib/models/download/livephotos_medatada.model.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,60 @@
 | 
				
			|||||||
 | 
					// ignore_for_file: public_member_api_docs, sort_constructors_first
 | 
				
			||||||
 | 
					import 'dart:convert';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					enum LivePhotosPart {
 | 
				
			||||||
 | 
					  video,
 | 
				
			||||||
 | 
					  image,
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class LivePhotosMetadata {
 | 
				
			||||||
 | 
					  // enum
 | 
				
			||||||
 | 
					  LivePhotosPart part;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  String id;
 | 
				
			||||||
 | 
					  LivePhotosMetadata({
 | 
				
			||||||
 | 
					    required this.part,
 | 
				
			||||||
 | 
					    required this.id,
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  LivePhotosMetadata copyWith({
 | 
				
			||||||
 | 
					    LivePhotosPart? part,
 | 
				
			||||||
 | 
					    String? id,
 | 
				
			||||||
 | 
					  }) {
 | 
				
			||||||
 | 
					    return LivePhotosMetadata(
 | 
				
			||||||
 | 
					      part: part ?? this.part,
 | 
				
			||||||
 | 
					      id: id ?? this.id,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Map<String, dynamic> toMap() {
 | 
				
			||||||
 | 
					    return <String, dynamic>{
 | 
				
			||||||
 | 
					      'part': part.index,
 | 
				
			||||||
 | 
					      'id': id,
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  factory LivePhotosMetadata.fromMap(Map<String, dynamic> map) {
 | 
				
			||||||
 | 
					    return LivePhotosMetadata(
 | 
				
			||||||
 | 
					      part: LivePhotosPart.values[map['part'] as int],
 | 
				
			||||||
 | 
					      id: map['id'] as String,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  String toJson() => json.encode(toMap());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  factory LivePhotosMetadata.fromJson(String source) =>
 | 
				
			||||||
 | 
					      LivePhotosMetadata.fromMap(json.decode(source) as Map<String, dynamic>);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  String toString() => 'LivePhotosMetadata(part: $part, id: $id)';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  bool operator ==(covariant LivePhotosMetadata other) {
 | 
				
			||||||
 | 
					    if (identical(this, other)) return true;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return other.part == part && other.id == id;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  int get hashCode => part.hashCode ^ id.hashCode;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										150
									
								
								mobile/lib/pages/common/download_panel.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										150
									
								
								mobile/lib/pages/common/download_panel.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,150 @@
 | 
				
			|||||||
 | 
					import 'package:background_downloader/background_downloader.dart';
 | 
				
			||||||
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/extensions/build_context_extensions.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class DownloadPanel extends ConsumerWidget {
 | 
				
			||||||
 | 
					  const DownloadPanel({
 | 
				
			||||||
 | 
					    super.key,
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context, WidgetRef ref) {
 | 
				
			||||||
 | 
					    final showProgress = ref.watch(
 | 
				
			||||||
 | 
					      downloadStateProvider.select((state) => state.showProgress),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final tasks = ref
 | 
				
			||||||
 | 
					        .watch(
 | 
				
			||||||
 | 
					          downloadStateProvider.select((state) => state.taskProgress),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        .entries
 | 
				
			||||||
 | 
					        .toList();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    onCancelDownload(String id) {
 | 
				
			||||||
 | 
					      ref.watch(downloadStateProvider.notifier).cancelDownload(id);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return Positioned(
 | 
				
			||||||
 | 
					      bottom: 140,
 | 
				
			||||||
 | 
					      left: 16,
 | 
				
			||||||
 | 
					      child: AnimatedSwitcher(
 | 
				
			||||||
 | 
					        duration: const Duration(milliseconds: 300),
 | 
				
			||||||
 | 
					        child: showProgress
 | 
				
			||||||
 | 
					            ? ConstrainedBox(
 | 
				
			||||||
 | 
					                constraints:
 | 
				
			||||||
 | 
					                    BoxConstraints.loose(Size(context.width - 32, 300)),
 | 
				
			||||||
 | 
					                child: ListView.builder(
 | 
				
			||||||
 | 
					                  shrinkWrap: true,
 | 
				
			||||||
 | 
					                  itemCount: tasks.length,
 | 
				
			||||||
 | 
					                  itemBuilder: (context, index) {
 | 
				
			||||||
 | 
					                    final task = tasks[index];
 | 
				
			||||||
 | 
					                    return DownloadTaskTile(
 | 
				
			||||||
 | 
					                      progress: task.value.progress,
 | 
				
			||||||
 | 
					                      fileName: task.value.fileName,
 | 
				
			||||||
 | 
					                      status: task.value.status,
 | 
				
			||||||
 | 
					                      onCancelDownload: () => onCancelDownload(task.key),
 | 
				
			||||||
 | 
					                    );
 | 
				
			||||||
 | 
					                  },
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					              )
 | 
				
			||||||
 | 
					            : const SizedBox.shrink(key: ValueKey('no_progress')),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class DownloadTaskTile extends StatelessWidget {
 | 
				
			||||||
 | 
					  final double progress;
 | 
				
			||||||
 | 
					  final String fileName;
 | 
				
			||||||
 | 
					  final TaskStatus status;
 | 
				
			||||||
 | 
					  final VoidCallback onCancelDownload;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const DownloadTaskTile({
 | 
				
			||||||
 | 
					    super.key,
 | 
				
			||||||
 | 
					    required this.progress,
 | 
				
			||||||
 | 
					    required this.fileName,
 | 
				
			||||||
 | 
					    required this.status,
 | 
				
			||||||
 | 
					    required this.onCancelDownload,
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Widget build(BuildContext context) {
 | 
				
			||||||
 | 
					    final progressPercent = (progress * 100).round();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    getStatusText() {
 | 
				
			||||||
 | 
					      switch (status) {
 | 
				
			||||||
 | 
					        case TaskStatus.running:
 | 
				
			||||||
 | 
					          return 'downloading'.tr();
 | 
				
			||||||
 | 
					        case TaskStatus.complete:
 | 
				
			||||||
 | 
					          return 'download_complete'.tr();
 | 
				
			||||||
 | 
					        case TaskStatus.failed:
 | 
				
			||||||
 | 
					          return 'download_failed'.tr();
 | 
				
			||||||
 | 
					        case TaskStatus.canceled:
 | 
				
			||||||
 | 
					          return 'download_canceled'.tr();
 | 
				
			||||||
 | 
					        case TaskStatus.paused:
 | 
				
			||||||
 | 
					          return 'download_paused'.tr();
 | 
				
			||||||
 | 
					        case TaskStatus.enqueued:
 | 
				
			||||||
 | 
					          return 'download_enqueue'.tr();
 | 
				
			||||||
 | 
					        case TaskStatus.notFound:
 | 
				
			||||||
 | 
					          return 'download_notfound'.tr();
 | 
				
			||||||
 | 
					        case TaskStatus.waitingToRetry:
 | 
				
			||||||
 | 
					          return 'download_waiting_to_retry'.tr();
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return SizedBox(
 | 
				
			||||||
 | 
					      key: const ValueKey('download_progress'),
 | 
				
			||||||
 | 
					      width: MediaQuery.of(context).size.width - 32,
 | 
				
			||||||
 | 
					      child: Card(
 | 
				
			||||||
 | 
					        clipBehavior: Clip.antiAlias,
 | 
				
			||||||
 | 
					        shape: RoundedRectangleBorder(
 | 
				
			||||||
 | 
					          borderRadius: BorderRadius.circular(16),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					        child: ListTile(
 | 
				
			||||||
 | 
					          minVerticalPadding: 18,
 | 
				
			||||||
 | 
					          leading: const Icon(Icons.video_file_outlined),
 | 
				
			||||||
 | 
					          title: Text(
 | 
				
			||||||
 | 
					            getStatusText(),
 | 
				
			||||||
 | 
					            style: context.textTheme.labelLarge,
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          trailing: IconButton(
 | 
				
			||||||
 | 
					            icon: Icon(Icons.close, color: context.colorScheme.onError),
 | 
				
			||||||
 | 
					            onPressed: onCancelDownload,
 | 
				
			||||||
 | 
					            style: ElevatedButton.styleFrom(
 | 
				
			||||||
 | 
					              backgroundColor: context.colorScheme.error.withAlpha(200),
 | 
				
			||||||
 | 
					            ),
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					          subtitle: Column(
 | 
				
			||||||
 | 
					            crossAxisAlignment: CrossAxisAlignment.start,
 | 
				
			||||||
 | 
					            children: [
 | 
				
			||||||
 | 
					              Text(
 | 
				
			||||||
 | 
					                fileName,
 | 
				
			||||||
 | 
					                style: context.textTheme.labelMedium,
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					              Row(
 | 
				
			||||||
 | 
					                children: [
 | 
				
			||||||
 | 
					                  Expanded(
 | 
				
			||||||
 | 
					                    child: LinearProgressIndicator(
 | 
				
			||||||
 | 
					                      minHeight: 8.0,
 | 
				
			||||||
 | 
					                      value: progress,
 | 
				
			||||||
 | 
					                      borderRadius:
 | 
				
			||||||
 | 
					                          const BorderRadius.all(Radius.circular(10.0)),
 | 
				
			||||||
 | 
					                    ),
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                  const SizedBox(width: 8),
 | 
				
			||||||
 | 
					                  Text(
 | 
				
			||||||
 | 
					                    '$progressPercent%',
 | 
				
			||||||
 | 
					                    style: context.textTheme.labelSmall,
 | 
				
			||||||
 | 
					                  ),
 | 
				
			||||||
 | 
					                ],
 | 
				
			||||||
 | 
					              ),
 | 
				
			||||||
 | 
					            ],
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -11,6 +11,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
				
			|||||||
import 'package:immich_mobile/constants/constants.dart';
 | 
					import 'package:immich_mobile/constants/constants.dart';
 | 
				
			||||||
import 'package:immich_mobile/entities/asset.entity.dart';
 | 
					import 'package:immich_mobile/entities/asset.entity.dart';
 | 
				
			||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
 | 
					import 'package:immich_mobile/extensions/build_context_extensions.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/pages/common/download_panel.dart';
 | 
				
			||||||
import 'package:immich_mobile/pages/common/video_viewer.page.dart';
 | 
					import 'package:immich_mobile/pages/common/video_viewer.page.dart';
 | 
				
			||||||
import 'package:immich_mobile/providers/app_settings.provider.dart';
 | 
					import 'package:immich_mobile/providers/app_settings.provider.dart';
 | 
				
			||||||
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
 | 
					import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
 | 
				
			||||||
@ -421,6 +422,7 @@ class GalleryViewerPage extends HookConsumerWidget {
 | 
				
			|||||||
                ],
 | 
					                ],
 | 
				
			||||||
              ),
 | 
					              ),
 | 
				
			||||||
            ),
 | 
					            ),
 | 
				
			||||||
 | 
					            const DownloadPanel(),
 | 
				
			||||||
          ],
 | 
					          ],
 | 
				
			||||||
        ),
 | 
					        ),
 | 
				
			||||||
      ),
 | 
					      ),
 | 
				
			||||||
 | 
				
			|||||||
							
								
								
									
										191
									
								
								mobile/lib/providers/asset_viewer/download.provider.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										191
									
								
								mobile/lib/providers/asset_viewer/download.provider.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,191 @@
 | 
				
			|||||||
 | 
					import 'package:background_downloader/background_downloader.dart';
 | 
				
			||||||
 | 
					import 'package:easy_localization/easy_localization.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:fluttertoast/fluttertoast.dart';
 | 
				
			||||||
 | 
					import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/extensions/build_context_extensions.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/models/download/download_state.model.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/services/download.service.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/entities/asset.entity.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/services/share.service.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/widgets/common/immich_toast.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/widgets/common/share_dialog.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class DownloadStateNotifier extends StateNotifier<DownloadState> {
 | 
				
			||||||
 | 
					  final DownloadService _downloadService;
 | 
				
			||||||
 | 
					  final ShareService _shareService;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  DownloadStateNotifier(
 | 
				
			||||||
 | 
					    this._downloadService,
 | 
				
			||||||
 | 
					    this._shareService,
 | 
				
			||||||
 | 
					  ) : super(
 | 
				
			||||||
 | 
					          DownloadState(
 | 
				
			||||||
 | 
					            downloadStatus: TaskStatus.complete,
 | 
				
			||||||
 | 
					            showProgress: false,
 | 
				
			||||||
 | 
					            taskProgress: <String, DownloadInfo>{},
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        ) {
 | 
				
			||||||
 | 
					    _downloadService.onImageDownloadStatus = _downloadImageCallback;
 | 
				
			||||||
 | 
					    _downloadService.onVideoDownloadStatus = _downloadVideoCallback;
 | 
				
			||||||
 | 
					    _downloadService.onLivePhotoDownloadStatus = _downloadLivePhotoCallback;
 | 
				
			||||||
 | 
					    _downloadService.onTaskProgress = _taskProgressCallback;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _updateDownloadStatus(String taskId, TaskStatus status) {
 | 
				
			||||||
 | 
					    if (status == TaskStatus.canceled) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    state = state.copyWith(
 | 
				
			||||||
 | 
					      taskProgress: <String, DownloadInfo>{}
 | 
				
			||||||
 | 
					        ..addAll(state.taskProgress)
 | 
				
			||||||
 | 
					        ..addAll({
 | 
				
			||||||
 | 
					          taskId: DownloadInfo(
 | 
				
			||||||
 | 
					            progress: state.taskProgress[taskId]?.progress ?? 0,
 | 
				
			||||||
 | 
					            fileName: state.taskProgress[taskId]?.fileName ?? '',
 | 
				
			||||||
 | 
					            status: status,
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        }),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Download live photo callback
 | 
				
			||||||
 | 
					  void _downloadLivePhotoCallback(TaskStatusUpdate update) {
 | 
				
			||||||
 | 
					    _updateDownloadStatus(update.task.taskId, update.status);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    switch (update.status) {
 | 
				
			||||||
 | 
					      case TaskStatus.complete:
 | 
				
			||||||
 | 
					        if (update.task.metaData.isEmpty) {
 | 
				
			||||||
 | 
					          return;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        final livePhotosId =
 | 
				
			||||||
 | 
					            LivePhotosMetadata.fromJson(update.task.metaData).id;
 | 
				
			||||||
 | 
					        _downloadService.saveLivePhotos(update.task, livePhotosId);
 | 
				
			||||||
 | 
					        _onDownloadComplete(update.task.taskId);
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      default:
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Download image callback
 | 
				
			||||||
 | 
					  void _downloadImageCallback(TaskStatusUpdate update) {
 | 
				
			||||||
 | 
					    _updateDownloadStatus(update.task.taskId, update.status);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    switch (update.status) {
 | 
				
			||||||
 | 
					      case TaskStatus.complete:
 | 
				
			||||||
 | 
					        _downloadService.saveImage(update.task);
 | 
				
			||||||
 | 
					        _onDownloadComplete(update.task.taskId);
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      default:
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Download video callback
 | 
				
			||||||
 | 
					  void _downloadVideoCallback(TaskStatusUpdate update) {
 | 
				
			||||||
 | 
					    _updateDownloadStatus(update.task.taskId, update.status);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    switch (update.status) {
 | 
				
			||||||
 | 
					      case TaskStatus.complete:
 | 
				
			||||||
 | 
					        _downloadService.saveVideo(update.task);
 | 
				
			||||||
 | 
					        _onDownloadComplete(update.task.taskId);
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      default:
 | 
				
			||||||
 | 
					        break;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _taskProgressCallback(TaskProgressUpdate update) {
 | 
				
			||||||
 | 
					    // Ignore if the task is cancled or completed
 | 
				
			||||||
 | 
					    if (update.progress == -2 || update.progress == -1) {
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    state = state.copyWith(
 | 
				
			||||||
 | 
					      showProgress: true,
 | 
				
			||||||
 | 
					      taskProgress: <String, DownloadInfo>{}
 | 
				
			||||||
 | 
					        ..addAll(state.taskProgress)
 | 
				
			||||||
 | 
					        ..addAll({
 | 
				
			||||||
 | 
					          update.task.taskId: DownloadInfo(
 | 
				
			||||||
 | 
					            progress: update.progress,
 | 
				
			||||||
 | 
					            fileName: update.task.filename,
 | 
				
			||||||
 | 
					            status: TaskStatus.running,
 | 
				
			||||||
 | 
					          ),
 | 
				
			||||||
 | 
					        }),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _onDownloadComplete(String id) {
 | 
				
			||||||
 | 
					    Future.delayed(const Duration(seconds: 2), () {
 | 
				
			||||||
 | 
					      state = state.copyWith(
 | 
				
			||||||
 | 
					        taskProgress: <String, DownloadInfo>{}
 | 
				
			||||||
 | 
					          ..addAll(state.taskProgress)
 | 
				
			||||||
 | 
					          ..remove(id),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      if (state.taskProgress.isEmpty) {
 | 
				
			||||||
 | 
					        state = state.copyWith(
 | 
				
			||||||
 | 
					          showProgress: false,
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void downloadAsset(Asset asset, BuildContext context) async {
 | 
				
			||||||
 | 
					    await _downloadService.download(asset);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void cancelDownload(String id) async {
 | 
				
			||||||
 | 
					    final isCanceled = await _downloadService.cancelDownload(id);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (isCanceled) {
 | 
				
			||||||
 | 
					      state = state.copyWith(
 | 
				
			||||||
 | 
					        taskProgress: <String, DownloadInfo>{}
 | 
				
			||||||
 | 
					          ..addAll(state.taskProgress)
 | 
				
			||||||
 | 
					          ..remove(id),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (state.taskProgress.isEmpty) {
 | 
				
			||||||
 | 
					      state = state.copyWith(
 | 
				
			||||||
 | 
					        showProgress: false,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void shareAsset(Asset asset, BuildContext context) async {
 | 
				
			||||||
 | 
					    showDialog(
 | 
				
			||||||
 | 
					      context: context,
 | 
				
			||||||
 | 
					      builder: (BuildContext buildContext) {
 | 
				
			||||||
 | 
					        _shareService.shareAsset(asset, context).then(
 | 
				
			||||||
 | 
					          (bool status) {
 | 
				
			||||||
 | 
					            if (!status) {
 | 
				
			||||||
 | 
					              ImmichToast.show(
 | 
				
			||||||
 | 
					                context: context,
 | 
				
			||||||
 | 
					                msg: 'image_viewer_page_state_provider_share_error'.tr(),
 | 
				
			||||||
 | 
					                toastType: ToastType.error,
 | 
				
			||||||
 | 
					                gravity: ToastGravity.BOTTOM,
 | 
				
			||||||
 | 
					              );
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					            buildContext.pop();
 | 
				
			||||||
 | 
					          },
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        return const ShareDialog();
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      barrierDismissible: false,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					final downloadStateProvider =
 | 
				
			||||||
 | 
					    StateNotifierProvider<DownloadStateNotifier, DownloadState>(
 | 
				
			||||||
 | 
					  ((ref) => DownloadStateNotifier(
 | 
				
			||||||
 | 
					        ref.watch(downloadServiceProvider),
 | 
				
			||||||
 | 
					        ref.watch(shareServiceProvider),
 | 
				
			||||||
 | 
					      )),
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
@ -1,99 +0,0 @@
 | 
				
			|||||||
import 'dart:io';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import 'package:easy_localization/easy_localization.dart';
 | 
					 | 
				
			||||||
import 'package:flutter/material.dart';
 | 
					 | 
				
			||||||
import 'package:fluttertoast/fluttertoast.dart';
 | 
					 | 
				
			||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
					 | 
				
			||||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
 | 
					 | 
				
			||||||
import 'package:immich_mobile/services/album.service.dart';
 | 
					 | 
				
			||||||
import 'package:immich_mobile/models/asset_viewer/asset_viewer_page_state.model.dart';
 | 
					 | 
				
			||||||
import 'package:immich_mobile/services/image_viewer.service.dart';
 | 
					 | 
				
			||||||
import 'package:immich_mobile/entities/asset.entity.dart';
 | 
					 | 
				
			||||||
import 'package:immich_mobile/services/share.service.dart';
 | 
					 | 
				
			||||||
import 'package:immich_mobile/widgets/common/immich_toast.dart';
 | 
					 | 
				
			||||||
import 'package:immich_mobile/widgets/common/share_dialog.dart';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class ImageViewerStateNotifier extends StateNotifier<AssetViewerPageState> {
 | 
					 | 
				
			||||||
  final ImageViewerService _imageViewerService;
 | 
					 | 
				
			||||||
  final ShareService _shareService;
 | 
					 | 
				
			||||||
  final AlbumService _albumService;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  ImageViewerStateNotifier(
 | 
					 | 
				
			||||||
    this._imageViewerService,
 | 
					 | 
				
			||||||
    this._shareService,
 | 
					 | 
				
			||||||
    this._albumService,
 | 
					 | 
				
			||||||
  ) : super(
 | 
					 | 
				
			||||||
          AssetViewerPageState(
 | 
					 | 
				
			||||||
            downloadAssetStatus: DownloadAssetStatus.idle,
 | 
					 | 
				
			||||||
          ),
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  void downloadAsset(Asset asset, BuildContext context) async {
 | 
					 | 
				
			||||||
    state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.loading);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    ImmichToast.show(
 | 
					 | 
				
			||||||
      context: context,
 | 
					 | 
				
			||||||
      msg: 'download_started'.tr(),
 | 
					 | 
				
			||||||
      toastType: ToastType.info,
 | 
					 | 
				
			||||||
      gravity: ToastGravity.BOTTOM,
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    bool isSuccess = await _imageViewerService.downloadAsset(asset);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (isSuccess) {
 | 
					 | 
				
			||||||
      state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.success);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      ImmichToast.show(
 | 
					 | 
				
			||||||
        context: context,
 | 
					 | 
				
			||||||
        msg: Platform.isAndroid
 | 
					 | 
				
			||||||
            ? 'download_sucess_android'.tr()
 | 
					 | 
				
			||||||
            : 'download_sucess'.tr(),
 | 
					 | 
				
			||||||
        toastType: ToastType.success,
 | 
					 | 
				
			||||||
        gravity: ToastGravity.BOTTOM,
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
      _albumService.refreshDeviceAlbums();
 | 
					 | 
				
			||||||
    } else {
 | 
					 | 
				
			||||||
      state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.error);
 | 
					 | 
				
			||||||
      ImmichToast.show(
 | 
					 | 
				
			||||||
        context: context,
 | 
					 | 
				
			||||||
        msg: 'download_error'.tr(),
 | 
					 | 
				
			||||||
        toastType: ToastType.error,
 | 
					 | 
				
			||||||
        gravity: ToastGravity.BOTTOM,
 | 
					 | 
				
			||||||
      );
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    state = state.copyWith(downloadAssetStatus: DownloadAssetStatus.idle);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  void shareAsset(Asset asset, BuildContext context) async {
 | 
					 | 
				
			||||||
    showDialog(
 | 
					 | 
				
			||||||
      context: context,
 | 
					 | 
				
			||||||
      builder: (BuildContext buildContext) {
 | 
					 | 
				
			||||||
        _shareService.shareAsset(asset, context).then(
 | 
					 | 
				
			||||||
          (bool status) {
 | 
					 | 
				
			||||||
            if (!status) {
 | 
					 | 
				
			||||||
              ImmichToast.show(
 | 
					 | 
				
			||||||
                context: context,
 | 
					 | 
				
			||||||
                msg: 'image_viewer_page_state_provider_share_error'.tr(),
 | 
					 | 
				
			||||||
                toastType: ToastType.error,
 | 
					 | 
				
			||||||
                gravity: ToastGravity.BOTTOM,
 | 
					 | 
				
			||||||
              );
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
            buildContext.pop();
 | 
					 | 
				
			||||||
          },
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
        return const ShareDialog();
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      barrierDismissible: false,
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
final imageViewerStateProvider =
 | 
					 | 
				
			||||||
    StateNotifierProvider<ImageViewerStateNotifier, AssetViewerPageState>(
 | 
					 | 
				
			||||||
  ((ref) => ImageViewerStateNotifier(
 | 
					 | 
				
			||||||
        ref.watch(imageViewerServiceProvider),
 | 
					 | 
				
			||||||
        ref.watch(shareServiceProvider),
 | 
					 | 
				
			||||||
        ref.watch(albumServiceProvider),
 | 
					 | 
				
			||||||
      )),
 | 
					 | 
				
			||||||
);
 | 
					 | 
				
			||||||
							
								
								
									
										68
									
								
								mobile/lib/repositories/download.repository.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										68
									
								
								mobile/lib/repositories/download.repository.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,68 @@
 | 
				
			|||||||
 | 
					import 'package:background_downloader/background_downloader.dart';
 | 
				
			||||||
 | 
					import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/interfaces/download.interface.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/utils/download.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					final downloadRepositoryProvider = Provider((ref) => DownloadRepository());
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class DownloadRepository implements IDownloadRepository {
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void Function(TaskStatusUpdate)? onImageDownloadStatus;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void Function(TaskStatusUpdate)? onVideoDownloadStatus;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  void Function(TaskProgressUpdate)? onTaskProgress;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  DownloadRepository() {
 | 
				
			||||||
 | 
					    FileDownloader().registerCallbacks(
 | 
				
			||||||
 | 
					      group: downloadGroupImage,
 | 
				
			||||||
 | 
					      taskStatusCallback: (update) => onImageDownloadStatus?.call(update),
 | 
				
			||||||
 | 
					      taskProgressCallback: (update) => onTaskProgress?.call(update),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    FileDownloader().registerCallbacks(
 | 
				
			||||||
 | 
					      group: downloadGroupVideo,
 | 
				
			||||||
 | 
					      taskStatusCallback: (update) => onVideoDownloadStatus?.call(update),
 | 
				
			||||||
 | 
					      taskProgressCallback: (update) => onTaskProgress?.call(update),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    FileDownloader().registerCallbacks(
 | 
				
			||||||
 | 
					      group: downloadGroupLivePhoto,
 | 
				
			||||||
 | 
					      taskStatusCallback: (update) => onLivePhotoDownloadStatus?.call(update),
 | 
				
			||||||
 | 
					      taskProgressCallback: (update) => onTaskProgress?.call(update),
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Future<bool> download(DownloadTask task) {
 | 
				
			||||||
 | 
					    return FileDownloader().enqueue(task);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Future<void> deleteAllTrackingRecords() {
 | 
				
			||||||
 | 
					    return FileDownloader().database.deleteAllRecords();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Future<bool> cancel(String id) {
 | 
				
			||||||
 | 
					    return FileDownloader().cancelTaskWithId(id);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Future<List<TaskRecord>> getLiveVideoTasks() {
 | 
				
			||||||
 | 
					    return FileDownloader().database.allRecordsWithStatus(
 | 
				
			||||||
 | 
					          TaskStatus.complete,
 | 
				
			||||||
 | 
					          group: downloadGroupLivePhoto,
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @override
 | 
				
			||||||
 | 
					  Future<void> deleteRecordsWithIds(List<String> ids) {
 | 
				
			||||||
 | 
					    return FileDownloader().database.deleteRecordsWithIds(ids);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
							
								
								
									
										193
									
								
								mobile/lib/services/download.service.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										193
									
								
								mobile/lib/services/download.service.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,193 @@
 | 
				
			|||||||
 | 
					import 'dart:io';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import 'package:background_downloader/background_downloader.dart';
 | 
				
			||||||
 | 
					import 'package:flutter/material.dart';
 | 
				
			||||||
 | 
					import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/entities/store.entity.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/entities/asset.entity.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/interfaces/download.interface.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/interfaces/file_media.interface.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/models/download/livephotos_medatada.model.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/repositories/download.repository.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/repositories/file_media.repository.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/services/api.service.dart';
 | 
				
			||||||
 | 
					import 'package:immich_mobile/utils/download.dart';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					final downloadServiceProvider = Provider(
 | 
				
			||||||
 | 
					  (ref) => DownloadService(
 | 
				
			||||||
 | 
					    ref.watch(fileMediaRepositoryProvider),
 | 
				
			||||||
 | 
					    ref.watch(downloadRepositoryProvider),
 | 
				
			||||||
 | 
					  ),
 | 
				
			||||||
 | 
					);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class DownloadService {
 | 
				
			||||||
 | 
					  final IDownloadRepository _downloadRepository;
 | 
				
			||||||
 | 
					  final IFileMediaRepository _fileMediaRepository;
 | 
				
			||||||
 | 
					  void Function(TaskStatusUpdate)? onImageDownloadStatus;
 | 
				
			||||||
 | 
					  void Function(TaskStatusUpdate)? onVideoDownloadStatus;
 | 
				
			||||||
 | 
					  void Function(TaskStatusUpdate)? onLivePhotoDownloadStatus;
 | 
				
			||||||
 | 
					  void Function(TaskProgressUpdate)? onTaskProgress;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  DownloadService(
 | 
				
			||||||
 | 
					    this._fileMediaRepository,
 | 
				
			||||||
 | 
					    this._downloadRepository,
 | 
				
			||||||
 | 
					  ) {
 | 
				
			||||||
 | 
					    _downloadRepository.onImageDownloadStatus = _onImageDownloadCallback;
 | 
				
			||||||
 | 
					    _downloadRepository.onVideoDownloadStatus = _onVideoDownloadCallback;
 | 
				
			||||||
 | 
					    _downloadRepository.onLivePhotoDownloadStatus =
 | 
				
			||||||
 | 
					        _onLivePhotoDownloadCallback;
 | 
				
			||||||
 | 
					    _downloadRepository.onTaskProgress = _onTaskProgressCallback;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _onTaskProgressCallback(TaskProgressUpdate update) {
 | 
				
			||||||
 | 
					    onTaskProgress?.call(update);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _onImageDownloadCallback(TaskStatusUpdate update) {
 | 
				
			||||||
 | 
					    onImageDownloadStatus?.call(update);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _onVideoDownloadCallback(TaskStatusUpdate update) {
 | 
				
			||||||
 | 
					    onVideoDownloadStatus?.call(update);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  void _onLivePhotoDownloadCallback(TaskStatusUpdate update) {
 | 
				
			||||||
 | 
					    onLivePhotoDownloadStatus?.call(update);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<bool> saveImage(Task task) async {
 | 
				
			||||||
 | 
					    final filePath = await task.filePath();
 | 
				
			||||||
 | 
					    final title = task.filename;
 | 
				
			||||||
 | 
					    final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null;
 | 
				
			||||||
 | 
					    final data = await File(filePath).readAsBytes();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final Asset? resultAsset = await _fileMediaRepository.saveImage(
 | 
				
			||||||
 | 
					      data,
 | 
				
			||||||
 | 
					      title: title,
 | 
				
			||||||
 | 
					      relativePath: relativePath,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return resultAsset != null;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<bool> saveVideo(Task task) async {
 | 
				
			||||||
 | 
					    final filePath = await task.filePath();
 | 
				
			||||||
 | 
					    final title = task.filename;
 | 
				
			||||||
 | 
					    final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null;
 | 
				
			||||||
 | 
					    final file = File(filePath);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    final Asset? resultAsset = await _fileMediaRepository.saveVideo(
 | 
				
			||||||
 | 
					      file,
 | 
				
			||||||
 | 
					      title: title,
 | 
				
			||||||
 | 
					      relativePath: relativePath,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return resultAsset != null;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<bool> saveLivePhotos(
 | 
				
			||||||
 | 
					    Task task,
 | 
				
			||||||
 | 
					    String livePhotosId,
 | 
				
			||||||
 | 
					  ) async {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      final records = await _downloadRepository.getLiveVideoTasks();
 | 
				
			||||||
 | 
					      if (records.length < 2) {
 | 
				
			||||||
 | 
					        return false;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      final imageRecord = records.firstWhere(
 | 
				
			||||||
 | 
					        (record) {
 | 
				
			||||||
 | 
					          final metadata = LivePhotosMetadata.fromJson(record.task.metaData);
 | 
				
			||||||
 | 
					          return metadata.id == livePhotosId &&
 | 
				
			||||||
 | 
					              metadata.part == LivePhotosPart.image;
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      final videoRecord = records.firstWhere((record) {
 | 
				
			||||||
 | 
					        final metadata = LivePhotosMetadata.fromJson(record.task.metaData);
 | 
				
			||||||
 | 
					        return metadata.id == livePhotosId &&
 | 
				
			||||||
 | 
					            metadata.part == LivePhotosPart.video;
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      final imageFilePath = await imageRecord.task.filePath();
 | 
				
			||||||
 | 
					      final videoFilePath = await videoRecord.task.filePath();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      final resultAsset = await _fileMediaRepository.saveLivePhoto(
 | 
				
			||||||
 | 
					        image: File(imageFilePath),
 | 
				
			||||||
 | 
					        video: File(videoFilePath),
 | 
				
			||||||
 | 
					        title: task.filename,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await _downloadRepository.deleteRecordsWithIds([
 | 
				
			||||||
 | 
					        imageRecord.task.taskId,
 | 
				
			||||||
 | 
					        videoRecord.task.taskId,
 | 
				
			||||||
 | 
					      ]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      return resultAsset != null;
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      debugPrint("Error saving live photo: $error");
 | 
				
			||||||
 | 
					      return false;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<bool> cancelDownload(String id) async {
 | 
				
			||||||
 | 
					    return await FileDownloader().cancelTaskWithId(id);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  Future<void> download(Asset asset) async {
 | 
				
			||||||
 | 
					    if (asset.isImage && asset.livePhotoVideoId != null && Platform.isIOS) {
 | 
				
			||||||
 | 
					      await _downloadRepository.download(
 | 
				
			||||||
 | 
					        _buildDownloadTask(
 | 
				
			||||||
 | 
					          asset.remoteId!,
 | 
				
			||||||
 | 
					          asset.fileName,
 | 
				
			||||||
 | 
					          group: downloadGroupLivePhoto,
 | 
				
			||||||
 | 
					          metadata: LivePhotosMetadata(
 | 
				
			||||||
 | 
					            part: LivePhotosPart.image,
 | 
				
			||||||
 | 
					            id: asset.remoteId!,
 | 
				
			||||||
 | 
					          ).toJson(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await _downloadRepository.download(
 | 
				
			||||||
 | 
					        _buildDownloadTask(
 | 
				
			||||||
 | 
					          asset.livePhotoVideoId!,
 | 
				
			||||||
 | 
					          asset.fileName.toUpperCase().replaceAll(".HEIC", '.MOV'),
 | 
				
			||||||
 | 
					          group: downloadGroupLivePhoto,
 | 
				
			||||||
 | 
					          metadata: LivePhotosMetadata(
 | 
				
			||||||
 | 
					            part: LivePhotosPart.video,
 | 
				
			||||||
 | 
					            id: asset.remoteId!,
 | 
				
			||||||
 | 
					          ).toJson(),
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      await _downloadRepository.download(
 | 
				
			||||||
 | 
					        _buildDownloadTask(
 | 
				
			||||||
 | 
					          asset.remoteId!,
 | 
				
			||||||
 | 
					          asset.fileName,
 | 
				
			||||||
 | 
					          group: asset.isImage ? downloadGroupImage : downloadGroupVideo,
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  DownloadTask _buildDownloadTask(
 | 
				
			||||||
 | 
					    String id,
 | 
				
			||||||
 | 
					    String filename, {
 | 
				
			||||||
 | 
					    String? group,
 | 
				
			||||||
 | 
					    String? metadata,
 | 
				
			||||||
 | 
					  }) {
 | 
				
			||||||
 | 
					    final path = r'/assets/{id}/original'.replaceAll('{id}', id);
 | 
				
			||||||
 | 
					    final serverEndpoint = Store.get(StoreKey.serverEndpoint);
 | 
				
			||||||
 | 
					    final headers = ApiService.getRequestHeaders();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return DownloadTask(
 | 
				
			||||||
 | 
					      taskId: id,
 | 
				
			||||||
 | 
					      url: serverEndpoint + path,
 | 
				
			||||||
 | 
					      headers: headers,
 | 
				
			||||||
 | 
					      filename: filename,
 | 
				
			||||||
 | 
					      updates: Updates.statusAndProgress,
 | 
				
			||||||
 | 
					      group: group ?? '',
 | 
				
			||||||
 | 
					      metaData: metadata ?? '',
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
@ -1,117 +0,0 @@
 | 
				
			|||||||
import 'dart:io';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
					 | 
				
			||||||
import 'package:immich_mobile/extensions/response_extensions.dart';
 | 
					 | 
				
			||||||
import 'package:immich_mobile/entities/asset.entity.dart';
 | 
					 | 
				
			||||||
import 'package:immich_mobile/interfaces/file_media.interface.dart';
 | 
					 | 
				
			||||||
import 'package:immich_mobile/providers/api.provider.dart';
 | 
					 | 
				
			||||||
import 'package:immich_mobile/repositories/file_media.repository.dart';
 | 
					 | 
				
			||||||
import 'package:immich_mobile/services/api.service.dart';
 | 
					 | 
				
			||||||
import 'package:logging/logging.dart';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import 'package:path_provider/path_provider.dart';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
final imageViewerServiceProvider = Provider(
 | 
					 | 
				
			||||||
  (ref) => ImageViewerService(
 | 
					 | 
				
			||||||
    ref.watch(apiServiceProvider),
 | 
					 | 
				
			||||||
    ref.watch(fileMediaRepositoryProvider),
 | 
					 | 
				
			||||||
  ),
 | 
					 | 
				
			||||||
);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class ImageViewerService {
 | 
					 | 
				
			||||||
  final ApiService _apiService;
 | 
					 | 
				
			||||||
  final IFileMediaRepository _fileMediaRepository;
 | 
					 | 
				
			||||||
  final Logger _log = Logger("ImageViewerService");
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  ImageViewerService(this._apiService, this._fileMediaRepository);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  Future<bool> downloadAsset(Asset asset) async {
 | 
					 | 
				
			||||||
    File? imageFile;
 | 
					 | 
				
			||||||
    File? videoFile;
 | 
					 | 
				
			||||||
    try {
 | 
					 | 
				
			||||||
      // Download LivePhotos image and motion part
 | 
					 | 
				
			||||||
      if (asset.isImage && asset.livePhotoVideoId != null && Platform.isIOS) {
 | 
					 | 
				
			||||||
        var imageResponse =
 | 
					 | 
				
			||||||
            await _apiService.assetsApi.downloadAssetWithHttpInfo(
 | 
					 | 
				
			||||||
          asset.remoteId!,
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        var motionResponse =
 | 
					 | 
				
			||||||
            await _apiService.assetsApi.downloadAssetWithHttpInfo(
 | 
					 | 
				
			||||||
          asset.livePhotoVideoId!,
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if (imageResponse.statusCode != 200 ||
 | 
					 | 
				
			||||||
            motionResponse.statusCode != 200) {
 | 
					 | 
				
			||||||
          final failedResponse =
 | 
					 | 
				
			||||||
              imageResponse.statusCode != 200 ? imageResponse : motionResponse;
 | 
					 | 
				
			||||||
          _log.severe(
 | 
					 | 
				
			||||||
            "Motion asset download failed",
 | 
					 | 
				
			||||||
            failedResponse.toLoggerString(),
 | 
					 | 
				
			||||||
          );
 | 
					 | 
				
			||||||
          return false;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        Asset? resultAsset;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        final tempDir = await getTemporaryDirectory();
 | 
					 | 
				
			||||||
        videoFile = await File('${tempDir.path}/livephoto.mov').create();
 | 
					 | 
				
			||||||
        imageFile = await File('${tempDir.path}/livephoto.heic').create();
 | 
					 | 
				
			||||||
        videoFile.writeAsBytesSync(motionResponse.bodyBytes);
 | 
					 | 
				
			||||||
        imageFile.writeAsBytesSync(imageResponse.bodyBytes);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        resultAsset = await _fileMediaRepository.saveLivePhoto(
 | 
					 | 
				
			||||||
          image: imageFile,
 | 
					 | 
				
			||||||
          video: videoFile,
 | 
					 | 
				
			||||||
          title: asset.fileName,
 | 
					 | 
				
			||||||
        );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if (resultAsset == null) {
 | 
					 | 
				
			||||||
          _log.warning(
 | 
					 | 
				
			||||||
            "Asset cannot be saved as a live photo. This is most likely a motion photo. Saving only the image file",
 | 
					 | 
				
			||||||
          );
 | 
					 | 
				
			||||||
          resultAsset = await _fileMediaRepository
 | 
					 | 
				
			||||||
              .saveImage(imageResponse.bodyBytes, title: asset.fileName);
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return resultAsset != null;
 | 
					 | 
				
			||||||
      } else {
 | 
					 | 
				
			||||||
        var res = await _apiService.assetsApi
 | 
					 | 
				
			||||||
            .downloadAssetWithHttpInfo(asset.remoteId!);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if (res.statusCode != 200) {
 | 
					 | 
				
			||||||
          _log.severe("Asset download failed", res.toLoggerString());
 | 
					 | 
				
			||||||
          return false;
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        final Asset? resultAsset;
 | 
					 | 
				
			||||||
        final relativePath = Platform.isAndroid ? 'DCIM/Immich' : null;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if (asset.isImage) {
 | 
					 | 
				
			||||||
          resultAsset = await _fileMediaRepository.saveImage(
 | 
					 | 
				
			||||||
            res.bodyBytes,
 | 
					 | 
				
			||||||
            title: asset.fileName,
 | 
					 | 
				
			||||||
            relativePath: relativePath,
 | 
					 | 
				
			||||||
          );
 | 
					 | 
				
			||||||
        } else {
 | 
					 | 
				
			||||||
          final tempDir = await getTemporaryDirectory();
 | 
					 | 
				
			||||||
          videoFile = await File('${tempDir.path}/${asset.fileName}').create();
 | 
					 | 
				
			||||||
          videoFile.writeAsBytesSync(res.bodyBytes);
 | 
					 | 
				
			||||||
          resultAsset = await _fileMediaRepository.saveVideo(
 | 
					 | 
				
			||||||
            videoFile,
 | 
					 | 
				
			||||||
            title: asset.fileName,
 | 
					 | 
				
			||||||
            relativePath: relativePath,
 | 
					 | 
				
			||||||
          );
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        return resultAsset != null;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    } catch (error, stack) {
 | 
					 | 
				
			||||||
      _log.severe("Error saving downloaded asset", error, stack);
 | 
					 | 
				
			||||||
      return false;
 | 
					 | 
				
			||||||
    } finally {
 | 
					 | 
				
			||||||
      // Clear temp files
 | 
					 | 
				
			||||||
      imageFile?.delete();
 | 
					 | 
				
			||||||
      videoFile?.delete();
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
							
								
								
									
										3
									
								
								mobile/lib/utils/download.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								mobile/lib/utils/download.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,3 @@
 | 
				
			|||||||
 | 
					const downloadGroupImage = 'group_image';
 | 
				
			||||||
 | 
					const downloadGroupVideo = 'group_video';
 | 
				
			||||||
 | 
					const downloadGroupLivePhoto = 'group_livephoto';
 | 
				
			||||||
@ -9,7 +9,7 @@ import 'package:immich_mobile/extensions/build_context_extensions.dart';
 | 
				
			|||||||
import 'package:immich_mobile/providers/album/current_album.provider.dart';
 | 
					import 'package:immich_mobile/providers/album/current_album.provider.dart';
 | 
				
			||||||
import 'package:immich_mobile/providers/album/shared_album.provider.dart';
 | 
					import 'package:immich_mobile/providers/album/shared_album.provider.dart';
 | 
				
			||||||
import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
 | 
					import 'package:immich_mobile/providers/asset_viewer/asset_stack.provider.dart';
 | 
				
			||||||
import 'package:immich_mobile/providers/asset_viewer/image_viewer_page_state.provider.dart';
 | 
					import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
 | 
				
			||||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
 | 
					import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
 | 
				
			||||||
import 'package:immich_mobile/services/stack.service.dart';
 | 
					import 'package:immich_mobile/services/stack.service.dart';
 | 
				
			||||||
import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
 | 
					import 'package:immich_mobile/widgets/asset_grid/asset_grid_data_structure.dart';
 | 
				
			||||||
@ -172,7 +172,16 @@ class BottomGalleryBar extends ConsumerWidget {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    shareAsset() {
 | 
					    shareAsset() {
 | 
				
			||||||
      ref.read(imageViewerStateProvider.notifier).shareAsset(asset, context);
 | 
					      if (asset.isOffline) {
 | 
				
			||||||
 | 
					        ImmichToast.show(
 | 
				
			||||||
 | 
					          durationInSecond: 1,
 | 
				
			||||||
 | 
					          context: context,
 | 
				
			||||||
 | 
					          msg: 'asset_action_share_err_offline'.tr(),
 | 
				
			||||||
 | 
					          gravity: ToastGravity.BOTTOM,
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      ref.read(downloadStateProvider.notifier).shareAsset(asset, context);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    void handleEdit() async {
 | 
					    void handleEdit() async {
 | 
				
			||||||
@ -202,7 +211,17 @@ class BottomGalleryBar extends ConsumerWidget {
 | 
				
			|||||||
      if (asset.isLocal) {
 | 
					      if (asset.isLocal) {
 | 
				
			||||||
        return;
 | 
					        return;
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
      ref.read(imageViewerStateProvider.notifier).downloadAsset(
 | 
					      if (asset.isOffline) {
 | 
				
			||||||
 | 
					        ImmichToast.show(
 | 
				
			||||||
 | 
					          durationInSecond: 1,
 | 
				
			||||||
 | 
					          context: context,
 | 
				
			||||||
 | 
					          msg: 'asset_action_share_err_offline'.tr(),
 | 
				
			||||||
 | 
					          gravity: ToastGravity.BOTTOM,
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					        return;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      ref.read(downloadStateProvider.notifier).downloadAsset(
 | 
				
			||||||
            asset,
 | 
					            asset,
 | 
				
			||||||
            context,
 | 
					            context,
 | 
				
			||||||
          );
 | 
					          );
 | 
				
			||||||
 | 
				
			|||||||
@ -5,7 +5,7 @@ import 'package:fluttertoast/fluttertoast.dart';
 | 
				
			|||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
					import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
				
			||||||
import 'package:immich_mobile/providers/album/current_album.provider.dart';
 | 
					import 'package:immich_mobile/providers/album/current_album.provider.dart';
 | 
				
			||||||
import 'package:immich_mobile/widgets/album/add_to_album_bottom_sheet.dart';
 | 
					import 'package:immich_mobile/widgets/album/add_to_album_bottom_sheet.dart';
 | 
				
			||||||
import 'package:immich_mobile/providers/asset_viewer/image_viewer_page_state.provider.dart';
 | 
					import 'package:immich_mobile/providers/asset_viewer/download.provider.dart';
 | 
				
			||||||
import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
 | 
					import 'package:immich_mobile/providers/asset_viewer/show_controls.provider.dart';
 | 
				
			||||||
import 'package:immich_mobile/widgets/asset_viewer/top_control_app_bar.dart';
 | 
					import 'package:immich_mobile/widgets/asset_viewer/top_control_app_bar.dart';
 | 
				
			||||||
import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
 | 
					import 'package:immich_mobile/providers/backup/manual_upload.provider.dart';
 | 
				
			||||||
@ -94,7 +94,7 @@ class GalleryAppBar extends ConsumerWidget {
 | 
				
			|||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    handleDownloadAsset() {
 | 
					    handleDownloadAsset() {
 | 
				
			||||||
      ref.read(imageViewerStateProvider.notifier).downloadAsset(asset, context);
 | 
					      ref.read(downloadStateProvider.notifier).downloadAsset(asset, context);
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return IgnorePointer(
 | 
					    return IgnorePointer(
 | 
				
			||||||
 | 
				
			|||||||
@ -176,7 +176,7 @@ class LoginForm extends HookConsumerWidget {
 | 
				
			|||||||
    populateTestLoginInfo1() {
 | 
					    populateTestLoginInfo1() {
 | 
				
			||||||
      usernameController.text = 'testuser@email.com';
 | 
					      usernameController.text = 'testuser@email.com';
 | 
				
			||||||
      passwordController.text = 'password';
 | 
					      passwordController.text = 'password';
 | 
				
			||||||
      serverEndpointController.text = 'http://10.1.15.216:2283/api';
 | 
					      serverEndpointController.text = 'http://192.168.1.16:2283/api';
 | 
				
			||||||
    }
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    login() async {
 | 
					    login() async {
 | 
				
			||||||
 | 
				
			|||||||
@ -78,6 +78,14 @@ packages:
 | 
				
			|||||||
      url: "https://pub.dev"
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
    source: hosted
 | 
					    source: hosted
 | 
				
			||||||
    version: "9.0.0"
 | 
					    version: "9.0.0"
 | 
				
			||||||
 | 
					  background_downloader:
 | 
				
			||||||
 | 
					    dependency: "direct main"
 | 
				
			||||||
 | 
					    description:
 | 
				
			||||||
 | 
					      name: background_downloader
 | 
				
			||||||
 | 
					      sha256: "6a945db1a1c7727a4bc9c1d7c882cfb1a819f873b77e01d5e5dd6a3fb231cb28"
 | 
				
			||||||
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
 | 
					    source: hosted
 | 
				
			||||||
 | 
					    version: "8.5.5"
 | 
				
			||||||
  boolean_selector:
 | 
					  boolean_selector:
 | 
				
			||||||
    dependency: transitive
 | 
					    dependency: transitive
 | 
				
			||||||
    description:
 | 
					    description:
 | 
				
			||||||
@ -744,10 +752,10 @@ packages:
 | 
				
			|||||||
    dependency: "direct main"
 | 
					    dependency: "direct main"
 | 
				
			||||||
    description:
 | 
					    description:
 | 
				
			||||||
      name: http
 | 
					      name: http
 | 
				
			||||||
      sha256: "5895291c13fa8a3bd82e76d5627f69e0d85ca6a30dcac95c4ea19a5d555879c2"
 | 
					      sha256: b9c29a161230ee03d3ccf545097fccd9b87a5264228c5d348202e0f0c28f9010
 | 
				
			||||||
      url: "https://pub.dev"
 | 
					      url: "https://pub.dev"
 | 
				
			||||||
    source: hosted
 | 
					    source: hosted
 | 
				
			||||||
    version: "0.13.6"
 | 
					    version: "1.2.2"
 | 
				
			||||||
  http_multi_server:
 | 
					  http_multi_server:
 | 
				
			||||||
    dependency: transitive
 | 
					    dependency: transitive
 | 
				
			||||||
    description:
 | 
					    description:
 | 
				
			||||||
 | 
				
			|||||||
@ -32,7 +32,7 @@ dependencies:
 | 
				
			|||||||
  flutter_svg: ^2.0.9
 | 
					  flutter_svg: ^2.0.9
 | 
				
			||||||
  package_info_plus: ^8.0.1
 | 
					  package_info_plus: ^8.0.1
 | 
				
			||||||
  url_launcher: ^6.2.4
 | 
					  url_launcher: ^6.2.4
 | 
				
			||||||
  http: ^0.13.6
 | 
					  http: ^1.1.0
 | 
				
			||||||
  cancellation_token_http: ^2.0.0
 | 
					  cancellation_token_http: ^2.0.0
 | 
				
			||||||
  easy_localization: ^3.0.3
 | 
					  easy_localization: ^3.0.3
 | 
				
			||||||
  share_plus: ^10.0.0
 | 
					  share_plus: ^10.0.0
 | 
				
			||||||
@ -56,6 +56,7 @@ dependencies:
 | 
				
			|||||||
  thumbhash: 0.1.0+1
 | 
					  thumbhash: 0.1.0+1
 | 
				
			||||||
  async: ^2.11.0
 | 
					  async: ^2.11.0
 | 
				
			||||||
  dynamic_color: ^1.7.0 #package to apply system theme
 | 
					  dynamic_color: ^1.7.0 #package to apply system theme
 | 
				
			||||||
 | 
					  background_downloader: ^8.5.5
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  #image editing packages
 | 
					  #image editing packages
 | 
				
			||||||
  crop_image: ^1.0.13
 | 
					  crop_image: ^1.0.13
 | 
				
			||||||
 | 
				
			|||||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user