import 'dart:math' as math; import 'package:flutter/material.dart'; import 'package:intl/intl.dart' show DateFormat, NumberFormat; import '../l10n/strings.dart'; import '../services/net/measure.dart'; import 'adaptive.dart'; import 'theme.dart'; /// Series colors: IPv4 blue, IPv6 violet, failures red. Color familyColor(Family f) => f == Family.v4 ? Brand.blue : Brand.violet; TextStyle mutedStyle(BuildContext context) => TextStyle( color: Theme.of(context).colorScheme.onSurfaceVariant, fontSize: 12, ); const tabular = [FontFeature.tabularFigures()]; String formatMs(BuildContext context, double v) => NumberFormat.decimalPatternDigits( locale: Localizations.localeOf(context).toString(), decimalDigits: v < 10 ? 2 : 1, ).format(v); String formatPercent(BuildContext context, double v) => '${NumberFormat.decimalPatternDigits(locale: Localizations.localeOf(context).toString(), decimalDigits: 1).format(v)} %'; String formatElapsed(Duration d) { final h = d.inHours, m = d.inMinutes % 60, s = d.inSeconds % 60; String two(int v) => v.toString().padLeft(2, '0'); return h > 0 ? '$h:${two(m)}:${two(s)}' : '${two(m)}:${two(s)}'; } String outcomeKey(Outcome o) => switch (o) { Outcome.ok => 'outcomeOk', Outcome.timeout => 'outcomeTimeout', Outcome.refused => 'outcomeRefused', Outcome.unreachable => 'outcomeUnreachable', Outcome.ttlExceeded => 'outcomeTtlExceeded', Outcome.error => 'outcomeError', }; /// A labelled number. class Stat extends StatelessWidget { final String label, value; const Stat(this.label, this.value, {super.key}); @override Widget build(BuildContext context) => Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: mutedStyle(context)), Text( value, style: Theme.of( context, ).textTheme.titleMedium?.copyWith(fontFeatures: tabular), ), ], ); } /// The statistics of one series. RTT figures are shown only when at least /// one attempt succeeded. class StatsGrid extends StatelessWidget { final RttStats stats; const StatsGrid(this.stats, {super.key}); @override Widget build(BuildContext context) { String ms(double? v) => v == null ? '—' : '${formatMs(context, v)} ms'; return AdaptiveGrid( minTileWidth: 110, maxColumns: 6, spacing: 12, children: [ Stat(context.tr('attempts'), '${stats.attempts}'), Stat(context.tr('successful'), '${stats.successes}'), Stat(context.tr('failedCount'), '${stats.failures}'), Stat( context.tr('successRate'), stats.attempts == 0 ? '—' : formatPercent(context, stats.successPercent), ), Stat(context.tr('rttMin'), ms(stats.min)), Stat(context.tr('rttAvg'), ms(stats.mean)), Stat(context.tr('rttMax'), ms(stats.max)), Stat(context.tr('rttMedian'), ms(stats.median)), Stat(context.tr('rttStddev'), ms(stats.stddev)), Stat(context.tr('jitter'), ms(stats.jitter)), Stat('P95', ms(stats.p95)), Stat(context.tr('elapsedTime'), formatElapsed(stats.elapsed)), ], ); } } class ChartSeries { final String label; final Color color; final List samples; const ChartSeries(this.label, this.color, this.samples); } /// RTT over time for one or more series. Failed attempts are drawn as red /// marks along the top edge, not as invented RTT values. class RttChart extends StatelessWidget { final List series; final double height; const RttChart(this.series, {this.height = 180, super.key}); @override Widget build(BuildContext context) { final scheme = Theme.of(context).colorScheme; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Semantics( label: context.tr('chartTitle'), child: SizedBox( height: height, child: CustomPaint( painter: _RttPainter( series, grid: scheme.outlineVariant, label: scheme.onSurfaceVariant, failure: scheme.error, textDirection: Directionality.of(context), ), ), ), ), const SizedBox(height: 6), Wrap( spacing: 16, runSpacing: 4, children: [ for (final s in series) Row( mainAxisSize: MainAxisSize.min, children: [ Container(width: 12, height: 3, color: s.color), const SizedBox(width: 6), Text(s.label, style: mutedStyle(context)), ], ), Row( mainAxisSize: MainAxisSize.min, children: [ Container(width: 3, height: 10, color: scheme.error), const SizedBox(width: 6), Text(context.tr('chartFailures'), style: mutedStyle(context)), ], ), ], ), ], ); } } class _RttPainter extends CustomPainter { final List series; final Color grid, label, failure; final TextDirection textDirection; _RttPainter( this.series, { required this.grid, required this.label, required this.failure, required this.textDirection, }); @override void paint(Canvas canvas, Size size) { const left = 44.0, top = 10.0, bottom = 16.0, right = 6.0; final plot = Rect.fromLTRB( left, top, size.width - right, size.height - bottom, ); if (plot.width <= 0 || plot.height <= 0) return; final all = [for (final s in series) ...s.samples]; if (all.isEmpty) return; final start = all .map((s) => s.time) .reduce((a, b) => a.isBefore(b) ? a : b); final end = all.map((s) => s.time).reduce((a, b) => a.isAfter(b) ? a : b); final span = math.max(1, end.difference(start).inMilliseconds); final maxRtt = [ for (final s in all) if (s.ok) s.rttMs!, ].fold(0, math.max); final top0 = _niceCeiling(maxRtt <= 0 ? 1 : maxRtt * 1.1); final gridPaint = Paint() ..color = grid ..strokeWidth = 1; for (var i = 0; i <= 3; i++) { final y = plot.bottom - plot.height * i / 3; canvas.drawLine(Offset(plot.left, y), Offset(plot.right, y), gridPaint); final value = top0 * i / 3; final text = TextPainter( text: TextSpan( text: value >= 10 ? value.toStringAsFixed(0) : value.toStringAsFixed(1), style: TextStyle(color: label, fontSize: 10), ), textDirection: textDirection, )..layout(); text.paint(canvas, Offset(left - text.width - 6, y - text.height / 2)); } final unit = TextPainter( text: TextSpan( text: 'ms', style: TextStyle(color: label, fontSize: 10), ), textDirection: textDirection, )..layout(); unit.paint(canvas, Offset(2, plot.bottom + 2)); double x(Sample s) => plot.left + plot.width * (all.length == 1 ? 0.5 : s.time.difference(start).inMilliseconds / span); double y(double rtt) => plot.bottom - plot.height * (rtt / top0); final failPaint = Paint() ..color = failure ..strokeWidth = 2; for (final s in series) { final line = Paint() ..color = s.color ..strokeWidth = 1.6 ..style = PaintingStyle.stroke; final dot = Paint()..color = s.color; // At most one point per horizontal pixel keeps long runs cheap. final step = math.max(1, (s.samples.length / plot.width).ceil()); for (var i = 0; i < s.samples.length; i += step) { final sample = s.samples[i]; if (!sample.ok) { final fx = x(sample); canvas.drawLine( Offset(fx, plot.top), Offset(fx, plot.top + 8), failPaint, ); } else if (s.samples.length <= 120) { canvas.drawCircle(Offset(x(sample), y(sample.rttMs!)), 2, dot); } } // Draw each continuous stretch. _drawStretches(canvas, s, step, x, y, line); } } void _drawStretches( Canvas canvas, ChartSeries s, int step, double Function(Sample) x, double Function(double) y, Paint line, ) { Path? path; void flush() { if (path != null) canvas.drawPath(path!, line); path = null; } for (var i = 0; i < s.samples.length; i += step) { final sample = s.samples[i]; if (!sample.ok) { flush(); continue; } final p = Offset(x(sample), y(sample.rttMs!)); if (path == null) { path = Path()..moveTo(p.dx, p.dy); } else { path!.lineTo(p.dx, p.dy); } } flush(); } static double _niceCeiling(double v) { final magnitude = math .pow(10, (math.log(v) / math.ln10).floor()) .toDouble(); for (final f in [1, 2, 2.5, 5, 10]) { if (f * magnitude >= v) return f * magnitude; } return 10 * magnitude; } @override bool shouldRepaint(_RttPainter old) => true; } /// The attempts of one series, one row each, in a bounded scrolling box. class SampleLogView extends StatefulWidget { final List samples; final int dropped; const SampleLogView(this.samples, {this.dropped = 0, super.key}); @override State createState() => _SampleLogViewState(); } class _SampleLogViewState extends State { final scroll = ScrollController(); @override void didUpdateWidget(SampleLogView old) { super.didUpdateWidget(old); // Follows new attempts while the user is at the end of the list. if (scroll.hasClients && scroll.position.pixels >= scroll.position.maxScrollExtent - 40) { WidgetsBinding.instance.addPostFrameCallback((_) { if (scroll.hasClients) { scroll.jumpTo(scroll.position.maxScrollExtent); } }); } } @override void dispose() { scroll.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final error = Theme.of(context).colorScheme.error; final time = DateFormat.Hms(Localizations.localeOf(context).toString()); final samples = widget.samples; return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ if (widget.dropped > 0) Padding( padding: const EdgeInsets.only(bottom: 4), child: Text( '${context.tr('olderOmitted')}: ${widget.dropped}', style: mutedStyle(context), ), ), SizedBox( height: math.min(280, 30.0 * samples.length + 8), child: Scrollbar( controller: scroll, child: ListView.builder( controller: scroll, itemCount: samples.length, itemExtent: 30, itemBuilder: (context, i) { final s = samples[i]; final ok = s.outcome == Outcome.ok; return Row( children: [ SizedBox( width: 52, child: Text( '#${s.seq}', style: const TextStyle(fontFeatures: tabular), ), ), SizedBox( width: 72, child: Text( time.format(s.time.toLocal()), style: mutedStyle( context, ).copyWith(fontFeatures: tabular), ), ), Expanded( child: Text( '${s.family.label} ${formatEndpoint(s.address, s.port)}', maxLines: 1, overflow: TextOverflow.ellipsis, style: mutedStyle(context), ), ), const SizedBox(width: 8), Text( [ if (s.rttMs != null) '${formatMs(context, s.rttMs!)} ms', if (!ok) context.tr(outcomeKey(s.outcome)), if (!ok && s.detail != null) '(${s.detail})', ].join(' '), style: TextStyle( color: ok ? null : error, fontFeatures: tabular, ), ), ], ); }, ), ), ), ], ); } }