diff --git a/README.md b/README.md index 461e60cc94..6db1ee01de 100644 --- a/README.md +++ b/README.md @@ -32,8 +32,9 @@ Loading ~4000 images/videos ## Screenshots

- - + + + @@ -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 diff --git a/design/backup-screen.png b/design/backup-screen.png new file mode 100644 index 0000000000..d70669a9d6 Binary files /dev/null and b/design/backup-screen.png differ diff --git a/design/login-screen.png b/design/login-screen.png new file mode 100644 index 0000000000..a3687fb12a Binary files /dev/null and b/design/login-screen.png differ diff --git a/design/nsc1.png b/design/nsc1.png deleted file mode 100644 index c4eaa711b5..0000000000 Binary files a/design/nsc1.png and /dev/null differ diff --git a/design/nsc2.png b/design/nsc2.png deleted file mode 100644 index bdcf015403..0000000000 Binary files a/design/nsc2.png and /dev/null differ diff --git a/design/selective-backup-screen.png b/design/selective-backup-screen.png new file mode 100644 index 0000000000..7b3d1ed047 Binary files /dev/null and b/design/selective-backup-screen.png differ diff --git a/docker/docker-compose.dev.yml b/docker/docker-compose.dev.yml index 12349fbb7f..08566b0017 100644 --- a/docker/docker-compose.dev.yml +++ b/docker/docker-compose.dev.yml @@ -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 diff --git a/docker/docker-compose.gpu.yml b/docker/docker-compose.gpu.yml index 1c289986fd..c2dde0c809 100644 --- a/docker/docker-compose.gpu.yml +++ b/docker/docker-compose.gpu.yml @@ -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 diff --git a/mobile/android/fastlane/metadata/android/en-US/changelogs/13.txt b/mobile/android/fastlane/metadata/android/en-US/changelogs/13.txt new file mode 100644 index 0000000000..d3ac68755c --- /dev/null +++ b/mobile/android/fastlane/metadata/android/en-US/changelogs/13.txt @@ -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. \ No newline at end of file diff --git a/mobile/android/metadata/en-US/images/phoneScreenshots/3_en-US.png b/mobile/android/metadata/en-US/images/phoneScreenshots/3_en-US.png index d5ac2595a2..7b3d1ed047 100644 Binary files a/mobile/android/metadata/en-US/images/phoneScreenshots/3_en-US.png and b/mobile/android/metadata/en-US/images/phoneScreenshots/3_en-US.png differ diff --git a/mobile/android/metadata/en-US/images/phoneScreenshots/4_en-US.png b/mobile/android/metadata/en-US/images/phoneScreenshots/4_en-US.png index b313b8da51..d70669a9d6 100644 Binary files a/mobile/android/metadata/en-US/images/phoneScreenshots/4_en-US.png and b/mobile/android/metadata/en-US/images/phoneScreenshots/4_en-US.png differ diff --git a/mobile/android/metadata/en-US/images/phoneScreenshots/5_en-US.png b/mobile/android/metadata/en-US/images/phoneScreenshots/5_en-US.png index 81f620959d..b313b8da51 100644 Binary files a/mobile/android/metadata/en-US/images/phoneScreenshots/5_en-US.png and b/mobile/android/metadata/en-US/images/phoneScreenshots/5_en-US.png differ diff --git a/mobile/android/metadata/en-US/images/phoneScreenshots/6_en-US.png b/mobile/android/metadata/en-US/images/phoneScreenshots/6_en-US.png new file mode 100644 index 0000000000..81f620959d Binary files /dev/null and b/mobile/android/metadata/en-US/images/phoneScreenshots/6_en-US.png differ diff --git a/mobile/ios/fastlane/Fastfile b/mobile/ios/fastlane/Fastfile index 72888b6123..bb117c9dff 100644 --- a/mobile/ios/fastlane/Fastfile +++ b/mobile/ios/fastlane/Fastfile @@ -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, diff --git a/mobile/lib/constants/hive_box.dart b/mobile/lib/constants/hive_box.dart index 4c000524a8..397680dd39 100644 --- a/mobile/lib/constants/hive_box.dart +++ b/mobile/lib/constants/hive_box.dart @@ -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"; diff --git a/mobile/lib/main.dart b/mobile/lib/main.dart index 836571913b..69fdf359a5 100644 --- a/mobile/lib/main.dart +++ b/mobile/lib/main.dart @@ -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(hiveLoginInfoBox); + await Hive.openBox(hiveBackupInfoBox); SystemChrome.setSystemUIOverlayStyle( const SystemUiOverlayStyle( diff --git a/mobile/lib/modules/backup/models/available_album.model.dart b/mobile/lib/modules/backup/models/available_album.model.dart new file mode 100644 index 0000000000..d202efd19e --- /dev/null +++ b/mobile/lib/modules/backup/models/available_album.model.dart @@ -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; +} diff --git a/mobile/lib/modules/backup/models/backup_state.model.dart b/mobile/lib/modules/backup/models/backup_state.model.dart new file mode 100644 index 0000000000..a1bc20c01a --- /dev/null +++ b/mobile/lib/modules/backup/models/backup_state.model.dart @@ -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 allAssetOnDatabase; + final double progressInPercentage; + final CancelToken cancelToken; + final ServerInfo serverInfo; + + /// All available albums on the device + final List availableAlbums; + final Set selectedBackupAlbums; + final Set excludedBackupAlbums; + + /// Assets that are not overlapping in selected backup albums and excluded backup albums + final Set allUniqueAssets; + + /// All assets from the selected albums that have been backup + final Set 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? allAssetOnDatabase, + double? progressInPercentage, + CancelToken? cancelToken, + ServerInfo? serverInfo, + List? availableAlbums, + Set? selectedBackupAlbums, + Set? excludedBackupAlbums, + Set? allUniqueAssets, + Set? 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 get props { + return [ + backupProgress, + allAssetOnDatabase, + progressInPercentage, + cancelToken, + serverInfo, + availableAlbums, + selectedBackupAlbums, + excludedBackupAlbums, + allUniqueAssets, + selectedAlbumsBackupAssetsIds, + ]; + } +} diff --git a/mobile/lib/modules/backup/models/hive_backup_albums.model.dart b/mobile/lib/modules/backup/models/hive_backup_albums.model.dart new file mode 100644 index 0000000000..12ca3c1310 --- /dev/null +++ b/mobile/lib/modules/backup/models/hive_backup_albums.model.dart @@ -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 selectedAlbumIds; + + @HiveField(1) + List excludedAlbumsIds; + + HiveBackupAlbums({ + required this.selectedAlbumIds, + required this.excludedAlbumsIds, + }); + + @override + String toString() => 'HiveBackupAlbums(selectedAlbumIds: $selectedAlbumIds, excludedAlbumsIds: $excludedAlbumsIds)'; + + HiveBackupAlbums copyWith({ + List? selectedAlbumIds, + List? excludedAlbumsIds, + }) { + return HiveBackupAlbums( + selectedAlbumIds: selectedAlbumIds ?? this.selectedAlbumIds, + excludedAlbumsIds: excludedAlbumsIds ?? this.excludedAlbumsIds, + ); + } + + Map toMap() { + final result = {}; + + result.addAll({'selectedAlbumIds': selectedAlbumIds}); + result.addAll({'excludedAlbumsIds': excludedAlbumsIds}); + + return result; + } + + factory HiveBackupAlbums.fromMap(Map map) { + return HiveBackupAlbums( + selectedAlbumIds: List.from(map['selectedAlbumIds']), + excludedAlbumsIds: List.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; +} diff --git a/mobile/lib/modules/backup/models/hive_backup_albums.model.g.dart b/mobile/lib/modules/backup/models/hive_backup_albums.model.g.dart new file mode 100644 index 0000000000..d64ce4e7ba Binary files /dev/null and b/mobile/lib/modules/backup/models/hive_backup_albums.model.g.dart differ diff --git a/mobile/lib/modules/backup/providers/backup.provider.dart b/mobile/lib/modules/backup/providers/backup.provider.dart new file mode 100644 index 0000000000..f71e248d73 --- /dev/null +++ b/mobile/lib/modules/backup/providers/backup.provider.dart @@ -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 { + 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 currentSelectedAlbums = state.selectedBackupAlbums; + + currentSelectedAlbums.removeWhere((a) => a == album); + + state = state.copyWith(selectedBackupAlbums: currentSelectedAlbums); + _updateBackupAssetCount(); + } + + void removeExcludedAlbumForBackup(AssetPathEntity album) { + Set 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 getBackupAlbumsInfo() async { + // Get all albums on the device + List availableAlbums = []; + List 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 backupAlbumInfoBox = Hive.box(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 assetsFromSelectedAlbums = {}; + Set 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 allUniqueAssets = assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums); + List allAssetOnDatabase = await _backupService.getDeviceBackupAsset(); + + // Find asset that were backup from selected albums + Set 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 backupAlbumInfoBox = Hive.box(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 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((ref) { + return BackupNotifier(ref: ref); +}); diff --git a/mobile/lib/shared/services/backup.service.dart b/mobile/lib/modules/backup/services/backup.service.dart similarity index 97% rename from mobile/lib/shared/services/backup.service.dart rename to mobile/lib/modules/backup/services/backup.service.dart index 6337a7de01..9f3ace1f22 100644 --- a/mobile/lib/shared/services/backup.service.dart +++ b/mobile/lib/modules/backup/services/backup.service.dart @@ -26,7 +26,7 @@ class BackupService { return result.cast(); } - backupAsset(List assetList, CancelToken cancelToken, Function(String, String) singleAssetDoneCb, + backupAsset(Set assetList, CancelToken cancelToken, Function(String, String) singleAssetDoneCb, Function(int, int) uploadProgress) async { var dio = Dio(); dio.interceptors.add(AuthenticatedRequestInterceptor()); diff --git a/mobile/lib/modules/backup/ui/album_info_card.dart b/mobile/lib/modules/backup/ui/album_info_card.dart new file mode 100644 index 0000000000..f983f43e32 --- /dev/null +++ b/mobile/lib/modules/backup/ui/album_info_card.dart @@ -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, + ), + ], + ), + ), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/modules/backup/ui/backup_info_card.dart b/mobile/lib/modules/backup/ui/backup_info_card.dart new file mode 100644 index 0000000000..d6f52fd354 --- /dev/null +++ b/mobile/lib/modules/backup/ui/backup_info_card.dart @@ -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"), + ], + ), + ), + ); + } +} diff --git a/mobile/lib/modules/backup/views/album_preview_page.dart b/mobile/lib/modules/backup/views/album_preview_page.dart new file mode 100644 index 0000000000..754afdb4de --- /dev/null +++ b/mobile/lib/modules/backup/views/album_preview_page.dart @@ -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>([]); + + _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 thumbData = + assets.value[index].thumbnailDataWithSize(const ThumbnailSize(200, 200), quality: 50); + + return FutureBuilder( + 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(), + ); + }), + ); + }, + ), + ); + } +} diff --git a/mobile/lib/modules/backup/views/backup_album_selection_page.dart b/mobile/lib/modules/backup/views/backup_album_selection_page.dart new file mode 100644 index 0000000000..100c61057a --- /dev/null +++ b/mobile/lib/modules/backup/views/backup_album_selection_page.dart @@ -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(), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/shared/views/backup_controller_page.dart b/mobile/lib/modules/backup/views/backup_controller_page.dart similarity index 56% rename from mobile/lib/shared/views/backup_controller_page.dart rename to mobile/lib/modules/backup/views/backup_controller_page.dart index 5962bab575..d195b81aa5 100644 --- a/mobile/lib/shared/views/backup_controller_page.dart +++ b/mobile/lib/modules/backup/views/backup_controller_page.dart @@ -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"), - ], - ), - ), - ); - } -} diff --git a/mobile/lib/modules/home/ui/immich_sliver_appbar.dart b/mobile/lib/modules/home/ui/immich_sliver_appbar.dart index ebc9d6d718..09584b2b7d 100644 --- a/mobile/lib/modules/home/ui/immich_sliver_appbar.dart +++ b/mobile/lib/modules/home/ui/immich_sliver_appbar.dart @@ -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), ), ) diff --git a/mobile/lib/modules/home/ui/profile_drawer.dart b/mobile/lib/modules/home/ui/profile_drawer.dart index cafab9e379..73af9d73aa 100644 --- a/mobile/lib/modules/home/ui/profile_drawer.dart +++ b/mobile/lib/modules/home/ui/profile_drawer.dart @@ -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'; diff --git a/mobile/lib/modules/login/providers/authentication.provider.dart b/mobile/lib/modules/login/providers/authentication.provider.dart index b7c22cae35..9882a8659c 100644 --- a/mobile/lib/modules/login/providers/authentication.provider.dart +++ b/mobile/lib/modules/login/providers/authentication.provider.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'; diff --git a/mobile/lib/modules/login/ui/login_form.dart b/mobile/lib/modules/login/ui/login_form.dart index bb3dde2b77..851338b38d 100644 --- a/mobile/lib/modules/login/ui/login_form.dart +++ b/mobile/lib/modules/login/ui/login_form.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 { diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index a25d45c7bd..27a82e43f9 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -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 { diff --git a/mobile/lib/routing/router.gr.dart b/mobile/lib/routing/router.gr.dart index 0d4f76074d..d59e33470a 100644 --- a/mobile/lib/routing/router.gr.dart +++ b/mobile/lib/routing/router.gr.dart @@ -93,6 +93,16 @@ class _$AppRouter extends RootStackRouter { opaque: true, barrierDismissible: false); }, + BackupAlbumSelectionRoute.name: (routeData) { + return MaterialPageX( + routeData: routeData, child: const BackupAlbumSelectionPage()); + }, + AlbumPreviewRoute.name: (routeData) { + final args = routeData.argsAs(); + return MaterialPageX( + routeData: routeData, + child: AlbumPreviewPage(key: args.key, album: args.album)); + }, HomeRoute.name: (routeData) { return MaterialPageX( 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 { + const BackupAlbumSelectionRoute() + : super(BackupAlbumSelectionRoute.name, + path: '/backup-album-selection-page'); + + static const String name = 'BackupAlbumSelectionRoute'; +} + +/// generated route for +/// [AlbumPreviewPage] +class AlbumPreviewRoute extends PageRouteInfo { + 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 { diff --git a/mobile/lib/shared/models/backup_state.model.dart b/mobile/lib/shared/models/backup_state.model.dart deleted file mode 100644 index db78327376..0000000000 --- a/mobile/lib/shared/models/backup_state.model.dart +++ /dev/null @@ -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; - } -} diff --git a/mobile/lib/shared/providers/backup.provider.dart b/mobile/lib/shared/providers/backup.provider.dart deleted file mode 100644 index d94a00a168..0000000000 --- a/mobile/lib/shared/providers/backup.provider.dart +++ /dev/null @@ -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 { - 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 list = await PhotoManager.getAssetPathList( - onlyAll: true, type: RequestType.common); - List 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 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 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 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((ref) { - return BackupNotifier(ref: ref); -}); diff --git a/mobile/pubspec.lock b/mobile/pubspec.lock index 44d267cc78..f2c280722b 100644 --- a/mobile/pubspec.lock +++ b/mobile/pubspec.lock @@ -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: diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index cae296b87e..a7c8217125 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -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: diff --git a/server/src/constants/server_version.constant.ts b/server/src/constants/server_version.constant.ts index 40326a71d1..775b97ccf5 100644 --- a/server/src/constants/server_version.constant.ts +++ b/server/src/constants/server_version.constant.ts @@ -3,7 +3,7 @@ export const serverVersion = { major: 1, - minor: 8, + minor: 9, patch: 0, - build: 12, + build: 13, };