1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2024-12-29 15:11:58 +00:00

Feature #120 #89 selective backup in app (#137)

This commit is contained in:
Alex 2022-05-06 07:22:23 -05:00 committed by GitHub
parent f1396761b0
commit 373b6918f8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
38 changed files with 1324 additions and 360 deletions

View file

@ -32,8 +32,9 @@ Loading ~4000 images/videos
## Screenshots ## Screenshots
<p align="left"> <p align="left">
<img src="design/nsc1.png" width="150" title="Login With Custom URL"> <img src="design/login-screen.png" width="150" title="Login With Custom URL">
<img src="design/nsc2.png" width="150" title="Backup Setting Info"> <img src="design/backup-screen.png" width="150" title="Backup Setting Info">
<img src="design/selective-backup-screen.png" width="150" title="Backup Setting Info">
<img src="design/home-screen.jpeg" width="150" title="Home Screen"> <img src="design/home-screen.jpeg" width="150" title="Home Screen">
<img src="design/search-screen.jpeg" width="150" title="Curated Search Info"> <img src="design/search-screen.jpeg" width="150" title="Curated Search Info">
<img src="design/shared-albums.png" width="150" title="Shared Albums"> <img src="design/shared-albums.png" width="150" title="Shared Albums">
@ -50,10 +51,10 @@ This project is under heavy development, there will be continous functions, feat
# Features # Features
- Upload and view assets (videos/images). - Upload and view assets (videos/images).
- Auto Backup.
- Download asset to local device. - Download asset to local device.
- Multi-user supported. - Multi-user supported.
- Quick navigation with drag scroll bar. - Quick navigation with drag scroll bar.
- Auto Backup.
- Support HEIC/HEIF Backup. - Support HEIC/HEIF Backup.
- Extract and display EXIF info. - Extract and display EXIF info.
- Real-time render from multi-device upload event. - Real-time render from multi-device upload event.
@ -65,14 +66,20 @@ This project is under heavy development, there will be continous functions, feat
- Show curated places on the search page - Show curated places on the search page
- Show curated objects on the search page - Show curated objects on the search page
- Shared album with users on the same server - Shared album with users on the same server
- Selective backup - albums can be included and excluded during the backup process.
# System Requirement # System Requirement
**OS**: Preferred Linux-based operating system (Ubuntu, Debian, MacOS...etc). I haven't tested with `Docker for Windows` as well as `WSL` on Windows **OS**: Preferred Linux-based operating system (Ubuntu, Debian, MacOS...etc).
I haven't tested with `Docker for Windows` as well as `WSL` on Windows
*Raspberry Pi can be used but `microservices` container has to be comment out in `docker-compose` since TensorFlow has not been supported in Dockec image on arm64v7 yet.*
**RAM**: At least 2GB, preffered 4GB. **RAM**: At least 2GB, preffered 4GB.
**Cores**: At least 2 cores, preffered 4 cores. **Core**: At least 2 cores, preffered 4 cores.
# Development and Testing out the application # Development and Testing out the application

BIN
design/backup-screen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 308 KiB

BIN
design/login-screen.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 278 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 176 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 303 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 570 KiB

View file

@ -2,7 +2,7 @@ version: "3.8"
services: services:
immich_server: immich_server:
image: immich-server-dev:1.8.0 image: immich-server-dev:1.9.0
build: build:
context: ../server context: ../server
dockerfile: Dockerfile dockerfile: Dockerfile
@ -24,7 +24,7 @@ services:
- immich_network - immich_network
immich_microservices: immich_microservices:
image: immich-microservices-dev:1.8.0 image: immich-microservices-dev:1.9.0
build: build:
context: ../microservices context: ../microservices
dockerfile: Dockerfile dockerfile: Dockerfile

View file

@ -2,7 +2,7 @@ version: "3.8"
services: services:
immich_server: immich_server:
image: immich-server-dev:1.8.0 image: immich-server-dev:1.9.0
build: build:
context: ../server context: ../server
dockerfile: Dockerfile dockerfile: Dockerfile
@ -22,7 +22,7 @@ services:
- immich_network - immich_network
immich_microservices: immich_microservices:
image: immich-microservices-dev:1.8.0 image: immich-microservices-dev:1.9.0
build: build:
context: ../microservices context: ../microservices
dockerfile: Dockerfile dockerfile: Dockerfile

View file

@ -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.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 55 KiB

After

Width:  |  Height:  |  Size: 570 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 74 KiB

After

Width:  |  Height:  |  Size: 308 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 183 KiB

After

Width:  |  Height:  |  Size: 74 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 183 KiB

View file

@ -19,7 +19,7 @@ platform :ios do
desc "iOS Beta" desc "iOS Beta"
lane :beta do lane :beta do
increment_version_number( increment_version_number(
version_number: "1.8.0" version_number: "1.9.0"
) )
increment_build_number( increment_build_number(
build_number: latest_testflight_build_number + 1, build_number: latest_testflight_build_number + 1,

View file

@ -9,3 +9,7 @@ const String serverEndpointKey = 'immichBoxServerEndpoint';
// Login Info // Login Info
const String hiveLoginInfoBox = "immichLoginInfoBox"; const String hiveLoginInfoBox = "immichLoginInfoBox";
const String savedLoginInfoKey = "immichSavedLoginInfoKey"; const String savedLoginInfoKey = "immichSavedLoginInfoKey";
// Backup Info
const String hiveBackupInfoBox = "immichBackupAlbumInfoBox";
const String backupInfoKey = "immichBackupAlbumInfoKey";

View file

@ -3,12 +3,13 @@ import 'package:flutter/services.dart';
import 'package:hive_flutter/hive_flutter.dart'; import 'package:hive_flutter/hive_flutter.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/immich_colors.dart'; import 'package:immich_mobile/constants/immich_colors.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart'; import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/routing/tab_navigation_observer.dart'; import 'package:immich_mobile/routing/tab_navigation_observer.dart';
import 'package:immich_mobile/shared/providers/app_state.provider.dart'; import 'package:immich_mobile/shared/providers/app_state.provider.dart';
import 'package:immich_mobile/shared/providers/backup.provider.dart'; import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart'; import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/shared/views/immich_loading_overlay.dart'; import 'package:immich_mobile/shared/views/immich_loading_overlay.dart';
@ -16,9 +17,13 @@ import 'constants/hive_box.dart';
void main() async { void main() async {
await Hive.initFlutter(); await Hive.initFlutter();
Hive.registerAdapter(HiveSavedLoginInfoAdapter()); Hive.registerAdapter(HiveSavedLoginInfoAdapter());
Hive.registerAdapter(HiveBackupAlbumsAdapter());
await Hive.openBox(userInfoBox); await Hive.openBox(userInfoBox);
await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox); await Hive.openBox<HiveSavedLoginInfo>(hiveLoginInfoBox);
await Hive.openBox<HiveBackupAlbums>(hiveBackupInfoBox);
SystemChrome.setSystemUIOverlayStyle( SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle( const SystemUiOverlayStyle(

View 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;
}

View 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,
];
}
}

View file

@ -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;
}

View 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);
});

View file

@ -26,7 +26,7 @@ class BackupService {
return result.cast<String>(); return result.cast<String>();
} }
backupAsset(List<AssetEntity> assetList, CancelToken cancelToken, Function(String, String) singleAssetDoneCb, backupAsset(Set<AssetEntity> assetList, CancelToken cancelToken, Function(String, String) singleAssetDoneCb,
Function(int, int) uploadProgress) async { Function(int, int) uploadProgress) async {
var dio = Dio(); var dio = Dio();
dio.interceptors.add(AuthenticatedRequestInterceptor()); dio.interceptors.add(AuthenticatedRequestInterceptor());

View 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,
),
],
),
),
],
),
),
);
}
}

View 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"),
],
),
),
);
}
}

View 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(),
);
}),
);
},
),
);
}
}

View 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(),
),
],
),
);
}
}

View file

@ -3,10 +3,12 @@ import 'package:flutter/material.dart';
import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart'; import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/shared/models/backup_state.model.dart'; import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/providers/backup.provider.dart'; import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart'; import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:immich_mobile/modules/backup/ui/backup_info_card.dart';
import 'package:percent_indicator/linear_percent_indicator.dart'; import 'package:percent_indicator/linear_percent_indicator.dart';
class BackupControllerPage extends HookConsumerWidget { class BackupControllerPage extends HookConsumerWidget {
@ -14,13 +16,13 @@ class BackupControllerPage extends HookConsumerWidget {
@override @override
Widget build(BuildContext context, WidgetRef ref) { Widget build(BuildContext context, WidgetRef ref) {
BackUpState _backupState = ref.watch(backupProvider); BackUpState backupState = ref.watch(backupProvider);
AuthenticationState _authenticationState = ref.watch(authenticationProvider); AuthenticationState _authenticationState = ref.watch(authenticationProvider);
bool shouldBackup =
bool shouldBackup = _backupState.totalAssetCount - _backupState.assetOnDatabase == 0 ? false : true; backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length == 0 ? false : true;
useEffect(() { useEffect(() {
if (_backupState.backupProgress != BackUpProgressEnum.inProgress) { if (backupState.backupProgress != BackUpProgressEnum.inProgress) {
ref.read(backupProvider.notifier).getBackupInfo(); ref.read(backupProvider.notifier).getBackupInfo();
} }
@ -46,13 +48,13 @@ class BackupControllerPage extends HookConsumerWidget {
LinearPercentIndicator( LinearPercentIndicator(
padding: const EdgeInsets.only(top: 8.0), padding: const EdgeInsets.only(top: 8.0),
lineHeight: 5.0, lineHeight: 5.0,
percent: _backupState.serverInfo.diskUsagePercentage / 100.0, percent: backupState.serverInfo.diskUsagePercentage / 100.0,
backgroundColor: Colors.grey, backgroundColor: Colors.grey,
progressColor: Theme.of(context).primaryColor, progressColor: Theme.of(context).primaryColor,
), ),
Padding( Padding(
padding: const EdgeInsets.only(top: 12.0), padding: const EdgeInsets.only(top: 12.0),
child: Text('${_backupState.serverInfo.diskUse} of ${_backupState.serverInfo.diskSize} used'), child: Text('${backupState.serverInfo.diskUse} of ${backupState.serverInfo.diskSize} used'),
), ),
], ],
), ),
@ -104,18 +106,120 @@ class BackupControllerPage extends HookConsumerWidget {
); );
} }
Widget _buildSelectedAlbumName() {
var text = "Selected: ";
var albums = ref.watch(backupProvider).selectedBackupAlbums;
if (albums.isNotEmpty) {
for (var album in albums) {
if (album.name == "Recent" || album.name == "Recents") {
text += "${album.name} (All), ";
} else {
text += "${album.name}, ";
}
}
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
text.trim().substring(0, text.length - 2),
style: TextStyle(color: Theme.of(context).primaryColor, fontSize: 12, fontWeight: FontWeight.bold),
),
);
} else {
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
"None selected",
style: TextStyle(color: Theme.of(context).primaryColor, fontSize: 12, fontWeight: FontWeight.bold),
),
);
}
}
Widget _buildExcludedAlbumName() {
var text = "Excluded: ";
var albums = ref.watch(backupProvider).excludedBackupAlbums;
if (albums.isNotEmpty) {
for (var album in albums) {
text += "${album.name}, ";
}
return Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
text.trim().substring(0, text.length - 2),
style: TextStyle(color: Colors.red[300], fontSize: 12, fontWeight: FontWeight.bold),
),
);
} else {
return Container();
}
}
_buildFolderSelectionTile() {
return Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5), // if you need this
side: const BorderSide(
color: Colors.black12,
width: 1,
),
),
elevation: 0,
borderOnForeground: false,
child: ListTile(
minVerticalPadding: 15,
title: const Text("Backup Albums", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 20)),
subtitle: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
"Albums to be backup",
style: TextStyle(color: Color(0xFF808080), fontSize: 12),
),
_buildSelectedAlbumName(),
_buildExcludedAlbumName()
],
),
),
trailing: OutlinedButton(
onPressed: () {
AutoRouter.of(context).push(const BackupAlbumSelectionRoute());
},
child: const Padding(
padding: EdgeInsets.symmetric(
vertical: 16.0,
),
child: Text(
"Select",
style: TextStyle(fontWeight: FontWeight.bold),
),
),
),
),
);
}
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
elevation: 0,
title: const Text( title: const Text(
"Backup", "Backup",
style: TextStyle(fontWeight: FontWeight.bold), style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
), ),
leading: IconButton( leading: IconButton(
onPressed: () { onPressed: () {
ref.watch(websocketProvider.notifier).listenUploadEvent(); ref.watch(websocketProvider.notifier).listenUploadEvent();
AutoRouter.of(context).pop(true); AutoRouter.of(context).pop(true);
}, },
icon: const Icon(Icons.arrow_back_ios_rounded)), splashRadius: 24,
icon: const Icon(
Icons.arrow_back_ios_rounded,
)),
), ),
body: Padding( body: Padding(
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
@ -129,20 +233,21 @@ class BackupControllerPage extends HookConsumerWidget {
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16), style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16),
), ),
), ),
_buildFolderSelectionTile(),
BackupInfoCard( BackupInfoCard(
title: "Total", title: "Total",
subtitle: "All images and videos on the device", subtitle: "All unique photos and videos from selected albums",
info: "${_backupState.totalAssetCount}", info: "${backupState.allUniqueAssets.length}",
), ),
BackupInfoCard( BackupInfoCard(
title: "Backup", title: "Backup",
subtitle: "Images and videos of the device that are backup on server", subtitle: "Photos and videos from selected albums that are backup",
info: "${_backupState.assetOnDatabase}", info: "${backupState.selectedAlbumsBackupAssetsIds.length}",
), ),
BackupInfoCard( BackupInfoCard(
title: "Remainder", title: "Remainder",
subtitle: "Images and videos that has not been backing up", subtitle: "Photos and videos that has not been backing up from selected albums",
info: "${_backupState.totalAssetCount - _backupState.assetOnDatabase}", info: "${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length}",
), ),
const Divider(), const Divider(),
_buildBackupController(), _buildBackupController(),
@ -152,14 +257,14 @@ class BackupControllerPage extends HookConsumerWidget {
Padding( Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Text( child: Text(
"Asset that were being backup: ${_backupState.backingUpAssetCount} [${_backupState.progressInPercentage.toStringAsFixed(0)}%]"), "Asset that were being backup: ${backupState.allUniqueAssets.length - backupState.selectedAlbumsBackupAssetsIds.length} [${backupState.progressInPercentage.toStringAsFixed(0)}%]"),
), ),
Padding( Padding(
padding: const EdgeInsets.only(left: 8.0), padding: const EdgeInsets.only(left: 8.0),
child: Row(children: [ child: Row(children: [
const Text("Backup Progress:"), const Text("Backup Progress:"),
const Padding(padding: EdgeInsets.symmetric(horizontal: 2)), const Padding(padding: EdgeInsets.symmetric(horizontal: 2)),
_backupState.backupProgress == BackUpProgressEnum.inProgress backupState.backupProgress == BackUpProgressEnum.inProgress
? const CircularProgressIndicator.adaptive() ? const CircularProgressIndicator.adaptive()
: const Text("Done"), : const Text("Done"),
]), ]),
@ -167,7 +272,7 @@ class BackupControllerPage extends HookConsumerWidget {
Padding( Padding(
padding: const EdgeInsets.all(8.0), padding: const EdgeInsets.all(8.0),
child: Container( child: Container(
child: _backupState.backupProgress == BackUpProgressEnum.inProgress child: backupState.backupProgress == BackUpProgressEnum.inProgress
? ElevatedButton( ? ElevatedButton(
style: ElevatedButton.styleFrom(primary: Colors.red[300]), style: ElevatedButton.styleFrom(primary: Colors.red[300]),
onPressed: () { onPressed: () {
@ -191,50 +296,3 @@ class BackupControllerPage extends HookConsumerWidget {
); );
} }
} }
class BackupInfoCard extends StatelessWidget {
final String title;
final String subtitle;
final String info;
const BackupInfoCard({Key? key, required this.title, required this.subtitle, required this.info}) : super(key: key);
@override
Widget build(BuildContext context) {
return Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5), // if you need this
side: const BorderSide(
color: Colors.black12,
width: 1,
),
),
elevation: 0,
borderOnForeground: false,
child: ListTile(
minVerticalPadding: 15,
isThreeLine: true,
title: Text(
title,
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 20),
),
subtitle: Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Text(
subtitle,
style: const TextStyle(color: Color(0xFF808080), fontSize: 12),
),
),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
info,
style: const TextStyle(fontSize: 24, fontWeight: FontWeight.bold),
),
const Text("assets"),
],
),
),
);
}
}

View file

@ -5,9 +5,9 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/routing/router.dart'; import 'package:immich_mobile/routing/router.dart';
import 'package:immich_mobile/shared/models/backup_state.model.dart'; import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
import 'package:immich_mobile/shared/models/server_info_state.model.dart'; import 'package:immich_mobile/shared/models/server_info_state.model.dart';
import 'package:immich_mobile/shared/providers/backup.provider.dart'; import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/providers/server_info.provider.dart';
class ImmichSliverAppBar extends ConsumerWidget { class ImmichSliverAppBar extends ConsumerWidget {
@ -130,7 +130,8 @@ class ImmichSliverAppBar extends ConsumerWidget {
? Positioned( ? Positioned(
bottom: 5, bottom: 5,
child: Text( child: Text(
_backupState.backingUpAssetCount.toString(), (_backupState.allUniqueAssets.length - _backupState.selectedAlbumsBackupAssetsIds.length)
.toString(),
style: const TextStyle(fontSize: 9, fontWeight: FontWeight.bold), style: const TextStyle(fontSize: 9, fontWeight: FontWeight.bold),
), ),
) )

View file

@ -6,7 +6,7 @@ import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart'; import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/models/server_info_state.model.dart'; import 'package:immich_mobile/shared/models/server_info_state.model.dart';
import 'package:immich_mobile/shared/providers/backup.provider.dart'; import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/shared/providers/server_info.provider.dart'; import 'package:immich_mobile/shared/providers/server_info.provider.dart';
import 'package:immich_mobile/shared/providers/websocket.provider.dart'; import 'package:immich_mobile/shared/providers/websocket.provider.dart';
import 'package:package_info_plus/package_info_plus.dart'; import 'package:package_info_plus/package_info_plus.dart';

View file

@ -6,7 +6,7 @@ import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart'; import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart'; import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/modules/login/models/login_response.model.dart'; import 'package:immich_mobile/modules/login/models/login_response.model.dart';
import 'package:immich_mobile/shared/services/backup.service.dart'; import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/shared/services/device_info.service.dart'; import 'package:immich_mobile/shared/services/device_info.service.dart';
import 'package:immich_mobile/shared/services/network.service.dart'; import 'package:immich_mobile/shared/services/network.service.dart';
import 'package:immich_mobile/shared/models/device_info.model.dart'; import 'package:immich_mobile/shared/models/device_info.model.dart';

View file

@ -7,7 +7,7 @@ import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart'; import 'package:immich_mobile/modules/login/models/hive_saved_login_info.model.dart';
import 'package:immich_mobile/shared/providers/asset.provider.dart'; import 'package:immich_mobile/shared/providers/asset.provider.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart'; import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
import 'package:immich_mobile/shared/providers/backup.provider.dart'; import 'package:immich_mobile/modules/backup/providers/backup.provider.dart';
import 'package:immich_mobile/shared/ui/immich_toast.dart'; import 'package:immich_mobile/shared/ui/immich_toast.dart';
class LoginForm extends HookConsumerWidget { class LoginForm extends HookConsumerWidget {

View file

@ -1,5 +1,7 @@
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:immich_mobile/modules/backup/views/album_preview_page.dart';
import 'package:immich_mobile/modules/backup/views/backup_album_selection_page.dart';
import 'package:immich_mobile/modules/login/views/login_page.dart'; import 'package:immich_mobile/modules/login/views/login_page.dart';
import 'package:immich_mobile/modules/home/views/home_page.dart'; import 'package:immich_mobile/modules/home/views/home_page.dart';
import 'package:immich_mobile/modules/search/views/search_page.dart'; import 'package:immich_mobile/modules/search/views/search_page.dart';
@ -14,10 +16,11 @@ import 'package:immich_mobile/modules/sharing/views/select_user_for_sharing_page
import 'package:immich_mobile/modules/sharing/views/sharing_page.dart'; import 'package:immich_mobile/modules/sharing/views/sharing_page.dart';
import 'package:immich_mobile/routing/auth_guard.dart'; import 'package:immich_mobile/routing/auth_guard.dart';
import 'package:immich_mobile/shared/models/immich_asset.model.dart'; import 'package:immich_mobile/shared/models/immich_asset.model.dart';
import 'package:immich_mobile/shared/views/backup_controller_page.dart'; import 'package:immich_mobile/modules/backup/views/backup_controller_page.dart';
import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart'; import 'package:immich_mobile/modules/asset_viewer/views/image_viewer_page.dart';
import 'package:immich_mobile/shared/views/tab_controller_page.dart'; import 'package:immich_mobile/shared/views/tab_controller_page.dart';
import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart'; import 'package:immich_mobile/modules/asset_viewer/views/video_viewer_page.dart';
import 'package:photo_manager/photo_manager.dart';
part 'router.gr.dart'; part 'router.gr.dart';
@ -55,6 +58,8 @@ part 'router.gr.dart';
guards: [AuthGuard], guards: [AuthGuard],
transitionsBuilder: TransitionsBuilders.slideBottom, transitionsBuilder: TransitionsBuilders.slideBottom,
), ),
AutoRoute(page: BackupAlbumSelectionPage, guards: [AuthGuard]),
AutoRoute(page: AlbumPreviewPage, guards: [AuthGuard]),
], ],
) )
class AppRouter extends _$AppRouter { class AppRouter extends _$AppRouter {

View file

@ -93,6 +93,16 @@ class _$AppRouter extends RootStackRouter {
opaque: true, opaque: true,
barrierDismissible: false); barrierDismissible: false);
}, },
BackupAlbumSelectionRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData, child: const BackupAlbumSelectionPage());
},
AlbumPreviewRoute.name: (routeData) {
final args = routeData.argsAs<AlbumPreviewRouteArgs>();
return MaterialPageX<dynamic>(
routeData: routeData,
child: AlbumPreviewPage(key: args.key, album: args.album));
},
HomeRoute.name: (routeData) { HomeRoute.name: (routeData) {
return MaterialPageX<dynamic>( return MaterialPageX<dynamic>(
routeData: routeData, child: const HomePage()); routeData: routeData, child: const HomePage());
@ -149,7 +159,11 @@ class _$AppRouter extends RootStackRouter {
path: '/album-viewer-page', guards: [authGuard]), path: '/album-viewer-page', guards: [authGuard]),
RouteConfig(SelectAdditionalUserForSharingRoute.name, RouteConfig(SelectAdditionalUserForSharingRoute.name,
path: '/select-additional-user-for-sharing-page', path: '/select-additional-user-for-sharing-page',
guards: [authGuard]) guards: [authGuard]),
RouteConfig(BackupAlbumSelectionRoute.name,
path: '/backup-album-selection-page', guards: [authGuard]),
RouteConfig(AlbumPreviewRoute.name,
path: '/album-preview-page', guards: [authGuard])
]; ];
} }
@ -358,6 +372,40 @@ class SelectAdditionalUserForSharingRouteArgs {
} }
} }
/// generated route for
/// [BackupAlbumSelectionPage]
class BackupAlbumSelectionRoute extends PageRouteInfo<void> {
const BackupAlbumSelectionRoute()
: super(BackupAlbumSelectionRoute.name,
path: '/backup-album-selection-page');
static const String name = 'BackupAlbumSelectionRoute';
}
/// generated route for
/// [AlbumPreviewPage]
class AlbumPreviewRoute extends PageRouteInfo<AlbumPreviewRouteArgs> {
AlbumPreviewRoute({Key? key, required AssetPathEntity album})
: super(AlbumPreviewRoute.name,
path: '/album-preview-page',
args: AlbumPreviewRouteArgs(key: key, album: album));
static const String name = 'AlbumPreviewRoute';
}
class AlbumPreviewRouteArgs {
const AlbumPreviewRouteArgs({this.key, required this.album});
final Key? key;
final AssetPathEntity album;
@override
String toString() {
return 'AlbumPreviewRouteArgs{key: $key, album: $album}';
}
}
/// generated route for /// generated route for
/// [HomePage] /// [HomePage]
class HomeRoute extends PageRouteInfo<void> { class HomeRoute extends PageRouteInfo<void> {

View file

@ -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;
}
}

View file

@ -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);
});

View file

@ -239,6 +239,13 @@ packages:
url: "https://pub.dartlang.org" url: "https://pub.dartlang.org"
source: hosted source: hosted
version: "4.0.4" version: "4.0.4"
equatable:
dependency: "direct main"
description:
name: equatable
url: "https://pub.dartlang.org"
source: hosted
version: "2.0.3"
exif: exif:
dependency: "direct main" dependency: "direct main"
description: description:

View file

@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: "none" publish_to: "none"
version: 1.8.0+12 version: 1.9.0+13
environment: environment:
sdk: ">=2.15.1 <3.0.0" sdk: ">=2.15.1 <3.0.0"
@ -37,6 +37,7 @@ dependencies:
package_info_plus: ^1.4.0 package_info_plus: ^1.4.0
flutter_spinkit: ^5.1.0 flutter_spinkit: ^5.1.0
flutter_swipe_detector: ^2.0.0 flutter_swipe_detector: ^2.0.0
equatable: ^2.0.3
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:

View file

@ -3,7 +3,7 @@
export const serverVersion = { export const serverVersion = {
major: 1, major: 1,
minor: 8, minor: 9,
patch: 0, patch: 0,
build: 12, build: 13,
}; };