Skip to main content

vital_bluetooth 0.1.0 copy "vital_bluetooth: ^0.1.0" to clipboard
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.

example/lib/main.dart

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')}';
0
likes
160
points
36
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

Bluetooth LE for wearables and healthcare devices that stays connected in the background: native reconnects, restored notifications, queued GATT and typed errors.

Repository (GitHub)
View/report issues

Topics

#bluetooth #ble #bluetooth-low-energy #wearables #healthcare

License

MIT (license)

Dependencies

flutter, meta

More

Packages that depend on vital_bluetooth

Packages that implement vital_bluetooth