Files
FlClash/lib/views/profiles/custom/groups.dart
T
chen08209 0bac50029a feat(profiles): manage external resources from the profile details
Changelog: Profile-detail resources and include-all in custom groups
2026-10-17 17:21:43 +08:00

1405 lines
42 KiB
Dart

import 'dart:async';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/form/form.dart';
import 'package:fl_clash/models/models.dart' hide FileInfo;
import 'package:fl_clash/pages/editor.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'icon.dart';
import 'members.dart';
import 'quick_edit.dart';
const _defaultEmptyFallback = 'COMPATIBLE';
String _emptyFallbackOf(ProxyGroup group) => switch (group.emptyFallback) {
final value? when value.isNotEmpty => value,
_ => _defaultEmptyFallback,
};
const _inUserHashKey = 'in-user';
/// What a group leaves to the core until it is added, starting from the
/// core's own default.
enum _GroupOption {
emptyFallback,
url,
interval,
timeout,
maxFailedTimes,
lazy,
expectedStatus,
tolerance,
defaultSelected,
strategy,
hashKey,
hidden,
disableUDP;
String label(AppLocalizations l) => switch (this) {
emptyFallback => l.emptyFallback,
url => l.testUrl,
interval => l.testInterval,
timeout => l.timeout,
maxFailedTimes => l.maxFailedTimes,
lazy => l.testWhenUsed,
expectedStatus => l.expectedStatus,
tolerance => l.tolerance,
defaultSelected => l.defaultSelected,
strategy => l.strategy,
hashKey => l.hashByInUser,
hidden => l.hideFromList,
disableUDP => l.disableUDP,
};
String section(AppLocalizations l) => switch (this) {
url ||
interval ||
timeout ||
maxFailedTimes ||
lazy ||
expectedStatus ||
tolerance => l.healthCheck,
emptyFallback ||
defaultSelected ||
strategy ||
hashKey ||
hidden ||
disableUDP => l.other,
};
bool appliesTo(GroupType type, LoadBalanceStrategy? strategy) =>
switch (this) {
tolerance => type == GroupType.URLTest,
defaultSelected => type == GroupType.Selector,
_GroupOption.strategy => type == GroupType.LoadBalance,
hashKey =>
type == GroupType.LoadBalance &&
strategy != LoadBalanceStrategy.roundRobin,
_ => true,
};
bool isSetIn(ProxyGroup group) => switch (this) {
emptyFallback => group.emptyFallback != null,
url => group.url != null,
interval => group.interval != null,
timeout => group.timeout != null,
maxFailedTimes => group.maxFailedTimes != null,
lazy => group.lazy != null,
expectedStatus => group.expectedStatus != null,
tolerance => group.tolerance != null,
defaultSelected => group.defaultSelected != null,
_GroupOption.strategy => group.strategy != null,
hashKey => group.hashKey != null,
hidden => group.hidden != null,
disableUDP => group.disableUDP != null,
};
ProxyGroup reset(ProxyGroup group) => switch (this) {
url => group.copyWith(url: defaultTestUrl),
interval => group.copyWith(interval: 300),
timeout => group.copyWith(timeout: 5000),
maxFailedTimes => group.copyWith(maxFailedTimes: 5),
lazy => group.copyWith(lazy: true),
expectedStatus => group.copyWith(expectedStatus: ''),
tolerance => group.copyWith(tolerance: 0),
_GroupOption.strategy => group.copyWith(
strategy: LoadBalanceStrategy.consistentHashing,
),
hidden => group.copyWith(hidden: false),
disableUDP => group.copyWith(disableUDP: false),
emptyFallback => group.copyWith(emptyFallback: _defaultEmptyFallback),
defaultSelected => group.copyWith(defaultSelected: ''),
hashKey => group.copyWith(hashKey: ''),
};
ProxyGroup clear(ProxyGroup group) => switch (this) {
emptyFallback => group.copyWith(emptyFallback: null),
url => group.copyWith(url: null),
interval => group.copyWith(interval: null),
timeout => group.copyWith(timeout: null),
maxFailedTimes => group.copyWith(maxFailedTimes: null),
lazy => group.copyWith(lazy: null),
expectedStatus => group.copyWith(expectedStatus: null),
tolerance => group.copyWith(tolerance: null),
defaultSelected => group.copyWith(defaultSelected: null),
_GroupOption.strategy => group.copyWith(strategy: null),
hashKey => group.copyWith(hashKey: null),
hidden => group.copyWith(hidden: null),
disableUDP => group.copyWith(disableUDP: null),
};
}
double get _groupRowExtent => globalState.measure.listTwoLineRowHeight;
/// Keeps the actions on the groups in their own header, off the page bar,
/// which belongs to the whole profile.
class CustomProxyGroupsSection extends ConsumerStatefulWidget {
final int profileId;
const CustomProxyGroupsSection(this.profileId, {super.key});
@override
ConsumerState<CustomProxyGroupsSection> createState() =>
_CustomProxyGroupsSectionState();
}
class _CustomProxyGroupsSectionState
extends ConsumerState<CustomProxyGroupsSection> {
var _selected = <int>{};
var _sorting = false;
void _toggleSorting() {
setState(() {
_sorting = !_sorting;
});
}
void _handleReorder(int oldIndex, int newIndex) {
ref
.read(proxyGroupsProvider(widget.profileId).notifier)
.order(oldIndex, newIndex);
}
void _handleAddOrUpdate({ProxyGroup? proxyGroup}) {
showNestedFormSheet<ProxyGroup>(
context: context,
profileId: widget.profileId,
overrides: [
proxyGroupProvider.overrideWithBuild(
(_, _) =>
proxyGroup ??
const ProxyGroup(id: -1, name: '', type: GroupType.Selector),
),
],
currentOf: (ref) => ref.read(proxyGroupProvider),
formBuilder: (_) => const EditProxyGroupView(),
);
}
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 String message}) async {
final appLocalizations = context.appLocalizations;
final inUse = await ref
.read(profilesActionProvider.notifier)
.groupsInUse(widget.profileId, ids);
if (!mounted) {
return;
}
final res = await dialogs.showMessage(
message: TextSpan(
text: inUse.isEmpty
? message
: appLocalizations.proxyGroupInUse(inUse.join(', ')),
),
destructive: true,
confirmText: appLocalizations.delete,
);
if (res != true || !mounted) {
return;
}
ref.read(proxyGroupsProvider(widget.profileId).notifier).delAll(ids);
setState(() {
_selected = {};
});
}
void _handleDuplicate(Set<int> ids) {
final notifier = ref.read(proxyGroupsProvider(widget.profileId).notifier);
final proxyGroups = notifier.value;
final taken = {
...reservedProxyNames,
...?ref.read(customProfileDataProvider(widget.profileId))?.proxies,
for (final group in proxyGroups) group.name,
};
notifier.insertAfterEach({
for (final proxyGroup in proxyGroups)
if (ids.contains(proxyGroup.id))
proxyGroup.id: proxyGroup.copyWith(
id: snowflake.id,
name: copiedProxyName(proxyGroup.name, taken),
),
});
}
List<CommonPopupMenuItem> _menuItems(ProxyGroup proxyGroup) {
final appLocalizations = context.appLocalizations;
return [
CommonPopupMenuItem(
glyph: AppGlyphs.copy,
label: appLocalizations.copy,
onPressed: () => _handleDuplicate({proxyGroup.id}),
),
CommonPopupMenuItem(
glyph: AppGlyphs.delete,
label: appLocalizations.delete,
danger: true,
onPressed: () => _handleDelete({
proxyGroup.id,
}, message: appLocalizations.confirmDeleteProxyGroup),
),
];
}
List<IconButtonData> _buildActions(Set<int> ids, Set<int> selected) {
final appLocalizations = context.appLocalizations;
if (selected.isNotEmpty) {
return [
IconButtonData(
glyph: AppGlyphs.delete,
tooltip: appLocalizations.delete,
onPressed: () => _handleDelete(
selected,
message: appLocalizations.deleteMultipTip(
appLocalizations.proxyGroup,
),
),
),
IconButtonData(
glyph: AppGlyphs.selectAll,
tooltip: appLocalizations.selectAll,
onPressed: () => _handleSelectAll(ids),
),
];
}
return [
if (_sorting || ids.length > 1)
sortModeAction(context, sorting: _sorting, onPressed: _toggleSorting),
IconButtonData(
glyph: AppGlyphs.addCircle,
tooltip: appLocalizations.add,
onPressed: _handleAddOrUpdate,
),
];
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
final proxyGroups = ref.watch(proxyGroupsProvider(widget.profileId)).value;
final ids = {...?proxyGroups?.map((proxyGroup) => proxyGroup.id)};
final selected = _selected.intersection(ids);
Widget itemAt(int index) {
final proxyGroup = proxyGroups![index];
return SortableItem(
key: ValueKey(proxyGroup.id),
index: index,
child: ContextMenuRegion(
menuItems: selected.isEmpty ? _menuItems(proxyGroup) : null,
child: ItemPositionProvider(
position: ItemPosition.get(index, proxyGroups.length),
child: _ProxyGroupItem(
profileId: widget.profileId,
proxyGroup: proxyGroup,
isEditing: selected.isNotEmpty,
isSelected: selected.contains(proxyGroup.id),
onSelected: () => _handleToggleSelected(proxyGroup.id),
onPressed: () => _handleAddOrUpdate(proxyGroup: proxyGroup),
),
),
),
);
}
return CommonPopScope(
onPop: selected.isEmpty && !_sorting
? null
: (_) {
setState(() {
_selected = {};
_sorting = false;
});
return false;
},
child: SortModeScope(
sorting: _sorting,
child: SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: TonalButtonTheme(
child: ListHeader(
title: appLocalizations.proxyGroup,
actions: _buildActions(ids, selected),
),
),
),
if (proxyGroups != null && proxyGroups.isEmpty)
SliverToBoxAdapter(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 24),
child: NullStatus(label: appLocalizations.proxyGroupEmpty),
),
)
else if (proxyGroups != null)
SliverReorderableList(
itemBuilder: (_, index) => itemAt(index),
itemCount: proxyGroups.length,
itemExtent: _groupRowExtent,
proxyDecorator: (child, index, animation) =>
commonProxyDecorator(itemAt(index), index, animation),
onReorderItem: _handleReorder,
),
],
),
),
);
}
}
class _ProxyGroupItem extends ConsumerWidget {
final int profileId;
final ProxyGroup proxyGroup;
final bool isEditing;
final bool isSelected;
final VoidCallback onSelected;
final VoidCallback onPressed;
const _ProxyGroupItem({
required this.profileId,
required this.proxyGroup,
required this.isEditing,
required this.isSelected,
required this.onSelected,
required this.onPressed,
});
@override
Widget build(BuildContext context, ref) {
final issues = ref
.watch(
customProfileIssuesProvider(profileId).select(
(state) => SelectValue(
state.proxyGroups[proxyGroup.id] ?? const <CustomIssue>[],
),
),
)
.value;
return DecorationListItem(
invalid: issues.isNotEmpty,
isSelected: isSelected,
onPressed: isEditing ? onSelected : onPressed,
contentPadding: const EdgeInsets.only(left: 16),
leading: SizedBox.square(
dimension: 32,
child: IconTheme.merge(
data: const IconThemeData(size: 32),
child: CommonTargetIcon(
src: proxyGroup.icon ?? '',
fallback: GroupMonogram(proxyGroup.name),
),
),
),
title: TooltipText(
text: Text(
proxyGroup.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
subtitle: Text(proxyGroup.type.name),
trailing: Row(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
if (issues.isNotEmpty) CustomIssueButton(issues: issues),
CommonCheckBox(
value: isSelected,
isCircle: true,
onChanged: (_) => onSelected(),
),
],
),
);
}
}
/// A missing member is left to the list to flag, since it usually appears when
/// the profile updates; these the core refuses whatever the profile holds.
bool _blocksSave(CustomIssue issue) => switch (issue) {
EmptyNameIssue() ||
ReservedNameIssue() ||
DuplicateNameIssue() ||
NoProxySourceIssue() ||
GroupLoopIssue() ||
DialerLoopIssue() ||
InvalidEmptyFallbackIssue() => true,
_ => false,
};
List<String> _filtersOf(ProxyGroup group) => [
group.filter ?? '',
group.excludeFilter ?? '',
];
List<CustomIssue> _filterIssues(
AppLocalizations appLocalizations,
List<String> errors,
) => [
for (final (index, title) in [
appLocalizations.filter,
appLocalizations.excludeFilter,
].indexed)
if (errors[index].isNotEmpty)
CustomIssue.invalidFilter(title, errors[index]),
];
Future<bool> _handleSaveProxyGroup(BuildContext context, WidgetRef ref) async {
final appLocalizations = context.appLocalizations;
final proxyGroup = ref.read(proxyGroupProvider);
final profileId = ProfileIdProvider.of(context)!.profileId;
final profileData = ref.read(customProfileDataProvider(profileId));
if (profileData == null) {
return false;
}
final blocking = proxyGroupIssues(
proxyGroup,
profileData,
).where(_blocksSave).toList();
final filterErrors = await checkFilters(ref, _filtersOf(proxyGroup));
if (!context.mounted) {
return false;
}
// The form stays editable while the core checks the filters.
if (!identical(ref.read(proxyGroupProvider), proxyGroup)) {
return _handleSaveProxyGroup(context, ref);
}
if (filterErrors != null) {
blocking.addAll(_filterIssues(appLocalizations, filterErrors));
}
if (blocking.isNotEmpty) {
await showSaveBlocked(context, blocking.getMessage(context));
return false;
}
final ProxyGroup newProxyGroup;
if (proxyGroup.id == -1) {
newProxyGroup = proxyGroup.copyWith(id: snowflake.id);
} else {
newProxyGroup = proxyGroup;
}
final isRepeat = ref
.read(proxyGroupsProvider(profileId).notifier)
.put(newProxyGroup);
if (isRepeat == false) {
await showSaveBlocked(
context,
CustomIssue.duplicateName(newProxyGroup.name).getMessage(context),
);
return false;
}
// The closing form would otherwise take its stored copy for another group
// with its name.
ref.read(proxyGroupProvider.notifier).value = newProxyGroup;
return true;
}
class EditProxyGroupView extends ConsumerStatefulWidget {
const EditProxyGroupView({super.key});
@override
ConsumerState createState() => _EditProxyGroupViewState();
}
class _EditProxyGroupViewState extends ConsumerState<EditProxyGroupView> {
static const _validateDelay = Duration(milliseconds: 400);
late final bool _isNew;
late Set<_GroupOption> _shown;
final _removedFrom = <_GroupOption, ProxyGroup>{};
int _revision = 0;
bool _submitted = false;
bool _saving = false;
Timer? _filterTimer;
int _filterRequest = 0;
List<String> _filterErrors = const ['', ''];
@override
void initState() {
super.initState();
_isNew = ref.read(proxyGroupProvider).id == -1;
_shown = _shownIn(ref.read(proxyGroupProvider));
NestedFormSheet.bindSave(context, _handleSave);
ref.listenManual(
proxyGroupProvider.select((state) => (state.filter, state.excludeFilter)),
(_, _) => _scheduleFilterCheck(),
fireImmediately: true,
);
}
@override
void dispose() {
_filterTimer?.cancel();
super.dispose();
}
void _scheduleFilterCheck() {
_filterTimer?.cancel();
final request = ++_filterRequest;
final filters = _filtersOf(ref.read(proxyGroupProvider));
if (filters.every((filter) => filter.isEmpty)) {
if (_filterErrors.any((error) => error.isNotEmpty)) {
setState(() {
_filterErrors = const ['', ''];
});
}
return;
}
_filterTimer = Timer(_validateDelay, () async {
final errors = await checkFilters(ref, filters);
if (!mounted || request != _filterRequest) {
return;
}
setState(() {
_filterErrors = errors ?? const ['', ''];
});
});
}
Future<void> _showTypeOptions() async {
final value = await Navigator.of(context).push(
PagedSheetRoute(
builder: (context) => SelectionSheet<GroupType>(
title: context.appLocalizations.proxyType,
sections: [SelectionSection(items: GroupType.selectableValues)],
labelBuilder: (item) => item.name,
selectedOf: (ref) =>
ref.watch(proxyGroupProvider.select((state) => state.type)),
onSelected: (item) => Navigator.of(context).pop(item),
),
),
);
if (value == null) {
return;
}
ref
.read(proxyGroupProvider.notifier)
.update((state) => state.copyWith(type: value));
}
Future<void> _showStrategyOptions() async {
final value = await Navigator.of(context).push(
PagedSheetRoute(
builder: (context) => SelectionSheet<LoadBalanceStrategy>(
title: context.appLocalizations.strategy,
sections: const [SelectionSection(items: LoadBalanceStrategy.values)],
labelBuilder: (item) => item.value,
selectedOf: (ref) => ref.watch(
proxyGroupProvider.select(
(state) =>
state.strategy ?? LoadBalanceStrategy.consistentHashing,
),
),
onSelected: (item) => Navigator.of(context).pop(item),
),
),
);
if (value == null) {
return;
}
ref
.read(proxyGroupProvider.notifier)
.update((state) => state.copyWith(strategy: value));
}
Future<void> _showEmptyFallbackOptions() async {
final profileId = ProfileIdProvider.of(context)!.profileId;
final res = await Navigator.of(context).push(
PagedSheetRoute(
builder: (context) => Consumer(
builder: (_, ref, _) {
final proxies = ref.watch(
customProfileDataProvider(
profileId,
).select((data) => data?.namedProxies ?? const <String>{}),
);
return SelectionSheet<String>(
title: context.appLocalizations.emptyFallback,
sections: [
SelectionSection(
label: context.appLocalizations.basicStrategy,
items: reservedProxyNames.toList(),
),
SelectionSection(
label: context.appLocalizations.proxies,
items: proxies.toList(),
),
],
labelBuilder: (item) => item,
selectedOf: (ref) =>
ref.watch(proxyGroupProvider.select(_emptyFallbackOf)),
onSelected: (item) => Navigator.of(context).pop(item),
);
},
),
),
);
if (res == null) {
return;
}
ref
.read(proxyGroupProvider.notifier)
.update((state) => state.copyWith(emptyFallback: res));
}
Future<void> _showDefaultSelectedOptions() async {
final res = await Navigator.of(context).push(
PagedSheetRoute(
builder: (context) => Consumer(
builder: (_, ref, _) {
final appLocalizations = context.appLocalizations;
final proxies = ref.watch(
proxyGroupProvider.select((state) => state.proxies),
);
return SelectionSheet<String>(
title: appLocalizations.defaultSelected,
sections: [
const SelectionSection(items: ['']),
SelectionSection(
label: appLocalizations.proxies,
items: proxies ?? const [],
),
],
labelBuilder: (item) =>
item.isEmpty ? appLocalizations.defaultText : item,
selectedOf: (ref) => ref.watch(
proxyGroupProvider.select(
(state) => state.defaultSelected ?? '',
),
),
onSelected: (item) => Navigator.of(context).pop(item),
);
},
),
),
);
if (res == null) {
return;
}
ref
.read(proxyGroupProvider.notifier)
.update((state) => state.copyWith(defaultSelected: res));
}
Future<void> _showIconPicker(String? icon) async {
final groupName = ref.read(proxyGroupProvider).name;
final value = await Navigator.of(context).push<String>(
PagedSheetRoute(
builder: (context) => IconPickerView(value: icon, groupName: groupName),
),
);
if (value == null) {
return;
}
ref
.read(proxyGroupProvider.notifier)
.update((state) => state.copyWith(icon: value.value));
}
Widget _buildItem({
required String title,
TextStyle? titleStyle,
Widget? leading,
Widget? trailing,
final VoidCallback? onPressed,
bool invalid = false,
}) {
return FormRow(
invalid: invalid,
onPressed: onPressed,
title: title,
titleStyle: titleStyle,
leading: leading,
trailing: trailing,
);
}
Widget _buildNumberItem({
required String title,
required int? value,
required ProxyGroup Function(ProxyGroup state, int? value) apply,
required Widget leading,
String? suffix,
}) {
final appLocalizations = context.appLocalizations;
final field = FormRowTextField(
keyboardType: TextInputType.number,
inputFormatters: TextInputLimits.digitsOnly(TextInputLimits.number),
initialValue: value?.toString(),
onChanged: (value) {
ref
.read(proxyGroupProvider.notifier)
.update((state) => apply(state, int.tryParse(value)));
},
hintText: appLocalizations.optional,
);
return _buildItem(
title: title,
leading: leading,
trailing: suffix == null
? field
: Row(
mainAxisSize: MainAxisSize.min,
spacing: 4,
children: [
Flexible(child: field),
Text(suffix, style: AppTextStyle.subheadline.of(context)),
],
),
);
}
Widget _buildUrlItem(String? url, Widget leading) {
final appLocalizations = context.appLocalizations;
return _buildItem(
title: appLocalizations.testUrl,
leading: leading,
trailing: FormRowTextField(
keyboardType: TextInputType.url,
inputFormatters: TextInputLimits.limit(TextInputLimits.url),
initialValue: url,
onChanged: (value) {
ref
.read(proxyGroupProvider.notifier)
.update(
(state) => state.copyWith(url: value.isEmpty ? null : value),
);
},
hintText: appLocalizations.optional,
),
);
}
Widget _buildPickerItem({
required String title,
required String value,
required VoidCallback onPressed,
required Widget leading,
List<CustomIssue> issues = const [],
}) {
final invalid = issues.isNotEmpty;
return _buildItem(
invalid: invalid,
leading: leading,
title: title,
onPressed: onPressed,
trailing: Row(
spacing: 2,
mainAxisSize: MainAxisSize.min,
children: [
if (invalid) CustomIssueButton(issues: issues),
Flexible(
child: Text(value, maxLines: 1, overflow: TextOverflow.ellipsis),
),
const GlyphIcon(AppGlyphs.chevronForward),
],
),
);
}
Widget _buildHashKeyItem(String? hashKey, Widget leading) {
final value = hashKey == _inUserHashKey;
void handleChangeHashKey() {
ref
.read(proxyGroupProvider.notifier)
.update(
(state) => state.copyWith(hashKey: value ? '' : _inUserHashKey),
);
}
return _buildItem(
title: context.appLocalizations.hashByInUser,
leading: leading,
onPressed: handleChangeHashKey,
trailing: Switch(
value: value,
onChanged: (_) {
handleChangeHashKey();
},
),
);
}
Widget _buildStrategyItem(LoadBalanceStrategy? strategy, Widget leading) {
return _buildPickerItem(
title: context.appLocalizations.strategy,
value: (strategy ?? LoadBalanceStrategy.consistentHashing).value,
onPressed: _showStrategyOptions,
leading: leading,
);
}
Widget _buildExpectedStatusItem(String? expectedStatus, Widget leading) {
final appLocalizations = context.appLocalizations;
return _buildItem(
title: appLocalizations.expectedStatus,
leading: leading,
trailing: FormRowTextField(
initialValue: expectedStatus,
inputFormatters: TextInputLimits.limit(TextInputLimits.status),
onChanged: (value) {
ref
.read(proxyGroupProvider.notifier)
.update(
(state) => state.copyWith(
expectedStatus: value.isEmpty ? null : value,
),
);
},
hintText: appLocalizations.optional,
),
);
}
Widget _buildTypeItem(GroupType type) {
final appLocalizations = context.appLocalizations;
return _buildItem(
title: appLocalizations.proxyType,
onPressed: _showTypeOptions,
trailing: Row(
spacing: 2,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
type.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const GlyphIcon(AppGlyphs.chevronForward),
],
),
);
}
Widget _buildIconItem(String? icon) {
final appLocalizations = context.appLocalizations;
final src = icon?.value;
return _buildItem(
title: appLocalizations.icon,
onPressed: () {
_showIconPicker(src);
},
trailing: Row(
spacing: 2,
mainAxisSize: MainAxisSize.min,
children: [
if (src != null)
SizedBox.square(
dimension: 28,
child: IconTheme.merge(
data: const IconThemeData(size: 28),
child: CommonTargetIcon(src: src),
),
)
else
Text(
appLocalizations.optional,
style: context.listTitleStyle.copyWith(
color: context.colorScheme.onSurfaceVariant,
),
),
const GlyphIcon(AppGlyphs.chevronForward),
],
),
);
}
Widget _buildNameItem(String name, {bool invalid = false}) {
final appLocalizations = context.appLocalizations;
return _buildItem(
invalid: invalid,
title: appLocalizations.name,
trailing: FormRowTextField(
initialValue: name,
keyboardType: TextInputType.name,
inputFormatters: TextInputLimits.policyName(TextInputLimits.groupName),
onChanged: (value) {
ref
.read(proxyGroupProvider.notifier)
.update((state) => state.copyWith(name: value));
},
onFieldSubmitted: (_) {
_handleSave();
},
hintText: appLocalizations.inputProxyGroupName,
),
);
}
Widget _buildHiddenItem(bool? hidden, Widget leading) {
final appLocalizations = context.appLocalizations;
void handleChangeHidden() {
ref
.read(proxyGroupProvider.notifier)
.update((state) => state.copyWith(hidden: !(hidden ?? false)));
}
return _buildItem(
title: appLocalizations.hideFromList,
leading: leading,
onPressed: handleChangeHidden,
trailing: Switch(
value: hidden ?? false,
onChanged: (_) {
handleChangeHidden();
},
),
);
}
Widget _buildLazyItem(bool? lazy, Widget leading) {
final appLocalizations = context.appLocalizations;
// The core defaults lazy to true, so an untouched group already tests lazily.
final value = lazy ?? true;
void handleChangeLazy() {
ref
.read(proxyGroupProvider.notifier)
.update((state) => state.copyWith(lazy: !value));
}
return _buildItem(
title: appLocalizations.testWhenUsed,
leading: leading,
onPressed: handleChangeLazy,
trailing: Switch(
value: value,
onChanged: (_) {
handleChangeLazy();
},
),
);
}
Widget _buildDisableUDPItem(bool? disableUDP, Widget leading) {
final appLocalizations = context.appLocalizations;
void handleChangeDisableUDP() {
ref
.read(proxyGroupProvider.notifier)
.update(
(state) => state.copyWith(disableUDP: !(disableUDP ?? false)),
);
}
return _buildItem(
title: appLocalizations.disableUDP,
leading: leading,
onPressed: handleChangeDisableUDP,
trailing: Switch(
value: disableUDP ?? false,
onChanged: (_) {
handleChangeDisableUDP();
},
),
);
}
Widget _field<S>(
S Function(ProxyGroup state) selector,
Widget Function(S value) builder,
) {
return Consumer(
builder: (_, ref, _) =>
builder(ref.watch(proxyGroupProvider.select(selector))),
);
}
/// A removed option is cleared at once, so while its row collapses it shows
/// the value it had rather than the cleared one.
Widget _optionField<S>(
_GroupOption option,
S Function(ProxyGroup state) selector,
Widget Function(S value) builder,
) {
return Consumer(
builder: (_, ref, _) {
final removedFrom = _shown.contains(option)
? null
: _removedFrom[option];
return builder(
removedFrom != null
? selector(removedFrom)
: ref.watch(proxyGroupProvider.select(selector)),
);
},
);
}
Future<void> _handleDelete(int profileId) async {
final appLocalizations = context.appLocalizations;
final id = ref.read(proxyGroupProvider).id;
final inUse = await ref.read(profilesActionProvider.notifier).groupsInUse(
profileId,
{id},
);
if (!mounted) {
return;
}
final res = await dialogs.showMessage(
message: TextSpan(
text: inUse.isEmpty
? appLocalizations.confirmDeleteProxyGroup
: appLocalizations.proxyGroupInUse(inUse.join(', ')),
),
destructive: true,
confirmText: appLocalizations.delete,
);
if (res == true && mounted) {
ref.read(proxyGroupsProvider(profileId).notifier).delAll([id]);
context.safeNestedPop();
}
}
Future<void> _handleSave() async {
final profileId = ProfileIdProvider.of(context)!.profileId;
if (_saving || ref.read(customProfileDataProvider(profileId)) == null) {
return;
}
_saving = true;
setState(() {
_submitted = true;
});
try {
if (await _handleSaveProxyGroup(context, ref) && mounted) {
context.safeNestedPop();
}
} finally {
_saving = false;
}
}
Future<void> _handleQuickEdit() {
final group = ref.read(proxyGroupProvider);
return showCustomQuickEdit(
context,
title: group.name.isEmpty
? context.appLocalizations.quickEdit
: group.name,
content: group.definitionYaml,
schema: EditorSchema.proxyGroup,
invalidMessage: context.appLocalizations.definitionNotMap,
apply: (content) {
final group = readRelaxed(
content,
EditorSchema.proxyGroup,
ref.read(proxyGroupProvider).withDefinitionYaml,
);
ref.read(proxyGroupProvider.notifier).value = group;
if (mounted) {
setState(() {
_shown = _shownIn(group);
_revision++;
});
}
},
);
}
static Set<_GroupOption> _shownIn(ProxyGroup group) => {
for (final option in _GroupOption.values)
if (option.isSetIn(group)) option,
};
void _handleAddOption(List<_GroupOption> options) {
final appLocalizations = context.appLocalizations;
final sections = <String, List<_GroupOption>>{};
for (final option in options) {
sections
.putIfAbsent(option.section(appLocalizations), () => [])
.add(option);
}
Navigator.of(context).push(
PagedSheetRoute(
builder: (_) => SelectionSheet<_GroupOption>(
title: appLocalizations.addSettingEntry,
sections: [
for (final MapEntry(:key, :value) in sections.entries)
SelectionSection(label: key, items: value),
],
labelBuilder: (option) => option.label(appLocalizations),
selectedOf: (_) => null,
removeOnSelect: true,
onSelected: _handleAddedOption,
),
),
);
}
void _handleAddedOption(_GroupOption option) {
if (!mounted) {
return;
}
ref.read(proxyGroupProvider.notifier).update(option.reset);
setState(() {
_shown = {..._shown, option};
});
}
void _handleRemoveOption(_GroupOption option) {
_removedFrom[option] = ref.read(proxyGroupProvider);
ref.read(proxyGroupProvider.notifier).update(option.clear);
setState(() {
_shown = {..._shown}..remove(option);
});
}
Widget _buildOption(
_GroupOption option, {
required List<CustomIssue> emptyFallbackIssues,
}) {
final appLocalizations = context.appLocalizations;
final leading = EntryButton.remove(
onPressed: () => _handleRemoveOption(option),
);
return switch (option) {
_GroupOption.emptyFallback => _optionField(
option,
_emptyFallbackOf,
(value) => _buildPickerItem(
title: appLocalizations.emptyFallback,
value: value,
leading: leading,
issues: emptyFallbackIssues,
onPressed: _showEmptyFallbackOptions,
),
),
_GroupOption.url => _optionField(
option,
(state) => state.url,
(value) => _buildUrlItem(value, leading),
),
_GroupOption.interval => _optionField(
option,
(state) => state.interval,
(value) => _buildNumberItem(
title: appLocalizations.testInterval,
value: value,
leading: leading,
suffix: 's',
apply: (state, value) => state.copyWith(interval: value),
),
),
_GroupOption.timeout => _optionField(
option,
(state) => state.timeout,
(value) => _buildNumberItem(
title: appLocalizations.timeout,
value: value,
leading: leading,
suffix: 'ms',
apply: (state, value) => state.copyWith(timeout: value),
),
),
_GroupOption.maxFailedTimes => _optionField(
option,
(state) => state.maxFailedTimes,
(value) => _buildNumberItem(
title: appLocalizations.maxFailedTimes,
value: value,
leading: leading,
apply: (state, value) => state.copyWith(maxFailedTimes: value),
),
),
_GroupOption.lazy => _optionField(
option,
(state) => state.lazy,
(value) => _buildLazyItem(value, leading),
),
_GroupOption.expectedStatus => _optionField(
option,
(state) => state.expectedStatus,
(value) => _buildExpectedStatusItem(value, leading),
),
_GroupOption.tolerance => _optionField(
option,
(state) => state.tolerance,
(value) => _buildNumberItem(
title: appLocalizations.tolerance,
value: value,
leading: leading,
suffix: 'ms',
apply: (state, value) => state.copyWith(tolerance: value),
),
),
_GroupOption.defaultSelected => _optionField(
option,
(state) => state.defaultSelected,
(value) => _buildPickerItem(
title: appLocalizations.defaultSelected,
value: value == null || value.isEmpty
? appLocalizations.defaultText
: value,
leading: leading,
onPressed: _showDefaultSelectedOptions,
),
),
_GroupOption.strategy => _optionField(
option,
(state) => state.strategy,
(value) => _buildStrategyItem(value, leading),
),
_GroupOption.hashKey => _optionField(
option,
(state) => state.hashKey,
(value) => _buildHashKeyItem(value, leading),
),
_GroupOption.hidden => _optionField(
option,
(state) => state.hidden,
(value) => _buildHiddenItem(value, leading),
),
_GroupOption.disableUDP => _optionField(
option,
(state) => state.disableUDP,
(value) => _buildDisableUDPItem(value, leading),
),
};
}
Widget _buildOptions({required List<CustomIssue> emptyFallbackIssues}) {
final appLocalizations = context.appLocalizations;
final (type, strategy) = ref.watch(
proxyGroupProvider.select((state) => (state.type, state.strategy)),
);
final applicable = [
for (final option in _GroupOption.values)
if (option.appliesTo(type, strategy)) option,
];
final shown = applicable.where(_shown.contains).toList();
final remaining = [
for (final option in applicable)
if (!_shown.contains(option)) option,
];
void handleAdd() => _handleAddOption(remaining);
return generateAnimatedSection(
title: appLocalizations.options,
items: [
for (final option in shown)
KeyedSubtree(
key: ValueKey(option),
child: _buildOption(
option,
emptyFallbackIssues: emptyFallbackIssues,
),
),
if (remaining.isNotEmpty)
FormRow(
key: const ValueKey(#add),
leading: EntryButton.add(onPressed: handleAdd),
title: appLocalizations.addSettingEntry,
titleStyle: TextStyle(color: context.colorScheme.primary),
onPressed: handleAdd,
),
],
);
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
final profileId = ProfileIdProvider.of(context)!.profileId;
final profileData = ref.watch(customProfileDataProvider(profileId));
final showIncomplete = !_isNew || _submitted;
final issues = [
...ref
.watch(
proxyGroupProvider.select(
(state) => SelectValue(
profileData == null
? const <CustomIssue>[]
: proxyGroupIssues(state, profileData),
),
),
)
.value,
..._filterIssues(appLocalizations, _filterErrors),
].where((issue) => showIncomplete || !issue.isIncomplete).toList();
final nameInvalid = issues.any(
(issue) =>
issue is EmptyNameIssue ||
issue is ReservedNameIssue ||
issue is DuplicateNameIssue,
);
final emptyFallbackIssues = issues
.whereType<InvalidEmptyFallbackIssue>()
.toList();
return CommonScaffold(
iconActions: [
customQuickEditAction(context, onPressed: _handleQuickEdit),
],
confirmationAction: customSaveAction(context, onPressed: _handleSave),
body: CustomScrollView(
key: ValueKey(_revision),
slivers: [
SliverCustomIssuesBanner(issues: issues),
SliverPadding(
padding: const EdgeInsets.symmetric(horizontal: 16),
sliver: SliverList.list(
children: [
generateSection(
title: appLocalizations.general,
items: [
_field(
(state) => state.name,
(value) => _buildNameItem(value, invalid: nameInvalid),
),
_field((state) => state.type, _buildTypeItem),
_field((state) => state.icon, _buildIconItem),
],
),
],
),
),
const SliverPadding(
padding: EdgeInsets.symmetric(horizontal: 16),
sliver: ProxyGroupMembersSliver(),
),
SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
).copyWith(bottom: 20),
sliver: SliverList.list(
children: [
_buildOptions(emptyFallbackIssues: emptyFallbackIssues),
generateSection(
title: appLocalizations.action,
items: [
if (!_isNew)
_buildItem(
title: appLocalizations.delete,
titleStyle: TextStyle(color: context.colorScheme.error),
onPressed: () {
_handleDelete(profileId);
},
),
],
),
],
),
),
],
),
title: _isNew
? appLocalizations.addProxyGroup
: appLocalizations.editProxyGroup,
);
}
}