forked from Cutlery/immich
		
	Add information for uploading asset and error indication with error message for each failed upload. (#315)
* Added info box * Fixed upload endpoint doesn't report error status code * Added chip to show update error * Added chip to show failed upload * Add duplication check for upload * Better duplication-checking placement * Remove check for duplicated asset * Added failed backup status route * added page * Display error card with thumbnail * Improved styling * Set thumbnail with better quality * Remove force upload error
This commit is contained in:
		
							parent
							
								
									357f7d1c31
								
							
						
					
					
						commit
						58ec7553ea
					
				@ -19,7 +19,7 @@ platform :ios do
 | 
			
		||||
  desc "iOS Beta"
 | 
			
		||||
  lane :beta do
 | 
			
		||||
    increment_version_number(
 | 
			
		||||
      version_number: "1.16.1"
 | 
			
		||||
      version_number: "1.17.0"
 | 
			
		||||
    )
 | 
			
		||||
    increment_build_number(
 | 
			
		||||
      build_number: latest_testflight_build_number + 1,
 | 
			
		||||
 | 
			
		||||
@ -1,13 +1,14 @@
 | 
			
		||||
import 'package:cancellation_token_http/http.dart';
 | 
			
		||||
import 'package:equatable/equatable.dart';
 | 
			
		||||
import 'package:collection/collection.dart';
 | 
			
		||||
import 'package:photo_manager/photo_manager.dart';
 | 
			
		||||
 | 
			
		||||
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/server_info.model.dart';
 | 
			
		||||
 | 
			
		||||
enum BackUpProgressEnum { idle, inProgress, done }
 | 
			
		||||
 | 
			
		||||
class BackUpState extends Equatable {
 | 
			
		||||
class BackUpState {
 | 
			
		||||
  // enum
 | 
			
		||||
  final BackUpProgressEnum backupProgress;
 | 
			
		||||
  final List<String> allAssetsInDatabase;
 | 
			
		||||
@ -26,6 +27,9 @@ class BackUpState extends Equatable {
 | 
			
		||||
  /// All assets from the selected albums that have been backup
 | 
			
		||||
  final Set<String> selectedAlbumsBackupAssetsIds;
 | 
			
		||||
 | 
			
		||||
  // Current Backup Asset
 | 
			
		||||
  final CurrentUploadAsset currentUploadAsset;
 | 
			
		||||
 | 
			
		||||
  const BackUpState({
 | 
			
		||||
    required this.backupProgress,
 | 
			
		||||
    required this.allAssetsInDatabase,
 | 
			
		||||
@ -37,6 +41,7 @@ class BackUpState extends Equatable {
 | 
			
		||||
    required this.excludedBackupAlbums,
 | 
			
		||||
    required this.allUniqueAssets,
 | 
			
		||||
    required this.selectedAlbumsBackupAssetsIds,
 | 
			
		||||
    required this.currentUploadAsset,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  BackUpState copyWith({
 | 
			
		||||
@ -50,6 +55,7 @@ class BackUpState extends Equatable {
 | 
			
		||||
    Set<AssetPathEntity>? excludedBackupAlbums,
 | 
			
		||||
    Set<AssetEntity>? allUniqueAssets,
 | 
			
		||||
    Set<String>? selectedAlbumsBackupAssetsIds,
 | 
			
		||||
    CurrentUploadAsset? currentUploadAsset,
 | 
			
		||||
  }) {
 | 
			
		||||
    return BackUpState(
 | 
			
		||||
      backupProgress: backupProgress ?? this.backupProgress,
 | 
			
		||||
@ -63,27 +69,47 @@ class BackUpState extends Equatable {
 | 
			
		||||
      allUniqueAssets: allUniqueAssets ?? this.allUniqueAssets,
 | 
			
		||||
      selectedAlbumsBackupAssetsIds:
 | 
			
		||||
          selectedAlbumsBackupAssetsIds ?? this.selectedAlbumsBackupAssetsIds,
 | 
			
		||||
      currentUploadAsset: currentUploadAsset ?? this.currentUploadAsset,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds)';
 | 
			
		||||
    return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  List<Object> get props {
 | 
			
		||||
    return [
 | 
			
		||||
      backupProgress,
 | 
			
		||||
      allAssetsInDatabase,
 | 
			
		||||
      progressInPercentage,
 | 
			
		||||
      cancelToken,
 | 
			
		||||
      serverInfo,
 | 
			
		||||
      availableAlbums,
 | 
			
		||||
      selectedBackupAlbums,
 | 
			
		||||
      excludedBackupAlbums,
 | 
			
		||||
      allUniqueAssets,
 | 
			
		||||
      selectedAlbumsBackupAssetsIds,
 | 
			
		||||
    ];
 | 
			
		||||
  bool operator ==(Object other) {
 | 
			
		||||
    if (identical(this, other)) return true;
 | 
			
		||||
    final collectionEquals = const DeepCollectionEquality().equals;
 | 
			
		||||
 | 
			
		||||
    return other is BackUpState &&
 | 
			
		||||
        other.backupProgress == backupProgress &&
 | 
			
		||||
        collectionEquals(other.allAssetsInDatabase, allAssetsInDatabase) &&
 | 
			
		||||
        other.progressInPercentage == progressInPercentage &&
 | 
			
		||||
        other.cancelToken == cancelToken &&
 | 
			
		||||
        other.serverInfo == serverInfo &&
 | 
			
		||||
        collectionEquals(other.availableAlbums, availableAlbums) &&
 | 
			
		||||
        collectionEquals(other.selectedBackupAlbums, selectedBackupAlbums) &&
 | 
			
		||||
        collectionEquals(other.excludedBackupAlbums, excludedBackupAlbums) &&
 | 
			
		||||
        collectionEquals(other.allUniqueAssets, allUniqueAssets) &&
 | 
			
		||||
        collectionEquals(other.selectedAlbumsBackupAssetsIds,
 | 
			
		||||
            selectedAlbumsBackupAssetsIds) &&
 | 
			
		||||
        other.currentUploadAsset == currentUploadAsset;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode {
 | 
			
		||||
    return backupProgress.hashCode ^
 | 
			
		||||
        allAssetsInDatabase.hashCode ^
 | 
			
		||||
        progressInPercentage.hashCode ^
 | 
			
		||||
        cancelToken.hashCode ^
 | 
			
		||||
        serverInfo.hashCode ^
 | 
			
		||||
        availableAlbums.hashCode ^
 | 
			
		||||
        selectedBackupAlbums.hashCode ^
 | 
			
		||||
        excludedBackupAlbums.hashCode ^
 | 
			
		||||
        allUniqueAssets.hashCode ^
 | 
			
		||||
        selectedAlbumsBackupAssetsIds.hashCode ^
 | 
			
		||||
        currentUploadAsset.hashCode;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,48 @@
 | 
			
		||||
import 'dart:convert';
 | 
			
		||||
 | 
			
		||||
class CheckDuplicateAssetResponse {
 | 
			
		||||
  final bool isExist;
 | 
			
		||||
  CheckDuplicateAssetResponse({
 | 
			
		||||
    required this.isExist,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  CheckDuplicateAssetResponse copyWith({
 | 
			
		||||
    bool? isExist,
 | 
			
		||||
  }) {
 | 
			
		||||
    return CheckDuplicateAssetResponse(
 | 
			
		||||
      isExist: isExist ?? this.isExist,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Map<String, dynamic> toMap() {
 | 
			
		||||
    final result = <String, dynamic>{};
 | 
			
		||||
 | 
			
		||||
    result.addAll({'isExist': isExist});
 | 
			
		||||
 | 
			
		||||
    return result;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  factory CheckDuplicateAssetResponse.fromMap(Map<String, dynamic> map) {
 | 
			
		||||
    return CheckDuplicateAssetResponse(
 | 
			
		||||
      isExist: map['isExist'] ?? false,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  String toJson() => json.encode(toMap());
 | 
			
		||||
 | 
			
		||||
  factory CheckDuplicateAssetResponse.fromJson(String source) =>
 | 
			
		||||
      CheckDuplicateAssetResponse.fromMap(json.decode(source));
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() => 'CheckDuplicateAssetResponse(isExist: $isExist)';
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) {
 | 
			
		||||
    if (identical(this, other)) return true;
 | 
			
		||||
 | 
			
		||||
    return other is CheckDuplicateAssetResponse && other.isExist == isExist;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode => isExist.hashCode;
 | 
			
		||||
}
 | 
			
		||||
@ -0,0 +1,78 @@
 | 
			
		||||
import 'dart:convert';
 | 
			
		||||
 | 
			
		||||
class CurrentUploadAsset {
 | 
			
		||||
  final String id;
 | 
			
		||||
  final DateTime createdAt;
 | 
			
		||||
  final String fileName;
 | 
			
		||||
  final String fileType;
 | 
			
		||||
 | 
			
		||||
  CurrentUploadAsset({
 | 
			
		||||
    required this.id,
 | 
			
		||||
    required this.createdAt,
 | 
			
		||||
    required this.fileName,
 | 
			
		||||
    required this.fileType,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  CurrentUploadAsset copyWith({
 | 
			
		||||
    String? id,
 | 
			
		||||
    DateTime? createdAt,
 | 
			
		||||
    String? fileName,
 | 
			
		||||
    String? fileType,
 | 
			
		||||
  }) {
 | 
			
		||||
    return CurrentUploadAsset(
 | 
			
		||||
      id: id ?? this.id,
 | 
			
		||||
      createdAt: createdAt ?? this.createdAt,
 | 
			
		||||
      fileName: fileName ?? this.fileName,
 | 
			
		||||
      fileType: fileType ?? this.fileType,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Map<String, dynamic> toMap() {
 | 
			
		||||
    final result = <String, dynamic>{};
 | 
			
		||||
 | 
			
		||||
    result.addAll({'id': id});
 | 
			
		||||
    result.addAll({'createdAt': createdAt.millisecondsSinceEpoch});
 | 
			
		||||
    result.addAll({'fileName': fileName});
 | 
			
		||||
    result.addAll({'fileType': fileType});
 | 
			
		||||
 | 
			
		||||
    return result;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  factory CurrentUploadAsset.fromMap(Map<String, dynamic> map) {
 | 
			
		||||
    return CurrentUploadAsset(
 | 
			
		||||
      id: map['id'] ?? '',
 | 
			
		||||
      createdAt: DateTime.fromMillisecondsSinceEpoch(map['createdAt']),
 | 
			
		||||
      fileName: map['fileName'] ?? '',
 | 
			
		||||
      fileType: map['fileType'] ?? '',
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  String toJson() => json.encode(toMap());
 | 
			
		||||
 | 
			
		||||
  factory CurrentUploadAsset.fromJson(String source) =>
 | 
			
		||||
      CurrentUploadAsset.fromMap(json.decode(source));
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'CurrentUploadAsset(id: $id, createdAt: $createdAt, fileName: $fileName, fileType: $fileType)';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) {
 | 
			
		||||
    if (identical(this, other)) return true;
 | 
			
		||||
 | 
			
		||||
    return other is CurrentUploadAsset &&
 | 
			
		||||
        other.id == id &&
 | 
			
		||||
        other.createdAt == createdAt &&
 | 
			
		||||
        other.fileName == fileName &&
 | 
			
		||||
        other.fileType == fileType;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode {
 | 
			
		||||
    return id.hashCode ^
 | 
			
		||||
        createdAt.hashCode ^
 | 
			
		||||
        fileName.hashCode ^
 | 
			
		||||
        fileType.hashCode;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -0,0 +1,53 @@
 | 
			
		||||
import 'package:equatable/equatable.dart';
 | 
			
		||||
import 'package:photo_manager/photo_manager.dart';
 | 
			
		||||
 | 
			
		||||
class ErrorUploadAsset extends Equatable {
 | 
			
		||||
  final String id;
 | 
			
		||||
  final DateTime createdAt;
 | 
			
		||||
  final String fileName;
 | 
			
		||||
  final String fileType;
 | 
			
		||||
  final AssetEntity asset;
 | 
			
		||||
  final String errorMessage;
 | 
			
		||||
 | 
			
		||||
  const ErrorUploadAsset({
 | 
			
		||||
    required this.id,
 | 
			
		||||
    required this.createdAt,
 | 
			
		||||
    required this.fileName,
 | 
			
		||||
    required this.fileType,
 | 
			
		||||
    required this.asset,
 | 
			
		||||
    required this.errorMessage,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  ErrorUploadAsset copyWith({
 | 
			
		||||
    String? id,
 | 
			
		||||
    DateTime? createdAt,
 | 
			
		||||
    String? fileName,
 | 
			
		||||
    String? fileType,
 | 
			
		||||
    AssetEntity? asset,
 | 
			
		||||
    String? errorMessage,
 | 
			
		||||
  }) {
 | 
			
		||||
    return ErrorUploadAsset(
 | 
			
		||||
      id: id ?? this.id,
 | 
			
		||||
      createdAt: createdAt ?? this.createdAt,
 | 
			
		||||
      fileName: fileName ?? this.fileName,
 | 
			
		||||
      fileType: fileType ?? this.fileType,
 | 
			
		||||
      asset: asset ?? this.asset,
 | 
			
		||||
      errorMessage: errorMessage ?? this.errorMessage,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'ErrorUploadAsset(id: $id, createdAt: $createdAt, fileName: $fileName, fileType: $fileType, asset: $asset, errorMessage: $errorMessage)';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  List<Object> get props {
 | 
			
		||||
    return [
 | 
			
		||||
      id,
 | 
			
		||||
      fileName,
 | 
			
		||||
      fileType,
 | 
			
		||||
      errorMessage,
 | 
			
		||||
    ];
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -5,7 +5,10 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/constants/hive_box.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 | 
			
		||||
@ -14,8 +17,12 @@ import 'package:immich_mobile/shared/services/server_info.service.dart';
 | 
			
		||||
import 'package:photo_manager/photo_manager.dart';
 | 
			
		||||
 | 
			
		||||
class BackupNotifier extends StateNotifier<BackUpState> {
 | 
			
		||||
  BackupNotifier(this._backupService, this._serverInfoService, this._authState)
 | 
			
		||||
      : super(
 | 
			
		||||
  BackupNotifier(
 | 
			
		||||
    this._backupService,
 | 
			
		||||
    this._serverInfoService,
 | 
			
		||||
    this._authState,
 | 
			
		||||
    this.ref,
 | 
			
		||||
  ) : super(
 | 
			
		||||
          BackUpState(
 | 
			
		||||
            backupProgress: BackUpProgressEnum.idle,
 | 
			
		||||
            allAssetsInDatabase: const [],
 | 
			
		||||
@ -35,6 +42,12 @@ class BackupNotifier extends StateNotifier<BackUpState> {
 | 
			
		||||
            excludedBackupAlbums: const {},
 | 
			
		||||
            allUniqueAssets: const {},
 | 
			
		||||
            selectedAlbumsBackupAssetsIds: const {},
 | 
			
		||||
            currentUploadAsset: CurrentUploadAsset(
 | 
			
		||||
              id: '...',
 | 
			
		||||
              createdAt: DateTime.parse('2020-10-04'),
 | 
			
		||||
              fileName: '...',
 | 
			
		||||
              fileType: '...',
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ) {
 | 
			
		||||
    getBackupInfo();
 | 
			
		||||
@ -43,6 +56,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
 | 
			
		||||
  final BackupService _backupService;
 | 
			
		||||
  final ServerInfoService _serverInfoService;
 | 
			
		||||
  final AuthenticationState _authState;
 | 
			
		||||
  final Ref ref;
 | 
			
		||||
 | 
			
		||||
  ///
 | 
			
		||||
  /// UI INTERACTION
 | 
			
		||||
@ -235,8 +249,11 @@ class BackupNotifier extends StateNotifier<BackUpState> {
 | 
			
		||||
  /// and then update the UI according to those information
 | 
			
		||||
  ///
 | 
			
		||||
  Future<void> getBackupInfo() async {
 | 
			
		||||
    await _getBackupAlbumsInfo();
 | 
			
		||||
    await _updateServerInfo();
 | 
			
		||||
    await Future.wait([
 | 
			
		||||
      _getBackupAlbumsInfo(),
 | 
			
		||||
      _updateServerInfo(),
 | 
			
		||||
    ]);
 | 
			
		||||
 | 
			
		||||
    await _updateBackupAssetCount();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
@ -287,13 +304,27 @@ class BackupNotifier extends StateNotifier<BackUpState> {
 | 
			
		||||
 | 
			
		||||
      // Perform Backup
 | 
			
		||||
      state = state.copyWith(cancelToken: CancellationToken());
 | 
			
		||||
      _backupService.backupAsset(assetsWillBeBackup, state.cancelToken,
 | 
			
		||||
          _onAssetUploaded, _onUploadProgress);
 | 
			
		||||
      _backupService.backupAsset(
 | 
			
		||||
        assetsWillBeBackup,
 | 
			
		||||
        state.cancelToken,
 | 
			
		||||
        _onAssetUploaded,
 | 
			
		||||
        _onUploadProgress,
 | 
			
		||||
        _onSetCurrentBackupAsset,
 | 
			
		||||
        _onBackupError,
 | 
			
		||||
      );
 | 
			
		||||
    } else {
 | 
			
		||||
      PhotoManager.openSetting();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _onBackupError(ErrorUploadAsset errorAssetInfo) {
 | 
			
		||||
    ref.watch(errorBackupListProvider.notifier).add(errorAssetInfo);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
 | 
			
		||||
    state = state.copyWith(currentUploadAsset: currentUploadAsset);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void cancelBackup() {
 | 
			
		||||
    state.cancelToken.cancel();
 | 
			
		||||
    state = state.copyWith(
 | 
			
		||||
@ -375,5 +406,6 @@ final backupProvider =
 | 
			
		||||
    ref.watch(backupServiceProvider),
 | 
			
		||||
    ref.watch(serverInfoServiceProvider),
 | 
			
		||||
    ref.watch(authenticationProvider),
 | 
			
		||||
    ref,
 | 
			
		||||
  );
 | 
			
		||||
});
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,23 @@
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
 | 
			
		||||
 | 
			
		||||
class ErrorBackupListNotifier extends StateNotifier<Set<ErrorUploadAsset>> {
 | 
			
		||||
  ErrorBackupListNotifier() : super({});
 | 
			
		||||
 | 
			
		||||
  add(ErrorUploadAsset errorAsset) {
 | 
			
		||||
    state = state.union({errorAsset});
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  remove(ErrorUploadAsset errorAsset) {
 | 
			
		||||
    state = state.difference({errorAsset});
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  empty() {
 | 
			
		||||
    state = {};
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
final errorBackupListProvider =
 | 
			
		||||
    StateNotifierProvider<ErrorBackupListNotifier, Set<ErrorUploadAsset>>(
 | 
			
		||||
  (ref) => ErrorBackupListNotifier(),
 | 
			
		||||
);
 | 
			
		||||
@ -7,6 +7,9 @@ import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:hive/hive.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/constants/hive_box.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/models/check_duplicate_asset_response.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/services/network.service.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/device_info.model.dart';
 | 
			
		||||
import 'package:immich_mobile/utils/files_helper.dart';
 | 
			
		||||
@ -20,6 +23,7 @@ final backupServiceProvider =
 | 
			
		||||
 | 
			
		||||
class BackupService {
 | 
			
		||||
  final NetworkService _networkService;
 | 
			
		||||
 | 
			
		||||
  BackupService(this._networkService);
 | 
			
		||||
 | 
			
		||||
  Future<List<String>> getDeviceBackupAsset() async {
 | 
			
		||||
@ -32,17 +36,40 @@ class BackupService {
 | 
			
		||||
    return result.cast<String>();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Future<bool> checkDuplicateAsset(String deviceAssetId) async {
 | 
			
		||||
    String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      Response response =
 | 
			
		||||
          await _networkService.postRequest(url: "asset/check", data: {
 | 
			
		||||
        "deviceId": deviceId,
 | 
			
		||||
        "deviceAssetId": deviceAssetId,
 | 
			
		||||
      });
 | 
			
		||||
 | 
			
		||||
      if (response.statusCode == 200) {
 | 
			
		||||
        var result = CheckDuplicateAssetResponse.fromJson(response.toString());
 | 
			
		||||
 | 
			
		||||
        return result.isExist;
 | 
			
		||||
      } else {
 | 
			
		||||
        return false;
 | 
			
		||||
      }
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      return false;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  backupAsset(
 | 
			
		||||
      Set<AssetEntity> assetList,
 | 
			
		||||
      http.CancellationToken cancelToken,
 | 
			
		||||
      Function(String, String) singleAssetDoneCb,
 | 
			
		||||
      Function(int, int) uploadProgress) async {
 | 
			
		||||
    Set<AssetEntity> assetList,
 | 
			
		||||
    http.CancellationToken cancelToken,
 | 
			
		||||
    Function(String, String) singleAssetDoneCb,
 | 
			
		||||
    Function(int, int) uploadProgressCb,
 | 
			
		||||
    Function(CurrentUploadAsset) setCurrentUploadAssetCb,
 | 
			
		||||
    Function(ErrorUploadAsset) errorCb,
 | 
			
		||||
  ) async {
 | 
			
		||||
    String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
 | 
			
		||||
    String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
 | 
			
		||||
    File? file;
 | 
			
		||||
 | 
			
		||||
    http.MultipartFile? thumbnailUploadData;
 | 
			
		||||
 | 
			
		||||
    for (var entity in assetList) {
 | 
			
		||||
      try {
 | 
			
		||||
        if (entity.type == AssetType.video) {
 | 
			
		||||
@ -74,7 +101,7 @@ class BackupService {
 | 
			
		||||
          var req = MultipartRequest(
 | 
			
		||||
              'POST', Uri.parse('$savedEndpoint/asset/upload'),
 | 
			
		||||
              onProgress: ((bytes, totalBytes) =>
 | 
			
		||||
                  uploadProgress(bytes, totalBytes)));
 | 
			
		||||
                  uploadProgressCb(bytes, totalBytes)));
 | 
			
		||||
          req.headers["Authorization"] = "Bearer ${box.get(accessTokenKey)}";
 | 
			
		||||
 | 
			
		||||
          req.fields['deviceAssetId'] = entity.id;
 | 
			
		||||
@ -88,10 +115,35 @@ class BackupService {
 | 
			
		||||
 | 
			
		||||
          req.files.add(assetRawUploadData);
 | 
			
		||||
 | 
			
		||||
          var res = await req.send(cancellationToken: cancelToken);
 | 
			
		||||
          setCurrentUploadAssetCb(
 | 
			
		||||
            CurrentUploadAsset(
 | 
			
		||||
              id: entity.id,
 | 
			
		||||
              createdAt: entity.createDateTime,
 | 
			
		||||
              fileName: originalFileName,
 | 
			
		||||
              fileType: _getAssetType(entity.type),
 | 
			
		||||
            ),
 | 
			
		||||
          );
 | 
			
		||||
 | 
			
		||||
          if (res.statusCode == 201) {
 | 
			
		||||
          var response = await req.send(cancellationToken: cancelToken);
 | 
			
		||||
 | 
			
		||||
          if (response.statusCode == 201) {
 | 
			
		||||
            singleAssetDoneCb(entity.id, deviceId);
 | 
			
		||||
          } else {
 | 
			
		||||
            var data = await response.stream.bytesToString();
 | 
			
		||||
            var error = jsonDecode(data);
 | 
			
		||||
 | 
			
		||||
            debugPrint(
 | 
			
		||||
                "Error(${error['statusCode']}) uploading ${entity.id} | $originalFileName | Created on ${entity.createDateTime} | ${error['error']}");
 | 
			
		||||
 | 
			
		||||
            errorCb(ErrorUploadAsset(
 | 
			
		||||
              asset: entity,
 | 
			
		||||
              id: entity.id,
 | 
			
		||||
              createdAt: entity.createDateTime,
 | 
			
		||||
              fileName: originalFileName,
 | 
			
		||||
              fileType: _getAssetType(entity.type),
 | 
			
		||||
              errorMessage: error['error'],
 | 
			
		||||
            ));
 | 
			
		||||
            continue;
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
      } on http.CancelledException {
 | 
			
		||||
@ -108,6 +160,8 @@ class BackupService {
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void sendBackupRequest(AssetEntity entity) {}
 | 
			
		||||
 | 
			
		||||
  String _getAssetType(AssetType assetType) {
 | 
			
		||||
    switch (assetType) {
 | 
			
		||||
      case AssetType.audio:
 | 
			
		||||
 | 
			
		||||
@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_hooks/flutter_hooks.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 | 
			
		||||
@ -9,6 +10,7 @@ import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/routing/router.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/ui/backup_info_card.dart';
 | 
			
		||||
import 'package:intl/intl.dart';
 | 
			
		||||
import 'package:percent_indicator/linear_percent_indicator.dart';
 | 
			
		||||
 | 
			
		||||
class BackupControllerPage extends HookConsumerWidget {
 | 
			
		||||
@ -42,7 +44,7 @@ class BackupControllerPage extends HookConsumerWidget {
 | 
			
		||||
          color: Theme.of(context).primaryColor,
 | 
			
		||||
        ),
 | 
			
		||||
        title: const Text(
 | 
			
		||||
          "Server Storage",
 | 
			
		||||
          "Server storage",
 | 
			
		||||
          style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
 | 
			
		||||
        ),
 | 
			
		||||
        subtitle: Padding(
 | 
			
		||||
@ -56,7 +58,7 @@ class BackupControllerPage extends HookConsumerWidget {
 | 
			
		||||
                  padding:
 | 
			
		||||
                      const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
 | 
			
		||||
                  barRadius: const Radius.circular(2),
 | 
			
		||||
                  lineHeight: 6.0,
 | 
			
		||||
                  lineHeight: 10.0,
 | 
			
		||||
                  percent: backupState.serverInfo.diskUsagePercentage / 100.0,
 | 
			
		||||
                  backgroundColor: Colors.grey,
 | 
			
		||||
                  progressColor: Theme.of(context).primaryColor,
 | 
			
		||||
@ -246,6 +248,141 @@ class BackupControllerPage extends HookConsumerWidget {
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _buildCurrentBackupAssetInfoCard() {
 | 
			
		||||
      return ListTile(
 | 
			
		||||
        leading: Icon(
 | 
			
		||||
          Icons.info_outline_rounded,
 | 
			
		||||
          color: Theme.of(context).primaryColor,
 | 
			
		||||
        ),
 | 
			
		||||
        title: Row(
 | 
			
		||||
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
 | 
			
		||||
          children: [
 | 
			
		||||
            const Text(
 | 
			
		||||
              "Uploading file info",
 | 
			
		||||
              style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
 | 
			
		||||
            ),
 | 
			
		||||
            if (ref.watch(errorBackupListProvider).isNotEmpty)
 | 
			
		||||
              ActionChip(
 | 
			
		||||
                avatar: Icon(
 | 
			
		||||
                  Icons.info,
 | 
			
		||||
                  size: 24,
 | 
			
		||||
                  color: Colors.red[400],
 | 
			
		||||
                ),
 | 
			
		||||
                elevation: 1,
 | 
			
		||||
                visualDensity: VisualDensity.compact,
 | 
			
		||||
                label: Text(
 | 
			
		||||
                  "Failed (${ref.watch(errorBackupListProvider).length})",
 | 
			
		||||
                  style: TextStyle(
 | 
			
		||||
                    color: Colors.red[400],
 | 
			
		||||
                    fontWeight: FontWeight.bold,
 | 
			
		||||
                    fontSize: 11,
 | 
			
		||||
                  ),
 | 
			
		||||
                ),
 | 
			
		||||
                backgroundColor: Colors.white,
 | 
			
		||||
                onPressed: () {
 | 
			
		||||
                  AutoRouter.of(context).push(const FailedBackupStatusRoute());
 | 
			
		||||
                },
 | 
			
		||||
              ),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
        subtitle: Column(
 | 
			
		||||
          children: [
 | 
			
		||||
            Padding(
 | 
			
		||||
              padding: const EdgeInsets.only(top: 8.0),
 | 
			
		||||
              child: LinearPercentIndicator(
 | 
			
		||||
                padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
 | 
			
		||||
                barRadius: const Radius.circular(2),
 | 
			
		||||
                lineHeight: 10.0,
 | 
			
		||||
                trailing: Text(
 | 
			
		||||
                  " ${backupState.progressInPercentage.toStringAsFixed(0)}%",
 | 
			
		||||
                  style: const TextStyle(fontSize: 12),
 | 
			
		||||
                ),
 | 
			
		||||
                percent: backupState.progressInPercentage / 100.0,
 | 
			
		||||
                backgroundColor: Colors.grey,
 | 
			
		||||
                progressColor: Theme.of(context).primaryColor,
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
            Padding(
 | 
			
		||||
              padding: const EdgeInsets.only(top: 8.0),
 | 
			
		||||
              child: Table(
 | 
			
		||||
                border: TableBorder.all(
 | 
			
		||||
                  color: Colors.black12,
 | 
			
		||||
                  width: 1,
 | 
			
		||||
                ),
 | 
			
		||||
                children: [
 | 
			
		||||
                  TableRow(
 | 
			
		||||
                    decoration: BoxDecoration(
 | 
			
		||||
                      color: Colors.grey[100],
 | 
			
		||||
                    ),
 | 
			
		||||
                    children: [
 | 
			
		||||
                      TableCell(
 | 
			
		||||
                        verticalAlignment: TableCellVerticalAlignment.middle,
 | 
			
		||||
                        child: Padding(
 | 
			
		||||
                          padding: const EdgeInsets.all(6.0),
 | 
			
		||||
                          child: Text(
 | 
			
		||||
                            'File name: ${backupState.currentUploadAsset.fileName} [${backupState.currentUploadAsset.fileType.toLowerCase()}]',
 | 
			
		||||
                            style: const TextStyle(
 | 
			
		||||
                                fontWeight: FontWeight.bold, fontSize: 10.0),
 | 
			
		||||
                          ),
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
                    ],
 | 
			
		||||
                  ),
 | 
			
		||||
                  TableRow(
 | 
			
		||||
                    decoration: BoxDecoration(
 | 
			
		||||
                      color: Colors.grey[200],
 | 
			
		||||
                    ),
 | 
			
		||||
                    children: [
 | 
			
		||||
                      TableCell(
 | 
			
		||||
                        verticalAlignment: TableCellVerticalAlignment.middle,
 | 
			
		||||
                        child: Padding(
 | 
			
		||||
                          padding: const EdgeInsets.all(6.0),
 | 
			
		||||
                          child: Text(
 | 
			
		||||
                            "Created on: ${DateFormat.yMMMMd('en_US').format(
 | 
			
		||||
                              DateTime.parse(
 | 
			
		||||
                                backupState.currentUploadAsset.createdAt
 | 
			
		||||
                                    .toString(),
 | 
			
		||||
                              ),
 | 
			
		||||
                            )}",
 | 
			
		||||
                            style: const TextStyle(
 | 
			
		||||
                                fontWeight: FontWeight.bold, fontSize: 10.0),
 | 
			
		||||
                          ),
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
                    ],
 | 
			
		||||
                  ),
 | 
			
		||||
                  TableRow(
 | 
			
		||||
                    decoration: BoxDecoration(
 | 
			
		||||
                      color: Colors.grey[100],
 | 
			
		||||
                    ),
 | 
			
		||||
                    children: [
 | 
			
		||||
                      TableCell(
 | 
			
		||||
                        child: Padding(
 | 
			
		||||
                          padding: const EdgeInsets.all(6.0),
 | 
			
		||||
                          child: Text(
 | 
			
		||||
                            "ID: ${backupState.currentUploadAsset.id}",
 | 
			
		||||
                            style: const TextStyle(
 | 
			
		||||
                              fontWeight: FontWeight.bold,
 | 
			
		||||
                              fontSize: 10.0,
 | 
			
		||||
                            ),
 | 
			
		||||
                          ),
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
                    ],
 | 
			
		||||
                  ),
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    void startBackup() {
 | 
			
		||||
      ref.watch(errorBackupListProvider.notifier).empty();
 | 
			
		||||
      ref.watch(backupProvider.notifier).startBackupProcess();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        elevation: 0,
 | 
			
		||||
@ -264,7 +401,7 @@ class BackupControllerPage extends HookConsumerWidget {
 | 
			
		||||
            )),
 | 
			
		||||
      ),
 | 
			
		||||
      body: Padding(
 | 
			
		||||
        padding: const EdgeInsets.all(16.0),
 | 
			
		||||
        padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 32),
 | 
			
		||||
        child: ListView(
 | 
			
		||||
          // crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
          children: [
 | 
			
		||||
@ -297,23 +434,11 @@ class BackupControllerPage extends HookConsumerWidget {
 | 
			
		||||
            const Divider(),
 | 
			
		||||
            _buildStorageInformation(),
 | 
			
		||||
            const Divider(),
 | 
			
		||||
            _buildCurrentBackupAssetInfoCard(),
 | 
			
		||||
            Padding(
 | 
			
		||||
              padding: const EdgeInsets.all(8.0),
 | 
			
		||||
              child: Text(
 | 
			
		||||
                  "Asset that were being backup: ${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length} [${backupState.progressInPercentage.toStringAsFixed(0)}%]"),
 | 
			
		||||
            ),
 | 
			
		||||
            Padding(
 | 
			
		||||
              padding: const EdgeInsets.only(left: 8.0),
 | 
			
		||||
              child: Row(children: [
 | 
			
		||||
                const Text("Backup Progress:"),
 | 
			
		||||
                const Padding(padding: EdgeInsets.symmetric(horizontal: 2)),
 | 
			
		||||
                backupState.backupProgress == BackUpProgressEnum.inProgress
 | 
			
		||||
                    ? const CircularProgressIndicator.adaptive()
 | 
			
		||||
                    : const Text("Done"),
 | 
			
		||||
              ]),
 | 
			
		||||
            ),
 | 
			
		||||
            Padding(
 | 
			
		||||
              padding: const EdgeInsets.all(8.0),
 | 
			
		||||
              padding: const EdgeInsets.only(
 | 
			
		||||
                top: 24,
 | 
			
		||||
              ),
 | 
			
		||||
              child: Container(
 | 
			
		||||
                child:
 | 
			
		||||
                    backupState.backupProgress == BackUpProgressEnum.inProgress
 | 
			
		||||
@ -321,25 +446,33 @@ class BackupControllerPage extends HookConsumerWidget {
 | 
			
		||||
                            style: ElevatedButton.styleFrom(
 | 
			
		||||
                              primary: Colors.red[300],
 | 
			
		||||
                              onPrimary: Colors.grey[50],
 | 
			
		||||
                              padding: const EdgeInsets.all(14),
 | 
			
		||||
                            ),
 | 
			
		||||
                            onPressed: () {
 | 
			
		||||
                              ref.read(backupProvider.notifier).cancelBackup();
 | 
			
		||||
                            },
 | 
			
		||||
                            child: const Text("Cancel"),
 | 
			
		||||
                            child: const Text(
 | 
			
		||||
                              "CANCEL",
 | 
			
		||||
                              style: TextStyle(
 | 
			
		||||
                                fontSize: 14,
 | 
			
		||||
                                fontWeight: FontWeight.bold,
 | 
			
		||||
                              ),
 | 
			
		||||
                            ),
 | 
			
		||||
                          )
 | 
			
		||||
                        : ElevatedButton(
 | 
			
		||||
                            style: ElevatedButton.styleFrom(
 | 
			
		||||
                              primary: Theme.of(context).primaryColor,
 | 
			
		||||
                              onPrimary: Colors.grey[50],
 | 
			
		||||
                              padding: const EdgeInsets.all(14),
 | 
			
		||||
                            ),
 | 
			
		||||
                            onPressed: shouldBackup ? startBackup : null,
 | 
			
		||||
                            child: const Text(
 | 
			
		||||
                              "START BACKUP",
 | 
			
		||||
                              style: TextStyle(
 | 
			
		||||
                                fontSize: 14,
 | 
			
		||||
                                fontWeight: FontWeight.bold,
 | 
			
		||||
                              ),
 | 
			
		||||
                            ),
 | 
			
		||||
                            onPressed: shouldBackup
 | 
			
		||||
                                ? () {
 | 
			
		||||
                                    ref
 | 
			
		||||
                                        .read(backupProvider.notifier)
 | 
			
		||||
                                        .startBackupProcess();
 | 
			
		||||
                                  }
 | 
			
		||||
                                : null,
 | 
			
		||||
                            child: const Text("Start Backup"),
 | 
			
		||||
                          ),
 | 
			
		||||
              ),
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										139
									
								
								mobile/lib/modules/backup/views/failed_backup_status_page.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										139
									
								
								mobile/lib/modules/backup/views/failed_backup_status_page.dart
									
									
									
									
									
										Normal file
									
								
							@ -0,0 +1,139 @@
 | 
			
		||||
import 'package:auto_route/auto_route.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
 | 
			
		||||
import 'package:intl/intl.dart';
 | 
			
		||||
import 'package:photo_manager/photo_manager.dart';
 | 
			
		||||
 | 
			
		||||
class FailedBackupStatusPage extends HookConsumerWidget {
 | 
			
		||||
  const FailedBackupStatusPage({Key? key}) : super(key: key);
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    final errorBackupList = ref.watch(errorBackupListProvider);
 | 
			
		||||
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        elevation: 0,
 | 
			
		||||
        title: Text(
 | 
			
		||||
          "Failed Backup (${errorBackupList.length})",
 | 
			
		||||
          style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
 | 
			
		||||
        ),
 | 
			
		||||
        leading: IconButton(
 | 
			
		||||
            onPressed: () {
 | 
			
		||||
              AutoRouter.of(context).pop(true);
 | 
			
		||||
            },
 | 
			
		||||
            splashRadius: 24,
 | 
			
		||||
            icon: const Icon(
 | 
			
		||||
              Icons.arrow_back_ios_rounded,
 | 
			
		||||
            )),
 | 
			
		||||
      ),
 | 
			
		||||
      body: ListView.builder(
 | 
			
		||||
        shrinkWrap: true,
 | 
			
		||||
        itemCount: errorBackupList.length,
 | 
			
		||||
        itemBuilder: ((context, index) {
 | 
			
		||||
          var errorAsset = errorBackupList.elementAt(index);
 | 
			
		||||
 | 
			
		||||
          return Padding(
 | 
			
		||||
            padding: const EdgeInsets.symmetric(
 | 
			
		||||
              horizontal: 12.0,
 | 
			
		||||
              vertical: 4,
 | 
			
		||||
            ),
 | 
			
		||||
            child: Card(
 | 
			
		||||
              shape: RoundedRectangleBorder(
 | 
			
		||||
                borderRadius: BorderRadius.circular(15), // if you need this
 | 
			
		||||
                side: const BorderSide(
 | 
			
		||||
                  color: Colors.black12,
 | 
			
		||||
                  width: 1,
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
              elevation: 0,
 | 
			
		||||
              child: Row(
 | 
			
		||||
                crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                mainAxisAlignment: MainAxisAlignment.spaceBetween,
 | 
			
		||||
                children: [
 | 
			
		||||
                  ConstrainedBox(
 | 
			
		||||
                    constraints: const BoxConstraints(
 | 
			
		||||
                      minWidth: 100,
 | 
			
		||||
                      minHeight: 150,
 | 
			
		||||
                      maxWidth: 100,
 | 
			
		||||
                      maxHeight: 200,
 | 
			
		||||
                    ),
 | 
			
		||||
                    child: ClipRRect(
 | 
			
		||||
                      borderRadius: const BorderRadius.only(
 | 
			
		||||
                        bottomLeft: Radius.circular(15),
 | 
			
		||||
                        topLeft: Radius.circular(15),
 | 
			
		||||
                      ),
 | 
			
		||||
                      clipBehavior: Clip.hardEdge,
 | 
			
		||||
                      child: Image(
 | 
			
		||||
                        fit: BoxFit.cover,
 | 
			
		||||
                        image: AssetEntityImageProvider(
 | 
			
		||||
                          errorAsset.asset,
 | 
			
		||||
                          isOriginal: false,
 | 
			
		||||
                          thumbnailSize: const ThumbnailSize.square(512),
 | 
			
		||||
                          thumbnailFormat: ThumbnailFormat.jpeg,
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
                  Expanded(
 | 
			
		||||
                    child: Padding(
 | 
			
		||||
                      padding: const EdgeInsets.all(16.0),
 | 
			
		||||
                      child: Column(
 | 
			
		||||
                        crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                        mainAxisAlignment: MainAxisAlignment.spaceEvenly,
 | 
			
		||||
                        children: [
 | 
			
		||||
                          Row(
 | 
			
		||||
                            mainAxisAlignment: MainAxisAlignment.spaceBetween,
 | 
			
		||||
                            children: [
 | 
			
		||||
                              Text(
 | 
			
		||||
                                DateFormat.yMMMMd('en_US').format(
 | 
			
		||||
                                  DateTime.parse(
 | 
			
		||||
                                    errorAsset.createdAt.toString(),
 | 
			
		||||
                                  ),
 | 
			
		||||
                                ),
 | 
			
		||||
                                style: TextStyle(
 | 
			
		||||
                                    fontSize: 12,
 | 
			
		||||
                                    fontWeight: FontWeight.w600,
 | 
			
		||||
                                    color: Colors.grey[700]),
 | 
			
		||||
                              ),
 | 
			
		||||
                              Icon(
 | 
			
		||||
                                Icons.error,
 | 
			
		||||
                                color: Colors.red.withAlpha(200),
 | 
			
		||||
                                size: 18,
 | 
			
		||||
                              ),
 | 
			
		||||
                            ],
 | 
			
		||||
                          ),
 | 
			
		||||
                          Padding(
 | 
			
		||||
                            padding: const EdgeInsets.symmetric(vertical: 8.0),
 | 
			
		||||
                            child: Text(
 | 
			
		||||
                              errorAsset.fileName,
 | 
			
		||||
                              maxLines: 1,
 | 
			
		||||
                              overflow: TextOverflow.ellipsis,
 | 
			
		||||
                              style: TextStyle(
 | 
			
		||||
                                fontWeight: FontWeight.bold,
 | 
			
		||||
                                fontSize: 12,
 | 
			
		||||
                                color: Theme.of(context).primaryColor,
 | 
			
		||||
                              ),
 | 
			
		||||
                            ),
 | 
			
		||||
                          ),
 | 
			
		||||
                          Text(
 | 
			
		||||
                            errorAsset.errorMessage,
 | 
			
		||||
                            style: TextStyle(
 | 
			
		||||
                              fontSize: 12,
 | 
			
		||||
                              fontWeight: FontWeight.w500,
 | 
			
		||||
                              color: Colors.grey[800],
 | 
			
		||||
                            ),
 | 
			
		||||
                          ),
 | 
			
		||||
                        ],
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                  )
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          );
 | 
			
		||||
        }),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -2,6 +2,7 @@ import 'package:auto_route/auto_route.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/views/album_preview_page.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/views/backup_album_selection_page.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/views/failed_backup_status_page.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/login/views/change_password_page.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/login/views/login_page.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/views/home_page.dart';
 | 
			
		||||
@ -65,6 +66,11 @@ part 'router.gr.dart';
 | 
			
		||||
    ),
 | 
			
		||||
    AutoRoute(page: BackupAlbumSelectionPage, guards: [AuthGuard]),
 | 
			
		||||
    AutoRoute(page: AlbumPreviewPage, guards: [AuthGuard]),
 | 
			
		||||
    CustomRoute(
 | 
			
		||||
      page: FailedBackupStatusPage,
 | 
			
		||||
      guards: [AuthGuard],
 | 
			
		||||
      transitionsBuilder: TransitionsBuilders.slideBottom,
 | 
			
		||||
    ),
 | 
			
		||||
  ],
 | 
			
		||||
)
 | 
			
		||||
class AppRouter extends _$AppRouter {
 | 
			
		||||
 | 
			
		||||
@ -115,6 +115,14 @@ class _$AppRouter extends RootStackRouter {
 | 
			
		||||
          routeData: routeData,
 | 
			
		||||
          child: AlbumPreviewPage(key: args.key, album: args.album));
 | 
			
		||||
    },
 | 
			
		||||
    FailedBackupStatusRoute.name: (routeData) {
 | 
			
		||||
      return CustomPage<dynamic>(
 | 
			
		||||
          routeData: routeData,
 | 
			
		||||
          child: const FailedBackupStatusPage(),
 | 
			
		||||
          transitionsBuilder: TransitionsBuilders.slideBottom,
 | 
			
		||||
          opaque: true,
 | 
			
		||||
          barrierDismissible: false);
 | 
			
		||||
    },
 | 
			
		||||
    HomeRoute.name: (routeData) {
 | 
			
		||||
      return MaterialPageX<dynamic>(
 | 
			
		||||
          routeData: routeData, child: const HomePage());
 | 
			
		||||
@ -177,7 +185,9 @@ class _$AppRouter extends RootStackRouter {
 | 
			
		||||
        RouteConfig(BackupAlbumSelectionRoute.name,
 | 
			
		||||
            path: '/backup-album-selection-page', guards: [authGuard]),
 | 
			
		||||
        RouteConfig(AlbumPreviewRoute.name,
 | 
			
		||||
            path: '/album-preview-page', guards: [authGuard])
 | 
			
		||||
            path: '/album-preview-page', guards: [authGuard]),
 | 
			
		||||
        RouteConfig(FailedBackupStatusRoute.name,
 | 
			
		||||
            path: '/failed-backup-status-page', guards: [authGuard])
 | 
			
		||||
      ];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -437,6 +447,15 @@ class AlbumPreviewRouteArgs {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// generated route for
 | 
			
		||||
/// [FailedBackupStatusPage]
 | 
			
		||||
class FailedBackupStatusRoute extends PageRouteInfo<void> {
 | 
			
		||||
  const FailedBackupStatusRoute()
 | 
			
		||||
      : super(FailedBackupStatusRoute.name, path: '/failed-backup-status-page');
 | 
			
		||||
 | 
			
		||||
  static const String name = 'FailedBackupStatusRoute';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// generated route for
 | 
			
		||||
/// [HomePage]
 | 
			
		||||
class HomeRoute extends PageRouteInfo<void> {
 | 
			
		||||
 | 
			
		||||
@ -79,7 +79,7 @@ class NetworkService {
 | 
			
		||||
 | 
			
		||||
      return res;
 | 
			
		||||
    } on DioError catch (e) {
 | 
			
		||||
      debugPrint("DioError: ${e.response}");
 | 
			
		||||
      debugPrint("[postRequest] DioError: ${e.response}");
 | 
			
		||||
      return null;
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      debugPrint("ERROR PostRequest: $e");
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,7 @@ name: immich_mobile
 | 
			
		||||
description: Immich - selfhosted backup media file on mobile phone
 | 
			
		||||
 | 
			
		||||
publish_to: "none"
 | 
			
		||||
version: 1.16.1+24
 | 
			
		||||
version: 1.17.0+25
 | 
			
		||||
 | 
			
		||||
environment:
 | 
			
		||||
  sdk: ">=2.17.0 <3.0.0"
 | 
			
		||||
 | 
			
		||||
@ -15,6 +15,7 @@ import {
 | 
			
		||||
  Delete,
 | 
			
		||||
  Logger,
 | 
			
		||||
  HttpCode,
 | 
			
		||||
  BadRequestException,
 | 
			
		||||
} from '@nestjs/common';
 | 
			
		||||
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
 | 
			
		||||
import { AssetService } from './asset.service';
 | 
			
		||||
@ -34,6 +35,7 @@ import { Queue } from 'bull';
 | 
			
		||||
import { IAssetUploadedJob } from '@app/job/index';
 | 
			
		||||
import { assetUploadedQueueName } from '@app/job/constants/queue-name.constant';
 | 
			
		||||
import { assetUploadedProcessorName } from '@app/job/constants/job-name.constant';
 | 
			
		||||
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
 | 
			
		||||
 | 
			
		||||
@UseGuards(JwtAuthGuard)
 | 
			
		||||
@Controller('asset')
 | 
			
		||||
@ -66,17 +68,16 @@ export class AssetController {
 | 
			
		||||
      try {
 | 
			
		||||
        const savedAsset = await this.assetService.createUserAsset(authUser, assetInfo, file.path, file.mimetype);
 | 
			
		||||
 | 
			
		||||
        if (!savedAsset) {
 | 
			
		||||
          return;
 | 
			
		||||
        if (savedAsset) {
 | 
			
		||||
          await this.assetUploadedQueue.add(
 | 
			
		||||
            assetUploadedProcessorName,
 | 
			
		||||
            { asset: savedAsset, fileName: file.originalname, fileSize: file.size },
 | 
			
		||||
            { jobId: savedAsset.id },
 | 
			
		||||
          );
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        await this.assetUploadedQueue.add(
 | 
			
		||||
          assetUploadedProcessorName,
 | 
			
		||||
          { asset: savedAsset, fileName: file.originalname, fileSize: file.size },
 | 
			
		||||
          { jobId: savedAsset.id },
 | 
			
		||||
        );
 | 
			
		||||
      } catch (e) {
 | 
			
		||||
        Logger.error(`Error receiving upload file ${e}`);
 | 
			
		||||
        Logger.error(`Error uploading file ${e}`);
 | 
			
		||||
        throw new BadRequestException(`Error uploading file`, `${e}`);
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
@ -172,9 +173,9 @@ export class AssetController {
 | 
			
		||||
  @HttpCode(200)
 | 
			
		||||
  async checkDuplicateAsset(
 | 
			
		||||
    @GetAuthUser() authUser: AuthUserDto,
 | 
			
		||||
    @Body(ValidationPipe) { deviceAssetId }: { deviceAssetId: string },
 | 
			
		||||
    @Body(ValidationPipe) checkDuplicateAssetDto: CheckDuplicateAssetDto,
 | 
			
		||||
  ) {
 | 
			
		||||
    const res = await this.assetService.checkDuplicatedAsset(authUser, deviceAssetId);
 | 
			
		||||
    const res = await this.assetService.checkDuplicatedAsset(authUser, checkDuplicateAssetDto);
 | 
			
		||||
 | 
			
		||||
    return {
 | 
			
		||||
      isExist: res,
 | 
			
		||||
 | 
			
		||||
@ -18,6 +18,7 @@ import { promisify } from 'util';
 | 
			
		||||
import { DeleteAssetDto } from './dto/delete-asset.dto';
 | 
			
		||||
import { SearchAssetDto } from './dto/search-asset.dto';
 | 
			
		||||
import fs from 'fs/promises';
 | 
			
		||||
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
 | 
			
		||||
 | 
			
		||||
const fileInfo = promisify(stat);
 | 
			
		||||
 | 
			
		||||
@ -58,15 +59,11 @@ export class AssetService {
 | 
			
		||||
    asset.mimeType = mimeType;
 | 
			
		||||
    asset.duration = assetInfo.duration || null;
 | 
			
		||||
 | 
			
		||||
    try {
 | 
			
		||||
      const createdAsset = await this.assetRepository.save(asset);
 | 
			
		||||
      if (!createdAsset) {
 | 
			
		||||
        throw new Error('Asset not created');
 | 
			
		||||
      }
 | 
			
		||||
      return createdAsset;
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      Logger.error(`Error Create New Asset ${e}`, 'createUserAsset');
 | 
			
		||||
    const createdAsset = await this.assetRepository.save(asset);
 | 
			
		||||
    if (!createdAsset) {
 | 
			
		||||
      throw new Error('Asset not created');
 | 
			
		||||
    }
 | 
			
		||||
    return createdAsset;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  public async getUserAssetsByDeviceId(authUser: AuthUserDto, deviceId: string) {
 | 
			
		||||
@ -439,10 +436,11 @@ export class AssetService {
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  async checkDuplicatedAsset(authUser: AuthUserDto, deviceAssetId: string) {
 | 
			
		||||
  async checkDuplicatedAsset(authUser: AuthUserDto, checkDuplicateAssetDto: CheckDuplicateAssetDto) {
 | 
			
		||||
    const res = await this.assetRepository.findOne({
 | 
			
		||||
      where: {
 | 
			
		||||
        deviceAssetId,
 | 
			
		||||
        deviceAssetId: checkDuplicateAssetDto.deviceAssetId,
 | 
			
		||||
        deviceId: checkDuplicateAssetDto.deviceId,
 | 
			
		||||
        userId: authUser.id,
 | 
			
		||||
      },
 | 
			
		||||
    });
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,9 @@
 | 
			
		||||
import { IsNotEmpty } from 'class-validator';
 | 
			
		||||
 | 
			
		||||
export class CheckDuplicateAssetDto {
 | 
			
		||||
  @IsNotEmpty()
 | 
			
		||||
  deviceAssetId!: string;
 | 
			
		||||
 | 
			
		||||
  @IsNotEmpty()
 | 
			
		||||
  deviceId!: string;
 | 
			
		||||
}
 | 
			
		||||
@ -3,7 +3,7 @@
 | 
			
		||||
 | 
			
		||||
export const serverVersion = {
 | 
			
		||||
  major: 1,
 | 
			
		||||
  minor: 16,
 | 
			
		||||
  minor: 17,
 | 
			
		||||
  patch: 0,
 | 
			
		||||
  build: 23,
 | 
			
		||||
  build: 25,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||
@ -53,7 +53,7 @@ export async function fileUploader(asset: File, accessToken: string) {
 | 
			
		||||
		// Check if asset upload on server before performing upload
 | 
			
		||||
		const res = await fetch(serverEndpoint + '/asset/check', {
 | 
			
		||||
			method: 'POST',
 | 
			
		||||
			body: JSON.stringify({ deviceAssetId }),
 | 
			
		||||
			body: JSON.stringify({ deviceAssetId, deviceId: 'WEB' }),
 | 
			
		||||
			headers: {
 | 
			
		||||
				Authorization: 'Bearer ' + accessToken,
 | 
			
		||||
				'Content-Type': 'application/json',
 | 
			
		||||
 | 
			
		||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user