bluetooth_flutter 0.0.1
bluetooth_flutter: ^0.0.1 copied to clipboard
Cross-platform Bluetooth Low Energy for Flutter. Integrates the bluetooth_dart package and uses cargokit to build the native Rust (btleplug) library and reach it via FFI.
import 'dart:async';
import 'dart:typed_data';
import 'package:bluetooth_flutter/bluetooth_flutter.dart';
import 'package:device_info_plus/device_info_plus.dart';
import 'package:flutter/foundation.dart'
show TargetPlatform, defaultTargetPlatform, kIsWeb;
import 'package:flutter/material.dart';
import 'package:flutter/services.dart' show Clipboard, ClipboardData;
// PermissionStatus collides with bluetooth_dart's; we only need Permission here.
import 'package:permission_handler/permission_handler.dart'
hide PermissionStatus;
void main() {
runApp(const BluetoothExampleApp());
}
class BluetoothExampleApp extends StatelessWidget {
const BluetoothExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'bluetooth_flutter example',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.indigo),
useMaterial3: true,
),
home: const ScanPage(),
);
}
}
class ScanPage extends StatefulWidget {
const ScanPage({super.key});
@override
State<ScanPage> createState() => _ScanPageState();
}
class _ScanPageState extends State<ScanPage> {
final Bluetooth _bt = Bluetooth.instance;
String _backendName = '...';
PermissionStatus _permission = PermissionStatus.notDetermined;
// On the web, per-browser advice for the permission banner (Web Bluetooth
// support and how to enable it differ by browser). Null off the web.
WebBluetoothGuidance? _webGuidance;
ScanSession? _scan;
String? _error;
// Optional service-UUID filter. On the web this is also required to access a
// device's GATT: the chosen services are added to the browser's
// `optionalServices` allowlist, without which read/write/subscribe are blocked.
final TextEditingController _filterCtrl = TextEditingController();
// Latest record per device id, so the list deduplicates and refreshes RSSI.
final Map<String, BleDevice> _devices = {};
bool get _isScanning => _scan?.isScanning ?? false;
/// The service UUIDs parsed from the filter field (comma/space separated).
List<String> get _serviceUuids => _filterCtrl.text
.split(RegExp(r'[,\s]+'))
.map((s) => s.trim())
.where((s) => s.isNotEmpty)
.toList();
/// Whether scanning can even be attempted here. On a web browser without Web
/// Bluetooth (Firefox/Safari) it never can, regardless of permission state;
/// elsewhere it follows the permission status.
bool get _canScan {
if (_webGuidance != null && !_webGuidance!.supported) return false;
return _permission.isUsable;
}
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
final name = await BluetoothFlutter.ensureInitialized();
final status = await _bt.permissionStatus();
if (!mounted) return;
setState(() {
_backendName = name;
_permission = status;
if (kIsWeb) _webGuidance = webBluetoothGuidance(status);
});
}
Future<void> _requestPermission() async {
final status = await _bt.requestPermission();
if (!mounted) return;
setState(() {
_permission = status;
if (kIsWeb) _webGuidance = webBluetoothGuidance(status);
});
}
Future<void> _toggleScan() async {
if (_isScanning) {
await _stopScan();
return;
}
if (!await _ensureScanPermissions()) {
if (!mounted) return;
setState(() => _error = 'Bluetooth scan permission denied.');
return;
}
setState(() {
_error = null;
_devices.clear();
});
try {
final scan = await _bt.startScan(serviceUuids: _serviceUuids);
scan.devices.listen((device) {
if (!mounted) return;
setState(() => _devices[device.id] = device);
});
if (!mounted) return;
setState(() => _scan = scan);
} on BluetoothException catch (e) {
if (!mounted) return;
setState(() => _error = e.message);
}
}
/// Stops scanning and opens the device detail page to connect and interact.
Future<void> _openDevice(BleDevice device) async {
await _stopScan(); // free the adapter for connecting
if (!mounted) return;
await Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DevicePage(
deviceId: device.id,
title: device.name ?? device.id,
serviceUuids: _serviceUuids,
),
),
);
}
/// Requests the runtime permissions BLE scanning needs. Only Android prompts
/// here: API 31+ gates scanning on BLUETOOTH_SCAN/CONNECT, while API <= 30
/// derives it from ACCESS_FINE_LOCATION (which also needs location services
/// on). Other platforms have no per-app runtime prompt, so this is a no-op.
Future<bool> _ensureScanPermissions() async {
if (defaultTargetPlatform != TargetPlatform.android) return true;
final sdk = (await DeviceInfoPlugin().androidInfo).version.sdkInt;
final permissions = sdk >= 31
? [Permission.bluetoothScan, Permission.bluetoothConnect]
: [Permission.locationWhenInUse];
final results = await permissions.request();
return results.values.every((status) => status.isGranted);
}
Future<void> _stopScan() async {
await _scan?.stop();
if (!mounted) return;
setState(() => _scan = null);
}
@override
void dispose() {
_scan?.stop();
_filterCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final devices = _devices.values.toList()
..sort((a, b) => (b.rssi ?? -999).compareTo(a.rssi ?? -999));
return Scaffold(
appBar: AppBar(
title: const Text('Bluetooth LE scan'),
actions: [
Padding(
padding: const EdgeInsets.only(right: 16),
child: Center(child: Text('backend: $_backendName')),
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _canScan ? _toggleScan : null,
icon: Icon(_isScanning ? Icons.stop : Icons.bluetooth_searching),
label: Text(_isScanning ? 'Stop' : 'Scan'),
),
body: Column(
children: [
_PermissionBanner(
status: _permission,
guidance: _webGuidance,
onRequest: _requestPermission,
),
if (_error != null)
Padding(
padding: const EdgeInsets.all(16),
child: Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 0),
child: TextField(
controller: _filterCtrl,
decoration: InputDecoration(
isDense: true,
border: const OutlineInputBorder(),
labelText: 'Service UUID filter (optional)',
helperText: kIsWeb
? 'Required on web to access a device\'s GATT (e.g. 180f, 180d)'
: 'Comma/space separated; empty scans for everything',
helperMaxLines: 2,
),
),
),
Expanded(
child: devices.isEmpty
? Center(
child: Text(
_isScanning ? 'Scanning...' : 'No devices yet.',
style: Theme.of(context).textTheme.bodyLarge,
),
)
: ListView.separated(
itemCount: devices.length,
separatorBuilder: (_, _) => const Divider(height: 1),
itemBuilder: (context, i) => _DeviceTile(
devices[i],
onTap: () => _openDevice(devices[i]),
),
),
),
],
),
);
}
}
class _PermissionBanner extends StatelessWidget {
const _PermissionBanner({
required this.status,
required this.onRequest,
this.guidance,
});
final PermissionStatus status;
final VoidCallback onRequest;
/// On the web, per-browser guidance for the message and actions. Null on
/// native platforms, where the original system-permission wording applies.
final WebBluetoothGuidance? guidance;
@override
Widget build(BuildContext context) {
final guidance = this.guidance;
if (guidance != null) return _webBanner(context, guidance);
// Native platforms: a real OS-level Bluetooth permission.
if (status.isUsable) return const SizedBox.shrink();
final denied =
status == PermissionStatus.denied ||
status == PermissionStatus.restricted;
return MaterialBanner(
content: Text(
denied
? 'Bluetooth permission ${status.name}. Enable it in system settings.'
: 'Bluetooth permission is required to scan.',
),
leading: const Icon(Icons.bluetooth_disabled),
actions: [
TextButton(
onPressed: denied ? null : onRequest,
child: const Text('Grant'),
),
],
);
}
/// The web banner: browser-specific advice. Shown whenever Web Bluetooth is
/// unsupported here (Firefox/Safari) or present but not yet usable (e.g. Brave
/// before the flag, or Bluetooth turned off).
Widget _webBanner(BuildContext context, WebBluetoothGuidance guidance) {
if (guidance.supported && status.isUsable) return const SizedBox.shrink();
final url = guidance.settingsUrl;
return MaterialBanner(
content: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(guidance.message),
if (url != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: SelectableText(
url,
style: const TextStyle(fontFamily: 'monospace'),
),
),
],
),
leading: const Icon(Icons.bluetooth_disabled),
actions: [
if (url != null)
TextButton(
onPressed: () async {
await Clipboard.setData(ClipboardData(text: url));
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Link copied to clipboard.')),
);
}
},
child: const Text('Copy link'),
)
else
TextButton(onPressed: onRequest, child: const Text('Recheck')),
],
);
}
}
class _DeviceTile extends StatelessWidget {
const _DeviceTile(this.device, {this.onTap});
final BleDevice device;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return ListTile(
leading: _RssiIcon(device.rssi),
title: Text(device.name ?? '(unknown)'),
subtitle: Text(device.id),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
Text(
device.rssi == null ? 'n/a' : '${device.rssi} dBm',
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(width: 8),
const Icon(Icons.chevron_right),
],
),
onTap: onTap,
);
}
}
class _RssiIcon extends StatelessWidget {
const _RssiIcon(this.rssi);
final int? rssi;
@override
Widget build(BuildContext context) {
final IconData icon;
if (rssi == null) {
icon = Icons.signal_cellular_off;
} else if (rssi! >= -60) {
icon = Icons.signal_cellular_alt;
} else if (rssi! >= -80) {
icon = Icons.signal_cellular_alt_2_bar;
} else {
icon = Icons.signal_cellular_alt_1_bar;
}
return Icon(icon);
}
}
/// Connects to one peripheral and exercises the full GATT loop: discover
/// services, then read / write / subscribe per characteristic. Also tracks live
/// connection state so an unexpected disconnect is reflected immediately.
class DevicePage extends StatefulWidget {
const DevicePage({
super.key,
required this.deviceId,
required this.title,
this.serviceUuids = const [],
});
final String deviceId;
final String title;
/// Services the scan allowlisted; used to hint about the web GATT requirement.
final List<String> serviceUuids;
@override
State<DevicePage> createState() => _DevicePageState();
}
class _DevicePageState extends State<DevicePage> {
late final BlePeripheral _peripheral = Bluetooth.instance.peripheral(
widget.deviceId,
);
String _status = 'Connecting...';
bool _connected = false;
String? _error;
List<BleService> _services = const [];
StreamSubscription<bool>? _connSub;
// Active notification subscriptions and the latest value per characteristic.
final Map<String, StreamSubscription<BleNotification>> _notifSubs = {};
final Map<String, String> _values = {};
@override
void initState() {
super.initState();
// Reflect connect/disconnect (including unexpected drops) live.
_connSub = _peripheral.connectionState().listen((connected) {
if (!mounted) return;
setState(() {
_connected = connected;
if (!connected && _status != 'Error') _status = 'Disconnected';
});
});
_connect();
}
Future<void> _connect() async {
setState(() {
_status = 'Connecting...';
_error = null;
});
try {
await _peripheral.connect();
final services = await _peripheral.discoverServices();
if (!mounted) return;
setState(() {
_connected = true;
_status = 'Connected';
_services = services;
});
} on BluetoothException catch (e) {
if (!mounted) return;
setState(() {
_status = 'Error';
_error = e.message;
});
}
}
Future<void> _read(BleCharacteristic ch) async {
try {
final value = await _peripheral.read(ch.uuid);
if (!mounted) return;
setState(() => _values[ch.uuid] = _formatBytes(value));
} on BluetoothException catch (e) {
_showError(e.message);
}
}
Future<void> _toggleSubscribe(BleCharacteristic ch) async {
final existing = _notifSubs.remove(ch.uuid);
if (existing != null) {
await existing.cancel();
if (mounted) setState(() {});
return;
}
final sub = _peripheral.subscribe(ch.uuid).listen((n) {
if (!mounted) return;
setState(() => _values[ch.uuid] = _formatBytes(n.value));
}, onError: (Object e) => _showError('$e'));
setState(() => _notifSubs[ch.uuid] = sub);
}
Future<void> _write(BleCharacteristic ch) async {
final input = await showDialog<String>(
context: context,
builder: (context) => _WriteDialog(characteristicUuid: ch.uuid),
);
if (input == null) return;
final bytes = _parseHex(input);
if (bytes == null) {
_showError('Enter hex bytes, e.g. "01 ff a0".');
return;
}
try {
// Prefer an acknowledged write when the characteristic supports it.
await _peripheral.write(
ch.uuid,
bytes,
withResponse: ch.properties.contains('write'),
);
_showSnack('Wrote ${bytes.length} byte(s).');
} on BluetoothException catch (e) {
_showError(e.message);
}
}
void _showError(String message) {
if (!mounted) return;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(message),
backgroundColor: Theme.of(context).colorScheme.error,
),
);
}
void _showSnack(String message) {
if (!mounted) return;
ScaffoldMessenger.of(
context,
).showSnackBar(SnackBar(content: Text(message)));
}
@override
void dispose() {
_connSub?.cancel();
for (final sub in _notifSubs.values) {
sub.cancel();
}
_peripheral.disconnect();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
actions: [
Padding(
padding: const EdgeInsets.only(right: 16),
child: Center(
child: Row(
children: [
Icon(
_connected ? Icons.bluetooth_connected : Icons.bluetooth,
size: 18,
),
const SizedBox(width: 6),
Text(_status),
],
),
),
),
],
),
body: _buildBody(context),
);
}
Widget _buildBody(BuildContext context) {
if (_error != null) {
return _CenteredMessage(
icon: Icons.error_outline,
text: _error!,
action: FilledButton(onPressed: _connect, child: const Text('Retry')),
);
}
if (_status == 'Connecting...') {
return const Center(child: CircularProgressIndicator());
}
if (_services.isEmpty) {
// The common web pitfall: no allowlisted services means GATT is blocked.
return _CenteredMessage(
icon: Icons.info_outline,
text: kIsWeb && widget.serviceUuids.isEmpty
? 'No services. On the web you must scan with the target service '
'UUIDs (the filter field) so the browser allowlists them.'
: 'No GATT services found on this device.',
);
}
return ListView(
children: [
for (final service in _services)
_ServiceCard(
service: service,
values: _values,
isSubscribed: (uuid) => _notifSubs.containsKey(uuid),
onRead: _read,
onWrite: _write,
onToggleSubscribe: _toggleSubscribe,
),
],
);
}
}
class _ServiceCard extends StatelessWidget {
const _ServiceCard({
required this.service,
required this.values,
required this.isSubscribed,
required this.onRead,
required this.onWrite,
required this.onToggleSubscribe,
});
final BleService service;
final Map<String, String> values;
final bool Function(String uuid) isSubscribed;
final ValueChanged<BleCharacteristic> onRead;
final ValueChanged<BleCharacteristic> onWrite;
final ValueChanged<BleCharacteristic> onToggleSubscribe;
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.fromLTRB(12, 8, 12, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(
'Service ${service.uuid}',
style: Theme.of(context).textTheme.titleSmall,
),
),
for (final ch in service.characteristics)
_CharacteristicTile(
characteristic: ch,
value: values[ch.uuid],
subscribed: isSubscribed(ch.uuid),
onRead: () => onRead(ch),
onWrite: () => onWrite(ch),
onToggleSubscribe: () => onToggleSubscribe(ch),
),
const SizedBox(height: 8),
],
),
);
}
}
class _CharacteristicTile extends StatelessWidget {
const _CharacteristicTile({
required this.characteristic,
required this.value,
required this.subscribed,
required this.onRead,
required this.onWrite,
required this.onToggleSubscribe,
});
final BleCharacteristic characteristic;
final String? value;
final bool subscribed;
final VoidCallback onRead;
final VoidCallback onWrite;
final VoidCallback onToggleSubscribe;
@override
Widget build(BuildContext context) {
final ch = characteristic;
return ListTile(
dense: true,
title: Text(
ch.uuid,
style: const TextStyle(fontFamily: 'monospace', fontSize: 13),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(ch.properties.join(', ')),
if (value != null)
Padding(
padding: const EdgeInsets.only(top: 2),
child: Text(
'value: $value',
style: const TextStyle(fontFamily: 'monospace'),
),
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (ch.canRead)
IconButton(
tooltip: 'Read',
icon: const Icon(Icons.download),
onPressed: onRead,
),
if (ch.canNotify)
IconButton(
tooltip: subscribed ? 'Unsubscribe' : 'Subscribe',
icon: Icon(
subscribed
? Icons.notifications_active
: Icons.notifications_none,
),
onPressed: onToggleSubscribe,
),
if (ch.canWrite)
IconButton(
tooltip: 'Write',
icon: const Icon(Icons.upload),
onPressed: onWrite,
),
],
),
);
}
}
class _WriteDialog extends StatefulWidget {
const _WriteDialog({required this.characteristicUuid});
final String characteristicUuid;
@override
State<_WriteDialog> createState() => _WriteDialogState();
}
class _WriteDialogState extends State<_WriteDialog> {
final TextEditingController _ctrl = TextEditingController();
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: const Text('Write value'),
content: TextField(
controller: _ctrl,
autofocus: true,
decoration: const InputDecoration(
labelText: 'Hex bytes',
hintText: '01 ff a0',
),
onSubmitted: (v) => Navigator.of(context).pop(v),
),
actions: [
TextButton(
onPressed: () => Navigator.of(context).pop(),
child: const Text('Cancel'),
),
FilledButton(
onPressed: () => Navigator.of(context).pop(_ctrl.text),
child: const Text('Write'),
),
],
);
}
}
class _CenteredMessage extends StatelessWidget {
const _CenteredMessage({required this.icon, required this.text, this.action});
final IconData icon;
final String text;
final Widget? action;
@override
Widget build(BuildContext context) {
return Center(
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 40, color: Theme.of(context).colorScheme.outline),
const SizedBox(height: 12),
Text(text, textAlign: TextAlign.center),
if (action != null) ...[const SizedBox(height: 16), action!],
],
),
),
);
}
}
/// Formats bytes as space-separated hex for display.
String _formatBytes(Uint8List bytes) => bytes.isEmpty
? '(empty)'
: bytes.map((b) => b.toRadixString(16).padLeft(2, '0')).join(' ');
/// Parses a hex string (any non-hex chars ignored) into bytes, or null if it has
/// an odd number of hex digits / is empty.
Uint8List? _parseHex(String input) {
final cleaned = input.replaceAll(RegExp('[^0-9a-fA-F]'), '');
if (cleaned.isEmpty || cleaned.length.isOdd) return null;
final out = <int>[];
for (var i = 0; i < cleaned.length; i += 2) {
out.add(int.parse(cleaned.substring(i, i + 2), radix: 16));
}
return Uint8List.fromList(out);
}