feat(task): record and display task completion time

Add DoneAt to the download task model: set when a task finishes and
cleared when it is downloaded again. The value flows through the API
model and task record, and is shown as "Completed at" in the task
drawer info tab next to the creation time.
This commit is contained in:
Levi committed 2026-09-20 08:56:54 +08:00
1 parent 694b43e3f9
commit 4b231f52c5
30 files changed
+82 -23

No files matched your search

+9
View File
@@ -32,6 +32,9 @@ type Task struct {
Progress *Progress `json:"progress"`
CreatedAt time.Time `json:"createdAt"`
UpdatedAt time.Time `json:"updatedAt"`
// DoneAt is the time when the task last completed; nil while the task has
// never finished or is being downloaded again.
DoneAt *time.Time `json:"doneAt"`
producer production.Source
producerReceived int64
@@ -134,6 +137,12 @@ func (t *Task) MarshalJSON() ([]byte, error) {
func (t *Task) updateStatus(status base.Status) {
t.UpdatedAt = time.Now()
t.Status = status
if status == base.DownloadStatusDone {
doneAt := time.Now()
t.DoneAt = &doneAt
} else {
t.DoneAt = nil
}
}
func (t *Task) clone() *Task {
+2
View File
@@ -35,6 +35,7 @@ class Task {
Progress progress;
DateTime createdAt;
DateTime updatedAt;
DateTime? doneAt;
Task({
required this.id,
@@ -45,6 +46,7 @@ class Task {
required this.progress,
required this.createdAt,
required this.updatedAt,
this.doneAt,
});
factory Task.fromJson(Map<String, dynamic> json) => _$TaskFromJson(json);
+2
View File
@@ -15,6 +15,7 @@ Task _$TaskFromJson(Map<String, dynamic> json) => Task(
progress: Progress.fromJson(json['progress'] as Map<String, dynamic>),
createdAt: DateTime.parse(json['createdAt'] as String),
updatedAt: DateTime.parse(json['updatedAt'] as String),
doneAt: json['doneAt'] == null ? null : DateTime.parse(json['doneAt'] as String),
)..protocol = $enumDecodeNullable(_$ProtocolEnumMap, json['protocol']);
Map<String, dynamic> _$TaskToJson(Task instance) => <String, dynamic>{
@@ -27,6 +28,7 @@ Map<String, dynamic> _$TaskToJson(Task instance) => <String, dynamic>{
'progress': instance.progress.toJson(),
'createdAt': instance.createdAt.toIso8601String(),
'updatedAt': instance.updatedAt.toIso8601String(),
'doneAt': ?instance.doneAt?.toIso8601String(),
};
const _$StatusEnumMap = {
@@ -76,6 +76,7 @@ class TaskRecord {
this.remaining,
this.downloadDuration,
this.createdAt,
this.doneAt,
this.progress,
this.error,
this.completedLabel,
@@ -97,6 +98,7 @@ class TaskRecord {
final String? remaining;
final Duration? downloadDuration;
final DateTime? createdAt;
final DateTime? doneAt;
final double? progress;
final String url;
final String storagePath;
@@ -145,6 +147,7 @@ class TaskRecord {
remainingSeconds: _remainingSeconds(task, totalBytes, downloadedBytes),
downloadDuration: task.progress.used > 0 ? Duration(microseconds: (task.progress.used + 999) ~/ 1000) : null,
createdAt: task.createdAt,
doneAt: task.doneAt,
waiting: task.status == api_task.Status.wait,
progress: progress,
url: displayUrl,
@@ -319,6 +319,8 @@ class _TaskInfoTab extends StatelessWidget {
_InfoBlock(label: context.l10n.remaining, value: remaining),
if (task.createdAt case final createdAt?)
_InfoBlock(label: context.l10n.createdAt, value: _formatTaskDateTime(createdAt)),
if (task.doneAt case final doneAt?)
_InfoBlock(label: context.l10n.doneAt, value: _formatTaskDateTime(doneAt)),
Container(
key: const ValueKey('task-details-info-divider'),
height: 1,
+1
View File
@@ -246,6 +246,7 @@
"refresh": "تحديث",
"status": "الحالة",
"createdAt": "وقت الإنشاء",
"doneAt": "وقت الإكتمال",
"speed": "السرعة",
"uploadSpeed": "سرعة التشغيل",
"remaining": "البقايا",
+1
View File
@@ -260,6 +260,7 @@
"refresh": "Yenilə",
"status": "Vəziyyət",
"createdAt": "Yaradılıb",
"doneAt": "Tamamlanıb",
"speed": "Sürət",
"uploadSpeed": "Yükləmə sürəti",
"remaining": "Qalıb",
+1
View File
@@ -246,6 +246,7 @@
"refresh": "Refresca",
"status": "Estat",
"createdAt": "Data de creació",
"doneAt": "Data de finalització",
"speed": "Velocitat",
"uploadSpeed": "Velocitat de pujada",
"remaining": "Restant",
+1
View File
@@ -246,6 +246,7 @@
"refresh": "Aktualisieren",
"status": "-Status",
"createdAt": "Erstellungszeit",
"doneAt": "Abgeschlossen am",
"speed": "Geschwindigkeit",
"uploadSpeed": "Upload-Geschwindigkeit",
"remaining": "Verbleibend",
+1
View File
@@ -268,6 +268,7 @@
"refresh": "Refresh",
"status": "Status",
"createdAt": "Created",
"doneAt": "Completed at",
"speed": "Speed",
"uploadSpeed": "Upload speed",
"remaining": "Remaining",
+1
View File
@@ -246,6 +246,7 @@
"refresh": "Actualizar",
"status": "Situación",
"createdAt": "Fecha de creación",
"doneAt": "Fecha de finalización",
"speed": "Velocidad",
"uploadSpeed": "Velocidad de carga",
"remaining": "Permaneciendo",
+1
View File
@@ -246,6 +246,7 @@
"refresh": "تازه‌سازی",
"status": "وضعیت",
"createdAt": "زمان ایجاد",
"doneAt": "زمان تکمیل",
"speed": "سرعت",
"uploadSpeed": "سرعت آپلود",
"remaining": "باقی مانده",
+1
View File
@@ -246,6 +246,7 @@
"refresh": "Rafraîchir",
"status": "État",
"createdAt": "Date de création",
"doneAt": "Terminé le",
"speed": "Vitesse",
"uploadSpeed": "Vitesse de chargement",
"remaining": "Reste",
+1
View File
@@ -246,6 +246,7 @@
"refresh": "Frissítés",
"status": "állapot",
"createdAt": "Létrehozás ideje",
"doneAt": "Befejezés ideje",
"speed": "sebesség",
"uploadSpeed": "Feltöltési sebesség",
"remaining": "Maradt",
+1
View File
@@ -246,6 +246,7 @@
"refresh": "Segarkan",
"status": "Status",
"createdAt": "Waktu dibuat",
"doneAt": "Waktu selesai",
"speed": "Kecepatan",
"uploadSpeed": "Kecepatan pemutakhiran",
"remaining": "Sisa",
+1
View File
@@ -246,6 +246,7 @@
"refresh": "Aggiorna",
"status": "Stato",
"createdAt": "Data di creazione",
"doneAt": "Data di completamento",
"speed": "Velocità",
"uploadSpeed": "Velocità di carico",
"remaining": "Rimanere",
+1
View File
@@ -246,6 +246,7 @@
"refresh": "リフレッシュ",
"status": "ステータス",
"createdAt": "作成日時",
"doneAt": "完了日時",
"speed": "スピード",
"uploadSpeed": "アップロード速度",
"remaining": "メンテナンス",
+1
View File
@@ -246,6 +246,7 @@
"refresh": "의붓기",
"status": "상태",
"createdAt": "생성 시간",
"doneAt": "완료 시간",
"speed": "속도",
"uploadSpeed": "연결 속도",
"remaining": "의논하기",
+1
View File
@@ -246,6 +246,7 @@
"refresh": "Odśwież",
"status": "Stan",
"createdAt": "Czas utworzenia",
"doneAt": "Czas ukończenia",
"speed": "Prędkość",
"uploadSpeed": "Prędkość wysyłania",
"remaining": "Pozostały",
+1
View File
@@ -246,6 +246,7 @@
"refresh": "Actualizar",
"status": "Estado",
"createdAt": "Data de criação",
"doneAt": "Data de conclusão",
"speed": "Velocidade",
"uploadSpeed": "Velocidade de envio",
"remaining": "Restante",
+1
View File
@@ -246,6 +246,7 @@
"refresh": "освежиться",
"status": "Статус",
"createdAt": "Время создания",
"doneAt": "Время завершения",
"speed": "Скорость",
"uploadSpeed": "Скорость загрузки",
"remaining": "оставаться",
+1
View File
@@ -246,6 +246,7 @@
"refresh": "புதுப்பி",
"status": "நிலை",
"createdAt": "உருவாக்கிய நேரம்",
"doneAt": "முடிந்த நேரம்",
"speed": "வேகம்",
"uploadSpeed": "வேகம்",
"remaining": "மீதம்",
+1
View File
@@ -246,6 +246,7 @@
"refresh": "Yenile",
"status": "Durum durumu",
"createdAt": "Oluşturulma zamanı",
"doneAt": "Tamamlanma zamanı",
"speed": "Hız",
"uploadSpeed": "Yük hızı",
"remaining": "Kalanlar",
+1
View File
@@ -246,6 +246,7 @@
"refresh": "Реверс",
"status": "Контакты",
"createdAt": "Час створення",
"doneAt": "Час завершення",
"speed": "ОДЯГ",
"uploadSpeed": "Швидкість завантаження",
"remaining": "Ременування",
+1
View File
@@ -246,6 +246,7 @@
"refresh": "Cập nhật",
"status": "Trạng thái",
"createdAt": "Thời gian tạo",
"doneAt": "Thời gian hoàn thành",
"speed": "Tốc độ",
"uploadSpeed": "Tốc độ",
"remaining": "Ở lại",
+1
View File
@@ -246,6 +246,7 @@
"refresh": "刷新",
"status": "状态",
"createdAt": "创建时间",
"doneAt": "完成时间",
"speed": "速度",
"uploadSpeed": "上传速度",
"remaining": "剩余时间",
+1
View File
@@ -246,6 +246,7 @@
"refresh": "重新整理",
"status": "狀態",
"createdAt": "建立時間",
"doneAt": "完成時間",
"speed": "速度",
"uploadSpeed": "上傳速度",
"remaining": "剩餘時間",
+22 -21
View File
@@ -7,7 +7,8 @@ PODS:
- FlutterMacOS
- device_info_plus (0.0.1):
- FlutterMacOS
- file_picker (0.0.1):
- file_picker_darwin (1.0.0):
- Flutter
- FlutterMacOS
- flutter_inappwebview_macos (0.0.1):
- FlutterMacOS
@@ -41,7 +42,7 @@ DEPENDENCIES:
- desktop_drop (from `Flutter/ephemeral/.symlinks/plugins/desktop_drop/macos`)
- desktop_multi_window (from `Flutter/ephemeral/.symlinks/plugins/desktop_multi_window/macos`)
- device_info_plus (from `Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos`)
- file_picker (from `Flutter/ephemeral/.symlinks/plugins/file_picker/macos`)
- file_picker_darwin (from `Flutter/ephemeral/.symlinks/plugins/file_picker_darwin/darwin`)
- flutter_inappwebview_macos (from `Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos`)
- flutter_local_notifications (from `Flutter/ephemeral/.symlinks/plugins/flutter_local_notifications/macos`)
- FlutterMacOS (from `Flutter/ephemeral`)
@@ -68,8 +69,8 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/desktop_multi_window/macos
device_info_plus:
:path: Flutter/ephemeral/.symlinks/plugins/device_info_plus/macos
file_picker:
:path: Flutter/ephemeral/.symlinks/plugins/file_picker/macos
file_picker_darwin:
:path: Flutter/ephemeral/.symlinks/plugins/file_picker_darwin/darwin
flutter_inappwebview_macos:
:path: Flutter/ephemeral/.symlinks/plugins/flutter_inappwebview_macos/macos
flutter_local_notifications:
@@ -96,25 +97,25 @@ EXTERNAL SOURCES:
:path: Flutter/ephemeral/.symlinks/plugins/window_manager/macos
SPEC CHECKSUMS:
app_links: 05a6ec2341985eb05e9f97dc63f5837c39895c3f
desktop_drop: e0b672a7d84c0a6cbc378595e82cdb15f2970a43
desktop_multi_window: 93667594ccc4b88d91a97972fd3b1b89667fa80a
device_info_plus: 4fb280989f669696856f8b129e4a5e3cd6c48f76
file_picker: 7584aae6fa07a041af2b36a2655122d42f578c1a
flutter_inappwebview_macos: c2d68649f9f8f1831bfcd98d73fd6256366d9d1d
flutter_local_notifications: 4bf37a31afde695b56091b4ae3e4d9c7a7e6cda0
app_links: c3185399a5cabc2e610ee5ad52fb7269b84ff869
desktop_drop: 69eeff437544aa619c8db7f4481b3a65f7696898
desktop_multi_window: 566489c048b501134f9d7fb6a2354c60a9126486
device_info_plus: 1b14eed9bf95428983aed283a8d51cce3d8c4215
file_picker_darwin: 27e043831d68462d54f1c40e724b1f53c9d1e8be
flutter_inappwebview_macos: bdf207b8f4ebd58e86ae06cd96b147de99a67c9b
flutter_local_notifications: 14e285ca39907db50704f7f46c9ab7a526bd7ead
FlutterMacOS: d0db08ddef1a9af05a5ec4b724367152bb0500b1
open_dir_macos: 79810d7921d777f8732bf6358b3a0839a8fad9b5
open_dir_macos: 9649018f88e71eb9fb8aa3d95caec30a73c08c5b
OrderedSet: e539b66b644ff081c73a262d24ad552a69be3a94
package_info_plus: f0052d280d17aa382b932f399edf32507174e870
screen_retriever_macos: c5508cc3c66ff0d4db650480cf0ab691e220d933
share_plus: 510bf0af1a42cd602274b4629920c9649c52f4cc
shared_preferences_foundation: 7036424c3d8ec98dfe75ff1667cb0cd531ec82bb
tray_manager: a104b5c81b578d83f3c3d0f40a997c8b10810166
uri_content: 5f98ff12ded3f2e1681b689828d747bafb03ba5f
url_launcher_macos: f87a979182d112f911de6820aefddaf56ee9fbfd
window_manager: b729e31d38fb04905235df9ea896128991cad99e
package_info_plus: 12f1c5c2cfe8727ca46cbd0b26677728972d9a5b
screen_retriever_macos: 038d3f7b34a779b06f536842003e41e8ec1fb4b7
share_plus: 1fa619de8392a4398bfaf176d441853922614e89
shared_preferences_foundation: 5086985c1d43c5ba4d5e69a4e8083a389e2909e6
tray_manager: c2a678ee19e2a094f0c2d03660ee2c3e81cea8ab
uri_content: e002074bcf6fbf905a3909603f9a2b1b490fce67
url_launcher_macos: 175a54c831f4375a6cf895875f716ee5af3888ce
window_manager: e25faf20d88283a0d46e7b1a759d07261ca27575
PODFILE CHECKSUM: 54d867c82ac51cbd61b565781b9fada492027009
COCOAPODS: 1.17.0
COCOAPODS: 1.15.2
@@ -4,7 +4,7 @@ import 'package:gopeed/core/icons/gopeed_icons.dart';
import 'package:gopeed/features/tasks/domain/task_record.dart';
void main() {
api.Task buildTask(List<int> selection, int size) => api.Task.fromJson({
api.Task buildTask(List<int> selection, int size, {String? doneAt}) => api.Task.fromJson({
'id': 'bt-selection',
'name': 'bundle',
'protocol': 'bt',
@@ -12,6 +12,7 @@ void main() {
'uploading': false,
'createdAt': '2026-01-01T00:00:00Z',
'updatedAt': '2026-01-01T00:00:00Z',
'doneAt': doneAt,
'meta': {
'req': {'url': 'magnet:?xt=urn:btih:test'},
'opts': {'path': '/downloads', 'selectFiles': selection},
@@ -43,6 +44,13 @@ void main() {
expect(record.totalBytes, 60);
});
test('completion time maps from api and stays null for legacy tasks', () {
final completed = TaskRecord.fromApi(buildTask([], 60, doneAt: '2026-01-02T03:04:05Z'));
expect(completed.doneAt, DateTime.parse('2026-01-02T03:04:05Z'));
final legacy = TaskRecord.fromApi(buildTask([], 60));
expect(legacy.doneAt, isNull);
});
test('file names preserve hash and question mark characters for type detection', () {
expect(
taskFileTypeIcon('Slow English Podcast #2 _ Eating, Talking, and Learning Natural English.mp4'),
+11 -1
View File
@@ -2649,7 +2649,7 @@ void main() {
await tester.tap(find.byKey(const ValueKey('settings-language-select')));
await tester.pumpAndSettle();
expect(find.text('English'), findsOneWidget);
expect(SettingsLanguageSelect.supportedValues, hasLength(21));
expect(SettingsLanguageSelect.supportedValues, hasLength(22));
expect(SettingsLanguageSelect.supportedValues, containsAll(<String>['zh', 'zh_TW', 'pt']));
expect(SettingsLanguageSelect.supportedValues, isNot(contains('zh_CN')));
expect(SettingsLanguageSelect.supportedValues, isNot(contains('pt_BR')));
@@ -4127,6 +4127,7 @@ void main() {
remaining: '1 minute remaining',
downloadDuration: const Duration(hours: 1, minutes: 2, seconds: 3),
createdAt: DateTime(2026, 8, 27, 12, 34, 56),
doneAt: DateTime(2026, 8, 28, 1, 2, 3),
);
await tester.pumpWidget(
@@ -4197,7 +4198,14 @@ void main() {
expect(find.text('50 B / 100 B'), findsNothing);
expect(find.text('Created'), findsOneWidget);
expect(find.text('2026-08-27 12:34:56'), findsOneWidget);
expect(find.text('Completed at'), findsOneWidget);
expect(find.text('2026-08-28 01:02:03'), findsOneWidget);
expect(
tester.getBottomLeft(find.text('2026-08-27 12:34:56')).dy,
lessThan(tester.getTopLeft(find.text('Completed at')).dy),
);
final infoDivider = find.byKey(const ValueKey('task-details-info-divider'));
expect(tester.getBottomLeft(find.text('Completed at')).dy, lessThan(tester.getTopLeft(infoDivider).dy));
expect(tester.getBottomLeft(find.text('Created')).dy, lessThan(tester.getTopLeft(infoDivider).dy));
expect(tester.getBottomLeft(infoDivider).dy, lessThan(tester.getTopLeft(find.text('Download Link')).dy));
expect(find.text('1 minute remaining'), findsNothing);
@@ -6164,6 +6172,7 @@ TaskRecord _taskRecord({
String? remaining,
Duration? downloadDuration,
DateTime? createdAt,
DateTime? doneAt,
bool isFolder = false,
Map<String, String> requestHeaders = const {},
}) {
@@ -6184,6 +6193,7 @@ TaskRecord _taskRecord({
remaining: remaining,
downloadDuration: downloadDuration,
createdAt: createdAt,
doneAt: doneAt,
requestHeaders: requestHeaders,
);
}