mirror of
https://github.com/rzuasti/oott.git
synced 2026-07-08 19:21:54 +02:00
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:
co-authored by
Claude Sonnet 4.6
parent
bf74cbb38e
commit
e7d075ec29
@@ -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<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
|
||||
let devices = list_devices(
|
||||
None,
|
||||
|
||||
@@ -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<DeviceList> {
|
||||
List<Device> _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<void> _loadDevices() async {
|
||||
setState(() {
|
||||
_isLoading = _devices.isEmpty;
|
||||
@@ -46,6 +59,8 @@ class _DeviceListState extends State<DeviceList> {
|
||||
|
||||
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<DeviceList> {
|
||||
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<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),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -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<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');
|
||||
|
||||
final params = <String, dynamic>{};
|
||||
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);
|
||||
|
||||
|
||||
Reference in New Issue
Block a user