Make scan button persistent

This commit is contained in:
Foti Dim
2026-02-09 21:23:29 +01:00
parent 8ad6bbf125
commit 061e0ae633
7 changed files with 280 additions and 55 deletions
+134
View File
@@ -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<void> syncState() async {
final value = await UniversalBle.isScanning();
if (_isScanning != value) {
_isScanning = value;
notifyListeners();
}
}
Future<void> 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<void> stopScan() async {
if (!_isScanning) return;
try {
await UniversalBle.stopScan();
_isScanning = false;
notifyListeners();
} catch (e) {
rethrow;
}
}
Future<ScanFilter?> _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 = <String>[];
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
? <String>[]
: namePrefix.split(',').map((e) => e.trim()).toList();
final manufacturerDataFilters = <ManufacturerDataFilter>[];
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,
);
}
}
+37 -53
View File
@@ -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<ScannerScreen> {
final _bleDevices = <BleDevice>[];
final _hiddenDevices = <BleDevice>[];
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<ScannerScreen> {
_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<ScannerScreen> {
}
Future<void> _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<String> 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<ScannerScreen> {
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<ScannerScreen> {
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<ScannerScreen> {
_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<ScannerScreen> {
Future<void> _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<ScannerScreen> {
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<ScannerScreen> {
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<ScannerScreen> {
),
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<ScannerScreen> {
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
elevation: _isScanning ? 0 : 2,
elevation: _scanController?.isScanning == true ? 0 : 2,
),
),
),
@@ -781,7 +765,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
),
),
)
: _isScanning && _bleDevices.isEmpty
: _scanController?.isScanning == true && _bleDevices.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@@ -803,7 +787,7 @@ class _ScannerScreenState extends State<ScannerScreen> {
],
),
)
: !_isScanning && _bleDevices.isEmpty
: _scanController?.isScanning != true && _bleDevices.isEmpty
? ScannedDevicesPlaceholderWidget(onTap: _startScan)
: _filteredDevices.isEmpty
? Center(
+8 -1
View File
@@ -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,
));
}
+4
View File
@@ -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,
),
@@ -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<PeripheralDetailPage> {
late final bleDevice = widget.bleDevice;
ScanController? _scanController;
void _onScanStateChanged() => setState(() {});
bool isConnected = false;
GlobalKey<FormState> valueFormKey = GlobalKey<FormState>();
List<BleService> discoveredServices = [];
@@ -91,8 +96,16 @@ class _PeripheralDetailPageState extends State<PeripheralDetailPage> {
_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<PeripheralDetailPage> {
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(
+8 -1
View File
@@ -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<bool> 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(),
),
);
}
}
@@ -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<ScanControllerScope>();
assert(scope != null, 'No ScanControllerScope found in context');
return scope!.controller;
}
@override
bool updateShouldNotify(ScanControllerScope oldWidget) {
return controller != oldWidget.controller;
}
}