flutter_simple_ble 1.0.0 copy "flutter_simple_ble: ^1.0.0" to clipboard
flutter_simple_ble: ^1.0.0 copied to clipboard

A lightweight Flutter plugin for Bluetooth Low Energy scanning, connections, service discovery, MTU handling, and characteristic writes on Android and iOS.

example/lib/main.dart

// SPDX-FileCopyrightText: 2026 Aleksandr <https://github.com/Wratheus>
// SPDX-License-Identifier: BSD-3-Clause

import 'dart:async';

import 'package:flutter/material.dart';
import 'package:flutter_simple_ble/flutter_simple_ble.dart';

void main() => runApp(const BleExampleApp());

class BleExampleApp extends StatelessWidget {
  const BleExampleApp({super.key});

  @override
  Widget build(BuildContext context) => MaterialApp(
    debugShowCheckedModeBanner: false,
    title: 'Simple BLE',
    theme: ThemeData(
      colorScheme: ColorScheme.fromSeed(seedColor: const Color(0xFF006E5A)),
      useMaterial3: true,
    ),
    home: const BleInspectorPage(),
  );
}

class BleInspectorPage extends StatefulWidget {
  const BleInspectorPage({super.key});

  @override
  State<BleInspectorPage> createState() => _BleInspectorPageState();
}

enum _ConnectionPhase { disconnected, connecting, connected, disconnecting }

class _BleInspectorPageState extends State<BleInspectorPage> {
  final Map<BleRemoteId, BleScanResult> _scanResults =
      <BleRemoteId, BleScanResult>{};
  final Set<BleRemoteId> _hiddenDeviceIds = <BleRemoteId>{};
  final TextEditingController _valueController = TextEditingController(
    text: '01',
  );

  StreamSubscription<BleAdapterState>? _adapterSubscription;
  StreamSubscription<BleScanResult>? _scanSubscription;
  StreamSubscription<BleConnectionState>? _connectionSubscription;
  BleAdapterState _adapterState = BleAdapterState.unknown;
  BleDevice? _device;
  _ConnectionPhase _connectionPhase = _ConnectionPhase.disconnected;
  BleCharacteristic? _selectedCharacteristic;
  List<BleService> _services = const <BleService>[];
  bool _isSupported = false;
  bool _isScanning = false;
  bool _isWorking = false;
  bool _connectionCancelRequested = false;
  String? _operationLabel;
  String? _error;

  @override
  void initState() {
    super.initState();
    _adapterSubscription = Ble.adapterState.listen((BleAdapterState state) {
      if (mounted) setState(() => _adapterState = state);
    }, onError: _setError);
    _loadSupport();
  }

  @override
  void dispose() {
    unawaited(_adapterSubscription?.cancel());
    unawaited(_scanSubscription?.cancel());
    unawaited(_connectionSubscription?.cancel());
    _valueController.dispose();
    super.dispose();
  }

  Future<void> _loadSupport() async {
    try {
      final bool supported = await Ble.isSupported;
      if (mounted) setState(() => _isSupported = supported);
    } catch (error) {
      _setError(error);
    }
  }

  Future<void> _startScan() async {
    await _stopScan();
    if (!mounted) return;

    setState(() {
      _scanResults.clear();
      _hiddenDeviceIds.clear();
      _error = null;
      _isScanning = true;
    });

    late final StreamSubscription<BleScanResult> subscription;
    subscription = Ble.scan(timeout: const Duration(seconds: 12)).listen(
      (BleScanResult result) {
        if (mounted) {
          setState(() {
            final BleRemoteId remoteId = result.device.remoteId;
            if (_deviceNameOrNull(result.device) == null) {
              _hiddenDeviceIds.add(remoteId);
              return;
            }
            _hiddenDeviceIds.remove(remoteId);
            _scanResults[remoteId] = result;
          });
        }
      },
      onError: _setError,
      onDone: () {
        if (mounted && identical(_scanSubscription, subscription)) {
          setState(() => _isScanning = false);
        }
      },
    );
    _scanSubscription = subscription;
  }

  Future<void> _stopScan() async {
    final StreamSubscription<BleScanResult>? subscription = _scanSubscription;
    _scanSubscription = null;
    await subscription?.cancel();
    if (mounted && _isScanning) setState(() => _isScanning = false);
  }

  Future<void> _connect(BleDevice device) async {
    await _stopScan();
    await _connectionSubscription?.cancel();
    _connectionSubscription = device.connectionState.listen((
      BleConnectionState state,
    ) {
      if (!mounted) return;
      setState(() {
        if (state == BleConnectionState.connected) {
          _connectionPhase = _ConnectionPhase.connected;
        } else if (_connectionPhase != _ConnectionPhase.connecting) {
          _connectionPhase = _ConnectionPhase.disconnected;
        }
      });
    }, onError: _setError);
    if (!mounted) return;
    setState(() {
      _device = device;
      _connectionPhase = _ConnectionPhase.connecting;
      _connectionCancelRequested = false;
      _services = const <BleService>[];
      _selectedCharacteristic = null;
      _error = null;
    });
    _setWorking(true, 'Connecting to ${_deviceName(device)}');
    try {
      await device.connect(mtu: null);
      if (mounted) {
        setState(() {
          _connectionPhase = _ConnectionPhase.connected;
        });
      }
    } catch (error) {
      if (mounted) {
        setState(() => _connectionPhase = _ConnectionPhase.disconnected);
      }
      if (!_connectionCancelRequested) _setError(error);
    } finally {
      _setWorking(false);
    }
  }

  Future<void> _cancelConnection() async {
    final BleDevice? device = _device;
    if (device == null || _connectionPhase != _ConnectionPhase.connecting) {
      return;
    }
    setState(() {
      _connectionCancelRequested = true;
      _connectionPhase = _ConnectionPhase.disconnecting;
      _operationLabel = 'Cancelling connection';
    });
    try {
      await device.disconnect();
      if (mounted) {
        setState(() => _connectionPhase = _ConnectionPhase.disconnected);
      }
    } catch (error) {
      _setError(error);
    } finally {
      _setWorking(false);
    }
  }

  Future<void> _disconnect() async {
    final BleDevice? device = _device;
    if (device == null) return;
    if (mounted) {
      setState(() => _connectionPhase = _ConnectionPhase.disconnecting);
    }
    _setWorking(true, 'Disconnecting from ${_deviceName(device)}');
    try {
      await device.disconnect();
      if (mounted) {
        setState(() {
          _connectionPhase = _ConnectionPhase.disconnected;
          _services = const <BleService>[];
          _selectedCharacteristic = null;
        });
      }
    } catch (error) {
      _setError(error);
    } finally {
      _setWorking(false);
    }
  }

  Future<void> _discoverServices() async {
    final BleDevice? device = _device;
    if (device == null) return;
    _setWorking(true, 'Discovering services');
    try {
      final List<BleService> services = await device.discoverServices();
      if (mounted) {
        setState(() {
          _services = services;
          _selectedCharacteristic = null;
          _error = null;
        });
      }
    } catch (error) {
      _setError(error);
    } finally {
      _setWorking(false);
    }
  }

  Future<void> _requestMtu() async {
    final BleDevice? device = _device;
    if (device == null) return;
    _setWorking(true, 'Requesting MTU');
    try {
      await device.requestMtu(247);
      if (mounted) setState(() => _error = null);
    } catch (error) {
      _setError(error);
    } finally {
      _setWorking(false);
    }
  }

  Future<void> _write() async {
    final BleCharacteristic? characteristic = _selectedCharacteristic;
    if (characteristic == null) return;
    _setWorking(true, 'Writing characteristic');
    try {
      final List<int> value = _parseHex(_valueController.text);
      await characteristic.write(
        value,
        withoutResponse:
            !characteristic.properties.write &&
            characteristic.properties.writeWithoutResponse,
      );
      if (mounted) setState(() => _error = null);
    } catch (error) {
      _setError(error);
    } finally {
      _setWorking(false);
    }
  }

  void _setWorking(bool value, [String? label]) {
    if (mounted) {
      setState(() {
        _isWorking = value;
        _operationLabel = value ? label : null;
      });
    }
  }

  void _setError(Object error, [StackTrace? stackTrace]) {
    if (mounted) setState(() => _error = error.toString());
  }

  List<int> _parseHex(String value) {
    final String normalized = value.replaceAll(RegExp(r'[^0-9a-fA-F]'), '');
    if (normalized.isEmpty || normalized.length.isOdd) {
      throw const FormatException('Use complete hex bytes, for example: 01 FF');
    }
    return List<int>.generate(
      normalized.length ~/ 2,
      (int index) =>
          int.parse(normalized.substring(index * 2, index * 2 + 2), radix: 16),
    );
  }

  @override
  Widget build(BuildContext context) {
    final List<BleScanResult> results =
        _scanResults.values
            .where(
              (BleScanResult result) =>
                  result.device.remoteId != _device?.remoteId,
            )
            .toList()
          ..sort(
            (BleScanResult first, BleScanResult second) =>
                second.rssi.compareTo(first.rssi),
          );

    return Scaffold(
      appBar: AppBar(
        title: const Text('Simple BLE inspector'),
        actions: <Widget>[
          IconButton(
            onPressed: _loadSupport,
            tooltip: 'Refresh adapter state',
            icon: const Icon(Icons.refresh),
          ),
        ],
      ),
      body: ListView(
        padding: const EdgeInsets.all(16),
        children: <Widget>[
          Card(
            child: Padding(
              padding: const EdgeInsets.all(16),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                spacing: 8,
                children: <Widget>[
                  Text(
                    'Adapter',
                    style: Theme.of(context).textTheme.titleLarge,
                  ),
                  Text('BLE supported: ${_isSupported ? 'yes' : 'no'}'),
                  Text('State: ${_adapterLabel(_adapterState)}'),
                ],
              ),
            ),
          ),
          Padding(
            padding: const EdgeInsets.only(top: 12),
            child: FilledButton.icon(
              onPressed: _isWorking
                  ? null
                  : _isScanning
                  ? _stopScan
                  : _startScan,
              icon: Icon(_isScanning ? Icons.stop : Icons.radar),
              label: Text(_isScanning ? 'Stop scan' : 'Scan for devices'),
            ),
          ),
          if (_error case final String error)
            Padding(
              padding: const EdgeInsets.only(top: 12),
              child: Card(
                color: Theme.of(context).colorScheme.errorContainer,
                child: Padding(
                  padding: const EdgeInsets.all(12),
                  child: Text(error),
                ),
              ),
            ),
          if (_isWorking)
            Padding(
              padding: const EdgeInsets.only(top: 12),
              child: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                spacing: 8,
                children: <Widget>[
                  const LinearProgressIndicator(),
                  if (_operationLabel case final String label) Text(label),
                ],
              ),
            ),
          if (_device case final BleDevice device)
            Padding(
              padding: const EdgeInsets.only(top: 16),
              child: _deviceCard(context, device),
            ),
          if (_services.isNotEmpty)
            Padding(
              padding: const EdgeInsets.only(top: 16),
              child: Text(
                'Services',
                style: Theme.of(context).textTheme.titleLarge,
              ),
            ),
          ..._services.map(_serviceCard),
          if (_selectedCharacteristic
              case final BleCharacteristic characteristic)
            Padding(
              padding: const EdgeInsets.only(top: 16),
              child: _writeCard(context, characteristic),
            ),
          Padding(
            padding: const EdgeInsets.only(top: 16),
            child: Wrap(
              alignment: WrapAlignment.spaceBetween,
              spacing: 12,
              runSpacing: 4,
              children: <Widget>[
                Text(
                  'Named devices',
                  style: Theme.of(context).textTheme.titleLarge,
                ),
                if (_hiddenDeviceIds.isNotEmpty)
                  Text(
                    '${_hiddenDeviceIds.length} unnamed hidden',
                    style: Theme.of(context).textTheme.bodySmall,
                  ),
              ],
            ),
          ),
          if (results.isEmpty)
            Padding(
              padding: const EdgeInsets.symmetric(vertical: 24),
              child: Text(
                _isScanning
                    ? 'Looking for named BLE devices...'
                    : _hiddenDeviceIds.isEmpty
                    ? 'Start a scan to find nearby BLE devices.'
                    : 'No named BLE devices found.',
              ),
            ),
          ...results.map(
            (BleScanResult result) => Card(
              child: ListTile(
                title: Text(_deviceName(result.device)),
                subtitle: Text(
                  '${result.device.remoteId}\nRSSI ${result.rssi} dBm',
                ),
                isThreeLine: true,
                trailing: TextButton(
                  onPressed:
                      _isWorking ||
                          _connectionPhase == _ConnectionPhase.connected
                      ? null
                      : () => _connect(result.device),
                  child: const Text('Connect'),
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }

  Widget _deviceCard(BuildContext context, BleDevice device) => Card(
    child: Padding(
      padding: const EdgeInsets.all(16),
      child: Column(
        crossAxisAlignment: CrossAxisAlignment.start,
        spacing: 12,
        children: <Widget>[
          Row(
            spacing: 12,
            children: <Widget>[
              if (_connectionPhase == _ConnectionPhase.connecting ||
                  _connectionPhase == _ConnectionPhase.disconnecting)
                const SizedBox.square(
                  dimension: 20,
                  child: CircularProgressIndicator(strokeWidth: 2),
                )
              else
                Icon(
                  _connectionPhase == _ConnectionPhase.connected
                      ? Icons.bluetooth_connected
                      : Icons.bluetooth_disabled,
                ),
              Expanded(
                child: Text(
                  _deviceName(device),
                  style: Theme.of(context).textTheme.titleLarge,
                ),
              ),
              Text(_connectionLabel(_connectionPhase)),
            ],
          ),
          Text(device.remoteId.str),
          if (_connectionPhase == _ConnectionPhase.connected)
            Text('MTU: ${device.mtuNow}'),
          Wrap(
            spacing: 8,
            runSpacing: 8,
            children: <Widget>[
              OutlinedButton(
                onPressed:
                    _isWorking || _connectionPhase != _ConnectionPhase.connected
                    ? null
                    : _discoverServices,
                child: const Text('Discover services'),
              ),
              OutlinedButton(
                onPressed:
                    _isWorking || _connectionPhase != _ConnectionPhase.connected
                    ? null
                    : _requestMtu,
                child: const Text('Request MTU 247'),
              ),
              if (_connectionPhase == _ConnectionPhase.connected)
                TextButton(
                  onPressed: _isWorking ? null : _disconnect,
                  child: const Text('Disconnect'),
                )
              else if (_connectionPhase == _ConnectionPhase.connecting)
                TextButton(
                  onPressed: _cancelConnection,
                  child: const Text('Cancel connection'),
                )
              else if (_connectionPhase == _ConnectionPhase.disconnected)
                TextButton(
                  onPressed: _isWorking ? null : () => _connect(device),
                  child: const Text('Reconnect'),
                ),
            ],
          ),
        ],
      ),
    ),
  );

  Widget _serviceCard(BleService service) => Card(
    child: ExpansionTile(
      title: Text(service.uuid),
      subtitle: Text(
        service.isPrimary ? 'Primary service' : 'Included service',
      ),
      children: service.characteristics
          .map(
            (BleCharacteristic characteristic) => ListTile(
              title: Text(characteristic.uuid),
              subtitle: Text(_propertiesLabel(characteristic.properties)),
              trailing: _isWritable(characteristic)
                  ? const Icon(Icons.edit)
                  : null,
              onTap: _isWritable(characteristic)
                  ? () =>
                        setState(() => _selectedCharacteristic = characteristic)
                  : null,
            ),
          )
          .toList(growable: false),
    ),
  );

  Widget _writeCard(BuildContext context, BleCharacteristic characteristic) =>
      Card(
        child: Padding(
          padding: const EdgeInsets.all(16),
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            spacing: 12,
            children: <Widget>[
              Text(
                'Write characteristic',
                style: Theme.of(context).textTheme.titleLarge,
              ),
              Text(characteristic.uuid),
              TextField(
                controller: _valueController,
                enabled: !_isWorking,
                decoration: const InputDecoration(
                  border: OutlineInputBorder(),
                  labelText: 'Hex bytes',
                  hintText: '01 FF A0',
                ),
                textCapitalization: TextCapitalization.characters,
              ),
              FilledButton.icon(
                onPressed: _isWorking ? null : _write,
                icon: const Icon(Icons.send),
                label: const Text('Write'),
              ),
            ],
          ),
        ),
      );

  bool _isWritable(BleCharacteristic characteristic) =>
      characteristic.properties.write ||
      characteristic.properties.writeWithoutResponse;

  String _deviceName(BleDevice device) {
    return _deviceNameOrNull(device) ?? 'Unnamed device';
  }

  String? _deviceNameOrNull(BleDevice device) {
    final String advName = device.advName.trim();
    if (advName.isNotEmpty) return advName;
    final String platformName = device.platformName.trim();
    if (platformName.isNotEmpty) return platformName;
    return null;
  }

  String _connectionLabel(_ConnectionPhase phase) => switch (phase) {
    _ConnectionPhase.disconnected => 'Disconnected',
    _ConnectionPhase.connecting => 'Connecting',
    _ConnectionPhase.connected => 'Connected',
    _ConnectionPhase.disconnecting => 'Disconnecting',
  };

  String _propertiesLabel(BleCharacteristicProperties properties) {
    final List<String> labels = <String>[];
    if (properties.read) labels.add('read');
    if (properties.write) labels.add('write');
    if (properties.writeWithoutResponse) labels.add('write without response');
    if (properties.notify) labels.add('notify');
    if (properties.indicate) labels.add('indicate');
    return labels.isEmpty ? 'No supported operations' : labels.join(' | ');
  }

  String _adapterLabel(BleAdapterState state) => switch (state) {
    BleAdapterState.unknown => 'unknown',
    BleAdapterState.unavailable => 'unavailable',
    BleAdapterState.unauthorized => 'permission required',
    BleAdapterState.turningOn => 'turning on',
    BleAdapterState.on => 'on',
    BleAdapterState.turningOff => 'turning off',
    BleAdapterState.off => 'off',
  };
}
0
likes
150
points
--
downloads

Documentation

API reference

Publisher

unverified uploader

Weekly Downloads

A lightweight Flutter plugin for Bluetooth Low Energy scanning, connections, service discovery, MTU handling, and characteristic writes on Android and iOS.

Repository (GitHub)
View/report issues

Topics

#bluetooth #ble #android #ios

License

BSD-3-Clause (license)

Dependencies

flutter, plugin_platform_interface

More

Packages that depend on flutter_simple_ble

Packages that implement flutter_simple_ble