1
0
Fork 0
mirror of https://github.com/immich-app/immich.git synced 2025-01-16 00:36:47 +01:00

Add information for uploading asset and error indication with error message for each failed upload. (#315)

* Added info box

* Fixed upload endpoint doesn't report error status code

* Added chip to show update error

* Added chip to show failed upload

* Add duplication check for upload

* Better duplication-checking placement

* Remove check for duplicated asset

* Added failed backup status route

* added page

* Display error card with thumbnail

* Improved styling

* Set thumbnail with better quality

* Remove force upload error
This commit is contained in:
Alex 2022-07-06 16:12:55 -05:00 committed by GitHub
parent 357f7d1c31
commit 58ec7553ea
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 706 additions and 87 deletions

View file

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

View file

@ -1,13 +1,14 @@
import 'package:cancellation_token_http/http.dart';
import 'package:equatable/equatable.dart';
import 'package:collection/collection.dart';
import 'package:photo_manager/photo_manager.dart';
import 'package:immich_mobile/modules/backup/models/available_album.model.dart';
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
import 'package:immich_mobile/shared/models/server_info.model.dart';
enum BackUpProgressEnum { idle, inProgress, done }
class BackUpState extends Equatable {
class BackUpState {
// enum
final BackUpProgressEnum backupProgress;
final List<String> allAssetsInDatabase;
@ -26,6 +27,9 @@ class BackUpState extends Equatable {
/// All assets from the selected albums that have been backup
final Set<String> selectedAlbumsBackupAssetsIds;
// Current Backup Asset
final CurrentUploadAsset currentUploadAsset;
const BackUpState({
required this.backupProgress,
required this.allAssetsInDatabase,
@ -37,6 +41,7 @@ class BackUpState extends Equatable {
required this.excludedBackupAlbums,
required this.allUniqueAssets,
required this.selectedAlbumsBackupAssetsIds,
required this.currentUploadAsset,
});
BackUpState copyWith({
@ -50,6 +55,7 @@ class BackUpState extends Equatable {
Set<AssetPathEntity>? excludedBackupAlbums,
Set<AssetEntity>? allUniqueAssets,
Set<String>? selectedAlbumsBackupAssetsIds,
CurrentUploadAsset? currentUploadAsset,
}) {
return BackUpState(
backupProgress: backupProgress ?? this.backupProgress,
@ -63,27 +69,47 @@ class BackUpState extends Equatable {
allUniqueAssets: allUniqueAssets ?? this.allUniqueAssets,
selectedAlbumsBackupAssetsIds:
selectedAlbumsBackupAssetsIds ?? this.selectedAlbumsBackupAssetsIds,
currentUploadAsset: currentUploadAsset ?? this.currentUploadAsset,
);
}
@override
String toString() {
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds)';
return 'BackUpState(backupProgress: $backupProgress, allAssetsInDatabase: $allAssetsInDatabase, progressInPercentage: $progressInPercentage, cancelToken: $cancelToken, serverInfo: $serverInfo, availableAlbums: $availableAlbums, selectedBackupAlbums: $selectedBackupAlbums, excludedBackupAlbums: $excludedBackupAlbums, allUniqueAssets: $allUniqueAssets, selectedAlbumsBackupAssetsIds: $selectedAlbumsBackupAssetsIds, currentUploadAsset: $currentUploadAsset)';
}
@override
List<Object> get props {
return [
backupProgress,
allAssetsInDatabase,
progressInPercentage,
cancelToken,
serverInfo,
availableAlbums,
selectedBackupAlbums,
excludedBackupAlbums,
allUniqueAssets,
selectedAlbumsBackupAssetsIds,
];
bool operator ==(Object other) {
if (identical(this, other)) return true;
final collectionEquals = const DeepCollectionEquality().equals;
return other is BackUpState &&
other.backupProgress == backupProgress &&
collectionEquals(other.allAssetsInDatabase, allAssetsInDatabase) &&
other.progressInPercentage == progressInPercentage &&
other.cancelToken == cancelToken &&
other.serverInfo == serverInfo &&
collectionEquals(other.availableAlbums, availableAlbums) &&
collectionEquals(other.selectedBackupAlbums, selectedBackupAlbums) &&
collectionEquals(other.excludedBackupAlbums, excludedBackupAlbums) &&
collectionEquals(other.allUniqueAssets, allUniqueAssets) &&
collectionEquals(other.selectedAlbumsBackupAssetsIds,
selectedAlbumsBackupAssetsIds) &&
other.currentUploadAsset == currentUploadAsset;
}
@override
int get hashCode {
return backupProgress.hashCode ^
allAssetsInDatabase.hashCode ^
progressInPercentage.hashCode ^
cancelToken.hashCode ^
serverInfo.hashCode ^
availableAlbums.hashCode ^
selectedBackupAlbums.hashCode ^
excludedBackupAlbums.hashCode ^
allUniqueAssets.hashCode ^
selectedAlbumsBackupAssetsIds.hashCode ^
currentUploadAsset.hashCode;
}
}

View file

@ -0,0 +1,48 @@
import 'dart:convert';
class CheckDuplicateAssetResponse {
final bool isExist;
CheckDuplicateAssetResponse({
required this.isExist,
});
CheckDuplicateAssetResponse copyWith({
bool? isExist,
}) {
return CheckDuplicateAssetResponse(
isExist: isExist ?? this.isExist,
);
}
Map<String, dynamic> toMap() {
final result = <String, dynamic>{};
result.addAll({'isExist': isExist});
return result;
}
factory CheckDuplicateAssetResponse.fromMap(Map<String, dynamic> map) {
return CheckDuplicateAssetResponse(
isExist: map['isExist'] ?? false,
);
}
String toJson() => json.encode(toMap());
factory CheckDuplicateAssetResponse.fromJson(String source) =>
CheckDuplicateAssetResponse.fromMap(json.decode(source));
@override
String toString() => 'CheckDuplicateAssetResponse(isExist: $isExist)';
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is CheckDuplicateAssetResponse && other.isExist == isExist;
}
@override
int get hashCode => isExist.hashCode;
}

View file

@ -0,0 +1,78 @@
import 'dart:convert';
class CurrentUploadAsset {
final String id;
final DateTime createdAt;
final String fileName;
final String fileType;
CurrentUploadAsset({
required this.id,
required this.createdAt,
required this.fileName,
required this.fileType,
});
CurrentUploadAsset copyWith({
String? id,
DateTime? createdAt,
String? fileName,
String? fileType,
}) {
return CurrentUploadAsset(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
fileName: fileName ?? this.fileName,
fileType: fileType ?? this.fileType,
);
}
Map<String, dynamic> toMap() {
final result = <String, dynamic>{};
result.addAll({'id': id});
result.addAll({'createdAt': createdAt.millisecondsSinceEpoch});
result.addAll({'fileName': fileName});
result.addAll({'fileType': fileType});
return result;
}
factory CurrentUploadAsset.fromMap(Map<String, dynamic> map) {
return CurrentUploadAsset(
id: map['id'] ?? '',
createdAt: DateTime.fromMillisecondsSinceEpoch(map['createdAt']),
fileName: map['fileName'] ?? '',
fileType: map['fileType'] ?? '',
);
}
String toJson() => json.encode(toMap());
factory CurrentUploadAsset.fromJson(String source) =>
CurrentUploadAsset.fromMap(json.decode(source));
@override
String toString() {
return 'CurrentUploadAsset(id: $id, createdAt: $createdAt, fileName: $fileName, fileType: $fileType)';
}
@override
bool operator ==(Object other) {
if (identical(this, other)) return true;
return other is CurrentUploadAsset &&
other.id == id &&
other.createdAt == createdAt &&
other.fileName == fileName &&
other.fileType == fileType;
}
@override
int get hashCode {
return id.hashCode ^
createdAt.hashCode ^
fileName.hashCode ^
fileType.hashCode;
}
}

View file

@ -0,0 +1,53 @@
import 'package:equatable/equatable.dart';
import 'package:photo_manager/photo_manager.dart';
class ErrorUploadAsset extends Equatable {
final String id;
final DateTime createdAt;
final String fileName;
final String fileType;
final AssetEntity asset;
final String errorMessage;
const ErrorUploadAsset({
required this.id,
required this.createdAt,
required this.fileName,
required this.fileType,
required this.asset,
required this.errorMessage,
});
ErrorUploadAsset copyWith({
String? id,
DateTime? createdAt,
String? fileName,
String? fileType,
AssetEntity? asset,
String? errorMessage,
}) {
return ErrorUploadAsset(
id: id ?? this.id,
createdAt: createdAt ?? this.createdAt,
fileName: fileName ?? this.fileName,
fileType: fileType ?? this.fileType,
asset: asset ?? this.asset,
errorMessage: errorMessage ?? this.errorMessage,
);
}
@override
String toString() {
return 'ErrorUploadAsset(id: $id, createdAt: $createdAt, fileName: $fileName, fileType: $fileType, asset: $asset, errorMessage: $errorMessage)';
}
@override
List<Object> get props {
return [
id,
fileName,
fileType,
errorMessage,
];
}
}

View file

@ -5,7 +5,10 @@ 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/backup_state.model.dart';
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/hive_backup_albums.model.dart';
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
import 'package:immich_mobile/modules/backup/services/backup.service.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
@ -14,8 +17,12 @@ import 'package:immich_mobile/shared/services/server_info.service.dart';
import 'package:photo_manager/photo_manager.dart';
class BackupNotifier extends StateNotifier<BackUpState> {
BackupNotifier(this._backupService, this._serverInfoService, this._authState)
: super(
BackupNotifier(
this._backupService,
this._serverInfoService,
this._authState,
this.ref,
) : super(
BackUpState(
backupProgress: BackUpProgressEnum.idle,
allAssetsInDatabase: const [],
@ -35,6 +42,12 @@ class BackupNotifier extends StateNotifier<BackUpState> {
excludedBackupAlbums: const {},
allUniqueAssets: const {},
selectedAlbumsBackupAssetsIds: const {},
currentUploadAsset: CurrentUploadAsset(
id: '...',
createdAt: DateTime.parse('2020-10-04'),
fileName: '...',
fileType: '...',
),
),
) {
getBackupInfo();
@ -43,6 +56,7 @@ class BackupNotifier extends StateNotifier<BackUpState> {
final BackupService _backupService;
final ServerInfoService _serverInfoService;
final AuthenticationState _authState;
final Ref ref;
///
/// UI INTERACTION
@ -235,8 +249,11 @@ class BackupNotifier extends StateNotifier<BackUpState> {
/// and then update the UI according to those information
///
Future<void> getBackupInfo() async {
await _getBackupAlbumsInfo();
await _updateServerInfo();
await Future.wait([
_getBackupAlbumsInfo(),
_updateServerInfo(),
]);
await _updateBackupAssetCount();
}
@ -287,13 +304,27 @@ class BackupNotifier extends StateNotifier<BackUpState> {
// Perform Backup
state = state.copyWith(cancelToken: CancellationToken());
_backupService.backupAsset(assetsWillBeBackup, state.cancelToken,
_onAssetUploaded, _onUploadProgress);
_backupService.backupAsset(
assetsWillBeBackup,
state.cancelToken,
_onAssetUploaded,
_onUploadProgress,
_onSetCurrentBackupAsset,
_onBackupError,
);
} else {
PhotoManager.openSetting();
}
}
void _onBackupError(ErrorUploadAsset errorAssetInfo) {
ref.watch(errorBackupListProvider.notifier).add(errorAssetInfo);
}
void _onSetCurrentBackupAsset(CurrentUploadAsset currentUploadAsset) {
state = state.copyWith(currentUploadAsset: currentUploadAsset);
}
void cancelBackup() {
state.cancelToken.cancel();
state = state.copyWith(
@ -375,5 +406,6 @@ final backupProvider =
ref.watch(backupServiceProvider),
ref.watch(serverInfoServiceProvider),
ref.watch(authenticationProvider),
ref,
);
});

View file

@ -0,0 +1,23 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
class ErrorBackupListNotifier extends StateNotifier<Set<ErrorUploadAsset>> {
ErrorBackupListNotifier() : super({});
add(ErrorUploadAsset errorAsset) {
state = state.union({errorAsset});
}
remove(ErrorUploadAsset errorAsset) {
state = state.difference({errorAsset});
}
empty() {
state = {};
}
}
final errorBackupListProvider =
StateNotifierProvider<ErrorBackupListNotifier, Set<ErrorUploadAsset>>(
(ref) => ErrorBackupListNotifier(),
);

View file

@ -7,6 +7,9 @@ import 'package:flutter/material.dart';
import 'package:hive/hive.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/constants/hive_box.dart';
import 'package:immich_mobile/modules/backup/models/check_duplicate_asset_response.model.dart';
import 'package:immich_mobile/modules/backup/models/current_upload_asset.model.dart';
import 'package:immich_mobile/modules/backup/models/error_upload_asset.model.dart';
import 'package:immich_mobile/shared/services/network.service.dart';
import 'package:immich_mobile/shared/models/device_info.model.dart';
import 'package:immich_mobile/utils/files_helper.dart';
@ -20,6 +23,7 @@ final backupServiceProvider =
class BackupService {
final NetworkService _networkService;
BackupService(this._networkService);
Future<List<String>> getDeviceBackupAsset() async {
@ -32,17 +36,40 @@ class BackupService {
return result.cast<String>();
}
Future<bool> checkDuplicateAsset(String deviceAssetId) async {
String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
try {
Response response =
await _networkService.postRequest(url: "asset/check", data: {
"deviceId": deviceId,
"deviceAssetId": deviceAssetId,
});
if (response.statusCode == 200) {
var result = CheckDuplicateAssetResponse.fromJson(response.toString());
return result.isExist;
} else {
return false;
}
} catch (e) {
return false;
}
}
backupAsset(
Set<AssetEntity> assetList,
http.CancellationToken cancelToken,
Function(String, String) singleAssetDoneCb,
Function(int, int) uploadProgress) async {
Set<AssetEntity> assetList,
http.CancellationToken cancelToken,
Function(String, String) singleAssetDoneCb,
Function(int, int) uploadProgressCb,
Function(CurrentUploadAsset) setCurrentUploadAssetCb,
Function(ErrorUploadAsset) errorCb,
) async {
String deviceId = Hive.box(userInfoBox).get(deviceIdKey);
String savedEndpoint = Hive.box(userInfoBox).get(serverEndpointKey);
File? file;
http.MultipartFile? thumbnailUploadData;
for (var entity in assetList) {
try {
if (entity.type == AssetType.video) {
@ -74,7 +101,7 @@ class BackupService {
var req = MultipartRequest(
'POST', Uri.parse('$savedEndpoint/asset/upload'),
onProgress: ((bytes, totalBytes) =>
uploadProgress(bytes, totalBytes)));
uploadProgressCb(bytes, totalBytes)));
req.headers["Authorization"] = "Bearer ${box.get(accessTokenKey)}";
req.fields['deviceAssetId'] = entity.id;
@ -88,10 +115,35 @@ class BackupService {
req.files.add(assetRawUploadData);
var res = await req.send(cancellationToken: cancelToken);
setCurrentUploadAssetCb(
CurrentUploadAsset(
id: entity.id,
createdAt: entity.createDateTime,
fileName: originalFileName,
fileType: _getAssetType(entity.type),
),
);
if (res.statusCode == 201) {
var response = await req.send(cancellationToken: cancelToken);
if (response.statusCode == 201) {
singleAssetDoneCb(entity.id, deviceId);
} else {
var data = await response.stream.bytesToString();
var error = jsonDecode(data);
debugPrint(
"Error(${error['statusCode']}) uploading ${entity.id} | $originalFileName | Created on ${entity.createDateTime} | ${error['error']}");
errorCb(ErrorUploadAsset(
asset: entity,
id: entity.id,
createdAt: entity.createDateTime,
fileName: originalFileName,
fileType: _getAssetType(entity.type),
errorMessage: error['error'],
));
continue;
}
}
} on http.CancelledException {
@ -108,6 +160,8 @@ class BackupService {
}
}
void sendBackupRequest(AssetEntity entity) {}
String _getAssetType(AssetType assetType) {
switch (assetType) {
case AssetType.audio:

View file

@ -2,6 +2,7 @@ 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/modules/backup/providers/error_backup_list.provider.dart';
import 'package:immich_mobile/modules/login/models/authentication_state.model.dart';
import 'package:immich_mobile/modules/backup/models/backup_state.model.dart';
import 'package:immich_mobile/modules/login/providers/authentication.provider.dart';
@ -9,6 +10,7 @@ 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:intl/intl.dart';
import 'package:percent_indicator/linear_percent_indicator.dart';
class BackupControllerPage extends HookConsumerWidget {
@ -42,7 +44,7 @@ class BackupControllerPage extends HookConsumerWidget {
color: Theme.of(context).primaryColor,
),
title: const Text(
"Server Storage",
"Server storage",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
subtitle: Padding(
@ -56,7 +58,7 @@ class BackupControllerPage extends HookConsumerWidget {
padding:
const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
barRadius: const Radius.circular(2),
lineHeight: 6.0,
lineHeight: 10.0,
percent: backupState.serverInfo.diskUsagePercentage / 100.0,
backgroundColor: Colors.grey,
progressColor: Theme.of(context).primaryColor,
@ -246,6 +248,141 @@ class BackupControllerPage extends HookConsumerWidget {
);
}
_buildCurrentBackupAssetInfoCard() {
return ListTile(
leading: Icon(
Icons.info_outline_rounded,
color: Theme.of(context).primaryColor,
),
title: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
"Uploading file info",
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 14),
),
if (ref.watch(errorBackupListProvider).isNotEmpty)
ActionChip(
avatar: Icon(
Icons.info,
size: 24,
color: Colors.red[400],
),
elevation: 1,
visualDensity: VisualDensity.compact,
label: Text(
"Failed (${ref.watch(errorBackupListProvider).length})",
style: TextStyle(
color: Colors.red[400],
fontWeight: FontWeight.bold,
fontSize: 11,
),
),
backgroundColor: Colors.white,
onPressed: () {
AutoRouter.of(context).push(const FailedBackupStatusRoute());
},
),
],
),
subtitle: Column(
children: [
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: LinearPercentIndicator(
padding: const EdgeInsets.symmetric(horizontal: 0, vertical: 0),
barRadius: const Radius.circular(2),
lineHeight: 10.0,
trailing: Text(
" ${backupState.progressInPercentage.toStringAsFixed(0)}%",
style: const TextStyle(fontSize: 12),
),
percent: backupState.progressInPercentage / 100.0,
backgroundColor: Colors.grey,
progressColor: Theme.of(context).primaryColor,
),
),
Padding(
padding: const EdgeInsets.only(top: 8.0),
child: Table(
border: TableBorder.all(
color: Colors.black12,
width: 1,
),
children: [
TableRow(
decoration: BoxDecoration(
color: Colors.grey[100],
),
children: [
TableCell(
verticalAlignment: TableCellVerticalAlignment.middle,
child: Padding(
padding: const EdgeInsets.all(6.0),
child: Text(
'File name: ${backupState.currentUploadAsset.fileName} [${backupState.currentUploadAsset.fileType.toLowerCase()}]',
style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 10.0),
),
),
),
],
),
TableRow(
decoration: BoxDecoration(
color: Colors.grey[200],
),
children: [
TableCell(
verticalAlignment: TableCellVerticalAlignment.middle,
child: Padding(
padding: const EdgeInsets.all(6.0),
child: Text(
"Created on: ${DateFormat.yMMMMd('en_US').format(
DateTime.parse(
backupState.currentUploadAsset.createdAt
.toString(),
),
)}",
style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 10.0),
),
),
),
],
),
TableRow(
decoration: BoxDecoration(
color: Colors.grey[100],
),
children: [
TableCell(
child: Padding(
padding: const EdgeInsets.all(6.0),
child: Text(
"ID: ${backupState.currentUploadAsset.id}",
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 10.0,
),
),
),
),
],
),
],
),
),
],
),
);
}
void startBackup() {
ref.watch(errorBackupListProvider.notifier).empty();
ref.watch(backupProvider.notifier).startBackupProcess();
}
return Scaffold(
appBar: AppBar(
elevation: 0,
@ -264,7 +401,7 @@ class BackupControllerPage extends HookConsumerWidget {
)),
),
body: Padding(
padding: const EdgeInsets.all(16.0),
padding: const EdgeInsets.only(left: 16.0, right: 16, bottom: 32),
child: ListView(
// crossAxisAlignment: CrossAxisAlignment.start,
children: [
@ -297,23 +434,11 @@ class BackupControllerPage extends HookConsumerWidget {
const Divider(),
_buildStorageInformation(),
const Divider(),
_buildCurrentBackupAssetInfoCard(),
Padding(
padding: const EdgeInsets.all(8.0),
child: Text(
"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
? const CircularProgressIndicator.adaptive()
: const Text("Done"),
]),
),
Padding(
padding: const EdgeInsets.all(8.0),
padding: const EdgeInsets.only(
top: 24,
),
child: Container(
child:
backupState.backupProgress == BackUpProgressEnum.inProgress
@ -321,25 +446,33 @@ class BackupControllerPage extends HookConsumerWidget {
style: ElevatedButton.styleFrom(
primary: Colors.red[300],
onPrimary: Colors.grey[50],
padding: const EdgeInsets.all(14),
),
onPressed: () {
ref.read(backupProvider.notifier).cancelBackup();
},
child: const Text("Cancel"),
child: const Text(
"CANCEL",
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
)
: ElevatedButton(
style: ElevatedButton.styleFrom(
primary: Theme.of(context).primaryColor,
onPrimary: Colors.grey[50],
padding: const EdgeInsets.all(14),
),
onPressed: shouldBackup ? startBackup : null,
child: const Text(
"START BACKUP",
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.bold,
),
),
onPressed: shouldBackup
? () {
ref
.read(backupProvider.notifier)
.startBackupProcess();
}
: null,
child: const Text("Start Backup"),
),
),
)

View file

@ -0,0 +1,139 @@
import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/modules/backup/providers/error_backup_list.provider.dart';
import 'package:intl/intl.dart';
import 'package:photo_manager/photo_manager.dart';
class FailedBackupStatusPage extends HookConsumerWidget {
const FailedBackupStatusPage({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final errorBackupList = ref.watch(errorBackupListProvider);
return Scaffold(
appBar: AppBar(
elevation: 0,
title: Text(
"Failed Backup (${errorBackupList.length})",
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
),
leading: IconButton(
onPressed: () {
AutoRouter.of(context).pop(true);
},
splashRadius: 24,
icon: const Icon(
Icons.arrow_back_ios_rounded,
)),
),
body: ListView.builder(
shrinkWrap: true,
itemCount: errorBackupList.length,
itemBuilder: ((context, index) {
var errorAsset = errorBackupList.elementAt(index);
return Padding(
padding: const EdgeInsets.symmetric(
horizontal: 12.0,
vertical: 4,
),
child: Card(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(15), // if you need this
side: const BorderSide(
color: Colors.black12,
width: 1,
),
),
elevation: 0,
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
ConstrainedBox(
constraints: const BoxConstraints(
minWidth: 100,
minHeight: 150,
maxWidth: 100,
maxHeight: 200,
),
child: ClipRRect(
borderRadius: const BorderRadius.only(
bottomLeft: Radius.circular(15),
topLeft: Radius.circular(15),
),
clipBehavior: Clip.hardEdge,
child: Image(
fit: BoxFit.cover,
image: AssetEntityImageProvider(
errorAsset.asset,
isOriginal: false,
thumbnailSize: const ThumbnailSize.square(512),
thumbnailFormat: ThumbnailFormat.jpeg,
),
),
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
DateFormat.yMMMMd('en_US').format(
DateTime.parse(
errorAsset.createdAt.toString(),
),
),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: Colors.grey[700]),
),
Icon(
Icons.error,
color: Colors.red.withAlpha(200),
size: 18,
),
],
),
Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Text(
errorAsset.fileName,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 12,
color: Theme.of(context).primaryColor,
),
),
),
Text(
errorAsset.errorMessage,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: Colors.grey[800],
),
),
],
),
),
)
],
),
),
);
}),
),
);
}
}

View file

@ -2,6 +2,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/backup/views/failed_backup_status_page.dart';
import 'package:immich_mobile/modules/login/views/change_password_page.dart';
import 'package:immich_mobile/modules/login/views/login_page.dart';
import 'package:immich_mobile/modules/home/views/home_page.dart';
@ -65,6 +66,11 @@ part 'router.gr.dart';
),
AutoRoute(page: BackupAlbumSelectionPage, guards: [AuthGuard]),
AutoRoute(page: AlbumPreviewPage, guards: [AuthGuard]),
CustomRoute(
page: FailedBackupStatusPage,
guards: [AuthGuard],
transitionsBuilder: TransitionsBuilders.slideBottom,
),
],
)
class AppRouter extends _$AppRouter {

View file

@ -115,6 +115,14 @@ class _$AppRouter extends RootStackRouter {
routeData: routeData,
child: AlbumPreviewPage(key: args.key, album: args.album));
},
FailedBackupStatusRoute.name: (routeData) {
return CustomPage<dynamic>(
routeData: routeData,
child: const FailedBackupStatusPage(),
transitionsBuilder: TransitionsBuilders.slideBottom,
opaque: true,
barrierDismissible: false);
},
HomeRoute.name: (routeData) {
return MaterialPageX<dynamic>(
routeData: routeData, child: const HomePage());
@ -177,7 +185,9 @@ class _$AppRouter extends RootStackRouter {
RouteConfig(BackupAlbumSelectionRoute.name,
path: '/backup-album-selection-page', guards: [authGuard]),
RouteConfig(AlbumPreviewRoute.name,
path: '/album-preview-page', guards: [authGuard])
path: '/album-preview-page', guards: [authGuard]),
RouteConfig(FailedBackupStatusRoute.name,
path: '/failed-backup-status-page', guards: [authGuard])
];
}
@ -437,6 +447,15 @@ class AlbumPreviewRouteArgs {
}
}
/// generated route for
/// [FailedBackupStatusPage]
class FailedBackupStatusRoute extends PageRouteInfo<void> {
const FailedBackupStatusRoute()
: super(FailedBackupStatusRoute.name, path: '/failed-backup-status-page');
static const String name = 'FailedBackupStatusRoute';
}
/// generated route for
/// [HomePage]
class HomeRoute extends PageRouteInfo<void> {

View file

@ -79,7 +79,7 @@ class NetworkService {
return res;
} on DioError catch (e) {
debugPrint("DioError: ${e.response}");
debugPrint("[postRequest] DioError: ${e.response}");
return null;
} catch (e) {
debugPrint("ERROR PostRequest: $e");

View file

@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
publish_to: "none"
version: 1.16.1+24
version: 1.17.0+25
environment:
sdk: ">=2.17.0 <3.0.0"

View file

@ -15,6 +15,7 @@ import {
Delete,
Logger,
HttpCode,
BadRequestException,
} from '@nestjs/common';
import { JwtAuthGuard } from '../../modules/immich-jwt/guards/jwt-auth.guard';
import { AssetService } from './asset.service';
@ -34,6 +35,7 @@ import { Queue } from 'bull';
import { IAssetUploadedJob } from '@app/job/index';
import { assetUploadedQueueName } from '@app/job/constants/queue-name.constant';
import { assetUploadedProcessorName } from '@app/job/constants/job-name.constant';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
@UseGuards(JwtAuthGuard)
@Controller('asset')
@ -66,17 +68,16 @@ export class AssetController {
try {
const savedAsset = await this.assetService.createUserAsset(authUser, assetInfo, file.path, file.mimetype);
if (!savedAsset) {
return;
if (savedAsset) {
await this.assetUploadedQueue.add(
assetUploadedProcessorName,
{ asset: savedAsset, fileName: file.originalname, fileSize: file.size },
{ jobId: savedAsset.id },
);
}
await this.assetUploadedQueue.add(
assetUploadedProcessorName,
{ asset: savedAsset, fileName: file.originalname, fileSize: file.size },
{ jobId: savedAsset.id },
);
} catch (e) {
Logger.error(`Error receiving upload file ${e}`);
Logger.error(`Error uploading file ${e}`);
throw new BadRequestException(`Error uploading file`, `${e}`);
}
}
@ -172,9 +173,9 @@ export class AssetController {
@HttpCode(200)
async checkDuplicateAsset(
@GetAuthUser() authUser: AuthUserDto,
@Body(ValidationPipe) { deviceAssetId }: { deviceAssetId: string },
@Body(ValidationPipe) checkDuplicateAssetDto: CheckDuplicateAssetDto,
) {
const res = await this.assetService.checkDuplicatedAsset(authUser, deviceAssetId);
const res = await this.assetService.checkDuplicatedAsset(authUser, checkDuplicateAssetDto);
return {
isExist: res,

View file

@ -18,6 +18,7 @@ import { promisify } from 'util';
import { DeleteAssetDto } from './dto/delete-asset.dto';
import { SearchAssetDto } from './dto/search-asset.dto';
import fs from 'fs/promises';
import { CheckDuplicateAssetDto } from './dto/check-duplicate-asset.dto';
const fileInfo = promisify(stat);
@ -58,15 +59,11 @@ export class AssetService {
asset.mimeType = mimeType;
asset.duration = assetInfo.duration || null;
try {
const createdAsset = await this.assetRepository.save(asset);
if (!createdAsset) {
throw new Error('Asset not created');
}
return createdAsset;
} catch (e) {
Logger.error(`Error Create New Asset ${e}`, 'createUserAsset');
const createdAsset = await this.assetRepository.save(asset);
if (!createdAsset) {
throw new Error('Asset not created');
}
return createdAsset;
}
public async getUserAssetsByDeviceId(authUser: AuthUserDto, deviceId: string) {
@ -439,10 +436,11 @@ export class AssetService {
);
}
async checkDuplicatedAsset(authUser: AuthUserDto, deviceAssetId: string) {
async checkDuplicatedAsset(authUser: AuthUserDto, checkDuplicateAssetDto: CheckDuplicateAssetDto) {
const res = await this.assetRepository.findOne({
where: {
deviceAssetId,
deviceAssetId: checkDuplicateAssetDto.deviceAssetId,
deviceId: checkDuplicateAssetDto.deviceId,
userId: authUser.id,
},
});

View file

@ -0,0 +1,9 @@
import { IsNotEmpty } from 'class-validator';
export class CheckDuplicateAssetDto {
@IsNotEmpty()
deviceAssetId!: string;
@IsNotEmpty()
deviceId!: string;
}

View file

@ -3,7 +3,7 @@
export const serverVersion = {
major: 1,
minor: 16,
minor: 17,
patch: 0,
build: 23,
build: 25,
};

View file

@ -53,7 +53,7 @@ export async function fileUploader(asset: File, accessToken: string) {
// Check if asset upload on server before performing upload
const res = await fetch(serverEndpoint + '/asset/check', {
method: 'POST',
body: JSON.stringify({ deviceAssetId }),
body: JSON.stringify({ deviceAssetId, deviceId: 'WEB' }),
headers: {
Authorization: 'Bearer ' + accessToken,
'Content-Type': 'application/json',