diff --git a/frontend/lib/devices/device_list.dart b/frontend/lib/devices/device_list.dart index 3fd113a..85f975f 100644 --- a/frontend/lib/devices/device_list.dart +++ b/frontend/lib/devices/device_list.dart @@ -1,6 +1,5 @@ import 'dart:async'; -import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -10,21 +9,15 @@ import '../navigation.dart'; import '../theme/dimens.dart'; import '../utils/friendly_date_formatter.dart'; import '../utils/oott_api.dart'; +import '../utils/paginated_list_state.dart'; import '../widgets/empty_state.dart'; import '../widgets/filter_selector.dart'; import '../widgets/pagination_bar.dart'; -import '../widgets/pagination_progress.dart'; import '../widgets/skeleton.dart'; import 'device_list_filter.dart'; import 'device_list_rows.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 { const DeviceList({super.key}); @@ -32,12 +25,10 @@ class DeviceList extends StatefulWidget { State createState() => _DeviceListState(); } -class _DeviceListState extends State with RouteAware { +class _DeviceListState extends State + with RouteAware, PaginatedListState { DeviceFilter _filter = DeviceFilter.newDevices; List _devices = []; - bool _isLoading = true; - bool _isPaging = false; - String? _error; final TextEditingController _ownerController = TextEditingController(); DeviceType? _typeFilter; Timer? _ownerDebounce; @@ -45,22 +36,20 @@ class _DeviceListState extends State with RouteAware { DeviceSortColumn _sortColumn = DeviceSortColumn.lastSeen; bool _sortAscending = false; - int _currentPage = 0; - // Total devices matching the current filters, used to show how many pages - // exist and to offer "go to last page". - int _totalCount = 0; - bool _didInitialFetch = false; - CancelToken? _fetchToken; - final ScrollController _scrollController = ScrollController(); - - int get _pageSize => MediaQuery.sizeOf(context).width < Breakpoints.medium - ? _phonePageSize - : _widePageSize; - int get _totalPages => (_totalCount / _pageSize).ceil().clamp(1, 1 << 30); + // 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. + @override + int get phonePageSize => 5; + @override + int get widePageSize => 10; + @override + bool get isListEmpty => _devices.isEmpty; @override void initState() { super.initState(); + isLoading = true; _ownerController.addListener(_onOwnerChanged); } @@ -73,26 +62,22 @@ class _DeviceListState extends State with RouteAware { } // Deferred from initState so the page size can read the screen width from // MediaQuery, which is only available once dependencies are in place. - if (!_didInitialFetch) { - _didInitialFetch = true; + if (!didInitialFetch) { + didInitialFetch = true; _fetchPage(0); } } @override void didPopNext() { - _fetchPage(_currentPage); + _fetchPage(currentPage); } @override void dispose() { routeObserver.unsubscribe(this); _ownerDebounce?.cancel(); - _fetchToken?.cancel(); _ownerController.dispose(); - _scrollController.dispose(); - // Clear any in-flight cue so it doesn't linger after leaving the page. - paginationLoading.value = false; super.dispose(); } @@ -113,57 +98,34 @@ class _DeviceListState extends State with RouteAware { int page, { bool scrollToTop = false, bool paging = false, - }) 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 = _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( + }) { + bool? isRegistered; + if (_filter == DeviceFilter.newDevices) isRegistered = false; + if (_filter == DeviceFilter.registered) isRegistered = true; + return runFetch( + page, + scrollToTop: scrollToTop, + paging: paging, + fetch: (page, perPage, token) => BackendAPI.instance.listDevices( isRegistered: isRegistered, owner: _ownerController.text.isEmpty ? null : _ownerController.text, deviceType: _typeFilter, sortBy: _sortColumn.apiName, sortAscending: _sortAscending, page: page, - perPage: _pageSize, + perPage: perPage, cancelToken: token, - ); - if (!mounted || token != _fetchToken) return; - setState(() { - _currentPage = page; - _totalCount = result.totalCount; - _devices = result.items; - _isLoading = 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; - } + ), + onResult: (result) { + setState(() { + currentPage = page; + totalCount = result.totalCount; + _devices = result.items; + isLoading = false; + isPaging = false; + }); + }, + ); } void _onSortHeaderTapped(DeviceSortColumn column) { @@ -295,13 +257,13 @@ class _DeviceListState extends State with RouteAware { } Widget _buildBody(BuildContext context, bool isWide) { - if (_isLoading) { + if (isLoading) { return const ListSkeleton(); } - if (_error != null) { + if (error != null) { return Center( child: Text( - 'Error: $_error', + 'Error: $error', style: TextStyle(color: Theme.of(context).colorScheme.error), ), ); @@ -317,9 +279,9 @@ class _DeviceListState extends State with RouteAware { final formatter = FriendlyDateFormatter(); return RefreshIndicator( - onRefresh: () => _fetchPage(_currentPage), + onRefresh: () => _fetchPage(currentPage), child: CustomScrollView( - controller: _scrollController, + controller: scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ if (isWide) @@ -340,24 +302,24 @@ class _DeviceListState extends State with RouteAware { key: ValueKey(device.macAddress), device: device, formatter: formatter, - onRefresh: () => _fetchPage(_currentPage), + onRefresh: () => _fetchPage(currentPage), ) : DeviceRowCompact( key: ValueKey(device.macAddress), device: device, formatter: formatter, - onRefresh: () => _fetchPage(_currentPage), + onRefresh: () => _fetchPage(currentPage), ); }, separatorBuilder: (_, _) => isWide ? const Divider(height: 1) : const SizedBox.shrink(), ), - if (_currentPage > 0 || _totalPages > 1) + if (currentPage > 0 || totalPages > 1) SliverToBoxAdapter( child: PaginationBar( - currentPage: _currentPage, - totalPages: _totalPages, - isLoading: _isPaging, + currentPage: currentPage, + totalPages: totalPages, + isLoading: isPaging, onPageChanged: (page) => _fetchPage(page, scrollToTop: true, paging: true), ), diff --git a/frontend/lib/home/notifications_list.dart b/frontend/lib/home/notifications_list.dart index 1ff637b..a45d638 100644 --- a/frontend/lib/home/notifications_list.dart +++ b/frontend/lib/home/notifications_list.dart @@ -1,27 +1,19 @@ import 'dart:async'; -import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import '../model/notification.dart' as oott_model; import '../navigation.dart'; -import '../theme/dimens.dart'; import '../utils/friendly_date_formatter.dart'; import '../utils/oott_api.dart'; +import '../utils/paginated_list_state.dart'; import '../utils/ui_snackbars.dart'; import '../widgets/empty_state.dart'; import '../widgets/filter_selector.dart'; import '../widgets/pagination_bar.dart'; -import '../widgets/pagination_progress.dart'; import '../widgets/skeleton.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. const _insertDuration = Duration(milliseconds: 300); const _removeDuration = Duration(milliseconds: 250); @@ -52,15 +44,14 @@ class NotificationsList extends StatefulWidget { } class _NotificationsListState extends State - with RouteAware, WidgetsBindingObserver { + with + RouteAware, + WidgetsBindingObserver, + PaginatedListState { _NotificationFilter _filter = _NotificationFilter.newOnly; Timer? _notificationTimer; - int _currentPage = 0; - bool _didInitialFetch = false; List _items = []; - bool _isLoading = false; - bool _isPaging = false; // Drives the animated list. Recreated on every reset (filter/page change, // initial load) so the new dataset mounts fresh without per-row animations; @@ -70,16 +61,15 @@ class _NotificationsListState extends State final Set _flashIds = {}; final FriendlyDateFormatter _formatter = FriendlyDateFormatter(); - int get _pageSize => MediaQuery.sizeOf(context).width < Breakpoints.medium - ? _phonePageSize - : _widePageSize; - // Total notifications matching the current filter, used to show how many pages - // exist and to offer "go to last page". - int _totalCount = 0; - int get _totalPages => (_totalCount / _pageSize).ceil().clamp(1, 1 << 30); - String? _error; - CancelToken? _fetchToken; - final ScrollController _scrollController = ScrollController(); + // 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. + @override + int get phonePageSize => 4; + @override + int get widePageSize => 5; + @override + bool get isListEmpty => _items.isEmpty; @override void initState() { @@ -97,8 +87,8 @@ class _NotificationsListState extends State } // Deferred from initState so the page size can read the screen width from // MediaQuery, which is only available once dependencies are in place. - if (!_didInitialFetch) { - _didInitialFetch = true; + if (!didInitialFetch) { + didInitialFetch = true; _fetchPage(0); } } @@ -111,7 +101,7 @@ class _NotificationsListState extends State @override void didPopNext() { - _fetchPage(_currentPage, animateDiff: true); + _fetchPage(currentPage, animateDiff: true); _startTimer(); } @@ -121,7 +111,7 @@ class _NotificationsListState extends State _notificationTimer?.cancel(); _notificationTimer = null; } else if (state == AppLifecycleState.resumed) { - _fetchPage(_currentPage, animateDiff: true); + _fetchPage(currentPage, animateDiff: true); _startTimer(); } } @@ -130,7 +120,7 @@ class _NotificationsListState extends State _notificationTimer?.cancel(); _notificationTimer = Timer.periodic( const Duration(minutes: 1), - (_) => _fetchPage(_currentPage, animateDiff: true), + (_) => _fetchPage(currentPage, animateDiff: true), ); } @@ -139,10 +129,6 @@ class _NotificationsListState extends State WidgetsBinding.instance.removeObserver(this); routeObserver.unsubscribe(this); _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(); } @@ -161,63 +147,42 @@ class _NotificationsListState extends State bool scrollToTop = false, bool paging = false, bool animateDiff = false, - }) 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 = _items.isEmpty; - _isPaging = paging; - _error = null; - }); - paginationLoading.value = paging; - try { - final result = await BackendAPI.instance.listNotifications( + }) { + return runFetch( + page, + scrollToTop: scrollToTop, + paging: paging, + fetch: (page, perPage, token) => BackendAPI.instance.listNotifications( _filter.isNew, page: page, - perPage: _pageSize, + perPage: perPage, cancelToken: token, - ); - if (!mounted || token != _fetchToken) return; - paginationLoading.value = false; - if (animateDiff && !_isLoading) { - // Merge into the live list so changes animate. Scalars are updated - // without setState here; _reconcile schedules the rebuild itself so it - // can defer swapping in the empty state until exit animations finish. - _currentPage = page; - _totalCount = result.totalCount; - _isLoading = false; - _isPaging = false; - _reconcile(result.items); - return; - } - // Reset: a fresh dataset. Recreate the list key so the animated list - // mounts anew and shows the rows immediately, without insert animations. - setState(() { - _listKey = GlobalKey(); - _currentPage = page; - _totalCount = result.totalCount; - _items = result.items; - _isLoading = 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; - } + ), + onResult: (result) { + if (animateDiff && !isLoading) { + // Merge into the live list so changes animate. Scalars are updated + // without setState here; _reconcile schedules the rebuild itself so + // it can defer swapping in the empty state until exit animations + // finish. + currentPage = page; + totalCount = result.totalCount; + isLoading = false; + isPaging = false; + _reconcile(result.items); + return; + } + // Reset: a fresh dataset. Recreate the list key so the animated list + // mounts anew and shows the rows immediately, without insert animations. + setState(() { + _listKey = GlobalKey(); + currentPage = page; + totalCount = result.totalCount; + _items = result.items; + isLoading = false; + isPaging = false; + }); + }, + ); } Future _markAllAsRead() async { @@ -229,7 +194,7 @@ class _NotificationsListState extends State return; } if (!mounted) return; - _fetchPage(_currentPage, animateDiff: true); + _fetchPage(currentPage, animateDiff: true); UISnackbars.showSuccess(context, 'All notifications marked as read'); } @@ -304,7 +269,7 @@ class _NotificationsListState extends State /// [_removeItem] directly so they don't double-count against a fresh total. void _removeAndSettle(int id, {required bool animated}) { _removeItem(id, animated: animated); - if (_totalCount > 0) _totalCount--; + if (totalCount > 0) totalCount--; _afterStructuralChange(); } @@ -369,9 +334,9 @@ class _NotificationsListState extends State _buildNotificationsHeader(context), Expanded( child: RefreshIndicator( - onRefresh: () => _fetchPage(_currentPage, animateDiff: true), + onRefresh: () => _fetchPage(currentPage, animateDiff: true), child: CustomScrollView( - controller: _scrollController, + controller: scrollController, physics: const AlwaysScrollableScrollPhysics(), slivers: [ ..._buildNotificationSlivers(context), @@ -417,15 +382,15 @@ class _NotificationsListState extends State } List _buildNotificationSlivers(BuildContext context) { - if (_isLoading) { + if (isLoading) { return [const SliverToBoxAdapter(child: ListSkeleton(rows: 4))]; } - if (_error != null) { + if (error != null) { return [ SliverFillRemaining( child: Center( child: Text( - 'Error: $_error', + 'Error: $error', style: TextStyle(color: Theme.of(context).colorScheme.error), ), ), @@ -444,12 +409,12 @@ class _NotificationsListState extends State } return [ _buildNotificationSliver(), - if (_currentPage > 0 || _totalPages > 1) + if (currentPage > 0 || totalPages > 1) SliverToBoxAdapter( child: PaginationBar( - currentPage: _currentPage, - totalPages: _totalPages, - isLoading: _isPaging, + currentPage: currentPage, + totalPages: totalPages, + isLoading: isPaging, onPageChanged: (page) => _fetchPage(page, scrollToTop: true, paging: true), ), diff --git a/frontend/lib/model/arp_scanner_status.dart b/frontend/lib/model/active_scanner_status.dart similarity index 68% rename from frontend/lib/model/arp_scanner_status.dart rename to frontend/lib/model/active_scanner_status.dart index c2c3d91..fd27bd8 100644 --- a/frontend/lib/model/arp_scanner_status.dart +++ b/frontend/lib/model/active_scanner_status.dart @@ -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 double? runningForSeconds; final double? nextRunInSeconds; final int? lastScanDevicesSeen; final double? lastScanSecondsAgo; - const ArpScannerStatus({ + const ActiveScannerStatus({ required this.isRunning, this.runningForSeconds, this.nextRunInSeconds, @@ -13,8 +15,8 @@ class ArpScannerStatus { this.lastScanSecondsAgo, }); - factory ArpScannerStatus.fromJson(Map json) { - return ArpScannerStatus( + factory ActiveScannerStatus.fromJson(Map json) { + return ActiveScannerStatus( isRunning: json['is_running'] as bool, runningForSeconds: (json['running_for_seconds'] as num?)?.toDouble(), nextRunInSeconds: (json['next_run_in_seconds'] as num?)?.toDouble(), diff --git a/frontend/lib/model/dhcp_scanner_status.dart b/frontend/lib/model/dhcp_scanner_status.dart deleted file mode 100644 index cc73ac8..0000000 --- a/frontend/lib/model/dhcp_scanner_status.dart +++ /dev/null @@ -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 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(), - ); - } -} diff --git a/frontend/lib/model/mdns_scanner_status.dart b/frontend/lib/model/mdns_scanner_status.dart deleted file mode 100644 index 0dfb5e1..0000000 --- a/frontend/lib/model/mdns_scanner_status.dart +++ /dev/null @@ -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 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(), - ); - } -} diff --git a/frontend/lib/model/ssdp_scanner_status.dart b/frontend/lib/model/passive_scanner_status.dart similarity index 63% rename from frontend/lib/model/ssdp_scanner_status.dart rename to frontend/lib/model/passive_scanner_status.dart index eb55f0f..d003344 100644 --- a/frontend/lib/model/ssdp_scanner_status.dart +++ b/frontend/lib/model/passive_scanner_status.dart @@ -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 double? listeningForSeconds; final int devicesSeen; final double? lastDeviceSeenSecondsAgo; - const SsdpScannerStatus({ + const PassiveScannerStatus({ required this.isListening, this.listeningForSeconds, required this.devicesSeen, this.lastDeviceSeenSecondsAgo, }); - factory SsdpScannerStatus.fromJson(Map json) { - return SsdpScannerStatus( + factory PassiveScannerStatus.fromJson(Map json) { + return PassiveScannerStatus( isListening: json['is_listening'] as bool, listeningForSeconds: (json['listening_for_seconds'] as num?)?.toDouble(), devicesSeen: (json['devices_seen'] as num).toInt(), diff --git a/frontend/lib/model/snmp_scanner_status.dart b/frontend/lib/model/snmp_scanner_status.dart deleted file mode 100644 index d3fde22..0000000 --- a/frontend/lib/model/snmp_scanner_status.dart +++ /dev/null @@ -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 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(), - ); - } -} diff --git a/frontend/lib/status/status_screen.dart b/frontend/lib/status/status_screen.dart index 4e5ede6..321c0a0 100644 --- a/frontend/lib/status/status_screen.dart +++ b/frontend/lib/status/status_screen.dart @@ -1,30 +1,22 @@ import 'package:flutter/material.dart'; import '../theme/dimens.dart'; -import '../widgets/arp_scanner_card.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'; +import '../widgets/scanner_status_cards.dart'; class StatusScreen extends StatelessWidget { const StatusScreen({super.key}); @override Widget build(BuildContext context) { + final cards = scannerStatusCards(); return SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - const ArpScannerCard(), - const SizedBox(height: Insets.sm), - const MdnsScannerCard(), - const SizedBox(height: Insets.sm), - const SsdpScannerCard(), - const SizedBox(height: Insets.sm), - const DhcpScannerCard(), - const SizedBox(height: Insets.sm), - const SnmpScannerCard(), + for (var i = 0; i < cards.length; i++) ...[ + if (i > 0) const SizedBox(height: Insets.sm), + cards[i], + ], ], ), ); diff --git a/frontend/lib/utils/api/oott_api_scanners.dart b/frontend/lib/utils/api/oott_api_scanners.dart index 7c13461..7a57295 100644 --- a/frontend/lib/utils/api/oott_api_scanners.dart +++ b/frontend/lib/utils/api/oott_api_scanners.dart @@ -1,40 +1,45 @@ part of '../oott_api.dart'; -/// Per-scanner status endpoints. Every scanner exposes the same -/// `/_scanner/status` shape, so each getter just decodes its model. +/// Per-scanner status endpoints. Active scanners (ARP, SNMP) share the +/// [ActiveScannerStatus] shape; passive scanners (mDNS, SSDP, DHCP) share the +/// [PassiveScannerStatus] shape. Each getter just decodes its model. extension ScannerApi on BackendAPI { - Future getArpScannerStatus({CancelToken? cancelToken}) => + Future getArpScannerStatus({CancelToken? cancelToken}) => _getModel( '/arp_scanner/status', - ArpScannerStatus.fromJson, + ActiveScannerStatus.fromJson, cancelToken: cancelToken, ); - Future getMdnsScannerStatus({CancelToken? cancelToken}) => - _getModel( - '/mdns_scanner/status', - MdnsScannerStatus.fromJson, - cancelToken: cancelToken, - ); + Future getMdnsScannerStatus({ + CancelToken? cancelToken, + }) => _getModel( + '/mdns_scanner/status', + PassiveScannerStatus.fromJson, + cancelToken: cancelToken, + ); - Future getSsdpScannerStatus({CancelToken? cancelToken}) => - _getModel( - '/ssdp_scanner/status', - SsdpScannerStatus.fromJson, - cancelToken: cancelToken, - ); + Future getSsdpScannerStatus({ + CancelToken? cancelToken, + }) => _getModel( + '/ssdp_scanner/status', + PassiveScannerStatus.fromJson, + cancelToken: cancelToken, + ); - Future getDhcpScannerStatus({CancelToken? cancelToken}) => - _getModel( - '/dhcp_scanner/status', - DhcpScannerStatus.fromJson, - cancelToken: cancelToken, - ); + Future getDhcpScannerStatus({ + CancelToken? cancelToken, + }) => _getModel( + '/dhcp_scanner/status', + PassiveScannerStatus.fromJson, + cancelToken: cancelToken, + ); - Future getSnmpScannerStatus({CancelToken? cancelToken}) => - _getModel( - '/snmp_scanner/status', - SnmpScannerStatus.fromJson, - cancelToken: cancelToken, - ); + Future getSnmpScannerStatus({ + CancelToken? cancelToken, + }) => _getModel( + '/snmp_scanner/status', + ActiveScannerStatus.fromJson, + cancelToken: cancelToken, + ); } diff --git a/frontend/lib/utils/oott_api.dart b/frontend/lib/utils/oott_api.dart index 1cea003..09fcea7 100644 --- a/frontend/lib/utils/oott_api.dart +++ b/frontend/lib/utils/oott_api.dart @@ -8,11 +8,8 @@ import 'api/api_error.dart'; import 'api/dio_config.dart'; import 'backend_reachability.dart'; import 'pref_utils.dart'; -import '../model/arp_scanner_status.dart'; -import '../model/dhcp_scanner_status.dart'; -import '../model/mdns_scanner_status.dart'; -import '../model/ssdp_scanner_status.dart'; -import '../model/snmp_scanner_status.dart'; +import '../model/active_scanner_status.dart'; +import '../model/passive_scanner_status.dart'; import '../model/device.dart'; import '../model/device_event.dart'; import '../model/device_summary.dart'; diff --git a/frontend/lib/utils/paginated_list_state.dart b/frontend/lib/utils/paginated_list_state.dart new file mode 100644 index 0000000..47b089e --- /dev/null +++ b/frontend/lib/utils/paginated_list_state.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 on State { + 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 runFetch( + int page, { + bool scrollToTop = false, + bool paging = false, + required Future 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(); + } +} diff --git a/frontend/lib/utils/periodic_rebuild.dart b/frontend/lib/utils/periodic_rebuild.dart new file mode 100644 index 0000000..52dd63c --- /dev/null +++ b/frontend/lib/utils/periodic_rebuild.dart @@ -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 on State { + 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(); + } +} diff --git a/frontend/lib/widgets/arp_scanner_card.dart b/frontend/lib/widgets/arp_scanner_card.dart deleted file mode 100644 index 526bc0f..0000000 --- a/frontend/lib/widgets/arp_scanner_card.dart +++ /dev/null @@ -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( - 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()!.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 = [ - 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: []); - } -} diff --git a/frontend/lib/widgets/dhcp_scanner_card.dart b/frontend/lib/widgets/dhcp_scanner_card.dart deleted file mode 100644 index 78fca78..0000000 --- a/frontend/lib/widgets/dhcp_scanner_card.dart +++ /dev/null @@ -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( - 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 = [ - 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()!.success, - label: 'Listening', - sublabels: sublabels, - ); - } -} diff --git a/frontend/lib/widgets/mdns_scanner_card.dart b/frontend/lib/widgets/mdns_scanner_card.dart deleted file mode 100644 index badce7a..0000000 --- a/frontend/lib/widgets/mdns_scanner_card.dart +++ /dev/null @@ -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( - 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 = [ - 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()!.success, - label: 'Listening', - sublabels: sublabels, - ); - } -} diff --git a/frontend/lib/widgets/polled_stale_indicator.dart b/frontend/lib/widgets/polled_stale_indicator.dart index d72119a..0ab74ab 100644 --- a/frontend/lib/widgets/polled_stale_indicator.dart +++ b/frontend/lib/widgets/polled_stale_indicator.dart @@ -1,9 +1,8 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; import '../utils/backend_reachability.dart'; import '../utils/duration_formatter.dart'; +import '../utils/periodic_rebuild.dart'; import '../utils/polled_value.dart'; class PolledStaleIndicator extends StatefulWidget { @@ -15,21 +14,12 @@ class PolledStaleIndicator extends StatefulWidget { State createState() => _PolledStaleIndicatorState(); } -class _PolledStaleIndicatorState extends State { - Timer? _ticker; - +class _PolledStaleIndicatorState extends State + with PeriodicRebuild { @override void initState() { super.initState(); - _ticker = Timer.periodic(const Duration(seconds: 1), (_) { - if (mounted) setState(() {}); - }); - } - - @override - void dispose() { - _ticker?.cancel(); - super.dispose(); + startRebuildTicker(); } @override diff --git a/frontend/lib/widgets/scanner_status_card.dart b/frontend/lib/widgets/scanner_status_card.dart index eb6a1f4..96ce4b5 100644 --- a/frontend/lib/widgets/scanner_status_card.dart +++ b/frontend/lib/widgets/scanner_status_card.dart @@ -1,21 +1,24 @@ -import 'dart:async'; - import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import '../utils/backend_reachability.dart'; +import '../utils/periodic_rebuild.dart'; import '../utils/polled_value.dart'; import 'polled_stale_indicator.dart'; typedef ScannerStatus = ({Color color, String label, List sublabels}); typedef ScannerStatusResolver = - 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 -/// using the provided [resolver]. The two scanner cards (ARP, mDNS) are thin -/// wrappers over this widget. +/// using the provided [resolver]. The per-scanner detail cards are configured +/// over this widget in `scanner_status_cards.dart`. class ScannerStatusCard extends StatefulWidget { const ScannerStatusCard({ super.key, @@ -32,9 +35,9 @@ class ScannerStatusCard extends StatefulWidget { State> createState() => _ScannerStatusCardState(); } -class _ScannerStatusCardState extends State> { +class _ScannerStatusCardState extends State> + with PeriodicRebuild> { late final PolledValue _polled; - Timer? _tickTimer; @override void initState() { @@ -44,14 +47,11 @@ class _ScannerStatusCardState extends State> { pollInterval: const Duration(seconds: 5), staleErrorAfter: const Duration(seconds: 30), ); - _tickTimer = Timer.periodic(const Duration(seconds: 1), (_) { - if (mounted) setState(() {}); - }); + startRebuildTicker(); } @override void dispose() { - _tickTimer?.cancel(); _polled.dispose(); super.dispose(); } @@ -124,7 +124,10 @@ class _ScannerStatusCardState extends State> { ); } - ScannerStatus _resolveStatus(BuildContext context, PolledFreshness freshness) { + ScannerStatus _resolveStatus( + BuildContext context, + PolledFreshness freshness, + ) { if (freshness == PolledFreshness.error) { return ( color: Theme.of(context).colorScheme.error, diff --git a/frontend/lib/widgets/scanner_status_cards.dart b/frontend/lib/widgets/scanner_status_cards.dart new file mode 100644 index 0000000..1f88bc0 --- /dev/null +++ b/frontend/lib/widgets/scanner_status_cards.dart @@ -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()!.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 = [ + 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 = [ + 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()!.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 scannerStatusCards() => [ + ScannerStatusCard( + title: 'ARP Scanner', + fetch: ({cancelToken}) => + BackendAPI.instance.getArpScannerStatus(cancelToken: cancelToken), + resolver: resolveActiveScanner, + ), + ScannerStatusCard( + title: 'mDNS Scanner', + fetch: ({cancelToken}) => + BackendAPI.instance.getMdnsScannerStatus(cancelToken: cancelToken), + resolver: resolvePassiveScanner, + ), + ScannerStatusCard( + title: 'SSDP/UPnP Scanner', + fetch: ({cancelToken}) => + BackendAPI.instance.getSsdpScannerStatus(cancelToken: cancelToken), + resolver: resolvePassiveScanner, + ), + ScannerStatusCard( + title: 'DHCP Scanner', + fetch: ({cancelToken}) => + BackendAPI.instance.getDhcpScannerStatus(cancelToken: cancelToken), + resolver: resolvePassiveScanner, + ), + ScannerStatusCard( + title: 'SNMP Scanner', + fetch: ({cancelToken}) => + BackendAPI.instance.getSnmpScannerStatus(cancelToken: cancelToken), + resolver: resolveActiveScanner, + ), +]; diff --git a/frontend/lib/widgets/scanners_status_card.dart b/frontend/lib/widgets/scanners_status_card.dart index 60abf3e..dc6807b 100644 --- a/frontend/lib/widgets/scanners_status_card.dart +++ b/frontend/lib/widgets/scanners_status_card.dart @@ -1,21 +1,28 @@ -import 'dart:async'; - +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; -import '../model/arp_scanner_status.dart'; -import '../model/dhcp_scanner_status.dart'; -import '../model/mdns_scanner_status.dart'; -import '../model/snmp_scanner_status.dart'; -import '../model/ssdp_scanner_status.dart'; +import '../model/active_scanner_status.dart'; +import '../model/passive_scanner_status.dart'; import '../navigation.dart'; import '../theme/app_colors.dart'; import '../utils/backend_reachability.dart'; import '../utils/duration_formatter.dart'; import '../utils/oott_api.dart'; +import '../utils/periodic_rebuild.dart'; import '../utils/polled_value.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 { const ScannersStatusCard({super.key}); @@ -24,51 +31,42 @@ class ScannersStatusCard extends StatefulWidget { } class _ScannersStatusCardState extends State - with RouteAware, WidgetsBindingObserver { - late final PolledValue _arp; - late final PolledValue _mdns; - late final PolledValue _ssdp; - late final PolledValue _dhcp; - late final PolledValue _snmp; - Timer? _tickTimer; + with + RouteAware, + WidgetsBindingObserver, + PeriodicRebuild { + late final List<_Scanner> _scanners; + + PolledValue _poll( + Future Function({CancelToken? cancelToken}) fetch, + ) => PolledValue( + fetch: fetch, + pollInterval: const Duration(seconds: 5), + staleErrorAfter: const Duration(seconds: 30), + ); @override void initState() { super.initState(); WidgetsBinding.instance.addObserver(this); - _arp = PolledValue( - fetch: ({cancelToken}) => - BackendAPI.instance.getArpScannerStatus(cancelToken: cancelToken), - pollInterval: const Duration(seconds: 5), - staleErrorAfter: const Duration(seconds: 30), - ); - _mdns = PolledValue( - fetch: ({cancelToken}) => - BackendAPI.instance.getMdnsScannerStatus(cancelToken: cancelToken), - pollInterval: const Duration(seconds: 5), - staleErrorAfter: const Duration(seconds: 30), - ); - _ssdp = PolledValue( - fetch: ({cancelToken}) => - BackendAPI.instance.getSsdpScannerStatus(cancelToken: cancelToken), - pollInterval: const Duration(seconds: 5), - staleErrorAfter: const Duration(seconds: 30), - ); - _dhcp = PolledValue( - fetch: ({cancelToken}) => - BackendAPI.instance.getDhcpScannerStatus(cancelToken: cancelToken), - pollInterval: const Duration(seconds: 5), - staleErrorAfter: const Duration(seconds: 30), - ); - _snmp = PolledValue( - 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(() {}); - }); + final api = BackendAPI.instance; + final arp = _poll(api.getArpScannerStatus); + final mdns = _poll(api.getMdnsScannerStatus); + final ssdp = _poll(api.getSsdpScannerStatus); + final dhcp = _poll(api.getDhcpScannerStatus); + final snmp = _poll(api.getSnmpScannerStatus); + _scanners = [ + _Scanner('ARP', arp, (c, f) => _resolve(c, arp, f, _activeStatus)), + _Scanner('mDNS', mdns, (c, f) => _resolve(c, mdns, f, _passiveStatus)), + _Scanner( + 'SSDP/UPnP', + ssdp, + (c, f) => _resolve(c, ssdp, f, _passiveStatus), + ), + _Scanner('DHCP', dhcp, (c, f) => _resolve(c, dhcp, f, _passiveStatus)), + _Scanner('SNMP', snmp, (c, f) => _resolve(c, snmp, f, _activeStatus)), + ]; + startRebuildTicker(); } @override @@ -100,36 +98,26 @@ class _ScannersStatusCardState extends State } void _pausePolling() { - _arp.pause(); - _mdns.pause(); - _ssdp.pause(); - _dhcp.pause(); - _snmp.pause(); - _tickTimer?.cancel(); - _tickTimer = null; + for (final scanner in _scanners) { + scanner.polled.pause(); + } + stopRebuildTicker(); } void _resumePolling() { - _arp.resume(); - _mdns.resume(); - _ssdp.resume(); - _dhcp.resume(); - _snmp.resume(); - _tickTimer ??= Timer.periodic(const Duration(seconds: 1), (_) { - if (mounted) setState(() {}); - }); + for (final scanner in _scanners) { + scanner.polled.resume(); + } + startRebuildTicker(); } @override void dispose() { WidgetsBinding.instance.removeObserver(this); routeObserver.unsubscribe(this); - _tickTimer?.cancel(); - _arp.dispose(); - _mdns.dispose(); - _ssdp.dispose(); - _dhcp.dispose(); - _snmp.dispose(); + for (final scanner in _scanners) { + scanner.polled.dispose(); + } super.dispose(); } @@ -137,24 +125,15 @@ class _ScannersStatusCardState extends State Widget build(BuildContext context) { return ListenableBuilder( listenable: Listenable.merge([ - _arp, - _mdns, - _ssdp, - _dhcp, - _snmp, + ..._scanners.map((s) => s.polled), BackendReachability.instance, ]), builder: (context, _) { - final arpFreshness = effectiveFreshness(_arp); - final mdnsFreshness = effectiveFreshness(_mdns); - final ssdpFreshness = effectiveFreshness(_ssdp); - final dhcpFreshness = effectiveFreshness(_dhcp); - final snmpFreshness = effectiveFreshness(_snmp); - if (arpFreshness == PolledFreshness.initialLoading || - mdnsFreshness == PolledFreshness.initialLoading || - ssdpFreshness == PolledFreshness.initialLoading || - dhcpFreshness == PolledFreshness.initialLoading || - snmpFreshness == PolledFreshness.initialLoading) { + final freshness = { + for (final scanner in _scanners) + scanner: effectiveFreshness(scanner.polled), + }; + if (freshness.values.any((f) => f == PolledFreshness.initialLoading)) { return const Card( child: Padding( padding: EdgeInsets.all(16), @@ -163,12 +142,6 @@ class _ScannersStatusCardState extends State ); } - 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( clipBehavior: Clip.antiAlias, child: InkWell( @@ -183,50 +156,14 @@ class _ScannersStatusCardState extends State style: Theme.of(context).textTheme.titleLarge, ), const SizedBox(height: 12), - _scannerRow( - context, - arpColor, - 'ARP', - arpText, - _arp, - 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, - ), + for (var i = 0; i < _scanners.length; i++) ...[ + if (i > 0) const SizedBox(height: 8), + _scannerRow( + context, + _scanners[i], + freshness[_scanners[i]]!, + ), + ], ], ), ), @@ -238,15 +175,16 @@ class _ScannersStatusCardState extends State Widget _scannerRow( BuildContext context, - Color color, - String name, - String statusText, - PolledValue polled, + _Scanner scanner, PolledFreshness freshness, ) { + final (color, statusText) = scanner.resolve(context, freshness); Widget dot = Icon(Icons.circle, color: color, size: 12); if (freshness == PolledFreshness.error) { - dot = Tooltip(message: polled.lastErrorMessage ?? 'Error', child: dot); + dot = Tooltip( + message: scanner.polled.lastErrorMessage ?? 'Error', + child: dot, + ); } return Row( children: [ @@ -255,10 +193,10 @@ class _ScannersStatusCardState extends State Expanded( child: Row( children: [ - Text(name, style: Theme.of(context).textTheme.bodyMedium), + Text(scanner.name, style: Theme.of(context).textTheme.bodyMedium), if (freshness == PolledFreshness.stale) ...[ const SizedBox(width: 6), - PolledStaleIndicator(polled: polled), + PolledStaleIndicator(polled: scanner.polled), ], ], ), @@ -274,18 +212,32 @@ class _ScannersStatusCardState extends State ); } - (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( + BuildContext context, + PolledValue 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 successColor = theme.extension()!.success; 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) { final secs = (status.runningForSeconds ?? 0) + elapsed; return (successColor, 'Running for ${formatSeconds(secs)}'); @@ -300,21 +252,14 @@ class _ScannersStatusCardState extends State return (neutralColor, 'Not started'); } - (Color, String) _resolveMdns( + (Color, String) _passiveStatus( BuildContext context, - PolledFreshness freshness, + PassiveScannerStatus status, + double elapsed, ) { final theme = Theme.of(context); final successColor = theme.extension()!.success; 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.lastDeviceSeenSecondsAgo != null) { final secs = status.lastDeviceSeenSecondsAgo! + elapsed; @@ -324,83 +269,4 @@ class _ScannersStatusCardState extends State } return (neutralColor, 'Not started'); } - - (Color, String) _resolveSsdp( - BuildContext context, - PolledFreshness freshness, - ) { - final theme = Theme.of(context); - final successColor = theme.extension()!.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()!.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()!.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'); - } } diff --git a/frontend/lib/widgets/snmp_scanner_card.dart b/frontend/lib/widgets/snmp_scanner_card.dart deleted file mode 100644 index d8f39c8..0000000 --- a/frontend/lib/widgets/snmp_scanner_card.dart +++ /dev/null @@ -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( - 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()!.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 = [ - 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: []); - } -} diff --git a/frontend/lib/widgets/ssdp_scanner_card.dart b/frontend/lib/widgets/ssdp_scanner_card.dart deleted file mode 100644 index d6c5306..0000000 --- a/frontend/lib/widgets/ssdp_scanner_card.dart +++ /dev/null @@ -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( - 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 = [ - 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()!.success, - label: 'Listening', - sublabels: sublabels, - ); - } -} diff --git a/frontend/test/unit/scanner_status_models_test.dart b/frontend/test/unit/scanner_status_models_test.dart index c53c1dc..c4dab3b 100644 --- a/frontend/test/unit/scanner_status_models_test.dart +++ b/frontend/test/unit/scanner_status_models_test.dart @@ -1,16 +1,13 @@ import 'package:flutter_test/flutter_test.dart'; -import 'package:frontend/model/arp_scanner_status.dart'; -import 'package:frontend/model/dhcp_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 'package:frontend/model/active_scanner_status.dart'; +import 'package:frontend/model/passive_scanner_status.dart'; import '../helpers/fixtures.dart'; void main() { - group('interval scanners (ARP, SNMP)', () { - test('ArpScannerStatus.fromJson maps populated fields', () { - final status = ArpScannerStatus.fromJson( + group('active scanners (ARP, SNMP)', () { + test('ActiveScannerStatus.fromJson maps populated fields', () { + final status = ActiveScannerStatus.fromJson( intervalScannerJson( isRunning: true, runningForSeconds: 12.5, @@ -27,28 +24,19 @@ void main() { expect(status.lastScanSecondsAgo, 5.0); }); - test('ArpScannerStatus.fromJson tolerates null optionals', () { - final status = ArpScannerStatus.fromJson(intervalScannerJson()); + test('ActiveScannerStatus.fromJson tolerates null optionals', () { + final status = ActiveScannerStatus.fromJson(intervalScannerJson()); expect(status.isRunning, isFalse); expect(status.runningForSeconds, isNull); expect(status.nextRunInSeconds, 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)', () { - test('MdnsScannerStatus.fromJson maps populated fields', () { - final status = MdnsScannerStatus.fromJson( + group('passive scanners (mDNS, SSDP, DHCP)', () { + test('PassiveScannerStatus.fromJson maps populated fields', () { + final status = PassiveScannerStatus.fromJson( listenerScannerJson( isListening: true, listeningForSeconds: 99.0, @@ -63,9 +51,9 @@ void main() { expect(status.lastDeviceSeenSecondsAgo, 8.0); }); - test('SsdpScannerStatus.fromJson requires devicesSeen and tolerates nulls', - () { - final status = SsdpScannerStatus.fromJson( + test('PassiveScannerStatus.fromJson requires devicesSeen and tolerates ' + 'nulls', () { + final status = PassiveScannerStatus.fromJson( listenerScannerJson(devicesSeen: 0), ); @@ -74,14 +62,5 @@ void main() { expect(status.listeningForSeconds, 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); - }); }); }