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>
This commit is contained in:
rzuasti
2026-06-12 09:49:11 -04:00
co-authored by Claude Opus 4.8
parent 2541d0989c
commit 587e097291
20 changed files with 1021 additions and 177 deletions
+136 -13
View File
@@ -6,20 +6,111 @@ import '../utils/oott_api.dart';
import '../utils/ui_snackbars.dart';
import '../theme/dimens.dart';
/// Confirms forgetting (and optionally permanently deleting) a registered
/// device.
///
/// On a successful permanent deletion [onDeleted] is invoked when provided
/// (e.g. to navigate away from a now-stale detail page); otherwise [onRefresh]
/// is used. A plain unregister always calls [onRefresh].
Future<void> confirmForgetDevice(
BuildContext context,
Device device,
VoidCallback onRefresh,
) async {
VoidCallback onRefresh, {
VoidCallback? onDeleted,
}) async {
final colorScheme = Theme.of(context).colorScheme;
bool alsoDelete = false;
final action = await showDialog<bool>(
context: context,
builder: (context) => StatefulBuilder(
builder: (context, setDialogState) => AlertDialog(
title: const Text('Forget Device'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'This device will be unregistered and will no longer be linked '
'to ${device.owner}. Are you sure?',
),
const SizedBox(height: Insets.md),
CheckboxListTile(
value: alsoDelete,
onChanged: (value) =>
setDialogState(() => alsoDelete = value ?? false),
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: const Text('Permanently delete this device'),
),
if (alsoDelete) const _PermanentDeletionWarning(),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
style: TextButton.styleFrom(foregroundColor: colorScheme.error),
onPressed: () => Navigator.of(context).pop(true),
child: Text(alsoDelete ? 'Delete' : 'Forget'),
),
],
),
),
);
if (action != true || !context.mounted) return;
try {
if (alsoDelete) {
await BackendAPI.instance.deleteDevice(device.macAddress);
if (!context.mounted) return;
UISnackbars.showSuccess(context, 'Device deleted');
(onDeleted ?? onRefresh)();
} else {
await BackendAPI.instance.forgetDevice(device.macAddress);
if (!context.mounted) return;
UISnackbars.showSuccess(context, 'Device forgotten');
onRefresh();
}
} catch (e) {
if (!context.mounted) return;
UISnackbars.showError(
context,
alsoDelete
? 'Failed to delete device. ${dioErrorToUserMessage(e)}'
: 'Failed to forget device. ${dioErrorToUserMessage(e)}',
);
}
}
/// Confirms and performs the permanent deletion of a not-registered device,
/// erasing the device record and all of its event history.
///
/// On success [onDeleted] is invoked when provided (e.g. to navigate away from
/// a now-stale detail page); otherwise [onRefresh] is used.
Future<void> confirmDeleteDevice(
BuildContext context,
Device device,
VoidCallback onRefresh, {
VoidCallback? onDeleted,
}) async {
final colorScheme = Theme.of(context).colorScheme;
final confirmed = await showDialog<bool>(
context: context,
builder: (context) => AlertDialog(
title: const Text('Forget Device'),
content: Text(
'This device will be unregistered and will no longer be linked to '
'${device.owner}. Are you sure?',
title: const Text('Delete Device'),
content: const Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Are you sure you want to delete this device?'),
SizedBox(height: Insets.md),
_PermanentDeletionWarning(),
],
),
actions: [
TextButton(
@@ -27,8 +118,9 @@ Future<void> confirmForgetDevice(
child: const Text('Cancel'),
),
TextButton(
style: TextButton.styleFrom(foregroundColor: colorScheme.error),
onPressed: () => Navigator.of(context).pop(true),
child: Text('Forget', style: TextStyle(color: colorScheme.error)),
child: const Text('Delete'),
),
],
),
@@ -37,15 +129,46 @@ Future<void> confirmForgetDevice(
if (confirmed != true || !context.mounted) return;
try {
await BackendAPI.instance.forgetDevice(device.macAddress);
await BackendAPI.instance.deleteDevice(device.macAddress);
if (!context.mounted) return;
UISnackbars.showSuccess(context, 'Device forgotten');
onRefresh();
UISnackbars.showSuccess(context, 'Device deleted');
(onDeleted ?? onRefresh)();
} catch (e) {
if (!context.mounted) return;
UISnackbars.showError(
context,
'Failed to forget device. ${dioErrorToUserMessage(e)}',
'Failed to delete device. ${dioErrorToUserMessage(e)}',
);
}
}
/// Embedded warning shown when a permanent deletion is about to happen.
class _PermanentDeletionWarning extends StatelessWidget {
const _PermanentDeletionWarning();
@override
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
return Container(
padding: const EdgeInsets.all(Insets.md),
decoration: BoxDecoration(
color: colorScheme.errorContainer,
borderRadius: BorderRadius.circular(Insets.sm),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.warning_amber, color: colorScheme.onErrorContainer),
const SizedBox(width: Insets.sm),
Expanded(
child: Text(
'This permanently erases the device and its entire event '
'history. This action cannot be undone.',
style: TextStyle(color: colorScheme.onErrorContainer),
),
),
],
),
);
}
}
@@ -136,7 +259,7 @@ Future<void> showEditDeviceDialog(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(onPressed: save, child: const Text('Save')),
FilledButton(onPressed: save, child: const Text('Save')),
],
);
},
@@ -240,7 +363,7 @@ Future<void> showRegisterDeviceDialog(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(onPressed: save, child: const Text('Save')),
FilledButton(onPressed: save, child: const Text('Register')),
],
);
},
+30 -13
View File
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
import '../model/device.dart';
import '../model/device_type.dart';
import '../routes.dart';
import '../utils/friendly_date_formatter.dart';
import '../utils/oott_api.dart';
import '../widgets/status_badge.dart';
@@ -220,41 +221,57 @@ class _DeviceActions extends StatelessWidget {
Widget build(BuildContext context) {
final colorScheme = Theme.of(context).colorScheme;
final destructiveStyle = TextButton.styleFrom(
foregroundColor: colorScheme.error,
);
if (device.isRegistered) {
return Wrap(
spacing: 12,
runSpacing: 12,
return Row(
children: [
FilledButton.icon(
onPressed: () => showEditDeviceDialog(context, device, onAction),
icon: const Icon(Icons.edit),
label: const Text('Edit'),
),
OutlinedButton.icon(
style: OutlinedButton.styleFrom(
foregroundColor: colorScheme.error,
side: BorderSide(color: colorScheme.error),
const Spacer(),
TextButton.icon(
style: destructiveStyle,
onPressed: () => confirmForgetDevice(
context,
device,
onAction,
onDeleted: () => context.go(Routes.devices),
),
onPressed: () => confirmForgetDevice(context, device, onAction),
icon: const Icon(Icons.link_off),
label: const Text('Forget Device'),
),
],
);
}
return Wrap(
spacing: 12,
runSpacing: 12,
return Row(
children: [
FilledButton.icon(
onPressed: () => showRegisterDeviceDialog(context, device, onAction),
icon: const Icon(Icons.how_to_reg),
label: const Text('Register Device'),
),
OutlinedButton.icon(
const SizedBox(width: Insets.md),
TextButton.icon(
onPressed: () => showDeviceIdentificationDialog(context, device),
icon: const Icon(Icons.help_outline),
label: const Text('How to identify this device'),
label: const Text('How to identify'),
),
const Spacer(),
TextButton.icon(
style: destructiveStyle,
onPressed: () => confirmDeleteDevice(
context,
device,
onAction,
onDeleted: () => context.go(Routes.devices),
),
icon: const Icon(Icons.delete_outline),
label: const Text('Delete'),
),
],
);
@@ -464,6 +464,8 @@ class _DeviceActionsMenu extends StatelessWidget {
await confirmForgetDevice(context, device, onRefresh);
} else if (value == 'register') {
await showRegisterDeviceDialog(context, device, onRefresh);
} else if (value == 'delete') {
await confirmDeleteDevice(context, device, onRefresh);
}
},
itemBuilder: (context) => [
@@ -474,6 +476,8 @@ class _DeviceActionsMenu extends StatelessWidget {
const PopupMenuItem(value: 'forget', child: Text('Forget')),
if (!device.isRegistered)
const PopupMenuItem(value: 'register', child: Text('Register')),
if (!device.isRegistered)
const PopupMenuItem(value: 'delete', child: Text('Delete')),
],
);
}
+4
View File
@@ -6,6 +6,7 @@ import 'package:frontend/theme/catppuccin_mocha_theme.dart';
import 'package:frontend/utils/local_network_permission.dart';
import 'package:frontend/utils/pref_utils.dart';
import 'package:frontend/utils/push_service.dart';
import 'package:frontend/utils/ui_snackbars.dart';
import 'package:provider/provider.dart';
import 'navigation.dart';
import 'theme/gruvbox_theme.dart';
@@ -64,6 +65,9 @@ final class MainApp extends StatelessWidget {
title: 'OOTT',
theme: appState.theme,
routerConfig: router,
// Host snackbars above the router's Navigator so they render on top
// of dialogs (their barrier no longer dims them). See buildSnackbarHost.
builder: buildSnackbarHost,
),
),
);
+131 -120
View File
@@ -246,135 +246,146 @@ class _MainShellState extends State<MainShell> {
@override
Widget build(BuildContext context) {
return LayoutBuilder(
builder: (context, constraints) {
final selectedIndex = _selectedIndex(_destinations, context);
final width = constraints.maxWidth;
// The shell gets its own ScaffoldMessenger so its Scaffold does not also
// render snackbars: those are shown through the top-level messenger in
// main.dart (rootMessengerKey), which paints above dialogs.
return ScaffoldMessenger(
child: LayoutBuilder(
builder: (context, constraints) {
final selectedIndex = _selectedIndex(_destinations, context);
final width = constraints.maxWidth;
if (width < Breakpoints.medium) {
if (width < Breakpoints.medium) {
return Scaffold(
appBar: _buildAppBar(context, selectedIndex),
// The overlay pins the pagination progress bar flush against the
// bottom of the body, i.e. the top of the navigation bar below.
body: PaginationProgressOverlay(
child: Column(
children: [
const OfflineBanner(),
Expanded(
child: Padding(
padding: const EdgeInsets.all(Insets.lg),
child: widget.child,
),
),
],
),
),
bottomNavigationBar: NavigationBar(
selectedIndex: _selectedIndex(_barDestinations, context),
onDestinationSelected: (index) =>
_onDestinationSelected(_barDestinations[index], context),
destinations: _barDestinations
.map(
(d) => NavigationDestination(
icon: Icon(d.icon),
selectedIcon: Icon(d.activeIcon),
label: d.label,
),
)
.toList(),
),
);
}
final mediaPadding = MediaQuery.paddingOf(context);
return Scaffold(
appBar: _buildAppBar(context, selectedIndex),
// The overlay pins the pagination progress bar flush against the
// bottom of the body, i.e. the top of the navigation bar below.
body: PaginationProgressOverlay(
child: Column(
children: [
const OfflineBanner(),
Expanded(
child: Padding(
padding: const EdgeInsets.all(Insets.lg),
child: widget.child,
),
),
],
),
),
bottomNavigationBar: NavigationBar(
selectedIndex: _selectedIndex(_barDestinations, context),
onDestinationSelected: (index) =>
_onDestinationSelected(_barDestinations[index], context),
destinations: _barDestinations
.map(
(d) => NavigationDestination(
icon: Icon(d.icon),
selectedIcon: Icon(d.activeIcon),
label: d.label,
),
)
.toList(),
),
);
}
final mediaPadding = MediaQuery.paddingOf(context);
return Scaffold(
appBar: _buildAppBar(context, selectedIndex),
// The body is a Stack so the collapse/expand toggle can be positioned
// freely within the rail (the rail's own slots give it an unbounded
// width, which prevents reliable horizontal alignment).
body: Stack(
children: [
Row(
children: [
SafeArea(
child: NavigationRail(
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerLow,
minWidth: _kRailCompactWidth,
minExtendedWidth: _kRailExtendedWidth,
extended:
width >= Breakpoints.expanded && _navRailExtended,
destinations: _destinations
.map(
(d) => NavigationRailDestination(
icon: Icon(d.icon),
selectedIcon: Icon(d.activeIcon),
label: Text(d.label),
),
)
.toList(),
selectedIndex: selectedIndex,
onDestinationSelected: (index) =>
_onDestinationSelected(_destinations[index], context),
),
),
Expanded(
child: Container(
color: Theme.of(context).colorScheme.surface,
// The overlay pins the pagination progress bar flush
// against the very bottom of the content region (the
// screen bottom).
child: PaginationProgressOverlay(
child: Column(
children: [
const OfflineBanner(),
Expanded(
child: Padding(
padding: const EdgeInsets.all(Insets.lg),
child: widget.child,
// The body is a Stack so the collapse/expand toggle can be positioned
// freely within the rail (the rail's own slots give it an unbounded
// width, which prevents reliable horizontal alignment).
body: Stack(
children: [
Row(
children: [
SafeArea(
child: NavigationRail(
backgroundColor: Theme.of(
context,
).colorScheme.surfaceContainerLow,
minWidth: _kRailCompactWidth,
minExtendedWidth: _kRailExtendedWidth,
extended:
width >= Breakpoints.expanded && _navRailExtended,
destinations: _destinations
.map(
(d) => NavigationRailDestination(
icon: Icon(d.icon),
selectedIcon: Icon(d.activeIcon),
label: Text(d.label),
),
)
.toList(),
selectedIndex: selectedIndex,
onDestinationSelected: (index) =>
_onDestinationSelected(
_destinations[index],
context,
),
],
),
),
Expanded(
child: Container(
color: Theme.of(context).colorScheme.surface,
// The overlay pins the pagination progress bar flush
// against the very bottom of the content region (the
// screen bottom).
child: PaginationProgressOverlay(
child: Column(
children: [
const OfflineBanner(),
Expanded(
child: Padding(
padding: const EdgeInsets.all(Insets.lg),
child: widget.child,
),
),
],
),
),
),
),
),
],
),
// The collapse/expand toggle, pinned to the bottom of the rail.
// It sits centred like the other icons when the rail is compact
// and slides to the rail's right side when it is extended. The
// double chevron points the way the rail will move: inward («) to
// collapse, outward (») to expand.
if (width >= Breakpoints.expanded)
AnimatedPositioned(
// Match the rail's own extend/collapse animation so the toggle
// slides with the edge instead of jumping after it settles.
duration: kThemeAnimationDuration,
curve: Curves.easeInOut,
bottom: mediaPadding.bottom + Insets.md,
left: _navRailExtended
? mediaPadding.left +
_kRailExtendedWidth -
kMinInteractiveDimension -
Insets.sm
: mediaPadding.left +
(_kRailCompactWidth - kMinInteractiveDimension) / 2,
child: IconButton(
icon: Icon(
_navRailExtended
? Icons.keyboard_double_arrow_left
: Icons.keyboard_double_arrow_right,
),
tooltip: _navRailExtended ? 'Collapse menu' : 'Expand menu',
onPressed: _toggleNavRailExtended,
),
],
),
],
),
);
},
// The collapse/expand toggle, pinned to the bottom of the rail.
// It sits centred like the other icons when the rail is compact
// and slides to the rail's right side when it is extended. The
// double chevron points the way the rail will move: inward («) to
// collapse, outward (») to expand.
if (width >= Breakpoints.expanded)
AnimatedPositioned(
// Match the rail's own extend/collapse animation so the toggle
// slides with the edge instead of jumping after it settles.
duration: kThemeAnimationDuration,
curve: Curves.easeInOut,
bottom: mediaPadding.bottom + Insets.md,
left: _navRailExtended
? mediaPadding.left +
_kRailExtendedWidth -
kMinInteractiveDimension -
Insets.sm
: mediaPadding.left +
(_kRailCompactWidth - kMinInteractiveDimension) /
2,
child: IconButton(
icon: Icon(
_navRailExtended
? Icons.keyboard_double_arrow_left
: Icons.keyboard_double_arrow_right,
),
tooltip: _navRailExtended
? 'Collapse menu'
: 'Expand menu',
onPressed: _toggleNavRailExtended,
),
),
],
),
);
},
),
);
}
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:encrypter/encrypter/xor.dart';
import 'package:flutter/material.dart';
@@ -51,6 +53,12 @@ class _BackendConfigDialogState extends State<_BackendConfigDialog> {
// prefilled config starts unmodified so it can be re-saved without retesting,
// but any edit re-gates Save behind a fresh Test.
bool _connectionModified = false;
// Briefly true after a failed test so the Test button flashes red as a clear,
// glanceable failure cue (the error snackbar can be missed). Cleared by the
// timer below, by editing a field, or by a successful test.
bool _testFailed = false;
Timer? _failFlashTimer;
static const _failFlashDuration = Duration(milliseconds: 1500);
@override
void initState() {
@@ -65,14 +73,17 @@ class _BackendConfigDialogState extends State<_BackendConfigDialog> {
@override
void dispose() {
_failFlashTimer?.cancel();
_baseUrlController.dispose();
_apiKeyController.dispose();
super.dispose();
}
void _onConnectionChanged(String _) {
_failFlashTimer?.cancel();
setState(() {
_testOk = false;
_testFailed = false;
_connectionModified = true;
});
}
@@ -84,13 +95,21 @@ class _BackendConfigDialogState extends State<_BackendConfigDialog> {
_apiKeyController.text,
);
if (!mounted) return;
final ok = result == null;
_failFlashTimer?.cancel();
setState(() {
_testOk = result == null;
if (result == null) _connectionModified = false;
_testOk = ok;
_testFailed = !ok;
if (ok) _connectionModified = false;
});
if (result == null) {
if (ok) {
UISnackbars.showSuccess(context, 'It works!');
} else {
// Revert the red flash after a visible beat; the button returns to its
// default look so the user can retry.
_failFlashTimer = Timer(_failFlashDuration, () {
if (mounted) setState(() => _testFailed = false);
});
UISnackbars.showError(context, result);
}
}
@@ -190,20 +209,32 @@ class _BackendConfigDialogState extends State<_BackendConfigDialog> {
// Test is a neutral utility action, so per Material 3 it sits on the
// left, separated from the dismiss/confirm pair (Cancel, Save) which
// stays grouped at the trailing edge with the confirming action last.
// It is a filled-tonal (medium emphasis) button so Save remains the
// single high-emphasis (filled) action in the dialog; once the test
// succeeds it recolours to the success accent.
actionsAlignment: MainAxisAlignment.spaceBetween,
actions: [
FilledButton.icon(
FilledButton.tonalIcon(
onPressed: _testConnection,
label: const Text('Test'),
icon: Icon(_testOk ? Icons.check : Icons.play_arrow),
style: FilledButton.styleFrom(
backgroundColor: _testOk
? appColors.success
: colorScheme.secondary,
foregroundColor: _testOk
? appColors.onSuccess
: colorScheme.onSecondary,
icon: Icon(
_testOk
? Icons.check
: _testFailed
? Icons.error_outline
: Icons.play_arrow,
),
style: _testOk
? FilledButton.styleFrom(
backgroundColor: appColors.success,
foregroundColor: appColors.onSuccess,
)
: _testFailed
? FilledButton.styleFrom(
backgroundColor: colorScheme.error,
foregroundColor: colorScheme.onError,
)
: null,
),
Row(
mainAxisSize: MainAxisSize.min,
@@ -81,6 +81,11 @@ extension DeviceApi on BackendAPI {
await _dio.delete('/devices/$macAddress');
}
/// Permanently deletes a device and all of its event history. This cannot be undone.
Future<void> deleteDevice(String macAddress) async {
await _dio.delete('/devices/$macAddress/permanently');
}
Future<List<DeviceEvent>> getDeviceEvents(
String macAddress, {
DateTime? createdFrom,
+67 -1
View File
@@ -1,6 +1,70 @@
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 {
@@ -26,13 +90,15 @@ class UISnackbars {
_Severity.info => (colors.info, colors.onInfo),
};
final messenger = ScaffoldMessenger.of(context);
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,
),
);
+9
View File
@@ -149,6 +149,15 @@ void main() {
await BackendAPI.instance.forgetDevice('aa:bb:cc:dd:ee:ff');
});
test('deleteDevice DELETEs the permanently path', () async {
adapter.onDelete(
'/devices/aa:bb:cc:dd:ee:ff/permanently',
(server) => server.reply(200, null),
);
await BackendAPI.instance.deleteDevice('aa:bb:cc:dd:ee:ff');
});
test('getDeviceEvents decodes a list of events', () async {
adapter.onGet(
'/devices/aa:bb:cc:dd:ee:ff/events',
@@ -0,0 +1,170 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/devices/device_list.dart';
import 'package:http_mock_adapter/http_mock_adapter.dart';
import '../helpers/backend_test_harness.dart';
import '../helpers/fixtures.dart';
import '../helpers/pump_app.dart';
void main() {
late DioAdapter adapter;
setUp(() async {
adapter = await setUpBackendForTest();
});
Future<void> openRowMenu(WidgetTester tester) async {
await tester.tap(find.byIcon(Icons.more_vert).first);
await tester.pumpAndSettle();
}
testWidgets(
'deleting a not-registered device from the list calls the delete endpoint',
(tester) async {
adapter.onGet(
'/devices',
(server) => server.reply(
200,
pagedListJson([
deviceJson(macAddress: 'aa:bb:cc:dd:ee:01', isRegistered: false),
]),
),
);
var deleteCalled = false;
adapter.onDelete('/devices/aa:bb:cc:dd:ee:01/permanently', (server) {
deleteCalled = true;
server.reply(200, null);
});
await pumpScreen(tester, const DeviceList());
await pumpUntilFound(tester, find.byIcon(Icons.more_vert));
await openRowMenu(tester);
expect(find.text('Delete'), findsOneWidget);
await tester.tap(find.text('Delete'));
await tester.pumpAndSettle();
// The confirmation dialog warns the deletion is permanent.
expect(find.textContaining('cannot be undone'), findsOneWidget);
await tester.tap(find.widgetWithText(TextButton, 'Delete'));
await tester.pumpAndSettle();
expect(deleteCalled, isTrue);
expect(find.text('Device deleted'), findsOneWidget);
await tearDownTree(tester);
},
);
testWidgets('not-registered devices do not expose Forget', (tester) async {
adapter.onGet(
'/devices',
(server) => server.reply(
200,
pagedListJson([
deviceJson(macAddress: 'aa:bb:cc:dd:ee:02', isRegistered: false),
]),
),
);
await pumpScreen(tester, const DeviceList());
await pumpUntilFound(tester, find.byIcon(Icons.more_vert));
await openRowMenu(tester);
expect(find.text('Forget'), findsNothing);
expect(find.text('Delete'), findsOneWidget);
await tearDownTree(tester);
});
testWidgets(
'forget dialog deletes the device when the also-delete box is ticked',
(tester) async {
adapter.onGet(
'/devices',
(server) => server.reply(
200,
pagedListJson([
deviceJson(
macAddress: 'aa:bb:cc:dd:ee:03',
isRegistered: true,
owner: 'alice',
),
]),
),
);
var deleteCalled = false;
// Only the permanent-delete route is mocked: had the dialog taken the
// plain "forget" path instead, the unmocked DELETE would surface an error
// snackbar rather than the success one asserted below.
adapter.onDelete('/devices/aa:bb:cc:dd:ee:03/permanently', (server) {
deleteCalled = true;
server.reply(200, null);
});
await pumpScreen(tester, const DeviceList());
await pumpUntilFound(tester, find.byIcon(Icons.more_vert));
await openRowMenu(tester);
await tester.tap(find.text('Forget'));
await tester.pumpAndSettle();
// Checkbox is unchecked by default, so no warning is shown yet.
expect(find.textContaining('cannot be undone'), findsNothing);
await tester.tap(find.byType(CheckboxListTile));
await tester.pumpAndSettle();
// Ticking it reveals the permanent-deletion warning.
expect(find.textContaining('cannot be undone'), findsOneWidget);
await tester.tap(find.widgetWithText(TextButton, 'Delete'));
await tester.pumpAndSettle();
expect(deleteCalled, isTrue);
expect(find.text('Device deleted'), findsOneWidget);
await tearDownTree(tester);
},
);
testWidgets('forget dialog unregisters when the box is left unticked', (
tester,
) async {
adapter.onGet(
'/devices',
(server) => server.reply(
200,
pagedListJson([
deviceJson(
macAddress: 'aa:bb:cc:dd:ee:04',
isRegistered: true,
owner: 'bob',
),
]),
),
);
var forgetCalled = false;
adapter.onDelete('/devices/aa:bb:cc:dd:ee:04', (server) {
forgetCalled = true;
server.reply(200, null);
});
await pumpScreen(tester, const DeviceList());
await pumpUntilFound(tester, find.byIcon(Icons.more_vert));
await openRowMenu(tester);
await tester.tap(find.text('Forget'));
await tester.pumpAndSettle();
await tester.tap(find.widgetWithText(TextButton, 'Forget'));
await tester.pumpAndSettle();
expect(forgetCalled, isTrue);
expect(find.text('Device forgotten'), findsOneWidget);
await tearDownTree(tester);
});
}
@@ -0,0 +1,142 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/devices/device_detail.dart';
import 'package:frontend/main.dart';
import 'package:frontend/theme/catppuccin_mocha_theme.dart';
import 'package:go_router/go_router.dart';
import 'package:http_mock_adapter/http_mock_adapter.dart';
import 'package:provider/provider.dart';
import '../helpers/backend_test_harness.dart';
import '../helpers/fixtures.dart';
import '../helpers/pump_app.dart';
void main() {
late DioAdapter adapter;
setUp(() async {
adapter = await setUpBackendForTest();
});
// Pumps the device-detail page inside a minimal router so `context.go` has a
// real GoRouter to navigate with. The `/devices` destination renders a marker
// we can assert against once a deletion navigates away from the detail page.
Future<void> pumpDetail(WidgetTester tester, String mac) async {
tester.view.physicalSize = const Size(900, 1600);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final router = GoRouter(
initialLocation: '/devices/$mac',
routes: [
GoRoute(
path: '/devices',
builder: (_, _) =>
const Scaffold(body: Center(child: Text('DEVICES LIST'))),
),
GoRoute(
path: '/devices/:mac',
builder: (_, state) => Scaffold(
body: DeviceDetail(macAddress: state.pathParameters['mac']!),
),
),
],
);
await tester.pumpWidget(
ChangeNotifierProvider(
create: (_) => AppState(),
child: MaterialApp.router(
theme: catppuccinMochaDarkTheme,
routerConfig: router,
),
),
);
}
testWidgets('deleting a not-registered device navigates to the list', (
tester,
) async {
const mac = 'aa:bb:cc:dd:ee:01';
adapter.onGet(
'/devices/$mac',
(server) =>
server.reply(200, deviceJson(macAddress: mac, isRegistered: false)),
);
adapter.onGet(
'/devices/$mac/events',
(server) => server.reply(200, const []),
);
var deleteCalled = false;
adapter.onDelete('/devices/$mac/permanently', (server) {
deleteCalled = true;
server.reply(200, null);
});
await pumpDetail(tester, mac);
await pumpUntilFound(tester, find.widgetWithText(TextButton, 'Delete'));
await tester.tap(find.widgetWithText(TextButton, 'Delete'));
await tester.pumpAndSettle();
await tester.tap(
find.descendant(
of: find.byType(AlertDialog),
matching: find.widgetWithText(TextButton, 'Delete'),
),
);
await pumpUntilFound(tester, find.text('DEVICES LIST'));
expect(deleteCalled, isTrue);
expect(find.text('DEVICES LIST'), findsOneWidget);
await tearDownTree(tester);
});
testWidgets('forgetting with the delete box ticked navigates to the list', (
tester,
) async {
const mac = 'aa:bb:cc:dd:ee:02';
adapter.onGet(
'/devices/$mac',
(server) => server.reply(
200,
deviceJson(macAddress: mac, isRegistered: true, owner: 'alice'),
),
);
adapter.onGet(
'/devices/$mac/events',
(server) => server.reply(200, const []),
);
var deleteCalled = false;
adapter.onDelete('/devices/$mac/permanently', (server) {
deleteCalled = true;
server.reply(200, null);
});
await pumpDetail(tester, mac);
await pumpUntilFound(
tester,
find.widgetWithText(TextButton, 'Forget Device'),
);
await tester.tap(find.widgetWithText(TextButton, 'Forget Device'));
await tester.pumpAndSettle();
await tester.tap(find.byType(CheckboxListTile));
await tester.pumpAndSettle();
await tester.tap(
find.descendant(
of: find.byType(AlertDialog),
matching: find.widgetWithText(TextButton, 'Delete'),
),
);
await pumpUntilFound(tester, find.text('DEVICES LIST'));
expect(deleteCalled, isTrue);
expect(find.text('DEVICES LIST'), findsOneWidget);
await tearDownTree(tester);
});
}
@@ -3,13 +3,16 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/settings/settings.dart';
import 'package:frontend/utils/pref_utils.dart';
import 'package:http_mock_adapter/http_mock_adapter.dart';
import '../helpers/backend_test_harness.dart';
import '../helpers/pump_app.dart';
void main() {
late DioAdapter adapter;
setUp(() async {
final adapter = await setUpBackendForTest(
adapter = await setUpBackendForTest(
prefs: {
'base_url': 'http://my.server/api',
'api_key': XOR().xorEncode('topsecret'),
@@ -56,6 +59,47 @@ void main() {
expect(find.text('The URL cannot be empty'), findsOneWidget);
});
// Locates the error icon inside the Test button (its failure-flash state).
Finder testButtonErrorIcon() => find.descendant(
of: find.widgetWithText(FilledButton, 'Test'),
matching: find.byIcon(Icons.error_outline),
);
testWidgets('a failed test flashes the Test button red, then reverts', (
tester,
) async {
// The test binding fails outbound HTTP, so the connection test fails
// without any explicit stubbing.
await openDialog(tester);
await tester.tap(find.widgetWithText(FilledButton, 'Test'));
// Let the request resolve and the snackbar's entrance animation run.
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
// The button flashes its failure state...
expect(testButtonErrorIcon(), findsOneWidget);
// ...and the error snackbar is shown *while the dialog is still open*. (In
// the real app it renders above the dialog via the top-level messenger; the
// bare test harness falls back to the page messenger, but co-presence still
// proves the snackbar fires on failure.)
expect(find.text('Backend configuration'), findsOneWidget);
expect(find.byType(SnackBar), findsOneWidget);
// After the flash duration the button reverts to its idle look.
await tester.pump(const Duration(milliseconds: 1600));
expect(testButtonErrorIcon(), findsNothing);
expect(
find.descendant(
of: find.widgetWithText(FilledButton, 'Test'),
matching: find.byIcon(Icons.play_arrow),
),
findsOneWidget,
);
await tearDownTree(tester);
});
testWidgets('editing the connection re-gates Save behind a Test', (
tester,
) async {
@@ -0,0 +1,66 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/theme/catppuccin_mocha_theme.dart';
import 'package:frontend/utils/ui_snackbars.dart';
import '../helpers/pump_app.dart';
void main() {
// Builds an app wired exactly like main.dart: the router/Navigator sits under
// the snackbar host so snackbars render above dialogs.
Future<BuildContext> pumpHostedApp(WidgetTester tester) async {
late BuildContext pageContext;
await tester.pumpWidget(
MaterialApp(
theme: catppuccinMochaDarkTheme,
builder: buildSnackbarHost,
home: Builder(
builder: (context) {
pageContext = context;
return const Scaffold(body: SizedBox.expand());
},
),
),
);
return pageContext;
}
testWidgets('snackbar shows through the host above an open dialog', (
tester,
) async {
final context = await pumpHostedApp(tester);
// Open a dialog so a modal barrier is on screen.
showDialog<void>(
context: context,
builder: (_) => const AlertDialog(title: Text('A dialog')),
);
await tester.pumpAndSettle();
expect(find.text('A dialog'), findsOneWidget);
UISnackbars.showSuccess(context, 'Hello there');
await tester.pumpAndSettle();
expect(find.byType(SnackBar), findsOneWidget);
expect(find.text('Hello there'), findsOneWidget);
await tearDownTree(tester);
});
testWidgets('the snackbar close-icon tooltip does not crash for lack of an '
'overlay', (tester) async {
final context = await pumpHostedApp(tester);
UISnackbars.showError(context, 'Boom');
await tester.pumpAndSettle();
// Long-pressing the close icon shows its tooltip, which needs an Overlay
// ancestor — the previous host had none and threw "No Overlay widget found".
await tester.longPress(find.byIcon(Icons.close));
await tester.pump(const Duration(seconds: 1));
expect(tester.takeException(), isNull);
await tearDownTree(tester);
});
}