17
									
								
								README.md
									
									
									
									
									
								
							
							
						
						@ -32,8 +32,9 @@ Loading ~4000 images/videos
 | 
			
		||||
## Screenshots
 | 
			
		||||
 | 
			
		||||
<p align="left">
 | 
			
		||||
  <img src="design/nsc1.png" width="150" title="Login With Custom URL">
 | 
			
		||||
  <img src="design/nsc2.png" width="150" title="Backup Setting Info">
 | 
			
		||||
  <img src="design/login-screen.png" width="150" title="Login With Custom URL">
 | 
			
		||||
  <img src="design/backup-screen.png" width="150" title="Backup Setting Info">
 | 
			
		||||
  <img src="design/selective-backup-screen.png" width="150" title="Backup Setting Info">
 | 
			
		||||
  <img src="design/home-screen.jpeg" width="150" title="Home Screen">
 | 
			
		||||
  <img src="design/search-screen.jpeg" width="150" title="Curated Search Info">
 | 
			
		||||
  <img src="design/shared-albums.png" width="150" title="Shared Albums">
 | 
			
		||||
@ -50,10 +51,10 @@ This project is under heavy development, there will be continous functions, feat
 | 
			
		||||
# Features
 | 
			
		||||
 | 
			
		||||
- Upload and view assets (videos/images).
 | 
			
		||||
- Auto Backup.
 | 
			
		||||
- Download asset to local device.
 | 
			
		||||
- Multi-user supported.
 | 
			
		||||
- Quick navigation with drag scroll bar.
 | 
			
		||||
- Auto Backup.
 | 
			
		||||
- Support HEIC/HEIF Backup.
 | 
			
		||||
- Extract and display EXIF info.
 | 
			
		||||
- Real-time render from multi-device upload event.
 | 
			
		||||
@ -65,14 +66,20 @@ This project is under heavy development, there will be continous functions, feat
 | 
			
		||||
- Show curated places on the search page
 | 
			
		||||
- Show curated objects on the search page
 | 
			
		||||
- Shared album with users on the same server
 | 
			
		||||
- Selective backup - albums can be included and excluded during the backup process.
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
# System Requirement
 | 
			
		||||
 | 
			
		||||
**OS**: Preferred Linux-based operating system (Ubuntu, Debian, MacOS...etc). I haven't tested with `Docker for Windows` as well as `WSL` on Windows
 | 
			
		||||
**OS**: Preferred Linux-based operating system (Ubuntu, Debian, MacOS...etc). 
 | 
			
		||||
 | 
			
		||||
I haven't tested with `Docker for Windows` as well as `WSL` on Windows
 | 
			
		||||
 | 
			
		||||
*Raspberry Pi can be used but `microservices` container has to be comment out in `docker-compose` since TensorFlow has not been supported in Dockec image on arm64v7 yet.*
 | 
			
		||||
 | 
			
		||||
**RAM**: At least 2GB, preffered 4GB.
 | 
			
		||||
 | 
			
		||||
**Cores**: At least 2 cores, preffered 4 cores.
 | 
			
		||||
**Core**: At least 2 cores, preffered 4 cores.
 | 
			
		||||
 | 
			
		||||
# Development and Testing out the application
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								design/backup-screen.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 308 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								design/login-screen.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 278 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								design/nsc1.png
									
									
									
									
									
								
							
							
						
						| 
		 Before Width: | Height: | Size: 176 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								design/nsc2.png
									
									
									
									
									
								
							
							
						
						| 
		 Before Width: | Height: | Size: 303 KiB  | 
							
								
								
									
										
											BIN
										
									
								
								design/selective-backup-screen.png
									
									
									
									
									
										Normal file
									
								
							
							
						
						| 
		 After Width: | Height: | Size: 570 KiB  | 
@ -2,7 +2,7 @@ version: "3.8"
 | 
			
		||||
 | 
			
		||||
services:
 | 
			
		||||
  immich_server:
 | 
			
		||||
    image: immich-server-dev:1.8.0
 | 
			
		||||
    image: immich-server-dev:1.9.0
 | 
			
		||||
    build:
 | 
			
		||||
      context: ../server
 | 
			
		||||
      dockerfile: Dockerfile
 | 
			
		||||
@ -24,7 +24,7 @@ services:
 | 
			
		||||
      - immich_network
 | 
			
		||||
 | 
			
		||||
  immich_microservices:
 | 
			
		||||
    image: immich-microservices-dev:1.8.0
 | 
			
		||||
    image: immich-microservices-dev:1.9.0
 | 
			
		||||
    build:
 | 
			
		||||
      context: ../microservices
 | 
			
		||||
      dockerfile: Dockerfile
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,7 @@ version: "3.8"
 | 
			
		||||
 | 
			
		||||
services:
 | 
			
		||||
  immich_server:
 | 
			
		||||
    image: immich-server-dev:1.8.0
 | 
			
		||||
    image: immich-server-dev:1.9.0
 | 
			
		||||
    build:
 | 
			
		||||
      context: ../server
 | 
			
		||||
      dockerfile: Dockerfile
 | 
			
		||||
@ -22,7 +22,7 @@ services:
 | 
			
		||||
      - immich_network
 | 
			
		||||
 | 
			
		||||
  immich_microservices:
 | 
			
		||||
    image: immich-microservices-dev:1.8.0
 | 
			
		||||
    image: immich-microservices-dev:1.9.0
 | 
			
		||||
    build:
 | 
			
		||||
      context: ../microservices
 | 
			
		||||
      dockerfile: Dockerfile
 | 
			
		||||
 | 
			
		||||
@ -0,0 +1,2 @@
 | 
			
		||||
* New Feature - Selection backup. User can now select a combination of albums to be included or excluded during the backup process, and only unique photos, and videos that are not overlapping between the two groups will be backup.
 | 
			
		||||
* Bug fix - Show correct count of backup and remainder assets.
 | 
			
		||||
| 
		 Before Width: | Height: | Size: 55 KiB After Width: | Height: | Size: 570 KiB  | 
| 
		 Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 308 KiB  | 
| 
		 Before Width: | Height: | Size: 183 KiB After Width: | Height: | Size: 74 KiB  | 
| 
		 After Width: | Height: | Size: 183 KiB  | 
@ -19,7 +19,7 @@ platform :ios do
 | 
			
		||||
  desc "iOS Beta"
 | 
			
		||||
  lane :beta do
 | 
			
		||||
    increment_version_number(
 | 
			
		||||
      version_number: "1.8.0"
 | 
			
		||||
      version_number: "1.9.0"
 | 
			
		||||
    )
 | 
			
		||||
    increment_build_number(
 | 
			
		||||
      build_number: latest_testflight_build_number + 1,
 | 
			
		||||
 | 
			
		||||
@ -9,3 +9,7 @@ const String serverEndpointKey = 'immichBoxServerEndpoint';
 | 
			
		||||
// Login Info
 | 
			
		||||
const String hiveLoginInfoBox = "immichLoginInfoBox";
 | 
			
		||||
const String savedLoginInfoKey = "immichSavedLoginInfoKey";
 | 
			
		||||
 | 
			
		||||
// Backup Info
 | 
			
		||||
const String hiveBackupInfoBox = "immichBackupAlbumInfoBox";
 | 
			
		||||
const String backupInfoKey = "immichBackupAlbumInfoKey";
 | 
			
		||||
 | 
			
		||||
@ -3,12 +3,13 @@ import 'package:flutter/services.dart';
 | 
			
		||||
import 'package:hive_flutter/hive_flutter.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/constants/immich_colors.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/routing/router.dart';
 | 
			
		||||
import 'package:immich_mobile/routing/tab_navigation_observer.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/providers/app_state.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/providers/backup.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
 | 
			
		||||
@ -16,9 +17,13 @@ import 'constants/hive_box.dart';
 | 
			
		||||
 | 
			
		||||
void main() async {
 | 
			
		||||
  await Hive.initFlutter();
 | 
			
		||||
 | 
			
		||||
  Hive.registerAdapter(HiveSavedLoginInfoAdapter());
 | 
			
		||||
  Hive.registerAdapter(HiveBackupAlbumsAdapter());
 | 
			
		||||
 | 
			
		||||
  await Hive.openBox(userInfoBox);
 | 
			
		||||
  await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
 | 
			
		||||
  await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
 | 
			
		||||
 | 
			
		||||
  SystemChrome.setSystemUIOverlayStyle(
 | 
			
		||||
    const SystemUiOverlayStyle(
 | 
			
		||||
 | 
			
		||||
							
								
								
									
										35
									
								
								mobile/lib/modules/backup/models/available_album.model.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,35 @@
 | 
			
		||||
import 'dart:typed_data';
 | 
			
		||||
 | 
			
		||||
import 'package:photo_manager/photo_manager.dart';
 | 
			
		||||
 | 
			
		||||
class AvailableAlbum {
 | 
			
		||||
  final AssetPathEntity albumEntity;
 | 
			
		||||
  final Uint8List? thumbnailData;
 | 
			
		||||
  AvailableAlbum({
 | 
			
		||||
    required this.albumEntity,
 | 
			
		||||
    this.thumbnailData,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  AvailableAlbum copyWith({
 | 
			
		||||
    AssetPathEntity? albumEntity,
 | 
			
		||||
    Uint8List? thumbnailData,
 | 
			
		||||
  }) {
 | 
			
		||||
    return AvailableAlbum(
 | 
			
		||||
      albumEntity: albumEntity ?? this.albumEntity,
 | 
			
		||||
      thumbnailData: thumbnailData ?? this.thumbnailData,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() => 'AvailableAlbum(albumEntity: $albumEntity, thumbnailData: $thumbnailData)';
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) {
 | 
			
		||||
    if (identical(this, other)) return true;
 | 
			
		||||
 | 
			
		||||
    return other is AvailableAlbum && other.albumEntity == albumEntity && other.thumbnailData == thumbnailData;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode => albumEntity.hashCode ^ thumbnailData.hashCode;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										88
									
								
								mobile/lib/modules/backup/models/backup_state.model.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,88 @@
 | 
			
		||||
import 'package:dio/dio.dart';
 | 
			
		||||
import 'package:equatable/equatable.dart';
 | 
			
		||||
import 'package:photo_manager/photo_manager.dart';
 | 
			
		||||
 | 
			
		||||
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/server_info.model.dart';
 | 
			
		||||
 | 
			
		||||
enum BackUpProgressEnum { idle, inProgress, done }
 | 
			
		||||
 | 
			
		||||
class BackUpState extends Equatable {
 | 
			
		||||
  // enum
 | 
			
		||||
  final BackUpProgressEnum backupProgress;
 | 
			
		||||
  final List<String> allAssetOnDatabase;
 | 
			
		||||
  final double progressInPercentage;
 | 
			
		||||
  final CancelToken cancelToken;
 | 
			
		||||
  final ServerInfo serverInfo;
 | 
			
		||||
 | 
			
		||||
  /// All available albums on the device
 | 
			
		||||
  final List<AvailableAlbum> availableAlbums;
 | 
			
		||||
  final Set<AssetPathEntity> selectedBackupAlbums;
 | 
			
		||||
  final Set<AssetPathEntity> excludedBackupAlbums;
 | 
			
		||||
 | 
			
		||||
  /// Assets that are not overlapping in selected backup albums and excluded backup albums
 | 
			
		||||
  final Set<AssetEntity> allUniqueAssets;
 | 
			
		||||
 | 
			
		||||
  /// All assets from the selected albums that have been backup
 | 
			
		||||
  final Set<String> selectedAlbumsBackupAssetsIds;
 | 
			
		||||
 | 
			
		||||
  const BackUpState({
 | 
			
		||||
    required this.backupProgress,
 | 
			
		||||
    required this.allAssetOnDatabase,
 | 
			
		||||
    required this.progressInPercentage,
 | 
			
		||||
    required this.cancelToken,
 | 
			
		||||
    required this.serverInfo,
 | 
			
		||||
    required this.availableAlbums,
 | 
			
		||||
    required this.selectedBackupAlbums,
 | 
			
		||||
    required this.excludedBackupAlbums,
 | 
			
		||||
    required this.allUniqueAssets,
 | 
			
		||||
    required this.selectedAlbumsBackupAssetsIds,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  BackUpState copyWith({
 | 
			
		||||
    BackUpProgressEnum? backupProgress,
 | 
			
		||||
    List<String>? allAssetOnDatabase,
 | 
			
		||||
    double? progressInPercentage,
 | 
			
		||||
    CancelToken? cancelToken,
 | 
			
		||||
    ServerInfo? serverInfo,
 | 
			
		||||
    List<AvailableAlbum>? availableAlbums,
 | 
			
		||||
    Set<AssetPathEntity>? selectedBackupAlbums,
 | 
			
		||||
    Set<AssetPathEntity>? excludedBackupAlbums,
 | 
			
		||||
    Set<AssetEntity>? allUniqueAssets,
 | 
			
		||||
    Set<String>? selectedAlbumsBackupAssetsIds,
 | 
			
		||||
  }) {
 | 
			
		||||
    return BackUpState(
 | 
			
		||||
      backupProgress: backupProgress ?? this.backupProgress,
 | 
			
		||||
      allAssetOnDatabase: allAssetOnDatabase ?? this.allAssetOnDatabase,
 | 
			
		||||
      progressInPercentage: progressInPercentage ?? this.progressInPercentage,
 | 
			
		||||
      cancelToken: cancelToken ?? this.cancelToken,
 | 
			
		||||
      serverInfo: serverInfo ?? this.serverInfo,
 | 
			
		||||
      availableAlbums: availableAlbums ?? this.availableAlbums,
 | 
			
		||||
      selectedBackupAlbums: selectedBackupAlbums ?? this.selectedBackupAlbums,
 | 
			
		||||
      excludedBackupAlbums: excludedBackupAlbums ?? this.excludedBackupAlbums,
 | 
			
		||||
      allUniqueAssets: allUniqueAssets ?? this.allUniqueAssets,
 | 
			
		||||
      selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssetsIds ?? this.selectedAlbumsBackupAssetsIds,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'BackUpState(backupProgress: $backupProgress, allAssetOnDatabase: $allAssetOnDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds)';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  List<Object> get props {
 | 
			
		||||
    return [
 | 
			
		||||
      backupProgress,
 | 
			
		||||
      allAssetOnDatabase,
 | 
			
		||||
      progressInPercentage,
 | 
			
		||||
      cancelToken,
 | 
			
		||||
      serverInfo,
 | 
			
		||||
      availableAlbums,
 | 
			
		||||
      selectedBackupAlbums,
 | 
			
		||||
      excludedBackupAlbums,
 | 
			
		||||
      allUniqueAssets,
 | 
			
		||||
      selectedAlbumsBackupAssetsIds,
 | 
			
		||||
    ];
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -0,0 +1,66 @@
 | 
			
		||||
import 'dart:convert';
 | 
			
		||||
 | 
			
		||||
import 'package:collection/collection.dart';
 | 
			
		||||
import 'package:hive/hive.dart';
 | 
			
		||||
 | 
			
		||||
part 'hive_backup_albums.model.g.dart';
 | 
			
		||||
 | 
			
		||||
@HiveType(typeId: 1)
 | 
			
		||||
class HiveBackupAlbums {
 | 
			
		||||
  @HiveField(0)
 | 
			
		||||
  List<String> selectedAlbumIds;
 | 
			
		||||
 | 
			
		||||
  @HiveField(1)
 | 
			
		||||
  List<String> excludedAlbumsIds;
 | 
			
		||||
 | 
			
		||||
  HiveBackupAlbums({
 | 
			
		||||
    required this.selectedAlbumIds,
 | 
			
		||||
    required this.excludedAlbumsIds,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() => 'HiveBackupAlbums(selectedAlbumIds: $selectedAlbumIds, excludedAlbumsIds: $excludedAlbumsIds)';
 | 
			
		||||
 | 
			
		||||
  HiveBackupAlbums copyWith({
 | 
			
		||||
    List<String>? selectedAlbumIds,
 | 
			
		||||
    List<String>? excludedAlbumsIds,
 | 
			
		||||
  }) {
 | 
			
		||||
    return HiveBackupAlbums(
 | 
			
		||||
      selectedAlbumIds: selectedAlbumIds ?? this.selectedAlbumIds,
 | 
			
		||||
      excludedAlbumsIds: excludedAlbumsIds ?? this.excludedAlbumsIds,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  Map<String, dynamic> toMap() {
 | 
			
		||||
    final result = <String, dynamic>{};
 | 
			
		||||
 | 
			
		||||
    result.addAll({'selectedAlbumIds': selectedAlbumIds});
 | 
			
		||||
    result.addAll({'excludedAlbumsIds': excludedAlbumsIds});
 | 
			
		||||
 | 
			
		||||
    return result;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  factory HiveBackupAlbums.fromMap(Map<String, dynamic> map) {
 | 
			
		||||
    return HiveBackupAlbums(
 | 
			
		||||
      selectedAlbumIds: List<String>.from(map['selectedAlbumIds']),
 | 
			
		||||
      excludedAlbumsIds: List<String>.from(map['excludedAlbumsIds']),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  String toJson() => json.encode(toMap());
 | 
			
		||||
 | 
			
		||||
  factory HiveBackupAlbums.fromJson(String source) => HiveBackupAlbums.fromMap(json.decode(source));
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) {
 | 
			
		||||
    if (identical(this, other)) return true;
 | 
			
		||||
    final listEquals = const DeepCollectionEquality().equals;
 | 
			
		||||
 | 
			
		||||
    return other is HiveBackupAlbums &&
 | 
			
		||||
        listEquals(other.selectedAlbumIds, selectedAlbumIds) &&
 | 
			
		||||
        listEquals(other.excludedAlbumsIds, excludedAlbumsIds);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode => selectedAlbumIds.hashCode ^ excludedAlbumsIds.hashCode;
 | 
			
		||||
}
 | 
			
		||||
@ -0,0 +1,42 @@
 | 
			
		||||
// GENERATED CODE - DO NOT MODIFY BY HAND
 | 
			
		||||
 | 
			
		||||
part of 'hive_backup_albums.model.dart';
 | 
			
		||||
 | 
			
		||||
// **************************************************************************
 | 
			
		||||
// TypeAdapterGenerator
 | 
			
		||||
// **************************************************************************
 | 
			
		||||
 | 
			
		||||
class HiveBackupAlbumsAdapter extends TypeAdapter<HiveBackupAlbums> {
 | 
			
		||||
  @override
 | 
			
		||||
  final int typeId = 1;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  HiveBackupAlbums read(BinaryReader reader) {
 | 
			
		||||
    final numOfFields = reader.readByte();
 | 
			
		||||
    final fields = <int, dynamic>{
 | 
			
		||||
      for (int i = 0; i < numOfFields; i++) reader.readByte(): reader.read(),
 | 
			
		||||
    };
 | 
			
		||||
    return HiveBackupAlbums(
 | 
			
		||||
      selectedAlbumIds: (fields[0] as List).cast<String>(),
 | 
			
		||||
      excludedAlbumsIds: (fields[1] as List).cast<String>(),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  void write(BinaryWriter writer, HiveBackupAlbums obj) {
 | 
			
		||||
    writer
 | 
			
		||||
      ..writeByte(2)
 | 
			
		||||
      ..writeByte(0)
 | 
			
		||||
      ..write(obj.selectedAlbumIds)
 | 
			
		||||
      ..writeByte(1)
 | 
			
		||||
      ..write(obj.excludedAlbumsIds);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode => typeId.hashCode;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) =>
 | 
			
		||||
      identical(this, other) ||
 | 
			
		||||
      other is HiveBackupAlbumsAdapter && runtimeType == other.runtimeType && typeId == other.typeId;
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										347
									
								
								mobile/lib/modules/backup/providers/backup.provider.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,347 @@
 | 
			
		||||
import 'package:dio/dio.dart';
 | 
			
		||||
import 'package:flutter/foundation.dart';
 | 
			
		||||
import 'package:hive_flutter/hive_flutter.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/constants/hive_box.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/services/server_info.service.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/server_info.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
 | 
			
		||||
import 'package:photo_manager/photo_manager.dart';
 | 
			
		||||
 | 
			
		||||
class BackupNotifier extends StateNotifier<BackUpState> {
 | 
			
		||||
  BackupNotifier({this.ref})
 | 
			
		||||
      : super(
 | 
			
		||||
          BackUpState(
 | 
			
		||||
            backupProgress: BackUpProgressEnum.idle,
 | 
			
		||||
            allAssetOnDatabase: const [],
 | 
			
		||||
            progressInPercentage: 0,
 | 
			
		||||
            cancelToken: CancelToken(),
 | 
			
		||||
            serverInfo: ServerInfo(
 | 
			
		||||
              diskAvailable: "0",
 | 
			
		||||
              diskAvailableRaw: 0,
 | 
			
		||||
              diskSize: "0",
 | 
			
		||||
              diskSizeRaw: 0,
 | 
			
		||||
              diskUsagePercentage: 0.0,
 | 
			
		||||
              diskUse: "0",
 | 
			
		||||
              diskUseRaw: 0,
 | 
			
		||||
            ),
 | 
			
		||||
            availableAlbums: const [],
 | 
			
		||||
            selectedBackupAlbums: const {},
 | 
			
		||||
            excludedBackupAlbums: const {},
 | 
			
		||||
            allUniqueAssets: const {},
 | 
			
		||||
            selectedAlbumsBackupAssetsIds: const {},
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
  Ref? ref;
 | 
			
		||||
  final BackupService _backupService = BackupService();
 | 
			
		||||
  final ServerInfoService _serverInfoService = ServerInfoService();
 | 
			
		||||
 | 
			
		||||
  ///
 | 
			
		||||
  /// UI INTERACTION
 | 
			
		||||
  ///
 | 
			
		||||
  /// Album selection
 | 
			
		||||
  /// Due to the overlapping assets across multiple albums on the device
 | 
			
		||||
  /// We have method to include and exclude albums
 | 
			
		||||
  /// The total unique assets will be used for backing mechanism
 | 
			
		||||
  ///
 | 
			
		||||
  void addAlbumForBackup(AssetPathEntity album) {
 | 
			
		||||
    if (state.excludedBackupAlbums.contains(album)) {
 | 
			
		||||
      removeExcludedAlbumForBackup(album);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    state = state.copyWith(selectedBackupAlbums: {...state.selectedBackupAlbums, album});
 | 
			
		||||
    _updateBackupAssetCount();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void addExcludedAlbumForBackup(AssetPathEntity album) {
 | 
			
		||||
    if (state.selectedBackupAlbums.contains(album)) {
 | 
			
		||||
      removeAlbumForBackup(album);
 | 
			
		||||
    }
 | 
			
		||||
    state = state.copyWith(excludedBackupAlbums: {...state.excludedBackupAlbums, album});
 | 
			
		||||
    _updateBackupAssetCount();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void removeAlbumForBackup(AssetPathEntity album) {
 | 
			
		||||
    Set<AssetPathEntity> currentSelectedAlbums = state.selectedBackupAlbums;
 | 
			
		||||
 | 
			
		||||
    currentSelectedAlbums.removeWhere((a) => a == album);
 | 
			
		||||
 | 
			
		||||
    state = state.copyWith(selectedBackupAlbums: currentSelectedAlbums);
 | 
			
		||||
    _updateBackupAssetCount();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void removeExcludedAlbumForBackup(AssetPathEntity album) {
 | 
			
		||||
    Set<AssetPathEntity> currentExcludedAlbums = state.excludedBackupAlbums;
 | 
			
		||||
 | 
			
		||||
    currentExcludedAlbums.removeWhere((a) => a == album);
 | 
			
		||||
 | 
			
		||||
    state = state.copyWith(excludedBackupAlbums: currentExcludedAlbums);
 | 
			
		||||
    _updateBackupAssetCount();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ///
 | 
			
		||||
  /// Get all album on the device
 | 
			
		||||
  /// Get all selected and excluded album from the user's persistent storage
 | 
			
		||||
  /// If this is the first time performing backup - set the default selected album to be
 | 
			
		||||
  /// the one that has all assets (Recent on Android, Recents on iOS)
 | 
			
		||||
  ///
 | 
			
		||||
  Future<void> getBackupAlbumsInfo() async {
 | 
			
		||||
    // Get all albums on the device
 | 
			
		||||
    List<AvailableAlbum> availableAlbums = [];
 | 
			
		||||
    List<AssetPathEntity> albums = await PhotoManager.getAssetPathList(hasAll: true, type: RequestType.common);
 | 
			
		||||
 | 
			
		||||
    for (AssetPathEntity album in albums) {
 | 
			
		||||
      AvailableAlbum availableAlbum = AvailableAlbum(albumEntity: album);
 | 
			
		||||
 | 
			
		||||
      var assetList = await album.getAssetListRange(start: 0, end: album.assetCount);
 | 
			
		||||
 | 
			
		||||
      if (assetList.isNotEmpty) {
 | 
			
		||||
        var thumbnailAsset = assetList.first;
 | 
			
		||||
        var thumbnailData = await thumbnailAsset.thumbnailDataWithSize(const ThumbnailSize(512, 512));
 | 
			
		||||
        availableAlbum = availableAlbum.copyWith(thumbnailData: thumbnailData);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      availableAlbums.add(availableAlbum);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    state = state.copyWith(availableAlbums: availableAlbums);
 | 
			
		||||
 | 
			
		||||
    // Put persistent storage info into local state of the app
 | 
			
		||||
    // Get local storage on selected backup album
 | 
			
		||||
    Box<HiveBackupAlbums> backupAlbumInfoBox = Hive.box<HiveBackupAlbums>(hiveBackupInfoBox);
 | 
			
		||||
    HiveBackupAlbums? backupAlbumInfo = backupAlbumInfoBox.get(
 | 
			
		||||
      backupInfoKey,
 | 
			
		||||
      defaultValue: HiveBackupAlbums(
 | 
			
		||||
        selectedAlbumIds: [],
 | 
			
		||||
        excludedAlbumsIds: [],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
 | 
			
		||||
    if (backupAlbumInfo == null) {
 | 
			
		||||
      debugPrint("[ERROR] getting Hive backup album infomation");
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // First time backup - set isAll album is the default one for backup.
 | 
			
		||||
    if (backupAlbumInfo.selectedAlbumIds.isEmpty) {
 | 
			
		||||
      debugPrint("First time backup setup recent album as default");
 | 
			
		||||
 | 
			
		||||
      // Get album that contains all assets
 | 
			
		||||
      var list = await PhotoManager.getAssetPathList(hasAll: true, onlyAll: true, type: RequestType.common);
 | 
			
		||||
      AssetPathEntity albumHasAllAssets = list.first;
 | 
			
		||||
 | 
			
		||||
      backupAlbumInfoBox.put(
 | 
			
		||||
        backupInfoKey,
 | 
			
		||||
        HiveBackupAlbums(
 | 
			
		||||
          selectedAlbumIds: [albumHasAllAssets.id],
 | 
			
		||||
          excludedAlbumsIds: [],
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
 | 
			
		||||
      backupAlbumInfo = backupAlbumInfoBox.get(backupInfoKey);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Generate AssetPathEntity from id to add to local state
 | 
			
		||||
    try {
 | 
			
		||||
      for (var selectedAlbumId in backupAlbumInfo!.selectedAlbumIds) {
 | 
			
		||||
        var albumAsset = await AssetPathEntity.fromId(selectedAlbumId);
 | 
			
		||||
        state = state.copyWith(selectedBackupAlbums: {...state.selectedBackupAlbums, albumAsset});
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      for (var excludedAlbumId in backupAlbumInfo.excludedAlbumsIds) {
 | 
			
		||||
        var albumAsset = await AssetPathEntity.fromId(excludedAlbumId);
 | 
			
		||||
        state = state.copyWith(excludedBackupAlbums: {...state.excludedBackupAlbums, albumAsset});
 | 
			
		||||
      }
 | 
			
		||||
    } catch (e) {
 | 
			
		||||
      debugPrint("[ERROR] Failed to generate album from id $e");
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ///
 | 
			
		||||
  /// From all the selected and albums assets
 | 
			
		||||
  /// Find the assets that are not overlapping between the two sets
 | 
			
		||||
  /// Those assets are unique and are used as the total assets
 | 
			
		||||
  ///
 | 
			
		||||
  void _updateBackupAssetCount() async {
 | 
			
		||||
    Set<AssetEntity> assetsFromSelectedAlbums = {};
 | 
			
		||||
    Set<AssetEntity> assetsFromExcludedAlbums = {};
 | 
			
		||||
 | 
			
		||||
    for (var album in state.selectedBackupAlbums) {
 | 
			
		||||
      var assets = await album.getAssetListRange(start: 0, end: album.assetCount);
 | 
			
		||||
      assetsFromSelectedAlbums.addAll(assets);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    for (var album in state.excludedBackupAlbums) {
 | 
			
		||||
      var assets = await album.getAssetListRange(start: 0, end: album.assetCount);
 | 
			
		||||
      assetsFromExcludedAlbums.addAll(assets);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Set<AssetEntity> allUniqueAssets = assetsFromSelectedAlbums.difference(assetsFromExcludedAlbums);
 | 
			
		||||
    List<String> allAssetOnDatabase = await _backupService.getDeviceBackupAsset();
 | 
			
		||||
 | 
			
		||||
    // Find asset that were backup from selected albums
 | 
			
		||||
    Set<String> selectedAlbumsBackupAssets = Set.from(allUniqueAssets.map((e) => e.id));
 | 
			
		||||
    selectedAlbumsBackupAssets.removeWhere((assetId) => !allAssetOnDatabase.contains(assetId));
 | 
			
		||||
 | 
			
		||||
    if (allUniqueAssets.isEmpty) {
 | 
			
		||||
      debugPrint("No Asset On Device");
 | 
			
		||||
      state = state.copyWith(
 | 
			
		||||
        backupProgress: BackUpProgressEnum.idle,
 | 
			
		||||
        allAssetOnDatabase: allAssetOnDatabase,
 | 
			
		||||
        allUniqueAssets: {},
 | 
			
		||||
        selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
 | 
			
		||||
      );
 | 
			
		||||
      return;
 | 
			
		||||
    } else {
 | 
			
		||||
      state = state.copyWith(
 | 
			
		||||
        allAssetOnDatabase: allAssetOnDatabase,
 | 
			
		||||
        allUniqueAssets: allUniqueAssets,
 | 
			
		||||
        selectedAlbumsBackupAssetsIds: selectedAlbumsBackupAssets,
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    // Save to persistent storage
 | 
			
		||||
    _updatePersistentAlbumsSelection();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ///
 | 
			
		||||
  /// Get all necessary information for calculating the available albums,
 | 
			
		||||
  /// which albums are selected or excluded
 | 
			
		||||
  /// and then update the UI according to those information
 | 
			
		||||
  ///
 | 
			
		||||
  void getBackupInfo() async {
 | 
			
		||||
    await getBackupAlbumsInfo();
 | 
			
		||||
    _updateServerInfo();
 | 
			
		||||
    _updateBackupAssetCount();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ///
 | 
			
		||||
  /// Save user selection of selected albums and excluded albums to
 | 
			
		||||
  /// Hive database
 | 
			
		||||
  ///
 | 
			
		||||
  void _updatePersistentAlbumsSelection() {
 | 
			
		||||
    Box<HiveBackupAlbums> backupAlbumInfoBox = Hive.box<HiveBackupAlbums>(hiveBackupInfoBox);
 | 
			
		||||
    backupAlbumInfoBox.put(
 | 
			
		||||
      backupInfoKey,
 | 
			
		||||
      HiveBackupAlbums(
 | 
			
		||||
        selectedAlbumIds: state.selectedBackupAlbums.map((e) => e.id).toList(),
 | 
			
		||||
        excludedAlbumsIds: state.excludedBackupAlbums.map((e) => e.id).toList(),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  ///
 | 
			
		||||
  /// Invoke backup process
 | 
			
		||||
  ///
 | 
			
		||||
  void startBackupProcess() async {
 | 
			
		||||
    _updateServerInfo();
 | 
			
		||||
    _updateBackupAssetCount();
 | 
			
		||||
 | 
			
		||||
    state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);
 | 
			
		||||
 | 
			
		||||
    var authResult = await PhotoManager.requestPermissionExtend();
 | 
			
		||||
    if (authResult.isAuth) {
 | 
			
		||||
      await PhotoManager.clearFileCache();
 | 
			
		||||
 | 
			
		||||
      if (state.allUniqueAssets.isEmpty) {
 | 
			
		||||
        debugPrint("No Asset On Device - Abort Backup Process");
 | 
			
		||||
        state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      Set<AssetEntity> assetsWillBeBackup = state.allUniqueAssets;
 | 
			
		||||
 | 
			
		||||
      // Remove item that has already been backed up
 | 
			
		||||
      for (var assetId in state.allAssetOnDatabase) {
 | 
			
		||||
        assetsWillBeBackup.removeWhere((e) => e.id == assetId);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (assetsWillBeBackup.isEmpty) {
 | 
			
		||||
        state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Perform Backup
 | 
			
		||||
      state = state.copyWith(cancelToken: CancelToken());
 | 
			
		||||
      _backupService.backupAsset(assetsWillBeBackup, state.cancelToken, _onAssetUploaded, _onUploadProgress);
 | 
			
		||||
    } else {
 | 
			
		||||
      PhotoManager.openSetting();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void cancelBackup() {
 | 
			
		||||
    state.cancelToken.cancel('Cancel Backup');
 | 
			
		||||
    state = state.copyWith(backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _onAssetUploaded(String deviceAssetId, String deviceId) {
 | 
			
		||||
    state = state.copyWith(
 | 
			
		||||
        selectedAlbumsBackupAssetsIds: {...state.selectedAlbumsBackupAssetsIds, deviceAssetId},
 | 
			
		||||
        allAssetOnDatabase: [...state.allAssetOnDatabase, deviceAssetId]);
 | 
			
		||||
 | 
			
		||||
    if (state.allUniqueAssets.length - state.selectedAlbumsBackupAssetsIds.length == 0) {
 | 
			
		||||
      state = state.copyWith(backupProgress: BackUpProgressEnum.done, progressInPercentage: 0.0);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _updateServerInfo();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _onUploadProgress(int sent, int total) {
 | 
			
		||||
    state = state.copyWith(progressInPercentage: (sent.toDouble() / total.toDouble() * 100));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _updateServerInfo() async {
 | 
			
		||||
    var serverInfo = await _serverInfoService.getServerInfo();
 | 
			
		||||
 | 
			
		||||
    // Update server info
 | 
			
		||||
    state = state.copyWith(
 | 
			
		||||
      serverInfo: ServerInfo(
 | 
			
		||||
        diskSize: serverInfo.diskSize,
 | 
			
		||||
        diskUse: serverInfo.diskUse,
 | 
			
		||||
        diskAvailable: serverInfo.diskAvailable,
 | 
			
		||||
        diskSizeRaw: serverInfo.diskSizeRaw,
 | 
			
		||||
        diskUseRaw: serverInfo.diskUseRaw,
 | 
			
		||||
        diskAvailableRaw: serverInfo.diskAvailableRaw,
 | 
			
		||||
        diskUsagePercentage: serverInfo.diskUsagePercentage,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void resumeBackup() {
 | 
			
		||||
    var authState = ref?.read(authenticationProvider);
 | 
			
		||||
 | 
			
		||||
    // Check if user is login
 | 
			
		||||
    var accessKey = Hive.box(userInfoBox).get(accessTokenKey);
 | 
			
		||||
 | 
			
		||||
    // User has been logged out return
 | 
			
		||||
    if (authState != null) {
 | 
			
		||||
      if (accessKey == null || !authState.isAuthenticated) {
 | 
			
		||||
        debugPrint("[resumeBackup] not authenticated - abort");
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Check if this device is enable backup by the user
 | 
			
		||||
      if ((authState.deviceInfo.deviceId == authState.deviceId) && authState.deviceInfo.isAutoBackup) {
 | 
			
		||||
        // check if backup is alreayd in process - then return
 | 
			
		||||
        if (state.backupProgress == BackUpProgressEnum.inProgress) {
 | 
			
		||||
          debugPrint("[resumeBackup] Backup is already in progress - abort");
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Run backup
 | 
			
		||||
        debugPrint("[resumeBackup] Start back up");
 | 
			
		||||
        startBackupProcess();
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
final backupProvider = StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
 | 
			
		||||
  return BackupNotifier(ref: ref);
 | 
			
		||||
});
 | 
			
		||||
@ -26,7 +26,7 @@ class BackupService {
 | 
			
		||||
    return result.cast<String>();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  backupAsset(List<AssetEntity> assetList, CancelToken cancelToken, Function(String, String) singleAssetDoneCb,
 | 
			
		||||
  backupAsset(Set<AssetEntity> assetList, CancelToken cancelToken, Function(String, String) singleAssetDoneCb,
 | 
			
		||||
      Function(int, int) uploadProgress) async {
 | 
			
		||||
    var dio = Dio();
 | 
			
		||||
    dio.interceptors.add(AuthenticatedRequestInterceptor());
 | 
			
		||||
							
								
								
									
										185
									
								
								mobile/lib/modules/backup/ui/album_info_card.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,185 @@
 | 
			
		||||
import 'dart:typed_data';
 | 
			
		||||
 | 
			
		||||
import 'package:auto_route/auto_route.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter/services.dart';
 | 
			
		||||
import 'package:fluttertoast/fluttertoast.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/routing/router.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
 | 
			
		||||
import 'package:photo_manager/photo_manager.dart';
 | 
			
		||||
 | 
			
		||||
class AlbumInfoCard extends HookConsumerWidget {
 | 
			
		||||
  final Uint8List? imageData;
 | 
			
		||||
  final AssetPathEntity albumInfo;
 | 
			
		||||
 | 
			
		||||
  const AlbumInfoCard({Key? key, this.imageData, required this.albumInfo}) : super(key: key);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    final bool isSelected = ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo);
 | 
			
		||||
    final bool isExcluded = ref.watch(backupProvider).excludedBackupAlbums.contains(albumInfo);
 | 
			
		||||
 | 
			
		||||
    ColorFilter selectedFilter = ColorFilter.mode(Theme.of(context).primaryColor.withAlpha(100), BlendMode.darken);
 | 
			
		||||
    ColorFilter excludedFilter = ColorFilter.mode(Colors.red.withAlpha(75), BlendMode.darken);
 | 
			
		||||
    ColorFilter unselectedFilter = const ColorFilter.mode(Colors.black, BlendMode.color);
 | 
			
		||||
 | 
			
		||||
    _buildSelectedTextBox() {
 | 
			
		||||
      if (isSelected) {
 | 
			
		||||
        return Chip(
 | 
			
		||||
          visualDensity: VisualDensity.compact,
 | 
			
		||||
          shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
 | 
			
		||||
          label: const Text(
 | 
			
		||||
            "INCLUDED",
 | 
			
		||||
            style: TextStyle(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold),
 | 
			
		||||
          ),
 | 
			
		||||
          backgroundColor: Theme.of(context).primaryColor,
 | 
			
		||||
        );
 | 
			
		||||
      } else if (isExcluded) {
 | 
			
		||||
        return Chip(
 | 
			
		||||
          visualDensity: VisualDensity.compact,
 | 
			
		||||
          shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
 | 
			
		||||
          label: const Text(
 | 
			
		||||
            "EXCLUDED",
 | 
			
		||||
            style: TextStyle(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold),
 | 
			
		||||
          ),
 | 
			
		||||
          backgroundColor: Colors.red[300],
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return Container();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _buildImageFilter() {
 | 
			
		||||
      if (isSelected) {
 | 
			
		||||
        return selectedFilter;
 | 
			
		||||
      } else if (isExcluded) {
 | 
			
		||||
        return excludedFilter;
 | 
			
		||||
      } else {
 | 
			
		||||
        return unselectedFilter;
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return GestureDetector(
 | 
			
		||||
      onTap: () {
 | 
			
		||||
        HapticFeedback.selectionClick();
 | 
			
		||||
 | 
			
		||||
        if (isSelected) {
 | 
			
		||||
          if (ref.watch(backupProvider).selectedBackupAlbums.length == 1) {
 | 
			
		||||
            ImmichToast.show(
 | 
			
		||||
              context: context,
 | 
			
		||||
              msg: "Cannot remove the only album",
 | 
			
		||||
              toastType: ToastType.error,
 | 
			
		||||
              gravity: ToastGravity.BOTTOM,
 | 
			
		||||
            );
 | 
			
		||||
            return;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          ref.watch(backupProvider.notifier).removeAlbumForBackup(albumInfo);
 | 
			
		||||
        } else {
 | 
			
		||||
          ref.watch(backupProvider.notifier).addAlbumForBackup(albumInfo);
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      onDoubleTap: () {
 | 
			
		||||
        HapticFeedback.selectionClick();
 | 
			
		||||
 | 
			
		||||
        if (isExcluded) {
 | 
			
		||||
          ref.watch(backupProvider.notifier).removeExcludedAlbumForBackup(albumInfo);
 | 
			
		||||
        } else {
 | 
			
		||||
          if (ref.watch(backupProvider).selectedBackupAlbums.length == 1 &&
 | 
			
		||||
              ref.watch(backupProvider).selectedBackupAlbums.contains(albumInfo)) {
 | 
			
		||||
            ImmichToast.show(
 | 
			
		||||
              context: context,
 | 
			
		||||
              msg: "Cannot exclude the only album",
 | 
			
		||||
              toastType: ToastType.error,
 | 
			
		||||
              gravity: ToastGravity.BOTTOM,
 | 
			
		||||
            );
 | 
			
		||||
            return;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          ref.watch(backupProvider.notifier).addExcludedAlbumForBackup(albumInfo);
 | 
			
		||||
        }
 | 
			
		||||
      },
 | 
			
		||||
      child: Card(
 | 
			
		||||
        margin: const EdgeInsets.all(1),
 | 
			
		||||
        shape: RoundedRectangleBorder(
 | 
			
		||||
          borderRadius: BorderRadius.circular(12), // if you need this
 | 
			
		||||
          side: const BorderSide(
 | 
			
		||||
            color: Color(0xFFC9C9C9),
 | 
			
		||||
            width: 1,
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
        elevation: 0,
 | 
			
		||||
        borderOnForeground: false,
 | 
			
		||||
        child: Column(
 | 
			
		||||
          crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
          children: [
 | 
			
		||||
            Stack(
 | 
			
		||||
              children: [
 | 
			
		||||
                Container(
 | 
			
		||||
                  width: 200,
 | 
			
		||||
                  height: 200,
 | 
			
		||||
                  decoration: BoxDecoration(
 | 
			
		||||
                    borderRadius: const BorderRadius.only(topLeft: Radius.circular(12), topRight: Radius.circular(12)),
 | 
			
		||||
                    image: DecorationImage(
 | 
			
		||||
                      colorFilter: _buildImageFilter(),
 | 
			
		||||
                      image: imageData != null
 | 
			
		||||
                          ? MemoryImage(imageData!)
 | 
			
		||||
                          : const AssetImage('assets/immich-logo-no-outline.png') as ImageProvider,
 | 
			
		||||
                      fit: BoxFit.cover,
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
                  child: null,
 | 
			
		||||
                ),
 | 
			
		||||
                Positioned(bottom: 10, left: 25, child: _buildSelectedTextBox())
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
            Padding(
 | 
			
		||||
              padding: const EdgeInsets.only(top: 8.0),
 | 
			
		||||
              child: Row(
 | 
			
		||||
                crossAxisAlignment: CrossAxisAlignment.center,
 | 
			
		||||
                children: [
 | 
			
		||||
                  SizedBox(
 | 
			
		||||
                    width: 140,
 | 
			
		||||
                    child: Padding(
 | 
			
		||||
                      padding: const EdgeInsets.only(left: 25.0),
 | 
			
		||||
                      child: Column(
 | 
			
		||||
                        crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
                        children: [
 | 
			
		||||
                          Text(
 | 
			
		||||
                            albumInfo.name,
 | 
			
		||||
                            style: TextStyle(
 | 
			
		||||
                                fontSize: 14, color: Theme.of(context).primaryColor, fontWeight: FontWeight.bold),
 | 
			
		||||
                          ),
 | 
			
		||||
                          Padding(
 | 
			
		||||
                            padding: const EdgeInsets.only(top: 2.0),
 | 
			
		||||
                            child: Text(
 | 
			
		||||
                              albumInfo.assetCount.toString() + (albumInfo.isAll ? " (ALL)" : ""),
 | 
			
		||||
                              style: TextStyle(fontSize: 12, color: Colors.grey[600]),
 | 
			
		||||
                            ),
 | 
			
		||||
                          )
 | 
			
		||||
                        ],
 | 
			
		||||
                      ),
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
                  IconButton(
 | 
			
		||||
                    onPressed: () {
 | 
			
		||||
                      AutoRouter.of(context).push(AlbumPreviewRoute(album: albumInfo));
 | 
			
		||||
                    },
 | 
			
		||||
                    icon: Icon(
 | 
			
		||||
                      Icons.image_outlined,
 | 
			
		||||
                      color: Theme.of(context).primaryColor,
 | 
			
		||||
                      size: 24,
 | 
			
		||||
                    ),
 | 
			
		||||
                    splashRadius: 25,
 | 
			
		||||
                  ),
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										48
									
								
								mobile/lib/modules/backup/ui/backup_info_card.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,48 @@
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
 | 
			
		||||
class BackupInfoCard extends StatelessWidget {
 | 
			
		||||
  final String title;
 | 
			
		||||
  final String subtitle;
 | 
			
		||||
  final String info;
 | 
			
		||||
  const BackupInfoCard({Key? key, required this.title, required this.subtitle, required this.info}) : super(key: key);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Card(
 | 
			
		||||
      shape: RoundedRectangleBorder(
 | 
			
		||||
        borderRadius: BorderRadius.circular(5), // if you need this
 | 
			
		||||
        side: const BorderSide(
 | 
			
		||||
          color: Colors.black12,
 | 
			
		||||
          width: 1,
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      elevation: 0,
 | 
			
		||||
      borderOnForeground: false,
 | 
			
		||||
      child: ListTile(
 | 
			
		||||
        minVerticalPadding: 15,
 | 
			
		||||
        isThreeLine: true,
 | 
			
		||||
        title: Text(
 | 
			
		||||
          title,
 | 
			
		||||
          style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 20),
 | 
			
		||||
        ),
 | 
			
		||||
        subtitle: Padding(
 | 
			
		||||
          padding: const EdgeInsets.only(top: 8.0),
 | 
			
		||||
          child: Text(
 | 
			
		||||
            subtitle,
 | 
			
		||||
            style: const TextStyle(color: Color(0xFF808080), fontSize: 12),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
        trailing: Column(
 | 
			
		||||
          mainAxisAlignment: MainAxisAlignment.center,
 | 
			
		||||
          children: [
 | 
			
		||||
            Text(
 | 
			
		||||
              info,
 | 
			
		||||
              style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
 | 
			
		||||
            ),
 | 
			
		||||
            const Text("assets"),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										84
									
								
								mobile/lib/modules/backup/views/album_preview_page.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,84 @@
 | 
			
		||||
import 'dart:typed_data';
 | 
			
		||||
 | 
			
		||||
import 'package:auto_route/auto_route.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_hooks/flutter_hooks.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 | 
			
		||||
import 'package:photo_manager/photo_manager.dart';
 | 
			
		||||
 | 
			
		||||
class AlbumPreviewPage extends HookConsumerWidget {
 | 
			
		||||
  final AssetPathEntity album;
 | 
			
		||||
  const AlbumPreviewPage({Key? key, required this.album}) : super(key: key);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    final assets = useState<List<AssetEntity>>([]);
 | 
			
		||||
 | 
			
		||||
    _getAssetsInAlbum() async {
 | 
			
		||||
      assets.value = await album.getAssetListRange(start: 0, end: album.assetCount);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    useEffect(() {
 | 
			
		||||
      _getAssetsInAlbum();
 | 
			
		||||
      return null;
 | 
			
		||||
    }, []);
 | 
			
		||||
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        elevation: 0,
 | 
			
		||||
        title: Column(
 | 
			
		||||
          children: [
 | 
			
		||||
            Text(
 | 
			
		||||
              "${album.name} (${album.assetCount})",
 | 
			
		||||
              style: const TextStyle(fontSize: 14, fontWeight: FontWeight.bold),
 | 
			
		||||
            ),
 | 
			
		||||
            Padding(
 | 
			
		||||
              padding: const EdgeInsets.only(top: 4.0),
 | 
			
		||||
              child: Text(
 | 
			
		||||
                "ID ${album.id}",
 | 
			
		||||
                style: TextStyle(fontSize: 10, color: Colors.grey[600], fontWeight: FontWeight.bold),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
        leading: IconButton(
 | 
			
		||||
          onPressed: () => AutoRouter.of(context).pop(),
 | 
			
		||||
          icon: const Icon(Icons.arrow_back_ios_new_rounded),
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      body: GridView.builder(
 | 
			
		||||
        gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
 | 
			
		||||
          crossAxisCount: 5,
 | 
			
		||||
          crossAxisSpacing: 2,
 | 
			
		||||
          mainAxisSpacing: 2,
 | 
			
		||||
        ),
 | 
			
		||||
        itemCount: assets.value.length,
 | 
			
		||||
        itemBuilder: (context, index) {
 | 
			
		||||
          Future<Uint8List?> thumbData =
 | 
			
		||||
              assets.value[index].thumbnailDataWithSize(const ThumbnailSize(200, 200), quality: 50);
 | 
			
		||||
 | 
			
		||||
          return FutureBuilder<Uint8List?>(
 | 
			
		||||
            future: thumbData,
 | 
			
		||||
            builder: ((context, snapshot) {
 | 
			
		||||
              if (snapshot.hasData && snapshot.data != null) {
 | 
			
		||||
                return Image.memory(
 | 
			
		||||
                  snapshot.data!,
 | 
			
		||||
                  width: 100,
 | 
			
		||||
                  height: 100,
 | 
			
		||||
                  fit: BoxFit.cover,
 | 
			
		||||
                );
 | 
			
		||||
              }
 | 
			
		||||
 | 
			
		||||
              return const SizedBox(
 | 
			
		||||
                width: 100,
 | 
			
		||||
                height: 100,
 | 
			
		||||
                child: ImmichLoadingIndicator(),
 | 
			
		||||
              );
 | 
			
		||||
            }),
 | 
			
		||||
          );
 | 
			
		||||
        },
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
							
								
								
									
										244
									
								
								mobile/lib/modules/backup/views/backup_album_selection_page.dart
									
									
									
									
									
										Normal file
									
								
							
							
						
						@ -0,0 +1,244 @@
 | 
			
		||||
import 'package:auto_route/auto_route.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_hooks/flutter_hooks.dart';
 | 
			
		||||
import 'package:fluttertoast/fluttertoast.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/ui/album_info_card.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/ui/immich_loading_indicator.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
 | 
			
		||||
 | 
			
		||||
class BackupAlbumSelectionPage extends HookConsumerWidget {
 | 
			
		||||
  const BackupAlbumSelectionPage({Key? key}) : super(key: key);
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    final availableAlbums = ref.watch(backupProvider).availableAlbums;
 | 
			
		||||
    final selectedBackupAlbums = ref.watch(backupProvider).selectedBackupAlbums;
 | 
			
		||||
    final excludedBackupAlbums = ref.watch(backupProvider).excludedBackupAlbums;
 | 
			
		||||
 | 
			
		||||
    useEffect(() {
 | 
			
		||||
      ref.read(backupProvider.notifier).getBackupAlbumsInfo();
 | 
			
		||||
      return null;
 | 
			
		||||
    }, []);
 | 
			
		||||
 | 
			
		||||
    _buildAlbumSelectionList() {
 | 
			
		||||
      if (availableAlbums.isEmpty) {
 | 
			
		||||
        return const Center(
 | 
			
		||||
          child: ImmichLoadingIndicator(),
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return SizedBox(
 | 
			
		||||
        height: 265,
 | 
			
		||||
        child: ListView.builder(
 | 
			
		||||
          scrollDirection: Axis.horizontal,
 | 
			
		||||
          itemCount: availableAlbums.length,
 | 
			
		||||
          physics: const BouncingScrollPhysics(),
 | 
			
		||||
          itemBuilder: ((context, index) {
 | 
			
		||||
            var thumbnailData = availableAlbums[index].thumbnailData;
 | 
			
		||||
            return Padding(
 | 
			
		||||
              padding: index == 0 ? const EdgeInsets.only(left: 16.00) : const EdgeInsets.all(0),
 | 
			
		||||
              child: AlbumInfoCard(imageData: thumbnailData, albumInfo: availableAlbums[index].albumEntity),
 | 
			
		||||
            );
 | 
			
		||||
          }),
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _buildSelectedAlbumNameChip() {
 | 
			
		||||
      return selectedBackupAlbums.map((album) {
 | 
			
		||||
        void removeSelection() {
 | 
			
		||||
          if (ref.watch(backupProvider).selectedBackupAlbums.length == 1) {
 | 
			
		||||
            ImmichToast.show(
 | 
			
		||||
              context: context,
 | 
			
		||||
              msg: "Cannot remove the only album",
 | 
			
		||||
              toastType: ToastType.error,
 | 
			
		||||
              gravity: ToastGravity.BOTTOM,
 | 
			
		||||
            );
 | 
			
		||||
            return;
 | 
			
		||||
          }
 | 
			
		||||
 | 
			
		||||
          ref.watch(backupProvider.notifier).removeAlbumForBackup(album);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return Padding(
 | 
			
		||||
          padding: const EdgeInsets.only(right: 8.0),
 | 
			
		||||
          child: GestureDetector(
 | 
			
		||||
            onTap: removeSelection,
 | 
			
		||||
            child: Chip(
 | 
			
		||||
              visualDensity: VisualDensity.compact,
 | 
			
		||||
              shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
 | 
			
		||||
              label: Text(
 | 
			
		||||
                album.name,
 | 
			
		||||
                style: const TextStyle(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold),
 | 
			
		||||
              ),
 | 
			
		||||
              backgroundColor: Theme.of(context).primaryColor,
 | 
			
		||||
              deleteIconColor: Colors.white,
 | 
			
		||||
              deleteIcon: const Icon(
 | 
			
		||||
                Icons.cancel_rounded,
 | 
			
		||||
                size: 15,
 | 
			
		||||
              ),
 | 
			
		||||
              onDeleted: removeSelection,
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
      }).toSet();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _buildExcludedAlbumNameChip() {
 | 
			
		||||
      return excludedBackupAlbums.map((album) {
 | 
			
		||||
        void removeSelection() {
 | 
			
		||||
          ref.watch(backupProvider.notifier).removeExcludedAlbumForBackup(album);
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return GestureDetector(
 | 
			
		||||
          onTap: removeSelection,
 | 
			
		||||
          child: Padding(
 | 
			
		||||
            padding: const EdgeInsets.only(right: 8.0),
 | 
			
		||||
            child: Chip(
 | 
			
		||||
              visualDensity: VisualDensity.compact,
 | 
			
		||||
              shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
 | 
			
		||||
              label: Text(
 | 
			
		||||
                album.name,
 | 
			
		||||
                style: const TextStyle(fontSize: 10, color: Colors.white, fontWeight: FontWeight.bold),
 | 
			
		||||
              ),
 | 
			
		||||
              backgroundColor: Colors.red[300],
 | 
			
		||||
              deleteIconColor: Colors.white,
 | 
			
		||||
              deleteIcon: const Icon(
 | 
			
		||||
                Icons.cancel_rounded,
 | 
			
		||||
                size: 15,
 | 
			
		||||
              ),
 | 
			
		||||
              onDeleted: removeSelection,
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
      }).toSet();
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        leading: IconButton(
 | 
			
		||||
          onPressed: () => AutoRouter.of(context).pop(),
 | 
			
		||||
          icon: const Icon(Icons.arrow_back_ios_rounded),
 | 
			
		||||
        ),
 | 
			
		||||
        title: const Text(
 | 
			
		||||
          "Select Albums",
 | 
			
		||||
          style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
 | 
			
		||||
        ),
 | 
			
		||||
        elevation: 0,
 | 
			
		||||
      ),
 | 
			
		||||
      body: ListView(
 | 
			
		||||
        physics: const ClampingScrollPhysics(),
 | 
			
		||||
        children: [
 | 
			
		||||
          const Padding(
 | 
			
		||||
            padding: EdgeInsets.symmetric(vertical: 8.0, horizontal: 16.0),
 | 
			
		||||
            child: Text(
 | 
			
		||||
              "Selection Info",
 | 
			
		||||
              style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          // Selected Album Chips
 | 
			
		||||
 | 
			
		||||
          Padding(
 | 
			
		||||
            padding: const EdgeInsets.symmetric(horizontal: 16.0),
 | 
			
		||||
            child: Wrap(
 | 
			
		||||
              children: [..._buildSelectedAlbumNameChip(), ..._buildExcludedAlbumNameChip()],
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
 | 
			
		||||
          Padding(
 | 
			
		||||
            padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8),
 | 
			
		||||
            child: Card(
 | 
			
		||||
              margin: const EdgeInsets.all(0),
 | 
			
		||||
              shape: RoundedRectangleBorder(
 | 
			
		||||
                borderRadius: BorderRadius.circular(5), // if you need this
 | 
			
		||||
                side: const BorderSide(
 | 
			
		||||
                  color: Color.fromARGB(255, 235, 235, 235),
 | 
			
		||||
                  width: 1,
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
              elevation: 0,
 | 
			
		||||
              borderOnForeground: false,
 | 
			
		||||
              child: Column(
 | 
			
		||||
                children: [
 | 
			
		||||
                  ListTile(
 | 
			
		||||
                    visualDensity: VisualDensity.compact,
 | 
			
		||||
                    title: Text(
 | 
			
		||||
                      "Total unique assets",
 | 
			
		||||
                      style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14, color: Colors.grey[700]),
 | 
			
		||||
                    ),
 | 
			
		||||
                    trailing: Text(
 | 
			
		||||
                      ref.watch(backupProvider).allUniqueAssets.length.toString(),
 | 
			
		||||
                      style: const TextStyle(fontWeight: FontWeight.bold),
 | 
			
		||||
                    ),
 | 
			
		||||
                  ),
 | 
			
		||||
                ],
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
 | 
			
		||||
          ListTile(
 | 
			
		||||
            title: Text(
 | 
			
		||||
              "Albums on device (${availableAlbums.length.toString()})",
 | 
			
		||||
              style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
 | 
			
		||||
            ),
 | 
			
		||||
            subtitle: Padding(
 | 
			
		||||
              padding: const EdgeInsets.symmetric(vertical: 8.0),
 | 
			
		||||
              child: Text(
 | 
			
		||||
                "Tap to include, double tap to exclude",
 | 
			
		||||
                style: TextStyle(
 | 
			
		||||
                  fontSize: 12,
 | 
			
		||||
                  color: Theme.of(context).primaryColor,
 | 
			
		||||
                  fontWeight: FontWeight.bold,
 | 
			
		||||
                ),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
            trailing: IconButton(
 | 
			
		||||
              splashRadius: 16,
 | 
			
		||||
              icon: Icon(
 | 
			
		||||
                Icons.info,
 | 
			
		||||
                size: 20,
 | 
			
		||||
                color: Theme.of(context).primaryColor,
 | 
			
		||||
              ),
 | 
			
		||||
              onPressed: () {
 | 
			
		||||
                // show the dialog
 | 
			
		||||
                showDialog(
 | 
			
		||||
                  context: context,
 | 
			
		||||
                  builder: (BuildContext context) {
 | 
			
		||||
                    return AlertDialog(
 | 
			
		||||
                      shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
 | 
			
		||||
                      elevation: 5,
 | 
			
		||||
                      title: Text(
 | 
			
		||||
                        'Selection Info',
 | 
			
		||||
                        style: TextStyle(
 | 
			
		||||
                          fontSize: 16,
 | 
			
		||||
                          fontWeight: FontWeight.bold,
 | 
			
		||||
                          color: Theme.of(context).primaryColor,
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
                      content: SingleChildScrollView(
 | 
			
		||||
                        child: ListBody(
 | 
			
		||||
                          children: [
 | 
			
		||||
                            Text(
 | 
			
		||||
                              'Assets can scatter across multiple albums. Thus, albums can be included or excluded during the backup process.',
 | 
			
		||||
                              style: TextStyle(fontSize: 14, color: Colors.grey[700]),
 | 
			
		||||
                            ),
 | 
			
		||||
                          ],
 | 
			
		||||
                        ),
 | 
			
		||||
                      ),
 | 
			
		||||
                    );
 | 
			
		||||
                  },
 | 
			
		||||
                );
 | 
			
		||||
              },
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
 | 
			
		||||
          Padding(
 | 
			
		||||
            padding: const EdgeInsets.only(bottom: 16.0),
 | 
			
		||||
            child: _buildAlbumSelectionList(),
 | 
			
		||||
          ),
 | 
			
		||||
        ],
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -3,10 +3,12 @@ import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:flutter_hooks/flutter_hooks.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/backup_state.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/providers/backup.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/routing/router.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/ui/backup_info_card.dart';
 | 
			
		||||
import 'package:percent_indicator/linear_percent_indicator.dart';
 | 
			
		||||
 | 
			
		||||
class BackupControllerPage extends HookConsumerWidget {
 | 
			
		||||
@ -14,13 +16,13 @@ class BackupControllerPage extends HookConsumerWidget {
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context, WidgetRef ref) {
 | 
			
		||||
    BackUpState _backupState = ref.watch(backupProvider);
 | 
			
		||||
    BackUpState backupState = ref.watch(backupProvider);
 | 
			
		||||
    AuthenticationState _authenticationState = ref.watch(authenticationProvider);
 | 
			
		||||
 | 
			
		||||
    bool shouldBackup = _backupState.totalAssetCount - _backupState.assetOnDatabase == 0 ? false : true;
 | 
			
		||||
    bool shouldBackup =
 | 
			
		||||
        backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length == 0 ? false : true;
 | 
			
		||||
 | 
			
		||||
    useEffect(() {
 | 
			
		||||
      if (_backupState.backupProgress != BackUpProgressEnum.inProgress) {
 | 
			
		||||
      if (backupState.backupProgress != BackUpProgressEnum.inProgress) {
 | 
			
		||||
        ref.read(backupProvider.notifier).getBackupInfo();
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
@ -46,13 +48,13 @@ class BackupControllerPage extends HookConsumerWidget {
 | 
			
		||||
              LinearPercentIndicator(
 | 
			
		||||
                padding: const EdgeInsets.only(top: 8.0),
 | 
			
		||||
                lineHeight: 5.0,
 | 
			
		||||
                percent: _backupState.serverInfo.diskUsagePercentage / 100.0,
 | 
			
		||||
                percent: backupState.serverInfo.diskUsagePercentage / 100.0,
 | 
			
		||||
                backgroundColor: Colors.grey,
 | 
			
		||||
                progressColor: Theme.of(context).primaryColor,
 | 
			
		||||
              ),
 | 
			
		||||
              Padding(
 | 
			
		||||
                padding: const EdgeInsets.only(top: 12.0),
 | 
			
		||||
                child: Text('${_backupState.serverInfo.diskUse} of ${_backupState.serverInfo.diskSize} used'),
 | 
			
		||||
                child: Text('${backupState.serverInfo.diskUse} of ${backupState.serverInfo.diskSize} used'),
 | 
			
		||||
              ),
 | 
			
		||||
            ],
 | 
			
		||||
          ),
 | 
			
		||||
@ -104,18 +106,120 @@ class BackupControllerPage extends HookConsumerWidget {
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Widget _buildSelectedAlbumName() {
 | 
			
		||||
      var text = "Selected: ";
 | 
			
		||||
      var albums = ref.watch(backupProvider).selectedBackupAlbums;
 | 
			
		||||
 | 
			
		||||
      if (albums.isNotEmpty) {
 | 
			
		||||
        for (var album in albums) {
 | 
			
		||||
          if (album.name == "Recent" || album.name == "Recents") {
 | 
			
		||||
            text += "${album.name} (All), ";
 | 
			
		||||
          } else {
 | 
			
		||||
            text += "${album.name}, ";
 | 
			
		||||
          }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return Padding(
 | 
			
		||||
          padding: const EdgeInsets.only(top: 8.0),
 | 
			
		||||
          child: Text(
 | 
			
		||||
            text.trim().substring(0, text.length - 2),
 | 
			
		||||
            style: TextStyle(color: Theme.of(context).primaryColor, fontSize: 12, fontWeight: FontWeight.bold),
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
      } else {
 | 
			
		||||
        return Padding(
 | 
			
		||||
          padding: const EdgeInsets.only(top: 8.0),
 | 
			
		||||
          child: Text(
 | 
			
		||||
            "None selected",
 | 
			
		||||
            style: TextStyle(color: Theme.of(context).primaryColor, fontSize: 12, fontWeight: FontWeight.bold),
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    Widget _buildExcludedAlbumName() {
 | 
			
		||||
      var text = "Excluded: ";
 | 
			
		||||
      var albums = ref.watch(backupProvider).excludedBackupAlbums;
 | 
			
		||||
 | 
			
		||||
      if (albums.isNotEmpty) {
 | 
			
		||||
        for (var album in albums) {
 | 
			
		||||
          text += "${album.name}, ";
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return Padding(
 | 
			
		||||
          padding: const EdgeInsets.only(top: 8.0),
 | 
			
		||||
          child: Text(
 | 
			
		||||
            text.trim().substring(0, text.length - 2),
 | 
			
		||||
            style: TextStyle(color: Colors.red[300], fontSize: 12, fontWeight: FontWeight.bold),
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
      } else {
 | 
			
		||||
        return Container();
 | 
			
		||||
      }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _buildFolderSelectionTile() {
 | 
			
		||||
      return Card(
 | 
			
		||||
        shape: RoundedRectangleBorder(
 | 
			
		||||
          borderRadius: BorderRadius.circular(5), // if you need this
 | 
			
		||||
          side: const BorderSide(
 | 
			
		||||
            color: Colors.black12,
 | 
			
		||||
            width: 1,
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
        elevation: 0,
 | 
			
		||||
        borderOnForeground: false,
 | 
			
		||||
        child: ListTile(
 | 
			
		||||
          minVerticalPadding: 15,
 | 
			
		||||
          title: const Text("Backup Albums", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)),
 | 
			
		||||
          subtitle: Padding(
 | 
			
		||||
            padding: const EdgeInsets.only(top: 8.0),
 | 
			
		||||
            child: Column(
 | 
			
		||||
              crossAxisAlignment: CrossAxisAlignment.start,
 | 
			
		||||
              children: [
 | 
			
		||||
                const Text(
 | 
			
		||||
                  "Albums to be backup",
 | 
			
		||||
                  style: TextStyle(color: Color(0xFF808080), fontSize: 12),
 | 
			
		||||
                ),
 | 
			
		||||
                _buildSelectedAlbumName(),
 | 
			
		||||
                _buildExcludedAlbumName()
 | 
			
		||||
              ],
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
          trailing: OutlinedButton(
 | 
			
		||||
            onPressed: () {
 | 
			
		||||
              AutoRouter.of(context).push(const BackupAlbumSelectionRoute());
 | 
			
		||||
            },
 | 
			
		||||
            child: const Padding(
 | 
			
		||||
              padding: EdgeInsets.symmetric(
 | 
			
		||||
                vertical: 16.0,
 | 
			
		||||
              ),
 | 
			
		||||
              child: Text(
 | 
			
		||||
                "Select",
 | 
			
		||||
                style: TextStyle(fontWeight: FontWeight.bold),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
      );
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    return Scaffold(
 | 
			
		||||
      appBar: AppBar(
 | 
			
		||||
        elevation: 0,
 | 
			
		||||
        title: const Text(
 | 
			
		||||
          "Backup",
 | 
			
		||||
          style: TextStyle(fontWeight: FontWeight.bold),
 | 
			
		||||
          style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
 | 
			
		||||
        ),
 | 
			
		||||
        leading: IconButton(
 | 
			
		||||
            onPressed: () {
 | 
			
		||||
              ref.watch(websocketProvider.notifier).listenUploadEvent();
 | 
			
		||||
              AutoRouter.of(context).pop(true);
 | 
			
		||||
            },
 | 
			
		||||
            icon: const Icon(Icons.arrow_back_ios_rounded)),
 | 
			
		||||
            splashRadius: 24,
 | 
			
		||||
            icon: const Icon(
 | 
			
		||||
              Icons.arrow_back_ios_rounded,
 | 
			
		||||
            )),
 | 
			
		||||
      ),
 | 
			
		||||
      body: Padding(
 | 
			
		||||
        padding: const EdgeInsets.all(16.0),
 | 
			
		||||
@ -129,20 +233,21 @@ class BackupControllerPage extends HookConsumerWidget {
 | 
			
		||||
                style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
 | 
			
		||||
              ),
 | 
			
		||||
            ),
 | 
			
		||||
            _buildFolderSelectionTile(),
 | 
			
		||||
            BackupInfoCard(
 | 
			
		||||
              title: "Total",
 | 
			
		||||
              subtitle: "All images and videos on the device",
 | 
			
		||||
              info: "${_backupState.totalAssetCount}",
 | 
			
		||||
              subtitle: "All unique photos and videos from selected albums",
 | 
			
		||||
              info: "${backupState.allUniqueAssets.length}",
 | 
			
		||||
            ),
 | 
			
		||||
            BackupInfoCard(
 | 
			
		||||
              title: "Backup",
 | 
			
		||||
              subtitle: "Images and videos of the device that are backup on server",
 | 
			
		||||
              info: "${_backupState.assetOnDatabase}",
 | 
			
		||||
              subtitle: "Photos and videos from selected albums that are backup",
 | 
			
		||||
              info: "${backupState.selectedAlbumsBackupAssetsIds.length}",
 | 
			
		||||
            ),
 | 
			
		||||
            BackupInfoCard(
 | 
			
		||||
              title: "Remainder",
 | 
			
		||||
              subtitle: "Images and videos that has not been backing up",
 | 
			
		||||
              info: "${_backupState.totalAssetCount - _backupState.assetOnDatabase}",
 | 
			
		||||
              subtitle: "Photos and videos that has not been backing up from selected albums",
 | 
			
		||||
              info: "${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length}",
 | 
			
		||||
            ),
 | 
			
		||||
            const Divider(),
 | 
			
		||||
            _buildBackupController(),
 | 
			
		||||
@ -152,14 +257,14 @@ class BackupControllerPage extends HookConsumerWidget {
 | 
			
		||||
            Padding(
 | 
			
		||||
              padding: const EdgeInsets.all(8.0),
 | 
			
		||||
              child: Text(
 | 
			
		||||
                  "Asset that were being backup: ${_backupState.backingUpAssetCount} [${_backupState.progressInPercentage.toStringAsFixed(0)}%]"),
 | 
			
		||||
                  "Asset that were being backup: ${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length} [${backupState.progressInPercentage.toStringAsFixed(0)}%]"),
 | 
			
		||||
            ),
 | 
			
		||||
            Padding(
 | 
			
		||||
              padding: const EdgeInsets.only(left: 8.0),
 | 
			
		||||
              child: Row(children: [
 | 
			
		||||
                const Text("Backup Progress:"),
 | 
			
		||||
                const Padding(padding: EdgeInsets.symmetric(horizontal: 2)),
 | 
			
		||||
                _backupState.backupProgress == BackUpProgressEnum.inProgress
 | 
			
		||||
                backupState.backupProgress == BackUpProgressEnum.inProgress
 | 
			
		||||
                    ? const CircularProgressIndicator.adaptive()
 | 
			
		||||
                    : const Text("Done"),
 | 
			
		||||
              ]),
 | 
			
		||||
@ -167,7 +272,7 @@ class BackupControllerPage extends HookConsumerWidget {
 | 
			
		||||
            Padding(
 | 
			
		||||
              padding: const EdgeInsets.all(8.0),
 | 
			
		||||
              child: Container(
 | 
			
		||||
                child: _backupState.backupProgress == BackUpProgressEnum.inProgress
 | 
			
		||||
                child: backupState.backupProgress == BackUpProgressEnum.inProgress
 | 
			
		||||
                    ? ElevatedButton(
 | 
			
		||||
                        style: ElevatedButton.styleFrom(primary: Colors.red[300]),
 | 
			
		||||
                        onPressed: () {
 | 
			
		||||
@ -191,50 +296,3 @@ class BackupControllerPage extends HookConsumerWidget {
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class BackupInfoCard extends StatelessWidget {
 | 
			
		||||
  final String title;
 | 
			
		||||
  final String subtitle;
 | 
			
		||||
  final String info;
 | 
			
		||||
  const BackupInfoCard({Key? key, required this.title, required this.subtitle, required this.info}) : super(key: key);
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  Widget build(BuildContext context) {
 | 
			
		||||
    return Card(
 | 
			
		||||
      shape: RoundedRectangleBorder(
 | 
			
		||||
        borderRadius: BorderRadius.circular(5), // if you need this
 | 
			
		||||
        side: const BorderSide(
 | 
			
		||||
          color: Colors.black12,
 | 
			
		||||
          width: 1,
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
      elevation: 0,
 | 
			
		||||
      borderOnForeground: false,
 | 
			
		||||
      child: ListTile(
 | 
			
		||||
        minVerticalPadding: 15,
 | 
			
		||||
        isThreeLine: true,
 | 
			
		||||
        title: Text(
 | 
			
		||||
          title,
 | 
			
		||||
          style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 20),
 | 
			
		||||
        ),
 | 
			
		||||
        subtitle: Padding(
 | 
			
		||||
          padding: const EdgeInsets.only(top: 8.0),
 | 
			
		||||
          child: Text(
 | 
			
		||||
            subtitle,
 | 
			
		||||
            style: const TextStyle(color: Color(0xFF808080), fontSize: 12),
 | 
			
		||||
          ),
 | 
			
		||||
        ),
 | 
			
		||||
        trailing: Column(
 | 
			
		||||
          mainAxisAlignment: MainAxisAlignment.center,
 | 
			
		||||
          children: [
 | 
			
		||||
            Text(
 | 
			
		||||
              info,
 | 
			
		||||
              style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
 | 
			
		||||
            ),
 | 
			
		||||
            const Text("assets"),
 | 
			
		||||
          ],
 | 
			
		||||
        ),
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -5,9 +5,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 | 
			
		||||
 | 
			
		||||
import 'package:immich_mobile/routing/router.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/backup_state.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/server_info_state.model.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/providers/backup.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
 | 
			
		||||
 | 
			
		||||
class ImmichSliverAppBar extends ConsumerWidget {
 | 
			
		||||
@ -130,7 +130,8 @@ class ImmichSliverAppBar extends ConsumerWidget {
 | 
			
		||||
                ? Positioned(
 | 
			
		||||
                    bottom: 5,
 | 
			
		||||
                    child: Text(
 | 
			
		||||
                      _backupState.backingUpAssetCount.toString(),
 | 
			
		||||
                      (_backupState.allUniqueAssets.length - _backupState.selectedAlbumsBackupAssetsIds.length)
 | 
			
		||||
                          .toString(),
 | 
			
		||||
                      style: const TextStyle(fontSize: 9, fontWeight: FontWeight.bold),
 | 
			
		||||
                    ),
 | 
			
		||||
                  )
 | 
			
		||||
 | 
			
		||||
@ -6,7 +6,7 @@ import 'package:immich_mobile/shared/providers/asset.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/server_info_state.model.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/providers/backup.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/providers/server_info.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/providers/websocket.provider.dart';
 | 
			
		||||
import 'package:package_info_plus/package_info_plus.dart';
 | 
			
		||||
 | 
			
		||||
@ -6,7 +6,7 @@ import 'package:immich_mobile/constants/hive_box.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/login/models/login_response.model.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/services/backup.service.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/services/device_info.service.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/services/network.service.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/device_info.model.dart';
 | 
			
		||||
 | 
			
		||||
@ -7,7 +7,7 @@ import 'package:immich_mobile/constants/hive_box.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/providers/asset.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/providers/backup.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/ui/immich_toast.dart';
 | 
			
		||||
 | 
			
		||||
class LoginForm extends HookConsumerWidget {
 | 
			
		||||
 | 
			
		||||
@ -1,5 +1,7 @@
 | 
			
		||||
import 'package:auto_route/auto_route.dart';
 | 
			
		||||
import 'package:flutter/material.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/views/album_preview_page.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/views/backup_album_selection_page.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/login/views/login_page.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/home/views/home_page.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/search/views/search_page.dart';
 | 
			
		||||
@ -14,10 +16,11 @@ import 'package:immich_mobile/modules/sharing/views/select_user_for_sharing_page
 | 
			
		||||
import 'package:immich_mobile/modules/sharing/views/sharing_page.dart';
 | 
			
		||||
import 'package:immich_mobile/routing/auth_guard.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/immich_asset.model.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/views/backup_controller_page.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/backup/views/backup_controller_page.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/views/tab_controller_page.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
 | 
			
		||||
import 'package:photo_manager/photo_manager.dart';
 | 
			
		||||
 | 
			
		||||
part 'router.gr.dart';
 | 
			
		||||
 | 
			
		||||
@ -55,6 +58,8 @@ part 'router.gr.dart';
 | 
			
		||||
      guards: [AuthGuard],
 | 
			
		||||
      transitionsBuilder: TransitionsBuilders.slideBottom,
 | 
			
		||||
    ),
 | 
			
		||||
    AutoRoute(page: BackupAlbumSelectionPage, guards: [AuthGuard]),
 | 
			
		||||
    AutoRoute(page: AlbumPreviewPage, guards: [AuthGuard]),
 | 
			
		||||
  ],
 | 
			
		||||
)
 | 
			
		||||
class AppRouter extends _$AppRouter {
 | 
			
		||||
 | 
			
		||||
@ -93,6 +93,16 @@ class _$AppRouter extends RootStackRouter {
 | 
			
		||||
          opaque: true,
 | 
			
		||||
          barrierDismissible: false);
 | 
			
		||||
    },
 | 
			
		||||
    BackupAlbumSelectionRoute.name: (routeData) {
 | 
			
		||||
      return MaterialPageX<dynamic>(
 | 
			
		||||
          routeData: routeData, child: const BackupAlbumSelectionPage());
 | 
			
		||||
    },
 | 
			
		||||
    AlbumPreviewRoute.name: (routeData) {
 | 
			
		||||
      final args = routeData.argsAs<AlbumPreviewRouteArgs>();
 | 
			
		||||
      return MaterialPageX<dynamic>(
 | 
			
		||||
          routeData: routeData,
 | 
			
		||||
          child: AlbumPreviewPage(key: args.key, album: args.album));
 | 
			
		||||
    },
 | 
			
		||||
    HomeRoute.name: (routeData) {
 | 
			
		||||
      return MaterialPageX<dynamic>(
 | 
			
		||||
          routeData: routeData, child: const HomePage());
 | 
			
		||||
@ -149,7 +159,11 @@ class _$AppRouter extends RootStackRouter {
 | 
			
		||||
            path: '/album-viewer-page', guards: [authGuard]),
 | 
			
		||||
        RouteConfig(SelectAdditionalUserForSharingRoute.name,
 | 
			
		||||
            path: '/select-additional-user-for-sharing-page',
 | 
			
		||||
            guards: [authGuard])
 | 
			
		||||
            guards: [authGuard]),
 | 
			
		||||
        RouteConfig(BackupAlbumSelectionRoute.name,
 | 
			
		||||
            path: '/backup-album-selection-page', guards: [authGuard]),
 | 
			
		||||
        RouteConfig(AlbumPreviewRoute.name,
 | 
			
		||||
            path: '/album-preview-page', guards: [authGuard])
 | 
			
		||||
      ];
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
@ -358,6 +372,40 @@ class SelectAdditionalUserForSharingRouteArgs {
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// generated route for
 | 
			
		||||
/// [BackupAlbumSelectionPage]
 | 
			
		||||
class BackupAlbumSelectionRoute extends PageRouteInfo<void> {
 | 
			
		||||
  const BackupAlbumSelectionRoute()
 | 
			
		||||
      : super(BackupAlbumSelectionRoute.name,
 | 
			
		||||
            path: '/backup-album-selection-page');
 | 
			
		||||
 | 
			
		||||
  static const String name = 'BackupAlbumSelectionRoute';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// generated route for
 | 
			
		||||
/// [AlbumPreviewPage]
 | 
			
		||||
class AlbumPreviewRoute extends PageRouteInfo<AlbumPreviewRouteArgs> {
 | 
			
		||||
  AlbumPreviewRoute({Key? key, required AssetPathEntity album})
 | 
			
		||||
      : super(AlbumPreviewRoute.name,
 | 
			
		||||
            path: '/album-preview-page',
 | 
			
		||||
            args: AlbumPreviewRouteArgs(key: key, album: album));
 | 
			
		||||
 | 
			
		||||
  static const String name = 'AlbumPreviewRoute';
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
class AlbumPreviewRouteArgs {
 | 
			
		||||
  const AlbumPreviewRouteArgs({this.key, required this.album});
 | 
			
		||||
 | 
			
		||||
  final Key? key;
 | 
			
		||||
 | 
			
		||||
  final AssetPathEntity album;
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'AlbumPreviewRouteArgs{key: $key, album: $album}';
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
/// generated route for
 | 
			
		||||
/// [HomePage]
 | 
			
		||||
class HomeRoute extends PageRouteInfo<void> {
 | 
			
		||||
 | 
			
		||||
@ -1,77 +0,0 @@
 | 
			
		||||
import 'dart:convert';
 | 
			
		||||
 | 
			
		||||
import 'package:dio/dio.dart';
 | 
			
		||||
 | 
			
		||||
import 'package:immich_mobile/shared/models/server_info.model.dart';
 | 
			
		||||
 | 
			
		||||
enum BackUpProgressEnum { idle, inProgress, done }
 | 
			
		||||
 | 
			
		||||
class BackUpState {
 | 
			
		||||
  final BackUpProgressEnum backupProgress;
 | 
			
		||||
  final int totalAssetCount;
 | 
			
		||||
  final int assetOnDatabase;
 | 
			
		||||
  final int backingUpAssetCount;
 | 
			
		||||
  final double progressInPercentage;
 | 
			
		||||
  final CancelToken cancelToken;
 | 
			
		||||
  final ServerInfo serverInfo;
 | 
			
		||||
 | 
			
		||||
  BackUpState({
 | 
			
		||||
    required this.backupProgress,
 | 
			
		||||
    required this.totalAssetCount,
 | 
			
		||||
    required this.assetOnDatabase,
 | 
			
		||||
    required this.backingUpAssetCount,
 | 
			
		||||
    required this.progressInPercentage,
 | 
			
		||||
    required this.cancelToken,
 | 
			
		||||
    required this.serverInfo,
 | 
			
		||||
  });
 | 
			
		||||
 | 
			
		||||
  BackUpState copyWith({
 | 
			
		||||
    BackUpProgressEnum? backupProgress,
 | 
			
		||||
    int? totalAssetCount,
 | 
			
		||||
    int? assetOnDatabase,
 | 
			
		||||
    int? backingUpAssetCount,
 | 
			
		||||
    double? progressInPercentage,
 | 
			
		||||
    CancelToken? cancelToken,
 | 
			
		||||
    ServerInfo? serverInfo,
 | 
			
		||||
  }) {
 | 
			
		||||
    return BackUpState(
 | 
			
		||||
      backupProgress: backupProgress ?? this.backupProgress,
 | 
			
		||||
      totalAssetCount: totalAssetCount ?? this.totalAssetCount,
 | 
			
		||||
      assetOnDatabase: assetOnDatabase ?? this.assetOnDatabase,
 | 
			
		||||
      backingUpAssetCount: backingUpAssetCount ?? this.backingUpAssetCount,
 | 
			
		||||
      progressInPercentage: progressInPercentage ?? this.progressInPercentage,
 | 
			
		||||
      cancelToken: cancelToken ?? this.cancelToken,
 | 
			
		||||
      serverInfo: serverInfo ?? this.serverInfo,
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  String toString() {
 | 
			
		||||
    return 'BackUpState(backupProgress: $backupProgress, totalAssetCount: $totalAssetCount, assetOnDatabase: $assetOnDatabase, backingUpAssetCount: $backingUpAssetCount, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo)';
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  bool operator ==(Object other) {
 | 
			
		||||
    if (identical(this, other)) return true;
 | 
			
		||||
 | 
			
		||||
    return other is BackUpState &&
 | 
			
		||||
        other.backupProgress == backupProgress &&
 | 
			
		||||
        other.totalAssetCount == totalAssetCount &&
 | 
			
		||||
        other.assetOnDatabase == assetOnDatabase &&
 | 
			
		||||
        other.backingUpAssetCount == backingUpAssetCount &&
 | 
			
		||||
        other.progressInPercentage == progressInPercentage &&
 | 
			
		||||
        other.cancelToken == cancelToken &&
 | 
			
		||||
        other.serverInfo == serverInfo;
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  @override
 | 
			
		||||
  int get hashCode {
 | 
			
		||||
    return backupProgress.hashCode ^
 | 
			
		||||
        totalAssetCount.hashCode ^
 | 
			
		||||
        assetOnDatabase.hashCode ^
 | 
			
		||||
        backingUpAssetCount.hashCode ^
 | 
			
		||||
        progressInPercentage.hashCode ^
 | 
			
		||||
        cancelToken.hashCode ^
 | 
			
		||||
        serverInfo.hashCode;
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
@ -1,194 +0,0 @@
 | 
			
		||||
import 'dart:async';
 | 
			
		||||
 | 
			
		||||
import 'package:dio/dio.dart';
 | 
			
		||||
import 'package:flutter/foundation.dart';
 | 
			
		||||
import 'package:hive_flutter/hive_flutter.dart';
 | 
			
		||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
 | 
			
		||||
import 'package:immich_mobile/constants/hive_box.dart';
 | 
			
		||||
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/services/server_info.service.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/backup_state.model.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/models/server_info.model.dart';
 | 
			
		||||
import 'package:immich_mobile/shared/services/backup.service.dart';
 | 
			
		||||
import 'package:photo_manager/photo_manager.dart';
 | 
			
		||||
 | 
			
		||||
class BackupNotifier extends StateNotifier<BackUpState> {
 | 
			
		||||
  BackupNotifier({this.ref})
 | 
			
		||||
      : super(
 | 
			
		||||
          BackUpState(
 | 
			
		||||
            backupProgress: BackUpProgressEnum.idle,
 | 
			
		||||
            backingUpAssetCount: 0,
 | 
			
		||||
            assetOnDatabase: 0,
 | 
			
		||||
            totalAssetCount: 0,
 | 
			
		||||
            progressInPercentage: 0,
 | 
			
		||||
            cancelToken: CancelToken(),
 | 
			
		||||
            serverInfo: ServerInfo(
 | 
			
		||||
              diskAvailable: "0",
 | 
			
		||||
              diskAvailableRaw: 0,
 | 
			
		||||
              diskSize: "0",
 | 
			
		||||
              diskSizeRaw: 0,
 | 
			
		||||
              diskUsagePercentage: 0.0,
 | 
			
		||||
              diskUse: "0",
 | 
			
		||||
              diskUseRaw: 0,
 | 
			
		||||
            ),
 | 
			
		||||
          ),
 | 
			
		||||
        );
 | 
			
		||||
 | 
			
		||||
  Ref? ref;
 | 
			
		||||
  final BackupService _backupService = BackupService();
 | 
			
		||||
  final ServerInfoService _serverInfoService = ServerInfoService();
 | 
			
		||||
  final StreamController _onAssetBackupStreamCtrl =
 | 
			
		||||
      StreamController.broadcast();
 | 
			
		||||
 | 
			
		||||
  void getBackupInfo() async {
 | 
			
		||||
    _updateServerInfo();
 | 
			
		||||
 | 
			
		||||
    List<AssetPathEntity> list = await PhotoManager.getAssetPathList(
 | 
			
		||||
        onlyAll: true, type: RequestType.common);
 | 
			
		||||
    List<String> didBackupAsset = await _backupService.getDeviceBackupAsset();
 | 
			
		||||
 | 
			
		||||
    if (list.isEmpty) {
 | 
			
		||||
      debugPrint("No Asset On Device");
 | 
			
		||||
      state = state.copyWith(
 | 
			
		||||
          backupProgress: BackUpProgressEnum.idle,
 | 
			
		||||
          totalAssetCount: 0,
 | 
			
		||||
          assetOnDatabase: didBackupAsset.length);
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    int totalAsset = list[0].assetCount;
 | 
			
		||||
 | 
			
		||||
    state = state.copyWith(
 | 
			
		||||
        totalAssetCount: totalAsset, assetOnDatabase: didBackupAsset.length);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void startBackupProcess() async {
 | 
			
		||||
    _updateServerInfo();
 | 
			
		||||
 | 
			
		||||
    state = state.copyWith(backupProgress: BackUpProgressEnum.inProgress);
 | 
			
		||||
 | 
			
		||||
    var authResult = await PhotoManager.requestPermissionExtend();
 | 
			
		||||
    if (authResult.isAuth) {
 | 
			
		||||
      await PhotoManager.clearFileCache();
 | 
			
		||||
      // await PhotoManager.presentLimited();
 | 
			
		||||
      // Gather assets info
 | 
			
		||||
      List<AssetPathEntity> list = await PhotoManager.getAssetPathList(
 | 
			
		||||
          hasAll: true, onlyAll: true, type: RequestType.common);
 | 
			
		||||
 | 
			
		||||
      // Get device assets info from database
 | 
			
		||||
      // Compare and find different assets that has not been backing up
 | 
			
		||||
      // Backup those assets
 | 
			
		||||
      List<String> backupAsset = await _backupService.getDeviceBackupAsset();
 | 
			
		||||
 | 
			
		||||
      if (list.isEmpty) {
 | 
			
		||||
        debugPrint("No Asset On Device - Abort Backup Process");
 | 
			
		||||
        state = state.copyWith(
 | 
			
		||||
            backupProgress: BackUpProgressEnum.idle,
 | 
			
		||||
            totalAssetCount: 0,
 | 
			
		||||
            assetOnDatabase: backupAsset.length);
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      int totalAsset = list[0].assetCount;
 | 
			
		||||
      List<AssetEntity> currentAssets =
 | 
			
		||||
          await list[0].getAssetListRange(start: 0, end: totalAsset);
 | 
			
		||||
 | 
			
		||||
      state = state.copyWith(
 | 
			
		||||
          totalAssetCount: totalAsset, assetOnDatabase: backupAsset.length);
 | 
			
		||||
      // Remove item that has already been backed up
 | 
			
		||||
      for (var backupAssetId in backupAsset) {
 | 
			
		||||
        currentAssets.removeWhere((e) => e.id == backupAssetId);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      if (currentAssets.isEmpty) {
 | 
			
		||||
        state = state.copyWith(backupProgress: BackUpProgressEnum.idle);
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      state = state.copyWith(backingUpAssetCount: currentAssets.length);
 | 
			
		||||
 | 
			
		||||
      // Perform Backup
 | 
			
		||||
      state = state.copyWith(cancelToken: CancelToken());
 | 
			
		||||
      _backupService.backupAsset(currentAssets, state.cancelToken,
 | 
			
		||||
          _onAssetUploaded, _onUploadProgress);
 | 
			
		||||
    } else {
 | 
			
		||||
      PhotoManager.openSetting();
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void cancelBackup() {
 | 
			
		||||
    state.cancelToken.cancel('Cancel Backup');
 | 
			
		||||
    state = state.copyWith(
 | 
			
		||||
        backupProgress: BackUpProgressEnum.idle, progressInPercentage: 0.0);
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _onAssetUploaded(String deviceAssetId, String deviceId) {
 | 
			
		||||
    state = state.copyWith(
 | 
			
		||||
        backingUpAssetCount: state.backingUpAssetCount - 1,
 | 
			
		||||
        assetOnDatabase: state.assetOnDatabase + 1);
 | 
			
		||||
 | 
			
		||||
    if (state.backingUpAssetCount == 0) {
 | 
			
		||||
      state = state.copyWith(
 | 
			
		||||
          backupProgress: BackUpProgressEnum.done, progressInPercentage: 0.0);
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    _updateServerInfo();
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _onUploadProgress(int sent, int total) {
 | 
			
		||||
    state = state.copyWith(
 | 
			
		||||
        progressInPercentage: (sent.toDouble() / total.toDouble() * 100));
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void _updateServerInfo() async {
 | 
			
		||||
    var serverInfo = await _serverInfoService.getServerInfo();
 | 
			
		||||
 | 
			
		||||
    // Update server info
 | 
			
		||||
    state = state.copyWith(
 | 
			
		||||
      serverInfo: ServerInfo(
 | 
			
		||||
        diskSize: serverInfo.diskSize,
 | 
			
		||||
        diskUse: serverInfo.diskUse,
 | 
			
		||||
        diskAvailable: serverInfo.diskAvailable,
 | 
			
		||||
        diskSizeRaw: serverInfo.diskSizeRaw,
 | 
			
		||||
        diskUseRaw: serverInfo.diskUseRaw,
 | 
			
		||||
        diskAvailableRaw: serverInfo.diskAvailableRaw,
 | 
			
		||||
        diskUsagePercentage: serverInfo.diskUsagePercentage,
 | 
			
		||||
      ),
 | 
			
		||||
    );
 | 
			
		||||
  }
 | 
			
		||||
 | 
			
		||||
  void resumeBackup() {
 | 
			
		||||
    var authState = ref?.read(authenticationProvider);
 | 
			
		||||
 | 
			
		||||
    // Check if user is login
 | 
			
		||||
    var accessKey = Hive.box(userInfoBox).get(accessTokenKey);
 | 
			
		||||
 | 
			
		||||
    // User has been logged out return
 | 
			
		||||
    if (authState != null) {
 | 
			
		||||
      if (accessKey == null || !authState.isAuthenticated) {
 | 
			
		||||
        debugPrint("[resumeBackup] not authenticated - abort");
 | 
			
		||||
        return;
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      // Check if this device is enable backup by the user
 | 
			
		||||
      if ((authState.deviceInfo.deviceId == authState.deviceId) &&
 | 
			
		||||
          authState.deviceInfo.isAutoBackup) {
 | 
			
		||||
        // check if backup is alreayd in process - then return
 | 
			
		||||
        if (state.backupProgress == BackUpProgressEnum.inProgress) {
 | 
			
		||||
          debugPrint("[resumeBackup] Backup is already in progress - abort");
 | 
			
		||||
          return;
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        // Run backup
 | 
			
		||||
        debugPrint("[resumeBackup] Start back up");
 | 
			
		||||
        startBackupProcess();
 | 
			
		||||
      }
 | 
			
		||||
 | 
			
		||||
      return;
 | 
			
		||||
    }
 | 
			
		||||
  }
 | 
			
		||||
}
 | 
			
		||||
 | 
			
		||||
final backupProvider =
 | 
			
		||||
    StateNotifierProvider<BackupNotifier, BackUpState>((ref) {
 | 
			
		||||
  return BackupNotifier(ref: ref);
 | 
			
		||||
});
 | 
			
		||||
@ -239,6 +239,13 @@ packages:
 | 
			
		||||
      url: "https://pub.dartlang.org"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "4.0.4"
 | 
			
		||||
  equatable:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
      name: equatable
 | 
			
		||||
      url: "https://pub.dartlang.org"
 | 
			
		||||
    source: hosted
 | 
			
		||||
    version: "2.0.3"
 | 
			
		||||
  exif:
 | 
			
		||||
    dependency: "direct main"
 | 
			
		||||
    description:
 | 
			
		||||
 | 
			
		||||
@ -2,7 +2,7 @@ name: immich_mobile
 | 
			
		||||
description: Immich - selfhosted backup media file on mobile phone
 | 
			
		||||
 | 
			
		||||
publish_to: "none"
 | 
			
		||||
version: 1.8.0+12
 | 
			
		||||
version: 1.9.0+13
 | 
			
		||||
 | 
			
		||||
environment:
 | 
			
		||||
  sdk: ">=2.15.1 <3.0.0"
 | 
			
		||||
@ -37,6 +37,7 @@ dependencies:
 | 
			
		||||
  package_info_plus: ^1.4.0
 | 
			
		||||
  flutter_spinkit: ^5.1.0
 | 
			
		||||
  flutter_swipe_detector: ^2.0.0
 | 
			
		||||
  equatable: ^2.0.3
 | 
			
		||||
 | 
			
		||||
dev_dependencies:
 | 
			
		||||
  flutter_test:
 | 
			
		||||
 | 
			
		||||
@ -3,7 +3,7 @@
 | 
			
		||||
 | 
			
		||||
export const serverVersion = {
 | 
			
		||||
  major: 1,
 | 
			
		||||
  minor: 8,
 | 
			
		||||
  minor: 9,
 | 
			
		||||
  patch: 0,
 | 
			
		||||
  build: 12,
 | 
			
		||||
  build: 13,
 | 
			
		||||
};
 | 
			
		||||
 | 
			
		||||