Files
oott/frontend/lib/utils/ui_snackbars.dart
T
rzuastiandClaude Opus 4.8 587e097291 Add permanent device deletion and refine frontend UI/snackbars
Backend:
- Add db::devices::delete to erase a device and its events atomically
- Expose DELETE /api/devices/{mac}/permanently, wired to OpenAPI
- Cover the new db method and endpoint with tests

Frontend:
- Delete action for not-registered devices (detail screen + list row)
  and an opt-in "permanently delete" checkbox in the Forget dialog
- Navigate to the devices list after deleting from the detail screen
- Refine button emphasis to M3: single filled primary, error-colored
  text buttons for destructive actions, Test demoted to filled-tonal
- Flash the backend-config Test button red on a failed connection test
- Render snackbars through a top-level ScaffoldMessenger host so they
  show above dialogs; keep the built-in SnackBar (with an Overlay host)

Docs:
- CLAUDE.md: rustfmt edition 2024, don't revert formatter-only changes,
  prefer built-in Flutter components, follow existing patterns + M3

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-12 09:49:11 -04:00

107 lines
4.1 KiB
Dart

import 'package:flutter/material.dart';
import '../theme/app_colors.dart';
/// Hosts snackbars *above* everything on screen, including dialog barriers.
///
/// A normal `ScaffoldMessenger.of(context)` snackbar is painted by the page
/// `Scaffold`, which sits below a dialog's modal barrier — so it gets dimmed by
/// the scrim while a dialog is open. This key belongs to a `ScaffoldMessenger`
/// wrapping a top-level `Scaffold` that is mounted *above* the router's
/// Navigator (see `main.dart`), so snackbars shown through it render on top of
/// every route. When the key is not mounted (e.g. in widget tests that pump a
/// bare `MaterialApp`) we fall back to the nearest messenger.
final GlobalKey<ScaffoldMessengerState> rootMessengerKey =
GlobalKey<ScaffoldMessengerState>();
/// `MaterialApp.builder` that mounts the app ([child], i.e. the router's
/// Navigator) beneath a top-level `ScaffoldMessenger` + `Scaffold`. Snackbars
/// shown through [rootMessengerKey] are rendered by that host Scaffold, which
/// sits above every route, so they paint on top of dialog barriers instead of
/// being dimmed by the scrim.
///
/// The host is wrapped in an [_OverlayHost] so the Scaffold has an `Overlay`
/// ancestor — the snackbar's close-icon tooltip and floating layout require one,
/// and the host Scaffold would otherwise have none (the Navigator's own Overlay
/// is a descendant). [_OverlayHost] rebuilds its entry as [child] changes, so
/// the live app is never frozen.
Widget buildSnackbarHost(BuildContext context, Widget? child) {
return _OverlayHost(
child: ScaffoldMessenger(
key: rootMessengerKey,
child: Scaffold(
// Purely a snackbar host; the inner shell Scaffold owns layout and
// keyboard-inset handling, so this one must not also resize.
resizeToAvoidBottomInset: false,
body: child ?? const SizedBox.shrink(),
),
),
);
}
/// Provides an [Overlay] ancestor for [child] while keeping it live: a plain
/// `Overlay(initialEntries: ...)` captures its entry once and would freeze the
/// subtree, so we rebuild the single entry whenever [child] updates.
class _OverlayHost extends StatefulWidget {
const _OverlayHost({required this.child});
final Widget child;
@override
State<_OverlayHost> createState() => _OverlayHostState();
}
class _OverlayHostState extends State<_OverlayHost> {
late final OverlayEntry _entry = OverlayEntry(builder: (_) => widget.child);
@override
void didUpdateWidget(_OverlayHost oldWidget) {
super.didUpdateWidget(oldWidget);
// Pull the latest [child] into the (otherwise static) overlay entry.
_entry.markNeedsBuild();
}
@override
Widget build(BuildContext context) => Overlay(initialEntries: [_entry]);
}
enum _Severity { error, success, warning, info }
class UISnackbars {
static void showError(BuildContext context, String message) =>
_show(context, message, _Severity.error);
static void showSuccess(BuildContext context, String message) =>
_show(context, message, _Severity.success);
static void showWarning(BuildContext context, String message) =>
_show(context, message, _Severity.warning);
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<AppColorExtension>()!;
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),
};
final messenger =
rootMessengerKey.currentState ?? ScaffoldMessenger.of(context);
messenger.clearSnackBars();
messenger.showSnackBar(
SnackBar(
content: Text(message, style: TextStyle(color: foreground)),
behavior: SnackBarBehavior.floating,
showCloseIcon: true,
closeIconColor: foreground,
backgroundColor: background,
),
);
}
}