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 <noreply@anthropic.com>
This commit is contained in:
rzuasti
2026-05-27 19:46:19 -04:00
co-authored by Claude Sonnet 4.6
parent bf74cbb38e
commit e7d075ec29
3 changed files with 135 additions and 30 deletions
+23 -2
View File
@@ -54,8 +54,8 @@ pub fn list_devices(
}; };
if let Some(owner) = owner { if let Some(owner) = owner {
debug!("Adding filter owner={}", owner); debug!("Adding filter owner={}", owner);
sql_statement.push_str("AND owner=? "); sql_statement.push_str("AND owner LIKE ? ");
params.push(owner.into()); params.push(format!("%{}%", owner).into());
}; };
if let Some(device_type) = device_type { if let Some(device_type) = device_type {
debug!("Adding filter device_type={}", device_type); debug!("Adding filter device_type={}", device_type);
@@ -241,6 +241,27 @@ mod tests {
"Vendor 2".to_string(), "Vendor 2".to_string(),
); );
// Filter by owner substring - "oh" matches "John" but not "Sarah"
let devices: Vec<Device> =
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 // List devices seen in a time period - 2026-02-03 13:14:15
let devices = list_devices( let devices = list_devices(
None, None,
+77 -3
View File
@@ -1,3 +1,5 @@
import 'dart:async';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart'; import 'package:go_router/go_router.dart';
@@ -22,18 +24,29 @@ class _DeviceListState extends State<DeviceList> {
List<Device> _devices = []; List<Device> _devices = [];
bool _isLoading = true; bool _isLoading = true;
String? _error; String? _error;
final TextEditingController _ownerController = TextEditingController();
DeviceType? _typeFilter;
Timer? _ownerDebounce;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_ownerController.addListener(_onOwnerChanged);
_loadDevices(); _loadDevices();
} }
@override @override
void dispose() { void dispose() {
_ownerDebounce?.cancel();
_ownerController.dispose();
super.dispose(); super.dispose();
} }
void _onOwnerChanged() {
_ownerDebounce?.cancel();
_ownerDebounce = Timer(const Duration(milliseconds: 500), _loadDevices);
}
Future<void> _loadDevices() async { Future<void> _loadDevices() async {
setState(() { setState(() {
_isLoading = _devices.isEmpty; _isLoading = _devices.isEmpty;
@@ -46,6 +59,8 @@ class _DeviceListState extends State<DeviceList> {
final devices = await BackendAPI.instance.listDevices( final devices = await BackendAPI.instance.listDevices(
isRegistered: isRegistered, isRegistered: isRegistered,
owner: _ownerController.text.isEmpty ? null : _ownerController.text,
deviceType: _typeFilter,
); );
if (!mounted) return; if (!mounted) return;
setState(() { setState(() {
@@ -81,9 +96,14 @@ class _DeviceListState extends State<DeviceList> {
appBar: AppBar(title: const Text('Devices')), appBar: AppBar(title: const Text('Devices')),
body: Column( body: Column(
children: [ children: [
Container( Padding(
height: 50, padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0),
alignment: Alignment.centerRight, child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
SingleChildScrollView(
scrollDirection: Axis.horizontal,
child: Wrap( child: Wrap(
spacing: 8.0, spacing: 8.0,
children: [ children: [
@@ -114,6 +134,60 @@ class _DeviceListState extends State<DeviceList> {
], ],
), ),
), ),
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<DeviceType?>(
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),
],
),
),
Expanded( Expanded(
child: _isLoading child: _isLoading
? const Center(child: CircularProgressIndicator()) ? const Center(child: CircularProgressIndicator())
+11 -1
View File
@@ -5,6 +5,7 @@ import 'package:encrypter/encrypter/xor.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:frontend/utils/pref_utils.dart'; import 'package:frontend/utils/pref_utils.dart';
import '../model/device.dart'; import '../model/device.dart';
import '../model/device_type.dart';
import '../model/notification.dart'; import '../model/notification.dart';
class BackendAPI { class BackendAPI {
@@ -90,11 +91,20 @@ class BackendAPI {
return Device.fromJson(response.data as Map<String, dynamic>); return Device.fromJson(response.data as Map<String, dynamic>);
} }
Future<List<Device>> listDevices({bool? isRegistered}) async { Future<List<Device>> listDevices({
bool? isRegistered,
String? owner,
DeviceType? deviceType,
}) async {
debugPrint('About to call /devices'); debugPrint('About to call /devices');
final params = <String, dynamic>{}; final params = <String, dynamic>{};
if (isRegistered != null) params['is_registered'] = isRegistered; 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); final response = await _dio.get('/devices', queryParameters: params);