From f113da9cd0c29c06799b5c111b17292c159938d2 Mon Sep 17 00:00:00 2001 From: rzuasti Date: Thu, 28 May 2026 19:23:27 -0400 Subject: [PATCH] Refactor frontend: extract widgets and eliminate duplicated scanner state - Convert ArpScannerCard to a self-managing StatefulWidget (owns its own 5s refresh + 1s tick timers), removing the duplicated state management that existed in both HomeScreen and StatusScreen - Extract NotificationCard to home/notification_card.dart - Extract DeviceSummaryCard (with its 1-min refresh timer) to widgets/device_summary_card.dart - HomeScreen drops from 515 to 253 lines; StatusScreen from 82 to 19 lines Co-Authored-By: Claude Sonnet 4.6 --- frontend/lib/home/home_screen.dart | 287 +----------------- frontend/lib/home/notification_card.dart | 98 ++++++ frontend/lib/status/status_screen.dart | 70 +---- frontend/lib/widgets/arp_scanner_card.dart | 91 ++++-- frontend/lib/widgets/device_summary_card.dart | 138 +++++++++ 5 files changed, 322 insertions(+), 362 deletions(-) create mode 100644 frontend/lib/home/notification_card.dart create mode 100644 frontend/lib/widgets/device_summary_card.dart diff --git a/frontend/lib/home/home_screen.dart b/frontend/lib/home/home_screen.dart index ae695c3..cd643d2 100644 --- a/frontend/lib/home/home_screen.dart +++ b/frontend/lib/home/home_screen.dart @@ -1,16 +1,15 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; -import '../model/arp_scanner_status.dart'; -import '../model/device_summary.dart'; import '../model/notification.dart' as oott_model; import '../utils/friendly_date_formatter.dart'; import '../utils/oott_api.dart'; import '../utils/ui_snackbars.dart'; import '../widgets/arp_scanner_card.dart'; +import '../widgets/device_summary_card.dart'; +import 'notification_card.dart'; const _twoColumnBreakpoint = 700.0; @@ -38,7 +37,6 @@ class HomeScreen extends StatefulWidget { } class _HomeScreenState extends State { - // Notification state _NotificationFilter _filter = _NotificationFilter.newOnly; Timer? _notificationTimer; @@ -51,20 +49,6 @@ class _HomeScreenState extends State { BackendAPI.instance.listNotifications(_filter.isNew, pageKey), ); - // Device summary state - DeviceSummary? _deviceSummary; - bool _isLoadingDeviceSummary = true; - String? _deviceSummaryError; - Timer? _deviceSummaryTimer; - - // Scanner status state - ArpScannerStatus? _scannerStatus; - DateTime? _scannerStatusReceivedAt; - bool _isLoadingScanner = true; - String? _scannerError; - Timer? _scannerRefreshTimer; - Timer? _scannerTickTimer; - @override void initState() { super.initState(); @@ -72,71 +56,15 @@ class _HomeScreenState extends State { const Duration(minutes: 1), (_) => _pagingController.refresh(), ); - _loadDeviceSummary(); - _deviceSummaryTimer = Timer.periodic( - const Duration(minutes: 1), - (_) => _loadDeviceSummary(), - ); - _loadScannerStatus(); - _scannerRefreshTimer = Timer.periodic( - const Duration(seconds: 5), - (_) => _loadScannerStatus(), - ); - _scannerTickTimer = Timer.periodic( - const Duration(seconds: 1), - (_) { - if (mounted) setState(() {}); - }, - ); } @override void dispose() { _notificationTimer?.cancel(); _pagingController.dispose(); - _deviceSummaryTimer?.cancel(); - _scannerRefreshTimer?.cancel(); - _scannerTickTimer?.cancel(); super.dispose(); } - Future _loadDeviceSummary() async { - try { - final summary = await BackendAPI.instance.getDeviceSummary(); - if (!mounted) return; - setState(() { - _deviceSummary = summary; - _deviceSummaryError = null; - _isLoadingDeviceSummary = false; - }); - } catch (e) { - if (!mounted) return; - setState(() { - _deviceSummaryError = e.toString(); - _isLoadingDeviceSummary = false; - }); - } - } - - Future _loadScannerStatus() async { - try { - final status = await BackendAPI.instance.getArpScannerStatus(); - if (!mounted) return; - setState(() { - _scannerStatus = status; - _scannerStatusReceivedAt = DateTime.now(); - _scannerError = null; - _isLoadingScanner = false; - }); - } catch (e) { - if (!mounted) return; - setState(() { - _scannerError = e.toString(); - _isLoadingScanner = false; - }); - } - } - Future _markAllAsRead(BuildContext context) async { await BackendAPI.instance.markAllNotificationsAsRead(); _pagingController.refresh(); @@ -217,15 +145,15 @@ class _HomeScreenState extends State { ), ), const VerticalDivider(width: 32), - SizedBox( + const SizedBox( width: 300, child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ - _buildDeviceSummaryCard(context), - const SizedBox(height: 16), - _buildScannerCard(), + DeviceSummaryCard(), + SizedBox(height: 16), + ArpScannerCard(), ], ), ), @@ -243,16 +171,16 @@ class _HomeScreenState extends State { slivers: [ SliverToBoxAdapter(child: _buildNotificationsHeader(context, state)), _buildNotificationSliver(state, fetchNextPage), - SliverToBoxAdapter( + const SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.only(top: 24), - child: _buildDeviceSummaryCard(context), + padding: EdgeInsets.only(top: 24), + child: DeviceSummaryCard(), ), ), - SliverToBoxAdapter( + const SliverToBoxAdapter( child: Padding( - padding: const EdgeInsets.only(top: 16, bottom: 20), - child: _buildScannerCard(), + padding: EdgeInsets.only(top: 16, bottom: 20), + child: ArpScannerCard(), ), ), ], @@ -314,7 +242,7 @@ class _HomeScreenState extends State { state: state, fetchNextPage: fetchNextPage, builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) => _NotificationCard( + itemBuilder: (context, item, index) => NotificationCard( item: item, formatter: formatter, onSetRead: (ctx, read) => _setRead(ctx, item, read), @@ -322,193 +250,4 @@ class _HomeScreenState extends State { ), ); } - - Widget _buildDeviceSummaryCard(BuildContext context) { - return Card( - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Devices', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 12), - if (_isLoadingDeviceSummary) - const Center(child: CircularProgressIndicator()) - else if (_deviceSummaryError != null) - Text( - 'Error loading device summary', - style: TextStyle( - color: Theme.of(context).colorScheme.error, - ), - ) - else if (_deviceSummary != null) ...[ - _SummaryRow( - label: 'Registered', - value: '${_deviceSummary!.totalRegistered}', - ), - const Divider(height: 20), - Text( - 'Seen in the last 24 hours', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.outline, - ), - ), - const SizedBox(height: 6), - _SummaryRow( - label: 'Registered', - value: '${_deviceSummary!.seenLastDayRegistered}', - ), - _SummaryRow( - label: 'Unregistered', - value: '${_deviceSummary!.seenLastDayUnregistered}', - ), - const Divider(height: 20), - Text( - 'Seen in the last 7 days', - style: Theme.of(context).textTheme.bodySmall?.copyWith( - color: Theme.of(context).colorScheme.outline, - ), - ), - const SizedBox(height: 6), - _SummaryRow( - label: 'Registered', - value: '${_deviceSummary!.seenLastWeekRegistered}', - ), - _SummaryRow( - label: 'Unregistered', - value: '${_deviceSummary!.seenLastWeekUnregistered}', - ), - ], - ], - ), - ), - ); - } - - Widget _buildScannerCard() { - return ArpScannerCard( - status: _scannerStatus, - statusReceivedAt: _scannerStatusReceivedAt, - error: _scannerError, - isLoading: _isLoadingScanner, - ); - } -} - -class _SummaryRow extends StatelessWidget { - final String label; - final String value; - - const _SummaryRow({required this.label, required this.value}); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 2), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(label, style: Theme.of(context).textTheme.bodyMedium), - Text( - value, - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - fontWeight: FontWeight.bold, - ), - ), - ], - ), - ); - } -} - -class _NotificationCard extends StatelessWidget { - final oott_model.Notification item; - final FriendlyDateFormatter formatter; - final Future Function(BuildContext, bool read) onSetRead; - - const _NotificationCard({ - required this.item, - required this.formatter, - required this.onSetRead, - }); - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Card( - color: item.isNew ? theme.colorScheme.secondaryContainer : null, - child: Dismissible( - key: UniqueKey(), - confirmDismiss: (direction) => direction == DismissDirection.startToEnd - ? onSetRead(context, false) - : onSetRead(context, true), - background: Container( - color: theme.colorScheme.tertiaryContainer, - alignment: Alignment.centerLeft, - padding: const EdgeInsets.only(left: 16), - child: const Icon(Icons.mark_email_unread), - ), - secondaryBackground: Container( - color: theme.colorScheme.primaryContainer, - alignment: Alignment.centerRight, - padding: const EdgeInsets.only(right: 16), - child: const Icon(Icons.done), - ), - child: ListTile( - leading: Icon( - item.notificationType.icon, - color: item.isNew ? theme.colorScheme.primary : null, - ), - title: Text( - '${formatter.format(item.createdOn)} - ${item.title}', - style: item.isNew - ? const TextStyle(fontWeight: FontWeight.bold) - : null, - ), - subtitle: Text(item.body, maxLines: 5), - trailing: PopupMenuButton( - icon: const Icon(Icons.more_vert), - onSelected: (value) async { - if (value == 'view_device') { - if (item.isNew) await onSetRead(context, true); - if (context.mounted) { - context.push('/devices/${item.macAddress}'); - } - } else if (value == 'mark_read') { - await onSetRead(context, true); - } else if (value == 'mark_new') { - await onSetRead(context, false); - } - }, - itemBuilder: (context) => [ - if (item.macAddress != null) - const PopupMenuItem( - value: 'view_device', - child: Text('View device'), - ), - if (item.isNew) - const PopupMenuItem( - value: 'mark_read', - child: Text('Mark as read'), - ), - if (!item.isNew) - const PopupMenuItem( - value: 'mark_new', - child: Text('Mark as unread'), - ), - ], - ), - onTap: item.macAddress != null - ? () async { - if (item.isNew) await onSetRead(context, true); - if (context.mounted) { - context.push('/devices/${item.macAddress}'); - } - } - : null, - isThreeLine: true, - ), - ), - ); - } } diff --git a/frontend/lib/home/notification_card.dart b/frontend/lib/home/notification_card.dart new file mode 100644 index 0000000..3e179e6 --- /dev/null +++ b/frontend/lib/home/notification_card.dart @@ -0,0 +1,98 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; + +import '../model/notification.dart' as oott_model; +import '../utils/friendly_date_formatter.dart'; + +class NotificationCard extends StatelessWidget { + final oott_model.Notification item; + final FriendlyDateFormatter formatter; + final Future Function(BuildContext, bool read) onSetRead; + + const NotificationCard({ + required this.item, + required this.formatter, + required this.onSetRead, + super.key, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Card( + color: item.isNew ? theme.colorScheme.secondaryContainer : null, + child: Dismissible( + key: UniqueKey(), + confirmDismiss: (direction) => direction == DismissDirection.startToEnd + ? onSetRead(context, false) + : onSetRead(context, true), + background: Container( + color: theme.colorScheme.tertiaryContainer, + alignment: Alignment.centerLeft, + padding: const EdgeInsets.only(left: 16), + child: const Icon(Icons.mark_email_unread), + ), + secondaryBackground: Container( + color: theme.colorScheme.primaryContainer, + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 16), + child: const Icon(Icons.done), + ), + child: ListTile( + leading: Icon( + item.notificationType.icon, + color: item.isNew ? theme.colorScheme.primary : null, + ), + title: Text( + '${formatter.format(item.createdOn)} - ${item.title}', + style: item.isNew + ? const TextStyle(fontWeight: FontWeight.bold) + : null, + ), + subtitle: Text(item.body, maxLines: 5), + trailing: PopupMenuButton( + icon: const Icon(Icons.more_vert), + onSelected: (value) async { + if (value == 'view_device') { + if (item.isNew) await onSetRead(context, true); + if (context.mounted) { + context.push('/devices/${item.macAddress}'); + } + } else if (value == 'mark_read') { + await onSetRead(context, true); + } else if (value == 'mark_new') { + await onSetRead(context, false); + } + }, + itemBuilder: (context) => [ + if (item.macAddress != null) + const PopupMenuItem( + value: 'view_device', + child: Text('View device'), + ), + if (item.isNew) + const PopupMenuItem( + value: 'mark_read', + child: Text('Mark as read'), + ), + if (!item.isNew) + const PopupMenuItem( + value: 'mark_new', + child: Text('Mark as unread'), + ), + ], + ), + onTap: item.macAddress != null + ? () async { + if (item.isNew) await onSetRead(context, true); + if (context.mounted) { + context.push('/devices/${item.macAddress}'); + } + } + : null, + isThreeLine: true, + ), + ), + ); + } +} diff --git a/frontend/lib/status/status_screen.dart b/frontend/lib/status/status_screen.dart index e1907d2..ef70b96 100644 --- a/frontend/lib/status/status_screen.dart +++ b/frontend/lib/status/status_screen.dart @@ -1,67 +1,10 @@ -import 'dart:async'; - import 'package:flutter/material.dart'; -import 'package:frontend/model/arp_scanner_status.dart'; -import 'package:frontend/utils/oott_api.dart'; -import 'package:frontend/widgets/arp_scanner_card.dart'; -class StatusScreen extends StatefulWidget { +import '../widgets/arp_scanner_card.dart'; + +class StatusScreen extends StatelessWidget { const StatusScreen({super.key}); - @override - State createState() => _StatusScreenState(); -} - -class _StatusScreenState extends State { - ArpScannerStatus? _status; - DateTime? _statusReceivedAt; - bool _isLoading = true; - String? _error; - Timer? _refreshTimer; - Timer? _tickTimer; - - @override - void initState() { - super.initState(); - _loadStatus(); - _refreshTimer = Timer.periodic( - const Duration(seconds: 5), - (_) => _loadStatus(), - ); - _tickTimer = Timer.periodic( - const Duration(seconds: 1), - (_) { - if (mounted) setState(() {}); - }, - ); - } - - @override - void dispose() { - _refreshTimer?.cancel(); - _tickTimer?.cancel(); - super.dispose(); - } - - Future _loadStatus() async { - try { - final status = await BackendAPI.instance.getArpScannerStatus(); - if (!mounted) return; - setState(() { - _status = status; - _statusReceivedAt = DateTime.now(); - _error = null; - _isLoading = false; - }); - } catch (e) { - if (!mounted) return; - setState(() { - _error = e.toString(); - _isLoading = false; - }); - } - } - @override Widget build(BuildContext context) { return Column( @@ -69,12 +12,7 @@ class _StatusScreenState extends State { children: [ Text('Status', style: Theme.of(context).textTheme.headlineMedium), const SizedBox(height: 20), - ArpScannerCard( - status: _status, - statusReceivedAt: _statusReceivedAt, - error: _error, - isLoading: _isLoading, - ), + const ArpScannerCard(), ], ); } diff --git a/frontend/lib/widgets/arp_scanner_card.dart b/frontend/lib/widgets/arp_scanner_card.dart index 332bfc1..30eaa3a 100644 --- a/frontend/lib/widgets/arp_scanner_card.dart +++ b/frontend/lib/widgets/arp_scanner_card.dart @@ -1,23 +1,70 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; -import 'package:frontend/model/arp_scanner_status.dart'; -class ArpScannerCard extends StatelessWidget { - final ArpScannerStatus? status; - final DateTime? statusReceivedAt; - final String? error; - final bool isLoading; +import '../model/arp_scanner_status.dart'; +import '../utils/oott_api.dart'; - const ArpScannerCard({ - super.key, - this.status, - this.statusReceivedAt, - this.error, - this.isLoading = false, - }); +class ArpScannerCard extends StatefulWidget { + const ArpScannerCard({super.key}); + + @override + State createState() => _ArpScannerCardState(); +} + +class _ArpScannerCardState extends State { + ArpScannerStatus? _status; + DateTime? _statusReceivedAt; + bool _isLoading = true; + String? _error; + Timer? _refreshTimer; + Timer? _tickTimer; + + @override + void initState() { + super.initState(); + _load(); + _refreshTimer = Timer.periodic( + const Duration(seconds: 5), + (_) => _load(), + ); + _tickTimer = Timer.periodic( + const Duration(seconds: 1), + (_) { + if (mounted) setState(() {}); + }, + ); + } + + @override + void dispose() { + _refreshTimer?.cancel(); + _tickTimer?.cancel(); + super.dispose(); + } + + Future _load() async { + try { + final status = await BackendAPI.instance.getArpScannerStatus(); + if (!mounted) return; + setState(() { + _status = status; + _statusReceivedAt = DateTime.now(); + _error = null; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } @override Widget build(BuildContext context) { - if (isLoading) { + if (_isLoading) { return const Card( child: Padding( padding: EdgeInsets.all(16), @@ -64,7 +111,7 @@ class ArpScannerCard extends StatelessWidget { } (Color, String, String?) _resolveState(BuildContext context) { - if (error != null || status == null) { + if (_error != null || _status == null) { return ( Colors.red, 'Error', @@ -72,18 +119,18 @@ class ArpScannerCard extends StatelessWidget { ); } - final elapsed = statusReceivedAt != null - ? DateTime.now().difference(statusReceivedAt!).inSeconds.toDouble() + final elapsed = _statusReceivedAt != null + ? DateTime.now().difference(_statusReceivedAt!).inSeconds.toDouble() : 0.0; - if (status!.isRunning) { - final sub = status!.runningForSeconds != null - ? 'Running for ${_formatSeconds(status!.runningForSeconds! + elapsed)}' + if (_status!.isRunning) { + final sub = _status!.runningForSeconds != null + ? 'Running for ${_formatSeconds(_status!.runningForSeconds! + elapsed)}' : null; return (Colors.green, 'Running', sub); } - if (status!.nextRunInSeconds != null) { - final remaining = (status!.nextRunInSeconds! - elapsed).clamp( + if (_status!.nextRunInSeconds != null) { + final remaining = (_status!.nextRunInSeconds! - elapsed).clamp( 0.0, double.infinity, ); diff --git a/frontend/lib/widgets/device_summary_card.dart b/frontend/lib/widgets/device_summary_card.dart new file mode 100644 index 0000000..91dc32d --- /dev/null +++ b/frontend/lib/widgets/device_summary_card.dart @@ -0,0 +1,138 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; + +import '../model/device_summary.dart'; +import '../utils/oott_api.dart'; + +class DeviceSummaryCard extends StatefulWidget { + const DeviceSummaryCard({super.key}); + + @override + State createState() => _DeviceSummaryCardState(); +} + +class _DeviceSummaryCardState extends State { + DeviceSummary? _summary; + bool _isLoading = true; + String? _error; + Timer? _timer; + + @override + void initState() { + super.initState(); + _load(); + _timer = Timer.periodic(const Duration(minutes: 1), (_) => _load()); + } + + @override + void dispose() { + _timer?.cancel(); + super.dispose(); + } + + Future _load() async { + try { + final summary = await BackendAPI.instance.getDeviceSummary(); + if (!mounted) return; + setState(() { + _summary = summary; + _error = null; + _isLoading = false; + }); + } catch (e) { + if (!mounted) return; + setState(() { + _error = e.toString(); + _isLoading = false; + }); + } + } + + @override + Widget build(BuildContext context) { + return Card( + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Devices', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 12), + if (_isLoading) + const Center(child: CircularProgressIndicator()) + else if (_error != null) + Text( + 'Error loading device summary', + style: TextStyle(color: Theme.of(context).colorScheme.error), + ) + else if (_summary != null) ...[ + _SummaryRow( + label: 'Registered', + value: '${_summary!.totalRegistered}', + ), + const Divider(height: 20), + Text( + 'Seen in the last 24 hours', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.outline, + ), + ), + const SizedBox(height: 6), + _SummaryRow( + label: 'Registered', + value: '${_summary!.seenLastDayRegistered}', + ), + _SummaryRow( + label: 'Unregistered', + value: '${_summary!.seenLastDayUnregistered}', + ), + const Divider(height: 20), + Text( + 'Seen in the last 7 days', + style: Theme.of(context).textTheme.bodySmall?.copyWith( + color: Theme.of(context).colorScheme.outline, + ), + ), + const SizedBox(height: 6), + _SummaryRow( + label: 'Registered', + value: '${_summary!.seenLastWeekRegistered}', + ), + _SummaryRow( + label: 'Unregistered', + value: '${_summary!.seenLastWeekUnregistered}', + ), + ], + ], + ), + ), + ); + } +} + +class _SummaryRow extends StatelessWidget { + final String label; + final String value; + + const _SummaryRow({required this.label, required this.value}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 2), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Text(label, style: Theme.of(context).textTheme.bodyMedium), + Text( + value, + style: Theme.of(context).textTheme.bodyMedium?.copyWith( + fontWeight: FontWeight.bold, + ), + ), + ], + ), + ); + } +}