From fb6ec3af3766990721725d426ee83ff70b554ab4 Mon Sep 17 00:00:00 2001 From: Mikkeli Date: Wed, 26 Aug 2026 19:03:22 +0900 Subject: [PATCH] feat(ui): show live output, add a stop button and duration/stream inputs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The screen could only start a 10s single-stream test and read the result afterwards. It now renders the transcript as it arrives in a scrolling monospace view that follows the newest line, exposes the duration and parallel-stream settings TestConfiguration already carried, can stop a run in progress, and copies the transcript to the clipboard. The output field previously built a TextEditingController inside build(), so every rebuild — one per output line now that output streams — discarded and replaced it. Replaced with SelectableText, which needs no controller. Seeds the inputs from TestConfiguration's own defaults rather than a second hardcoded server address, so there is one default to change. Co-Authored-By: Claude Opus 5 --- lib/ui/features/test_run/test_run_screen.dart | 231 ++++++++++++------ 1 file changed, 158 insertions(+), 73 deletions(-) diff --git a/lib/ui/features/test_run/test_run_screen.dart b/lib/ui/features/test_run/test_run_screen.dart index 715ca5f..56285ca 100644 --- a/lib/ui/features/test_run/test_run_screen.dart +++ b/lib/ui/features/test_run/test_run_screen.dart @@ -1,4 +1,5 @@ import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import 'package:iperf3gui/data/services/iperf3_service.dart'; import 'package:iperf3gui/domain/models/test_configuration.dart'; @@ -6,8 +7,8 @@ import 'package:iperf3gui/ui/features/test_run/test_run_view_model.dart'; /// Main test run screen. /// -/// iperf3 is now compiled as a native shared library (libiperf3jni.so) -/// and accessed via JNI. No binary extraction or chmod needed. +/// iperf3 is compiled as a native shared library (libiperf3jni.so) and accessed +/// via JNI, so there is no binary extraction or chmod step. class TestRunScreen extends StatelessWidget { const TestRunScreen({super.key}); @@ -30,109 +31,193 @@ class _TestRunContent extends StatefulWidget { } class _TestRunContentState extends State<_TestRunContent> { - final _serverController = TextEditingController(text: 'speedtest.uztelecom.uz'); - final _portController = TextEditingController(text: '5201'); + 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( + 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: [ - // Server input TextField( controller: _serverController, + autocorrect: false, decoration: const InputDecoration( labelText: 'Server', hintText: 'e.g. iperf.es.net or 192.168.1.1', ), ), - const SizedBox(height: 16), + const SizedBox(height: 12), - // Port input - TextField( - controller: _portController, - decoration: const InputDecoration( - labelText: 'Port', - hintText: '5201', - ), - keyboardType: TextInputType.number, - ), - const SizedBox(height: 16), - - // Go button - Consumer( - builder: (context, vm, _) { - return ElevatedButton( - onPressed: vm.state == TestRunState.running - ? null - : () { - final config = TestConfiguration( - server: _serverController.text, - port: int.tryParse(_portController.text) ?? 5201, - ); - vm.runTest(config); - }, - child: vm.state == TestRunState.running - ? const SizedBox( - width: 20, - height: 20, - child: CircularProgressIndicator(strokeWidth: 2), - ) - : const Text('GO'), - ); - }, - ), - const SizedBox(height: 16), - - // Status indicator - Consumer( - builder: (context, vm, _) { - final statusText = switch (vm.state) { - TestRunState.idle => '', - TestRunState.running => 'Running...', - TestRunState.completed => 'Test complete', - TestRunState.error => 'Error', - }; - return Text( - statusText, - style: TextStyle( - color: vm.state == TestRunState.error - ? Colors.red - : Colors.grey, + 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), - // Raw output text box - Expanded( - child: Consumer( - builder: (context, vm, _) { - final output = vm.result?.rawOutput ?? ''; - return TextField( - controller: TextEditingController(text: output), - decoration: const InputDecoration( - labelText: 'Raw Output', - border: OutlineInputBorder(), - ), - maxLines: null, - readOnly: true, + Consumer( + 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( + 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 => ('Test complete', 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 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( + 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, + ), + ), + ); + }, + ), ), ), ],