From 15cba64f4127fdbe3adf20c8cbe8adf6b152088d Mon Sep 17 00:00:00 2001 From: GT610 Date: Sun, 7 Jun 2026 16:19:44 +0800 Subject: [PATCH 01/10] fix: resolve 8 bugs across tray, settings, dialog, and lifecycle Bug fixes: - SystemTrayService.updateMenuState now rebuilds tray context menu after updating labels (menu was stale until next right-click) - BuiltinInstanceService.dispose() explicitly discards stopInstance() future instead of fire-and-forget, and removes duplicate _upnpService.shutdown() call (stopInstance() already handles UPnP shutdown) - AppearanceDialog: await async setThemeMode/setPrimaryColor calls so try-catch blocks can actually catch failures; add mounted guard before showing SnackBar after await - SettingsPage locale dialog: await setLocale() before Navigator.pop so save failures are not silently swallowed; add mounted guard - DebugProvider: replace in-place VNode list mutation (add/removeRange/clear) with immutable list reassignment to maintain VNode setter contract - Input: add didUpdateWidget to sync _obscureText when parent changes obscureText prop - App: remove duplicate loadSettings() call from _ThemeProviderState.initState (HomeWrapper._initialize already handles loading with proper await) --- lib/app.dart | 7 ----- lib/kit/provider/debug.dart | 19 ++++++-------- lib/kit/widgets/input.dart | 8 ++++++ .../components/appearance_dialog.dart | 26 ++++++++++++------- lib/pages/settings_page/settings_page.dart | 15 ++++++----- lib/services/builtin_instance_service.dart | 3 +-- lib/services/system_tray_service.dart | 10 +++++++ 7 files changed, 52 insertions(+), 36 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 4a1ac6d..83a049d 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -45,13 +45,6 @@ class _ThemeProvider extends StatefulWidget { } class _ThemeProviderState extends State<_ThemeProvider> { - @override - void initState() { - super.initState(); - // Load settings - Provider.of(context, listen: false).loadSettings(); - } - @override Widget build(BuildContext context) { final display = context diff --git a/lib/kit/provider/debug.dart b/lib/kit/provider/debug.dart index 9ec96b9..a714ebd 100644 --- a/lib/kit/provider/debug.dart +++ b/lib/kit/provider/debug.dart @@ -28,7 +28,7 @@ final class DebugProvider { lines.add('$title$level$message'); var widgetCount = 1; - widgets.value.add( + final newWidgets = [ Text.rich( TextSpan( children: [ @@ -47,10 +47,10 @@ final class DebugProvider { ], ), ), - ); + ]; if (record.stackTrace != null) { widgetCount++; - widgets.value.add( + newWidgets.add( SingleChildScrollView( scrollDirection: Axis.horizontal, child: Text( @@ -61,24 +61,21 @@ final class DebugProvider { ); } widgetCount++; - widgets.value.add(UIs.height13); + newWidgets.add(UIs.height13); _widgetCounts.add(widgetCount); while (lines.length > maxLines) { - final removed = _widgetCounts.removeAt(0); + _widgetCounts.removeAt(0); lines.removeAt(0); - if (widgets.value.length >= removed) { - widgets.value.removeRange(0, removed); - } } - widgets.notify(); + + widgets.value = [...widgets.value, ...newWidgets]; } static void clear() { - widgets.value.clear(); + widgets.value = []; lines.clear(); _widgetCounts.clear(); - widgets.notify(); } static void copy() => diff --git a/lib/kit/widgets/input.dart b/lib/kit/widgets/input.dart index e646fc6..4d4b473 100644 --- a/lib/kit/widgets/input.dart +++ b/lib/kit/widgets/input.dart @@ -69,6 +69,14 @@ class Input extends StatefulWidget { class _InputState extends State { late bool _obscureText = widget.obscureText; + @override + void didUpdateWidget(covariant Input oldWidget) { + super.didUpdateWidget(oldWidget); + if (widget.obscureText != oldWidget.obscureText) { + _obscureText = widget.obscureText; + } + } + @override Widget build(BuildContext context) { final icon = widget.icon != null diff --git a/lib/pages/settings_page/components/appearance_dialog.dart b/lib/pages/settings_page/components/appearance_dialog.dart index f3a3335..3e5073a 100644 --- a/lib/pages/settings_page/components/appearance_dialog.dart +++ b/lib/pages/settings_page/components/appearance_dialog.dart @@ -92,15 +92,16 @@ class _AppearanceDialogState extends State { ButtonSegment(value: 'system', label: Text(l10n.system)), ], selected: {_selectedThemeMode}, - onSelectionChanged: (newSelection) { + onSelectionChanged: (newSelection) async { if (newSelection.isNotEmpty) { setState(() { _selectedThemeMode = newSelection.first; }); final themeMode = _getThemeModeFromString(newSelection.first); try { - widget.settings.setThemeMode(themeMode); + await widget.settings.setThemeMode(themeMode); } catch (e) { + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text(l10n.failedToSetThemeMode('$e'))), ); @@ -141,13 +142,17 @@ class _AppearanceDialogState extends State { widget.settings.customColorCode == null; return GestureDetector( - onTap: () { + onTap: () async { setState(() { _selectedColor = color; }); try { - widget.settings.setPrimaryColor(color, isCustom: false); + await widget.settings.setPrimaryColor( + color, + isCustom: false, + ); } catch (e) { + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(l10n.failedToSetThemeColor('$e')), @@ -221,7 +226,7 @@ class _AppearanceDialogState extends State { _selectedColor = newColor; }); }, - onChangeEnd: (value) { + onChangeEnd: (value) async { final newColor = Color.fromRGBO( value.toInt(), (_selectedColor.g * 255.0).round() & 0xff, @@ -229,11 +234,12 @@ class _AppearanceDialogState extends State { 1.0, ); try { - widget.settings.setPrimaryColor( + await widget.settings.setPrimaryColor( newColor, isCustom: true, ); } catch (e) { + if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( @@ -260,14 +266,14 @@ class _AppearanceDialogState extends State { _selectedColor = newColor; }); }, - onChangeEnd: (value) { + onChangeEnd: (value) async { final newColor = Color.fromRGBO( (_selectedColor.r * 255.0).round() & 0xff, value.toInt(), (_selectedColor.b * 255.0).round() & 0xff, 1.0, ); - widget.settings.setPrimaryColor( + await widget.settings.setPrimaryColor( newColor, isCustom: true, ); @@ -289,14 +295,14 @@ class _AppearanceDialogState extends State { _selectedColor = newColor; }); }, - onChangeEnd: (value) { + onChangeEnd: (value) async { final newColor = Color.fromRGBO( (_selectedColor.r * 255.0).round() & 0xff, (_selectedColor.g * 255.0).round() & 0xff, value.toInt(), 1.0, ); - widget.settings.setPrimaryColor( + await widget.settings.setPrimaryColor( newColor, isCustom: true, ); diff --git a/lib/pages/settings_page/settings_page.dart b/lib/pages/settings_page/settings_page.dart index 1cb6a71..95f5960 100644 --- a/lib/pages/settings_page/settings_page.dart +++ b/lib/pages/settings_page/settings_page.dart @@ -725,8 +725,9 @@ class _SettingsPageState extends State trailing: settings.locale == null ? const Icon(Icons.check, color: Colors.green) : null, - onTap: () { - settings.setLocale(null); + onTap: () async { + await settings.setLocale(null); + if (!context.mounted) return; Navigator.pop(context); }, ), @@ -735,8 +736,9 @@ class _SettingsPageState extends State trailing: settings.locale?.languageCode == 'en' ? const Icon(Icons.check, color: Colors.green) : null, - onTap: () { - settings.setLocale(const Locale('en')); + onTap: () async { + await settings.setLocale(const Locale('en')); + if (!context.mounted) return; Navigator.pop(context); }, ), @@ -745,8 +747,9 @@ class _SettingsPageState extends State trailing: settings.locale?.languageCode == 'zh' ? const Icon(Icons.check, color: Colors.green) : null, - onTap: () { - settings.setLocale(const Locale('zh')); + onTap: () async { + await settings.setLocale(const Locale('zh')); + if (!context.mounted) return; Navigator.pop(context); }, ), diff --git a/lib/services/builtin_instance_service.dart b/lib/services/builtin_instance_service.dart index c6315ae..025a9e0 100644 --- a/lib/services/builtin_instance_service.dart +++ b/lib/services/builtin_instance_service.dart @@ -542,9 +542,8 @@ class BuiltinInstanceService with Loggable { void dispose() { if (_aria2Process != null) { - stopInstance(); + unawaited(stopInstance()); } - unawaited(_upnpService.shutdown()); clearPendingApply(); _instance = null; } diff --git a/lib/services/system_tray_service.dart b/lib/services/system_tray_service.dart index 08716b9..6875fd0 100644 --- a/lib/services/system_tray_service.dart +++ b/lib/services/system_tray_service.dart @@ -94,6 +94,16 @@ class SystemTrayService extends ChangeNotifier with Loggable, TrayListener { if (!_isInitialized || !hasChanged) { return; } + + try { + await trayManager.setContextMenu(_buildMenu()); + } catch (e, stackTrace) { + w( + 'Failed to update tray context menu', + error: e, + stackTrace: stackTrace, + ); + } } Future initialize() async { From ed9ebe91676bc5c6f8bbf6fcea1e8de134404fd7 Mon Sep 17 00:00:00 2001 From: GT610 Date: Sun, 7 Jun 2026 16:21:51 +0800 Subject: [PATCH 02/10] perf: reduce repeated settings reads and remove dead initialization code MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Performance: - Refactor _getConfiguredRpcPort/Secret to accept optional settings map parameter, eliminating 2 redundant _readSettingsSnapshot() calls in both _buildArgs() and getBuiltinInstanceConfig() (6 sync file reads → 4) - Remove unreachable duplicate guards in SystemTrayService.initialize() (dead code after in-flight await) --- lib/services/builtin_instance_service.dart | 22 ++++++++++------------ lib/services/system_tray_service.dart | 9 --------- 2 files changed, 10 insertions(+), 21 deletions(-) diff --git a/lib/services/builtin_instance_service.dart b/lib/services/builtin_instance_service.dart index 025a9e0..6ba290f 100644 --- a/lib/services/builtin_instance_service.dart +++ b/lib/services/builtin_instance_service.dart @@ -88,16 +88,14 @@ class BuiltinInstanceService with Loggable { return {}; } - int _getConfiguredRpcPort() { - final settings = _readSettingsSnapshot(); - return settings['rpcListenPort'] is int - ? settings['rpcListenPort'] as int - : 16800; + int _getConfiguredRpcPort([Map? settings]) { + final s = settings ?? _readSettingsSnapshot(); + return s['rpcListenPort'] is int ? s['rpcListenPort'] as int : 16800; } - String _getConfiguredRpcSecret() { - final settings = _readSettingsSnapshot(); - return settings['rpcSecret'] as String? ?? ''; + String _getConfiguredRpcSecret([Map? settings]) { + final s = settings ?? _readSettingsSnapshot(); + return s['rpcSecret'] as String? ?? ''; } String _defaultSessionPath() { @@ -277,8 +275,8 @@ class BuiltinInstanceService with Loggable { List _buildArgs() { final settings = _readSettingsSnapshot(); - final rpcPort = _getConfiguredRpcPort(); - final rpcSecret = _getConfiguredRpcSecret(); + final rpcPort = _getConfiguredRpcPort(settings); + final rpcSecret = _getConfiguredRpcSecret(settings); final keepSeeding = settings['keepSeeding'] == true; final seedTime = _effectiveSeedTime(keepSeeding, settings['seedTime']); final seedRatio = _effectiveSeedRatio(keepSeeding, settings['seedRatio']); @@ -530,8 +528,8 @@ class BuiltinInstanceService with Loggable { type: InstanceType.builtin, protocol: 'ws', host: '127.0.0.1', - port: _getConfiguredRpcPort(), - secret: _getConfiguredRpcSecret(), + port: _getConfiguredRpcPort(settings), + secret: _getConfiguredRpcSecret(settings), downloadDir: _resolveConfiguredFilePath( settings['downloadDir'], _defaultDownloadDir(), diff --git a/lib/services/system_tray_service.dart b/lib/services/system_tray_service.dart index 6875fd0..1f7eee1 100644 --- a/lib/services/system_tray_service.dart +++ b/lib/services/system_tray_service.dart @@ -119,15 +119,6 @@ class SystemTrayService extends ChangeNotifier with Loggable, TrayListener { } } - if (_isInitialized) { - return; - } - - if (_initializingTray != null) { - await _initializingTray; - return; - } - final generation = ++_trayLifecycleGeneration; final initialization = _initializeTray(generation); _initializingTray = initialization; From ff779f23c8ae03ca02768179c1db87da454aaacf Mon Sep 17 00:00:00 2001 From: GT610 Date: Sun, 7 Jun 2026 16:24:54 +0800 Subject: [PATCH 03/10] refactor: code quality improvements across settings, widgets, and app Code quality: - SettingsPage._buildSettingsGroup: remove no-op identity map (.map((child) => child)) - Settings._settingsFileName: change from final to static const - Settings: inline trivial _getDataDirectory() wrapper (single caller) - CustomAppBar/CardX: remove redundant key forwarding to inner widget (Flutter handles key reconciliation on the outermost widget) - App: extract _swapListener helper to deduplicate 3 near-identical listener swap blocks in didChangeDependencies --- lib/app.dart | 51 ++++++++++++---------- lib/kit/widgets/appbar.dart | 1 - lib/kit/widgets/card.dart | 1 - lib/models/settings.dart | 9 +--- lib/pages/settings_page/settings_page.dart | 2 +- 5 files changed, 32 insertions(+), 32 deletions(-) diff --git a/lib/app.dart b/lib/app.dart index 83a049d..b1d8d31 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -272,38 +272,45 @@ class _MainWindowState extends State with WindowListener, Loggable { @override void didChangeDependencies() { super.didChangeDependencies(); - final nextDownloadDataService = Provider.of( - context, - listen: false, + _downloadDataService = _swapListener( + Provider.of(context, listen: false), + _downloadDataService, + (l) => l?.removeListener(_handleDownloadNotifications), + (l) => l?.addListener(_handleDownloadNotifications), ); - if (_downloadDataService != nextDownloadDataService) { - _downloadDataService?.removeListener(_handleDownloadNotifications); - _downloadDataService = nextDownloadDataService; - _downloadDataService?.addListener(_handleDownloadNotifications); - } - final nextInstanceManager = Provider.of( - context, - listen: false, + _instanceManager = _swapListener( + Provider.of(context, listen: false), + _instanceManager, + (l) => l?.removeListener(_handleInstanceManagerChanged), + (l) => l?.addListener(_handleInstanceManagerChanged), ); - if (_instanceManager != nextInstanceManager) { - _instanceManager?.removeListener(_handleInstanceManagerChanged); - _instanceManager = nextInstanceManager; - _instanceManager?.addListener(_handleInstanceManagerChanged); - } - final nextSettings = Provider.of(context, listen: false); - if (_settings != nextSettings) { - _settings?.removeListener(_handleSettingsChanged); - _settings = nextSettings; - _settings?.addListener(_handleSettingsChanged); - } + _settings = _swapListener( + Provider.of(context, listen: false), + _settings, + (l) => l?.removeListener(_handleSettingsChanged), + (l) => l?.addListener(_handleSettingsChanged), + ); WidgetsBinding.instance.addPostFrameCallback((_) { unawaited(_applyShellSettings()); }); } + T _swapListener( + T next, + T? current, + void Function(T? l) remove, + void Function(T l) add, + ) { + if (current != next) { + remove(current); + add(next); + } + return next; + } + void _handleSettingsChanged() { unawaited(_handleTrayStateChanged()); unawaited(_applyShellSettings()); diff --git a/lib/kit/widgets/appbar.dart b/lib/kit/widgets/appbar.dart index 41b16ea..18e23c9 100644 --- a/lib/kit/widgets/appbar.dart +++ b/lib/kit/widgets/appbar.dart @@ -30,7 +30,6 @@ class CustomAppBar extends StatelessWidget implements PreferredSizeWidget { @override Widget build(BuildContext context) { return AppBar( - key: key, title: title, actions: actions, centerTitle: centerTitle, diff --git a/lib/kit/widgets/card.dart b/lib/kit/widgets/card.dart index f330299..dcdddff 100644 --- a/lib/kit/widgets/card.dart +++ b/lib/kit/widgets/card.dart @@ -19,7 +19,6 @@ class CardX extends StatelessWidget { @override Widget build(BuildContext context) { return Card( - key: key, clipBehavior: clipBehavior, color: color, shape: RoundedRectangleBorder(borderRadius: radius ?? borderRadius), diff --git a/lib/models/settings.dart b/lib/models/settings.dart index c021e10..1d0607a 100644 --- a/lib/models/settings.dart +++ b/lib/models/settings.dart @@ -84,16 +84,11 @@ class Settings extends ChangeNotifier with Loggable { 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36'; // User agent // Settings file name - final String _settingsFileName = 'settings.json'; + static const String _settingsFileName = 'settings.json'; // Constructor initialization Settings(); - /// Get program data directory - Directory _getDataDirectory() { - return getAppDataDirectory(); - } - Future _defaultDownloadDirectory() { return Future.value(getDefaultDownloadDirectorySync()); } @@ -165,7 +160,7 @@ class Settings extends ChangeNotifier with Loggable { /// Get settings file path String _getSettingsFilePath() { - final dataDir = _getDataDirectory(); + final dataDir = getAppDataDirectory(); final configDir = Directory(p.join(dataDir.path, 'config')); if (!configDir.existsSync()) { configDir.createSync(recursive: true); diff --git a/lib/pages/settings_page/settings_page.dart b/lib/pages/settings_page/settings_page.dart index 95f5960..c2b76e3 100644 --- a/lib/pages/settings_page/settings_page.dart +++ b/lib/pages/settings_page/settings_page.dart @@ -206,7 +206,7 @@ class _SettingsPageState extends State } Widget _buildSettingsGroup(List children) { - return Column(children: children.map((child) => child).toList()); + return Column(children: children); } _SettingsSection _buildBehaviorSection( From 9c8c46cd0de379705f6359eff767a8c318f597c4 Mon Sep 17 00:00:00 2001 From: GT610 Date: Sun, 7 Jun 2026 16:27:08 +0800 Subject: [PATCH 04/10] chore: minor cleanup across settings, enums, and virtual window frame Cleanup: - Remove unused dart:async import in AutoHideWindowService - Fix _formatVersionLabel to display full version string (was showing only patch number, e.g. 'v3' instead of 'v1.2.3') - Remove redundant inline comments on enum values in enums.dart (comments restated the value names) - Simplify VirtualWindowFrame switch to a single conditional expression (first and third switch arms produced identical output) --- lib/kit/widgets/virtual_window_frame.dart | 20 ++++++++-------- lib/pages/download_page/enums.dart | 28 +++++++++++----------- lib/pages/settings_page/settings_page.dart | 6 ++--- lib/services/auto_hide_window_service.dart | 2 -- 4 files changed, 26 insertions(+), 30 deletions(-) diff --git a/lib/kit/widgets/virtual_window_frame.dart b/lib/kit/widgets/virtual_window_frame.dart index bd1086d..d65e821 100644 --- a/lib/kit/widgets/virtual_window_frame.dart +++ b/lib/kit/widgets/virtual_window_frame.dart @@ -22,16 +22,16 @@ class VirtualWindowFrame extends StatelessWidget { @override Widget build(BuildContext context) { - final content = switch (CustomAppBar.sysStatusBarHeight) { - 0.0 => child, - _ when showCaption && WindowFrameConfig.showCaption => Column( - children: [ - _WindowCaption(title: title), - Expanded(child: child), - ], - ), - _ => child, - }; + final content = (CustomAppBar.sysStatusBarHeight != 0.0 && + showCaption && + WindowFrameConfig.showCaption) + ? Column( + children: [ + _WindowCaption(title: title), + Expanded(child: child), + ], + ) + : child; return wm.VirtualWindowFrame(child: content); } } diff --git a/lib/pages/download_page/enums.dart b/lib/pages/download_page/enums.dart index d06ab65..5ee28c2 100644 --- a/lib/pages/download_page/enums.dart +++ b/lib/pages/download_page/enums.dart @@ -2,28 +2,28 @@ /// Define download task status enum enum DownloadStatus { - active, // Active - waiting, // Waiting - stopped, // Stopped + active, + waiting, + stopped, } /// Define category type enum enum CategoryType { - all, // All - byStatus, // By status - byType, // By type - byInstance, // By instance + all, + byStatus, + byType, + byInstance, } /// Define filter option enum enum FilterOption { - all, // All items - active, // Active status - waiting, // Waiting status - stopped, // Stopped status - local, // Local type - remote, // Remote type - instance, // Instance filter (dynamic) + all, + active, + waiting, + stopped, + local, + remote, + instance, } /// Define task sort option enum diff --git a/lib/pages/settings_page/settings_page.dart b/lib/pages/settings_page/settings_page.dart index c2b76e3..db96cae 100644 --- a/lib/pages/settings_page/settings_page.dart +++ b/lib/pages/settings_page/settings_page.dart @@ -92,13 +92,11 @@ class _SettingsPageState extends State required String version, required String buildNumber, }) { - final segments = version.split('.'); - final displayVersion = segments.isNotEmpty ? segments.last : version; final normalizedBuildNumber = buildNumber.trim(); if (normalizedBuildNumber.isEmpty) { - return 'v$displayVersion'; + return 'v$version'; } - return 'v$displayVersion (rev $normalizedBuildNumber)'; + return 'v$version (rev $normalizedBuildNumber)'; } @override diff --git a/lib/services/auto_hide_window_service.dart b/lib/services/auto_hide_window_service.dart index e25eba8..96f29d8 100644 --- a/lib/services/auto_hide_window_service.dart +++ b/lib/services/auto_hide_window_service.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - class AutoHideWindowService { static final AutoHideWindowService _instance = AutoHideWindowService._internal(); From 6ca232ca17d65767ce684119a65c396ca5f002ac Mon Sep 17 00:00:00 2001 From: GT610 Date: Sun, 7 Jun 2026 16:27:32 +0800 Subject: [PATCH 05/10] Update packages --- pubspec.lock | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/pubspec.lock b/pubspec.lock index 8d60523..39ed5da 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -305,10 +305,10 @@ packages: dependency: transitive description: name: meta - sha256: "23f08335362185a5ea2ad3a4e597f1375e78bce8a040df5c600c8d3552ef2394" + sha256: "1741988757a65eb6b36abe716829688cf01910bbf91c34354ff7ec1c3de2b349" url: "https://pub.flutter-io.cn" source: hosted - version: "1.17.0" + version: "1.18.0" nested: dependency: transitive description: @@ -614,10 +614,10 @@ packages: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: "949a932224383300f01be9221c39180316445ecb8e7547f70a41a35bf421fb9e" url: "https://pub.flutter-io.cn" source: hosted - version: "0.7.10" + version: "0.7.11" tray_manager: dependency: "direct main" description: From 9d79580b12672f3d2c392244cea9002194532b2d Mon Sep 17 00:00:00 2001 From: GT610 Date: Sun, 7 Jun 2026 16:44:43 +0800 Subject: [PATCH 06/10] fix: resolve 5 bugs in RPC client, debug provider, and selection pruning Bug fixes: - DebugProvider: trim widgets.value alongside lines and _widgetCounts (widgets list was growing unboundedly while lines was capped at 100) - Aria2RpcClient._handleWebSocketMessage: add type guard for non-Map JSON responses (arrays/primitives caused NoSuchMethodError) - Aria2RpcClient._callHttpRpc: replace _httpClient! force-unwrap with null check + ConnectionFailedException (prevents crash after close()) - Aria2RpcClient: store WebSocket StreamSubscription in _webSocketSubscription field; cancel in close() and _connectWebSocket to prevent callbacks firing on disposed client - DownloadPage._pruneSelection: skip setState when no keys were removed (prevents unnecessary rebuild cycles on every data change) --- lib/kit/provider/debug.dart | 5 ++++- lib/pages/download_page/download_page.dart | 6 +++--- lib/services/aria2_rpc_client.dart | 13 +++++++++++-- 3 files changed, 18 insertions(+), 6 deletions(-) diff --git a/lib/kit/provider/debug.dart b/lib/kit/provider/debug.dart index a714ebd..1db93a9 100644 --- a/lib/kit/provider/debug.dart +++ b/lib/kit/provider/debug.dart @@ -65,8 +65,11 @@ final class DebugProvider { _widgetCounts.add(widgetCount); while (lines.length > maxLines) { - _widgetCounts.removeAt(0); + final removeCount = _widgetCounts.removeAt(0); lines.removeAt(0); + if (widgets.value.length >= removeCount) { + widgets.value = widgets.value.sublist(removeCount); + } } widgets.value = [...widgets.value, ...newWidgets]; diff --git a/lib/pages/download_page/download_page.dart b/lib/pages/download_page/download_page.dart index 3ced6bb..c7c460d 100644 --- a/lib/pages/download_page/download_page.dart +++ b/lib/pages/download_page/download_page.dart @@ -317,9 +317,9 @@ class DownloadPageState extends State if (downloadDataService == null || _selectedTaskKeys.isEmpty) return; final validKeys = downloadDataService!.tasks.map(_taskKey).toSet(); - setState(() { - _selectedTaskKeys.removeWhere((key) => !validKeys.contains(key)); - }); + final before = _selectedTaskKeys.length; + _selectedTaskKeys.removeWhere((key) => !validKeys.contains(key)); + if (_selectedTaskKeys.length != before) setState(() {}); } List _filterTasks() { diff --git a/lib/services/aria2_rpc_client.dart b/lib/services/aria2_rpc_client.dart index e093936..978bc14 100644 --- a/lib/services/aria2_rpc_client.dart +++ b/lib/services/aria2_rpc_client.dart @@ -22,6 +22,7 @@ class Aria2RpcClient with Loggable { final Aria2Instance instance; http.Client? _httpClient; WebSocket? _webSocket; + StreamSubscription? _webSocketSubscription; Future? _webSocketInitFuture; final Map>> _pendingRequests = {}; bool _isWebSocket = false; @@ -64,7 +65,9 @@ class Aria2RpcClient with Loggable { final requestId = _nextRequestId(); final requestBody = _buildRequestBody(method, params, requestId); - final response = await _httpClient! + final client = _httpClient; + if (client == null) throw ConnectionFailedException(); + final response = await client .post( Uri.parse(_buildRpcUrl()), headers: _buildHttpHeaders(), @@ -193,6 +196,8 @@ class Aria2RpcClient with Loggable { return; } + _webSocketSubscription?.cancel(); + _webSocketSubscription = null; _webSocket?.close(); _webSocket = null; @@ -200,7 +205,8 @@ class Aria2RpcClient with Loggable { _webSocket = await WebSocket.connect( _buildRpcUrl(), ).timeout(const Duration(seconds: 10)); - _webSocket!.listen( + _webSocketSubscription?.cancel(); + _webSocketSubscription = _webSocket!.listen( _handleWebSocketMessage, onError: _handleWebSocketError, onDone: _handleWebSocketDone, @@ -215,6 +221,7 @@ class Aria2RpcClient with Loggable { void _handleWebSocketMessage(dynamic message) { try { final data = jsonDecode(message); + if (data is! Map) return; final requestId = data['id']?.toString(); if (requestId != null && _pendingRequests.containsKey(requestId)) { @@ -581,6 +588,8 @@ class Aria2RpcClient with Loggable { void close() { if (_isWebSocket) { _webSocketInitFuture = null; + _webSocketSubscription?.cancel(); + _webSocketSubscription = null; _webSocket?.close(); _webSocket = null; _pendingRequests.clear(); From 27692f94690c8f4ce596deff316e50e93a5b4fed Mon Sep 17 00:00:00 2001 From: GT610 Date: Sun, 7 Jun 2026 16:49:48 +0800 Subject: [PATCH 07/10] perf: optimize filter, sort, and pieces grid rendering Performance: - DownloadPage._filterTasks: combine category filter, status/type filter, and search filter into a single retainWhere pass (was creating 3-5 intermediate lists via .where().toList() per filter call) - DownloadPage._filterTasks: inline sort key computation in comparator (eliminates O(n) sortKeys map allocation per sort) - TaskDetailsBtHelpers._buildPiecesGrid: replace Wrap+List.generate with CustomPaint painter (renders thousands of pieces without creating individual Container widgets, eliminates O(n) widget allocation) --- .../components/task_details_bt_helpers.dart | 122 ++++++++++++------ lib/pages/download_page/download_page.dart | 96 ++++++-------- 2 files changed, 123 insertions(+), 95 deletions(-) diff --git a/lib/pages/download_page/components/task_details_bt_helpers.dart b/lib/pages/download_page/components/task_details_bt_helpers.dart index b039494..c68ce03 100644 --- a/lib/pages/download_page/components/task_details_bt_helpers.dart +++ b/lib/pages/download_page/components/task_details_bt_helpers.dart @@ -424,56 +424,94 @@ class TaskDetailsBtHelpers { } static Widget _buildPiecesGrid(List pieces) { - final pieceSize = pieces.length > 1000 - ? 4.0 - : (pieces.length > 500 ? 6.0 : 8.0); - - return Wrap( - spacing: 1, - runSpacing: 1, - children: List.generate(pieces.length, (index) { - return Container( - width: pieceSize, - height: pieceSize, - decoration: BoxDecoration( - color: _getPieceColor(pieces[index]), - border: Border.all( - width: 0.5, - color: Colors.black.withValues(alpha: 0.1), + return LayoutBuilder( + builder: (context, constraints) { + final maxWidth = constraints.maxWidth; + final pieceSize = pieces.length > 1000 + ? 4.0 + : (pieces.length > 500 ? 6.0 : 8.0); + final spacing = 1.0; + final cols = (maxWidth / (pieceSize + spacing)).floor().clamp(1, pieces.length); + final rows = (pieces.length / cols).ceil(); + final gridHeight = rows * (pieceSize + spacing); + + return SizedBox( + width: maxWidth, + height: gridHeight, + child: CustomPaint( + painter: _PiecesGridPainter( + pieces: pieces, + pieceSize: pieceSize, + spacing: spacing, + cols: cols, ), ), ); - }), + }, ); } +} - static Color _getPieceColor(int pieceValue) { - switch (pieceValue) { - case 0: - return Colors.grey; - case 1: - case 2: - case 3: - return Colors.orange; - case 4: - case 5: - case 6: - case 7: - return Colors.yellow; - case 8: - case 9: - case 10: - case 11: - return Colors.lightGreen; - case 12: - case 13: - case 14: - case 15: - return Colors.green; - default: - return Colors.grey; +class _PiecesGridPainter extends CustomPainter { + final List pieces; + final double pieceSize; + final double spacing; + final int cols; + + _PiecesGridPainter({ + required this.pieces, + required this.pieceSize, + required this.spacing, + required this.cols, + }); + + static const _pieceColors = { + 0: Color(0xFF9E9E9E), // grey + 1: Color(0xFFFF9800), // orange + 2: Color(0xFFFF9800), + 3: Color(0xFFFF9800), + 4: Color(0xFFFFEB3B), // yellow + 5: Color(0xFFFFEB3B), + 6: Color(0xFFFFEB3B), + 7: Color(0xFFFFEB3B), + 8: Color(0xFF8BC34A), // lightGreen + 9: Color(0xFF8BC34A), + 10: Color(0xFF8BC34A), + 11: Color(0xFF8BC34A), + 12: Color(0xFF4CAF50), // green + 13: Color(0xFF4CAF50), + 14: Color(0xFF4CAF50), + 15: Color(0xFF4CAF50), + }; + static const _defaultColor = Color(0xFF9E9E9E); + static const _borderColor = Color(0x1A000000); + + @override + void paint(Canvas canvas, Size size) { + final borderPaint = Paint() + ..color = _borderColor + ..style = PaintingStyle.stroke + ..strokeWidth = 0.5; + + for (var i = 0; i < pieces.length; i++) { + final col = i % cols; + final row = i ~/ cols; + final x = col * (pieceSize + spacing); + final y = row * (pieceSize + spacing); + final rect = Rect.fromLTWH(x, y, pieceSize, pieceSize); + + canvas.drawRect( + rect, + Paint()..color = _pieceColors[pieces[i]] ?? _defaultColor, + ); + canvas.drawRect(rect, borderPaint); } } + + @override + bool shouldRepaint(covariant _PiecesGridPainter oldDelegate) { + return oldDelegate.pieces != pieces; + } } class TaskDetailsTorrentOverviewMetadata { diff --git a/lib/pages/download_page/download_page.dart b/lib/pages/download_page/download_page.dart index c7c460d..6642ef3 100644 --- a/lib/pages/download_page/download_page.dart +++ b/lib/pages/download_page/download_page.dart @@ -339,71 +339,61 @@ class DownloadPageState extends State return _cachedFilteredTasks!; } - var tasks = List.from(tasksRef); - - if (_currentCategoryType == CategoryType.byInstance && - _selectedInstanceId != null) { - tasks = tasks - .where((task) => task.instanceId == _selectedInstanceId) - .toList(); - } else if (_currentCategoryType == CategoryType.byStatus || - _currentCategoryType == CategoryType.byType) { - switch (_selectedFilter) { - case FilterOption.all: - break; - case FilterOption.active: - tasks = tasks.where(DownloadTaskService.matchesActiveFilter).toList(); - break; - case FilterOption.waiting: - tasks = tasks - .where(DownloadTaskService.matchesWaitingFilter) - .toList(); - break; - case FilterOption.stopped: - tasks = tasks - .where((task) => task.status == DownloadStatus.stopped) - .toList(); - break; - case FilterOption.local: - tasks = tasks.where((task) => task.isLocal).toList(); - break; - case FilterOption.remote: - tasks = tasks.where((task) => !task.isLocal).toList(); - break; - case FilterOption.instance: - break; + final tasks = List.from(tasksRef); + + bool matchesCategory(DownloadTask task) { + if (_currentCategoryType == CategoryType.byInstance && + _selectedInstanceId != null) { + return task.instanceId == _selectedInstanceId; } + if (_currentCategoryType == CategoryType.byStatus || + _currentCategoryType == CategoryType.byType) { + return switch (_selectedFilter) { + FilterOption.all || FilterOption.instance => true, + FilterOption.active => DownloadTaskService.matchesActiveFilter(task), + FilterOption.waiting => + DownloadTaskService.matchesWaitingFilter(task), + FilterOption.stopped => task.status == DownloadStatus.stopped, + FilterOption.local => task.isLocal, + FilterOption.remote => !task.isLocal, + }; + } + return true; } + String? query; + Map? lowerInstanceNames; if (_searchQuery.isNotEmpty) { - final query = _searchQuery.toLowerCase(); - final lowerInstanceNames = { + query = _searchQuery.toLowerCase(); + lowerInstanceNames = { for (final entry in _instanceNames.entries) entry.key: entry.value.toLowerCase(), }; - tasks = tasks.where((task) { - final instanceName = lowerInstanceNames[task.instanceId] ?? ''; - final taskDir = (task.dir ?? '').toLowerCase(); - final taskName = task.name.toLowerCase(); - return taskName.contains(query) || - taskDir.contains(query) || - instanceName.contains(query); - }).toList(); } + bool matchesSearch(DownloadTask task) { + if (query == null) return true; + final instanceName = lowerInstanceNames![task.instanceId] ?? ''; + final taskDir = (task.dir ?? '').toLowerCase(); + final taskName = task.name.toLowerCase(); + return taskName.contains(query) || + taskDir.contains(query) || + instanceName.contains(query); + } + + tasks.retainWhere((task) => matchesCategory(task) && matchesSearch(task)); + if (_sortOption == TaskSortOption.name || _sortOption == TaskSortOption.instance) { - final sortKeys = {}; - for (final task in tasks) { - final key = '${task.instanceId}::${task.id}'; - sortKeys[key] = _sortOption == TaskSortOption.name - ? task.name.toLowerCase() - : (_instanceNames[task.instanceId] ?? task.instanceId) - .toLowerCase(); - } tasks.sort((left, right) { - final leftKey = sortKeys['${left.instanceId}::${left.id}'] ?? ''; - final rightKey = sortKeys['${right.instanceId}::${right.id}'] ?? ''; + final leftKey = _sortOption == TaskSortOption.name + ? left.name.toLowerCase() + : (_instanceNames[left.instanceId] ?? left.instanceId) + .toLowerCase(); + final rightKey = _sortOption == TaskSortOption.name + ? right.name.toLowerCase() + : (_instanceNames[right.instanceId] ?? right.instanceId) + .toLowerCase(); final result = leftKey.compareTo(rightKey); if (result != 0) return _sortDescending ? -result : result; final idResult = left.id.compareTo(right.id); From 3c9976347a5cdaa3b51f75f6e8291e443e9ea6ff Mon Sep 17 00:00:00 2001 From: GT610 Date: Sun, 7 Jun 2026 16:57:50 +0800 Subject: [PATCH 08/10] refactor: code quality improvements across RPC client, dialogs, and models Code quality: - Aria2RpcClient._callHttpRpc: remove redundant response.body.contains check for 'Unauthorized' (structured JSON check is sufficient) - Aria2RpcClient.getVersion: delegate to getVersionInfo() to eliminate duplicate RPC call - Aria2RpcClient._handleWebSocketError: always wrap errors in ConnectionFailedException instead of leaking raw error types - AddTaskDialog: move outputField creation inside two-column branch (was allocated but unused in single-column path) - DownloadTask: add key getter ('::') to eliminate duplicate _taskKey function in download_page.dart and task_list_view.dart - FilterSelector: inline trivial _getInstanceFilterOptions wrapper --- .../components/add_task_dialog.dart | 25 ++++++++++--------- .../components/filter_selector.dart | 6 +---- .../components/task_list_view.dart | 4 +-- lib/pages/download_page/download_page.dart | 14 +++++------ .../download_page/models/download_task.dart | 2 ++ lib/services/aria2_rpc_client.dart | 19 +++++++------- 6 files changed, 32 insertions(+), 38 deletions(-) diff --git a/lib/pages/download_page/components/add_task_dialog.dart b/lib/pages/download_page/components/add_task_dialog.dart index 72029ea..d9f09c2 100644 --- a/lib/pages/download_page/components/add_task_dialog.dart +++ b/lib/pages/download_page/components/add_task_dialog.dart @@ -559,18 +559,6 @@ class _AddTaskDialogState extends State return LayoutBuilder( builder: (context, constraints) { final useTwoColumns = constraints.maxWidth >= 480; - final splitField = _buildSplitStepper(l10n); - final outputField = Expanded( - flex: 3, - child: TextField( - controller: outputFileNameController, - enabled: !_isSubmitting, - decoration: InputDecoration( - labelText: l10n.renameOutput, - hintText: l10n.renameOutputPlaceholder, - ), - ), - ); if (!useTwoColumns) { return Column( @@ -589,6 +577,19 @@ class _AddTaskDialogState extends State ); } + final splitField = _buildSplitStepper(l10n); + final outputField = Expanded( + flex: 3, + child: TextField( + controller: outputFileNameController, + enabled: !_isSubmitting, + decoration: InputDecoration( + labelText: l10n.renameOutput, + hintText: l10n.renameOutputPlaceholder, + ), + ), + ); + return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [outputField, const SizedBox(width: 12), splitField], diff --git a/lib/pages/download_page/components/filter_selector.dart b/lib/pages/download_page/components/filter_selector.dart index 2e16c2a..65484d2 100644 --- a/lib/pages/download_page/components/filter_selector.dart +++ b/lib/pages/download_page/components/filter_selector.dart @@ -88,7 +88,7 @@ class FilterSelector extends StatelessWidget { borderRadius: BorderRadius.circular(20), ), ), - ..._getInstanceFilterOptions().map((instanceId) { + ...instanceIds.map((instanceId) { final isSelected = selectedInstanceId == instanceId; final instanceColor = colorScheme.tertiary; final instanceName = @@ -220,10 +220,6 @@ class FilterSelector extends StatelessWidget { } } - List _getInstanceFilterOptions() { - return instanceIds; - } - List _getFilterOptionsForCurrentCategory() { switch (currentCategoryType) { case CategoryType.byStatus: diff --git a/lib/pages/download_page/components/task_list_view.dart b/lib/pages/download_page/components/task_list_view.dart index ef8525b..b763de2 100644 --- a/lib/pages/download_page/components/task_list_view.dart +++ b/lib/pages/download_page/components/task_list_view.dart @@ -34,8 +34,6 @@ class TaskListView extends StatelessWidget { this.onClearViewFilters, }); - String _taskKey(DownloadTask task) => '${task.instanceId}::${task.id}'; - @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context)!; @@ -126,7 +124,7 @@ class TaskListView extends StatelessWidget { } }, onLongPress: () => onTaskLongPress(task), - isSelected: selectedTaskKeys.contains(_taskKey(task)), + isSelected: selectedTaskKeys.contains(task.key), showSelectionControl: selectedTaskKeys.isNotEmpty, showProgressBar: showProgressBar, onTaskUpdated: onTaskUpdated, diff --git a/lib/pages/download_page/download_page.dart b/lib/pages/download_page/download_page.dart index 6642ef3..381c965 100644 --- a/lib/pages/download_page/download_page.dart +++ b/lib/pages/download_page/download_page.dart @@ -281,13 +281,11 @@ class DownloadPageState extends State _pruneSelection(); } - String _taskKey(DownloadTask task) => '${task.instanceId}::${task.id}'; - bool get _isSelectionMode => _selectedTaskKeys.isNotEmpty; List _selectedTasksFrom(List visibleTasks) { return visibleTasks - .where((task) => _selectedTaskKeys.contains(_taskKey(task))) + .where((task) => _selectedTaskKeys.contains(task.key)) .toList(); } @@ -316,7 +314,7 @@ class DownloadPageState extends State void _pruneSelection() { if (downloadDataService == null || _selectedTaskKeys.isEmpty) return; - final validKeys = downloadDataService!.tasks.map(_taskKey).toSet(); + final validKeys = downloadDataService!.tasks.map((t) => t.key).toSet(); final before = _selectedTaskKeys.length; _selectedTaskKeys.removeWhere((key) => !validKeys.contains(key)); if (_selectedTaskKeys.length != before) setState(() {}); @@ -501,7 +499,7 @@ class DownloadPageState extends State } void _toggleTaskSelection(DownloadTask task) { - final key = _taskKey(task); + final key = task.key; setState(() { if (_selectedTaskKeys.contains(key)) { _selectedTaskKeys.remove(key); @@ -512,7 +510,7 @@ class DownloadPageState extends State } void _startTaskSelection(DownloadTask task) { - final key = _taskKey(task); + final key = task.key; setState(() { _selectedTaskKeys.add(key); }); @@ -548,7 +546,7 @@ class DownloadPageState extends State void _selectAllVisibleTasks(List tasks) { setState(() { - final visibleKeys = tasks.map(_taskKey).toSet(); + final visibleKeys = tasks.map((t) => t.key).toSet(); final allVisibleSelected = visibleKeys.isNotEmpty && visibleKeys.every(_selectedTaskKeys.contains); @@ -564,7 +562,7 @@ class DownloadPageState extends State } void _pruneSelectionToVisible() { - final visibleKeys = _filterTasks().map(_taskKey).toSet(); + final visibleKeys = _filterTasks().map((t) => t.key).toSet(); _selectedTaskKeys.removeWhere((key) => !visibleKeys.contains(key)); } diff --git a/lib/pages/download_page/models/download_task.dart b/lib/pages/download_page/models/download_task.dart index 5923c2c..5b8af4b 100644 --- a/lib/pages/download_page/models/download_task.dart +++ b/lib/pages/download_page/models/download_task.dart @@ -12,6 +12,8 @@ class DownloadTask { final String completedSize; final bool isLocal; final String instanceId; + + String get key => '$instanceId::$id'; final int? connections; final int? numSeeders; final String? dir; diff --git a/lib/services/aria2_rpc_client.dart b/lib/services/aria2_rpc_client.dart index 978bc14..3fd2974 100644 --- a/lib/services/aria2_rpc_client.dart +++ b/lib/services/aria2_rpc_client.dart @@ -79,10 +79,9 @@ class Aria2RpcClient with Loggable { try { final data = jsonDecode(response.body); - // Check for Unauthorized error, whether in error field or elsewhere - if ((data.containsKey('error') && - data['error']['message'] == 'Unauthorized') || - response.body.contains('Unauthorized')) { + // Check for Unauthorized error in structured response + if (data.containsKey('error') && + data['error']['message'] == 'Unauthorized') { throw UnauthorizedException(); } @@ -252,9 +251,9 @@ class Aria2RpcClient with Loggable { /// Handle WebSocket errors void _handleWebSocketError(dynamic error) { // Complete all pending requests with error - final errorToThrow = error is TimeoutException || error is SocketException - ? ConnectionFailedException() - : error; + final errorToThrow = error is ConnectionFailedException + ? error + : ConnectionFailedException(); for (final Completer> completer in _pendingRequests.values) { @@ -271,10 +270,10 @@ class Aria2RpcClient with Loggable { _webSocket = null; } - /// Get version information + /// Get version string Future getVersion() async { - final response = await callRpc('aria2.getVersion', []); - return response['result']['version']; + final info = await getVersionInfo(); + return info['version'] as String; } /// Get detailed version information, including enabled features. From 2e3d5bacb9d2b51b03ed4399007884059411fde8 Mon Sep 17 00:00:00 2001 From: GT610 Date: Sun, 7 Jun 2026 17:01:34 +0800 Subject: [PATCH 09/10] chore: minor cleanup in task service, utils, and helpers Cleanup: - DownloadTaskService: inline trivial _stoppingSeedingTip and _failedToStopSeedingMessage wrappers (single-line l10n accessors) - task_utils: use Uri.file() instead of Uri.parse('file://...') for correct platform-specific file URI construction (handles paths with spaces and special characters on Linux/macOS) - TaskDetailsBtHelpers: add explanatory comment to catch(_) block in parseTorrentMetadata (best-effort parsing returns empty on malformed torrent data) --- .../components/task_details_bt_helpers.dart | 1 + .../services/download_task_service.dart | 15 ++------------- lib/pages/download_page/utils/task_utils.dart | 2 +- 3 files changed, 4 insertions(+), 14 deletions(-) diff --git a/lib/pages/download_page/components/task_details_bt_helpers.dart b/lib/pages/download_page/components/task_details_bt_helpers.dart index c68ce03..66c2ab2 100644 --- a/lib/pages/download_page/components/task_details_bt_helpers.dart +++ b/lib/pages/download_page/components/task_details_bt_helpers.dart @@ -193,6 +193,7 @@ class TaskDetailsBtHelpers { : null, ); } catch (_) { + // Best-effort parsing: return empty metadata if torrent data is malformed return const TaskDetailsTorrentOverviewMetadata(); } } diff --git a/lib/pages/download_page/services/download_task_service.dart b/lib/pages/download_page/services/download_task_service.dart index 612e29c..7f913f7 100644 --- a/lib/pages/download_page/services/download_task_service.dart +++ b/lib/pages/download_page/services/download_task_service.dart @@ -185,17 +185,6 @@ class DownloadTaskService with Loggable { task.isSeeder; } - static String _stoppingSeedingTip(BuildContext context) { - return AppLocalizations.of(context)!.stoppingSeedingTip; - } - - static String _failedToStopSeedingMessage( - BuildContext context, - String error, - ) { - return AppLocalizations.of(context)!.failedToStopSeeding(error); - } - static Future pauseTask( BuildContext context, DownloadTask task, @@ -319,7 +308,7 @@ class DownloadTaskService with Loggable { if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( - content: Text(_stoppingSeedingTip(context)), + content: Text(l10n.stoppingSeedingTip), duration: const Duration(seconds: 8), ), ); @@ -337,7 +326,7 @@ class DownloadTaskService with Loggable { ); if (context.mounted) { ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(_failedToStopSeedingMessage(context, '$e'))), + SnackBar(content: Text(l10n.failedToStopSeeding('$e'))), ); } } finally { diff --git a/lib/pages/download_page/utils/task_utils.dart b/lib/pages/download_page/utils/task_utils.dart index c7e3ed3..4ac7ee1 100644 --- a/lib/pages/download_page/utils/task_utils.dart +++ b/lib/pages/download_page/utils/task_utils.dart @@ -53,7 +53,7 @@ class TaskUtils { await Process.run('explorer.exe', [directoryPath]); } else { // Non-Windows platforms use file:// protocol - Uri uri = Uri.parse('file://$directoryPath'); + Uri uri = Uri.file(directoryPath); if (await canLaunchUrl(uri)) { await launchUrl(uri, mode: LaunchMode.externalApplication); From 13dad47b22bdecdd27a93e26f68185056f64dfe9 Mon Sep 17 00:00:00 2001 From: GT610 Date: Sun, 7 Jun 2026 23:04:57 +0800 Subject: [PATCH 10/10] =?UTF-8?q?fix:=20address=20review=20findings=20?= =?UTF-8?q?=E2=80=94=20guards,=20error=20handling,=20and=20formatting?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug fixes: - TaskDetailsBtHelpers._buildPiecesGrid: add empty pieces guard to prevent ArgumentError from .clamp(1, 0) when pieces list is empty - Aria2RpcClient._callHttpRpc: guard data['error'] is Map before accessing ['message'] to prevent TypeError on malformed responses - Aria2RpcClient._handleWebSocketMessage: same guard for data['error'] is Map before accessing ['message'] - AppearanceDialog: add try-catch to G and B slider onChangeEnd handlers (R slider already had error handling, G/B were missing) Formatting: - Run dart format on 7 files to comply with project 80-column style: virtual_window_frame, task_details_bt_helpers, download_page, enums, download_task_service, system_tray_service, aria2_rpc_client Skipped findings (not valid): - _connectWebSocket await subscription cancel: cancel() returns synchronously for non-pending operations, no race condition risk - settings_page setLocale try-catch: already has await + context.mounted guard, setLocale failures propagate as unhandled which is acceptable --- lib/kit/widgets/virtual_window_frame.dart | 3 +- .../components/task_details_bt_helpers.dart | 6 ++- lib/pages/download_page/download_page.dart | 5 ++- lib/pages/download_page/enums.dart | 23 ++--------- .../services/download_task_service.dart | 6 +-- .../components/appearance_dialog.dart | 38 +++++++++++++++---- lib/services/aria2_rpc_client.dart | 16 +++++--- lib/services/system_tray_service.dart | 6 +-- 8 files changed, 58 insertions(+), 45 deletions(-) diff --git a/lib/kit/widgets/virtual_window_frame.dart b/lib/kit/widgets/virtual_window_frame.dart index d65e821..bbc74da 100644 --- a/lib/kit/widgets/virtual_window_frame.dart +++ b/lib/kit/widgets/virtual_window_frame.dart @@ -22,7 +22,8 @@ class VirtualWindowFrame extends StatelessWidget { @override Widget build(BuildContext context) { - final content = (CustomAppBar.sysStatusBarHeight != 0.0 && + final content = + (CustomAppBar.sysStatusBarHeight != 0.0 && showCaption && WindowFrameConfig.showCaption) ? Column( diff --git a/lib/pages/download_page/components/task_details_bt_helpers.dart b/lib/pages/download_page/components/task_details_bt_helpers.dart index 66c2ab2..56b0666 100644 --- a/lib/pages/download_page/components/task_details_bt_helpers.dart +++ b/lib/pages/download_page/components/task_details_bt_helpers.dart @@ -427,12 +427,16 @@ class TaskDetailsBtHelpers { static Widget _buildPiecesGrid(List pieces) { return LayoutBuilder( builder: (context, constraints) { + if (pieces.isEmpty) return const SizedBox.shrink(); final maxWidth = constraints.maxWidth; final pieceSize = pieces.length > 1000 ? 4.0 : (pieces.length > 500 ? 6.0 : 8.0); final spacing = 1.0; - final cols = (maxWidth / (pieceSize + spacing)).floor().clamp(1, pieces.length); + final cols = (maxWidth / (pieceSize + spacing)).floor().clamp( + 1, + pieces.length, + ); final rows = (pieces.length / cols).ceil(); final gridHeight = rows * (pieceSize + spacing); diff --git a/lib/pages/download_page/download_page.dart b/lib/pages/download_page/download_page.dart index 381c965..8d0c6d2 100644 --- a/lib/pages/download_page/download_page.dart +++ b/lib/pages/download_page/download_page.dart @@ -349,8 +349,9 @@ class DownloadPageState extends State return switch (_selectedFilter) { FilterOption.all || FilterOption.instance => true, FilterOption.active => DownloadTaskService.matchesActiveFilter(task), - FilterOption.waiting => - DownloadTaskService.matchesWaitingFilter(task), + FilterOption.waiting => DownloadTaskService.matchesWaitingFilter( + task, + ), FilterOption.stopped => task.status == DownloadStatus.stopped, FilterOption.local => task.isLocal, FilterOption.remote => !task.isLocal, diff --git a/lib/pages/download_page/enums.dart b/lib/pages/download_page/enums.dart index 5ee28c2..28a36a0 100644 --- a/lib/pages/download_page/enums.dart +++ b/lib/pages/download_page/enums.dart @@ -1,30 +1,13 @@ // Enum definitions for download page functionality /// Define download task status enum -enum DownloadStatus { - active, - waiting, - stopped, -} +enum DownloadStatus { active, waiting, stopped } /// Define category type enum -enum CategoryType { - all, - byStatus, - byType, - byInstance, -} +enum CategoryType { all, byStatus, byType, byInstance } /// Define filter option enum -enum FilterOption { - all, - active, - waiting, - stopped, - local, - remote, - instance, -} +enum FilterOption { all, active, waiting, stopped, local, remote, instance } /// Define task sort option enum enum TaskSortOption { name, progress, size, speed, instance } diff --git a/lib/pages/download_page/services/download_task_service.dart b/lib/pages/download_page/services/download_task_service.dart index 7f913f7..851dfd3 100644 --- a/lib/pages/download_page/services/download_task_service.dart +++ b/lib/pages/download_page/services/download_task_service.dart @@ -325,9 +325,9 @@ class DownloadTaskService with Loggable { stackTrace: stackTrace, ); if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(l10n.failedToStopSeeding('$e'))), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.failedToStopSeeding('$e')))); } } finally { client?.close(); diff --git a/lib/pages/settings_page/components/appearance_dialog.dart b/lib/pages/settings_page/components/appearance_dialog.dart index 3e5073a..696d340 100644 --- a/lib/pages/settings_page/components/appearance_dialog.dart +++ b/lib/pages/settings_page/components/appearance_dialog.dart @@ -273,10 +273,21 @@ class _AppearanceDialogState extends State { (_selectedColor.b * 255.0).round() & 0xff, 1.0, ); - await widget.settings.setPrimaryColor( - newColor, - isCustom: true, - ); + try { + await widget.settings.setPrimaryColor( + newColor, + isCustom: true, + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + l10n.failedToSetCustomThemeColor('$e'), + ), + ), + ); + } }, activeColor: Colors.green, ), @@ -302,10 +313,21 @@ class _AppearanceDialogState extends State { value.toInt(), 1.0, ); - await widget.settings.setPrimaryColor( - newColor, - isCustom: true, - ); + try { + await widget.settings.setPrimaryColor( + newColor, + isCustom: true, + ); + } catch (e) { + if (!mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text( + l10n.failedToSetCustomThemeColor('$e'), + ), + ), + ); + } }, activeColor: Colors.blue, ), diff --git a/lib/services/aria2_rpc_client.dart b/lib/services/aria2_rpc_client.dart index 3fd2974..dd9f4df 100644 --- a/lib/services/aria2_rpc_client.dart +++ b/lib/services/aria2_rpc_client.dart @@ -81,13 +81,17 @@ class Aria2RpcClient with Loggable { // Check for Unauthorized error in structured response if (data.containsKey('error') && + data['error'] is Map && data['error']['message'] == 'Unauthorized') { throw UnauthorizedException(); } if (response.statusCode == 200) { if (data.containsKey('error')) { - throw Exception('RPC Error: ${data['error']['message']}'); + final errorMsg = data['error'] is Map + ? data['error']['message'] + : data['error']; + throw Exception('RPC Error: $errorMsg'); } return data; } else { @@ -228,12 +232,14 @@ class Aria2RpcClient with Loggable { _pendingRequests.remove(requestId); if (data.containsKey('error')) { - if (data['error']['message'] == 'Unauthorized') { + if (data['error'] is Map && + data['error']['message'] == 'Unauthorized') { completer.completeError(UnauthorizedException()); } else { - completer.completeError( - Exception('RPC Error: ${data['error']['message']}'), - ); + final errorMsg = data['error'] is Map + ? data['error']['message'] + : data['error']; + completer.completeError(Exception('RPC Error: $errorMsg')); } } else { completer.complete(data); diff --git a/lib/services/system_tray_service.dart b/lib/services/system_tray_service.dart index 1f7eee1..299aa10 100644 --- a/lib/services/system_tray_service.dart +++ b/lib/services/system_tray_service.dart @@ -98,11 +98,7 @@ class SystemTrayService extends ChangeNotifier with Loggable, TrayListener { try { await trayManager.setContextMenu(_buildMenu()); } catch (e, stackTrace) { - w( - 'Failed to update tray context menu', - error: e, - stackTrace: stackTrace, - ); + w('Failed to update tray context menu', error: e, stackTrace: stackTrace); } }