From 17712c25f135adbeee024b891db1773968d1e69d Mon Sep 17 00:00:00 2001 From: rzuasti Date: Mon, 1 Jun 2026 07:09:24 -0400 Subject: [PATCH] Collapse scanner-card and pagination duplication into shared widgets Extract ScannerStatusCard and PaginationBar so the ARP/mDNS cards and the two paginated lists share one implementation each. Also fold the four UISnackbars methods over a severity enum and fix Notification.toJson serializing a method tear-off instead of the enum name. Co-Authored-By: Claude Opus 4.7 --- frontend/lib/devices/device_list.dart | 53 ++---- frontend/lib/home/notifications_list.dart | 53 ++---- frontend/lib/model/notification.dart | 2 +- frontend/lib/utils/ui_snackbars.dart | 42 +++-- frontend/lib/widgets/arp_scanner_card.dart | 139 ++-------------- frontend/lib/widgets/mdns_scanner_card.dart | 154 ++---------------- frontend/lib/widgets/pagination_bar.dart | 55 +++++++ frontend/lib/widgets/scanner_status_card.dart | 144 ++++++++++++++++ 8 files changed, 271 insertions(+), 371 deletions(-) create mode 100644 frontend/lib/widgets/pagination_bar.dart create mode 100644 frontend/lib/widgets/scanner_status_card.dart diff --git a/frontend/lib/devices/device_list.dart b/frontend/lib/devices/device_list.dart index 4619ff5..aa7a9b3 100644 --- a/frontend/lib/devices/device_list.dart +++ b/frontend/lib/devices/device_list.dart @@ -8,6 +8,7 @@ import '../model/device_type.dart'; import '../navigation.dart'; import '../utils/friendly_date_formatter.dart'; import '../utils/oott_api.dart'; +import '../widgets/pagination_bar.dart'; import 'device_list_filter.dart'; import 'device_list_rows.dart'; import 'device_list_sort.dart'; @@ -300,51 +301,17 @@ class _DeviceListState extends State with RouteAware { }, separatorBuilder: (_, _) => const Divider(height: 1), ), - if (_currentPage > 0 || _hasNextPage) _buildPaginationControls(), + if (_currentPage > 0 || _hasNextPage) + SliverToBoxAdapter( + child: PaginationBar( + currentPage: _currentPage, + hasNextPage: _hasNextPage, + isLoading: _isLoading, + onPageChanged: _fetchPage, + ), + ), ], ), ); } - - Widget _buildPaginationControls() { - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - IconButton.outlined( - onPressed: _currentPage > 0 && !_isLoading - ? () => _fetchPage(0) - : null, - icon: const Icon(Icons.first_page), - tooltip: 'First page', - ), - const SizedBox(width: 8), - IconButton.outlined( - onPressed: _currentPage > 0 && !_isLoading - ? () => _fetchPage(_currentPage - 1) - : null, - icon: const Icon(Icons.chevron_left), - tooltip: 'Previous page', - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'Page ${_currentPage + 1}', - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - IconButton.outlined( - onPressed: _hasNextPage && !_isLoading - ? () => _fetchPage(_currentPage + 1) - : null, - icon: const Icon(Icons.chevron_right), - tooltip: 'Next page', - ), - ], - ), - ), - ); - } } diff --git a/frontend/lib/home/notifications_list.dart b/frontend/lib/home/notifications_list.dart index 641a501..a9a61b5 100644 --- a/frontend/lib/home/notifications_list.dart +++ b/frontend/lib/home/notifications_list.dart @@ -8,6 +8,7 @@ import '../navigation.dart'; import '../utils/friendly_date_formatter.dart'; import '../utils/oott_api.dart'; import '../utils/ui_snackbars.dart'; +import '../widgets/pagination_bar.dart'; import 'notification_card.dart'; const _pageSize = 5; @@ -288,7 +289,15 @@ class _NotificationsListState extends State } return [ _buildNotificationSliver(), - if (_currentPage > 0 || _hasNextPage) _buildPaginationControls(context), + if (_currentPage > 0 || _hasNextPage) + SliverToBoxAdapter( + child: PaginationBar( + currentPage: _currentPage, + hasNextPage: _hasNextPage, + isLoading: _isLoading, + onPageChanged: _fetchPage, + ), + ), ]; } @@ -306,46 +315,4 @@ class _NotificationsListState extends State }, ); } - - Widget _buildPaginationControls(BuildContext context) { - return SliverToBoxAdapter( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 8), - child: Row( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - IconButton.outlined( - onPressed: _currentPage > 0 && !_isLoading - ? () => _fetchPage(0) - : null, - icon: const Icon(Icons.first_page), - tooltip: 'First page', - ), - const SizedBox(width: 8), - IconButton.outlined( - onPressed: _currentPage > 0 && !_isLoading - ? () => _fetchPage(_currentPage - 1) - : null, - icon: const Icon(Icons.chevron_left), - tooltip: 'Previous page', - ), - Padding( - padding: const EdgeInsets.symmetric(horizontal: 16), - child: Text( - 'Page ${_currentPage + 1}', - style: Theme.of(context).textTheme.bodyMedium, - ), - ), - IconButton.outlined( - onPressed: _hasNextPage && !_isLoading - ? () => _fetchPage(_currentPage + 1) - : null, - icon: const Icon(Icons.chevron_right), - tooltip: 'Next page', - ), - ], - ), - ), - ); - } } diff --git a/frontend/lib/model/notification.dart b/frontend/lib/model/notification.dart index fc00265..87d80ef 100644 --- a/frontend/lib/model/notification.dart +++ b/frontend/lib/model/notification.dart @@ -43,7 +43,7 @@ class Notification { Map toJson() => { 'id': id, 'created_on': createdOn.toUtc().toIso8601String(), - 'notification_type': notificationType.toString, + 'notification_type': notificationType.name, 'title': title, 'body': body, 'is_new': isNew, diff --git a/frontend/lib/utils/ui_snackbars.dart b/frontend/lib/utils/ui_snackbars.dart index 5658766..cdcab65 100644 --- a/frontend/lib/utils/ui_snackbars.dart +++ b/frontend/lib/utils/ui_snackbars.dart @@ -1,33 +1,31 @@ import 'package:flutter/material.dart'; import '../theme/app_colors.dart'; +enum _Severity { error, success, warning, info } + class UISnackbars { - static void showError(BuildContext context, String message) { - final colorScheme = Theme.of(context).colorScheme; - _show(context, message, colorScheme.error, colorScheme.onError); - } + static void showError(BuildContext context, String message) => + _show(context, message, _Severity.error); - static void showSuccess(BuildContext context, String message) { - final appColors = Theme.of(context).extension()!; - _show(context, message, appColors.success, appColors.onSuccess); - } + static void showSuccess(BuildContext context, String message) => + _show(context, message, _Severity.success); - static void showWarning(BuildContext context, String message) { - final appColors = Theme.of(context).extension()!; - _show(context, message, appColors.warning, appColors.onWarning); - } + static void showWarning(BuildContext context, String message) => + _show(context, message, _Severity.warning); - static void showInfo(BuildContext context, String message) { - final appColors = Theme.of(context).extension()!; - _show(context, message, appColors.info, appColors.onInfo); - } + static void showInfo(BuildContext context, String message) => + _show(context, message, _Severity.info); + + static void _show(BuildContext context, String message, _Severity severity) { + final theme = Theme.of(context); + final colors = theme.extension()!; + final (background, foreground) = switch (severity) { + _Severity.error => (theme.colorScheme.error, theme.colorScheme.onError), + _Severity.success => (colors.success, colors.onSuccess), + _Severity.warning => (colors.warning, colors.onWarning), + _Severity.info => (colors.info, colors.onInfo), + }; - static void _show( - BuildContext context, - String message, - Color background, - Color foreground, - ) { final messenger = ScaffoldMessenger.of(context); messenger.clearSnackBars(); messenger.showSnackBar( diff --git a/frontend/lib/widgets/arp_scanner_card.dart b/frontend/lib/widgets/arp_scanner_card.dart index 716a24d..63e5bdb 100644 --- a/frontend/lib/widgets/arp_scanner_card.dart +++ b/frontend/lib/widgets/arp_scanner_card.dart @@ -1,140 +1,29 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; import '../model/arp_scanner_status.dart'; -import '../utils/backend_reachability.dart'; import '../utils/duration_formatter.dart'; import '../utils/oott_api.dart'; -import '../utils/polled_value.dart'; -import 'polled_stale_indicator.dart'; +import 'scanner_status_card.dart'; -class ArpScannerCard extends StatefulWidget { +class ArpScannerCard extends StatelessWidget { const ArpScannerCard({super.key}); @override - State createState() => _ArpScannerCardState(); -} - -class _ArpScannerCardState extends State { - late final PolledValue _polled; - Timer? _tickTimer; - - @override - void initState() { - super.initState(); - _polled = PolledValue( + Widget build(BuildContext context) { + return ScannerStatusCard( + title: 'ARP Scanner', fetch: ({cancelToken}) => BackendAPI.instance.getArpScannerStatus(cancelToken: cancelToken), - pollInterval: const Duration(seconds: 5), - staleErrorAfter: const Duration(seconds: 30), - ); - _tickTimer = Timer.periodic(const Duration(seconds: 1), (_) { - if (mounted) setState(() {}); - }); - } - - @override - void dispose() { - _tickTimer?.cancel(); - _polled.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return ListenableBuilder( - listenable: Listenable.merge([_polled, BackendReachability.instance]), - builder: (context, _) { - final freshness = effectiveFreshness(_polled); - if (freshness == PolledFreshness.initialLoading) { - return const Card( - child: Padding( - padding: EdgeInsets.all(16), - child: Center(child: CircularProgressIndicator()), - ), - ); - } - - final (color, label, sublabel) = _resolveState(freshness); - final isStale = freshness == PolledFreshness.stale; - - return Card( - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () => context.go('/status'), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Icon(Icons.circle, color: color, size: 14), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - 'ARP Scanner', - style: Theme.of(context).textTheme.titleMedium, - ), - if (isStale) ...[ - const SizedBox(width: 6), - PolledStaleIndicator(polled: _polled), - ], - ], - ), - const SizedBox(height: 2), - Text( - label, - style: Theme.of(context).textTheme.bodyMedium, - ), - if (sublabel != null) ...[ - const SizedBox(height: 2), - Text( - sublabel, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: Theme.of(context).colorScheme.outline, - ), - ), - ], - ], - ), - ), - ], - ), - ), - ), - ); - }, + resolver: _resolve, ); } - (Color, String, String?) _resolveState(PolledFreshness freshness) { - if (freshness == PolledFreshness.error) { - return ( - Colors.red, - 'Error', - _polled.lastErrorMessage ?? - 'Unable to reach the server. Check the logs for details.', - ); - } - - final status = _polled.value!; - final lastSuccessAt = _polled.lastSuccessAt!; - final elapsed = DateTime.now() - .difference(lastSuccessAt) - .inSeconds - .toDouble(); - + static ScannerStatus _resolve(ArpScannerStatus status, double elapsed) { if (status.isRunning) { final sub = status.runningForSeconds != null - ? 'Running for ${formatSeconds(status.runningForSeconds! + elapsed)}' - : null; - return (Colors.green, 'Running', sub); + ? ['Running for ${formatSeconds(status.runningForSeconds! + elapsed)}'] + : []; + return (color: Colors.green, label: 'Running', sublabels: sub); } if (status.nextRunInSeconds != null) { final remaining = (status.nextRunInSeconds! - elapsed).clamp( @@ -142,11 +31,11 @@ class _ArpScannerCardState extends State { double.infinity, ); return ( - Colors.amber, - 'Waiting for next run', - 'Next run in ${formatSeconds(remaining)}', + color: Colors.amber, + label: 'Waiting for next run', + sublabels: ['Next run in ${formatSeconds(remaining)}'], ); } - return (Colors.grey, 'Not yet started', null); + return (color: Colors.grey, label: 'Not yet started', sublabels: []); } } diff --git a/frontend/lib/widgets/mdns_scanner_card.dart b/frontend/lib/widgets/mdns_scanner_card.dart index 2ed5383..dd58e2b 100644 --- a/frontend/lib/widgets/mdns_scanner_card.dart +++ b/frontend/lib/widgets/mdns_scanner_card.dart @@ -1,154 +1,34 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; import '../model/mdns_scanner_status.dart'; -import '../utils/backend_reachability.dart'; import '../utils/duration_formatter.dart'; import '../utils/oott_api.dart'; -import '../utils/polled_value.dart'; -import 'polled_stale_indicator.dart'; +import 'scanner_status_card.dart'; -class MdnsScannerCard extends StatefulWidget { +class MdnsScannerCard extends StatelessWidget { const MdnsScannerCard({super.key}); @override - State createState() => _MdnsScannerCardState(); -} - -class _MdnsScannerCardState extends State { - late final PolledValue _polled; - Timer? _tickTimer; - - @override - void initState() { - super.initState(); - _polled = PolledValue( + Widget build(BuildContext context) { + return ScannerStatusCard( + title: 'mDNS Scanner', fetch: ({cancelToken}) => BackendAPI.instance.getMdnsScannerStatus(cancelToken: cancelToken), - pollInterval: const Duration(seconds: 5), - staleErrorAfter: const Duration(seconds: 30), - ); - _tickTimer = Timer.periodic(const Duration(seconds: 1), (_) { - if (mounted) setState(() {}); - }); - } - - @override - void dispose() { - _tickTimer?.cancel(); - _polled.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return ListenableBuilder( - listenable: Listenable.merge([_polled, BackendReachability.instance]), - builder: (context, _) { - final freshness = effectiveFreshness(_polled); - if (freshness == PolledFreshness.initialLoading) { - return const Card( - child: Padding( - padding: EdgeInsets.all(16), - child: Center(child: CircularProgressIndicator()), - ), - ); - } - - final (color, label, sublabels) = _resolveState(freshness); - final isStale = freshness == PolledFreshness.stale; - - return Card( - clipBehavior: Clip.antiAlias, - child: InkWell( - onTap: () => context.go('/status'), - child: Padding( - padding: const EdgeInsets.all(16), - child: Row( - children: [ - Icon(Icons.circle, color: color, size: 14), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Text( - 'mDNS Scanner', - style: Theme.of(context).textTheme.titleMedium, - ), - if (isStale) ...[ - const SizedBox(width: 6), - PolledStaleIndicator(polled: _polled), - ], - ], - ), - const SizedBox(height: 2), - Text( - label, - style: Theme.of(context).textTheme.bodyMedium, - ), - for (final sublabel in sublabels) ...[ - const SizedBox(height: 2), - Text( - sublabel, - style: Theme.of(context).textTheme.bodySmall - ?.copyWith( - color: Theme.of(context).colorScheme.outline, - ), - ), - ], - ], - ), - ), - ], - ), - ), - ), - ); - }, + resolver: _resolve, ); } - (Color, String, List) _resolveState(PolledFreshness freshness) { - if (freshness == PolledFreshness.error) { - return ( - Colors.red, - 'Error', - [ - _polled.lastErrorMessage ?? - 'Unable to reach the server. Check the logs for details.', - ], - ); + static ScannerStatus _resolve(MdnsScannerStatus status, double elapsed) { + if (!status.isListening) { + return (color: Colors.grey, label: 'Not yet started', sublabels: []); } - - final status = _polled.value!; - final lastSuccessAt = _polled.lastSuccessAt!; - final elapsed = DateTime.now() - .difference(lastSuccessAt) - .inSeconds - .toDouble(); - - if (status.isListening) { - final sublabels = []; - if (status.listeningForSeconds != null) { - sublabels.add( - 'Listening for ${formatSeconds(status.listeningForSeconds! + elapsed)} · ${status.devicesSeen} devices seen', - ); - } else { - sublabels.add('${status.devicesSeen} devices seen'); - } - if (status.lastDeviceSeenSecondsAgo != null) { - sublabels.add( - 'Last device ${formatSeconds(status.lastDeviceSeenSecondsAgo! + elapsed)} ago', - ); - } - return (Colors.green, 'Listening', sublabels); - } - - return (Colors.grey, 'Not yet started', []); + final sublabels = [ + status.listeningForSeconds != null + ? 'Listening for ${formatSeconds(status.listeningForSeconds! + elapsed)} · ${status.devicesSeen} devices seen' + : '${status.devicesSeen} devices seen', + if (status.lastDeviceSeenSecondsAgo != null) + 'Last device ${formatSeconds(status.lastDeviceSeenSecondsAgo! + elapsed)} ago', + ]; + return (color: Colors.green, label: 'Listening', sublabels: sublabels); } } diff --git a/frontend/lib/widgets/pagination_bar.dart b/frontend/lib/widgets/pagination_bar.dart new file mode 100644 index 0000000..0c06f2b --- /dev/null +++ b/frontend/lib/widgets/pagination_bar.dart @@ -0,0 +1,55 @@ +import 'package:flutter/material.dart'; + +class PaginationBar extends StatelessWidget { + const PaginationBar({ + super.key, + required this.currentPage, + required this.hasNextPage, + required this.isLoading, + required this.onPageChanged, + }); + + final int currentPage; + final bool hasNextPage; + final bool isLoading; + final ValueChanged onPageChanged; + + @override + Widget build(BuildContext context) { + final canGoBack = currentPage > 0 && !isLoading; + final canGoForward = hasNextPage && !isLoading; + return Padding( + padding: const EdgeInsets.symmetric(vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + IconButton.outlined( + onPressed: canGoBack ? () => onPageChanged(0) : null, + icon: const Icon(Icons.first_page), + tooltip: 'First page', + ), + const SizedBox(width: 8), + IconButton.outlined( + onPressed: canGoBack ? () => onPageChanged(currentPage - 1) : null, + icon: const Icon(Icons.chevron_left), + tooltip: 'Previous page', + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16), + child: Text( + 'Page ${currentPage + 1}', + style: Theme.of(context).textTheme.bodyMedium, + ), + ), + IconButton.outlined( + onPressed: canGoForward + ? () => onPageChanged(currentPage + 1) + : null, + icon: const Icon(Icons.chevron_right), + tooltip: 'Next page', + ), + ], + ), + ); + } +} diff --git a/frontend/lib/widgets/scanner_status_card.dart b/frontend/lib/widgets/scanner_status_card.dart new file mode 100644 index 0000000..cdcc611 --- /dev/null +++ b/frontend/lib/widgets/scanner_status_card.dart @@ -0,0 +1,144 @@ +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/polled_value.dart'; +import 'polled_stale_indicator.dart'; + +typedef ScannerStatus = ({Color color, String label, List sublabels}); + +typedef ScannerStatusResolver = + ScannerStatus Function(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. +class ScannerStatusCard extends StatefulWidget { + const ScannerStatusCard({ + super.key, + required this.title, + required this.fetch, + required this.resolver, + }); + + final String title; + final Future Function({CancelToken? cancelToken}) fetch; + final ScannerStatusResolver resolver; + + @override + State> createState() => _ScannerStatusCardState(); +} + +class _ScannerStatusCardState extends State> { + late final PolledValue _polled; + Timer? _tickTimer; + + @override + void initState() { + super.initState(); + _polled = PolledValue( + fetch: widget.fetch, + pollInterval: const Duration(seconds: 5), + staleErrorAfter: const Duration(seconds: 30), + ); + _tickTimer = Timer.periodic(const Duration(seconds: 1), (_) { + if (mounted) setState(() {}); + }); + } + + @override + void dispose() { + _tickTimer?.cancel(); + _polled.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return ListenableBuilder( + listenable: Listenable.merge([_polled, BackendReachability.instance]), + builder: (context, _) { + final freshness = effectiveFreshness(_polled); + if (freshness == PolledFreshness.initialLoading) { + return const Card( + child: Padding( + padding: EdgeInsets.all(16), + child: Center(child: CircularProgressIndicator()), + ), + ); + } + + final status = _resolveStatus(freshness); + final isStale = freshness == PolledFreshness.stale; + final theme = Theme.of(context); + + return Card( + clipBehavior: Clip.antiAlias, + child: InkWell( + onTap: () => context.go('/status'), + child: Padding( + padding: const EdgeInsets.all(16), + child: Row( + children: [ + Icon(Icons.circle, color: status.color, size: 14), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text( + widget.title, + style: theme.textTheme.titleMedium, + ), + if (isStale) ...[ + const SizedBox(width: 6), + PolledStaleIndicator(polled: _polled), + ], + ], + ), + const SizedBox(height: 2), + Text(status.label, style: theme.textTheme.bodyMedium), + for (final sublabel in status.sublabels) ...[ + const SizedBox(height: 2), + Text( + sublabel, + style: theme.textTheme.bodySmall?.copyWith( + color: theme.colorScheme.outline, + ), + ), + ], + ], + ), + ), + ], + ), + ), + ), + ); + }, + ); + } + + ScannerStatus _resolveStatus(PolledFreshness freshness) { + if (freshness == PolledFreshness.error) { + return ( + color: Colors.red, + label: 'Error', + sublabels: [ + _polled.lastErrorMessage ?? + 'Unable to reach the server. Check the logs for details.', + ], + ); + } + final elapsed = DateTime.now() + .difference(_polled.lastSuccessAt!) + .inSeconds + .toDouble(); + return widget.resolver(_polled.value as T, elapsed); + } +}