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
+9 -37
View File
@@ -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<DeviceList> {
}
}
Future<void> _confirmForget(BuildContext context, Device device) async {
final messenger = ScaffoldMessenger.of(context);
Future<void> _confirmForget(Device device) async {
final colorScheme = Theme.of(context).colorScheme;
final confirmed = await showDialog<bool>(
@@ -99,28 +99,15 @@ class _DeviceListState extends State<DeviceList> {
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<void> _showRegisterDialog(BuildContext context, Device device) async {
final messenger = ScaffoldMessenger.of(context);
Future<void> _showRegisterDialog(Device device) async {
final formKey = GlobalKey<FormState>();
String owner = '';
String deviceType = _deviceTypes.contains(device.deviceType)
@@ -189,23 +176,11 @@ class _DeviceListState extends State<DeviceList> {
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<DeviceList> {
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) => [
+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 '../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<NotificationList> {
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<NotificationList> {
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<NotificationList> {
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<NotificationList> {
),
),
),
PagedSliverList<int, oott_model.Notification>(
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<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,
),
PagedSliverList<int, oott_model.Notification>(
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<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
}
+31 -6
View File
@@ -2,17 +2,38 @@ import 'package:flutter/material.dart';
@immutable
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 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<AppColorExtension> {
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)!,
);
}
}
@@ -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,
),
],
);
+4
View File
@@ -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,
),
],
);
+26 -17
View File
@@ -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<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(
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,
),
);
}