feat(views): show connections, requests and DNS as tables

Changelog: Sortable tables for connections, requests and DNS
This commit is contained in:
chen08209
2026-10-14 13:54:04 +08:00
parent 0f3f2140c6
commit 5c7e081d26
33 changed files with 2357 additions and 245 deletions
+4 -1
View File
@@ -20,7 +20,10 @@ func init() {
}
statistic.DefaultRequestNotify = func(c statistic.Tracker) {
notifyProbeRoute(c)
sendMessage(RequestMessage, c)
if hasEventListener() {
sendMessage(RequestMessage, c)
watchRequestTraffic(c)
}
}
dns.DefaultQueryNotify = func(record dns.QueryRecord) {
if hasEventListener() {
+1 -1
View File
@@ -40,7 +40,7 @@ func classOfMessage(message Message) messageClass {
return stateMessageClass
case LogMessage, RequestMessage:
return bulkMessageClass
case DnsMessage:
case DnsMessage, RequestTrafficMessage:
return yieldingBulkMessageClass
default:
return priorityMessageClass
+9 -8
View File
@@ -55,14 +55,15 @@ func runBatcherUntilDrained(t *testing.T, state, priority, bulk chan Message) *b
func TestClassOfMessageRoutesEachTier(t *testing.T) {
for messageType, want := range map[MessageType]messageClass{
LoadedMessage: stateMessageClass,
GeoUpdateMessage: stateMessageClass,
RouteChangedMessage: stateMessageClass,
DelayMessage: priorityMessageClass,
LogMessage: bulkMessageClass,
RequestMessage: bulkMessageClass,
DnsMessage: yieldingBulkMessageClass,
DialerLoopMessage: priorityMessageClass,
LoadedMessage: stateMessageClass,
GeoUpdateMessage: stateMessageClass,
RouteChangedMessage: stateMessageClass,
DelayMessage: priorityMessageClass,
LogMessage: bulkMessageClass,
RequestMessage: bulkMessageClass,
DnsMessage: yieldingBulkMessageClass,
RequestTrafficMessage: yieldingBulkMessageClass,
DialerLoopMessage: priorityMessageClass,
} {
if got := classOfMessage(Message{Type: messageType}); got != want {
t.Errorf("classOfMessage(%s) = %d, want %d", messageType, got, want)
+3 -2
View File
@@ -76,8 +76,9 @@ const (
LoadedMessage MessageType = "loaded"
GeoUpdateMessage MessageType = "geoUpdate"
// Named after the Dart enum value, which is how CoreEvent.fromJson decodes it.
RouteChangedMessage MessageType = "routeChanged"
DialerLoopMessage MessageType = "dialerLoop"
RouteChangedMessage MessageType = "routeChanged"
DialerLoopMessage MessageType = "dialerLoop"
RequestTrafficMessage MessageType = "requestTraffic"
)
type Message struct {
+105
View File
@@ -0,0 +1,105 @@
package core
import (
"sync"
"time"
"github.com/metacubex/mihomo/tunnel/statistic"
)
// mihomo announces a connection only as it opens, and has no hook for its
// close; the request list learns the traffic that follows from these reports.
const requestTrafficInterval = time.Second
type RequestTraffic struct {
Id string `json:"id"`
Upload int64 `json:"upload"`
Download int64 `json:"download"`
}
type watchedRequest struct {
tracker statistic.Tracker
upload int64
download int64
// The tracker is announced before the manager stores it, so a request
// missing from the manager on its first report may still be opening.
fresh bool
}
type requestTrafficWatch struct {
mu sync.Mutex
requests map[string]*watchedRequest
running bool
}
var currentRequestTraffic = requestTrafficWatch{
requests: map[string]*watchedRequest{},
}
func (w *requestTrafficWatch) add(tracker statistic.Tracker) (start bool) {
info := tracker.Info()
w.mu.Lock()
defer w.mu.Unlock()
w.requests[tracker.ID()] = &watchedRequest{
tracker: tracker,
upload: info.UploadTotal.Load(),
download: info.DownloadTotal.Load(),
fresh: true,
}
start = !w.running
w.running = true
return start
}
func (w *requestTrafficWatch) collect(isOpen func(id string) bool) (changed []RequestTraffic, watching bool) {
w.mu.Lock()
defer w.mu.Unlock()
for id, request := range w.requests {
closed := !request.fresh && !isOpen(id)
request.fresh = false
info := request.tracker.Info()
upload, download := info.UploadTotal.Load(), info.DownloadTotal.Load()
if upload != request.upload || download != request.download {
request.upload, request.download = upload, download
changed = append(changed, RequestTraffic{Id: id, Upload: upload, Download: download})
}
if closed {
delete(w.requests, id)
}
}
w.running = len(w.requests) > 0
return changed, w.running
}
func (w *requestTrafficWatch) clear() {
w.mu.Lock()
defer w.mu.Unlock()
clear(w.requests)
w.running = false
}
func watchRequestTraffic(tracker statistic.Tracker) {
if currentRequestTraffic.add(tracker) {
go runRequestTrafficWatch()
}
}
func runRequestTrafficWatch() {
ticker := time.NewTicker(requestTrafficInterval)
defer ticker.Stop()
for range ticker.C {
if !hasEventListener() {
currentRequestTraffic.clear()
return
}
changed, watching := currentRequestTraffic.collect(func(id string) bool {
return statistic.DefaultManager.Get(id) != nil
})
if len(changed) > 0 {
sendMessage(RequestTrafficMessage, changed)
}
if !watching {
return
}
}
}
+88
View File
@@ -0,0 +1,88 @@
package core
import (
"io"
"net"
"testing"
"github.com/metacubex/mihomo/adapter/outbound"
"github.com/metacubex/mihomo/constant"
"github.com/metacubex/mihomo/tunnel/statistic"
)
type pipeTracker interface {
statistic.Tracker
io.Writer
}
func newPipeTracker(t *testing.T) pipeTracker {
t.Helper()
local, remote := net.Pipe()
go func() { _, _ = io.Copy(io.Discard, remote) }()
direct := outbound.NewDirectWithOption(outbound.DirectOption{Name: "DIRECT"})
tracker := statistic.NewTCPTracker(
outbound.NewConn(local, direct),
statistic.DefaultManager,
&constant.Metadata{},
nil,
0,
0,
false,
)
t.Cleanup(func() {
_ = tracker.Close()
_ = remote.Close()
})
return tracker
}
func TestRequestTrafficReportsChangesUntilTheRequestCloses(t *testing.T) {
tracker := newPipeTracker(t)
watch := requestTrafficWatch{requests: map[string]*watchedRequest{}}
open := true
isOpen := func(string) bool { return open }
if !watch.add(tracker) {
t.Fatal("the first request did not start the watch")
}
if changed, watching := watch.collect(isOpen); len(changed) != 0 || !watching {
t.Fatalf("idle request: changed %v, watching %v", changed, watching)
}
if _, err := tracker.Write([]byte("hello")); err != nil {
t.Fatalf("write: %v", err)
}
changed, _ := watch.collect(isOpen)
if len(changed) != 1 || changed[0].Id != tracker.ID() || changed[0].Upload != 5 {
t.Fatalf("after a write: %v", changed)
}
if changed, _ := watch.collect(isOpen); len(changed) != 0 {
t.Fatalf("unchanged traffic reported again: %v", changed)
}
if _, err := tracker.Write([]byte("bye")); err != nil {
t.Fatalf("write: %v", err)
}
open = false
changed, watching := watch.collect(isOpen)
if len(changed) != 1 || changed[0].Upload != 8 || watching {
t.Fatalf("closed request: changed %v, watching %v", changed, watching)
}
if !watch.add(tracker) {
t.Fatal("a request after the watch stopped did not restart it")
}
}
func TestRequestTrafficKeepsARequestTheManagerHasNotStoredYet(t *testing.T) {
tracker := newPipeTracker(t)
watch := requestTrafficWatch{requests: map[string]*watchedRequest{}}
closed := func(string) bool { return false }
watch.add(tracker)
if _, watching := watch.collect(closed); !watching {
t.Fatal("a request announced before it was stored was dropped on its first report")
}
if _, watching := watch.collect(closed); watching {
t.Fatal("a closed request was kept past its second report")
}
}
+16 -2
View File
@@ -32,6 +32,12 @@ class FixedList<T> {
return FixedList._(maxLength, _list, _revision + 1);
}
FixedList<T> replace(Map<int, T> items) {
items.forEach((index, item) => _list[index] = item);
_snapshot = null;
return FixedList._(maxLength, _list, _revision + 1);
}
List<T> get list => _snapshot ??= List.unmodifiable(_list);
int get length => _list.length;
@@ -54,11 +60,19 @@ class FixedList<T> {
/// Restores the head that [latest] trimmed off [current] so a paused view
/// stays anchored; the result is capped at [limit] from the tail.
List<T> retainTrimmedHead<T>(List<T> current, List<T> latest, int limit) {
List<T> retainTrimmedHead<T>(
List<T> current,
List<T> latest,
int limit, {
Object Function(T item)? keyOf,
}) {
if (current.isEmpty || latest.isEmpty) {
return latest;
}
final index = current.indexWhere((item) => identical(item, latest.first));
final first = latest.first;
final index = keyOf == null
? current.indexWhere((item) => identical(item, first))
: current.indexWhere((item) => keyOf(item) == keyOf(first));
if (index == 0) {
return latest;
}
+11
View File
@@ -28,6 +28,8 @@ abstract mixin class CoreEventListener {
void onRequest(TrackerInfo connection) {}
void onRequestTraffic(Map<String, RequestTraffic> traffic) {}
void onDns(DnsQuery dnsQuery) {}
void onLoaded(String providerName) {}
@@ -63,6 +65,15 @@ class CoreEventManager {
case CoreEventType.request:
listener.onRequest(TrackerInfo.fromJson(event.data));
break;
case CoreEventType.requestTraffic:
listener.onRequestTraffic({
for (final Map item in event.data)
item['id'] as String: (
upload: (item['upload'] as num).toInt(),
download: (item['download'] as num).toInt(),
),
});
break;
case CoreEventType.dns:
listener.onDns(DnsQuery.fromJson(event.data));
break;
+1
View File
@@ -224,6 +224,7 @@ enum CoreEventType {
log,
delay,
request,
requestTraffic,
dns,
loaded,
crash,
+1
View File
@@ -1,3 +1,4 @@
export 'tracker_info_item.dart';
export 'tracker_info_list.dart';
export 'tracker_info_table.dart';
export 'tracker_speed_ranker.dart';
+32 -21
View File
@@ -1,8 +1,8 @@
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/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';
@@ -26,6 +26,22 @@ String _endpointText(String ip, String port) {
return ip;
}
void showTrackerInfoDetail(
BuildContext context,
TrackerInfo trackerInfo,
String title,
) {
showInSheet<void>(
context,
builder: (_) {
return CommonScaffold(
body: TrackerInfoDetailView(trackerInfo: trackerInfo),
title: title,
);
},
);
}
class TrackerInfoItem extends ConsumerWidget {
final TrackerInfo trackerInfo;
final bool isLive;
@@ -51,17 +67,7 @@ class TrackerInfoItem extends ConsumerWidget {
),
);
return RecordListItem(
onTap: () {
showInSheet<void>(
context,
builder: (_) {
return CommonScaffold(
body: TrackerInfoDetailView(trackerInfo: trackerInfo),
title: detailTitle,
);
},
);
},
onTap: () => showTrackerInfoDetail(context, trackerInfo, detailTitle),
header: _buildHeader(context),
body: _TrackerInfoBody(
trackerInfo: trackerInfo,
@@ -74,7 +80,9 @@ class TrackerInfoItem extends ConsumerWidget {
Widget _buildHeader(BuildContext context) {
final network = Text(
trackerInfo.metadata.network.toUpperCase(),
style: const TextStyle(fontWeight: FontWeight.w500),
style: AppTextStyle.caption1
.of(context, emphasized: true)
.copyWith(color: context.colorScheme.onSurfaceVariant),
);
if (!isLive) {
return RecordHeader(
@@ -135,10 +143,13 @@ class _TrackerInfoBody extends StatelessWidget {
children: [
TextSpan(
text: _endpointText(trackerInfo.title, metadata.destinationPort),
style: styles.primary?.copyWith(fontWeight: FontWeight.w500),
style: styles.title,
),
if (metadata.host.isNotEmpty && metadata.destinationIP.isNotEmpty)
TextSpan(text: ' ${metadata.destinationIP}', style: styles.muted),
TextSpan(
text: ' ${metadata.destinationIP}',
style: styles.secondary,
),
],
),
);
@@ -188,8 +199,8 @@ class _TrackerInfoBody extends StatelessWidget {
Flexible(
child: TonalChip(
label: chain,
color: colorScheme.secondaryContainer,
foregroundColor: colorScheme.onSecondaryContainer,
color: context.raisedSurfaceLevel.colorOf(colorScheme),
foregroundColor: colorScheme.onSurfaceVariant,
onPressed: () => onClickKeyword?.call(chain),
),
),
@@ -197,7 +208,7 @@ class _TrackerInfoBody extends StatelessWidget {
),
],
),
if (source.isNotEmpty) Text(source, style: styles.muted),
if (source.isNotEmpty) Text(source, style: styles.secondary),
],
);
}
@@ -247,7 +258,7 @@ class TrackerInfoDetailView extends StatelessWidget {
horizontal: 16,
).copyWith(bottom: 20, top: context.contentTopPadding),
children: [
generateSectionV3(
generateSection(
title: appLocalizations.basicInfo,
items: _buildRows([
(appLocalizations.creationTime, trackerInfo.start.showFull),
@@ -258,7 +269,7 @@ class TrackerInfoDetailView extends StatelessWidget {
(appLocalizations.download, trackerInfo.download.traffic.show),
]),
),
generateSectionV3(
generateSection(
title: appLocalizations.address,
items: _buildRows([
(appLocalizations.host, metadata.host),
@@ -278,7 +289,7 @@ class TrackerInfoDetailView extends StatelessWidget {
(appLocalizations.remoteDestination, metadata.remoteDestination),
]),
),
generateSectionV3(
generateSection(
title: appLocalizations.proxies,
items: [
..._buildRows([
+33 -3
View File
@@ -5,6 +5,7 @@ import 'package:material_ui/material_ui.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
import 'tracker_info_item.dart';
import 'tracker_info_table.dart';
class TrackerInfoListController extends ValueNotifier<TrackerInfosState> {
TrackerInfoListController() : super(const TrackerInfosState());
@@ -28,6 +29,7 @@ class TrackerInfoListController extends ValueNotifier<TrackerInfosState> {
value.trackerInfos,
trackerInfos,
pausedMaxRequestsLength,
keyOf: (trackerInfo) => trackerInfo.id,
),
);
}
@@ -44,6 +46,7 @@ class TrackerInfoListController extends ValueNotifier<TrackerInfosState> {
class TrackerInfoList extends StatelessWidget {
final List<TrackerInfo> trackerInfos;
final String detailTitle;
final RecordTable<TrackerInfo>? table;
final ScrollController? controller;
final bool reverse;
final bool shrinkWrap;
@@ -55,6 +58,7 @@ class TrackerInfoList extends StatelessWidget {
super.key,
required this.trackerInfos,
required this.detailTitle,
this.table,
this.controller,
this.reverse = false,
this.shrinkWrap = false,
@@ -72,13 +76,17 @@ class TrackerInfoList extends StatelessWidget {
controller: controller,
padding: padding,
itemCount: trackerInfos.length,
extentEstimation: zeroDividerExtentEstimation,
separatorBuilder: (_, _) => const Divider(height: 0),
extentEstimation: table?.estimateExtent ?? recordListExtentEstimation,
separatorBuilder: (_, _) => table == null
? const SizedBox(height: recordListGap)
: const SizedBox.shrink(),
itemBuilder: (_, index) => _buildTrackerInfoItem(
context,
trackerInfos[index],
isLive: false,
detailTitle: detailTitle,
table: table,
striped: index.isOdd,
actionBuilder: actionBuilder,
),
);
@@ -90,6 +98,7 @@ class TrackerInfoList extends StatelessWidget {
class TrackerInfoAnimatedList extends StatelessWidget {
final List<TrackerInfo> trackerInfos;
final String detailTitle;
final RecordTable<TrackerInfo>? table;
final ScrollController? controller;
final EdgeInsetsGeometry? padding;
final Widget? Function(TrackerInfo trackerInfo)? actionBuilder;
@@ -98,6 +107,7 @@ class TrackerInfoAnimatedList extends StatelessWidget {
super.key,
required this.trackerInfos,
required this.detailTitle,
this.table,
this.controller,
this.padding,
this.actionBuilder,
@@ -105,17 +115,26 @@ class TrackerInfoAnimatedList extends StatelessWidget {
@override
Widget build(BuildContext context) {
final table = this.table;
final positions = table == null
? const <String, int>{}
: {
for (final (index, trackerInfo) in trackerInfos.indexed)
trackerInfo.id: index,
};
return KeyedAnimatedList<TrackerInfo>(
controller: controller,
padding: padding,
items: trackerInfos,
keyOf: (trackerInfo) => trackerInfo.id,
separator: const Divider(height: 0),
separator: table == null ? const SizedBox(height: recordListGap) : null,
itemBuilder: (context, trackerInfo) => _buildTrackerInfoItem(
context,
trackerInfo,
isLive: true,
detailTitle: detailTitle,
table: table,
striped: positions[trackerInfo.id]?.isOdd ?? false,
actionBuilder: actionBuilder,
),
);
@@ -127,8 +146,19 @@ Widget _buildTrackerInfoItem(
TrackerInfo trackerInfo, {
required bool isLive,
required String detailTitle,
required RecordTable<TrackerInfo>? table,
required bool striped,
required Widget? Function(TrackerInfo trackerInfo)? actionBuilder,
}) {
if (table != null) {
return TrackerInfoTableRow(
key: Key(trackerInfo.id),
trackerInfo: trackerInfo,
table: table,
striped: striped,
detailTitle: detailTitle,
);
}
return TrackerInfoItem(
key: Key(trackerInfo.id),
trackerInfo: trackerInfo,
@@ -0,0 +1,229 @@
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';
List<RecordColumn<TrackerInfo>> trackerInfoColumns(
BuildContext context, {
required bool wide,
required bool isLive,
Widget? Function(TrackerInfo trackerInfo)? actionBuilder,
}) {
final appLocalizations = context.appLocalizations;
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 (wide)
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,
),
] 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 && wide)
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}'),
],
),
);
}
}
+6
View File
@@ -134,6 +134,12 @@ class _CoreContainerState extends ConsumerState<CoreManager>
super.onRequest(trackerInfo);
}
@override
void onRequestTraffic(Map<String, RequestTraffic> traffic) {
ref.read(requestsProvider.notifier).updateTraffic(traffic);
super.onRequestTraffic(traffic);
}
@override
void onDns(DnsQuery dnsQuery) {
ref.read(dnsQueriesProvider.notifier).addQuery(dnsQuery);
+2
View File
@@ -125,6 +125,8 @@ abstract class TrackerInfo with _$TrackerInfo {
_$TrackerInfoFromJson(json);
}
typedef RequestTraffic = ({int upload, int download});
extension TrackerInfoExt on TrackerInfo {
String get title {
final host = metadata.host;
+1
View File
@@ -237,6 +237,7 @@ const _$CoreEventTypeEnumMap = {
CoreEventType.log: 'log',
CoreEventType.delay: 'delay',
CoreEventType.request: 'request',
CoreEventType.requestTraffic: 'requestTraffic',
CoreEventType.dns: 'dns',
CoreEventType.loaded: 'loaded',
CoreEventType.crash: 'crash',
+25
View File
@@ -62,6 +62,31 @@ class Requests extends _$Requests with AutoDisposeNotifierMixin {
}
this.value = state.append(value);
}
void updateTraffic(Map<String, RequestTraffic> traffic) {
if (!ref.mounted) {
return;
}
final pending = Map.of(traffic);
final updated = <int, TrackerInfo>{};
for (
var index = state.length - 1;
index >= 0 && pending.isNotEmpty;
index--
) {
final request = state[index];
final item = pending.remove(request.id);
if (item != null) {
updated[index] = request.copyWith(
upload: item.upload,
download: item.download,
);
}
}
if (updated.isNotEmpty) {
value = state.replace(updated);
}
}
}
@Riverpod(keepAlive: true)
+243
View File
@@ -0,0 +1,243 @@
import 'package:fl_clash/ui/shape.dart';
import 'package:material_ui/material_ui.dart';
const tableColumnMinWidth = 48.0;
const _resizeHandleWidth = 12.0;
class TableColumn {
const TableColumn(
this.id,
this.label, {
this.width,
this.flex = 0,
this.end = false,
this.resizable = true,
}) : assert((width == null) != (flex == 0));
final String id;
final String label;
final double? width;
final double flex;
final bool end;
final bool resizable;
}
List<double> tableColumnWidths(
List<TableColumn> columns,
double width, [
Map<String, double> overrides = const {},
]) {
final sized = [
for (final column in columns) overrides[column.id] ?? column.width,
];
var fixed = 0.0;
var flex = 0.0;
var flexCount = 0;
for (final (index, column) in columns.indexed) {
final size = sized[index];
if (size == null) {
flex += column.flex;
flexCount++;
} else {
fixed += size;
}
}
final free = width - fixed;
if (overrides.isEmpty ||
(flexCount > 0 && free >= flexCount * tableColumnMinWidth)) {
final share = free.clamp(0.0, double.infinity);
return [
for (final (index, column) in columns.indexed)
sized[index] ?? share * column.flex / flex,
];
}
// The window has shrunk below the dragged widths, or they left no column to
// take up the slack: everything scales to fit.
final natural = [for (final size in sized) size ?? tableColumnMinWidth];
final total = natural.fold<double>(0, (sum, size) => sum + size);
return [for (final size in natural) size * width / total];
}
Map<String, double> resizedTableColumns(
List<TableColumn> columns,
List<double> widths,
Map<String, double> overrides,
int boundary,
double delta,
) {
final resized = [...widths];
if (delta > 0) {
var rest = delta;
for (var index = boundary + 1; index < columns.length; index++) {
if (rest <= 0) {
break;
}
if (!columns[index].resizable) {
continue;
}
final taken = rest.clamp(
0.0,
(resized[index] - tableColumnMinWidth).clamp(0.0, double.infinity),
);
resized[index] -= taken;
rest -= taken;
}
resized[boundary] += delta - rest;
} else {
final given = (-delta).clamp(
0.0,
(resized[boundary] - tableColumnMinWidth).clamp(0.0, double.infinity),
);
resized[boundary] -= given;
resized[boundary + 1] += given;
}
return {
...overrides,
for (final (index, column) in columns.indexed)
if (resized[index] != widths[index]) column.id: resized[index],
};
}
class TableCells extends StatelessWidget {
const TableCells({
super.key,
required this.columns,
required this.widths,
required this.height,
required this.inset,
required this.cells,
});
final List<TableColumn> columns;
final List<double> widths;
final double height;
final double inset;
final List<Widget> cells;
@override
Widget build(BuildContext context) {
return SizedBox(
height: height,
child: Row(
children: [
for (final (index, cell) in cells.indexed)
SizedBox(
width: widths[index],
child: Padding(
padding: EdgeInsets.symmetric(horizontal: inset),
child: Align(
alignment: columns[index].end
? AlignmentDirectional.centerEnd
: AlignmentDirectional.centerStart,
child: cell,
),
),
),
],
),
);
}
}
class TableColumnResizer extends StatelessWidget {
const TableColumnResizer({
super.key,
required this.columns,
required this.widths,
required this.onResize,
required this.onResizeEnd,
required this.onReset,
required this.child,
});
final List<TableColumn> columns;
final List<double> widths;
final void Function(int boundary, double delta) onResize;
final VoidCallback onResizeEnd;
final VoidCallback onReset;
final Widget child;
@override
Widget build(BuildContext context) {
final direction = Directionality.of(context) == TextDirection.rtl ? -1 : 1;
final handles = <Widget>[];
var edge = 0.0;
for (var boundary = 0; boundary < columns.length - 1; boundary++) {
edge += widths[boundary];
if (!columns[boundary].resizable || !columns[boundary + 1].resizable) {
continue;
}
handles.add(
PositionedDirectional(
start: edge - _resizeHandleWidth / 2,
top: 0,
bottom: 0,
width: _resizeHandleWidth,
child: _ResizeHandle(
onDrag: (delta) => onResize(boundary, delta * direction),
onDragEnd: onResizeEnd,
onReset: onReset,
),
),
);
}
return Stack(children: [child, ...handles]);
}
}
class _ResizeHandle extends StatefulWidget {
const _ResizeHandle({
required this.onDrag,
required this.onDragEnd,
required this.onReset,
});
final ValueChanged<double> onDrag;
final VoidCallback onDragEnd;
final VoidCallback onReset;
@override
State<_ResizeHandle> createState() => _ResizeHandleState();
}
class _ResizeHandleState extends State<_ResizeHandle> {
var _hovered = false;
var _dragging = false;
void _endDrag() {
setState(() => _dragging = false);
widget.onDragEnd();
}
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.resizeColumn,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragStart: (_) => setState(() => _dragging = true),
onHorizontalDragUpdate: (details) => widget.onDrag(details.delta.dx),
onHorizontalDragEnd: (_) => _endDrag(),
onHorizontalDragCancel: _endDrag,
onDoubleTap: widget.onReset,
child: Center(
child: Visibility.maintain(
visible: _hovered || _dragging,
child: SizedBox(
width: 2,
height: 16,
child: DecoratedBox(
decoration: ShapeDecoration(
color: Theme.of(context).colorScheme.outline,
shape: AppShape.full,
),
),
),
),
),
),
);
}
}
+29 -14
View File
@@ -5,9 +5,9 @@ import 'package:fl_clash/core/controller.dart';
import 'package:fl_clash/core/method.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/features.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/models/models.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';
@@ -127,6 +127,14 @@ class _ConnectionsViewState extends ConsumerState<ConnectionsView>
valueListenable: _listController,
builder: (context, state, _) {
final connections = state.list;
Widget buildAction(TrackerInfo trackerInfo) {
return _BlockConnectionButton(
onPressed: () {
_handleBlockConnection(trackerInfo.id);
},
);
}
return NullStatusSwitcher(
isEmpty: connections.isEmpty,
isSearching: state.isSearching,
@@ -135,20 +143,27 @@ class _ConnectionsViewState extends ConsumerState<ConnectionsView>
label: appLocalizations.nullTip(appLocalizations.connections),
illustration: NullStatusIllustration.connections,
),
child: TrackerInfoAnimatedList(
controller: _scrollController,
padding: EdgeInsets.only(
top: context.contentTopPadding,
bottom: 16 + BottomInsetScope.of(context),
child: RecordTableFrame<TrackerInfo>(
tableId: 'connections',
items: connections,
columnsOf: (wide) => trackerInfoColumns(
context,
wide: wide,
isLive: true,
actionBuilder: buildAction,
),
trackerInfos: connections,
detailTitle: appLocalizations.details(
appLocalizations.connection,
),
actionBuilder: (trackerInfo) => _BlockConnectionButton(
onPressed: () {
_handleBlockConnection(trackerInfo.id);
},
builder: (context, table, connections) => TrackerInfoAnimatedList(
controller: _scrollController,
padding: EdgeInsets.only(
top: context.contentTopPadding,
bottom: 16 + BottomInsetScope.of(context),
),
trackerInfos: connections,
table: table,
detailTitle: appLocalizations.details(
appLocalizations.connection,
),
actionBuilder: buildAction,
),
),
);
+36 -28
View File
@@ -77,37 +77,45 @@ class _RequestsViewState extends ConsumerState<RequestsView>
label: appLocalizations.nullTip(appLocalizations.requests),
illustration: NullStatusIllustration.requests,
),
child: Align(
alignment: Alignment.topCenter,
child: FloatingScrollbar(
controller: _scrollController,
hintBuilder: (fraction) {
final index = (fraction * (requests.length - 1)).round();
return requests[index].start.showFull;
},
child: ScrollToEndBox(
child: RecordTableFrame<TrackerInfo>(
tableId: 'requests',
items: requests,
reverse: true,
columnsOf: (wide) =>
trackerInfoColumns(context, wide: wide, isLive: false),
builder: (context, table, requests) => Align(
alignment: Alignment.topCenter,
child: FloatingScrollbar(
controller: _scrollController,
enable: state.autoScrollToEnd,
onCancelToEnd: () {
_listController.setAutoScrollToEnd(false);
hintBuilder: (fraction) {
final index = (fraction * (requests.length - 1)).round();
return requests[index].start.showFull;
},
onResumeToEnd: () {
_listController.resumeAutoScrollToEnd(
ref.read(requestsProvider).list,
);
},
child: TrackerInfoList(
reverse: true,
shrinkWrap: true,
physics: _physics,
child: ScrollToEndBox(
controller: _scrollController,
padding: EdgeInsets.only(
top: context.contentTopPadding,
bottom: 16 + BottomInsetScope.of(context),
),
trackerInfos: requests,
detailTitle: appLocalizations.details(
appLocalizations.request,
enable: state.autoScrollToEnd,
onCancelToEnd: () {
_listController.setAutoScrollToEnd(false);
},
onResumeToEnd: () {
_listController.resumeAutoScrollToEnd(
ref.read(requestsProvider).list,
);
},
child: TrackerInfoList(
reverse: true,
shrinkWrap: true,
physics: _physics,
controller: _scrollController,
padding: EdgeInsets.only(
top: context.contentTopPadding,
bottom: 16 + BottomInsetScope.of(context),
),
trackerInfos: requests,
table: table,
detailTitle: appLocalizations.details(
appLocalizations.request,
),
),
),
),
+192 -63
View File
@@ -113,45 +113,61 @@ class _DnsQueriesViewState extends ConsumerState<DnsQueriesView>
label: appLocalizations.nullTip(appLocalizations.dnsQueries),
illustration: NullStatusIllustration.dns,
),
child: Align(
alignment: Alignment.topCenter,
child: FloatingScrollbar(
controller: _scrollController,
hintBuilder: (fraction) {
final index = (fraction * (dnsQueries.length - 1)).round();
return dnsQueries[index].time.showFull;
},
child: ScrollToEndBox(
child: RecordTableFrame<DnsQuery>(
tableId: 'dns',
items: dnsQueries,
reverse: true,
columnsOf: (wide) => _dnsQueryColumns(context, wide: wide),
builder: (context, table, dnsQueries) => Align(
alignment: Alignment.topCenter,
child: FloatingScrollbar(
controller: _scrollController,
enable: state.autoScrollToEnd,
onCancelToEnd: () {
_listController.setAutoScrollToEnd(false);
hintBuilder: (fraction) {
final index = (fraction * (dnsQueries.length - 1)).round();
return dnsQueries[index].time.showFull;
},
onResumeToEnd: () {
_listController.resumeAutoScrollToEnd(
ref.read(dnsQueriesProvider).list,
);
},
child: SuperListView.separated(
reverse: true,
shrinkWrap: true,
physics: _physics,
child: ScrollToEndBox(
controller: _scrollController,
padding: EdgeInsets.only(
top: context.contentTopPadding,
bottom: 16 + BottomInsetScope.of(context),
),
itemCount: dnsQueries.length,
extentEstimation: zeroDividerExtentEstimation,
separatorBuilder: (_, _) => const Divider(height: 0),
itemBuilder: (_, index) {
return DnsQueryItem(
dnsQuery: dnsQueries[index],
onClickKeyword: (value) {
context.commonScaffoldState?.addKeyword(value);
},
enable: state.autoScrollToEnd,
onCancelToEnd: () {
_listController.setAutoScrollToEnd(false);
},
onResumeToEnd: () {
_listController.resumeAutoScrollToEnd(
ref.read(dnsQueriesProvider).list,
);
},
child: SuperListView.separated(
reverse: true,
shrinkWrap: true,
physics: _physics,
controller: _scrollController,
padding: EdgeInsets.only(
top: context.contentTopPadding,
bottom: 16 + BottomInsetScope.of(context),
),
itemCount: dnsQueries.length,
extentEstimation:
table?.estimateExtent ?? recordListExtentEstimation,
separatorBuilder: (_, _) => table == null
? const SizedBox(height: recordListGap)
: const SizedBox.shrink(),
itemBuilder: (_, index) {
if (table != null) {
return _DnsQueryTableRow(
dnsQuery: dnsQueries[index],
table: table,
striped: index.isOdd,
);
}
return DnsQueryItem(
dnsQuery: dnsQueries[index],
onClickKeyword: (value) {
context.commonScaffoldState?.addKeyword(value);
},
);
},
),
),
),
),
@@ -163,34 +179,150 @@ class _DnsQueriesViewState extends ConsumerState<DnsQueriesView>
}
}
void _showDnsQueryDetail(BuildContext context, DnsQuery dnsQuery) {
showInSheet<void>(
context,
builder: (_) {
return CommonScaffold(
body: DnsQueryDetailView(dnsQuery: dnsQuery),
title: context.appLocalizations.details('DNS'),
);
},
);
}
List<RecordColumn<DnsQuery>> _dnsQueryColumns(
BuildContext context, {
required bool wide,
}) {
final appLocalizations = context.appLocalizations;
return [
RecordColumn(
TableColumn('time', appLocalizations.time, width: 80),
cell: (_, query) =>
RecordCellText(query.time.showTime.trim(), muted: true),
sortKey: (query) => query.time,
descendingFirst: true,
),
RecordColumn(
TableColumn('domain', appLocalizations.domain, flex: 1.3),
cell: (_, query) => RecordCellText(query.domain),
sortKey: (query) => query.domain.toLowerCase(),
),
RecordColumn(
TableColumn('type', appLocalizations.ipType, width: 72),
cell: (_, query) => _DnsKeyword(query.type),
sortKey: (query) => query.type,
),
RecordColumn(
TableColumn('initiator', appLocalizations.initiatorShort, width: 72),
cell: (_, query) => switch (query.initiator) {
final initiator? => _DnsKeyword(initiator.label),
null => const RecordCellText(''),
},
sortKey: (query) => query.initiator?.label ?? '',
),
RecordColumn(
TableColumn('result', appLocalizations.result, flex: 1),
cell: (_, query) => _DnsResult(query),
sortKey: (query) => query.summary.toLowerCase(),
),
if (wide)
RecordColumn(
TableColumn('upstream', appLocalizations.source, flex: 0.8),
cell: (_, query) => RecordCellText(query.upstream, muted: true),
sortKey: (query) => query.upstream.toLowerCase(),
),
RecordColumn(
TableColumn('elapsed', appLocalizations.elapsed, width: 76, end: true),
cell: (_, query) => RecordCellText('${query.delay} ms', muted: true),
sortKey: (query) => query.delay,
descendingFirst: true,
),
];
}
class _DnsKeyword extends StatelessWidget {
final String label;
final RecordTone tone;
const _DnsKeyword(this.label, {this.tone = RecordTone.neutral});
@override
Widget build(BuildContext context) {
return RecordLabel(
label: label,
tone: tone,
onPressed: () => context.commonScaffoldState?.addKeyword(label),
);
}
}
class _DnsResult extends StatelessWidget {
final DnsQuery dnsQuery;
const _DnsResult(this.dnsQuery);
@override
Widget build(BuildContext context) {
final summary = dnsQuery.summary;
return Row(
spacing: 6,
children: [
if (dnsQuery.cached) _DnsKeyword(context.appLocalizations.cache),
if (dnsQuery.hasFailureRcode)
_DnsKeyword(dnsQuery.rcode, tone: RecordTone.error),
if (summary.isNotEmpty)
Flexible(
child: Text(
summary,
style: TextStyle(
color: dnsQuery.isFailed
? context.colorScheme.error
: context.colorScheme.onSurfaceVariant,
),
),
),
],
);
}
}
class _DnsQueryTableRow extends StatelessWidget {
final DnsQuery dnsQuery;
final RecordTable<DnsQuery> table;
final bool striped;
const _DnsQueryTableRow({
required this.dnsQuery,
required this.table,
required this.striped,
});
@override
Widget build(BuildContext context) {
return RecordTableRow(
table: table,
striped: striped,
onTap: () => _showDnsQueryDetail(context, dnsQuery),
cells: table.cellsOf(context, dnsQuery),
);
}
}
class DnsQueryItem extends StatelessWidget {
final DnsQuery dnsQuery;
final ValueChanged<String>? onClickKeyword;
const DnsQueryItem({super.key, required this.dnsQuery, this.onClickKeyword});
void _showDetail(BuildContext context) {
showInSheet<void>(
context,
builder: (_) {
return CommonScaffold(
body: DnsQueryDetailView(dnsQuery: dnsQuery),
title: context.appLocalizations.details('DNS'),
);
},
);
}
@override
Widget build(BuildContext context) {
final styles = RecordTextStyles.of(context);
final tone = dnsQuery.isFailed ? RecordTone.error : RecordTone.neutral;
final summary = dnsQuery.error.isNotEmpty
? dnsQuery.error
: dnsQuery.answers.join(', ');
final summary = dnsQuery.summary;
return RecordListItem(
tone: tone,
onTap: () => _showDetail(context),
onTap: () => _showDnsQueryDetail(context, dnsQuery),
header: RecordHeader(
trailing: Text('${dnsQuery.delay} ms'),
children: [
@@ -205,16 +337,13 @@ class DnsQueryItem extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
spacing: 4,
children: [
Text(
dnsQuery.domain,
style: styles.primary?.copyWith(fontWeight: FontWeight.w500),
),
Text(dnsQuery.domain, style: styles.title),
if (summary.isNotEmpty)
Text(
summary,
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: styles.secondary?.copyWith(
style: styles.secondary.copyWith(
color: tone.accentColor(context),
),
),
@@ -232,7 +361,7 @@ class DnsQueryItem extends StatelessWidget {
onPressed: () => onClickKeyword?.call(tag),
),
if (dnsQuery.upstream.isNotEmpty)
Text(dnsQuery.upstream, style: styles.muted),
Text(dnsQuery.upstream, style: styles.secondary),
],
),
],
@@ -267,7 +396,7 @@ class DnsQueryDetailView extends StatelessWidget {
horizontal: 16,
).copyWith(bottom: 20, top: context.contentTopPadding),
children: [
generateSectionV3(
generateSection(
title: appLocalizations.basicInfo,
items: [
for (final (title, value) in entries)
@@ -275,12 +404,12 @@ class DnsQueryDetailView extends StatelessWidget {
],
),
if (dnsQuery.error.isNotEmpty)
generateSectionV3(
generateSection(
title: appLocalizations.error,
items: [_ErrorRow(error: dnsQuery.error)],
),
if (dnsQuery.answers.isNotEmpty)
generateSectionV3(
generateSection(
title: appLocalizations.answers,
items: [
for (final answer in dnsQuery.answers)
@@ -310,9 +439,9 @@ class _ErrorRow extends StatelessWidget {
minVerticalPadding: 14,
title: Text(
error,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.error,
),
style: AppTextStyle.subheadline
.of(context)
.copyWith(color: context.colorScheme.error),
),
);
}
+15 -19
View File
@@ -2,10 +2,10 @@ import 'dart:async';
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/providers.dart';
import 'package:fl_clash/state.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';
@@ -156,12 +156,13 @@ class _LogsViewState extends ConsumerState<LogsView>
shrinkWrap: true,
controller: _scrollController,
padding: EdgeInsets.only(
top: context.appBarInset,
top: context.contentTopPadding,
bottom: 16 + BottomInsetScope.of(context),
),
itemCount: logs.length,
extentEstimation: zeroDividerExtentEstimation,
separatorBuilder: (_, _) => const Divider(height: 0),
extentEstimation: recordListExtentEstimation,
separatorBuilder: (_, _) =>
const SizedBox(height: recordListGap),
itemBuilder: (_, index) {
final log = logs[index];
return LogItem(
@@ -193,12 +194,10 @@ class LogItem extends StatelessWidget {
final tone = switch (log.logLevel) {
LogLevel.warning => RecordTone.warning,
LogLevel.error => RecordTone.error,
LogLevel.info => RecordTone.neutral,
LogLevel.debug || LogLevel.silent => RecordTone.muted,
LogLevel.info || LogLevel.debug || LogLevel.silent => RecordTone.neutral,
};
final payload = LogPayload.parse(log.payload);
return RecordListItem(
tone: tone,
onTap: () {},
header: RecordHeader(
children: [
@@ -235,35 +234,32 @@ class _LogBody extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(payload.message, style: styles.primary),
if (payload.tag.isNotEmpty) Text(payload.tag, style: styles.muted),
if (payload.tag.isNotEmpty)
Text(payload.tag, style: styles.secondary),
],
);
}
return SelectableText.rich(
TextSpan(
children: [
TextSpan(
text: route.destination,
style: styles.primary?.copyWith(fontWeight: FontWeight.w500),
),
TextSpan(text: route.destination, style: styles.title),
if (route.error.isNotEmpty)
TextSpan(
text: '\n${route.error}',
style: styles.secondary?.copyWith(color: errorColor),
style: styles.secondary.copyWith(color: errorColor),
),
const TextSpan(text: '\n'),
if (route.rule.isNotEmpty) ...[
TextSpan(text: route.rule, style: styles.secondary),
TextSpan(text: ' \u2192 ', style: styles.muted?.toJetBrainsMono),
TextSpan(text: ' \u2192 ', style: styles.secondary.toJetBrainsMono),
],
TextSpan(
text: route.proxy,
style: styles.secondary?.copyWith(
color: context.colorScheme.primary,
fontWeight: FontWeight.w500,
),
style: AppTextStyle.footnote
.of(context, emphasized: true)
.copyWith(color: context.colorScheme.primary),
),
TextSpan(text: '\n$_sourceText', style: styles.muted),
TextSpan(text: '\n$_sourceText', style: styles.secondary),
],
),
);
+71 -59
View File
@@ -1,69 +1,93 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/ui/ui.dart';
import 'package:flutter/services.dart';
import 'package:material_ui/material_ui.dart';
import 'chip.dart';
import 'list.dart';
class RecordTextStyles {
final TextStyle? primary;
final TextStyle? secondary;
final TextStyle? muted;
final TextStyle title;
final TextStyle primary;
final TextStyle secondary;
const RecordTextStyles._({this.primary, this.secondary, this.muted});
const RecordTextStyles._({
required this.title,
required this.primary,
required this.secondary,
});
factory RecordTextStyles.of(BuildContext context) {
final colorScheme = context.colorScheme;
final textTheme = context.textTheme;
final secondary = textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
);
return RecordTextStyles._(
primary: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurface),
secondary: secondary,
muted: secondary?.copyWith(color: colorScheme.outline),
title: AppTextStyle.subheadline
.of(context, emphasized: true)
.copyWith(color: colorScheme.onSurface),
primary: AppTextStyle.subheadline
.of(context)
.copyWith(color: colorScheme.onSurface),
secondary: AppTextStyle.footnote
.of(context)
.copyWith(color: colorScheme.onSurfaceVariant),
);
}
}
const recordListGap = 8.0;
double recordListExtentEstimation(int? index, double crossAxisExtent) {
return switch (index) {
// SuperSliverList gives every child the null estimate unless it is 0.
null => 0,
_ when index.isEven => 100,
_ => recordListGap,
};
}
class RecordListItem extends StatelessWidget {
final Widget header;
final Widget body;
final RecordTone tone;
final VoidCallback? onTap;
const RecordListItem({
super.key,
required this.header,
required this.body,
this.tone = RecordTone.neutral,
this.onTap,
});
@override
Widget build(BuildContext context) {
final item = ListItem(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 12),
minVerticalPadding: 0,
minTileHeight: 0,
horizontalTitleGap: 12,
tileTitleAlignment: ListTileTitleAlignment.top,
color: tone.tintColor(context),
onTap: onTap,
title: header,
subtitle: Padding(padding: const EdgeInsets.only(top: 6), child: body),
);
final accentColor = tone.accentColor(context);
if (accentColor == null) {
return item;
}
return DecoratedBox(
position: DecorationPosition.foreground,
decoration: BoxDecoration(
border: Border(left: BorderSide(color: accentColor, width: 3)),
const shape = AppShape.xl;
final level = context.raisedSurfaceLevel;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: DecoratedBox(
decoration: ShapeDecoration(
color: level.colorOf(context.colorScheme),
shape: shape,
),
child: Material(
type: MaterialType.transparency,
child: SurfaceScope(
level: level,
child: ListTileTheme.merge(
shape: shape,
child: ListItem(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 12),
minVerticalPadding: 0,
minTileHeight: 0,
horizontalTitleGap: 12,
tileTitleAlignment: ListTileTitleAlignment.top,
onTap: onTap,
title: header,
subtitle: Padding(
padding: const EdgeInsets.only(top: 6),
child: body,
),
),
),
),
),
),
child: item,
);
}
}
@@ -78,11 +102,10 @@ class RecordHeader extends StatelessWidget {
Widget build(BuildContext context) {
final trailing = this.trailing;
return DefaultTextStyle.merge(
style: context.textTheme.labelMedium?.copyWith(
color: context.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w400,
fontFeatures: const [FontFeature.tabularFigures()],
),
style: AppTextStyle.caption1
.of(context)
.toTabular
.copyWith(color: context.colorScheme.onSurfaceVariant),
child: SizedBox(
width: double.infinity,
child: Wrap(
@@ -135,20 +158,7 @@ class RecordTimestamp extends StatelessWidget {
@override
Widget build(BuildContext context) {
final split = dateTime.lastIndexOf(' ');
return Text.rich(
TextSpan(
children: [
if (split > 0)
TextSpan(
text: dateTime.substring(0, split + 1),
style: TextStyle(color: context.colorScheme.outline),
),
TextSpan(text: split > 0 ? dateTime.substring(split + 1) : dateTime),
],
),
maxLines: 1,
);
return Text(dateTime, maxLines: 1);
}
}
@@ -159,7 +169,7 @@ class RecordArrow extends StatelessWidget {
Widget build(BuildContext context) {
return Text(
'→',
style: RecordTextStyles.of(context).muted?.toJetBrainsMono,
style: RecordTextStyles.of(context).secondary.toJetBrainsMono,
);
}
}
@@ -199,9 +209,11 @@ class DetailRow extends StatelessWidget {
alignment: Alignment.centerRight,
child: DefaultTextStyle.merge(
textAlign: TextAlign.end,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.onSurfaceVariant,
),
style: AppTextStyle.subheadline
.of(context)
.copyWith(
color: context.colorScheme.onSurfaceVariant,
),
child: value,
),
),
+482
View File
@@ -0,0 +1,482 @@
import 'package:collection/collection.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/ui/ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:material_ui/material_ui.dart';
const _recordTableMinWidth = 720.0;
const _wideRecordTableWidth = 1000.0;
const _recordTableMargin = 8.0;
const _recordCellInset = 8.0;
const _recordCellLineHeight = 20.0;
const _recordHeaderLineHeight = 16.0;
const _recordRowPadding = 8.0;
class RecordColumn<T> {
final TableColumn layout;
final Widget Function(BuildContext context, T item) cell;
final Comparable<Object> Function(T item)? sortKey;
final bool descendingFirst;
const RecordColumn(
this.layout, {
required this.cell,
this.sortKey,
this.descendingFirst = false,
});
String get id => layout.id;
}
typedef RecordSort = ({String columnId, bool descending});
/// The first tap sorts the way the column reads best, the second flips it and
/// the third gives the list its own order back.
RecordSort? nextRecordSort<T>(RecordSort? sort, RecordColumn<T> column) {
final first = column.descendingFirst;
if (sort == null || sort.columnId != column.id) {
return (columnId: column.id, descending: first);
}
if (sort.descending == first) {
return (columnId: column.id, descending: !first);
}
return null;
}
class RecordSorter<T> {
List<T>? _source;
RecordSort? _sortedBy;
List<T> _sorted = const [];
/// [reverse] marks a list shown bottom up, its last item on top.
List<T> sort(
List<T> items,
List<RecordColumn<T>> columns,
RecordSort? sort, {
bool reverse = false,
}) {
final sortKey = sort == null
? null
: columns
.firstWhereOrNull((column) => column.id == sort.columnId)
?.sortKey;
if (sort == null || sortKey == null) {
return items;
}
if (identical(_source, items) && _sortedBy == sort) {
return _sorted;
}
final shown = reverse ? items.reversed.toList() : items;
final keys = [for (final item in shown) sortKey(item)];
final direction = sort.descending ? -1 : 1;
// Sorting indexes keeps ties in their shown order; collection's
// mergeSort is stable too but measured over ten times slower.
final order = List.generate(shown.length, (index) => index)
..sort((a, b) {
final compared = keys[a].compareTo(keys[b]);
return compared != 0 ? direction * compared : a - b;
});
final sorted = [for (final index in order) shown[index]];
_source = items;
_sortedBy = sort;
return _sorted = reverse ? sorted.reversed.toList() : sorted;
}
}
/// Holds the widths a table's columns are dragged to and keeps them in the
/// app settings once a drag ends.
mixin TableColumnWidthsMixin<T extends ConsumerStatefulWidget>
on ConsumerState<T> {
String get tableId;
late Map<String, double> columnWidths =
ref.read(appSettingProvider).tableColumnWidths[tableId] ?? const {};
void resizeColumn(
List<TableColumn> columns,
List<double> widths,
int boundary,
double delta,
) {
setState(() {
columnWidths = resizedTableColumns(
columns,
widths,
columnWidths,
boundary,
delta,
);
});
}
void resetColumnWidths() {
setState(() => columnWidths = const {});
saveColumnWidths();
}
void saveColumnWidths() {
final widths = columnWidths;
ref
.read(appSettingProvider.notifier)
.update(
(state) => state.copyWith(
tableColumnWidths: {...state.tableColumnWidths, tableId: widths}
..removeWhere((_, widths) => widths.isEmpty),
),
);
}
}
class RecordTable<T> {
final List<RecordColumn<T>> columns;
final List<TableColumn> layouts;
final List<double> widths;
final double rowHeight;
const RecordTable._({
required this.columns,
required this.layouts,
required this.widths,
required this.rowHeight,
});
List<Widget> cellsOf(BuildContext context, T item) => [
for (final column in columns) column.cell(context, item),
];
/// For a list separated by zero-height dividers, which sit at odd indexes.
double estimateExtent(int? index, double crossAxisExtent) {
return index != null && index.isEven ? rowHeight : 0;
}
}
/// Builds a record list as a table once it is wide enough, with the column
/// header held above the rows, sorting them by the column tapped and resizing
/// columns by their edges.
class RecordTableFrame<T> extends ConsumerStatefulWidget {
final String tableId;
final List<T> items;
/// Whether the list shows [items] bottom up, its last item on top.
final bool reverse;
final List<RecordColumn<T>> Function(bool wide) columnsOf;
final Widget Function(
BuildContext context,
RecordTable<T>? table,
List<T> items,
)
builder;
const RecordTableFrame({
super.key,
required this.tableId,
required this.items,
this.reverse = false,
required this.columnsOf,
required this.builder,
});
@override
ConsumerState<RecordTableFrame<T>> createState() =>
_RecordTableFrameState<T>();
}
class _RecordTableFrameState<T> extends ConsumerState<RecordTableFrame<T>>
with TableColumnWidthsMixin<RecordTableFrame<T>> {
final _sorter = RecordSorter<T>();
RecordSort? _sort;
@override
String get tableId => widget.tableId;
RecordTable<T>? _tableOf(BuildContext context, double width) {
if (width < _recordTableMinWidth) {
return null;
}
final columns = widget.columnsOf(width >= _wideRecordTableWidth);
final layouts = [for (final column in columns) column.layout];
return RecordTable._(
columns: columns,
layouts: layouts,
widths: tableColumnWidths(
layouts,
width - _recordTableMargin * 2,
columnWidths,
),
rowHeight:
MediaQuery.textScalerOf(context).scale(_recordCellLineHeight) +
_recordRowPadding * 2,
);
}
@override
Widget build(BuildContext context) {
return WidthBuilder(
builder: (context, width) {
final table = _tableOf(context, width);
if (table == null) {
return widget.builder(context, null, widget.items);
}
final items = _sorter.sort(
widget.items,
table.columns,
_sort,
reverse: widget.reverse,
);
return Padding(
padding: EdgeInsets.only(top: context.contentTopPadding),
child: MediaQuery.removePadding(
context: context,
removeTop: true,
child: FloatingBarScope(
inset: 0,
child: TopInsetScope(
inset: 4,
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
_RecordTableHeader(
table: table,
sort: _sort,
onSort: (column) =>
setState(() => _sort = nextRecordSort(_sort, column)),
onResize: (boundary, delta) => resizeColumn(
table.layouts,
table.widths,
boundary,
delta,
),
onResizeEnd: saveColumnWidths,
onReset: resetColumnWidths,
),
Expanded(
child: Builder(
builder: (context) =>
widget.builder(context, table, items),
),
),
],
),
),
),
),
);
},
);
}
}
class _RecordTableHeader<T> extends StatelessWidget {
final RecordTable<T> table;
final RecordSort? sort;
final ValueChanged<RecordColumn<T>> onSort;
final void Function(int boundary, double delta) onResize;
final VoidCallback onResizeEnd;
final VoidCallback onReset;
const _RecordTableHeader({
required this.table,
required this.sort,
required this.onSort,
required this.onResize,
required this.onResizeEnd,
required this.onReset,
});
@override
Widget build(BuildContext context) {
final colorScheme = context.colorScheme;
final height =
MediaQuery.textScalerOf(context).scale(_recordHeaderLineHeight) +
_recordRowPadding * 2;
final labelStyle = AppTextStyle.caption1.of(context, emphasized: true);
final style = labelStyle.copyWith(
color: colorScheme.onSurfaceVariant,
height: _recordHeaderLineHeight / labelStyle.fontSize!,
);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: _recordTableMargin),
child: Material(
color: context.raisedSurfaceLevel.colorOf(colorScheme),
shape: AppShape.all(AppCorner.fit(height)),
clipBehavior: Clip.antiAlias,
child: TableColumnResizer(
columns: table.layouts,
widths: table.widths,
onResize: onResize,
onResizeEnd: onResizeEnd,
onReset: onReset,
child: TableCells(
columns: table.layouts,
widths: table.widths,
height: height,
inset: 0,
cells: [
for (final column in table.columns)
RecordHeaderCell(
label: column.layout.label,
end: column.layout.end,
style: style,
inset: _recordCellInset,
descending: sort?.columnId == column.id
? sort?.descending
: null,
onTap: column.sortKey == null ? null : () => onSort(column),
),
],
),
),
),
);
}
}
/// The width a fixed column takes to show [label] whole in its header.
double recordHeaderWidth(BuildContext context, String label) =>
MeasureScope.of(context)
.computeTextSize(
Text(
label,
style: AppTextStyle.caption1.of(context, emphasized: true),
),
)
.width
.ceilToDouble() +
_recordCellInset * 2;
class RecordHeaderCell extends StatelessWidget {
final String label;
final bool end;
final TextStyle style;
final double inset;
/// Null unless the rows are sorted by this column.
final bool? descending;
final VoidCallback? onTap;
const RecordHeaderCell({
super.key,
required this.label,
required this.end,
required this.style,
required this.inset,
required this.descending,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final descending = this.descending;
final color = descending == null
? style.color
: context.colorScheme.onSurface;
final content = Padding(
padding: EdgeInsets.symmetric(horizontal: inset),
child: Row(
mainAxisAlignment: end
? MainAxisAlignment.end
: MainAxisAlignment.start,
spacing: 2,
children: [
Flexible(
child: Text(
label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: style.copyWith(color: color),
),
),
if (descending != null)
GlyphIcon(
descending ? AppGlyphs.arrowDown : AppGlyphs.arrowUp,
size: 12,
color: color,
),
],
),
);
final onTap = this.onTap;
if (onTap == null) {
return content;
}
return InkWell(
onTap: onTap,
child: SizedBox.expand(child: content),
);
}
}
class RecordTableRow extends StatelessWidget {
final RecordTable<Object?> table;
final List<Widget> cells;
final bool striped;
final VoidCallback? onTap;
const RecordTableRow({
super.key,
required this.table,
required this.cells,
required this.striped,
this.onTap,
});
@override
Widget build(BuildContext context) {
final colorScheme = context.colorScheme;
final style = AppTextStyle.subheadline.of(context);
final shape = AppShape.all(AppCorner.fit(table.rowHeight));
final level = striped ? context.raisedSurfaceLevel : context.surfaceLevel;
final fill = striped ? level.colorOf(colorScheme) : null;
Widget row = Material(
type: MaterialType.transparency,
child: InkWell(
onTap: onTap,
customBorder: shape,
child: DefaultTextStyle(
style: style.copyWith(
color: colorScheme.onSurface,
height: _recordCellLineHeight / style.fontSize!,
fontFeatures: const [FontFeature.tabularFigures()],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
child: TableCells(
columns: table.layouts,
widths: table.widths,
height: table.rowHeight,
inset: _recordCellInset,
cells: cells,
),
),
),
);
if (fill != null) {
row = DecoratedBox(
decoration: ShapeDecoration(color: fill, shape: shape),
child: row,
);
}
return Padding(
padding: const EdgeInsets.symmetric(horizontal: _recordTableMargin),
child: SurfaceScope(level: level, child: row),
);
}
}
class RecordCellText extends StatelessWidget {
final String text;
final bool muted;
const RecordCellText(this.text, {super.key, this.muted = false});
@override
Widget build(BuildContext context) {
return Text(
text.isEmpty ? '—' : text,
style: muted
? TextStyle(color: context.colorScheme.onSurfaceVariant)
: null,
);
}
}
+21
View File
@@ -76,6 +76,15 @@ void main() {
expect(before, [1, 2]);
});
test('replace swaps items in a new generation', () {
final first = FixedList(3, list: [1, 2, 3]);
final before = first.list;
final second = first.replace({0: 10, 2: 30});
expect(second.list, [10, 2, 30]);
expect(second.revision, first.revision + 1);
expect(before, [1, 2, 3]);
});
test('append still honours maxLength', () {
var list = FixedList<int>(2);
for (var i = 1; i <= 4; i++) {
@@ -107,6 +116,18 @@ void main() {
expect(retainTrimmedHead(current, latest, 10), [...current, ...latest]);
});
test('finds where latest starts by key when it carries a copy', () {
final current = [(1, 'a'), (2, 'a'), (3, 'a'), (4, 'a')];
final latest = [(3, 'b'), (4, 'a'), (5, 'a')];
expect(retainTrimmedHead(current, latest, 10, keyOf: (item) => item.$1), [
(1, 'a'),
(2, 'a'),
(3, 'b'),
(4, 'a'),
(5, 'a'),
]);
});
test('caps growth from the tail', () {
final current = items.sublist(0, 4);
final latest = items.sublist(2, 6);
+25
View File
@@ -9,6 +9,7 @@ class _RecordingListener with CoreEventListener {
final void Function()? onLoadedCallback;
final List<String> loaded = [];
final List<String> dialerLoops = [];
final List<Map<String, RequestTraffic>> requestTraffic = [];
@override
void onLoaded(String providerName) {
@@ -20,6 +21,11 @@ class _RecordingListener with CoreEventListener {
void onDialerLoop(String proxyName) {
dialerLoops.add(proxyName);
}
@override
void onRequestTraffic(Map<String, RequestTraffic> traffic) {
requestTraffic.add(traffic);
}
}
void main() {
@@ -70,4 +76,23 @@ void main() {
expect(listener.dialerLoops, ['Front']);
});
test('the traffic of requests reaches the listeners by id', () async {
final listener = _RecordingListener();
coreEventManager.addListener(listener);
addTearDown(() => coreEventManager.removeListener(listener));
coreEventsFromData({
'type': 'requestTraffic',
'data': [
{'id': 'a', 'upload': 460, 'download': 257835},
{'id': 'b', 'upload': 0, 'download': 12},
],
}).forEach(coreEventManager.sendEvent);
await pumpEventQueue();
expect(listener.requestTraffic, [
{'a': (upload: 460, download: 257835), 'b': (upload: 0, download: 12)},
]);
});
}
+39
View File
@@ -286,6 +286,45 @@ void main() {
expect(container.read(requestsProvider).length, 1);
});
test('Requests.updateTraffic updates the requests it names', () {
TrackerInfo request(String id) => TrackerInfo(
id: id,
upload: 460,
start: DateTime.utc(2026),
metadata: const Metadata(network: 'tcp', host: 'example.com'),
chains: const ['Proxy'],
rule: 'DOMAIN',
rulePayload: 'example.com',
);
final notifier = container.read(requestsProvider.notifier);
notifier.value = FixedList(3);
notifier.addRequest(request('a'));
notifier.addRequest(request('b'));
var notifications = 0;
final subscription = container.listen(
requestsProvider.select((state) => state.revision),
(_, _) => notifications++,
);
addTearDown(subscription.close);
notifier.updateTraffic({'gone': (upload: 1, download: 1)});
expect(notifications, 0);
notifier.updateTraffic({
'a': (upload: 520, download: 257835),
'gone': (upload: 1, download: 1),
});
expect(notifications, 1);
expect(
container
.read(requestsProvider)
.list
.map((request) => (request.id, request.upload, request.download)),
[('a', 520, 257835), ('b', 460, 0)],
);
});
test('Traffics.addTraffic reaches listeners and clear resets', () {
container.read(trafficsProvider.notifier).value = FixedList(3);
var notifications = 0;
+90 -7
View File
@@ -1,13 +1,14 @@
import 'dart:async';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/core/controller.dart';
import 'package:fl_clash/core/interface.dart';
import 'package:fl_clash/features/features.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/core.dart';
import 'package:fl_clash/providers/database.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/ui/ui.dart';
import 'package:fl_clash/views/connection/connections.dart';
import 'package:fl_clash/widgets/null_status.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -53,16 +54,15 @@ void main() {
setUpAll(() {
core = MockCoreHandlerInterface();
CoreController.resetInstance();
CoreController.test(core);
});
tearDownAll(CoreController.resetInstance);
setUp(() {
reset(core);
container = ProviderContainer(
overrides: [profilesProvider.overrideWith(TestProfiles.new)],
overrides: [
profilesProvider.overrideWith(TestProfiles.new),
coreHandlerProvider.overrideWithValue(CoreController.scoped(core)),
],
);
globalState.container = container;
});
@@ -175,6 +175,89 @@ void main() {
await teardownView(tester);
});
testWidgets('a wide page lays the connections out as a table and blocks '
'a row from it', (tester) async {
var connections = [
_tracker(id: 'a', host: 'alpha.test', chains: const ['Tokyo', 'Proxy']),
_tracker(id: 'b', host: 'beta.test'),
];
when(core.getConnections).thenAnswer((_) async => connections);
when(() => core.closeConnection(any())).thenAnswer((_) async => true);
await pumpConnections(tester);
await pumpPastArrivalHold(tester);
for (final label in [
'Process',
'Target',
'Protocol',
'Rule',
'Exit',
'Download',
'Upload',
'Duration',
]) {
expect(find.text(label), findsOneWidget);
}
expect(find.byType(TrackerInfoTableRow), findsNWidgets(2));
expect(find.byType(Divider), findsNothing);
expect(
tester
.widgetList<TrackerInfoTableRow>(find.byType(TrackerInfoTableRow))
.map((row) => row.striped)
.toSet(),
{true, false},
);
expect(find.text('Tokyo'), findsOneWidget);
connections = [connections.last];
await tester.tap(
find.descendant(
of: find.ancestor(
of: find.textContaining('alpha.test').first,
matching: find.byType(TrackerInfoTableRow),
),
matching: find.byTooltip('Block connection'),
),
);
await tester.pumpAndSettle();
verify(() => core.closeConnection('a')).called(1);
expect(find.textContaining('alpha.test'), findsNothing);
expect(find.textContaining('beta.test'), findsWidgets);
expect(tester.takeException(), null);
await teardownView(tester);
});
testWidgets('a sorted table keeps its order across polls', (tester) async {
when(core.getConnections).thenAnswer(
(_) async => [
_tracker(id: 'a', host: 'alpha.test', process: 'zsh', download: 300),
_tracker(id: 'b', host: 'beta.test', process: 'curl', download: 200),
_tracker(id: 'c', host: 'gamma.test', process: 'make', download: 100),
],
);
await pumpConnections(tester);
await pumpPastArrivalHold(tester);
double topOf(String host) =>
tester.getTopLeft(find.textContaining(host).first).dy;
expect(topOf('alpha.test'), lessThan(topOf('beta.test')));
await tester.tap(find.text('Process'));
await tester.pumpAndSettle();
expect(topOf('beta.test'), lessThan(topOf('gamma.test')));
expect(topOf('gamma.test'), lessThan(topOf('alpha.test')));
await pumpPastArrivalHold(tester);
expect(topOf('beta.test'), lessThan(topOf('gamma.test')));
expect(topOf('gamma.test'), lessThan(topOf('alpha.test')));
await teardownView(tester);
});
testWidgets('keeps the empty state when core throws', (tester) async {
when(core.getConnections).thenThrow(StateError('core down'));
+114 -6
View File
@@ -12,6 +12,9 @@ import 'package:flutter_test/flutter_test.dart';
import '../helpers/test_app.dart';
import '../helpers/test_profiles.dart';
const _tableSize = Size(1400, 1000);
const _listSize = Size(600, 1000);
DnsQuery _dnsQuery(
String domain, {
String type = 'A',
@@ -21,6 +24,7 @@ DnsQuery _dnsQuery(
List<String> answers = const ['1.1.1.1'],
String rcode = 'NOERROR',
String error = '',
int delay = 12,
}) {
return DnsQuery(
domain: domain,
@@ -31,7 +35,7 @@ DnsQuery _dnsQuery(
answers: answers,
rcode: rcode,
error: error,
delay: 12,
delay: delay,
time: DateTime.utc(2026, 9, 18, 4, 30),
);
}
@@ -61,8 +65,12 @@ void main() {
}
}
Future<void> pumpDnsQueries(WidgetTester tester) async {
tester.view.physicalSize = const Size(1400, 1000);
Future<void> pumpDnsQueries(
WidgetTester tester, {
Size size = _tableSize,
}) async {
container.read(viewSizeProvider.notifier).update((_) => size);
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
@@ -117,7 +125,7 @@ void main() {
),
]);
await pumpDnsQueries(tester);
await pumpDnsQueries(tester, size: _listSize);
expect(find.text('alpha.test'), findsOneWidget);
expect(find.text('93.184.216.34'), findsOneWidget);
@@ -143,6 +151,106 @@ void main() {
await teardownView(tester);
});
testWidgets('a wide page lays the queries out under a column header', (
tester,
) async {
seedDnsQueries([
_dnsQuery(
'alpha.test',
initiator: DnsQueryInitiator.app,
upstream: 'tls://1.1.1.1:853',
answers: ['93.184.216.34', '93.184.216.35'],
),
_dnsQuery('cached.test', cached: true),
_dnsQuery(
'missing.test',
type: 'AAAA',
answers: const [],
rcode: 'NXDOMAIN',
),
_dnsQuery(
'timeout.test',
answers: const [],
rcode: '',
error: 'i/o timeout',
),
]);
await pumpDnsQueries(tester);
for (final label in [
'Time',
'Domain',
'Type',
'From',
'Result',
'Source',
'Took',
]) {
expect(find.text(label), findsOneWidget);
}
expect(find.byType(DnsQueryItem), findsNothing);
expect(find.text('93.184.216.34, 93.184.216.35'), findsOneWidget);
expect(find.text('tls://1.1.1.1:853'), findsOneWidget);
expect(find.widgetWithText(RecordLabel, 'App'), findsOneWidget);
expect(find.widgetWithText(RecordLabel, 'Cache'), findsOneWidget);
expect(find.widgetWithText(RecordLabel, 'NXDOMAIN'), findsOneWidget);
expect(find.text('12 ms'), findsNWidgets(4));
final error = tester.widget<Text>(find.text('i/o timeout'));
expect(
error.style?.color,
Theme.of(tester.element(find.text('i/o timeout'))).colorScheme.error,
);
expect(
tester
.widget<RecordLabel>(find.widgetWithText(RecordLabel, 'NXDOMAIN'))
.tone,
RecordTone.error,
);
expect(tester.takeException(), null);
await teardownView(tester);
});
testWidgets('tapping a column header sorts the queries by it', (
tester,
) async {
seedDnsQueries([
_dnsQuery('fast.test', delay: 5),
_dnsQuery('slow.test', delay: 300),
_dnsQuery('mid.test', delay: 40),
]);
await pumpDnsQueries(tester);
double topOf(String domain) => tester.getTopLeft(find.text(domain)).dy;
expect(topOf('mid.test'), lessThan(topOf('slow.test')));
await tester.tap(find.text('Took'));
await tester.pumpAndSettle();
expect(topOf('slow.test'), lessThan(topOf('mid.test')));
expect(topOf('mid.test'), lessThan(topOf('fast.test')));
await tester.tap(find.text('Domain'));
await tester.pumpAndSettle();
expect(topOf('fast.test'), lessThan(topOf('mid.test')));
expect(topOf('mid.test'), lessThan(topOf('slow.test')));
await teardownView(tester);
});
testWidgets('a narrow page keeps the stacked rows', (tester) async {
seedDnsQueries([_dnsQuery('alpha.test')]);
await pumpDnsQueries(tester, size: _listSize);
expect(find.byType(DnsQueryItem), findsOneWidget);
expect(find.byType(RecordTableRow), findsNothing);
expect(find.text('Domain'), findsNothing);
await teardownView(tester);
});
testWidgets('a query arriving after mount reaches the list', (tester) async {
seedDnsQueries([_dnsQuery('alpha.test')]);
@@ -201,12 +309,12 @@ void main() {
expect(find.text('DNS details'), findsOneWidget);
expect(find.text('Record type'), findsOneWidget);
expect(find.text('Initiator'), findsOneWidget);
expect(find.text('Source'), findsOneWidget);
Finder detailText(String text) => find.descendant(
of: find.byType(DnsQueryDetailView),
matching: find.text(text),
);
expect(find.text('Initiator'), findsOneWidget);
expect(detailText('Source'), findsOneWidget);
expect(detailText('tls://1.1.1.1:853'), findsOneWidget);
expect(detailText('Cache'), findsOneWidget);
expect(detailText('Yes'), findsOneWidget);
+288 -7
View File
@@ -1,10 +1,14 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/features/features.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/ui/chip.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/ui/null_status.dart';
import 'package:fl_clash/ui/scroll.dart';
import 'package:fl_clash/views/connection/requests.dart';
import 'package:fl_clash/widgets/null_status.dart';
import 'package:fl_clash/widgets/scroll.dart';
import 'package:flutter/gestures.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -13,9 +17,18 @@ import '../helpers/scrollbar.dart';
import '../helpers/test_app.dart';
import '../helpers/test_profiles.dart';
TrackerInfo _tracker({required String id, String host = 'example.com'}) {
const _tableSize = Size(1400, 1000);
const _listSize = Size(600, 1000);
TrackerInfo _tracker({
required String id,
String host = 'example.com',
List<String> chains = const ['Proxy'],
int download = 0,
}) {
return TrackerInfo(
id: id,
download: download,
start: DateTime.utc(2026),
metadata: Metadata(
network: 'tcp',
@@ -24,7 +37,7 @@ TrackerInfo _tracker({required String id, String host = 'example.com'}) {
destinationPort: '443',
process: 'curl',
),
chains: const ['Proxy'],
chains: chains,
rule: 'DOMAIN',
rulePayload: host,
);
@@ -59,8 +72,12 @@ void main() {
container.read(requestsProvider.notifier).addRequest(request);
}
Future<void> pumpRequests(WidgetTester tester) async {
tester.view.physicalSize = const Size(1400, 1000);
Future<void> pumpRequests(
WidgetTester tester, {
Size size = _tableSize,
}) async {
container.read(viewSizeProvider.notifier).update((_) => size);
tester.view.physicalSize = size;
tester.view.devicePixelRatio = 1;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
@@ -124,6 +141,24 @@ void main() {
await teardownView(tester);
});
testWidgets('traffic after a request opened reaches its row', (tester) async {
seedRequests([_tracker(id: 'a', host: 'alpha.test')]);
await pumpRequests(tester);
expect(find.text('251.8KB'), findsNothing);
container.read(requestsProvider.notifier).updateTraffic({
'a': (upload: 460, download: 257835),
});
await tester.pump(const Duration(seconds: 1));
await tester.pump();
await tester.pump();
expect(find.text('251.8KB'), findsOneWidget);
await teardownView(tester);
});
testWidgets('holding the scrollbar thumb floats the start time beside it', (
tester,
) async {
@@ -146,7 +181,7 @@ void main() {
);
seedRequests(requests);
await pumpRequests(tester);
await pumpRequests(tester, size: _listSize);
const hintKey = ValueKey('scrollbarHintPill');
expect(find.byKey(hintKey), findsNothing);
@@ -179,6 +214,252 @@ void main() {
await teardownView(tester);
});
testWidgets('tapping a column header sorts the table by it, then the '
'other way, then back to newest first', (tester) async {
seedRequests([
_tracker(id: 'a', host: 'gamma.test', download: 10),
_tracker(id: 'b', host: 'alpha.test', download: 30),
_tracker(id: 'c', host: 'beta.test', download: 20),
]);
await pumpRequests(tester);
List<String> hostsTopDown() {
final rows = tester
.widgetList<TrackerInfoTableRow>(find.byType(TrackerInfoTableRow))
.toList();
rows.sort(
(a, b) => tester
.getTopLeft(find.byWidget(a))
.dy
.compareTo(tester.getTopLeft(find.byWidget(b)).dy),
);
return [for (final row in rows) row.trackerInfo.metadata.host];
}
Finder sortArrow(Glyph glyph) => find.byWidgetPredicate(
(widget) => widget is GlyphIcon && widget.glyph == glyph,
);
Future<void> tapHeader(String label) async {
await tester.tap(find.text(label));
await tester.pumpAndSettle();
}
expect(hostsTopDown(), ['beta.test', 'alpha.test', 'gamma.test']);
await tapHeader('Download');
expect(hostsTopDown(), ['alpha.test', 'beta.test', 'gamma.test']);
expect(sortArrow(AppGlyphs.arrowDown), findsOneWidget);
await tapHeader('Download');
expect(hostsTopDown(), ['gamma.test', 'beta.test', 'alpha.test']);
expect(sortArrow(AppGlyphs.arrowUp), findsOneWidget);
await tapHeader('Download');
expect(hostsTopDown(), ['beta.test', 'alpha.test', 'gamma.test']);
expect(sortArrow(AppGlyphs.arrowDown), findsNothing);
expect(sortArrow(AppGlyphs.arrowUp), findsNothing);
await tapHeader('Target');
expect(hostsTopDown(), ['alpha.test', 'beta.test', 'gamma.test']);
await teardownView(tester);
});
testWidgets('dragging a column edge widens it into the columns after it, '
'keeps the widths and a double click gives them back', (tester) async {
container.listen(appSettingProvider, (_, _) {});
seedRequests([_tracker(id: 'a', host: 'alpha.test')]);
await pumpRequests(tester);
final handles = find.byWidgetPredicate(
(widget) =>
widget is MouseRegion &&
widget.cursor == SystemMouseCursors.resizeColumn,
);
double leftOf(String text) => tester.getTopLeft(find.text(text)).dx;
final protocol = leftOf('Protocol');
final rule = leftOf('Rule');
final exit = leftOf('Exit');
final tcp = leftOf('TCP');
await tester.drag(
handles.at(2),
const Offset(100, 0),
kind: PointerDeviceKind.mouse,
);
await tester.pump();
final moved = leftOf('Protocol') - protocol;
expect(moved, greaterThan(50));
expect(leftOf('TCP'), closeTo(tcp + moved, 0.01));
expect(
leftOf('Rule'),
closeTo(rule + moved - (rule - protocol - 48), 0.01),
);
expect(leftOf('Exit'), exit);
expect(
container.read(appSettingProvider).tableColumnWidths['requests']?.keys,
unorderedEquals(['target', 'protocol', 'rule']),
);
await tester.tap(handles.at(2), kind: PointerDeviceKind.mouse);
await tester.pump(const Duration(milliseconds: 50));
await tester.tap(handles.at(2), kind: PointerDeviceKind.mouse);
await tester.pumpAndSettle();
expect(leftOf('Protocol'), protocol);
expect(container.read(appSettingProvider).tableColumnWidths, isEmpty);
await teardownView(tester);
});
testWidgets('the table opens at the widths kept from before', (tester) async {
container.listen(appSettingProvider, (_, _) {});
seedRequests([_tracker(id: 'a', host: 'alpha.test')]);
await pumpRequests(tester);
final target = tester.getTopLeft(find.text('Target')).dx;
await teardownView(tester);
container
.read(appSettingProvider.notifier)
.update(
(state) => state.copyWith(
tableColumnWidths: {
'requests': {'target': 400},
},
),
);
await pumpRequests(tester);
expect(
tester.getTopLeft(find.text('Protocol')).dx,
closeTo(target + 400, 0.01),
);
await teardownView(tester);
});
testWidgets('a wide page builds only the rows in view', (tester) async {
seedRequests([for (var i = 0; i < 500; i++) _tracker(id: '$i')]);
await pumpRequests(tester);
final built = find.byType(TrackerInfoTableRow).evaluate().length;
expect(built, greaterThan(0));
expect(built, lessThan(60));
await teardownView(tester);
});
testWidgets('a narrow page keeps the stacked rows', (tester) async {
seedRequests([_tracker(id: 'a', host: 'alpha.test')]);
await pumpRequests(tester, size: _listSize);
expect(find.byType(TrackerInfoItem), findsOneWidget);
expect(find.byType(TrackerInfoTableRow), findsNothing);
expect(find.text('Target'), findsNothing);
await teardownView(tester);
});
testWidgets('a wide page lays the requests out under a column header', (
tester,
) async {
seedRequests([
_tracker(id: 'a', host: 'alpha.test'),
_tracker(id: 'b', host: 'beta.test', chains: const ['Tokyo', 'Proxy']),
]);
await pumpRequests(tester);
for (final label in [
'Time',
'Process',
'Target',
'Protocol',
'Rule',
'Exit',
'Download',
'Upload',
]) {
expect(find.text(label), findsOneWidget);
}
expect(find.byType(TrackerInfoTableRow), findsNWidgets(2));
expect(find.byType(Divider), findsNothing);
expect(
tester
.widgetList<TrackerInfoTableRow>(find.byType(TrackerInfoTableRow))
.map((row) => row.striped)
.toSet(),
{true, false},
);
expect(find.byType(TrackerInfoItem), findsNothing);
expect(find.text('TCP'), findsNWidgets(2));
final rowHeights = tester
.widgetList(find.byType(TrackerInfoTableRow))
.map((row) => tester.getSize(find.byWidget(row)).height)
.toSet();
expect(rowHeights, hasLength(1));
expect(
tester.getTopLeft(find.textContaining('beta.test').first).dy,
greaterThan(tester.getBottomLeft(find.text('Target')).dy),
);
expect(tester.takeException(), null);
await teardownView(tester);
});
testWidgets('tapping an exit in the table keeps only the requests using it', (
tester,
) async {
seedRequests([
_tracker(id: 'a', host: 'alpha.test'),
_tracker(id: 'b', host: 'beta.test', chains: const ['Tokyo', 'Proxy']),
]);
await pumpRequests(tester);
await tester.tap(find.widgetWithText(TonalChip, 'Tokyo'));
await tester.pumpAndSettle();
expect(find.textContaining('alpha.test'), findsNothing);
expect(find.textContaining('beta.test'), findsWidgets);
await teardownView(tester);
});
testWidgets('tapping a table row opens its details', (tester) async {
seedRequests([_tracker(id: 'a', host: 'alpha.test')]);
await pumpRequests(tester);
await tester.tap(find.text('curl'));
await tester.pumpAndSettle();
expect(find.text('Request details'), findsOneWidget);
expect(find.byType(TrackerInfoDetailView), findsOneWidget);
await teardownView(tester);
});
testWidgets('the table scrollbar leaves no room for the bar above it', (
tester,
) async {
seedRequests([for (var i = 0; i < 200; i++) _tracker(id: '$i')]);
await pumpRequests(tester);
await revealScrollbarThumb(tester);
expect(scrollbarPainter(tester).padding.resolve(TextDirection.ltr).top, 0);
await teardownView(tester);
});
testWidgets('following pauses when the list is scrolled off its head and '
'resumes when it is scrolled back', (tester) async {
seedRequests([for (var i = 0; i < 60; i++) _tracker(id: '$i')]);
+19 -4
View File
@@ -1,5 +1,4 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/common/theme.dart';
import 'package:fl_clash/core/controller.dart';
import 'package:fl_clash/core/interface.dart';
import 'package:fl_clash/l10n/l10n.dart';
@@ -10,10 +9,13 @@ import 'package:fl_clash/views/connection/connections.dart';
import 'package:fl_clash/features/features.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
import '../helpers/test_app.dart';
void main() {
late ProviderContainer container;
@@ -134,6 +136,20 @@ void main() {
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('a wide table shows its whole protocol header', (tester) async {
await pumpConnections(
tester,
connectionsReader: () async => buildConnections(2),
);
tester.view.physicalSize = const Size(1600, 900);
await tester.pump(const Duration(seconds: 1));
final header = tester.renderObject<RenderParagraph>(find.text('Protocol'));
expect(header.didExceedMaxLines, isFalse);
await tester.pumpWidget(const SizedBox.shrink());
});
testWidgets('ConnectionsView polls only while the page is active', (
tester,
) async {
@@ -234,13 +250,12 @@ class _TestApp extends StatelessWidget {
navigatorKey: globalState.navigatorKey,
localizationsDelegates: const [
AppLocalizations.delegate,
AppUiLocalizations.delegate,
...GlobalMaterialLocalizations.delegates,
],
supportedLocales: AppLocalizations.delegate.supportedLocales,
builder: (context, child) {
globalState.measure = Measure.of(context, 1);
globalState.theme = CommonTheme.of(context, 1);
return child!;
return wrapAppScopes(context, child!);
},
home: child,
);
+35
View File
@@ -0,0 +1,35 @@
import 'package:fl_clash/widgets/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:material_ui/material_ui.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
void main() {
testWidgets('the gaps between record cards do not misjudge the list', (
tester,
) async {
final controller = ScrollController();
addTearDown(controller.dispose);
await tester.pumpWidget(
MaterialApp(
home: SuperListView.separated(
controller: controller,
itemCount: 200,
extentEstimation: recordListExtentEstimation,
separatorBuilder: (_, _) => const SizedBox(height: recordListGap),
itemBuilder: (_, index) =>
SizedBox(height: 100, child: Text('$index')),
),
),
);
final viewport = controller.position.viewportDimension;
const extent = 200 * 100 + 199 * recordListGap;
expect(controller.position.maxScrollExtent, extent - viewport);
controller.jumpTo(controller.position.maxScrollExtent);
await tester.pump();
expect(controller.position.maxScrollExtent, extent - viewport);
expect(controller.offset, controller.position.maxScrollExtent);
});
}
+91
View File
@@ -0,0 +1,91 @@
import 'package:fl_clash/ui/table_layout.dart';
import 'package:flutter_test/flutter_test.dart';
const _columns = [
TableColumn('time', 'Time', width: 80),
TableColumn('target', 'Target', flex: 2),
TableColumn('rule', 'Rule', flex: 1),
TableColumn('exit', 'Exit', width: 120),
];
double _sum(List<double> widths) => widths.fold(0, (sum, w) => sum + w);
void main() {
test('flexible columns share what the fixed ones leave', () {
expect(tableColumnWidths(_columns, 500), [80, 200, 100, 120]);
});
test('a dragged column keeps its width and the other flexible ones '
'take up the rest', () {
final widths = tableColumnWidths(_columns, 500, {'target': 150});
expect(widths, [80, 150, 150, 120]);
});
test('dragged widths scale to fit once the table is narrower than them', () {
final widths = tableColumnWidths(_columns, 400, {
'target': 300,
'rule': 200,
});
expect(_sum(widths), closeTo(400, 0.001));
expect(widths[1] / widths[2], closeTo(1.5, 0.001));
});
test('dragged widths scale to fill when no flexible column is left', () {
final widths = tableColumnWidths(_columns, 700, {
'target': 150,
'rule': 150,
});
expect(_sum(widths), closeTo(700, 0.001));
expect(widths[0] / widths[3], closeTo(80 / 120, 0.001));
});
test('a drag moves one edge and leaves the other columns in place', () {
final widths = tableColumnWidths(_columns, 500);
final overrides = resizedTableColumns(_columns, widths, const {}, 1, 30);
final resized = tableColumnWidths(_columns, 500, overrides);
expect(overrides, {'target': 230, 'rule': 70});
expect(resized, [80, 230, 70, 120]);
});
test('widening a column squeezes the ones after it in turn', () {
final widths = tableColumnWidths(_columns, 500);
expect(resizedTableColumns(_columns, widths, const {}, 1, 80), {
'target': 280,
'rule': tableColumnMinWidth,
'exit': 120 - (80 - (100 - tableColumnMinWidth)),
});
expect(resizedTableColumns(_columns, widths, const {}, 1, 500), {
'target': 200 + (100 - tableColumnMinWidth) + (120 - tableColumnMinWidth),
'rule': tableColumnMinWidth,
'exit': tableColumnMinWidth,
});
});
test('narrowing a column gives its space to the next one only', () {
final widths = tableColumnWidths(_columns, 500);
expect(resizedTableColumns(_columns, widths, const {}, 0, -500), {
'time': tableColumnMinWidth,
'target': 200 + 80 - tableColumnMinWidth,
});
});
test('a column that cannot be resized is never squeezed', () {
const columns = [
TableColumn('target', 'Target', flex: 1),
TableColumn('action', '', width: 44, resizable: false),
TableColumn('exit', 'Exit', width: 120),
];
final widths = tableColumnWidths(columns, 400);
expect(resizedTableColumns(columns, widths, const {}, 0, 50), {
'target': 286,
'exit': 70,
});
});
}