314a3ea059
The presentation layer imported Iperf3Service directly, so the UI was welded to the MethodChannel transport: the view model named it, and the screen constructed one. Dependency direction was already inward-only, but the UI depended on an implementation rather than a contract. Iperf3Runner states that contract in the domain layer in five members, in pure Dart with no Flutter import, and Iperf3Service implements it. The view model and screen now take an Iperf3Runner; main.dart is the only file that names a concrete one, which makes it the composition root. Two consequences worth having. The UI can be driven against an in-memory runner with no MethodChannel, native library or device involved. And a test double implements five stable members rather than tracking every public member the transport happens to expose — the previous double, forced to implement the concrete class, broke twice while this branch's work was going on. Also drops flutter/foundation from the data layer: debugPrint gives way to dart:developer, leaving flutter/services (where MethodChannel lives) as that layer's only Flutter dependency. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
292 lines
10 KiB
Dart
292 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. 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<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, derived from the parsed interval series
|
|
Consumer<TestRunViewModel>(
|
|
builder: (context, vm, _) {
|
|
final intervals = vm.intervals;
|
|
if (intervals.isEmpty) return const SizedBox.shrink();
|
|
final rates =
|
|
intervals.map((i) => i.bitsPerSecond).toList(growable: false);
|
|
final latest = rates.last;
|
|
final peak = rates.reduce((a, b) => a > b ? a : b);
|
|
final mean = rates.reduce((a, b) => a + b) / rates.length;
|
|
final summary = vm.summary?.sent;
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
_Stat(
|
|
label: summary != null ? 'total' : 'now',
|
|
value: formatBitrate(summary?.bitsPerSecond ?? latest),
|
|
),
|
|
_Stat(label: 'avg', value: formatBitrate(mean)),
|
|
_Stat(label: 'peak', value: formatBitrate(peak)),
|
|
_Stat(label: 'samples', value: '${rates.length}'),
|
|
],
|
|
),
|
|
);
|
|
},
|
|
),
|
|
|
|
// 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()]),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|