2. Permissions
AndroidManifest.xml:
<uses-permission android:name="android.permission.BLUETOOTH_SCAN"
android:usesPermissionFlags="neverForLocation" />
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
<uses-feature android:name="android.hardware.bluetooth_le" android:required="true" />
iOS Info.plist:
<key>NSBluetoothAlwaysUsageDescription</key>
<string>This app connects to BLE sensors to display real-time data.</string>
See the complete permissions guide for all edge cases.
Architecture
The app uses Provider for state management with three layers:
lib/
├── main.dart
├── models/
│ └── sensor_data.dart # Data models
├── services/
│ ├── ble_scanner.dart # Scanning logic
│ ├── ble_connection.dart # Connection + GATT
│ └── ble_permission.dart # Permission management
├── providers/
│ └── ble_provider.dart # App-wide BLE state
└── screens/
├── scan_screen.dart # Device list
└── device_screen.dart # Connected device dashboard
Step 1: Data Models
// lib/models/sensor_data.dart
class SensorData {
final double temperature;
final double humidity;
final int batteryLevel;
final DateTime timestamp;
const SensorData({
required this.temperature,
required this.humidity,
required this.batteryLevel,
required this.timestamp,
});
factory SensorData.fromBytes(List<int> bytes) {
if (bytes.length < 5) return SensorData.empty();
return SensorData(
temperature: bytes[0] + bytes[1] / 100,
humidity: bytes[2].toDouble(),
batteryLevel: bytes[3],
timestamp: DateTime.now(),
);
}
factory SensorData.empty() => SensorData(
temperature: 0, humidity: 0, batteryLevel: 0, timestamp: DateTime.now(),
);
}
// Custom GATT UUIDs for our sensor device
class SensorGatt {
static const String serviceUuid = '12345678-1234-1234-1234-123456789abc';
static const String dataCharUuid = '12345678-1234-1234-1234-123456789abd'; // Notify
static const String cmdCharUuid = '12345678-1234-1234-1234-123456789abe'; // Write
static const int cmdStart = 0x01;
static const int cmdStop = 0x02;
static const int cmdReset = 0xFF;
}
Step 2: BLE Scanner Service
// lib/services/ble_scanner.dart
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
class BleScanner {
final Map<DeviceIdentifier, ScanResult> _results = {};
bool _isScanning = false;
List<ScanResult> get results => _results.values.toList()
..sort((a, b) => b.rssi.compareTo(a.rssi));
bool get isScanning => _isScanning;
Future<void> startScan({VoidCallback? onUpdate}) async {
_results.clear();
_isScanning = true;
FlutterBluePlus.scanResults.listen((results) {
for (final r in results) {
if (r.device.platformName.isNotEmpty) {
_results[r.device.remoteId] = r;
onUpdate?.call();
}
}
});
FlutterBluePlus.isScanning.listen((scanning) {
_isScanning = scanning;
onUpdate?.call();
});
await FlutterBluePlus.startScan(
withServices: [], // Filter by SensorGatt.serviceUuid in production
timeout: const Duration(seconds: 10),
androidScanMode: AndroidScanMode.lowLatency,
);
}
Future<void> stopScan() async {
await FlutterBluePlus.stopScan();
}
}
Step 3: BLE Connection Service
// lib/services/ble_connection.dart
import 'dart:async';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import '../models/sensor_data.dart';
class BleConnection {
BluetoothDevice? _device;
BluetoothCharacteristic? _dataChar;
BluetoothCharacteristic? _cmdChar;
StreamSubscription? _connectionSub;
StreamSubscription? _dataSub;
final _sensorDataController = StreamController<SensorData>.broadcast();
final _connectionStateController = StreamController<BluetoothConnectionState>.broadcast();
Stream<SensorData> get sensorStream => _sensorDataController.stream;
Stream<BluetoothConnectionState> get connectionStream => _connectionStateController.stream;
BluetoothDevice? get connectedDevice => _device;
Future<void> connect(BluetoothDevice device) async {
_device = device;
// Monitor connection state
_connectionSub = device.connectionState.listen((state) {
_connectionStateController.add(state);
if (state == BluetoothConnectionState.disconnected) {
_handleDisconnect();
}
});
// Connect
await device.connect(
timeout: const Duration(seconds: 15),
autoConnect: false,
);
// Discover services and set up characteristics
await _setupGatt();
}
Future<void> _setupGatt() async {
if (_device == null) return;
final services = await _device!.discoverServices();
for (final svc in services) {
if (svc.uuid.str128.toLowerCase() == SensorGatt.serviceUuid.toLowerCase()) {
for (final char in svc.characteristics) {
final uuid = char.uuid.str128.toLowerCase();
if (uuid == SensorGatt.dataCharUuid.toLowerCase()) {
_dataChar = char;
}
if (uuid == SensorGatt.cmdCharUuid.toLowerCase()) {
_cmdChar = char;
}
}
}
}
// Subscribe to sensor data notifications
if (_dataChar != null && _dataChar!.properties.notify) {
await _dataChar!.setNotifyValue(true);
_dataSub = _dataChar!.lastValueStream.listen((bytes) {
if (bytes.isNotEmpty) {
_sensorDataController.add(SensorData.fromBytes(bytes));
}
});
}
}
Future<void> sendCommand(int command) async {
if (_cmdChar == null) return;
try {
await _cmdChar!.write([command], withoutResponse: true);
} catch (e) {
print('Command error: $e');
}
}
void _handleDisconnect() {
_dataSub?.cancel();
_dataChar = null;
_cmdChar = null;
}
Future<void> disconnect() async {
await _dataSub?.cancel();
await _dataChar?.setNotifyValue(false);
await _device?.disconnect();
await _connectionSub?.cancel();
}
void dispose() {
disconnect();
_sensorDataController.close();
_connectionStateController.close();
}
}
Step 4: BLE Provider (App State)
// lib/providers/ble_provider.dart
import 'package:flutter/material.dart';
import '../services/ble_scanner.dart';
import '../services/ble_connection.dart';
import '../models/sensor_data.dart';
class BleProvider extends ChangeNotifier {
final BleScanner _scanner = BleScanner();
final BleConnection _connection = BleConnection();
List<ScanResult> get scanResults => _scanner.results;
bool get isScanning => _scanner.isScanning;
Stream<SensorData> get sensorStream => _connection.sensorStream;
Stream<BluetoothConnectionState> get connectionStream => _connection.connectionStream;
Future<void> startScan() async {
await _scanner.startScan(onUpdate: notifyListeners);
notifyListeners();
}
Future<void> stopScan() async {
await _scanner.stopScan();
notifyListeners();
}
Future<void> connectTo(BluetoothDevice device) async {
await _scanner.stopScan();
await _connection.connect(device);
notifyListeners();
}
Future<void> sendCommand(int cmd) => _connection.sendCommand(cmd);
Future<void> disconnect() async {
await _connection.disconnect();
notifyListeners();
}
@override
void dispose() {
_connection.dispose();
super.dispose();
}
}
Step 5: Scan Screen
// lib/screens/scan_screen.dart
class ScanScreen extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Consumer<BleProvider>(
builder: (context, ble, _) => Scaffold(
appBar: AppBar(
title: const Text('BLE Sensor Dashboard'),
actions: [
if (ble.isScanning)
const Padding(padding: EdgeInsets.all(16),
child: SizedBox(width: 20, height: 20,
child: CircularProgressIndicator(color: Colors.white, strokeWidth: 2))),
],
),
body: ble.scanResults.isEmpty
? Center(child: Text(ble.isScanning ? 'Scanning...' : 'No devices found'))
: ListView.builder(
itemCount: ble.scanResults.length,
itemBuilder: (ctx, i) {
final r = ble.scanResults[i];
return ListTile(
leading: const Icon(Icons.bluetooth),
title: Text(r.device.platformName.isEmpty
? 'Unknown Device' : r.device.platformName),
subtitle: Text(r.device.remoteId.toString()),
trailing: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text('${r.rssi} dBm'),
Text(_rssiLabel(r.rssi),
style: TextStyle(color: _rssiColor(r.rssi), fontSize: 10)),
],
),
onTap: () {
ble.connectTo(r.device);
Navigator.push(ctx, MaterialPageRoute(
builder: (_) => DeviceScreen(device: r.device)));
},
);
},
),
floatingActionButton: FloatingActionButton.extended(
onPressed: ble.isScanning ? ble.stopScan : ble.startScan,
icon: Icon(ble.isScanning ? Icons.stop : Icons.search),
label: Text(ble.isScanning ? 'Stop' : 'Scan'),
),
),
);
}
String _rssiLabel(int rssi) {
if (rssi >= -55) return 'Excellent';
if (rssi >= -70) return 'Good';
if (rssi >= -85) return 'Fair';
return 'Weak';
}
Color _rssiColor(int rssi) {
if (rssi >= -55) return Colors.green;
if (rssi >= -70) return Colors.orange;
return Colors.red;
}
}
Step 6: Device Dashboard Screen
// lib/screens/device_screen.dart
class DeviceScreen extends StatelessWidget {
final BluetoothDevice device;
const DeviceScreen({required this.device});
@override
Widget build(BuildContext context) {
return Consumer<BleProvider>(
builder: (context, ble, _) => Scaffold(
appBar: AppBar(
title: Text(device.platformName.isEmpty ? 'Device' : device.platformName),
actions: [
StreamBuilder<BluetoothConnectionState>(
stream: ble.connectionStream,
builder: (_, snap) {
final connected = snap.data == BluetoothConnectionState.connected;
return Padding(padding: const EdgeInsets.all(16),
child: Icon(connected ? Icons.bluetooth_connected : Icons.bluetooth_disabled,
color: connected ? Colors.green : Colors.grey));
},
),
],
),
body: StreamBuilder<SensorData>(
stream: ble.sensorStream,
builder: (_, snap) {
final data = snap.data ?? SensorData.empty();
return Padding(
padding: const EdgeInsets.all(24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_DataCard('Temperature', '${data.temperature.toStringAsFixed(1)}°C', Icons.thermostat),
_DataCard('Humidity', '${data.humidity.toStringAsFixed(0)}%', Icons.water_drop),
_DataCard('Battery', '${data.batteryLevel}%', Icons.battery_std),
const Spacer(),
Row(children: [
Expanded(child: ElevatedButton.icon(
onPressed: () => ble.sendCommand(SensorGatt.cmdStart),
icon: const Icon(Icons.play_arrow),
label: const Text('Start'),
)),
const SizedBox(width: 12),
Expanded(child: OutlinedButton.icon(
onPressed: () => ble.sendCommand(SensorGatt.cmdStop),
icon: const Icon(Icons.stop),
label: const Text('Stop'),
)),
]),
const SizedBox(height: 12),
SizedBox(width: double.infinity, child: TextButton(
onPressed: () { ble.disconnect(); Navigator.pop(context); },
child: const Text('Disconnect'),
)),
],
),
);
},
),
),
);
}
}
class _DataCard extends StatelessWidget {
final String label;
final String value;
final IconData icon;
const _DataCard(this.label, this.value, this.icon);
@override
Widget build(BuildContext context) => Card(
margin: const EdgeInsets.only(bottom: 12),
child: ListTile(
leading: Icon(icon, size: 32),
title: Text(label, style: const TextStyle(fontSize: 14, color: Colors.grey)),
subtitle: Text(value, style: const TextStyle(fontSize: 28, fontWeight: FontWeight.bold)),
),
);
}
Step 7: main.dart
void main() {
runApp(
ChangeNotifierProvider(
create: (_) => BleProvider(),
child: const MyApp(),
),
);
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) => MaterialApp(
title: 'BLE Sensor Dashboard',
theme: ThemeData(colorSchemeSeed: Colors.blue, useMaterial3: true),
home: const PermissionGate(),
);
}
class PermissionGate extends StatefulWidget {
const PermissionGate({super.key});
@override
State<PermissionGate> createState() => _PermissionGateState();
}
class _PermissionGateState extends State<PermissionGate> {
bool _ready = false;
@override
void initState() {
super.initState();
_init();
}
Future<void> _init() async {
final result = await BlePermissionManager.checkAndRequest();
setState(() => _ready = result == BlePermissionResult.ready);
}
@override
Widget build(BuildContext context) {
if (!_ready) return const Scaffold(body: Center(child: CircularProgressIndicator()));
return ScanScreen();
}
}
Hardware Pairing
This app works with any BLE peripheral that exposes the SensorGatt UUIDs. For development, use:
- ESP32 — easiest to prototype with. See ESP32 vs Arduino for Flutter BLE
- Arduino + HM-10 — classic setup with AT commands
- nRF52840 — production-grade, most BLE features
Testing on Real Devices
BLE cannot be tested in the iOS Simulator or Android Emulator. You need real devices:
- Enable Developer Mode on your phone
- Connect via USB
- Run
flutter run --device-id <your-device-id>
- Use nRF Connect app on a second phone to simulate a BLE peripheral
Related Guides
Frequently Asked Questions
How do I test my Flutter BLE app without real BLE hardware?
Use the nRF Connect mobile app on a second phone to simulate a BLE peripheral — it lets you create custom services, characteristics, and send notifications. For the iOS Simulator and Android Emulator, BLE is not supported; you need a real device.
What's the best architecture for a production Flutter BLE app?
Separate BLE logic into service classes (scanner, connection, data handler), expose state through a ChangeNotifier provider or Riverpod, and never put BLE code directly in widgets. The pattern above (Services + Provider) scales well to real apps.
How do I handle BLE disconnections and auto-reconnect?
Listen to device.connectionState and on disconnected, either notify the user or trigger a reconnect loop with exponential backoff. Re-run discoverServices() and re-subscribe to notifications after reconnecting — they don't persist across connections.
Can this Flutter BLE app run in the background?
On iOS, background BLE requires declaring bluetooth-central in UIBackgroundModes and filtering scans by service UUID. On Android, use a Foreground Service. Both platforms impose heavy restrictions on background BLE to protect battery life.
How do I add OTA firmware updates to my Flutter BLE app?
OTA over BLE requires chunked writes to a firmware characteristic (usually using Nordic DFU or a custom protocol), checksum verification, and a device bootloader that accepts and applies the update. Our ESP32 OTA over BLE guide walks through a working implementation.
Is this app production-ready?
The architecture above is production-quality for most use cases. For shipping apps, add: (1) exponential backoff reconnect logic, (2) persistent device ID storage so users don't re-scan every launch, (3) comprehensive error UI, and (4) background mode support if needed.
Ship Your BLE App
You now have a complete, production-architecture Flutter BLE app: permissions, scanning, connection management, GATT data streaming, command sending, and proper cleanup. Every pattern here maps directly to real shipping apps.