diff --git a/lib/app.dart b/lib/app.dart index 4a1ac6d..b1d8d31 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 @@ -279,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/provider/debug.dart b/lib/kit/provider/debug.dart index 9ec96b9..1db93a9 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,24 @@ 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); + final removeCount = _widgetCounts.removeAt(0); lines.removeAt(0); - if (widgets.value.length >= removed) { - widgets.value.removeRange(0, removed); + if (widgets.value.length >= removeCount) { + widgets.value = widgets.value.sublist(removeCount); } } - 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/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/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/kit/widgets/virtual_window_frame.dart b/lib/kit/widgets/virtual_window_frame.dart index bd1086d..bbc74da 100644 --- a/lib/kit/widgets/virtual_window_frame.dart +++ b/lib/kit/widgets/virtual_window_frame.dart @@ -22,16 +22,17 @@ 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/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/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_details_bt_helpers.dart b/lib/pages/download_page/components/task_details_bt_helpers.dart index b039494..56b0666 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(); } } @@ -424,56 +425,98 @@ 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) { + 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 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/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 3ced6bb..8d0c6d2 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,10 +314,10 @@ class DownloadPageState extends State void _pruneSelection() { if (downloadDataService == null || _selectedTaskKeys.isEmpty) return; - final validKeys = downloadDataService!.tasks.map(_taskKey).toSet(); - setState(() { - _selectedTaskKeys.removeWhere((key) => !validKeys.contains(key)); - }); + final validKeys = downloadDataService!.tasks.map((t) => t.key).toSet(); + final before = _selectedTaskKeys.length; + _selectedTaskKeys.removeWhere((key) => !validKeys.contains(key)); + if (_selectedTaskKeys.length != before) setState(() {}); } List _filterTasks() { @@ -339,71 +337,62 @@ 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); @@ -511,7 +500,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); @@ -522,7 +511,7 @@ class DownloadPageState extends State } void _startTaskSelection(DownloadTask task) { - final key = _taskKey(task); + final key = task.key; setState(() { _selectedTaskKeys.add(key); }); @@ -558,7 +547,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); @@ -574,7 +563,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/enums.dart b/lib/pages/download_page/enums.dart index d06ab65..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, // Active - waiting, // Waiting - stopped, // Stopped -} +enum DownloadStatus { active, waiting, stopped } /// Define category type enum -enum CategoryType { - all, // All - byStatus, // By status - byType, // By type - byInstance, // By instance -} +enum CategoryType { 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) -} +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/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/pages/download_page/services/download_task_service.dart b/lib/pages/download_page/services/download_task_service.dart index 612e29c..851dfd3 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), ), ); @@ -336,9 +325,9 @@ class DownloadTaskService with Loggable { stackTrace: stackTrace, ); if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar(content: Text(_failedToStopSeedingMessage(context, '$e'))), - ); + ScaffoldMessenger.of( + context, + ).showSnackBar(SnackBar(content: Text(l10n.failedToStopSeeding('$e')))); } } finally { client?.close(); 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); diff --git a/lib/pages/settings_page/components/appearance_dialog.dart b/lib/pages/settings_page/components/appearance_dialog.dart index f3a3335..696d340 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,17 +266,28 @@ 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( - 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, ), @@ -289,17 +306,28 @@ 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( - 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/pages/settings_page/settings_page.dart b/lib/pages/settings_page/settings_page.dart index 1cb6a71..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 @@ -206,7 +204,7 @@ class _SettingsPageState extends State } Widget _buildSettingsGroup(List children) { - return Column(children: children.map((child) => child).toList()); + return Column(children: children); } _SettingsSection _buildBehaviorSection( @@ -725,8 +723,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 +734,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 +745,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/aria2_rpc_client.dart b/lib/services/aria2_rpc_client.dart index e093936..dd9f4df 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(), @@ -76,16 +79,19 @@ 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'] 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 { @@ -193,6 +199,8 @@ class Aria2RpcClient with Loggable { return; } + _webSocketSubscription?.cancel(); + _webSocketSubscription = null; _webSocket?.close(); _webSocket = null; @@ -200,7 +208,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 +224,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)) { @@ -222,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); @@ -245,9 +257,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) { @@ -264,10 +276,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. @@ -581,6 +593,8 @@ class Aria2RpcClient with Loggable { void close() { if (_isWebSocket) { _webSocketInitFuture = null; + _webSocketSubscription?.cancel(); + _webSocketSubscription = null; _webSocket?.close(); _webSocket = null; _pendingRequests.clear(); 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(); diff --git a/lib/services/builtin_instance_service.dart b/lib/services/builtin_instance_service.dart index c6315ae..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(), @@ -542,9 +540,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..299aa10 100644 --- a/lib/services/system_tray_service.dart +++ b/lib/services/system_tray_service.dart @@ -94,6 +94,12 @@ 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 { @@ -109,15 +115,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; 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: