bluetooth_flutter 0.0.1 copy "bluetooth_flutter: ^0.0.1" to clipboard
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.

example/lib/main.dart

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);
}
1
likes
160
points
122
downloads

Documentation

API reference

Publisher

verified publishermanymath.com

Weekly Downloads

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.

Repository (GitHub)
View/report issues

Topics

#bluetooth #ble #btleplug #ffi

License

MIT (license)

Dependencies

bluetooth_dart, flutter

More

Packages that depend on bluetooth_flutter

Packages that implement bluetooth_flutter