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

701 lines
21 KiB
Dart

import 'dart:async';
import 'dart:typed_data';
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';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/views/config/dns.dart';
import 'package:fl_clash/views/config/ntp.dart';
import 'package:fl_clash/views/config/sniffer.dart';
import 'package:fl_clash/views/config/tun.dart';
import 'package:fl_clash/views/profiles/edit.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'dialers.dart';
import 'groups.dart';
import 'providers.dart';
import 'rules.dart';
void showCustomProfileSheet(BuildContext context, int profileId) {
showSheet<void>(
context: context,
props: nestedPagedSheetProps,
builder: (_) => NestedPagedSheet(
builder: (_) => CustomProfileView(profileId: profileId),
),
);
}
void _push(BuildContext context, Widget view) {
if (isSheetPage(context)) {
Navigator.of(context).push(PagedSheetRoute<void>(builder: (_) => view));
return;
}
BaseNavigator.push(context, view);
}
class CustomProfileView extends ConsumerStatefulWidget {
final int profileId;
/// A new profile is dropped again when it is left as it was created.
final bool isNew;
const CustomProfileView({
super.key,
required this.profileId,
this.isNew = false,
});
@override
ConsumerState<CustomProfileView> createState() => _CustomProfileViewState();
}
class _CustomProfileViewState extends ConsumerState<CustomProfileView>
with RouteSettledMixin<CustomProfileView> {
late SetupAction _setupAction;
late ProfilesAction _profilesAction;
late final Profile? _initialProfile;
late final _overview = _Overview(showsTun: system.isDesktop);
late final _groups = CustomProxyGroupsSection(widget.profileId);
var _discarded = false;
var _discardOnDispose = false;
var _loaded = false;
List<Object?>? _loadedInputs;
var _edited = false;
var _importing = false;
@override
void initState() {
super.initState();
_setupAction = ref.read(setupActionProvider.notifier);
_profilesAction = ref.read(profilesActionProvider.notifier);
_initialProfile = ref.read(profileProvider(widget.profileId));
}
bool get _isUntouched {
if (!routeSettled) {
return true;
}
final profileId = widget.profileId;
final profile = ref.read(profileProvider(profileId));
return profile != null &&
profile.label == _initialProfile?.label &&
profile.overrides == const ProfileOverrides() &&
ref.read(proxyGroupsProvider(profileId)).value?.isEmpty == true &&
ref.read(profileRulesProvider(profileId)).value?.isEmpty == true;
}
List<Object?> get _inputs {
final profileId = widget.profileId;
return [
ref.read(profileProvider(profileId)),
ref.read(customProfileDataProvider(profileId)),
ref.read(profileRulesProvider(profileId)).value,
];
}
@override
void didSettleRoute() => setState(() {});
bool get _isEmpty {
final profileId = widget.profileId;
return ref.read(profileProvider(profileId))?.overrides ==
const ProfileOverrides() &&
ref.read(proxyGroupsProvider(profileId)).value?.isEmpty == true &&
ref.read(profileRulesProvider(profileId)).value?.isEmpty == true &&
ref.read(proxyDialersProvider(profileId)).value?.isEmpty == true;
}
Future<void> _handleImportFromUrl() async {
final url = await dialogs.showUrlInput(
title: context.appLocalizations.importUrl,
);
if (url == null || !mounted) {
return;
}
await _import(() async {
final response = await request.getFileResponseForUrl(url);
return decompressUnlabeled(response.data ?? Uint8List(0));
});
}
Future<void> _handleImportFromFile() async {
final file = await globalState.safeRun(picker.pickerFile);
if (file == null || !mounted) {
return;
}
await _import(file.readBytes);
}
Future<void> _import(Future<Uint8List> Function() readBytes) async {
final appLocalizations = context.appLocalizations;
setState(() {
_importing = true;
});
try {
final config = await globalState.safeRun(
() async => _profilesAction.readCustomImport(
widget.profileId,
await readBytes(),
),
silence: false,
title: appLocalizations.import,
);
if (config == null || !mounted) {
return;
}
if (!_isEmpty) {
final res = await dialogs.showMessage(
message: TextSpan(text: appLocalizations.importConfigReplaceTip),
);
if (res != true || !mounted) {
return;
}
}
await _profilesAction.importCustomProfile(widget.profileId, config);
} finally {
if (mounted) {
setState(() {
_importing = false;
});
}
}
}
/// Done as the pop starts, so the page uncovered on the way out no longer
/// lists the profile.
void _discardIfUntouched() {
if (_discarded || !widget.isNew || !_isUntouched) {
return;
}
_discarded = true;
unawaited(_profilesAction.deleteProfile(widget.profileId));
}
Future<bool> _nameBeforeLeaving(BuildContext context) async {
final profile = ref.read(profileProvider(widget.profileId));
if (profile != null &&
profile.label == _initialProfile?.label &&
!_isUntouched) {
await renameProfile(context, ref, profile, selectAll: true);
}
return true;
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
final profileId = widget.profileId;
final label = ref.watch(
profileProvider(
profileId,
).select((state) => state?.realLabel ?? _initialProfile?.realLabel ?? ''),
);
// Reads nothing until the route settles, since loading mid-transition
// drops frames.
_loaded =
_loaded ||
(routeSettled &&
ref.watch(
customProfileDataProvider(
profileId,
).select((state) => state != null),
) &&
!ref.watch(
profileRulesProvider(
profileId,
).select((state) => state.isLoading),
));
if (_loaded) {
_loadedInputs ??= _inputs;
}
return CommonPopScope(
onPop: widget.isNew ? _nameBeforeLeaving : null,
onPopSuccess: _discardIfUntouched,
child: ProfileIdProvider(
profileId: profileId,
child: CommonScaffold(
title: label,
isLoading: _importing,
actions: [
CommonPopupBox(
targetBuilder: (open) => FilledButton(
onPressed: _loaded && !_importing
? () =>
open(offset: Offset(0, context.isMobileView ? 0 : 20))
: null,
child: Text(appLocalizations.import),
),
popupBuilder: (_) => CommonPopupMenu(
items: [
CommonPopupMenuItem(
glyph: AppGlyphs.cloudDownload,
label: appLocalizations.importUrl,
onPressed: _handleImportFromUrl,
),
CommonPopupMenuItem(
glyph: AppGlyphs.importFile,
label: appLocalizations.importFile,
onPressed: _handleImportFromFile,
),
],
),
),
],
body: NullStatusSwitcher(
isEmpty: !_loaded,
nullStatus: const NullStatus(
illustration: NullStatusIllustration.profile,
),
child: ScrollConfiguration(
behavior: const ShowBarScrollBehavior(),
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: SizedBox(height: context.contentTopPadding),
),
SliverPadding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
).copyWith(bottom: 24),
sliver: SliverMainAxisGroup(
slivers: [
SliverToBoxAdapter(child: _overview),
_groups,
],
),
),
],
),
),
),
),
),
);
}
@override
void deactivate() {
_discardOnDispose = !_discarded && widget.isNew && _isUntouched;
_edited =
_loadedInputs != null &&
!const DeepCollectionEquality().equals(_loadedInputs, _inputs);
super.deactivate();
}
@override
void dispose() {
if (_discardOnDispose) {
final profileId = widget.profileId;
// Providers cannot change while the tree is unmounting.
unawaited(Future(() => _profilesAction.deleteProfile(profileId)));
} else if (!_discarded && _edited) {
final profileId = widget.profileId;
scheduleMicrotask(() {
_profilesAction.touchProfile(profileId);
_setupAction.autoApplyProfile();
});
}
super.dispose();
}
}
class _Overview extends ConsumerWidget {
const _Overview({required this.showsTun});
final bool showsTun;
@override
Widget build(BuildContext context, WidgetRef ref) {
final appLocalizations = context.appLocalizations;
final profileId = ProfileIdProvider.of(context)!.profileId;
final sections = [
(AppGlyphs.rules, appLocalizations.rule, CustomRulesView(profileId)),
(
AppGlyphs.resources,
appLocalizations.providers,
CustomProvidersView(profileId),
),
(AppGlyphs.dns, 'DNS', DnsView.profile(profileId)),
(
AppGlyphs.sliders,
appLocalizations.more,
_MoreSectionsView(profileId, showsTun: showsTun),
),
];
Widget tile(int index, Widget value, {bool invalid = false}) {
final (glyph, label, view) = sections[index];
return _OverviewTile(
glyph: glyph,
label: label,
invalid: invalid,
onPressed: () => _push(context, view),
value: value,
);
}
ref.listen(customProfileDataProvider(profileId), (_, _) {});
final ruleNum = ref.watch(
profileRulesProvider(profileId).select((state) => state.value?.length),
);
final issueCounts = ref.watch(
customProfileIssuesProvider(profileId).select(
(state) => (
proxyGroups: state.proxyGroups.length,
rules: state.rules.length,
dialers: state.dialers.length,
dns: state.dns.length,
ntp: state.ntp.length,
),
),
);
final issueCount =
issueCounts.proxyGroups +
issueCounts.rules +
issueCounts.dialers +
issueCounts.dns +
issueCounts.ntp;
final dialerCount = ref.watch(_dialerCount(profileId));
final providers = referencedProviders(ref, profileId);
final missingProviders = ref.watch(
customProfileDataProvider(profileId).select(
(state) => state != null
? [
for (final name in providers.proxy)
if (!state.proxyProviders.contains(name)) name,
for (final name in providers.rule)
if (!state.ruleProviders.contains(name)) name,
].length
: 0,
),
);
final status = ref.watch(_sectionStatus(profileId));
final moreInEffect = [
dialerCount > 0,
status.ntp,
status.sniffer,
showsTun && status.tunSettings > 0,
].where((inEffect) => inEffect).length;
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
ErrorBanner(
message: issueCount > 0
? appLocalizations.customIssuesSummary(issueCount)
: null,
),
_TileGrid(
children: [
tile(
0,
_CountText(count: ruleNum, issueCount: issueCounts.rules),
invalid: issueCounts.rules > 0,
),
tile(
1,
_CountText(
count: providers.proxy.length + providers.rule.length,
issueCount: missingProviders,
),
invalid: missingProviders > 0,
),
tile(
2,
_StatusText(
_enabledText(appLocalizations, status.dns),
active: status.dns,
),
invalid: issueCounts.dns > 0,
),
tile(
3,
_StatusText(
moreInEffect > 0
? appLocalizations.sectionsInEffect(moreInEffect)
: appLocalizations.defaultText,
active: moreInEffect > 0,
),
invalid: issueCounts.dialers + issueCounts.ntp > 0,
),
],
),
],
);
}
}
typedef _SectionStatus = ({bool dns, bool ntp, bool sniffer, int tunSettings});
ProviderListenable<_SectionStatus> _sectionStatus(int profileId) =>
profileProvider(profileId).select((profile) {
final overrides = profile?.overrides ?? const ProfileOverrides();
return (
dns: overrides.customDnsEnabled,
ntp: overrides.customNtpEnabled,
sniffer: overrides.customSnifferEnabled,
tunSettings: overrides.tunOverrideKeys.length,
);
});
ProviderListenable<int> _dialerCount(int profileId) =>
customProfileDataProvider(profileId).select(
(state) => state == null
? 0
: state.dialers.keys.where(state.namedProxies.contains).length,
);
String _enabledText(AppLocalizations l, bool enabled) =>
enabled ? l.enabled : l.disabled;
class _MoreSectionsView extends ConsumerWidget {
const _MoreSectionsView(this.profileId, {required this.showsTun});
final int profileId;
final bool showsTun;
@override
Widget build(BuildContext context, WidgetRef ref) {
final appLocalizations = context.appLocalizations;
final status = ref.watch(_sectionStatus(profileId));
final dialerCount = ref.watch(_dialerCount(profileId));
final (:hasDialerIssues, :hasNtpIssues) = ref.watch(
customProfileIssuesProvider(profileId).select(
(state) => (
hasDialerIssues: state.dialers.isNotEmpty,
hasNtpIssues: state.ntp.isNotEmpty,
),
),
);
Widget section(Glyph glyph, String title, Widget state, Widget view) {
return ListItem(
leading: GlyphIcon(glyph),
title: Text(title),
subtitle: state,
onTap: () => _push(context, view),
);
}
return CommonScaffold(
title: appLocalizations.more,
body: ListView(
padding: const EdgeInsets.symmetric(
horizontal: 16,
).copyWith(top: context.contentTopPadding, bottom: 16),
children: [
generateSectionV3(
items: [
section(
AppGlyphs.link,
appLocalizations.dialerProxy,
Text(
dialerCount > 0
? appLocalizations.proxiesCount(
dialerCount,
dialerCount.compact,
)
: appLocalizations.none,
style: hasDialerIssues
? TextStyle(color: context.colorScheme.error)
: null,
),
CustomProxyDialersView(profileId),
),
section(
AppGlyphs.clock,
'NTP',
Text(
_enabledText(appLocalizations, status.ntp),
style: hasNtpIssues
? TextStyle(color: context.colorScheme.error)
: null,
),
NtpView(profileId),
),
section(
AppGlyphs.eye,
appLocalizations.sniffer,
Text(_enabledText(appLocalizations, status.sniffer)),
SnifferView(profileId),
),
if (showsTun)
section(
AppGlyphs.vpn,
appLocalizations.tun,
Text(
status.tunSettings > 0
? appLocalizations.settingsCount(status.tunSettings)
: appLocalizations.defaultText,
),
TunView(profileId),
),
],
),
],
),
);
}
}
/// Balances its rows, so four tiles that fit three abreast make two rows of
/// two rather than three and one.
class _TileGrid extends StatelessWidget {
const _TileGrid({required this.children});
final List<Widget> children;
static const _spacing = 8.0;
static const _minTileWidth = 150.0;
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final fit =
((constraints.maxWidth + _spacing) ~/ (_minTileWidth + _spacing))
.clamp(1, children.length);
final rows = (children.length / fit).ceil();
final columns = (children.length / rows).ceil();
return Column(
spacing: _spacing,
children: [
for (var start = 0; start < children.length; start += columns)
Row(
spacing: _spacing,
children: [
for (var index = start; index < start + columns; index++)
Expanded(
child: index < children.length
? children[index]
: const SizedBox.shrink(),
),
],
),
],
);
},
);
}
}
class _OverviewTile extends StatelessWidget {
const _OverviewTile({
required this.glyph,
required this.label,
required this.onPressed,
required this.value,
this.invalid = false,
});
final Glyph glyph;
final String label;
final VoidCallback onPressed;
final Widget value;
final bool invalid;
@override
Widget build(BuildContext context) {
final colorScheme = context.colorScheme;
final textTheme = context.textTheme;
return CommonCard(
type: context.isInBottomSheet
? CommonCardType.filled
: CommonCardType.plain,
radius: AppCorner.lg,
onPressed: onPressed,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
spacing: 12,
children: [
Container(
width: 40,
height: 40,
alignment: Alignment.center,
decoration: ShapeDecoration(
color: invalid
? colorScheme.errorContainer
: colorScheme.secondaryContainer,
shape: AppShape.circle,
),
child: GlyphIcon(
glyph,
size: 20,
color: invalid
? colorScheme.onErrorContainer
: colorScheme.onSecondaryContainer,
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
),
),
DefaultTextStyle.merge(
style: textTheme.titleMedium,
maxLines: 1,
overflow: TextOverflow.ellipsis,
child: value,
),
],
),
),
],
),
),
);
}
}
class _CountText extends StatelessWidget {
const _CountText({required this.count, this.issueCount = 0});
final int? count;
final int issueCount;
@override
Widget build(BuildContext context) {
final count = this.count?.toString() ?? '';
if (issueCount == 0) {
return Text(count);
}
return Text(
'$issueCount / $count',
style: TextStyle(color: context.colorScheme.error),
);
}
}
class _StatusText extends StatelessWidget {
const _StatusText(this.text, {required this.active});
final String text;
final bool active;
@override
Widget build(BuildContext context) {
final colorScheme = context.colorScheme;
return Text(
text,
style: TextStyle(
color: active ? colorScheme.tertiary : colorScheme.onSurfaceVariant,
),
);
}
}