mirror of
				https://github.com/KevinMidboe/immich.git
				synced 2025-10-29 17:40:28 +00:00 
			
		
		
		
	
							
								
								
									
										35
									
								
								mobile/lib/modules/backup/models/available_album.model.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								mobile/lib/modules/backup/models/available_album.model.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,35 @@ | ||||
| import 'dart:typed_data'; | ||||
|  | ||||
| import 'package:photo_manager/photo_manager.dart'; | ||||
|  | ||||
| class AvailableAlbum { | ||||
|   final AssetPathEntity albumEntity; | ||||
|   final Uint8List? thumbnailData; | ||||
|   AvailableAlbum({ | ||||
|     required this.albumEntity, | ||||
|     this.thumbnailData, | ||||
|   }); | ||||
|  | ||||
|   AvailableAlbum copyWith({ | ||||
|     AssetPathEntity? albumEntity, | ||||
|     Uint8List? thumbnailData, | ||||
|   }) { | ||||
|     return AvailableAlbum( | ||||
|       albumEntity: albumEntity ?? this.albumEntity, | ||||
|       thumbnailData: thumbnailData ?? this.thumbnailData, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String toString() => 'AvailableAlbum(albumEntity: $albumEntity, thumbnailData: $thumbnailData)'; | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     if (identical(this, other)) return true; | ||||
|  | ||||
|     return other is AvailableAlbum && other.albumEntity == albumEntity && other.thumbnailData == thumbnailData; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   int get hashCode => albumEntity.hashCode ^ thumbnailData.hashCode; | ||||
| } | ||||
							
								
								
									
										88
									
								
								mobile/lib/modules/backup/models/backup_state.model.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										88
									
								
								mobile/lib/modules/backup/models/backup_state.model.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,88 @@ | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:equatable/equatable.dart'; | ||||
| import 'package:photo_manager/photo_manager.dart'; | ||||
|  | ||||
| import 'package:immich_mobile/modules/backup/models/available_album.model.dart'; | ||||
| import 'package:immich_mobile/shared/models/server_info.model.dart'; | ||||
|  | ||||
| enum BackUpProgressEnum { idle, inProgress, done } | ||||
|  | ||||
| class BackUpState extends Equatable { | ||||
|   // enum | ||||
|   final BackUpProgressEnum backupProgress; | ||||
|   final List<String> allAssetOnDatabase; | ||||
|   final double progressInPercentage; | ||||
|   final CancelToken cancelToken; | ||||
|   final ServerInfo serverInfo; | ||||
|  | ||||
|   /// All available albums on the device | ||||
|   final List<AvailableAlbum> availableAlbums; | ||||
|   final Set<AssetPathEntity> selectedBackupAlbums; | ||||
|   final Set<AssetPathEntity> excludedBackupAlbums; | ||||
|  | ||||
|   /// Assets that are not overlapping in selected backup albums and excluded backup albums | ||||
|   final Set<AssetEntity> allUniqueAssets; | ||||
|  | ||||
|   /// All assets from the selected albums that have been backup | ||||
|   final Set<String> selectedAlbumsBackupAssetsIds; | ||||
|  | ||||
|   const BackUpState({ | ||||
|     required this.backupProgress, | ||||
|     required this.allAssetOnDatabase, | ||||
|     required this.progressInPercentage, | ||||
|     required this.cancelToken, | ||||
|     required this.serverInfo, | ||||
|     required this.availableAlbums, | ||||
|     required this.selectedBackupAlbums, | ||||
|     required this.excludedBackupAlbums, | ||||
|     required this.allUniqueAssets, | ||||
|     required this.selectedAlbumsBackupAssetsIds, | ||||
|   }); | ||||
|  | ||||
|   BackUpState copyWith({ | ||||
|     BackUpProgressEnum? backupProgress, | ||||
|     List<String>? allAssetOnDatabase, | ||||
|     double? progressInPercentage, | ||||
|     CancelToken? cancelToken, | ||||
|     ServerInfo? serverInfo, | ||||
|     List<AvailableAlbum>? availableAlbums, | ||||
|     Set<AssetPathEntity>? selectedBackupAlbums, | ||||
|     Set<AssetPathEntity>? excludedBackupAlbums, | ||||
|     Set<AssetEntity>? allUniqueAssets, | ||||
|     Set<String>? selectedAlbumsBackupAssetsIds, | ||||
|   }) { | ||||
|     return BackUpState( | ||||
|       backupProgress: backupProgress ?? this.backupProgress, | ||||
|       allAssetOnDatabase: allAssetOnDatabase ?? this.allAssetOnDatabase, | ||||
|       progressInPercentage: progressInPercentage ?? this.progressInPercentage, | ||||
|       cancelToken: cancelToken ?? this.cancelToken, | ||||
|       serverInfo: serverInfo ?? this.serverInfo, | ||||
|       availableAlbums: availableAlbums ?? this.availableAlbums, | ||||
|       selectedBackupAlbums: selectedBackupAlbums ?? this.selectedBackupAlbums, | ||||
|       excludedBackupAlbums: excludedBackupAlbums ?? this.excludedBackupAlbums, | ||||
|       allUniqueAssets: allUniqueAssets ?? this.allUniqueAssets, | ||||
|       selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssetsIds ?? this.selectedAlbumsBackupAssetsIds, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'BackUpState(backupProgress: $backupProgress, allAssetOnDatabase: $allAssetOnDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds)'; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   List<Object> get props { | ||||
|     return [ | ||||
|       backupProgress, | ||||
|       allAssetOnDatabase, | ||||
|       progressInPercentage, | ||||
|       cancelToken, | ||||
|       serverInfo, | ||||
|       availableAlbums, | ||||
|       selectedBackupAlbums, | ||||
|       excludedBackupAlbums, | ||||
|       allUniqueAssets, | ||||
|       selectedAlbumsBackupAssetsIds, | ||||
|     ]; | ||||
|   } | ||||
| } | ||||
| @@ -0,0 +1,66 @@ | ||||
| import 'dart:convert'; | ||||
|  | ||||
| import 'package:collection/collection.dart'; | ||||
| import 'package:hive/hive.dart'; | ||||
|  | ||||
| part 'hive_backup_albums.model.g.dart'; | ||||
|  | ||||
| @HiveType(typeId: 1) | ||||
| class HiveBackupAlbums { | ||||
|   @HiveField(0) | ||||
|   List<String> selectedAlbumIds; | ||||
|  | ||||
|   @HiveField(1) | ||||
|   List<String> excludedAlbumsIds; | ||||
|  | ||||
|   HiveBackupAlbums({ | ||||
|     required this.selectedAlbumIds, | ||||
|     required this.excludedAlbumsIds, | ||||
|   }); | ||||
|  | ||||
|   @override | ||||
|   String toString() => 'HiveBackupAlbums(selectedAlbumIds: $selectedAlbumIds, excludedAlbumsIds: $excludedAlbumsIds)'; | ||||
|  | ||||
|   HiveBackupAlbums copyWith({ | ||||
|     List<String>? selectedAlbumIds, | ||||
|     List<String>? excludedAlbumsIds, | ||||
|   }) { | ||||
|     return HiveBackupAlbums( | ||||
|       selectedAlbumIds: selectedAlbumIds ?? this.selectedAlbumIds, | ||||
|       excludedAlbumsIds: excludedAlbumsIds ?? this.excludedAlbumsIds, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   Map<String, dynamic> toMap() { | ||||
|     final result = <String, dynamic>{}; | ||||
|  | ||||
|     result.addAll({'selectedAlbumIds': selectedAlbumIds}); | ||||
|     result.addAll({'excludedAlbumsIds': excludedAlbumsIds}); | ||||
|  | ||||
|     return result; | ||||
|   } | ||||
|  | ||||
|   factory HiveBackupAlbums.fromMap(Map<String, dynamic> map) { | ||||
|     return HiveBackupAlbums( | ||||
|       selectedAlbumIds: List<String>.from(map['selectedAlbumIds']), | ||||
|       excludedAlbumsIds: List<String>.from(map['excludedAlbumsIds']), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   String toJson() => json.encode(toMap()); | ||||
|  | ||||
|   factory HiveBackupAlbums.fromJson(String source) => HiveBackupAlbums.fromMap(json.decode(source)); | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     if (identical(this, other)) return true; | ||||
|     final listEquals = const DeepCollectionEquality().equals; | ||||
|  | ||||
|     return other is HiveBackupAlbums && | ||||
|         listEquals(other.selectedAlbumIds, selectedAlbumIds) && | ||||
|         listEquals(other.excludedAlbumsIds, excludedAlbumsIds); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   int get hashCode => selectedAlbumIds.hashCode ^ excludedAlbumsIds.hashCode; | ||||
| } | ||||
| @@ -0,0 +1,42 @@ | ||||
| // GENERATED CODE - DO NOT MODIFY BY HAND | ||||
|  | ||||
| part of 'hive_backup_albums.model.dart'; | ||||
|  | ||||
| // ************************************************************************** | ||||
| // TypeAdapterGenerator | ||||
| // ************************************************************************** | ||||
|  | ||||
| class HiveBackupAlbumsAdapter extends TypeAdapter<HiveBackupAlbums> { | ||||
|   @override | ||||
|   final int typeId = 1; | ||||
|  | ||||
|   @override | ||||
|   HiveBackupAlbums read(BinaryReader reader) { | ||||
|     final numOfFields = reader.readByte(); | ||||
|     final fields = <int, dynamic>{ | ||||
|       for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(), | ||||
|     }; | ||||
|     return HiveBackupAlbums( | ||||
|       selectedAlbumIds: (fields[0] as List).cast<String>(), | ||||
|       excludedAlbumsIds: (fields[1] as List).cast<String>(), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   void write(BinaryWriter writer, HiveBackupAlbums obj) { | ||||
|     writer | ||||
|       ..writeByte(2) | ||||
|       ..writeByte(0) | ||||
|       ..write(obj.selectedAlbumIds) | ||||
|       ..writeByte(1) | ||||
|       ..write(obj.excludedAlbumsIds); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   int get hashCode => typeId.hashCode; | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) => | ||||
|       identical(this, other) || | ||||
|       other is HiveBackupAlbumsAdapter && runtimeType == other.runtimeType && typeId == other.typeId; | ||||
| } | ||||
							
								
								
									
										347
									
								
								mobile/lib/modules/backup/providers/backup.provider.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										347
									
								
								mobile/lib/modules/backup/providers/backup.provider.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,347 @@ | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:flutter/foundation.dart'; | ||||
| import 'package:hive_flutter/hive_flutter.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/constants/hive_box.dart'; | ||||
| import 'package:immich_mobile/modules/backup/models/available_album.model.dart'; | ||||
| import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart'; | ||||
| import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; | ||||
| import 'package:immich_mobile/shared/services/server_info.service.dart'; | ||||
| import 'package:immich_mobile/modules/backup/models/backup_state.model.dart'; | ||||
| import 'package:immich_mobile/shared/models/server_info.model.dart'; | ||||
| import 'package:immich_mobile/modules/backup/services/backup.service.dart'; | ||||
| import 'package:photo_manager/photo_manager.dart'; | ||||
|  | ||||
| class BackupNotifier extends StateNotifier<BackUpState> { | ||||
|   BackupNotifier({this.ref}) | ||||
|       : super( | ||||
|           BackUpState( | ||||
|             backupProgress: BackUpProgressEnum.idle, | ||||
|             allAssetOnDatabase: const [], | ||||
|             progressInPercentage: 0, | ||||
|             cancelToken: CancelToken(), | ||||
|             serverInfo: ServerInfo( | ||||
|               diskAvailable: "0", | ||||
|               diskAvailableRaw: 0, | ||||
|               diskSize: "0", | ||||
|               diskSizeRaw: 0, | ||||
|               diskUsagePercentage: 0.0, | ||||
|               diskUse: "0", | ||||
|               diskUseRaw: 0, | ||||
|             ), | ||||
|             availableAlbums: const [], | ||||
|             selectedBackupAlbums: const {}, | ||||
|             excludedBackupAlbums: const {}, | ||||
|             allUniqueAssets: const {}, | ||||
|             selectedAlbumsBackupAssetsIds: const {}, | ||||
|           ), | ||||
|         ); | ||||
|  | ||||
|   Ref? ref; | ||||
|   final BackupService _backupService = BackupService(); | ||||
|   final ServerInfoService _serverInfoService = ServerInfoService(); | ||||
|  | ||||
|   /// | ||||
|   /// UI INTERACTION | ||||
|   /// | ||||
|   /// Album selection | ||||
|   /// Due to the overlapping assets across multiple albums on the device | ||||
|   /// We have method to include and exclude albums | ||||
|   /// The total unique assets will be used for backing mechanism | ||||
|   /// | ||||
|   void addAlbumForBackup(AssetPathEntity album) { | ||||
|     if (state.excludedBackupAlbums.contains(album)) { | ||||
|       removeExcludedAlbumForBackup(album); | ||||
|     } | ||||
|  | ||||
|     state = state.copyWith(selectedBackupAlbums: {...state.selectedBackupAlbums, album}); | ||||
|     _updateBackupAssetCount(); | ||||
|   } | ||||
|  | ||||
|   void addExcludedAlbumForBackup(AssetPathEntity album) { | ||||
|     if (state.selectedBackupAlbums.contains(album)) { | ||||
|       removeAlbumForBackup(album); | ||||
|     } | ||||
|     state = state.copyWith(excludedBackupAlbums: {...state.excludedBackupAlbums, album}); | ||||
|     _updateBackupAssetCount(); | ||||
|   } | ||||
|  | ||||
|   void removeAlbumForBackup(AssetPathEntity album) { | ||||
|     Set<AssetPathEntity> currentSelectedAlbums = state.selectedBackupAlbums; | ||||
|  | ||||
|     currentSelectedAlbums.removeWhere((a) => a == album); | ||||
|  | ||||
|     state = state.copyWith(selectedBackupAlbums: currentSelectedAlbums); | ||||
|     _updateBackupAssetCount(); | ||||
|   } | ||||
|  | ||||
|   void removeExcludedAlbumForBackup(AssetPathEntity album) { | ||||
|     Set<AssetPathEntity> currentExcludedAlbums = state.excludedBackupAlbums; | ||||
|  | ||||
|     currentExcludedAlbums.removeWhere((a) => a == album); | ||||
|  | ||||
|     state = state.copyWith(excludedBackupAlbums: currentExcludedAlbums); | ||||
|     _updateBackupAssetCount(); | ||||
|   } | ||||
|  | ||||
|   /// | ||||
|   /// Get all album on the device | ||||
|   /// Get all selected and excluded album from the user's persistent storage | ||||
|   /// If this is the first time performing backup - set the default selected album to be | ||||
|   /// the one that has all assets (Recent on Android, Recents on iOS) | ||||
|   /// | ||||
|   Future<void> getBackupAlbumsInfo() async { | ||||
|     // Get all albums on the device | ||||
|     List<AvailableAlbum> availableAlbums = []; | ||||
|     List<AssetPathEntity> albums = await PhotoManager.getAssetPathList(hasAll: true, type: RequestType.common); | ||||
|  | ||||
|     for (AssetPathEntity album in albums) { | ||||
|       AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album); | ||||
|  | ||||
|       var assetList = await album.getAssetListRange(start: 0, end: album.assetCount); | ||||
|  | ||||
|       if (assetList.isNotEmpty) { | ||||
|         var thumbnailAsset = assetList.first; | ||||
|         var thumbnailData = await thumbnailAsset.thumbnailDataWithSize(const ThumbnailSize(512, 512)); | ||||
|         availableAlbum = availableAlbum.copyWith(thumbnailData: thumbnailData); | ||||
|       } | ||||
|  | ||||
|       availableAlbums.add(availableAlbum); | ||||
|     } | ||||
|  | ||||
|     state = state.copyWith(availableAlbums: availableAlbums); | ||||
|  | ||||
|     // Put persistent storage info into local state of the app | ||||
|     // Get local storage on selected backup album | ||||
|     Box<HiveBackupAlbums> backupAlbumInfoBox = Hive.box<HiveBackupAlbums>(hiveBackupInfoBox); | ||||
|     HiveBackupAlbums? backupAlbumInfo = backupAlbumInfoBox.get( | ||||
|       backupInfoKey, | ||||
|       defaultValue: HiveBackupAlbums( | ||||
|         selectedAlbumIds: [], | ||||
|         excludedAlbumsIds: [], | ||||
|       ), | ||||
|     ); | ||||
|  | ||||
|     if (backupAlbumInfo == null) { | ||||
|       debugPrint("[ERROR] getting Hive backup album infomation"); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     // First time backup - set isAll album is the default one for backup. | ||||
|     if (backupAlbumInfo.selectedAlbumIds.isEmpty) { | ||||
|       debugPrint("First time backup setup recent album as default"); | ||||
|  | ||||
|       // Get album that contains all assets | ||||
|       var list = await PhotoManager.getAssetPathList(hasAll: true, onlyAll: true, type: RequestType.common); | ||||
|       AssetPathEntity albumHasAllAssets = list.first; | ||||
|  | ||||
|       backupAlbumInfoBox.put( | ||||
|         backupInfoKey, | ||||
|         HiveBackupAlbums( | ||||
|           selectedAlbumIds: [albumHasAllAssets.id], | ||||
|           excludedAlbumsIds: [], | ||||
|         ), | ||||
|       ); | ||||
|  | ||||
|       backupAlbumInfo = backupAlbumInfoBox.get(backupInfoKey); | ||||
|     } | ||||
|  | ||||
|     // Generate AssetPathEntity from id to add to local state | ||||
|     try { | ||||
|       for (var selectedAlbumId in backupAlbumInfo!.selectedAlbumIds) { | ||||
|         var albumAsset = await AssetPathEntity.fromId(selectedAlbumId); | ||||
|         state = state.copyWith(selectedBackupAlbums: {...state.selectedBackupAlbums, albumAsset}); | ||||
|       } | ||||
|  | ||||
|       for (var excludedAlbumId in backupAlbumInfo.excludedAlbumsIds) { | ||||
|         var albumAsset = await AssetPathEntity.fromId(excludedAlbumId); | ||||
|         state = state.copyWith(excludedBackupAlbums: {...state.excludedBackupAlbums, albumAsset}); | ||||
|       } | ||||
|     } catch (e) { | ||||
|       debugPrint("[ERROR] Failed to generate album from id $e"); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   /// | ||||
|   /// From all the selected and albums assets | ||||
|   /// Find the assets that are not overlapping between the two sets | ||||
|   /// Those assets are unique and are used as the total assets | ||||
|   /// | ||||
|   void _updateBackupAssetCount() async { | ||||
|     Set<AssetEntity> assetsFromSelectedAlbums = {}; | ||||
|     Set<AssetEntity> assetsFromExcludedAlbums = {}; | ||||
|  | ||||
|     for (var album in state.selectedBackupAlbums) { | ||||
|       var assets = await album.getAssetListRange(start: 0, end: album.assetCount); | ||||
|       assetsFromSelectedAlbums.addAll(assets); | ||||
|     } | ||||
|  | ||||
|     for (var album in state.excludedBackupAlbums) { | ||||
|       var assets = await album.getAssetListRange(start: 0, end: album.assetCount); | ||||
|       assetsFromExcludedAlbums.addAll(assets); | ||||
|     } | ||||
|  | ||||
|     Set<AssetEntity> allUniqueAssets = assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums); | ||||
|     List<String> allAssetOnDatabase = await _backupService.getDeviceBackupAsset(); | ||||
|  | ||||
|     // Find asset that were backup from selected albums | ||||
|     Set<String> selectedAlbumsBackupAssets = Set.from(allUniqueAssets.map((e) => e.id)); | ||||
|     selectedAlbumsBackupAssets.removeWhere((assetId) => !allAssetOnDatabase.contains(assetId)); | ||||
|  | ||||
|     if (allUniqueAssets.isEmpty) { | ||||
|       debugPrint("No Asset On Device"); | ||||
|       state = state.copyWith( | ||||
|         backupProgress: BackUpProgressEnum.idle, | ||||
|         allAssetOnDatabase: allAssetOnDatabase, | ||||
|         allUniqueAssets: {}, | ||||
|         selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets, | ||||
|       ); | ||||
|       return; | ||||
|     } else { | ||||
|       state = state.copyWith( | ||||
|         allAssetOnDatabase: allAssetOnDatabase, | ||||
|         allUniqueAssets: allUniqueAssets, | ||||
|         selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets, | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     // Save to persistent storage | ||||
|     _updatePersistentAlbumsSelection(); | ||||
|   } | ||||
|  | ||||
|   /// | ||||
|   /// Get all necessary information for calculating the available albums, | ||||
|   /// which albums are selected or excluded | ||||
|   /// and then update the UI according to those information | ||||
|   /// | ||||
|   void getBackupInfo() async { | ||||
|     await getBackupAlbumsInfo(); | ||||
|     _updateServerInfo(); | ||||
|     _updateBackupAssetCount(); | ||||
|   } | ||||
|  | ||||
|   /// | ||||
|   /// Save user selection of selected albums and excluded albums to | ||||
|   /// Hive database | ||||
|   /// | ||||
|   void _updatePersistentAlbumsSelection() { | ||||
|     Box<HiveBackupAlbums> backupAlbumInfoBox = Hive.box<HiveBackupAlbums>(hiveBackupInfoBox); | ||||
|     backupAlbumInfoBox.put( | ||||
|       backupInfoKey, | ||||
|       HiveBackupAlbums( | ||||
|         selectedAlbumIds: state.selectedBackupAlbums.map((e) => e.id).toList(), | ||||
|         excludedAlbumsIds: state.excludedBackupAlbums.map((e) => e.id).toList(), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   /// | ||||
|   /// Invoke backup process | ||||
|   /// | ||||
|   void startBackupProcess() async { | ||||
|     _updateServerInfo(); | ||||
|     _updateBackupAssetCount(); | ||||
|  | ||||
|     state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress); | ||||
|  | ||||
|     var authResult = await PhotoManager.requestPermissionExtend(); | ||||
|     if (authResult.isAuth) { | ||||
|       await PhotoManager.clearFileCache(); | ||||
|  | ||||
|       if (state.allUniqueAssets.isEmpty) { | ||||
|         debugPrint("No Asset On Device - Abort Backup Process"); | ||||
|         state = state.copyWith(backupProgress: BackUpProgressEnum.idle); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       Set<AssetEntity> assetsWillBeBackup = state.allUniqueAssets; | ||||
|  | ||||
|       // Remove item that has already been backed up | ||||
|       for (var assetId in state.allAssetOnDatabase) { | ||||
|         assetsWillBeBackup.removeWhere((e) => e.id == assetId); | ||||
|       } | ||||
|  | ||||
|       if (assetsWillBeBackup.isEmpty) { | ||||
|         state = state.copyWith(backupProgress: BackUpProgressEnum.idle); | ||||
|       } | ||||
|  | ||||
|       // Perform Backup | ||||
|       state = state.copyWith(cancelToken: CancelToken()); | ||||
|       _backupService.backupAsset(assetsWillBeBackup, state.cancelToken, _onAssetUploaded, _onUploadProgress); | ||||
|     } else { | ||||
|       PhotoManager.openSetting(); | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   void cancelBackup() { | ||||
|     state.cancelToken.cancel('Cancel Backup'); | ||||
|     state = state.copyWith(backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0); | ||||
|   } | ||||
|  | ||||
|   void _onAssetUploaded(String deviceAssetId, String deviceId) { | ||||
|     state = state.copyWith( | ||||
|         selectedAlbumsBackupAssetsIds: {...state.selectedAlbumsBackupAssetsIds, deviceAssetId}, | ||||
|         allAssetOnDatabase: [...state.allAssetOnDatabase, deviceAssetId]); | ||||
|  | ||||
|     if (state.allUniqueAssets.length - state.selectedAlbumsBackupAssetsIds.length == 0) { | ||||
|       state = state.copyWith(backupProgress: BackUpProgressEnum.done, progressInPercentage: 0.0); | ||||
|     } | ||||
|  | ||||
|     _updateServerInfo(); | ||||
|   } | ||||
|  | ||||
|   void _onUploadProgress(int sent, int total) { | ||||
|     state = state.copyWith(progressInPercentage: (sent.toDouble() / total.toDouble() * 100)); | ||||
|   } | ||||
|  | ||||
|   void _updateServerInfo() async { | ||||
|     var serverInfo = await _serverInfoService.getServerInfo(); | ||||
|  | ||||
|     // Update server info | ||||
|     state = state.copyWith( | ||||
|       serverInfo: ServerInfo( | ||||
|         diskSize: serverInfo.diskSize, | ||||
|         diskUse: serverInfo.diskUse, | ||||
|         diskAvailable: serverInfo.diskAvailable, | ||||
|         diskSizeRaw: serverInfo.diskSizeRaw, | ||||
|         diskUseRaw: serverInfo.diskUseRaw, | ||||
|         diskAvailableRaw: serverInfo.diskAvailableRaw, | ||||
|         diskUsagePercentage: serverInfo.diskUsagePercentage, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   void resumeBackup() { | ||||
|     var authState = ref?.read(authenticationProvider); | ||||
|  | ||||
|     // Check if user is login | ||||
|     var accessKey = Hive.box(userInfoBox).get(accessTokenKey); | ||||
|  | ||||
|     // User has been logged out return | ||||
|     if (authState != null) { | ||||
|       if (accessKey == null || !authState.isAuthenticated) { | ||||
|         debugPrint("[resumeBackup] not authenticated - abort"); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       // Check if this device is enable backup by the user | ||||
|       if ((authState.deviceInfo.deviceId == authState.deviceId) && authState.deviceInfo.isAutoBackup) { | ||||
|         // check if backup is alreayd in process - then return | ||||
|         if (state.backupProgress == BackUpProgressEnum.inProgress) { | ||||
|           debugPrint("[resumeBackup] Backup is already in progress - abort"); | ||||
|           return; | ||||
|         } | ||||
|  | ||||
|         // Run backup | ||||
|         debugPrint("[resumeBackup] Start back up"); | ||||
|         startBackupProcess(); | ||||
|       } | ||||
|  | ||||
|       return; | ||||
|     } | ||||
|   } | ||||
| } | ||||
|  | ||||
| final backupProvider = StateNotifierProvider<BackupNotifier, BackUpState>((ref) { | ||||
|   return BackupNotifier(ref: ref); | ||||
| }); | ||||
							
								
								
									
										152
									
								
								mobile/lib/modules/backup/services/backup.service.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										152
									
								
								mobile/lib/modules/backup/services/backup.service.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,152 @@ | ||||
| import 'dart:async'; | ||||
| import 'dart:convert'; | ||||
| import 'dart:io'; | ||||
|  | ||||
| import 'package:dio/dio.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:hive/hive.dart'; | ||||
| import 'package:immich_mobile/constants/hive_box.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/dio_http_interceptor.dart'; | ||||
| import 'package:immich_mobile/utils/files_helper.dart'; | ||||
| import 'package:photo_manager/photo_manager.dart'; | ||||
| import 'package:http_parser/http_parser.dart'; | ||||
| import 'package:path/path.dart' as p; | ||||
|  | ||||
| class BackupService { | ||||
|   final NetworkService _networkService = NetworkService(); | ||||
|  | ||||
|   Future<List<String>> getDeviceBackupAsset() async { | ||||
|     String deviceId = Hive.box(userInfoBox).get(deviceIdKey); | ||||
|  | ||||
|     Response response = await _networkService.getRequest(url: "asset/$deviceId"); | ||||
|     List<dynamic> result = jsonDecode(response.toString()); | ||||
|  | ||||
|     return result.cast<String>(); | ||||
|   } | ||||
|  | ||||
|   backupAsset(Set<AssetEntity> assetList, CancelToken cancelToken, Function(String, String) singleAssetDoneCb, | ||||
|       Function(int, int) uploadProgress) async { | ||||
|     var dio = Dio(); | ||||
|     dio.interceptors.add(AuthenticatedRequestInterceptor()); | ||||
|  | ||||
|     String deviceId = Hive.box(userInfoBox).get(deviceIdKey); | ||||
|     String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey); | ||||
|     File? file; | ||||
|  | ||||
|     MultipartFile assetRawUploadData; | ||||
|     MultipartFile thumbnailUploadData; | ||||
|  | ||||
|     for (var entity in assetList) { | ||||
|       try { | ||||
|         if (entity.type == AssetType.video) { | ||||
|           file = await entity.originFile; | ||||
|         } else { | ||||
|           file = await entity.originFile.timeout(const Duration(seconds: 5)); | ||||
|         } | ||||
|  | ||||
|         if (file != null) { | ||||
|           FormData formData; | ||||
|           String originalFileName = await entity.titleAsync; | ||||
|           String fileNameWithoutPath = originalFileName.toString().split(".")[0]; | ||||
|           var fileExtension = p.extension(file.path); | ||||
|           var mimeType = FileHelper.getMimeType(file.path); | ||||
|           assetRawUploadData = await MultipartFile.fromFile( | ||||
|             file.path, | ||||
|             filename: fileNameWithoutPath, | ||||
|             contentType: MediaType( | ||||
|               mimeType["type"], | ||||
|               mimeType["subType"], | ||||
|             ), | ||||
|           ); | ||||
|           formData = FormData.fromMap({ | ||||
|             'deviceAssetId': entity.id, | ||||
|             'deviceId': deviceId, | ||||
|             'assetType': _getAssetType(entity.type), | ||||
|             'createdAt': entity.createDateTime.toIso8601String(), | ||||
|             'modifiedAt': entity.modifiedDateTime.toIso8601String(), | ||||
|             'isFavorite': entity.isFavorite, | ||||
|             'fileExtension': fileExtension, | ||||
|             'duration': entity.videoDuration, | ||||
|             'assetData': [assetRawUploadData] | ||||
|           }); | ||||
|  | ||||
|           // Build thumbnail multipart data | ||||
|           var thumbnailData = await entity.thumbnailDataWithSize(const ThumbnailSize(720, 1280)); | ||||
|           if (thumbnailData != null) { | ||||
|             thumbnailUploadData = MultipartFile.fromBytes( | ||||
|               List.from(thumbnailData), | ||||
|               filename: fileNameWithoutPath, | ||||
|               contentType: MediaType( | ||||
|                 "image", | ||||
|                 "jpeg", | ||||
|               ), | ||||
|             ); | ||||
|  | ||||
|             // Send thumbnail data if it is exist | ||||
|             formData = FormData.fromMap({ | ||||
|               'deviceAssetId': entity.id, | ||||
|               'deviceId': deviceId, | ||||
|               'assetType': _getAssetType(entity.type), | ||||
|               'createdAt': entity.createDateTime.toIso8601String(), | ||||
|               'modifiedAt': entity.modifiedDateTime.toIso8601String(), | ||||
|               'isFavorite': entity.isFavorite, | ||||
|               'fileExtension': fileExtension, | ||||
|               'duration': entity.videoDuration, | ||||
|               'thumbnailData': [thumbnailUploadData], | ||||
|               'assetData': [assetRawUploadData] | ||||
|             }); | ||||
|           } | ||||
|  | ||||
|           Response res = await dio.post( | ||||
|             '$savedEndpoint/asset/upload', | ||||
|             data: formData, | ||||
|             cancelToken: cancelToken, | ||||
|             onSendProgress: (sent, total) => uploadProgress(sent, total), | ||||
|           ); | ||||
|  | ||||
|           if (res.statusCode == 201) { | ||||
|             singleAssetDoneCb(entity.id, deviceId); | ||||
|           } | ||||
|         } | ||||
|       } on DioError catch (e) { | ||||
|         debugPrint("DioError backupAsset: ${e.response}"); | ||||
|         if (e.type == DioErrorType.cancel || e.type == DioErrorType.other) { | ||||
|           return; | ||||
|         } | ||||
|         continue; | ||||
|       } catch (e) { | ||||
|         debugPrint("ERROR backupAsset: ${e.toString()}"); | ||||
|         continue; | ||||
|       } finally { | ||||
|         if (Platform.isIOS) { | ||||
|           file?.deleteSync(); | ||||
|         } | ||||
|       } | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   String _getAssetType(AssetType assetType) { | ||||
|     switch (assetType) { | ||||
|       case AssetType.audio: | ||||
|         return "AUDIO"; | ||||
|       case AssetType.image: | ||||
|         return "IMAGE"; | ||||
|       case AssetType.video: | ||||
|         return "VIDEO"; | ||||
|       case AssetType.other: | ||||
|         return "OTHER"; | ||||
|     } | ||||
|   } | ||||
|  | ||||
|   Future<DeviceInfoRemote> setAutoBackup(bool status, String deviceId, String deviceType) async { | ||||
|     var res = await _networkService.patchRequest(url: 'device-info', data: { | ||||
|       "isAutoBackup": status, | ||||
|       "deviceId": deviceId, | ||||
|       "deviceType": deviceType, | ||||
|     }); | ||||
|  | ||||
|     return DeviceInfoRemote.fromJson(res.toString()); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										185
									
								
								mobile/lib/modules/backup/ui/album_info_card.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										185
									
								
								mobile/lib/modules/backup/ui/album_info_card.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,185 @@ | ||||
| import 'dart:typed_data'; | ||||
|  | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter/services.dart'; | ||||
| import 'package:fluttertoast/fluttertoast.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/backup/providers/backup.provider.dart'; | ||||
| import 'package:immich_mobile/routing/router.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_toast.dart'; | ||||
| import 'package:photo_manager/photo_manager.dart'; | ||||
|  | ||||
| class AlbumInfoCard extends HookConsumerWidget { | ||||
|   final Uint8List? imageData; | ||||
|   final AssetPathEntity albumInfo; | ||||
|  | ||||
|   const AlbumInfoCard({Key? key, this.imageData, required this.albumInfo}) : super(key: key); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final bool isSelected = ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo); | ||||
|     final bool isExcluded = ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo); | ||||
|  | ||||
|     ColorFilter selectedFilter = ColorFilter.mode(Theme.of(context).primaryColor.withAlpha(100), BlendMode.darken); | ||||
|     ColorFilter excludedFilter = ColorFilter.mode(Colors.red.withAlpha(75), BlendMode.darken); | ||||
|     ColorFilter unselectedFilter = const ColorFilter.mode(Colors.black, BlendMode.color); | ||||
|  | ||||
|     _buildSelectedTextBox() { | ||||
|       if (isSelected) { | ||||
|         return Chip( | ||||
|           visualDensity: VisualDensity.compact, | ||||
|           shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), | ||||
|           label: const Text( | ||||
|             "INCLUDED", | ||||
|             style: TextStyle(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold), | ||||
|           ), | ||||
|           backgroundColor: Theme.of(context).primaryColor, | ||||
|         ); | ||||
|       } else if (isExcluded) { | ||||
|         return Chip( | ||||
|           visualDensity: VisualDensity.compact, | ||||
|           shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), | ||||
|           label: const Text( | ||||
|             "EXCLUDED", | ||||
|             style: TextStyle(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold), | ||||
|           ), | ||||
|           backgroundColor: Colors.red[300], | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       return Container(); | ||||
|     } | ||||
|  | ||||
|     _buildImageFilter() { | ||||
|       if (isSelected) { | ||||
|         return selectedFilter; | ||||
|       } else if (isExcluded) { | ||||
|         return excludedFilter; | ||||
|       } else { | ||||
|         return unselectedFilter; | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     return GestureDetector( | ||||
|       onTap: () { | ||||
|         HapticFeedback.selectionClick(); | ||||
|  | ||||
|         if (isSelected) { | ||||
|           if (ref.watch(backupProvider).selectedBackupAlbums.length == 1) { | ||||
|             ImmichToast.show( | ||||
|               context: context, | ||||
|               msg: "Cannot remove the only album", | ||||
|               toastType: ToastType.error, | ||||
|               gravity: ToastGravity.BOTTOM, | ||||
|             ); | ||||
|             return; | ||||
|           } | ||||
|  | ||||
|           ref.watch(backupProvider.notifier).removeAlbumForBackup(albumInfo); | ||||
|         } else { | ||||
|           ref.watch(backupProvider.notifier).addAlbumForBackup(albumInfo); | ||||
|         } | ||||
|       }, | ||||
|       onDoubleTap: () { | ||||
|         HapticFeedback.selectionClick(); | ||||
|  | ||||
|         if (isExcluded) { | ||||
|           ref.watch(backupProvider.notifier).removeExcludedAlbumForBackup(albumInfo); | ||||
|         } else { | ||||
|           if (ref.watch(backupProvider).selectedBackupAlbums.length == 1 && | ||||
|               ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo)) { | ||||
|             ImmichToast.show( | ||||
|               context: context, | ||||
|               msg: "Cannot exclude the only album", | ||||
|               toastType: ToastType.error, | ||||
|               gravity: ToastGravity.BOTTOM, | ||||
|             ); | ||||
|             return; | ||||
|           } | ||||
|  | ||||
|           ref.watch(backupProvider.notifier).addExcludedAlbumForBackup(albumInfo); | ||||
|         } | ||||
|       }, | ||||
|       child: Card( | ||||
|         margin: const EdgeInsets.all(1), | ||||
|         shape: RoundedRectangleBorder( | ||||
|           borderRadius: BorderRadius.circular(12), // if you need this | ||||
|           side: const BorderSide( | ||||
|             color: Color(0xFFC9C9C9), | ||||
|             width: 1, | ||||
|           ), | ||||
|         ), | ||||
|         elevation: 0, | ||||
|         borderOnForeground: false, | ||||
|         child: Column( | ||||
|           crossAxisAlignment: CrossAxisAlignment.start, | ||||
|           children: [ | ||||
|             Stack( | ||||
|               children: [ | ||||
|                 Container( | ||||
|                   width: 200, | ||||
|                   height: 200, | ||||
|                   decoration: BoxDecoration( | ||||
|                     borderRadius: const BorderRadius.only(topLeft: Radius.circular(12), topRight: Radius.circular(12)), | ||||
|                     image: DecorationImage( | ||||
|                       colorFilter: _buildImageFilter(), | ||||
|                       image: imageData != null | ||||
|                           ? MemoryImage(imageData!) | ||||
|                           : const AssetImage('assets/immich-logo-no-outline.png') as ImageProvider, | ||||
|                       fit: BoxFit.cover, | ||||
|                     ), | ||||
|                   ), | ||||
|                   child: null, | ||||
|                 ), | ||||
|                 Positioned(bottom: 10, left: 25, child: _buildSelectedTextBox()) | ||||
|               ], | ||||
|             ), | ||||
|             Padding( | ||||
|               padding: const EdgeInsets.only(top: 8.0), | ||||
|               child: Row( | ||||
|                 crossAxisAlignment: CrossAxisAlignment.center, | ||||
|                 children: [ | ||||
|                   SizedBox( | ||||
|                     width: 140, | ||||
|                     child: Padding( | ||||
|                       padding: const EdgeInsets.only(left: 25.0), | ||||
|                       child: Column( | ||||
|                         crossAxisAlignment: CrossAxisAlignment.start, | ||||
|                         children: [ | ||||
|                           Text( | ||||
|                             albumInfo.name, | ||||
|                             style: TextStyle( | ||||
|                                 fontSize: 14, color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold), | ||||
|                           ), | ||||
|                           Padding( | ||||
|                             padding: const EdgeInsets.only(top: 2.0), | ||||
|                             child: Text( | ||||
|                               albumInfo.assetCount.toString() + (albumInfo.isAll ? " (ALL)" : ""), | ||||
|                               style: TextStyle(fontSize: 12, color: Colors.grey[600]), | ||||
|                             ), | ||||
|                           ) | ||||
|                         ], | ||||
|                       ), | ||||
|                     ), | ||||
|                   ), | ||||
|                   IconButton( | ||||
|                     onPressed: () { | ||||
|                       AutoRouter.of(context).push(AlbumPreviewRoute(album: albumInfo)); | ||||
|                     }, | ||||
|                     icon: Icon( | ||||
|                       Icons.image_outlined, | ||||
|                       color: Theme.of(context).primaryColor, | ||||
|                       size: 24, | ||||
|                     ), | ||||
|                     splashRadius: 25, | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										48
									
								
								mobile/lib/modules/backup/ui/backup_info_card.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										48
									
								
								mobile/lib/modules/backup/ui/backup_info_card.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,48 @@ | ||||
| import 'package:flutter/material.dart'; | ||||
|  | ||||
| class BackupInfoCard extends StatelessWidget { | ||||
|   final String title; | ||||
|   final String subtitle; | ||||
|   final String info; | ||||
|   const BackupInfoCard({Key? key, required this.title, required this.subtitle, required this.info}) : super(key: key); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context) { | ||||
|     return Card( | ||||
|       shape: RoundedRectangleBorder( | ||||
|         borderRadius: BorderRadius.circular(5), // if you need this | ||||
|         side: const BorderSide( | ||||
|           color: Colors.black12, | ||||
|           width: 1, | ||||
|         ), | ||||
|       ), | ||||
|       elevation: 0, | ||||
|       borderOnForeground: false, | ||||
|       child: ListTile( | ||||
|         minVerticalPadding: 15, | ||||
|         isThreeLine: true, | ||||
|         title: Text( | ||||
|           title, | ||||
|           style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 20), | ||||
|         ), | ||||
|         subtitle: Padding( | ||||
|           padding: const EdgeInsets.only(top: 8.0), | ||||
|           child: Text( | ||||
|             subtitle, | ||||
|             style: const TextStyle(color: Color(0xFF808080), fontSize: 12), | ||||
|           ), | ||||
|         ), | ||||
|         trailing: Column( | ||||
|           mainAxisAlignment: MainAxisAlignment.center, | ||||
|           children: [ | ||||
|             Text( | ||||
|               info, | ||||
|               style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold), | ||||
|             ), | ||||
|             const Text("assets"), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										84
									
								
								mobile/lib/modules/backup/views/album_preview_page.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								mobile/lib/modules/backup/views/album_preview_page.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,84 @@ | ||||
| import 'dart:typed_data'; | ||||
|  | ||||
| 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/shared/ui/immich_loading_indicator.dart'; | ||||
| import 'package:photo_manager/photo_manager.dart'; | ||||
|  | ||||
| class AlbumPreviewPage extends HookConsumerWidget { | ||||
|   final AssetPathEntity album; | ||||
|   const AlbumPreviewPage({Key? key, required this.album}) : super(key: key); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final assets = useState<List<AssetEntity>>([]); | ||||
|  | ||||
|     _getAssetsInAlbum() async { | ||||
|       assets.value = await album.getAssetListRange(start: 0, end: album.assetCount); | ||||
|     } | ||||
|  | ||||
|     useEffect(() { | ||||
|       _getAssetsInAlbum(); | ||||
|       return null; | ||||
|     }, []); | ||||
|  | ||||
|     return Scaffold( | ||||
|       appBar: AppBar( | ||||
|         elevation: 0, | ||||
|         title: Column( | ||||
|           children: [ | ||||
|             Text( | ||||
|               "${album.name} (${album.assetCount})", | ||||
|               style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold), | ||||
|             ), | ||||
|             Padding( | ||||
|               padding: const EdgeInsets.only(top: 4.0), | ||||
|               child: Text( | ||||
|                 "ID ${album.id}", | ||||
|                 style: TextStyle(fontSize: 10, color: Colors.grey[600], fontWeight: FontWeight.bold), | ||||
|               ), | ||||
|             ), | ||||
|           ], | ||||
|         ), | ||||
|         leading: IconButton( | ||||
|           onPressed: () => AutoRouter.of(context).pop(), | ||||
|           icon: const Icon(Icons.arrow_back_ios_new_rounded), | ||||
|         ), | ||||
|       ), | ||||
|       body: GridView.builder( | ||||
|         gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( | ||||
|           crossAxisCount: 5, | ||||
|           crossAxisSpacing: 2, | ||||
|           mainAxisSpacing: 2, | ||||
|         ), | ||||
|         itemCount: assets.value.length, | ||||
|         itemBuilder: (context, index) { | ||||
|           Future<Uint8List?> thumbData = | ||||
|               assets.value[index].thumbnailDataWithSize(const ThumbnailSize(200, 200), quality: 50); | ||||
|  | ||||
|           return FutureBuilder<Uint8List?>( | ||||
|             future: thumbData, | ||||
|             builder: ((context, snapshot) { | ||||
|               if (snapshot.hasData && snapshot.data != null) { | ||||
|                 return Image.memory( | ||||
|                   snapshot.data!, | ||||
|                   width: 100, | ||||
|                   height: 100, | ||||
|                   fit: BoxFit.cover, | ||||
|                 ); | ||||
|               } | ||||
|  | ||||
|               return const SizedBox( | ||||
|                 width: 100, | ||||
|                 height: 100, | ||||
|                 child: ImmichLoadingIndicator(), | ||||
|               ); | ||||
|             }), | ||||
|           ); | ||||
|         }, | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										244
									
								
								mobile/lib/modules/backup/views/backup_album_selection_page.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										244
									
								
								mobile/lib/modules/backup/views/backup_album_selection_page.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,244 @@ | ||||
| import 'package:auto_route/auto_route.dart'; | ||||
| import 'package:flutter/material.dart'; | ||||
| import 'package:flutter_hooks/flutter_hooks.dart'; | ||||
| import 'package:fluttertoast/fluttertoast.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/backup/providers/backup.provider.dart'; | ||||
| import 'package:immich_mobile/modules/backup/ui/album_info_card.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_toast.dart'; | ||||
|  | ||||
| class BackupAlbumSelectionPage extends HookConsumerWidget { | ||||
|   const BackupAlbumSelectionPage({Key? key}) : super(key: key); | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     final availableAlbums = ref.watch(backupProvider).availableAlbums; | ||||
|     final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums; | ||||
|     final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums; | ||||
|  | ||||
|     useEffect(() { | ||||
|       ref.read(backupProvider.notifier).getBackupAlbumsInfo(); | ||||
|       return null; | ||||
|     }, []); | ||||
|  | ||||
|     _buildAlbumSelectionList() { | ||||
|       if (availableAlbums.isEmpty) { | ||||
|         return const Center( | ||||
|           child: ImmichLoadingIndicator(), | ||||
|         ); | ||||
|       } | ||||
|  | ||||
|       return SizedBox( | ||||
|         height: 265, | ||||
|         child: ListView.builder( | ||||
|           scrollDirection: Axis.horizontal, | ||||
|           itemCount: availableAlbums.length, | ||||
|           physics: const BouncingScrollPhysics(), | ||||
|           itemBuilder: ((context, index) { | ||||
|             var thumbnailData = availableAlbums[index].thumbnailData; | ||||
|             return Padding( | ||||
|               padding: index == 0 ? const EdgeInsets.only(left: 16.00) : const EdgeInsets.all(0), | ||||
|               child: AlbumInfoCard(imageData: thumbnailData, albumInfo: availableAlbums[index].albumEntity), | ||||
|             ); | ||||
|           }), | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     _buildSelectedAlbumNameChip() { | ||||
|       return selectedBackupAlbums.map((album) { | ||||
|         void removeSelection() { | ||||
|           if (ref.watch(backupProvider).selectedBackupAlbums.length == 1) { | ||||
|             ImmichToast.show( | ||||
|               context: context, | ||||
|               msg: "Cannot remove the only album", | ||||
|               toastType: ToastType.error, | ||||
|               gravity: ToastGravity.BOTTOM, | ||||
|             ); | ||||
|             return; | ||||
|           } | ||||
|  | ||||
|           ref.watch(backupProvider.notifier).removeAlbumForBackup(album); | ||||
|         } | ||||
|  | ||||
|         return Padding( | ||||
|           padding: const EdgeInsets.only(right: 8.0), | ||||
|           child: GestureDetector( | ||||
|             onTap: removeSelection, | ||||
|             child: Chip( | ||||
|               visualDensity: VisualDensity.compact, | ||||
|               shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), | ||||
|               label: Text( | ||||
|                 album.name, | ||||
|                 style: const TextStyle(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold), | ||||
|               ), | ||||
|               backgroundColor: Theme.of(context).primaryColor, | ||||
|               deleteIconColor: Colors.white, | ||||
|               deleteIcon: const Icon( | ||||
|                 Icons.cancel_rounded, | ||||
|                 size: 15, | ||||
|               ), | ||||
|               onDeleted: removeSelection, | ||||
|             ), | ||||
|           ), | ||||
|         ); | ||||
|       }).toSet(); | ||||
|     } | ||||
|  | ||||
|     _buildExcludedAlbumNameChip() { | ||||
|       return excludedBackupAlbums.map((album) { | ||||
|         void removeSelection() { | ||||
|           ref.watch(backupProvider.notifier).removeExcludedAlbumForBackup(album); | ||||
|         } | ||||
|  | ||||
|         return GestureDetector( | ||||
|           onTap: removeSelection, | ||||
|           child: Padding( | ||||
|             padding: const EdgeInsets.only(right: 8.0), | ||||
|             child: Chip( | ||||
|               visualDensity: VisualDensity.compact, | ||||
|               shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)), | ||||
|               label: Text( | ||||
|                 album.name, | ||||
|                 style: const TextStyle(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold), | ||||
|               ), | ||||
|               backgroundColor: Colors.red[300], | ||||
|               deleteIconColor: Colors.white, | ||||
|               deleteIcon: const Icon( | ||||
|                 Icons.cancel_rounded, | ||||
|                 size: 15, | ||||
|               ), | ||||
|               onDeleted: removeSelection, | ||||
|             ), | ||||
|           ), | ||||
|         ); | ||||
|       }).toSet(); | ||||
|     } | ||||
|  | ||||
|     return Scaffold( | ||||
|       appBar: AppBar( | ||||
|         leading: IconButton( | ||||
|           onPressed: () => AutoRouter.of(context).pop(), | ||||
|           icon: const Icon(Icons.arrow_back_ios_rounded), | ||||
|         ), | ||||
|         title: const Text( | ||||
|           "Select Albums", | ||||
|           style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), | ||||
|         ), | ||||
|         elevation: 0, | ||||
|       ), | ||||
|       body: ListView( | ||||
|         physics: const ClampingScrollPhysics(), | ||||
|         children: [ | ||||
|           const Padding( | ||||
|             padding: EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0), | ||||
|             child: Text( | ||||
|               "Selection Info", | ||||
|               style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), | ||||
|             ), | ||||
|           ), | ||||
|           // Selected Album Chips | ||||
|  | ||||
|           Padding( | ||||
|             padding: const EdgeInsets.symmetric(horizontal: 16.0), | ||||
|             child: Wrap( | ||||
|               children: [..._buildSelectedAlbumNameChip(), ..._buildExcludedAlbumNameChip()], | ||||
|             ), | ||||
|           ), | ||||
|  | ||||
|           Padding( | ||||
|             padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), | ||||
|             child: Card( | ||||
|               margin: const EdgeInsets.all(0), | ||||
|               shape: RoundedRectangleBorder( | ||||
|                 borderRadius: BorderRadius.circular(5), // if you need this | ||||
|                 side: const BorderSide( | ||||
|                   color: Color.fromARGB(255, 235, 235, 235), | ||||
|                   width: 1, | ||||
|                 ), | ||||
|               ), | ||||
|               elevation: 0, | ||||
|               borderOnForeground: false, | ||||
|               child: Column( | ||||
|                 children: [ | ||||
|                   ListTile( | ||||
|                     visualDensity: VisualDensity.compact, | ||||
|                     title: Text( | ||||
|                       "Total unique assets", | ||||
|                       style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.grey[700]), | ||||
|                     ), | ||||
|                     trailing: Text( | ||||
|                       ref.watch(backupProvider).allUniqueAssets.length.toString(), | ||||
|                       style: const TextStyle(fontWeight: FontWeight.bold), | ||||
|                     ), | ||||
|                   ), | ||||
|                 ], | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|  | ||||
|           ListTile( | ||||
|             title: Text( | ||||
|               "Albums on device (${availableAlbums.length.toString()})", | ||||
|               style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), | ||||
|             ), | ||||
|             subtitle: Padding( | ||||
|               padding: const EdgeInsets.symmetric(vertical: 8.0), | ||||
|               child: Text( | ||||
|                 "Tap to include, double tap to exclude", | ||||
|                 style: TextStyle( | ||||
|                   fontSize: 12, | ||||
|                   color: Theme.of(context).primaryColor, | ||||
|                   fontWeight: FontWeight.bold, | ||||
|                 ), | ||||
|               ), | ||||
|             ), | ||||
|             trailing: IconButton( | ||||
|               splashRadius: 16, | ||||
|               icon: Icon( | ||||
|                 Icons.info, | ||||
|                 size: 20, | ||||
|                 color: Theme.of(context).primaryColor, | ||||
|               ), | ||||
|               onPressed: () { | ||||
|                 // show the dialog | ||||
|                 showDialog( | ||||
|                   context: context, | ||||
|                   builder: (BuildContext context) { | ||||
|                     return AlertDialog( | ||||
|                       shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), | ||||
|                       elevation: 5, | ||||
|                       title: Text( | ||||
|                         'Selection Info', | ||||
|                         style: TextStyle( | ||||
|                           fontSize: 16, | ||||
|                           fontWeight: FontWeight.bold, | ||||
|                           color: Theme.of(context).primaryColor, | ||||
|                         ), | ||||
|                       ), | ||||
|                       content: SingleChildScrollView( | ||||
|                         child: ListBody( | ||||
|                           children: [ | ||||
|                             Text( | ||||
|                               'Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.', | ||||
|                               style: TextStyle(fontSize: 14, color: Colors.grey[700]), | ||||
|                             ), | ||||
|                           ], | ||||
|                         ), | ||||
|                       ), | ||||
|                     ); | ||||
|                   }, | ||||
|                 ); | ||||
|               }, | ||||
|             ), | ||||
|           ), | ||||
|  | ||||
|           Padding( | ||||
|             padding: const EdgeInsets.only(bottom: 16.0), | ||||
|             child: _buildAlbumSelectionList(), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
							
								
								
									
										298
									
								
								mobile/lib/modules/backup/views/backup_controller_page.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										298
									
								
								mobile/lib/modules/backup/views/backup_controller_page.dart
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,298 @@ | ||||
| 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/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'; | ||||
| 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:percent_indicator/linear_percent_indicator.dart'; | ||||
|  | ||||
| class BackupControllerPage extends HookConsumerWidget { | ||||
|   const BackupControllerPage({Key? key}) : super(key: key); | ||||
|  | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     BackUpState backupState = ref.watch(backupProvider); | ||||
|     AuthenticationState _authenticationState = ref.watch(authenticationProvider); | ||||
|     bool shouldBackup = | ||||
|         backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length == 0 ? false : true; | ||||
|  | ||||
|     useEffect(() { | ||||
|       if (backupState.backupProgress != BackUpProgressEnum.inProgress) { | ||||
|         ref.read(backupProvider.notifier).getBackupInfo(); | ||||
|       } | ||||
|  | ||||
|       ref.watch(websocketProvider.notifier).stopListenToEvent('on_upload_success'); | ||||
|       return null; | ||||
|     }, []); | ||||
|  | ||||
|     Widget _buildStorageInformation() { | ||||
|       return ListTile( | ||||
|         leading: Icon( | ||||
|           Icons.storage_rounded, | ||||
|           color: Theme.of(context).primaryColor, | ||||
|         ), | ||||
|         title: const Text( | ||||
|           "Server Storage", | ||||
|           style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14), | ||||
|         ), | ||||
|         subtitle: Padding( | ||||
|           padding: const EdgeInsets.only(top: 8.0), | ||||
|           child: Column( | ||||
|             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|             children: [ | ||||
|               LinearPercentIndicator( | ||||
|                 padding: const EdgeInsets.only(top: 8.0), | ||||
|                 lineHeight: 5.0, | ||||
|                 percent: backupState.serverInfo.diskUsagePercentage / 100.0, | ||||
|                 backgroundColor: Colors.grey, | ||||
|                 progressColor: Theme.of(context).primaryColor, | ||||
|               ), | ||||
|               Padding( | ||||
|                 padding: const EdgeInsets.only(top: 12.0), | ||||
|                 child: Text('${backupState.serverInfo.diskUse} of ${backupState.serverInfo.diskSize} used'), | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     ListTile _buildBackupController() { | ||||
|       var backUpOption = _authenticationState.deviceInfo.isAutoBackup ? "on" : "off"; | ||||
|       var isAutoBackup = _authenticationState.deviceInfo.isAutoBackup; | ||||
|       var backupBtnText = _authenticationState.deviceInfo.isAutoBackup ? "off" : "on"; | ||||
|       return ListTile( | ||||
|         isThreeLine: true, | ||||
|         leading: isAutoBackup | ||||
|             ? Icon( | ||||
|                 Icons.cloud_done_rounded, | ||||
|                 color: Theme.of(context).primaryColor, | ||||
|               ) | ||||
|             : const Icon(Icons.cloud_off_rounded), | ||||
|         title: Text( | ||||
|           "Back up is $backUpOption", | ||||
|           style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14), | ||||
|         ), | ||||
|         subtitle: Padding( | ||||
|           padding: const EdgeInsets.symmetric(vertical: 8.0), | ||||
|           child: Column( | ||||
|             crossAxisAlignment: CrossAxisAlignment.start, | ||||
|             children: [ | ||||
|               !isAutoBackup | ||||
|                   ? const Text( | ||||
|                       "Turn on backup to automatically upload new assets to the server.", | ||||
|                       style: TextStyle(fontSize: 14), | ||||
|                     ) | ||||
|                   : Container(), | ||||
|               Padding( | ||||
|                 padding: const EdgeInsets.only(top: 8.0), | ||||
|                 child: OutlinedButton( | ||||
|                   onPressed: () { | ||||
|                     isAutoBackup | ||||
|                         ? ref.watch(authenticationProvider.notifier).setAutoBackup(false) | ||||
|                         : ref.watch(authenticationProvider.notifier).setAutoBackup(true); | ||||
|                   }, | ||||
|                   child: Text("Turn $backupBtnText Backup", style: const TextStyle(fontWeight: FontWeight.bold)), | ||||
|                 ), | ||||
|               ) | ||||
|             ], | ||||
|           ), | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     Widget _buildSelectedAlbumName() { | ||||
|       var text = "Selected: "; | ||||
|       var albums = ref.watch(backupProvider).selectedBackupAlbums; | ||||
|  | ||||
|       if (albums.isNotEmpty) { | ||||
|         for (var album in albums) { | ||||
|           if (album.name == "Recent" || album.name == "Recents") { | ||||
|             text += "${album.name} (All), "; | ||||
|           } else { | ||||
|             text += "${album.name}, "; | ||||
|           } | ||||
|         } | ||||
|  | ||||
|         return Padding( | ||||
|           padding: const EdgeInsets.only(top: 8.0), | ||||
|           child: Text( | ||||
|             text.trim().substring(0, text.length - 2), | ||||
|             style: TextStyle(color: Theme.of(context).primaryColor, fontSize: 12, fontWeight: FontWeight.bold), | ||||
|           ), | ||||
|         ); | ||||
|       } else { | ||||
|         return Padding( | ||||
|           padding: const EdgeInsets.only(top: 8.0), | ||||
|           child: Text( | ||||
|             "None selected", | ||||
|             style: TextStyle(color: Theme.of(context).primaryColor, fontSize: 12, fontWeight: FontWeight.bold), | ||||
|           ), | ||||
|         ); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     Widget _buildExcludedAlbumName() { | ||||
|       var text = "Excluded: "; | ||||
|       var albums = ref.watch(backupProvider).excludedBackupAlbums; | ||||
|  | ||||
|       if (albums.isNotEmpty) { | ||||
|         for (var album in albums) { | ||||
|           text += "${album.name}, "; | ||||
|         } | ||||
|  | ||||
|         return Padding( | ||||
|           padding: const EdgeInsets.only(top: 8.0), | ||||
|           child: Text( | ||||
|             text.trim().substring(0, text.length - 2), | ||||
|             style: TextStyle(color: Colors.red[300], fontSize: 12, fontWeight: FontWeight.bold), | ||||
|           ), | ||||
|         ); | ||||
|       } else { | ||||
|         return Container(); | ||||
|       } | ||||
|     } | ||||
|  | ||||
|     _buildFolderSelectionTile() { | ||||
|       return Card( | ||||
|         shape: RoundedRectangleBorder( | ||||
|           borderRadius: BorderRadius.circular(5), // if you need this | ||||
|           side: const BorderSide( | ||||
|             color: Colors.black12, | ||||
|             width: 1, | ||||
|           ), | ||||
|         ), | ||||
|         elevation: 0, | ||||
|         borderOnForeground: false, | ||||
|         child: ListTile( | ||||
|           minVerticalPadding: 15, | ||||
|           title: const Text("Backup Albums", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)), | ||||
|           subtitle: Padding( | ||||
|             padding: const EdgeInsets.only(top: 8.0), | ||||
|             child: Column( | ||||
|               crossAxisAlignment: CrossAxisAlignment.start, | ||||
|               children: [ | ||||
|                 const Text( | ||||
|                   "Albums to be backup", | ||||
|                   style: TextStyle(color: Color(0xFF808080), fontSize: 12), | ||||
|                 ), | ||||
|                 _buildSelectedAlbumName(), | ||||
|                 _buildExcludedAlbumName() | ||||
|               ], | ||||
|             ), | ||||
|           ), | ||||
|           trailing: OutlinedButton( | ||||
|             onPressed: () { | ||||
|               AutoRouter.of(context).push(const BackupAlbumSelectionRoute()); | ||||
|             }, | ||||
|             child: const Padding( | ||||
|               padding: EdgeInsets.symmetric( | ||||
|                 vertical: 16.0, | ||||
|               ), | ||||
|               child: Text( | ||||
|                 "Select", | ||||
|                 style: TextStyle(fontWeight: FontWeight.bold), | ||||
|               ), | ||||
|             ), | ||||
|           ), | ||||
|         ), | ||||
|       ); | ||||
|     } | ||||
|  | ||||
|     return Scaffold( | ||||
|       appBar: AppBar( | ||||
|         elevation: 0, | ||||
|         title: const Text( | ||||
|           "Backup", | ||||
|           style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), | ||||
|         ), | ||||
|         leading: IconButton( | ||||
|             onPressed: () { | ||||
|               ref.watch(websocketProvider.notifier).listenUploadEvent(); | ||||
|               AutoRouter.of(context).pop(true); | ||||
|             }, | ||||
|             splashRadius: 24, | ||||
|             icon: const Icon( | ||||
|               Icons.arrow_back_ios_rounded, | ||||
|             )), | ||||
|       ), | ||||
|       body: Padding( | ||||
|         padding: const EdgeInsets.all(16.0), | ||||
|         child: ListView( | ||||
|           // crossAxisAlignment: CrossAxisAlignment.start, | ||||
|           children: [ | ||||
|             const Padding( | ||||
|               padding: EdgeInsets.all(8.0), | ||||
|               child: Text( | ||||
|                 "Backup Information", | ||||
|                 style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), | ||||
|               ), | ||||
|             ), | ||||
|             _buildFolderSelectionTile(), | ||||
|             BackupInfoCard( | ||||
|               title: "Total", | ||||
|               subtitle: "All unique photos and videos from selected albums", | ||||
|               info: "${backupState.allUniqueAssets.length}", | ||||
|             ), | ||||
|             BackupInfoCard( | ||||
|               title: "Backup", | ||||
|               subtitle: "Photos and videos from selected albums that are backup", | ||||
|               info: "${backupState.selectedAlbumsBackupAssetsIds.length}", | ||||
|             ), | ||||
|             BackupInfoCard( | ||||
|               title: "Remainder", | ||||
|               subtitle: "Photos and videos that has not been backing up from selected albums", | ||||
|               info: "${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length}", | ||||
|             ), | ||||
|             const Divider(), | ||||
|             _buildBackupController(), | ||||
|             const Divider(), | ||||
|             _buildStorageInformation(), | ||||
|             const Divider(), | ||||
|             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), | ||||
|               child: Container( | ||||
|                 child: backupState.backupProgress == BackUpProgressEnum.inProgress | ||||
|                     ? ElevatedButton( | ||||
|                         style: ElevatedButton.styleFrom(primary: Colors.red[300]), | ||||
|                         onPressed: () { | ||||
|                           ref.read(backupProvider.notifier).cancelBackup(); | ||||
|                         }, | ||||
|                         child: const Text("Cancel"), | ||||
|                       ) | ||||
|                     : ElevatedButton( | ||||
|                         onPressed: shouldBackup | ||||
|                             ? () { | ||||
|                                 ref.read(backupProvider.notifier).startBackupProcess(); | ||||
|                               } | ||||
|                             : null, | ||||
|                         child: const Text("Start Backup"), | ||||
|                       ), | ||||
|               ), | ||||
|             ) | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
		Reference in New Issue
	
	Block a user