From 061e0ae63377bd527d9d1a9a176274ab756e6c9b Mon Sep 17 00:00:00 2001 From: Foti Dim Date: Mon, 9 Feb 2026 21:23:29 +0100 Subject: [PATCH] Make scan button persistent --- example/lib/data/scan_controller.dart | 134 ++++++++++++++++++ example/lib/home/scanner_screen.dart | 90 +++++------- example/lib/main.dart | 9 +- example/lib/main_device_preview.dart | 4 + .../peripheral_detail_page.dart | 63 ++++++++ example/lib/universal_ble_app.dart | 9 +- .../lib/widgets/scan_controller_scope.dart | 26 ++++ 7 files changed, 280 insertions(+), 55 deletions(-) create mode 100644 example/lib/data/scan_controller.dart create mode 100644 example/lib/widgets/scan_controller_scope.dart diff --git a/example/lib/data/scan_controller.dart b/example/lib/data/scan_controller.dart new file mode 100644 index 0000000..2144666 --- /dev/null +++ b/example/lib/data/scan_controller.dart @@ -0,0 +1,134 @@ +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; +import 'package:universal_ble/universal_ble.dart'; +import 'package:universal_ble_example/data/company_identifier_service.dart'; + +/// Shared controller for BLE scan state so the scan button can be shown +/// on both the scanner screen and the peripheral detail screen. +class ScanController extends ChangeNotifier { + bool _isScanning = false; + bool get isScanning => _isScanning; + + /// Syncs [isScanning] with the actual native state (e.g. after app restart). + Future syncState() async { + final value = await UniversalBle.isScanning(); + if (_isScanning != value) { + _isScanning = value; + notifyListeners(); + } + } + + Future startScan() async { + if (_isScanning) return; + try { + final prefs = await SharedPreferences.getInstance(); + final filter = await _loadFilterFromPrefs(prefs); + final platformConfig = _loadPlatformConfigFromPrefs(prefs); + + await UniversalBle.startScan( + scanFilter: filter, + platformConfig: platformConfig, + ); + _isScanning = true; + notifyListeners(); + } catch (e) { + _isScanning = false; + notifyListeners(); + rethrow; + } + } + + Future stopScan() async { + if (!_isScanning) return; + try { + await UniversalBle.stopScan(); + _isScanning = false; + notifyListeners(); + } catch (e) { + rethrow; + } + } + + Future _loadFilterFromPrefs(SharedPreferences prefs) async { + final services = prefs.getString('scan_filter_services') ?? ''; + final namePrefix = prefs.getString('scan_filter_name_prefix') ?? ''; + final manufacturerData = + prefs.getString('scan_filter_manufacturer_data') ?? ''; + + if (services.isEmpty && + namePrefix.isEmpty && + manufacturerData.isEmpty) { + return null; + } + + await CompanyIdentifierService.instance.load(); + + final serviceUUids = []; + if (services.isNotEmpty) { + for (final s in services.split(',').map((e) => e.trim())) { + if (s.isEmpty) continue; + try { + serviceUUids.add(BleUuidParser.string(s)); + } on FormatException catch (_) { + continue; + } + } + } + + final namePrefixes = namePrefix.isEmpty + ? [] + : namePrefix.split(',').map((e) => e.trim()).toList(); + + final manufacturerDataFilters = []; + if (manufacturerData.isNotEmpty) { + final companyService = CompanyIdentifierService.instance; + for (final s in manufacturerData + .split(',') + .map((e) => e.trim()) + .where((s) => s.isNotEmpty)) { + final id = companyService.parseCompanyIdentifier(s); + if (id != null) { + manufacturerDataFilters.add(ManufacturerDataFilter( + companyIdentifier: id, + )); + } + } + } + + if (serviceUUids.isEmpty && + namePrefixes.isEmpty && + manufacturerDataFilters.isEmpty) { + return null; + } + + return ScanFilter( + withServices: serviceUUids, + withNamePrefix: namePrefixes, + withManufacturerData: manufacturerDataFilters, + ); + } + + PlatformConfig _loadPlatformConfigFromPrefs(SharedPreferences prefs) { + WebOptions? web; + if (kIsWeb) { + final webServices = prefs.getString('scan_filter_web_services') ?? ''; + if (webServices.isNotEmpty) { + final list = webServices + .split(',') + .where((s) => s.trim().isNotEmpty) + .map((s) { + try { + return BleUuidParser.string(s.trim()); + } catch (_) { + return s.trim(); + } + }).toList(); + web = WebOptions(optionalServices: list); + } + } + return PlatformConfig( + android: AndroidOptions(scanMode: AndroidScanMode.lowLatency), + web: web, + ); + } +} diff --git a/example/lib/home/scanner_screen.dart b/example/lib/home/scanner_screen.dart index 25e1652..f289ef7 100644 --- a/example/lib/home/scanner_screen.dart +++ b/example/lib/home/scanner_screen.dart @@ -6,12 +6,14 @@ import 'package:shared_preferences/shared_preferences.dart'; import 'package:universal_ble/universal_ble.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'; import 'package:universal_ble_example/home/widgets/ble_availability_icon.dart'; import 'package:universal_ble_example/home/widgets/drawer.dart'; import 'package:universal_ble_example/home/widgets/scan_filter_widget.dart'; import 'package:universal_ble_example/home/widgets/scanned_devices_placeholder_widget.dart'; import 'package:universal_ble_example/home/widgets/scanned_item_widget.dart'; import 'package:universal_ble_example/peripheral_details/peripheral_detail_page.dart'; +import 'package:universal_ble_example/widgets/scan_controller_scope.dart'; class ScannerScreen extends StatefulWidget { const ScannerScreen({super.key}); @@ -23,7 +25,8 @@ class ScannerScreen extends StatefulWidget { class _ScannerScreenState extends State { final _bleDevices = []; final _hiddenDevices = []; - bool _isScanning = false; + ScanController? _scanController; + void _onScanStateChanged() => setState(() {}); QueueType _queueType = QueueType.global; TextEditingController servicesFilterController = TextEditingController(); TextEditingController namePrefixController = TextEditingController(); @@ -48,10 +51,6 @@ class _ScannerScreenState extends State { _scanSubscription = UniversalBle.scanStream.listen(_handleScanResult); - UniversalBle.isScanning().then( - (isScanning) => setState(() => _isScanning = isScanning), - ); - // Get initial Bluetooth availability state UniversalBle.getBluetoothAvailabilityState().then((state) { if (mounted) { @@ -92,38 +91,10 @@ class _ScannerScreenState extends State { } Future _startScan() async { - setState(() { - _bleDevices.clear(); - _isScanning = true; - }); + setState(() => _bleDevices.clear()); try { - PlatformConfig platformConfig = PlatformConfig( - android: AndroidOptions( - scanMode: AndroidScanMode.lowLatency, - ), - ); - if (kIsWeb && _webServicesController.text.isNotEmpty) { - List webServices = _webServicesController.text - .split(',') - .where((s) => s.trim().isNotEmpty) - .map((s) { - try { - return BleUuidParser.string(s.trim()); - } catch (_) { - return s.trim(); - } - }).toList(); - platformConfig.web = WebOptions(optionalServices: webServices); - } - - await UniversalBle.startScan( - scanFilter: scanFilter, - platformConfig: platformConfig, - ); + await _scanController?.startScan(); } catch (e) { - setState(() { - _isScanning = false; - }); showSnackbar(e.toString()); } } @@ -136,11 +107,13 @@ class _ScannerScreenState extends State { final manufacturerData = prefs.getString('scan_filter_manufacturer_data') ?? ''; final searchFilter = prefs.getString('scan_filter_search') ?? ''; + final webServices = prefs.getString('scan_filter_web_services') ?? ''; servicesFilterController.text = services; namePrefixController.text = namePrefix; manufacturerDataController.text = manufacturerData; _searchFilterController.text = searchFilter; + _webServicesController.text = webServices; // Reconstruct the scan filter if any values exist if (services.isNotEmpty || @@ -164,6 +137,8 @@ class _ScannerScreenState extends State { await prefs.setString( 'scan_filter_manufacturer_data', manufacturerDataController.text); await prefs.setString('scan_filter_search', _searchFilterController.text); + await prefs.setString( + 'scan_filter_web_services', _webServicesController.text); } catch (e) { debugPrint('Failed to save scan filters: $e'); } @@ -247,10 +222,9 @@ class _ScannerScreenState extends State { _deviceAdFlashTrigger.clear(); }); await _saveScanFilters(); - if (_isScanning) { - await UniversalBle.stopScan(); + if (_scanController?.isScanning == true) { + await _scanController?.stopScan(); if (!mounted) return; - setState(() => _isScanning = false); } if (mounted) await _startScan(); } @@ -333,8 +307,7 @@ class _ScannerScreenState extends State { Future _tryAutoStartScan() async { // Only auto-start if Bluetooth is available and not already scanning - if (_isBluetoothAvailable && !_isScanning) { - // Check again to make sure we're not already scanning + if (_isBluetoothAvailable && _scanController?.isScanning != true) { final isScanning = await UniversalBle.isScanning(); if (!isScanning && mounted) { await _startScan(); @@ -366,8 +339,16 @@ class _ScannerScreenState extends State { if (mounted) super.setState(fn); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _scanController ??= ScanControllerScope.of(context) + ..addListener(_onScanStateChanged); + } + @override void dispose() { + _scanController?.removeListener(_onScanStateChanged); servicesFilterController.dispose(); namePrefixController.dispose(); manufacturerDataController.dispose(); @@ -467,24 +448,23 @@ class _ScannerScreenState extends State { Padding( padding: const EdgeInsets.symmetric(horizontal: 8.0, vertical: 4.0), child: FilledButton.icon( - onPressed: _isBluetoothAvailable + onPressed: _isBluetoothAvailable && _scanController != null ? () async { - if (_isScanning) { - await UniversalBle.stopScan(); - setState(() { - _isScanning = false; - }); + if (_scanController!.isScanning) { + await _scanController!.stopScan(); } else { await _startScan(); } } : null, icon: Icon( - _isScanning ? Icons.stop_circle : Icons.play_arrow, + _scanController?.isScanning == true + ? Icons.stop_circle + : Icons.play_arrow, size: 20, ), label: Text( - _isScanning ? 'Stop Scan' : 'Start Scan', + _scanController?.isScanning == true ? 'Stop Scan' : 'Start Scan', style: const TextStyle( fontSize: 14, fontWeight: FontWeight.w600, @@ -492,9 +472,13 @@ class _ScannerScreenState extends State { ), style: FilledButton.styleFrom( backgroundColor: - _isScanning ? colorScheme.error : colorScheme.primary, + _scanController?.isScanning == true + ? colorScheme.error + : colorScheme.primary, foregroundColor: - _isScanning ? colorScheme.onError : colorScheme.onPrimary, + _scanController?.isScanning == true + ? colorScheme.onError + : colorScheme.onPrimary, padding: const EdgeInsets.symmetric( horizontal: 16, vertical: 12, @@ -502,7 +486,7 @@ class _ScannerScreenState extends State { shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(8), ), - elevation: _isScanning ? 0 : 2, + elevation: _scanController?.isScanning == true ? 0 : 2, ), ), ), @@ -781,7 +765,7 @@ class _ScannerScreenState extends State { ), ), ) - : _isScanning && _bleDevices.isEmpty + : _scanController?.isScanning == true && _bleDevices.isEmpty ? Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -803,7 +787,7 @@ class _ScannerScreenState extends State { ], ), ) - : !_isScanning && _bleDevices.isEmpty + : _scanController?.isScanning != true && _bleDevices.isEmpty ? ScannedDevicesPlaceholderWidget(onTap: _startScan) : _filteredDevices.isEmpty ? Center( diff --git a/example/lib/main.dart b/example/lib/main.dart index 6d8e219..55ce12d 100644 --- a/example/lib/main.dart +++ b/example/lib/main.dart @@ -1,7 +1,14 @@ import 'package:flutter/material.dart'; +import 'package:universal_ble_example/data/scan_controller.dart'; import 'package:universal_ble_example/universal_ble_app.dart'; void main() async { + WidgetsFlutterBinding.ensureInitialized(); bool hasPermission = await initializeApp(); - runApp(UniversalBleApp(hasPermission: hasPermission)); + final scanController = ScanController(); + await scanController.syncState(); + runApp(UniversalBleApp( + hasPermission: hasPermission, + scanController: scanController, + )); } diff --git a/example/lib/main_device_preview.dart b/example/lib/main_device_preview.dart index 7c55cc9..63b29ca 100644 --- a/example/lib/main_device_preview.dart +++ b/example/lib/main_device_preview.dart @@ -3,10 +3,13 @@ import 'dart:io'; import 'package:device_preview_screenshot/device_preview_screenshot.dart'; import 'package:flutter/material.dart'; import 'package:path_provider/path_provider.dart'; +import 'package:universal_ble_example/data/scan_controller.dart'; import 'package:universal_ble_example/universal_ble_app.dart'; void main() async { bool hasPermission = await initializeApp(); + final scanController = ScanController(); + await scanController.syncState(); // On macOS: ~/Library/Containers/com.navideck.universalble/Data/Documents/screenshots final screenshotsDir = await getScreenshotsDirectory(); @@ -17,6 +20,7 @@ void main() async { enabled: true, builder: (context) => UniversalBleApp( hasPermission: hasPermission, + scanController: scanController, locale: DevicePreview.locale(context), builder: DevicePreview.appBuilder, ), diff --git a/example/lib/peripheral_details/peripheral_detail_page.dart b/example/lib/peripheral_details/peripheral_detail_page.dart index 06ef4ca..c84bcd9 100644 --- a/example/lib/peripheral_details/peripheral_detail_page.dart +++ b/example/lib/peripheral_details/peripheral_detail_page.dart @@ -11,8 +11,10 @@ import 'package:universal_ble_example/peripheral_details/widgets/result_header_b import 'package:universal_ble_example/peripheral_details/widgets/result_widget.dart'; import 'package:universal_ble_example/peripheral_details/widgets/services_list_widget.dart'; import 'package:universal_ble_example/peripheral_details/widgets/services_side_widget.dart'; +import 'package:universal_ble_example/data/scan_controller.dart'; import 'package:universal_ble_example/widgets/company_info_widget.dart'; import 'package:universal_ble_example/widgets/responsive_view.dart'; +import 'package:universal_ble_example/widgets/scan_controller_scope.dart'; class PeripheralDetailPage extends StatefulWidget { final BleDevice bleDevice; @@ -26,6 +28,9 @@ class PeripheralDetailPage extends StatefulWidget { class _PeripheralDetailPageState extends State { late final bleDevice = widget.bleDevice; + ScanController? _scanController; + void _onScanStateChanged() => setState(() {}); + bool isConnected = false; GlobalKey valueFormKey = GlobalKey(); List discoveredServices = []; @@ -91,8 +96,16 @@ class _PeripheralDetailPageState extends State { _loadFavoriteServices(); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _scanController ??= ScanControllerScope.of(context) + ..addListener(_onScanStateChanged); + } + @override void dispose() { + _scanController?.removeListener(_onScanStateChanged); _advertisementSubscription?.cancel(); connectionStreamSubscription?.cancel(); pairingStateSubscription?.cancel(); @@ -521,6 +534,56 @@ class _PeripheralDetailPageState extends State { centerTitle: false, elevation: 0, actions: [ + // Persistent scan button + if (_scanController != null) + Padding( + padding: const EdgeInsets.only(right: 8.0), + child: FilledButton.icon( + onPressed: () async { + if (_scanController!.isScanning) { + await _scanController!.stopScan(); + } else { + try { + await _scanController!.startScan(); + } catch (e) { + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text(e.toString())), + ); + } + } + }, + icon: Icon( + _scanController!.isScanning + ? Icons.stop_circle + : Icons.play_arrow, + size: 20, + ), + label: Text( + _scanController!.isScanning ? 'Stop Scan' : 'Start Scan', + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w600, + ), + ), + style: FilledButton.styleFrom( + backgroundColor: _scanController!.isScanning + ? colorScheme.error + : colorScheme.primary, + foregroundColor: _scanController!.isScanning + ? colorScheme.onError + : colorScheme.onPrimary, + padding: const EdgeInsets.symmetric( + horizontal: 16, + vertical: 12, + ), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + elevation: _scanController!.isScanning ? 0 : 2, + ), + ), + ), Visibility.maintain( visible: _isLoading, child: Padding( diff --git a/example/lib/universal_ble_app.dart b/example/lib/universal_ble_app.dart index 45ae809..b84f86f 100644 --- a/example/lib/universal_ble_app.dart +++ b/example/lib/universal_ble_app.dart @@ -1,8 +1,10 @@ import 'package:flutter/material.dart'; import 'package:universal_ble/universal_ble.dart'; +import 'package:universal_ble_example/data/scan_controller.dart'; import 'package:universal_ble_example/data/storage_service.dart'; import 'package:universal_ble_example/home/permission_screen.dart'; import 'package:universal_ble_example/home/scanner_screen.dart'; +import 'package:universal_ble_example/widgets/scan_controller_scope.dart'; /// Initializes the app services and checks permissions. /// Returns whether the app has the required permissions. @@ -17,19 +19,23 @@ Future initializeApp() async { class UniversalBleApp extends StatelessWidget { final bool hasPermission; + final ScanController scanController; final Locale? locale; final Widget Function(BuildContext, Widget?)? builder; const UniversalBleApp({ super.key, required this.hasPermission, + required this.scanController, this.locale, this.builder, }); @override Widget build(BuildContext context) { - return MaterialApp( + return ScanControllerScope( + controller: scanController, + child: MaterialApp( title: 'Universal BLE', debugShowCheckedModeBanner: false, locale: locale, @@ -50,6 +56,7 @@ class UniversalBleApp extends StatelessWidget { ), themeMode: ThemeMode.system, home: hasPermission ? ScannerScreen() : const PermissionScreen(), + ), ); } } diff --git a/example/lib/widgets/scan_controller_scope.dart b/example/lib/widgets/scan_controller_scope.dart new file mode 100644 index 0000000..c2751b0 --- /dev/null +++ b/example/lib/widgets/scan_controller_scope.dart @@ -0,0 +1,26 @@ +import 'package:flutter/material.dart'; +import 'package:universal_ble_example/data/scan_controller.dart'; + +/// Provides [ScanController] to the widget tree so the scan button +/// can be shown on both the scanner and detail screens. +class ScanControllerScope extends InheritedWidget { + const ScanControllerScope({ + super.key, + required this.controller, + required super.child, + }); + + final ScanController controller; + + static ScanController of(BuildContext context) { + final scope = context + .dependOnInheritedWidgetOfExactType(); + assert(scope != null, 'No ScanControllerScope found in context'); + return scope!.controller; + } + + @override + bool updateShouldNotify(ScanControllerScope oldWidget) { + return controller != oldWidget.controller; + } +}