import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart' show Divider, Icons, Scrollbar, ScrollbarOrientation; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:go_router/go_router.dart'; import 'package:shadcn_flutter/shadcn_flutter.dart' as shad; import 'package:url_launcher/url_launcher.dart'; import '../../../../api/model/downloader_config.dart'; import '../../../../app/application/app_appearance_controller.dart'; import '../../../../app/application/app_platform_controller.dart'; import '../../../../app/application/app_runtime_controller.dart'; import '../../../../app/application/location_keep_alive.dart'; import '../../../../app/application/continued_processing.dart'; import '../../../../core/common/start_config.dart'; import '../../../../core/network/gopeed/gopeed_transport.dart'; import '../../../../core/utils/breakpoints.dart'; import '../../../../core/window/app_window_chrome.dart'; import '../../../../shared/theme/app_design_tokens.dart'; import '../../../../shared/theme/app_palette.dart'; import '../../../../shared/widgets/app_choice_segmented_control.dart'; import '../../../../shared/widgets/app_loading_button.dart'; import '../../../../shared/widgets/app_number_input.dart'; import '../../../../shared/widgets/app_path_picker_field.dart'; import '../../../../shared/widgets/app_text_field.dart'; import '../../../../shared/widgets/app_tooltip.dart'; import '../../../../shared/widgets/app_toast.dart'; import '../../../../shared/widgets/responsive_menu_layout.dart'; import '../../../../l10n/l10n.dart'; import '../../../../util/log_util.dart'; import '../../../../util/package_info.dart'; import '../../../../util/scheme_register/scheme_register.dart'; import '../../../../util/util.dart'; import '../../../home/presentation/widgets/primary_rail.dart'; import '../../application/settings_controller.dart'; import '../widgets/app_update_dialog.dart'; import '../widgets/download_categories_setting.dart'; import '../widgets/mcp_agent_setup.dart'; import '../widgets/settings_language_select.dart'; import '../widgets/settings_content_frame.dart'; import '../widgets/settings_item.dart'; import '../widgets/settings_list_editor.dart'; import '../widgets/theme_settings_control.dart'; enum _SettingsSection { basic, downloads, advanced } String webApiDisplayAddress(Uri pageUri) { final host = pageUri.host.toLowerCase() == 'localhost' ? '127.0.0.1' : pageUri.host; return Uri(host: host, port: pageUri.hasPort ? pageUri.port : null).authority; } class SettingsPage extends ConsumerStatefulWidget { const SettingsPage({super.key, this.sectionKey}); final String? sectionKey; @override ConsumerState createState() => _SettingsPageState(); } class _SettingsPageState extends ConsumerState { final _downloadDirController = TextEditingController(); final _maxRunningController = TextEditingController(); final _httpUserAgentController = TextEditingController(); final _httpConnectionsController = TextEditingController(); final _hlsConnectionsController = TextEditingController(); final _hlsMaxRetriesController = TextEditingController(); final _btListenPortController = TextEditingController(); final _btSeedRatioController = TextEditingController(); final _btSeedTimeController = TextEditingController(); final _ed2kListenPortController = TextEditingController(); final _ed2kUdpPortController = TextEditingController(); final _ed2kServerAddrController = TextEditingController(); final _ed2kServerMetController = TextEditingController(); final _ed2kNodesDatController = TextEditingController(); final _proxyHostController = TextEditingController(); final _proxyPortController = TextEditingController(); final _proxyUserController = TextEditingController(); final _proxyPasswordController = TextEditingController(); final _customTrackersController = TextEditingController(); final _apiHostController = TextEditingController(); final _apiPortController = TextEditingController(); final _apiTokenController = TextEditingController(); DownloaderConfig? _config; StartConfig? _startConfig; String? _loadedSignature; String? _loadedStartSignature; Timer? _textSaveTimer; bool _syncingControllers = false; bool _syncingStartControllers = false; bool _savingStartConfig = false; List get _textControllers => [ _downloadDirController, _maxRunningController, _httpUserAgentController, _httpConnectionsController, _hlsConnectionsController, _hlsMaxRetriesController, _btListenPortController, _btSeedRatioController, _btSeedTimeController, _ed2kListenPortController, _ed2kUdpPortController, _ed2kServerAddrController, _ed2kServerMetController, _ed2kNodesDatController, _proxyHostController, _proxyPortController, _proxyUserController, _proxyPasswordController, _customTrackersController, ]; List get _startTextControllers => [ _apiHostController, _apiPortController, _apiTokenController, ]; @override void initState() { super.initState(); for (final controller in _textControllers) { controller.addListener(_scheduleTextSave); } for (final controller in _startTextControllers) { controller.addListener(_handleStartDraftChanged); } } @override void dispose() { _textSaveTimer?.cancel(); for (final controller in _textControllers) { controller.removeListener(_scheduleTextSave); controller.dispose(); } for (final controller in _startTextControllers) { controller.removeListener(_handleStartDraftChanged); controller.dispose(); } super.dispose(); } @override Widget build(BuildContext context) { final palette = AppPalette.of(context); final stateAsync = ref.watch(settingsControllerProvider); final runtimeState = ref.watch(appRuntimeControllerProvider).value; final isDesktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile; final selectedSection = _sectionFromKey(widget.sectionKey) ?? _SettingsSection.basic; final content = stateAsync.when( loading: () => const Center(child: shad.CircularProgressIndicator()), error: (error, _) => _ErrorState(error: error, onRetry: () => ref.read(settingsControllerProvider.notifier).reload()), data: (state) { _syncConfig(state.config); if (kIsWeb) { _syncWebStartConfig(state.config.api, runtimeState); } else if (runtimeState != null) { _syncStartConfig(runtimeState.startConfig); } return ResponsiveMenuLayout<_SettingsSection>( title: context.l10n.setting, items: _menuItems, selectedValue: selectedSection, onSelected: (section) => _selectSection(section, isDesktop: isDesktop), mobileContentTitleBuilder: _sectionTitle, mobileContentVisible: widget.sectionKey != null, onMobileBack: _leaveMobileSection, contentBuilder: (context, section) => _buildSectionContent(section, runtimeState), ); }, ); return shad.Scaffold( backgroundColor: palette.bg, child: isDesktop ? Row( children: [ const PrimaryRail(activeSection: RailSection.settings), Expanded(child: content), ], ) : Column( children: [ Expanded(child: content), const PrimaryBottomNavigation(activeSection: RailSection.settings), ], ), ); } void _selectSection(_SettingsSection section, {required bool isDesktop}) { final location = '/settings/${section.name}'; if (isDesktop) { context.go(location); } else { context.push(location); } } void _leaveMobileSection() { if (context.canPop()) { context.pop(); } else { context.go('/settings'); } } _SettingsSection? _sectionFromKey(String? key) { for (final section in _SettingsSection.values) { if (section.name == key) return section; } return null; } List> get _menuItems => [ ResponsiveMenuItem(value: _SettingsSection.basic, label: context.l10n.basicSettings, icon: Icons.tune_outlined), ResponsiveMenuItem( value: _SettingsSection.downloads, label: context.l10n.downloadSettings, icon: Icons.download_outlined, ), ResponsiveMenuItem( value: _SettingsSection.advanced, label: context.l10n.advancedSettings, icon: Icons.code_outlined, ), ]; Widget _buildSectionContent(_SettingsSection section, AppRuntimeState? runtimeState) { final config = _config!; final palette = AppPalette.of(context); final platform = ref.watch(appPlatformControllerProvider).value; final isDesktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile; return Padding( padding: EdgeInsets.only( top: isDesktop && AppWindowChrome.reservesHeaderInset ? AppDesignTokens.windowHeaderHeight : 0, ), child: Column( children: [ if (isDesktop) SizedBox( height: AppDesignTokens.contentHeaderHeight, child: SettingsContentFrame( child: SizedBox( key: const ValueKey('settings-header-content'), width: double.infinity, child: Row( children: [ Text( _sectionTitle(section), style: TextStyle(color: palette.textPrimary, fontSize: 22, fontWeight: FontWeight.w800), ), ], ), ), ), ), Expanded( child: ListView( key: const ValueKey('settings-section-scroll-view'), padding: const EdgeInsets.only(top: AppDesignTokens.space8, bottom: 28), children: [ SettingsContentFrame( child: SizedBox( key: const ValueKey('settings-body-content'), width: double.infinity, child: _sectionBody(section, config, platform, runtimeState), ), ), ], ), ), ], ), ); } Widget _sectionBody( _SettingsSection section, DownloaderConfig config, AppPlatformState? platform, AppRuntimeState? runtimeState, ) { final apiConfigDirty = _startConfig != null && _startDraftSignature() != _loadedStartSignature; final apiServerVisualState = _apiServerVisualState(runtimeState); final palette = AppPalette.of(context); switch (section) { case _SettingsSection.basic: return _SettingsSectionStack( children: [ _SettingsBlock( title: context.l10n.ui, child: _SettingsGroup( children: [ SettingsItem( title: context.l10n.theme, child: ThemeModeSelector( value: ref.watch(appAppearanceControllerProvider).themeMode, accent: ref.watch(appAppearanceControllerProvider).themeColor, onChanged: (mode) { ref.read(appAppearanceControllerProvider.notifier).setThemeMode(mode); _mutateConfig((next) => next.extra.themeMode = mode.key); }, ), ), SettingsItem( title: context.l10n.themeColor, subtitle: context.l10n.themeColorDescription, child: ThemeColorSelector( value: ref.watch(appAppearanceControllerProvider).themeColor, onChanged: (color) { ref.read(appAppearanceControllerProvider.notifier).setThemeColor(color); _mutateConfig((next) => next.extra.themeColor = color.key); }, ), ), SettingsItem( title: context.l10n.locale, child: SettingsLanguageSelect( value: _languageValue(config), onChanged: (value) { _mutateConfig((next) => next.extra.locale = value == 'system' ? '' : value); }, ), ), ], ), ), _SettingsBlock( title: context.l10n.general, child: _SettingsGroup( children: [ SettingsItem( title: context.l10n.browserExtension, subtitle: context.l10n.browserExtensionDescription, child: _BrowserExtensionLinks(onOpen: _openBrowserExtension), ), if (Util.isWindows() || Util.isLinux()) SettingsItem( title: context.l10n.launchAtStartup, child: shad.Switch( value: platform?.launchAtStartup ?? false, onChanged: (value) => _runAction( () => ref.read(appPlatformControllerProvider.notifier).setLaunchAtStartup(value), ), ), ), if (Util.isMacos()) SettingsItem( title: context.l10n.runAsMenubarApp, subtitle: context.l10n.runAsMenubarAppDesc, child: shad.Switch( value: platform?.runAsMenubarApp ?? false, onChanged: (value) => _runAction( () => ref.read(appPlatformControllerProvider.notifier).setRunAsMenubarApp(value), ), ), ), if (Util.isDesktop()) SettingsItem( title: context.l10n.desktopNotification, subtitle: context.l10n.desktopNotificationDescription, child: shad.Switch( value: config.extra.desktopNotification, onChanged: (value) => _mutateConfig((next) => next.extra.desktopNotification = value), ), ), if (Util.isIOS()) SettingsItem( title: context.l10n.backgroundLocationKeepAlive, subtitle: context.l10n.backgroundLocationKeepAliveDescription, child: shad.Switch( value: config.extra.backgroundLocationKeepAlive, onChanged: (value) => unawaited(_setBackgroundLocationKeepAlive(value)), ), ), if (Util.isIOS()) SettingsItem( title: context.l10n.backgroundContinuedProcessing, subtitle: context.l10n.backgroundContinuedProcessingDescription, child: shad.Switch( value: config.extra.backgroundContinuedProcessing, onChanged: (value) => unawaited( _setBackgroundContinuedProcessing(value), ), ), ), ], ), ), _SettingsBlock( title: context.l10n.about, child: _SettingsGroup( children: [ SettingsItem( title: context.l10n.homepage, child: _ExternalTextLink( key: const ValueKey('gopeed-homepage'), label: 'gopeed.com', onPressed: () => unawaited(_openExternalUri(Uri.parse('https://gopeed.com'))), ), ), SettingsItem( title: 'GitHub', child: _ExternalTextLink( key: const ValueKey('gopeed-github'), label: 'github.com/GopeedLab/gopeed', onPressed: () => unawaited(_openExternalUri(Uri.parse('https://github.com/GopeedLab/gopeed'))), ), ), SettingsItem( title: context.l10n.contributors, subtitle: context.l10n.thanksDesc, child: _ExternalTextLink( key: const ValueKey('gopeed-contributors'), label: context.l10n.viewContributors, onPressed: () => unawaited( _openExternalUri(Uri.parse('https://github.com/GopeedLab/gopeed/graphs/contributors')), ), ), ), SettingsItem( title: context.l10n.analyticsEnabled, subtitle: context.l10n.analyticsEnabledDesc, child: shad.Switch( value: config.extra.analyticsEnabled, onChanged: (value) => _mutateConfig((next) => next.extra.analyticsEnabled = value), ), ), SettingsItem( title: context.l10n.notifyWhenNewVersion, child: shad.Switch( value: config.extra.notifyWhenNewVersion, onChanged: (value) => _mutateConfig((next) => next.extra.notifyWhenNewVersion = value), ), ), SettingsItem( title: context.l10n.version, subtitle: context.l10n.currentVersion(_versionLabel), child: _UpdateActionControl(platform: platform), ), ], ), ), ], ); case _SettingsSection.downloads: return _SettingsSectionStack( children: [ _SettingsBlock( title: context.l10n.general, child: _SettingsGroup( children: [ SettingsItem( title: context.l10n.downloadDir, child: AppPathPickerField.downloadDirectory( fieldKey: const ValueKey('download-directory-input'), controller: _downloadDirController, pickerKey: const ValueKey('download-directory-picker'), desktopWidth: AppDesignTokens.settingsFormControlWidth, ), ), SettingsItem( title: context.l10n.downloadCategories, child: DownloadCategoriesControl( categories: config.extra.downloadCategories, displayName: _categoryName, onAdd: () => unawaited(_editDownloadCategory()), onEdit: (category) => unawaited(_editDownloadCategory(category)), onDelete: (category) => unawaited(_deleteDownloadCategory(category)), ), ), SettingsItem( title: context.l10n.maxRunning, subtitle: context.l10n.maxRunningDescription, child: _NumberSettingControl( fieldKey: const ValueKey('max-running-input'), controller: _maxRunningController, min: 1, max: 256, ), ), SettingsItem( title: context.l10n.defaultDirectDownload, child: shad.Switch( value: config.extra.defaultDirectDownload, onChanged: (value) => _mutateConfig((next) => next.extra.defaultDirectDownload = value), ), ), SettingsItem( title: context.l10n.autoStartTasks, child: shad.Switch( value: config.autoStartTasks, onChanged: (value) => _mutateConfig((next) => next.autoStartTasks = value), ), ), SettingsItem( title: context.l10n.autoTorrentEnable, child: shad.Switch( value: config.autoTorrent.enable, onChanged: (value) => _mutateConfig((next) => next.autoTorrent.enable = value), ), ), if (config.autoTorrent.enable) SettingsItem( title: context.l10n.autoTorrentDeleteAfterDownload, child: shad.Switch( value: config.autoTorrent.deleteAfterDownload, onChanged: (value) => _mutateConfig((next) => next.autoTorrent.deleteAfterDownload = value), ), ), SettingsItem( title: context.l10n.autoDeleteMissingFileTasks, child: shad.Switch( value: config.autoDeleteMissingFileTasks, onChanged: (value) => _mutateConfig((next) => next.autoDeleteMissingFileTasks = value), ), ), ], ), ), _SettingsBlock( key: const ValueKey('settings-http-block'), title: 'HTTP', child: _SettingsGroup( children: [ SettingsItem( title: 'User-Agent', subtitle: context.l10n.defaultUserAgentDescription, child: _TextSettingControl( fieldKey: const ValueKey('http-user-agent-input'), controller: _httpUserAgentController, ), ), SettingsItem( title: context.l10n.connections, child: _NumberSettingControl( fieldKey: const ValueKey('http-connections-input'), controller: _httpConnectionsController, min: 1, max: 256, ), ), SettingsItem( title: context.l10n.useServerCtime, child: shad.Switch( value: config.protocolConfig.http.useServerCtime, onChanged: (value) => _mutateConfig((next) => next.protocolConfig.http.useServerCtime = value), ), ), SettingsItem( title: context.l10n.autoExtract, child: shad.Switch( value: config.archive.autoExtract, onChanged: (value) => _mutateConfig((next) => next.archive.autoExtract = value), ), ), if (config.archive.autoExtract) SettingsItem( title: context.l10n.deleteAfterExtract, child: shad.Switch( value: config.archive.deleteAfterExtract, onChanged: (value) => _mutateConfig((next) => next.archive.deleteAfterExtract = value), ), ), ], ), ), _SettingsBlock( key: const ValueKey('settings-hls-block'), title: 'HLS', child: _SettingsGroup( children: [ SettingsItem( title: context.l10n.connections, child: _NumberSettingControl( fieldKey: const ValueKey('hls-connections-input'), controller: _hlsConnectionsController, min: 1, max: 256, ), ), SettingsItem( title: context.l10n.maxRetries, child: _NumberSettingControl( fieldKey: const ValueKey('hls-max-retries-input'), controller: _hlsMaxRetriesController, min: 0, max: 30, ), ), ], ), ), _SettingsBlock( title: 'BitTorrent', child: _SettingsGroup( children: [ if (Util.isWindows()) SettingsItem( title: context.l10n.setAsDefaultBtClient, child: shad.Switch( value: config.extra.defaultBtClient, onChanged: (value) => unawaited(_setDefaultBtClient(value)), ), ), SettingsItem( title: context.l10n.listenPort, child: _NumberSettingControl( fieldKey: const ValueKey('bt-listen-port-input'), controller: _btListenPortController, min: 0, max: 65535, ), ), SettingsItem( title: context.l10n.seedKeep, child: shad.Switch( value: config.protocolConfig.bt.seedKeep, onChanged: (value) => _mutateConfig((next) => next.protocolConfig.bt.seedKeep = value), ), ), if (!config.protocolConfig.bt.seedKeep) ...[ SettingsItem( title: context.l10n.seedRatio, child: _NumberSettingControl( fieldKey: const ValueKey('bt-seed-ratio-input'), controller: _btSeedRatioController, min: 0, step: 0.1, decimalPlaces: 2, ), ), SettingsItem( title: context.l10n.seedTime, child: _NumberSettingControl( fieldKey: const ValueKey('bt-seed-time-input'), controller: _btSeedTimeController, min: 0, max: 100000000, ), ), ], SettingsItem( title: context.l10n.subscribeTracker, child: _TrackerSubscriptionsControl( selected: config.extra.bt.trackerSubscribeUrls, autoUpdate: config.extra.bt.autoUpdateTrackers, lastUpdated: config.extra.bt.lastTrackerUpdateTime, onChanged: (urls) => _mutateConfig((next) => next.extra.bt.trackerSubscribeUrls = urls), onAutoUpdateChanged: (value) => _mutateConfig((next) => next.extra.bt.autoUpdateTrackers = value), onUpdate: _updateTrackers, ), ), SettingsItem( title: context.l10n.addTracker, subtitle: context.l10n.onePerLine, child: _TextSettingControl(controller: _customTrackersController, minLines: 7), ), ], ), ), _SettingsBlock( title: 'ED2K', child: _SettingsGroup( children: [ SettingsItem( title: context.l10n.ed2kTcpPort, child: _NumberSettingControl( fieldKey: const ValueKey('ed2k-tcp-port-input'), controller: _ed2kListenPortController, min: 0, max: 65535, ), ), SettingsItem( title: context.l10n.ed2kUdpPort, child: _NumberSettingControl( fieldKey: const ValueKey('ed2k-udp-port-input'), controller: _ed2kUdpPortController, min: 0, max: 65535, ), ), SettingsItem( title: context.l10n.ed2kServerList, subtitle: context.l10n.onePerLine, child: _TextSettingControl( fieldKey: const ValueKey('ed2k-server-address-input'), hintText: context.l10n.ed2kServersHint, controller: _ed2kServerAddrController, minLines: 5, ), ), SettingsItem( title: context.l10n.ed2kServerMet, subtitle: context.l10n.onePerLine, child: _TextSettingControl( fieldKey: const ValueKey('ed2k-server-met-input'), hintText: context.l10n.ed2kServerMetHint, controller: _ed2kServerMetController, minLines: 4, ), ), SettingsItem( title: context.l10n.ed2kNodesDat, subtitle: context.l10n.onePerLine, child: _TextSettingControl( fieldKey: const ValueKey('ed2k-nodes-dat-input'), hintText: context.l10n.ed2kNodesDatHint, controller: _ed2kNodesDatController, minLines: 4, ), ), ], ), ), ], ); case _SettingsSection.advanced: return _SettingsSectionStack( children: [ _SettingsBlock( title: 'API', child: _SettingsGroup( children: [ SettingsItem( title: context.l10n.apiServerRuntimeStatus, subtitle: _apiServerRuntimeDetails(runtimeState), child: _ApiServerStatusLight( key: const ValueKey('api-server-runtime-status'), state: apiServerVisualState, label: switch (apiServerVisualState) { _ApiServerVisualState.loading => context.l10n.readingRuntimeStatus, _ApiServerVisualState.running => context.l10n.apiServerRunning, _ApiServerVisualState.stopped => context.l10n.apiServerStopped, _ApiServerVisualState.failed => context.l10n.apiServerFailed, }, ), ), SettingsItem( title: context.l10n.listenAddress, child: _HostPortControl( hostController: _apiHostController, portController: _apiPortController, hostKey: const ValueKey('api-host-input'), portKey: const ValueKey('api-port-input'), enabled: !kIsWeb, ), ), if (!kIsWeb) SettingsItem( title: context.l10n.apiToken, child: _TextSettingControl(controller: _apiTokenController, obscureText: true), ), McpSettingsItem( enabled: _startConfig?.mcpEnable ?? false, readOnly: kIsWeb, onChanged: (value) => setState(() => _startConfig?.mcpEnable = value), onOpenAgentSetup: () => unawaited(_openMcpAgentSetup(runtimeState)), ), Align( alignment: Alignment.centerRight, child: Wrap( spacing: 8, runSpacing: 8, alignment: WrapAlignment.end, children: [ if (kIsWeb) AppTooltip( message: context.l10n.webApiConfigReadOnlyHelp, child: Padding( key: const ValueKey('web-api-config-help'), padding: const EdgeInsets.all(8), child: Icon(Icons.help_outline, size: 17, color: palette.textMuted), ), ), AppLoadingButton( key: const ValueKey('toggle-api-server-button'), onPressed: kIsWeb || runtimeState == null ? null : _toggleApiServer, loading: _savingStartConfig, icon: Icon( kIsWeb || (runtimeState?.apiServerState.running ?? false) ? Icons.stop_circle_outlined : Icons.play_circle_outline, ), child: Text( kIsWeb || (runtimeState?.apiServerState.running ?? false) ? context.l10n.stopApiServer : context.l10n.startApiServer, ), ), AppLoadingButton( key: const ValueKey('save-api-config-button'), onPressed: kIsWeb || runtimeState == null || !apiConfigDirty ? null : _saveApiServerConfig, loading: _savingStartConfig, icon: const Icon(Icons.save_outlined), variant: AppLoadingButtonVariant.primary, child: Text(context.l10n.save), ), ], ), ), ], ), ), _SettingsBlock( title: context.l10n.network, child: _SettingsGroup( children: [ SettingsItem( title: context.l10n.proxy, child: AppChoiceSegmentedControl( value: _proxyMode, buttonKeyPrefix: 'settings-choice', options: [ AppChoiceOption(value: 'none', label: context.l10n.noProxy, icon: Icons.block_outlined), AppChoiceOption( value: 'system', label: context.l10n.systemProxy, icon: Icons.computer_outlined, ), AppChoiceOption(value: 'custom', label: context.l10n.customProxy, icon: Icons.tune_outlined), ], onChanged: (value) => _mutateConfig((next) { next.proxy.enable = value != 'none'; next.proxy.system = value == 'system'; }), ), ), if (_proxyMode == 'custom') ...[ SettingsItem( title: context.l10n.proxyProtocol, child: AppChoiceSegmentedControl( value: _proxyScheme, buttonKeyPrefix: 'settings-choice', options: const [ AppChoiceOption(value: 'http', label: 'HTTP', icon: Icons.http_outlined), AppChoiceOption(value: 'https', label: 'HTTPS', icon: Icons.https_outlined), AppChoiceOption(value: 'socks5', label: 'SOCKS5', icon: Icons.security_outlined), ], onChanged: (value) => _mutateConfig((next) => next.proxy.scheme = value), ), ), SettingsItem( title: context.l10n.server, child: _HostPortControl( hostController: _proxyHostController, portController: _proxyPortController, hostKey: const ValueKey('proxy-host-input'), portKey: const ValueKey('proxy-port-input'), ), ), SettingsItem( title: context.l10n.username, child: _TextSettingControl(controller: _proxyUserController), ), SettingsItem( title: context.l10n.password, child: _TextSettingControl(controller: _proxyPasswordController, obscureText: true), ), ], SettingsItem( title: context.l10n.githubMirrorEnable, subtitle: context.l10n.githubMirrorDesc, child: shad.Switch( value: config.extra.githubMirror.enabled, onChanged: (value) => _mutateConfig((next) => next.extra.githubMirror.enabled = value), ), ), SettingsItem( title: context.l10n.githubMirror, child: SettingsListEditor( entries: [ for (final entry in config.extra.githubMirror.mirrors.indexed) if (!entry.$2.isDeleted) SettingsListEntry( id: 'github-mirror-${entry.$1}', title: entry.$2.url, subtitle: entry.$2.type.name, ), ], onAdd: () => unawaited(_editGithubMirror()), onEdit: (visibleIndex) => unawaited(_editGithubMirrorAtVisibleIndex(visibleIndex)), onDelete: (visibleIndex) => unawaited(_deleteGithubMirrorAtVisibleIndex(visibleIndex)), ), ), ], ), ), _SettingsBlock( title: context.l10n.developer, child: _SettingsGroup( children: [ SettingsItem( title: context.l10n.webhookEnable, subtitle: context.l10n.webhookDesc, child: shad.Switch( value: config.webhook.enable, onChanged: (value) => _mutateConfig((next) => next.webhook.enable = value), ), ), SettingsItem( title: context.l10n.webhookPush, subtitle: context.l10n.webhookPushDescription, child: SettingsListEditor( entries: [ for (final entry in config.webhook.urls.indexed) SettingsListEntry(id: 'webhook-${entry.$1}', title: entry.$2), ], onAdd: () => unawaited(_editWebhook()), onEdit: (index) => unawaited(_editWebhook(index)), onDelete: _deleteWebhook, addButtonKey: const ValueKey('add-webhook-button'), ), ), if (Util.isDesktop()) ...[ SettingsItem( title: context.l10n.scriptEnable, subtitle: context.l10n.scriptDesc, child: shad.Switch( value: config.script.enable, onChanged: (value) => _mutateConfig((next) => next.script.enable = value), ), ), SettingsItem( title: context.l10n.scriptExecution, subtitle: context.l10n.scriptExecutionDescription, child: SettingsListEditor( entries: [ for (final entry in config.script.paths.indexed) SettingsListEntry(id: 'script-${entry.$1}', title: entry.$2), ], onAdd: () => unawaited(_editScript()), onEdit: (index) => unawaited(_editScript(index)), onDelete: _deleteScript, addButtonKey: const ValueKey('add-script-button'), ), ), ], SettingsItem( title: context.l10n.logDirectory, subtitle: _logDirectoryLabel, child: shad.SecondaryButton( onPressed: kIsWeb ? null : _openLogDirectory, child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.folder_open_outlined), const SizedBox(width: AppDesignTokens.space8), Text(context.l10n.open), ], ), ), ), ], ), ), ], ); } } void _syncConfig(DownloaderConfig config) { final signature = _signature(config); if (_loadedSignature == signature && _config != null) { return; } _loadedSignature = signature; _config = DownloaderConfig.fromJson(config.toJson()); _syncingControllers = true; _downloadDirController.text = _config!.downloadDir; _maxRunningController.text = _config!.maxRunning.clamp(1, 256).toString(); _httpUserAgentController.text = _config!.protocolConfig.http.userAgent; _httpConnectionsController.text = _config!.protocolConfig.http.connections.clamp(1, 256).toString(); _hlsConnectionsController.text = _config!.protocolConfig.hls.segmentConnections.clamp(1, 256).toString(); _hlsMaxRetriesController.text = _config!.protocolConfig.hls.maxRetries.clamp(0, 30).toString(); _btListenPortController.text = _config!.protocolConfig.bt.listenPort.clamp(0, 65535).toString(); _btSeedRatioController.text = _config!.protocolConfig.bt.seedRatio.toString(); _btSeedTimeController.text = (_config!.protocolConfig.bt.seedTime ~/ 60).clamp(0, 100000000).toString(); _ed2kListenPortController.text = _config!.protocolConfig.ed2k.listenPort.clamp(0, 65535).toString(); _ed2kUdpPortController.text = _config!.protocolConfig.ed2k.udpPort.clamp(0, 65535).toString(); _ed2kServerAddrController.text = _formatEd2kMultiline(_config!.protocolConfig.ed2k.serverAddr); _ed2kServerMetController.text = _formatEd2kMultiline(_config!.protocolConfig.ed2k.serverMet); _ed2kNodesDatController.text = _formatEd2kMultiline(_config!.protocolConfig.ed2k.nodesDat); final proxyAddress = _splitHostPort(_config!.proxy.host); _proxyHostController.text = proxyAddress.host; _proxyPortController.text = proxyAddress.port; _proxyUserController.text = _config!.proxy.usr; _proxyPasswordController.text = _config!.proxy.pwd; _customTrackersController.text = _config!.extra.bt.customTrackers.join('\n'); _syncingControllers = false; } void _syncStartConfig(StartConfig config, {bool force = false}) { final signature = _startSignature(config); if (!force && _loadedStartSignature == signature && _startConfig != null) { return; } if (!force && _startConfig != null && _startConfig!.network == config.network && _startConfig!.address == config.address && _startConfig!.apiToken == config.apiToken) { _startConfig!.apiEnable = config.apiEnable; _startConfig!.mcpEnable = config.mcpEnable; _loadedStartSignature = signature; return; } _loadedStartSignature = signature; _startConfig = _copyStartConfig(config); _syncingStartControllers = true; if (config.network == 'tcp' && _looksLikeTcpAddress(_startConfig!.address)) { final apiAddress = _splitHostPort(_startConfig!.address); _apiHostController.text = apiAddress.host; _apiPortController.text = apiAddress.port; } else { _apiHostController.text = '127.0.0.1'; _apiPortController.text = '9999'; } _apiTokenController.text = _startConfig!.apiToken; _syncingStartControllers = false; } void _syncWebStartConfig(ApiServerConfig config, AppRuntimeState? runtimeState) { final webConfig = StartConfig() ..network = 'tcp' ..address = webApiDisplayAddress(_webApiUri(runtimeState)) ..apiEnable = true ..mcpEnable = config.mcpEnable ..storage = '' ..storageDir = '' ..refreshInterval = 0 ..apiToken = config.token; _syncStartConfig(webConfig); } void _scheduleTextSave() { if (_syncingControllers || _config == null) { return; } _textSaveTimer?.cancel(); _textSaveTimer = Timer(const Duration(milliseconds: 650), () { _applyTextControllers(_config!); unawaited(_saveConfig()); }); } void _handleStartDraftChanged() { if (_syncingStartControllers || !mounted) return; setState(() {}); } void _mutateConfig(void Function(DownloaderConfig config) mutation) { final config = _config; if (config == null) { return; } setState(() => mutation(config)); unawaited(_saveConfig()); } void _applyTextControllers(DownloaderConfig config) { config.downloadDir = _downloadDirController.text.trim(); config.maxRunning = _boundedInt(_maxRunningController, fallback: config.maxRunning, min: 1, max: 256); config.protocolConfig.http.userAgent = _httpUserAgentController.text.trim(); config.protocolConfig.http.connections = _boundedInt( _httpConnectionsController, fallback: config.protocolConfig.http.connections, min: 1, max: 256, ); config.protocolConfig.hls.segmentConnections = _boundedInt( _hlsConnectionsController, fallback: config.protocolConfig.hls.segmentConnections, min: 1, max: 256, ); config.protocolConfig.hls.maxRetries = _boundedInt( _hlsMaxRetriesController, fallback: config.protocolConfig.hls.maxRetries, min: 0, max: 30, ); config.protocolConfig.bt.listenPort = _boundedInt( _btListenPortController, fallback: config.protocolConfig.bt.listenPort, min: 0, max: 65535, ); config.protocolConfig.bt.seedRatio = (double.tryParse(_btSeedRatioController.text.trim()) ?? config.protocolConfig.bt.seedRatio) .clamp(0, double.infinity) .toDouble(); final seedMinutes = _boundedInt( _btSeedTimeController, fallback: config.protocolConfig.bt.seedTime ~/ 60, min: 0, max: 100000000, ); config.protocolConfig.bt.seedTime = seedMinutes * 60; config.protocolConfig.ed2k.listenPort = _boundedInt( _ed2kListenPortController, fallback: config.protocolConfig.ed2k.listenPort, min: 0, max: 65535, ); config.protocolConfig.ed2k.udpPort = _boundedInt( _ed2kUdpPortController, fallback: config.protocolConfig.ed2k.udpPort, min: 0, max: 65535, ); config.protocolConfig.ed2k.serverAddr = _lines(_ed2kServerAddrController.text).join(','); config.protocolConfig.ed2k.serverMet = _lines(_ed2kServerMetController.text).join(','); config.protocolConfig.ed2k.nodesDat = _lines(_ed2kNodesDatController.text).join(','); config.proxy.host = _joinHostPort(_proxyHostController.text, _proxyPortController.text); config.proxy.usr = _proxyUserController.text.trim(); config.proxy.pwd = _proxyPasswordController.text; config.extra.bt.customTrackers = _lines(_customTrackersController.text); config.protocolConfig.bt.trackers = { ...config.extra.bt.subscribeTrackers, ...config.extra.bt.customTrackers, }.toList(); } void _applyStartControllers(StartConfig config) { config.network = 'tcp'; config.address = _joinHostPort(_apiHostController.text, _apiPortController.text); config.apiToken = _apiTokenController.text.trim(); } Future _saveConfig() async { final config = _config; if (config == null) { return; } _applyTextControllers(config); final snapshot = DownloaderConfig.fromJson(config.toJson()); _loadedSignature = _signature(snapshot); try { await ref.read(settingsControllerProvider.notifier).save(snapshot); } catch (error) { if (mounted) _toast(error.toString()); } } Future _saveApiServerConfig() async { final config = _startConfig; final runtimeState = ref.read(appRuntimeControllerProvider).value; if (config == null || runtimeState == null || _savingStartConfig) { return; } final snapshot = _copyStartConfig(config); _applyStartControllers(snapshot); final validationMessage = await _validateStartConfig(snapshot); if (validationMessage != null) { if (mounted) _toast(validationMessage); return; } if (runtimeState.apiServerState.running && !await _confirmRestartApiServer()) { return; } setState(() => _savingStartConfig = true); try { final controller = ref.read(appRuntimeControllerProvider.notifier); if (runtimeState.apiServerState.running) { await controller.restartApiServer(snapshot); } else { await controller.saveApiServerConfig(snapshot); } _syncStartConfig(ref.read(appRuntimeControllerProvider).requireValue.startConfig, force: true); if (mounted) _toast(context.l10n.apiConfigSaved, type: AppToastType.success); } catch (error) { if (mounted) _toast(error.toString()); } finally { if (mounted) setState(() => _savingStartConfig = false); } } Future _toggleApiServer() async { final runtimeState = ref.read(appRuntimeControllerProvider).value; if (runtimeState == null || _savingStartConfig) return; if (runtimeState.apiServerState.running && !await _confirmDisableApiServer()) return; setState(() => _savingStartConfig = true); try { final controller = ref.read(appRuntimeControllerProvider.notifier); if (runtimeState.apiServerState.running) { await controller.stopApiServer(); } else { await controller.startApiServer(); } } catch (error) { if (mounted) _toast(error.toString()); } finally { if (mounted) setState(() => _savingStartConfig = false); } } Future _confirmDisableApiServer() async { final overlay = const shad.DialogOverlayHandler().show( context: context, alignment: Alignment.center, barrierDismissable: false, builder: (dialogContext) => shad.AlertDialog( key: const ValueKey('disable-api-server-dialog'), title: Text(dialogContext.l10n.stopListening), content: Text(dialogContext.l10n.disableApiServerConfirm), actions: [ shad.SecondaryButton( key: const ValueKey('cancel-disable-api-server-button'), onPressed: () => shad.closeOverlay(dialogContext, false), child: Text(dialogContext.l10n.cancel), ), shad.DestructiveButton( key: const ValueKey('confirm-disable-api-server-button'), onPressed: () => shad.closeOverlay(dialogContext, true), child: Text(dialogContext.l10n.confirm), ), ], ), ); return await overlay.future ?? false; } Future _confirmRestartApiServer() async { final overlay = const shad.DialogOverlayHandler().show( context: context, alignment: Alignment.center, barrierDismissable: false, builder: (dialogContext) => shad.AlertDialog( key: const ValueKey('restart-api-server-dialog'), title: Text(dialogContext.l10n.saveAndRestartApiServer), content: Text(dialogContext.l10n.restartApiServerConfirm), actions: [ shad.SecondaryButton( key: const ValueKey('cancel-restart-api-server-button'), onPressed: () => shad.closeOverlay(dialogContext, false), child: Text(dialogContext.l10n.cancel), ), shad.PrimaryButton( key: const ValueKey('confirm-restart-api-server-button'), onPressed: () => shad.closeOverlay(dialogContext, true), child: Text(dialogContext.l10n.saveAndRestartApiServer), ), ], ), ); return await overlay.future ?? false; } Future _validateStartConfig(StartConfig candidate) async { final l10n = context.l10n; final address = _splitHostPort(candidate.address); if (address.host.isEmpty) return l10n.serverRequired; final port = int.tryParse(address.port); if (port == null || port < 0 || port > 65535) return l10n.portRangeError; return null; } Future _runAction(Future Function() action) async { try { await action(); } catch (error) { if (mounted) _toast(error.toString()); } } Future _setDefaultBtClient(bool enabled) async { try { enabled ? registerDefaultTorrentClient() : unregisterDefaultTorrentClient(); _mutateConfig((config) => config.extra.defaultBtClient = enabled); } catch (error) { if (mounted) _toast(error.toString()); } } Future _setBackgroundLocationKeepAlive(bool enabled) async { if (enabled && !await LocationKeepAlive.requestPermission()) { if (mounted) _toast(context.l10n.backgroundLocationPermissionRequired); return; } _mutateConfig((config) => config.extra.backgroundLocationKeepAlive = enabled); await LocationKeepAliveCoordinator.instance.reconcile(enabled: enabled); } Future _setBackgroundContinuedProcessing( bool enabled, ) async { if (enabled && !await ContinuedProcessing.isSupported()) { if (mounted) { _toast( context.l10n .backgroundContinuedProcessingUnsupported, ); } return; } final applied = await ContinuedProcessing.setEnabled( enabled, ); if (enabled && !applied) { if (mounted) { _toast( context.l10n .backgroundContinuedProcessingUnsupported, ); } return; } _mutateConfig( (config) => config.extra .backgroundContinuedProcessing = enabled, ); } List _lines(String text) { return text.split('\n').map((line) => line.trim()).where((line) => line.isNotEmpty).toList(); } String _formatEd2kMultiline(String value) { return value.split(RegExp(r'[,\r\n]+')).map((entry) => entry.trim()).where((entry) => entry.isNotEmpty).join('\n'); } String _categoryName(DownloadCategory category) { if (category.name.isNotEmpty) { return category.name; } return switch (category.nameKey) { 'categoryMusic' => context.l10n.categoryMusic, 'categoryVideo' => context.l10n.categoryVideo, 'categoryDocument' => context.l10n.categoryDocument, 'categoryProgram' => context.l10n.categoryProgram, _ => context.l10n.downloadCategories, }; } Future _editDownloadCategory([DownloadCategory? category]) async { final draft = await showDownloadCategoryDialog( context, category: category, initialName: category == null ? '' : _categoryName(category), initialPath: _downloadDirController.text.trim(), ); if (draft == null || !mounted) return; _mutateConfig((config) { final categories = List.from(config.extra.downloadCategories); if (category == null) { categories.add(DownloadCategory(name: draft.name, path: draft.path)); } else { final index = categories.indexOf(category); if (index < 0) return; final nameChanged = draft.name != _categoryName(category); category ..name = draft.name ..path = draft.path ..isDeleted = false; if (nameChanged) category.nameKey = null; } config.extra.downloadCategories = categories; }); } Future _deleteDownloadCategory(DownloadCategory category) async { final confirmed = await showDeleteDownloadCategoryDialog(context, _categoryName(category)); if (!confirmed || !mounted) return; _mutateConfig((config) { if (category.isBuiltIn) { category.isDeleted = true; return; } config.extra.downloadCategories = List.from(config.extra.downloadCategories)..remove(category); }); } List get _visibleGithubMirrors => _config?.extra.githubMirror.mirrors.where((mirror) => !mirror.isDeleted).toList(growable: false) ?? const []; Future _editGithubMirror([GithubMirror? mirror]) async { final draft = await showGithubMirrorDialog(context, mirror: mirror); if (draft == null || !mounted) return; _mutateConfig((config) { final mirrors = List.from(config.extra.githubMirror.mirrors); if (mirror == null) { mirrors.add(GithubMirror(type: draft.type, url: draft.url)); } else { final index = mirrors.indexOf(mirror); if (index < 0) return; mirrors[index] = GithubMirror(type: draft.type, url: draft.url, isBuiltIn: mirror.isBuiltIn, isDeleted: false); } config.extra.githubMirror.mirrors = mirrors; }); } Future _editGithubMirrorAtVisibleIndex(int index) async { final mirrors = _visibleGithubMirrors; if (index < 0 || index >= mirrors.length) return; await _editGithubMirror(mirrors[index]); } Future _deleteGithubMirrorAtVisibleIndex(int index) async { final mirrors = _visibleGithubMirrors; if (index < 0 || index >= mirrors.length) return; final mirror = mirrors[index]; if (!await showDeleteSettingsEntryDialog(context, mirror.url) || !mounted) return; _mutateConfig((config) { if (mirror.isBuiltIn) { mirror.isDeleted = true; } else { config.extra.githubMirror.mirrors = List.from(config.extra.githubMirror.mirrors)..remove(mirror); } }); } Future _editWebhook([int? index]) async { final urls = _config?.webhook.urls ?? const []; if (index != null && (index < 0 || index >= urls.length)) return; final value = await showTextSettingDialog( context, title: index == null ? context.l10n.addWebhook : context.l10n.editWebhook, fieldLabel: context.l10n.webhookUrlHint, initialValue: index == null ? '' : urls[index], requireHttpUrl: true, onTest: (url) => ref.read(settingsControllerProvider.notifier).testWebhook(url), ); if (value == null || !mounted) return; _mutateConfig((config) { final next = List.from(config.webhook.urls); if (index == null) { next.add(value); } else { next[index] = value; } config.webhook.urls = next; }); } void _deleteWebhook(int index) { _mutateConfig((config) { if (index < 0 || index >= config.webhook.urls.length) return; config.webhook.urls = List.from(config.webhook.urls)..removeAt(index); }); } Future _editScript([int? index]) async { final paths = _config?.script.paths ?? const []; if (index != null && (index < 0 || index >= paths.length)) return; final value = await showTextSettingDialog( context, title: index == null ? context.l10n.addScript : context.l10n.editScript, fieldLabel: context.l10n.scriptPath, initialValue: index == null ? '' : paths[index], pickFile: true, ); if (value == null || !mounted) return; _mutateConfig((config) { final next = List.from(config.script.paths); if (index == null) { next.add(value); } else { next[index] = value; } config.script.paths = next; }); } void _deleteScript(int index) { _mutateConfig((config) { if (index < 0 || index >= config.script.paths.length) return; config.script.paths = List.from(config.script.paths)..removeAt(index); }); } Future _updateTrackers() async { final config = _config; if (config == null) return; await _runAction(() async { await ref.read(appRuntimeControllerProvider.notifier).updateTrackers(DownloaderConfig.fromJson(config.toJson())); if (mounted) await ref.read(settingsControllerProvider.notifier).reload(showLoading: false); }); } String _signature(DownloaderConfig config) { return config.toJson().toString(); } String _startSignature(StartConfig config) { return '${config.apiEnable}|${config.mcpEnable}|${config.network}|${config.address}|${config.apiToken}'; } String _startDraftSignature() { final address = _joinHostPort(_apiHostController.text, _apiPortController.text); return '${_startConfig?.apiEnable ?? false}|${_startConfig?.mcpEnable ?? false}|tcp|$address|${_apiTokenController.text.trim()}'; } StartConfig _copyStartConfig(StartConfig config) { return StartConfig() ..network = config.network ..address = config.address ..apiEnable = config.apiEnable ..mcpEnable = config.mcpEnable ..storage = config.storage ..storageDir = config.storageDir ..refreshInterval = config.refreshInterval ..apiToken = config.apiToken ..webViewRpcConfig = config.webViewRpcConfig; } String get _proxyMode { final proxy = _config?.proxy; if (proxy == null || !proxy.enable) return 'none'; return proxy.system ? 'system' : 'custom'; } String get _proxyScheme { final scheme = _config?.proxy.scheme; return switch (scheme) { 'https' || 'socks5' => scheme!, _ => 'http', }; } String get _versionLabel { try { return appVersion; } catch (_) { return '-'; } } String get _logDirectoryLabel { try { return logsDir(); } catch (_) { return context.l10n.logDirectoryUnavailable; } } void _openLogDirectory() { try { unawaited(launchUrl(Uri.file(logsDir()), mode: LaunchMode.externalApplication)); } catch (error) { _toast(error.toString()); } } Future _openBrowserExtension(Uri uri) async { await _openExternalUri(uri); } Future _openExternalUri(Uri uri) async { try { final opened = await launchUrl(uri, mode: LaunchMode.externalApplication); if (!opened && mounted) { _toast(context.l10n.unableOpenExternalLink); } } catch (error) { if (mounted) _toast(error.toString()); } } Future _openMcpAgentSetup(AppRuntimeState? runtimeState) async { final mcpRunning = kIsWeb ? (_startConfig?.mcpEnable ?? false) : runtimeState?.apiServerState.running == true && runtimeState?.apiServerState.mcpEnabled == true; await showMcpAgentSetupDialog( context, endpoint: _mcpEndpoint(runtimeState), apiToken: _apiTokenController.text.trim(), mcpRunning: mcpRunning, allowManualApiTokenTemplate: kIsWeb, ); } String _mcpEndpoint(AppRuntimeState? runtimeState) { if (kIsWeb) { return webMcpEndpoint(_webApiUri(runtimeState)); } final apiState = runtimeState?.apiServerState; final address = apiState != null && apiState.running && apiState.network == 'tcp' ? apiState.runningAddress() : _joinHostPort(_apiHostController.text, _apiPortController.text); final parts = _splitHostPort(address); var host = parts.host; if (host.startsWith('[') && host.endsWith(']')) { host = host.substring(1, host.length - 1); } if (host == '0.0.0.0' || host == '::') { host = host == '::' ? '::1' : '127.0.0.1'; } final port = int.tryParse(parts.port); return Uri(scheme: 'http', host: host, port: port, path: '/mcp').toString(); } bool _looksLikeTcpAddress(String address) { final separator = address.lastIndexOf(':'); if (separator <= 0 || separator == address.length - 1) { return false; } return int.tryParse(address.substring(separator + 1)) != null; } ({String host, String port}) _splitHostPort(String address) { final value = address.trim(); if (value.isEmpty) return (host: '', port: ''); // Bracketed IPv6 addresses keep their internal colons, for example [::1]:9999. if (value.startsWith('[')) { final closingBracket = value.indexOf(']'); if (closingBracket > 0 && closingBracket + 1 < value.length && value[closingBracket + 1] == ':') { return (host: value.substring(0, closingBracket + 1), port: value.substring(closingBracket + 2)); } } final separator = value.lastIndexOf(':'); if (separator <= 0 || separator == value.length - 1) { return (host: value, port: ''); } return (host: value.substring(0, separator), port: value.substring(separator + 1)); } String _joinHostPort(String hostText, String portText) { final host = hostText.trim(); final port = portText.trim(); if (host.isEmpty && port.isEmpty) return ''; if (port.isEmpty) return host; return '$host:$port'; } int _boundedInt(TextEditingController controller, {required int fallback, required int min, required int max}) { final value = int.tryParse(controller.text.trim()); return (value ?? fallback).clamp(min, max).toInt(); } String _sectionTitle(_SettingsSection section) { return switch (section) { _SettingsSection.basic => context.l10n.basicSettings, _SettingsSection.downloads => context.l10n.downloadSettings, _SettingsSection.advanced => context.l10n.advancedSettings, }; } String? _apiServerRuntimeDetails(AppRuntimeState? runtimeState) { if (runtimeState == null) return null; if (kIsWeb) { final apiUri = _webApiUri(runtimeState); return '${apiUri.scheme}://${webApiDisplayAddress(apiUri)}'; } final apiState = runtimeState.apiServerState; final details = []; if (apiState.running) { details.add('http://${apiState.runningAddress()}'); } if (apiState.lastError.isNotEmpty) { details.add(apiState.lastError); } else if (apiState.pendingApply) { details.add(context.l10n.apiConfigPendingApply); } return details.isEmpty ? null : details.join('\n'); } Uri _webApiUri(AppRuntimeState? runtimeState) { final address = runtimeState?.startConfig.address ?? ''; return Uri.parse(normalizeWebApiBaseUrl(address, Uri.base)); } _ApiServerVisualState _apiServerVisualState(AppRuntimeState? runtimeState) { if (runtimeState == null) return _ApiServerVisualState.loading; if (kIsWeb) return _ApiServerVisualState.running; final apiState = runtimeState.apiServerState; if (apiState.running) return _ApiServerVisualState.running; if (apiState.lastError.isNotEmpty) return _ApiServerVisualState.failed; return _ApiServerVisualState.stopped; } String _languageValue(DownloaderConfig config) { final locale = supportedLocaleFromConfig(config.extra.locale); return locale == null ? 'system' : localeConfigValue(locale); } void _toast(String message, {AppToastType type = AppToastType.error}) { showAppToast(context, message, type: type); } } enum _ApiServerVisualState { loading, running, stopped, failed } class _ApiServerStatusLight extends StatefulWidget { const _ApiServerStatusLight({super.key, required this.state, required this.label}); final _ApiServerVisualState state; final String label; @override State<_ApiServerStatusLight> createState() => _ApiServerStatusLightState(); } class _ApiServerStatusLightState extends State<_ApiServerStatusLight> with SingleTickerProviderStateMixin { late final AnimationController _pulseController; bool _disableAnimations = false; @override void initState() { super.initState(); _pulseController = AnimationController(vsync: this, duration: const Duration(milliseconds: 1400), value: 0.35); } @override void didChangeDependencies() { super.didChangeDependencies(); _disableAnimations = MediaQuery.maybeOf(context)?.disableAnimations ?? false; _syncPulseAnimation(); } @override void didUpdateWidget(covariant _ApiServerStatusLight oldWidget) { super.didUpdateWidget(oldWidget); if (oldWidget.state != widget.state) _syncPulseAnimation(); } void _syncPulseAnimation() { final shouldPulse = !_disableAnimations && (widget.state == _ApiServerVisualState.running || widget.state == _ApiServerVisualState.loading); if (shouldPulse) { if (!_pulseController.isAnimating) _pulseController.repeat(reverse: true); return; } _pulseController ..stop() ..value = switch (widget.state) { _ApiServerVisualState.running => 0.55, _ApiServerVisualState.loading => 0.3, _ApiServerVisualState.failed => 0.5, _ApiServerVisualState.stopped => 0, }; } @override void dispose() { _pulseController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final palette = AppPalette.of(context); final color = switch (widget.state) { _ApiServerVisualState.running => palette.success, _ApiServerVisualState.failed => palette.error, _ApiServerVisualState.loading || _ApiServerVisualState.stopped => palette.textMuted, }; final labelColor = widget.state == _ApiServerVisualState.stopped ? palette.textSecondary : color; return Semantics( label: widget.label, child: Row( mainAxisSize: MainAxisSize.min, children: [ AnimatedBuilder( animation: _pulseController, builder: (context, child) { final pulse = Curves.easeInOut.transform(_pulseController.value); final glowSize = switch (widget.state) { _ApiServerVisualState.running => 12.0 + pulse * 8, _ApiServerVisualState.loading => 10.0 + pulse * 5, _ApiServerVisualState.failed => 16.0, _ApiServerVisualState.stopped => 10.0, }; final glowAlpha = switch (widget.state) { _ApiServerVisualState.running => 0.08 + pulse * 0.12, _ApiServerVisualState.loading => 0.05 + pulse * 0.06, _ApiServerVisualState.failed => 0.13, _ApiServerVisualState.stopped => 0.08, }; final glowShadow = switch (widget.state) { _ApiServerVisualState.running => [ BoxShadow( color: color.withValues(alpha: 0.18 + pulse * 0.2), blurRadius: 4 + pulse * 8, spreadRadius: pulse * 1.5, ), ], _ApiServerVisualState.failed => [ BoxShadow(color: color.withValues(alpha: 0.28), blurRadius: 7, spreadRadius: 0.5), ], _ApiServerVisualState.loading || _ApiServerVisualState.stopped => const [], }; return SizedBox.square( dimension: 24, child: Stack( alignment: Alignment.center, children: [ Container( key: const ValueKey('api-server-status-glow'), width: glowSize, height: glowSize, decoration: BoxDecoration( shape: BoxShape.circle, color: color.withValues(alpha: glowAlpha), boxShadow: glowShadow, ), ), child!, ], ), ); }, child: AnimatedContainer( key: const ValueKey('api-server-status-dot'), duration: const Duration(milliseconds: 300), width: widget.state == _ApiServerVisualState.loading ? 6 : 8, height: widget.state == _ApiServerVisualState.loading ? 6 : 8, decoration: BoxDecoration( shape: BoxShape.circle, color: widget.state == _ApiServerVisualState.stopped ? color.withValues(alpha: 0.72) : color, ), ), ), const SizedBox(width: 6), Text( widget.label, style: TextStyle(color: labelColor, fontSize: 12, fontWeight: FontWeight.w700), ), ], ), ); } } class _SettingsGroup extends StatelessWidget { const _SettingsGroup({required this.children}); final List children; @override Widget build(BuildContext context) { final palette = AppPalette.of(context); return Container( decoration: BoxDecoration( color: palette.cardBg, borderRadius: BorderRadius.circular(6), border: Border.all(color: palette.border), ), child: ListView.separated( shrinkWrap: true, physics: const NeverScrollableScrollPhysics(), padding: const EdgeInsets.all(16), itemCount: children.length, separatorBuilder: (_, _) => Container(height: 1, margin: const EdgeInsets.symmetric(vertical: 12), color: palette.border), itemBuilder: (context, index) => children[index], ), ); } } class _SettingsSectionStack extends StatelessWidget { const _SettingsSectionStack({required this.children}); final List children; @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ for (var index = 0; index < children.length; index++) ...[ if (index > 0) const SizedBox(height: 18), children[index], ], ], ); } } class _SettingsBlock extends StatelessWidget { const _SettingsBlock({super.key, required this.title, required this.child}); final String title; final Widget child; @override Widget build(BuildContext context) { final palette = AppPalette.of(context); return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Padding( padding: const EdgeInsets.only(left: 2, bottom: 8), child: Text( title, style: TextStyle(color: palette.textSecondary, fontSize: 12, fontWeight: FontWeight.w700), ), ), child, ], ); } } class _TextSettingControl extends StatelessWidget { const _TextSettingControl({ required this.controller, this.fieldKey, this.hintText, this.minLines = 1, this.obscureText = false, }); final TextEditingController controller; final Key? fieldKey; final String? hintText; final int minLines; final bool obscureText; @override Widget build(BuildContext context) { final desktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile; return SizedBox( width: desktop ? AppDesignTokens.settingsFormControlWidth : double.infinity, child: AppTextField( key: fieldKey, controller: controller, hintText: hintText, keyboardType: minLines > 1 ? TextInputType.multiline : null, obscureText: obscureText, minLines: minLines, maxLines: minLines == 1 ? 1 : minLines, ), ); } } class _NumberSettingControl extends StatelessWidget { const _NumberSettingControl({ required this.controller, required this.min, this.fieldKey, this.max, this.step = 1, this.decimalPlaces = 0, }); final TextEditingController controller; final num min; final Key? fieldKey; final num? max; final double step; final int decimalPlaces; @override Widget build(BuildContext context) { final desktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile; return SizedBox( width: desktop ? AppDesignTokens.settingsNumberControlWidth : double.infinity, child: AppNumberInput( fieldKey: fieldKey, controller: controller, min: min, max: max, step: step, decimalPlaces: decimalPlaces, ), ); } } class _HostPortControl extends StatelessWidget { const _HostPortControl({ required this.hostController, required this.portController, required this.hostKey, required this.portKey, this.enabled = true, }); final TextEditingController hostController; final TextEditingController portController; final Key hostKey; final Key portKey; final bool enabled; @override Widget build(BuildContext context) { final desktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile; return SizedBox( width: desktop ? AppDesignTokens.settingsFormControlWidth : double.infinity, child: Row( children: [ Expanded( child: AppTextField( key: hostKey, controller: hostController, hintText: context.l10n.server, enabled: enabled, ), ), const SizedBox(width: AppDesignTokens.space8), SizedBox( width: AppDesignTokens.settingsNumberControlWidth, child: AppNumberInput( fieldKey: portKey, controller: portController, min: 0, max: 65535, step: 1, decimalPlaces: 0, hintText: context.l10n.port, enabled: enabled, ), ), ], ), ); } } class _ExternalTextLink extends StatefulWidget { const _ExternalTextLink({super.key, required this.label, required this.onPressed}); final String label; final VoidCallback onPressed; @override State<_ExternalTextLink> createState() => _ExternalTextLinkState(); } class _ExternalTextLinkState extends State<_ExternalTextLink> { bool _hovered = false; @override Widget build(BuildContext context) { final palette = AppPalette.of(context); final color = _hovered ? palette.brandProgress : palette.brand; return Semantics( link: true, label: widget.label, child: MouseRegion( cursor: SystemMouseCursors.click, onEnter: (_) => setState(() => _hovered = true), onExit: (_) => setState(() => _hovered = false), child: GestureDetector( behavior: HitTestBehavior.opaque, onTap: widget.onPressed, child: Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row( mainAxisSize: MainAxisSize.min, children: [ Flexible( child: Text( widget.label, maxLines: 1, overflow: TextOverflow.ellipsis, style: TextStyle( color: color, fontSize: 12, fontWeight: FontWeight.w500, decoration: TextDecoration.underline, decorationColor: color, decorationThickness: _hovered ? 1.5 : 1, ), ), ), const SizedBox(width: 5), Icon(Icons.open_in_new, size: 13, color: color), ], ), ), ), ), ); } } class _BrowserExtensionLinks extends StatelessWidget { const _BrowserExtensionLinks({required this.onOpen}); static final _links = <({String label, Uri uri})>[ ( label: 'Chrome', uri: Uri.parse('https://chromewebstore.google.com/detail/gopeed/mijpgljlfcapndmchhjffkpckknofcnd'), ), ( label: 'Edge', uri: Uri.parse('https://microsoftedge.microsoft.com/addons/detail/dkajnckekendchdleoaenoophcobooce'), ), (label: 'Firefox', uri: Uri.parse('https://addons.mozilla.org/firefox/addon/gopeed-extension/')), ]; final ValueChanged onOpen; @override Widget build(BuildContext context) { final desktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile; return Wrap( spacing: 8, runSpacing: 8, alignment: desktop ? WrapAlignment.end : WrapAlignment.start, children: [ for (final link in _links) shad.SecondaryButton( key: ValueKey('browser-extension-${link.label.toLowerCase()}'), onPressed: () => onOpen(link.uri), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.open_in_new, size: 15), const SizedBox(width: AppDesignTokens.space8), Text(link.label), ], ), ), ], ); } } class _TrackerSubscriptionsControl extends StatefulWidget { const _TrackerSubscriptionsControl({ required this.selected, required this.autoUpdate, required this.lastUpdated, required this.onChanged, required this.onAutoUpdateChanged, required this.onUpdate, }); final List selected; final bool autoUpdate; final DateTime? lastUpdated; final ValueChanged> onChanged; final ValueChanged onAutoUpdateChanged; final Future Function() onUpdate; @override State<_TrackerSubscriptionsControl> createState() => _TrackerSubscriptionsControlState(); } class _TrackerSubscriptionsControlState extends State<_TrackerSubscriptionsControl> { final _verticalScrollController = ScrollController(); final _horizontalScrollController = ScrollController(); bool _updating = false; @override void dispose() { _verticalScrollController.dispose(); _horizontalScrollController.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final desktop = MediaQuery.sizeOf(context).width >= Breakpoints.mobile; final palette = AppPalette.of(context); final selectedCount = allTrackerSubscribeUrls.where(widget.selected.contains).length; final allSelected = selectedCount == allTrackerSubscribeUrls.length; final selectAllState = allSelected ? shad.CheckboxState.checked : selectedCount == 0 ? shad.CheckboxState.unchecked : shad.CheckboxState.indeterminate; return SizedBox( width: desktop ? AppDesignTokens.settingsFormControlWidth : double.infinity, child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Container( height: 210, decoration: BoxDecoration( border: Border.all(color: palette.border), borderRadius: BorderRadius.circular(AppDesignTokens.controlRadius), ), child: Column( children: [ GestureDetector( key: const ValueKey('tracker-select-all'), behavior: HitTestBehavior.opaque, onTap: () => _toggleAll(allSelected), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: Row( children: [ shad.Checkbox(state: selectAllState, onChanged: (_) => _toggleAll(allSelected)), const SizedBox(width: AppDesignTokens.checkboxLabelGap), Expanded( child: Text( context.l10n.selectAll, style: TextStyle(color: palette.textPrimary, fontSize: 12, fontWeight: FontWeight.w600), ), ), Text( '$selectedCount/${allTrackerSubscribeUrls.length}', style: TextStyle(color: palette.textMuted, fontSize: 11), ), ], ), ), ), Divider(height: 1, color: palette.border), Expanded( child: LayoutBuilder( builder: (context, constraints) { final labelStyle = TextStyle(color: palette.textSecondary, fontSize: 11); final contentHeight = (constraints.maxHeight - AppDesignTokens.space12).clamp( 0.0, double.infinity, ); return Scrollbar( key: const ValueKey('tracker-horizontal-scrollbar'), controller: _horizontalScrollController, thumbVisibility: true, scrollbarOrientation: ScrollbarOrientation.bottom, notificationPredicate: (notification) => notification.metrics.axis == Axis.horizontal, child: Padding( padding: const EdgeInsets.only(bottom: AppDesignTokens.space12), child: SingleChildScrollView( key: const ValueKey('tracker-horizontal-scroll-view'), controller: _horizontalScrollController, scrollDirection: Axis.horizontal, child: SizedBox( height: contentHeight, child: Scrollbar( key: const ValueKey('tracker-vertical-scrollbar'), controller: _verticalScrollController, thumbVisibility: true, scrollbarOrientation: ScrollbarOrientation.right, notificationPredicate: (notification) => notification.metrics.axis == Axis.vertical, child: SingleChildScrollView( key: const ValueKey('tracker-vertical-scroll-view'), controller: _verticalScrollController, child: IntrinsicWidth( child: ConstrainedBox( constraints: BoxConstraints(minWidth: constraints.maxWidth), child: Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ for (final (index, url) in allTrackerSubscribeUrls.indexed) ...[ Builder( builder: (context) { final checked = widget.selected.contains(url); return GestureDetector( behavior: HitTestBehavior.opaque, onTap: () => _toggle(url, checked), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), child: Row( children: [ shad.Checkbox( state: checked ? shad.CheckboxState.checked : shad.CheckboxState.unchecked, onChanged: (_) => _toggle(url, checked), ), const SizedBox(width: AppDesignTokens.checkboxLabelGap), Text(url, maxLines: 1, style: labelStyle), ], ), ), ); }, ), if (index != allTrackerSubscribeUrls.length - 1) Divider(height: 1, color: palette.border), ], ], ), ), ), ), ), ), ), ), ), ); }, ), ), ], ), ), const SizedBox(height: 10), Row( children: [ AppLoadingButton( key: const ValueKey('tracker-update-button'), onPressed: _update, loading: _updating, icon: const Icon(Icons.refresh, size: 17), child: Text(context.l10n.newVersionUpdate), ), const SizedBox(width: 12), Expanded( child: Text( context.l10n.updateEveryDay, maxLines: 1, overflow: TextOverflow.ellipsis, textAlign: TextAlign.end, style: TextStyle(color: palette.textSecondary, fontSize: 12), ), ), const SizedBox(width: 8), shad.Switch(value: widget.autoUpdate, onChanged: widget.onAutoUpdateChanged), ], ), const SizedBox(height: 6), Text( widget.lastUpdated == null ? context.l10n.neverUpdated : context.l10n.lastUpdated(_formatDateTime(widget.lastUpdated!)), style: TextStyle(color: palette.textMuted, fontSize: 11), ), ], ), ); } void _toggle(String url, bool checked) { final next = List.from(widget.selected); checked ? next.remove(url) : next.add(url); widget.onChanged(next); } void _toggleAll(bool allSelected) { widget.onChanged(allSelected ? const [] : List.from(allTrackerSubscribeUrls)); } Future _update() async { setState(() => _updating = true); try { await widget.onUpdate(); } finally { if (mounted) setState(() => _updating = false); } } String _formatDateTime(DateTime value) { String two(int number) => number.toString().padLeft(2, '0'); return '${value.year}-${two(value.month)}-${two(value.day)} ${two(value.hour)}:${two(value.minute)}:${two(value.second)}'; } } class _UpdateActionControl extends ConsumerWidget { const _UpdateActionControl({required this.platform}); final AppPlatformState? platform; @override Widget build(BuildContext context, WidgetRef ref) { final status = platform?.updateStatus ?? AppUpdateStatus.idle; final latest = platform?.latestVersion; final checking = status == AppUpdateStatus.checking; Future handlePressed() async { var nextVersion = latest; if (status != AppUpdateStatus.available || nextVersion == null) { nextVersion = await ref.read(appPlatformControllerProvider.notifier).checkForUpdate(); } if (!context.mounted || nextVersion == null) return; await showAppUpdateDialog( context, versionInfo: nextVersion, onUpdate: ref.read(appPlatformControllerProvider.notifier).startUpdate, ); } return _UpdateActionButton( available: status == AppUpdateStatus.available, checking: checking, onPressed: () => unawaited(handlePressed()), ); } } class _UpdateActionButton extends StatelessWidget { const _UpdateActionButton({required this.available, required this.checking, required this.onPressed}); final bool available; final bool checking; final VoidCallback onPressed; @override Widget build(BuildContext context) { return AppLoadingButton( key: const ValueKey('check-app-update-button'), onPressed: onPressed, loading: checking, icon: Icon(available ? Icons.system_update_alt_outlined : Icons.refresh, size: 17), variant: available ? AppLoadingButtonVariant.brand : AppLoadingButtonVariant.secondary, child: Text(available ? context.l10n.updateVersion : context.l10n.checkForUpdates), ); } } class _ErrorState extends StatelessWidget { const _ErrorState({required this.error, required this.onRetry}); final Object error; final VoidCallback onRetry; @override Widget build(BuildContext context) { final palette = AppPalette.of(context); return Center( child: Padding( padding: const EdgeInsets.all(24), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.settings_outlined, size: 32, color: palette.error), const SizedBox(height: 12), Text( context.l10n.unableLoadSettings, style: TextStyle(color: palette.textPrimary, fontWeight: FontWeight.w700), ), const SizedBox(height: 8), Text( error.toString(), textAlign: TextAlign.center, style: TextStyle(color: palette.textSecondary), ), const SizedBox(height: 16), shad.SecondaryButton(onPressed: onRetry, child: Text(context.l10n.retry)), ], ), ), ); } }