Flutter Bluetooth Mastery: BLE vs Classic with BLoC (The Guide Everyone Wishes They Had) (Part 2)
FlutterPulse},
);
},
),
],
),
body: BlocConsumer<BluetoothBloc, BluetoothState>(
listener: (context, state) {
if (state is BluetoothError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: Colors.red,
),
);
}
if (state is BluetoothPermissionDenied) {
_showPermissionDialog(context);
}
},
builder: (context, state) {
if (state is BluetoothLoading) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: 16),
Text(state.message),
],
),
);
}
if (state is BluetoothNotAvailable) {
return _buildNotAvailable(state.message);
}
if (state is BluetoothOff) {
return _buildBluetoothOff(context);
}
if (state is BluetoothPermissionDenied) {
return _buildPermissionDenied(context);
}
if (state is BluetoothScanning) {
return _buildDeviceList(context, state.devices, isScanning: true);
}
if (state is BluetoothScanComplete) {
return _buildDeviceList(context, state.devices, isScanning: false);
}
if (state is BluetoothReady) {
return _buildReadyState(context);
}
return _buildInitialState(context);
},
),
);
}
Widget _buildNotAvailable(String message) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.bluetooth_disabled,
size: 64,
color: Colors.grey,
),
const SizedBox(height: 16),
Text(
message,
style: const TextStyle(fontSize: 18),
textAlign: TextAlign.center,
),
],
),
);
}
Widget _buildBluetoothOff(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.bluetooth_disabled,
size: 64,
color: Colors.orange,
),
const SizedBox(height: 16),
const Text(
'Bluetooth is off',
style: TextStyle(fontSize: 18),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () {
context.read<BluetoothBloc>().add(TurnOnBluetooth());
},
child: const Text('Turn On Bluetooth'),
),
],
),
);
}
Widget _buildPermissionDenied(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.warning,
size: 64,
color: Colors.orange,
),
const SizedBox(height: 16),
const Text(
'Bluetooth permission required',
style: TextStyle(fontSize: 18),
textAlign: TextAlign.center,
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () {
context
.read<BluetoothBloc>()
.add(RequestBluetoothPermission());
},
child: const Text('Grant Permission'),
),
],
),
);
}
Widget _buildInitialState(BuildContext context) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.bluetooth_searching,
size: 64,
color: Colors.blue,
),
const SizedBox(height: 16),
const Text(
'Ready to scan',
style: TextStyle(fontSize: 18),
),
const SizedBox(height: 24),
ElevatedButton.icon(
onPressed: () => _startScan(context),
icon: const Icon(Icons.search),
label: const Text('Start Scanning'),
),
],
),
);
}
Widget _buildReadyState(BuildContext context) {
return _buildInitialState(context);
}
Widget _buildDeviceList(
BuildContext context,
List<BleDevice> devices, {
required bool isScanning,
}) {
if (devices.isEmpty) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (isScanning)
const CircularProgressIndicator()
else
const Icon(
Icons.bluetooth_searching,
size: 64,
color: Colors.grey,
),
const SizedBox(height: 16),
Text(
isScanning ? 'Searching for devices...' : 'No devices found',
style: const TextStyle(fontSize: 18),
),
if (!isScanning) ...[
const SizedBox(height: 8),
const Text(
'Make sure your device is turned on and in range',
style: TextStyle(color: Colors.grey),
textAlign: TextAlign.center,
),
],
],
),
);
}
return RefreshIndicator(
onRefresh: () async {
_startScan(context);
},
child: ListView.builder(
itemCount: devices.length + (isScanning ? 1 : 0),
itemBuilder: (context, index) {
if (isScanning && index == 0) {
return const Padding(
padding: EdgeInsets.all(16.0),
child: Row(
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
),
SizedBox(width: 16),
Text('Scanning...'),
],
),
);
}
final deviceIndex = isScanning ? index - 1 : index;
final device = devices[deviceIndex];
return DeviceTile(
device: device,
onTap: () => _connectToDevice(context, device),
);
},
),
);
}
void _startScan(BuildContext context) {
context.read<BluetoothBloc>().add(
const StartBluetoothScan(
timeout: Duration(seconds: 15),
),
);
}
void _connectToDevice(BuildContext context, BleDevice device) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => DeviceDetailScreen(device: device),
),
);
}
void _showPermissionDialog(BuildContext context) {
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Permission Required'),
content: const Text(
'This app needs Bluetooth permission to scan for devices. '
'Please grant the permission to continue.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () {
Navigator.pop(context);
context
.read<BluetoothBloc>()
.add(RequestBluetoothPermission());
},
child: const Text('Grant Permission'),
),
],
),
);
}
}
7. Create Device Tile Widget
// features/bluetooth/presentation/widgets/device_tile.dart
import 'package:flutter/material.dart';
import '../../data/models/ble_device.dart';
class DeviceTile extends StatelessWidget {
final BleDevice device;
final VoidCallback onTap;
const DeviceTile({
required this.device,
required this.onTap,
super.key,
});
@override
Widget build(BuildContext context) {
return Card(
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: ListTile(
leading: Container(
width: 48,
height: 48,
decoration: BoxDecoration(
color: _getSignalColor(device.rssi).withOpacity(0.1),
borderRadius: BorderRadius.circular(8),
),
child: Icon(
Icons.bluetooth,
color: _getSignalColor(device.rssi),
),
),
title: Text(
device.name,
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 16,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 4),
Text(
'ID: ${device.id}',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
const SizedBox(height: 4),
Row(
children: [
Icon(
Icons.signal_cellular_alt,
size: 14,
color: _getSignalColor(device.rssi),
),
const SizedBox(width: 4),
Text(
'${device.rssi} dBm',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(
horizontal: 6,
vertical: 2,
),
decoration: BoxDecoration(
color: _getSignalColor(device.rssi).withOpacity(0.1),
borderRadius: BorderRadius.circular(4),
),
child: Text(
device.signalQuality,
style: TextStyle(
fontSize: 10,
color: _getSignalColor(device.rssi),
fontWeight: FontWeight.bold,
),
),
),
const SizedBox(width: 8),
Icon(
Icons.location_on,
size: 14,
color: Colors.grey[600],
),
const SizedBox(width: 4),
Text(
'~${device.estimatedDistance}',
style: TextStyle(
fontSize: 12,
color: Colors.grey[600],
),
),
],
),
],
),
trailing: const Icon(Icons.arrow_forward_ios, size: 16),
onTap: onTap,
),
);
}
Color _getSignalColor(int rssi) {
if (rssi >= -50) return Colors.green;
if (rssi >= -60) return Colors.lightGreen;
if (rssi >= -70) return Colors.orange;
return Colors.red;
}
}
8. Build Device Detail Screen
// features/bluetooth/presentation/screens/device_detail_screen.dart
import 'package:flutter/material.dart';
import 'package:flutter_bloc/flutter_bloc.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
import '../../data/models/ble_device.dart';
import '../../data/repositories/bluetooth_repository.dart';
import '../bloc/bluetooth_bloc.dart';
import '../bloc/bluetooth_event.dart';
import '../bloc/bluetooth_state.dart';
class DeviceDetailScreen extends StatelessWidget {
final BleDevice device;
const DeviceDetailScreen({
required this.device,
super.key,
});
@override
Widget build(BuildContext context) {
return BlocProvider(
create: (context) => BluetoothBloc(
repository: BluetoothRepository(),
)..add(ConnectToDevice(device.device)),
child: DeviceDetailView(device: device),
);
}
}
class DeviceDetailView extends StatelessWidget {
final BleDevice device;
const DeviceDetailView({
required this.device,
super.key,
});
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(device.name),
actions: [
BlocBuilder<BluetoothBloc, BluetoothState>(
builder: (context, state) {
if (state is BluetoothConnected) {
return IconButton(
icon: const Icon(Icons.bluetooth_connected),
onPressed: () {
context
.read<BluetoothBloc>()
.add(DisconnectFromDevice(device.device));
},
);
}
return const SizedBox.shrink();
},
),
],
),
body: BlocConsumer<BluetoothBloc, BluetoothState>(
listener: (context, state) {
if (state is BluetoothDisconnected) {
Navigator.pop(context);
}
if (state is BluetoothError) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(state.message),
backgroundColor: Colors.red,
),
);
}
},
builder: (context, state) {
if (state is BluetoothConnecting) {
return const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(),
SizedBox(height: 16),
Text('Connecting...'),
],
),
);
}
if (state is BluetoothConnected) {
return _buildConnectedView(context, state);
}
if (state is BluetoothError) {
return _buildErrorView(context, state.message);
}
return const Center(child: CircularProgressIndicator());
},
),
);
}
Widget _buildConnectedView(
BuildContext context,
BluetoothConnected state,
) {
final services = state.services ?? [];
if (services.isEmpty) {
return const Center(
child: Text('No services found'),
);
}
return ListView.builder(
itemCount: services.length,
itemBuilder: (context, index) {
final service = services[index];
return _buildServiceTile(context, service);
},
);
}
Widget _buildServiceTile(
BuildContext context,
BluetoothService service,
) {
return ExpansionTile(
leading: const Icon(Icons.settings_input_antenna),
title: const Text('Service'),
subtitle: Text(
service.uuid.toString(),
style: const TextStyle(fontSize: 12),
),
children: service.characteristics.map((characteristic) {
return _buildCharacteristicTile(context, characteristic);
}).toList(),
);
}
Widget _buildCharacteristicTile(
BuildContext context,
BluetoothCharacteristic characteristic,
) {
return ListTile(
title: const Text('Characteristic'),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
characteristic.uuid.toString(),
style: const TextStyle(fontSize: 12),
),
const SizedBox(height: 4),
Wrap(
spacing: 4,
children: [
if (characteristic.properties.read)
Chip(
label: const Text('Read', style: TextStyle(fontSize: 10)),
backgroundColor: Colors.blue.withOpacity(0.1),
),
if (characteristic.properties.write)
Chip(
label: const Text('Write', style: TextStyle(fontSize: 10)),
backgroundColor: Colors.green.withOpacity(0.1),
),
if (characteristic.properties.notify)
Chip(
label: const Text('Notify', style: TextStyle(fontSize: 10)),
backgroundColor: Colors.orange.withOpacity(0.1),
),
],
),
],
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (characteristic.properties.read)
IconButton(
icon: const Icon(Icons.download, size: 20),
onPressed: () => _readCharacteristic(context, characteristic),
),
if (characteristic.properties.write)
IconButton(
icon: const Icon(Icons.upload, size: 20),
onPressed: () => _writeCharacteristic(context, characteristic),
),
if (characteristic.properties.notify)
IconButton(
icon: const Icon(Icons.notifications, size: 20),
onPressed: () => _subscribeCharacteristic(context, characteristic),
),
],
),
);
}
Widget _buildErrorView(BuildContext context, String message) {
return Center(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Icon(
Icons.error_outline,
size: 64,
color: Colors.red,
),
const SizedBox(height: 16),
Text(
message,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16),
),
const SizedBox(height: 24),
ElevatedButton(
onPressed: () => Navigator.pop(context),
child: const Text('Go Back'),
),
],
),
),
);
}
Future<void> _readCharacteristic(
BuildContext context,
BluetoothCharacteristic characteristic,
) async {
try {
final repository = BluetoothRepository();
final value = await repository.readCharacteristic(characteristic);
if (context.mounted) {
showDialog(
context: context,
builder: (_) => AlertDialog(
title: const Text('Read Value'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Bytes: $value'),
const SizedBox(height: 8),
Text('String: ${String.fromCharCodes(value)}'),
],
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('OK'),
),
],
),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Read failed: $e')),
);
}
}
}
Future<void> _writeCharacteristic(
BuildContext context,
BluetoothCharacteristic characteristic,
) async {
final controller = TextEditingController();
final result = await showDialog<String>(
context: context,
builder: (_) => AlertDialog(
title: const Text('Write Value'),
content: TextField(
controller: controller,
decoration: const InputDecoration(
hintText: 'Enter text to write',
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Cancel'),
),
ElevatedButton(
onPressed: () => Navigator.pop(context, controller.text),
child: const Text('Write'),
),
],
),
);
if (result != null && result.isNotEmpty && context.mounted) {
try {
final repository = BluetoothRepository();
final bytes = result.codeUnits;
await repository.writeCharacteristic(characteristic, bytes);
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(content: Text('Write successful')),
);
}
} catch (e) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Write failed: $e')),
);
}
}
}
}
void _subscribeCharacteristic(
BuildContext context,
BluetoothCharacteristic characteristic,
) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => NotificationScreen(characteristic: characteristic),
),
);
}
}
class NotificationScreen extends StatelessWidget {
final BluetoothCharacteristic characteristic;
const NotificationScreen({
required this.characteristic,
super.key,
});
@override
Widget build(BuildContext context) {
final repository = BluetoothRepository();
return Scaffold(
appBar: AppBar(title: const Text('Notifications')),
body: StreamBuilder<List<int>>(
stream: repository.subscribeToCharacteristic(characteristic),
builder: (context, snapshot) {
if (snapshot.hasError) {
return Center(
child: Text('Error: ${snapshot.error}'),
);
}
if (!snapshot.hasData) {
return const Center(
child: CircularProgressIndicator(),
);
}
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'Received Value:',
style: TextStyle(fontSize: 18),
),
const SizedBox(height: 16),
Text(
'Bytes: ${snapshot.data}',
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Text(
'String: ${String.fromCharCodes(snapshot.data!)}',
style: const TextStyle(fontSize: 16),
),
],
),
);
},
),
);
}
}
9. Update main.dart
// main.dart
import 'package:flutter/material.dart';
import 'features/bluetooth/presentation/screens/device_scan_screen.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'Flutter BLE with BLoC',
theme: ThemeData(
primarySwatch: Colors.blue,
useMaterial3: true,
),
home: const DeviceScanScreen(),
);
}
}
Real-World Examples
Reading Heart Rate from Fitness Tracker
// Heart Rate Service: 0x180D
// Heart Rate Measurement: 0x2A37
Future<void> monitorHeartRate(
BluetoothCharacteristic characteristic,
) async {
final repository = BluetoothRepository();
repository.subscribeToCharacteristic(characteristic).listen((value) {
// Parse heart rate data
// Byte 0: Flags
// Byte 1: Heart rate value
final heartRate = value[1];
print('Heart Rate: $heartRate bpm');
});
}
Controlling Smart LED
Future<void> setLEDColor(
BluetoothCharacteristic characteristic,
Color color,
) async {
final repository = BluetoothRepository();
// Convert Color to RGB bytes
final bytes = [
color.red,
color.green,
color.blue,
];
await repository.writeCharacteristic(characteristic, bytes);
}
Testing Your BLoC
// test/bluetooth_bloc_test.dart
import 'package:bloc_test/bloc_test.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mocktail/mocktail.dart';
class MockBluetoothRepository extends Mock implements BluetoothRepository {}
void main() {
late BluetoothBloc bloc;
late MockBluetoothRepository repository;
setUp(() {
repository = MockBluetoothRepository();
bloc = BluetoothBloc(repository: repository);
});
tearDown(() {
bloc.close();
});
test('initial state is BluetoothInitial', () {
expect(bloc.state, equals(BluetoothInitial()));
});
blocTest<BluetoothBloc, BluetoothState>(
'emits [BluetoothLoading, BluetoothReady] when Bluetooth is available',
build: () {
when(() => repository.isBluetoothAvailable())
.thenAnswer((_) async => true);
when(() => repository.adapterStateStream)
.thenAnswer((_) => Stream.value(BluetoothAdapterState.on));
return bloc;
},
act: (bloc) => bloc.add(CheckBluetoothStatus()),
expect: () => [
isA<BluetoothLoading>(),
isA<BluetoothReady>(),
],
);
}
Troubleshooting Guide
Issue 1: "Permission denied"
Android 12+ specific: Make sure you have BOTH BLUETOOTH_SCAN and BLUETOOTH_CONNECT
Test on real device: Emulators have quirky permission behavior
Issue 2: "Device disconnects immediately"
Check battery: Low battery devices disconnect frequently
Increase timeout: Use 30+ second connection timeout
Use autoConnect: Set autoConnect: true for automatic reconnection
Issue 3: "No devices found"
Location permission: Required on Android 11 and below
Device advertising: Make sure device is in pairing/advertising mode
Remove filters: Try scanning without service UUID filters first
Issue 4: "iOS Simulator crash"
Real device only: iOS Simulator doesn't support Bluetooth
Always test on iPhone/iPad
Pro Tips for Success
Always stop scanning before connecting — Improves connection reliability
Handle disconnections gracefully — BLE connections are fragile by nature
Use BlocObserver for debugging — Track all state changes during development
Cache discovered services — Don't rediscover on every connection
Implement retry logic — BLE operations can fail due to radio interference
Test with multiple devices — Different manufacturers handle BLE differently
Monitor RSSI changes — Use for proximity detection
Keep connections short — Battery life matters
What's Next?
You're now a Bluetooth expert with production-ready BLoC implementation! Here's what to explore:
Background BLE — Keep connections alive when app is minimized
Multiple device connections — Connect to several devices simultaneously
Custom GATT profiles — Design your own services
OTA firmware updates — Update device firmware over Bluetooth
BLE mesh networking — Build device mesh networks
Let's Wrap This Up
Bluetooth doesn't have to be scary. With BLoC managing your state and the right platform configurations, you're ready to build professional BLE apps that users love.
Found this helpful? Smash that clap button — seriously, it helps other developers discover this guide!
Building something cool? Drop a comment and share what you're working on! I love seeing what the community creates.
Questions? Fire away in the comments — I read and respond to every single one.
Want more Flutter deep dives? Follow me for architecture patterns, state management guides, and real-world solutions.
Now go connect to something awesome! 🚀📱💙
#Flutter #Bluetooth #BLE #BluetoothLowEnergy #BLoC #FlutterBloc #FlutterBluePlus #StateManagement #IoT #SmartDevices #WearableTech #MobileApp #FlutterDev #iOS #Android #FitnessTracker #SmartHome #FlutterTutorial #AppDevelopment #TechGuide
Related Articles:
- Flutter BLoC vs Riverpod: The Ultimate State Management Showdown
- Building Offline-First Flutter Apps: Complete Data Sync Strategy
- Flutter Performance Optimization: From Laggy to Lightning Fast
- Real-Time Data in Flutter: WebSockets vs Firebase vs BLE
- Testing Flutter Apps: Unit, Widget, BLoC, and Integration Tests Made Easy