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

Changelog: Sortable tables for connections, requests and DNS
This commit is contained in:
chen08209
2026-10-16 14:55:07 +08:00
parent 7c9627fffb
commit da6fb0a8a1
36 changed files with 2732 additions and 430 deletions
+16 -2
View File
@@ -32,6 +32,12 @@ class FixedList<T> {
return FixedList._(maxLength, _list, _revision + 1);
}
FixedList<T> replace(Map<int, T> items) {
items.forEach((index, item) => _list[index] = item);
_snapshot = null;
return FixedList._(maxLength, _list, _revision + 1);
}
List<T> get list => _snapshot ??= List.unmodifiable(_list);
int get length => _list.length;
@@ -54,11 +60,19 @@ class FixedList<T> {
/// Restores the head that [latest] trimmed off [current] so a paused view
/// stays anchored; the result is capped at [limit] from the tail.
List<T> retainTrimmedHead<T>(List<T> current, List<T> latest, int limit) {
List<T> retainTrimmedHead<T>(
List<T> current,
List<T> latest,
int limit, {
Object Function(T item)? keyOf,
}) {
if (current.isEmpty || latest.isEmpty) {
return latest;
}
final index = current.indexWhere((item) => identical(item, latest.first));
final first = latest.first;
final index = keyOf == null
? current.indexWhere((item) => identical(item, first))
: current.indexWhere((item) => keyOf(item) == keyOf(first));
if (index == 0) {
return latest;
}
+11
View File
@@ -28,6 +28,8 @@ abstract mixin class CoreEventListener {
void onRequest(TrackerInfo connection) {}
void onRequestTraffic(Map<String, RequestTraffic> traffic) {}
void onDns(DnsQuery dnsQuery) {}
void onLoaded(String providerName) {}
@@ -63,6 +65,15 @@ class CoreEventManager {
case CoreEventType.request:
listener.onRequest(TrackerInfo.fromJson(event.data));
break;
case CoreEventType.requestTraffic:
listener.onRequestTraffic({
for (final Map item in event.data)
item['id'] as String: (
upload: (item['upload'] as num).toInt(),
download: (item['download'] as num).toInt(),
),
});
break;
case CoreEventType.dns:
listener.onDns(DnsQuery.fromJson(event.data));
break;
+3
View File
@@ -224,6 +224,7 @@ enum CoreEventType {
log,
delay,
request,
requestTraffic,
dns,
loaded,
crash,
@@ -792,6 +793,8 @@ enum RestoreStrategy { compatible, override }
enum TabAnimation { slide, fade }
enum LiquidGlassMode { none, standard }
enum Language { yaml, javaScript, json }
enum ScrollPositionCacheKey { proxiesTabList }
+1
View File
@@ -1,3 +1,4 @@
export 'tracker_info_item.dart';
export 'tracker_info_list.dart';
export 'tracker_info_table.dart';
export 'tracker_speed_ranker.dart';
+32 -21
View File
@@ -1,8 +1,8 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -26,6 +26,22 @@ String _endpointText(String ip, String port) {
return ip;
}
void showTrackerInfoDetail(
BuildContext context,
TrackerInfo trackerInfo,
String title,
) {
showInSheet<void>(
context,
builder: (_) {
return CommonScaffold(
body: TrackerInfoDetailView(trackerInfo: trackerInfo),
title: title,
);
},
);
}
class TrackerInfoItem extends ConsumerWidget {
final TrackerInfo trackerInfo;
final bool isLive;
@@ -51,17 +67,7 @@ class TrackerInfoItem extends ConsumerWidget {
),
);
return RecordListItem(
onTap: () {
showInSheet<void>(
context,
builder: (_) {
return CommonScaffold(
body: TrackerInfoDetailView(trackerInfo: trackerInfo),
title: detailTitle,
);
},
);
},
onTap: () => showTrackerInfoDetail(context, trackerInfo, detailTitle),
header: _buildHeader(context),
body: _TrackerInfoBody(
trackerInfo: trackerInfo,
@@ -74,7 +80,9 @@ class TrackerInfoItem extends ConsumerWidget {
Widget _buildHeader(BuildContext context) {
final network = Text(
trackerInfo.metadata.network.toUpperCase(),
style: const TextStyle(fontWeight: FontWeight.w500),
style: AppTextStyle.caption1
.of(context, emphasized: true)
.copyWith(color: context.colorScheme.onSurfaceVariant),
);
if (!isLive) {
return RecordHeader(
@@ -135,10 +143,13 @@ class _TrackerInfoBody extends StatelessWidget {
children: [
TextSpan(
text: _endpointText(trackerInfo.title, metadata.destinationPort),
style: styles.primary?.copyWith(fontWeight: FontWeight.w500),
style: styles.title,
),
if (metadata.host.isNotEmpty && metadata.destinationIP.isNotEmpty)
TextSpan(text: ' ${metadata.destinationIP}', style: styles.muted),
TextSpan(
text: ' ${metadata.destinationIP}',
style: styles.secondary,
),
],
),
);
@@ -188,8 +199,8 @@ class _TrackerInfoBody extends StatelessWidget {
Flexible(
child: TonalChip(
label: chain,
color: colorScheme.secondaryContainer,
foregroundColor: colorScheme.onSecondaryContainer,
color: context.raisedSurfaceLevel.colorOf(colorScheme),
foregroundColor: colorScheme.onSurfaceVariant,
onPressed: () => onClickKeyword?.call(chain),
),
),
@@ -197,7 +208,7 @@ class _TrackerInfoBody extends StatelessWidget {
),
],
),
if (source.isNotEmpty) Text(source, style: styles.muted),
if (source.isNotEmpty) Text(source, style: styles.secondary),
],
);
}
@@ -247,7 +258,7 @@ class TrackerInfoDetailView extends StatelessWidget {
horizontal: 16,
).copyWith(bottom: 20, top: context.contentTopPadding),
children: [
generateSectionV3(
generateSection(
title: appLocalizations.basicInfo,
items: _buildRows([
(appLocalizations.creationTime, trackerInfo.start.showFull),
@@ -258,7 +269,7 @@ class TrackerInfoDetailView extends StatelessWidget {
(appLocalizations.download, trackerInfo.download.traffic.show),
]),
),
generateSectionV3(
generateSection(
title: appLocalizations.address,
items: _buildRows([
(appLocalizations.host, metadata.host),
@@ -278,7 +289,7 @@ class TrackerInfoDetailView extends StatelessWidget {
(appLocalizations.remoteDestination, metadata.remoteDestination),
]),
),
generateSectionV3(
generateSection(
title: appLocalizations.proxies,
items: [
..._buildRows([
+33 -3
View File
@@ -5,6 +5,7 @@ import 'package:material_ui/material_ui.dart';
import 'package:super_sliver_list/super_sliver_list.dart';
import 'tracker_info_item.dart';
import 'tracker_info_table.dart';
class TrackerInfoListController extends ValueNotifier<TrackerInfosState> {
TrackerInfoListController() : super(const TrackerInfosState());
@@ -28,6 +29,7 @@ class TrackerInfoListController extends ValueNotifier<TrackerInfosState> {
value.trackerInfos,
trackerInfos,
pausedMaxRequestsLength,
keyOf: (trackerInfo) => trackerInfo.id,
),
);
}
@@ -44,6 +46,7 @@ class TrackerInfoListController extends ValueNotifier<TrackerInfosState> {
class TrackerInfoList extends StatelessWidget {
final List<TrackerInfo> trackerInfos;
final String detailTitle;
final RecordTable<TrackerInfo>? table;
final ScrollController? controller;
final bool reverse;
final bool shrinkWrap;
@@ -55,6 +58,7 @@ class TrackerInfoList extends StatelessWidget {
super.key,
required this.trackerInfos,
required this.detailTitle,
this.table,
this.controller,
this.reverse = false,
this.shrinkWrap = false,
@@ -72,13 +76,17 @@ class TrackerInfoList extends StatelessWidget {
controller: controller,
padding: padding,
itemCount: trackerInfos.length,
extentEstimation: zeroDividerExtentEstimation,
separatorBuilder: (_, _) => const Divider(height: 0),
extentEstimation: table?.estimateExtent ?? recordListExtentEstimation,
separatorBuilder: (_, _) => table == null
? const SizedBox(height: recordListGap)
: const SizedBox.shrink(),
itemBuilder: (_, index) => _buildTrackerInfoItem(
context,
trackerInfos[index],
isLive: false,
detailTitle: detailTitle,
table: table,
striped: index.isOdd,
actionBuilder: actionBuilder,
),
);
@@ -90,6 +98,7 @@ class TrackerInfoList extends StatelessWidget {
class TrackerInfoAnimatedList extends StatelessWidget {
final List<TrackerInfo> trackerInfos;
final String detailTitle;
final RecordTable<TrackerInfo>? table;
final ScrollController? controller;
final EdgeInsetsGeometry? padding;
final Widget? Function(TrackerInfo trackerInfo)? actionBuilder;
@@ -98,6 +107,7 @@ class TrackerInfoAnimatedList extends StatelessWidget {
super.key,
required this.trackerInfos,
required this.detailTitle,
this.table,
this.controller,
this.padding,
this.actionBuilder,
@@ -105,17 +115,26 @@ class TrackerInfoAnimatedList extends StatelessWidget {
@override
Widget build(BuildContext context) {
final table = this.table;
final positions = table == null
? const <String, int>{}
: {
for (final (index, trackerInfo) in trackerInfos.indexed)
trackerInfo.id: index,
};
return KeyedAnimatedList<TrackerInfo>(
controller: controller,
padding: padding,
items: trackerInfos,
keyOf: (trackerInfo) => trackerInfo.id,
separator: const Divider(height: 0),
separator: table == null ? const SizedBox(height: recordListGap) : null,
itemBuilder: (context, trackerInfo) => _buildTrackerInfoItem(
context,
trackerInfo,
isLive: true,
detailTitle: detailTitle,
table: table,
striped: positions[trackerInfo.id]?.isOdd ?? false,
actionBuilder: actionBuilder,
),
);
@@ -127,8 +146,19 @@ Widget _buildTrackerInfoItem(
TrackerInfo trackerInfo, {
required bool isLive,
required String detailTitle,
required RecordTable<TrackerInfo>? table,
required bool striped,
required Widget? Function(TrackerInfo trackerInfo)? actionBuilder,
}) {
if (table != null) {
return TrackerInfoTableRow(
key: Key(trackerInfo.id),
trackerInfo: trackerInfo,
table: table,
striped: striped,
detailTitle: detailTitle,
);
}
return TrackerInfoItem(
key: Key(trackerInfo.id),
trackerInfo: trackerInfo,
@@ -0,0 +1,249 @@
import 'dart:math';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'tracker_info_item.dart';
const _fullLiveTableWidth = 1200.0;
List<RecordColumn<TrackerInfo>> trackerInfoColumns(
BuildContext context, {
required double width,
required bool isLive,
Widget? Function(TrackerInfo trackerInfo)? actionBuilder,
}) {
final appLocalizations = context.appLocalizations;
final wide = width >= recordWideTableWidth;
// A live table spends its first extra width on the totals.
final detailed = wide && (!isLive || width >= _fullLiveTableWidth);
double totalWidth(String label) => max(88, recordHeaderWidth(context, label));
return [
if (!isLive)
RecordColumn(
TableColumn('time', appLocalizations.time, width: 80),
cell: (_, info) =>
RecordCellText(info.start.showTime.trim(), muted: true),
sortKey: (info) => info.start,
descendingFirst: true,
),
RecordColumn(
TableColumn('process', appLocalizations.process, width: wide ? 160 : 140),
cell: (_, info) => RecordCellText(info.progressText),
sortKey: (info) => info.progressText.toLowerCase(),
),
RecordColumn(
TableColumn('target', appLocalizations.target, flex: wide ? 1.4 : 1.3),
cell: (_, info) => TrackerTargetText(info),
sortKey: (info) => info.title.toLowerCase(),
),
if (detailed)
RecordColumn(
TableColumn(
'protocol',
appLocalizations.protocol,
width: max(56, recordHeaderWidth(context, appLocalizations.protocol)),
),
cell: (_, info) =>
RecordCellText(info.metadata.network.toUpperCase(), muted: true),
sortKey: (info) => info.metadata.network.toLowerCase(),
),
if (wide || !isLive)
RecordColumn(
TableColumn('rule', appLocalizations.rule, flex: 1),
cell: (_, info) => TrackerRuleText(info),
sortKey: (info) => '${info.rule} ${info.rulePayload}'.toLowerCase(),
),
RecordColumn(
TableColumn('exit', appLocalizations.outboundExit, width: 120),
cell: (_, info) => _ExitChip(exit: info.chains.firstOrNull ?? ''),
sortKey: (info) => (info.chains.firstOrNull ?? '').toLowerCase(),
),
if (isLive) ...[
_amountColumn(
'downloadSpeed',
appLocalizations.download,
(info) => info.downloadSpeed ?? 0,
perSecond: true,
),
_amountColumn(
'uploadSpeed',
appLocalizations.upload,
(info) => info.uploadSpeed ?? 0,
perSecond: true,
),
],
if (isLive && wide) ...[
_amountColumn(
'download',
appLocalizations.totalDownload,
(info) => info.download,
width: totalWidth(appLocalizations.totalDownload),
),
_amountColumn(
'upload',
appLocalizations.totalUpload,
(info) => info.upload,
width: totalWidth(appLocalizations.totalUpload),
),
] else if (wide) ...[
_amountColumn(
'download',
appLocalizations.download,
(info) => info.download,
),
_amountColumn('upload', appLocalizations.upload, (info) => info.upload),
] else
_amountColumn(
'traffic',
appLocalizations.traffic,
(info) => info.upload + info.download,
width: 88,
),
if (isLive && detailed)
RecordColumn(
TableColumn(
'duration',
appLocalizations.duration,
width: 80,
end: true,
),
cell: (_, info) => RecordCellText(
trackerDurationText(DateTime.now().difference(info.start)),
muted: true,
),
sortKey: (info) => -info.start.microsecondsSinceEpoch,
descendingFirst: true,
),
if (actionBuilder != null)
RecordColumn(
const TableColumn('action', '', width: 44, resizable: false),
cell: (_, info) => actionBuilder(info) ?? const SizedBox.shrink(),
),
];
}
RecordColumn<TrackerInfo> _amountColumn(
String id,
String label,
int Function(TrackerInfo info) amountOf, {
double width = 100,
bool perSecond = false,
}) {
return RecordColumn(
TableColumn(id, label, width: width, end: true),
cell: (_, info) {
final amount = amountOf(info);
return RecordCellText(
perSecond ? '${amount.traffic.show}/s' : amount.traffic.show,
muted: perSecond && amount == 0,
);
},
sortKey: amountOf,
descendingFirst: true,
);
}
String trackerDurationText(Duration duration) {
final seconds = duration.inSeconds;
if (seconds < 60) {
return '${seconds < 0 ? 0 : seconds} s';
}
final minutes = seconds / 60;
if (minutes < 60) {
return '${minutes < 10 ? minutes.toStringAsFixed(1) : minutes.round()} min';
}
return '${(minutes / 60).toStringAsFixed(1)} h';
}
class TrackerInfoTableRow extends StatelessWidget {
final TrackerInfo trackerInfo;
final RecordTable<TrackerInfo> table;
final bool striped;
final String detailTitle;
const TrackerInfoTableRow({
super.key,
required this.trackerInfo,
required this.table,
required this.striped,
required this.detailTitle,
});
@override
Widget build(BuildContext context) {
return RecordTableRow(
table: table,
striped: striped,
onTap: () => showTrackerInfoDetail(context, trackerInfo, detailTitle),
cells: table.cellsOf(context, trackerInfo),
);
}
}
class _ExitChip extends StatelessWidget {
final String exit;
const _ExitChip({required this.exit});
@override
Widget build(BuildContext context) {
if (exit.isEmpty) {
return const RecordCellText('');
}
final colorScheme = context.colorScheme;
return TonalChip(
label: exit,
color: context.raisedSurfaceLevel.colorOf(colorScheme),
foregroundColor: colorScheme.onSurfaceVariant,
onPressed: () => context.commonScaffoldState?.addKeyword(exit),
);
}
}
class TrackerTargetText extends StatelessWidget {
final TrackerInfo trackerInfo;
const TrackerTargetText(this.trackerInfo, {super.key});
@override
Widget build(BuildContext context) {
final port = trackerInfo.metadata.destinationPort;
return Text.rich(
TextSpan(
children: [
TextSpan(text: trackerInfo.title),
if (port.isNotEmpty)
TextSpan(
text: ':$port',
style: TextStyle(color: context.colorScheme.onSurfaceVariant),
),
],
),
);
}
}
class TrackerRuleText extends StatelessWidget {
final TrackerInfo trackerInfo;
const TrackerRuleText(this.trackerInfo, {super.key});
@override
Widget build(BuildContext context) {
return Text.rich(
TextSpan(
children: [
TextSpan(
text: trackerInfo.rule,
style: TextStyle(color: context.colorScheme.onSurfaceVariant),
),
if (trackerInfo.rulePayload.isNotEmpty)
TextSpan(text: ' ${trackerInfo.rulePayload}'),
],
),
);
}
}
@@ -40,10 +40,13 @@ class TrackerSpeedRanker {
..clear()
..addAll(samples);
int bucketOf(TrackerInfo trackerInfo) {
final speed = samples[trackerInfo.id]!.rankSpeed;
return speed < 1 ? 0 : (log(speed) / ln2).floor() + 1;
}
final buckets = {
for (final MapEntry(key: id, value: sample) in samples.entries)
id: sample.rankSpeed < 1
? 0
: (log(sample.rankSpeed) / ln2).floor() + 1,
};
int bucketOf(TrackerInfo trackerInfo) => buckets[trackerInfo.id]!;
final ranked =
[
+6
View File
@@ -134,6 +134,12 @@ class _CoreContainerState extends ConsumerState<CoreManager>
super.onRequest(trackerInfo);
}
@override
void onRequestTraffic(Map<String, RequestTraffic> traffic) {
ref.read(requestsProvider.notifier).updateTraffic(traffic);
super.onRequestTraffic(traffic);
}
@override
void onDns(DnsQuery dnsQuery) {
ref.read(dnsQueriesProvider.notifier).addQuery(dnsQuery);
+2
View File
@@ -125,6 +125,8 @@ abstract class TrackerInfo with _$TrackerInfo {
_$TrackerInfoFromJson(json);
}
typedef RequestTraffic = ({int upload, int download});
extension TrackerInfoExt on TrackerInfo {
String get title {
final host = metadata.host;
+1
View File
@@ -237,6 +237,7 @@ const _$CoreEventTypeEnumMap = {
CoreEventType.log: 'log',
CoreEventType.delay: 'delay',
CoreEventType.request: 'request',
CoreEventType.requestTraffic: 'requestTraffic',
CoreEventType.dns: 'dns',
CoreEventType.loaded: 'loaded',
CoreEventType.crash: 'crash',
+25
View File
@@ -62,6 +62,31 @@ class Requests extends _$Requests with AutoDisposeNotifierMixin {
}
this.value = state.append(value);
}
void updateTraffic(Map<String, RequestTraffic> traffic) {
if (!ref.mounted) {
return;
}
final pending = Map.of(traffic);
final updated = <int, TrackerInfo>{};
for (
var index = state.length - 1;
index >= 0 && pending.isNotEmpty;
index--
) {
final request = state[index];
final item = pending.remove(request.id);
if (item != null) {
updated[index] = request.copyWith(
upload: item.upload,
download: item.download,
);
}
}
if (updated.isNotEmpty) {
value = state.replace(updated);
}
}
}
@Riverpod(keepAlive: true)
+243
View File
@@ -0,0 +1,243 @@
import 'package:fl_clash/ui/shape.dart';
import 'package:material_ui/material_ui.dart';
const tableColumnMinWidth = 48.0;
const _resizeHandleWidth = 12.0;
class TableColumn {
const TableColumn(
this.id,
this.label, {
this.width,
this.flex = 0,
this.end = false,
this.resizable = true,
}) : assert((width == null) != (flex == 0));
final String id;
final String label;
final double? width;
final double flex;
final bool end;
final bool resizable;
}
List<double> tableColumnWidths(
List<TableColumn> columns,
double width, [
Map<String, double> overrides = const {},
]) {
final sized = [
for (final column in columns) overrides[column.id] ?? column.width,
];
var fixed = 0.0;
var flex = 0.0;
var flexCount = 0;
for (final (index, column) in columns.indexed) {
final size = sized[index];
if (size == null) {
flex += column.flex;
flexCount++;
} else {
fixed += size;
}
}
final free = width - fixed;
if (overrides.isEmpty ||
(flexCount > 0 && free >= flexCount * tableColumnMinWidth)) {
final share = free.clamp(0.0, double.infinity);
return [
for (final (index, column) in columns.indexed)
sized[index] ?? share * column.flex / flex,
];
}
// The window has shrunk below the dragged widths, or they left no column to
// take up the slack: everything scales to fit.
final natural = [for (final size in sized) size ?? tableColumnMinWidth];
final total = natural.fold<double>(0, (sum, size) => sum + size);
return [for (final size in natural) size * width / total];
}
Map<String, double> resizedTableColumns(
List<TableColumn> columns,
List<double> widths,
Map<String, double> overrides,
int boundary,
double delta,
) {
final resized = [...widths];
if (delta > 0) {
var rest = delta;
for (var index = boundary + 1; index < columns.length; index++) {
if (rest <= 0) {
break;
}
if (!columns[index].resizable) {
continue;
}
final taken = rest.clamp(
0.0,
(resized[index] - tableColumnMinWidth).clamp(0.0, double.infinity),
);
resized[index] -= taken;
rest -= taken;
}
resized[boundary] += delta - rest;
} else {
final given = (-delta).clamp(
0.0,
(resized[boundary] - tableColumnMinWidth).clamp(0.0, double.infinity),
);
resized[boundary] -= given;
resized[boundary + 1] += given;
}
return {
...overrides,
for (final (index, column) in columns.indexed)
if (resized[index] != widths[index]) column.id: resized[index],
};
}
class TableCells extends StatelessWidget {
const TableCells({
super.key,
required this.columns,
required this.widths,
required this.height,
required this.inset,
required this.cells,
});
final List<TableColumn> columns;
final List<double> widths;
final double height;
final double inset;
final List<Widget> cells;
@override
Widget build(BuildContext context) {
return SizedBox(
height: height,
child: Row(
children: [
for (final (index, cell) in cells.indexed)
SizedBox(
width: widths[index],
child: Padding(
padding: EdgeInsets.symmetric(horizontal: inset),
child: Align(
alignment: columns[index].end
? AlignmentDirectional.centerEnd
: AlignmentDirectional.centerStart,
child: cell,
),
),
),
],
),
);
}
}
class TableColumnResizer extends StatelessWidget {
const TableColumnResizer({
super.key,
required this.columns,
required this.widths,
required this.onResize,
required this.onResizeEnd,
required this.onReset,
required this.child,
});
final List<TableColumn> columns;
final List<double> widths;
final void Function(int boundary, double delta) onResize;
final VoidCallback onResizeEnd;
final VoidCallback onReset;
final Widget child;
@override
Widget build(BuildContext context) {
final direction = Directionality.of(context) == TextDirection.rtl ? -1 : 1;
final handles = <Widget>[];
var edge = 0.0;
for (var boundary = 0; boundary < columns.length - 1; boundary++) {
edge += widths[boundary];
if (!columns[boundary].resizable || !columns[boundary + 1].resizable) {
continue;
}
handles.add(
PositionedDirectional(
start: edge - _resizeHandleWidth / 2,
top: 0,
bottom: 0,
width: _resizeHandleWidth,
child: _ResizeHandle(
onDrag: (delta) => onResize(boundary, delta * direction),
onDragEnd: onResizeEnd,
onReset: onReset,
),
),
);
}
return Stack(children: [child, ...handles]);
}
}
class _ResizeHandle extends StatefulWidget {
const _ResizeHandle({
required this.onDrag,
required this.onDragEnd,
required this.onReset,
});
final ValueChanged<double> onDrag;
final VoidCallback onDragEnd;
final VoidCallback onReset;
@override
State<_ResizeHandle> createState() => _ResizeHandleState();
}
class _ResizeHandleState extends State<_ResizeHandle> {
var _hovered = false;
var _dragging = false;
void _endDrag() {
setState(() => _dragging = false);
widget.onDragEnd();
}
@override
Widget build(BuildContext context) {
return MouseRegion(
cursor: SystemMouseCursors.resizeColumn,
onEnter: (_) => setState(() => _hovered = true),
onExit: (_) => setState(() => _hovered = false),
child: GestureDetector(
behavior: HitTestBehavior.opaque,
onHorizontalDragStart: (_) => setState(() => _dragging = true),
onHorizontalDragUpdate: (details) => widget.onDrag(details.delta.dx),
onHorizontalDragEnd: (_) => _endDrag(),
onHorizontalDragCancel: _endDrag,
onDoubleTap: widget.onReset,
child: Center(
child: Visibility.maintain(
visible: _hovered || _dragging,
child: SizedBox(
width: 2,
height: 16,
child: DecoratedBox(
decoration: ShapeDecoration(
color: Theme.of(context).colorScheme.outline,
shape: AppShape.full,
),
),
),
),
),
),
);
}
}
+47 -23
View File
@@ -5,9 +5,9 @@ import 'package:fl_clash/core/controller.dart';
import 'package:fl_clash/core/method.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/features/features.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -36,6 +36,7 @@ class _ConnectionsViewState extends ConsumerState<ConnectionsView>
final _listController = TrackerInfoListController();
final _speedRanker = TrackerSpeedRanker();
late final ScrollController _scrollController;
var _loaded = false;
@override
void initState() {
@@ -66,7 +67,7 @@ class _ConnectionsViewState extends ConsumerState<ConnectionsView>
if (!isCurrent()) {
return;
}
updateWhenRouteSettled(() => _applyConnections(trackerInfos));
_showConnections(trackerInfos);
}
Future<void> _refreshConnections() async {
@@ -74,7 +75,7 @@ class _ConnectionsViewState extends ConsumerState<ConnectionsView>
if (!mounted) {
return;
}
_applyConnections(trackerInfos);
_showConnections(trackerInfos);
}
Future<List<TrackerInfo>?> _readConnections() async {
@@ -92,13 +93,21 @@ class _ConnectionsViewState extends ConsumerState<ConnectionsView>
}
}
void _applyConnections(List<TrackerInfo>? trackerInfos) {
void _showConnections(List<TrackerInfo>? trackerInfos) {
final readAt = DateTime.now();
if (!_loaded) {
setState(() => _loaded = true);
_applyConnections(trackerInfos, readAt);
return;
}
updateWhenRouteSettled(() => _applyConnections(trackerInfos, readAt));
}
void _applyConnections(List<TrackerInfo>? trackerInfos, DateTime readAt) {
if (trackerInfos == null) {
return;
}
_listController.setTrackerInfos(
_speedRanker.rank(trackerInfos, DateTime.now()),
);
_listController.setTrackerInfos(_speedRanker.rank(trackerInfos, readAt));
}
Future<void> _handleBlockConnection(String id) async {
@@ -127,28 +136,43 @@ 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(
isLoading: !_loaded,
isEmpty: connections.isEmpty,
isSearching: state.isSearching,
holdsArrival: true,
nullStatus: NullStatus(
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: (width) => trackerInfoColumns(
context,
width: width,
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,
),
),
);
@@ -170,9 +194,9 @@ class _BlockConnectionButton extends StatelessWidget {
message: context.appLocalizations.blockConnection,
child: Semantics(
button: true,
child: InkResponse(
child: InkWell(
onTap: onPressed,
radius: 14,
customBorder: AppShape.circle,
child: SizedBox.square(
dimension: 28,
child: Center(
+36 -28
View File
@@ -77,37 +77,45 @@ class _RequestsViewState extends ConsumerState<RequestsView>
label: appLocalizations.nullTip(appLocalizations.requests),
illustration: NullStatusIllustration.requests,
),
child: Align(
alignment: Alignment.topCenter,
child: FloatingScrollbar(
controller: _scrollController,
hintBuilder: (fraction) {
final index = (fraction * (requests.length - 1)).round();
return requests[index].start.showFull;
},
child: ScrollToEndBox(
child: RecordTableFrame<TrackerInfo>(
tableId: 'requests',
items: requests,
reverse: true,
columnsOf: (width) =>
trackerInfoColumns(context, width: width, 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,
),
),
),
),
+93 -105
View File
@@ -390,7 +390,7 @@ class _PolicyLink extends StatelessWidget {
}
}
class _ConsentLayout extends StatefulWidget {
class _ConsentLayout extends StatelessWidget {
final double maxWidth;
final double gutter;
final bool compact;
@@ -403,48 +403,12 @@ class _ConsentLayout extends StatefulWidget {
required this.child,
});
@override
State<_ConsentLayout> createState() => _ConsentLayoutState();
}
class _ConsentLayoutState extends State<_ConsentLayout> {
static const _endTolerance = 1.0;
bool _readToEnd = false;
bool _handleNotification(Notification notification) {
if (_readToEnd) {
return false;
}
final metrics = switch (notification) {
ScrollNotification(depth: 0, :final metrics) ||
ScrollMetricsNotification(depth: 0, :final metrics) => metrics,
_ => null,
};
if (metrics != null && metrics.extentAfter <= _endTolerance) {
setState(() {
_readToEnd = true;
});
}
return false;
}
@override
Widget build(BuildContext context) {
return Column(
children: [
Expanded(
child: NotificationListener<Notification>(
onNotification: _handleNotification,
child: widget.child,
),
),
_ConsentBar(
maxWidth: widget.maxWidth,
gutter: widget.gutter,
compact: widget.compact,
readToEnd: _readToEnd,
),
Expanded(child: child),
_ConsentBar(maxWidth: maxWidth, gutter: gutter, compact: compact),
],
);
}
@@ -454,13 +418,11 @@ class _ConsentBar extends StatelessWidget {
final double maxWidth;
final double gutter;
final bool compact;
final bool readToEnd;
const _ConsentBar({
required this.maxWidth,
required this.gutter,
required this.compact,
required this.readToEnd,
});
Widget _buildExitButton(BuildContext context) {
@@ -485,51 +447,32 @@ class _ConsentBar extends StatelessWidget {
}
}
Widget _buildAgreeButton(BuildContext context, {required String label}) {
Widget _buildAgreeButton(BuildContext context) {
return ElasticButton(
child: FilledButton(
onPressed: readToEnd ? () => _handleAgree(context) : null,
child: Text(label),
onPressed: () => _handleAgree(context),
child: Text(context.appLocalizations.agree),
),
);
}
Widget _buildCompactActions(BuildContext context) {
final appLocalizations = context.appLocalizations;
return Row(
children: [
Expanded(child: _buildExitButton(context)),
const SizedBox(width: 12),
Expanded(
child: _buildAgreeButton(
context,
label: readToEnd
? appLocalizations.agree
: appLocalizations.disclaimerReadToEnd,
),
),
Expanded(child: _buildAgreeButton(context)),
],
);
}
Widget _buildActions(BuildContext context) {
final appLocalizations = context.appLocalizations;
return Row(
children: [
Expanded(
child: readToEnd
? const SizedBox.shrink()
: Text(
appLocalizations.disclaimerReadToEnd,
style: AppTextStyle.subheadline
.of(context)
.copyWith(color: context.colorScheme.onSurfaceVariant),
),
),
const SizedBox(width: 12),
const Spacer(),
_buildExitButton(context),
const SizedBox(width: 12),
_buildAgreeButton(context, label: appLocalizations.agree),
_buildAgreeButton(context),
],
);
}
@@ -585,6 +528,7 @@ class _RestateDialog extends StatefulWidget {
class _RestateDialogState extends State<_RestateDialog> {
final _controller = TextEditingController();
bool _acknowledged = false;
@override
void dispose() {
@@ -608,56 +552,100 @@ class _RestateDialogState extends State<_RestateDialog> {
);
}
Widget _buildDialog({
required String title,
required String tip,
required String statement,
required bool canConfirm,
required Widget confirmation,
}) {
final appLocalizations = context.appLocalizations;
return CommonDialog(
title: title,
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(false);
},
child: Text(appLocalizations.cancel),
),
TextButton(
onPressed: canConfirm
? () {
Navigator.of(context).pop(true);
}
: null,
child: Text(appLocalizations.confirm),
),
],
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
tip,
style: AppTextStyle.subheadline
.of(context)
.copyWith(color: context.colorScheme.onSurfaceVariant),
),
const SizedBox(height: 12),
_buildStatement(statement),
const SizedBox(height: 16),
confirmation,
],
),
);
}
@override
Widget build(BuildContext context) {
final appLocalizations = context.appLocalizations;
final statement = appLocalizations.disclaimerNoServiceStatement;
// Typing the statement out takes hundreds of presses on a remote.
if (RemoteFocusAdapter.isEnabledOf(context)) {
return _buildDialog(
title: appLocalizations.disclaimerAcknowledgeTitle,
tip: appLocalizations.disclaimerAcknowledgeTip,
statement: statement,
canConfirm: _acknowledged,
confirmation: generateSection(
items: [
ListItem.checkbox(
title: Text(appLocalizations.disclaimerAcknowledgeLabel),
value: _acknowledged,
onChanged: (value) {
setState(() => _acknowledged = value ?? false);
},
),
],
),
);
}
return ValueListenableBuilder<TextEditingValue>(
valueListenable: _controller,
builder: (context, value, _) {
final restatement = _Restatement.of(value, statement);
return CommonDialog(
return _buildDialog(
title: appLocalizations.disclaimerRestateTitle,
actions: [
TextButton(
onPressed: () {
Navigator.of(context).pop(false);
},
child: Text(appLocalizations.cancel),
tip: appLocalizations.disclaimerRestateTip,
statement: statement,
canConfirm: restatement == _Restatement.complete,
confirmation: FilledInputTheme(
borderRadius: AppRadius.lg,
contentPadding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
TextButton(
onPressed: restatement == _Restatement.complete
? () {
Navigator.of(context).pop(true);
}
: null,
child: Text(appLocalizations.confirm),
child: TextField(
controller: _controller,
minLines: 3,
maxLines: null,
decoration: InputDecoration(
hintText: appLocalizations.disclaimerRestateHint,
errorText: restatement == _Restatement.diverged
? appLocalizations.disclaimerRestateMismatch
: null,
),
),
],
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
appLocalizations.disclaimerRestateTip,
style: AppTextStyle.subheadline
.of(context)
.copyWith(color: context.colorScheme.onSurfaceVariant),
),
const SizedBox(height: 12),
_buildStatement(statement),
const SizedBox(height: 16),
TextField(
controller: _controller,
minLines: 3,
maxLines: 6,
decoration: InputDecoration(
hintText: appLocalizations.disclaimerRestateHint,
errorText: restatement == _Restatement.diverged
? appLocalizations.disclaimerRestateMismatch
: null,
),
),
],
),
);
},
+195 -63
View File
@@ -113,45 +113,64 @@ 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: (width) => _dnsQueryColumns(
context,
wide: width >= recordWideTableWidth,
),
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 +182,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 +340,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 +364,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 +399,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 +407,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 +442,9 @@ class _ErrorRow extends StatelessWidget {
minVerticalPadding: 14,
title: Text(
error,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.error,
),
style: AppTextStyle.subheadline
.of(context)
.copyWith(color: context.colorScheme.error),
),
);
}
+15 -19
View File
@@ -2,10 +2,10 @@ import 'dart:async';
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/icons/icons.dart';
import 'package:fl_clash/models/models.dart';
import 'package:fl_clash/providers/providers.dart';
import 'package:fl_clash/state.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/widgets/widgets.dart';
import 'package:material_ui/material_ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@@ -156,12 +156,13 @@ class _LogsViewState extends ConsumerState<LogsView>
shrinkWrap: true,
controller: _scrollController,
padding: EdgeInsets.only(
top: context.appBarInset,
top: context.contentTopPadding,
bottom: 16 + BottomInsetScope.of(context),
),
itemCount: logs.length,
extentEstimation: zeroDividerExtentEstimation,
separatorBuilder: (_, _) => const Divider(height: 0),
extentEstimation: recordListExtentEstimation,
separatorBuilder: (_, _) =>
const SizedBox(height: recordListGap),
itemBuilder: (_, index) {
final log = logs[index];
return LogItem(
@@ -193,12 +194,10 @@ class LogItem extends StatelessWidget {
final tone = switch (log.logLevel) {
LogLevel.warning => RecordTone.warning,
LogLevel.error => RecordTone.error,
LogLevel.info => RecordTone.neutral,
LogLevel.debug || LogLevel.silent => RecordTone.muted,
LogLevel.info || LogLevel.debug || LogLevel.silent => RecordTone.neutral,
};
final payload = LogPayload.parse(log.payload);
return RecordListItem(
tone: tone,
onTap: () {},
header: RecordHeader(
children: [
@@ -235,35 +234,32 @@ class _LogBody extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SelectableText(payload.message, style: styles.primary),
if (payload.tag.isNotEmpty) Text(payload.tag, style: styles.muted),
if (payload.tag.isNotEmpty)
Text(payload.tag, style: styles.secondary),
],
);
}
return SelectableText.rich(
TextSpan(
children: [
TextSpan(
text: route.destination,
style: styles.primary?.copyWith(fontWeight: FontWeight.w500),
),
TextSpan(text: route.destination, style: styles.title),
if (route.error.isNotEmpty)
TextSpan(
text: '\n${route.error}',
style: styles.secondary?.copyWith(color: errorColor),
style: styles.secondary.copyWith(color: errorColor),
),
const TextSpan(text: '\n'),
if (route.rule.isNotEmpty) ...[
TextSpan(text: route.rule, style: styles.secondary),
TextSpan(text: ' \u2192 ', style: styles.muted?.toJetBrainsMono),
TextSpan(text: ' \u2192 ', style: styles.secondary.toJetBrainsMono),
],
TextSpan(
text: route.proxy,
style: styles.secondary?.copyWith(
color: context.colorScheme.primary,
fontWeight: FontWeight.w500,
),
style: AppTextStyle.footnote
.of(context, emphasized: true)
.copyWith(color: context.colorScheme.primary),
),
TextSpan(text: '\n$_sourceText', style: styles.muted),
TextSpan(text: '\n$_sourceText', style: styles.secondary),
],
),
);
+71 -59
View File
@@ -1,69 +1,93 @@
import 'package:fl_clash/common/common.dart';
import 'package:fl_clash/enum/enum.dart';
import 'package:fl_clash/ui/ui.dart';
import 'package:flutter/services.dart';
import 'package:material_ui/material_ui.dart';
import 'chip.dart';
import 'list.dart';
class RecordTextStyles {
final TextStyle? primary;
final TextStyle? secondary;
final TextStyle? muted;
final TextStyle title;
final TextStyle primary;
final TextStyle secondary;
const RecordTextStyles._({this.primary, this.secondary, this.muted});
const RecordTextStyles._({
required this.title,
required this.primary,
required this.secondary,
});
factory RecordTextStyles.of(BuildContext context) {
final colorScheme = context.colorScheme;
final textTheme = context.textTheme;
final secondary = textTheme.bodySmall?.copyWith(
color: colorScheme.onSurfaceVariant,
);
return RecordTextStyles._(
primary: textTheme.bodyMedium?.copyWith(color: colorScheme.onSurface),
secondary: secondary,
muted: secondary?.copyWith(color: colorScheme.outline),
title: AppTextStyle.subheadline
.of(context, emphasized: true)
.copyWith(color: colorScheme.onSurface),
primary: AppTextStyle.subheadline
.of(context)
.copyWith(color: colorScheme.onSurface),
secondary: AppTextStyle.footnote
.of(context)
.copyWith(color: colorScheme.onSurfaceVariant),
);
}
}
const recordListGap = 8.0;
double recordListExtentEstimation(int? index, double crossAxisExtent) {
return switch (index) {
// SuperSliverList gives every child the null estimate unless it is 0.
null => 0,
_ when index.isEven => 100,
_ => recordListGap,
};
}
class RecordListItem extends StatelessWidget {
final Widget header;
final Widget body;
final RecordTone tone;
final VoidCallback? onTap;
const RecordListItem({
super.key,
required this.header,
required this.body,
this.tone = RecordTone.neutral,
this.onTap,
});
@override
Widget build(BuildContext context) {
final item = ListItem(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 12),
minVerticalPadding: 0,
minTileHeight: 0,
horizontalTitleGap: 12,
tileTitleAlignment: ListTileTitleAlignment.top,
color: tone.tintColor(context),
onTap: onTap,
title: header,
subtitle: Padding(padding: const EdgeInsets.only(top: 6), child: body),
);
final accentColor = tone.accentColor(context);
if (accentColor == null) {
return item;
}
return DecoratedBox(
position: DecorationPosition.foreground,
decoration: BoxDecoration(
border: Border(left: BorderSide(color: accentColor, width: 3)),
const shape = AppShape.xl;
final level = context.raisedSurfaceLevel;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: DecoratedBox(
decoration: ShapeDecoration(
color: level.colorOf(context.colorScheme),
shape: shape,
),
child: Material(
type: MaterialType.transparency,
child: SurfaceScope(
level: level,
child: ListTileTheme.merge(
shape: shape,
child: ListItem(
padding: const EdgeInsets.fromLTRB(16, 10, 16, 12),
minVerticalPadding: 0,
minTileHeight: 0,
horizontalTitleGap: 12,
tileTitleAlignment: ListTileTitleAlignment.top,
onTap: onTap,
title: header,
subtitle: Padding(
padding: const EdgeInsets.only(top: 6),
child: body,
),
),
),
),
),
),
child: item,
);
}
}
@@ -78,11 +102,10 @@ class RecordHeader extends StatelessWidget {
Widget build(BuildContext context) {
final trailing = this.trailing;
return DefaultTextStyle.merge(
style: context.textTheme.labelMedium?.copyWith(
color: context.colorScheme.onSurfaceVariant,
fontWeight: FontWeight.w400,
fontFeatures: const [FontFeature.tabularFigures()],
),
style: AppTextStyle.caption1
.of(context)
.toTabular
.copyWith(color: context.colorScheme.onSurfaceVariant),
child: SizedBox(
width: double.infinity,
child: Wrap(
@@ -135,20 +158,7 @@ class RecordTimestamp extends StatelessWidget {
@override
Widget build(BuildContext context) {
final split = dateTime.lastIndexOf(' ');
return Text.rich(
TextSpan(
children: [
if (split > 0)
TextSpan(
text: dateTime.substring(0, split + 1),
style: TextStyle(color: context.colorScheme.outline),
),
TextSpan(text: split > 0 ? dateTime.substring(split + 1) : dateTime),
],
),
maxLines: 1,
);
return Text(dateTime, maxLines: 1);
}
}
@@ -159,7 +169,7 @@ class RecordArrow extends StatelessWidget {
Widget build(BuildContext context) {
return Text(
'→',
style: RecordTextStyles.of(context).muted?.toJetBrainsMono,
style: RecordTextStyles.of(context).secondary.toJetBrainsMono,
);
}
}
@@ -199,9 +209,11 @@ class DetailRow extends StatelessWidget {
alignment: Alignment.centerRight,
child: DefaultTextStyle.merge(
textAlign: TextAlign.end,
style: context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.onSurfaceVariant,
),
style: AppTextStyle.subheadline
.of(context)
.copyWith(
color: context.colorScheme.onSurfaceVariant,
),
child: value,
),
),
+482
View File
@@ -0,0 +1,482 @@
import 'package:collection/collection.dart';
import 'package:fl_clash/providers/config.dart';
import 'package:fl_clash/ui/icons/icons.dart';
import 'package:fl_clash/ui/ui.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:material_ui/material_ui.dart';
const _recordTableMinWidth = 720.0;
const recordWideTableWidth = 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(double width) 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);
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,
);
}
}