From e7d075ec29ef04049b2a1bf48c5779be542ffd0b Mon Sep 17 00:00:00 2001 From: rzuasti Date: Wed, 27 May 2026 19:46:19 -0400 Subject: [PATCH] Add owner and device type filters to device list Owner filter uses a server-side substring match (LIKE '%VALUE%'); device type filter matches the stored value exactly, sending an empty string for unknown/unclassified devices. Both filters combine with the existing registration status chip via AND logic. Co-Authored-By: Claude Sonnet 4.6 --- backend/src/db/devices.rs | 25 ++++- frontend/lib/devices/device_list.dart | 128 ++++++++++++++++++++------ frontend/lib/utils/oott_api.dart | 12 ++- 3 files changed, 135 insertions(+), 30 deletions(-) diff --git a/backend/src/db/devices.rs b/backend/src/db/devices.rs index 2fa95a2..22dbcbd 100644 --- a/backend/src/db/devices.rs +++ b/backend/src/db/devices.rs @@ -54,8 +54,8 @@ pub fn list_devices( }; if let Some(owner) = owner { debug!("Adding filter owner={}", owner); - sql_statement.push_str("AND owner=? "); - params.push(owner.into()); + sql_statement.push_str("AND owner LIKE ? "); + params.push(format!("%{}%", owner).into()); }; if let Some(device_type) = device_type { debug!("Adding filter device_type={}", device_type); @@ -241,6 +241,27 @@ mod tests { "Vendor 2".to_string(), ); + // Filter by owner substring - "oh" matches "John" but not "Sarah" + let devices: Vec = + list_devices(None, None, None, Some("oh".to_string()), None, None).unwrap(); + + assert!( + devices.len() >= 1, + "There should be at least one device matching owner 'oh'" + ); + assert!( + devices + .iter() + .any(|item| item.mac_address == "bb:bb:bb:bb:bb:bb"), + "Device bb:bb:bb:bb:bb:bb (John) should be present" + ); + assert!( + !devices + .iter() + .any(|item| item.mac_address == "cc:cc:cc:cc:cc:cc"), + "Device cc:cc:cc:cc:cc:cc (Sarah) should not be present" + ); + // List devices seen in a time period - 2026-02-03 13:14:15 let devices = list_devices( None, diff --git a/frontend/lib/devices/device_list.dart b/frontend/lib/devices/device_list.dart index 7363477..d01ade1 100644 --- a/frontend/lib/devices/device_list.dart +++ b/frontend/lib/devices/device_list.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; @@ -22,18 +24,29 @@ class _DeviceListState extends State { List _devices = []; bool _isLoading = true; String? _error; + final TextEditingController _ownerController = TextEditingController(); + DeviceType? _typeFilter; + Timer? _ownerDebounce; @override void initState() { super.initState(); + _ownerController.addListener(_onOwnerChanged); _loadDevices(); } @override void dispose() { + _ownerDebounce?.cancel(); + _ownerController.dispose(); super.dispose(); } + void _onOwnerChanged() { + _ownerDebounce?.cancel(); + _ownerDebounce = Timer(const Duration(milliseconds: 500), _loadDevices); + } + Future _loadDevices() async { setState(() { _isLoading = _devices.isEmpty; @@ -46,6 +59,8 @@ class _DeviceListState extends State { final devices = await BackendAPI.instance.listDevices( isRegistered: isRegistered, + owner: _ownerController.text.isEmpty ? null : _ownerController.text, + deviceType: _typeFilter, ); if (!mounted) return; setState(() { @@ -81,36 +96,95 @@ class _DeviceListState extends State { appBar: AppBar(title: const Text('Devices')), body: Column( children: [ - Container( - height: 50, - alignment: Alignment.centerRight, - child: Wrap( - spacing: 8.0, + Padding( + padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, children: [ - ChoiceChip( - label: const Text('Not registered'), - selected: _filter == _DeviceFilter.newDevices, - onSelected: (bool selected) { - setState(() => _filter = _DeviceFilter.newDevices); - _loadDevices(); - }, + SingleChildScrollView( + scrollDirection: Axis.horizontal, + child: Wrap( + spacing: 8.0, + children: [ + ChoiceChip( + label: const Text('Not registered'), + selected: _filter == _DeviceFilter.newDevices, + onSelected: (bool selected) { + setState(() => _filter = _DeviceFilter.newDevices); + _loadDevices(); + }, + ), + ChoiceChip( + label: const Text('Registered'), + selected: _filter == _DeviceFilter.registered, + onSelected: (bool selected) { + setState(() => _filter = _DeviceFilter.registered); + _loadDevices(); + }, + ), + ChoiceChip( + label: const Text('All'), + selected: _filter == _DeviceFilter.all, + onSelected: (bool selected) { + setState(() => _filter = _DeviceFilter.all); + _loadDevices(); + }, + ), + ], + ), ), - ChoiceChip( - label: const Text('Registered'), - selected: _filter == _DeviceFilter.registered, - onSelected: (bool selected) { - setState(() => _filter = _DeviceFilter.registered); - _loadDevices(); - }, - ), - ChoiceChip( - label: const Text('All'), - selected: _filter == _DeviceFilter.all, - onSelected: (bool selected) { - setState(() => _filter = _DeviceFilter.all); - _loadDevices(); - }, + const SizedBox(height: 8), + Row( + children: [ + Expanded( + flex: 2, + child: TextField( + controller: _ownerController, + decoration: const InputDecoration( + labelText: 'Owner', + prefixIcon: Icon(Icons.person_outline), + isDense: true, + border: OutlineInputBorder(), + ), + ), + ), + const SizedBox(width: 8), + Expanded( + child: DropdownButtonFormField( + initialValue: _typeFilter, + decoration: const InputDecoration( + labelText: 'Type', + isDense: true, + border: OutlineInputBorder(), + ), + items: [ + const DropdownMenuItem( + value: null, + child: Text('All'), + ), + ...DeviceType.values.map( + (t) => DropdownMenuItem( + value: t, + child: Row( + children: [ + Icon(t.icon, size: 16), + const SizedBox(width: 4), + Text(t.label), + ], + ), + ), + ), + ], + onChanged: (value) { + setState(() => _typeFilter = value); + _loadDevices(); + }, + ), + ), + ], ), + const SizedBox(height: 4), ], ), ), diff --git a/frontend/lib/utils/oott_api.dart b/frontend/lib/utils/oott_api.dart index 33d7231..339f047 100644 --- a/frontend/lib/utils/oott_api.dart +++ b/frontend/lib/utils/oott_api.dart @@ -5,6 +5,7 @@ import 'package:encrypter/encrypter/xor.dart'; import 'package:flutter/foundation.dart'; import 'package:frontend/utils/pref_utils.dart'; import '../model/device.dart'; +import '../model/device_type.dart'; import '../model/notification.dart'; class BackendAPI { @@ -90,11 +91,20 @@ class BackendAPI { return Device.fromJson(response.data as Map); } - Future> listDevices({bool? isRegistered}) async { + Future> listDevices({ + bool? isRegistered, + String? owner, + DeviceType? deviceType, + }) async { debugPrint('About to call /devices'); final params = {}; if (isRegistered != null) params['is_registered'] = isRegistered; + if (owner != null && owner.isNotEmpty) params['owner'] = owner; + if (deviceType != null) { + params['device_type'] = + deviceType == DeviceType.unknown ? '' : deviceType.name; + } final response = await _dio.get('/devices', queryParameters: params);