17
README.md
|
@ -32,8 +32,9 @@ Loading ~4000 images/videos
|
||||||
## Screenshots
|
## Screenshots
|
||||||
|
|
||||||
<p align="left">
|
<p align="left">
|
||||||
<img src="design/nsc1.png" width="150" title="Login With Custom URL">
|
<img src="design/login-screen.png" width="150" title="Login With Custom URL">
|
||||||
<img src="design/nsc2.png" width="150" title="Backup Setting Info">
|
<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/home-screen.jpeg" width="150" title="Home Screen">
|
||||||
<img src="design/search-screen.jpeg" width="150" title="Curated Search Info">
|
<img src="design/search-screen.jpeg" width="150" title="Curated Search Info">
|
||||||
<img src="design/shared-albums.png" width="150" title="Shared Albums">
|
<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
|
# Features
|
||||||
|
|
||||||
- Upload and view assets (videos/images).
|
- Upload and view assets (videos/images).
|
||||||
|
- Auto Backup.
|
||||||
- Download asset to local device.
|
- Download asset to local device.
|
||||||
- Multi-user supported.
|
- Multi-user supported.
|
||||||
- Quick navigation with drag scroll bar.
|
- Quick navigation with drag scroll bar.
|
||||||
- Auto Backup.
|
|
||||||
- Support HEIC/HEIF Backup.
|
- Support HEIC/HEIF Backup.
|
||||||
- Extract and display EXIF info.
|
- Extract and display EXIF info.
|
||||||
- Real-time render from multi-device upload event.
|
- 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 places on the search page
|
||||||
- Show curated objects on the search page
|
- Show curated objects on the search page
|
||||||
- Shared album with users on the same server
|
- Shared album with users on the same server
|
||||||
|
- Selective backup - albums can be included and excluded during the backup process.
|
||||||
|
|
||||||
|
|
||||||
# System Requirement
|
# 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.
|
**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
|
# 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:
|
services:
|
||||||
immich_server:
|
immich_server:
|
||||||
image: immich-server-dev:1.8.0
|
image: immich-server-dev:1.9.0
|
||||||
build:
|
build:
|
||||||
context: ../server
|
context: ../server
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
@ -24,7 +24,7 @@ services:
|
||||||
- immich_network
|
- immich_network
|
||||||
|
|
||||||
immich_microservices:
|
immich_microservices:
|
||||||
image: immich-microservices-dev:1.8.0
|
image: immich-microservices-dev:1.9.0
|
||||||
build:
|
build:
|
||||||
context: ../microservices
|
context: ../microservices
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
|
|
@ -2,7 +2,7 @@ version: "3.8"
|
||||||
|
|
||||||
services:
|
services:
|
||||||
immich_server:
|
immich_server:
|
||||||
image: immich-server-dev:1.8.0
|
image: immich-server-dev:1.9.0
|
||||||
build:
|
build:
|
||||||
context: ../server
|
context: ../server
|
||||||
dockerfile: Dockerfile
|
dockerfile: Dockerfile
|
||||||
|
@ -22,7 +22,7 @@ services:
|
||||||
- immich_network
|
- immich_network
|
||||||
|
|
||||||
immich_microservices:
|
immich_microservices:
|
||||||
image: immich-microservices-dev:1.8.0
|
image: immich-microservices-dev:1.9.0
|
||||||
build:
|
build:
|
||||||
context: ../microservices
|
context: ../microservices
|
||||||
dockerfile: Dockerfile
|
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"
|
desc "iOS Beta"
|
||||||
lane :beta do
|
lane :beta do
|
||||||
increment_version_number(
|
increment_version_number(
|
||||||
version_number: "1.8.0"
|
version_number: "1.9.0"
|
||||||
)
|
)
|
||||||
increment_build_number(
|
increment_build_number(
|
||||||
build_number: latest_testflight_build_number + 1,
|
build_number: latest_testflight_build_number + 1,
|
||||||
|
|
|
@ -9,3 +9,7 @@ const String serverEndpointKey = 'immichBoxServerEndpoint';
|
||||||
// Login Info
|
// Login Info
|
||||||
const String hiveLoginInfoBox = "immichLoginInfoBox";
|
const String hiveLoginInfoBox = "immichLoginInfoBox";
|
||||||
const String savedLoginInfoKey = "immichSavedLoginInfoKey";
|
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:hive_flutter/hive_flutter.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/constants/immich_colors.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/modules/login/models/hive_saved_login_info.model.dart';
|
||||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||||
import 'package:immich_mobile/routing/router.dart';
|
import 'package:immich_mobile/routing/router.dart';
|
||||||
import 'package:immich_mobile/routing/tab_navigation_observer.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/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/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||||
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
|
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
|
||||||
|
@ -16,9 +17,13 @@ import 'constants/hive_box.dart';
|
||||||
|
|
||||||
void main() async {
|
void main() async {
|
||||||
await Hive.initFlutter();
|
await Hive.initFlutter();
|
||||||
|
|
||||||
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
|
Hive.registerAdapter(HiveSavedLoginInfoAdapter());
|
||||||
|
Hive.registerAdapter(HiveBackupAlbumsAdapter());
|
||||||
|
|
||||||
await Hive.openBox(userInfoBox);
|
await Hive.openBox(userInfoBox);
|
||||||
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
|
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
|
||||||
|
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||||
|
|
||||||
SystemChrome.setSystemUIOverlayStyle(
|
SystemChrome.setSystemUIOverlayStyle(
|
||||||
const SystemUiOverlayStyle(
|
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;
|
||||||
|
}
|
BIN
mobile/lib/modules/backup/models/hive_backup_albums.model.g.dart
Normal file
347
mobile/lib/modules/backup/providers/backup.provider.dart
Normal file
|
@ -0,0 +1,347 @@
|
||||||
|
import 'package:dio/dio.dart';
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:hive_flutter/hive_flutter.dart';
|
||||||
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
|
import 'package:immich_mobile/constants/hive_box.dart';
|
||||||
|
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
|
||||||
|
import 'package:immich_mobile/shared/services/server_info.service.dart';
|
||||||
|
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
|
||||||
|
import 'package:immich_mobile/shared/models/server_info.model.dart';
|
||||||
|
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
|
||||||
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
|
class BackupNotifier extends StateNotifier<BackUpState> {
|
||||||
|
BackupNotifier({this.ref})
|
||||||
|
: super(
|
||||||
|
BackUpState(
|
||||||
|
backupProgress: BackUpProgressEnum.idle,
|
||||||
|
allAssetOnDatabase: const [],
|
||||||
|
progressInPercentage: 0,
|
||||||
|
cancelToken: CancelToken(),
|
||||||
|
serverInfo: ServerInfo(
|
||||||
|
diskAvailable: "0",
|
||||||
|
diskAvailableRaw: 0,
|
||||||
|
diskSize: "0",
|
||||||
|
diskSizeRaw: 0,
|
||||||
|
diskUsagePercentage: 0.0,
|
||||||
|
diskUse: "0",
|
||||||
|
diskUseRaw: 0,
|
||||||
|
),
|
||||||
|
availableAlbums: const [],
|
||||||
|
selectedBackupAlbums: const {},
|
||||||
|
excludedBackupAlbums: const {},
|
||||||
|
allUniqueAssets: const {},
|
||||||
|
selectedAlbumsBackupAssetsIds: const {},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ref? ref;
|
||||||
|
final BackupService _backupService = BackupService();
|
||||||
|
final ServerInfoService _serverInfoService = ServerInfoService();
|
||||||
|
|
||||||
|
///
|
||||||
|
/// UI INTERACTION
|
||||||
|
///
|
||||||
|
/// Album selection
|
||||||
|
/// Due to the overlapping assets across multiple albums on the device
|
||||||
|
/// We have method to include and exclude albums
|
||||||
|
/// The total unique assets will be used for backing mechanism
|
||||||
|
///
|
||||||
|
void addAlbumForBackup(AssetPathEntity album) {
|
||||||
|
if (state.excludedBackupAlbums.contains(album)) {
|
||||||
|
removeExcludedAlbumForBackup(album);
|
||||||
|
}
|
||||||
|
|
||||||
|
state = state.copyWith(selectedBackupAlbums: {...state.selectedBackupAlbums, album});
|
||||||
|
_updateBackupAssetCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
void addExcludedAlbumForBackup(AssetPathEntity album) {
|
||||||
|
if (state.selectedBackupAlbums.contains(album)) {
|
||||||
|
removeAlbumForBackup(album);
|
||||||
|
}
|
||||||
|
state = state.copyWith(excludedBackupAlbums: {...state.excludedBackupAlbums, album});
|
||||||
|
_updateBackupAssetCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeAlbumForBackup(AssetPathEntity album) {
|
||||||
|
Set<AssetPathEntity> currentSelectedAlbums = state.selectedBackupAlbums;
|
||||||
|
|
||||||
|
currentSelectedAlbums.removeWhere((a) => a == album);
|
||||||
|
|
||||||
|
state = state.copyWith(selectedBackupAlbums: currentSelectedAlbums);
|
||||||
|
_updateBackupAssetCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
void removeExcludedAlbumForBackup(AssetPathEntity album) {
|
||||||
|
Set<AssetPathEntity> currentExcludedAlbums = state.excludedBackupAlbums;
|
||||||
|
|
||||||
|
currentExcludedAlbums.removeWhere((a) => a == album);
|
||||||
|
|
||||||
|
state = state.copyWith(excludedBackupAlbums: currentExcludedAlbums);
|
||||||
|
_updateBackupAssetCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
///
|
||||||
|
/// Get all album on the device
|
||||||
|
/// Get all selected and excluded album from the user's persistent storage
|
||||||
|
/// If this is the first time performing backup - set the default selected album to be
|
||||||
|
/// the one that has all assets (Recent on Android, Recents on iOS)
|
||||||
|
///
|
||||||
|
Future<void> getBackupAlbumsInfo() async {
|
||||||
|
// Get all albums on the device
|
||||||
|
List<AvailableAlbum> availableAlbums = [];
|
||||||
|
List<AssetPathEntity> albums = await PhotoManager.getAssetPathList(hasAll: true, type: RequestType.common);
|
||||||
|
|
||||||
|
for (AssetPathEntity album in albums) {
|
||||||
|
AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
|
||||||
|
|
||||||
|
var assetList = await album.getAssetListRange(start: 0, end: album.assetCount);
|
||||||
|
|
||||||
|
if (assetList.isNotEmpty) {
|
||||||
|
var thumbnailAsset = assetList.first;
|
||||||
|
var thumbnailData = await thumbnailAsset.thumbnailDataWithSize(const ThumbnailSize(512, 512));
|
||||||
|
availableAlbum = availableAlbum.copyWith(thumbnailData: thumbnailData);
|
||||||
|
}
|
||||||
|
|
||||||
|
availableAlbums.add(availableAlbum);
|
||||||
|
}
|
||||||
|
|
||||||
|
state = state.copyWith(availableAlbums: availableAlbums);
|
||||||
|
|
||||||
|
// Put persistent storage info into local state of the app
|
||||||
|
// Get local storage on selected backup album
|
||||||
|
Box<HiveBackupAlbums> backupAlbumInfoBox = Hive.box<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||||
|
HiveBackupAlbums? backupAlbumInfo = backupAlbumInfoBox.get(
|
||||||
|
backupInfoKey,
|
||||||
|
defaultValue: HiveBackupAlbums(
|
||||||
|
selectedAlbumIds: [],
|
||||||
|
excludedAlbumsIds: [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (backupAlbumInfo == null) {
|
||||||
|
debugPrint("[ERROR] getting Hive backup album infomation");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// First time backup - set isAll album is the default one for backup.
|
||||||
|
if (backupAlbumInfo.selectedAlbumIds.isEmpty) {
|
||||||
|
debugPrint("First time backup setup recent album as default");
|
||||||
|
|
||||||
|
// Get album that contains all assets
|
||||||
|
var list = await PhotoManager.getAssetPathList(hasAll: true, onlyAll: true, type: RequestType.common);
|
||||||
|
AssetPathEntity albumHasAllAssets = list.first;
|
||||||
|
|
||||||
|
backupAlbumInfoBox.put(
|
||||||
|
backupInfoKey,
|
||||||
|
HiveBackupAlbums(
|
||||||
|
selectedAlbumIds: [albumHasAllAssets.id],
|
||||||
|
excludedAlbumsIds: [],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
backupAlbumInfo = backupAlbumInfoBox.get(backupInfoKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generate AssetPathEntity from id to add to local state
|
||||||
|
try {
|
||||||
|
for (var selectedAlbumId in backupAlbumInfo!.selectedAlbumIds) {
|
||||||
|
var albumAsset = await AssetPathEntity.fromId(selectedAlbumId);
|
||||||
|
state = state.copyWith(selectedBackupAlbums: {...state.selectedBackupAlbums, albumAsset});
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var excludedAlbumId in backupAlbumInfo.excludedAlbumsIds) {
|
||||||
|
var albumAsset = await AssetPathEntity.fromId(excludedAlbumId);
|
||||||
|
state = state.copyWith(excludedBackupAlbums: {...state.excludedBackupAlbums, albumAsset});
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint("[ERROR] Failed to generate album from id $e");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
///
|
||||||
|
/// From all the selected and albums assets
|
||||||
|
/// Find the assets that are not overlapping between the two sets
|
||||||
|
/// Those assets are unique and are used as the total assets
|
||||||
|
///
|
||||||
|
void _updateBackupAssetCount() async {
|
||||||
|
Set<AssetEntity> assetsFromSelectedAlbums = {};
|
||||||
|
Set<AssetEntity> assetsFromExcludedAlbums = {};
|
||||||
|
|
||||||
|
for (var album in state.selectedBackupAlbums) {
|
||||||
|
var assets = await album.getAssetListRange(start: 0, end: album.assetCount);
|
||||||
|
assetsFromSelectedAlbums.addAll(assets);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var album in state.excludedBackupAlbums) {
|
||||||
|
var assets = await album.getAssetListRange(start: 0, end: album.assetCount);
|
||||||
|
assetsFromExcludedAlbums.addAll(assets);
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<AssetEntity> allUniqueAssets = assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums);
|
||||||
|
List<String> allAssetOnDatabase = await _backupService.getDeviceBackupAsset();
|
||||||
|
|
||||||
|
// Find asset that were backup from selected albums
|
||||||
|
Set<String> selectedAlbumsBackupAssets = Set.from(allUniqueAssets.map((e) => e.id));
|
||||||
|
selectedAlbumsBackupAssets.removeWhere((assetId) => !allAssetOnDatabase.contains(assetId));
|
||||||
|
|
||||||
|
if (allUniqueAssets.isEmpty) {
|
||||||
|
debugPrint("No Asset On Device");
|
||||||
|
state = state.copyWith(
|
||||||
|
backupProgress: BackUpProgressEnum.idle,
|
||||||
|
allAssetOnDatabase: allAssetOnDatabase,
|
||||||
|
allUniqueAssets: {},
|
||||||
|
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
} else {
|
||||||
|
state = state.copyWith(
|
||||||
|
allAssetOnDatabase: allAssetOnDatabase,
|
||||||
|
allUniqueAssets: allUniqueAssets,
|
||||||
|
selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save to persistent storage
|
||||||
|
_updatePersistentAlbumsSelection();
|
||||||
|
}
|
||||||
|
|
||||||
|
///
|
||||||
|
/// Get all necessary information for calculating the available albums,
|
||||||
|
/// which albums are selected or excluded
|
||||||
|
/// and then update the UI according to those information
|
||||||
|
///
|
||||||
|
void getBackupInfo() async {
|
||||||
|
await getBackupAlbumsInfo();
|
||||||
|
_updateServerInfo();
|
||||||
|
_updateBackupAssetCount();
|
||||||
|
}
|
||||||
|
|
||||||
|
///
|
||||||
|
/// Save user selection of selected albums and excluded albums to
|
||||||
|
/// Hive database
|
||||||
|
///
|
||||||
|
void _updatePersistentAlbumsSelection() {
|
||||||
|
Box<HiveBackupAlbums> backupAlbumInfoBox = Hive.box<HiveBackupAlbums>(hiveBackupInfoBox);
|
||||||
|
backupAlbumInfoBox.put(
|
||||||
|
backupInfoKey,
|
||||||
|
HiveBackupAlbums(
|
||||||
|
selectedAlbumIds: state.selectedBackupAlbums.map((e) => e.id).toList(),
|
||||||
|
excludedAlbumsIds: state.excludedBackupAlbums.map((e) => e.id).toList(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
///
|
||||||
|
/// Invoke backup process
|
||||||
|
///
|
||||||
|
void startBackupProcess() async {
|
||||||
|
_updateServerInfo();
|
||||||
|
_updateBackupAssetCount();
|
||||||
|
|
||||||
|
state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);
|
||||||
|
|
||||||
|
var authResult = await PhotoManager.requestPermissionExtend();
|
||||||
|
if (authResult.isAuth) {
|
||||||
|
await PhotoManager.clearFileCache();
|
||||||
|
|
||||||
|
if (state.allUniqueAssets.isEmpty) {
|
||||||
|
debugPrint("No Asset On Device - Abort Backup Process");
|
||||||
|
state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Set<AssetEntity> assetsWillBeBackup = state.allUniqueAssets;
|
||||||
|
|
||||||
|
// Remove item that has already been backed up
|
||||||
|
for (var assetId in state.allAssetOnDatabase) {
|
||||||
|
assetsWillBeBackup.removeWhere((e) => e.id == assetId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (assetsWillBeBackup.isEmpty) {
|
||||||
|
state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Perform Backup
|
||||||
|
state = state.copyWith(cancelToken: CancelToken());
|
||||||
|
_backupService.backupAsset(assetsWillBeBackup, state.cancelToken, _onAssetUploaded, _onUploadProgress);
|
||||||
|
} else {
|
||||||
|
PhotoManager.openSetting();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void cancelBackup() {
|
||||||
|
state.cancelToken.cancel('Cancel Backup');
|
||||||
|
state = state.copyWith(backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onAssetUploaded(String deviceAssetId, String deviceId) {
|
||||||
|
state = state.copyWith(
|
||||||
|
selectedAlbumsBackupAssetsIds: {...state.selectedAlbumsBackupAssetsIds, deviceAssetId},
|
||||||
|
allAssetOnDatabase: [...state.allAssetOnDatabase, deviceAssetId]);
|
||||||
|
|
||||||
|
if (state.allUniqueAssets.length - state.selectedAlbumsBackupAssetsIds.length == 0) {
|
||||||
|
state = state.copyWith(backupProgress: BackUpProgressEnum.done, progressInPercentage: 0.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
_updateServerInfo();
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onUploadProgress(int sent, int total) {
|
||||||
|
state = state.copyWith(progressInPercentage: (sent.toDouble() / total.toDouble() * 100));
|
||||||
|
}
|
||||||
|
|
||||||
|
void _updateServerInfo() async {
|
||||||
|
var serverInfo = await _serverInfoService.getServerInfo();
|
||||||
|
|
||||||
|
// Update server info
|
||||||
|
state = state.copyWith(
|
||||||
|
serverInfo: ServerInfo(
|
||||||
|
diskSize: serverInfo.diskSize,
|
||||||
|
diskUse: serverInfo.diskUse,
|
||||||
|
diskAvailable: serverInfo.diskAvailable,
|
||||||
|
diskSizeRaw: serverInfo.diskSizeRaw,
|
||||||
|
diskUseRaw: serverInfo.diskUseRaw,
|
||||||
|
diskAvailableRaw: serverInfo.diskAvailableRaw,
|
||||||
|
diskUsagePercentage: serverInfo.diskUsagePercentage,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void resumeBackup() {
|
||||||
|
var authState = ref?.read(authenticationProvider);
|
||||||
|
|
||||||
|
// Check if user is login
|
||||||
|
var accessKey = Hive.box(userInfoBox).get(accessTokenKey);
|
||||||
|
|
||||||
|
// User has been logged out return
|
||||||
|
if (authState != null) {
|
||||||
|
if (accessKey == null || !authState.isAuthenticated) {
|
||||||
|
debugPrint("[resumeBackup] not authenticated - abort");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if this device is enable backup by the user
|
||||||
|
if ((authState.deviceInfo.deviceId == authState.deviceId) && authState.deviceInfo.isAutoBackup) {
|
||||||
|
// check if backup is alreayd in process - then return
|
||||||
|
if (state.backupProgress == BackUpProgressEnum.inProgress) {
|
||||||
|
debugPrint("[resumeBackup] Backup is already in progress - abort");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Run backup
|
||||||
|
debugPrint("[resumeBackup] Start back up");
|
||||||
|
startBackupProcess();
|
||||||
|
}
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
final backupProvider = StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
|
||||||
|
return BackupNotifier(ref: ref);
|
||||||
|
});
|
|
@ -26,7 +26,7 @@ class BackupService {
|
||||||
return result.cast<String>();
|
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 {
|
Function(int, int) uploadProgress) async {
|
||||||
var dio = Dio();
|
var dio = Dio();
|
||||||
dio.interceptors.add(AuthenticatedRequestInterceptor());
|
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:flutter_hooks/flutter_hooks.dart';
|
||||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.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/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/shared/providers/websocket.provider.dart';
|
||||||
|
import 'package:immich_mobile/modules/backup/ui/backup_info_card.dart';
|
||||||
import 'package:percent_indicator/linear_percent_indicator.dart';
|
import 'package:percent_indicator/linear_percent_indicator.dart';
|
||||||
|
|
||||||
class BackupControllerPage extends HookConsumerWidget {
|
class BackupControllerPage extends HookConsumerWidget {
|
||||||
|
@ -14,13 +16,13 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context, WidgetRef ref) {
|
Widget build(BuildContext context, WidgetRef ref) {
|
||||||
BackUpState _backupState = ref.watch(backupProvider);
|
BackUpState backupState = ref.watch(backupProvider);
|
||||||
AuthenticationState _authenticationState = ref.watch(authenticationProvider);
|
AuthenticationState _authenticationState = ref.watch(authenticationProvider);
|
||||||
|
bool shouldBackup =
|
||||||
bool shouldBackup = _backupState.totalAssetCount - _backupState.assetOnDatabase == 0 ? false : true;
|
backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length == 0 ? false : true;
|
||||||
|
|
||||||
useEffect(() {
|
useEffect(() {
|
||||||
if (_backupState.backupProgress != BackUpProgressEnum.inProgress) {
|
if (backupState.backupProgress != BackUpProgressEnum.inProgress) {
|
||||||
ref.read(backupProvider.notifier).getBackupInfo();
|
ref.read(backupProvider.notifier).getBackupInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -46,13 +48,13 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||||
LinearPercentIndicator(
|
LinearPercentIndicator(
|
||||||
padding: const EdgeInsets.only(top: 8.0),
|
padding: const EdgeInsets.only(top: 8.0),
|
||||||
lineHeight: 5.0,
|
lineHeight: 5.0,
|
||||||
percent: _backupState.serverInfo.diskUsagePercentage / 100.0,
|
percent: backupState.serverInfo.diskUsagePercentage / 100.0,
|
||||||
backgroundColor: Colors.grey,
|
backgroundColor: Colors.grey,
|
||||||
progressColor: Theme.of(context).primaryColor,
|
progressColor: Theme.of(context).primaryColor,
|
||||||
),
|
),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.only(top: 12.0),
|
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(
|
return Scaffold(
|
||||||
appBar: AppBar(
|
appBar: AppBar(
|
||||||
|
elevation: 0,
|
||||||
title: const Text(
|
title: const Text(
|
||||||
"Backup",
|
"Backup",
|
||||||
style: TextStyle(fontWeight: FontWeight.bold),
|
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||||
),
|
),
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
ref.watch(websocketProvider.notifier).listenUploadEvent();
|
ref.watch(websocketProvider.notifier).listenUploadEvent();
|
||||||
AutoRouter.of(context).pop(true);
|
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(
|
body: Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
@ -129,20 +233,21 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||||
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
_buildFolderSelectionTile(),
|
||||||
BackupInfoCard(
|
BackupInfoCard(
|
||||||
title: "Total",
|
title: "Total",
|
||||||
subtitle: "All images and videos on the device",
|
subtitle: "All unique photos and videos from selected albums",
|
||||||
info: "${_backupState.totalAssetCount}",
|
info: "${backupState.allUniqueAssets.length}",
|
||||||
),
|
),
|
||||||
BackupInfoCard(
|
BackupInfoCard(
|
||||||
title: "Backup",
|
title: "Backup",
|
||||||
subtitle: "Images and videos of the device that are backup on server",
|
subtitle: "Photos and videos from selected albums that are backup",
|
||||||
info: "${_backupState.assetOnDatabase}",
|
info: "${backupState.selectedAlbumsBackupAssetsIds.length}",
|
||||||
),
|
),
|
||||||
BackupInfoCard(
|
BackupInfoCard(
|
||||||
title: "Remainder",
|
title: "Remainder",
|
||||||
subtitle: "Images and videos that has not been backing up",
|
subtitle: "Photos and videos that has not been backing up from selected albums",
|
||||||
info: "${_backupState.totalAssetCount - _backupState.assetOnDatabase}",
|
info: "${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length}",
|
||||||
),
|
),
|
||||||
const Divider(),
|
const Divider(),
|
||||||
_buildBackupController(),
|
_buildBackupController(),
|
||||||
|
@ -152,14 +257,14 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: Text(
|
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(
|
||||||
padding: const EdgeInsets.only(left: 8.0),
|
padding: const EdgeInsets.only(left: 8.0),
|
||||||
child: Row(children: [
|
child: Row(children: [
|
||||||
const Text("Backup Progress:"),
|
const Text("Backup Progress:"),
|
||||||
const Padding(padding: EdgeInsets.symmetric(horizontal: 2)),
|
const Padding(padding: EdgeInsets.symmetric(horizontal: 2)),
|
||||||
_backupState.backupProgress == BackUpProgressEnum.inProgress
|
backupState.backupProgress == BackUpProgressEnum.inProgress
|
||||||
? const CircularProgressIndicator.adaptive()
|
? const CircularProgressIndicator.adaptive()
|
||||||
: const Text("Done"),
|
: const Text("Done"),
|
||||||
]),
|
]),
|
||||||
|
@ -167,7 +272,7 @@ class BackupControllerPage extends HookConsumerWidget {
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.all(8.0),
|
padding: const EdgeInsets.all(8.0),
|
||||||
child: Container(
|
child: Container(
|
||||||
child: _backupState.backupProgress == BackUpProgressEnum.inProgress
|
child: backupState.backupProgress == BackUpProgressEnum.inProgress
|
||||||
? ElevatedButton(
|
? ElevatedButton(
|
||||||
style: ElevatedButton.styleFrom(primary: Colors.red[300]),
|
style: ElevatedButton.styleFrom(primary: Colors.red[300]),
|
||||||
onPressed: () {
|
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/modules/login/providers/authentication.provider.dart';
|
||||||
|
|
||||||
import 'package:immich_mobile/routing/router.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/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/server_info.provider.dart';
|
||||||
|
|
||||||
class ImmichSliverAppBar extends ConsumerWidget {
|
class ImmichSliverAppBar extends ConsumerWidget {
|
||||||
|
@ -130,7 +130,8 @@ class ImmichSliverAppBar extends ConsumerWidget {
|
||||||
? Positioned(
|
? Positioned(
|
||||||
bottom: 5,
|
bottom: 5,
|
||||||
child: Text(
|
child: Text(
|
||||||
_backupState.backingUpAssetCount.toString(),
|
(_backupState.allUniqueAssets.length - _backupState.selectedAlbumsBackupAssetsIds.length)
|
||||||
|
.toString(),
|
||||||
style: const TextStyle(fontSize: 9, fontWeight: FontWeight.bold),
|
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/models/authentication_state.model.dart';
|
||||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.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/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/server_info.provider.dart';
|
||||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
|
||||||
import 'package:package_info_plus/package_info_plus.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/authentication_state.model.dart';
|
||||||
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.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/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/device_info.service.dart';
|
||||||
import 'package:immich_mobile/shared/services/network.service.dart';
|
import 'package:immich_mobile/shared/services/network.service.dart';
|
||||||
import 'package:immich_mobile/shared/models/device_info.model.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/modules/login/models/hive_saved_login_info.model.dart';
|
||||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
import 'package:immich_mobile/shared/providers/asset.provider.dart';
|
||||||
import 'package:immich_mobile/modules/login/providers/authentication.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';
|
import 'package:immich_mobile/shared/ui/immich_toast.dart';
|
||||||
|
|
||||||
class LoginForm extends HookConsumerWidget {
|
class LoginForm extends HookConsumerWidget {
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
import 'package:auto_route/auto_route.dart';
|
import 'package:auto_route/auto_route.dart';
|
||||||
import 'package:flutter/material.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/login/views/login_page.dart';
|
||||||
import 'package:immich_mobile/modules/home/views/home_page.dart';
|
import 'package:immich_mobile/modules/home/views/home_page.dart';
|
||||||
import 'package:immich_mobile/modules/search/views/search_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/modules/sharing/views/sharing_page.dart';
|
||||||
import 'package:immich_mobile/routing/auth_guard.dart';
|
import 'package:immich_mobile/routing/auth_guard.dart';
|
||||||
import 'package:immich_mobile/shared/models/immich_asset.model.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/modules/asset_viewer/views/image_viewer_page.dart';
|
||||||
import 'package:immich_mobile/shared/views/tab_controller_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:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
|
||||||
|
import 'package:photo_manager/photo_manager.dart';
|
||||||
|
|
||||||
part 'router.gr.dart';
|
part 'router.gr.dart';
|
||||||
|
|
||||||
|
@ -55,6 +58,8 @@ part 'router.gr.dart';
|
||||||
guards: [AuthGuard],
|
guards: [AuthGuard],
|
||||||
transitionsBuilder: TransitionsBuilders.slideBottom,
|
transitionsBuilder: TransitionsBuilders.slideBottom,
|
||||||
),
|
),
|
||||||
|
AutoRoute(page: BackupAlbumSelectionPage, guards: [AuthGuard]),
|
||||||
|
AutoRoute(page: AlbumPreviewPage, guards: [AuthGuard]),
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
class AppRouter extends _$AppRouter {
|
class AppRouter extends _$AppRouter {
|
||||||
|
|
|
@ -93,6 +93,16 @@ class _$AppRouter extends RootStackRouter {
|
||||||
opaque: true,
|
opaque: true,
|
||||||
barrierDismissible: false);
|
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) {
|
HomeRoute.name: (routeData) {
|
||||||
return MaterialPageX<dynamic>(
|
return MaterialPageX<dynamic>(
|
||||||
routeData: routeData, child: const HomePage());
|
routeData: routeData, child: const HomePage());
|
||||||
|
@ -149,7 +159,11 @@ class _$AppRouter extends RootStackRouter {
|
||||||
path: '/album-viewer-page', guards: [authGuard]),
|
path: '/album-viewer-page', guards: [authGuard]),
|
||||||
RouteConfig(SelectAdditionalUserForSharingRoute.name,
|
RouteConfig(SelectAdditionalUserForSharingRoute.name,
|
||||||
path: '/select-additional-user-for-sharing-page',
|
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
|
/// generated route for
|
||||||
/// [HomePage]
|
/// [HomePage]
|
||||||
class HomeRoute extends PageRouteInfo<void> {
|
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"
|
url: "https://pub.dartlang.org"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "4.0.4"
|
version: "4.0.4"
|
||||||
|
equatable:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: equatable
|
||||||
|
url: "https://pub.dartlang.org"
|
||||||
|
source: hosted
|
||||||
|
version: "2.0.3"
|
||||||
exif:
|
exif:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
|
|
|
@ -2,7 +2,7 @@ name: immich_mobile
|
||||||
description: Immich - selfhosted backup media file on mobile phone
|
description: Immich - selfhosted backup media file on mobile phone
|
||||||
|
|
||||||
publish_to: "none"
|
publish_to: "none"
|
||||||
version: 1.8.0+12
|
version: 1.9.0+13
|
||||||
|
|
||||||
environment:
|
environment:
|
||||||
sdk: ">=2.15.1 <3.0.0"
|
sdk: ">=2.15.1 <3.0.0"
|
||||||
|
@ -37,6 +37,7 @@ dependencies:
|
||||||
package_info_plus: ^1.4.0
|
package_info_plus: ^1.4.0
|
||||||
flutter_spinkit: ^5.1.0
|
flutter_spinkit: ^5.1.0
|
||||||
flutter_swipe_detector: ^2.0.0
|
flutter_swipe_detector: ^2.0.0
|
||||||
|
equatable: ^2.0.3
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
|
|
|
@ -3,7 +3,7 @@
|
||||||
|
|
||||||
export const serverVersion = {
|
export const serverVersion = {
|
||||||
major: 1,
|
major: 1,
|
||||||
minor: 8,
|
minor: 9,
|
||||||
patch: 0,
|
patch: 0,
|
||||||
build: 12,
|
build: 13,
|
||||||
};
|
};
|
||||||
|
|