Files
FlClash/lib/features/connection/tracker_info_item.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

296 lines
8.7 KiB
Dart

import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
String _ruleText(TrackerInfo trackerInfo) {
final rule = trackerInfo.rule;
final rulePayload = trackerInfo.rulePayload;
if (rulePayload.isNotEmpty) {
return '$rule($rulePayload)';
}
return rule;
}
String _endpointText(String ip, String port) {
if (ip.isEmpty) {
return '';
}
if (port.isNotEmpty) {
return '$ip:$port';
}
return ip;
}
class TrackerInfoItem extends ConsumerWidget {
final TrackerInfo trackerInfo;
final bool isLive;
final Function(String)? onClickKeyword;
final Widget? action;
final String detailTitle;
const TrackerInfoItem({
super.key,
required this.trackerInfo,
this.isLive = false,
this.onClickKeyword,
this.action,
required this.detailTitle,
});
@override
Widget build(BuildContext context, ref) {
final showIcon = ref.watch(
patchClashConfigProvider.select(
(state) =>
state.findProcessMode == FindProcessMode.always && system.isAndroid,
),
);
return RecordListItem(
onTap: () {
showInSheet<void>(
context,
builder: (_) {
return CommonScaffold(
body: TrackerInfoDetailView(trackerInfo: trackerInfo),
title: detailTitle,
);
},
);
},
header: _buildHeader(context),
body: _TrackerInfoBody(
trackerInfo: trackerInfo,
showIcon: showIcon,
onClickKeyword: onClickKeyword,
),
);
}
Widget _buildHeader(BuildContext context) {
final network = Text(
trackerInfo.metadata.network.toUpperCase(),
style: const TextStyle(fontWeight: FontWeight.w500),
);
if (!isLive) {
return RecordHeader(
trailing: action,
children: [RecordTimestamp(trackerInfo.start.showFull), network],
);
}
final color = context.colorScheme.onSurfaceVariant;
WidgetSpan arrow(Glyph glyph) => WidgetSpan(
alignment: PlaceholderAlignment.middle,
child: GlyphIcon(glyph, size: 12, color: color),
);
return RecordHeader(
trailing: Row(
mainAxisSize: MainAxisSize.min,
spacing: 4,
children: [
Text.rich(
TextSpan(
children: [
arrow(AppGlyphs.arrowUp),
TextSpan(
text: ' ${(trackerInfo.uploadSpeed ?? 0).traffic.show}/s ',
),
arrow(AppGlyphs.arrowDown),
TextSpan(
text: ' ${(trackerInfo.downloadSpeed ?? 0).traffic.show}/s',
),
],
),
),
?action,
],
),
children: [
Text(trackerInfo.start.getLastUpdateTimeDesc(context, compact: true)),
network,
],
);
}
}
class _TrackerInfoBody extends StatelessWidget {
final TrackerInfo trackerInfo;
final bool showIcon;
final Function(String)? onClickKeyword;
const _TrackerInfoBody({
required this.trackerInfo,
required this.showIcon,
this.onClickKeyword,
});
Widget _buildTitle(RecordTextStyles styles) {
final metadata = trackerInfo.metadata;
final title = Text.rich(
TextSpan(
children: [
TextSpan(
text: _endpointText(trackerInfo.title, metadata.destinationPort),
style: styles.primary?.copyWith(fontWeight: FontWeight.w500),
),
if (metadata.host.isNotEmpty && metadata.destinationIP.isNotEmpty)
TextSpan(text: ' ${metadata.destinationIP}', style: styles.muted),
],
),
);
final process = metadata.process;
if (!showIcon || process.isEmpty) {
return title;
}
return Row(
spacing: 8,
children: [
GestureDetector(
onTap: () => onClickKeyword?.call(process),
child: PackageIcon(packageName: process, size: 24),
),
Flexible(child: title),
],
);
}
@override
Widget build(BuildContext context) {
final colorScheme = context.colorScheme;
final styles = RecordTextStyles.of(context);
final metadata = trackerInfo.metadata;
final rule = _ruleText(trackerInfo);
final source = [
trackerInfo.progressText,
_endpointText(metadata.sourceIP, metadata.sourcePort),
].where((text) => text.isNotEmpty).join(' · ');
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
_buildTitle(styles),
Wrap(
spacing: 6,
runSpacing: 4,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
if (rule.isNotEmpty) Text(rule, style: styles.secondary),
for (final (index, chain) in trackerInfo.chains.reversed.indexed)
Row(
mainAxisSize: MainAxisSize.min,
spacing: 6,
children: [
if (index > 0 || rule.isNotEmpty) const RecordArrow(),
Flexible(
child: TonalChip(
label: chain,
color: colorScheme.secondaryContainer,
foregroundColor: colorScheme.onSecondaryContainer,
onPressed: () => onClickKeyword?.call(chain),
),
),
],
),
],
),
if (source.isNotEmpty) Text(source, style: styles.muted),
],
);
}
}
class TrackerInfoDetailView extends StatelessWidget {
final TrackerInfo trackerInfo;
const TrackerInfoDetailView({super.key, required this.trackerInfo});
Widget _buildChains(BuildContext context) {
return DetailRow(
title: context.appLocalizations.proxyChains,
value: Wrap(
spacing: 6,
runSpacing: 4,
alignment: WrapAlignment.end,
crossAxisAlignment: WrapCrossAlignment.center,
children: [
for (final (index, chain) in trackerInfo.chains.reversed.indexed)
Row(
mainAxisSize: MainAxisSize.min,
spacing: 6,
children: [
if (index > 0) const RecordArrow(),
Flexible(child: MetaChip(label: chain)),
],
),
],
),
);
}
List<Widget> _buildRows(List<(String, String)> entries) {
return [
for (final (title, value) in entries)
if (value.isNotEmpty) DetailRow.text(title: title, value: value),
];
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
final metadata = trackerInfo.metadata;
return ListView(
padding: const EdgeInsets.symmetric(
horizontal: 16,
).copyWith(bottom: 20, top: context.contentTopPadding),
children: [
generateSectionV3(
title: appLocalizations.basicInfo,
items: _buildRows([
(appLocalizations.creationTime, trackerInfo.start.showFull),
(appLocalizations.networkType, metadata.network),
(appLocalizations.process, trackerInfo.progressText),
(appLocalizations.rule, _ruleText(trackerInfo)),
(appLocalizations.upload, trackerInfo.upload.traffic.show),
(appLocalizations.download, trackerInfo.download.traffic.show),
]),
),
generateSectionV3(
title: appLocalizations.address,
items: _buildRows([
(appLocalizations.host, metadata.host),
(
appLocalizations.source,
_endpointText(metadata.sourceIP, metadata.sourcePort),
),
(
appLocalizations.destination,
_endpointText(metadata.destinationIP, metadata.destinationPort),
),
(
appLocalizations.destinationGeoIP,
metadata.destinationGeoIP.join(' '),
),
(appLocalizations.destinationIPASN, metadata.destinationIPASN),
(appLocalizations.remoteDestination, metadata.remoteDestination),
]),
),
generateSectionV3(
title: appLocalizations.proxies,
items: [
..._buildRows([
(appLocalizations.specialProxy, metadata.specialProxy),
(appLocalizations.specialRules, metadata.specialRules),
(appLocalizations.dnsMode, metadata.dnsMode?.name ?? ''),
]),
_buildChains(context),
],
),
],
);
}
}