Files
xianyan/lib/features/file_transfer/models/transfer_task.dart
Developer 83720002e6 feat: 新增工作台模式、系统托盘,修复多平台兼容性问题
1. 新增工作台三栏布局模式,适配宽屏设备
2. 添加跨平台系统托盘支持,新增托盘图标资源
3. 修复工作台模式下导航返回异常问题
4. 统一JSON类型安全解析,替换硬类型转换
5. 增加macOS深度链接支持,统一渠道分发信息
6. 优化部分页面生命周期和状态加载逻辑
7. 移除废弃的nearby_connections依赖
2026-06-19 06:43:55 +08:00

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