Make scan button persistent
This commit is contained in:
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
));
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user