Add Bluetooth permission and adapter state handling for Android and iOS

This commit is contained in:
2026-09-21 18:14:31 -04:00
parent e8e7dc4a81
commit d8efcd7a36
6 changed files with 391 additions and 11 deletions
+142 -5
View File
@@ -2,7 +2,11 @@ import 'package:flutter/material.dart';
import '../models/scooter_device.dart';
import '../scooters/apollo_scooter.dart';
import 'dart:async';
import 'dart:io';
import '../services/ble_client.dart';
import '../services/ble_permissions.dart';
import '../services/demo_ble_client.dart';
import '../theme.dart';
import 'scooter_screen.dart';
@@ -16,8 +20,14 @@ class ScanScreen extends StatefulWidget {
State<ScanScreen> createState() => _ScanScreenState();
}
class _ScanScreenState extends State<ScanScreen> {
/// What is stopping us from scanning, if anything.
enum _Gate { checking, permissionDenied, permissionPermanentlyDenied, bluetoothOff, unsupported, ready }
class _ScanScreenState extends State<ScanScreen> with WidgetsBindingObserver {
Stream<List<ScooterDevice>>? _scan;
_Gate _gate = _Gate.checking;
StreamSubscription<BleAdapterState>? _adapterSub;
bool _requesting = false;
/// Development path: list every BLE device so a scooter that does not
/// advertise F1F0/F2F0 can still be selected and classified after GATT
@@ -27,10 +37,86 @@ class _ScanScreenState extends State<ScanScreen> {
@override
void initState() {
super.initState();
_scan = widget.ble.scan();
WidgetsBinding.instance.addObserver(this);
_prepare(request: true);
}
void _restart() => setState(() => _scan = widget.ble.scan());
@override
void dispose() {
WidgetsBinding.instance.removeObserver(this);
_adapterSub?.cancel();
super.dispose();
}
/// Re-check when coming back from system settings or the Bluetooth toggle.
@override
void didChangeAppLifecycleState(AppLifecycleState state) {
if (state == AppLifecycleState.resumed && _gate != _Gate.ready) {
_prepare(request: false);
}
}
/// Permission first, then adapter state, then scan.
Future<void> _prepare({required bool request}) async {
if (_requesting) return;
_requesting = true;
try {
final access = request ? await BlePermissions.request() : await BlePermissions.status();
if (!mounted) return;
switch (access) {
case BleAccess.denied:
_setGate(_Gate.permissionDenied);
return;
case BleAccess.permanentlyDenied:
_setGate(_Gate.permissionPermanentlyDenied);
return;
case BleAccess.unsupported:
_setGate(_Gate.unsupported);
return;
case BleAccess.granted:
break;
}
_watchAdapter();
} finally {
_requesting = false;
}
}
void _watchAdapter() {
_adapterSub ??= widget.ble.adapterState.listen((s) {
if (!mounted) return;
switch (s) {
case BleAdapterState.on:
if (_gate != _Gate.ready) {
_setGate(_Gate.ready);
_restart();
}
case BleAdapterState.off:
case BleAdapterState.unknown:
_setGate(_Gate.bluetoothOff);
case BleAdapterState.unauthorized:
_setGate(_Gate.permissionPermanentlyDenied);
case BleAdapterState.unsupported:
_setGate(_Gate.unsupported);
}
});
}
void _setGate(_Gate g) {
if (_gate == g) return;
setState(() {
_gate = g;
if (g != _Gate.ready) _scan = null;
});
}
void _restart() {
if (_gate != _Gate.ready) {
_prepare(request: true);
return;
}
setState(() => _scan = widget.ble.scan());
}
Future<void> _connect(ScooterDevice device, {BleClient? ble}) async {
setState(() => _scan = null);
@@ -89,7 +175,9 @@ class _ScanScreenState extends State<ScanScreen> {
),
),
Expanded(
child: _scan == null
child: _gate != _Gate.ready
? _gateView()
: _scan == null
? const SizedBox.shrink()
: StreamBuilder<List<ScooterDevice>>(
stream: _scan,
@@ -152,6 +240,54 @@ class _ScanScreenState extends State<ScanScreen> {
}
}
extension on _ScanScreenState {
Widget _gateView() {
final android = Platform.isAndroid;
return switch (_gate) {
_Gate.checking => const Center(child: CircularProgressIndicator()),
_Gate.permissionDenied => _Empty(
icon: Icons.bluetooth_rounded,
title: 'Bluetooth permission needed',
message: android
? 'OpenMotion needs the Nearby devices permission to find and talk to your scooter. '
'Scan results are never used for location.'
: 'OpenMotion needs Bluetooth access to find and talk to your scooter.',
action: FilledButton(
onPressed: () => _prepare(request: true),
child: const Text('Allow Bluetooth'),
),
),
_Gate.permissionPermanentlyDenied => _Empty(
icon: Icons.bluetooth_disabled_rounded,
title: 'Bluetooth permission blocked',
message: android
? 'Nearby devices access was denied. Enable it for OpenMotion in system settings, then come back.'
: 'Bluetooth access is off for OpenMotion. Enable it in Settings, then come back.',
action: FilledButton(
onPressed: BlePermissions.openSettings,
child: const Text('Open settings'),
),
),
_Gate.bluetoothOff => _Empty(
icon: Icons.bluetooth_disabled_rounded,
title: 'Bluetooth is off',
message: android
? 'Turn on Bluetooth to scan for your scooter.'
: 'Turn on Bluetooth in Control Center or Settings to scan for your scooter.',
action: android
? FilledButton(onPressed: widget.ble.turnOn, child: const Text('Turn on Bluetooth'))
: null,
),
_Gate.unsupported => const _Empty(
icon: Icons.error_outline_rounded,
title: 'Bluetooth unavailable',
message: 'This device does not support Bluetooth Low Energy.',
),
_Gate.ready => const SizedBox.shrink(),
};
}
}
class _DeviceCard extends StatelessWidget {
const _DeviceCard({required this.device, required this.onConnect});
@@ -229,7 +365,8 @@ class _Empty extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Padding(
return Container(
width: double.infinity,
padding: const EdgeInsets.fromLTRB(24, 48, 24, 24),
child: Column(
children: [
+31 -6
View File
@@ -8,6 +8,8 @@ import 'protocol_log.dart';
enum BleConnectionState { disconnected, connected }
enum BleAdapterState { unknown, unsupported, off, on, unauthorized }
/// Thin boundary between OpenScooter and the underlying BLE plugin.
///
/// OpenScooter v1 supports EXACTLY ONE active scooter connection at a time.
@@ -17,6 +19,13 @@ enum BleConnectionState { disconnected, connected }
/// Protocol code never imports the BLE plugin directly, so protocol tests run
/// with `flutter test` and a fake subclass, without hardware.
abstract class BleClient {
/// Current Bluetooth adapter state, emitting on change. Fakes default to on.
Stream<BleAdapterState> get adapterState => Stream.value(BleAdapterState.on);
/// Asks the OS to enable Bluetooth. Only Android supports this; elsewhere it
/// is a no-op and the UI must direct the user to system settings.
Future<void> turnOn() async {}
/// Streams the current set of visible devices. Scanning starts on listen
/// and stops when the subscription is cancelled.
Stream<List<ScooterDevice>> scan();
@@ -63,6 +72,27 @@ class FlutterBleClient extends BleClient {
@override
Stream<BleConnectionState> get connectionState => _connState.stream;
@override
Stream<BleAdapterState> get adapterState =>
fbp.FlutterBluePlus.adapterState.map((s) => switch (s) {
fbp.BluetoothAdapterState.on => BleAdapterState.on,
fbp.BluetoothAdapterState.off ||
fbp.BluetoothAdapterState.turningOff ||
fbp.BluetoothAdapterState.turningOn =>
BleAdapterState.off,
fbp.BluetoothAdapterState.unauthorized => BleAdapterState.unauthorized,
fbp.BluetoothAdapterState.unavailable => BleAdapterState.unsupported,
fbp.BluetoothAdapterState.unknown => BleAdapterState.unknown,
});
@override
Future<void> turnOn() async {
if (defaultTargetPlatform == TargetPlatform.android) {
_log('ADAPTER turnOn requested');
await fbp.FlutterBluePlus.turnOn();
}
}
@override
Stream<List<ScooterDevice>> scan() {
late StreamController<List<ScooterDevice>> controller;
@@ -70,12 +100,7 @@ class FlutterBleClient extends BleClient {
Future<void> start() async {
try {
var adapter = await fbp.FlutterBluePlus.adapterState.first;
if (adapter != fbp.BluetoothAdapterState.on &&
defaultTargetPlatform == TargetPlatform.android) {
await fbp.FlutterBluePlus.turnOn();
}
adapter = await fbp.FlutterBluePlus.adapterState
await fbp.FlutterBluePlus.adapterState
.where((s) => s == fbp.BluetoothAdapterState.on)
.first
.timeout(const Duration(seconds: 10));
+75
View File
@@ -0,0 +1,75 @@
import 'dart:io';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:permission_handler/permission_handler.dart';
/// Outcome of a Bluetooth permission check or request.
enum BleAccess {
/// Everything granted. Bluetooth adapter state is checked separately.
granted,
/// The user declined but can be asked again.
denied,
/// The user declined with "don't ask again" (Android) or in Settings (iOS).
/// Only the system settings screen can fix this.
permanentlyDenied,
/// The platform reported Bluetooth as unavailable.
unsupported,
}
/// Requests the runtime permissions OpenMotion needs to scan and connect.
///
/// Android 12+ (API 31): BLUETOOTH_SCAN and BLUETOOTH_CONNECT, shown to the
/// user as "Nearby devices". Android 11 and below: legacy BLE scanning needs
/// location while in use. iOS: the single Bluetooth permission, which the OS
/// prompts for the first time CoreBluetooth is touched.
class BlePermissions {
BlePermissions._();
static int? _androidSdk;
static Future<int> androidSdk() async {
if (!Platform.isAndroid) return 0;
return _androidSdk ??= (await DeviceInfoPlugin().androidInfo).version.sdkInt;
}
static Future<List<Permission>> _needed() async {
if (Platform.isAndroid) {
final sdk = await androidSdk();
return sdk >= 31
? const [Permission.bluetoothScan, Permission.bluetoothConnect]
: const [Permission.locationWhenInUse];
}
if (Platform.isIOS || Platform.isMacOS) return const [Permission.bluetooth];
return const [];
}
/// Checks without prompting.
static Future<BleAccess> status() async {
final statuses = <PermissionStatus>[];
for (final p in await _needed()) {
statuses.add(await p.status);
}
return _combine(statuses);
}
/// Prompts for anything not yet granted.
static Future<BleAccess> request() async {
final needed = await _needed();
if (needed.isEmpty) return BleAccess.granted;
final results = await needed.request();
return _combine(results.values.toList());
}
static BleAccess _combine(List<PermissionStatus> statuses) {
if (statuses.any((s) => s.isPermanentlyDenied || s.isRestricted)) {
return BleAccess.permanentlyDenied;
}
if (statuses.any((s) => s.isDenied)) return BleAccess.denied;
return BleAccess.granted;
}
static Future<bool> openSettings() => openAppSettings();
}