Files
FlClash/lib/views/profiles/custom/custom_proxies.dart
T
chen08209 491fae10f5 feat(views)!: set DNS, NTP, sniffer and TUN key by key
BREAKING CHANGE: DNS and NTP override toggles are removed; picked keys apply to every profile
Changelog: Per-key DNS, NTP, sniffer and TUN settings
2026-10-12 10:34:14 +08:00

1581 lines
47 KiB
Dart

import 'dart:async';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/core/controller.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart' hide FileInfo;
import 'package:fl_clash/pages/editor.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'quick_edit.dart';
extension on ProxyField {
String label(AppLocalizations l) => switch (this) {
ProxyField.username => l.username,
ProxyField.password => l.password,
ProxyField.uuid => 'UUID',
ProxyField.psk || ProxyField.preSharedKey => l.preSharedKey,
ProxyField.authStr => l.password,
ProxyField.key => l.secretKey,
ProxyField.privateKey => l.privateKey,
ProxyField.publicKey => l.publicKey,
ProxyField.privateKeyPassphrase => l.privateKeyPassphrase,
ProxyField.cipher => l.cipher,
ProxyField.alterId => 'Alter ID',
ProxyField.protocol => l.protocol,
ProxyField.obfs => l.obfs,
ProxyField.transport => l.transport,
ProxyField.network => l.network,
ProxyField.up => l.uploadBandwidth,
ProxyField.down => l.downloadBandwidth,
ProxyField.flow => 'Flow',
ProxyField.wsPath => 'WebSocket Path',
ProxyField.wsHost => 'WebSocket Host',
ProxyField.grpcServiceName => 'gRPC Service Name',
ProxyField.xhttpPath => 'XHTTP Path',
ProxyField.xhttpHost => 'XHTTP Host',
ProxyField.tls => 'TLS',
ProxyField.sni || ProxyField.servername => 'SNI',
ProxyField.alpn => 'ALPN',
ProxyField.skipCertVerify => l.skipCertVerify,
ProxyField.clientFingerprint => l.clientFingerprint,
ProxyField.realityPublicKey => 'Reality Public Key',
ProxyField.realityShortId => 'Reality Short ID',
ProxyField.plugin => l.plugin,
ProxyField.pluginMode => l.pluginMode,
ProxyField.pluginHost => l.pluginHost,
ProxyField.udpOverTcp => 'UDP over TCP',
ProxyField.protocolParam => l.protocolParam,
ProxyField.obfsParam => l.obfsParam,
ProxyField.obfsPassword || ProxyField.obfsSecret => l.obfsPassword,
ProxyField.obfsMode => l.obfsMode,
ProxyField.obfsHost => l.obfsHost,
ProxyField.version => l.protocolVersion,
ProxyField.ports => l.portHopping,
ProxyField.hopInterval => l.hopInterval,
ProxyField.congestionController => l.congestionControl,
ProxyField.udpRelayMode => l.udpRelayMode,
ProxyField.reduceRtt => '0-RTT',
ProxyField.ip => 'IPv4',
ProxyField.ipv6 => 'IPv6',
ProxyField.allowedIps => l.allowedIps,
ProxyField.dns => 'DNS',
ProxyField.remoteDnsResolve => l.remoteDnsResolve,
ProxyField.mtu => 'MTU',
ProxyField.tfo => 'TCP Fast Open',
ProxyField.mptcp => 'MPTCP',
ProxyField.interfaceName => l.interfaceName,
ProxyField.routingMark => l.routingMark,
ProxyField.ipVersion => l.ipVersion,
};
bool get isCredential => switch (this) {
ProxyField.username ||
ProxyField.password ||
ProxyField.uuid ||
ProxyField.psk ||
ProxyField.key ||
ProxyField.privateKey ||
ProxyField.publicKey ||
ProxyField.privateKeyPassphrase ||
ProxyField.preSharedKey ||
ProxyField.authStr => true,
_ => false,
};
_OptionSection get section => switch (this) {
_ when isCredential => _OptionSection.authentication,
ProxyField.network ||
ProxyField.flow ||
ProxyField.wsPath ||
ProxyField.wsHost ||
ProxyField.grpcServiceName ||
ProxyField.xhttpPath ||
ProxyField.xhttpHost => _OptionSection.transport,
ProxyField.tls ||
ProxyField.sni ||
ProxyField.servername ||
ProxyField.alpn ||
ProxyField.skipCertVerify ||
ProxyField.clientFingerprint ||
ProxyField.realityPublicKey ||
ProxyField.realityShortId => _OptionSection.tls,
ProxyField.tfo ||
ProxyField.mptcp ||
ProxyField.interfaceName ||
ProxyField.routingMark ||
ProxyField.ipVersion => _OptionSection.connection,
_ => _OptionSection.other,
};
int get maxLength => switch (this) {
ProxyField.username => TextInputLimits.userName,
ProxyField.interfaceName || ProxyField.alpn => TextInputLimits.name,
ProxyField.ip ||
ProxyField.ipv6 ||
ProxyField.allowedIps => TextInputLimits.cidr,
ProxyField.dns => TextInputLimits.dnsServer,
ProxyField.wsPath || ProxyField.xhttpPath => TextInputLimits.url,
_ when kind == ProxyFieldKind.number => TextInputLimits.number,
_ => TextInputLimits.domain,
};
String? get hintText => switch (this) {
ProxyField.up || ProxyField.down => '100 Mbps',
ProxyField.ports => '443,8000-9000',
_ => null,
};
}
enum _OptionSection {
authentication,
transport,
tls,
other,
connection;
String label(AppLocalizations l) => switch (this) {
authentication => l.authentication,
transport => l.transport,
tls => 'TLS',
other => l.other,
connection => l.connection,
};
}
class CustomProxiesView extends ConsumerStatefulWidget {
const CustomProxiesView({super.key});
@override
ConsumerState createState() => _CustomProxiesViewState();
}
class _CustomProxiesViewState extends ConsumerState<CustomProxiesView> {
late final ClashProvidersAction _providersAction;
final _seenNames = <String>{};
List<CustomProxy>? _opened;
List<CustomProxy>? _latest;
final _searchTexts = Expando<String>();
var _query = SearchQuery('');
var _selected = <int>{};
@override
void initState() {
super.initState();
_providersAction = ref.read(clashProvidersActionProvider.notifier);
ref.listenManual(customProxiesProvider, (_, next) {
final proxies = next.value;
if (proxies == null) {
return;
}
_seenNames.addAll([for (final proxy in proxies) proxy.name]);
_opened ??= proxies;
_latest = proxies;
}, fireImmediately: true);
}
@override
void dispose() {
if (!customProxiesEquality.equals(_applied(_opened), _applied(_latest))) {
unawaited(_providersAction.applyIfProxiesNamed(_seenNames));
}
super.dispose();
}
static List<CustomProxy> _applied(List<CustomProxy>? proxies) => [
for (final proxy in proxies ?? const <CustomProxy>[])
proxy.copyWith(emptyOptions: const {}),
];
void _handleReorder(int oldIndex, int newIndex) {
ref.read(customProxiesProvider.notifier).order(oldIndex, newIndex);
}
void _handleSearch(String query) {
setState(() {
_query = SearchQuery(query);
});
}
void _handleToggleSelected(int id) {
setState(() {
_selected = {..._selected}..addOrRemove(id);
});
}
void _handleSelectAll(Set<int> ids) {
setState(() {
_selected = _selected.containsAll(ids) ? {} : ids;
});
}
Future<void> _handleDelete(Set<int> ids, {required Set<int> kept}) async {
final appLocalizations = context.appLocalizations;
final message = await _deleteMessage(
ref,
ids,
appLocalizations.deleteMultipTip(appLocalizations.proxies),
);
if (!mounted) {
return;
}
final res = await dialogs.showMessage(message: TextSpan(text: message));
if (res != true || !mounted) {
return;
}
ref.read(customProxiesProvider.notifier).delAll(ids);
setState(() {
_selected = kept;
});
}
Future<void> _handleQuickEdit() async {
final previous = ref.read(customProxiesProvider).value;
if (previous == null) {
return;
}
final appLocalizations = context.appLocalizations;
final core = ref.read(coreHandlerProvider);
final List<String> links;
try {
links = await core.encodeShareLinks([
for (final proxy in previous) proxy.definition,
]);
} catch (error) {
await dialogs.showMessage(message: TextSpan(text: compactError(error)));
return;
}
if (!mounted) {
return;
}
final notifier = ref.read(customProxiesProvider.notifier);
return showCustomQuickEdit(
context,
title: appLocalizations.proxies,
content: ShareLinkEdit.textOf(links),
schema: EditorSchema.shareLinks,
invalidMessage: appLocalizations.shareLinksInvalid,
apply: (content) async {
final edit = ShareLinkEdit.read(
content,
previous: previous,
links: links,
);
final decoded = await _decodeShareLinks(core, edit.pending);
final dropped = edit.droppedLinks;
final linked = dropped.isEmpty
? const <List<Map<String, dynamic>>>[]
: await core.decodeShareLinks(dropped.values.toList());
final proxies = edit.apply(
decoded,
groupNames: await ref.read(customGroupNamesProvider.future),
linked: {
for (final (index, id) in dropped.keys.indexed)
if (linked[index] case [final definition]) id: definition,
},
);
if (!await _confirmReplaced(previous, proxies)) {
throw const QuickEditCancelled();
}
notifier.setAll(proxies);
},
);
}
/// A line whose name changed is a new proxy: what custom profiles name the
/// old one by is left missing, so the edit says which go and which come.
Future<bool> _confirmReplaced(
List<CustomProxy> previous,
List<CustomProxy> next,
) async {
final kept = {for (final proxy in next) proxy.id};
final removed = [
for (final proxy in previous)
if (!kept.contains(proxy.id)) proxy.name,
];
if (removed.isEmpty) {
return true;
}
final existed = {for (final proxy in previous) proxy.id};
final added = [
for (final proxy in next)
if (!existed.contains(proxy.id)) proxy.name,
];
final appLocalizations = currentAppLocalizations;
final res = await dialogs.showMessage(
title: appLocalizations.tip,
message: TextSpan(
text: added.isEmpty
? appLocalizations.proxiesRemovedTip(removed.join(', '))
: appLocalizations.proxiesReplacedTip(
removed.join(', '),
added.join(', '),
),
),
);
return res == true;
}
void _handleAddOrUpdate({CustomProxy? proxy}) {
showNestedFormSheet<CustomProxy>(
context: context,
profileId: null,
overrides: [
customProxyProvider.overrideWithBuild(
(_, _) =>
proxy ??
const CustomProxy(id: -1, definition: {'name': '', 'type': 'ss'}),
),
],
currentOf: (ref) => ref.read(customProxyProvider),
formBuilder: (_) => const _EditCustomProxyView(),
);
}
Widget _buildNode(
CustomProxy proxy, {
required Set<int> selected,
required ItemPosition position,
int? dragIndex,
}) {
final item = ItemPositionProvider(
position: position,
child: _NodeItem(
proxy: proxy,
isEditing: selected.isNotEmpty,
isSelected: selected.contains(proxy.id),
onSelected: () => _handleToggleSelected(proxy.id),
onPressed: () => _handleAddOrUpdate(proxy: proxy),
),
);
if (dragIndex == null) {
return KeyedSubtree(key: ValueKey(proxy.id), child: item);
}
return ReorderableDelayedDragStartListener(
key: ValueKey(proxy.id),
index: dragIndex,
child: item,
);
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
final proxies = ref.watch(customProxiesProvider).value;
final all = proxies ?? const <CustomProxy>[];
final visible = all
.whereMatches(
_query,
(proxy) => proxy.searchFields,
texts: _searchTexts,
)
.toList();
// Indices among the matches cannot be mapped back onto the full list.
final reorderable = _query.isEmpty;
final selected = _selected.intersection({
for (final proxy in all) proxy.id,
});
final visibleIds = {for (final proxy in visible) proxy.id};
final visibleSelected = selected.intersection(visibleIds);
return CommonPopScope(
onPop: selected.isEmpty
? null
: (_) {
setState(() {
_selected = {};
});
return false;
},
child: CommonScaffold(
title: appLocalizations.proxies,
searchState: AppBarSearchState(onSearch: _handleSearch),
actions: [
if (selected.isEmpty)
ListEditorAddAction(
onAdd: _handleAddOrUpdate,
onQuickEdit: _handleQuickEdit,
),
],
selectionActions: selected.isEmpty
? const []
: [
IconButtonData(
glyph: AppGlyphs.delete,
onPressed: visibleSelected.isEmpty
? null
: () => _handleDelete(
visibleSelected,
kept: selected.difference(visibleSelected),
),
tooltip: appLocalizations.delete,
),
IconButtonData(
glyph: AppGlyphs.selectAll,
onPressed: () => _handleSelectAll(visibleIds),
tooltip: appLocalizations.selectAll,
),
],
body: NullStatusSwitcher(
isLoading: proxies == null,
isEmpty: visible.isEmpty,
isSearching: _query.isNotEmpty,
nullStatus: NullStatus(
label: appLocalizations.nullTip(appLocalizations.proxies),
illustration: NullStatusIllustration.proxies,
action: ElasticButton(
child: FilledButton.tonalIcon(
onPressed: _handleAddOrUpdate,
icon: const GlyphIcon(AppGlyphs.addCircle, fill: 1),
label: Text(appLocalizations.add),
),
),
),
child: Builder(
builder: (context) => CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: SizedBox(height: context.contentTopPadding),
),
SliverPadding(
padding: EdgeInsets.fromLTRB(
16,
0,
16,
16 + BottomInsetScope.of(context),
),
sliver: reorderable
? SliverReorderableList(
itemCount: visible.length,
itemBuilder: (_, index) => _buildNode(
visible[index],
selected: selected,
position: ItemPosition.get(index, visible.length),
dragIndex: index,
),
proxyDecorator: (child, index, animation) =>
commonProxyDecorator(
_buildNode(
visible[index],
selected: selected,
position: ItemPosition.get(
index,
visible.length,
),
dragIndex: index,
),
index,
animation,
),
onReorderItem: _handleReorder,
)
: SliverList.builder(
itemCount: visible.length,
itemBuilder: (_, index) => _buildNode(
visible[index],
selected: selected,
position: ItemPosition.get(index, visible.length),
),
),
),
],
),
),
),
),
);
}
}
class _NodeItem extends ConsumerWidget {
final CustomProxy proxy;
final bool isEditing;
final bool isSelected;
final VoidCallback onSelected;
final VoidCallback onPressed;
const _NodeItem({
required this.proxy,
required this.isEditing,
required this.isSelected,
required this.onSelected,
required this.onPressed,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final issues = ref
.watch(
customProxyListIssuesProvider.select(
(state) => SelectValue(state[proxy.id] ?? const <CustomIssue>[]),
),
)
.value;
return DecorationListItem(
invalid: issues.isNotEmpty,
isSelected: isSelected,
contentPadding: const EdgeInsets.only(left: 16, right: 8),
onPressed: isEditing ? onSelected : onPressed,
title: EmojiText(
proxy.name.isEmpty ? '-' : proxy.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(proxy.type, maxLines: 1, overflow: TextOverflow.ellipsis),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (issues.isNotEmpty) CustomIssueButton(issues: issues),
CommonCheckBox(
value: isSelected,
isCircle: true,
onChanged: (_) => onSelected(),
),
],
),
);
}
}
Future<Map<int, List<Map<String, dynamic>>>> _decodeShareLinks(
CoreController core,
List<ShareLinkLine> lines,
) async {
final appLocalizations = currentAppLocalizations;
final results = await core.decodeShareLinks([
for (final line in lines) line.link,
]);
final decoded = <int, List<Map<String, dynamic>>>{};
for (final (index, line) in lines.indexed) {
if (results[index].isEmpty) {
throw MessageException(
appLocalizations.lineIssueTip(
line.line,
appLocalizations.shareLinksInvalid,
),
);
}
decoded[line.line] = results[index];
}
final entries = [
for (final MapEntry(key: line, value: proxies) in decoded.entries)
for (final proxy in proxies) (line: line, proxy: proxy),
];
final errors = await core.validateProxies([
for (final entry in entries) entry.proxy,
]);
for (final (index, error) in errors.indexed) {
if (error.isNotEmpty) {
throw MessageException(
appLocalizations.lineIssueTip(entries[index].line, error),
);
}
}
return decoded;
}
/// Pops with what [read] made of the link, or with nothing when the link was
/// left as it came, which would only drop what the link cannot carry.
class _ShareLinkDialog extends StatefulWidget {
final String initialLink;
final Future<Map<String, dynamic>> Function(String link) read;
const _ShareLinkDialog({required this.initialLink, required this.read});
@override
State<_ShareLinkDialog> createState() => _ShareLinkDialogState();
}
class _ShareLinkDialogState extends State<_ShareLinkDialog> {
late final TextEditingController _controller;
String? _error;
bool _reading = false;
@override
void initState() {
super.initState();
_controller = TextEditingController(text: widget.initialLink);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _submit() async {
final link = _controller.text.trim();
if (link == widget.initialLink) {
Navigator.of(context).pop();
return;
}
if (_reading) {
return;
}
setState(() {
_reading = true;
_error = null;
});
try {
final definition = await widget.read(link);
if (mounted) {
Navigator.of(context).pop(definition);
}
} catch (error) {
if (mounted) {
setState(() {
_error = compactError(error);
});
}
} finally {
if (mounted) {
setState(() {
_reading = false;
});
}
}
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
return CommonDialog(
title: appLocalizations.quickEdit,
actions: [
TextButton(
onPressed: _reading ? null : _submit,
child: Text(appLocalizations.confirm),
),
],
child: TextField(
controller: _controller,
autofocus: true,
minLines: 3,
maxLines: 6,
keyboardType: TextInputType.url,
decoration: InputDecoration(
labelText: 'URI',
alignLabelWithHint: true,
hintText: 'vless://…',
errorText: _error,
errorMaxLines: 3,
),
),
);
}
}
Future<String> _deleteMessage(
WidgetRef ref,
Set<int> ids,
String fallback,
) async {
final names = {
for (final proxy
in ref.read(customProxiesProvider).value ?? const <CustomProxy>[])
if (ids.contains(proxy.id)) proxy.name,
};
final users = await ref
.read(profilesActionProvider.notifier)
.proxyUsers(names);
if (users.isEmpty) {
return fallback;
}
return currentAppLocalizations.providerInUse(
names.join(', '),
users.map((profile) => profile.realLabel).join(', '),
);
}
Future<bool> _handleSaveCustomProxy(
BuildContext context,
WidgetRef ref, {
required ValueChanged<String> onCoreRejected,
}) async {
final proxy = ref.read(customProxyProvider);
// A source still loading reads as empty below and would pass any name.
Set<String> groupNames = const {};
try {
(_, groupNames) = await (
ref.read(customProxiesProvider.future),
ref.read(customGroupNamesProvider.future),
).wait;
} catch (_) {}
if (!context.mounted) {
return false;
}
final issues = customProxyIssues(
proxy,
proxies: ref.read(customProxiesProvider).value ?? const [],
groupNames: groupNames,
);
if (issues.isEmpty) {
try {
final errors = await ref.read(coreHandlerProvider).validateProxies([
proxy.definition,
]);
if (errors.first.isNotEmpty) {
issues.add(CustomIssue.coreRejected(errors.first));
}
} catch (error) {
issues.add(CustomIssue.coreRejected(compactError(error)));
}
}
if (!context.mounted) {
return false;
}
// The form stays editable while the core checks the proxy.
if (!identical(ref.read(customProxyProvider), proxy)) {
return _handleSaveCustomProxy(context, ref, onCoreRejected: onCoreRejected);
}
if (issues.isNotEmpty) {
if (issues case [CoreRejectedIssue(:final message)]) {
onCoreRejected(message);
}
await showSaveBlocked(context, issues.getMessage(context));
return false;
}
final saved = proxy.id == -1 ? proxy.copyWith(id: snowflake.id) : proxy;
ref.read(customProxiesProvider.notifier).put(saved);
// The closing form would otherwise take its stored copy for another proxy
// with its name.
ref.read(customProxyProvider.notifier).value = saved;
return true;
}
class _EditCustomProxyView extends ConsumerStatefulWidget {
const _EditCustomProxyView();
@override
ConsumerState<_EditCustomProxyView> createState() =>
_EditCustomProxyViewState();
}
class _EditCustomProxyViewState extends ConsumerState<_EditCustomProxyView> {
static const _validateDelay = Duration(milliseconds: 400);
late final bool _isNew;
Timer? _validateTimer;
int _validateRequest = 0;
String? _coreError;
bool _saving = false;
bool _submitted = false;
var _revealed = <String>{};
late Set<ProxyField> _shown;
/// Added options not given a value since, the only ones a save keeps empty.
late Set<ProxyField> _untouched;
/// Bumped when the whole mapping is replaced, so the fields drop the text
/// they were initialized with.
int _revision = 0;
@override
void initState() {
super.initState();
final proxy = ref.read(customProxyProvider);
_isNew = proxy.id == -1;
_shown = _shownIn(proxy);
_untouched = {
for (final field in proxy.emptyOptions)
if (proxy.valueOf(field) == null) field,
};
NestedFormSheet.bindSave(context, _handleSave);
ref.listenManual(customProxyProvider, (_, proxy) {
_untouched.removeWhere((field) => proxy.valueOf(field) != null);
});
ref.listenManual(
customProxyProvider.select((state) => state.definition),
(_, _) => _scheduleValidate(),
fireImmediately: true,
);
}
@override
void dispose() {
_validateTimer?.cancel();
super.dispose();
}
void _scheduleValidate() {
_validateTimer?.cancel();
_validateTimer = Timer(_validateDelay, _validate);
}
Future<void> _handleQuickEdit() async {
final appLocalizations = context.appLocalizations;
final core = ref.read(coreHandlerProvider);
var link = '';
try {
link = (await core.encodeShareLinks([
ref.read(customProxyProvider).definition,
])).single;
} catch (_) {}
if (!mounted) {
return;
}
final definition = await dialogs.showCommonDialog<Map<String, dynamic>>(
child: _ShareLinkDialog(
initialLink: link,
read: (text) async {
final results = await core.decodeShareLinks([
text,
if (link.isNotEmpty) link,
]);
final definitions = results.first;
if (definitions.isEmpty) {
throw MessageException(appLocalizations.shareLinksInvalid);
}
if (definitions.length > 1) {
throw MessageException(appLocalizations.singleShareLinkOnly);
}
return withUnlinkedFields(
definitions.single,
previous: ref.read(customProxyProvider).definition,
linked: results.skip(1).firstOrNull?.singleOrNull,
);
},
),
);
if (definition == null || !mounted) {
return;
}
_fillFromLink(definition);
}
void _fillFromLink(Map<String, dynamic> definition) {
final current = ref.read(customProxyProvider);
final named = (definition['name'] ?? '') == '' && current.name.isNotEmpty
? {...definition, 'name': current.name}
: importedCustomProxies(
[definition],
groupNames: ref.read(customGroupNamesProvider).value ?? const {},
existing: [
for (final proxy in ref.read(customProxiesProvider).value ?? [])
if (proxy.id != current.id) proxy,
],
).single.definition;
final next = _withShown(current.copyWith(definition: named), {
for (final field in _shown)
if (current.valueOf(field) == null) field,
});
ref.read(customProxyProvider.notifier).value = next;
setState(() {
_shown = _shownIn(next);
_revision++;
});
}
static Set<ProxyField> _shownIn(CustomProxy proxy) => {
for (final field in proxy.options)
if (proxy.valueOf(field) != null || proxy.emptyOptions.contains(field))
field,
};
CustomProxy _withShown(CustomProxy proxy, Set<ProxyField> shown) =>
proxy.copyWith(
emptyOptions: {
for (final field in shown)
if (proxy.valueOf(field) == null && _untouched.contains(field))
field,
},
);
/// Keeps a row for every option [proxy] offers that has a value, was shown,
/// or was [unlocked] by the change that led to [proxy].
void _syncShown(
CustomProxy proxy, {
Iterable<ProxyField> unlocked = const [],
}) {
final shown = {
for (final field in proxy.options)
if (_shown.contains(field) ||
unlocked.contains(field) ||
proxy.valueOf(field) != null)
field,
};
_untouched.addAll(unlocked.where((field) => proxy.valueOf(field) == null));
ref.read(customProxyProvider.notifier).value = _withShown(proxy, shown);
setState(() {
_shown = shown;
});
}
Future<void> _validate() async {
final request = ++_validateRequest;
final definition = ref.read(customProxyProvider).definition;
String? error;
try {
final errors = await ref.read(coreHandlerProvider).validateProxies([
definition,
]);
error = errors.first.isEmpty ? null : errors.first;
} catch (_) {
error = null;
}
if (!mounted || request != _validateRequest || error == _coreError) {
return;
}
setState(() {
_coreError = error;
});
}
void _update(String key, Object? value) {
ref
.read(customProxyProvider.notifier)
.update((state) => state.withValue(key, value));
}
void _updateField(ProxyField field, Object? value) {
ref
.read(customProxyProvider.notifier)
.update((state) => state.withField(field, value));
}
String? _requiredHint(CustomProxy proxy, String key) =>
proxy.requiredKeys.contains(key)
? context.appLocalizations.required
: null;
void _updateOption(ProxyField field, Object? value) {
final proxy = ref.read(customProxyProvider);
final next = proxy.withField(field, value);
final offered = proxy.options;
_syncShown(
next,
unlocked: [
for (final option in next.options)
if (!offered.contains(option)) option,
],
);
}
Widget _buildTextItem({
required String title,
required String key,
required String? value,
required int maxLength,
String? hintText,
bool digitsOnly = false,
bool policyName = false,
bool invalid = false,
bool obscureText = false,
Widget? leading,
Widget? suffix,
ValueChanged<Object?>? onChanged,
}) {
final update = onChanged ?? (Object? value) => _update(key, value);
final field = TextFormField(
key: ValueKey('$key-$_revision'),
initialValue: value,
textAlign: TextAlign.end,
obscureText: obscureText,
keyboardType: digitsOnly ? TextInputType.number : TextInputType.text,
inputFormatters: digitsOnly
? TextInputLimits.digitsOnly(maxLength)
: policyName
? TextInputLimits.policyName(maxLength)
: TextInputLimits.limit(maxLength),
onChanged: (value) {
update(digitsOnly ? int.tryParse(value) : value);
},
decoration: InputDecoration.collapsed(
border: const NoInputBorder(),
hintText: hintText ?? context.appLocalizations.optional,
),
);
return FormRow(
title: title,
invalid: invalid,
leading: leading,
trailing: suffix == null
? field
: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(child: field),
suffix,
],
),
);
}
Widget _buildSecretItem({
required String title,
required String key,
required String? value,
String? hintText,
Widget? leading,
ValueChanged<Object?>? onChanged,
}) {
final appLocalizations = context.appLocalizations;
final revealed = _revealed.contains(key);
return _buildTextItem(
title: title,
key: key,
value: value,
maxLength: TextInputLimits.password,
hintText: hintText,
obscureText: !revealed,
leading: leading,
onChanged: onChanged,
suffix: CommonMinIconButtonTheme(
child: IconButton(
tooltip: revealed
? appLocalizations.hidePassword
: appLocalizations.showPassword,
onPressed: () {
setState(() {
_revealed = {..._revealed}..addOrRemove(key);
});
},
icon: GlyphIcon(revealed ? AppGlyphs.eyeOff : AppGlyphs.eye),
),
),
);
}
Widget _buildSwitchItem({
required String title,
required bool value,
required ValueChanged<bool> onChanged,
Widget? leading,
}) {
return FormRow(
title: title,
leading: leading,
onPressed: () => onChanged(!value),
trailing: Switch(value: value, onChanged: onChanged),
);
}
Widget _buildField(CustomProxy proxy, ProxyField field, {Widget? leading}) {
final appLocalizations = context.appLocalizations;
final title = field.label(appLocalizations);
final key = field.path.join('.');
final value = proxy.valueOf(field);
final hintText = _requiredHint(proxy, key);
void update(Object? value) => _updateField(field, value);
return switch (field.kind) {
ProxyFieldKind.text || ProxyFieldKind.number => _buildTextItem(
title: title,
key: key,
value: value?.toString(),
maxLength: field.maxLength,
hintText: field.hintText ?? hintText,
digitsOnly: field.kind == ProxyFieldKind.number,
leading: leading,
onChanged: update,
),
ProxyFieldKind.secret => _buildSecretItem(
title: title,
key: key,
value: value?.toString(),
hintText: hintText,
leading: leading,
onChanged: update,
),
ProxyFieldKind.toggle => _buildSwitchItem(
title: title,
value: value == true,
leading: leading,
onChanged: update,
),
ProxyFieldKind.choice => _buildPickerItem(
title: title,
value: value?.toString() ?? hintText ?? appLocalizations.optional,
leading: leading,
onPressed: () => _handleSelectChoice(field),
),
ProxyFieldKind.list => _buildListItem(
proxy,
title: title,
key: key,
itemMaxLength: field.maxLength,
leading: leading,
),
};
}
Future<void> _handleSelectType() async {
final res = await _selectChoice(
title: context.appLocalizations.proxyType,
items: customProxyTypes,
selectedOf: (state) => state.type,
);
if (res == null) {
return;
}
_syncShown(ref.read(customProxyProvider).withType(res));
}
Future<void> _handleSelectChoice(ProxyField field) async {
final res = await _selectChoice(
title: field.label(context.appLocalizations),
items: ref.read(customProxyProvider).choicesOf(field),
selectedOf: (state) => state.valueOf(field)?.toString(),
);
if (res == null) {
return;
}
_updateOption(field, res);
}
Future<String?> _selectChoice({
required String title,
required List<String> items,
required String? Function(CustomProxy state) selectedOf,
}) {
return Navigator.of(context).push(
PagedSheetRoute(
builder: (context) => SelectionSheet<String>(
title: title,
sections: [SelectionSection(items: items)],
labelBuilder: (item) => item,
selectedOf: (ref) =>
ref.watch(customProxyProvider.select(selectedOf)),
onSelected: (item) => Navigator.of(context).pop(item),
),
),
);
}
Widget _buildPickerItem({
required String title,
required String value,
required VoidCallback onPressed,
Widget? leading,
}) {
return FormRow(
title: title,
leading: leading,
onPressed: onPressed,
trailing: Row(
spacing: 4,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
value,
style: context.listTitleStyle?.copyWith(
color: context.colorScheme.onSurfaceVariant,
),
),
),
const GlyphIcon(AppGlyphs.chevronForward),
],
),
);
}
Widget _buildTypeItem(String type) {
return _buildPickerItem(
title: context.appLocalizations.proxyType,
value: type,
onPressed: _handleSelectType,
);
}
Widget _buildListItem(
CustomProxy proxy, {
required String title,
required String key,
required int itemMaxLength,
Widget? leading,
}) {
final appLocalizations = context.appLocalizations;
final items = proxy.stringsOf(key);
return _buildPickerItem(
title: title,
value: items.isEmpty
? appLocalizations.none
: appLocalizations.itemsCount(items.length),
leading: leading,
onPressed: () => Navigator.of(context).push(
PagedSheetRoute<void>(
builder: (_) => _ProxyValuesView(
title: title,
field: key,
itemMaxLength: itemMaxLength,
),
),
),
);
}
void _handleAddOption(List<ProxyField> fields) {
final appLocalizations = context.appLocalizations;
final sections = {
for (final section in _OptionSection.values)
section: [
for (final field in fields)
if (field.section == section) field,
],
};
Navigator.of(context).push(
PagedSheetRoute(
builder: (_) => SelectionSheet<ProxyField>(
title: appLocalizations.addSettingEntry,
sections: [
for (final MapEntry(:key, :value) in sections.entries)
if (value.isNotEmpty)
SelectionSection(
label: key.label(appLocalizations),
items: value,
),
],
labelBuilder: (field) => field.label(appLocalizations),
selectedOf: (_) => null,
removeOnSelect: true,
onSelected: _handleAddedOption,
),
),
);
}
void _handleAddedOption(ProxyField field) {
if (!mounted) {
return;
}
final proxy = ref.read(customProxyProvider);
final initial = switch (field.kind) {
ProxyFieldKind.toggle => true,
ProxyFieldKind.choice => proxy.choicesOf(field).firstOrNull,
_ => null,
};
final value = proxy.valueOf(field) ?? initial;
_shown = {..._shown, field};
if (value == null) {
_untouched.add(field);
}
_updateOption(field, value);
}
void _handleRemoveOption(ProxyField field) {
_shown = {..._shown}..remove(field);
_updateOption(field, null);
}
Widget _buildOptions(CustomProxy proxy) {
final appLocalizations = context.appLocalizations;
final options = proxy.options;
final remaining = [
for (final field in options)
if (!_shown.contains(field)) field,
];
void handleAdd() => _handleAddOption(remaining);
return generateAnimatedSection(
title: appLocalizations.options,
items: [
for (final field in options)
if (_shown.contains(field))
KeyedSubtree(
key: ValueKey(field),
child: _buildField(
proxy,
field,
leading: EntryButton.remove(
onPressed: () => _handleRemoveOption(field),
),
),
),
if (remaining.isNotEmpty)
FormRow(
key: const ValueKey(#add),
leading: EntryButton.add(onPressed: handleAdd),
title: appLocalizations.addSettingEntry,
titleStyle: TextStyle(color: context.colorScheme.primary),
onPressed: handleAdd,
),
],
);
}
Widget _buildEasyTierSection(CustomProxy proxy) {
final appLocalizations = context.appLocalizations;
final definition = proxy.definition;
return generateSectionV3(
title: appLocalizations.network,
items: [
_buildTextItem(
title: appLocalizations.networkName,
key: 'network-name',
value: definition['network-name']?.toString(),
maxLength: TextInputLimits.name,
hintText: appLocalizations.networkName,
),
_buildSecretItem(
title: appLocalizations.networkSecret,
key: 'network-secret',
value: definition['network-secret']?.toString(),
),
_buildListItem(
proxy,
title: appLocalizations.peers,
key: 'peers',
itemMaxLength: TextInputLimits.uri,
),
_buildTextItem(
title: appLocalizations.virtualIpv4,
key: 'ipv4',
value: definition['ipv4']?.toString(),
maxLength: TextInputLimits.cidr,
hintText: 'DHCP',
),
_buildTextItem(
title: appLocalizations.hostname,
key: 'hostname',
value: definition['hostname']?.toString(),
maxLength: TextInputLimits.domain,
),
_buildListItem(
proxy,
title: appLocalizations.exitNodes,
key: 'exit-nodes',
itemMaxLength: TextInputLimits.cidr,
),
],
);
}
Future<void> _handleDelete() async {
final appLocalizations = context.appLocalizations;
final id = ref.read(customProxyProvider).id;
final message = await _deleteMessage(ref, {
id,
}, appLocalizations.deleteTip(appLocalizations.proxies));
if (!mounted) {
return;
}
final res = await dialogs.showMessage(message: TextSpan(text: message));
if (res == true && mounted) {
ref.read(customProxiesProvider.notifier).delAll([id]);
context.safeNestedPop();
}
}
Future<void> _handleSave() async {
if (_saving) return;
_saving = true;
ref
.read(customProxyProvider.notifier)
.update((state) => _withShown(state, _shown));
try {
String? coreError;
final saved = await _handleSaveCustomProxy(
context,
ref,
onCoreRejected: (message) => coreError = message,
);
if (!mounted) return;
if (saved) {
context.safeNestedPop();
return;
}
setState(() {
_submitted = true;
_coreError = coreError ?? _coreError;
});
} finally {
_saving = false;
}
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
final proxy = ref.watch(customProxyProvider);
final showIncomplete = !_isNew || _submitted;
final issues = [
...customProxyIssues(
proxy,
proxies: ref.watch(customProxiesProvider).value ?? const [],
groupNames: ref.watch(customGroupNamesProvider).value ?? const {},
),
if (_coreError != null) CustomIssue.coreRejected(_coreError!),
].where((issue) => showIncomplete || !issue.isIncomplete).toList();
final nameInvalid = issues.any(
(issue) =>
issue is EmptyNameIssue ||
issue is ReservedNameIssue ||
issue is DuplicateNameIssue,
);
final easyTier = proxy.type == 'easytier';
final fields = proxy.fields;
return CommonScaffold(
iconActions: customFormActions(
context,
onQuickEdit: _handleQuickEdit,
onSave: _handleSave,
),
body: CustomScrollView(
slivers: [
SliverCustomIssuesBanner(issues: issues),
SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
).copyWith(bottom: 20),
sliver: SliverList.list(
children: [
generateSectionV3(
title: appLocalizations.basicInfo,
items: [
_buildTextItem(
title: appLocalizations.name,
key: 'name',
value: proxy.name,
maxLength: TextInputLimits.proxyName,
hintText: appLocalizations.name,
policyName: true,
invalid: nameInvalid,
),
_buildTypeItem(proxy.type),
if (!easyTier) ...[
_buildTextItem(
title: appLocalizations.server,
key: 'server',
value: proxy.server,
maxLength: TextInputLimits.domain,
hintText: _requiredHint(proxy, 'server'),
),
_buildTextItem(
title: appLocalizations.port,
key: 'port',
value: proxy.port?.toString(),
maxLength: TextInputLimits.port,
hintText: _requiredHint(proxy, 'port'),
digitsOnly: true,
),
],
for (final field in fields)
if (!field.isCredential) _buildField(proxy, field),
_buildSwitchItem(
title: 'UDP',
value: proxy.definition['udp'] == true,
onChanged: (value) => _update('udp', value),
),
],
),
if (easyTier) _buildEasyTierSection(proxy),
generateSectionV3(
title: appLocalizations.authentication,
items: [
for (final field in fields)
if (field.isCredential) _buildField(proxy, field),
],
),
_buildOptions(proxy),
generateSectionV3(
title: appLocalizations.action,
items: [
if (!_isNew)
FormRow(
title: appLocalizations.delete,
titleStyle: TextStyle(color: context.colorScheme.error),
onPressed: _handleDelete,
),
],
),
],
),
),
],
),
title: _isNew
? appLocalizations.addCustomProxy
: appLocalizations.editProxy,
);
}
}
/// Writes each change straight into the form, since a sheet page's back
/// button pops it without a result.
class _ProxyValuesView extends ConsumerWidget {
final String title;
final String field;
final int itemMaxLength;
const _ProxyValuesView({
required this.title,
required this.field,
required this.itemMaxLength,
});
List<String> _valuesOf(WidgetRef ref) =>
ref.read(customProxyProvider).stringsOf(field);
void _save(WidgetRef ref, List<String> values) {
ref
.read(customProxyProvider.notifier)
.update(
(state) => state.withValue(field, values.isEmpty ? null : values),
);
}
Future<void> _handleAddOrUpdate(
BuildContext context,
WidgetRef ref, [
String? value,
]) async {
final res = await showListEntryDialog(
context,
title: title,
entries: _valuesOf(ref),
entry: value,
itemMaxLength: itemMaxLength,
);
if (res == null || res.isEmpty || !context.mounted) {
return;
}
final values = _valuesOf(ref);
_save(
ref,
value == null
? [...values, ...res]
: [for (final item in values) item == value ? res.single : item],
);
}
@override
Widget build(BuildContext context, WidgetRef ref) {
return ListEditorPage<String, String>(
title: title,
selectionEnabled: true,
idOf: (value) => value,
itemsOf: (ref) => ref.watch(
customProxyProvider.select((state) => state.stringsOf(field)),
),
itemBuilder:
(
context,
ref,
value,
index,
isEditing,
isSelected,
onToggleSelected,
) => SelectedDecorationListItem(
title: Text(value),
isEditing: isEditing,
isSelected: isSelected,
onSelected: onToggleSelected,
onPressed: () => _handleAddOrUpdate(context, ref, value),
),
onReorder: (oldIndex, newIndex) =>
_save(ref, _valuesOf(ref).copyAndReorder(oldIndex, newIndex)),
onAdd: () => _handleAddOrUpdate(context, ref),
onDelete: (values) => _save(ref, [
for (final value in _valuesOf(ref))
if (!values.contains(value)) value,
]),
emptyLabel: context.appLocalizations.nullTip(title),
);
}
}