mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
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:
co-authored by
Claude Opus 4.8
parent
cf8979f63d
commit
a51b9c06dc
@@ -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 +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
@@ -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]
|
||||
|
||||
@@ -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')),
|
||||
],
|
||||
);
|
||||
},
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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: () {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 [
|
||||
|
||||
@@ -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!));
|
||||
}
|
||||
},
|
||||
),
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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),
|
||||
],
|
||||
);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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,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(
|
||||
|
||||
@@ -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,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'));
|
||||
});
|
||||
|
||||
@@ -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>(
|
||||
|
||||
Reference in New Issue
Block a user