From 3a579f0321db4c81135e20bc92d68d4c5d98fc03 Mon Sep 17 00:00:00 2001 From: rzuasti Date: Sat, 30 May 2026 11:09:55 -0400 Subject: [PATCH] Redesign devices list as a sortable, responsive headered list GET /api/devices accepts sort_by/sort_order (whitelisted columns; default last_seen DESC, stable secondary sort on mac_address), device registration captures an optional hostname, and the Flutter list switches between a wide headered layout and a compact ListTile under 600px. Per-row overflow menu retains View details / Register / Forget actions. Co-Authored-By: Claude Opus 4.7 --- backend/src/db/devices.rs | 215 ++++++++- backend/src/web_server/devices.rs | 14 +- frontend/lib/devices/device_actions.dart | 11 + frontend/lib/devices/device_detail.dart | 1 + frontend/lib/devices/device_list.dart | 435 +++++++------------ frontend/lib/devices/device_list_filter.dart | 91 ++++ frontend/lib/devices/device_list_rows.dart | 351 +++++++++++++++ frontend/lib/devices/device_list_sort.dart | 89 ++++ frontend/lib/model/device.dart | 5 +- frontend/lib/utils/oott_api.dart | 12 +- 10 files changed, 939 insertions(+), 285 deletions(-) create mode 100644 frontend/lib/devices/device_list_filter.dart create mode 100644 frontend/lib/devices/device_list_rows.dart create mode 100644 frontend/lib/devices/device_list_sort.dart diff --git a/backend/src/db/devices.rs b/backend/src/db/devices.rs index f3de2ae..7cffe2c 100644 --- a/backend/src/db/devices.rs +++ b/backend/src/db/devices.rs @@ -7,6 +7,28 @@ use crate::{ model::devices::{Device, DeviceSummary}, }; +// Whitelist of columns allowed for `sort_by`. Anything outside this list falls back to the +// default. Kept here so the API handler doesn't have to know about SQL column names. +fn resolve_sort_column(sort_by: Option<&str>) -> &'static str { + match sort_by.map(|s| s.to_ascii_lowercase()).as_deref() { + Some("name") => "name", + Some("owner") => "owner", + Some("mac_address") => "mac_address", + Some("ipv4_address") => "ipv4_address", + Some("vendor") => "vendor", + Some("is_registered") => "is_registered", + Some("device_type") => "device_type", + _ => "last_seen", + } +} + +fn resolve_sort_direction(sort_order: Option<&str>) -> &'static str { + match sort_order.map(|s| s.to_ascii_lowercase()).as_deref() { + Some("asc") => "ASC", + _ => "DESC", + } +} + #[allow(clippy::too_many_arguments)] pub fn list_devices( is_registered: Option, @@ -15,6 +37,8 @@ pub fn list_devices( owner: Option, device_type: Option, vendor: Option, + sort_by: Option, + sort_order: Option, page_offset: Option, page_limit: Option, ) -> Result, DbError> { @@ -55,8 +79,15 @@ pub fn list_devices( params.push(vendor.into()); } - // List order - sql_statement.push_str("ORDER BY last_seen DESC "); + // List order — both column and direction are validated against a whitelist so user input + // is never interpolated raw. A secondary `mac_address ASC` keeps paging deterministic when + // the primary sort key ties. + let sort_column = resolve_sort_column(sort_by.as_deref()); + let sort_direction = resolve_sort_direction(sort_order.as_deref()); + sql_statement.push_str(&format!( + "ORDER BY {} {}, mac_address ASC ", + sort_column, sort_direction + )); // Paging if let (Some(page_offset), Some(page_limit)) = (page_offset, page_limit) { @@ -262,13 +293,26 @@ pub fn update( } } -pub fn register(mac_address: String, owner: String, device_type: String) -> Result<(), DbError> { +pub fn register( + mac_address: String, + owner: String, + device_type: String, + name: Option, +) -> Result<(), DbError> { let conn = db::get_db_connection(); - match conn.execute( - "UPDATE devices SET is_registered=1, owner=?1, device_type=?2 WHERE mac_address=?3", - params![owner, device_type, mac_address], - ) { + // Only write the name column when supplied, so a user registering without typing a name + // never wipes a hostname previously stored by the mDNS scanner. + let mut sql = "UPDATE devices SET is_registered=1, owner=?, device_type=?".to_string(); + let mut params: Vec = vec![owner.into(), device_type.into()]; + if let Some(name) = name { + sql.push_str(", name=?"); + params.push(name.into()); + } + sql.push_str(" WHERE mac_address=?"); + params.push(mac_address.clone().into()); + + match conn.execute(sql.as_str(), params_from_iter(params.iter())) { Ok(_) => { debug!("Device registered in database: {mac_address}"); Ok(()) @@ -311,7 +355,7 @@ mod tests { // List all devices let devices: Vec = - list_devices(None, None, None, None, None, None, None, None).unwrap(); + list_devices(None, None, None, None, None, None, None, None, None, None).unwrap(); assert!(devices.len() >= 3, "There should be at least 3 devices"); // Validate 1 device data @@ -335,7 +379,8 @@ mod tests { // List registered devices let devices: Vec = - list_devices(Some(true), None, None, None, None, None, None, None).unwrap(); + list_devices(Some(true), None, None, None, None, None, None, None, None, None) + .unwrap(); assert!( devices.len() >= 2, @@ -386,6 +431,8 @@ mod tests { None, None, None, + None, + None, ) .unwrap(); @@ -416,6 +463,8 @@ mod tests { None, None, None, + None, + None, ) .unwrap(); @@ -428,18 +477,125 @@ mod tests { ); } + #[tokio::test] + async fn test_list_sorting() { + tests_common::setup().await; + + // Sort by mac_address ascending — first result should have the smallest MAC. + let by_mac_asc = list_devices( + None, + None, + None, + None, + None, + None, + Some("mac_address".to_string()), + Some("asc".to_string()), + None, + None, + ) + .unwrap(); + assert!(by_mac_asc.len() >= 3); + let macs_asc: Vec<&str> = by_mac_asc.iter().map(|d| d.mac_address.as_str()).collect(); + let mut sorted_macs = macs_asc.clone(); + sorted_macs.sort(); + assert_eq!(macs_asc, sorted_macs, "mac_address asc should be sorted"); + + // Sort by mac_address descending — same list, reversed. + let by_mac_desc = list_devices( + None, + None, + None, + None, + None, + None, + Some("mac_address".to_string()), + Some("desc".to_string()), + None, + None, + ) + .unwrap(); + let macs_desc: Vec<&str> = by_mac_desc.iter().map(|d| d.mac_address.as_str()).collect(); + let mut sorted_macs_desc = macs_desc.clone(); + sorted_macs_desc.sort_by(|a, b| b.cmp(a)); + assert_eq!(macs_desc, sorted_macs_desc, "mac_address desc should be sorted reversed"); + + // Sort by owner ascending — empty-string owners (unregistered) come first lexically. + let by_owner_asc = list_devices( + None, + None, + None, + None, + None, + None, + Some("owner".to_string()), + Some("asc".to_string()), + None, + None, + ) + .unwrap(); + let owners: Vec<&str> = by_owner_asc.iter().map(|d| d.owner.as_str()).collect(); + let mut sorted_owners = owners.clone(); + sorted_owners.sort(); + assert_eq!(owners, sorted_owners, "owner asc should be sorted"); + + // Invalid sort_by falls back to default (last_seen DESC). + let invalid = list_devices( + None, + None, + None, + None, + None, + None, + Some("drop_table".to_string()), + None, + None, + None, + ) + .unwrap(); + let default_sorted = list_devices(None, None, None, None, None, None, None, None, None, None).unwrap(); + let invalid_macs: Vec<&str> = invalid.iter().map(|d| d.mac_address.as_str()).collect(); + let default_macs: Vec<&str> = default_sorted.iter().map(|d| d.mac_address.as_str()).collect(); + assert_eq!( + invalid_macs, default_macs, + "invalid sort_by must fall back to the default ordering" + ); + } + #[tokio::test] async fn test_list_pagination() { tests_common::setup().await; // First page with 2 devices - let first_page = - list_devices(None, None, None, None, None, None, Some(0), Some(2)).unwrap(); + let first_page = list_devices( + None, + None, + None, + None, + None, + None, + None, + None, + Some(0), + Some(2), + ) + .unwrap(); assert_eq!(first_page.len(), 2, "First page should have 2 devices"); // Second page with 2 devices, should have at least 1 (seed data has >= 3 devices) - let second_page = - list_devices(None, None, None, None, None, None, Some(2), Some(2)).unwrap(); + let second_page = list_devices( + None, + None, + None, + None, + None, + None, + None, + None, + Some(2), + Some(2), + ) + .unwrap(); assert!( !second_page.is_empty(), "Second page should have at least 1 device" @@ -475,6 +631,7 @@ mod tests { "uu:tt:tt:tt:tt:aa".to_string(), "Grace".to_string(), "Phone".to_string(), + None, ) .unwrap(); @@ -643,6 +800,7 @@ mod tests { "pp:pp:pp:pp:pp:01".to_string(), "Grace".to_string(), "Laptop".to_string(), + None, ) .unwrap(); @@ -716,6 +874,7 @@ mod tests { "rr:rr:rr:rr:rr:01".to_string(), "Grace".to_string(), "Phone".to_string(), + None, ) .unwrap(); @@ -724,6 +883,35 @@ mod tests { assert_eq!(device.owner, "Grace".to_string()); assert_eq!(device.device_type, "Phone".to_string()); assert_eq!(device.vendor, "".to_string()); + + // Registering with Some(name) persists the supplied hostname. + insert(Device::new( + "rr:rr:rr:rr:rr:nm".to_string(), + "192.168.230.5".to_string(), + "".to_string(), + Utc::now(), + )) + .unwrap(); + register( + "rr:rr:rr:rr:rr:nm".to_string(), + "Henry".to_string(), + "Laptop".to_string(), + Some("kitchen-laptop".to_string()), + ) + .unwrap(); + let device = read("rr:rr:rr:rr:rr:nm".to_string()).unwrap(); + assert_eq!(device.name, Some("kitchen-laptop".to_string())); + + // Re-registering with None must NOT clobber the previously stored name. + register( + "rr:rr:rr:rr:rr:nm".to_string(), + "Henry".to_string(), + "Laptop".to_string(), + None, + ) + .unwrap(); + let device = read("rr:rr:rr:rr:rr:nm".to_string()).unwrap(); + assert_eq!(device.name, Some("kitchen-laptop".to_string())); } #[tokio::test] @@ -743,6 +931,7 @@ mod tests { "rr:rr:rr:rr:rr:02".to_string(), "Grace".to_string(), "Phone".to_string(), + None, ) .unwrap(); diff --git a/backend/src/web_server/devices.rs b/backend/src/web_server/devices.rs index 69833cc..30583b8 100644 --- a/backend/src/web_server/devices.rs +++ b/backend/src/web_server/devices.rs @@ -26,6 +26,8 @@ use crate::web_server::utils; ("owner" = Option, Query, description = "Filter by owner"), ("device_type" = Option, Query, description = "Filter by device type"), ("vendor" = Option, Query, description = "Filter by vendor"), + ("sort_by" = Option, Query, description = "Column to sort by (name, owner, mac_address, ipv4_address, vendor, last_seen, is_registered, device_type)"), + ("sort_order" = Option, Query, description = "Sort direction: asc or desc (default desc)"), ("page_offset" = Option, Query, description = "Pagination offset"), ("page_limit" = Option, Query, description = "Maximum number of results to return"), ), @@ -45,6 +47,8 @@ pub async fn list( let owner: Option = utils::parse_parameter_string(¶ms, "owner"); let device_type: Option = utils::parse_parameter_string(¶ms, "device_type"); let vendor: Option = utils::parse_parameter_string(¶ms, "vendor"); + let sort_by: Option = utils::parse_parameter_string(¶ms, "sort_by"); + let sort_order: Option = utils::parse_parameter_string(¶ms, "sort_order"); let page_offset: Option = utils::parse_parameter_int(¶ms, "page_offset"); let page_limit: Option = utils::parse_parameter_int(¶ms, "page_limit"); @@ -55,6 +59,8 @@ pub async fn list( owner, device_type, vendor, + sort_by, + sort_order, page_offset, page_limit, ) { @@ -122,7 +128,12 @@ pub async fn register(Json(payload): Json) -> impl IntoRe ); } - match db::devices::register(payload.mac_address, payload.owner, payload.device_type) { + match db::devices::register( + payload.mac_address, + payload.owner, + payload.device_type, + payload.name, + ) { Ok(_) => (axum::http::StatusCode::CREATED, "Device registered"), Err(err) => { error!("Error registering device in the database: {}", err); @@ -264,6 +275,7 @@ pub struct RegisterDevicePayload { mac_address: String, owner: String, device_type: String, + name: Option, } #[derive(Deserialize, ToSchema)] diff --git a/frontend/lib/devices/device_actions.dart b/frontend/lib/devices/device_actions.dart index 1820d89..dd7f7d3 100644 --- a/frontend/lib/devices/device_actions.dart +++ b/frontend/lib/devices/device_actions.dart @@ -53,6 +53,7 @@ Future showRegisterDeviceDialog( ) async { final formKey = GlobalKey(); String owner = ''; + String name = device.name ?? ''; DeviceType deviceType = device.deviceType; final saved = await showDialog( @@ -83,6 +84,15 @@ Future showRegisterDeviceDialog( onSaved: (value) => owner = value?.trim() ?? '', ), const SizedBox(height: 16), + TextFormField( + initialValue: name, + decoration: const InputDecoration( + labelText: 'Name (optional)', + ), + onFieldSubmitted: (_) => save(), + onSaved: (value) => name = value?.trim() ?? '', + ), + const SizedBox(height: 16), DropdownButtonFormField( initialValue: deviceType, decoration: const InputDecoration(labelText: 'Device Type'), @@ -128,6 +138,7 @@ Future showRegisterDeviceDialog( device.macAddress, owner, deviceType.apiName, + name: name.isEmpty ? null : name, ); if (!context.mounted) return; UISnackbars.showSuccess(context, 'Device registered'); diff --git a/frontend/lib/devices/device_detail.dart b/frontend/lib/devices/device_detail.dart index ef62071..fc53415 100644 --- a/frontend/lib/devices/device_detail.dart +++ b/frontend/lib/devices/device_detail.dart @@ -166,6 +166,7 @@ class _DeviceInfoCard extends StatelessWidget { Widget build(BuildContext context) { final formatter = FriendlyDateFormatter(); final rows = <(String, String)>[ + ('Name', device.name == null || device.name!.isEmpty ? '—' : device.name!), ('MAC Address', device.macAddress), ('IP Address', device.ipv4Address), ('Vendor', device.vendor.isEmpty ? '—' : device.vendor), diff --git a/frontend/lib/devices/device_list.dart b/frontend/lib/devices/device_list.dart index f9447ea..e83d877 100644 --- a/frontend/lib/devices/device_list.dart +++ b/frontend/lib/devices/device_list.dart @@ -1,27 +1,18 @@ import 'dart:async'; import 'package:flutter/material.dart'; -import 'package:go_router/go_router.dart'; import '../model/device.dart'; import '../model/device_type.dart'; import '../navigation.dart'; import '../utils/friendly_date_formatter.dart'; import '../utils/oott_api.dart'; -import '../widgets/status_badge.dart'; -import 'device_actions.dart'; +import 'device_list_filter.dart'; +import 'device_list_rows.dart'; +import 'device_list_sort.dart'; -const _pageSize = 5; - -enum _DeviceFilter { - newDevices('Not registered'), - registered('Registered'), - all('All'); - - const _DeviceFilter(this.label); - - final String label; -} +const _pageSize = 10; +const _wideLayoutBreakpoint = 600.0; class DeviceList extends StatefulWidget { const DeviceList({super.key}); @@ -31,7 +22,7 @@ class DeviceList extends StatefulWidget { } class _DeviceListState extends State with RouteAware { - _DeviceFilter _filter = _DeviceFilter.newDevices; + DeviceFilter _filter = DeviceFilter.newDevices; List _devices = []; bool _isLoading = true; String? _error; @@ -39,6 +30,9 @@ class _DeviceListState extends State with RouteAware { DeviceType? _typeFilter; Timer? _ownerDebounce; + DeviceSortColumn _sortColumn = DeviceSortColumn.lastSeen; + bool _sortAscending = false; + int _currentPage = 0; bool _hasNextPage = false; @@ -86,13 +80,15 @@ class _DeviceListState extends State with RouteAware { }); try { bool? isRegistered; - if (_filter == _DeviceFilter.newDevices) isRegistered = false; - if (_filter == _DeviceFilter.registered) isRegistered = true; + if (_filter == DeviceFilter.newDevices) isRegistered = false; + if (_filter == DeviceFilter.registered) isRegistered = true; final results = await BackendAPI.instance.listDevices( isRegistered: isRegistered, owner: _ownerController.text.isEmpty ? null : _ownerController.text, deviceType: _typeFilter, + sortBy: _sortColumn.apiName, + sortAscending: _sortAscending, offset: page * _pageSize, limit: _pageSize + 1, ); @@ -112,20 +108,41 @@ class _DeviceListState extends State with RouteAware { } } + void _onSortHeaderTapped(DeviceSortColumn column) { + setState(() { + if (_sortColumn == column) { + _sortAscending = !_sortAscending; + } else { + _sortColumn = column; + _sortAscending = true; + } + }); + _fetchPage(0); + } + + void _onSortSheetChanged(DeviceSortColumn column, bool ascending) { + if (_sortColumn == column && _sortAscending == ascending) return; + setState(() { + _sortColumn = column; + _sortAscending = ascending; + }); + _fetchPage(0); + } + String _emptyMessage() => switch (_filter) { - _DeviceFilter.newDevices => 'No unregistered devices', - _DeviceFilter.registered => 'No registered devices', - _DeviceFilter.all => 'No devices found', + DeviceFilter.newDevices => 'No unregistered devices', + DeviceFilter.registered => 'No registered devices', + DeviceFilter.all => 'No devices found', }; bool get _hasActiveDetailFilters => _ownerController.text.isNotEmpty || _typeFilter != null; - void _showFilterSheet(BuildContext context) { + void _showFilterSheet() { showModalBottomSheet( context: context, isScrollControlled: true, - builder: (_) => _DeviceFilterSheet( + builder: (_) => DeviceFilterSheet( ownerController: _ownerController, typeFilter: _typeFilter, hasActiveFilters: _hasActiveDetailFilters, @@ -144,88 +161,137 @@ class _DeviceListState extends State with RouteAware { ); } - @override - Widget build(BuildContext context) { - final formatter = FriendlyDateFormatter(); - final textTheme = Theme.of(context).textTheme; - - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - Expanded(child: Text('Devices', style: textTheme.headlineSmall)), - Badge( - isLabelVisible: _hasActiveDetailFilters, - child: IconButton( - icon: const Icon(Icons.filter_list), - tooltip: 'Filter', - onPressed: () => _showFilterSheet(context), - ), - ), - ], - ), - SingleChildScrollView( - scrollDirection: Axis.horizontal, - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), - child: Wrap( - spacing: 8.0, - children: _DeviceFilter.values - .map( - (f) => ChoiceChip( - label: Text(f.label), - selected: _filter == f, - onSelected: (_) { - setState(() => _filter = f); - _fetchPage(0); - }, - ), - ) - .toList(), - ), - ), - if (_isLoading) - const Expanded(child: Center(child: CircularProgressIndicator())) - else if (_error != null) - Expanded(child: Center(child: Text('Error: $_error'))) - else if (_devices.isEmpty) - Padding( - padding: const EdgeInsets.only(top: 16, bottom: 12), - child: Center( - child: Text( - _emptyMessage(), - style: Theme.of(context).textTheme.bodyMedium?.copyWith( - color: Theme.of(context).colorScheme.onSurfaceVariant, - ), - ), - ), - ) - else - Expanded( - child: RefreshIndicator( - onRefresh: () => _fetchPage(_currentPage), - child: CustomScrollView( - physics: const AlwaysScrollableScrollPhysics(), - slivers: [ - SliverList.builder( - itemCount: _devices.length, - itemBuilder: (context, index) => _DeviceCard( - device: _devices[index], - formatter: formatter, - onRefresh: () => _fetchPage(_currentPage), - ), - ), - if (_currentPage > 0 || _hasNextPage) - _buildPaginationControls(context), - ], - ), - ), - ), - ], + void _showSortSheet() { + showModalBottomSheet( + context: context, + isScrollControlled: true, + builder: (_) => DeviceSortSheet( + currentColumn: _sortColumn, + ascending: _sortAscending, + onChanged: _onSortSheetChanged, + ), ); } - Widget _buildPaginationControls(BuildContext context) { + @override + Widget build(BuildContext context) { + final textTheme = Theme.of(context).textTheme; + + return LayoutBuilder( + builder: (context, constraints) { + final isWide = constraints.maxWidth >= _wideLayoutBreakpoint; + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Expanded( + child: Text('Devices', style: textTheme.headlineSmall), + ), + if (!isWide) + IconButton( + icon: const Icon(Icons.sort), + tooltip: 'Sort', + onPressed: _showSortSheet, + ), + Badge( + isLabelVisible: _hasActiveDetailFilters, + child: IconButton( + icon: const Icon(Icons.filter_list), + tooltip: 'Filter', + onPressed: _showFilterSheet, + ), + ), + ], + ), + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 6), + child: Wrap( + spacing: 8.0, + children: DeviceFilter.values + .map( + (f) => ChoiceChip( + label: Text(f.label), + selected: _filter == f, + onSelected: (_) { + setState(() => _filter = f); + _fetchPage(0); + }, + ), + ) + .toList(), + ), + ), + Expanded(child: _buildBody(context, isWide)), + ], + ); + }, + ); + } + + Widget _buildBody(BuildContext context, bool isWide) { + if (_isLoading) { + return const Center(child: CircularProgressIndicator()); + } + if (_error != null) { + return Center(child: Text('Error: $_error')); + } + if (_devices.isEmpty) { + final theme = Theme.of(context); + return Padding( + padding: const EdgeInsets.only(top: 16, bottom: 12), + child: Center( + child: Text( + _emptyMessage(), + style: theme.textTheme.bodyMedium?.copyWith( + color: theme.colorScheme.onSurfaceVariant, + ), + ), + ), + ); + } + + final formatter = FriendlyDateFormatter(); + return RefreshIndicator( + onRefresh: () => _fetchPage(_currentPage), + child: CustomScrollView( + physics: const AlwaysScrollableScrollPhysics(), + slivers: [ + if (isWide) + SliverPersistentHeader( + pinned: true, + delegate: DeviceListHeaderDelegate( + sortColumn: _sortColumn, + ascending: _sortAscending, + onTap: _onSortHeaderTapped, + ), + ), + SliverList.separated( + itemCount: _devices.length, + itemBuilder: (context, index) { + final device = _devices[index]; + return isWide + ? DeviceRowWide( + device: device, + formatter: formatter, + onRefresh: () => _fetchPage(_currentPage), + ) + : DeviceRowCompact( + device: device, + formatter: formatter, + onRefresh: () => _fetchPage(_currentPage), + ); + }, + separatorBuilder: (_, _) => const Divider(height: 1), + ), + if (_currentPage > 0 || _hasNextPage) _buildPaginationControls(), + ], + ), + ); + } + + Widget _buildPaginationControls() { return SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.symmetric(vertical: 8), @@ -267,170 +333,3 @@ class _DeviceListState extends State with RouteAware { ); } } - -class _DeviceFilterSheet extends StatelessWidget { - final TextEditingController ownerController; - final DeviceType? typeFilter; - final bool hasActiveFilters; - final ValueChanged onTypeChanged; - final VoidCallback onClear; - - const _DeviceFilterSheet({ - required this.ownerController, - required this.typeFilter, - required this.hasActiveFilters, - required this.onTypeChanged, - required this.onClear, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: EdgeInsets.only( - left: 16, - right: 16, - top: 24, - bottom: MediaQuery.of(context).viewInsets.bottom + 24, - ), - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.stretch, - children: [ - Text('Filters', style: Theme.of(context).textTheme.titleMedium), - const SizedBox(height: 16), - TextField( - controller: ownerController, - decoration: const InputDecoration( - labelText: 'Owner', - prefixIcon: Icon(Icons.person_outline), - border: OutlineInputBorder(), - ), - ), - const SizedBox(height: 16), - DropdownButtonFormField( - initialValue: typeFilter, - decoration: const InputDecoration( - labelText: 'Type', - border: OutlineInputBorder(), - ), - items: [ - const DropdownMenuItem(value: null, child: Text('All types')), - ...DeviceType.values.map( - (t) => DropdownMenuItem( - value: t, - child: Row( - children: [ - Icon(t.icon, size: 16), - const SizedBox(width: 4), - Text(t.label), - ], - ), - ), - ), - ], - onChanged: onTypeChanged, - ), - const SizedBox(height: 16), - if (hasActiveFilters) - OutlinedButton( - onPressed: () { - onClear(); - Navigator.of(context).pop(); - }, - child: const Text('Clear filters'), - ), - ], - ), - ); - } -} - -class _DeviceCard extends StatelessWidget { - final Device device; - final FriendlyDateFormatter formatter; - final VoidCallback onRefresh; - - const _DeviceCard({ - required this.device, - required this.formatter, - required this.onRefresh, - }); - - String get _registeredName { - final type = device.deviceType == DeviceType.unknown - ? 'Device' - : device.deviceType.label; - return "${device.owner}'s $type"; - } - - @override - Widget build(BuildContext context) { - final theme = Theme.of(context); - return Card( - color: device.isRegistered ? null : theme.colorScheme.secondaryContainer, - child: ListTile( - onTap: () => context.push('/devices/${device.macAddress}'), - leading: Tooltip( - message: device.deviceType == DeviceType.unknown - ? 'Device type unknown' - : device.deviceType.label, - child: Icon(device.deviceType.icon), - ), - title: Row( - children: [ - Flexible( - child: Text( - device.isRegistered ? _registeredName : device.ipv4Address, - overflow: TextOverflow.ellipsis, - ), - ), - const SizedBox(width: 8), - if (device.isRegistered) - const StatusBadge(label: 'Registered', color: BadgeColor.success) - else - const StatusBadge( - label: 'Not registered', - color: BadgeColor.secondary, - ), - ], - ), - subtitle: Text( - '${device.isRegistered ? '${device.ipv4Address}\n' : ''}' - '${device.vendor} · ${device.macAddress}\n' - 'Last seen: ${formatter.format(device.lastSeen)}', - ), - isThreeLine: true, - trailing: Row( - mainAxisSize: MainAxisSize.min, - children: [ - PopupMenuButton( - icon: const Icon(Icons.more_vert), - onSelected: (value) async { - if (value == 'details') { - context.push('/devices/${device.macAddress}'); - } else if (value == 'forget') { - await confirmForgetDevice(context, device, onRefresh); - } else if (value == 'register') { - await showRegisterDeviceDialog(context, device, onRefresh); - } - }, - itemBuilder: (context) => [ - const PopupMenuItem( - value: 'details', - child: Text('View details'), - ), - if (device.isRegistered) - const PopupMenuItem(value: 'forget', child: Text('Forget')), - if (!device.isRegistered) - const PopupMenuItem( - value: 'register', - child: Text('Register'), - ), - ], - ), - ], - ), - ), - ); - } -} diff --git a/frontend/lib/devices/device_list_filter.dart b/frontend/lib/devices/device_list_filter.dart new file mode 100644 index 0000000..f62d4b7 --- /dev/null +++ b/frontend/lib/devices/device_list_filter.dart @@ -0,0 +1,91 @@ +import 'package:flutter/material.dart'; + +import '../model/device_type.dart'; + +enum DeviceFilter { + newDevices('Not registered'), + registered('Registered'), + all('All'); + + const DeviceFilter(this.label); + + final String label; +} + +class DeviceFilterSheet extends StatelessWidget { + final TextEditingController ownerController; + final DeviceType? typeFilter; + final bool hasActiveFilters; + final ValueChanged onTypeChanged; + final VoidCallback onClear; + + const DeviceFilterSheet({ + super.key, + required this.ownerController, + required this.typeFilter, + required this.hasActiveFilters, + required this.onTypeChanged, + required this.onClear, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 24, + bottom: MediaQuery.of(context).viewInsets.bottom + 24, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Filters', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 16), + TextField( + controller: ownerController, + decoration: const InputDecoration( + labelText: 'Owner', + prefixIcon: Icon(Icons.person_outline), + border: OutlineInputBorder(), + ), + ), + const SizedBox(height: 16), + DropdownButtonFormField( + initialValue: typeFilter, + decoration: const InputDecoration( + labelText: 'Type', + border: OutlineInputBorder(), + ), + items: [ + const DropdownMenuItem(value: null, child: Text('All types')), + ...DeviceType.values.map( + (t) => DropdownMenuItem( + value: t, + child: Row( + children: [ + Icon(t.icon, size: 16), + const SizedBox(width: 4), + Text(t.label), + ], + ), + ), + ), + ], + onChanged: onTypeChanged, + ), + const SizedBox(height: 16), + if (hasActiveFilters) + OutlinedButton( + onPressed: () { + onClear(); + Navigator.of(context).pop(); + }, + child: const Text('Clear filters'), + ), + ], + ), + ); + } +} diff --git a/frontend/lib/devices/device_list_rows.dart b/frontend/lib/devices/device_list_rows.dart new file mode 100644 index 0000000..94fc61a --- /dev/null +++ b/frontend/lib/devices/device_list_rows.dart @@ -0,0 +1,351 @@ +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 '../widgets/status_badge.dart'; +import 'device_actions.dart'; +import 'device_list_sort.dart'; + +// Column layout shared between the header and data rows so cells line up. +const double _iconWidth = 40; +const double _trailingWidth = 48; + +class _ColumnSpec { + final DeviceSortColumn column; + final int flex; + const _ColumnSpec(this.column, this.flex); +} + +const List<_ColumnSpec> _columnSpecs = [ + _ColumnSpec(DeviceSortColumn.name, 3), + _ColumnSpec(DeviceSortColumn.owner, 2), + _ColumnSpec(DeviceSortColumn.macAddress, 3), + _ColumnSpec(DeviceSortColumn.ipAddress, 2), + _ColumnSpec(DeviceSortColumn.lastSeen, 3), + _ColumnSpec(DeviceSortColumn.vendor, 2), +]; + +String _displayName(Device device) { + if (device.name != null && device.name!.isNotEmpty) return device.name!; + if (device.isRegistered && device.owner.isNotEmpty) { + final type = device.deviceType == DeviceType.unknown + ? 'Device' + : device.deviceType.label; + return "${device.owner}'s $type"; + } + return '—'; +} + +class DeviceListHeaderDelegate extends SliverPersistentHeaderDelegate { + final DeviceSortColumn sortColumn; + final bool ascending; + final void Function(DeviceSortColumn column) onTap; + + DeviceListHeaderDelegate({ + required this.sortColumn, + required this.ascending, + required this.onTap, + }); + + @override + double get minExtent => 48; + + @override + double get maxExtent => 48; + + @override + Widget build( + BuildContext context, + double shrinkOffset, + bool overlapsContent, + ) { + return Material( + color: Theme.of(context).colorScheme.surface, + child: Column( + children: [ + Expanded( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + const SizedBox(width: _iconWidth), + for (final spec in _columnSpecs) + _HeaderCell( + flex: spec.flex, + column: spec.column, + active: sortColumn, + ascending: ascending, + onTap: onTap, + ), + const SizedBox(width: _trailingWidth), + ], + ), + ), + ), + const Divider(height: 1), + ], + ), + ); + } + + @override + bool shouldRebuild(covariant DeviceListHeaderDelegate oldDelegate) { + return oldDelegate.sortColumn != sortColumn || + oldDelegate.ascending != ascending; + } +} + +class _HeaderCell extends StatelessWidget { + final int flex; + final DeviceSortColumn column; + final DeviceSortColumn active; + final bool ascending; + final void Function(DeviceSortColumn column) onTap; + + const _HeaderCell({ + required this.flex, + required this.column, + required this.active, + required this.ascending, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + final isActive = column == active; + final color = isActive + ? theme.colorScheme.primary + : theme.colorScheme.onSurfaceVariant; + return Expanded( + flex: flex, + child: InkWell( + onTap: () => onTap(column), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 12), + child: Row( + children: [ + Flexible( + child: Text( + column.label, + style: theme.textTheme.labelLarge?.copyWith(color: color), + overflow: TextOverflow.ellipsis, + ), + ), + if (isActive) ...[ + const SizedBox(width: 4), + Icon( + ascending ? Icons.arrow_upward : Icons.arrow_downward, + size: 16, + color: color, + ), + ], + ], + ), + ), + ), + ); + } +} + +class DeviceRowWide extends StatelessWidget { + final Device device; + final FriendlyDateFormatter formatter; + final VoidCallback onRefresh; + + const DeviceRowWide({ + super.key, + required this.device, + required this.formatter, + required this.onRefresh, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Material( + color: device.isRegistered ? null : theme.colorScheme.secondaryContainer, + child: InkWell( + onTap: () => context.push('/devices/${device.macAddress}'), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Row( + children: [ + SizedBox( + width: _iconWidth, + child: Tooltip( + message: device.deviceType == DeviceType.unknown + ? 'Device type unknown' + : device.deviceType.label, + child: Icon(device.deviceType.icon), + ), + ), + for (final spec in _columnSpecs) + Expanded( + flex: spec.flex, + child: Padding( + padding: const EdgeInsets.symmetric( + horizontal: 8, + vertical: 12, + ), + child: _cellContent(theme, spec.column, device, formatter), + ), + ), + SizedBox( + width: _trailingWidth, + child: _DeviceActionsMenu( + device: device, + onRefresh: onRefresh, + ), + ), + ], + ), + ), + ), + ); + } +} + +Widget _cellContent( + ThemeData theme, + DeviceSortColumn column, + Device device, + FriendlyDateFormatter formatter, +) { + switch (column) { + case DeviceSortColumn.name: + return Text( + _displayName(device), + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium, + ); + case DeviceSortColumn.owner: + if (!device.isRegistered) { + return const Align( + alignment: Alignment.centerLeft, + child: StatusBadge( + label: 'Not registered', + color: BadgeColor.secondary, + ), + ); + } + return Text( + device.owner.isEmpty ? '—' : device.owner, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium, + ); + case DeviceSortColumn.macAddress: + return Text( + device.macAddress, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'), + ); + case DeviceSortColumn.ipAddress: + return Text( + device.ipv4Address, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium?.copyWith(fontFamily: 'monospace'), + ); + case DeviceSortColumn.lastSeen: + return Text( + formatter.format(device.lastSeen), + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium, + ); + case DeviceSortColumn.vendor: + return Text( + device.vendor.isEmpty ? '—' : device.vendor, + overflow: TextOverflow.ellipsis, + style: theme.textTheme.bodyMedium, + ); + } +} + +class DeviceRowCompact extends StatelessWidget { + final Device device; + final FriendlyDateFormatter formatter; + final VoidCallback onRefresh; + + const DeviceRowCompact({ + super.key, + required this.device, + required this.formatter, + required this.onRefresh, + }); + + @override + Widget build(BuildContext context) { + final theme = Theme.of(context); + return Material( + color: device.isRegistered ? null : theme.colorScheme.secondaryContainer, + child: ListTile( + onTap: () => context.push('/devices/${device.macAddress}'), + leading: Tooltip( + message: device.deviceType == DeviceType.unknown + ? 'Device type unknown' + : device.deviceType.label, + child: Icon(device.deviceType.icon), + ), + title: Row( + children: [ + Flexible( + child: Text( + _displayName(device), + overflow: TextOverflow.ellipsis, + ), + ), + const SizedBox(width: 8), + if (device.isRegistered) + const StatusBadge(label: 'Registered', color: BadgeColor.success) + else + const StatusBadge( + label: 'Not registered', + color: BadgeColor.secondary, + ), + ], + ), + subtitle: Text( + [ + '${device.isRegistered ? (device.owner.isEmpty ? '—' : device.owner) : device.ipv4Address} · ${device.macAddress}', + if (!device.isRegistered && device.vendor.isNotEmpty) device.vendor, + 'Last seen: ${formatter.format(device.lastSeen)}', + ].join('\n'), + ), + isThreeLine: true, + trailing: _DeviceActionsMenu(device: device, onRefresh: onRefresh), + ), + ); + } +} + +class _DeviceActionsMenu extends StatelessWidget { + final Device device; + final VoidCallback onRefresh; + + const _DeviceActionsMenu({required this.device, required this.onRefresh}); + + @override + Widget build(BuildContext context) { + return PopupMenuButton( + icon: const Icon(Icons.more_vert), + onSelected: (value) async { + if (value == 'details') { + context.push('/devices/${device.macAddress}'); + } else if (value == 'forget') { + await confirmForgetDevice(context, device, onRefresh); + } else if (value == 'register') { + await showRegisterDeviceDialog(context, device, onRefresh); + } + }, + itemBuilder: (context) => [ + const PopupMenuItem(value: 'details', child: Text('View details')), + if (device.isRegistered) + const PopupMenuItem(value: 'forget', child: Text('Forget')), + if (!device.isRegistered) + const PopupMenuItem(value: 'register', child: Text('Register')), + ], + ); + } +} diff --git a/frontend/lib/devices/device_list_sort.dart b/frontend/lib/devices/device_list_sort.dart new file mode 100644 index 0000000..30ebb87 --- /dev/null +++ b/frontend/lib/devices/device_list_sort.dart @@ -0,0 +1,89 @@ +import 'package:flutter/material.dart'; + +enum DeviceSortColumn { + name('Name', 'name'), + owner('Owner', 'owner'), + macAddress('MAC Address', 'mac_address'), + ipAddress('IP Address', 'ipv4_address'), + lastSeen('Last Seen', 'last_seen'), + vendor('Vendor', 'vendor'); + + const DeviceSortColumn(this.label, this.apiName); + + final String label; + final String apiName; +} + +class DeviceSortSheet extends StatelessWidget { + final DeviceSortColumn currentColumn; + final bool ascending; + final void Function(DeviceSortColumn column, bool ascending) onChanged; + + const DeviceSortSheet({ + super.key, + required this.currentColumn, + required this.ascending, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + left: 16, + right: 16, + top: 24, + bottom: MediaQuery.of(context).viewInsets.bottom + 24, + ), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('Sort by', style: Theme.of(context).textTheme.titleMedium), + const SizedBox(height: 8), + RadioGroup( + groupValue: currentColumn, + onChanged: (value) { + if (value != null) { + onChanged(value, ascending); + Navigator.of(context).pop(); + } + }, + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + for (final column in DeviceSortColumn.values) + RadioListTile( + title: Text(column.label), + value: column, + ), + ], + ), + ), + const SizedBox(height: 8), + Center( + child: SegmentedButton( + segments: const [ + ButtonSegment( + value: true, + label: Text('Ascending'), + icon: Icon(Icons.arrow_upward), + ), + ButtonSegment( + value: false, + label: Text('Descending'), + icon: Icon(Icons.arrow_downward), + ), + ], + selected: {ascending}, + onSelectionChanged: (selection) { + onChanged(currentColumn, selection.first); + Navigator.of(context).pop(); + }, + ), + ), + ], + ), + ); + } +} diff --git a/frontend/lib/model/device.dart b/frontend/lib/model/device.dart index 88df80c..0a6ca13 100644 --- a/frontend/lib/model/device.dart +++ b/frontend/lib/model/device.dart @@ -8,6 +8,7 @@ class Device { final bool isRegistered; final String owner; final DeviceType deviceType; + final String? name; Device({ required this.macAddress, @@ -17,6 +18,7 @@ class Device { required this.isRegistered, required this.owner, required this.deviceType, + this.name, }); Device.fromJson(Map json) @@ -26,5 +28,6 @@ class Device { lastSeen = DateTime.parse(json['last_seen'] as String), isRegistered = json['is_registered'] as bool, owner = json['owner'] as String, - deviceType = DeviceType.fromString(json['device_type'] as String); + deviceType = DeviceType.fromString(json['device_type'] as String), + name = json['name'] as String?; } diff --git a/frontend/lib/utils/oott_api.dart b/frontend/lib/utils/oott_api.dart index 2ee2b09..dd92de9 100644 --- a/frontend/lib/utils/oott_api.dart +++ b/frontend/lib/utils/oott_api.dart @@ -99,6 +99,8 @@ class BackendAPI { bool? isRegistered, String? owner, DeviceType? deviceType, + String? sortBy, + bool? sortAscending, int? offset, int? limit, }) async { @@ -112,6 +114,10 @@ class BackendAPI { ? '' : deviceType.apiName; } + if (sortBy != null) params['sort_by'] = sortBy; + if (sortAscending != null) { + params['sort_order'] = sortAscending ? 'asc' : 'desc'; + } if (offset != null) { params['page_offset'] = offset; params['page_limit'] = limit ?? _pageSize; @@ -129,8 +135,9 @@ class BackendAPI { Future registerDevice( String macAddress, String owner, - String deviceType, - ) async { + String deviceType, { + String? name, + }) async { debugPrint('About to call PUT /devices'); await _dio.put( '/devices', @@ -138,6 +145,7 @@ class BackendAPI { 'mac_address': macAddress, 'owner': owner, 'device_type': deviceType, + 'name': ?name, }, ); }