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

621 lines
19 KiB
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/models/clash_config.dart';
import 'package:fl_clash/pages/editor.dart';
import 'package:fl_clash/providers/providers.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 'quick_edit.dart';
class CustomRulesView extends ConsumerStatefulWidget {
final int profileId;
const CustomRulesView(this.profileId, {super.key});
@override
ConsumerState createState() => _CustomRulesViewState();
}
class _CustomRulesViewState extends ConsumerState<CustomRulesView> {
int get _profileId => widget.profileId;
void _handleReorder(int oldIndex, int newIndex) {
ref
.read(profileRulesProvider(_profileId).notifier)
.order(oldIndex, newIndex);
}
void _handleQuickActions() {
showSheet<void>(
context: context,
builder: (_) => RulePresetSheet(
onAdd: ref.read(profileRulesProvider(_profileId).notifier).putAll,
),
);
}
void _handleDelete(Set<int> ruleIds) {
ref.read(profileRulesProvider(_profileId).notifier).delAll(ruleIds);
}
Future<void> _handleConfirmDelete(Rule rule) async {
final appLocalizations = context.appLocalizations;
final res = await dialogs.showMessage(
message: TextSpan(
text: appLocalizations.deleteTip(appLocalizations.rule),
),
destructive: true,
confirmText: appLocalizations.delete,
);
if (res == true && mounted) {
_handleDelete({rule.id});
}
}
void _handleDuplicate(Set<int> ruleIds) {
final notifier = ref.read(profileRulesProvider(_profileId).notifier);
notifier.insertAfterEach({
for (final rule in notifier.value)
if (ruleIds.contains(rule.id)) rule.id: rule.copyWith(id: snowflake.id),
});
}
List<CommonPopupMenuItem> _menuItems(Rule rule) {
final appLocalizations = context.appLocalizations;
return [
CommonPopupMenuItem(
glyph: AppGlyphs.copy,
label: appLocalizations.copy,
onPressed: () => _handleDuplicate({rule.id}),
),
CommonPopupMenuItem(
glyph: AppGlyphs.delete,
label: appLocalizations.delete,
danger: true,
onPressed: () => _handleConfirmDelete(rule),
),
];
}
Future<void> _handleQuickEdit() {
final appLocalizations = context.appLocalizations;
final provider = profileRulesProvider(_profileId);
final previous = ref.read(provider).value;
if (previous == null) {
return Future.value();
}
final notifier = ref.read(provider.notifier);
return showCustomQuickEdit(
context,
title: appLocalizations.rule,
content: encodeRuleList(previous),
schema: EditorSchema.rules,
invalidMessage: appLocalizations.ruleListInvalid,
apply: (content) {
final rules = <Rule>[];
try {
for (final (:line, :rule) in decodeRuleList(
content,
previous: previous,
)) {
if (rule.ruleAction == RuleAction.SUB_RULE) {
continue;
}
final error = _ruleSaveError(context, rule);
if (error != null) {
throw MessageException(
appLocalizations.lineIssueTip(line, error),
);
}
rules.add(rule);
}
} on RuleLineException catch (error) {
throw MessageException(
appLocalizations.lineIssueTip(
error.line,
appLocalizations.ruleTextInvalid,
),
);
}
notifier.setAll(rules);
},
);
}
void _handleAddOrUpdate({Rule? rule}) {
showNestedFormSheet<Rule>(
context: context,
profileId: widget.profileId,
overrides: [
ruleProvider.overrideWithBuild((_, _) => rule ?? Rule.init()),
],
currentOf: (ref) => ref.read(ruleProvider),
formBuilder: (_) => const _AddOrEditRuleView(),
);
}
@override
Widget build(context) {
final appLocalizations = context.appLocalizations;
final profileData = ref.watch(customProfileDataProvider(_profileId));
return ListEditorPage<Rule, int>(
title: appLocalizations.rule,
idOf: (rule) => rule.id,
itemsOf: (ref) {
return ref.watch(profileRulesProvider(_profileId)).value;
},
itemBuilder:
(context, ref, rule, index, isEditing, isSelected, onToggleSelected) {
return RuleItem(
invalidMessageOf: (target) {
final issues = profileData == null
? const <CustomIssue>[]
: customRuleIssues(target, profileData);
return issues.isEmpty ? null : issues.getMessage(context);
},
isEditing: isEditing,
isSelected: isSelected,
rule: rule,
onSelected: onToggleSelected,
onEdit: (rule) {
_handleAddOrUpdate(rule: rule);
},
);
},
onQuickEdit: _handleQuickEdit,
onReorder: _handleReorder,
onAdd: () => _handleAddOrUpdate(),
addMenuItems: [
CommonPopupMenuItem(
glyph: AppGlyphs.rulePresets,
label: appLocalizations.quickActions,
onPressed: _handleQuickActions,
),
],
onDelete: _handleDelete,
menuItemsOf: _menuItems,
searchFieldsOf: (rule) => rule.searchFields,
emptyLabel: appLocalizations.ruleEmpty,
itemExtent: ruleItemHeight,
);
}
}
class _AddOrEditRuleView extends ConsumerStatefulWidget {
const _AddOrEditRuleView();
@override
ConsumerState<_AddOrEditRuleView> createState() => _AddOrEditRuleViewState();
}
class _AddOrEditRuleViewState extends ConsumerState<_AddOrEditRuleView> {
@override
void initState() {
super.initState();
NestedFormSheet.bindSave(context, _handleSave);
}
Widget _buildItem({
required String title,
TextStyle? titleStyle,
Widget? trailing,
bool? invalid,
final VoidCallback? onPressed,
}) {
return FormRow(
invalid: invalid ?? false,
onPressed: onPressed,
title: title,
titleStyle: titleStyle,
trailing: trailing,
);
}
Future<void> _handleSelectedType() async {
final res = await Navigator.of(context).push(
PagedSheetRoute(
builder: (context) => SelectionSheet<RuleAction>(
title: context.appLocalizations.proxyType,
sections: [
SelectionSection(
items: [
for (final action in RuleAction.values)
if (action != RuleAction.SUB_RULE) action,
],
subtitleBuilder: (context, item) => item.getDesc(context),
),
],
labelBuilder: (item) => item.name,
selectedOf: (ref) =>
ref.watch(ruleProvider.select((state) => state.ruleAction)),
onSelected: (item) => Navigator.of(context).pop(item),
),
),
);
if (res == null) {
return;
}
ref
.read(ruleProvider.notifier)
.update((state) => state.copyWith(ruleAction: res));
}
Widget _buildTypeItem(RuleAction action) {
return _buildItem(
title: context.appLocalizations.proxyType,
onPressed: () {
_handleSelectedType();
},
trailing: Row(
spacing: 4,
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: TooltipText(
text: Text(
action.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.listTitleStyle.copyWith(
color: context.colorScheme.onSurfaceVariant,
),
),
),
),
const GlyphIcon(AppGlyphs.chevronForward),
],
),
);
}
Widget _buildContentItem(String? content) {
final appLocalizations = context.appLocalizations;
final payloadError = ref.watch(
ruleProvider.select((state) => state.payloadError),
);
final field = FormRowTextField(
initialValue: content,
keyboardType: TextInputType.name,
inputFormatters: TextInputLimits.limit(TextInputLimits.rule),
onChanged: (value) {
ref
.read(ruleProvider.notifier)
.update((state) => state.copyWith(content: value));
},
hintText: appLocalizations.inputRuleContent,
);
return _buildItem(
invalid: payloadError != null,
title: appLocalizations.content,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (payloadError != null)
InfoMessageButton(message: payloadError.getMessage(context)),
Flexible(child: field),
],
),
);
}
Future<void> _handleSelectedRuleProvider() async {
final res = await Navigator.of(context).push(
PagedSheetRoute(
builder: (context) => Consumer(
builder: (_, ref, _) {
final ruleProviders = ref.watch(
appProviderNamesProvider(ProviderKind.rule),
);
return SelectionSheet<String>(
title: context.appLocalizations.ruleSet,
sections: [SelectionSection(items: ruleProviders.toList())],
labelBuilder: (item) => item,
selectedOf: (ref) =>
ref.watch(ruleProvider.select((state) => state.ruleProvider)),
onSelected: (item) => Navigator.of(context).pop(item),
emptyLabel: context.appLocalizations.nullTip(
context.appLocalizations.ruleProviders,
),
);
},
),
),
);
if (res == null) {
return;
}
ref
.read(ruleProvider.notifier)
.update((state) => state.copyWith(ruleProvider: res));
}
Widget _buildRuleProviderItem(int profileId, String? ruleProvider) {
final appLocalizations = context.appLocalizations;
final invalid =
ruleProvider != null &&
!ref.watch(
customProfileRuleProviderIsValidProvider(profileId, ruleProvider),
);
final foregroundColor = invalid
? context.colorScheme.error
: context.colorScheme.onSurfaceVariant;
return _buildItem(
invalid: invalid,
title: appLocalizations.ruleSet,
onPressed: _handleSelectedRuleProvider,
trailing: Row(
spacing: 4,
mainAxisSize: MainAxisSize.min,
children: [
if (invalid)
InfoMessageButton(
message: appLocalizations.invalidRuleSet(ruleProvider),
),
Flexible(
child: TooltipText(
text: Text(
ruleProvider ?? appLocalizations.selectRuleSet,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.listTitleStyle.copyWith(color: foregroundColor),
),
),
),
GlyphIcon(AppGlyphs.chevronForward, color: foregroundColor),
],
),
);
}
Future<void> _handleSelectedTarget() async {
final res = await Navigator.of(context).push(
PagedSheetRoute(
builder: (context) => Consumer(
builder: (_, ref, _) {
final profileId = ProfileIdProvider.of(context)!.profileId;
final profileData = ref.watch(customProfileDataProvider(profileId));
final groupTypes = {
for (final item
in profileData?.proxyGroups ?? const <ProxyGroup>[])
item.name: item.type.name,
};
final named = profileData?.namedProxies ?? const <String>{};
final proxyTypes = {
for (final item
in ref.watch(customProxiesProvider).value ??
const <CustomProxy>[])
if (named.contains(item.name)) item.name: item.type,
};
return SelectionSheet<String>(
title: context.appLocalizations.splitStrategy,
sections: [
SelectionSection(
label: context.appLocalizations.basicStrategy,
items: [
for (final name in RuleTarget.ruleTargetNames)
if (!groupTypes.containsKey(name)) name,
],
),
SelectionSection(
label: context.appLocalizations.ruleTarget,
items: groupTypes.keys.toList(),
subtitleBuilder: (context, name) => groupTypes[name] ?? '',
),
SelectionSection(
label: context.appLocalizations.localProxies,
items: proxyTypes.keys.toList(),
subtitleBuilder: (context, name) => proxyTypes[name],
),
],
labelBuilder: (item) => item,
selectedOf: (ref) =>
ref.watch(ruleProvider.select((state) => state.ruleTarget)),
onSelected: (item) => Navigator.of(context).pop(item),
);
},
),
),
);
if (res == null) {
return;
}
ref
.read(ruleProvider.notifier)
.update((state) => state.copyWith(ruleTarget: res));
}
Widget _buildTargetItem(int profileId, String? target) {
final appLocalizations = context.appLocalizations;
return Consumer(
builder: (_, ref, _) {
final invalid = !ref.watch(
customProfileTargetIsValidProvider(profileId, target),
);
final foregroundColor = invalid
? context.colorScheme.error
: context.colorScheme.onSurfaceVariant;
return _buildItem(
invalid: invalid,
title: appLocalizations.splitStrategy,
onPressed: _handleSelectedTarget,
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (invalid && target != null)
InfoMessageButton(
message: appLocalizations.invalidPolicy(target),
),
Flexible(
flex: 1,
child: TooltipText(
text: Text(
target ?? appLocalizations.selectSplitStrategy,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: context.listTitleStyle.copyWith(
color: foregroundColor,
),
),
),
),
const SizedBox(width: 4),
GlyphIcon(AppGlyphs.chevronForward, color: foregroundColor),
],
),
);
},
);
}
Widget _buildNoResolveItem(bool noResolve, bool src) {
final appLocalizations = context.appLocalizations;
// The core turns no-resolve on with src, so the switch cannot disagree.
return _buildItem(
title: appLocalizations.noResolveHostname,
trailing: Switch(
value: noResolve || src,
onChanged: src
? null
: (value) {
ref
.read(ruleProvider.notifier)
.update((state) => state.copyWith(noResolve: value));
},
),
);
}
Widget _buildSrcItem(bool src) {
final appLocalizations = context.appLocalizations;
return _buildItem(
title: appLocalizations.matchSourceIp,
trailing: Switch(
value: src,
onChanged: (value) {
ref
.read(ruleProvider.notifier)
.update((state) => state.copyWith(src: value));
},
),
);
}
Future<void> _handleSave() async {
final error = _handleSaveRule(context, ref);
if (error != null) {
await showSaveBlocked(context, error);
return;
}
context.safeNestedPop();
}
Future<void> _handleDelete(int profileId) async {
final appLocalizations = context.appLocalizations;
final res = await dialogs.showMessage(
message: TextSpan(
text: appLocalizations.deleteTip(appLocalizations.rule),
),
destructive: true,
confirmText: appLocalizations.delete,
);
if (res == true && mounted) {
final id = ref.read(ruleProvider).id;
ref.read(profileRulesProvider(profileId).notifier).delAll([id]);
context.safeNestedPop();
}
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
final profileId = ProfileIdProvider.of(context)!.profileId;
final rule = ref.watch(ruleProvider);
return CommonScaffold(
confirmationAction: customSaveAction(context, onPressed: _handleSave),
body: ListView(
shrinkWrap: true,
padding: const EdgeInsets.symmetric(
horizontal: 16,
).copyWith(bottom: 20, top: context.contentTopPadding),
children: [
generateSection(
title: appLocalizations.basicInfo,
items: [
_buildTypeItem(rule.ruleAction),
if (rule.ruleAction != RuleAction.MATCH)
rule.ruleAction == RuleAction.RULE_SET
? _buildRuleProviderItem(profileId, rule.ruleProvider)
: _buildContentItem(rule.content),
_buildTargetItem(profileId, rule.ruleTarget),
],
),
if (rule.ruleAction.hasParams)
generateSection(
title: appLocalizations.additionalParameters,
items: [
_buildNoResolveItem(rule.noResolve, rule.src),
_buildSrcItem(rule.src),
],
),
generateSection(
title: appLocalizations.action,
items: [
if (rule.id != -1)
_buildItem(
title: appLocalizations.delete,
titleStyle: TextStyle(color: context.colorScheme.error),
onPressed: () {
_handleDelete(profileId);
},
),
],
),
],
),
title: rule.id == -1
? appLocalizations.addRule
: appLocalizations.editRule,
);
}
}
String? _handleSaveRule(BuildContext context, WidgetRef ref) {
final rule = ref.read(ruleProvider);
final error = _ruleSaveError(context, rule);
if (error != null) {
return error;
}
final profileId = ProfileIdProvider.of(context)!.profileId;
Rule addedRule = rule;
if (rule.id == -1) {
addedRule = rule.copyWith(id: snowflake.id);
}
ref.read(profileRulesProvider(profileId).notifier).put(addedRule);
return null;
}
String? _ruleSaveError(BuildContext context, Rule rule) {
final appLocalizations = context.appLocalizations;
if (rule.ruleAction == RuleAction.SUB_RULE) {
return appLocalizations.invalidSubRule(rule.subRule ?? '');
}
final payloadError = rule.payloadError;
if (payloadError != null) {
return payloadError.getMessage(context);
}
if (rule.ruleAction != RuleAction.MATCH &&
rule.realContent?.isNotEmpty != true) {
return rule.ruleAction == RuleAction.RULE_SET
? appLocalizations.emptyTip(appLocalizations.ruleSet)
: appLocalizations.contentNotEmpty;
}
if (rule.realTarget?.isNotEmpty != true) {
return appLocalizations.splitStrategyNotEmpty;
}
return null;
}