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( 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. iperf.es.net 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( 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 => ( 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( 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( 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()]), ), ], ); } }