895b963745
Consolidating the UI's seed values onto TestConfiguration's own defaults also silently changed the default target to iperf.es.net, which was not asked for. Sets it back in TestConfiguration, which is now the single definition the screen and the headless entry point both read. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
294 lines
10 KiB
Dart
294 lines
10 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:provider/provider.dart';
|
|
import 'package:iperf3gui/domain/models/test_configuration.dart';
|
|
import 'package:iperf3gui/domain/services/iperf3_runner.dart';
|
|
import 'package:iperf3gui/ui/features/test_run/iperf3_event_formatter.dart';
|
|
import 'package:iperf3gui/ui/features/test_run/test_run_view_model.dart';
|
|
|
|
/// Main test run screen.
|
|
///
|
|
/// Takes an [Iperf3Runner] rather than constructing one, so this layer names no
|
|
/// transport: the Android app injects the JNI-backed implementation from
|
|
/// main(), and a test injects a fake.
|
|
class TestRunScreen extends StatelessWidget {
|
|
const TestRunScreen({required this.runner, super.key});
|
|
|
|
final Iperf3Runner runner;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return ChangeNotifierProvider(
|
|
create: (_) => TestRunViewModel(iperf3Service: runner),
|
|
child: const _TestRunContent(),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TestRunContent extends StatefulWidget {
|
|
const _TestRunContent();
|
|
|
|
@override
|
|
State<_TestRunContent> createState() => _TestRunContentState();
|
|
}
|
|
|
|
class _TestRunContentState extends State<_TestRunContent> {
|
|
static const _defaults = TestConfiguration();
|
|
|
|
final _serverController =
|
|
TextEditingController(text: _defaults.server);
|
|
final _portController =
|
|
TextEditingController(text: _defaults.port.toString());
|
|
final _durationController =
|
|
TextEditingController(text: _defaults.duration.inSeconds.toString());
|
|
final _streamsController =
|
|
TextEditingController(text: _defaults.parallelStreams.toString());
|
|
final _outputScrollController = ScrollController();
|
|
|
|
@override
|
|
void dispose() {
|
|
_serverController.dispose();
|
|
_portController.dispose();
|
|
_durationController.dispose();
|
|
_streamsController.dispose();
|
|
_outputScrollController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
TestConfiguration _readConfiguration() {
|
|
return TestConfiguration(
|
|
server: _serverController.text.trim(),
|
|
port: int.tryParse(_portController.text) ?? _defaults.port,
|
|
duration: Duration(
|
|
seconds: int.tryParse(_durationController.text) ??
|
|
_defaults.duration.inSeconds,
|
|
),
|
|
parallelStreams:
|
|
int.tryParse(_streamsController.text) ?? _defaults.parallelStreams,
|
|
);
|
|
}
|
|
|
|
/// Keep the newest output in view, but only once the frame that added it has
|
|
/// been laid out.
|
|
void _scrollToBottom() {
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!_outputScrollController.hasClients) return;
|
|
_outputScrollController
|
|
.jumpTo(_outputScrollController.position.maxScrollExtent);
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Scaffold(
|
|
appBar: AppBar(
|
|
title: const Text('iperf3-gui'),
|
|
actions: [
|
|
Consumer<TestRunViewModel>(
|
|
builder: (context, vm, _) => IconButton(
|
|
tooltip: 'Copy output',
|
|
icon: const Icon(Icons.copy_all),
|
|
onPressed: vm.output.isEmpty
|
|
? null
|
|
: () {
|
|
Clipboard.setData(ClipboardData(text: vm.output));
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text('Output copied')),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
body: Padding(
|
|
padding: const EdgeInsets.all(16.0),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
|
children: [
|
|
TextField(
|
|
controller: _serverController,
|
|
autocorrect: false,
|
|
decoration: const InputDecoration(
|
|
labelText: 'Server',
|
|
hintText: 'e.g. speedtest.uztelecom.uz or 192.168.1.1',
|
|
),
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _portController,
|
|
keyboardType: TextInputType.number,
|
|
decoration: const InputDecoration(labelText: 'Port'),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _durationController,
|
|
keyboardType: TextInputType.number,
|
|
decoration: const InputDecoration(labelText: 'Seconds'),
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _streamsController,
|
|
keyboardType: TextInputType.number,
|
|
decoration: const InputDecoration(labelText: 'Streams'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
Consumer<TestRunViewModel>(
|
|
builder: (context, vm, _) {
|
|
if (vm.isRunning) {
|
|
return OutlinedButton.icon(
|
|
onPressed: vm.stopTest,
|
|
icon: const Icon(Icons.stop),
|
|
label: const Text('STOP'),
|
|
);
|
|
}
|
|
return ElevatedButton(
|
|
onPressed: () => vm.runTest(_readConfiguration()),
|
|
child: const Text('GO'),
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
// Status line
|
|
Consumer<TestRunViewModel>(
|
|
builder: (context, vm, _) {
|
|
final scheme = Theme.of(context).colorScheme;
|
|
final (String label, Color color) = switch (vm.state) {
|
|
TestRunState.idle => ('Ready', scheme.onSurfaceVariant),
|
|
TestRunState.running => ('Running…', scheme.primary),
|
|
TestRunState.completed => (
|
|
vm.wasStopped ? 'Stopped' : 'Test complete',
|
|
vm.wasStopped ? scheme.onSurfaceVariant : scheme.primary,
|
|
),
|
|
TestRunState.error => (
|
|
'Error${vm.result != null ? ' (exit ${vm.result!.exitCode})' : ''}',
|
|
scheme.error,
|
|
),
|
|
};
|
|
return Row(
|
|
children: [
|
|
if (vm.isRunning)
|
|
const Padding(
|
|
padding: EdgeInsets.only(right: 8),
|
|
child: SizedBox(
|
|
width: 14,
|
|
height: 14,
|
|
child: CircularProgressIndicator(strokeWidth: 2),
|
|
),
|
|
),
|
|
Text(label, style: TextStyle(color: color)),
|
|
],
|
|
);
|
|
},
|
|
),
|
|
const SizedBox(height: 12),
|
|
|
|
// Live throughput figures. Every number here is read off the
|
|
// session; none are computed in the widget tree.
|
|
Consumer<TestRunViewModel>(
|
|
builder: (context, vm, _) {
|
|
final session = vm.session;
|
|
final latest = session.latestBitsPerSecond;
|
|
final mean = session.meanBitsPerSecond;
|
|
final peak = session.peakBitsPerSecond;
|
|
if (latest == null || mean == null || peak == null) {
|
|
return const SizedBox.shrink();
|
|
}
|
|
final total = session.summary?.sent;
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
_Stat(
|
|
label: total != null ? 'total' : 'now',
|
|
value: formatBitrate(total?.bitsPerSecond ?? latest),
|
|
),
|
|
_Stat(label: 'avg', value: formatBitrate(mean)),
|
|
_Stat(label: 'peak', value: formatBitrate(peak)),
|
|
_Stat(
|
|
label: 'samples', value: '${session.sampleCount}'),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
|
|
// Live output
|
|
Expanded(
|
|
child: Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(8),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(
|
|
color: Theme.of(context).colorScheme.outlineVariant,
|
|
),
|
|
borderRadius: BorderRadius.circular(4),
|
|
),
|
|
child: Consumer<TestRunViewModel>(
|
|
builder: (context, vm, _) {
|
|
_scrollToBottom();
|
|
return SingleChildScrollView(
|
|
controller: _outputScrollController,
|
|
child: SelectableText(
|
|
vm.output.isEmpty
|
|
? 'Output will appear here.'
|
|
: vm.output,
|
|
style: const TextStyle(
|
|
fontFamily: 'monospace',
|
|
fontSize: 12,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
/// One figure in the throughput row.
|
|
class _Stat extends StatelessWidget {
|
|
const _Stat({required this.label, required this.value});
|
|
|
|
final String label;
|
|
final String value;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final theme = Theme.of(context);
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
label,
|
|
style: theme.textTheme.labelSmall
|
|
?.copyWith(color: theme.colorScheme.onSurfaceVariant),
|
|
),
|
|
Text(
|
|
value,
|
|
style: theme.textTheme.titleSmall
|
|
?.copyWith(fontFeatures: const [FontFeature.tabularFigures()]),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|