// Reachability-based offline marker (#427 S1). // // `connectivityProvider` only knows whether a network *interface* is // up — not whether the Minstrel server is actually reachable (captive // portals, server down, DNS, VPN-only routes all read "online"). This // is the single source of truth other features gate on: offline = // the server failed its /healthz probe N times in a row; recovery on // the first success. // // Deliberately NOT coupled to connectivityProvider: subscribing to // that StreamProvider eagerly mounts its 2s checkConnectivity timeout // and leaks a pending Timer through widget tests that never reach the // auth state (the bug fixed for MutationReplayer). /healthz failing // already covers interface-down — it just fails the probe. // // Optimistic: assume online until proven offline, so a cold launch // behaves normally and only flips after sustained unreachability. import 'dart:async'; import 'package:dio/dio.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import '../api/endpoints/health.dart'; import '../library/library_providers.dart' show dioProvider; class OfflineMonitor extends Notifier { Timer? _initial; Timer? _periodic; int _consecutiveFails = 0; /// Consecutive failed probes before we declare offline. Small /// enough to react within ~half a minute, large enough that a /// single dropped request doesn't flip the whole UI. static const _threshold = 3; /// Probe cadence. Slower when believed-online (cheap heartbeat); /// faster when offline so recovery is noticed quickly. static const _onlineInterval = Duration(seconds: 30); static const _offlineInterval = Duration(seconds: 10); /// Let auth + server-url + dio providers finish async warmup /// before the first probe so a cold start doesn't false-positive. static const _initialDelay = Duration(seconds: 5); @override bool build() { ref.onDispose(() { _initial?.cancel(); _periodic?.cancel(); }); _initial = Timer(_initialDelay, () { _check(); _arm(); }); return false; // optimistic until a probe says otherwise } void _arm() { _periodic?.cancel(); _periodic = Timer.periodic( state ? _offlineInterval : _onlineInterval, (_) => _check(), ); } Future _check() async { final Dio dio; try { // Not configured yet (no server URL) → don't flip; the app is // still on the connect screen and "offline" is meaningless. dio = await ref.read(dioProvider.future); } catch (_) { return; } try { // dio's own connect/receive timeouts bound this — no extra // Timer (which would leak in widget tests). await HealthApi(dio).check(); _consecutiveFails = 0; _set(false); } catch (_) { _consecutiveFails++; if (_consecutiveFails >= _threshold) { _set(true); } } } void _set(bool offline) { if (state == offline) return; state = offline; _arm(); // cadence follows the new state } /// Force an immediate probe — e.g. a user-initiated retry. Public /// so S4's offline surfaces can offer a "try again" affordance. Future recheck() => _check(); } /// `true` when the server is unreachable. Watch this to gate /// online-only affordances; read it once at app start to activate /// the poller. final offlineProvider = NotifierProvider(OfflineMonitor.new);