Allow saving filters

This commit is contained in:
Foti Dim
2026-02-10 22:44:12 +01:00
parent 062f6c4c8c
commit ffd69a7c49
3 changed files with 324 additions and 1 deletions
+63
View File
@@ -0,0 +1,63 @@
import 'dart:convert';
/// A saved scan filter preset (name + the three filter field values).
class SavedScanFilter {
const SavedScanFilter({
required this.name,
required this.services,
required this.namePrefix,
required this.manufacturerData,
});
final String name;
final String services;
final String namePrefix;
final String manufacturerData;
Map<String, dynamic> toJson() => {
'name': name,
'services': services,
'namePrefix': namePrefix,
'manufacturerData': manufacturerData,
};
factory SavedScanFilter.fromJson(Map<String, dynamic> json) {
return SavedScanFilter(
name: json['name'] as String? ?? '',
services: json['services'] as String? ?? '',
namePrefix: json['namePrefix'] as String? ?? '',
manufacturerData: json['manufacturerData'] as String? ?? '',
);
}
static List<SavedScanFilter> fromJsonList(String? jsonString) {
if (jsonString == null || jsonString.isEmpty) return [];
try {
final list = jsonDecode(jsonString) as List<dynamic>?;
if (list == null) return [];
return list
.map((e) => SavedScanFilter.fromJson(e as Map<String, dynamic>))
.toList();
} catch (_) {
return [];
}
}
static String toJsonList(List<SavedScanFilter> filters) {
return jsonEncode(filters.map((e) => e.toJson()).toList());
}
SavedScanFilter copyWith({
String? name,
String? services,
String? namePrefix,
String? manufacturerData,
}) {
return SavedScanFilter(
name: name ?? this.name,
services: services ?? this.services,
namePrefix: namePrefix ?? this.namePrefix,
manufacturerData: manufacturerData ?? this.manufacturerData,
);
}
}
+105 -1
View File
@@ -4,6 +4,7 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:universal_ble/universal_ble.dart';
import 'package:universal_ble_example/data/prefs_async.dart';
import 'package:universal_ble_example/data/saved_scan_filter.dart';
import 'package:universal_ble_example/data/company_identifier_service.dart';
import 'package:universal_ble_example/data/mock_universal_ble.dart';
import 'package:universal_ble_example/data/scan_controller.dart';
@@ -42,6 +43,9 @@ class _ScannerScreenState extends State<ScannerScreen> {
AvailabilityState? bleAvailabilityState;
ScanFilter? scanFilter;
List<SavedScanFilter> _savedFilters = [];
final ValueNotifier<List<SavedScanFilter>> _savedFiltersNotifier =
ValueNotifier([]);
ScanOrder _scanOrder = ScanOrder.rssi;
final Map<String, bool> _isExpanded = {};
final Map<String, int> _deviceAdFlashTrigger = {};
@@ -118,6 +122,10 @@ class _ScannerScreenState extends State<ScannerScreen> {
final webServices =
(await prefsAsync.getString('scan_filter_web_services')) ?? '';
final scanOrderStr = await prefsAsync.getString('scan_order');
final savedFiltersJson =
await prefsAsync.getString('saved_scan_filters');
_savedFilters = SavedScanFilter.fromJsonList(savedFiltersJson);
_savedFiltersNotifier.value = List.from(_savedFilters);
servicesFilterController.text = services;
namePrefixController.text = namePrefix;
@@ -152,6 +160,17 @@ class _ScannerScreenState extends State<ScannerScreen> {
}
}
Future<void> _saveSavedFilters() async {
try {
await prefsAsync.setString(
'saved_scan_filters',
SavedScanFilter.toJsonList(_savedFilters),
);
} catch (e) {
debugPrint('Failed to save saved filters: $e');
}
}
Future<void> _saveScanFilters() async {
try {
await prefsAsync.setString(
@@ -170,7 +189,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
}
}
void _applyFilterFromControllers() async {
Future<void> _applyFilterFromControllers() async {
try {
// Ensure company identifier service is loaded
await CompanyIdentifierService.instance.load();
@@ -248,6 +267,38 @@ class _ScannerScreenState extends State<ScannerScreen> {
_deviceAdFlashTrigger.clear();
});
await _saveScanFilters();
// Store applied filter as a saved preset (keep last 10), unless it already matches one
final servicesText = servicesFilterController.text;
final namePrefixText = namePrefixController.text;
final manufacturerDataText = manufacturerDataController.text;
final hasValues = servicesText.trim().isNotEmpty ||
namePrefixText.trim().isNotEmpty ||
manufacturerDataText.trim().isNotEmpty;
if (hasValues) {
final alreadyStored = _savedFilters.any((f) =>
f.services == servicesText &&
f.namePrefix == namePrefixText &&
f.manufacturerData == manufacturerDataText);
if (!alreadyStored) {
final name = 'Filter ${_savedFilters.length + 1}';
_savedFilters.add(SavedScanFilter(
name: name,
services: servicesText,
namePrefix: namePrefixText,
manufacturerData: manufacturerDataText,
));
if (_savedFilters.length > 10) {
_savedFilters = _savedFilters.sublist(_savedFilters.length - 10);
}
await _saveSavedFilters();
if (mounted) {
setState(() {});
_savedFiltersNotifier.value = List.from(_savedFilters);
}
}
}
if (_scanController?.isScanning == true) {
await _scanController?.stopScan();
if (!mounted) return;
@@ -264,6 +315,54 @@ class _ScannerScreenState extends State<ScannerScreen> {
return n;
}
Future<void> _onSelectSavedFilter(SavedScanFilter preset) async {
servicesFilterController.text = preset.services;
namePrefixController.text = preset.namePrefix;
manufacturerDataController.text = preset.manufacturerData;
await _applyFilterFromControllers();
await _saveScanFilters();
if (_scanController?.isScanning == true) {
await _scanController?.stopScan();
if (!mounted) return;
}
if (mounted) {
await _startScan();
setState(() {});
}
}
Future<void> _onRenameFilter(SavedScanFilter preset, String newName) async {
final i = _savedFilters.indexWhere(
(f) =>
f.services == preset.services &&
f.namePrefix == preset.namePrefix &&
f.manufacturerData == preset.manufacturerData,
);
if (i >= 0) {
_savedFilters[i] = _savedFilters[i].copyWith(name: newName);
await _saveSavedFilters();
if (mounted) {
setState(() {});
_savedFiltersNotifier.value = List.from(_savedFilters);
}
}
}
Future<void> _onDeleteFilter(SavedScanFilter preset) async {
_savedFilters.removeWhere(
(f) =>
f.name == preset.name &&
f.services == preset.services &&
f.namePrefix == preset.namePrefix &&
f.manufacturerData == preset.manufacturerData,
);
await _saveSavedFilters();
if (mounted) {
setState(() {});
_savedFiltersNotifier.value = List.from(_savedFilters);
}
}
void _showScanFilterBottomSheet() {
showModalBottomSheet(
isScrollControlled: true,
@@ -273,9 +372,13 @@ class _ScannerScreenState extends State<ScannerScreen> {
servicesFilterController: servicesFilterController,
namePrefixController: namePrefixController,
manufacturerDataController: manufacturerDataController,
savedFiltersListenable: _savedFiltersNotifier,
onScanFilter: (ScanFilter? filter) {
_onScanFilterApplied(filter);
},
onSelectSavedFilter: _onSelectSavedFilter,
onRenameFilter: _onRenameFilter,
onDeleteFilter: _onDeleteFilter,
);
},
);
@@ -401,6 +504,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
manufacturerDataController.dispose();
_searchFilterController.dispose();
_webServicesController.dispose();
_savedFiltersNotifier.dispose();
_scanSubscription?.cancel();
super.dispose();
@@ -1,12 +1,18 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:universal_ble/universal_ble.dart';
import 'package:universal_ble_example/data/company_identifier_service.dart';
import 'package:universal_ble_example/data/saved_scan_filter.dart';
class ScanFilterWidget extends StatefulWidget {
final void Function(ScanFilter? filter) onScanFilter;
final TextEditingController servicesFilterController;
final TextEditingController namePrefixController;
final TextEditingController manufacturerDataController;
final ValueListenable<List<SavedScanFilter>> savedFiltersListenable;
final void Function(SavedScanFilter preset) onSelectSavedFilter;
final void Function(SavedScanFilter preset, String newName) onRenameFilter;
final void Function(SavedScanFilter preset) onDeleteFilter;
const ScanFilterWidget({
super.key,
@@ -14,6 +20,10 @@ class ScanFilterWidget extends StatefulWidget {
required this.servicesFilterController,
required this.namePrefixController,
required this.manufacturerDataController,
required this.savedFiltersListenable,
required this.onSelectSavedFilter,
required this.onRenameFilter,
required this.onDeleteFilter,
});
@override
@@ -23,6 +33,54 @@ class ScanFilterWidget extends StatefulWidget {
class _ScanFilterWidgetState extends State<ScanFilterWidget> {
String? error;
int? _selectedSavedFilterIndex(List<SavedScanFilter> savedFilters) {
final s = widget.servicesFilterController.text;
final n = widget.namePrefixController.text;
final m = widget.manufacturerDataController.text;
for (var i = 0; i < savedFilters.length; i++) {
final f = savedFilters[i];
if (f.services == s && f.namePrefix == n && f.manufacturerData == m) {
return i;
}
}
return null;
}
Future<void> _showRenameDialog(
BuildContext context, SavedScanFilter preset) async {
final controller = TextEditingController(text: preset.name);
final newName = await showDialog<String>(
context: context,
builder: (ctx) {
return AlertDialog(
title: const Text('Rename filter'),
content: TextField(
controller: controller,
decoration: const InputDecoration(
labelText: 'Name',
border: OutlineInputBorder(),
),
autofocus: true,
onSubmitted: (v) => Navigator.pop(ctx, v),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, controller.text.trim()),
child: const Text('Save'),
),
],
);
},
);
if (newName != null && newName.isNotEmpty && mounted) {
widget.onRenameFilter(preset, newName);
}
}
void applyFilter() async {
setState(() {
error = null;
@@ -200,6 +258,104 @@ class _ScanFilterWidgetState extends State<ScanFilterWidget> {
),
),
// Saved filters dropdown (rebuilds when list changes)
ValueListenableBuilder<List<SavedScanFilter>>(
valueListenable: widget.savedFiltersListenable,
builder: (context, savedFilters, child) {
if (savedFilters.isEmpty) {
return const SizedBox.shrink();
}
final selectedIndex =
_selectedSavedFilterIndex(savedFilters);
final selectedPreset = selectedIndex != null &&
selectedIndex < savedFilters.length
? savedFilters[selectedIndex]
: null;
// With a single filter, always allow edit/delete even if not selected in dropdown
final presetForActions = selectedPreset ??
(savedFilters.length == 1 ? savedFilters[0] : null);
return Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Text(
'Saved filters',
style: theme.textTheme.titleSmall?.copyWith(
fontWeight: FontWeight.w600,
color: colorScheme.primary,
),
),
const SizedBox(height: 8),
Row(
children: [
Expanded(
child: DropdownButtonFormField<int>(
initialValue: selectedIndex,
decoration: InputDecoration(
isDense: true,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8),
),
contentPadding: const EdgeInsets.symmetric(
horizontal: 12,
vertical: 10,
),
),
hint: const Text(''),
items: [
const DropdownMenuItem<int>(
value: null,
child: Text(''),
),
...List.generate(
savedFilters.length,
(i) => DropdownMenuItem<int>(
value: i,
child: Text(
savedFilters[i].name,
overflow: TextOverflow.ellipsis,
),
),
),
],
onChanged: (int? index) {
if (index != null &&
index < savedFilters.length) {
widget.onSelectSavedFilter(
savedFilters[index]);
}
},
),
),
if (presetForActions != null) ...[
const SizedBox(width: 8),
IconButton(
icon: const Icon(Icons.edit_outlined),
onPressed: () => _showRenameDialog(
context, presetForActions),
tooltip: 'Rename',
style: IconButton.styleFrom(
minimumSize: const Size(36, 36),
),
),
IconButton(
icon: const Icon(Icons.delete_outline),
onPressed: () =>
widget.onDeleteFilter(presetForActions),
tooltip: 'Delete',
style: IconButton.styleFrom(
minimumSize: const Size(36, 36),
),
),
],
],
),
const SizedBox(height: 16),
],
);
},
),
// Name Prefix Filter
_buildFilterCard(
context: context,