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 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-05-27 17:43:01 -04:00
co-authored by Claude Sonnet 4.6
parent 3094753587
commit 07f7d54531
8 changed files with 165 additions and 178 deletions
+1
View File
@@ -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 - 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 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 - 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.
+2 -1
View File
@@ -13,7 +13,8 @@
- [x] List recorded devices - [x] List recorded devices
- [x] Register a device - [x] Register a device
- [x] Forget 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 - [ ] In the devices list add filters by owner and device type
- [ ] View detailed log of device activity (based on event log in the backend) - [ ] View detailed log of device activity (based on event log in the backend)
- [ ] Scan process monitor and summary page - [ ] Scan process monitor and summary page
+9 -37
View File
@@ -3,6 +3,7 @@ import 'package:flutter/material.dart';
import '../model/device.dart'; import '../model/device.dart';
import '../utils/friendly_date_formatter.dart'; import '../utils/friendly_date_formatter.dart';
import '../utils/oott_api.dart'; import '../utils/oott_api.dart';
import '../utils/ui_snackbars.dart';
import '../widgets/status_badge.dart'; import '../widgets/status_badge.dart';
const _deviceTypes = [ const _deviceTypes = [
@@ -69,8 +70,7 @@ class _DeviceListState extends State<DeviceList> {
} }
} }
Future<void> _confirmForget(BuildContext context, Device device) async { Future<void> _confirmForget(Device device) async {
final messenger = ScaffoldMessenger.of(context);
final colorScheme = Theme.of(context).colorScheme; final colorScheme = Theme.of(context).colorScheme;
final confirmed = await showDialog<bool>( final confirmed = await showDialog<bool>(
@@ -99,28 +99,15 @@ class _DeviceListState extends State<DeviceList> {
try { try {
await BackendAPI.instance.forgetDevice(device.macAddress); await BackendAPI.instance.forgetDevice(device.macAddress);
if (!mounted) return; if (!mounted) return;
messenger.showSnackBar( UISnackbars.showSuccess(context, 'Device forgotten');
const SnackBar(
content: Text('Device forgotten'),
behavior: SnackBarBehavior.floating,
showCloseIcon: true,
),
);
_loadDevices(); _loadDevices();
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
messenger.showSnackBar( UISnackbars.showError(context, 'Failed to forget device: $e');
SnackBar(
content: Text('Failed to forget device: $e'),
behavior: SnackBarBehavior.floating,
showCloseIcon: true,
),
);
} }
} }
Future<void> _showRegisterDialog(BuildContext context, Device device) async { Future<void> _showRegisterDialog(Device device) async {
final messenger = ScaffoldMessenger.of(context);
final formKey = GlobalKey<FormState>(); final formKey = GlobalKey<FormState>();
String owner = ''; String owner = '';
String deviceType = _deviceTypes.contains(device.deviceType) String deviceType = _deviceTypes.contains(device.deviceType)
@@ -189,23 +176,11 @@ class _DeviceListState extends State<DeviceList> {
deviceType, deviceType,
); );
if (!mounted) return; if (!mounted) return;
messenger.showSnackBar( UISnackbars.showSuccess(context, 'Device registered');
const SnackBar(
content: Text('Device registered'),
behavior: SnackBarBehavior.floating,
showCloseIcon: true,
),
);
_loadDevices(); _loadDevices();
} catch (e) { } catch (e) {
if (!mounted) return; if (!mounted) return;
messenger.showSnackBar( UISnackbars.showError(context, 'Failed to register device: $e');
SnackBar(
content: Text('Failed to register device: $e'),
behavior: SnackBarBehavior.floating,
showCloseIcon: true,
),
);
} }
} }
@@ -335,12 +310,9 @@ class _DeviceListState extends State<DeviceList> {
icon: const Icon(Icons.more_vert), icon: const Icon(Icons.more_vert),
onSelected: (value) async { onSelected: (value) async {
if (value == 'forget') { if (value == 'forget') {
await _confirmForget(context, device); await _confirmForget(device);
} else if (value == 'register') { } else if (value == 'register') {
await _showRegisterDialog( await _showRegisterDialog(device);
context,
device,
);
} }
}, },
itemBuilder: (context) => [ itemBuilder: (context) => [
+88 -117
View File
@@ -6,6 +6,7 @@ import 'package:infinite_scroll_pagination/infinite_scroll_pagination.dart';
import '../model/notification.dart' as oott_model; import '../model/notification.dart' as oott_model;
import '../utils/friendly_date_formatter.dart'; import '../utils/friendly_date_formatter.dart';
import '../utils/oott_api.dart'; import '../utils/oott_api.dart';
import '../utils/ui_snackbars.dart';
class NotificationList extends StatefulWidget { class NotificationList extends StatefulWidget {
const NotificationList({super.key}); const NotificationList({super.key});
@@ -48,13 +49,7 @@ class _NotificationListState extends State<NotificationList> {
await BackendAPI.instance.markAllNotificationsAsRead(); await BackendAPI.instance.markAllNotificationsAsRead();
_pagingController.refresh(); _pagingController.refresh();
if (context.mounted) { if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar( UISnackbars.showSuccess(context, 'All notifications marked as read');
const SnackBar(
content: Text('All notifications marked as read'),
behavior: SnackBarBehavior.floating,
showCloseIcon: true,
),
);
} }
} }
@@ -63,24 +58,15 @@ class _NotificationListState extends State<NotificationList> {
oott_model.Notification item, oott_model.Notification item,
) async { ) async {
if (!item.isNew) { if (!item.isNew) {
ScaffoldMessenger.of(context).showSnackBar( UISnackbars.showWarning(
SnackBar( context,
content: Text('Notification was already marked as read'), 'Notification was already marked as read',
behavior: SnackBarBehavior.floating,
showCloseIcon: true,
),
); );
return false; return false;
} }
await BackendAPI.instance.markNotificationAsRead(item.id); await BackendAPI.instance.markNotificationAsRead(item.id);
if (!context.mounted) return false; if (!context.mounted) return false;
ScaffoldMessenger.of(context).showSnackBar( UISnackbars.showSuccess(context, 'Event marked as read');
SnackBar(
content: Text('Event marked as read'),
behavior: SnackBarBehavior.floating,
showCloseIcon: true,
),
);
if (_filterChoice != 3) { if (_filterChoice != 3) {
_pagingController.value = _pagingController.value.filterItems( _pagingController.value = _pagingController.value.filterItems(
(n) => n.id != item.id, (n) => n.id != item.id,
@@ -98,24 +84,15 @@ class _NotificationListState extends State<NotificationList> {
oott_model.Notification item, oott_model.Notification item,
) async { ) async {
if (item.isNew) { if (item.isNew) {
ScaffoldMessenger.of(context).showSnackBar( UISnackbars.showWarning(
SnackBar( context,
content: Text('Notification was already marked as unread'), 'Notification was already marked as unread',
behavior: SnackBarBehavior.floating,
showCloseIcon: true,
),
); );
return false; return false;
} }
await BackendAPI.instance.markNotificationAsNew(item.id); await BackendAPI.instance.markNotificationAsNew(item.id);
if (!context.mounted) return false; if (!context.mounted) return false;
ScaffoldMessenger.of(context).showSnackBar( UISnackbars.showSuccess(context, 'Event marked as unread');
SnackBar(
content: Text('Event marked as unread'),
behavior: SnackBarBehavior.floating,
showCloseIcon: true,
),
);
if (_filterChoice != 3) { if (_filterChoice != 3) {
_pagingController.value = _pagingController.value.filterItems( _pagingController.value = _pagingController.value.filterItems(
(n) => n.id != item.id, (n) => n.id != item.id,
@@ -183,92 +160,86 @@ class _NotificationListState extends State<NotificationList> {
), ),
), ),
), ),
PagedSliverList<int, oott_model.Notification>( PagedSliverList<int, oott_model.Notification>(
state: state, state: state,
fetchNextPage: fetchNextPage, fetchNextPage: fetchNextPage,
builderDelegate: PagedChildBuilderDelegate( builderDelegate: PagedChildBuilderDelegate(
itemBuilder: (context, item, index) { itemBuilder: (context, item, index) {
return Column( return Column(
children: [ children: [
Dismissible( Dismissible(
key: UniqueKey(), key: UniqueKey(),
confirmDismiss: (direction) => confirmDismiss: (direction) =>
direction == DismissDirection.startToEnd direction == DismissDirection.startToEnd
? _markAsNew(context, item) ? _markAsNew(context, item)
: _markAsRead(context, item), : _markAsRead(context, item),
background: Container( background: Container(
color: Theme.of( color: Theme.of(
context, context,
).colorScheme.tertiaryContainer, ).colorScheme.tertiaryContainer,
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
padding: const EdgeInsets.only(left: 16), padding: const EdgeInsets.only(left: 16),
child: Icon(Icons.mark_email_unread), child: Icon(Icons.mark_email_unread),
), ),
secondaryBackground: Container( secondaryBackground: Container(
color: Theme.of( color: Theme.of(context).colorScheme.primaryContainer,
context, alignment: Alignment.centerRight,
).colorScheme.primaryContainer, padding: const EdgeInsets.only(right: 16),
alignment: Alignment.centerRight, child: Icon(Icons.done),
padding: const EdgeInsets.only(right: 16), ),
child: Icon(Icons.done), child: ListTile(
), tileColor: item.isNew
child: ListTile( ? Theme.of(context).colorScheme.secondaryContainer
tileColor: item.isNew : null,
? Theme.of( leading: Icon(
context, item.notificationType.icon,
).colorScheme.secondaryContainer color: item.isNew
: null, ? Theme.of(context).colorScheme.primary
leading: Icon( : null,
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<String>(
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(), 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<String>(
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 // Paginated list end
} }
+31 -6
View File
@@ -2,17 +2,38 @@ import 'package:flutter/material.dart';
@immutable @immutable
class AppColorExtension extends ThemeExtension<AppColorExtension> { class AppColorExtension extends ThemeExtension<AppColorExtension> {
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 success;
final Color onSuccess; final Color onSuccess;
final Color warning;
final Color onWarning;
final Color info;
final Color onInfo;
@override @override
AppColorExtension copyWith({Color? success, Color? onSuccess}) => AppColorExtension copyWith({
AppColorExtension( Color? success,
success: success ?? this.success, Color? onSuccess,
onSuccess: onSuccess ?? this.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 @override
AppColorExtension lerp(AppColorExtension? other, double t) { AppColorExtension lerp(AppColorExtension? other, double t) {
@@ -20,6 +41,10 @@ class AppColorExtension extends ThemeExtension<AppColorExtension> {
return AppColorExtension( return AppColorExtension(
success: Color.lerp(success, other.success, t)!, success: Color.lerp(success, other.success, t)!,
onSuccess: Color.lerp(onSuccess, other.onSuccess, 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)!,
); );
} }
} }
@@ -84,6 +84,10 @@ final ThemeData catppuccinMochaDarkTheme = ThemeData(
const AppColorExtension( const AppColorExtension(
success: CatppuccinMochaColors.green, success: CatppuccinMochaColors.green,
onSuccess: CatppuccinMochaColors.crust, onSuccess: CatppuccinMochaColors.crust,
warning: CatppuccinMochaColors.peach,
onWarning: CatppuccinMochaColors.crust,
info: CatppuccinMochaColors.blue,
onInfo: CatppuccinMochaColors.crust,
), ),
], ],
); );
+4
View File
@@ -81,6 +81,10 @@ final ThemeData gruvboxDarkTheme = ThemeData(
const AppColorExtension( const AppColorExtension(
success: GruvboxColors.brightGreen, success: GruvboxColors.brightGreen,
onSuccess: GruvboxColors.bgHard, onSuccess: GruvboxColors.bgHard,
warning: GruvboxColors.brightYellow,
onWarning: GruvboxColors.bgHard,
info: GruvboxColors.brightBlue,
onInfo: GruvboxColors.bgHard,
), ),
], ],
); );
+26 -17
View File
@@ -3,30 +3,39 @@ import '../theme/app_colors.dart';
class UISnackbars { class UISnackbars {
static void showError(BuildContext context, String message) { static void showError(BuildContext context, String message) {
ScaffoldMessenger.of(context).showSnackBar( final colorScheme = Theme.of(context).colorScheme;
SnackBar( _show(context, message, colorScheme.error, colorScheme.onError);
content: Text(
message,
style: TextStyle(color: Theme.of(context).colorScheme.onError),
),
behavior: SnackBarBehavior.floating,
showCloseIcon: true,
backgroundColor: Theme.of(context).colorScheme.error,
),
);
} }
static void showSuccess(BuildContext context, String message) { static void showSuccess(BuildContext context, String message) {
final appColors = Theme.of(context).extension<AppColorExtension>()!; final appColors = Theme.of(context).extension<AppColorExtension>()!;
ScaffoldMessenger.of(context).showSnackBar( _show(context, message, appColors.success, appColors.onSuccess);
}
static void showWarning(BuildContext context, String message) {
final appColors = Theme.of(context).extension<AppColorExtension>()!;
_show(context, message, appColors.warning, appColors.onWarning);
}
static void showInfo(BuildContext context, String message) {
final appColors = Theme.of(context).extension<AppColorExtension>()!;
_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( SnackBar(
content: Text( content: Text(message, style: TextStyle(color: foreground)),
message,
style: TextStyle(color: appColors.onSuccess),
),
behavior: SnackBarBehavior.floating, behavior: SnackBarBehavior.floating,
showCloseIcon: true, showCloseIcon: true,
backgroundColor: appColors.success, backgroundColor: background,
), ),
); );
} }