feat(views): show connections, requests and DNS as tables
Changelog: Sortable tables for connections, requests and DNS
This commit is contained in:
+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,
|
||||
@@ -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,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,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 =
|
||||
[
|
||||
|
||||
@@ -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';
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 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,
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user