Files
gopeed/ui/flutter/lib/features/settings/presentation/pages/settings_page.dart
T

2365 lines
90 KiB
Dart

import 'dart:async';
import 'package:file_picker/file_picker.dart';
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 '../../../../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<SettingsPage> createState() => _SettingsPageState();
}
class _SettingsPageState extends ConsumerState<SettingsPage> {
final _downloadDirController = TextEditingController();
final _maxRunningController = TextEditingController();
final _httpUserAgentController = TextEditingController();
final _httpConnectionsController = 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<TextEditingController> get _textControllers => [
_downloadDirController,
_maxRunningController,
_httpUserAgentController,
_httpConnectionsController,
_btListenPortController,
_btSeedRatioController,
_btSeedTimeController,
_ed2kListenPortController,
_ed2kUdpPortController,
_ed2kServerAddrController,
_ed2kServerMetController,
_ed2kNodesDatController,
_proxyHostController,
_proxyPortController,
_proxyUserController,
_proxyPasswordController,
_customTrackersController,
];
List<TextEditingController> 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<ResponsiveMenuItem<_SettingsSection>> 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)),
),
),
],
),
),
_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(
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<String>(
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<String>(
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();
_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.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<void> _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<void> _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<void> _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<bool> _confirmDisableApiServer() async {
final overlay = const shad.DialogOverlayHandler().show<bool>(
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<bool> _confirmRestartApiServer() async {
final overlay = const shad.DialogOverlayHandler().show<bool>(
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<String?> _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<void> _runAction(Future<void> Function() action) async {
try {
await action();
} catch (error) {
if (mounted) _toast(error.toString());
}
}
Future<void> _setDefaultBtClient(bool enabled) async {
try {
enabled ? registerDefaultTorrentClient() : unregisterDefaultTorrentClient();
_mutateConfig((config) => config.extra.defaultBtClient = enabled);
} catch (error) {
if (mounted) _toast(error.toString());
}
}
Future<void> _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);
}
List<String> _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<void> _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<DownloadCategory>.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<void> _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<DownloadCategory>.from(config.extra.downloadCategories)..remove(category);
});
}
List<GithubMirror> get _visibleGithubMirrors =>
_config?.extra.githubMirror.mirrors.where((mirror) => !mirror.isDeleted).toList(growable: false) ?? const [];
Future<void> _editGithubMirror([GithubMirror? mirror]) async {
final draft = await showGithubMirrorDialog(context, mirror: mirror);
if (draft == null || !mounted) return;
_mutateConfig((config) {
final mirrors = List<GithubMirror>.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<void> _editGithubMirrorAtVisibleIndex(int index) async {
final mirrors = _visibleGithubMirrors;
if (index < 0 || index >= mirrors.length) return;
await _editGithubMirror(mirrors[index]);
}
Future<void> _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<GithubMirror>.from(config.extra.githubMirror.mirrors)..remove(mirror);
}
});
}
Future<void> _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<String>.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<String>.from(config.webhook.urls)..removeAt(index);
});
}
Future<void> _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],
pickPath: Util.isDesktop()
? () async {
final file = await FilePicker.pickFile();
return file?.path;
}
: null,
);
if (value == null || !mounted) return;
_mutateConfig((config) {
final next = List<String>.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<String>.from(config.script.paths)..removeAt(index);
});
}
Future<void> _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<void> _openBrowserExtension(Uri uri) async {
await _openExternalUri(uri);
}
Future<void> _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<void> _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 = <String>[];
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 <BoxShadow>[],
};
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<Widget> 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<Widget> 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<Uri> 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<String> selected;
final bool autoUpdate;
final DateTime? lastUpdated;
final ValueChanged<List<String>> onChanged;
final ValueChanged<bool> onAutoUpdateChanged;
final Future<void> 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<String>.from(widget.selected);
checked ? next.remove(url) : next.add(url);
widget.onChanged(next);
}
void _toggleAll(bool allSelected) {
widget.onChanged(allSelected ? const [] : List<String>.from(allTrackerSubscribeUrls));
}
Future<void> _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<void> 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)),
],
),
),
);
}
}