Allow saving filters
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user