Consolidate frontend scanner, pagination, and tick-timer duplication

Collapse the five scanner status models into two shared shapes,
ActiveScannerStatus and PassiveScannerStatus, mirroring the backend's
active/passive vocabulary. Replace the five near-identical per-scanner
detail card files with a single scanner_status_cards.dart (two shared
resolvers plus a config list), and rebuild the combined home card to
iterate a list of scanners with two shape resolvers instead of five
copy-pasted resolve methods.

Extract two reusable mixins:
- PeriodicRebuild: the shared once-a-second "rebuild to refresh elapsed
  text" timer used by the scanner cards and the stale indicator.
- PaginatedListState: the shared pagination state, page-size/page-count
  getters, cancel-token-aware fetch orchestration, and disposal used by
  the device and notification lists.

No behaviour change; ~900 lines removed. Tests and analyzer pass.
This commit is contained in:
rzuasti
2026-06-05 21:57:58 -04:00
parent 6b044a6739
commit 48d73fb207
22 changed files with 533 additions and 859 deletions
+48 -86
View File
@@ -1,6 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -10,21 +9,15 @@ import '../navigation.dart';
import '../theme/dimens.dart'; import '../theme/dimens.dart';
import '../utils/friendly_date_formatter.dart'; import '../utils/friendly_date_formatter.dart';
import '../utils/oott_api.dart'; import '../utils/oott_api.dart';
import '../utils/paginated_list_state.dart';
import '../widgets/empty_state.dart'; import '../widgets/empty_state.dart';
import '../widgets/filter_selector.dart'; import '../widgets/filter_selector.dart';
import '../widgets/pagination_bar.dart'; import '../widgets/pagination_bar.dart';
import '../widgets/pagination_progress.dart';
import '../widgets/skeleton.dart'; import '../widgets/skeleton.dart';
import 'device_list_filter.dart'; import 'device_list_filter.dart';
import 'device_list_rows.dart'; import 'device_list_rows.dart';
import 'device_list_sort.dart'; import 'device_list_sort.dart';
// Phones show fewer devices so the list and its pagination controls fit on
// screen at once on the common current phones (e.g. iPhone 15, Pixel 8); the
// wider table layout has the vertical room for a full page.
const _phonePageSize = 5;
const _widePageSize = 10;
class DeviceList extends StatefulWidget { class DeviceList extends StatefulWidget {
const DeviceList({super.key}); const DeviceList({super.key});
@@ -32,12 +25,10 @@ class DeviceList extends StatefulWidget {
State<DeviceList> createState() => _DeviceListState(); State<DeviceList> createState() => _DeviceListState();
} }
class _DeviceListState extends State<DeviceList> with RouteAware { class _DeviceListState extends State<DeviceList>
with RouteAware, PaginatedListState<DeviceList> {
DeviceFilter _filter = DeviceFilter.newDevices; DeviceFilter _filter = DeviceFilter.newDevices;
List<Device> _devices = []; List<Device> _devices = [];
bool _isLoading = true;
bool _isPaging = false;
String? _error;
final TextEditingController _ownerController = TextEditingController(); final TextEditingController _ownerController = TextEditingController();
DeviceType? _typeFilter; DeviceType? _typeFilter;
Timer? _ownerDebounce; Timer? _ownerDebounce;
@@ -45,22 +36,20 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
DeviceSortColumn _sortColumn = DeviceSortColumn.lastSeen; DeviceSortColumn _sortColumn = DeviceSortColumn.lastSeen;
bool _sortAscending = false; bool _sortAscending = false;
int _currentPage = 0; // Phones show fewer devices so the list and its pagination controls fit on
// Total devices matching the current filters, used to show how many pages // screen at once on the common current phones (e.g. iPhone 15, Pixel 8); the
// exist and to offer "go to last page". // wider table layout has the vertical room for a full page.
int _totalCount = 0; @override
bool _didInitialFetch = false; int get phonePageSize => 5;
CancelToken? _fetchToken; @override
final ScrollController _scrollController = ScrollController(); int get widePageSize => 10;
@override
int get _pageSize => MediaQuery.sizeOf(context).width < Breakpoints.medium bool get isListEmpty => _devices.isEmpty;
? _phonePageSize
: _widePageSize;
int get _totalPages => (_totalCount / _pageSize).ceil().clamp(1, 1 << 30);
@override @override
void initState() { void initState() {
super.initState(); super.initState();
isLoading = true;
_ownerController.addListener(_onOwnerChanged); _ownerController.addListener(_onOwnerChanged);
} }
@@ -73,26 +62,22 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
} }
// Deferred from initState so the page size can read the screen width from // Deferred from initState so the page size can read the screen width from
// MediaQuery, which is only available once dependencies are in place. // MediaQuery, which is only available once dependencies are in place.
if (!_didInitialFetch) { if (!didInitialFetch) {
_didInitialFetch = true; didInitialFetch = true;
_fetchPage(0); _fetchPage(0);
} }
} }
@override @override
void didPopNext() { void didPopNext() {
_fetchPage(_currentPage); _fetchPage(currentPage);
} }
@override @override
void dispose() { void dispose() {
routeObserver.unsubscribe(this); routeObserver.unsubscribe(this);
_ownerDebounce?.cancel(); _ownerDebounce?.cancel();
_fetchToken?.cancel();
_ownerController.dispose(); _ownerController.dispose();
_scrollController.dispose();
// Clear any in-flight cue so it doesn't linger after leaving the page.
paginationLoading.value = false;
super.dispose(); super.dispose();
} }
@@ -113,57 +98,34 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
int page, { int page, {
bool scrollToTop = false, bool scrollToTop = false,
bool paging = false, bool paging = false,
}) async { }) {
if (scrollToTop && _scrollController.hasClients) { bool? isRegistered;
_scrollController.animateTo( if (_filter == DeviceFilter.newDevices) isRegistered = false;
0, if (_filter == DeviceFilter.registered) isRegistered = true;
duration: const Duration(milliseconds: 300), return runFetch(
curve: Curves.easeOut, page,
); scrollToTop: scrollToTop,
} paging: paging,
_fetchToken?.cancel(); fetch: (page, perPage, token) => BackendAPI.instance.listDevices(
final token = CancelToken();
_fetchToken = token;
setState(() {
_isLoading = _devices.isEmpty;
_isPaging = paging;
_error = null;
});
paginationLoading.value = paging;
try {
bool? isRegistered;
if (_filter == DeviceFilter.newDevices) isRegistered = false;
if (_filter == DeviceFilter.registered) isRegistered = true;
final result = await BackendAPI.instance.listDevices(
isRegistered: isRegistered, isRegistered: isRegistered,
owner: _ownerController.text.isEmpty ? null : _ownerController.text, owner: _ownerController.text.isEmpty ? null : _ownerController.text,
deviceType: _typeFilter, deviceType: _typeFilter,
sortBy: _sortColumn.apiName, sortBy: _sortColumn.apiName,
sortAscending: _sortAscending, sortAscending: _sortAscending,
page: page, page: page,
perPage: _pageSize, perPage: perPage,
cancelToken: token, cancelToken: token,
); ),
if (!mounted || token != _fetchToken) return; onResult: (result) {
setState(() { setState(() {
_currentPage = page; currentPage = page;
_totalCount = result.totalCount; totalCount = result.totalCount;
_devices = result.items; _devices = result.items;
_isLoading = false; isLoading = false;
_isPaging = false; isPaging = false;
}); });
paginationLoading.value = false; },
} catch (e) { );
if (!mounted || token != _fetchToken) return;
if (e is DioException && e.type == DioExceptionType.cancel) return;
setState(() {
_error = dioErrorToUserMessage(e);
_isLoading = false;
_isPaging = false;
});
paginationLoading.value = false;
}
} }
void _onSortHeaderTapped(DeviceSortColumn column) { void _onSortHeaderTapped(DeviceSortColumn column) {
@@ -295,13 +257,13 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
} }
Widget _buildBody(BuildContext context, bool isWide) { Widget _buildBody(BuildContext context, bool isWide) {
if (_isLoading) { if (isLoading) {
return const ListSkeleton(); return const ListSkeleton();
} }
if (_error != null) { if (error != null) {
return Center( return Center(
child: Text( child: Text(
'Error: $_error', 'Error: $error',
style: TextStyle(color: Theme.of(context).colorScheme.error), style: TextStyle(color: Theme.of(context).colorScheme.error),
), ),
); );
@@ -317,9 +279,9 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
final formatter = FriendlyDateFormatter(); final formatter = FriendlyDateFormatter();
return RefreshIndicator( return RefreshIndicator(
onRefresh: () => _fetchPage(_currentPage), onRefresh: () => _fetchPage(currentPage),
child: CustomScrollView( child: CustomScrollView(
controller: _scrollController, controller: scrollController,
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
slivers: [ slivers: [
if (isWide) if (isWide)
@@ -340,24 +302,24 @@ class _DeviceListState extends State<DeviceList> with RouteAware {
key: ValueKey(device.macAddress), key: ValueKey(device.macAddress),
device: device, device: device,
formatter: formatter, formatter: formatter,
onRefresh: () => _fetchPage(_currentPage), onRefresh: () => _fetchPage(currentPage),
) )
: DeviceRowCompact( : DeviceRowCompact(
key: ValueKey(device.macAddress), key: ValueKey(device.macAddress),
device: device, device: device,
formatter: formatter, formatter: formatter,
onRefresh: () => _fetchPage(_currentPage), onRefresh: () => _fetchPage(currentPage),
); );
}, },
separatorBuilder: (_, _) => separatorBuilder: (_, _) =>
isWide ? const Divider(height: 1) : const SizedBox.shrink(), isWide ? const Divider(height: 1) : const SizedBox.shrink(),
), ),
if (_currentPage > 0 || _totalPages > 1) if (currentPage > 0 || totalPages > 1)
SliverToBoxAdapter( SliverToBoxAdapter(
child: PaginationBar( child: PaginationBar(
currentPage: _currentPage, currentPage: currentPage,
totalPages: _totalPages, totalPages: totalPages,
isLoading: _isPaging, isLoading: isPaging,
onPageChanged: (page) => onPageChanged: (page) =>
_fetchPage(page, scrollToTop: true, paging: true), _fetchPage(page, scrollToTop: true, paging: true),
), ),
+63 -98
View File
@@ -1,27 +1,19 @@
import 'dart:async'; import 'dart:async';
import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../model/notification.dart' as oott_model; import '../model/notification.dart' as oott_model;
import '../navigation.dart'; import '../navigation.dart';
import '../theme/dimens.dart';
import '../utils/friendly_date_formatter.dart'; import '../utils/friendly_date_formatter.dart';
import '../utils/oott_api.dart'; import '../utils/oott_api.dart';
import '../utils/paginated_list_state.dart';
import '../utils/ui_snackbars.dart'; import '../utils/ui_snackbars.dart';
import '../widgets/empty_state.dart'; import '../widgets/empty_state.dart';
import '../widgets/filter_selector.dart'; import '../widgets/filter_selector.dart';
import '../widgets/pagination_bar.dart'; import '../widgets/pagination_bar.dart';
import '../widgets/pagination_progress.dart';
import '../widgets/skeleton.dart'; import '../widgets/skeleton.dart';
import 'notification_card.dart'; import 'notification_card.dart';
// Phones show fewer notifications so the list and its pagination controls fit
// on screen at once on the common current phones (e.g. iPhone 15, Pixel 8);
// wider layouts have the vertical room for a couple more.
const _phonePageSize = 4;
const _widePageSize = 5;
// Durations for the list's enter/exit animations. // Durations for the list's enter/exit animations.
const _insertDuration = Duration(milliseconds: 300); const _insertDuration = Duration(milliseconds: 300);
const _removeDuration = Duration(milliseconds: 250); const _removeDuration = Duration(milliseconds: 250);
@@ -52,15 +44,14 @@ class NotificationsList extends StatefulWidget {
} }
class _NotificationsListState extends State<NotificationsList> class _NotificationsListState extends State<NotificationsList>
with RouteAware, WidgetsBindingObserver { with
RouteAware,
WidgetsBindingObserver,
PaginatedListState<NotificationsList> {
_NotificationFilter _filter = _NotificationFilter.newOnly; _NotificationFilter _filter = _NotificationFilter.newOnly;
Timer? _notificationTimer; Timer? _notificationTimer;
int _currentPage = 0;
bool _didInitialFetch = false;
List<oott_model.Notification> _items = []; List<oott_model.Notification> _items = [];
bool _isLoading = false;
bool _isPaging = false;
// Drives the animated list. Recreated on every reset (filter/page change, // Drives the animated list. Recreated on every reset (filter/page change,
// initial load) so the new dataset mounts fresh without per-row animations; // initial load) so the new dataset mounts fresh without per-row animations;
@@ -70,16 +61,15 @@ class _NotificationsListState extends State<NotificationsList>
final Set<int> _flashIds = {}; final Set<int> _flashIds = {};
final FriendlyDateFormatter _formatter = FriendlyDateFormatter(); final FriendlyDateFormatter _formatter = FriendlyDateFormatter();
int get _pageSize => MediaQuery.sizeOf(context).width < Breakpoints.medium // Phones show fewer notifications so the list and its pagination controls fit
? _phonePageSize // on screen at once on the common current phones (e.g. iPhone 15, Pixel 8);
: _widePageSize; // wider layouts have the vertical room for a couple more.
// Total notifications matching the current filter, used to show how many pages @override
// exist and to offer "go to last page". int get phonePageSize => 4;
int _totalCount = 0; @override
int get _totalPages => (_totalCount / _pageSize).ceil().clamp(1, 1 << 30); int get widePageSize => 5;
String? _error; @override
CancelToken? _fetchToken; bool get isListEmpty => _items.isEmpty;
final ScrollController _scrollController = ScrollController();
@override @override
void initState() { void initState() {
@@ -97,8 +87,8 @@ class _NotificationsListState extends State<NotificationsList>
} }
// Deferred from initState so the page size can read the screen width from // Deferred from initState so the page size can read the screen width from
// MediaQuery, which is only available once dependencies are in place. // MediaQuery, which is only available once dependencies are in place.
if (!_didInitialFetch) { if (!didInitialFetch) {
_didInitialFetch = true; didInitialFetch = true;
_fetchPage(0); _fetchPage(0);
} }
} }
@@ -111,7 +101,7 @@ class _NotificationsListState extends State<NotificationsList>
@override @override
void didPopNext() { void didPopNext() {
_fetchPage(_currentPage, animateDiff: true); _fetchPage(currentPage, animateDiff: true);
_startTimer(); _startTimer();
} }
@@ -121,7 +111,7 @@ class _NotificationsListState extends State<NotificationsList>
_notificationTimer?.cancel(); _notificationTimer?.cancel();
_notificationTimer = null; _notificationTimer = null;
} else if (state == AppLifecycleState.resumed) { } else if (state == AppLifecycleState.resumed) {
_fetchPage(_currentPage, animateDiff: true); _fetchPage(currentPage, animateDiff: true);
_startTimer(); _startTimer();
} }
} }
@@ -130,7 +120,7 @@ class _NotificationsListState extends State<NotificationsList>
_notificationTimer?.cancel(); _notificationTimer?.cancel();
_notificationTimer = Timer.periodic( _notificationTimer = Timer.periodic(
const Duration(minutes: 1), const Duration(minutes: 1),
(_) => _fetchPage(_currentPage, animateDiff: true), (_) => _fetchPage(currentPage, animateDiff: true),
); );
} }
@@ -139,10 +129,6 @@ class _NotificationsListState extends State<NotificationsList>
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
routeObserver.unsubscribe(this); routeObserver.unsubscribe(this);
_notificationTimer?.cancel(); _notificationTimer?.cancel();
_fetchToken?.cancel();
_scrollController.dispose();
// Clear any in-flight cue so it doesn't linger after leaving the page.
paginationLoading.value = false;
super.dispose(); super.dispose();
} }
@@ -161,63 +147,42 @@ class _NotificationsListState extends State<NotificationsList>
bool scrollToTop = false, bool scrollToTop = false,
bool paging = false, bool paging = false,
bool animateDiff = false, bool animateDiff = false,
}) async { }) {
if (scrollToTop && _scrollController.hasClients) { return runFetch(
_scrollController.animateTo( page,
0, scrollToTop: scrollToTop,
duration: const Duration(milliseconds: 300), paging: paging,
curve: Curves.easeOut, fetch: (page, perPage, token) => BackendAPI.instance.listNotifications(
);
}
_fetchToken?.cancel();
final token = CancelToken();
_fetchToken = token;
setState(() {
_isLoading = _items.isEmpty;
_isPaging = paging;
_error = null;
});
paginationLoading.value = paging;
try {
final result = await BackendAPI.instance.listNotifications(
_filter.isNew, _filter.isNew,
page: page, page: page,
perPage: _pageSize, perPage: perPage,
cancelToken: token, cancelToken: token,
); ),
if (!mounted || token != _fetchToken) return; onResult: (result) {
paginationLoading.value = false; if (animateDiff && !isLoading) {
if (animateDiff && !_isLoading) { // Merge into the live list so changes animate. Scalars are updated
// Merge into the live list so changes animate. Scalars are updated // without setState here; _reconcile schedules the rebuild itself so
// without setState here; _reconcile schedules the rebuild itself so it // it can defer swapping in the empty state until exit animations
// can defer swapping in the empty state until exit animations finish. // finish.
_currentPage = page; currentPage = page;
_totalCount = result.totalCount; totalCount = result.totalCount;
_isLoading = false; isLoading = false;
_isPaging = false; isPaging = false;
_reconcile(result.items); _reconcile(result.items);
return; return;
} }
// Reset: a fresh dataset. Recreate the list key so the animated list // Reset: a fresh dataset. Recreate the list key so the animated list
// mounts anew and shows the rows immediately, without insert animations. // mounts anew and shows the rows immediately, without insert animations.
setState(() { setState(() {
_listKey = GlobalKey(); _listKey = GlobalKey();
_currentPage = page; currentPage = page;
_totalCount = result.totalCount; totalCount = result.totalCount;
_items = result.items; _items = result.items;
_isLoading = false; isLoading = false;
_isPaging = false; isPaging = false;
}); });
} catch (e) { },
if (!mounted || token != _fetchToken) return; );
if (e is DioException && e.type == DioExceptionType.cancel) return;
setState(() {
_error = dioErrorToUserMessage(e);
_isLoading = false;
_isPaging = false;
});
paginationLoading.value = false;
}
} }
Future<void> _markAllAsRead() async { Future<void> _markAllAsRead() async {
@@ -229,7 +194,7 @@ class _NotificationsListState extends State<NotificationsList>
return; return;
} }
if (!mounted) return; if (!mounted) return;
_fetchPage(_currentPage, animateDiff: true); _fetchPage(currentPage, animateDiff: true);
UISnackbars.showSuccess(context, 'All notifications marked as read'); UISnackbars.showSuccess(context, 'All notifications marked as read');
} }
@@ -304,7 +269,7 @@ class _NotificationsListState extends State<NotificationsList>
/// [_removeItem] directly so they don't double-count against a fresh total. /// [_removeItem] directly so they don't double-count against a fresh total.
void _removeAndSettle(int id, {required bool animated}) { void _removeAndSettle(int id, {required bool animated}) {
_removeItem(id, animated: animated); _removeItem(id, animated: animated);
if (_totalCount > 0) _totalCount--; if (totalCount > 0) totalCount--;
_afterStructuralChange(); _afterStructuralChange();
} }
@@ -369,9 +334,9 @@ class _NotificationsListState extends State<NotificationsList>
_buildNotificationsHeader(context), _buildNotificationsHeader(context),
Expanded( Expanded(
child: RefreshIndicator( child: RefreshIndicator(
onRefresh: () => _fetchPage(_currentPage, animateDiff: true), onRefresh: () => _fetchPage(currentPage, animateDiff: true),
child: CustomScrollView( child: CustomScrollView(
controller: _scrollController, controller: scrollController,
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
slivers: [ slivers: [
..._buildNotificationSlivers(context), ..._buildNotificationSlivers(context),
@@ -417,15 +382,15 @@ class _NotificationsListState extends State<NotificationsList>
} }
List<Widget> _buildNotificationSlivers(BuildContext context) { List<Widget> _buildNotificationSlivers(BuildContext context) {
if (_isLoading) { if (isLoading) {
return [const SliverToBoxAdapter(child: ListSkeleton(rows: 4))]; return [const SliverToBoxAdapter(child: ListSkeleton(rows: 4))];
} }
if (_error != null) { if (error != null) {
return [ return [
SliverFillRemaining( SliverFillRemaining(
child: Center( child: Center(
child: Text( child: Text(
'Error: $_error', 'Error: $error',
style: TextStyle(color: Theme.of(context).colorScheme.error), style: TextStyle(color: Theme.of(context).colorScheme.error),
), ),
), ),
@@ -444,12 +409,12 @@ class _NotificationsListState extends State<NotificationsList>
} }
return [ return [
_buildNotificationSliver(), _buildNotificationSliver(),
if (_currentPage > 0 || _totalPages > 1) if (currentPage > 0 || totalPages > 1)
SliverToBoxAdapter( SliverToBoxAdapter(
child: PaginationBar( child: PaginationBar(
currentPage: _currentPage, currentPage: currentPage,
totalPages: _totalPages, totalPages: totalPages,
isLoading: _isPaging, isLoading: isPaging,
onPageChanged: (page) => onPageChanged: (page) =>
_fetchPage(page, scrollToTop: true, paging: true), _fetchPage(page, scrollToTop: true, paging: true),
), ),
@@ -1,11 +1,13 @@
class ArpScannerStatus { /// Status of an active scanner (one that runs on an interval, e.g. ARP, SNMP).
/// Mirrors the backend's `ActiveSnapshot` / `ActiveScannerStatusResponse`.
class ActiveScannerStatus {
final bool isRunning; final bool isRunning;
final double? runningForSeconds; final double? runningForSeconds;
final double? nextRunInSeconds; final double? nextRunInSeconds;
final int? lastScanDevicesSeen; final int? lastScanDevicesSeen;
final double? lastScanSecondsAgo; final double? lastScanSecondsAgo;
const ArpScannerStatus({ const ActiveScannerStatus({
required this.isRunning, required this.isRunning,
this.runningForSeconds, this.runningForSeconds,
this.nextRunInSeconds, this.nextRunInSeconds,
@@ -13,8 +15,8 @@ class ArpScannerStatus {
this.lastScanSecondsAgo, this.lastScanSecondsAgo,
}); });
factory ArpScannerStatus.fromJson(Map<String, dynamic> json) { factory ActiveScannerStatus.fromJson(Map<String, dynamic> json) {
return ArpScannerStatus( return ActiveScannerStatus(
isRunning: json['is_running'] as bool, isRunning: json['is_running'] as bool,
runningForSeconds: (json['running_for_seconds'] as num?)?.toDouble(), runningForSeconds: (json['running_for_seconds'] as num?)?.toDouble(),
nextRunInSeconds: (json['next_run_in_seconds'] as num?)?.toDouble(), nextRunInSeconds: (json['next_run_in_seconds'] as num?)?.toDouble(),
@@ -1,23 +0,0 @@
class DhcpScannerStatus {
final bool isListening;
final double? listeningForSeconds;
final int devicesSeen;
final double? lastDeviceSeenSecondsAgo;
const DhcpScannerStatus({
required this.isListening,
this.listeningForSeconds,
required this.devicesSeen,
this.lastDeviceSeenSecondsAgo,
});
factory DhcpScannerStatus.fromJson(Map<String, dynamic> json) {
return DhcpScannerStatus(
isListening: json['is_listening'] as bool,
listeningForSeconds: (json['listening_for_seconds'] as num?)?.toDouble(),
devicesSeen: (json['devices_seen'] as num).toInt(),
lastDeviceSeenSecondsAgo: (json['last_device_seen_seconds_ago'] as num?)
?.toDouble(),
);
}
}
@@ -1,23 +0,0 @@
class MdnsScannerStatus {
final bool isListening;
final double? listeningForSeconds;
final int devicesSeen;
final double? lastDeviceSeenSecondsAgo;
const MdnsScannerStatus({
required this.isListening,
this.listeningForSeconds,
required this.devicesSeen,
this.lastDeviceSeenSecondsAgo,
});
factory MdnsScannerStatus.fromJson(Map<String, dynamic> json) {
return MdnsScannerStatus(
isListening: json['is_listening'] as bool,
listeningForSeconds: (json['listening_for_seconds'] as num?)?.toDouble(),
devicesSeen: (json['devices_seen'] as num).toInt(),
lastDeviceSeenSecondsAgo: (json['last_device_seen_seconds_ago'] as num?)
?.toDouble(),
);
}
}
@@ -1,18 +1,20 @@
class SsdpScannerStatus { /// Status of a passive scanner (one that listens continuously, e.g. mDNS, SSDP,
/// DHCP). Mirrors the backend's `PassiveSnapshot` / `PassiveScannerStatusResponse`.
class PassiveScannerStatus {
final bool isListening; final bool isListening;
final double? listeningForSeconds; final double? listeningForSeconds;
final int devicesSeen; final int devicesSeen;
final double? lastDeviceSeenSecondsAgo; final double? lastDeviceSeenSecondsAgo;
const SsdpScannerStatus({ const PassiveScannerStatus({
required this.isListening, required this.isListening,
this.listeningForSeconds, this.listeningForSeconds,
required this.devicesSeen, required this.devicesSeen,
this.lastDeviceSeenSecondsAgo, this.lastDeviceSeenSecondsAgo,
}); });
factory SsdpScannerStatus.fromJson(Map<String, dynamic> json) { factory PassiveScannerStatus.fromJson(Map<String, dynamic> json) {
return SsdpScannerStatus( return PassiveScannerStatus(
isListening: json['is_listening'] as bool, isListening: json['is_listening'] as bool,
listeningForSeconds: (json['listening_for_seconds'] as num?)?.toDouble(), listeningForSeconds: (json['listening_for_seconds'] as num?)?.toDouble(),
devicesSeen: (json['devices_seen'] as num).toInt(), devicesSeen: (json['devices_seen'] as num).toInt(),
@@ -1,25 +0,0 @@
class SnmpScannerStatus {
final bool isRunning;
final double? runningForSeconds;
final double? nextRunInSeconds;
final int? lastScanDevicesSeen;
final double? lastScanSecondsAgo;
const SnmpScannerStatus({
required this.isRunning,
this.runningForSeconds,
this.nextRunInSeconds,
this.lastScanDevicesSeen,
this.lastScanSecondsAgo,
});
factory SnmpScannerStatus.fromJson(Map<String, dynamic> json) {
return SnmpScannerStatus(
isRunning: json['is_running'] as bool,
runningForSeconds: (json['running_for_seconds'] as num?)?.toDouble(),
nextRunInSeconds: (json['next_run_in_seconds'] as num?)?.toDouble(),
lastScanDevicesSeen: (json['last_scan_devices_seen'] as num?)?.toInt(),
lastScanSecondsAgo: (json['last_scan_seconds_ago'] as num?)?.toDouble(),
);
}
}
+6 -14
View File
@@ -1,30 +1,22 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../theme/dimens.dart'; import '../theme/dimens.dart';
import '../widgets/arp_scanner_card.dart'; import '../widgets/scanner_status_cards.dart';
import '../widgets/dhcp_scanner_card.dart';
import '../widgets/mdns_scanner_card.dart';
import '../widgets/snmp_scanner_card.dart';
import '../widgets/ssdp_scanner_card.dart';
class StatusScreen extends StatelessWidget { class StatusScreen extends StatelessWidget {
const StatusScreen({super.key}); const StatusScreen({super.key});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final cards = scannerStatusCards();
return SingleChildScrollView( return SingleChildScrollView(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
const ArpScannerCard(), for (var i = 0; i < cards.length; i++) ...[
const SizedBox(height: Insets.sm), if (i > 0) const SizedBox(height: Insets.sm),
const MdnsScannerCard(), cards[i],
const SizedBox(height: Insets.sm), ],
const SsdpScannerCard(),
const SizedBox(height: Insets.sm),
const DhcpScannerCard(),
const SizedBox(height: Insets.sm),
const SnmpScannerCard(),
], ],
), ),
); );
+33 -28
View File
@@ -1,40 +1,45 @@
part of '../oott_api.dart'; part of '../oott_api.dart';
/// Per-scanner status endpoints. Every scanner exposes the same /// Per-scanner status endpoints. Active scanners (ARP, SNMP) share the
/// `/<scanner>_scanner/status` shape, so each getter just decodes its model. /// [ActiveScannerStatus] shape; passive scanners (mDNS, SSDP, DHCP) share the
/// [PassiveScannerStatus] shape. Each getter just decodes its model.
extension ScannerApi on BackendAPI { extension ScannerApi on BackendAPI {
Future<ArpScannerStatus> getArpScannerStatus({CancelToken? cancelToken}) => Future<ActiveScannerStatus> getArpScannerStatus({CancelToken? cancelToken}) =>
_getModel( _getModel(
'/arp_scanner/status', '/arp_scanner/status',
ArpScannerStatus.fromJson, ActiveScannerStatus.fromJson,
cancelToken: cancelToken, cancelToken: cancelToken,
); );
Future<MdnsScannerStatus> getMdnsScannerStatus({CancelToken? cancelToken}) => Future<PassiveScannerStatus> getMdnsScannerStatus({
_getModel( CancelToken? cancelToken,
'/mdns_scanner/status', }) => _getModel(
MdnsScannerStatus.fromJson, '/mdns_scanner/status',
cancelToken: cancelToken, PassiveScannerStatus.fromJson,
); cancelToken: cancelToken,
);
Future<SsdpScannerStatus> getSsdpScannerStatus({CancelToken? cancelToken}) => Future<PassiveScannerStatus> getSsdpScannerStatus({
_getModel( CancelToken? cancelToken,
'/ssdp_scanner/status', }) => _getModel(
SsdpScannerStatus.fromJson, '/ssdp_scanner/status',
cancelToken: cancelToken, PassiveScannerStatus.fromJson,
); cancelToken: cancelToken,
);
Future<DhcpScannerStatus> getDhcpScannerStatus({CancelToken? cancelToken}) => Future<PassiveScannerStatus> getDhcpScannerStatus({
_getModel( CancelToken? cancelToken,
'/dhcp_scanner/status', }) => _getModel(
DhcpScannerStatus.fromJson, '/dhcp_scanner/status',
cancelToken: cancelToken, PassiveScannerStatus.fromJson,
); cancelToken: cancelToken,
);
Future<SnmpScannerStatus> getSnmpScannerStatus({CancelToken? cancelToken}) => Future<ActiveScannerStatus> getSnmpScannerStatus({
_getModel( CancelToken? cancelToken,
'/snmp_scanner/status', }) => _getModel(
SnmpScannerStatus.fromJson, '/snmp_scanner/status',
cancelToken: cancelToken, ActiveScannerStatus.fromJson,
); cancelToken: cancelToken,
);
} }
+2 -5
View File
@@ -8,11 +8,8 @@ import 'api/api_error.dart';
import 'api/dio_config.dart'; import 'api/dio_config.dart';
import 'backend_reachability.dart'; import 'backend_reachability.dart';
import 'pref_utils.dart'; import 'pref_utils.dart';
import '../model/arp_scanner_status.dart'; import '../model/active_scanner_status.dart';
import '../model/dhcp_scanner_status.dart'; import '../model/passive_scanner_status.dart';
import '../model/mdns_scanner_status.dart';
import '../model/ssdp_scanner_status.dart';
import '../model/snmp_scanner_status.dart';
import '../model/device.dart'; import '../model/device.dart';
import '../model/device_event.dart'; import '../model/device_event.dart';
import '../model/device_summary.dart'; import '../model/device_summary.dart';
@@ -0,0 +1,93 @@
import 'package:dio/dio.dart';
import 'package:flutter/material.dart';
import '../theme/dimens.dart';
import '../widgets/pagination_progress.dart';
import 'api/api_error.dart';
/// Shared state and orchestration for screens that show a paged, server-backed
/// list (devices, notifications). Provides the common pagination state, the
/// responsive page-size/page-count getters, the cancel-token-aware [runFetch]
/// helper, and disposal of the scroll controller and in-flight token.
///
/// Subclasses supply [phonePageSize], [widePageSize] and [isListEmpty], call
/// [runFetch] to load a page, and chain [dispose] via `super.dispose()`.
mixin PaginatedListState<W extends StatefulWidget> on State<W> {
int currentPage = 0;
// Total items matching the current filters, used to show how many pages exist
// and to offer "go to last page".
int totalCount = 0;
bool isLoading = false;
bool isPaging = false;
bool didInitialFetch = false;
String? error;
CancelToken? fetchToken;
final ScrollController scrollController = ScrollController();
/// Page sizes for the phone and wider layouts.
int get phonePageSize;
int get widePageSize;
/// Whether the list currently has no items. Controls whether a fetch shows
/// the full loading state or refreshes the current page in place.
bool get isListEmpty;
int get pageSize => MediaQuery.sizeOf(context).width < Breakpoints.medium
? phonePageSize
: widePageSize;
int get totalPages => (totalCount / pageSize).ceil().clamp(1, 1 << 30);
/// Runs a paged fetch with the orchestration shared by every paged list:
/// optional scroll-to-top, cancel-token swap, loading flags, the
/// unmounted/superseded guard, and error handling. [fetch] performs the
/// request for the given page; [onResult] applies a still-current result to
/// state (it is not called for cancelled, superseded, or failed fetches).
Future<void> runFetch<R>(
int page, {
bool scrollToTop = false,
bool paging = false,
required Future<R> Function(int page, int perPage, CancelToken token) fetch,
required void Function(R result) onResult,
}) async {
if (scrollToTop && scrollController.hasClients) {
scrollController.animateTo(
0,
duration: const Duration(milliseconds: 300),
curve: Curves.easeOut,
);
}
fetchToken?.cancel();
final token = CancelToken();
fetchToken = token;
setState(() {
isLoading = isListEmpty;
isPaging = paging;
error = null;
});
paginationLoading.value = paging;
try {
final result = await fetch(page, pageSize, token);
if (!mounted || token != fetchToken) return;
paginationLoading.value = false;
onResult(result);
} catch (e) {
if (!mounted || token != fetchToken) return;
if (e is DioException && e.type == DioExceptionType.cancel) return;
setState(() {
error = dioErrorToUserMessage(e);
isLoading = false;
isPaging = false;
});
paginationLoading.value = false;
}
}
@override
void dispose() {
fetchToken?.cancel();
scrollController.dispose();
// Clear any in-flight cue so it doesn't linger after leaving the page.
paginationLoading.value = false;
super.dispose();
}
}
+33
View File
@@ -0,0 +1,33 @@
import 'dart:async';
import 'package:flutter/widgets.dart';
/// Mixin for [State]s that display continuously-updating elapsed-time text
/// (e.g. "Running for 3m 12s"). It runs a once-per-second timer that calls
/// [setState] so those labels stay current, and cancels it automatically on
/// dispose.
///
/// Call [startRebuildTicker] from `initState` (or when becoming visible) and
/// [stopRebuildTicker] when the widget is paused/hidden. Both are idempotent.
mixin PeriodicRebuild<T extends StatefulWidget> on State<T> {
Timer? _rebuildTicker;
/// Starts the per-second rebuild ticker if it isn't already running.
void startRebuildTicker() {
_rebuildTicker ??= Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() {});
});
}
/// Stops the per-second rebuild ticker.
void stopRebuildTicker() {
_rebuildTicker?.cancel();
_rebuildTicker = null;
}
@override
void dispose() {
stopRebuildTicker();
super.dispose();
}
}
@@ -1,55 +0,0 @@
import 'package:flutter/material.dart';
import '../model/arp_scanner_status.dart';
import '../theme/app_colors.dart';
import '../utils/duration_formatter.dart';
import '../utils/oott_api.dart';
import 'scanner_status_card.dart';
class ArpScannerCard extends StatelessWidget {
const ArpScannerCard({super.key});
@override
Widget build(BuildContext context) {
return ScannerStatusCard<ArpScannerStatus>(
title: 'ARP Scanner',
fetch: ({cancelToken}) =>
BackendAPI.instance.getArpScannerStatus(cancelToken: cancelToken),
resolver: _resolve,
);
}
static ScannerStatus _resolve(
BuildContext context,
ArpScannerStatus status,
double elapsed,
) {
final successColor = Theme.of(
context,
).extension<AppColorExtension>()!.success;
final neutralColor = Theme.of(context).colorScheme.outline;
final lastScan = status.lastScanDevicesSeen != null
? '${status.lastScanDevicesSeen} devices on last scan'
: null;
if (status.isRunning) {
final sublabels = <String>[
if (status.runningForSeconds != null)
'Running for ${formatSeconds(status.runningForSeconds! + elapsed)}',
?lastScan,
];
return (color: successColor, label: 'Running', sublabels: sublabels);
}
if (status.nextRunInSeconds != null) {
final remaining = (status.nextRunInSeconds! - elapsed).clamp(
0.0,
double.infinity,
);
return (
color: neutralColor,
label: 'Waiting for next run',
sublabels: ['Next run in ${formatSeconds(remaining)}', ?lastScan],
);
}
return (color: neutralColor, label: 'Not started', sublabels: []);
}
}
@@ -1,47 +0,0 @@
import 'package:flutter/material.dart';
import '../model/dhcp_scanner_status.dart';
import '../theme/app_colors.dart';
import '../utils/duration_formatter.dart';
import '../utils/oott_api.dart';
import 'scanner_status_card.dart';
class DhcpScannerCard extends StatelessWidget {
const DhcpScannerCard({super.key});
@override
Widget build(BuildContext context) {
return ScannerStatusCard<DhcpScannerStatus>(
title: 'DHCP Scanner',
fetch: ({cancelToken}) =>
BackendAPI.instance.getDhcpScannerStatus(cancelToken: cancelToken),
resolver: _resolve,
);
}
static ScannerStatus _resolve(
BuildContext context,
DhcpScannerStatus status,
double elapsed,
) {
if (!status.isListening) {
return (
color: Theme.of(context).colorScheme.outline,
label: 'Not started',
sublabels: [],
);
}
final sublabels = <String>[
status.listeningForSeconds != null
? 'Listening for ${formatSeconds(status.listeningForSeconds! + elapsed)} · ${status.devicesSeen} devices in the last hour'
: '${status.devicesSeen} devices in the last hour',
if (status.lastDeviceSeenSecondsAgo != null)
'Last device ${formatSeconds(status.lastDeviceSeenSecondsAgo! + elapsed)} ago',
];
return (
color: Theme.of(context).extension<AppColorExtension>()!.success,
label: 'Listening',
sublabels: sublabels,
);
}
}
@@ -1,47 +0,0 @@
import 'package:flutter/material.dart';
import '../model/mdns_scanner_status.dart';
import '../theme/app_colors.dart';
import '../utils/duration_formatter.dart';
import '../utils/oott_api.dart';
import 'scanner_status_card.dart';
class MdnsScannerCard extends StatelessWidget {
const MdnsScannerCard({super.key});
@override
Widget build(BuildContext context) {
return ScannerStatusCard<MdnsScannerStatus>(
title: 'mDNS Scanner',
fetch: ({cancelToken}) =>
BackendAPI.instance.getMdnsScannerStatus(cancelToken: cancelToken),
resolver: _resolve,
);
}
static ScannerStatus _resolve(
BuildContext context,
MdnsScannerStatus status,
double elapsed,
) {
if (!status.isListening) {
return (
color: Theme.of(context).colorScheme.outline,
label: 'Not started',
sublabels: [],
);
}
final sublabels = <String>[
status.listeningForSeconds != null
? 'Listening for ${formatSeconds(status.listeningForSeconds! + elapsed)} · ${status.devicesSeen} devices in the last hour'
: '${status.devicesSeen} devices in the last hour',
if (status.lastDeviceSeenSecondsAgo != null)
'Last device ${formatSeconds(status.lastDeviceSeenSecondsAgo! + elapsed)} ago',
];
return (
color: Theme.of(context).extension<AppColorExtension>()!.success,
label: 'Listening',
sublabels: sublabels,
);
}
}
@@ -1,9 +1,8 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../utils/backend_reachability.dart'; import '../utils/backend_reachability.dart';
import '../utils/duration_formatter.dart'; import '../utils/duration_formatter.dart';
import '../utils/periodic_rebuild.dart';
import '../utils/polled_value.dart'; import '../utils/polled_value.dart';
class PolledStaleIndicator extends StatefulWidget { class PolledStaleIndicator extends StatefulWidget {
@@ -15,21 +14,12 @@ class PolledStaleIndicator extends StatefulWidget {
State<PolledStaleIndicator> createState() => _PolledStaleIndicatorState(); State<PolledStaleIndicator> createState() => _PolledStaleIndicatorState();
} }
class _PolledStaleIndicatorState extends State<PolledStaleIndicator> { class _PolledStaleIndicatorState extends State<PolledStaleIndicator>
Timer? _ticker; with PeriodicRebuild<PolledStaleIndicator> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_ticker = Timer.periodic(const Duration(seconds: 1), (_) { startRebuildTicker();
if (mounted) setState(() {});
});
}
@override
void dispose() {
_ticker?.cancel();
super.dispose();
} }
@override @override
+15 -12
View File
@@ -1,21 +1,24 @@
import 'dart:async';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
import '../utils/backend_reachability.dart'; import '../utils/backend_reachability.dart';
import '../utils/periodic_rebuild.dart';
import '../utils/polled_value.dart'; import '../utils/polled_value.dart';
import 'polled_stale_indicator.dart'; import 'polled_stale_indicator.dart';
typedef ScannerStatus = ({Color color, String label, List<String> sublabels}); typedef ScannerStatus = ({Color color, String label, List<String> sublabels});
typedef ScannerStatusResolver<T> = typedef ScannerStatusResolver<T> =
ScannerStatus Function(BuildContext context, T value, double elapsedSeconds); ScannerStatus Function(
BuildContext context,
T value,
double elapsedSeconds,
);
/// Generic card that polls a scanner status endpoint and renders the result /// Generic card that polls a scanner status endpoint and renders the result
/// using the provided [resolver]. The two scanner cards (ARP, mDNS) are thin /// using the provided [resolver]. The per-scanner detail cards are configured
/// wrappers over this widget. /// over this widget in `scanner_status_cards.dart`.
class ScannerStatusCard<T> extends StatefulWidget { class ScannerStatusCard<T> extends StatefulWidget {
const ScannerStatusCard({ const ScannerStatusCard({
super.key, super.key,
@@ -32,9 +35,9 @@ class ScannerStatusCard<T> extends StatefulWidget {
State<ScannerStatusCard<T>> createState() => _ScannerStatusCardState<T>(); State<ScannerStatusCard<T>> createState() => _ScannerStatusCardState<T>();
} }
class _ScannerStatusCardState<T> extends State<ScannerStatusCard<T>> { class _ScannerStatusCardState<T> extends State<ScannerStatusCard<T>>
with PeriodicRebuild<ScannerStatusCard<T>> {
late final PolledValue<T> _polled; late final PolledValue<T> _polled;
Timer? _tickTimer;
@override @override
void initState() { void initState() {
@@ -44,14 +47,11 @@ class _ScannerStatusCardState<T> extends State<ScannerStatusCard<T>> {
pollInterval: const Duration(seconds: 5), pollInterval: const Duration(seconds: 5),
staleErrorAfter: const Duration(seconds: 30), staleErrorAfter: const Duration(seconds: 30),
); );
_tickTimer = Timer.periodic(const Duration(seconds: 1), (_) { startRebuildTicker();
if (mounted) setState(() {});
});
} }
@override @override
void dispose() { void dispose() {
_tickTimer?.cancel();
_polled.dispose(); _polled.dispose();
super.dispose(); super.dispose();
} }
@@ -124,7 +124,10 @@ class _ScannerStatusCardState<T> extends State<ScannerStatusCard<T>> {
); );
} }
ScannerStatus _resolveStatus(BuildContext context, PolledFreshness freshness) { ScannerStatus _resolveStatus(
BuildContext context,
PolledFreshness freshness,
) {
if (freshness == PolledFreshness.error) { if (freshness == PolledFreshness.error) {
return ( return (
color: Theme.of(context).colorScheme.error, color: Theme.of(context).colorScheme.error,
@@ -0,0 +1,107 @@
import 'package:flutter/material.dart';
import '../model/active_scanner_status.dart';
import '../model/passive_scanner_status.dart';
import '../theme/app_colors.dart';
import '../utils/duration_formatter.dart';
import '../utils/oott_api.dart';
import 'scanner_status_card.dart';
/// Resolves the display status for an active (interval) scanner — ARP, SNMP.
ScannerStatus resolveActiveScanner(
BuildContext context,
ActiveScannerStatus status,
double elapsed,
) {
final successColor = Theme.of(
context,
).extension<AppColorExtension>()!.success;
final neutralColor = Theme.of(context).colorScheme.outline;
final lastScan = status.lastScanDevicesSeen != null
? '${status.lastScanDevicesSeen} devices on last scan'
: null;
if (status.isRunning) {
final sublabels = <String>[
if (status.runningForSeconds != null)
'Running for ${formatSeconds(status.runningForSeconds! + elapsed)}',
?lastScan,
];
return (color: successColor, label: 'Running', sublabels: sublabels);
}
if (status.nextRunInSeconds != null) {
final remaining = (status.nextRunInSeconds! - elapsed).clamp(
0.0,
double.infinity,
);
return (
color: neutralColor,
label: 'Waiting for next run',
sublabels: ['Next run in ${formatSeconds(remaining)}', ?lastScan],
);
}
return (color: neutralColor, label: 'Not started', sublabels: []);
}
/// Resolves the display status for a passive (listening) scanner — mDNS, SSDP,
/// DHCP.
ScannerStatus resolvePassiveScanner(
BuildContext context,
PassiveScannerStatus status,
double elapsed,
) {
if (!status.isListening) {
return (
color: Theme.of(context).colorScheme.outline,
label: 'Not started',
sublabels: [],
);
}
final sublabels = <String>[
status.listeningForSeconds != null
? 'Listening for ${formatSeconds(status.listeningForSeconds! + elapsed)} · ${status.devicesSeen} devices in the last hour'
: '${status.devicesSeen} devices in the last hour',
if (status.lastDeviceSeenSecondsAgo != null)
'Last device ${formatSeconds(status.lastDeviceSeenSecondsAgo! + elapsed)} ago',
];
return (
color: Theme.of(context).extension<AppColorExtension>()!.success,
label: 'Listening',
sublabels: sublabels,
);
}
/// The scanner status detail cards, in display order. Each is a thin
/// configuration over the generic [ScannerStatusCard]: a title, the endpoint to
/// poll, and the resolver for its shape.
List<Widget> scannerStatusCards() => [
ScannerStatusCard<ActiveScannerStatus>(
title: 'ARP Scanner',
fetch: ({cancelToken}) =>
BackendAPI.instance.getArpScannerStatus(cancelToken: cancelToken),
resolver: resolveActiveScanner,
),
ScannerStatusCard<PassiveScannerStatus>(
title: 'mDNS Scanner',
fetch: ({cancelToken}) =>
BackendAPI.instance.getMdnsScannerStatus(cancelToken: cancelToken),
resolver: resolvePassiveScanner,
),
ScannerStatusCard<PassiveScannerStatus>(
title: 'SSDP/UPnP Scanner',
fetch: ({cancelToken}) =>
BackendAPI.instance.getSsdpScannerStatus(cancelToken: cancelToken),
resolver: resolvePassiveScanner,
),
ScannerStatusCard<PassiveScannerStatus>(
title: 'DHCP Scanner',
fetch: ({cancelToken}) =>
BackendAPI.instance.getDhcpScannerStatus(cancelToken: cancelToken),
resolver: resolvePassiveScanner,
),
ScannerStatusCard<ActiveScannerStatus>(
title: 'SNMP Scanner',
fetch: ({cancelToken}) =>
BackendAPI.instance.getSnmpScannerStatus(cancelToken: cancelToken),
resolver: resolveActiveScanner,
),
];
+104 -238
View File
@@ -1,21 +1,28 @@
import 'dart:async'; import 'package:dio/dio.dart';
import 'package:flutter/material.dart'; 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/active_scanner_status.dart';
import '../model/dhcp_scanner_status.dart'; import '../model/passive_scanner_status.dart';
import '../model/mdns_scanner_status.dart';
import '../model/snmp_scanner_status.dart';
import '../model/ssdp_scanner_status.dart';
import '../navigation.dart'; import '../navigation.dart';
import '../theme/app_colors.dart'; import '../theme/app_colors.dart';
import '../utils/backend_reachability.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/periodic_rebuild.dart';
import '../utils/polled_value.dart'; import '../utils/polled_value.dart';
import 'polled_stale_indicator.dart'; import 'polled_stale_indicator.dart';
/// One scanner shown in the combined card: its display name, the polled status,
/// and how to turn the current value into a (colour, one-line text) summary.
class _Scanner {
final String name;
final PolledValue polled;
final (Color, String) Function(BuildContext, PolledFreshness) resolve;
const _Scanner(this.name, this.polled, this.resolve);
}
class ScannersStatusCard extends StatefulWidget { class ScannersStatusCard extends StatefulWidget {
const ScannersStatusCard({super.key}); const ScannersStatusCard({super.key});
@@ -24,51 +31,42 @@ class ScannersStatusCard extends StatefulWidget {
} }
class _ScannersStatusCardState extends State<ScannersStatusCard> class _ScannersStatusCardState extends State<ScannersStatusCard>
with RouteAware, WidgetsBindingObserver { with
late final PolledValue<ArpScannerStatus> _arp; RouteAware,
late final PolledValue<MdnsScannerStatus> _mdns; WidgetsBindingObserver,
late final PolledValue<SsdpScannerStatus> _ssdp; PeriodicRebuild<ScannersStatusCard> {
late final PolledValue<DhcpScannerStatus> _dhcp; late final List<_Scanner> _scanners;
late final PolledValue<SnmpScannerStatus> _snmp;
Timer? _tickTimer; PolledValue<T> _poll<T>(
Future<T> Function({CancelToken? cancelToken}) fetch,
) => PolledValue<T>(
fetch: fetch,
pollInterval: const Duration(seconds: 5),
staleErrorAfter: const Duration(seconds: 30),
);
@override @override
void initState() { void initState() {
super.initState(); super.initState();
WidgetsBinding.instance.addObserver(this); WidgetsBinding.instance.addObserver(this);
_arp = PolledValue<ArpScannerStatus>( final api = BackendAPI.instance;
fetch: ({cancelToken}) => final arp = _poll(api.getArpScannerStatus);
BackendAPI.instance.getArpScannerStatus(cancelToken: cancelToken), final mdns = _poll(api.getMdnsScannerStatus);
pollInterval: const Duration(seconds: 5), final ssdp = _poll(api.getSsdpScannerStatus);
staleErrorAfter: const Duration(seconds: 30), final dhcp = _poll(api.getDhcpScannerStatus);
); final snmp = _poll(api.getSnmpScannerStatus);
_mdns = PolledValue<MdnsScannerStatus>( _scanners = [
fetch: ({cancelToken}) => _Scanner('ARP', arp, (c, f) => _resolve(c, arp, f, _activeStatus)),
BackendAPI.instance.getMdnsScannerStatus(cancelToken: cancelToken), _Scanner('mDNS', mdns, (c, f) => _resolve(c, mdns, f, _passiveStatus)),
pollInterval: const Duration(seconds: 5), _Scanner(
staleErrorAfter: const Duration(seconds: 30), 'SSDP/UPnP',
); ssdp,
_ssdp = PolledValue<SsdpScannerStatus>( (c, f) => _resolve(c, ssdp, f, _passiveStatus),
fetch: ({cancelToken}) => ),
BackendAPI.instance.getSsdpScannerStatus(cancelToken: cancelToken), _Scanner('DHCP', dhcp, (c, f) => _resolve(c, dhcp, f, _passiveStatus)),
pollInterval: const Duration(seconds: 5), _Scanner('SNMP', snmp, (c, f) => _resolve(c, snmp, f, _activeStatus)),
staleErrorAfter: const Duration(seconds: 30), ];
); startRebuildTicker();
_dhcp = PolledValue<DhcpScannerStatus>(
fetch: ({cancelToken}) =>
BackendAPI.instance.getDhcpScannerStatus(cancelToken: cancelToken),
pollInterval: const Duration(seconds: 5),
staleErrorAfter: const Duration(seconds: 30),
);
_snmp = PolledValue<SnmpScannerStatus>(
fetch: ({cancelToken}) =>
BackendAPI.instance.getSnmpScannerStatus(cancelToken: cancelToken),
pollInterval: const Duration(seconds: 5),
staleErrorAfter: const Duration(seconds: 30),
);
_tickTimer = Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() {});
});
} }
@override @override
@@ -100,36 +98,26 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
} }
void _pausePolling() { void _pausePolling() {
_arp.pause(); for (final scanner in _scanners) {
_mdns.pause(); scanner.polled.pause();
_ssdp.pause(); }
_dhcp.pause(); stopRebuildTicker();
_snmp.pause();
_tickTimer?.cancel();
_tickTimer = null;
} }
void _resumePolling() { void _resumePolling() {
_arp.resume(); for (final scanner in _scanners) {
_mdns.resume(); scanner.polled.resume();
_ssdp.resume(); }
_dhcp.resume(); startRebuildTicker();
_snmp.resume();
_tickTimer ??= Timer.periodic(const Duration(seconds: 1), (_) {
if (mounted) setState(() {});
});
} }
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance.removeObserver(this); WidgetsBinding.instance.removeObserver(this);
routeObserver.unsubscribe(this); routeObserver.unsubscribe(this);
_tickTimer?.cancel(); for (final scanner in _scanners) {
_arp.dispose(); scanner.polled.dispose();
_mdns.dispose(); }
_ssdp.dispose();
_dhcp.dispose();
_snmp.dispose();
super.dispose(); super.dispose();
} }
@@ -137,24 +125,15 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
Widget build(BuildContext context) { Widget build(BuildContext context) {
return ListenableBuilder( return ListenableBuilder(
listenable: Listenable.merge([ listenable: Listenable.merge([
_arp, ..._scanners.map((s) => s.polled),
_mdns,
_ssdp,
_dhcp,
_snmp,
BackendReachability.instance, BackendReachability.instance,
]), ]),
builder: (context, _) { builder: (context, _) {
final arpFreshness = effectiveFreshness(_arp); final freshness = {
final mdnsFreshness = effectiveFreshness(_mdns); for (final scanner in _scanners)
final ssdpFreshness = effectiveFreshness(_ssdp); scanner: effectiveFreshness(scanner.polled),
final dhcpFreshness = effectiveFreshness(_dhcp); };
final snmpFreshness = effectiveFreshness(_snmp); if (freshness.values.any((f) => f == PolledFreshness.initialLoading)) {
if (arpFreshness == PolledFreshness.initialLoading ||
mdnsFreshness == PolledFreshness.initialLoading ||
ssdpFreshness == PolledFreshness.initialLoading ||
dhcpFreshness == PolledFreshness.initialLoading ||
snmpFreshness == PolledFreshness.initialLoading) {
return const Card( return const Card(
child: Padding( child: Padding(
padding: EdgeInsets.all(16), padding: EdgeInsets.all(16),
@@ -163,12 +142,6 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
); );
} }
final (arpColor, arpText) = _resolveArp(context, arpFreshness);
final (mdnsColor, mdnsText) = _resolveMdns(context, mdnsFreshness);
final (ssdpColor, ssdpText) = _resolveSsdp(context, ssdpFreshness);
final (dhcpColor, dhcpText) = _resolveDhcp(context, dhcpFreshness);
final (snmpColor, snmpText) = _resolveSnmp(context, snmpFreshness);
return Card( return Card(
clipBehavior: Clip.antiAlias, clipBehavior: Clip.antiAlias,
child: InkWell( child: InkWell(
@@ -183,50 +156,14 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
style: Theme.of(context).textTheme.titleLarge, style: Theme.of(context).textTheme.titleLarge,
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
_scannerRow( for (var i = 0; i < _scanners.length; i++) ...[
context, if (i > 0) const SizedBox(height: 8),
arpColor, _scannerRow(
'ARP', context,
arpText, _scanners[i],
_arp, freshness[_scanners[i]]!,
arpFreshness, ),
), ],
const SizedBox(height: 8),
_scannerRow(
context,
mdnsColor,
'mDNS',
mdnsText,
_mdns,
mdnsFreshness,
),
const SizedBox(height: 8),
_scannerRow(
context,
ssdpColor,
'SSDP/UPnP',
ssdpText,
_ssdp,
ssdpFreshness,
),
const SizedBox(height: 8),
_scannerRow(
context,
dhcpColor,
'DHCP',
dhcpText,
_dhcp,
dhcpFreshness,
),
const SizedBox(height: 8),
_scannerRow(
context,
snmpColor,
'SNMP',
snmpText,
_snmp,
snmpFreshness,
),
], ],
), ),
), ),
@@ -238,15 +175,16 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
Widget _scannerRow( Widget _scannerRow(
BuildContext context, BuildContext context,
Color color, _Scanner scanner,
String name,
String statusText,
PolledValue polled,
PolledFreshness freshness, PolledFreshness freshness,
) { ) {
final (color, statusText) = scanner.resolve(context, freshness);
Widget dot = Icon(Icons.circle, color: color, size: 12); Widget dot = Icon(Icons.circle, color: color, size: 12);
if (freshness == PolledFreshness.error) { if (freshness == PolledFreshness.error) {
dot = Tooltip(message: polled.lastErrorMessage ?? 'Error', child: dot); dot = Tooltip(
message: scanner.polled.lastErrorMessage ?? 'Error',
child: dot,
);
} }
return Row( return Row(
children: [ children: [
@@ -255,10 +193,10 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
Expanded( Expanded(
child: Row( child: Row(
children: [ children: [
Text(name, style: Theme.of(context).textTheme.bodyMedium), Text(scanner.name, style: Theme.of(context).textTheme.bodyMedium),
if (freshness == PolledFreshness.stale) ...[ if (freshness == PolledFreshness.stale) ...[
const SizedBox(width: 6), const SizedBox(width: 6),
PolledStaleIndicator(polled: polled), PolledStaleIndicator(polled: scanner.polled),
], ],
], ],
), ),
@@ -274,18 +212,32 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
); );
} }
(Color, String) _resolveArp(BuildContext context, PolledFreshness freshness) { /// Wraps the shape-specific [compute] with the shared error handling and
/// elapsed-time calculation common to every scanner row.
(Color, String) _resolve<T>(
BuildContext context,
PolledValue<T> polled,
PolledFreshness freshness,
(Color, String) Function(BuildContext, T, double) compute,
) {
if (freshness == PolledFreshness.error) {
return (Theme.of(context).colorScheme.error, 'Error');
}
final elapsed = DateTime.now()
.difference(polled.lastSuccessAt!)
.inSeconds
.toDouble();
return compute(context, polled.value as T, elapsed);
}
(Color, String) _activeStatus(
BuildContext context,
ActiveScannerStatus status,
double elapsed,
) {
final theme = Theme.of(context); final theme = Theme.of(context);
final successColor = theme.extension<AppColorExtension>()!.success; final successColor = theme.extension<AppColorExtension>()!.success;
final neutralColor = theme.colorScheme.outline; final neutralColor = theme.colorScheme.outline;
if (freshness == PolledFreshness.error) {
return (theme.colorScheme.error, 'Error');
}
final status = _arp.value!;
final elapsed = DateTime.now()
.difference(_arp.lastSuccessAt!)
.inSeconds
.toDouble();
if (status.isRunning) { if (status.isRunning) {
final secs = (status.runningForSeconds ?? 0) + elapsed; final secs = (status.runningForSeconds ?? 0) + elapsed;
return (successColor, 'Running for ${formatSeconds(secs)}'); return (successColor, 'Running for ${formatSeconds(secs)}');
@@ -300,21 +252,14 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
return (neutralColor, 'Not started'); return (neutralColor, 'Not started');
} }
(Color, String) _resolveMdns( (Color, String) _passiveStatus(
BuildContext context, BuildContext context,
PolledFreshness freshness, PassiveScannerStatus status,
double elapsed,
) { ) {
final theme = Theme.of(context); final theme = Theme.of(context);
final successColor = theme.extension<AppColorExtension>()!.success; final successColor = theme.extension<AppColorExtension>()!.success;
final neutralColor = theme.colorScheme.outline; final neutralColor = theme.colorScheme.outline;
if (freshness == PolledFreshness.error) {
return (theme.colorScheme.error, 'Error');
}
final status = _mdns.value!;
final elapsed = DateTime.now()
.difference(_mdns.lastSuccessAt!)
.inSeconds
.toDouble();
if (status.isListening) { if (status.isListening) {
if (status.lastDeviceSeenSecondsAgo != null) { if (status.lastDeviceSeenSecondsAgo != null) {
final secs = status.lastDeviceSeenSecondsAgo! + elapsed; final secs = status.lastDeviceSeenSecondsAgo! + elapsed;
@@ -324,83 +269,4 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
} }
return (neutralColor, 'Not started'); return (neutralColor, 'Not started');
} }
(Color, String) _resolveSsdp(
BuildContext context,
PolledFreshness freshness,
) {
final theme = Theme.of(context);
final successColor = theme.extension<AppColorExtension>()!.success;
final neutralColor = theme.colorScheme.outline;
if (freshness == PolledFreshness.error) {
return (theme.colorScheme.error, 'Error');
}
final status = _ssdp.value!;
final elapsed = DateTime.now()
.difference(_ssdp.lastSuccessAt!)
.inSeconds
.toDouble();
if (status.isListening) {
if (status.lastDeviceSeenSecondsAgo != null) {
final secs = status.lastDeviceSeenSecondsAgo! + elapsed;
return (successColor, 'Last device seen ${formatSeconds(secs)} ago');
}
return (successColor, 'No devices seen yet');
}
return (neutralColor, 'Not started');
}
(Color, String) _resolveDhcp(
BuildContext context,
PolledFreshness freshness,
) {
final theme = Theme.of(context);
final successColor = theme.extension<AppColorExtension>()!.success;
final neutralColor = theme.colorScheme.outline;
if (freshness == PolledFreshness.error) {
return (theme.colorScheme.error, 'Error');
}
final status = _dhcp.value!;
final elapsed = DateTime.now()
.difference(_dhcp.lastSuccessAt!)
.inSeconds
.toDouble();
if (status.isListening) {
if (status.lastDeviceSeenSecondsAgo != null) {
final secs = status.lastDeviceSeenSecondsAgo! + elapsed;
return (successColor, 'Last device seen ${formatSeconds(secs)} ago');
}
return (successColor, 'No devices seen yet');
}
return (neutralColor, 'Not started');
}
(Color, String) _resolveSnmp(
BuildContext context,
PolledFreshness freshness,
) {
final theme = Theme.of(context);
final successColor = theme.extension<AppColorExtension>()!.success;
final neutralColor = theme.colorScheme.outline;
if (freshness == PolledFreshness.error) {
return (theme.colorScheme.error, 'Error');
}
final status = _snmp.value!;
final elapsed = DateTime.now()
.difference(_snmp.lastSuccessAt!)
.inSeconds
.toDouble();
if (status.isRunning) {
final secs = (status.runningForSeconds ?? 0) + elapsed;
return (successColor, 'Running for ${formatSeconds(secs)}');
}
if (status.nextRunInSeconds != null) {
final remaining = (status.nextRunInSeconds! - elapsed).clamp(
0.0,
double.infinity,
);
return (neutralColor, 'Next run in ${formatSeconds(remaining)}');
}
return (neutralColor, 'Not started');
}
} }
@@ -1,55 +0,0 @@
import 'package:flutter/material.dart';
import '../model/snmp_scanner_status.dart';
import '../theme/app_colors.dart';
import '../utils/duration_formatter.dart';
import '../utils/oott_api.dart';
import 'scanner_status_card.dart';
class SnmpScannerCard extends StatelessWidget {
const SnmpScannerCard({super.key});
@override
Widget build(BuildContext context) {
return ScannerStatusCard<SnmpScannerStatus>(
title: 'SNMP Scanner',
fetch: ({cancelToken}) =>
BackendAPI.instance.getSnmpScannerStatus(cancelToken: cancelToken),
resolver: _resolve,
);
}
static ScannerStatus _resolve(
BuildContext context,
SnmpScannerStatus status,
double elapsed,
) {
final successColor = Theme.of(
context,
).extension<AppColorExtension>()!.success;
final neutralColor = Theme.of(context).colorScheme.outline;
final lastScan = status.lastScanDevicesSeen != null
? '${status.lastScanDevicesSeen} devices on last scan'
: null;
if (status.isRunning) {
final sublabels = <String>[
if (status.runningForSeconds != null)
'Running for ${formatSeconds(status.runningForSeconds! + elapsed)}',
?lastScan,
];
return (color: successColor, label: 'Running', sublabels: sublabels);
}
if (status.nextRunInSeconds != null) {
final remaining = (status.nextRunInSeconds! - elapsed).clamp(
0.0,
double.infinity,
);
return (
color: neutralColor,
label: 'Waiting for next run',
sublabels: ['Next run in ${formatSeconds(remaining)}', ?lastScan],
);
}
return (color: neutralColor, label: 'Not started', sublabels: []);
}
}
@@ -1,47 +0,0 @@
import 'package:flutter/material.dart';
import '../model/ssdp_scanner_status.dart';
import '../theme/app_colors.dart';
import '../utils/duration_formatter.dart';
import '../utils/oott_api.dart';
import 'scanner_status_card.dart';
class SsdpScannerCard extends StatelessWidget {
const SsdpScannerCard({super.key});
@override
Widget build(BuildContext context) {
return ScannerStatusCard<SsdpScannerStatus>(
title: 'SSDP/UPnP Scanner',
fetch: ({cancelToken}) =>
BackendAPI.instance.getSsdpScannerStatus(cancelToken: cancelToken),
resolver: _resolve,
);
}
static ScannerStatus _resolve(
BuildContext context,
SsdpScannerStatus status,
double elapsed,
) {
if (!status.isListening) {
return (
color: Theme.of(context).colorScheme.outline,
label: 'Not started',
sublabels: [],
);
}
final sublabels = <String>[
status.listeningForSeconds != null
? 'Listening for ${formatSeconds(status.listeningForSeconds! + elapsed)} · ${status.devicesSeen} devices in the last hour'
: '${status.devicesSeen} devices in the last hour',
if (status.lastDeviceSeenSecondsAgo != null)
'Last device ${formatSeconds(status.lastDeviceSeenSecondsAgo! + elapsed)} ago',
];
return (
color: Theme.of(context).extension<AppColorExtension>()!.success,
label: 'Listening',
sublabels: sublabels,
);
}
}
@@ -1,16 +1,13 @@
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/model/arp_scanner_status.dart'; import 'package:frontend/model/active_scanner_status.dart';
import 'package:frontend/model/dhcp_scanner_status.dart'; import 'package:frontend/model/passive_scanner_status.dart';
import 'package:frontend/model/mdns_scanner_status.dart';
import 'package:frontend/model/snmp_scanner_status.dart';
import 'package:frontend/model/ssdp_scanner_status.dart';
import '../helpers/fixtures.dart'; import '../helpers/fixtures.dart';
void main() { void main() {
group('interval scanners (ARP, SNMP)', () { group('active scanners (ARP, SNMP)', () {
test('ArpScannerStatus.fromJson maps populated fields', () { test('ActiveScannerStatus.fromJson maps populated fields', () {
final status = ArpScannerStatus.fromJson( final status = ActiveScannerStatus.fromJson(
intervalScannerJson( intervalScannerJson(
isRunning: true, isRunning: true,
runningForSeconds: 12.5, runningForSeconds: 12.5,
@@ -27,28 +24,19 @@ void main() {
expect(status.lastScanSecondsAgo, 5.0); expect(status.lastScanSecondsAgo, 5.0);
}); });
test('ArpScannerStatus.fromJson tolerates null optionals', () { test('ActiveScannerStatus.fromJson tolerates null optionals', () {
final status = ArpScannerStatus.fromJson(intervalScannerJson()); final status = ActiveScannerStatus.fromJson(intervalScannerJson());
expect(status.isRunning, isFalse); expect(status.isRunning, isFalse);
expect(status.runningForSeconds, isNull); expect(status.runningForSeconds, isNull);
expect(status.nextRunInSeconds, isNull); expect(status.nextRunInSeconds, isNull);
expect(status.lastScanDevicesSeen, isNull); expect(status.lastScanDevicesSeen, isNull);
}); });
test('SnmpScannerStatus.fromJson maps fields', () {
final status = SnmpScannerStatus.fromJson(
intervalScannerJson(isRunning: true, runningForSeconds: 3),
);
expect(status.isRunning, isTrue);
expect(status.runningForSeconds, 3.0);
});
}); });
group('listener scanners (mDNS, SSDP, DHCP)', () { group('passive scanners (mDNS, SSDP, DHCP)', () {
test('MdnsScannerStatus.fromJson maps populated fields', () { test('PassiveScannerStatus.fromJson maps populated fields', () {
final status = MdnsScannerStatus.fromJson( final status = PassiveScannerStatus.fromJson(
listenerScannerJson( listenerScannerJson(
isListening: true, isListening: true,
listeningForSeconds: 99.0, listeningForSeconds: 99.0,
@@ -63,9 +51,9 @@ void main() {
expect(status.lastDeviceSeenSecondsAgo, 8.0); expect(status.lastDeviceSeenSecondsAgo, 8.0);
}); });
test('SsdpScannerStatus.fromJson requires devicesSeen and tolerates nulls', test('PassiveScannerStatus.fromJson requires devicesSeen and tolerates '
() { 'nulls', () {
final status = SsdpScannerStatus.fromJson( final status = PassiveScannerStatus.fromJson(
listenerScannerJson(devicesSeen: 0), listenerScannerJson(devicesSeen: 0),
); );
@@ -74,14 +62,5 @@ void main() {
expect(status.listeningForSeconds, isNull); expect(status.listeningForSeconds, isNull);
expect(status.lastDeviceSeenSecondsAgo, isNull); expect(status.lastDeviceSeenSecondsAgo, isNull);
}); });
test('DhcpScannerStatus.fromJson maps fields', () {
final status = DhcpScannerStatus.fromJson(
listenerScannerJson(isListening: true, devicesSeen: 2),
);
expect(status.isListening, isTrue);
expect(status.devicesSeen, 2);
});
}); });
} }