Files
oott/frontend/lib/utils/backend_reachability.dart
T
rzuastiandClaude Opus 4.8 98b5fa267f Add automated test suite for the Flutter frontend
Introduce a headless `flutter test` suite (83 tests) covering models,
utilities, every API endpoint, and the five screens, with shared helpers
and fixtures so new tests stay terse.

API endpoint methods are statically-dispatched extensions on the
BackendAPI singleton, so they cannot be mocked via `implements`. Mock at
the Dio HTTP-adapter layer (http_mock_adapter) instead, enabled by two
small @visibleForTesting seams: BackendAPI.dioForTesting swaps the
singleton's Dio, and BackendReachability.forceOnlineForTesting() forces a
deterministic online state so polling widgets load.

Add frontend/run_tests.sh and document it in CLAUDE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:41:52 -04:00

107 lines
3.1 KiB
Dart

import 'dart:async';
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart';
import 'package:flutter/foundation.dart';
import 'oott_api.dart';
class BackendReachability extends ChangeNotifier {
BackendReachability._internal() {
_subscription = Connectivity().onConnectivityChanged.listen(
_handleConnectivityChange,
);
Connectivity().checkConnectivity().then(_handleConnectivityChange);
}
static final BackendReachability instance = BackendReachability._internal();
StreamSubscription<List<ConnectivityResult>>? _subscription;
Future<void> Function()? _prober;
bool _deviceHasNetwork = true;
bool _isBackendReachable = true;
DateTime? _lastSuccessAt;
DateTime? _lastFailureAt;
String? _lastErrorMessage;
bool _probing = false;
bool get deviceHasNetwork => _deviceHasNetwork;
bool get isBackendReachable => _isBackendReachable;
bool get isOnline => _deviceHasNetwork && _isBackendReachable;
bool get isProbing => _probing;
DateTime? get lastSuccessAt => _lastSuccessAt;
DateTime? get lastFailureAt => _lastFailureAt;
String? get lastErrorMessage => _lastErrorMessage;
void setProber(Future<void> Function() prober) {
_prober = prober;
}
/// Test-only hook to force a deterministic online state so polling widgets
/// load during tests (where connectivity_plus has no platform backing).
@visibleForTesting
void forceOnlineForTesting() {
_deviceHasNetwork = true;
_isBackendReachable = true;
_lastErrorMessage = null;
notifyListeners();
}
void recordSuccess() {
_lastSuccessAt = DateTime.now();
_lastErrorMessage = null;
final wasReachable = _isBackendReachable;
_isBackendReachable = true;
if (!wasReachable) notifyListeners();
}
void recordFailure(DioException error) {
if (!_isConnectionLevel(error)) return;
_lastFailureAt = DateTime.now();
_lastErrorMessage = dioErrorToUserMessage(error);
final wasReachable = _isBackendReachable;
_isBackendReachable = false;
if (wasReachable) notifyListeners();
}
Future<void> probe() async {
final prober = _prober;
if (prober == null || _probing) return;
_probing = true;
notifyListeners();
try {
await prober();
} catch (_) {
// Interceptor records the outcome; nothing else to do here.
} finally {
_probing = false;
notifyListeners();
}
}
void _handleConnectivityChange(List<ConnectivityResult> results) {
final hasNetwork = results.any((r) => r != ConnectivityResult.none);
if (hasNetwork == _deviceHasNetwork) return;
_deviceHasNetwork = hasNetwork;
notifyListeners();
if (hasNetwork) {
unawaited(probe());
}
}
bool _isConnectionLevel(DioException error) {
if (error.type == DioExceptionType.cancel) return false;
// Anything without a response from the server is a connection-level
// failure (timeouts, connection refused, DNS, TLS, web fetch errors
// surfaced as DioExceptionType.unknown, etc.).
return error.response == null;
}
@override
void dispose() {
_subscription?.cancel();
super.dispose();
}
}