From 014221a1a7b0f2c701036260f3ab4c92b5982b95 Mon Sep 17 00:00:00 2001 From: Levi Date: Thu, 17 Sep 2026 09:55:07 +0800 Subject: [PATCH] fix(flutter): drop action buttons from error notifications A failed download has no usable output file, so error notifications now carry no buttons at all; only the done event offers open file / open folder actions. --- .../app_notification_controller.dart | 31 ++++++++----------- .../app_notification_controller_test.dart | 26 ++++++---------- 2 files changed, 23 insertions(+), 34 deletions(-) diff --git a/ui/flutter/lib/app/application/app_notification_controller.dart b/ui/flutter/lib/app/application/app_notification_controller.dart index 83e29017..0dc1f473 100644 --- a/ui/flutter/lib/app/application/app_notification_controller.dart +++ b/ui/flutter/lib/app/application/app_notification_controller.dart @@ -27,10 +27,10 @@ const _openFileAction = 'open_file'; const _openFolderAction = 'open_folder'; // macOS resolves buttons through pre-registered notification categories, one -// per action combination a terminal task event can offer. +// per action combination a done task event can offer. Error notifications +// carry no buttons at all. const _categoryDoneSingleFile = 'taskDoneSingleFile'; const _categoryDoneFolder = 'taskDoneFolder'; -const _categoryTaskError = 'taskTaskError'; /// A notification button resolved for one task event. /// @@ -105,7 +105,6 @@ class AppNotificationController extends AsyncNotifier { notificationCategories: [ DarwinNotificationCategory(_categoryDoneSingleFile, actions: [openFileAction, openFolderAction]), DarwinNotificationCategory(_categoryDoneFolder, actions: [openFolderAction]), - DarwinNotificationCategory(_categoryTaskError, actions: [openFolderAction]), ], ); final linux = LinuxInitializationSettings( @@ -164,16 +163,16 @@ class AppNotificationController extends AsyncNotifier { title: locale.notificationTaskDone, body: event.name, taskId: event.taskId, - allowOpenFile: true, + withActions: true, ); case TaskEventType.error: + // A failed download has no usable output to open, so no buttons. await _showNotification( locale: locale, title: locale.notificationTaskError, body: event.name, taskId: event.taskId, - // A failed download rarely has a usable output file to open. - allowOpenFile: false, + withActions: false, ); } }); @@ -184,17 +183,16 @@ class AppNotificationController extends AsyncNotifier { required String title, required String body, required String taskId, - required bool allowOpenFile, + required bool withActions, }) async { - final target = await resolveTaskTarget(taskId, allowOpenFile: allowOpenFile); + final target = withActions ? await resolveTaskTarget(taskId) : null; final payload = target == null ? '' : jsonEncode({'path': target.path}); final details = NotificationDetails( macOS: DarwinNotificationDetails( categoryIdentifier: switch (target) { null => null, _ when target.canOpenFile => _categoryDoneSingleFile, - _ when allowOpenFile => _categoryDoneFolder, - _ => _categoryTaskError, + _ => _categoryDoneFolder, }, ), linux: LinuxNotificationDetails( @@ -216,19 +214,16 @@ class AppNotificationController extends AsyncNotifier { await _plugin.show(id: _notificationId++, title: title, body: body, notificationDetails: details, payload: payload); } - /// Resolves the download target a terminal task event points at, following - /// the same rule as the task details page: only single-file tasks offer - /// "open file", folder tasks (e.g. multi-file torrents) can only reveal the + /// Resolves the download target a done task event points at, following the + /// same rule as the task details page: only single-file tasks offer "open + /// file", folder tasks (e.g. multi-file torrents) can only reveal the /// folder. Returns null when the task is already gone. @visibleForTesting - Future<({String path, bool canOpenFile})?> resolveTaskTarget( - String taskId, { - required bool allowOpenFile, - }) async { + Future<({String path, bool canOpenFile})?> resolveTaskTarget(String taskId) async { final apiTask = await findTask(taskId); if (apiTask == null) return null; final record = TaskRecord.fromApi(apiTask); - return (path: record.storagePath, canOpenFile: allowOpenFile && !record.isFolder); + return (path: record.storagePath, canOpenFile: !record.isFolder); } @visibleForTesting diff --git a/ui/flutter/test/app/application/app_notification_controller_test.dart b/ui/flutter/test/app/application/app_notification_controller_test.dart index 4fbfa5b0..69a13238 100644 --- a/ui/flutter/test/app/application/app_notification_controller_test.dart +++ b/ui/flutter/test/app/application/app_notification_controller_test.dart @@ -122,12 +122,12 @@ void main() { expect(calls.where((call) => call.method == 'show'), hasLength(2)); }); - test('macOS registers action categories for every terminal event combination', () async { + test('macOS registers action categories for done task combinations', () async { await start(enabled: true); final categories = initialization()['notificationCategories'] as List; final identifiers = categories.map((category) => (category as Map)['identifier']).toList(); - expect(identifiers, containsAll(['taskDoneSingleFile', 'taskDoneFolder', 'taskTaskError'])); + expect(identifiers, ['taskDoneSingleFile', 'taskDoneFolder']); final singleFile = categories.firstWhere((c) => c['identifier'] == 'taskDoneSingleFile') as Map; final singleActions = (singleFile['actions'] as List).cast(); expect(singleActions.map((action) => action['identifier']), ['open_file', 'open_folder']); @@ -138,7 +138,7 @@ void main() { final container = await start(enabled: true); final notifier = container.read(appNotificationControllerProvider.notifier) as _TestNotificationController; - final target = await notifier.resolveTaskTarget('single', allowOpenFile: true); + final target = await notifier.resolveTaskTarget('single'); expect(target?.path, '/downloads/archive.zip'); expect(target?.canOpenFile, isTrue); @@ -148,21 +148,12 @@ void main() { final container = await start(enabled: true); final notifier = container.read(appNotificationControllerProvider.notifier) as _TestNotificationController; - final target = await notifier.resolveTaskTarget('folder', allowOpenFile: true); + final target = await notifier.resolveTaskTarget('folder'); expect(target?.path, '/downloads/torrent-bundle'); expect(target?.canOpenFile, isFalse); }); - test('an errored task never resolves an open-file target', () async { - final container = await start(enabled: true); - final notifier = container.read(appNotificationControllerProvider.notifier) as _TestNotificationController; - - final target = await notifier.resolveTaskTarget('single', allowOpenFile: false); - - expect(target?.canOpenFile, isFalse); - }); - test('a done single-file task notification selects the single-file category and payload path', () async { await start(enabled: true); events.add(const TaskEvent(type: TaskEventType.done, taskId: 'single', name: 'archive.zip')); @@ -184,13 +175,16 @@ void main() { expect(platformSpecifics['categoryIdentifier'], 'taskDoneFolder'); }); - test('an errored task notification selects the error category', () async { + test('an errored task notification carries no actions at all', () async { await start(enabled: true); events.add(const TaskEvent(type: TaskEventType.error, taskId: 'single', name: 'archive.zip')); await Future.delayed(Duration.zero); - final platformSpecifics = shownNotifications()[0]['platformSpecifics'] as Map; - expect(platformSpecifics['categoryIdentifier'], 'taskTaskError'); + final notifications = shownNotifications(); + expect(notifications, hasLength(1)); + final platformSpecifics = notifications[0]['platformSpecifics'] as Map; + expect(platformSpecifics['categoryIdentifier'], isNull); + expect(notifications[0]['payload'], ''); }); test('action responses route to file and folder handlers by encoded spec', () async {