17
									
								
								README.md
									
									
									
									
									
								
							
							
						
						| @@ -32,8 +32,9 @@ Loading ~4000 images/videos | ||||
| ## Screenshots | ||||
|  | ||||
| <p align="left"> | ||||
|   <img src="design/nsc1.png" width="150" title="Login With Custom URL"> | ||||
|   <img src="design/nsc2.png" width="150" title="Backup Setting Info"> | ||||
|   <img src="design/login-screen.png" width="150" title="Login With Custom URL"> | ||||
|   <img src="design/backup-screen.png" width="150" title="Backup Setting Info"> | ||||
|   <img src="design/selective-backup-screen.png" width="150" title="Backup Setting Info"> | ||||
|   <img src="design/home-screen.jpeg" width="150" title="Home Screen"> | ||||
|   <img src="design/search-screen.jpeg" width="150" title="Curated Search Info"> | ||||
|   <img src="design/shared-albums.png" width="150" title="Shared Albums"> | ||||
| @@ -50,10 +51,10 @@ This project is under heavy development, there will be continous functions, feat | ||||
| # Features | ||||
|  | ||||
| - Upload and view assets (videos/images). | ||||
| - Auto Backup. | ||||
| - Download asset to local device. | ||||
| - Multi-user supported. | ||||
| - Quick navigation with drag scroll bar. | ||||
| - Auto Backup. | ||||
| - Support HEIC/HEIF Backup. | ||||
| - Extract and display EXIF info. | ||||
| - Real-time render from multi-device upload event. | ||||
| @@ -65,14 +66,20 @@ This project is under heavy development, there will be continous functions, feat | ||||
| - Show curated places on the search page | ||||
| - Show curated objects on the search page | ||||
| - Shared album with users on the same server | ||||
| - Selective backup - albums can be included and excluded during the backup process. | ||||
|  | ||||
|  | ||||
| # System Requirement | ||||
|  | ||||
| **OS**: Preferred Linux-based operating system (Ubuntu, Debian, MacOS...etc). I haven't tested with `Docker for Windows` as well as `WSL` on Windows | ||||
| **OS**: Preferred Linux-based operating system (Ubuntu, Debian, MacOS...etc).  | ||||
|  | ||||
| I haven't tested with `Docker for Windows` as well as `WSL` on Windows | ||||
|  | ||||
| *Raspberry Pi can be used but `microservices` container has to be comment out in `docker-compose` since TensorFlow has not been supported in Dockec image on arm64v7 yet.* | ||||
|  | ||||
| **RAM**: At least 2GB, preffered 4GB. | ||||
|  | ||||
| **Cores**: At least 2 cores, preffered 4 cores. | ||||
| **Core**: At least 2 cores, preffered 4 cores. | ||||
|  | ||||
| # Development and Testing out the application | ||||
|  | ||||
|   | ||||
							
								
								
									
										
											BIN
										
									
								
								design/backup-screen.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 308 KiB | 
							
								
								
									
										
											BIN
										
									
								
								design/login-screen.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 278 KiB | 
							
								
								
									
										
											BIN
										
									
								
								design/nsc1.png
									
									
									
									
									
								
							
							
						
						| Before Width: | Height: | Size: 176 KiB | 
							
								
								
									
										
											BIN
										
									
								
								design/nsc2.png
									
									
									
									
									
								
							
							
						
						| Before Width: | Height: | Size: 303 KiB | 
							
								
								
									
										
											BIN
										
									
								
								design/selective-backup-screen.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| After Width: | Height: | Size: 570 KiB | 
| @@ -2,7 +2,7 @@ version: "3.8" | ||||
|  | ||||
| services: | ||||
|   immich_server: | ||||
|     image: immich-server-dev:1.8.0 | ||||
|     image: immich-server-dev:1.9.0 | ||||
|     build: | ||||
|       context: ../server | ||||
|       dockerfile: Dockerfile | ||||
| @@ -24,7 +24,7 @@ services: | ||||
|       - immich_network | ||||
|  | ||||
|   immich_microservices: | ||||
|     image: immich-microservices-dev:1.8.0 | ||||
|     image: immich-microservices-dev:1.9.0 | ||||
|     build: | ||||
|       context: ../microservices | ||||
|       dockerfile: Dockerfile | ||||
|   | ||||
| @@ -2,7 +2,7 @@ version: "3.8" | ||||
|  | ||||
| services: | ||||
|   immich_server: | ||||
|     image: immich-server-dev:1.8.0 | ||||
|     image: immich-server-dev:1.9.0 | ||||
|     build: | ||||
|       context: ../server | ||||
|       dockerfile: Dockerfile | ||||
| @@ -22,7 +22,7 @@ services: | ||||
|       - immich_network | ||||
|  | ||||
|   immich_microservices: | ||||
|     image: immich-microservices-dev:1.8.0 | ||||
|     image: immich-microservices-dev:1.9.0 | ||||
|     build: | ||||
|       context: ../microservices | ||||
|       dockerfile: Dockerfile | ||||
|   | ||||
| @@ -0,0 +1,2 @@ | ||||
| * New Feature - Selection backup. User can now select a combination of albums to be included or excluded during the backup process, and only unique photos, and videos that are not overlapping between the two groups will be backup. | ||||
| * Bug fix - Show correct count of backup and remainder assets. | ||||
| Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 570 KiB | 
| Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 308 KiB | 
| Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 74 KiB | 
| After Width: | Height: | Size: 183 KiB | 
| @@ -19,7 +19,7 @@ platform :ios do | ||||
|   desc "iOS Beta" | ||||
|   lane :beta do | ||||
|     increment_version_number( | ||||
|       version_number: "1.8.0" | ||||
|       version_number: "1.9.0" | ||||
|     ) | ||||
|     increment_build_number( | ||||
|       build_number: latest_testflight_build_number + 1, | ||||
|   | ||||
| @@ -9,3 +9,7 @@ const String serverEndpointKey = 'immichBoxServerEndpoint'; | ||||
| // Login Info | ||||
| const String hiveLoginInfoBox = "immichLoginInfoBox"; | ||||
| const String savedLoginInfoKey = "immichSavedLoginInfoKey"; | ||||
|  | ||||
| // Backup Info | ||||
| const String hiveBackupInfoBox = "immichBackupAlbumInfoBox"; | ||||
| const String backupInfoKey = "immichBackupAlbumInfoKey"; | ||||
|   | ||||
| @@ -3,12 +3,13 @@ import 'package:flutter/services.dart'; | ||||
| import 'package:hive_flutter/hive_flutter.dart'; | ||||
| import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/constants/immich_colors.dart'; | ||||
| import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart'; | ||||
| import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart'; | ||||
| import 'package:immich_mobile/shared/providers/asset.provider.dart'; | ||||
| import 'package:immich_mobile/routing/router.dart'; | ||||
| import 'package:immich_mobile/routing/tab_navigation_observer.dart'; | ||||
| import 'package:immich_mobile/shared/providers/app_state.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/backup.provider.dart'; | ||||
| import 'package:immich_mobile/modules/backup/providers/backup.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/server_info.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/websocket.provider.dart'; | ||||
| import 'package:immich_mobile/shared/views/immich_loading_overlay.dart'; | ||||
| @@ -16,9 +17,13 @@ import 'constants/hive_box.dart'; | ||||
|  | ||||
| void main() async { | ||||
|   await Hive.initFlutter(); | ||||
|  | ||||
|   Hive.registerAdapter(HiveSavedLoginInfoAdapter()); | ||||
|   Hive.registerAdapter(HiveBackupAlbumsAdapter()); | ||||
|  | ||||
|   await Hive.openBox(userInfoBox); | ||||
|   await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox); | ||||
|   await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox); | ||||
|  | ||||
|   SystemChrome.setSystemUIOverlayStyle( | ||||
|     const SystemUiOverlayStyle( | ||||
|   | ||||
							
								
								
									
										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
									
								
							
							
						
						| @@ -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
									
								
							
							
						
						| @@ -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); | ||||
| }); | ||||
| @@ -26,7 +26,7 @@ class BackupService { | ||||
|     return result.cast<String>(); | ||||
|   } | ||||
| 
 | ||||
|   backupAsset(List<AssetEntity> assetList, CancelToken cancelToken, Function(String, String) singleAssetDoneCb, | ||||
|   backupAsset(Set<AssetEntity> assetList, CancelToken cancelToken, Function(String, String) singleAssetDoneCb, | ||||
|       Function(int, int) uploadProgress) async { | ||||
|     var dio = Dio(); | ||||
|     dio.interceptors.add(AuthenticatedRequestInterceptor()); | ||||
							
								
								
									
										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
									
								
							
							
						
						| @@ -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
									
								
							
							
						
						| @@ -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
									
								
							
							
						
						| @@ -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(), | ||||
|           ), | ||||
|         ], | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -3,10 +3,12 @@ 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/shared/models/backup_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/shared/providers/backup.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 { | ||||
| @@ -14,13 +16,13 @@ class BackupControllerPage extends HookConsumerWidget { | ||||
| 
 | ||||
|   @override | ||||
|   Widget build(BuildContext context, WidgetRef ref) { | ||||
|     BackUpState _backupState = ref.watch(backupProvider); | ||||
|     BackUpState backupState = ref.watch(backupProvider); | ||||
|     AuthenticationState _authenticationState = ref.watch(authenticationProvider); | ||||
| 
 | ||||
|     bool shouldBackup = _backupState.totalAssetCount - _backupState.assetOnDatabase == 0 ? false : true; | ||||
|     bool shouldBackup = | ||||
|         backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length == 0 ? false : true; | ||||
| 
 | ||||
|     useEffect(() { | ||||
|       if (_backupState.backupProgress != BackUpProgressEnum.inProgress) { | ||||
|       if (backupState.backupProgress != BackUpProgressEnum.inProgress) { | ||||
|         ref.read(backupProvider.notifier).getBackupInfo(); | ||||
|       } | ||||
| 
 | ||||
| @@ -46,13 +48,13 @@ class BackupControllerPage extends HookConsumerWidget { | ||||
|               LinearPercentIndicator( | ||||
|                 padding: const EdgeInsets.only(top: 8.0), | ||||
|                 lineHeight: 5.0, | ||||
|                 percent: _backupState.serverInfo.diskUsagePercentage / 100.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'), | ||||
|                 child: Text('${backupState.serverInfo.diskUse} of ${backupState.serverInfo.diskSize} used'), | ||||
|               ), | ||||
|             ], | ||||
|           ), | ||||
| @@ -104,18 +106,120 @@ class BackupControllerPage extends HookConsumerWidget { | ||||
|       ); | ||||
|     } | ||||
| 
 | ||||
|     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(fontWeight: FontWeight.bold), | ||||
|           style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), | ||||
|         ), | ||||
|         leading: IconButton( | ||||
|             onPressed: () { | ||||
|               ref.watch(websocketProvider.notifier).listenUploadEvent(); | ||||
|               AutoRouter.of(context).pop(true); | ||||
|             }, | ||||
|             icon: const Icon(Icons.arrow_back_ios_rounded)), | ||||
|             splashRadius: 24, | ||||
|             icon: const Icon( | ||||
|               Icons.arrow_back_ios_rounded, | ||||
|             )), | ||||
|       ), | ||||
|       body: Padding( | ||||
|         padding: const EdgeInsets.all(16.0), | ||||
| @@ -129,20 +233,21 @@ class BackupControllerPage extends HookConsumerWidget { | ||||
|                 style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), | ||||
|               ), | ||||
|             ), | ||||
|             _buildFolderSelectionTile(), | ||||
|             BackupInfoCard( | ||||
|               title: "Total", | ||||
|               subtitle: "All images and videos on the device", | ||||
|               info: "${_backupState.totalAssetCount}", | ||||
|               subtitle: "All unique photos and videos from selected albums", | ||||
|               info: "${backupState.allUniqueAssets.length}", | ||||
|             ), | ||||
|             BackupInfoCard( | ||||
|               title: "Backup", | ||||
|               subtitle: "Images and videos of the device that are backup on server", | ||||
|               info: "${_backupState.assetOnDatabase}", | ||||
|               subtitle: "Photos and videos from selected albums that are backup", | ||||
|               info: "${backupState.selectedAlbumsBackupAssetsIds.length}", | ||||
|             ), | ||||
|             BackupInfoCard( | ||||
|               title: "Remainder", | ||||
|               subtitle: "Images and videos that has not been backing up", | ||||
|               info: "${_backupState.totalAssetCount - _backupState.assetOnDatabase}", | ||||
|               subtitle: "Photos and videos that has not been backing up from selected albums", | ||||
|               info: "${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length}", | ||||
|             ), | ||||
|             const Divider(), | ||||
|             _buildBackupController(), | ||||
| @@ -152,14 +257,14 @@ class BackupControllerPage extends HookConsumerWidget { | ||||
|             Padding( | ||||
|               padding: const EdgeInsets.all(8.0), | ||||
|               child: Text( | ||||
|                   "Asset that were being backup: ${_backupState.backingUpAssetCount} [${_backupState.progressInPercentage.toStringAsFixed(0)}%]"), | ||||
|                   "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 | ||||
|                 backupState.backupProgress == BackUpProgressEnum.inProgress | ||||
|                     ? const CircularProgressIndicator.adaptive() | ||||
|                     : const Text("Done"), | ||||
|               ]), | ||||
| @@ -167,7 +272,7 @@ class BackupControllerPage extends HookConsumerWidget { | ||||
|             Padding( | ||||
|               padding: const EdgeInsets.all(8.0), | ||||
|               child: Container( | ||||
|                 child: _backupState.backupProgress == BackUpProgressEnum.inProgress | ||||
|                 child: backupState.backupProgress == BackUpProgressEnum.inProgress | ||||
|                     ? ElevatedButton( | ||||
|                         style: ElevatedButton.styleFrom(primary: Colors.red[300]), | ||||
|                         onPressed: () { | ||||
| @@ -191,50 +296,3 @@ class BackupControllerPage extends HookConsumerWidget { | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| 
 | ||||
| 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"), | ||||
|           ], | ||||
|         ), | ||||
|       ), | ||||
|     ); | ||||
|   } | ||||
| } | ||||
| @@ -5,9 +5,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; | ||||
| import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; | ||||
|  | ||||
| import 'package:immich_mobile/routing/router.dart'; | ||||
| import 'package:immich_mobile/shared/models/backup_state.model.dart'; | ||||
| import 'package:immich_mobile/modules/backup/models/backup_state.model.dart'; | ||||
| import 'package:immich_mobile/shared/models/server_info_state.model.dart'; | ||||
| import 'package:immich_mobile/shared/providers/backup.provider.dart'; | ||||
| import 'package:immich_mobile/modules/backup/providers/backup.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/server_info.provider.dart'; | ||||
|  | ||||
| class ImmichSliverAppBar extends ConsumerWidget { | ||||
| @@ -130,7 +130,8 @@ class ImmichSliverAppBar extends ConsumerWidget { | ||||
|                 ? Positioned( | ||||
|                     bottom: 5, | ||||
|                     child: Text( | ||||
|                       _backupState.backingUpAssetCount.toString(), | ||||
|                       (_backupState.allUniqueAssets.length - _backupState.selectedAlbumsBackupAssetsIds.length) | ||||
|                           .toString(), | ||||
|                       style: const TextStyle(fontSize: 9, fontWeight: FontWeight.bold), | ||||
|                     ), | ||||
|                   ) | ||||
|   | ||||
| @@ -6,7 +6,7 @@ import 'package:immich_mobile/shared/providers/asset.provider.dart'; | ||||
| import 'package:immich_mobile/modules/login/models/authentication_state.model.dart'; | ||||
| import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; | ||||
| import 'package:immich_mobile/shared/models/server_info_state.model.dart'; | ||||
| import 'package:immich_mobile/shared/providers/backup.provider.dart'; | ||||
| import 'package:immich_mobile/modules/backup/providers/backup.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/server_info.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/websocket.provider.dart'; | ||||
| import 'package:package_info_plus/package_info_plus.dart'; | ||||
|   | ||||
| @@ -6,7 +6,7 @@ import 'package:immich_mobile/constants/hive_box.dart'; | ||||
| import 'package:immich_mobile/modules/login/models/authentication_state.model.dart'; | ||||
| import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart'; | ||||
| import 'package:immich_mobile/modules/login/models/login_response.model.dart'; | ||||
| import 'package:immich_mobile/shared/services/backup.service.dart'; | ||||
| import 'package:immich_mobile/modules/backup/services/backup.service.dart'; | ||||
| import 'package:immich_mobile/shared/services/device_info.service.dart'; | ||||
| import 'package:immich_mobile/shared/services/network.service.dart'; | ||||
| import 'package:immich_mobile/shared/models/device_info.model.dart'; | ||||
|   | ||||
| @@ -7,7 +7,7 @@ import 'package:immich_mobile/constants/hive_box.dart'; | ||||
| import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart'; | ||||
| import 'package:immich_mobile/shared/providers/asset.provider.dart'; | ||||
| import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; | ||||
| import 'package:immich_mobile/shared/providers/backup.provider.dart'; | ||||
| import 'package:immich_mobile/modules/backup/providers/backup.provider.dart'; | ||||
| import 'package:immich_mobile/shared/ui/immich_toast.dart'; | ||||
|  | ||||
| class LoginForm extends HookConsumerWidget { | ||||
|   | ||||
| @@ -1,5 +1,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/login/views/login_page.dart'; | ||||
| import 'package:immich_mobile/modules/home/views/home_page.dart'; | ||||
| import 'package:immich_mobile/modules/search/views/search_page.dart'; | ||||
| @@ -14,10 +16,11 @@ import 'package:immich_mobile/modules/sharing/views/select_user_for_sharing_page | ||||
| import 'package:immich_mobile/modules/sharing/views/sharing_page.dart'; | ||||
| import 'package:immich_mobile/routing/auth_guard.dart'; | ||||
| import 'package:immich_mobile/shared/models/immich_asset.model.dart'; | ||||
| import 'package:immich_mobile/shared/views/backup_controller_page.dart'; | ||||
| import 'package:immich_mobile/modules/backup/views/backup_controller_page.dart'; | ||||
| import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart'; | ||||
| import 'package:immich_mobile/shared/views/tab_controller_page.dart'; | ||||
| import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart'; | ||||
| import 'package:photo_manager/photo_manager.dart'; | ||||
|  | ||||
| part 'router.gr.dart'; | ||||
|  | ||||
| @@ -55,6 +58,8 @@ part 'router.gr.dart'; | ||||
|       guards: [AuthGuard], | ||||
|       transitionsBuilder: TransitionsBuilders.slideBottom, | ||||
|     ), | ||||
|     AutoRoute(page: BackupAlbumSelectionPage, guards: [AuthGuard]), | ||||
|     AutoRoute(page: AlbumPreviewPage, guards: [AuthGuard]), | ||||
|   ], | ||||
| ) | ||||
| class AppRouter extends _$AppRouter { | ||||
|   | ||||
| @@ -93,6 +93,16 @@ class _$AppRouter extends RootStackRouter { | ||||
|           opaque: true, | ||||
|           barrierDismissible: false); | ||||
|     }, | ||||
|     BackupAlbumSelectionRoute.name: (routeData) { | ||||
|       return MaterialPageX<dynamic>( | ||||
|           routeData: routeData, child: const BackupAlbumSelectionPage()); | ||||
|     }, | ||||
|     AlbumPreviewRoute.name: (routeData) { | ||||
|       final args = routeData.argsAs<AlbumPreviewRouteArgs>(); | ||||
|       return MaterialPageX<dynamic>( | ||||
|           routeData: routeData, | ||||
|           child: AlbumPreviewPage(key: args.key, album: args.album)); | ||||
|     }, | ||||
|     HomeRoute.name: (routeData) { | ||||
|       return MaterialPageX<dynamic>( | ||||
|           routeData: routeData, child: const HomePage()); | ||||
| @@ -149,7 +159,11 @@ class _$AppRouter extends RootStackRouter { | ||||
|             path: '/album-viewer-page', guards: [authGuard]), | ||||
|         RouteConfig(SelectAdditionalUserForSharingRoute.name, | ||||
|             path: '/select-additional-user-for-sharing-page', | ||||
|             guards: [authGuard]) | ||||
|             guards: [authGuard]), | ||||
|         RouteConfig(BackupAlbumSelectionRoute.name, | ||||
|             path: '/backup-album-selection-page', guards: [authGuard]), | ||||
|         RouteConfig(AlbumPreviewRoute.name, | ||||
|             path: '/album-preview-page', guards: [authGuard]) | ||||
|       ]; | ||||
| } | ||||
|  | ||||
| @@ -358,6 +372,40 @@ class SelectAdditionalUserForSharingRouteArgs { | ||||
|   } | ||||
| } | ||||
|  | ||||
| /// generated route for | ||||
| /// [BackupAlbumSelectionPage] | ||||
| class BackupAlbumSelectionRoute extends PageRouteInfo<void> { | ||||
|   const BackupAlbumSelectionRoute() | ||||
|       : super(BackupAlbumSelectionRoute.name, | ||||
|             path: '/backup-album-selection-page'); | ||||
|  | ||||
|   static const String name = 'BackupAlbumSelectionRoute'; | ||||
| } | ||||
|  | ||||
| /// generated route for | ||||
| /// [AlbumPreviewPage] | ||||
| class AlbumPreviewRoute extends PageRouteInfo<AlbumPreviewRouteArgs> { | ||||
|   AlbumPreviewRoute({Key? key, required AssetPathEntity album}) | ||||
|       : super(AlbumPreviewRoute.name, | ||||
|             path: '/album-preview-page', | ||||
|             args: AlbumPreviewRouteArgs(key: key, album: album)); | ||||
|  | ||||
|   static const String name = 'AlbumPreviewRoute'; | ||||
| } | ||||
|  | ||||
| class AlbumPreviewRouteArgs { | ||||
|   const AlbumPreviewRouteArgs({this.key, required this.album}); | ||||
|  | ||||
|   final Key? key; | ||||
|  | ||||
|   final AssetPathEntity album; | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'AlbumPreviewRouteArgs{key: $key, album: $album}'; | ||||
|   } | ||||
| } | ||||
|  | ||||
| /// generated route for | ||||
| /// [HomePage] | ||||
| class HomeRoute extends PageRouteInfo<void> { | ||||
|   | ||||
| @@ -1,77 +0,0 @@ | ||||
| import 'dart:convert'; | ||||
|  | ||||
| import 'package:dio/dio.dart'; | ||||
|  | ||||
| import 'package:immich_mobile/shared/models/server_info.model.dart'; | ||||
|  | ||||
| enum BackUpProgressEnum { idle, inProgress, done } | ||||
|  | ||||
| class BackUpState { | ||||
|   final BackUpProgressEnum backupProgress; | ||||
|   final int totalAssetCount; | ||||
|   final int assetOnDatabase; | ||||
|   final int backingUpAssetCount; | ||||
|   final double progressInPercentage; | ||||
|   final CancelToken cancelToken; | ||||
|   final ServerInfo serverInfo; | ||||
|  | ||||
|   BackUpState({ | ||||
|     required this.backupProgress, | ||||
|     required this.totalAssetCount, | ||||
|     required this.assetOnDatabase, | ||||
|     required this.backingUpAssetCount, | ||||
|     required this.progressInPercentage, | ||||
|     required this.cancelToken, | ||||
|     required this.serverInfo, | ||||
|   }); | ||||
|  | ||||
|   BackUpState copyWith({ | ||||
|     BackUpProgressEnum? backupProgress, | ||||
|     int? totalAssetCount, | ||||
|     int? assetOnDatabase, | ||||
|     int? backingUpAssetCount, | ||||
|     double? progressInPercentage, | ||||
|     CancelToken? cancelToken, | ||||
|     ServerInfo? serverInfo, | ||||
|   }) { | ||||
|     return BackUpState( | ||||
|       backupProgress: backupProgress ?? this.backupProgress, | ||||
|       totalAssetCount: totalAssetCount ?? this.totalAssetCount, | ||||
|       assetOnDatabase: assetOnDatabase ?? this.assetOnDatabase, | ||||
|       backingUpAssetCount: backingUpAssetCount ?? this.backingUpAssetCount, | ||||
|       progressInPercentage: progressInPercentage ?? this.progressInPercentage, | ||||
|       cancelToken: cancelToken ?? this.cancelToken, | ||||
|       serverInfo: serverInfo ?? this.serverInfo, | ||||
|     ); | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   String toString() { | ||||
|     return 'BackUpState(backupProgress: $backupProgress, totalAssetCount: $totalAssetCount, assetOnDatabase: $assetOnDatabase, backingUpAssetCount: $backingUpAssetCount, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo)'; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   bool operator ==(Object other) { | ||||
|     if (identical(this, other)) return true; | ||||
|  | ||||
|     return other is BackUpState && | ||||
|         other.backupProgress == backupProgress && | ||||
|         other.totalAssetCount == totalAssetCount && | ||||
|         other.assetOnDatabase == assetOnDatabase && | ||||
|         other.backingUpAssetCount == backingUpAssetCount && | ||||
|         other.progressInPercentage == progressInPercentage && | ||||
|         other.cancelToken == cancelToken && | ||||
|         other.serverInfo == serverInfo; | ||||
|   } | ||||
|  | ||||
|   @override | ||||
|   int get hashCode { | ||||
|     return backupProgress.hashCode ^ | ||||
|         totalAssetCount.hashCode ^ | ||||
|         assetOnDatabase.hashCode ^ | ||||
|         backingUpAssetCount.hashCode ^ | ||||
|         progressInPercentage.hashCode ^ | ||||
|         cancelToken.hashCode ^ | ||||
|         serverInfo.hashCode; | ||||
|   } | ||||
| } | ||||
| @@ -1,194 +0,0 @@ | ||||
| import 'dart:async'; | ||||
|  | ||||
| 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/login/providers/authentication.provider.dart'; | ||||
| import 'package:immich_mobile/shared/services/server_info.service.dart'; | ||||
| import 'package:immich_mobile/shared/models/backup_state.model.dart'; | ||||
| import 'package:immich_mobile/shared/models/server_info.model.dart'; | ||||
| import 'package:immich_mobile/shared/services/backup.service.dart'; | ||||
| import 'package:photo_manager/photo_manager.dart'; | ||||
|  | ||||
| class BackupNotifier extends StateNotifier<BackUpState> { | ||||
|   BackupNotifier({this.ref}) | ||||
|       : super( | ||||
|           BackUpState( | ||||
|             backupProgress: BackUpProgressEnum.idle, | ||||
|             backingUpAssetCount: 0, | ||||
|             assetOnDatabase: 0, | ||||
|             totalAssetCount: 0, | ||||
|             progressInPercentage: 0, | ||||
|             cancelToken: CancelToken(), | ||||
|             serverInfo: ServerInfo( | ||||
|               diskAvailable: "0", | ||||
|               diskAvailableRaw: 0, | ||||
|               diskSize: "0", | ||||
|               diskSizeRaw: 0, | ||||
|               diskUsagePercentage: 0.0, | ||||
|               diskUse: "0", | ||||
|               diskUseRaw: 0, | ||||
|             ), | ||||
|           ), | ||||
|         ); | ||||
|  | ||||
|   Ref? ref; | ||||
|   final BackupService _backupService = BackupService(); | ||||
|   final ServerInfoService _serverInfoService = ServerInfoService(); | ||||
|   final StreamController _onAssetBackupStreamCtrl = | ||||
|       StreamController.broadcast(); | ||||
|  | ||||
|   void getBackupInfo() async { | ||||
|     _updateServerInfo(); | ||||
|  | ||||
|     List<AssetPathEntity> list = await PhotoManager.getAssetPathList( | ||||
|         onlyAll: true, type: RequestType.common); | ||||
|     List<String> didBackupAsset = await _backupService.getDeviceBackupAsset(); | ||||
|  | ||||
|     if (list.isEmpty) { | ||||
|       debugPrint("No Asset On Device"); | ||||
|       state = state.copyWith( | ||||
|           backupProgress: BackUpProgressEnum.idle, | ||||
|           totalAssetCount: 0, | ||||
|           assetOnDatabase: didBackupAsset.length); | ||||
|       return; | ||||
|     } | ||||
|  | ||||
|     int totalAsset = list[0].assetCount; | ||||
|  | ||||
|     state = state.copyWith( | ||||
|         totalAssetCount: totalAsset, assetOnDatabase: didBackupAsset.length); | ||||
|   } | ||||
|  | ||||
|   void startBackupProcess() async { | ||||
|     _updateServerInfo(); | ||||
|  | ||||
|     state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress); | ||||
|  | ||||
|     var authResult = await PhotoManager.requestPermissionExtend(); | ||||
|     if (authResult.isAuth) { | ||||
|       await PhotoManager.clearFileCache(); | ||||
|       // await PhotoManager.presentLimited(); | ||||
|       // Gather assets info | ||||
|       List<AssetPathEntity> list = await PhotoManager.getAssetPathList( | ||||
|           hasAll: true, onlyAll: true, type: RequestType.common); | ||||
|  | ||||
|       // Get device assets info from database | ||||
|       // Compare and find different assets that has not been backing up | ||||
|       // Backup those assets | ||||
|       List<String> backupAsset = await _backupService.getDeviceBackupAsset(); | ||||
|  | ||||
|       if (list.isEmpty) { | ||||
|         debugPrint("No Asset On Device - Abort Backup Process"); | ||||
|         state = state.copyWith( | ||||
|             backupProgress: BackUpProgressEnum.idle, | ||||
|             totalAssetCount: 0, | ||||
|             assetOnDatabase: backupAsset.length); | ||||
|         return; | ||||
|       } | ||||
|  | ||||
|       int totalAsset = list[0].assetCount; | ||||
|       List<AssetEntity> currentAssets = | ||||
|           await list[0].getAssetListRange(start: 0, end: totalAsset); | ||||
|  | ||||
|       state = state.copyWith( | ||||
|           totalAssetCount: totalAsset, assetOnDatabase: backupAsset.length); | ||||
|       // Remove item that has already been backed up | ||||
|       for (var backupAssetId in backupAsset) { | ||||
|         currentAssets.removeWhere((e) => e.id == backupAssetId); | ||||
|       } | ||||
|  | ||||
|       if (currentAssets.isEmpty) { | ||||
|         state = state.copyWith(backupProgress: BackUpProgressEnum.idle); | ||||
|       } | ||||
|  | ||||
|       state = state.copyWith(backingUpAssetCount: currentAssets.length); | ||||
|  | ||||
|       // Perform Backup | ||||
|       state = state.copyWith(cancelToken: CancelToken()); | ||||
|       _backupService.backupAsset(currentAssets, 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( | ||||
|         backingUpAssetCount: state.backingUpAssetCount - 1, | ||||
|         assetOnDatabase: state.assetOnDatabase + 1); | ||||
|  | ||||
|     if (state.backingUpAssetCount == 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); | ||||
| }); | ||||
| @@ -239,6 +239,13 @@ packages: | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "4.0.4" | ||||
|   equatable: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|       name: equatable | ||||
|       url: "https://pub.dartlang.org" | ||||
|     source: hosted | ||||
|     version: "2.0.3" | ||||
|   exif: | ||||
|     dependency: "direct main" | ||||
|     description: | ||||
|   | ||||
| @@ -2,7 +2,7 @@ name: immich_mobile | ||||
| description: Immich - selfhosted backup media file on mobile phone | ||||
|  | ||||
| publish_to: "none" | ||||
| version: 1.8.0+12 | ||||
| version: 1.9.0+13 | ||||
|  | ||||
| environment: | ||||
|   sdk: ">=2.15.1 <3.0.0" | ||||
| @@ -37,6 +37,7 @@ dependencies: | ||||
|   package_info_plus: ^1.4.0 | ||||
|   flutter_spinkit: ^5.1.0 | ||||
|   flutter_swipe_detector: ^2.0.0 | ||||
|   equatable: ^2.0.3 | ||||
|  | ||||
| dev_dependencies: | ||||
|   flutter_test: | ||||
|   | ||||
| @@ -3,7 +3,7 @@ | ||||
|  | ||||
| export const serverVersion = { | ||||
|   major: 1, | ||||
|   minor: 8, | ||||
|   minor: 9, | ||||
|   patch: 0, | ||||
|   build: 12, | ||||
|   build: 13, | ||||
| }; | ||||
|   | ||||