vital_bluetooth 0.1.0
vital_bluetooth: ^0.1.0 copied to clipboard
Bluetooth LE for wearables and healthcare devices that stays connected in the background: native reconnects, restored notifications, queued GATT and typed errors.
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:vital_bluetooth/vital_bluetooth.dart';
import 'device_page.dart';
void main() {
runApp(const ExampleApp());
}
class ExampleApp extends StatelessWidget {
const ExampleApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'vital_bluetooth example',
theme: ThemeData(colorSchemeSeed: Colors.teal, useMaterial3: true),
home: const ScanPage(),
);
}
}
/// Shows the adapter state, asks for permissions, and lists nearby devices.
class ScanPage extends StatefulWidget {
const ScanPage({super.key});
@override
State<ScanPage> createState() => _ScanPageState();
}
class _ScanPageState extends State<ScanPage> {
final _bluetooth = VitalBluetooth();
final _results = <String, ScanResult>{};
/// Devices opened in this session, kept so their connection stays visible
/// after leaving the device page.
final _myDevices = <String, (BluetoothDevice, String?)>{};
StreamSubscription<ScanResult>? _scan;
PermissionStatus? _permission;
bool _background = false;
String? _error;
@override
void initState() {
super.initState();
// Wearables should stay connected: keep reconnecting with no limit.
_bluetooth.defaultOptions = const ConnectionOptions(
reconnect: ReconnectPolicy.unlimited(),
);
_checkPermission();
_restore();
}
/// Picks up links that background mode kept alive while the app was
/// closed, and reports what arrived meanwhile.
Future<void> _restore() async {
try {
final background = await _bluetooth.isBackgroundModeEnabled();
final devices = await _bluetooth.restoreDevices();
final values = await _bluetooth.takeBackgroundValues();
if (!mounted) return;
setState(() {
_background = background;
for (final d in devices) {
_myDevices[d.id] = (d, null);
}
});
if (values.isNotEmpty) {
final first = values.first.timestamp;
final last = values.last.timestamp;
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
'${values.length} values arrived while the app was closed '
'(${_time(first)} - ${_time(last)})',
),
),
);
}
} on VitalBluetoothException catch (e) {
_showError(e);
}
}
Future<void> _setBackground(bool enable) async {
try {
if (enable) {
await _bluetooth.enableBackgroundMode(
title: 'vital_bluetooth',
text: 'Keeping Bluetooth devices connected',
);
} else {
await _bluetooth.disableBackgroundMode();
}
if (mounted) setState(() => _background = enable);
} on VitalBluetoothException catch (e) {
_showError(e);
}
}
@override
void dispose() {
_scan?.cancel();
super.dispose();
}
Future<void> _checkPermission() async {
try {
final status = await _bluetooth.permissionStatus();
if (mounted) setState(() => _permission = status);
} on VitalBluetoothException catch (e) {
_showError(e);
}
}
Future<void> _requestPermission() async {
try {
final status = await _bluetooth.requestPermissions();
if (mounted) setState(() => _permission = status);
} on VitalBluetoothException catch (e) {
_showError(e);
}
}
Future<void> _turnOn() async {
try {
await _bluetooth.turnOn();
} on VitalBluetoothException catch (e) {
_showError(e);
}
}
void _toggleScan() {
if (_scan != null) {
_scan!.cancel();
setState(() => _scan = null);
return;
}
setState(() {
_results.clear();
_error = null;
_scan = _bluetooth
.scan(allowDuplicates: true, timeout: const Duration(seconds: 30))
.listen(
(r) => setState(() => _results[r.device.id] = r),
onError: (Object e) => _showError(e),
onDone: () {
if (mounted) setState(() => _scan = null);
},
);
});
}
void _open(BluetoothDevice device, String? name) {
setState(() => _myDevices[device.id] = (device, name));
Navigator.of(context).push(
MaterialPageRoute<void>(
builder: (_) => DevicePage(device: device, name: name),
),
);
}
void _showError(Object error) {
if (!mounted) return;
setState(() => _error = error.toString());
}
@override
Widget build(BuildContext context) {
// Kept in the order devices were first seen. Sorting by RSSI would make
// rows jump around, because RSSI swings by several dBm every second.
final results = _results.values.toList();
return Scaffold(
appBar: AppBar(title: const Text('vital_bluetooth')),
body: Column(
children: [
ValueListenableBuilder(
valueListenable: _bluetooth.adapterState,
builder: (context, state, _) => ListTile(
leading: Icon(
state.isOn ? Icons.bluetooth : Icons.bluetooth_disabled,
),
title: Text('Adapter: ${state.name}'),
trailing: state == AdapterState.poweredOff
? TextButton(onPressed: _turnOn, child: const Text('Turn on'))
: null,
),
),
ListTile(
leading: const Icon(Icons.lock_open),
title: Text('Permission: ${_permission?.name ?? '...'}'),
trailing: _permission == PermissionStatus.granted
? null
: TextButton(
onPressed: _requestPermission,
child: const Text('Request'),
),
),
SwitchListTile(
secondary: const Icon(Icons.nightlight_round),
title: const Text('Background mode'),
subtitle: const Text('Stay connected after the app is closed'),
value: _background,
onChanged: _setBackground,
),
if (_error != null)
Padding(
padding: const EdgeInsets.symmetric(horizontal: 16),
child: Text(
_error!,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
),
const Divider(),
Expanded(
child: ListView(
children: [
if (_myDevices.isNotEmpty) ...[
const _Header('My devices'),
for (final (device, name) in _myDevices.values)
_DeviceTile(
device: device,
name: name,
onTap: () => _open(device, name),
),
const _Header('Nearby'),
],
for (final r in results)
if (!_myDevices.containsKey(r.device.id))
_DeviceTile(
device: r.device,
name: r.name,
rssi: r.rssi,
enabled: r.connectable,
onTap: () => _open(r.device, r.name),
),
],
),
),
],
),
floatingActionButton: FloatingActionButton.extended(
onPressed: _toggleScan,
icon: Icon(_scan == null ? Icons.search : Icons.stop),
label: Text(_scan == null ? 'Scan' : 'Stop'),
),
);
}
}
class _Header extends StatelessWidget {
const _Header(this.text);
final String text;
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(16, 12, 16, 4),
child: Text(text, style: Theme.of(context).textTheme.titleSmall),
);
}
/// One device with its live connection status.
class _DeviceTile extends StatelessWidget {
const _DeviceTile({
required this.device,
required this.name,
required this.onTap,
this.rssi,
this.enabled = true,
});
final BluetoothDevice device;
final String? name;
final int? rssi;
final bool enabled;
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final label = name == null || name!.isEmpty ? '(no name)' : name!;
return ValueListenableBuilder(
valueListenable: device.connectionState,
builder: (context, state, _) {
final status = state.status;
final connected = status != DeviceConnectionStatus.disconnected;
return ListTile(
leading: Icon(
state.isReady
? Icons.bluetooth_connected
: connected
? Icons.bluetooth_searching
: Icons.bluetooth,
color: state.isReady ? Theme.of(context).colorScheme.primary : null,
),
title: ValueListenableBuilder(
valueListenable: device.name,
builder: (context, known, _) => Text(known ?? label),
),
subtitle: Text(
connected ? '${device.id} · ${status.name}' : device.id,
),
trailing: rssi == null ? null : Text('$rssi dBm'),
enabled: enabled || connected,
onTap: onTap,
);
},
);
}
}
String _time(DateTime t) =>
'${t.hour.toString().padLeft(2, '0')}:'
'${t.minute.toString().padLeft(2, '0')}:'
'${t.second.toString().padLeft(2, '0')}';