Display unknown values as a dash and centralise repeated literals

Backend: notifications now show a plain "-" for an absent name, vendor, or
device type (was empty string / "(unknown)" / "Unknown"), via a single
UNKNOWN_PLACEHOLDER constant.

Frontend:
- Empty/unknown values render as an em dash everywhere, centralised in a new
  Placeholders.emptyValue constant (replaces inline '—' and '(unknown)').
- Route paths moved to a new Routes class, used by the router and every
  navigation call site.
- Device event type modelled as a DeviceEventType enum mirroring the backend
  (NewDevice/DeviceSeen) instead of bare string comparisons.
- Hardcoded EdgeInsets/SizedBox spacing replaced with existing Insets tokens.

Tests and formatting updated; all backend and frontend tests pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-06-06 16:07:05 -04:00
co-authored by Claude Opus 4.8
parent cf8979f63d
commit a51b9c06dc
32 changed files with 295 additions and 180 deletions
+1
View File
@@ -60,3 +60,4 @@ For Flutter/Dart code:
- 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 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.
+2 -8
View File
@@ -2,8 +2,6 @@
- [ ] Add support for push notifications to the app (iOS and Android)
- [ ] Modify the release script to check that gh is logged in and that frontend tests are run before releasing
- [x] README.md - Add section about securing access (reverse proxy, never expose to the internet, etc.)
- [x] README.md - Simplify storage section (remove the calculations - just leave the results)
## Release plan for 0.2.0
- [x] Test Android UI on emulator
@@ -22,18 +20,14 @@
## Backend
- [ ] In notifications, when the vendor is empty put (unknown)
- [x] In the active scanners status, it should count the number of distinct devices it saw on the last scan (avoid duplicates)
- [ ] In notifications, when its a new device(s) found notification, remove the status block (new devices are never registered)
- [x] In notifications, when the vendor is empty put (unknown)
- [ ] Implement the pushover API call directly to support HTML content and review notification text to use it
## Frontend
- [ ] When the user goes to the / URI in a production server redirect him to /web
- [ ] Add a link to the API docs (/api/docs) in the navigation (new window - only visible in wide)
- [x] Review the whole codebase for dead code, duplication and simplicity
- [x] In the devices list, add an order by type (in the wide version it should be over the icon on the left of the list)
- [x] How expensive it is to get the total number of pages and implement a go to last page
- [x] Mobile - Device details - Chart buttons not fully visible, replace with combo only for narrow devices
## Improve engine
+43 -14
View File
@@ -113,14 +113,18 @@ fn enqueue_delivery(title: String, body: String) {
}
}
// Device name for display in messages; falls back to "(unknown)" for devices with no
// Placeholder shown in notifications for a value the scanners could not determine. A plain ASCII
// hyphen (rather than an em dash) avoids encoding issues across notification transports.
const UNKNOWN_PLACEHOLDER: &str = "-";
// Device name for display in messages; falls back to the placeholder for devices with no
// mDNS-discovered hostname (e.g. those found only via ARP).
fn display_name(device: &Device) -> &str {
device
.name
.as_deref()
.filter(|name| !name.is_empty())
.unwrap_or("(unknown)")
.unwrap_or(UNKNOWN_PLACEHOLDER)
}
// Identifier used in notification titles, so a Pushover preview is triageable
@@ -142,14 +146,22 @@ fn title_identity(device: &Device) -> String {
format!("device …{suffix}")
}
fn device_type_or_unknown(device: &Device) -> &str {
fn device_type_or_placeholder(device: &Device) -> &str {
if device.device_type.is_empty() {
"Unknown"
UNKNOWN_PLACEHOLDER
} else {
&device.device_type
}
}
fn vendor_or_placeholder(device: &Device) -> &str {
if device.vendor.is_empty() {
UNKNOWN_PLACEHOLDER
} else {
&device.vendor
}
}
fn registration_line(device: &Device) -> String {
if device.is_registered {
if device.owner.is_empty() {
@@ -181,8 +193,8 @@ fn render_new_device(device: &Device) -> (String, String) {
writeln!(body).unwrap();
writeln!(body, "Device").unwrap();
writeln!(body, " Name: {}", display_name(device)).unwrap();
writeln!(body, " Vendor: {}", device.vendor).unwrap();
writeln!(body, " Type: {}", device_type_or_unknown(device)).unwrap();
writeln!(body, " Vendor: {}", vendor_or_placeholder(device)).unwrap();
writeln!(body, " Type: {}", device_type_or_placeholder(device)).unwrap();
writeln!(body).unwrap();
writeln!(body, "Status").unwrap();
writeln!(body, " {}", registration_line(device)).unwrap();
@@ -211,8 +223,8 @@ fn render_device_back_online(device: &Device, duration_text: &str) -> (String, S
writeln!(body).unwrap();
writeln!(body, "Device").unwrap();
writeln!(body, " Name: {}", display_name(device)).unwrap();
writeln!(body, " Vendor: {}", device.vendor).unwrap();
writeln!(body, " Type: {}", device_type_or_unknown(device)).unwrap();
writeln!(body, " Vendor: {}", vendor_or_placeholder(device)).unwrap();
writeln!(body, " Type: {}", device_type_or_placeholder(device)).unwrap();
writeln!(body).unwrap();
writeln!(body, "Status").unwrap();
writeln!(body, " {}", registration_line(device)).unwrap();
@@ -241,7 +253,7 @@ fn render_device_changed(
writeln!(body).unwrap();
writeln!(body, "Device").unwrap();
writeln!(body, " Name: {}", display_name(new)).unwrap();
writeln!(body, " Type: {}", device_type_or_unknown(new)).unwrap();
writeln!(body, " Type: {}", device_type_or_placeholder(new)).unwrap();
writeln!(body).unwrap();
writeln!(body, "Status").unwrap();
writeln!(body, " {}", registration_line(new)).unwrap();
@@ -642,12 +654,29 @@ mod tests {
}
#[test]
fn device_type_falls_back_to_unknown_when_empty() {
fn device_type_falls_back_to_placeholder_when_empty() {
let mut device = sample_device(None);
assert_eq!(device_type_or_unknown(&device), "Smartphone");
assert_eq!(device_type_or_placeholder(&device), "Smartphone");
device.device_type = "".to_string();
assert_eq!(device_type_or_unknown(&device), "Unknown");
assert_eq!(device_type_or_placeholder(&device), UNKNOWN_PLACEHOLDER);
}
#[test]
fn vendor_falls_back_to_placeholder_when_empty() {
let mut device = sample_device(None);
assert_eq!(vendor_or_placeholder(&device), "Apple, Inc.");
device.vendor = "".to_string();
assert_eq!(vendor_or_placeholder(&device), UNKNOWN_PLACEHOLDER);
}
#[test]
fn new_device_body_uses_placeholder_for_missing_vendor() {
let mut device = sample_device(Some("printer.local"));
device.vendor = "".to_string();
let (_, body) = render_new_device(&device);
assert!(body.contains(&format!("Vendor: {UNKNOWN_PLACEHOLDER}")));
}
#[test]
@@ -667,10 +696,10 @@ mod tests {
}
#[test]
fn new_device_body_uses_unknown_for_missing_name() {
fn new_device_body_uses_placeholder_for_missing_name() {
let device = sample_device(None);
let (_, body) = render_new_device(&device);
assert!(body.contains("Name: (unknown)"));
assert!(body.contains(&format!("Name: {UNKNOWN_PLACEHOLDER}")));
}
#[test]
+10 -15
View File
@@ -4,6 +4,7 @@ import '../model/device.dart';
import '../model/device_type.dart';
import '../utils/oott_api.dart';
import '../utils/ui_snackbars.dart';
import '../theme/dimens.dart';
Future<void> confirmForgetDevice(
BuildContext context,
@@ -88,7 +89,7 @@ Future<void> showEditDeviceDialog(
: null,
onSaved: (value) => owner = value?.trim() ?? '',
),
const SizedBox(height: 16),
const SizedBox(height: Insets.lg),
TextFormField(
initialValue: name,
decoration: const InputDecoration(
@@ -97,7 +98,7 @@ Future<void> showEditDeviceDialog(
onFieldSubmitted: (_) => save(),
onSaved: (value) => name = value?.trim() ?? '',
),
const SizedBox(height: 16),
const SizedBox(height: Insets.lg),
TextFormField(
initialValue: vendor,
decoration: const InputDecoration(
@@ -106,7 +107,7 @@ Future<void> showEditDeviceDialog(
onFieldSubmitted: (_) => save(),
onSaved: (value) => vendor = value?.trim() ?? '',
),
const SizedBox(height: 16),
const SizedBox(height: Insets.lg),
DropdownButtonFormField<DeviceType>(
initialValue: deviceType,
decoration: const InputDecoration(labelText: 'Device Type'),
@@ -117,7 +118,7 @@ Future<void> showEditDeviceDialog(
child: Row(
children: [
Icon(t.icon, size: 16),
const SizedBox(width: 4),
const SizedBox(width: Insets.xs),
Text(t.label),
],
),
@@ -135,10 +136,7 @@ Future<void> showEditDeviceDialog(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: save,
child: const Text('Save'),
),
TextButton(onPressed: save, child: const Text('Save')),
],
);
},
@@ -204,7 +202,7 @@ Future<void> showRegisterDeviceDialog(
: null,
onSaved: (value) => owner = value?.trim() ?? '',
),
const SizedBox(height: 16),
const SizedBox(height: Insets.lg),
TextFormField(
initialValue: name,
decoration: const InputDecoration(
@@ -213,7 +211,7 @@ Future<void> showRegisterDeviceDialog(
onFieldSubmitted: (_) => save(),
onSaved: (value) => name = value?.trim() ?? '',
),
const SizedBox(height: 16),
const SizedBox(height: Insets.lg),
DropdownButtonFormField<DeviceType>(
initialValue: deviceType,
decoration: const InputDecoration(labelText: 'Device Type'),
@@ -224,7 +222,7 @@ Future<void> showRegisterDeviceDialog(
child: Row(
children: [
Icon(t.icon, size: 16),
const SizedBox(width: 4),
const SizedBox(width: Insets.xs),
Text(t.label),
],
),
@@ -242,10 +240,7 @@ Future<void> showRegisterDeviceDialog(
onPressed: () => Navigator.of(context).pop(false),
child: const Text('Cancel'),
),
TextButton(
onPressed: save,
child: const Text('Save'),
),
TextButton(onPressed: save, child: const Text('Save')),
],
);
},
+29 -13
View File
@@ -3,11 +3,14 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../model/device.dart';
import '../model/device_type.dart';
import '../utils/friendly_date_formatter.dart';
import '../utils/oott_api.dart';
import '../widgets/status_badge.dart';
import 'device_actions.dart';
import 'device_event_history.dart';
import '../theme/dimens.dart';
import '../utils/placeholders.dart';
class DeviceDetail extends StatefulWidget {
final String macAddress;
@@ -78,7 +81,7 @@ class _DeviceDetailState extends State<DeviceDetail> {
mainAxisSize: MainAxisSize.min,
children: [
Text('Error: $_error'),
const SizedBox(height: 16),
const SizedBox(height: Insets.lg),
FilledButton(
onPressed: _loadDevice,
child: const Text('Retry'),
@@ -89,18 +92,18 @@ class _DeviceDetailState extends State<DeviceDetail> {
: device == null
? const Center(child: Text('Device not found'))
: SingleChildScrollView(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(Insets.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_DeviceHeader(device: device),
const SizedBox(height: 24),
const SizedBox(height: Insets.xxl),
_DeviceInfoCard(device: device),
const SizedBox(height: 24),
const SizedBox(height: Insets.xxl),
_DeviceActions(device: device, onAction: _loadDevice),
const SizedBox(height: 24),
const SizedBox(height: Insets.xxl),
_SectionHeader(title: 'Event History'),
const SizedBox(height: 12),
const SizedBox(height: Insets.md),
DeviceEventHistory(device: device),
],
),
@@ -137,7 +140,7 @@ class _DeviceHeader extends StatelessWidget {
size: 48,
color: theme.colorScheme.onSurface,
),
const SizedBox(width: 16),
const SizedBox(width: Insets.lg),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -146,7 +149,7 @@ class _DeviceHeader extends StatelessWidget {
device.ipv4Address,
style: theme.textTheme.headlineSmall,
),
const SizedBox(height: 4),
const SizedBox(height: Insets.xs),
if (device.isRegistered)
StatusBadge(label: 'Registered', color: BadgeColor.success)
else
@@ -173,14 +176,24 @@ class _DeviceInfoCard extends StatelessWidget {
final rows = <(String, String)>[
(
'Name',
device.name == null || device.name!.isEmpty ? '' : device.name!,
device.name == null || device.name!.isEmpty
? Placeholders.emptyValue
: device.name!,
),
('MAC Address', device.macAddress),
('IP Address', device.ipv4Address),
('Vendor', device.vendor.isEmpty ? '' : device.vendor),
(
'Vendor',
device.vendor.isEmpty ? Placeholders.emptyValue : device.vendor,
),
('Last Seen', formatter.format(device.lastSeen)),
('Device Type', device.deviceType.label),
('Owner', device.owner.isEmpty ? '' : device.owner),
(
'Device Type',
device.deviceType == DeviceType.unknown
? Placeholders.emptyValue
: device.deviceType.label,
),
('Owner', device.owner.isEmpty ? Placeholders.emptyValue : device.owner),
];
return Card(
@@ -246,7 +259,10 @@ class _InfoRow extends StatelessWidget {
Widget build(BuildContext context) {
final theme = Theme.of(context);
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
padding: const EdgeInsets.symmetric(
horizontal: Insets.lg,
vertical: Insets.md,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
+11 -8
View File
@@ -5,8 +5,11 @@ import 'package:intl/intl.dart';
import '../model/device.dart';
import '../model/device_event.dart';
import '../model/device_event_type.dart';
import '../utils/oott_api.dart';
import '../widgets/filter_selector.dart';
import '../theme/dimens.dart';
import '../utils/placeholders.dart';
enum _TimeRange {
today('Today'),
@@ -112,14 +115,14 @@ class _DeviceEventHistoryState extends State<DeviceEventHistory> {
Widget build(BuildContext context) {
if (_isLoading) {
return const Padding(
padding: EdgeInsets.all(32),
padding: EdgeInsets.all(Insets.xxxl),
child: Center(child: CircularProgressIndicator()),
);
}
if (_error != null) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 16),
padding: const EdgeInsets.symmetric(vertical: Insets.lg),
child: Center(child: Text('Failed to load event history: $_error')),
);
}
@@ -138,10 +141,10 @@ class _DeviceEventHistoryState extends State<DeviceEventHistory> {
_loadEvents();
},
),
const SizedBox(height: 16),
const SizedBox(height: Insets.lg),
if (events.isEmpty)
const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
padding: EdgeInsets.symmetric(vertical: Insets.xxl),
child: Center(child: Text('No events in this time range')),
)
else
@@ -184,7 +187,7 @@ class _EventChart extends StatelessWidget {
final maxX = (nowMs / intervalMs).ceil() * intervalMs;
final spots = events.map((e) {
final isNew = e.eventType == 'NewDevice';
final isNew = e.eventType == DeviceEventType.newDevice;
return ScatterSpot(
e.createdOn.millisecondsSinceEpoch.toDouble(),
1.0,
@@ -254,7 +257,7 @@ class _EventChart extends StatelessWidget {
final event = events[idx];
final dt = event.createdOn.toLocal();
final dateStr = DateFormat('MMM d, yyyy HH:mm').format(dt);
final baseLabel = event.eventType == 'NewDevice'
final baseLabel = event.eventType == DeviceEventType.newDevice
? 'First seen'
: 'Device seen';
final typeLabel = '$baseLabel (${event.scannerLabel})';
@@ -274,10 +277,10 @@ class _EventChart extends StatelessWidget {
}
if (event.vendor != device.vendor) {
final eventVendor = event.vendor.isEmpty
? '(unknown)'
? Placeholders.emptyValue
: event.vendor;
final currentVendor = device.vendor.isEmpty
? '(unknown)'
? Placeholders.emptyValue
: device.vendor;
diffs.add(
TextSpan(
+2 -1
View File
@@ -17,6 +17,7 @@ import '../widgets/skeleton.dart';
import 'device_list_filter.dart';
import 'device_list_rows.dart';
import 'device_list_sort.dart';
import '../routes.dart';
class DeviceList extends StatefulWidget {
const DeviceList({super.key});
@@ -273,7 +274,7 @@ class _DeviceListState extends State<DeviceList>
icon: Icons.devices_other_outlined,
message: _emptyMessage(),
actionLabel: 'Check scanner status',
onAction: () => context.go('/status'),
onAction: () => context.go(Routes.status),
);
}
+5 -4
View File
@@ -1,6 +1,7 @@
import 'package:flutter/material.dart';
import '../model/device_type.dart';
import '../theme/dimens.dart';
enum DeviceFilter {
newDevices('Not registered'),
@@ -42,7 +43,7 @@ class DeviceFilterSheet extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Filters', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 16),
const SizedBox(height: Insets.lg),
TextField(
controller: ownerController,
decoration: const InputDecoration(
@@ -51,7 +52,7 @@ class DeviceFilterSheet extends StatelessWidget {
border: OutlineInputBorder(),
),
),
const SizedBox(height: 16),
const SizedBox(height: Insets.lg),
DropdownButtonFormField<DeviceType?>(
initialValue: typeFilter,
decoration: const InputDecoration(
@@ -66,7 +67,7 @@ class DeviceFilterSheet extends StatelessWidget {
child: Row(
children: [
Icon(t.icon, size: 16),
const SizedBox(width: 4),
const SizedBox(width: Insets.xs),
Text(t.label),
],
),
@@ -75,7 +76,7 @@ class DeviceFilterSheet extends StatelessWidget {
],
onChanged: onTypeChanged,
),
const SizedBox(height: 16),
const SizedBox(height: Insets.lg),
if (hasActiveFilters)
OutlinedButton(
onPressed: () {
+20 -23
View File
@@ -8,6 +8,9 @@ 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;
@@ -37,7 +40,7 @@ String _displayName(Device device) {
: device.deviceType.label;
return "${device.owner}'s $type";
}
return '';
return Placeholders.emptyValue;
}
class DeviceListHeaderDelegate extends SliverPersistentHeaderDelegate {
@@ -130,7 +133,10 @@ class _HeaderCell extends StatelessWidget {
child: InkWell(
onTap: () => onTap(column),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12),
padding: const EdgeInsets.symmetric(
horizontal: Insets.sm,
vertical: Insets.md,
),
child: Row(
children: [
Flexible(
@@ -141,7 +147,7 @@ class _HeaderCell extends StatelessWidget {
),
),
if (isActive) ...[
const SizedBox(width: 4),
const SizedBox(width: Insets.xs),
Icon(
ascending ? Icons.arrow_upward : Icons.arrow_downward,
size: 16,
@@ -183,7 +189,7 @@ class _IconHeaderCell extends StatelessWidget {
child: Tooltip(
message: 'Sort by device type',
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
padding: const EdgeInsets.symmetric(vertical: Insets.md),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@@ -221,7 +227,7 @@ class DeviceRowWide extends StatelessWidget {
return Material(
color: device.isRegistered ? null : theme.colorScheme.secondaryContainer,
child: InkWell(
onTap: () => context.push('/devices/${device.macAddress}'),
onTap: () => context.push(Routes.deviceDetail(device.macAddress)),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8),
child: Row(
@@ -248,10 +254,7 @@ class DeviceRowWide extends StatelessWidget {
),
SizedBox(
width: _trailingWidth,
child: _DeviceActionsMenu(
device: device,
onRefresh: onRefresh,
),
child: _DeviceActionsMenu(device: device, onRefresh: onRefresh),
),
],
),
@@ -287,7 +290,7 @@ Widget _cellContent(
);
}
return _OverflowTooltipText(
text: device.owner.isEmpty ? '' : device.owner,
text: device.owner.isEmpty ? Placeholders.emptyValue : device.owner,
style: theme.textTheme.bodyMedium,
);
case DeviceSortColumn.macAddress:
@@ -315,7 +318,7 @@ Widget _cellContent(
);
case DeviceSortColumn.vendor:
return _OverflowTooltipText(
text: device.vendor.isEmpty ? '' : device.vendor,
text: device.vendor.isEmpty ? Placeholders.emptyValue : device.vendor,
style: theme.textTheme.bodyMedium,
);
}
@@ -338,11 +341,7 @@ class _OverflowTooltipText extends StatelessWidget {
textDirection: Directionality.of(context),
textScaler: MediaQuery.textScalerOf(context),
)..layout(maxWidth: constraints.maxWidth);
final child = Text(
text,
overflow: TextOverflow.ellipsis,
style: style,
);
final child = Text(text, overflow: TextOverflow.ellipsis, style: style);
return painter.didExceedMaxLines
? Tooltip(message: text, child: child)
: child;
@@ -369,7 +368,7 @@ class DeviceRowCompact extends StatelessWidget {
return Card(
color: device.isRegistered ? null : theme.colorScheme.secondaryContainer,
child: ListTile(
onTap: () => context.push('/devices/${device.macAddress}'),
onTap: () => context.push(Routes.deviceDetail(device.macAddress)),
leading: Tooltip(
message: device.deviceType == DeviceType.unknown
? 'Device type unknown'
@@ -378,10 +377,8 @@ class DeviceRowCompact extends StatelessWidget {
),
title: Row(
children: [
Flexible(
child: _OverflowTooltipText(text: _displayName(device)),
),
const SizedBox(width: 8),
Flexible(child: _OverflowTooltipText(text: _displayName(device))),
const SizedBox(width: Insets.sm),
if (device.isRegistered)
const StatusBadge(label: 'Registered', color: BadgeColor.success)
else
@@ -400,7 +397,7 @@ class DeviceRowCompact extends StatelessWidget {
children: [
_OverflowTooltipText(
text:
'${device.isRegistered ? (device.owner.isEmpty ? '' : device.owner) : device.ipv4Address} · ${device.macAddress}',
'${device.isRegistered ? (device.owner.isEmpty ? Placeholders.emptyValue : device.owner) : device.ipv4Address} · ${device.macAddress}',
),
if (!device.isRegistered && device.vendor.isNotEmpty)
_OverflowTooltipText(text: device.vendor),
@@ -460,7 +457,7 @@ class _DeviceActionsMenu extends StatelessWidget {
icon: const Icon(Icons.more_vert),
onSelected: (value) async {
if (value == 'details') {
context.push('/devices/${device.macAddress}');
context.push(Routes.deviceDetail(device.macAddress));
} else if (value == 'edit') {
await showEditDeviceDialog(context, device, onRefresh);
} else if (value == 'forget') {
+3 -2
View File
@@ -1,4 +1,5 @@
import 'package:flutter/material.dart';
import '../theme/dimens.dart';
enum DeviceSortColumn {
deviceType('Device Type', 'device_type'),
@@ -41,7 +42,7 @@ class DeviceSortSheet extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text('Sort by', style: Theme.of(context).textTheme.titleMedium),
const SizedBox(height: 8),
const SizedBox(height: Insets.sm),
RadioGroup<DeviceSortColumn>(
groupValue: currentColumn,
onChanged: (value) {
@@ -61,7 +62,7 @@ class DeviceSortSheet extends StatelessWidget {
],
),
),
const SizedBox(height: 8),
const SizedBox(height: Insets.sm),
Center(
child: SegmentedButton<bool>(
segments: const [
+2 -1
View File
@@ -5,6 +5,7 @@ import '../model/notification.dart' as oott_model;
import '../theme/app_colors.dart';
import '../theme/dimens.dart';
import '../utils/friendly_date_formatter.dart';
import '../routes.dart';
// How long the arrival highlight takes to fade out.
const _flashDuration = Duration(milliseconds: 900);
@@ -67,7 +68,7 @@ class _NotificationCardState extends State<NotificationCard> {
if (left) widget.onRemove?.call(animated: true);
}
if (context.mounted) {
context.push('/devices/${widget.item.macAddress}');
context.push(Routes.deviceDetail(widget.item.macAddress!));
}
},
),
+3 -1
View File
@@ -25,7 +25,9 @@ void main() {
try {
await PrefUtil.init();
} catch (e, stack) {
debugPrint('PrefUtil.init failed, continuing with defaults: $e\n$stack');
debugPrint(
'PrefUtil.init failed, continuing with defaults: $e\n$stack',
);
}
runApp(const MainApp());
+4 -2
View File
@@ -1,8 +1,10 @@
import 'device_event_type.dart';
class DeviceEvent {
final int id;
final String macAddress;
final DateTime createdOn;
final String eventType;
final DeviceEventType eventType;
final String ipv4Address;
final String vendor;
final String scanner;
@@ -22,7 +24,7 @@ class DeviceEvent {
id: json['id'] as int,
macAddress: json['mac_address'] as String,
createdOn: DateTime.parse(json['created_on'] as String),
eventType: json['event_type'] as String,
eventType: DeviceEventType.fromString(json['event_type'] as String),
ipv4Address: json['ipv4_address'] as String,
vendor: json['vendor'] as String,
scanner: json['scanner'] as String,
+18
View File
@@ -0,0 +1,18 @@
/// Type of a recorded device event, mirroring the backend's `DeviceEventType`.
enum DeviceEventType {
newDevice('NewDevice'),
deviceSeen('DeviceSeen');
const DeviceEventType(this.wireValue);
/// Value used by the backend API to represent this event type.
final String wireValue;
/// Parses a backend wire value, falling back to [deviceSeen] for any
/// unrecognised value (the non-special case).
static DeviceEventType fromString(String value) =>
DeviceEventType.values.firstWhere(
(type) => type.wireValue == value,
orElse: () => DeviceEventType.deviceSeen,
);
}
+13 -12
View File
@@ -10,6 +10,7 @@ import 'theme/dimens.dart';
import 'utils/pref_utils.dart';
import 'widgets/offline_banner.dart';
import 'widgets/pagination_progress.dart';
import 'routes.dart';
typedef _NavDest = ({IconData icon, IconData activeIcon, String label});
@@ -40,7 +41,7 @@ final RouteObserver<ModalRoute<void>> routeObserver =
// Routes definitions
final GoRouter router = GoRouter(
initialLocation: '/',
initialLocation: Routes.home,
routes: [
ShellRoute(
observers: [routeObserver],
@@ -49,19 +50,19 @@ final GoRouter router = GoRouter(
},
routes: [
GoRoute(
path: '/',
path: Routes.home,
name: 'home',
builder: (context, state) => const HomeScreen(),
redirect: (context, state) => _redirectToSettings(),
),
GoRoute(
path: '/devices',
path: Routes.devices,
name: 'devices',
builder: (context, state) => const DeviceList(),
redirect: (context, state) => _redirectToSettings(),
routes: [
GoRoute(
path: ':macAddress',
path: Routes.deviceDetailSegment,
name: 'deviceDetail',
builder: (context, state) {
final mac = state.pathParameters['macAddress']!;
@@ -71,18 +72,18 @@ final GoRouter router = GoRouter(
],
),
GoRoute(
path: '/status',
path: Routes.status,
name: 'status',
builder: (context, state) => const StatusScreen(),
redirect: (context, state) => _redirectToSettings(),
),
GoRoute(
path: '/settings',
path: Routes.settings,
name: 'settings',
builder: (context, state) => Settings(),
),
GoRoute(
path: '/about',
path: Routes.about,
name: 'about',
builder: (context, state) => const About(),
),
@@ -310,19 +311,19 @@ int _calculateSelectedIndex(BuildContext context) {
void _onDestinationSelected(int index, BuildContext context) {
switch (index) {
case 0:
context.go('/');
context.go(Routes.home);
break;
case 1:
context.go('/devices');
context.go(Routes.devices);
break;
case 2:
context.go('/status');
context.go(Routes.status);
break;
case 3:
context.go('/settings');
context.go(Routes.settings);
break;
case 4:
context.go('/about');
context.go(Routes.about);
break;
}
}
+15
View File
@@ -0,0 +1,15 @@
/// Centralised route locations so navigation targets are defined once and
/// reused by both the router (see `navigation.dart`) and every call site.
abstract final class Routes {
static const String home = '/';
static const String devices = '/devices';
static const String status = '/status';
static const String settings = '/settings';
static const String about = '/about';
/// Path segment for the device-detail route, nested under [devices].
static const String deviceDetailSegment = ':macAddress';
/// Full location for a single device's detail page.
static String deviceDetail(String macAddress) => '$devices/$macAddress';
}
+7
View File
@@ -0,0 +1,7 @@
/// Shared placeholders for rendering values in the UI.
abstract final class Placeholders {
/// Shown in place of a value that is absent or unknown (e.g. a device with no
/// hostname, vendor, or determined type). An em dash reads better than a blank
/// cell. The backend uses a plain hyphen in notifications for the same purpose.
static const String emptyValue = '';
}
@@ -6,6 +6,8 @@ import '../utils/backend_reachability.dart';
import '../utils/oott_api.dart';
import '../utils/polled_value.dart';
import 'polled_stale_indicator.dart';
import '../theme/dimens.dart';
import '../routes.dart';
class DeviceSummaryCard extends StatefulWidget {
const DeviceSummaryCard({super.key});
@@ -39,9 +41,9 @@ class _DeviceSummaryCardState extends State<DeviceSummaryCard> {
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => context.go('/devices'),
onTap: () => context.go(Routes.devices),
child: Padding(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(Insets.lg),
child: ListenableBuilder(
listenable: Listenable.merge([
_polled,
@@ -64,7 +66,7 @@ class _DeviceSummaryCardState extends State<DeviceSummaryCard> {
],
],
),
const SizedBox(height: 12),
const SizedBox(height: Insets.md),
..._buildBody(context, freshness),
],
);
+2 -4
View File
@@ -71,10 +71,8 @@ class FilterSelector<T> extends StatelessWidget {
onSelected: onSelected,
itemBuilder: (context) => values
.map(
(value) => PopupMenuItem<T>(
value: value,
child: Text(labelOf(value)),
),
(value) =>
PopupMenuItem<T>(value: value, child: Text(labelOf(value))),
)
.toList(),
child: Container(
+8 -3
View File
@@ -2,6 +2,8 @@ import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import '../utils/backend_reachability.dart';
import '../theme/dimens.dart';
import '../routes.dart';
class OfflineBanner extends StatelessWidget {
const OfflineBanner({super.key});
@@ -22,7 +24,10 @@ class OfflineBanner extends StatelessWidget {
return Material(
color: theme.colorScheme.errorContainer,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
padding: const EdgeInsets.symmetric(
horizontal: Insets.lg,
vertical: Insets.sm,
),
child: Row(
children: [
Icon(
@@ -30,7 +35,7 @@ class OfflineBanner extends StatelessWidget {
size: 18,
color: theme.colorScheme.onErrorContainer,
),
const SizedBox(width: 12),
const SizedBox(width: Insets.md),
Expanded(
child: Text(
message,
@@ -49,7 +54,7 @@ class OfflineBanner extends StatelessWidget {
child: const Text('Retry'),
),
TextButton(
onPressed: () => context.go('/settings'),
onPressed: () => context.go(Routes.settings),
style: TextButton.styleFrom(
foregroundColor: theme.colorScheme.onErrorContainer,
),
+4 -4
View File
@@ -31,7 +31,7 @@ class PaginationBar extends StatelessWidget {
// registered and double-taps are blocked; the progress cue itself is drawn
// by the app shell at the bottom of the page body.
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
padding: const EdgeInsets.symmetric(vertical: Insets.sm),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
@@ -40,14 +40,14 @@ class PaginationBar extends StatelessWidget {
icon: const Icon(Icons.first_page),
tooltip: 'First page',
),
const SizedBox(width: 8),
const SizedBox(width: Insets.sm),
IconButton.outlined(
onPressed: canGoBack ? () => onPageChanged(currentPage - 1) : null,
icon: const Icon(Icons.chevron_left),
tooltip: 'Previous page',
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
padding: const EdgeInsets.symmetric(horizontal: Insets.lg),
child: Text(label, style: Theme.of(context).textTheme.bodyMedium),
),
IconButton.outlined(
@@ -57,7 +57,7 @@ class PaginationBar extends StatelessWidget {
icon: const Icon(Icons.chevron_right),
tooltip: 'Next page',
),
const SizedBox(width: 8),
const SizedBox(width: Insets.sm),
IconButton.outlined(
onPressed: canGoForward ? () => onPageChanged(lastPage) : null,
icon: const Icon(Icons.last_page),
@@ -6,6 +6,8 @@ import '../utils/backend_reachability.dart';
import '../utils/periodic_rebuild.dart';
import '../utils/polled_value.dart';
import 'polled_stale_indicator.dart';
import '../theme/dimens.dart';
import '../routes.dart';
typedef ScannerStatus = ({Color color, String label, List<String> sublabels});
@@ -65,7 +67,7 @@ class _ScannerStatusCardState<T> extends State<ScannerStatusCard<T>>
if (freshness == PolledFreshness.initialLoading) {
return const Card(
child: Padding(
padding: EdgeInsets.all(16),
padding: EdgeInsets.all(Insets.lg),
child: Center(child: CircularProgressIndicator()),
),
);
@@ -78,13 +80,13 @@ class _ScannerStatusCardState<T> extends State<ScannerStatusCard<T>>
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => context.go('/status'),
onTap: () => context.go(Routes.status),
child: Padding(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(Insets.lg),
child: Row(
children: [
Icon(Icons.circle, color: status.color, size: 14),
const SizedBox(width: 12),
const SizedBox(width: Insets.md),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
@@ -12,6 +12,8 @@ import '../utils/oott_api.dart';
import '../utils/periodic_rebuild.dart';
import '../utils/polled_value.dart';
import 'polled_stale_indicator.dart';
import '../theme/dimens.dart';
import '../routes.dart';
/// One scanner shown in the combined card: its display name, the polled status,
/// and how to turn the current value into a (colour, one-line text) summary.
@@ -136,7 +138,7 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
if (freshness.values.any((f) => f == PolledFreshness.initialLoading)) {
return const Card(
child: Padding(
padding: EdgeInsets.all(16),
padding: EdgeInsets.all(Insets.lg),
child: Center(child: CircularProgressIndicator()),
),
);
@@ -145,9 +147,9 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
return Card(
clipBehavior: Clip.antiAlias,
child: InkWell(
onTap: () => context.go('/status'),
onTap: () => context.go(Routes.status),
child: Padding(
padding: const EdgeInsets.all(16),
padding: const EdgeInsets.all(Insets.lg),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
@@ -155,9 +157,9 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
'Scanners',
style: Theme.of(context).textTheme.titleLarge,
),
const SizedBox(height: 12),
const SizedBox(height: Insets.md),
for (var i = 0; i < _scanners.length; i++) ...[
if (i > 0) const SizedBox(height: 8),
if (i > 0) const SizedBox(height: Insets.sm),
_scannerRow(
context,
_scanners[i],
@@ -201,7 +203,7 @@ class _ScannersStatusCardState extends State<ScannersStatusCard>
],
),
),
const SizedBox(width: 8),
const SizedBox(width: Insets.sm),
Text(
statusText,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
@@ -6,13 +6,15 @@ void main() {
// only its error-mapping path is testable here: an unresolvable host must
// come back as a non-null, user-facing message rather than throwing. The
// success path is a documented coverage gap.
test('BackendAPI.test returns a user message for an unreachable backend',
() async {
// Loopback port 1 is closed: connection is refused immediately, keeping the
// test hermetic (no DNS, no TLS, no traffic leaving the machine).
final result = await BackendAPI.test('http://127.0.0.1:1/api', '');
test(
'BackendAPI.test returns a user message for an unreachable backend',
() async {
// Loopback port 1 is closed: connection is refused immediately, keeping the
// test hermetic (no DNS, no TLS, no traffic leaving the machine).
final result = await BackendAPI.test('http://127.0.0.1:1/api', '');
expect(result, isNotNull);
expect(result, isNotEmpty);
});
expect(result, isNotNull);
expect(result, isNotEmpty);
},
);
}
+1 -1
View File
@@ -42,7 +42,7 @@ Map<String, dynamic> deviceEventJson({
int id = 1,
String macAddress = 'aa:bb:cc:dd:ee:ff',
String createdOn = '2026-06-01T12:00:00Z',
String eventType = 'seen',
String eventType = 'DeviceSeen',
String ipv4Address = '192.168.0.10',
String vendor = 'Acme Corp',
String scanner = 'Arp',
+6 -6
View File
@@ -6,12 +6,12 @@ void main() {
final options = RequestOptions(path: '/x');
DioException dio(DioExceptionType type, {int? status}) => DioException(
requestOptions: options,
type: type,
response: status == null
? null
: Response(requestOptions: options, statusCode: status),
);
requestOptions: options,
type: type,
response: status == null
? null
: Response(requestOptions: options, statusCode: status),
);
test('maps shape errors', () {
expect(
+12 -2
View File
@@ -1,5 +1,6 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:frontend/model/device_event.dart';
import 'package:frontend/model/device_event_type.dart';
import '../helpers/fixtures.dart';
@@ -10,7 +11,7 @@ void main() {
id: 42,
macAddress: '11:22:33:44:55:66',
createdOn: '2026-05-20T10:30:00Z',
eventType: 'changed',
eventType: 'NewDevice',
ipv4Address: '10.0.0.9',
vendor: 'Initech',
scanner: 'Ssdp',
@@ -20,12 +21,21 @@ void main() {
expect(event.id, 42);
expect(event.macAddress, '11:22:33:44:55:66');
expect(event.createdOn, DateTime.parse('2026-05-20T10:30:00Z'));
expect(event.eventType, 'changed');
expect(event.eventType, DeviceEventType.newDevice);
expect(event.ipv4Address, '10.0.0.9');
expect(event.vendor, 'Initech');
expect(event.scanner, 'Ssdp');
});
test('DeviceEvent.fromJson parses event types and falls back to seen', () {
DeviceEventType parsed(String value) =>
DeviceEvent.fromJson(deviceEventJson(eventType: value)).eventType;
expect(parsed('NewDevice'), DeviceEventType.newDevice);
expect(parsed('DeviceSeen'), DeviceEventType.deviceSeen);
expect(parsed('something_unexpected'), DeviceEventType.deviceSeen);
});
test('scannerLabel humanises known scanner names', () {
expect(_label('Arp'), 'ARP');
expect(_label('Mdns'), 'mDNS');
+4 -1
View File
@@ -4,7 +4,10 @@ import 'package:frontend/model/device_type.dart';
void main() {
test('fromString parses every snake_case API name', () {
expect(DeviceType.fromString('phone'), DeviceType.phone);
expect(DeviceType.fromString('network_appliance'), DeviceType.networkAppliance);
expect(
DeviceType.fromString('network_appliance'),
DeviceType.networkAppliance,
);
expect(DeviceType.fromString('home_security'), DeviceType.homeSecurity);
expect(DeviceType.fromString('home_appliance'), DeviceType.homeAppliance);
expect(DeviceType.fromString('gaming_console'), DeviceType.gamingConsole);
@@ -6,8 +6,14 @@ void main() {
final now = DateTime.now();
test('renders very recent times as "Just now"', () {
expect(formatter.format(now.subtract(const Duration(seconds: 30))), 'Just now');
expect(formatter.format(now.subtract(const Duration(minutes: 2))), 'Just now');
expect(
formatter.format(now.subtract(const Duration(seconds: 30))),
'Just now',
);
expect(
formatter.format(now.subtract(const Duration(minutes: 2))),
'Just now',
);
});
test('renders times under an hour as relative minutes', () {
@@ -19,7 +25,8 @@ void main() {
test('renders earlier-today / late-yesterday times with a clock time', () {
final candidate = now.subtract(const Duration(hours: 2));
final sameDay = candidate.year == now.year &&
final sameDay =
candidate.year == now.year &&
candidate.month == now.month &&
candidate.day == now.day;
@@ -30,8 +37,7 @@ void main() {
});
test('renders a yesterday-noon time as "Yesterday at"', () {
final yesterdayNoon =
DateTime(now.year, now.month, now.day - 1, 12, 0);
final yesterdayNoon = DateTime(now.year, now.month, now.day - 1, 12, 0);
expect(formatter.format(yesterdayNoon), startsWith('Yesterday at'));
});
+23 -21
View File
@@ -32,9 +32,9 @@ void main() {
}
DioException connectionError() => DioException(
requestOptions: RequestOptions(path: '/x'),
type: DioExceptionType.connectionError,
);
requestOptions: RequestOptions(path: '/x'),
type: DioExceptionType.connectionError,
);
test('starts in initialLoading then becomes fresh on success', () async {
final polled = makePolled();
@@ -62,28 +62,30 @@ void main() {
expect(polled.lastErrorMessage, isNotNull);
});
test('a failure after a success is stale, then error past staleErrorAfter',
() async {
final polled = makePolled(staleErrorAfter: const Duration(seconds: 1));
addTearDown(polled.dispose);
test(
'a failure after a success is stale, then error past staleErrorAfter',
() async {
final polled = makePolled(staleErrorAfter: const Duration(seconds: 1));
addTearDown(polled.dispose);
completers[0].complete(1);
await pumpEventQueue();
expect(polled.freshness, PolledFreshness.fresh);
completers[0].complete(1);
await pumpEventQueue();
expect(polled.freshness, PolledFreshness.fresh);
// Force a second load and fail it.
polled.pause();
polled.resume();
await pumpEventQueue();
completers[1].completeError(connectionError());
await pumpEventQueue();
// Force a second load and fail it.
polled.pause();
polled.resume();
await pumpEventQueue();
completers[1].completeError(connectionError());
await pumpEventQueue();
expect(polled.value, 1, reason: 'keeps the last good value');
expect(polled.freshness, PolledFreshness.stale);
expect(polled.value, 1, reason: 'keeps the last good value');
expect(polled.freshness, PolledFreshness.stale);
await Future<void>.delayed(const Duration(milliseconds: 1200));
expect(polled.freshness, PolledFreshness.error);
});
await Future<void>.delayed(const Duration(milliseconds: 1200));
expect(polled.freshness, PolledFreshness.error);
},
);
test('effectiveFreshness downgrades error to stale while offline', () async {
final polled = makePolled(staleErrorAfter: const Duration(milliseconds: 1));
@@ -14,8 +14,9 @@ void main() {
adapter = await setUpBackendForTest();
});
testWidgets('shows a loading spinner before the summary arrives',
(tester) async {
testWidgets('shows a loading spinner before the summary arrives', (
tester,
) async {
adapter.onGet(
'/devices/summary',
(server) => server.reply(200, deviceSummaryJson()),
@@ -60,10 +61,7 @@ void main() {
find.textContaining('Backend error (status 500)'),
);
expect(
find.textContaining('Backend error (status 500)'),
findsOneWidget,
);
expect(find.textContaining('Backend error (status 500)'), findsOneWidget);
await tearDownTree(tester);
});
@@ -14,8 +14,9 @@ void main() {
await setUpBackendForTest();
});
testWidgets('shows a spinner, then the resolved label and sublabels',
(tester) async {
testWidgets('shows a spinner, then the resolved label and sublabels', (
tester,
) async {
await pumpScreen(
tester,
ScannerStatusCard<int>(