import 'dart:async'; import 'dart:io'; import 'package:badges/badges.dart' as badges; import 'package:file_picker/file_picker.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; import 'package:gopeed/app/views/copy_button.dart'; import 'package:intl/intl.dart'; import 'package:launch_at_startup/launch_at_startup.dart'; import 'package:url_launcher/url_launcher.dart'; import 'package:window_manager/window_manager.dart'; import '../../../../api/api.dart' as api; import '../../../../api/model/downloader_config.dart'; import '../../../../database/database.dart'; import '../../../../util/analytics.dart'; import '../../../../i18n/message.dart'; import '../../../../util/input_formatter.dart'; import '../../../../util/locale_manager.dart'; import '../../../../util/log_util.dart'; import '../../../../util/message.dart'; import '../../../../util/package_info.dart'; import '../../../../util/scheme_register/scheme_register.dart'; import '../../../../util/updater.dart'; import '../../../../util/util.dart'; import '../../../views/check_list_view.dart'; import '../../../views/directory_selector.dart'; import '../../../views/open_in_new.dart'; import '../../../views/outlined_button_loading.dart'; import '../../../views/text_button_loading.dart'; import '../../app/controllers/app_controller.dart'; import '../controllers/setting_controller.dart'; const _padding = SizedBox(height: 10); final _divider = const Divider().paddingOnly(left: 10, right: 10); class SettingView extends GetView { const SettingView({Key? key}) : super(key: key); // Helper function to get display name for a category static String _getCategoryDisplayName(DownloadCategory category) { if (category.nameKey != null && category.nameKey!.isNotEmpty) { return category.nameKey!.tr; } return category.name; } @override Widget build(BuildContext context) { final appController = Get.find(); final downloaderCfg = appController.downloaderConfig; final startCfg = appController.startConfig; Timer? timer; Future debounceSave( {Future Function()? check, bool needRestart = false}) { var completer = Completer(); timer?.cancel(); timer = Timer(const Duration(milliseconds: 1000), () async { if (check != null) { final checkResult = await check(); if (checkResult.isNotEmpty) { showErrorMessage(checkResult); completer.complete(false); return; } } appController .saveConfig() .then((_) => completer.complete(true)) .onError(completer.completeError); if (needRestart) { showMessage('tip'.tr, 'effectAfterRestart'.tr); } }); return completer.future; } // download basic config items start final buildDownloadDir = _buildConfigItem( 'downloadDir', () => downloaderCfg.value.downloadDir, (Key key) { final downloadDirController = TextEditingController(text: downloaderCfg.value.downloadDir); // Update config only when editing is done (on focus lost or submit) void onEditComplete() { if (downloadDirController.text != downloaderCfg.value.downloadDir) { downloaderCfg.value.downloadDir = downloadDirController.text; if (Util.isDesktop()) { controller.clearTap(); } debounceSave(); } } return DirectorySelector( controller: downloadDirController, showLabel: false, showAndoirdToggle: true, allowEdit: true, showPlaceholderButton: true, onEditComplete: onEditComplete, ); }); final buildMaxRunning = _buildConfigItem( 'maxRunning', () => downloaderCfg.value.maxRunning.toString(), (Key key) { final maxRunningController = TextEditingController( text: downloaderCfg.value.maxRunning.toString()); maxRunningController.addListener(() async { if (maxRunningController.text.isNotEmpty && maxRunningController.text != downloaderCfg.value.maxRunning.toString()) { downloaderCfg.value.maxRunning = int.parse(maxRunningController.text); await debounceSave(); } }); return TextField( key: key, focusNode: FocusNode(), controller: maxRunningController, keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, NumericalRangeFormatter(min: 1, max: 256), ], ); }); final buildDefaultDirectDownload = _buildConfigItem('defaultDirectDownload', () { return appController.downloaderConfig.value.extra.defaultDirectDownload ? 'on'.tr : 'off'.tr; }, (Key key) { return Container( alignment: Alignment.centerLeft, child: Switch( value: appController.downloaderConfig.value.extra.defaultDirectDownload, onChanged: (bool value) async { appController.downloaderConfig.update((val) { val!.extra.defaultDirectDownload = value; }); await debounceSave(); }, ), ); }); final buildAutoStartTasks = _buildConfigItem('autoStartTasks', () { return appController.downloaderConfig.value.extra.autoStartTasks ? 'on'.tr : 'off'.tr; }, (Key key) { return Container( alignment: Alignment.centerLeft, child: Switch( value: appController.downloaderConfig.value.extra.autoStartTasks, onChanged: (bool value) async { appController.downloaderConfig.update((val) { val!.extra.autoStartTasks = value; }); await debounceSave(); }, ), ); }); // AutoTorrent: Enable auto create BT tasks from .torrent files final buildAutoTorrentEnable = _buildConfigItem('autoTorrentEnable', () { return appController.downloaderConfig.value.autoTorrent.enable ? 'on'.tr : 'off'.tr; }, (Key key) { return Container( alignment: Alignment.centerLeft, child: Switch( value: appController.downloaderConfig.value.autoTorrent.enable, onChanged: (bool value) async { appController.downloaderConfig.update((val) { val!.autoTorrent.enable = value; }); await debounceSave(); }, ), ); }); // AutoTorrent: Delete .torrent file after BT task creation final buildAutoTorrentDeleteAfterDownload = _buildConfigItem('autoTorrentDeleteAfterDownload', () { return appController .downloaderConfig.value.autoTorrent.deleteAfterDownload ? 'on'.tr : 'off'.tr; }, (Key key) { return Container( alignment: Alignment.centerLeft, child: Switch( value: appController .downloaderConfig.value.autoTorrent.deleteAfterDownload, onChanged: (bool value) async { appController.downloaderConfig.update((val) { val!.autoTorrent.deleteAfterDownload = value; }); await debounceSave(); }, ), ); }); // New: Auto Delete Missing File Tasks final buildAutoDeleteMissingFileTasks = _buildConfigItem('autoDeleteMissingFileTasks', () { return appController.downloaderConfig.value.autoDeleteMissingFileTasks ? 'on'.tr : 'off'.tr; }, (Key key) { return Container( alignment: Alignment.centerLeft, child: Switch( value: appController.downloaderConfig.value.autoDeleteMissingFileTasks, onChanged: (bool value) async { appController.downloaderConfig.update((val) { val!.autoDeleteMissingFileTasks = value; }); await debounceSave(); }, ), ); }); // Archive auto extract configuration final buildAutoExtract = _buildConfigItem('autoExtract', () { return appController.downloaderConfig.value.archive.autoExtract ? 'on'.tr : 'off'.tr; }, (Key key) { return Container( alignment: Alignment.centerLeft, child: Switch( value: appController.downloaderConfig.value.archive.autoExtract, onChanged: (bool value) async { appController.downloaderConfig.update((val) { val!.archive.autoExtract = value; }); await debounceSave(); }, ), ); }); // Archive delete after extract configuration final buildDeleteAfterExtract = _buildConfigItem('deleteAfterExtract', () { return appController.downloaderConfig.value.archive.deleteAfterExtract ? 'on'.tr : 'off'.tr; }, (Key key) { return Container( alignment: Alignment.centerLeft, child: Switch( value: appController.downloaderConfig.value.archive.deleteAfterExtract, onChanged: (bool value) async { appController.downloaderConfig.update((val) { val!.archive.deleteAfterExtract = value; }); await debounceSave(); }, ), ); }); // Download categories configuration buildDownloadCategories() { final categories = downloaderCfg.value.extra.downloadCategories .where((c) => !c.isDeleted) // Filter out deleted categories .toList(); return ListTile( title: Text('downloadCategories'.tr), subtitle: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ if (categories.isEmpty) Padding( padding: const EdgeInsets.only(top: 8), child: Text( 'add'.tr, style: TextStyle(color: Theme.of(context).hintColor), ), ), ...categories.map((category) { return Padding( padding: const EdgeInsets.only(top: 8), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( _getCategoryDisplayName(category), style: const TextStyle(fontWeight: FontWeight.bold), ), Text( category.path, style: TextStyle( fontSize: 12, color: Theme.of(context).hintColor, ), overflow: TextOverflow.ellipsis, ), ], ), ), IconButton( icon: Icon( Icons.edit, size: 20, color: Theme.of(context).hintColor, ), onPressed: () { _showCategoryDialog( context, debounceSave, downloaderCfg, category: category, ); }, ), IconButton( icon: Icon( Icons.delete, size: 20, color: Theme.of(context).hintColor, ), onPressed: () async { // Show confirmation dialog final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: Text('tip'.tr), content: Text('confirmDelete'.tr), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: Text('cancel'.tr), ), TextButton( onPressed: () => Navigator.of(context).pop(true), child: Text('confirm'.tr), ), ], ), ); if (confirmed == true) { if (category.isBuiltIn) { // Mark built-in category as deleted instead of removing it downloaderCfg.update((val) { category.isDeleted = true; }); } else { // Remove custom categories completely downloaderCfg.update((val) { val!.extra.downloadCategories = val .extra.downloadCategories .where((c) => c != category) .toList(); }); } debounceSave(); } }, ), ], ), ); }), Padding( padding: const EdgeInsets.only(top: 8), child: OutlinedButton.icon( icon: const Icon(Icons.add, size: 18), label: Text('add'.tr), onPressed: () { _showCategoryDialog( context, debounceSave, downloaderCfg, ); }, ), ), ], ), ); } buildBrowserExtension() { return ListTile( title: Text('browserExtension'.tr), subtitle: const Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ OpenInNew( text: "Chrome", url: "https://chromewebstore.google.com/detail/gopeed/mijpgljlfcapndmchhjffkpckknofcnd", ), SizedBox(width: 10), OpenInNew( text: "Edge", url: "https://microsoftedge.microsoft.com/addons/detail/dkajnckekendchdleoaenoophcobooce", ), SizedBox(width: 10), OpenInNew( text: "Firefox", url: "https://addons.mozilla.org/zh-CN/firefox/addon/gopeed-extension", ), ], ).paddingOnly(top: 5)); } // Currently auto startup only support Windows and Linux final buildAutoStartup = !Util.isWindows() && !Util.isLinux() ? () => null : _buildConfigItem('launchAtStartup', () { return appController.autoStartup.value ? 'on'.tr : 'off'.tr; }, (Key key) { return Container( alignment: Alignment.centerLeft, child: Switch( value: appController.autoStartup.value, onChanged: (bool value) async { try { if (value) { await launchAtStartup.enable(); } else { await launchAtStartup.disable(); } appController.autoStartup.value = value; } catch (e) { showErrorMessage(e); logger.e('launchAtStartup fail', e); } }, ), ); }); // Menubar mode only for macOS final buildMenubarMode = !Util.isMacos() ? () => null : _buildConfigItem('runAsMenubarApp', () { return Database.instance.getRunAsMenubarApp() ? 'on'.tr : 'off'.tr; }, (Key key) { return Container( alignment: Alignment.centerLeft, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Switch( value: Database.instance.getRunAsMenubarApp(), onChanged: (bool value) async { // Save to database (single source of truth) Database.instance.saveRunAsMenubarApp(value); // Apply dock icon visibility await windowManager.setSkipTaskbar(value); // Small delay to let macOS process the activation policy change await Future.delayed(const Duration(milliseconds: 100)); // Ensure window stays visible after toggle await windowManager.show(); await windowManager.focus(); // Force UI refresh controller.clearTap(); await debounceSave(); }, ), Text( 'runAsMenubarAppDesc'.tr, style: TextStyle( fontSize: 12, color: Theme.of(context).hintColor, ), ), ], ), ); }); 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 = _buildConfigItem('User-Agent', () => httpConfig.userAgent, (Key key) { final uaController = TextEditingController(text: httpConfig.userAgent); uaController.addListener(() async { if (uaController.text.isNotEmpty && uaController.text != httpConfig.userAgent) { httpConfig.userAgent = uaController.text; await debounceSave(); } }); return TextField( key: key, focusNode: FocusNode(), controller: uaController, ); }); final buildHttpConnections = _buildConfigItem( 'connections', () => httpConfig.connections.toString(), (Key key) { final connectionsController = TextEditingController(text: httpConfig.connections.toString()); connectionsController.addListener(() async { if (connectionsController.text.isNotEmpty && connectionsController.text != httpConfig.connections.toString()) { httpConfig.connections = int.parse(connectionsController.text); await debounceSave(); } }); return TextField( key: key, focusNode: FocusNode(), controller: connectionsController, keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, NumericalRangeFormatter(min: 1, max: 256), ], ); }); final buildHttpUseServerCtime = _buildConfigItem( 'useServerCtime', () => httpConfig.useServerCtime ? 'on'.tr : 'off'.tr, (Key key) { return Container( alignment: Alignment.centerLeft, child: Switch( value: httpConfig.useServerCtime, onChanged: (bool value) { downloaderCfg.update((val) { val!.protocolConfig.http.useServerCtime = value; }); debounceSave(); }, ), ); }); // bt config items start final btConfig = downloaderCfg.value.protocolConfig.bt; final btExtConfig = downloaderCfg.value.extra.bt; final buildBtListenPort = _buildConfigItem( 'port', () => btConfig.listenPort.toString(), (Key key) { final listenPortController = TextEditingController(text: btConfig.listenPort.toString()); listenPortController.addListener(() async { if (listenPortController.text.isNotEmpty && listenPortController.text != btConfig.listenPort.toString()) { btConfig.listenPort = int.parse(listenPortController.text); await debounceSave(); } }); return TextField( key: key, focusNode: FocusNode(), controller: listenPortController, keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, NumericalRangeFormatter(min: 0, max: 65535), ], ); }); final buildBtTrackerSubscribeUrls = _buildConfigItem( 'subscribeTracker', () => 'items'.trParams( {'count': btExtConfig.trackerSubscribeUrls.length.toString()}), (Key key) { final trackerUpdateController = OutlinedButtonLoadingController(); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( height: 200, child: CheckListView( items: allTrackerSubscribeUrls, checked: btExtConfig.trackerSubscribeUrls, onChanged: (value) { btExtConfig.trackerSubscribeUrls = value; debounceSave(); }, ), ), _padding, Row( children: [ OutlinedButtonLoading( onPressed: () async { trackerUpdateController.start(); try { await appController.trackerUpdate(); } catch (e) { showErrorMessage('subscribeFail'.tr); } finally { trackerUpdateController.stop(); } }, controller: trackerUpdateController, child: Text('update'.tr), ), Expanded( child: SwitchListTile( controlAffinity: ListTileControlAffinity.leading, value: btExtConfig.autoUpdateTrackers, onChanged: (bool value) { downloaderCfg.update((val) { val!.extra.bt.autoUpdateTrackers = value; }); debounceSave(); }, title: Text('updateDaily'.tr)), ), ], ), Text('lastUpdate'.trParams({ 'time': btExtConfig.lastTrackerUpdateTime != null ? DateFormat('yyyy-MM-dd HH:mm:ss') .format(btExtConfig.lastTrackerUpdateTime!) : '' })), ], ); }); final buildBtTrackers = _buildConfigItem( 'addTracker', () => 'items' .trParams({'count': btExtConfig.customTrackers.length.toString()}), (Key key) { final trackersController = TextEditingController( text: btExtConfig.customTrackers.join('\r\n').toString()); return TextField( key: key, focusNode: FocusNode(), controller: trackersController, keyboardType: TextInputType.multiline, maxLines: 5, decoration: InputDecoration( hintText: 'addTrackerHit'.tr, ), onChanged: (value) async { btExtConfig.customTrackers = Util.textToLines(value); appController.refreshTrackers(); await debounceSave(); }, ); }); final buildBtSeedConfig = _buildConfigItem('seedConfig', () => '${'seedKeep'.tr}(${btConfig.seedKeep ? 'on'.tr : 'off'.tr})', (Key key) { final seedRatioController = TextEditingController(text: btConfig.seedRatio.toString()); seedRatioController.addListener(() { if (seedRatioController.text.isNotEmpty) { btConfig.seedRatio = double.parse(seedRatioController.text); debounceSave(); } }); final seedTimeController = TextEditingController(text: (btConfig.seedTime ~/ 60).toString()); seedTimeController.addListener(() { if (seedTimeController.text.isNotEmpty) { btConfig.seedTime = int.parse(seedTimeController.text) * 60; debounceSave(); } }); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ SwitchListTile( controlAffinity: ListTileControlAffinity.leading, contentPadding: EdgeInsets.zero, value: btConfig.seedKeep, onChanged: (bool value) { downloaderCfg.update((val) { val!.protocolConfig.bt.seedKeep = value; }); debounceSave(); }, title: Text('seedKeep'.tr)), btConfig.seedKeep ? null : TextField( controller: seedRatioController, decoration: InputDecoration( labelText: 'seedRatio'.tr, ), keyboardType: const TextInputType.numberWithOptions(decimal: true), inputFormatters: [ FilteringTextInputFormatter.allow( RegExp(r'^\d+\.?\d{0,2}')), ], ), btConfig.seedKeep ? null : TextField( controller: seedTimeController, decoration: InputDecoration( labelText: 'seedTime'.tr, ), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, NumericalRangeFormatter(min: 0, max: 100000000), ], ), ].where((e) => e != null).map((e) => e!).toList(), ); }); final buildBtDefaultClientConfig = !Util.isWindows() ? () => null : _buildConfigItem('setAsDefaultBtClient', () { return appController.downloaderConfig.value.extra.defaultBtClient ? 'on'.tr : 'off'.tr; }, (Key key) { return Container( alignment: Alignment.centerLeft, child: Switch( value: appController.downloaderConfig.value.extra.defaultBtClient, onChanged: (bool value) async { try { if (value) { registerDefaultTorrentClient(); } else { unregisterDefaultTorrentClient(); } appController.downloaderConfig.update((val) { val!.extra.defaultBtClient = value; }); await debounceSave(); } catch (e) { showErrorMessage(e); logger.e('register default torrent client fail', e); } }, ), ); }); // ed2k config items start final ed2kConfig = downloaderCfg.value.protocolConfig.ed2k; List parseEd2kEntries(String value) { return value .split(',') .map((e) => e.trim()) .where((e) => e.isNotEmpty) .toList(); } String summarizeEd2kEntries(String value) { final entries = parseEd2kEntries(value); if (entries.isEmpty) { return 'notSet'.tr; } return 'items'.trParams({'count': entries.length.toString()}); } String formatEd2kMultilineValue(String value) { return parseEd2kEntries(value).join('\r\n'); } final buildEd2kListenPort = _buildConfigItem( 'ed2kTcpPort', () => ed2kConfig.listenPort.toString(), (Key key) { final controller = TextEditingController(text: ed2kConfig.listenPort.toString()); controller.addListener(() async { if (controller.text.isNotEmpty && controller.text != ed2kConfig.listenPort.toString()) { ed2kConfig.listenPort = int.parse(controller.text); await debounceSave(); } }); return TextField( key: key, focusNode: FocusNode(), controller: controller, keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, NumericalRangeFormatter(min: 0, max: 65535), ], ); }); final buildEd2kUdpPort = _buildConfigItem( 'ed2kUdpPort', () => ed2kConfig.udpPort.toString(), (Key key) { final controller = TextEditingController(text: ed2kConfig.udpPort.toString()); controller.addListener(() async { if (controller.text.isNotEmpty && controller.text != ed2kConfig.udpPort.toString()) { ed2kConfig.udpPort = int.parse(controller.text); await debounceSave(); } }); return TextField( key: key, focusNode: FocusNode(), controller: controller, keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, NumericalRangeFormatter(min: 0, max: 65535), ], ); }); final buildEd2kServerAddr = _buildConfigItem( 'ed2kServerList', () => summarizeEd2kEntries(ed2kConfig.serverAddr), (Key key) { final controller = TextEditingController( text: formatEd2kMultilineValue(ed2kConfig.serverAddr)); return TextField( key: key, focusNode: FocusNode(), controller: controller, keyboardType: TextInputType.multiline, maxLines: 5, decoration: InputDecoration( hintText: 'ed2kServersHint'.tr, helperText: 'ed2kOnePerLine'.tr, ), onChanged: (value) async { ed2kConfig.serverAddr = Util.textToLines(value).join(','); await debounceSave(); }, ); }); final buildEd2kServerMet = _buildConfigItem( 'ed2kServerMet', () => summarizeEd2kEntries(ed2kConfig.serverMet), (Key key) { final controller = TextEditingController( text: formatEd2kMultilineValue(ed2kConfig.serverMet)); return TextField( key: key, focusNode: FocusNode(), controller: controller, keyboardType: TextInputType.multiline, maxLines: 4, decoration: InputDecoration( hintText: 'ed2kServerMetHint'.tr, helperText: 'ed2kOnePerLine'.tr, ), onChanged: (value) async { ed2kConfig.serverMet = Util.textToLines(value).join(','); await debounceSave(); }, ); }); final buildEd2kNodesDat = _buildConfigItem( 'ed2kNodesDat', () => summarizeEd2kEntries(ed2kConfig.nodesDat), (Key key) { final controller = TextEditingController( text: formatEd2kMultilineValue(ed2kConfig.nodesDat)); return TextField( key: key, focusNode: FocusNode(), controller: controller, keyboardType: TextInputType.multiline, maxLines: 4, decoration: InputDecoration( hintText: 'ed2kNodesDatHint'.tr, helperText: 'ed2kOnePerLine'.tr, ), onChanged: (value) async { ed2kConfig.nodesDat = Util.textToLines(value).join(','); await debounceSave(); }, ); }); // ui config items start final buildTheme = _buildConfigItem( 'theme', () => _getThemeName(downloaderCfg.value.extra.themeMode), (Key key) => DropdownButton( key: key, value: downloaderCfg.value.extra.themeMode, onChanged: (value) async { downloaderCfg.update((val) { val?.extra.themeMode = value!; }); Get.changeThemeMode(ThemeMode.values.byName(value!)); controller.clearTap(); await debounceSave(); }, items: ThemeMode.values .map((e) => DropdownMenuItem( value: e.name, child: Text(_getThemeName(e.name)), )) .toList(), )); final buildLocale = _buildConfigItem( 'locale', () => messages.keys[downloaderCfg.value.extra.locale]!['label']!, (Key key) => DropdownButton( key: key, value: downloaderCfg.value.extra.locale, isDense: true, onChanged: (value) async { downloaderCfg.update((val) { val!.extra.locale = value!; }); Get.updateLocale(toLocale(value!)); controller.clearTap(); await debounceSave(); }, items: messages.keys.keys .map((e) => DropdownMenuItem( value: e, child: Text(messages.keys[e]!['label']!), )) .toList(), )); // about config items start buildHomepage() { const homePage = 'https://gopeed.com'; return ListTile( title: Text('homepage'.tr), subtitle: const Text(homePage), onTap: () { launchUrl(Uri.parse(homePage), mode: LaunchMode.externalApplication); }, ); } buildVersion() { var hasNewVersion = controller.latestVersion.value != null; return ListTile( title: hasNewVersion ? badges.Badge( position: badges.BadgePosition.topStart(start: 36), child: Text('version'.tr)) : Text('version'.tr), subtitle: Text(packageInfo.version), onTap: () { if (hasNewVersion) { showUpdateDialog(context, controller.latestVersion.value!); } }, ); } final buildAutoCheckUpdate = _buildConfigItem( 'notifyWhenNewVersion', () => downloaderCfg.value.extra.notifyWhenNewVersion ? 'on'.tr : 'off'.tr, (Key key) { return Container( alignment: Alignment.centerLeft, child: Switch( value: downloaderCfg.value.extra.notifyWhenNewVersion, onChanged: (bool value) async { downloaderCfg.update((val) { val!.extra.notifyWhenNewVersion = value; }); await debounceSave(); }, ), ); }); final analyticsEnabled = Database.instance.getAnalyticsEnabled().obs; buildAnalyticsEnabled() { return ListTile( title: Text('analyticsEnabled'.tr), subtitle: Text('analyticsEnabledDesc'.tr), trailing: Obx(() => Switch( value: analyticsEnabled.value, onChanged: (bool value) { analyticsEnabled.value = value; Database.instance.saveAnalyticsEnabled(value); }, )), ); } buildThanks() { const thankPage = 'https://github.com/GopeedLab/gopeed/graphs/contributors'; return ListTile( title: Text('thanks'.tr), subtitle: Text('thanksDesc'.tr), onTap: () { launchUrl(Uri.parse(thankPage), mode: LaunchMode.externalApplication); }, ); } // advanced config proxy items start final proxy = downloaderCfg.value.proxy; final buildProxy = _buildConfigItem( 'proxy', () { switch (proxy.proxyMode) { case ProxyModeEnum.noProxy: return 'noProxy'.tr; case ProxyModeEnum.systemProxy: return 'systemProxy'.tr; case ProxyModeEnum.customProxy: return '${downloaderCfg.value.proxy.scheme}://${downloaderCfg.value.proxy.host}'; } }, (Key key) { final mode = SizedBox( width: 150, child: DropdownButtonFormField( value: proxy.proxyMode, onChanged: (value) async { if (value != null && value != proxy.proxyMode) { proxy.proxyMode = value; downloaderCfg.update((val) { val!.proxy = proxy; }); await debounceSave(); } }, items: [ DropdownMenuItem( value: ProxyModeEnum.noProxy, child: Text('noProxy'.tr), ), DropdownMenuItem( value: ProxyModeEnum.systemProxy, child: Text('systemProxy'.tr), ), DropdownMenuItem( value: ProxyModeEnum.customProxy, child: Text('customProxy'.tr), ), ], ), ); final scheme = SizedBox( width: 150, child: DropdownButtonFormField( value: proxy.scheme, onChanged: (value) async { if (value != null && value != proxy.scheme) { proxy.scheme = value; await debounceSave(); } }, items: const [ DropdownMenuItem( value: 'http', child: Text('HTTP'), ), DropdownMenuItem( value: 'https', child: Text('HTTPS'), ), DropdownMenuItem( value: 'socks5', child: Text('SOCKS5'), ), ], ), ); final arr = proxy.host.split(':'); var host = ''; var port = ''; if (arr.length > 1) { host = arr[0]; port = arr[1]; } final ipController = TextEditingController(text: host); final portController = TextEditingController(text: port); updateAddress() async { final newAddress = '${ipController.text}:${portController.text}'; if (newAddress != startCfg.value.address) { proxy.host = newAddress; await debounceSave(); } } ipController.addListener(updateAddress); portController.addListener(updateAddress); final server = Row(children: [ Flexible( child: TextFormField( controller: ipController, decoration: InputDecoration( labelText: 'server'.tr, contentPadding: const EdgeInsets.all(0.0), ), ), ), const Padding(padding: EdgeInsets.only(left: 10)), Flexible( child: TextFormField( controller: portController, decoration: InputDecoration( labelText: 'port'.tr, contentPadding: const EdgeInsets.all(0.0), ), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, NumericalRangeFormatter(min: 0, max: 65535), ], ), ), ]); final usrController = TextEditingController(text: proxy.usr); final pwdController = TextEditingController(text: proxy.pwd); updateAuth() async { if (usrController.text != proxy.usr || pwdController.text != proxy.pwd) { proxy.usr = usrController.text; proxy.pwd = pwdController.text; await debounceSave(); } } usrController.addListener(updateAuth); pwdController.addListener(updateAuth); final auth = Row(children: [ Flexible( child: TextFormField( controller: usrController, decoration: InputDecoration( labelText: 'username'.tr, contentPadding: const EdgeInsets.all(0.0), ), ), ), const Padding(padding: EdgeInsets.only(left: 10)), Flexible( child: TextFormField( controller: pwdController, decoration: InputDecoration( labelText: 'password'.tr, contentPadding: const EdgeInsets.all(0.0), ), obscureText: true, ), ), ]); List customView() { if (proxy.proxyMode != ProxyModeEnum.customProxy) { return []; } return [scheme, server, auth]; } return Form( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: _addPadding([ mode, ...customView(), ]), ), ); }, ); // advanced config GitHub mirror items start final buildGithubMirror = _buildConfigItem( 'githubMirror', () => downloaderCfg.value.extra.githubMirror.enabled ? 'on'.tr : 'off'.tr, (Key key) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( 'githubMirrorEnable'.tr, style: Theme.of(Get.context!).textTheme.bodyMedium, ), const Spacer(), Switch( value: downloaderCfg.value.extra.githubMirror.enabled, onChanged: (value) { downloaderCfg.update((val) { val!.extra.githubMirror.enabled = value; }); debounceSave(); }, ), ], ), _padding, Text( 'githubMirrorDesc'.tr, style: Theme.of(Get.context!).textTheme.bodySmall, ), _padding, // List of existing GitHub mirrors ...downloaderCfg.value.extra.githubMirror.mirrors .where((m) => !m.isDeleted) .toList() .asMap() .entries .map((entry) { // Get the original index in the full list final mirror = entry.value; final index = downloaderCfg.value.extra.githubMirror.mirrors .indexOf(mirror); return Padding( padding: const EdgeInsets.only(bottom: 8.0), child: Row( children: [ Expanded( child: Row( children: [ Expanded( child: Text( mirror.url, style: Theme.of(Get.context!).textTheme.bodyMedium, overflow: TextOverflow.ellipsis, ), ), ], ), ), IconButton( icon: const Icon(Icons.edit, size: 20), tooltip: 'edit'.tr, onPressed: () { _showGithubMirrorDialog( index: index, initialMirror: mirror); }, ), IconButton( icon: const Icon(Icons.delete, size: 20), tooltip: 'delete'.tr, onPressed: () async { // Show confirmation dialog final confirmed = await showDialog( context: context, builder: (context) => AlertDialog( title: Text('tip'.tr), content: Text('confirmDelete'.tr), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(false), child: Text('cancel'.tr), ), TextButton( onPressed: () => Navigator.of(context).pop(true), child: Text('confirm'.tr), ), ], ), ); if (confirmed != true) return; if (mirror.isBuiltIn) { // Mark built-in mirror as deleted (logical delete) downloaderCfg.update((val) { mirror.isDeleted = true; }); } else { // Remove custom mirrors completely (physical delete) final mirrors = List.from( downloaderCfg.value.extra.githubMirror.mirrors); mirrors.removeAt(index); downloaderCfg.update((val) { val!.extra.githubMirror.mirrors = mirrors; }); } await debounceSave(); }, ), ], ), ); }), _padding, // Add button OutlinedButton.icon( onPressed: () { _showGithubMirrorDialog(); }, icon: const Icon(Icons.add), label: Text('add'.tr), ), ], ); }, ); // advanced config API items start final buildApiProtocol = _buildConfigItem( 'protocol', () => startCfg.value.network == 'tcp' ? 'TCP ${startCfg.value.address}' : 'Unix', (Key key) { final items = [ SizedBox( width: 80, child: DropdownButtonFormField( value: startCfg.value.network, onChanged: Util.isDesktop() || Util.isAndroid() ? (value) async { startCfg.update((val) { val!.network = value!; }); await debounceSave(needRestart: true); } : null, items: [ const DropdownMenuItem( value: 'tcp', child: Text('TCP'), ), Util.supportUnixSocket() ? const DropdownMenuItem( value: 'unix', child: Text('Unix'), ) : null, ].where((e) => e != null).map((e) => e!).toList(), ), ) ]; if ((Util.isDesktop() || Util.isAndroid()) && startCfg.value.network == 'tcp') { final arr = startCfg.value.address.split(':'); var ip = '127.0.0.1'; var port = '0'; if (arr.length > 1) { ip = arr[0]; port = arr[1]; } final ipController = TextEditingController(text: ip); final portController = TextEditingController(text: port); updateAddress() async { if (ipController.text.isEmpty || portController.text.isEmpty) { return; } final newAddress = '${ipController.text}:${portController.text}'; if (newAddress != startCfg.value.address) { startCfg.value.address = newAddress; final saved = await debounceSave( check: () async { // Check if address already in use final configIp = ipController.text; final configPort = int.parse(portController.text); if (configPort == 0) { return ''; } try { final socket = await Socket.connect(configIp, configPort, timeout: const Duration(seconds: 3)); socket.close(); return 'portInUse' .trParams({'port': configPort.toString()}); } catch (e) { return ''; } }, needRestart: true); // If save failed, restore the old address if (!saved) { final oldAddress = (await appController.loadStartConfig()).address; startCfg.update((val) async { val!.address = oldAddress; }); } } } ipController.addListener(updateAddress); portController.addListener(updateAddress); items.addAll([ const Padding(padding: EdgeInsets.only(left: 20)), Flexible( child: TextFormField( controller: ipController, decoration: const InputDecoration( labelText: 'IP', contentPadding: EdgeInsets.all(0.0), ), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.allow(RegExp('[0-9.]')), ], ), ), const Padding(padding: EdgeInsets.only(left: 10)), Flexible( child: TextFormField( controller: portController, decoration: InputDecoration( labelText: 'port'.tr, contentPadding: const EdgeInsets.all(0.0), ), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, NumericalRangeFormatter(min: 0, max: 65535), ], ), ), ]); } return Form( child: Row( children: items, ), ); }, ); final buildApiToken = _buildConfigItem('apiToken', () => startCfg.value.apiToken.isEmpty ? 'notSet'.tr : 'set'.tr, (Key key) { final apiTokenController = TextEditingController(text: startCfg.value.apiToken); apiTokenController.addListener(() async { if (apiTokenController.text != startCfg.value.apiToken) { startCfg.value.apiToken = apiTokenController.text; await debounceSave(needRestart: true); } }); return TextField( key: key, obscureText: true, controller: apiTokenController, focusNode: FocusNode(), ); }); // advanced config webhook items final buildWebhook = _buildConfigItem( 'webhook', () => downloaderCfg.value.webhook.enable ? 'on'.tr : 'off'.tr, (Key key) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( 'webhookEnable'.tr, style: Theme.of(Get.context!).textTheme.bodyMedium, ), const Spacer(), Switch( value: downloaderCfg.value.webhook.enable, onChanged: (value) { downloaderCfg.update((val) { val!.webhook.enable = value; }); debounceSave(); }, ), ], ), _padding, Text( 'webhookDesc'.tr, style: Theme.of(Get.context!).textTheme.bodySmall, ), _padding, // List of existing webhook URLs ...downloaderCfg.value.webhook.urls.asMap().entries.map((entry) { final index = entry.key; final url = entry.value; return Padding( padding: const EdgeInsets.only(bottom: 8.0), child: Row( children: [ Expanded( child: Text( url, style: Theme.of(Get.context!).textTheme.bodyMedium, overflow: TextOverflow.ellipsis, ), ), IconButton( icon: const Icon(Icons.edit, size: 20), tooltip: 'edit'.tr, onPressed: () { _showWebhookDialog(index: index, initialUrl: url); }, ), IconButton( icon: const Icon(Icons.delete, size: 20), tooltip: 'delete'.tr, onPressed: () async { // Create new list to avoid unmodifiable list error final urls = List.from(downloaderCfg.value.webhook.urls); urls.removeAt(index); downloaderCfg.update((val) { val!.webhook.urls = urls; }); await debounceSave(); }, ), ], ), ); }), _padding, // Add button OutlinedButton.icon( onPressed: () { _showWebhookDialog(); }, icon: const Icon(Icons.add), label: Text('add'.tr), ), ], ); }, ); // advanced config script items final buildScript = _buildConfigItem( 'script', () => downloaderCfg.value.script.enable ? 'on'.tr : 'off'.tr, (Key key) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Text( 'scriptEnable'.tr, style: Theme.of(Get.context!).textTheme.bodyMedium, ), const Spacer(), Switch( value: downloaderCfg.value.script.enable, onChanged: (value) { downloaderCfg.update((val) { val!.script.enable = value; }); debounceSave(); }, ), ], ), _padding, Text( 'scriptDesc'.tr, style: Theme.of(Get.context!).textTheme.bodySmall, ), _padding, // List of existing script paths ...downloaderCfg.value.script.paths.asMap().entries.map((entry) { final index = entry.key; final path = entry.value; return Padding( padding: const EdgeInsets.only(bottom: 8.0), child: Row( children: [ Expanded( child: Text( path, style: Theme.of(Get.context!).textTheme.bodyMedium, overflow: TextOverflow.ellipsis, ), ), IconButton( icon: const Icon(Icons.edit, size: 20), tooltip: 'edit'.tr, onPressed: () { _showScriptDialog(index: index, initialPath: path); }, ), IconButton( icon: const Icon(Icons.delete, size: 20), tooltip: 'delete'.tr, onPressed: () async { // Create new list to avoid unmodifiable list error final paths = List.from(downloaderCfg.value.script.paths); paths.removeAt(index); downloaderCfg.update((val) { val!.script.paths = paths; }); await debounceSave(); }, ), ], ), ); }), _padding, // Add button OutlinedButton.icon( onPressed: () { _showScriptDialog(); }, icon: const Icon(Icons.add), label: Text('add'.tr), ), ], ); }, ); // advanced config log items start buildLogsDir() { return ListTile( title: Text("logDirectory".tr), subtitle: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Expanded( child: TextField( controller: TextEditingController(text: logsDir()), enabled: false, readOnly: true, ), ), Util.isDesktop() ? IconButton( icon: const Icon(Icons.folder_open), onPressed: () { launchUrl(Uri.file(logsDir())); }, ) : CopyButton(logsDir()), ], )); } return Obx(() { return GestureDetector( onTap: () { controller.clearTap(); }, child: DefaultTabController( length: 2, child: Scaffold( appBar: PreferredSize( preferredSize: const Size.fromHeight(56), child: AppBar( bottom: TabBar( tabs: [ Tab( text: 'basic'.tr, ), Tab( text: 'advanced'.tr, ), ], ), )), body: TabBarView( children: [ SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: _addPadding([ Text('general'.tr), Card( child: Column( children: _addDivider([ buildDownloadDir(), buildDownloadCategories(), buildMaxRunning(), buildDefaultDirectDownload(), buildAutoStartTasks(), buildAutoTorrentEnable(), Obx(() => Visibility( visible: appController.downloaderConfig.value .autoTorrent.enable, child: buildAutoTorrentDeleteAfterDownload(), )), buildAutoDeleteMissingFileTasks(), buildBrowserExtension(), buildAutoStartup(), buildMenubarMode(), buildDesktopNotification(), ]), )), Text('archives'.tr), Card( child: Column( children: _addDivider([ buildAutoExtract(), Obx(() => Visibility( visible: appController.downloaderConfig.value .archive.autoExtract, child: buildDeleteAfterExtract(), )), ]), )), const Text('HTTP'), Card( child: Column( children: _addDivider([ buildHttpUa(), buildHttpConnections(), buildHttpUseServerCtime(), ]), )), const Text('BitTorrent'), Card( child: Column( children: _addDivider([ buildBtListenPort(), buildBtTrackerSubscribeUrls(), buildBtTrackers(), buildBtSeedConfig(), buildBtDefaultClientConfig(), ]), )), Text('ed2k'.tr), Card( child: Column( children: _addDivider([ buildEd2kListenPort(), buildEd2kUdpPort(), buildEd2kServerAddr(), buildEd2kServerMet(), buildEd2kNodesDat(), ]), )), Text('ui'.tr), Card( child: Column( children: _addDivider([ buildTheme(), buildLocale(), ]), )), Text('about'.tr), Card( child: Column( children: _addDivider([ buildHomepage(), buildVersion(), buildAutoCheckUpdate(), if (Config.isConfigured) buildAnalyticsEnabled(), buildThanks(), ]), )), ]), ), ), // Column( // children: [ // Card( // child: Column( // children: [ // ..._addDivider([ // buildApiProtocol(), // Util.isDesktop() && startCfg.value.network == 'tcp' // ? buildApiToken() // : null, // ]), // ], // )), // ], // ), SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: _addPadding([ Text('network'.tr), Card( child: Column( children: _addDivider([ buildProxy(), buildGithubMirror(), ]), )), const Text('API'), Card( child: Column( children: _addDivider([ buildApiProtocol(), Util.isDesktop() && startCfg.value.network == 'tcp' ? buildApiToken() : null, ]), )), Text('developer'.tr), Card( child: Column( children: _addDivider([ buildWebhook(), if (Util.isDesktop()) buildScript(), buildLogsDir(), ]), )), ]), )) ], ).paddingOnly(left: 16, right: 16, top: 16, bottom: 16)), ), ); }); } void _showWebhookDialog({int? index, String? initialUrl}) { final urlController = TextEditingController(text: initialUrl ?? ''); final testController = OutlinedButtonLoadingController(); final saveController = TextButtonLoadingController(); final appController = Get.find(); final downloaderCfg = appController.downloaderConfig; final isEdit = index != null; final formKey = GlobalKey(); showDialog( context: Get.context!, builder: (dialogContext) => AlertDialog( title: Text(isEdit ? 'edit'.tr : 'add'.tr), content: Form( key: formKey, child: Column( mainAxisSize: MainAxisSize.min, children: [ TextFormField( controller: urlController, decoration: InputDecoration( hintText: 'webhookUrlHint'.tr, ), keyboardType: TextInputType.url, validator: (value) { if (value == null || value.trim().isEmpty) { return 'required'.tr; } final url = value.trim().toLowerCase(); if (!url.startsWith('http://') && !url.startsWith('https://')) { return 'urlInvalid'.tr; } try { Uri.parse(value.trim()); } catch (e) { return 'urlInvalid'.tr; } return null; }, ), ], ), ), actionsAlignment: MainAxisAlignment.spaceBetween, actions: [ // Test button on the left Padding( padding: const EdgeInsets.only(left: 16.0), child: OutlinedButtonLoading( controller: testController, onPressed: () async { if (!formKey.currentState!.validate()) { return; } final url = urlController.text.trim(); if (url.isEmpty) return; testController.start(); try { await api.testWebhook(url); showMessage('tip'.tr, 'webhookTestSuccess'.tr); } catch (e) { showErrorMessage('webhookTestFail'.tr); } finally { testController.stop(); } }, child: Text('webhookTest'.tr), ), ), // Cancel and Confirm on the right Row( mainAxisSize: MainAxisSize.min, children: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: Text('cancel'.tr), ), TextButtonLoading( controller: saveController, onPressed: () async { if (!formKey.currentState!.validate()) { return; } final url = urlController.text.trim(); if (url.isEmpty) return; saveController.start(); try { // Create new list to avoid unmodifiable list error final urls = List.from(downloaderCfg.value.webhook.urls); if (isEdit) { urls[index] = url; } else { urls.add(url); } downloaderCfg.update((val) { val!.webhook.urls = urls; }); await appController.saveConfig(); if (dialogContext.mounted) { Navigator.of(dialogContext).pop(); } } catch (e) { showErrorMessage(e); } finally { saveController.stop(); } }, child: Text('confirm'.tr), ), ], ), ], ), ); } void _showScriptDialog({int? index, String? initialPath}) { final pathController = TextEditingController(text: initialPath ?? ''); final saveController = TextButtonLoadingController(); final appController = Get.find(); final downloaderCfg = appController.downloaderConfig; final isEdit = index != null; final formKey = GlobalKey(); showDialog( context: Get.context!, builder: (dialogContext) => AlertDialog( title: Text(isEdit ? 'edit'.tr : 'add'.tr), content: Form( key: formKey, child: Column( mainAxisSize: MainAxisSize.min, children: [ Row( children: [ Expanded( child: TextFormField( controller: pathController, decoration: InputDecoration( hintText: 'scriptPathHint'.tr, ), validator: (value) { if (value == null || value.trim().isEmpty) { return 'required'.tr; } return null; }, ), ), if (Util.isDesktop()) Padding( padding: const EdgeInsets.only(left: 8.0), child: IconButton( icon: const Icon(Icons.folder_open), onPressed: () async { final result = await FilePicker.platform.pickFiles(); if (result != null && result.files.isNotEmpty) { final filePath = result.files.first.path; if (filePath != null) { pathController.text = filePath; } } }, ), ), ], ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: Text('cancel'.tr), ), TextButtonLoading( controller: saveController, onPressed: () async { if (!formKey.currentState!.validate()) { return; } final path = pathController.text.trim(); if (path.isEmpty) return; saveController.start(); try { // Create new list to avoid unmodifiable list error final paths = List.from(downloaderCfg.value.script.paths); if (isEdit) { paths[index] = path; } else { paths.add(path); } downloaderCfg.update((val) { val!.script.paths = paths; }); await appController.saveConfig(); if (dialogContext.mounted) { Navigator.of(dialogContext).pop(); } } catch (e) { showErrorMessage(e); } finally { saveController.stop(); } }, child: Text('confirm'.tr), ), ], ), ); } void _showGithubMirrorDialog({int? index, GithubMirror? initialMirror}) { final isEdit = index != null; GithubMirrorType selectedType = initialMirror?.type ?? GithubMirrorType.jsdelivr; final urlController = TextEditingController(text: initialMirror?.url ?? ''); final saveController = TextButtonLoadingController(); final appController = Get.find(); final downloaderCfg = appController.downloaderConfig; final formKey = GlobalKey(); showDialog( context: Get.context!, builder: (dialogContext) => StatefulBuilder( builder: (context, setState) => AlertDialog( title: Text(isEdit ? 'edit'.tr : 'add'.tr), content: Form( key: formKey, child: Column( mainAxisSize: MainAxisSize.min, children: [ DropdownButtonFormField( value: selectedType, decoration: InputDecoration( labelText: 'githubMirrorType'.tr, ), onChanged: (value) { if (value != null) { setState(() { selectedType = value; }); } }, items: GithubMirrorType.values .map((type) => DropdownMenuItem( value: type, child: Text(type.name), )) .toList(), ), const SizedBox(height: 16), TextFormField( controller: urlController, decoration: InputDecoration( labelText: 'githubMirrorUrl'.tr, hintText: 'githubMirrorUrlHint'.tr, ), keyboardType: TextInputType.url, validator: (value) { if (value == null || value.trim().isEmpty) { return 'required'.tr; } final url = value.trim().toLowerCase(); if (!url.startsWith('http://') && !url.startsWith('https://')) { return 'urlInvalid'.tr; } try { Uri.parse(value.trim()); } catch (e) { return 'urlInvalid'.tr; } return null; }, ), ], ), ), actions: [ TextButton( onPressed: () => Navigator.of(dialogContext).pop(), child: Text('cancel'.tr), ), TextButtonLoading( controller: saveController, onPressed: () async { if (!formKey.currentState!.validate()) { return; } final url = urlController.text.trim(); if (url.isEmpty) return; saveController.start(); try { // Create new list to avoid unmodifiable list error final mirrors = List.from( downloaderCfg.value.extra.githubMirror.mirrors); final newMirror = GithubMirror( type: selectedType, url: url, ); if (isEdit) { mirrors[index] = newMirror; } else { mirrors.add(newMirror); } downloaderCfg.update((val) { val!.extra.githubMirror.mirrors = mirrors; }); await appController.saveConfig(); if (dialogContext.mounted) { Navigator.of(dialogContext).pop(); } } catch (e) { showErrorMessage(e); } finally { saveController.stop(); } }, child: Text('confirm'.tr), ), ], ), ), ); } void _tapInputWidget(GlobalKey key) { if (key.currentContext == null) { return; } if (key.currentContext?.widget is TextField) { final textField = key.currentContext?.widget as TextField; textField.focusNode?.requestFocus(); return; } /* GestureDetector? detector; void searchForGestureDetector(BuildContext? element) { element?.visitChildElements((element) { if (element.widget is GestureDetector) { detector = element.widget as GestureDetector?; } else { searchForGestureDetector(element); } }); } searchForGestureDetector(key.currentContext); detector?.onTap?.call(); */ } Widget Function() _buildConfigItem( String label, String Function() text, Widget Function(Key key) input) { final tapStatues = controller.tapStatues; final inputKey = GlobalKey(); return () => ListTile( title: Text(label.tr), subtitle: tapStatues[label] ?? false ? input(inputKey) : Text(text()), onTap: () { controller.onTap(label); WidgetsBinding.instance.addPostFrameCallback((timeStamp) { _tapInputWidget(inputKey); }); }); } List _addPadding(List widgets) { final result = []; for (var i = 0; i < widgets.length; i++) { result.add(widgets[i]); result.add(_padding); } return result; } List _addDivider(List widgets) { final result = []; final newArr = widgets.where((e) => e != null).map((e) => e!).toList(); for (var i = 0; i < newArr.length; i++) { result.add(newArr[i]); if (i != newArr.length - 1) { result.add(_divider); } } return result; } String _getThemeName(String? themeMode) { switch (ThemeMode.values.byName(themeMode ?? ThemeMode.system.name)) { case ThemeMode.light: return 'themeLight'.tr; case ThemeMode.dark: return 'themeDark'.tr; default: return 'themeSystem'.tr; } } void _showCategoryDialog( BuildContext context, Future Function() debounceSave, Rx downloaderCfg, { DownloadCategory? category, }) { final isEdit = category != null; final nameController = TextEditingController( text: isEdit ? _getCategoryDisplayName(category) : '', ); final pathController = TextEditingController(text: category?.path ?? ''); showDialog( context: context, builder: (context) => AlertDialog( title: Text(isEdit ? 'edit'.tr : 'add'.tr), content: Column( mainAxisSize: MainAxisSize.min, children: [ TextField( controller: nameController, decoration: InputDecoration( labelText: 'categoryName'.tr, ), ), const SizedBox(height: 16), DirectorySelector( controller: pathController, showLabel: true, allowEdit: true, showPlaceholderButton: true, ), ], ), actions: [ TextButton( onPressed: () => Navigator.of(context).pop(), child: Text('cancel'.tr), ), TextButton( onPressed: () { if (nameController.text.isEmpty || pathController.text.isEmpty) { return; } if (isEdit) { // Trigger UI update by wrapping changes in update() downloaderCfg.update((val) { // If name changed, clear nameKey so it won't be re-translated final nameChanged = nameController.text != _getCategoryDisplayName(category); category.name = nameController.text; category.path = pathController.text; if (nameChanged) { category.nameKey = null; } // If editing a deleted built-in category, unmark it as deleted if (category.isBuiltIn && category.isDeleted) { category.isDeleted = false; } }); } else { downloaderCfg.update((val) { val!.extra.downloadCategories = [ ...val.extra.downloadCategories, DownloadCategory( name: nameController.text, path: pathController.text, ), ]; }); } debounceSave(); Navigator.of(context).pop(); }, child: Text('confirm'.tr), ), ], ), ); } } enum ProxyModeEnum { noProxy, systemProxy, customProxy, } extension ProxyMode on ProxyConfig { ProxyModeEnum get proxyMode { if (!enable) { return ProxyModeEnum.noProxy; } if (system) { return ProxyModeEnum.systemProxy; } return ProxyModeEnum.customProxy; } set proxyMode(ProxyModeEnum value) { switch (value) { case ProxyModeEnum.noProxy: enable = false; break; case ProxyModeEnum.systemProxy: enable = true; system = true; break; case ProxyModeEnum.customProxy: enable = true; system = false; break; } } }