Files
oott/frontend/lib/devices/device_list_rows.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

485 lines
14 KiB
Dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../model/device.dart';
import '../model/device_type.dart';
import '../theme/app_colors.dart';
import '../utils/friendly_date_formatter.dart';
import '../widgets/status_badge.dart';
import 'device_actions.dart';
import 'device_list_sort.dart';
import '../theme/dimens.dart';
import '../utils/placeholders.dart';
import '../routes.dart';
// Column layout shared between the header and data rows so cells line up.
const double _iconWidth = 40;
const double _trailingWidth = 48;
const Duration _onlineThreshold = Duration(minutes: 10);
class _ColumnSpec {
final DeviceSortColumn column;
final int flex;
const _ColumnSpec(this.column, this.flex);
}
const List<_ColumnSpec> _columnSpecs = [
_ColumnSpec(DeviceSortColumn.name, 3),
_ColumnSpec(DeviceSortColumn.owner, 2),
_ColumnSpec(DeviceSortColumn.macAddress, 3),
_ColumnSpec(DeviceSortColumn.ipAddress, 2),
_ColumnSpec(DeviceSortColumn.lastSeen, 3),
_ColumnSpec(DeviceSortColumn.vendor, 2),
];
String _displayName(Device device) {
if (device.name != null && device.name!.isNotEmpty) return device.name!;
if (device.isRegistered && device.owner.isNotEmpty) {
final type = device.deviceType == DeviceType.unknown
? 'Device'
: device.deviceType.label;
return "${device.owner}'s $type";
}
return Placeholders.emptyValue;
}
class DeviceListHeaderDelegate extends SliverPersistentHeaderDelegate {
final DeviceSortColumn sortColumn;
final bool ascending;
final void Function(DeviceSortColumn column) onTap;
DeviceListHeaderDelegate({
required this.sortColumn,
required this.ascending,
required this.onTap,
});
@override
double get minExtent => 48;
@override
double get maxExtent => 48;
@override
Widget build(
BuildContext context,
double shrinkOffset,
bool overlapsContent,
) {
return Material(
color: Theme.of(context).colorScheme.surface,
child: Column(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
_IconHeaderCell(
active: sortColumn,
ascending: ascending,
onTap: onTap,
),
for (final spec in _columnSpecs)
_HeaderCell(
flex: spec.flex,
column: spec.column,
active: sortColumn,
ascending: ascending,
onTap: onTap,
),
const SizedBox(width: _trailingWidth),
],
),
),
),
const Divider(height: 1),
],
),
);
}
@override
bool shouldRebuild(covariant DeviceListHeaderDelegate oldDelegate) {
return oldDelegate.sortColumn != sortColumn ||
oldDelegate.ascending != ascending;
}
}
class _HeaderCell extends StatelessWidget {
final int flex;
final DeviceSortColumn column;
final DeviceSortColumn active;
final bool ascending;
final void Function(DeviceSortColumn column) onTap;
const _HeaderCell({
required this.flex,
required this.column,
required this.active,
required this.ascending,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isActive = column == active;
final color = isActive
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant;
return Expanded(
flex: flex,
child: InkWell(
onTap: () => onTap(column),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: Insets.sm,
vertical: Insets.md,
),
child: Row(
children: [
Flexible(
child: Text(
column.label,
style: theme.textTheme.labelLarge?.copyWith(color: color),
overflow: TextOverflow.ellipsis,
),
),
if (isActive) ...[
const SizedBox(width: Insets.xs),
Icon(
ascending ? Icons.arrow_upward : Icons.arrow_downward,
size: 16,
color: color,
),
],
],
),
),
),
);
}
}
// The leading device-type column shows only an icon per row, so its header is a
// compact icon button that sorts by device type rather than a labelled cell.
class _IconHeaderCell extends StatelessWidget {
final DeviceSortColumn active;
final bool ascending;
final void Function(DeviceSortColumn column) onTap;
const _IconHeaderCell({
required this.active,
required this.ascending,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isActive = active == DeviceSortColumn.deviceType;
final color = isActive
? theme.colorScheme.primary
: theme.colorScheme.onSurfaceVariant;
return SizedBox(
width: _iconWidth,
child: InkWell(
onTap: () => onTap(DeviceSortColumn.deviceType),
child: Tooltip(
message: 'Sort by device type',
child: Padding(
padding: const EdgeInsets.symmetric(vertical: Insets.md),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.devices, size: 18, color: color),
if (isActive)
Icon(
ascending ? Icons.arrow_upward : Icons.arrow_downward,
size: 14,
color: color,
),
],
),
),
),
),
);
}
}
class DeviceRowWide extends StatelessWidget {
final Device device;
final FriendlyDateFormatter formatter;
final VoidCallback onRefresh;
const DeviceRowWide({
super.key,
required this.device,
required this.formatter,
required this.onRefresh,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Material(
color: device.isRegistered ? null : theme.colorScheme.secondaryContainer,
child: InkWell(
onTap: () => context.push(Routes.deviceDetail(device.macAddress)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
children: [
SizedBox(
width: _iconWidth,
child: Tooltip(
message: device.deviceType == DeviceType.unknown
? 'Device type unknown'
: device.deviceType.label,
child: Icon(device.deviceType.icon),
),
),
for (final spec in _columnSpecs)
Expanded(
flex: spec.flex,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 8,
vertical: 12,
),
child: _cellContent(theme, spec.column, device, formatter),
),
),
SizedBox(
width: _trailingWidth,
child: _DeviceActionsMenu(device: device, onRefresh: onRefresh),
),
],
),
),
),
);
}
}
Widget _cellContent(
ThemeData theme,
DeviceSortColumn column,
Device device,
FriendlyDateFormatter formatter,
) {
switch (column) {
case DeviceSortColumn.deviceType:
// The device type is shown via the leading icon column, never a text cell.
return const SizedBox.shrink();
case DeviceSortColumn.name:
return _OverflowTooltipText(
text: _displayName(device),
style: theme.textTheme.bodyMedium,
);
case DeviceSortColumn.owner:
if (!device.isRegistered) {
return const Align(
alignment: Alignment.centerLeft,
child: StatusBadge(
label: 'Not registered',
color: BadgeColor.secondary,
),
);
}
return _OverflowTooltipText(
text: device.owner.isEmpty ? Placeholders.emptyValue : device.owner,
style: theme.textTheme.bodyMedium,
);
case DeviceSortColumn.macAddress:
return _OverflowTooltipText(
text: device.macAddress,
style: theme.textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
);
case DeviceSortColumn.ipAddress:
return _OverflowTooltipText(
text: device.ipv4Address,
style: theme.textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'),
);
case DeviceSortColumn.lastSeen:
return Row(
children: [
_StatusDot(lastSeen: device.lastSeen),
const SizedBox(width: 6),
Flexible(
child: _OverflowTooltipText(
text: formatter.format(device.lastSeen),
style: theme.textTheme.bodyMedium,
),
),
],
);
case DeviceSortColumn.vendor:
return _OverflowTooltipText(
text: device.vendor.isEmpty ? Placeholders.emptyValue : device.vendor,
style: theme.textTheme.bodyMedium,
);
}
}
class _OverflowTooltipText extends StatelessWidget {
final String text;
final TextStyle? style;
const _OverflowTooltipText({required this.text, this.style});
@override
Widget build(BuildContext context) {
final effectiveStyle = DefaultTextStyle.of(context).style.merge(style);
return LayoutBuilder(
builder: (context, constraints) {
final painter = TextPainter(
text: TextSpan(text: text, style: effectiveStyle),
maxLines: 1,
textDirection: Directionality.of(context),
textScaler: MediaQuery.textScalerOf(context),
)..layout(maxWidth: constraints.maxWidth);
final child = Text(text, overflow: TextOverflow.ellipsis, style: style);
return painter.didExceedMaxLines
? Tooltip(message: text, child: child)
: child;
},
);
}
}
class DeviceRowCompact extends StatelessWidget {
final Device device;
final FriendlyDateFormatter formatter;
final VoidCallback onRefresh;
const DeviceRowCompact({
super.key,
required this.device,
required this.formatter,
required this.onRefresh,
});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Card(
color: device.isRegistered ? null : theme.colorScheme.secondaryContainer,
child: ListTile(
onTap: () => context.push(Routes.deviceDetail(device.macAddress)),
leading: Tooltip(
message: device.deviceType == DeviceType.unknown
? 'Device type unknown'
: device.deviceType.label,
child: Icon(device.deviceType.icon),
),
title: Row(
children: [
Flexible(child: _OverflowTooltipText(text: _displayName(device))),
const SizedBox(width: Insets.sm),
if (device.isRegistered)
const StatusBadge(label: 'Registered', color: BadgeColor.success)
else
const StatusBadge(
label: 'Not registered',
color: BadgeColor.secondary,
),
],
),
subtitle: DefaultTextStyle.merge(
style: theme.textTheme.bodyMedium?.copyWith(
color: theme.colorScheme.onSurfaceVariant,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_OverflowTooltipText(
text:
'${device.isRegistered ? (device.owner.isEmpty ? Placeholders.emptyValue : device.owner) : device.ipv4Address} · ${device.macAddress}',
),
if (!device.isRegistered && device.vendor.isNotEmpty)
_OverflowTooltipText(text: device.vendor),
Row(
children: [
_StatusDot(lastSeen: device.lastSeen),
const SizedBox(width: 6),
Flexible(
child: _OverflowTooltipText(
text: 'Last seen: ${formatter.format(device.lastSeen)}',
),
),
],
),
],
),
),
isThreeLine: true,
trailing: _DeviceActionsMenu(device: device, onRefresh: onRefresh),
),
);
}
}
class _StatusDot extends StatelessWidget {
final DateTime lastSeen;
const _StatusDot({required this.lastSeen});
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final isOnline = DateTime.now().difference(lastSeen) < _onlineThreshold;
final color = isOnline
? theme.extension<AppColorExtension>()!.success
: theme.colorScheme.outline;
return Tooltip(
message: isOnline ? 'Online' : 'Appears offline',
child: Container(
width: 8,
height: 8,
decoration: BoxDecoration(color: color, shape: BoxShape.circle),
),
);
}
}
class _DeviceActionsMenu extends StatelessWidget {
final Device device;
final VoidCallback onRefresh;
const _DeviceActionsMenu({required this.device, required this.onRefresh});
@override
Widget build(BuildContext context) {
return PopupMenuButton<String>(
icon: const Icon(Icons.more_vert),
onSelected: (value) async {
if (value == 'details') {
context.push(Routes.deviceDetail(device.macAddress));
} else if (value == 'edit') {
await showEditDeviceDialog(context, device, onRefresh);
} else if (value == 'forget') {
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) => [
const PopupMenuItem(value: 'details', child: Text('View details')),
if (device.isRegistered)
const PopupMenuItem(value: 'edit', child: Text('Edit')),
if (device.isRegistered)
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')),
],
);
}
}