mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
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:
co-authored by
Claude Opus 4.8
parent
2541d0989c
commit
587e097291
@@ -11,7 +11,7 @@ It has two major components:
|
||||
|
||||
For Rust code:
|
||||
- Use the standard [Rust style guide](https://doc.rust-lang.org/style-guide/)
|
||||
- Use `rustfmt` to format the code
|
||||
- Use `rustfmt` to format the code. This crate is edition 2024, so always pass `--edition 2024` when invoking `rustfmt` directly (e.g. `rustfmt --edition 2024 <file>`) to avoid spurious import-ordering changes
|
||||
|
||||
For Flutter/Dart code:
|
||||
- Use the standard [Dart style guide](https://dart.dev/effective-dart/style)
|
||||
@@ -58,6 +58,9 @@ For Flutter/Dart code:
|
||||
- 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.
|
||||
- In the frontend, always use colors from the selected theme. Never hard code colors any other way. If a color is needed and it's not covered semantically by the theme, suggest an addition to the theme extension implemented in the project.
|
||||
- In the frontend, prefer built-in Flutter/Material components over custom-built ones. Only build a custom component when it is genuinely a better fit for the requirements — and in that case, present the pros/cons of custom vs. built-in to the human and get confirmation before proceeding.
|
||||
- In the frontend, when building a new screen, widget, or dialog, make it consistent: follow the patterns already used by similar widgets in this project, and follow Material 3 guidance and best practices (e.g. button emphasis hierarchy, action placement, theming).
|
||||
- In the backend Rust code, avoid import aliases ("use ... as ...") unless necessary
|
||||
- Do not create branches by default, commit directly to main (this is a single developer project)
|
||||
- When files are changed by the formatter do not revert them to keep the commit pure, just add them to the current commit.
|
||||
- Never revert a file solely because the formatter reformatted it (even files you did not otherwise touch) — keep the formatter's changes. Only revert if the formatting change actually introduces a bug or other issue.
|
||||
|
||||
@@ -2,7 +2,8 @@
|
||||
|
||||
- [x] Add support for push notifications to the app (iOS and Android)
|
||||
- [x] Remove push notifications plan
|
||||
- [ ] Add the capability to delete a device from the system
|
||||
- [x] Add the capability to delete a device from the system
|
||||
- [ ] Review CLAUDE.md to make it shorter and easier to read for AIs while keeping all its intentions
|
||||
|
||||
## Release plan for 0.2.0
|
||||
- [x] Test Android UI on emulator
|
||||
@@ -15,6 +16,7 @@
|
||||
- [x] Release 0.2.0
|
||||
- [x] Install in test server and test app on iPhone
|
||||
- [ ] Test in-house for 3 days
|
||||
- [ ] Test flake install
|
||||
- [ ] Release 0.2.1 (or as many versions as needed)
|
||||
- [ ] Publish website
|
||||
- [ ] Post on reddit
|
||||
@@ -27,6 +29,8 @@
|
||||
|
||||
## Frontend
|
||||
|
||||
- [ ] Add more themes (Dracula, Nord, Catppuccin Latte, Gruvbox light)
|
||||
- [ ] Remove the license and notice from the UI, just link to the github pages
|
||||
- [ ] In the status screen, change the order of the scanners so that SNMP is second
|
||||
- [ ] On Android, make the font of the icon (phone screen) bigger
|
||||
- [x] In the status screen, the "listening for" property of passive scanners should change to days and months (now its always minutes)
|
||||
|
||||
@@ -406,6 +406,28 @@ pub fn unregister(mac_address: String) -> Result<(), DbError> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Permanently deletes a device and all of its related events. Both deletes run inside a single
|
||||
/// transaction so the device and its history are removed atomically. Returns the number of device
|
||||
/// rows removed (0 when the device did not exist).
|
||||
pub fn delete(mac_address: String) -> Result<usize, DbError> {
|
||||
let mut conn = db::get_db_connection()?;
|
||||
let mac_address = normalize_mac(&mac_address);
|
||||
|
||||
let tx = conn.transaction()?;
|
||||
tx.execute(
|
||||
"DELETE FROM device_events WHERE mac_address=?1",
|
||||
params![mac_address],
|
||||
)?;
|
||||
let deleted = tx.execute(
|
||||
"DELETE FROM devices WHERE mac_address=?1",
|
||||
params![mac_address],
|
||||
)?;
|
||||
tx.commit()?;
|
||||
|
||||
debug!("Deleted device {mac_address} and its events (device rows removed: {deleted})");
|
||||
Ok(deleted)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use chrono::{TimeZone, Utc};
|
||||
@@ -1086,6 +1108,55 @@ mod tests {
|
||||
assert_eq!(device.device_type, "Phone".to_string());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete() {
|
||||
use crate::db::device_events;
|
||||
use crate::model::device_events::{DeviceEvent, DeviceEventScanner, DeviceEventType};
|
||||
|
||||
tests_common::setup().await;
|
||||
|
||||
let last_seen = Utc::now();
|
||||
insert(Device::new(
|
||||
"rr:rr:rr:rr:rr:03".to_string(),
|
||||
"192.168.230.3".to_string(),
|
||||
"Test vendor".to_string(),
|
||||
last_seen,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
device_events::insert(DeviceEvent::new(
|
||||
"rr:rr:rr:rr:rr:03".to_string(),
|
||||
Utc::now(),
|
||||
DeviceEventType::NewDevice,
|
||||
"192.168.230.3".to_string(),
|
||||
"Test vendor".to_string(),
|
||||
DeviceEventScanner::Arp,
|
||||
))
|
||||
.unwrap();
|
||||
|
||||
let deleted = delete("rr:rr:rr:rr:rr:03".to_string()).unwrap();
|
||||
assert_eq!(deleted, 1, "One device row should be removed");
|
||||
|
||||
assert!(
|
||||
read("rr:rr:rr:rr:rr:03".to_string()).is_none(),
|
||||
"Device should no longer exist"
|
||||
);
|
||||
assert!(
|
||||
device_events::list(Some("rr:rr:rr:rr:rr:03".to_string()), None, None, None)
|
||||
.unwrap()
|
||||
.is_empty(),
|
||||
"All device events should be removed"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_delete_unknown_device() {
|
||||
tests_common::setup().await;
|
||||
|
||||
let deleted = delete("rr:rr:rr:rr:rr:99".to_string()).unwrap();
|
||||
assert_eq!(deleted, 0, "Deleting an unknown device removes no rows");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_insert() {
|
||||
tests_common::setup().await;
|
||||
|
||||
@@ -60,6 +60,7 @@ pub mod utils;
|
||||
devices::register,
|
||||
devices::update,
|
||||
devices::unregister,
|
||||
devices::delete,
|
||||
notifications::list,
|
||||
notifications::send_test,
|
||||
notifications::read,
|
||||
@@ -177,6 +178,10 @@ pub async fn serve() -> Result<(), Box<dyn Error>> {
|
||||
.route("/api/devices", put(devices::register))
|
||||
.route("/api/devices/summary", get(devices::summary))
|
||||
.route("/api/devices/{mac_address}", delete(devices::unregister))
|
||||
.route(
|
||||
"/api/devices/{mac_address}/permanently",
|
||||
delete(devices::delete),
|
||||
)
|
||||
.route("/api/devices/{mac_address}", get(devices::read))
|
||||
.route("/api/devices/{mac_address}", put(devices::update))
|
||||
.route(
|
||||
@@ -204,10 +209,7 @@ pub async fn serve() -> Result<(), Box<dyn Error>> {
|
||||
post(notifications::mark_as_new),
|
||||
)
|
||||
.route("/api/push_tokens", put(push_tokens::register))
|
||||
.route(
|
||||
"/api/push_tokens/{token}",
|
||||
delete(push_tokens::unregister),
|
||||
)
|
||||
.route("/api/push_tokens/{token}", delete(push_tokens::unregister))
|
||||
.route_layer(axum::middleware::from_fn(auth))
|
||||
.layer(ServiceBuilder::new().layer(cors_layer))
|
||||
// Send visitors straight to the UI; the bare "/" has no content of its own.
|
||||
|
||||
@@ -280,6 +280,43 @@ pub async fn unregister(Path(mac_address): Path<String>) -> impl IntoResponse {
|
||||
.await
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
delete,
|
||||
path = "/api/devices/{mac_address}/permanently",
|
||||
tag = "devices",
|
||||
params(
|
||||
("mac_address" = String, Path, description = "MAC address of the device"),
|
||||
),
|
||||
responses(
|
||||
(status = 200, description = "Device permanently deleted"),
|
||||
(status = 404, description = "Device not found"),
|
||||
(status = 500, description = "Internal server error"),
|
||||
),
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn delete(Path(mac_address): Path<String>) -> impl IntoResponse {
|
||||
db::run_blocking(move || {
|
||||
if db::devices::read(mac_address.clone()).is_none() {
|
||||
return (
|
||||
axum::http::StatusCode::NOT_FOUND,
|
||||
"Device not found or could not be read",
|
||||
);
|
||||
}
|
||||
|
||||
match db::devices::delete(mac_address) {
|
||||
Ok(_) => (axum::http::StatusCode::OK, "Device permanently deleted"),
|
||||
Err(err) => {
|
||||
error!("Error deleting device from the database: {}", err);
|
||||
(
|
||||
axum::http::StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Error deleting device in the server, check your logs",
|
||||
)
|
||||
}
|
||||
}
|
||||
})
|
||||
.await
|
||||
}
|
||||
|
||||
#[utoipa::path(
|
||||
get,
|
||||
path = "/api/devices/summary",
|
||||
@@ -317,3 +354,35 @@ pub struct UpdateDevicePayload {
|
||||
vendor: String,
|
||||
name: Option<String>,
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::tests_common;
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_removes_existing_device() {
|
||||
tests_common::setup().await;
|
||||
|
||||
// Seed device from tests/database_setup/02-devices.sql
|
||||
let mac = "aa:aa:aa:aa:aa:aa".to_string();
|
||||
assert!(db::devices::read(mac.clone()).is_some());
|
||||
|
||||
let response = delete(Path(mac.clone())).await.into_response();
|
||||
assert_eq!(response.status(), StatusCode::OK);
|
||||
assert!(
|
||||
db::devices::read(mac).is_none(),
|
||||
"Deleted device should be gone"
|
||||
);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn delete_unknown_device_returns_404() {
|
||||
tests_common::setup().await;
|
||||
|
||||
let response = delete(Path("ff:ff:ff:ff:ff:ff".to_string()))
|
||||
.await
|
||||
.into_response();
|
||||
assert_eq!(response.status(), StatusCode::NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,8 +11,9 @@ use serde::Serialize;
|
||||
use utoipa::ToSchema;
|
||||
|
||||
use crate::{
|
||||
db, notifications,
|
||||
db,
|
||||
model::notifications::{Notification, NotificationListResponse},
|
||||
notifications,
|
||||
web_server::utils,
|
||||
};
|
||||
|
||||
|
||||
@@ -20,16 +20,18 @@ use crate::model::push_tokens::PushPlatform;
|
||||
security(("bearer_auth" = []))
|
||||
)]
|
||||
pub async fn register(Json(payload): Json<RegisterPushTokenPayload>) -> impl IntoResponse {
|
||||
db::run_blocking(move || match db::push_tokens::upsert(&payload.token, payload.platform) {
|
||||
Ok(_) => (StatusCode::OK, "Push token registered"),
|
||||
Err(err) => {
|
||||
error!("Error registering push token in the database: {err}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Error registering push token in the server, check your logs",
|
||||
)
|
||||
}
|
||||
})
|
||||
db::run_blocking(
|
||||
move || match db::push_tokens::upsert(&payload.token, payload.platform) {
|
||||
Ok(_) => (StatusCode::OK, "Push token registered"),
|
||||
Err(err) => {
|
||||
error!("Error registering push token in the database: {err}");
|
||||
(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"Error registering push token in the server, check your logs",
|
||||
)
|
||||
}
|
||||
},
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
|
||||
@@ -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')),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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')),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user