mirror of https://github.com/immich-app/immich.git
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 errorpull/246/head
parent
357f7d1c31
commit
58ec7553ea
@ -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;
|
||||||
|
}
|
||||||
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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,
|
||||||
|
];
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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(),
|
||||||
|
);
|
||||||
@ -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],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,9 @@
|
|||||||
|
import { IsNotEmpty } from 'class-validator';
|
||||||
|
|
||||||
|
export class CheckDuplicateAssetDto {
|
||||||
|
@IsNotEmpty()
|
||||||
|
deviceAssetId!: string;
|
||||||
|
|
||||||
|
@IsNotEmpty()
|
||||||
|
deviceId!: string;
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue