mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
Pause polling and show a banner when the backend is unreachable
Adds a centralized BackendReachability singleton fed by a Dio interceptor (connection-level failures only) and connectivity_plus. PolledValue subscribes to it and pauses on offline / reloads immediately on reconnect, replacing the previous per-card error cascade. MainShell renders a global OfflineBanner with Retry and Settings actions; cards downgrade error→stale when offline so the banner is the only red element. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 4.6
parent
8be65f3ffa
commit
b5da90be59
@@ -8,6 +8,7 @@ import 'devices/device_list.dart';
|
|||||||
import 'home/home_screen.dart';
|
import 'home/home_screen.dart';
|
||||||
import 'status/status_screen.dart';
|
import 'status/status_screen.dart';
|
||||||
import 'utils/pref_utils.dart';
|
import 'utils/pref_utils.dart';
|
||||||
|
import 'widgets/offline_banner.dart';
|
||||||
|
|
||||||
// M3 window size class breakpoints
|
// M3 window size class breakpoints
|
||||||
const _mediumBreakpoint = 600.0;
|
const _mediumBreakpoint = 600.0;
|
||||||
@@ -109,9 +110,19 @@ class MainShell extends StatelessWidget {
|
|||||||
if (width < _mediumBreakpoint) {
|
if (width < _mediumBreakpoint) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
appBar: _buildAppBar(context),
|
appBar: _buildAppBar(context),
|
||||||
body: Padding(
|
body: Column(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
|
children: [
|
||||||
child: child,
|
const OfflineBanner(),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16,
|
||||||
|
vertical: 12,
|
||||||
|
),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
bottomNavigationBar: NavigationBar(
|
bottomNavigationBar: NavigationBar(
|
||||||
selectedIndex: selectedIndex,
|
selectedIndex: selectedIndex,
|
||||||
@@ -156,9 +167,18 @@ class MainShell extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Container(
|
child: Container(
|
||||||
padding: const EdgeInsets.all(20),
|
|
||||||
color: Theme.of(context).colorScheme.surface,
|
color: Theme.of(context).colorScheme.surface,
|
||||||
child: child,
|
child: Column(
|
||||||
|
children: [
|
||||||
|
const OfflineBanner(),
|
||||||
|
Expanded(
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(20),
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -0,0 +1,96 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,6 +4,7 @@ import 'package:dio/dio.dart';
|
|||||||
import 'package:dio_smart_retry/dio_smart_retry.dart';
|
import 'package:dio_smart_retry/dio_smart_retry.dart';
|
||||||
import 'package:encrypter/encrypter/xor.dart';
|
import 'package:encrypter/encrypter/xor.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
import 'package:frontend/utils/backend_reachability.dart';
|
||||||
import 'package:frontend/utils/pref_utils.dart';
|
import 'package:frontend/utils/pref_utils.dart';
|
||||||
import '../model/arp_scanner_status.dart';
|
import '../model/arp_scanner_status.dart';
|
||||||
import '../model/mdns_scanner_status.dart';
|
import '../model/mdns_scanner_status.dart';
|
||||||
@@ -36,6 +37,17 @@ RetryInterceptor _buildRetryInterceptor(Dio dio) => RetryInterceptor(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
InterceptorsWrapper _buildReachabilityInterceptor() => InterceptorsWrapper(
|
||||||
|
onResponse: (response, handler) {
|
||||||
|
BackendReachability.instance.recordSuccess();
|
||||||
|
handler.next(response);
|
||||||
|
},
|
||||||
|
onError: (error, handler) {
|
||||||
|
BackendReachability.instance.recordFailure(error);
|
||||||
|
handler.next(error);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
LogInterceptor _buildLogInterceptor() => LogInterceptor(
|
LogInterceptor _buildLogInterceptor() => LogInterceptor(
|
||||||
request: true,
|
request: true,
|
||||||
requestHeader: false,
|
requestHeader: false,
|
||||||
@@ -106,9 +118,11 @@ class BackendAPI {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
_dio.interceptors.add(_buildRetryInterceptor(_dio));
|
_dio.interceptors.add(_buildRetryInterceptor(_dio));
|
||||||
|
_dio.interceptors.add(_buildReachabilityInterceptor());
|
||||||
if (kDebugMode) {
|
if (kDebugMode) {
|
||||||
_dio.interceptors.add(_buildLogInterceptor());
|
_dio.interceptors.add(_buildLogInterceptor());
|
||||||
}
|
}
|
||||||
|
BackendReachability.instance.setProber(() => _dio.get('/test'));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Returns null if the test was successful, and a String with a message about the issue if not
|
// Returns null if the test was successful, and a String with a message about the issue if not
|
||||||
|
|||||||
@@ -3,10 +3,27 @@ import 'dart:async';
|
|||||||
import 'package:dio/dio.dart';
|
import 'package:dio/dio.dart';
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
import 'backend_reachability.dart';
|
||||||
import 'oott_api.dart';
|
import 'oott_api.dart';
|
||||||
|
|
||||||
enum PolledFreshness { initialLoading, fresh, stale, error }
|
enum PolledFreshness { initialLoading, fresh, stale, error }
|
||||||
|
|
||||||
|
enum PolledPauseReason { manual, unreachable }
|
||||||
|
|
||||||
|
/// Returns the freshness of [polled] downgraded to [PolledFreshness.stale]
|
||||||
|
/// when the backend is known to be unreachable and a previous value exists.
|
||||||
|
/// This avoids showing per-card error chrome while the global offline banner
|
||||||
|
/// is already communicating the connectivity issue.
|
||||||
|
PolledFreshness effectiveFreshness(PolledValue polled) {
|
||||||
|
final base = polled.freshness;
|
||||||
|
if (base == PolledFreshness.error &&
|
||||||
|
polled.value != null &&
|
||||||
|
!BackendReachability.instance.isOnline) {
|
||||||
|
return PolledFreshness.stale;
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
class PolledValue<T> extends ChangeNotifier {
|
class PolledValue<T> extends ChangeNotifier {
|
||||||
PolledValue({
|
PolledValue({
|
||||||
required Future<T> Function({CancelToken? cancelToken}) fetch,
|
required Future<T> Function({CancelToken? cancelToken}) fetch,
|
||||||
@@ -15,13 +32,23 @@ class PolledValue<T> extends ChangeNotifier {
|
|||||||
}) : _fetch = fetch,
|
}) : _fetch = fetch,
|
||||||
_pollInterval = pollInterval,
|
_pollInterval = pollInterval,
|
||||||
_staleErrorAfter = staleErrorAfter {
|
_staleErrorAfter = staleErrorAfter {
|
||||||
_load();
|
_reachability = BackendReachability.instance;
|
||||||
_pollTimer = Timer.periodic(pollInterval, (_) => _load());
|
_reachability.addListener(_onReachabilityChanged);
|
||||||
|
if (!_reachability.isOnline) {
|
||||||
|
_pauseReasons.add(PolledPauseReason.unreachable);
|
||||||
|
}
|
||||||
|
if (_pauseReasons.isEmpty) {
|
||||||
|
_load();
|
||||||
|
_startPolling();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
final Future<T> Function({CancelToken? cancelToken}) _fetch;
|
final Future<T> Function({CancelToken? cancelToken}) _fetch;
|
||||||
final Duration _pollInterval;
|
final Duration _pollInterval;
|
||||||
final Duration _staleErrorAfter;
|
final Duration _staleErrorAfter;
|
||||||
|
final Set<PolledPauseReason> _pauseReasons = {};
|
||||||
|
late final BackendReachability _reachability;
|
||||||
|
|
||||||
Timer? _pollTimer;
|
Timer? _pollTimer;
|
||||||
CancelToken? _cancelToken;
|
CancelToken? _cancelToken;
|
||||||
bool _disposed = false;
|
bool _disposed = false;
|
||||||
@@ -68,23 +95,49 @@ class PolledValue<T> extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void pause() {
|
void _startPolling() {
|
||||||
|
_pollTimer ??= Timer.periodic(_pollInterval, (_) => _load());
|
||||||
|
}
|
||||||
|
|
||||||
|
void _stopPolling() {
|
||||||
_pollTimer?.cancel();
|
_pollTimer?.cancel();
|
||||||
_pollTimer = null;
|
_pollTimer = null;
|
||||||
_cancelToken?.cancel();
|
_cancelToken?.cancel();
|
||||||
}
|
}
|
||||||
|
|
||||||
void resume() {
|
void pauseFor(PolledPauseReason reason) {
|
||||||
|
final added = _pauseReasons.add(reason);
|
||||||
|
if (added && _pauseReasons.length == 1) {
|
||||||
|
_stopPolling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void resumeFor(PolledPauseReason reason) {
|
||||||
if (_disposed) return;
|
if (_disposed) return;
|
||||||
_load();
|
final removed = _pauseReasons.remove(reason);
|
||||||
_pollTimer = Timer.periodic(_pollInterval, (_) => _load());
|
if (removed && _pauseReasons.isEmpty) {
|
||||||
|
_load();
|
||||||
|
_startPolling();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
void pause() => pauseFor(PolledPauseReason.manual);
|
||||||
|
|
||||||
|
void resume() => resumeFor(PolledPauseReason.manual);
|
||||||
|
|
||||||
|
void _onReachabilityChanged() {
|
||||||
|
if (_reachability.isOnline) {
|
||||||
|
resumeFor(PolledPauseReason.unreachable);
|
||||||
|
} else {
|
||||||
|
pauseFor(PolledPauseReason.unreachable);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_disposed = true;
|
_disposed = true;
|
||||||
_pollTimer?.cancel();
|
_reachability.removeListener(_onReachabilityChanged);
|
||||||
_cancelToken?.cancel();
|
_stopPolling();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../model/arp_scanner_status.dart';
|
import '../model/arp_scanner_status.dart';
|
||||||
|
import '../utils/backend_reachability.dart';
|
||||||
import '../utils/duration_formatter.dart';
|
import '../utils/duration_formatter.dart';
|
||||||
import '../utils/oott_api.dart';
|
import '../utils/oott_api.dart';
|
||||||
import '../utils/polled_value.dart';
|
import '../utils/polled_value.dart';
|
||||||
@@ -44,9 +45,10 @@ class _ArpScannerCardState extends State<ArpScannerCard> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
listenable: _polled,
|
listenable: Listenable.merge([_polled, BackendReachability.instance]),
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
if (_polled.freshness == PolledFreshness.initialLoading) {
|
final freshness = effectiveFreshness(_polled);
|
||||||
|
if (freshness == PolledFreshness.initialLoading) {
|
||||||
return const Card(
|
return const Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(16),
|
padding: EdgeInsets.all(16),
|
||||||
@@ -55,8 +57,8 @@ class _ArpScannerCardState extends State<ArpScannerCard> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final (color, label, sublabel) = _resolveState();
|
final (color, label, sublabel) = _resolveState(freshness);
|
||||||
final isStale = _polled.freshness == PolledFreshness.stale;
|
final isStale = freshness == PolledFreshness.stale;
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
@@ -111,8 +113,8 @@ class _ArpScannerCardState extends State<ArpScannerCard> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
(Color, String, String?) _resolveState() {
|
(Color, String, String?) _resolveState(PolledFreshness freshness) {
|
||||||
if (_polled.freshness == PolledFreshness.error) {
|
if (freshness == PolledFreshness.error) {
|
||||||
return (
|
return (
|
||||||
Colors.red,
|
Colors.red,
|
||||||
'Error',
|
'Error',
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../model/device_summary.dart';
|
import '../model/device_summary.dart';
|
||||||
|
import '../utils/backend_reachability.dart';
|
||||||
import '../utils/oott_api.dart';
|
import '../utils/oott_api.dart';
|
||||||
import '../utils/polled_value.dart';
|
import '../utils/polled_value.dart';
|
||||||
import 'polled_stale_indicator.dart';
|
import 'polled_stale_indicator.dart';
|
||||||
@@ -42,8 +43,12 @@ class _DeviceSummaryCardState extends State<DeviceSummaryCard> {
|
|||||||
child: Padding(
|
child: Padding(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
child: ListenableBuilder(
|
child: ListenableBuilder(
|
||||||
listenable: _polled,
|
listenable: Listenable.merge([
|
||||||
|
_polled,
|
||||||
|
BackendReachability.instance,
|
||||||
|
]),
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
|
final freshness = effectiveFreshness(_polled);
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@@ -53,14 +58,14 @@ class _DeviceSummaryCardState extends State<DeviceSummaryCard> {
|
|||||||
'Devices',
|
'Devices',
|
||||||
style: Theme.of(context).textTheme.titleLarge,
|
style: Theme.of(context).textTheme.titleLarge,
|
||||||
),
|
),
|
||||||
if (_polled.freshness == PolledFreshness.stale) ...[
|
if (freshness == PolledFreshness.stale) ...[
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
PolledStaleIndicator(polled: _polled),
|
PolledStaleIndicator(polled: _polled),
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
..._buildBody(context),
|
..._buildBody(context, freshness),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -70,8 +75,8 @@ class _DeviceSummaryCardState extends State<DeviceSummaryCard> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
List<Widget> _buildBody(BuildContext context) {
|
List<Widget> _buildBody(BuildContext context, PolledFreshness freshness) {
|
||||||
switch (_polled.freshness) {
|
switch (freshness) {
|
||||||
case PolledFreshness.initialLoading:
|
case PolledFreshness.initialLoading:
|
||||||
return const [Center(child: CircularProgressIndicator())];
|
return const [Center(child: CircularProgressIndicator())];
|
||||||
case PolledFreshness.error:
|
case PolledFreshness.error:
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:flutter/material.dart';
|
|||||||
import 'package:go_router/go_router.dart';
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
import '../model/mdns_scanner_status.dart';
|
import '../model/mdns_scanner_status.dart';
|
||||||
|
import '../utils/backend_reachability.dart';
|
||||||
import '../utils/duration_formatter.dart';
|
import '../utils/duration_formatter.dart';
|
||||||
import '../utils/oott_api.dart';
|
import '../utils/oott_api.dart';
|
||||||
import '../utils/polled_value.dart';
|
import '../utils/polled_value.dart';
|
||||||
@@ -44,9 +45,10 @@ class _MdnsScannerCardState extends State<MdnsScannerCard> {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
listenable: _polled,
|
listenable: Listenable.merge([_polled, BackendReachability.instance]),
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
if (_polled.freshness == PolledFreshness.initialLoading) {
|
final freshness = effectiveFreshness(_polled);
|
||||||
|
if (freshness == PolledFreshness.initialLoading) {
|
||||||
return const Card(
|
return const Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(16),
|
padding: EdgeInsets.all(16),
|
||||||
@@ -55,8 +57,8 @@ class _MdnsScannerCardState extends State<MdnsScannerCard> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final (color, label, sublabels) = _resolveState();
|
final (color, label, sublabels) = _resolveState(freshness);
|
||||||
final isStale = _polled.freshness == PolledFreshness.stale;
|
final isStale = freshness == PolledFreshness.stale;
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
@@ -111,8 +113,8 @@ class _MdnsScannerCardState extends State<MdnsScannerCard> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
(Color, String, List<String>) _resolveState() {
|
(Color, String, List<String>) _resolveState(PolledFreshness freshness) {
|
||||||
if (_polled.freshness == PolledFreshness.error) {
|
if (freshness == PolledFreshness.error) {
|
||||||
return (
|
return (
|
||||||
Colors.red,
|
Colors.red,
|
||||||
'Error',
|
'Error',
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:go_router/go_router.dart';
|
||||||
|
|
||||||
|
import '../utils/backend_reachability.dart';
|
||||||
|
|
||||||
|
class OfflineBanner extends StatelessWidget {
|
||||||
|
const OfflineBanner({super.key});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final reachability = BackendReachability.instance;
|
||||||
|
return ListenableBuilder(
|
||||||
|
listenable: reachability,
|
||||||
|
builder: (context, _) {
|
||||||
|
if (reachability.isOnline) return const SizedBox.shrink();
|
||||||
|
final theme = Theme.of(context);
|
||||||
|
final message = !reachability.deviceHasNetwork
|
||||||
|
? 'No network connection.'
|
||||||
|
: reachability.isProbing
|
||||||
|
? 'Reconnecting to backend…'
|
||||||
|
: reachability.lastErrorMessage ?? 'Cannot reach backend.';
|
||||||
|
return Material(
|
||||||
|
color: theme.colorScheme.errorContainer,
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.cloud_off,
|
||||||
|
size: 18,
|
||||||
|
color: theme.colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
|
message,
|
||||||
|
style: theme.textTheme.bodyMedium?.copyWith(
|
||||||
|
color: theme.colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: reachability.isProbing
|
||||||
|
? null
|
||||||
|
: () => reachability.probe(),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: theme.colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
child: const Text('Retry'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () => context.go('/settings'),
|
||||||
|
style: TextButton.styleFrom(
|
||||||
|
foregroundColor: theme.colorScheme.onErrorContainer,
|
||||||
|
),
|
||||||
|
child: const Text('Settings'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ import 'dart:async';
|
|||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../utils/backend_reachability.dart';
|
||||||
import '../utils/duration_formatter.dart';
|
import '../utils/duration_formatter.dart';
|
||||||
import '../utils/polled_value.dart';
|
import '../utils/polled_value.dart';
|
||||||
|
|
||||||
@@ -39,9 +40,15 @@ class _PolledStaleIndicatorState extends State<PolledStaleIndicator> {
|
|||||||
DateTime.now().difference(lastSuccessAt).inSeconds.toDouble(),
|
DateTime.now().difference(lastSuccessAt).inSeconds.toDouble(),
|
||||||
)
|
)
|
||||||
: 'a while';
|
: 'a while';
|
||||||
final message = widget.polled.lastErrorMessage != null
|
final isOffline = !BackendReachability.instance.isOnline;
|
||||||
? 'Last updated $ago ago — ${widget.polled.lastErrorMessage}'
|
final String message;
|
||||||
: 'Last updated $ago ago';
|
if (isOffline) {
|
||||||
|
message = 'Offline — last updated $ago ago';
|
||||||
|
} else if (widget.polled.lastErrorMessage != null) {
|
||||||
|
message = 'Last updated $ago ago — ${widget.polled.lastErrorMessage}';
|
||||||
|
} else {
|
||||||
|
message = 'Last updated $ago ago';
|
||||||
|
}
|
||||||
return Tooltip(
|
return Tooltip(
|
||||||
message: message,
|
message: message,
|
||||||
child: Icon(
|
child: Icon(
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import 'package:go_router/go_router.dart';
|
|||||||
import '../model/arp_scanner_status.dart';
|
import '../model/arp_scanner_status.dart';
|
||||||
import '../model/mdns_scanner_status.dart';
|
import '../model/mdns_scanner_status.dart';
|
||||||
import '../navigation.dart';
|
import '../navigation.dart';
|
||||||
|
import '../utils/backend_reachability.dart';
|
||||||
import '../utils/duration_formatter.dart';
|
import '../utils/duration_formatter.dart';
|
||||||
import '../utils/oott_api.dart';
|
import '../utils/oott_api.dart';
|
||||||
import '../utils/polled_value.dart';
|
import '../utils/polled_value.dart';
|
||||||
@@ -101,10 +102,16 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return ListenableBuilder(
|
return ListenableBuilder(
|
||||||
listenable: Listenable.merge([_arp, _mdns]),
|
listenable: Listenable.merge([
|
||||||
|
_arp,
|
||||||
|
_mdns,
|
||||||
|
BackendReachability.instance,
|
||||||
|
]),
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
if (_arp.freshness == PolledFreshness.initialLoading ||
|
final arpFreshness = effectiveFreshness(_arp);
|
||||||
_mdns.freshness == PolledFreshness.initialLoading) {
|
final mdnsFreshness = effectiveFreshness(_mdns);
|
||||||
|
if (arpFreshness == PolledFreshness.initialLoading ||
|
||||||
|
mdnsFreshness == PolledFreshness.initialLoading) {
|
||||||
return const Card(
|
return const Card(
|
||||||
child: Padding(
|
child: Padding(
|
||||||
padding: EdgeInsets.all(16),
|
padding: EdgeInsets.all(16),
|
||||||
@@ -113,8 +120,8 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final (arpColor, arpText) = _resolveArp();
|
final (arpColor, arpText) = _resolveArp(arpFreshness);
|
||||||
final (mdnsColor, mdnsText) = _resolveMdns();
|
final (mdnsColor, mdnsText) = _resolveMdns(mdnsFreshness);
|
||||||
|
|
||||||
return Card(
|
return Card(
|
||||||
clipBehavior: Clip.antiAlias,
|
clipBehavior: Clip.antiAlias,
|
||||||
@@ -130,9 +137,23 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
|
|||||||
style: Theme.of(context).textTheme.titleMedium,
|
style: Theme.of(context).textTheme.titleMedium,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
_scannerRow(context, arpColor, 'ARP', arpText, _arp),
|
_scannerRow(
|
||||||
|
context,
|
||||||
|
arpColor,
|
||||||
|
'ARP',
|
||||||
|
arpText,
|
||||||
|
_arp,
|
||||||
|
arpFreshness,
|
||||||
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
_scannerRow(context, mdnsColor, 'mDNS', mdnsText, _mdns),
|
_scannerRow(
|
||||||
|
context,
|
||||||
|
mdnsColor,
|
||||||
|
'mDNS',
|
||||||
|
mdnsText,
|
||||||
|
_mdns,
|
||||||
|
mdnsFreshness,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -148,9 +169,10 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
|
|||||||
String name,
|
String name,
|
||||||
String statusText,
|
String statusText,
|
||||||
PolledValue polled,
|
PolledValue polled,
|
||||||
|
PolledFreshness freshness,
|
||||||
) {
|
) {
|
||||||
Widget dot = Icon(Icons.circle, color: color, size: 12);
|
Widget dot = Icon(Icons.circle, color: color, size: 12);
|
||||||
if (polled.freshness == PolledFreshness.error) {
|
if (freshness == PolledFreshness.error) {
|
||||||
dot = Tooltip(message: polled.lastErrorMessage ?? 'Error', child: dot);
|
dot = Tooltip(message: polled.lastErrorMessage ?? 'Error', child: dot);
|
||||||
}
|
}
|
||||||
return Row(
|
return Row(
|
||||||
@@ -161,7 +183,7 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
|
|||||||
child: Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Text(name, style: Theme.of(context).textTheme.bodyMedium),
|
Text(name, style: Theme.of(context).textTheme.bodyMedium),
|
||||||
if (polled.freshness == PolledFreshness.stale) ...[
|
if (freshness == PolledFreshness.stale) ...[
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
PolledStaleIndicator(polled: polled),
|
PolledStaleIndicator(polled: polled),
|
||||||
],
|
],
|
||||||
@@ -179,8 +201,8 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
(Color, String) _resolveArp() {
|
(Color, String) _resolveArp(PolledFreshness freshness) {
|
||||||
if (_arp.freshness == PolledFreshness.error) {
|
if (freshness == PolledFreshness.error) {
|
||||||
return (Colors.red, 'Error');
|
return (Colors.red, 'Error');
|
||||||
}
|
}
|
||||||
final status = _arp.value!;
|
final status = _arp.value!;
|
||||||
@@ -202,8 +224,8 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
|
|||||||
return (Colors.grey, 'Not yet started');
|
return (Colors.grey, 'Not yet started');
|
||||||
}
|
}
|
||||||
|
|
||||||
(Color, String) _resolveMdns() {
|
(Color, String) _resolveMdns(PolledFreshness freshness) {
|
||||||
if (_mdns.freshness == PolledFreshness.error) {
|
if (freshness == PolledFreshness.error) {
|
||||||
return (Colors.red, 'Error');
|
return (Colors.red, 'Error');
|
||||||
}
|
}
|
||||||
final status = _mdns.value!;
|
final status = _mdns.value!;
|
||||||
|
|||||||
@@ -5,11 +5,13 @@
|
|||||||
import FlutterMacOS
|
import FlutterMacOS
|
||||||
import Foundation
|
import Foundation
|
||||||
|
|
||||||
|
import connectivity_plus
|
||||||
import encrypter
|
import encrypter
|
||||||
import shared_preferences_foundation
|
import shared_preferences_foundation
|
||||||
import url_launcher_macos
|
import url_launcher_macos
|
||||||
|
|
||||||
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
|
||||||
|
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
|
||||||
EncrypterPlugin.register(with: registry.registrar(forPlugin: "EncrypterPlugin"))
|
EncrypterPlugin.register(with: registry.registrar(forPlugin: "EncrypterPlugin"))
|
||||||
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin"))
|
||||||
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin"))
|
||||||
|
|||||||
+49
-1
@@ -65,6 +65,22 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.19.1"
|
version: "1.19.1"
|
||||||
|
connectivity_plus:
|
||||||
|
dependency: "direct main"
|
||||||
|
description:
|
||||||
|
name: connectivity_plus
|
||||||
|
sha256: "62ffa266d9a23b79fb3fcbc206afc00bb979417ba57b1324c546b5aab95ba057"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.1.1"
|
||||||
|
connectivity_plus_platform_interface:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: connectivity_plus_platform_interface
|
||||||
|
sha256: "3c09627c536d22fd24691a905cdd8b14520de69da52c7a97499c8be5284a32ed"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "2.1.0"
|
||||||
convert:
|
convert:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -81,6 +97,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "3.0.7"
|
version: "3.0.7"
|
||||||
|
dbus:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: dbus
|
||||||
|
sha256: "792974a4007974fbc5c1b5433eb2330a9db3e368c3f906253af4c007d0f49a91"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.7.13"
|
||||||
dio:
|
dio:
|
||||||
dependency: "direct main"
|
dependency: "direct main"
|
||||||
description:
|
description:
|
||||||
@@ -336,6 +360,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.0.0"
|
version: "1.0.0"
|
||||||
|
nm:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: nm
|
||||||
|
sha256: "2c9aae4127bdc8993206464fcc063611e0e36e72018696cd9631023a31b24254"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "0.5.0"
|
||||||
objective_c:
|
objective_c:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -408,6 +440,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "2.3.0"
|
version: "2.3.0"
|
||||||
|
petitparser:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: petitparser
|
||||||
|
sha256: "91bd59303e9f769f108f8df05e371341b15d59e995e6806aefab827b58336675"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.2"
|
||||||
platform:
|
platform:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -669,6 +709,14 @@ packages:
|
|||||||
url: "https://pub.dev"
|
url: "https://pub.dev"
|
||||||
source: hosted
|
source: hosted
|
||||||
version: "1.1.0"
|
version: "1.1.0"
|
||||||
|
xml:
|
||||||
|
dependency: transitive
|
||||||
|
description:
|
||||||
|
name: xml
|
||||||
|
sha256: "67f0aff7be013d107995e9b75bf4e7f2c3ef2dfdb2c8e68024bba0a7fd5756a4"
|
||||||
|
url: "https://pub.dev"
|
||||||
|
source: hosted
|
||||||
|
version: "7.0.1"
|
||||||
yaml:
|
yaml:
|
||||||
dependency: transitive
|
dependency: transitive
|
||||||
description:
|
description:
|
||||||
@@ -678,5 +726,5 @@ packages:
|
|||||||
source: hosted
|
source: hosted
|
||||||
version: "3.1.3"
|
version: "3.1.3"
|
||||||
sdks:
|
sdks:
|
||||||
dart: ">=3.10.8 <4.0.0"
|
dart: ">=3.11.0 <4.0.0"
|
||||||
flutter: ">=3.38.4"
|
flutter: ">=3.38.4"
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ dependencies:
|
|||||||
provider: ^6.1.5
|
provider: ^6.1.5
|
||||||
dio: ^5.9.1
|
dio: ^5.9.1
|
||||||
dio_smart_retry: ^7.0.1
|
dio_smart_retry: ^7.0.1
|
||||||
|
connectivity_plus: ^7.0.0
|
||||||
encrypter: ^2.0.0
|
encrypter: ^2.0.0
|
||||||
shared_preferences: ^2.5.4
|
shared_preferences: ^2.5.4
|
||||||
fl_chart: ^1.2.0
|
fl_chart: ^1.2.0
|
||||||
|
|||||||
@@ -6,10 +6,13 @@
|
|||||||
|
|
||||||
#include "generated_plugin_registrant.h"
|
#include "generated_plugin_registrant.h"
|
||||||
|
|
||||||
|
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
|
||||||
#include <encrypter/encrypter_plugin_c_api.h>
|
#include <encrypter/encrypter_plugin_c_api.h>
|
||||||
#include <url_launcher_windows/url_launcher_windows.h>
|
#include <url_launcher_windows/url_launcher_windows.h>
|
||||||
|
|
||||||
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
void RegisterPlugins(flutter::PluginRegistry* registry) {
|
||||||
|
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
|
||||||
|
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
|
||||||
EncrypterPluginCApiRegisterWithRegistrar(
|
EncrypterPluginCApiRegisterWithRegistrar(
|
||||||
registry->GetRegistrarForPlugin("EncrypterPluginCApi"));
|
registry->GetRegistrarForPlugin("EncrypterPluginCApi"));
|
||||||
UrlLauncherWindowsRegisterWithRegistrar(
|
UrlLauncherWindowsRegisterWithRegistrar(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
#
|
#
|
||||||
|
|
||||||
list(APPEND FLUTTER_PLUGIN_LIST
|
list(APPEND FLUTTER_PLUGIN_LIST
|
||||||
|
connectivity_plus
|
||||||
encrypter
|
encrypter
|
||||||
url_launcher_windows
|
url_launcher_windows
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user