omni_ble 0.1.0-dev.1
omni_ble: ^0.1.0-dev.1 copied to clipboard
Flutter Bluetooth Low Energy plugin with Dart-first central and peripheral APIs for Android, Apple, Windows, and Linux.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:omni_ble/omni_ble.dart';
import 'package:omni_ble_example/device_lab_session.dart';
void main() {
runApp(const OmniBleExampleApp());
}
class OmniBleExampleApp extends StatelessWidget {
const OmniBleExampleApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(home: OmniBleHomePage());
}
}
class OmniBleHomePage extends StatefulWidget {
const OmniBleHomePage({super.key});
@override
State<OmniBleHomePage> createState() => _OmniBleHomePageState();
}
class _OmniBleHomePageState extends State<OmniBleHomePage> {
static const _demoServiceUuid = '12345678-1234-5678-1234-56789abcdef0';
static const _demoCharacteristicUuid =
'12345678-1234-5678-1234-56789abcdef1';
static const _demoDescriptorUuid = '00002901-0000-1000-8000-00805f9b34fb';
static const _runtimePermissions = {
OmniBlePermission.scan,
OmniBlePermission.connect,
OmniBlePermission.advertise,
};
final OmniBle _ble = const OmniBle();
late Future<OmniBleCapabilities> _capabilitiesFuture;
StreamSubscription<OmniBleEvent>? _eventsSubscription;
OmniBleAdapterState _adapterState = OmniBleAdapterState.unknown;
OmniBlePermissionStatus _permissionStatus = const OmniBlePermissionStatus();
OmniBleCapabilities? _lastCapabilities;
Map<OmniBlePermission, bool> _permissionRationales = const {};
Map<String, int> _connectedRssi = const {};
Map<String, String> _serviceSummaries = const {};
List<DeviceLabSessionEntry> _sessionEntries = const [];
bool _isScanning = false;
bool _isPeripheralBusy = false;
bool _peripheralPublished = false;
bool _peripheralAdvertising = false;
int _demoNotificationValue = 1;
String? _lastScanError;
String? _lastPeripheralEvent;
String? _centralActionDeviceId;
List<OmniBleScanResult> _scanResults = const [];
@override
void initState() {
super.initState();
_capabilitiesFuture = _loadCapabilities();
_eventsSubscription = _ble.events.listen(_onEvent);
unawaited(_refreshPermissionStatus());
unawaited(_refreshPermissionRationales());
}
@override
void dispose() {
_eventsSubscription?.cancel();
super.dispose();
}
Future<OmniBleCapabilities> _loadCapabilities() async {
final capabilities = await _ble.getCapabilities();
if (!mounted) {
return capabilities;
}
setState(() {
_lastCapabilities = capabilities;
});
return capabilities;
}
void _recordSessionEntry(String category, String message) {
if (!mounted) {
return;
}
final nextEntries = [
..._sessionEntries,
DeviceLabSessionEntry(
timestamp: DateTime.now(),
category: category,
message: message,
),
];
final cappedEntries = nextEntries.length > 60
? nextEntries.sublist(nextEntries.length - 60)
: nextEntries;
setState(() {
_sessionEntries = cappedEntries;
});
}
List<String> _permissionStatusLabels() {
return _permissionStatus.permissions.entries
.map((entry) => '${entry.key.value}=${entry.value.value}')
.toList(growable: false);
}
List<String> _scanSummaries() {
return _scanResults
.take(8)
.map((result) {
final label = result.name ?? result.deviceId;
final inspected = _serviceSummaries[result.deviceId];
return inspected == null
? '$label (RSSI ${result.rssi})'
: '$label (RSSI ${result.rssi}) - $inspected';
})
.toList(growable: false);
}
String _buildSessionReport() {
final capabilities = _lastCapabilities;
return buildDeviceLabSessionReport(
DeviceLabSessionSnapshot(
platform: capabilities?.platform ?? 'unknown',
platformVersion: capabilities?.platformVersion ?? 'unknown',
adapterState: _adapterState.value,
availableFeatures: capabilities?.availableFeatures
.map((feature) => feature.value)
.toList(growable: false) ??
const [],
permissionStatuses: _permissionStatusLabels(),
isScanning: _isScanning,
peripheralPublished: _peripheralPublished,
peripheralAdvertising: _peripheralAdvertising,
scanSummaries: _scanSummaries(),
lastScanError: _lastScanError,
entries: _sessionEntries,
),
);
}
Future<void> _copySessionReport() async {
final report = _buildSessionReport();
await Clipboard.setData(ClipboardData(text: report));
if (!mounted) {
return;
}
_recordSessionEntry('lab', 'Copied the device-lab session report.');
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Copied the device-lab session report.')),
);
}
void _clearSessionLog() {
if (!mounted) {
return;
}
setState(() {
_sessionEntries = const [];
});
}
void _onEvent(OmniBleEvent event) {
if (!mounted) {
return;
}
String? errorMessage;
String? sessionCategory;
String? sessionMessage;
var discoveredNewDevice = false;
setState(() {
if (event is OmniBleAdapterStateChanged) {
_adapterState = event.state;
sessionCategory = 'adapter';
sessionMessage = 'Adapter state changed to ${event.state.value}.';
return;
}
if (event is OmniBleScanResultEvent) {
final existingIndex = _scanResults.indexWhere(
(result) => result.deviceId == event.result.deviceId,
);
discoveredNewDevice = existingIndex == -1;
final nextResults = [..._scanResults];
if (existingIndex == -1) {
nextResults.add(event.result);
} else {
nextResults[existingIndex] = event.result;
}
nextResults.sort((left, right) => right.rssi.compareTo(left.rssi));
_scanResults = nextResults;
if (discoveredNewDevice) {
sessionCategory = 'scan';
sessionMessage =
'Discovered ${event.result.name ?? event.result.deviceId} at RSSI ${event.result.rssi}.';
}
return;
}
if (event is OmniBleScanErrorEvent) {
final codeLabel = event.code?.toString() ?? 'unknown';
_isScanning = false;
_lastScanError =
event.message ?? 'Bluetooth scanning failed with code $codeLabel.';
errorMessage = _lastScanError;
sessionCategory = 'scan';
sessionMessage = _lastScanError;
return;
}
if (event is OmniBleConnectionStateChanged) {
sessionCategory = 'central';
sessionMessage =
'Connection state for ${event.deviceId} changed to ${event.state.value}.';
return;
}
if (event is OmniBleMtuChangedEvent) {
sessionCategory = 'central';
sessionMessage = event.status == null
? 'MTU for ${event.deviceId} changed to ${event.mtu}.'
: 'MTU for ${event.deviceId} changed to ${event.mtu} (status ${event.status}).';
return;
}
if (event is OmniBlePhyUpdatedEvent) {
sessionCategory = 'central';
sessionMessage = event.status == null
? 'PHY updated for ${event.deviceId} to tx=${event.txPhy.value}, rx=${event.rxPhy.value}.'
: 'PHY updated for ${event.deviceId} to tx=${event.txPhy.value}, rx=${event.rxPhy.value} (status ${event.status}).';
return;
}
if (event is OmniBleCharacteristicValueChanged) {
sessionCategory = 'notify';
sessionMessage =
'Characteristic ${event.address.characteristicUuid} changed with ${event.value.length} bytes.';
return;
}
if (event is OmniBleReadRequestEvent) {
_lastPeripheralEvent =
'Read request at offset ${event.offset} for ${event.characteristicUuid}.';
sessionCategory = 'peripheral';
sessionMessage =
'Incoming read request ${event.requestId} at offset ${event.offset} for ${event.characteristicUuid}.';
unawaited(_respondToReadRequest(event));
return;
}
if (event is OmniBleWriteRequestEvent) {
final payloadLabel = event.value.isEmpty
? 'empty payload'
: event.value.join(',');
if (event.value.isNotEmpty) {
_demoNotificationValue = event.value.last;
}
_lastPeripheralEvent =
'Write request at offset ${event.offset} with $payloadLabel.';
sessionCategory = 'peripheral';
sessionMessage =
'Incoming write request ${event.requestId} at offset ${event.offset} with $payloadLabel.';
unawaited(_respondToWriteRequest(event));
return;
}
if (event is OmniBleSubscriptionChanged) {
_lastPeripheralEvent =
'${event.subscribed ? 'Subscribed' : 'Unsubscribed'} on ${event.characteristicUuid}.';
sessionCategory = 'peripheral';
sessionMessage =
'${event.subscribed ? 'Subscribed' : 'Unsubscribed'} on ${event.characteristicUuid}.';
return;
}
if (event is OmniBleNotificationQueueReadyEvent) {
_lastPeripheralEvent = event.status == null
? 'Notification queue is ready.'
: 'Notification queue is ready (status ${event.status}).';
sessionCategory = 'peripheral';
sessionMessage = _lastPeripheralEvent;
}
});
if (sessionCategory != null && sessionMessage != null) {
_recordSessionEntry(sessionCategory!, sessionMessage!);
}
if (errorMessage != null) {
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(errorMessage!)));
}
}
Future<void> _refreshPermissionStatus() async {
try {
final permissionStatus = await _ble.permissions.check(
_runtimePermissions,
);
if (!mounted) {
return;
}
setState(() {
_permissionStatus = permissionStatus;
});
} on OmniBleException {
if (!mounted) {
return;
}
setState(() {
_permissionStatus = const OmniBlePermissionStatus();
});
}
}
Future<void> _refreshPermissionRationales() async {
try {
final rationale = await _ble.permissions.shouldShowRequestRationale(
_runtimePermissions,
);
if (!mounted) {
return;
}
setState(() {
_permissionRationales = rationale;
});
} on OmniBleException {
if (!mounted) {
return;
}
setState(() {
_permissionRationales = const {};
});
}
}
Future<void> _requestPermissionStatus() async {
try {
final permissionStatus = await _ble.permissions.request(
_runtimePermissions,
);
if (!mounted) {
return;
}
setState(() {
_permissionStatus = permissionStatus;
});
await _refreshPermissionRationales();
_recordSessionEntry(
'permissions',
'Requested BLE permissions: ${_permissionStatusLabels().join(', ')}.',
);
} on OmniBleException catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${error.code}: ${error.message}')),
);
}
}
Future<bool> _ensureAndroidPermission(OmniBlePermission permission) async {
final permissionStatus = await _ble.permissions.request({permission});
if (!mounted) {
return false;
}
setState(() {
_permissionStatus = OmniBlePermissionStatus(
permissions: {
..._permissionStatus.permissions,
...permissionStatus.permissions,
},
);
});
await _refreshPermissionRationales();
_recordSessionEntry(
'permissions',
'Requested ${permission.value}: ${permissionStatus.permissions[permission]?.value ?? 'unknown'}.',
);
return permissionStatus.isGranted(permission);
}
Future<void> _openAppSettings() async {
try {
final opened = await _ble.permissions.openAppSettings();
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
opened
? 'Opened app settings.'
: 'App settings are not available on this platform.',
),
),
);
_recordSessionEntry(
'permissions',
opened
? 'Opened app settings from the example app.'
: 'App settings are not available on this platform.',
);
} on OmniBleException catch (error) {
_showError(error);
}
}
Future<void> _openBluetoothSettings() async {
try {
final opened = await _ble.permissions.openBluetoothSettings();
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
opened
? 'Opened Bluetooth settings.'
: 'Bluetooth settings are not available on this platform.',
),
),
);
_recordSessionEntry(
'permissions',
opened
? 'Opened Bluetooth settings from the example app.'
: 'Bluetooth settings are not available on this platform.',
);
} on OmniBleException catch (error) {
_showError(error);
}
}
void _showError(OmniBleException error) {
if (!mounted) {
return;
}
_recordSessionEntry('error', '${error.code}: ${error.message}');
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text('${error.code}: ${error.message}')));
}
bool _supportsConnectedRssi(OmniBleCapabilities capabilities) {
return switch (capabilities.platform) {
'android' || 'ios' || 'macos' => true,
_ => false,
};
}
bool _supportsPeripheralSmoke(OmniBleCapabilities capabilities) {
return capabilities.supports(OmniBleFeature.peripheral) &&
capabilities.supports(OmniBleFeature.advertising) &&
capabilities.supports(OmniBleFeature.gattServer);
}
String _centralActionLabel(OmniBleCapabilities capabilities) {
return _supportsConnectedRssi(capabilities)
? 'Connect, inspect, and read RSSI'
: 'Connect and inspect GATT';
}
Future<void> _toggleScan(OmniBleCapabilities capabilities) async {
if (!capabilities.supports(OmniBleFeature.scanning)) {
return;
}
try {
if (_isScanning) {
await _ble.central.stopScan();
if (!mounted) {
return;
}
setState(() {
_isScanning = false;
});
_recordSessionEntry('scan', 'Stopped scanning.');
return;
}
if (capabilities.platform == 'android') {
final isGranted = await _ensureAndroidPermission(
OmniBlePermission.scan,
);
if (!isGranted) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Bluetooth scan permission was denied.'),
),
);
_recordSessionEntry(
'permissions',
'Bluetooth scan permission was denied before scanning.',
);
return;
}
}
await _ble.central.startScan();
if (!mounted) {
return;
}
setState(() {
_isScanning = true;
_lastScanError = null;
_scanResults = const [];
});
_recordSessionEntry('scan', 'Started scanning for BLE peripherals.');
} on OmniBleException catch (error) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('${error.code}: ${error.message}')),
);
}
}
Future<void> _runCentralSmoke(
OmniBleCapabilities capabilities,
OmniBleScanResult result,
) async {
if (!capabilities.supports(OmniBleFeature.gattClient)) {
return;
}
if (capabilities.platform == 'android') {
final isGranted = await _ensureAndroidPermission(
OmniBlePermission.connect,
);
if (!isGranted) {
if (!mounted) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Bluetooth connect permission was denied.'),
),
);
return;
}
}
try {
_recordSessionEntry(
'central',
'Started central smoke against ${result.name ?? result.deviceId}.',
);
setState(() {
_centralActionDeviceId = result.deviceId;
});
int? negotiatedMtu;
int? rssi;
await _ble.central.connect(
result.deviceId,
config: const OmniBleConnectionConfig(timeout: Duration(seconds: 10)),
);
if (capabilities.platform == 'android') {
await _ble.central.requestConnectionPriority(
result.deviceId,
OmniBleConnectionPriority.high,
);
negotiatedMtu = await _ble.central.requestMtu(
result.deviceId,
mtu: 247,
);
try {
await _ble.central.setPreferredPhy(
result.deviceId,
txPhy: OmniBlePhy.le2m,
rxPhy: OmniBlePhy.le2m,
);
} on OmniBleException {
// PHY tuning is best-effort for the demo flow.
}
}
final services = await _ble.central.discoverServices(result.deviceId);
final characteristicCount = services.fold<int>(
0,
(sum, service) => sum + service.characteristics.length,
);
final descriptorCount = services.fold<int>(
0,
(sum, service) =>
sum +
service.characteristics.fold<int>(
0,
(value, characteristic) =>
value + characteristic.descriptors.length,
),
);
if (_supportsConnectedRssi(capabilities)) {
rssi = await _ble.central.readRssi(result.deviceId);
}
if (!mounted) {
return;
}
setState(() {
if (rssi != null) {
_connectedRssi = {..._connectedRssi, result.deviceId: rssi};
}
_serviceSummaries = {
..._serviceSummaries,
result.deviceId:
'${services.length} services, $characteristicCount characteristics, $descriptorCount descriptors',
};
});
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${result.name ?? result.deviceId}: '
'${services.length} services, $characteristicCount characteristics'
'${descriptorCount == 0 ? '' : ', $descriptorCount descriptors'}'
'${rssi == null ? '' : ' • RSSI $rssi dBm'}'
'${negotiatedMtu == null ? '' : ' • MTU $negotiatedMtu'}',
),
),
);
_recordSessionEntry(
'central',
'Central smoke passed for ${result.name ?? result.deviceId}: '
'${services.length} services, $characteristicCount characteristics'
'${descriptorCount == 0 ? '' : ', $descriptorCount descriptors'}'
'${rssi == null ? '' : ', RSSI $rssi dBm'}'
'${negotiatedMtu == null ? '' : ', MTU $negotiatedMtu'}.',
);
} on OmniBleException catch (error) {
_showError(error);
} finally {
try {
await _ble.central.disconnect(result.deviceId);
} on OmniBleException {
// Best-effort cleanup for the demo flow.
}
if (mounted) {
setState(() {
_centralActionDeviceId = null;
});
}
}
}
Future<void> _publishDemoGattDatabase() async {
try {
setState(() {
_isPeripheralBusy = true;
});
await _ble.peripheral.publishGattDatabase(
OmniBleGattDatabase(
services: [
OmniBleGattService(
uuid: _demoServiceUuid,
characteristics: [
OmniBleGattCharacteristic(
uuid: _demoCharacteristicUuid,
properties: {
OmniBleGattProperty.read,
OmniBleGattProperty.write,
OmniBleGattProperty.writeWithoutResponse,
OmniBleGattProperty.notify,
},
permissions: {
OmniBleGattPermission.read,
OmniBleGattPermission.write,
},
descriptors: [
OmniBleGattDescriptor(
uuid: _demoDescriptorUuid,
permissions: {
OmniBleGattPermission.read,
},
initialValue: Uint8List.fromList([
111,
109,
110,
105,
95,
98,
108,
101,
]),
),
],
initialValue: Uint8List.fromList([1]),
),
],
),
],
),
);
if (!mounted) {
return;
}
setState(() {
_peripheralPublished = true;
_lastPeripheralEvent = 'Demo GATT database published.';
});
_recordSessionEntry('peripheral', 'Published the demo GATT database.');
} on OmniBleException catch (error) {
_showError(error);
} finally {
if (mounted) {
setState(() {
_isPeripheralBusy = false;
});
}
}
}
Future<void> _clearDemoGattDatabase() async {
try {
setState(() {
_isPeripheralBusy = true;
});
if (_peripheralAdvertising) {
await _ble.peripheral.stopAdvertising();
}
await _ble.peripheral.clearGattDatabase();
if (!mounted) {
return;
}
setState(() {
_peripheralPublished = false;
_peripheralAdvertising = false;
_lastPeripheralEvent = 'Demo GATT database cleared.';
});
_recordSessionEntry('peripheral', 'Cleared the demo GATT database.');
} on OmniBleException catch (error) {
_showError(error);
} finally {
if (mounted) {
setState(() {
_isPeripheralBusy = false;
});
}
}
}
Future<void> _togglePeripheralAdvertising() async {
try {
setState(() {
_isPeripheralBusy = true;
});
if (_peripheralAdvertising) {
await _ble.peripheral.stopAdvertising();
if (!mounted) {
return;
}
setState(() {
_peripheralAdvertising = false;
_lastPeripheralEvent = 'Demo advertising stopped.';
});
_recordSessionEntry('peripheral', 'Stopped demo advertising.');
return;
}
if (!_peripheralPublished) {
await _publishDemoGattDatabase();
if (!mounted || !_peripheralPublished) {
return;
}
}
await _ble.peripheral.startAdvertising(
const OmniBleAdvertisement(
localName: 'omni_ble demo',
serviceUuids: [_demoServiceUuid],
connectable: true,
includeTxPowerLevel: true,
),
);
if (!mounted) {
return;
}
setState(() {
_peripheralAdvertising = true;
_lastPeripheralEvent = 'Demo advertising started.';
});
_recordSessionEntry('peripheral', 'Started demo advertising.');
} on OmniBleException catch (error) {
_showError(error);
} finally {
if (mounted) {
setState(() {
_isPeripheralBusy = false;
});
}
}
}
Future<void> _sendDemoNotification() async {
try {
setState(() {
_isPeripheralBusy = true;
});
final value = Uint8List.fromList([_demoNotificationValue & 0xFF]);
await _ble.peripheral.notifyCharacteristicValue(
const OmniBleCharacteristicAddress(
serviceUuid: _demoServiceUuid,
characteristicUuid: _demoCharacteristicUuid,
),
value,
);
if (!mounted) {
return;
}
setState(() {
_demoNotificationValue = (_demoNotificationValue + 1) & 0xFF;
_lastPeripheralEvent = 'Sent demo notification ${value.first}.';
});
_recordSessionEntry(
'peripheral',
'Sent demo notification ${value.first} on $_demoCharacteristicUuid.',
);
} on OmniBleException catch (error) {
_showError(error);
} finally {
if (mounted) {
setState(() {
_isPeripheralBusy = false;
});
}
}
}
Future<void> _respondToReadRequest(OmniBleReadRequestEvent event) async {
try {
await _ble.peripheral.respondToReadRequest(
event.requestId,
Uint8List.fromList([_demoNotificationValue & 0xFF]),
);
_recordSessionEntry(
'peripheral',
'Answered read request ${event.requestId} with value ${_demoNotificationValue & 0xFF}.',
);
} on OmniBleException catch (error) {
_showError(error);
}
}
Future<void> _respondToWriteRequest(OmniBleWriteRequestEvent event) async {
try {
await _ble.peripheral.respondToWriteRequest(event.requestId);
_recordSessionEntry(
'peripheral',
'Acknowledged write request ${event.requestId}.',
);
} on OmniBleException catch (error) {
_showError(error);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text('omni_ble scaffold')),
body: FutureBuilder<OmniBleCapabilities>(
future: _capabilitiesFuture,
builder: (context, snapshot) {
if (!snapshot.hasData) {
return const Center(child: CircularProgressIndicator());
}
final capabilities = snapshot.data!;
final features = capabilities.availableFeatures.toList(
growable: false,
);
final deniedPermissions = _permissionStatus.permissions.entries
.where((entry) => entry.value == OmniBlePermissionState.denied)
.toList(growable: false);
final shouldExplainPermission = deniedPermissions.any(
(entry) => _permissionRationales[entry.key] == true,
);
return ListView(
padding: const EdgeInsets.all(24),
children: [
Text(
'${capabilities.platform} ${capabilities.platformVersion}',
style: Theme.of(context).textTheme.headlineSmall,
),
const SizedBox(height: 12),
Text('Adapter state: ${_adapterState.value}'),
const SizedBox(height: 12),
Text(
capabilities.supports(OmniBleFeature.gattClient)
? capabilities.platform == 'android'
? 'Central and peripheral BLE operations are implemented on Android too. Runtime Bluetooth permission still has to be granted, and this example can request it through the plugin API.'
: capabilities.platform == 'windows' ||
capabilities.platform == 'linux'
? 'Desktop central and peripheral BLE flows are now available too. This example can publish a demo GATT server and advertise it, while connected RSSI still remains unavailable on desktop.'
: 'Central and peripheral BLE operations, including connected RSSI reads, are currently implemented on Apple platforms and Android.'
: capabilities.supports(OmniBleFeature.scanning)
? 'Central scanning is implemented, but the rest of the GATT client surface is still being filled in for this target.'
: 'This target currently exposes the scaffold only. Native BLE backends still need to be implemented.',
),
const SizedBox(height: 24),
Text(
'Available features',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
if (features.isEmpty)
const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text(
'No platform features are marked as implemented yet.',
),
),
)
else
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final feature in features)
Chip(label: Text(feature.value)),
],
),
const SizedBox(height: 24),
Text(
'Runtime permissions',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: _permissionStatus.permissions.isEmpty
? const Text(
'No runtime permission status is available yet.',
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final entry
in _permissionStatus.permissions.entries)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'${entry.key.value}: ${entry.value.value}',
),
),
],
),
),
),
if (capabilities.platform == 'android') ...[
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: _permissionRationales.isEmpty
? const Text(
'Android permission rationale guidance is not available yet.',
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final entry in _permissionRationales.entries)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(
'${entry.key.value}: '
'${entry.value ? 'show rationale before requesting again' : 'no rationale needed right now'}',
),
),
if (deniedPermissions.isNotEmpty)
Text(
shouldExplainPermission
? 'Android recommends showing an in-app rationale before re-requesting at least one denied permission.'
: 'If the system dialog no longer appears, use app settings to recover denied permissions.',
),
],
),
),
),
],
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
FilledButton.tonal(
onPressed: capabilities.platform == 'android'
? _requestPermissionStatus
: null,
child: const Text('Request BLE permissions'),
),
OutlinedButton(
onPressed: capabilities.platform == 'android'
? _openAppSettings
: null,
child: const Text('Open app settings'),
),
OutlinedButton(
onPressed: capabilities.platform == 'android'
? _openBluetoothSettings
: null,
child: const Text('Bluetooth settings'),
),
],
),
const SizedBox(height: 24),
FilledButton(
onPressed: capabilities.supports(OmniBleFeature.scanning)
? () => _toggleScan(capabilities)
: null,
child: Text(_isScanning ? 'Stop scan' : 'Start scan'),
),
if (_lastScanError != null) ...[
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Text('Last scan error: $_lastScanError'),
),
),
],
const SizedBox(height: 12),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: _scanResults.isEmpty
? const Text('No scan results yet.')
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final result in _scanResults.take(8))
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${result.name ?? 'Unnamed'}\n'
'${result.deviceId}\n'
'RSSI ${result.rssi}',
),
const SizedBox(height: 8),
Text(
_serviceSummaries[result.deviceId] ??
'Services not inspected yet.',
),
const SizedBox(height: 8),
Text(
_supportsConnectedRssi(capabilities)
? _connectedRssi.containsKey(
result.deviceId,
)
? 'Connected RSSI ${_connectedRssi[result.deviceId]} dBm'
: 'Connected RSSI not read yet.'
: 'Connected RSSI is not exposed on this platform.',
),
const SizedBox(height: 8),
FilledButton.tonal(
onPressed:
capabilities.supports(
OmniBleFeature.gattClient,
) &&
_centralActionDeviceId == null
? () => _runCentralSmoke(
capabilities,
result,
)
: null,
child: Text(
_centralActionDeviceId == result.deviceId
? 'Running central smoke...'
: _centralActionLabel(capabilities),
),
),
],
),
),
],
),
),
),
const SizedBox(height: 24),
if (_supportsPeripheralSmoke(capabilities)) ...[
Text(
'Desktop Peripheral Smoke',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_peripheralPublished
? _peripheralAdvertising
? 'Demo GATT is published and advertising.'
: 'Demo GATT is published and ready to advertise.'
: 'Publish the demo GATT database to start the desktop peripheral smoke flow.',
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
FilledButton.tonal(
onPressed: _isPeripheralBusy
? null
: _publishDemoGattDatabase,
child: Text(
_isPeripheralBusy &&
!_peripheralPublished
? 'Publishing demo GATT...'
: 'Publish demo GATT',
),
),
OutlinedButton(
onPressed: _isPeripheralBusy || !_peripheralPublished
? null
: _clearDemoGattDatabase,
child: const Text('Clear demo GATT'),
),
FilledButton(
onPressed: _isPeripheralBusy
? null
: _togglePeripheralAdvertising,
child: Text(
_peripheralAdvertising
? 'Stop demo advertising'
: 'Start demo advertising',
),
),
OutlinedButton(
onPressed: _isPeripheralBusy || !_peripheralPublished
? null
: _sendDemoNotification,
child: const Text('Send demo notification'),
),
],
),
const SizedBox(height: 12),
Text(
_lastPeripheralEvent ??
'Read/write requests will be auto-acknowledged when a central interacts with the demo characteristic.',
),
],
),
),
),
const SizedBox(height: 24),
],
Text(
'Device-lab checklist',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
const Card(
child: Padding(
padding: EdgeInsets.all(16),
child: Text(
'Central smoke: startScan -> connect -> discoverServices\n'
'Mobile smoke: readRssi after connect, then disconnect\n'
'GATT smoke: read/write known characteristics and descriptors, then enable notifications\n'
'Peripheral smoke: publishGattDatabase -> startAdvertising -> handle read/write requests -> notifyCharacteristicValue\n'
'Desktop smoke: use the demo GATT buttons above, then connect from another host and validate remote reads/writes/notifications',
),
),
),
const SizedBox(height: 24),
Text(
'Device-lab session log',
style: Theme.of(context).textTheme.titleMedium,
),
const SizedBox(height: 8),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: _sessionEntries.isEmpty
? const Text(
'No device-lab session entries yet. Run a smoke flow or copy a report after interacting with the app.',
)
: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (final entry in _sessionEntries.reversed.take(10))
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(entry.toDisplayLine()),
),
],
),
),
),
const SizedBox(height: 12),
Wrap(
spacing: 12,
runSpacing: 12,
children: [
FilledButton.tonal(
onPressed: _copySessionReport,
child: const Text('Copy session report'),
),
OutlinedButton(
onPressed:
_sessionEntries.isEmpty ? null : _clearSessionLog,
child: const Text('Clear session log'),
),
],
),
const SizedBox(height: 24),
FilledButton(
onPressed: () {
setState(() {
_capabilitiesFuture = _loadCapabilities();
});
unawaited(_refreshPermissionStatus());
_recordSessionEntry(
'lab',
'Refreshed capabilities and runtime permission status.',
);
},
child: const Text('Refresh capabilities'),
),
],
);
},
),
);
}
}