diff --git a/example/lib/data/saved_scan_filter.dart b/example/lib/data/saved_scan_filter.dart new file mode 100644 index 0000000..38fc6bb --- /dev/null +++ b/example/lib/data/saved_scan_filter.dart @@ -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 toJson() => { + 'name': name, + 'services': services, + 'namePrefix': namePrefix, + 'manufacturerData': manufacturerData, + }; + + factory SavedScanFilter.fromJson(Map 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 fromJsonList(String? jsonString) { + if (jsonString == null || jsonString.isEmpty) return []; + try { + final list = jsonDecode(jsonString) as List?; + if (list == null) return []; + return list + .map((e) => SavedScanFilter.fromJson(e as Map)) + .toList(); + } catch (_) { + return []; + } + } + + static String toJsonList(List 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, + ); + } +} diff --git a/example/lib/home/scanner_screen.dart b/example/lib/home/scanner_screen.dart index b3e738d..9210b86 100644 --- a/example/lib/home/scanner_screen.dart +++ b/example/lib/home/scanner_screen.dart @@ -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 { AvailabilityState? bleAvailabilityState; ScanFilter? scanFilter; + List _savedFilters = []; + final ValueNotifier> _savedFiltersNotifier = + ValueNotifier([]); ScanOrder _scanOrder = ScanOrder.rssi; final Map _isExpanded = {}; final Map _deviceAdFlashTrigger = {}; @@ -118,6 +122,10 @@ class _ScannerScreenState extends State { 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 { } } + Future _saveSavedFilters() async { + try { + await prefsAsync.setString( + 'saved_scan_filters', + SavedScanFilter.toJsonList(_savedFilters), + ); + } catch (e) { + debugPrint('Failed to save saved filters: $e'); + } + } + Future _saveScanFilters() async { try { await prefsAsync.setString( @@ -170,7 +189,7 @@ class _ScannerScreenState extends State { } } - void _applyFilterFromControllers() async { + Future _applyFilterFromControllers() async { try { // Ensure company identifier service is loaded await CompanyIdentifierService.instance.load(); @@ -248,6 +267,38 @@ class _ScannerScreenState extends State { _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 { return n; } + Future _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 _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 _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 { 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 { manufacturerDataController.dispose(); _searchFilterController.dispose(); _webServicesController.dispose(); + _savedFiltersNotifier.dispose(); _scanSubscription?.cancel(); super.dispose(); diff --git a/example/lib/home/widgets/scan_filter_widget.dart b/example/lib/home/widgets/scan_filter_widget.dart index 382ef9d..f18d23a 100644 --- a/example/lib/home/widgets/scan_filter_widget.dart +++ b/example/lib/home/widgets/scan_filter_widget.dart @@ -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> 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 { String? error; + int? _selectedSavedFilterIndex(List 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 _showRenameDialog( + BuildContext context, SavedScanFilter preset) async { + final controller = TextEditingController(text: preset.name); + final newName = await showDialog( + 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 { ), ), + // Saved filters dropdown (rebuilds when list changes) + ValueListenableBuilder>( + 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( + 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( + value: null, + child: Text('—'), + ), + ...List.generate( + savedFilters.length, + (i) => DropdownMenuItem( + 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,