Files
FlClash/lib/views/profiles/custom/members.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

804 lines
24 KiB
Dart

import 'package:collection/collection.dart';
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/icons/icons.dart';
import 'package:fl_clash/l10n/l10n.dart';
import 'package:fl_clash/models/models.dart' hide FileInfo;
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';
/// `proxies` names a group's proxies, `use` its providers, `use-position` where
/// those sit among the proxies, and `filter` what of the providers' proxies it
/// takes. `exclude-filter` drops whatever matches it, named members too.
enum _MemberKind {
proxy,
provider,
filter,
excludeFilter;
bool get isFilter => this == filter || this == excludeFilter;
_MemberKind get run => this == provider ? proxy : this;
(Color, Color) chipColorsOf(ColorScheme colorScheme) => switch (this) {
proxy || provider => (
colorScheme.secondaryContainer,
colorScheme.onSecondaryContainer,
),
filter => (colorScheme.tertiaryContainer, colorScheme.onTertiaryContainer),
excludeFilter => (colorScheme.errorContainer, colorScheme.onErrorContainer),
};
List<String> partsOf(String name) => isFilter ? filterParts(name) : [name];
bool isValid(WidgetRef ref, int profileId, String name) => switch (this) {
proxy => ref.watch(
customProfileDataProvider(profileId).select(
(state) =>
state == null ||
state.ruleTargets.contains(name) ||
state.proxies.contains(name),
),
),
provider => ref.watch(
customProfileProxyProviderIsValidProvider(profileId, name),
),
filter || excludeFilter => true,
};
String invalidMessage(BuildContext context, String name) => switch (this) {
proxy => context.appLocalizations.invalidProxy(name),
provider => context.appLocalizations.invalidProxyProvider(name),
filter || excludeFilter => '',
};
}
typedef _Member = ({_MemberKind kind, String name});
/// The form state of type [M] whose [kinds] of members are listed.
abstract class _MemberHost<M> {
const _MemberHost();
List<_MemberKind> get kinds;
ProviderListenable<M> get state;
AutoDisposeNotifierMixin<M> notifierOf(WidgetRef ref);
String title(AppLocalizations appLocalizations);
String addLabel(AppLocalizations appLocalizations);
List<String> namesOf(M model, _MemberKind kind);
M withNames(M model, _MemberKind kind, List<String> names);
List<String>? layoutOf(M model);
M withLayout(M model, List<String>? layout);
/// What the picker no longer offers as a [kind].
Set<String> takenIn(M model, _MemberKind kind) => {...namesOf(model, kind)};
Iterable<_MemberKind> get _runs => {for (final kind in kinds) kind.run};
List<_Member> runOf(M model, _MemberKind run) => [
for (final name in namesOf(model, run)) (kind: run, name: name),
];
/// The core reads each run's own order only, so the rows take a run each
/// as [layoutOf] names it, and the rest follow run by run.
List<_Member> membersOf(M model) {
final runs = {
for (final run in _runs) run.name: runOf(model, run).iterator,
};
final members = <_Member>[];
for (final name in layoutOf(model) ?? const <String>[]) {
final run = runs[name];
if (run != null && run.moveNext()) {
members.add(run.current);
}
}
for (final run in runs.values) {
while (run.moveNext()) {
members.add(run.current);
}
}
return members;
}
M withMembers(M model, Iterable<_Member> members) {
var next = model;
for (final kind in kinds) {
final names = [
for (final member in members)
if (member.kind == kind) member.name,
];
if (!const ListEquality<String>().equals(namesOf(model, kind), names)) {
next = withNames(next, kind, names);
}
}
final layout = _layoutOf(members);
if (const ListEquality<String>().equals(layoutOf(model), layout)) {
return next;
}
return withLayout(next, layout);
}
/// Leaves out the trailing rows [membersOf] would put there anyway.
List<String>? _layoutOf(Iterable<_Member> members) {
final order = _runs.toList();
final runs = [for (final member in members) order.indexOf(member.kind.run)];
var end = runs.length;
while (end > 0 && (end == runs.length || runs[end - 1] <= runs[end])) {
end--;
}
return end == 0
? null
: [for (final run in runs.take(end)) order[run].name];
}
}
class _GroupMembers extends _MemberHost<ProxyGroup> {
const _GroupMembers();
@override
List<_MemberKind> get kinds => _MemberKind.values;
@override
ProviderListenable<ProxyGroup> get state => proxyGroupProvider;
@override
AutoDisposeNotifierMixin<ProxyGroup> notifierOf(WidgetRef ref) =>
ref.read(proxyGroupProvider.notifier);
@override
String title(AppLocalizations appLocalizations) => appLocalizations.nodes;
@override
String addLabel(AppLocalizations appLocalizations) =>
appLocalizations.addNodes;
@override
List<String> namesOf(ProxyGroup group, _MemberKind kind) => switch (kind) {
_MemberKind.proxy => group.proxies ?? const [],
_MemberKind.provider => group.use ?? const [],
_MemberKind.filter => filterParts(group.filter),
_MemberKind.excludeFilter => filterParts(group.excludeFilter),
};
@override
ProxyGroup withNames(
ProxyGroup group,
_MemberKind kind,
List<String> names,
) => switch (kind) {
_MemberKind.proxy => group.copyWith(proxies: names.isEmpty ? null : names),
_MemberKind.provider => group.copyWith(use: names.isEmpty ? null : names),
_MemberKind.filter => group.copyWith(filter: joinFilterParts(names)),
_MemberKind.excludeFilter => group.copyWith(
excludeFilter: joinFilterParts(names),
),
};
@override
List<String>? layoutOf(ProxyGroup group) => group.memberLayout;
@override
ProxyGroup withLayout(ProxyGroup group, List<String>? layout) =>
group.copyWith(memberLayout: layout);
@override
Set<String> takenIn(ProxyGroup group, _MemberKind kind) => {
...super.takenIn(group, kind),
if (kind == _MemberKind.proxy) group.name,
};
@override
List<_Member> runOf(ProxyGroup group, _MemberKind run) {
if (run != _MemberKind.proxy) {
return super.runOf(group, run);
}
final proxies = group.proxies ?? const <String>[];
final positions = group.effectiveUsePosition ?? const <String, int>{};
final before = [for (var i = 0; i <= proxies.length; i++) <_Member>[]];
for (final name in group.use ?? const <String>[]) {
before[positions[name] ?? proxies.length].add((
kind: _MemberKind.provider,
name: name,
));
}
return [
for (final (index, providers) in before.indexed) ...[
...providers,
if (index < proxies.length)
(kind: _MemberKind.proxy, name: proxies[index]),
],
];
}
@override
ProxyGroup withMembers(ProxyGroup group, Iterable<_Member> members) {
final next = super.withMembers(group, members);
final count = members
.where((member) => member.kind == _MemberKind.proxy)
.length;
var seen = 0;
final positions = <String, int>{};
for (final member in members) {
if (member.kind == _MemberKind.proxy) {
seen++;
} else if (member.kind == _MemberKind.provider && seen < count) {
positions[member.name] = seen;
}
}
final usePosition = positions.isEmpty ? null : positions;
if (const MapEquality<String, int>().equals(
group.usePosition,
usePosition,
)) {
return next;
}
return next.copyWith(usePosition: usePosition);
}
}
class _ProviderFilters extends _MemberHost<ProxyProviderOptions> {
const _ProviderFilters();
@override
List<_MemberKind> get kinds => const [
_MemberKind.filter,
_MemberKind.excludeFilter,
];
@override
ProviderListenable<ProxyProviderOptions> get state =>
proxyProviderOptionsProvider;
@override
AutoDisposeNotifierMixin<ProxyProviderOptions> notifierOf(WidgetRef ref) =>
ref.read(proxyProviderOptionsProvider.notifier);
@override
String title(AppLocalizations appLocalizations) => appLocalizations.filters;
@override
String addLabel(AppLocalizations appLocalizations) =>
appLocalizations.addFilters;
@override
List<String> namesOf(ProxyProviderOptions options, _MemberKind kind) =>
switch (kind) {
_MemberKind.excludeFilter => filterParts(options.excludeFilter),
_ => filterParts(options.filter),
};
@override
ProxyProviderOptions withNames(
ProxyProviderOptions options,
_MemberKind kind,
List<String> names,
) => switch (kind) {
_MemberKind.excludeFilter => options.copyWith(
excludeFilter: joinFilterParts(names),
),
_ => options.copyWith(filter: joinFilterParts(names)),
};
@override
List<String>? layoutOf(ProxyProviderOptions options) => options.memberLayout;
@override
ProxyProviderOptions withLayout(
ProxyProviderOptions options,
List<String>? layout,
) => options.copyWith(memberLayout: layout);
}
Map<String, String> _providerTypes(
WidgetRef ref,
AppLocalizations appLocalizations,
) {
final profiles = ref.watch(profilesProvider);
return {
for (final profile in profiles)
if (profile.type != ProfileType.custom)
profile.realLabel: profile.type == ProfileType.url
? appLocalizations.url
: appLocalizations.file,
};
}
Map<String, String> _nodeTypes(WidgetRef ref) => {
for (final proxy
in ref.watch(customProxiesProvider).value ?? const <CustomProxy>[])
proxy.name: proxy.type,
};
class ProxyGroupMembersSliver extends StatelessWidget {
const ProxyGroupMembersSliver({super.key});
@override
Widget build(BuildContext context) => const _MembersSliver(_GroupMembers());
}
class ProxyProviderFiltersSliver extends StatelessWidget {
const ProxyProviderFiltersSliver({super.key});
@override
Widget build(BuildContext context) =>
const _MembersSliver(_ProviderFilters());
}
class _MembersSliver<M> extends ConsumerStatefulWidget {
final _MemberHost<M> host;
const _MembersSliver(this.host);
@override
ConsumerState<_MembersSliver<M>> createState() => _MembersSliverState<M>();
}
class _MembersSliverState<M> extends ConsumerState<_MembersSliver<M>>
with TickerProviderStateMixin {
late final _host = widget.host;
late final _transitions = EntryTransitions<_Member>(
vsync: this,
onLeft: _handleLeft,
);
late var _members = _host.membersOf(ref.read(_host.state));
/// [_members] with the removed ones still collapsing.
late var _entries = _members;
@override
void initState() {
super.initState();
ref.listenManual(
_host.state.select((state) => SelectValue(_host.membersOf(state))),
(_, next) => _handleMembers(next.value),
);
}
@override
void dispose() {
_transitions.dispose();
super.dispose();
}
void _handleMembers(List<_Member> members) {
setState(() {
_members = members;
_entries = _transitions.sync(
_entries,
members,
(member) => member,
context.motionDuration(commonDuration),
);
});
}
void _handleLeft(_Member member) {
if (!mounted || _members.contains(member)) {
return;
}
setState(() {
_entries = [
for (final entry in _entries)
if (entry != member) entry,
];
});
}
void _handleToPicker() {
Navigator.of(
context,
).push(PagedSheetRoute(builder: (_) => _MemberPicker(_host)));
}
void _update(List<_Member> Function(List<_Member> members) apply) {
_host
.notifierOf(ref)
.update(
(state) => _host.withMembers(state, apply(_host.membersOf(state))),
);
}
/// [oldIndex] and [newIndex] count the rows still collapsing too.
void _handleReorder(int oldIndex, int newIndex) {
final rest = [..._entries];
final moved = rest.removeAt(oldIndex);
final target = rest
.take(newIndex)
.where((member) => !_transitions.isLeaving(member))
.length;
_update(
(_) => [
for (final member in rest)
if (!_transitions.isLeaving(member)) member,
]..insert(target, moved),
);
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
final profileId = ProfileIdProvider.of(context)!.profileId;
final members = _members;
final hasProxies = _host.kinds.contains(_MemberKind.proxy);
final groupTypes = hasProxies
? ref
.watch(
customProfileDataProvider(profileId).select(
(state) => SelectValue({
for (final group
in state?.proxyGroups ?? const <ProxyGroup>[])
group.name: group.type.value,
}),
),
)
.value
: const <String, String>{};
final nodeTypes = hasProxies ? _nodeTypes(ref) : const <String, String>{};
final presets = {
for (final filter in ref.watch(
appSettingProvider.select((state) => state.filters),
))
filter.regex: filter.label,
};
final count = members.length + 1;
final entries = _entries;
_transitions.place(entries, trailing: 1);
Widget itemAt(int index) {
final member = entries[index];
final name = member.name;
return EntryTransition(
key: ValueKey(member),
animation: _transitions.animationOf(member),
leaving: _transitions.isLeaving(member),
child: ItemPositionProvider(
position: _transitions.positionOf(member),
child: _MemberRow(
member: member,
index: index,
title: member.kind.isFilter ? presets[name] ?? name : name,
subtitle: switch (member.kind) {
_MemberKind.proxy =>
groupTypes[name] ??
nodeTypes[name] ??
(RuleTarget.baseTargets.contains(name)
? appLocalizations.basicStrategy
: null),
_MemberKind.provider => appLocalizations.proxyProviders,
_MemberKind.filter => appLocalizations.filters,
_MemberKind.excludeFilter => appLocalizations.excludeFilter,
},
onRemove: () => _update((state) => state..remove(member)),
),
),
);
}
return SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(
child: ListHeader(title: _host.title(appLocalizations)),
),
SuperSliverReorderableList(
itemBuilder: (_, index) => itemAt(index),
itemCount: entries.length,
proxyDecorator: (child, index, animation) =>
commonProxyDecorator(itemAt(index), index, animation),
onReorderItem: _handleReorder,
),
SliverToBoxAdapter(
child: ItemPositionProvider(
position: ItemPosition.get(members.length, count),
child: FormRow(
leading: EntryButton.add(onPressed: _handleToPicker),
title: _host.addLabel(appLocalizations),
titleStyle: TextStyle(color: context.colorScheme.primary),
onPressed: _handleToPicker,
),
),
),
],
);
}
}
class _MemberRow extends ConsumerWidget {
final _Member member;
final int index;
final String title;
final String? subtitle;
final VoidCallback onRemove;
const _MemberRow({
required this.member,
required this.index,
required this.title,
required this.subtitle,
required this.onRemove,
});
@override
Widget build(BuildContext context, WidgetRef ref) {
final profileId = ProfileIdProvider.of(context)!.profileId;
final isValid = member.kind.isValid(ref, profileId, member.name);
final subtitle = this.subtitle;
final (chipColor, chipForeground) = member.kind.chipColorsOf(
context.colorScheme,
);
return DecorationListItem(
invalid: !isValid,
contentPadding: const EdgeInsets.only(left: 16),
leading: EntryButton.remove(onPressed: onRemove),
title: TooltipText(
text: Text(title, maxLines: 1, overflow: TextOverflow.ellipsis),
),
subtitle: subtitle == null
? null
: Padding(
padding: const EdgeInsets.only(top: 4, bottom: 2),
child: Row(
children: [
Flexible(
child: TonalChip(
label: subtitle,
color: chipColor,
foregroundColor: chipForeground,
),
),
],
),
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (!isValid)
InfoMessageButton(
message: member.kind.invalidMessage(context, member.name),
),
SortHandle(index: index),
],
),
);
}
}
typedef _Entry = ({String name, String title, String? subtitle});
_Entry _named(String name, String? subtitle) =>
(name: name, title: name, subtitle: subtitle);
class _Category {
final String label;
final _MemberKind kind;
final List<_Entry> entries;
const _Category({
required this.label,
required this.kind,
required this.entries,
});
}
class _MemberPicker<M> extends ConsumerStatefulWidget {
final _MemberHost<M> host;
const _MemberPicker(this.host);
@override
ConsumerState<_MemberPicker<M>> createState() => _MemberPickerState<M>();
}
class _MemberPickerState<M> extends ConsumerState<_MemberPicker<M>> {
late final _host = widget.host;
/// Picks go into the form as they are made, since closing the sheet from
/// here saves the form without popping this page first; the list keeps to
/// the form as it was, so a pick stays there to be undone.
late final _origin = ref.read(_host.state);
var _selected = <_Member>[];
var _query = SearchQuery('');
void _handleSearch(String query) {
setState(() {
_query = SearchQuery(query);
});
}
void _handleToggle(_Member member) {
setState(() {
_selected = [
for (final item in _selected)
if (item != member) item,
if (!_selected.contains(member)) member,
];
});
var next = _origin;
for (final kind in _host.kinds) {
final picked = [
for (final member in _selected)
if (member.kind == kind) ...kind.partsOf(member.name),
];
if (picked.isNotEmpty) {
next = _host.withNames(next, kind, [
...{..._host.namesOf(next, kind), ...picked},
]);
}
}
_host.notifierOf(ref).value = next;
}
List<_Category> _categories(AppLocalizations appLocalizations) {
final kinds = _host.kinds;
final filters = ref.watch(
appSettingProvider.select((state) => state.filters),
);
List<_Entry> presetsMissingFrom(_MemberKind kind) {
final value = joinFilterParts(_host.namesOf(_origin, kind));
return [
for (final filter in filters)
if (!filter.isAppliedTo(value))
(name: filter.regex, title: filter.label, subtitle: filter.regex),
];
}
return [
if (kinds.contains(_MemberKind.proxy))
..._proxyCategories(appLocalizations),
if (kinds.contains(_MemberKind.provider))
_providerCategory(appLocalizations),
if (kinds.contains(_MemberKind.filter))
_Category(
label: appLocalizations.filters,
kind: _MemberKind.filter,
entries: presetsMissingFrom(_MemberKind.filter),
),
if (kinds.contains(_MemberKind.excludeFilter))
_Category(
label: appLocalizations.excludeFilter,
kind: _MemberKind.excludeFilter,
entries: presetsMissingFrom(_MemberKind.excludeFilter),
),
];
}
List<_Category> _proxyCategories(AppLocalizations appLocalizations) {
final profileId = ProfileIdProvider.of(context)!.profileId;
final proxies = _host.takenIn(_origin, _MemberKind.proxy);
final groups = ref.watch(
customProfileDataProvider(profileId).select(
(state) => SelectValue(state?.proxyGroups ?? const <ProxyGroup>[]),
),
);
final nodes = _nodeTypes(ref);
return [
_Category(
label: appLocalizations.basicStrategy,
kind: _MemberKind.proxy,
entries: [
for (final target in RuleTarget.baseTargetNames)
if (!proxies.contains(target)) _named(target, null),
],
),
_Category(
label: appLocalizations.localProxies,
kind: _MemberKind.proxy,
entries: [
for (final MapEntry(key: name, value: description) in nodes.entries)
if (!proxies.contains(name)) _named(name, description),
],
),
_Category(
label: appLocalizations.proxyGroup,
kind: _MemberKind.proxy,
entries: [
for (final item in groups.value)
if (!proxies.contains(item.name))
_named(item.name, item.type.value),
],
),
];
}
_Category _providerCategory(AppLocalizations appLocalizations) {
final use = _host.takenIn(_origin, _MemberKind.provider);
final providerNames = ref.watch(
appProviderNamesProvider(ProviderKind.proxy),
);
final providers = _providerTypes(ref, appLocalizations);
return _Category(
label: appLocalizations.proxyProviders,
kind: _MemberKind.provider,
entries: [
for (final name in providerNames)
if (!use.contains(name)) _named(name, providers[name]),
],
);
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
final categories = [
for (final category in _categories(appLocalizations))
_Category(
label: category.label,
kind: category.kind,
entries: category.entries
.whereMatches(_query, (entry) => [entry.title, entry.subtitle])
.toList(),
),
].where((category) => category.entries.isNotEmpty).toList();
final total = categories.fold(
0,
(count, category) => count + category.entries.length,
);
return CommonScaffold(
title: _host.addLabel(appLocalizations),
searchState: _query.isNotEmpty || total >= sheetSearchMinItemCount
? AppBarSearchState(onSearch: _handleSearch)
: null,
body: Builder(
builder: (context) => NullStatusSwitcher(
isEmpty: categories.isEmpty,
isSearching: _query.isNotEmpty,
nullStatus: NullStatus(label: appLocalizations.noData),
child: CustomScrollView(
slivers: [
SliverPadding(
padding: EdgeInsets.fromLTRB(
16,
context.contentTopPadding,
16,
16 + BottomInsetScope.of(context),
),
sliver: SliverMainAxisGroup(
slivers: [
for (final category in categories)
SliverCollapsibleSection(
key: ValueKey(category.label),
label: category.label,
collapsible: _query.isEmpty,
initiallyExpanded: false,
itemCount: category.entries.length,
itemBuilder: (_, index) =>
_buildEntry(category.kind, category.entries[index]),
),
],
),
),
],
),
),
),
);
}
Widget _buildEntry(_MemberKind kind, _Entry entry) {
final member = (kind: kind, name: entry.name);
final isSelected = _selected.contains(member);
final subtitle = entry.subtitle;
return DecorationListItem(
isSelected: isSelected,
onPressed: () => _handleToggle(member),
title: TooltipText(
text: Text(entry.title, maxLines: 1, overflow: TextOverflow.ellipsis),
),
subtitle: switch (subtitle) {
null => null,
_ when kind.isFilter => FilterRegexText(subtitle),
_ => TooltipLabel(subtitle, maxLines: 1),
},
trailing: isSelected ? const GlyphIcon(AppGlyphs.check) : null,
);
}
}