feat(views): show connections, requests and DNS as tables
Changelog: Sortable tables for connections, requests and DNS
This commit is contained in:
+4
-1
@@ -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
@@ -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
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -224,6 +224,7 @@ enum CoreEventType {
|
||||
log,
|
||||
delay,
|
||||
request,
|
||||
requestTraffic,
|
||||
dns,
|
||||
loaded,
|
||||
crash,
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
export 'tracker_info_item.dart';
|
||||
export 'tracker_info_list.dart';
|
||||
export 'tracker_info_table.dart';
|
||||
export 'tracker_speed_ranker.dart';
|
||||
|
||||
@@ -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([
|
||||
|
||||
@@ -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}'),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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,
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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)},
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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'));
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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')]);
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user