From 07f7d54531efcf5a9d9ab4ef4c3e10f1544eb391 Mon Sep 17 00:00:00 2001 From: rzuasti Date: Wed, 27 May 2026 17:43:01 -0400 Subject: [PATCH] Extract snackbar notifications to UISnackbars utility Centralises all SnackBar calls behind UISnackbars.showError/showSuccess/ showWarning/showInfo, each themed via AppColorExtension. New warning and info colour tokens added to both themes. Calling any method clears any visible snackbar before showing the new one. Co-Authored-By: Claude Sonnet 4.6 --- CLAUDE.md | 1 + TODO.md | 3 +- frontend/lib/devices/device_list.dart | 46 +--- .../lib/notifications/notification_list.dart | 205 ++++++++---------- frontend/lib/theme/app_colors.dart | 37 +++- .../lib/theme/catppuccin_mocha_theme.dart | 4 + frontend/lib/theme/gruvbox_theme.dart | 4 + frontend/lib/utils/ui_snackbars.dart | 43 ++-- 8 files changed, 165 insertions(+), 178 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 96add05..4d5412c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -51,3 +51,4 @@ For Flutter/Dart code: - ALWAYS run all tests after making a new change and do not continue until all tests pass - When adding a new API endpoint, ALWAYS wire it to the OpenAPI generation - When adding a significant chunk of new code (either Rust or Dart), run the corresponding linter +- In the frontend, use the UISnackbars component to display messages to the user that do not require action on their part. diff --git a/TODO.md b/TODO.md index 3efe226..f97079e 100644 --- a/TODO.md +++ b/TODO.md @@ -13,7 +13,8 @@ - [x] List recorded devices - [x] Register a device - [x] Forget a device -- [ ] Extract the snack bar confirmations as a utility widget so it can be reused +- [x] Extract the snack bar confirmations as a utility widget so it can be reused +- [ ] In the notifications list add an action to register the device if new - [ ] In the devices list add filters by owner and device type - [ ] View detailed log of device activity (based on event log in the backend) - [ ] Scan process monitor and summary page diff --git a/frontend/lib/devices/device_list.dart b/frontend/lib/devices/device_list.dart index 5a34fe1..f15c859 100644 --- a/frontend/lib/devices/device_list.dart +++ b/frontend/lib/devices/device_list.dart @@ -3,6 +3,7 @@ import 'package:flutter/material.dart'; import '../model/device.dart'; import '../utils/friendly_date_formatter.dart'; import '../utils/oott_api.dart'; +import '../utils/ui_snackbars.dart'; import '../widgets/status_badge.dart'; const _deviceTypes = [ @@ -69,8 +70,7 @@ class _DeviceListState extends State { } } - Future _confirmForget(BuildContext context, Device device) async { - final messenger = ScaffoldMessenger.of(context); + Future _confirmForget(Device device) async { final colorScheme = Theme.of(context).colorScheme; final confirmed = await showDialog( @@ -99,28 +99,15 @@ class _DeviceListState extends State { try { await BackendAPI.instance.forgetDevice(device.macAddress); if (!mounted) return; - messenger.showSnackBar( - const SnackBar( - content: Text('Device forgotten'), - behavior: SnackBarBehavior.floating, - showCloseIcon: true, - ), - ); + UISnackbars.showSuccess(context, 'Device forgotten'); _loadDevices(); } catch (e) { if (!mounted) return; - messenger.showSnackBar( - SnackBar( - content: Text('Failed to forget device: $e'), - behavior: SnackBarBehavior.floating, - showCloseIcon: true, - ), - ); + UISnackbars.showError(context, 'Failed to forget device: $e'); } } - Future _showRegisterDialog(BuildContext context, Device device) async { - final messenger = ScaffoldMessenger.of(context); + Future _showRegisterDialog(Device device) async { final formKey = GlobalKey(); String owner = ''; String deviceType = _deviceTypes.contains(device.deviceType) @@ -189,23 +176,11 @@ class _DeviceListState extends State { deviceType, ); if (!mounted) return; - messenger.showSnackBar( - const SnackBar( - content: Text('Device registered'), - behavior: SnackBarBehavior.floating, - showCloseIcon: true, - ), - ); + UISnackbars.showSuccess(context, 'Device registered'); _loadDevices(); } catch (e) { if (!mounted) return; - messenger.showSnackBar( - SnackBar( - content: Text('Failed to register device: $e'), - behavior: SnackBarBehavior.floating, - showCloseIcon: true, - ), - ); + UISnackbars.showError(context, 'Failed to register device: $e'); } } @@ -335,12 +310,9 @@ class _DeviceListState extends State { icon: const Icon(Icons.more_vert), onSelected: (value) async { if (value == 'forget') { - await _confirmForget(context, device); + await _confirmForget(device); } else if (value == 'register') { - await _showRegisterDialog( - context, - device, - ); + await _showRegisterDialog(device); } }, itemBuilder: (context) => [ diff --git a/frontend/lib/notifications/notification_list.dart b/frontend/lib/notifications/notification_list.dart index f893490..eb4cdd9 100644 --- a/frontend/lib/notifications/notification_list.dart +++ b/frontend/lib/notifications/notification_list.dart @@ -6,6 +6,7 @@ import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart'; import '../model/notification.dart' as oott_model; import '../utils/friendly_date_formatter.dart'; import '../utils/oott_api.dart'; +import '../utils/ui_snackbars.dart'; class NotificationList extends StatefulWidget { const NotificationList({super.key}); @@ -48,13 +49,7 @@ class _NotificationListState extends State { await BackendAPI.instance.markAllNotificationsAsRead(); _pagingController.refresh(); if (context.mounted) { - ScaffoldMessenger.of(context).showSnackBar( - const SnackBar( - content: Text('All notifications marked as read'), - behavior: SnackBarBehavior.floating, - showCloseIcon: true, - ), - ); + UISnackbars.showSuccess(context, 'All notifications marked as read'); } } @@ -63,24 +58,15 @@ class _NotificationListState extends State { oott_model.Notification item, ) async { if (!item.isNew) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Notification was already marked as read'), - behavior: SnackBarBehavior.floating, - showCloseIcon: true, - ), + UISnackbars.showWarning( + context, + 'Notification was already marked as read', ); return false; } await BackendAPI.instance.markNotificationAsRead(item.id); if (!context.mounted) return false; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Event marked as read'), - behavior: SnackBarBehavior.floating, - showCloseIcon: true, - ), - ); + UISnackbars.showSuccess(context, 'Event marked as read'); if (_filterChoice != 3) { _pagingController.value = _pagingController.value.filterItems( (n) => n.id != item.id, @@ -98,24 +84,15 @@ class _NotificationListState extends State { oott_model.Notification item, ) async { if (item.isNew) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Notification was already marked as unread'), - behavior: SnackBarBehavior.floating, - showCloseIcon: true, - ), + UISnackbars.showWarning( + context, + 'Notification was already marked as unread', ); return false; } await BackendAPI.instance.markNotificationAsNew(item.id); if (!context.mounted) return false; - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('Event marked as unread'), - behavior: SnackBarBehavior.floating, - showCloseIcon: true, - ), - ); + UISnackbars.showSuccess(context, 'Event marked as unread'); if (_filterChoice != 3) { _pagingController.value = _pagingController.value.filterItems( (n) => n.id != item.id, @@ -183,92 +160,86 @@ class _NotificationListState extends State { ), ), ), - PagedSliverList( - state: state, - fetchNextPage: fetchNextPage, - builderDelegate: PagedChildBuilderDelegate( - itemBuilder: (context, item, index) { - return Column( - children: [ - Dismissible( - key: UniqueKey(), - confirmDismiss: (direction) => - direction == DismissDirection.startToEnd - ? _markAsNew(context, item) - : _markAsRead(context, item), - background: Container( - color: Theme.of( - context, - ).colorScheme.tertiaryContainer, - alignment: Alignment.centerLeft, - padding: const EdgeInsets.only(left: 16), - child: Icon(Icons.mark_email_unread), - ), - secondaryBackground: Container( - color: Theme.of( - context, - ).colorScheme.primaryContainer, - alignment: Alignment.centerRight, - padding: const EdgeInsets.only(right: 16), - child: Icon(Icons.done), - ), - child: ListTile( - tileColor: item.isNew - ? Theme.of( - context, - ).colorScheme.secondaryContainer - : null, - leading: Icon( - item.notificationType.icon, - color: item.isNew - ? Theme.of(context).colorScheme.primary - : null, - ), - title: Text( - '${FriendlyDateFormatter().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 == 'mark_read') { - await _markAsRead(context, item); - } else if (value == 'mark_new') { - await _markAsNew(context, item); - } - }, - itemBuilder: (context) => [ - 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: () {}, - isThreeLine: true, - ), + PagedSliverList( + state: state, + fetchNextPage: fetchNextPage, + builderDelegate: PagedChildBuilderDelegate( + itemBuilder: (context, item, index) { + return Column( + children: [ + Dismissible( + key: UniqueKey(), + confirmDismiss: (direction) => + direction == DismissDirection.startToEnd + ? _markAsNew(context, item) + : _markAsRead(context, item), + background: Container( + color: Theme.of( + context, + ).colorScheme.tertiaryContainer, + alignment: Alignment.centerLeft, + padding: const EdgeInsets.only(left: 16), + child: Icon(Icons.mark_email_unread), + ), + secondaryBackground: Container( + color: Theme.of(context).colorScheme.primaryContainer, + alignment: Alignment.centerRight, + padding: const EdgeInsets.only(right: 16), + child: Icon(Icons.done), + ), + child: ListTile( + tileColor: item.isNew + ? Theme.of(context).colorScheme.secondaryContainer + : null, + leading: Icon( + item.notificationType.icon, + color: item.isNew + ? Theme.of(context).colorScheme.primary + : null, ), - Divider(), - ], - ); - }, - ), - ), - ], + title: Text( + '${FriendlyDateFormatter().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 == 'mark_read') { + await _markAsRead(context, item); + } else if (value == 'mark_new') { + await _markAsNew(context, item); + } + }, + itemBuilder: (context) => [ + 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: () {}, + isThreeLine: true, + ), + ), + Divider(), + ], + ); + }, + ), ), - ), - ); + ], + ), + ), + ); // Paginated list end } diff --git a/frontend/lib/theme/app_colors.dart b/frontend/lib/theme/app_colors.dart index dfd7a92..f328b15 100644 --- a/frontend/lib/theme/app_colors.dart +++ b/frontend/lib/theme/app_colors.dart @@ -2,17 +2,38 @@ import 'package:flutter/material.dart'; @immutable class AppColorExtension extends ThemeExtension { - const AppColorExtension({required this.success, required this.onSuccess}); + const AppColorExtension({ + required this.success, + required this.onSuccess, + required this.warning, + required this.onWarning, + required this.info, + required this.onInfo, + }); final Color success; final Color onSuccess; + final Color warning; + final Color onWarning; + final Color info; + final Color onInfo; @override - AppColorExtension copyWith({Color? success, Color? onSuccess}) => - AppColorExtension( - success: success ?? this.success, - onSuccess: onSuccess ?? this.onSuccess, - ); + AppColorExtension copyWith({ + Color? success, + Color? onSuccess, + Color? warning, + Color? onWarning, + Color? info, + Color? onInfo, + }) => AppColorExtension( + success: success ?? this.success, + onSuccess: onSuccess ?? this.onSuccess, + warning: warning ?? this.warning, + onWarning: onWarning ?? this.onWarning, + info: info ?? this.info, + onInfo: onInfo ?? this.onInfo, + ); @override AppColorExtension lerp(AppColorExtension? other, double t) { @@ -20,6 +41,10 @@ class AppColorExtension extends ThemeExtension { return AppColorExtension( success: Color.lerp(success, other.success, t)!, onSuccess: Color.lerp(onSuccess, other.onSuccess, t)!, + warning: Color.lerp(warning, other.warning, t)!, + onWarning: Color.lerp(onWarning, other.onWarning, t)!, + info: Color.lerp(info, other.info, t)!, + onInfo: Color.lerp(onInfo, other.onInfo, t)!, ); } } diff --git a/frontend/lib/theme/catppuccin_mocha_theme.dart b/frontend/lib/theme/catppuccin_mocha_theme.dart index 2a17cde..ea6b82b 100644 --- a/frontend/lib/theme/catppuccin_mocha_theme.dart +++ b/frontend/lib/theme/catppuccin_mocha_theme.dart @@ -84,6 +84,10 @@ final ThemeData catppuccinMochaDarkTheme = ThemeData( const AppColorExtension( success: CatppuccinMochaColors.green, onSuccess: CatppuccinMochaColors.crust, + warning: CatppuccinMochaColors.peach, + onWarning: CatppuccinMochaColors.crust, + info: CatppuccinMochaColors.blue, + onInfo: CatppuccinMochaColors.crust, ), ], ); diff --git a/frontend/lib/theme/gruvbox_theme.dart b/frontend/lib/theme/gruvbox_theme.dart index f414bd3..706cd3d 100644 --- a/frontend/lib/theme/gruvbox_theme.dart +++ b/frontend/lib/theme/gruvbox_theme.dart @@ -81,6 +81,10 @@ final ThemeData gruvboxDarkTheme = ThemeData( const AppColorExtension( success: GruvboxColors.brightGreen, onSuccess: GruvboxColors.bgHard, + warning: GruvboxColors.brightYellow, + onWarning: GruvboxColors.bgHard, + info: GruvboxColors.brightBlue, + onInfo: GruvboxColors.bgHard, ), ], ); diff --git a/frontend/lib/utils/ui_snackbars.dart b/frontend/lib/utils/ui_snackbars.dart index c504068..5658766 100644 --- a/frontend/lib/utils/ui_snackbars.dart +++ b/frontend/lib/utils/ui_snackbars.dart @@ -3,30 +3,39 @@ import '../theme/app_colors.dart'; class UISnackbars { static void showError(BuildContext context, String message) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text( - message, - style: TextStyle(color: Theme.of(context).colorScheme.onError), - ), - behavior: SnackBarBehavior.floating, - showCloseIcon: true, - backgroundColor: Theme.of(context).colorScheme.error, - ), - ); + final colorScheme = Theme.of(context).colorScheme; + _show(context, message, colorScheme.error, colorScheme.onError); } static void showSuccess(BuildContext context, String message) { final appColors = Theme.of(context).extension()!; - ScaffoldMessenger.of(context).showSnackBar( + _show(context, message, appColors.success, appColors.onSuccess); + } + + static void showWarning(BuildContext context, String message) { + final appColors = Theme.of(context).extension()!; + _show(context, message, appColors.warning, appColors.onWarning); + } + + static void showInfo(BuildContext context, String message) { + final appColors = Theme.of(context).extension()!; + _show(context, message, appColors.info, appColors.onInfo); + } + + static void _show( + BuildContext context, + String message, + Color background, + Color foreground, + ) { + final messenger = ScaffoldMessenger.of(context); + messenger.clearSnackBars(); + messenger.showSnackBar( SnackBar( - content: Text( - message, - style: TextStyle(color: appColors.onSuccess), - ), + content: Text(message, style: TextStyle(color: foreground)), behavior: SnackBarBehavior.floating, showCloseIcon: true, - backgroundColor: appColors.success, + backgroundColor: background, ), ); }