feat(desktop): implement native desktop notifications for task completion and errors (#1293)

This commit is contained in:
Locon213 authored and GitHub committed 2026-03-01 11:12:40 +08:00
1 parent 98bd3305b7
commit bc5c818eae
26 files changed
+285 -4

No files matched your search

+2
View File
@@ -28,6 +28,7 @@ android {
ndkVersion flutter.ndkVersion
compileOptions {
coreLibraryDesugaringEnabled true
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
@@ -83,4 +84,5 @@ flutter {
dependencies {
implementation(name: 'libgopeed', ext: 'aar')
coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:2.1.4'
}
@@ -89,6 +89,7 @@ class ExtraConfig {
bool defaultBtClient;
bool notifyWhenNewVersion;
bool autoStartTasks;
bool desktopNotification;
List<DownloadCategory> downloadCategories;
ExtraConfigBt bt = ExtraConfigBt();
@@ -102,6 +103,7 @@ class ExtraConfig {
this.defaultBtClient = true,
this.notifyWhenNewVersion = true,
this.autoStartTasks = false,
this.desktopNotification = true,
this.downloadCategories = const [],
});
@@ -19,8 +19,7 @@ DownloaderConfig _$DownloaderConfigFromJson(Map<String, dynamic> json) =>
..proxy = ProxyConfig.fromJson(json['proxy'] as Map<String, dynamic>)
..webhook =
WebhookConfig.fromJson(json['webhook'] as Map<String, dynamic>?)
..script =
ScriptConfig.fromJson(json['script'] as Map<String, dynamic>?)
..script = ScriptConfig.fromJson(json['script'] as Map<String, dynamic>?)
..autoTorrent = AutoTorrentConfig.fromJson(
json['autoTorrent'] as Map<String, dynamic>?)
..archive =
@@ -91,6 +90,7 @@ ExtraConfig _$ExtraConfigFromJson(Map<String, dynamic> json) => ExtraConfig(
defaultBtClient: json['defaultBtClient'] as bool? ?? true,
notifyWhenNewVersion: json['notifyWhenNewVersion'] as bool? ?? true,
autoStartTasks: json['autoStartTasks'] as bool? ?? false,
desktopNotification: json['desktopNotification'] as bool? ?? true,
downloadCategories: (json['downloadCategories'] as List<dynamic>?)
?.map((e) => DownloadCategory.fromJson(e as Map<String, dynamic>))
.toList() ??
@@ -109,6 +109,7 @@ Map<String, dynamic> _$ExtraConfigToJson(ExtraConfig instance) =>
'defaultBtClient': instance.defaultBtClient,
'notifyWhenNewVersion': instance.notifyWhenNewVersion,
'autoStartTasks': instance.autoStartTasks,
'desktopNotification': instance.desktopNotification,
'downloadCategories':
instance.downloadCategories.map((e) => e.toJson()).toList(),
'bt': instance.bt.toJson(),
@@ -156,8 +157,7 @@ Map<String, dynamic> _$WebhookConfigToJson(WebhookConfig instance) =>
'urls': instance.urls,
};
ScriptConfig _$ScriptConfigFromJson(Map<String, dynamic> json) =>
ScriptConfig(
ScriptConfig _$ScriptConfigFromJson(Map<String, dynamic> json) => ScriptConfig(
enable: json['enable'] as bool? ?? false,
paths:
(json['paths'] as List<dynamic>?)?.map((e) => e as String).toList() ??
@@ -17,6 +17,7 @@ import 'package:uri_to_file/uri_to_file.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:window_manager/window_manager.dart';
import '../../../../api/api.dart';
import '../../../../api/model/create_task.dart';
import '../../../../api/model/downloader_config.dart';
@@ -38,6 +39,7 @@ import '../../../../util/util.dart';
import '../../../routes/app_pages.dart';
import '../../../rpc/rpc.dart';
import '../../redirect/views/redirect_view.dart';
import '../../../services/notification_service.dart';
const unixSocketPath = 'gopeed.sock';
@@ -96,6 +98,10 @@ class AppController extends GetxController with WindowListener, TrayListener {
_initWindows().onError((error, stackTrace) =>
logger.w("initWindows error", error, stackTrace));
if (Util.isDesktop()) {
Get.put(NotificationService());
}
_initTray().onError(
(error, stackTrace) => logger.w("initTray error", error, stackTrace));
@@ -498,6 +498,27 @@ class SettingView extends GetView<SettingController> {
);
});
final buildDesktopNotification = !Util.isDesktop()
? () => null
: _buildConfigItem('desktopNotification', () {
return appController.downloaderConfig.value.extra.desktopNotification
? 'on'.tr
: 'off'.tr;
}, (Key key) {
return Container(
alignment: Alignment.centerLeft,
child: Switch(
value: appController.downloaderConfig.value.extra.desktopNotification,
onChanged: (bool value) async {
appController.downloaderConfig.update((val) {
val!.extra.desktopNotification = value;
});
await debounceSave();
},
),
);
});
// http config items start
final httpConfig = downloaderCfg.value.protocolConfig.http;
final buildHttpUa =
@@ -1592,6 +1613,7 @@ class SettingView extends GetView<SettingController> {
buildBrowserExtension(),
buildAutoStartup(),
buildMenubarMode(),
buildDesktopNotification(),
]),
)),
Text('archives'.tr),
@@ -0,0 +1,146 @@
import 'dart:async';
import 'dart:io';
import 'package:flutter/services.dart';
import 'package:flutter_local_notifications/flutter_local_notifications.dart';
import 'package:get/get.dart';
import 'package:path_provider/path_provider.dart';
import '../../api/api.dart';
import '../../api/model/task.dart';
import '../../util/util.dart';
import '../modules/app/controllers/app_controller.dart';
class NotificationService extends GetxService {
Timer? _timer;
final Map<String, Status> _previousStatus = {};
final AppController appController = Get.find<AppController>();
final FlutterLocalNotificationsPlugin flutterLocalNotificationsPlugin =
FlutterLocalNotificationsPlugin();
@override
void onInit() {
super.onInit();
_initNotifications();
_startPolling();
}
Future<void> _initNotifications() async {
const DarwinInitializationSettings initializationSettingsDarwin =
DarwinInitializationSettings(
requestAlertPermission: false,
requestBadgePermission: false,
requestSoundPermission: false);
final LinuxInitializationSettings initializationSettingsLinux =
LinuxInitializationSettings(
defaultActionName: 'Open notification',
defaultIcon: AssetsLinuxIcon('assets/icon/icon.png'),
);
String? windowsIconPath;
try {
if (Util.isWindows()) {
final byteData = await rootBundle.load('assets/icon/icon.ico');
final tempDir = await getTemporaryDirectory();
final file = File('${tempDir.path}/notification_icon.ico');
await file.writeAsBytes(byteData.buffer
.asUint8List(byteData.offsetInBytes, byteData.lengthInBytes));
windowsIconPath = file.path;
}
} catch (e) {
// Ignore
}
final WindowsInitializationSettings initializationSettingsWindows =
WindowsInitializationSettings(
appName: 'Gopeed',
appUserModelId: 'com.gopeed.gopeed',
guid: '3c1bf3f4-3d91-4eaa-a33f-8705e71cf1ce', // unique guid
iconPath: windowsIconPath,
);
final InitializationSettings initializationSettings =
InitializationSettings(
macOS: initializationSettingsDarwin,
linux: initializationSettingsLinux,
windows: initializationSettingsWindows,
);
await flutterLocalNotificationsPlugin.initialize(
settings: initializationSettings,
);
}
@override
void onClose() {
_timer?.cancel();
super.onClose();
}
void _startPolling() {
_timer = Timer.periodic(const Duration(seconds: 2), (timer) async {
try {
final config = appController.downloaderConfig.value;
if (!config.extra.desktopNotification) {
return;
}
final tasks = await getTasks([
Status.ready,
Status.running,
Status.pause,
Status.wait,
Status.error,
Status.done,
]);
for (var task in tasks) {
final prevStatus = _previousStatus[task.id];
final currentStatus = task.status;
if (prevStatus != null && prevStatus != currentStatus) {
if (currentStatus == Status.done) {
_showNotification(
title: 'notificationTaskDone'.tr,
body: task.name,
);
} else if (currentStatus == Status.error) {
_showNotification(
title: 'notificationTaskError'.tr,
body: task.name,
);
}
}
_previousStatus[task.id] = currentStatus;
}
// Clean up deleted tasks from map
final currentTaskIds = tasks.map((t) => t.id).toSet();
_previousStatus
.removeWhere((id, status) => !currentTaskIds.contains(id));
} catch (e) {
// Ignored
}
});
}
int _notificationId = 0;
Future<void> _showNotification(
{required String title, required String body}) async {
const NotificationDetails notificationDetails = NotificationDetails(
macOS: DarwinNotificationDetails(),
linux: LinuxNotificationDetails(),
windows: WindowsNotificationDetails(),
);
await flutterLocalNotificationsPlugin.show(
id: _notificationId++,
title: title,
body: body,
notificationDetails: notificationDetails,
);
}
}
+3
View File
@@ -164,5 +164,8 @@ const deDE = {
'categoryPath': 'Kategoriepfad',
'builtInCategory': 'Integrierte Kategorie kann nicht gelöscht werden',
'selectCategory': 'Kategorie auswählen',
'desktopNotification': 'Desktop-Benachrichtigungen',
'notificationTaskDone': 'Aufgabe abgeschlossen',
'notificationTaskError': 'Aufgabenfehler',
},
};
+3
View File
@@ -193,5 +193,8 @@ const enUS = {
'Task "@name" is waiting for URL update. Do you want to update it with the new URL?',
'pendingUpdateYes': 'Update Task',
'pendingUpdateNo': 'Create New Task',
'desktopNotification': 'Desktop Notifications',
'notificationTaskDone': 'Task completed',
'notificationTaskError': 'Task error',
},
};
+3
View File
@@ -142,5 +142,8 @@ const esES = {
'categoryPath': 'Ruta de categoría',
'builtInCategory': 'La categoría incorporada no se puede eliminar',
'selectCategory': 'Seleccionar categoría',
'desktopNotification': 'Notificaciones de escritorio',
'notificationTaskDone': 'Tarea completada',
'notificationTaskError': 'Error de tarea',
},
};
+3
View File
@@ -93,5 +93,8 @@ const faIR = {
'launchAtStartup': 'راه‌اندازی در استارت‌آپ',
'runAsMenubarApp': 'اجرا به عنوان برنامه نوار منو',
'runAsMenubarAppDesc': 'پنهان کردن آیکون Dock و اجرا فقط در نوار منو',
'desktopNotification': 'اعلان‌های دسکتاپ',
'notificationTaskDone': 'وظیفه تکمیل شد',
'notificationTaskError': 'خطای وظیفه',
}
};
+3
View File
@@ -135,5 +135,8 @@ const frFR = {
'categoryPath': 'Chemin de la catégorie',
'builtInCategory': 'La catégorie intégrée ne peut pas être supprimée',
'selectCategory': 'Sélectionner une catégorie',
'desktopNotification': 'Notifications de bureau',
'notificationTaskDone': 'Tâche terminée',
'notificationTaskError': 'Erreur de tâche',
},
};
+3
View File
@@ -156,5 +156,8 @@ const huHU = {
'categoryPath': 'Kategória útvonala',
'builtInCategory': 'A beépített kategória nem törölhető',
'selectCategory': 'Kategória kiválasztása',
'desktopNotification': 'Asztali értesítések',
'notificationTaskDone': 'Feladat befejezve',
'notificationTaskError': 'Feladat hiba',
},
};
+3
View File
@@ -142,5 +142,8 @@ const idID = {
'categoryPath': 'Path Kategori',
'builtInCategory': 'Kategori bawaan tidak dapat dihapus',
'selectCategory': 'Pilih Kategori',
'desktopNotification': 'Notifikasi Desktop',
'notificationTaskDone': 'Tugas selesai',
'notificationTaskError': 'Tugas error',
},
};
+3
View File
@@ -130,5 +130,8 @@ const itIT = {
'categoryPath': 'Percorso categoria',
'builtInCategory': 'La categoria predefinita non può essere eliminata',
'selectCategory': 'Seleziona categoria',
'desktopNotification': 'Notifiche Desktop',
'notificationTaskDone': 'Attività completata',
'notificationTaskError': 'Errore attività',
},
};
+3
View File
@@ -95,5 +95,8 @@ const jaJP = {
'launchAtStartup': '起動時に起動',
'runAsMenubarApp': 'メニューバーアプリとして実行',
'runAsMenubarAppDesc': 'Dockアイコンを非表示にして、メニューバーのみで実行',
'desktopNotification': 'デスクトップ通知',
'notificationTaskDone': 'タスク完了',
'notificationTaskError': 'タスクエラー',
}
};
+3
View File
@@ -127,5 +127,8 @@ const plPL = {
'categoryPath': 'Ścieżka kategorii',
'builtInCategory': 'Wbudowanej kategorii nie można usunąć',
'selectCategory': 'Wybierz kategorię',
'desktopNotification': 'Powiadomienia na pulpicie',
'notificationTaskDone': 'Zadanie zakończone',
'notificationTaskError': 'Błąd zadania',
},
};
+3
View File
@@ -153,5 +153,8 @@ const ptBR = {
'categoryPath': 'Caminho da Categoria',
'builtInCategory': 'Categoria integrada não pode ser excluída',
'selectCategory': 'Selecionar Categoria',
'desktopNotification': 'Notificações na área de trabalho',
'notificationTaskDone': 'Tarefa concluída',
'notificationTaskError': 'Erro na tarefa',
},
};
+3
View File
@@ -152,5 +152,8 @@ const ruRU = {
'categoryPath': 'Путь категории',
'builtInCategory': 'Встроенную категорию нельзя удалить',
'selectCategory': 'Выбрать категорию',
'desktopNotification': 'Уведомления',
'notificationTaskDone': 'Задача завершена',
'notificationTaskError': 'Ошибка задачи',
}
};
+3
View File
@@ -129,5 +129,8 @@ const taTA = {
'categoryPath': 'வகை பாதை',
'builtInCategory': 'உள்ளமைக்கப்பட்ட வகையை நீக்க முடியாது',
'selectCategory': 'வகையைத் தேர்ந்தெடுக்கவும்',
'desktopNotification': 'கணினி அறிவிப்புகள்',
'notificationTaskDone': 'பணி முடிந்தது',
'notificationTaskError': 'பணி பிழை',
},
};
+3
View File
@@ -193,5 +193,8 @@ const trTR = {
'"@name" işlemi URL güncellemesi bekliyor. Yeni URL ile güncellemek istiyor musunuz?',
'pendingUpdateYes': 'İşlemi Güncelle',
'pendingUpdateNo': 'Yeni İşlem Oluştur',
'desktopNotification': 'Masaüstü Bildirimleri',
'notificationTaskDone': 'Görev tamamlandı',
'notificationTaskError': 'Görev hatası',
},
};
+3
View File
@@ -152,5 +152,8 @@ const ukUA = {
'categoryPath': 'Шлях категорії',
'builtInCategory': 'Вбудовану категорію неможливо видалити',
'selectCategory': 'Вибрати категорію',
'desktopNotification': 'Сповіщення',
'notificationTaskDone': 'Завдання завершено',
'notificationTaskError': 'Помилка завдання',
},
};
+3
View File
@@ -122,5 +122,8 @@ const viVN = {
'launchAtStartup': 'Khởi động cùng hệ thống',
'runAsMenubarApp': 'Chạy như ứng dụng thanh menu',
'runAsMenubarAppDesc': 'Ẩn biểu tượng Dock và chỉ chạy trong thanh menu',
'desktopNotification': 'Thông báo trên màn hình',
'notificationTaskDone': 'Nhiệm vụ hoàn thành',
'notificationTaskError': 'Lỗi nhiệm vụ',
},
};
+3
View File
@@ -188,5 +188,8 @@ const zhCN = {
'pendingUpdateConfirm': '任务 "@name" 正在等待更新地址,是否使用新地址更新该任务?',
'pendingUpdateYes': '更新任务',
'pendingUpdateNo': '创建新任务',
'desktopNotification': '桌面通知',
'notificationTaskDone': '任务完成',
'notificationTaskError': '任务失败',
}
};
+3
View File
@@ -147,5 +147,8 @@ const zhTW = {
'categoryPath': '分類路徑',
'builtInCategory': '內建分類無法刪除',
'selectCategory': '選擇分類',
'desktopNotification': '桌面通知',
'notificationTaskDone': '任務完成',
'notificationTaskError': '任務失敗',
}
};
+48
View File
@@ -265,6 +265,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.3.6"
dbus:
dependency: transitive
description:
name: dbus
sha256: d0c98dcd4f5169878b6cf8f6e0a52403a9dff371a3e2f019697accbf6f44a270
url: "https://pub.dev"
source: hosted
version: "0.7.12"
desktop_drop:
dependency: "direct main"
description:
@@ -406,6 +414,38 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.0.3"
flutter_local_notifications:
dependency: "direct main"
description:
name: flutter_local_notifications
sha256: "2b50e938a275e1ad77352d6a25e25770f4130baa61eaf02de7a9a884680954ad"
url: "https://pub.dev"
source: hosted
version: "20.1.0"
flutter_local_notifications_linux:
dependency: transitive
description:
name: flutter_local_notifications_linux
sha256: dce0116868cedd2cdf768af0365fc37ff1cbef7c02c4f51d0587482e625868d0
url: "https://pub.dev"
source: hosted
version: "7.0.0"
flutter_local_notifications_platform_interface:
dependency: transitive
description:
name: flutter_local_notifications_platform_interface
sha256: "23de31678a48c084169d7ae95866df9de5c9d2a44be3e5915a2ff067aeeba899"
url: "https://pub.dev"
source: hosted
version: "10.0.0"
flutter_local_notifications_windows:
dependency: transitive
description:
name: flutter_local_notifications_windows
sha256: e97a1a3016512437d9c0b12fae7d1491c3c7b9aa7f03a69b974308840656b02a
url: "https://pub.dev"
source: hosted
version: "2.0.1"
flutter_localizations:
dependency: "direct main"
description: flutter
@@ -1212,6 +1252,14 @@ packages:
url: "https://pub.dev"
source: hosted
version: "0.7.7"
timezone:
dependency: transitive
description:
name: timezone
sha256: dd14a3b83cfd7cb19e7888f1cbc20f258b8d71b54c06f79ac585f14093a287d1
url: "https://pub.dev"
source: hosted
version: "0.10.1"
timing:
dependency: transitive
description:
+1
View File
@@ -82,6 +82,7 @@ dependencies:
ref: cf08af829f4a4145634f8a047108f505fdbe5eaa
flutter_svg: ^2.0.17
dart_ipc: ^1.0.1
flutter_local_notifications: 20.1.0
dependency_overrides:
permission_handler_windows:
git: