1. 新增工作台三栏布局模式,适配宽屏设备 2. 添加跨平台系统托盘支持,新增托盘图标资源 3. 修复工作台模式下导航返回异常问题 4. 统一JSON类型安全解析,替换硬类型转换 5. 增加macOS深度链接支持,统一渠道分发信息 6. 优化部分页面生命周期和状态加载逻辑 7. 移除废弃的nearby_connections依赖
347 lines
11 KiB
Dart
347 lines
11 KiB
Dart
// ============================================================
|
|
// 闲言APP — 传输任务模型
|
|
// 创建时间: 2026-05-09
|
|
// 更新时间: 2026-06-19
|
|
// 作用: 文件传输助手传输任务数据模型 — 进度/速度/状态/校验/断点续传
|
|
// 上次更新: 类型安全修复(int vs num): receivedChunks 列表元素使用 SafeJson.parseInt
|
|
// ============================================================
|
|
|
|
import 'package:xianyan/core/utils/safe_json.dart';
|
|
import 'transfer_enums.dart';
|
|
import 'transfer_device.dart';
|
|
|
|
class TransferTask {
|
|
TransferTask({
|
|
required this.id,
|
|
required this.sessionId,
|
|
required this.peer,
|
|
required this.transport,
|
|
required this.direction,
|
|
required this.status,
|
|
required this.fileName,
|
|
required this.fileSize,
|
|
required this.transferredBytes,
|
|
required this.speed,
|
|
required this.startTime,
|
|
this.mimeType,
|
|
this.filePath,
|
|
this.thumbnailPath,
|
|
this.endTime,
|
|
this.errorMessage,
|
|
this.fileSha256,
|
|
this.hashVerified,
|
|
this.fileId,
|
|
this.chunkSize = 65536,
|
|
this.totalChunks,
|
|
this.receivedChunks,
|
|
this.retryCount = 0,
|
|
this.maxRetries = 3,
|
|
this.isResumable = false,
|
|
this.pausedAt,
|
|
});
|
|
|
|
final String id;
|
|
final String sessionId;
|
|
final TransferDevice peer;
|
|
final TransportType transport;
|
|
final TransferDirection direction;
|
|
TransferTaskStatus status;
|
|
final String fileName;
|
|
final int fileSize;
|
|
int transferredBytes;
|
|
double speed;
|
|
final String? mimeType;
|
|
final String? filePath;
|
|
final String? thumbnailPath;
|
|
final DateTime startTime;
|
|
DateTime? endTime;
|
|
String? errorMessage;
|
|
final String? fileSha256;
|
|
bool? hashVerified;
|
|
|
|
final String? fileId;
|
|
final int chunkSize;
|
|
final int? totalChunks;
|
|
final Set<int>? receivedChunks;
|
|
int retryCount;
|
|
final int maxRetries;
|
|
final bool isResumable;
|
|
final DateTime? pausedAt;
|
|
|
|
bool get canResume => isResumable && isPaused && fileId != null;
|
|
bool get canRetry => isFailed && retryCount < maxRetries;
|
|
double get chunkProgress {
|
|
if (totalChunks == null || totalChunks! <= 0) return progressPercent;
|
|
final received = receivedChunks?.length ?? 0;
|
|
return (received / totalChunks!).clamp(0.0, 1.0);
|
|
}
|
|
|
|
int? get missingChunks {
|
|
if (totalChunks == null) return null;
|
|
final received = receivedChunks?.length ?? 0;
|
|
return totalChunks! - received;
|
|
}
|
|
|
|
double get progressPercent {
|
|
if (fileSize <= 0) return 0.0;
|
|
final raw = transferredBytes / fileSize;
|
|
return raw.clamp(0.0, 1.0);
|
|
}
|
|
|
|
double get progressPercentDisplay =>
|
|
double.parse((progressPercent * 100).toStringAsFixed(1));
|
|
|
|
String get progressText => '${(progressPercent * 100).toStringAsFixed(1)}%';
|
|
|
|
Duration? get duration {
|
|
final end = endTime ?? DateTime.now();
|
|
return end.difference(startTime);
|
|
}
|
|
|
|
String get durationText {
|
|
final d = duration;
|
|
if (d == null) return '--';
|
|
final minutes = d.inMinutes;
|
|
final seconds = d.inSeconds.remainder(60);
|
|
if (minutes > 0) return '$minutes:${seconds.toString().padLeft(2, '0')}';
|
|
return '${seconds}s';
|
|
}
|
|
|
|
String get remainingTimeText {
|
|
if (speed <= 0 || transferredBytes >= fileSize) return '--';
|
|
final remaining = fileSize - transferredBytes;
|
|
final seconds = (remaining / speed).ceil();
|
|
if (seconds < 60) return '${seconds}s';
|
|
final minutes = seconds ~/ 60;
|
|
final secs = seconds.remainder(60);
|
|
return '$minutes:${secs.toString().padLeft(2, '0')}';
|
|
}
|
|
|
|
bool get isComplete => status == TransferTaskStatus.completed;
|
|
bool get isFailed => status == TransferTaskStatus.failed;
|
|
bool get isActive => status.isActive;
|
|
bool get isPaused => status == TransferTaskStatus.paused;
|
|
bool get isTerminal => status.isTerminal;
|
|
bool get isSend => direction == TransferDirection.send;
|
|
bool get isReceive => direction == TransferDirection.receive;
|
|
bool get isImage => mimeType?.startsWith('image/') ?? false;
|
|
bool get isVideo => mimeType?.startsWith('video/') ?? false;
|
|
bool get isFile => !isImage && !isVideo;
|
|
|
|
String get fileTypeEmoji {
|
|
if (isImage) return '🖼️';
|
|
if (isVideo) return '🎬';
|
|
if (mimeType?.startsWith('audio/') ?? false) return '🎵';
|
|
if (mimeType?.contains('pdf') ?? false) return '📄';
|
|
if (mimeType?.contains('zip') ?? false) return '📦';
|
|
if (mimeType?.contains('text') ?? false) return '📝';
|
|
return '📁';
|
|
}
|
|
|
|
static String formatSpeed(double bytesPerSecond) {
|
|
if (bytesPerSecond <= 0) return '0 B/s';
|
|
if (bytesPerSecond < 1024)
|
|
return '${bytesPerSecond.toStringAsFixed(0)} B/s';
|
|
if (bytesPerSecond < 1024 * 1024)
|
|
return '${(bytesPerSecond / 1024).toStringAsFixed(1)} KB/s';
|
|
if (bytesPerSecond < 1024 * 1024 * 1024)
|
|
return '${(bytesPerSecond / (1024 * 1024)).toStringAsFixed(1)} MB/s';
|
|
return '${(bytesPerSecond / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB/s';
|
|
}
|
|
|
|
static String formatFileSize(int bytes) {
|
|
if (bytes <= 0) return '0 B';
|
|
if (bytes < 1024) return '$bytes B';
|
|
if (bytes < 1024 * 1024) return '${(bytes / 1024).toStringAsFixed(1)} KB';
|
|
if (bytes < 1024 * 1024 * 1024)
|
|
return '${(bytes / (1024 * 1024)).toStringAsFixed(1)} MB';
|
|
return '${(bytes / (1024 * 1024 * 1024)).toStringAsFixed(1)} GB';
|
|
}
|
|
|
|
String get speedText => formatSpeed(speed);
|
|
String get fileSizeText => formatFileSize(fileSize);
|
|
String get transferredText => formatFileSize(transferredBytes);
|
|
|
|
TransferTask copyWith({
|
|
String? id,
|
|
String? sessionId,
|
|
TransferDevice? peer,
|
|
TransportType? transport,
|
|
TransferDirection? direction,
|
|
TransferTaskStatus? status,
|
|
String? fileName,
|
|
int? fileSize,
|
|
int? transferredBytes,
|
|
double? speed,
|
|
String? mimeType,
|
|
String? filePath,
|
|
String? thumbnailPath,
|
|
DateTime? startTime,
|
|
DateTime? endTime,
|
|
String? errorMessage,
|
|
String? fileSha256,
|
|
bool? hashVerified,
|
|
String? fileId,
|
|
int? totalChunks,
|
|
Set<int>? receivedChunks,
|
|
int? retryCount,
|
|
bool? isResumable,
|
|
DateTime? pausedAt,
|
|
}) {
|
|
return TransferTask(
|
|
id: id ?? this.id,
|
|
sessionId: sessionId ?? this.sessionId,
|
|
peer: peer ?? this.peer,
|
|
transport: transport ?? this.transport,
|
|
direction: direction ?? this.direction,
|
|
status: status ?? this.status,
|
|
fileName: fileName ?? this.fileName,
|
|
fileSize: fileSize ?? this.fileSize,
|
|
transferredBytes: transferredBytes ?? this.transferredBytes,
|
|
speed: speed ?? this.speed,
|
|
mimeType: mimeType ?? this.mimeType,
|
|
filePath: filePath ?? this.filePath,
|
|
thumbnailPath: thumbnailPath ?? this.thumbnailPath,
|
|
startTime: startTime ?? this.startTime,
|
|
endTime: endTime ?? this.endTime,
|
|
errorMessage: errorMessage ?? this.errorMessage,
|
|
fileSha256: fileSha256 ?? this.fileSha256,
|
|
hashVerified: hashVerified ?? this.hashVerified,
|
|
fileId: fileId ?? this.fileId,
|
|
totalChunks: totalChunks ?? this.totalChunks,
|
|
receivedChunks: receivedChunks ?? this.receivedChunks,
|
|
retryCount: retryCount ?? this.retryCount,
|
|
isResumable: isResumable ?? this.isResumable,
|
|
pausedAt: pausedAt ?? this.pausedAt,
|
|
);
|
|
}
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'id': id,
|
|
'sessionId': sessionId,
|
|
'peer': peer.toJson(),
|
|
'transport': transport.id,
|
|
'direction': direction.id,
|
|
'status': status.id,
|
|
'fileName': fileName,
|
|
'fileSize': fileSize,
|
|
'transferredBytes': transferredBytes,
|
|
'speed': speed,
|
|
'mimeType': mimeType,
|
|
'filePath': filePath,
|
|
'thumbnailPath': thumbnailPath,
|
|
'startTime': startTime.toIso8601String(),
|
|
'endTime': endTime?.toIso8601String(),
|
|
'errorMessage': errorMessage,
|
|
'fileSha256': fileSha256,
|
|
'hashVerified': hashVerified,
|
|
'fileId': fileId,
|
|
'chunkSize': chunkSize,
|
|
'totalChunks': totalChunks,
|
|
'receivedChunks': receivedChunks?.toList(),
|
|
'retryCount': retryCount,
|
|
'maxRetries': maxRetries,
|
|
'isResumable': isResumable,
|
|
'pausedAt': pausedAt?.toIso8601String(),
|
|
};
|
|
|
|
factory TransferTask.fromJson(Map<String, dynamic> json) {
|
|
return TransferTask(
|
|
id: json['id'] as String? ?? '',
|
|
sessionId: json['sessionId'] as String? ?? '',
|
|
peer: json['peer'] is Map<String, dynamic>
|
|
? TransferDevice.fromJson(json['peer'] as Map<String, dynamic>)
|
|
: TransferDevice(
|
|
id: json['peerId'] as String? ?? '',
|
|
alias: '未知设备',
|
|
deviceType: DeviceType.mobile,
|
|
port: 53317,
|
|
pairingMethod: PairingMethod.lan,
|
|
preferredTransport: TransportType.localsendHttp,
|
|
lastSeen: DateTime.now(),
|
|
isOnline: false,
|
|
isVerified: false,
|
|
),
|
|
transport: TransportType.fromId(
|
|
json['transport'] as String? ?? 'localsend_http',
|
|
),
|
|
direction: TransferDirection.fromId(
|
|
json['direction'] as String? ?? 'send',
|
|
),
|
|
status: TransferTaskStatus.fromId(json['status'] as String? ?? 'waiting'),
|
|
fileName: json['fileName'] as String? ?? 'unknown',
|
|
fileSize: (json['fileSize'] as num?)?.toInt() ?? 0,
|
|
transferredBytes: (json['transferredBytes'] as num?)?.toInt() ?? 0,
|
|
speed: (json['speed'] as num?)?.toDouble() ?? 0.0,
|
|
mimeType: json['mimeType'] as String?,
|
|
filePath: json['filePath'] as String?,
|
|
thumbnailPath: json['thumbnailPath'] as String?,
|
|
startTime: json['startTime'] != null
|
|
? DateTime.parse(json['startTime'] as String)
|
|
: DateTime.now(),
|
|
endTime: json['endTime'] != null
|
|
? DateTime.parse(json['endTime'] as String)
|
|
: null,
|
|
errorMessage: json['errorMessage'] as String?,
|
|
fileSha256: json['fileSha256'] as String?,
|
|
hashVerified: json['hashVerified'] as bool?,
|
|
fileId: json['fileId'] as String?,
|
|
chunkSize: (json['chunkSize'] as num?)?.toInt() ?? 65536,
|
|
totalChunks: (json['totalChunks'] as num?)?.toInt(),
|
|
receivedChunks: (json['receivedChunks'] as List<dynamic>?)
|
|
?.map((e) => SafeJson.parseInt(e))
|
|
.toSet(),
|
|
retryCount: (json['retryCount'] as num?)?.toInt() ?? 0,
|
|
maxRetries: (json['maxRetries'] as num?)?.toInt() ?? 3,
|
|
isResumable: json['isResumable'] as bool? ?? false,
|
|
pausedAt: json['pausedAt'] != null
|
|
? DateTime.parse(json['pausedAt'] as String)
|
|
: null,
|
|
);
|
|
}
|
|
|
|
@override
|
|
String toString() =>
|
|
'TransferTask(id: $id, file: $fileName, status: ${status.label}, progress: $progressText)';
|
|
}
|
|
|
|
class TransferFileInfo {
|
|
const TransferFileInfo({
|
|
required this.name,
|
|
required this.size,
|
|
required this.mimeType,
|
|
this.id,
|
|
this.path,
|
|
this.sha256,
|
|
});
|
|
|
|
final String? id;
|
|
final String name;
|
|
final int size;
|
|
final String mimeType;
|
|
final String? path;
|
|
final String? sha256;
|
|
|
|
bool get isImage => mimeType.startsWith('image/');
|
|
bool get isVideo => mimeType.startsWith('video/');
|
|
|
|
Map<String, dynamic> toJson() => {
|
|
'id': id,
|
|
'fileName': name,
|
|
'size': size,
|
|
'mimeType': mimeType,
|
|
if (sha256 != null) 'sha256': sha256,
|
|
};
|
|
|
|
factory TransferFileInfo.fromJson(Map<String, dynamic> json) {
|
|
return TransferFileInfo(
|
|
id: json['id'] as String?,
|
|
name: json['fileName'] as String? ?? 'unknown',
|
|
size: (json['size'] as num?)?.toInt() ?? 0,
|
|
mimeType: json['mimeType'] as String? ?? 'application/octet-stream',
|
|
path: json['path'] as String?,
|
|
sha256: json['sha256'] as String?,
|
|
);
|
|
}
|
|
}
|