Files
FlClash/lib/features/connection/tracker_info_table.dart
T
chen08209 e82ebf785b feat(views): show connections, requests and DNS as tables
Changelog: Sortable tables for connections, requests and DNS
2026-10-17 09:54:29 +08:00

250 lines
7.0 KiB
Dart

import 'dart:math';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'tracker_info_item.dart';
const _fullLiveTableWidth = 1200.0;
List<RecordColumn<TrackerInfo>> trackerInfoColumns(
BuildContext context, {
required double width,
required bool isLive,
Widget? Function(TrackerInfo trackerInfo)? actionBuilder,
}) {
final appLocalizations = context.appLocalizations;
final wide = width >= recordWideTableWidth;
// A live table spends its first extra width on the totals.
final detailed = wide && (!isLive || width >= _fullLiveTableWidth);
double totalWidth(String label) => max(88, recordHeaderWidth(context, label));
return [
if (!isLive)
RecordColumn(
TableColumn('time', appLocalizations.time, width: 80),
cell: (_, info) =>
RecordCellText(info.start.showTime.trim(), muted: true),
sortKey: (info) => info.start,
descendingFirst: true,
),
RecordColumn(
TableColumn('process', appLocalizations.process, width: wide ? 160 : 140),
cell: (_, info) => RecordCellText(info.progressText),
sortKey: (info) => info.progressText.toLowerCase(),
),
RecordColumn(
TableColumn('target', appLocalizations.target, flex: wide ? 1.4 : 1.3),
cell: (_, info) => TrackerTargetText(info),
sortKey: (info) => info.title.toLowerCase(),
),
if (detailed)
RecordColumn(
TableColumn(
'protocol',
appLocalizations.protocol,
width: max(56, recordHeaderWidth(context, appLocalizations.protocol)),
),
cell: (_, info) =>
RecordCellText(info.metadata.network.toUpperCase(), muted: true),
sortKey: (info) => info.metadata.network.toLowerCase(),
),
if (wide || !isLive)
RecordColumn(
TableColumn('rule', appLocalizations.rule, flex: 1),
cell: (_, info) => TrackerRuleText(info),
sortKey: (info) => '${info.rule} ${info.rulePayload}'.toLowerCase(),
),
RecordColumn(
TableColumn('exit', appLocalizations.outboundExit, width: 120),
cell: (_, info) => _ExitChip(exit: info.chains.firstOrNull ?? ''),
sortKey: (info) => (info.chains.firstOrNull ?? '').toLowerCase(),
),
if (isLive) ...[
_amountColumn(
'downloadSpeed',
appLocalizations.download,
(info) => info.downloadSpeed ?? 0,
perSecond: true,
),
_amountColumn(
'uploadSpeed',
appLocalizations.upload,
(info) => info.uploadSpeed ?? 0,
perSecond: true,
),
],
if (isLive && wide) ...[
_amountColumn(
'download',
appLocalizations.totalDownload,
(info) => info.download,
width: totalWidth(appLocalizations.totalDownload),
),
_amountColumn(
'upload',
appLocalizations.totalUpload,
(info) => info.upload,
width: totalWidth(appLocalizations.totalUpload),
),
] else if (wide) ...[
_amountColumn(
'download',
appLocalizations.download,
(info) => info.download,
),
_amountColumn('upload', appLocalizations.upload, (info) => info.upload),
] else
_amountColumn(
'traffic',
appLocalizations.traffic,
(info) => info.upload + info.download,
width: 88,
),
if (isLive && detailed)
RecordColumn(
TableColumn(
'duration',
appLocalizations.duration,
width: 80,
end: true,
),
cell: (_, info) => RecordCellText(
trackerDurationText(DateTime.now().difference(info.start)),
muted: true,
),
sortKey: (info) => -info.start.microsecondsSinceEpoch,
descendingFirst: true,
),
if (actionBuilder != null)
RecordColumn(
const TableColumn('action', '', width: 44, resizable: false),
cell: (_, info) => actionBuilder(info) ?? const SizedBox.shrink(),
),
];
}
RecordColumn<TrackerInfo> _amountColumn(
String id,
String label,
int Function(TrackerInfo info) amountOf, {
double width = 100,
bool perSecond = false,
}) {
return RecordColumn(
TableColumn(id, label, width: width, end: true),
cell: (_, info) {
final amount = amountOf(info);
return RecordCellText(
perSecond ? '${amount.traffic.show}/s' : amount.traffic.show,
muted: perSecond && amount == 0,
);
},
sortKey: amountOf,
descendingFirst: true,
);
}
String trackerDurationText(Duration duration) {
final seconds = duration.inSeconds;
if (seconds < 60) {
return '${seconds < 0 ? 0 : seconds} s';
}
final minutes = seconds / 60;
if (minutes < 60) {
return '${minutes < 10 ? minutes.toStringAsFixed(1) : minutes.round()} min';
}
return '${(minutes / 60).toStringAsFixed(1)} h';
}
class TrackerInfoTableRow extends StatelessWidget {
final TrackerInfo trackerInfo;
final RecordTable<TrackerInfo> table;
final bool striped;
final String detailTitle;
const TrackerInfoTableRow({
super.key,
required this.trackerInfo,
required this.table,
required this.striped,
required this.detailTitle,
});
@override
Widget build(BuildContext context) {
return RecordTableRow(
table: table,
striped: striped,
onTap: () => showTrackerInfoDetail(context, trackerInfo, detailTitle),
cells: table.cellsOf(context, trackerInfo),
);
}
}
class _ExitChip extends StatelessWidget {
final String exit;
const _ExitChip({required this.exit});
@override
Widget build(BuildContext context) {
if (exit.isEmpty) {
return const RecordCellText('');
}
final colorScheme = context.colorScheme;
return TonalChip(
label: exit,
color: context.raisedSurfaceLevel.colorOf(colorScheme),
foregroundColor: colorScheme.onSurfaceVariant,
onPressed: () => context.commonScaffoldState?.addKeyword(exit),
);
}
}
class TrackerTargetText extends StatelessWidget {
final TrackerInfo trackerInfo;
const TrackerTargetText(this.trackerInfo, {super.key});
@override
Widget build(BuildContext context) {
final port = trackerInfo.metadata.destinationPort;
return Text.rich(
TextSpan(
children: [
TextSpan(text: trackerInfo.title),
if (port.isNotEmpty)
TextSpan(
text: ':$port',
style: TextStyle(color: context.colorScheme.onSurfaceVariant),
),
],
),
);
}
}
class TrackerRuleText extends StatelessWidget {
final TrackerInfo trackerInfo;
const TrackerRuleText(this.trackerInfo, {super.key});
@override
Widget build(BuildContext context) {
return Text.rich(
TextSpan(
children: [
TextSpan(
text: trackerInfo.rule,
style: TextStyle(color: context.colorScheme.onSurfaceVariant),
),
if (trackerInfo.rulePayload.isNotEmpty)
TextSpan(text: ' ${trackerInfo.rulePayload}'),
],
),
);
}
}