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'; import 'package:iperf3gui/ui/features/test_run/test_run_view_model.dart'; /// Main test run screen. /// /// 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}); @override Widget build(BuildContext context) { return ChangeNotifierProvider( create: (_) => TestRunViewModel( iperf3Service: Iperf3Service(), ), 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 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, ), ), ); }, ), ), ), ], ), ), ); } }