From 4b231f52c5b9be6ab1f7fce644c6ec770417224f Mon Sep 17 00:00:00 2001 From: Levi Date: Sun, 20 Sep 2026 08:56:54 +0800 Subject: [PATCH 1/3] 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. --- pkg/download/model.go | 9 ++++ ui/flutter/lib/api/model/task.dart | 2 + ui/flutter/lib/api/model/task.g.dart | 2 + .../features/tasks/domain/task_record.dart | 3 ++ .../presentation/widgets/task_drawer.dart | 2 + ui/flutter/lib/l10n/app_ar.arb | 1 + ui/flutter/lib/l10n/app_az.arb | 1 + ui/flutter/lib/l10n/app_ca.arb | 1 + ui/flutter/lib/l10n/app_de.arb | 1 + ui/flutter/lib/l10n/app_en.arb | 1 + ui/flutter/lib/l10n/app_es.arb | 1 + ui/flutter/lib/l10n/app_fa.arb | 1 + ui/flutter/lib/l10n/app_fr.arb | 1 + ui/flutter/lib/l10n/app_hu.arb | 1 + ui/flutter/lib/l10n/app_id.arb | 1 + ui/flutter/lib/l10n/app_it.arb | 1 + ui/flutter/lib/l10n/app_ja.arb | 1 + ui/flutter/lib/l10n/app_ko.arb | 1 + ui/flutter/lib/l10n/app_pl.arb | 1 + ui/flutter/lib/l10n/app_pt.arb | 1 + ui/flutter/lib/l10n/app_ru.arb | 1 + ui/flutter/lib/l10n/app_ta.arb | 1 + ui/flutter/lib/l10n/app_tr.arb | 1 + ui/flutter/lib/l10n/app_uk.arb | 1 + ui/flutter/lib/l10n/app_vi.arb | 1 + ui/flutter/lib/l10n/app_zh.arb | 1 + ui/flutter/lib/l10n/app_zh_TW.arb | 1 + ui/flutter/macos/Podfile.lock | 43 ++++++++++--------- .../tasks/domain/task_record_test.dart | 10 ++++- ui/flutter/test/widget_test.dart | 12 +++++- 30 files changed, 82 insertions(+), 23 deletions(-) diff --git a/pkg/download/model.go b/pkg/download/model.go index c838cf31..36dfa4ab 100644 --- a/pkg/download/model.go +++ b/pkg/download/model.go @@ -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 { diff --git a/ui/flutter/lib/api/model/task.dart b/ui/flutter/lib/api/model/task.dart index fd08e781..e72c6aa2 100644 --- a/ui/flutter/lib/api/model/task.dart +++ b/ui/flutter/lib/api/model/task.dart @@ -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 json) => _$TaskFromJson(json); diff --git a/ui/flutter/lib/api/model/task.g.dart b/ui/flutter/lib/api/model/task.g.dart index f127beee..d394703f 100644 --- a/ui/flutter/lib/api/model/task.g.dart +++ b/ui/flutter/lib/api/model/task.g.dart @@ -15,6 +15,7 @@ Task _$TaskFromJson(Map json) => Task( progress: Progress.fromJson(json['progress'] as Map), 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 _$TaskToJson(Task instance) => { @@ -27,6 +28,7 @@ Map _$TaskToJson(Task instance) => { 'progress': instance.progress.toJson(), 'createdAt': instance.createdAt.toIso8601String(), 'updatedAt': instance.updatedAt.toIso8601String(), + 'doneAt': ?instance.doneAt?.toIso8601String(), }; const _$StatusEnumMap = { diff --git a/ui/flutter/lib/features/tasks/domain/task_record.dart b/ui/flutter/lib/features/tasks/domain/task_record.dart index bd066ddb..85a10cb1 100644 --- a/ui/flutter/lib/features/tasks/domain/task_record.dart +++ b/ui/flutter/lib/features/tasks/domain/task_record.dart @@ -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, diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart index 49de9689..dce5e86f 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart @@ -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, diff --git a/ui/flutter/lib/l10n/app_ar.arb b/ui/flutter/lib/l10n/app_ar.arb index a450ab56..f25ea37b 100644 --- a/ui/flutter/lib/l10n/app_ar.arb +++ b/ui/flutter/lib/l10n/app_ar.arb @@ -246,6 +246,7 @@ "refresh": "تحديث", "status": "الحالة", "createdAt": "وقت الإنشاء", + "doneAt": "وقت الإكتمال", "speed": "السرعة", "uploadSpeed": "سرعة التشغيل", "remaining": "البقايا", diff --git a/ui/flutter/lib/l10n/app_az.arb b/ui/flutter/lib/l10n/app_az.arb index d4f297d5..4a74c52f 100644 --- a/ui/flutter/lib/l10n/app_az.arb +++ b/ui/flutter/lib/l10n/app_az.arb @@ -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", diff --git a/ui/flutter/lib/l10n/app_ca.arb b/ui/flutter/lib/l10n/app_ca.arb index 86185834..a1ddc641 100644 --- a/ui/flutter/lib/l10n/app_ca.arb +++ b/ui/flutter/lib/l10n/app_ca.arb @@ -246,6 +246,7 @@ "refresh": "Refresca", "status": "Estat", "createdAt": "Data de creació", + "doneAt": "Data de finalització", "speed": "Velocitat", "uploadSpeed": "Velocitat de pujada", "remaining": "Restant", diff --git a/ui/flutter/lib/l10n/app_de.arb b/ui/flutter/lib/l10n/app_de.arb index c0c97ad8..81f3fc7f 100644 --- a/ui/flutter/lib/l10n/app_de.arb +++ b/ui/flutter/lib/l10n/app_de.arb @@ -246,6 +246,7 @@ "refresh": "Aktualisieren", "status": "-Status", "createdAt": "Erstellungszeit", + "doneAt": "Abgeschlossen am", "speed": "Geschwindigkeit", "uploadSpeed": "Upload-Geschwindigkeit", "remaining": "Verbleibend", diff --git a/ui/flutter/lib/l10n/app_en.arb b/ui/flutter/lib/l10n/app_en.arb index a241bbfd..883bed0c 100644 --- a/ui/flutter/lib/l10n/app_en.arb +++ b/ui/flutter/lib/l10n/app_en.arb @@ -268,6 +268,7 @@ "refresh": "Refresh", "status": "Status", "createdAt": "Created", + "doneAt": "Completed at", "speed": "Speed", "uploadSpeed": "Upload speed", "remaining": "Remaining", diff --git a/ui/flutter/lib/l10n/app_es.arb b/ui/flutter/lib/l10n/app_es.arb index 3beff20c..002c8597 100644 --- a/ui/flutter/lib/l10n/app_es.arb +++ b/ui/flutter/lib/l10n/app_es.arb @@ -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", diff --git a/ui/flutter/lib/l10n/app_fa.arb b/ui/flutter/lib/l10n/app_fa.arb index 08664277..59a17a59 100644 --- a/ui/flutter/lib/l10n/app_fa.arb +++ b/ui/flutter/lib/l10n/app_fa.arb @@ -246,6 +246,7 @@ "refresh": "تازه‌سازی", "status": "وضعیت", "createdAt": "زمان ایجاد", + "doneAt": "زمان تکمیل", "speed": "سرعت", "uploadSpeed": "سرعت آپلود", "remaining": "باقی مانده", diff --git a/ui/flutter/lib/l10n/app_fr.arb b/ui/flutter/lib/l10n/app_fr.arb index 4c667ae7..66274439 100644 --- a/ui/flutter/lib/l10n/app_fr.arb +++ b/ui/flutter/lib/l10n/app_fr.arb @@ -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", diff --git a/ui/flutter/lib/l10n/app_hu.arb b/ui/flutter/lib/l10n/app_hu.arb index 7330fe85..d0fe3092 100644 --- a/ui/flutter/lib/l10n/app_hu.arb +++ b/ui/flutter/lib/l10n/app_hu.arb @@ -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", diff --git a/ui/flutter/lib/l10n/app_id.arb b/ui/flutter/lib/l10n/app_id.arb index c28ee0a7..9ad8eddd 100644 --- a/ui/flutter/lib/l10n/app_id.arb +++ b/ui/flutter/lib/l10n/app_id.arb @@ -246,6 +246,7 @@ "refresh": "Segarkan", "status": "Status", "createdAt": "Waktu dibuat", + "doneAt": "Waktu selesai", "speed": "Kecepatan", "uploadSpeed": "Kecepatan pemutakhiran", "remaining": "Sisa", diff --git a/ui/flutter/lib/l10n/app_it.arb b/ui/flutter/lib/l10n/app_it.arb index 0ae8fc65..0500ce26 100644 --- a/ui/flutter/lib/l10n/app_it.arb +++ b/ui/flutter/lib/l10n/app_it.arb @@ -246,6 +246,7 @@ "refresh": "Aggiorna", "status": "Stato", "createdAt": "Data di creazione", + "doneAt": "Data di completamento", "speed": "Velocità", "uploadSpeed": "Velocità di carico", "remaining": "Rimanere", diff --git a/ui/flutter/lib/l10n/app_ja.arb b/ui/flutter/lib/l10n/app_ja.arb index d85cb2bc..2544161c 100644 --- a/ui/flutter/lib/l10n/app_ja.arb +++ b/ui/flutter/lib/l10n/app_ja.arb @@ -246,6 +246,7 @@ "refresh": "リフレッシュ", "status": "ステータス", "createdAt": "作成日時", + "doneAt": "完了日時", "speed": "スピード", "uploadSpeed": "アップロード速度", "remaining": "メンテナンス", diff --git a/ui/flutter/lib/l10n/app_ko.arb b/ui/flutter/lib/l10n/app_ko.arb index 051a42f4..504b25b0 100644 --- a/ui/flutter/lib/l10n/app_ko.arb +++ b/ui/flutter/lib/l10n/app_ko.arb @@ -246,6 +246,7 @@ "refresh": "의붓기", "status": "상태", "createdAt": "생성 시간", + "doneAt": "완료 시간", "speed": "속도", "uploadSpeed": "연결 속도", "remaining": "의논하기", diff --git a/ui/flutter/lib/l10n/app_pl.arb b/ui/flutter/lib/l10n/app_pl.arb index bf86c584..4d512aa4 100644 --- a/ui/flutter/lib/l10n/app_pl.arb +++ b/ui/flutter/lib/l10n/app_pl.arb @@ -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", diff --git a/ui/flutter/lib/l10n/app_pt.arb b/ui/flutter/lib/l10n/app_pt.arb index b7f581e4..f93da09e 100644 --- a/ui/flutter/lib/l10n/app_pt.arb +++ b/ui/flutter/lib/l10n/app_pt.arb @@ -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", diff --git a/ui/flutter/lib/l10n/app_ru.arb b/ui/flutter/lib/l10n/app_ru.arb index aea0b6f5..57035b2b 100644 --- a/ui/flutter/lib/l10n/app_ru.arb +++ b/ui/flutter/lib/l10n/app_ru.arb @@ -246,6 +246,7 @@ "refresh": "освежиться", "status": "Статус", "createdAt": "Время создания", + "doneAt": "Время завершения", "speed": "Скорость", "uploadSpeed": "Скорость загрузки", "remaining": "оставаться", diff --git a/ui/flutter/lib/l10n/app_ta.arb b/ui/flutter/lib/l10n/app_ta.arb index a967ea8c..04193bd1 100644 --- a/ui/flutter/lib/l10n/app_ta.arb +++ b/ui/flutter/lib/l10n/app_ta.arb @@ -246,6 +246,7 @@ "refresh": "புதுப்பி", "status": "நிலை", "createdAt": "உருவாக்கிய நேரம்", + "doneAt": "முடிந்த நேரம்", "speed": "வேகம்", "uploadSpeed": "வேகம்", "remaining": "மீதம்", diff --git a/ui/flutter/lib/l10n/app_tr.arb b/ui/flutter/lib/l10n/app_tr.arb index 89be13ea..7c01725b 100644 --- a/ui/flutter/lib/l10n/app_tr.arb +++ b/ui/flutter/lib/l10n/app_tr.arb @@ -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", diff --git a/ui/flutter/lib/l10n/app_uk.arb b/ui/flutter/lib/l10n/app_uk.arb index d32d2701..65fc23a9 100644 --- a/ui/flutter/lib/l10n/app_uk.arb +++ b/ui/flutter/lib/l10n/app_uk.arb @@ -246,6 +246,7 @@ "refresh": "Реверс", "status": "Контакты", "createdAt": "Час створення", + "doneAt": "Час завершення", "speed": "ОДЯГ", "uploadSpeed": "Швидкість завантаження", "remaining": "Ременування", diff --git a/ui/flutter/lib/l10n/app_vi.arb b/ui/flutter/lib/l10n/app_vi.arb index db3e0c37..8cbd8393 100644 --- a/ui/flutter/lib/l10n/app_vi.arb +++ b/ui/flutter/lib/l10n/app_vi.arb @@ -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", diff --git a/ui/flutter/lib/l10n/app_zh.arb b/ui/flutter/lib/l10n/app_zh.arb index 0269904d..fed70a8d 100644 --- a/ui/flutter/lib/l10n/app_zh.arb +++ b/ui/flutter/lib/l10n/app_zh.arb @@ -246,6 +246,7 @@ "refresh": "刷新", "status": "状态", "createdAt": "创建时间", + "doneAt": "完成时间", "speed": "速度", "uploadSpeed": "上传速度", "remaining": "剩余时间", diff --git a/ui/flutter/lib/l10n/app_zh_TW.arb b/ui/flutter/lib/l10n/app_zh_TW.arb index 5ad3f7f7..9d8a4b94 100644 --- a/ui/flutter/lib/l10n/app_zh_TW.arb +++ b/ui/flutter/lib/l10n/app_zh_TW.arb @@ -246,6 +246,7 @@ "refresh": "重新整理", "status": "狀態", "createdAt": "建立時間", + "doneAt": "完成時間", "speed": "速度", "uploadSpeed": "上傳速度", "remaining": "剩餘時間", diff --git a/ui/flutter/macos/Podfile.lock b/ui/flutter/macos/Podfile.lock index 25728b41..ab2d2f29 100644 --- a/ui/flutter/macos/Podfile.lock +++ b/ui/flutter/macos/Podfile.lock @@ -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 diff --git a/ui/flutter/test/features/tasks/domain/task_record_test.dart b/ui/flutter/test/features/tasks/domain/task_record_test.dart index 17a8e4be..eba26e4f 100644 --- a/ui/flutter/test/features/tasks/domain/task_record_test.dart +++ b/ui/flutter/test/features/tasks/domain/task_record_test.dart @@ -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 selection, int size) => api.Task.fromJson({ + api.Task buildTask(List 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'), diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index 7269398e..66f4e295 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -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(['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 requestHeaders = const {}, }) { @@ -6184,6 +6193,7 @@ TaskRecord _taskRecord({ remaining: remaining, downloadDuration: downloadDuration, createdAt: createdAt, + doneAt: doneAt, requestHeaders: requestHeaders, ); } From 1d8a502ac4d6240ab8b737237c2ff601b487563b Mon Sep 17 00:00:00 2001 From: Levi Date: Sun, 20 Sep 2026 09:21:36 +0800 Subject: [PATCH 2/3] fix(task): keep DoneAt once written instead of clearing it Done tasks never leave the done state (doStart, pause and markTaskError all guard against Done), so the else branch clearing DoneAt was unreachable dead code. DoneAt is now written once on completion and kept for the task lifetime. --- pkg/download/model.go | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/pkg/download/model.go b/pkg/download/model.go index 36dfa4ab..bf479a0b 100644 --- a/pkg/download/model.go +++ b/pkg/download/model.go @@ -32,8 +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 is the time when the task completed; nil if it has never + // finished. A done task never leaves the done state, so the value is + // written once and kept. DoneAt *time.Time `json:"doneAt"` producer production.Source @@ -137,11 +138,11 @@ func (t *Task) MarshalJSON() ([]byte, error) { func (t *Task) updateStatus(status base.Status) { t.UpdatedAt = time.Now() t.Status = status + // A done task never leaves the done state, so there is no branch to clear + // DoneAt on other transitions. if status == base.DownloadStatusDone { doneAt := time.Now() t.DoneAt = &doneAt - } else { - t.DoneAt = nil } } From 7bc7efed5b7bfe99ad552f03e0cc23837f42e968 Mon Sep 17 00:00:00 2001 From: Levi Date: Sun, 20 Sep 2026 09:28:46 +0800 Subject: [PATCH 3/3] feat(task): show a dash for completed tasks without a done time Tasks completed before this change have no doneAt value; the drawer now always renders the Completed at row for completed tasks and falls back to a dash instead of hiding the row. --- .../presentation/widgets/task_drawer.dart | 10 ++++++-- ui/flutter/test/widget_test.dart | 24 +++++++++++++++++++ 2 files changed, 32 insertions(+), 2 deletions(-) diff --git a/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart b/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart index dce5e86f..c668ff9b 100644 --- a/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart +++ b/ui/flutter/lib/features/tasks/presentation/widgets/task_drawer.dart @@ -319,8 +319,14 @@ 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)), + if (task.status == TaskStatus.completed) + _InfoBlock( + label: context.l10n.doneAt, + value: switch (task.doneAt) { + final doneAt? => _formatTaskDateTime(doneAt), + _ => '—', + }, + ), Container( key: const ValueKey('task-details-info-divider'), height: 1, diff --git a/ui/flutter/test/widget_test.dart b/ui/flutter/test/widget_test.dart index 66f4e295..c9427a11 100644 --- a/ui/flutter/test/widget_test.dart +++ b/ui/flutter/test/widget_test.dart @@ -4267,6 +4267,30 @@ void main() { expect(find.text('—'), findsOneWidget); }); + testWidgets('completed task details show a dash when no done time is recorded', (WidgetTester tester) async { + await _setTestSize(tester, const Size(900, 700)); + final task = _taskRecord( + id: 'done-details', + name: 'done.zip', + status: TaskStatus.completed, + downloadDuration: const Duration(hours: 1, minutes: 2, seconds: 3), + ); + + await tester.pumpWidget( + ProviderScope( + child: shad.ShadcnApp( + theme: AppTheme.light(), + materialTheme: AppTheme.materialLight(), + home: TaskDetailsView(task: task, mobile: false, onOpenStorage: () {}, onUpdateUrl: (_) async {}), + ), + ), + ); + await tester.pumpAndSettle(); + + expect(find.text('Completed at'), findsOneWidget); + expect(find.text('—'), findsOneWidget); + }); + testWidgets('failed task details omit the unavailable error reason', (WidgetTester tester) async { await _setTestSize(tester, const Size(900, 700)); final task = TaskRecord(