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>
173 lines
4.8 KiB
Dart
173 lines
4.8 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/foundation.dart';
|
|
import 'package:iperf3gui/domain/models/iperf3_event.dart';
|
|
import 'package:iperf3gui/domain/models/iperf3_result.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';
|
|
|
|
/// State of the test run.
|
|
enum TestRunState { idle, running, completed, error }
|
|
|
|
/// ViewModel for managing test run state.
|
|
///
|
|
/// Subscribes to [Iperf3Runner.events] so the transcript and the interval
|
|
/// series both grow while the test runs, rather than appearing at the end.
|
|
class TestRunViewModel extends ChangeNotifier {
|
|
final Iperf3Runner iperf3Service;
|
|
|
|
/// How often to notify listeners while output is arriving. iperf3 can report
|
|
/// several times a second across many streams, and each rebuild re-renders
|
|
/// the whole transcript — so repaints are coalesced instead of one per event.
|
|
static const Duration displayInterval = Duration(milliseconds: 250);
|
|
|
|
TestRunViewModel({required this.iperf3Service}) {
|
|
_eventSubscription = iperf3Service.events.listen(_onEvent);
|
|
}
|
|
|
|
StreamSubscription<Iperf3Event>? _eventSubscription;
|
|
Timer? _repaintTimer;
|
|
bool _dirty = false;
|
|
|
|
TestRunState _state = TestRunState.idle;
|
|
TestRunState get state => _state;
|
|
|
|
Iperf3Result? _result;
|
|
Iperf3Result? get result => _result;
|
|
|
|
TestConfiguration? _currentConfig;
|
|
TestConfiguration? get currentConfig => _currentConfig;
|
|
|
|
final StringBuffer _transcript = StringBuffer();
|
|
|
|
/// Human-readable output accumulated so far.
|
|
String get output => _transcript.toString();
|
|
|
|
final List<Iperf3Interval> _intervals = [];
|
|
|
|
/// Per-interval throughput samples, in arrival order. Structured, so a chart
|
|
/// can be driven from this without re-parsing text.
|
|
List<Iperf3Interval> get intervals => List.unmodifiable(_intervals);
|
|
|
|
Iperf3Start? _start;
|
|
|
|
/// Connection details reported at the start of the run.
|
|
Iperf3Start? get start => _start;
|
|
|
|
Iperf3End? _summary;
|
|
|
|
/// Totals reported at the end of the run.
|
|
Iperf3End? get summary => _summary;
|
|
|
|
/// Whether a test is currently in flight.
|
|
bool get isRunning => _state == TestRunState.running;
|
|
|
|
/// Whether the most recent finished run ended because the user stopped it.
|
|
bool get wasStopped => _result?.stopped ?? false;
|
|
|
|
/// Start a test with the given configuration.
|
|
Future<void> runTest(TestConfiguration config) async {
|
|
if (_state == TestRunState.running) return;
|
|
|
|
if (!config.isValid) {
|
|
_resetRunData();
|
|
_transcript
|
|
.writeln('Invalid configuration: check the server address and port.');
|
|
_state = TestRunState.error;
|
|
notifyListeners();
|
|
return;
|
|
}
|
|
|
|
_resetRunData();
|
|
_state = TestRunState.running;
|
|
_currentConfig = config;
|
|
notifyListeners();
|
|
|
|
try {
|
|
final result = await iperf3Service.run(config);
|
|
_result = result;
|
|
if (_transcript.isEmpty && result.rawOutput.isNotEmpty) {
|
|
_transcript.write(result.rawOutput);
|
|
}
|
|
_state =
|
|
result.exitCode == 0 ? TestRunState.completed : TestRunState.error;
|
|
} catch (e) {
|
|
_transcript.writeln('Test failed: $e');
|
|
_state = TestRunState.error;
|
|
} finally {
|
|
_stopRepaintTimer();
|
|
notifyListeners();
|
|
}
|
|
}
|
|
|
|
/// Request that a running test stop early.
|
|
Future<void> stopTest() async {
|
|
if (_state != TestRunState.running) return;
|
|
await iperf3Service.stop();
|
|
}
|
|
|
|
/// Reset to idle state.
|
|
void reset() {
|
|
_resetRunData();
|
|
_state = TestRunState.idle;
|
|
_currentConfig = null;
|
|
notifyListeners();
|
|
}
|
|
|
|
void _resetRunData() {
|
|
_result = null;
|
|
_transcript.clear();
|
|
_intervals.clear();
|
|
_start = null;
|
|
_summary = null;
|
|
_dirty = false;
|
|
}
|
|
|
|
void _onEvent(Iperf3Event event) {
|
|
switch (event) {
|
|
case Iperf3Start e:
|
|
_start = e;
|
|
case Iperf3Interval e:
|
|
// Omitted intervals are warm-up (-O) and are not part of the series.
|
|
if (!e.omitted) _intervals.add(e);
|
|
case Iperf3End e:
|
|
_summary = e;
|
|
case Iperf3Error _:
|
|
case Iperf3Stopped _:
|
|
case Iperf3LogLine _:
|
|
break;
|
|
}
|
|
|
|
final line = formatIperf3Event(event);
|
|
if (line != null && line.isNotEmpty) {
|
|
_transcript.writeln(line);
|
|
}
|
|
_scheduleRepaint();
|
|
}
|
|
|
|
/// Coalesce repaints: mark dirty and let a timer flush at [displayInterval].
|
|
void _scheduleRepaint() {
|
|
_dirty = true;
|
|
_repaintTimer ??= Timer.periodic(displayInterval, (_) {
|
|
if (!_dirty) return;
|
|
_dirty = false;
|
|
notifyListeners();
|
|
});
|
|
}
|
|
|
|
void _stopRepaintTimer() {
|
|
_repaintTimer?.cancel();
|
|
_repaintTimer = null;
|
|
_dirty = false;
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_stopRepaintTimer();
|
|
_eventSubscription?.cancel();
|
|
iperf3Service.dispose();
|
|
super.dispose();
|
|
}
|
|
}
|