bt_service 0.0.3
bt_service: ^0.0.3 copied to clipboard
A production-ready Flutter plugin for Bluetooth Classic (RFCOMM) on Android. Supports connect, disconnect, and data transfer with robust error handling.
import 'dart:async';
import 'package:bt_service/bt_service.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:permission_handler/permission_handler.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatefulWidget {
const MyApp({super.key});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
final _addressController = TextEditingController(text: '00:11:22:33:44:55');
final _messageController = TextEditingController(text: 'Hello from Flutter!');
final _logs = <String>[];
final _discoveredDevices = <Map<String, dynamic>>[];
bool _isConnected = false;
bool _isConnecting = false;
bool _isScanning = false;
bool _permissionsGranted = false;
bool _appendCr = false; // Toggle for appending Carriage Return
String? _permissionError;
StreamSubscription<Uint8List>? _dataSub;
StreamSubscription<String>? _stateSub;
StreamSubscription<Map<String, dynamic>>? _deviceSub;
@override
void initState() {
super.initState();
_setupListeners();
_checkConnectionStatus();
// Request permissions automatically when app starts
WidgetsBinding.instance.addPostFrameCallback((_) {
_requestPermissions();
});
}
/// Request Bluetooth and Location permissions automatically
Future<void> _requestPermissions() async {
try {
_addLog('Requesting Bluetooth and Location permissions...');
// Request permissions based on Android version
final permissions = <Permission>[];
// For Android 12+ (API 31+), we need BLUETOOTH_SCAN and BLUETOOTH_CONNECT
// For Android < 12, we need location permissions for scanning
if (await _isAndroid12OrHigher()) {
permissions.addAll([
Permission.bluetoothScan,
Permission.bluetoothConnect,
]);
// Also request location for backward compatibility
permissions.add(Permission.location);
} else {
permissions.addAll([Permission.bluetooth, Permission.location]);
}
final statuses = await permissions.request();
// Check if all required permissions are granted
bool allGranted = true;
final deniedPermissions = <String>[];
for (final permission in permissions) {
final status = statuses[permission];
if (status != null && !status.isGranted) {
allGranted = false;
deniedPermissions.add(permission.toString());
}
}
setState(() {
_permissionsGranted = allGranted;
if (allGranted) {
_permissionError = null;
_addLog('All permissions granted successfully');
} else {
_permissionError =
'Permission not granted: ${deniedPermissions.join(", ")}';
_addLog('PERMISSION ERROR: $_permissionError', isError: true);
}
});
} catch (e) {
setState(() {
_permissionError = 'Failed to request permissions: $e';
_addLog('PERMISSION ERROR: $_permissionError', isError: true);
});
}
}
/// Check if Android version is 12 or higher
Future<bool> _isAndroid12OrHigher() async {
try {
// This is a simple check - in a real app you might use platform_info
// For now, we'll request both sets of permissions to be safe
return true; // Assume Android 12+ to be safe
} catch (e) {
return true; // Default to requesting new permissions
}
}
void _setupListeners() {
try {
_stateSub = BtService.instance.onState.listen(
(state) {
if (mounted) {
setState(() {
_isConnected = (state == 'connected');
_isConnecting = false;
if (state == 'scan_finished') {
_isScanning = false;
_addLog(
'Scan finished. Found ${_discoveredDevices.length} devices',
);
} else {
_addLog('STATE: $state', isError: state == 'error');
}
});
}
},
onError: (error) {
if (mounted) {
_addLog('STATE STREAM ERROR: $error', isError: true);
}
},
);
_dataSub = BtService.instance.onData.listen(
(bytes) {
if (mounted) {
setState(() {
_addLog('DATA RECEIVED: ${bytes.length} bytes');
// Optionally display the data as text if it's printable
try {
// Visualize control characters
final text = String.fromCharCodes(bytes);
if (text.length < 100) {
_addLog(' Content: $text');
}
} catch (_) {
// Not valid UTF-8, skip text display
}
});
}
},
onError: (error) {
if (mounted) {
_addLog('DATA STREAM ERROR: $error', isError: true);
}
},
);
_deviceSub = BtService.instance.onDeviceDiscovered.listen(
(device) {
if (mounted) {
setState(() {
try {
// Safely extract device information
final address = device['address']?.toString() ?? '';
final name = device['name']?.toString() ?? 'Unknown Device';
// Check if device already exists
final exists = _discoveredDevices.any(
(d) => d['address']?.toString() == address,
);
if (!exists && address.isNotEmpty) {
_discoveredDevices.add({
'address': address,
'name': name,
'type': device['type']?.toString() ?? '',
});
_addLog('DEVICE FOUND: $name ($address)');
}
} catch (e) {
_addLog('ERROR parsing device: $e', isError: true);
}
});
}
},
onError: (error) {
if (mounted) {
_addLog('DEVICE STREAM ERROR: $error', isError: true);
}
},
);
} catch (e) {
_addLog('ERROR setting up listeners: $e', isError: true);
}
}
Future<void> _checkConnectionStatus() async {
try {
final connected = await BtService.instance.isConnected();
if (mounted) {
setState(() {
_isConnected = connected;
});
}
} catch (e) {
_addLog('Error checking connection: $e', isError: true);
}
}
void _addLog(String message, {bool isError = false}) {
if (!mounted) return;
final timestamp = DateTime.now().toString().substring(11, 19);
setState(() {
_logs.insert(0, '[$timestamp] $message');
if (_logs.length > 100) {
_logs.removeLast();
}
});
}
Future<void> _connect() async {
if (!_permissionsGranted) {
_addLog(
'ERROR: Permissions not granted. Please grant Bluetooth permissions.',
isError: true,
);
await _requestPermissions();
return;
}
final addr = _addressController.text.trim();
if (addr.isEmpty) {
_addLog('ERROR: Please enter a MAC address', isError: true);
return;
}
setState(() {
_isConnecting = true;
_addLog('Connecting to $addr...');
});
try {
await BtService.instance.connect(addr);
_addLog('Connection request sent');
} catch (e) {
if (mounted) {
setState(() {
_isConnecting = false;
final errorMsg = e.toString();
if (errorMsg.contains('PERMISSION_DENIED')) {
_addLog(
'PERMISSION ERROR: Permission not granted. Please grant Bluetooth permissions.',
isError: true,
);
_permissionError = 'Permission not granted';
_permissionsGranted = false;
} else {
_addLog('CONNECTION ERROR: $e', isError: true);
}
});
}
}
}
Future<void> _disconnect() async {
try {
await BtService.instance.disconnect();
if (mounted) {
setState(() {
_isConnected = false;
_addLog('Disconnection requested');
});
}
} catch (e) {
_addLog('DISCONNECT ERROR: $e', isError: true);
}
}
Future<void> _send() async {
if (!_isConnected) {
_addLog('ERROR: Not connected to any device', isError: true);
return;
}
var message = _messageController.text;
if (message.isEmpty) {
_addLog('ERROR: Please enter a message to send', isError: true);
return;
}
if (_appendCr) {
message += '\r';
}
try {
final bytes = Uint8List.fromList(message.codeUnits);
await BtService.instance.send(bytes);
_addLog('SENT: ${message.length} bytes - "$message"');
} catch (e) {
_addLog('SEND ERROR: $e', isError: true);
// If send fails, connection might be lost
if (mounted) {
setState(() {
_isConnected = false;
});
}
}
}
void _clearLogs() {
if (mounted) {
setState(() {
_logs.clear();
});
}
}
Future<void> _startScan() async {
if (!_permissionsGranted) {
_addLog(
'ERROR: Permissions not granted. Please grant Bluetooth permissions.',
isError: true,
);
await _requestPermissions();
return;
}
setState(() {
_isScanning = true;
_discoveredDevices.clear();
_addLog('Starting device scan...');
});
try {
await BtService.instance.startScan();
} catch (e) {
if (mounted) {
setState(() {
_isScanning = false;
final errorMsg = e.toString();
if (errorMsg.contains('PERMISSION_DENIED')) {
_addLog(
'PERMISSION ERROR: Permission not granted. Please grant Bluetooth and Location permissions.',
isError: true,
);
_permissionError = 'Permission not granted';
_permissionsGranted = false;
} else {
_addLog('SCAN ERROR: $e', isError: true);
}
});
}
}
}
Future<void> _stopScan() async {
try {
await BtService.instance.stopScan();
if (mounted) {
setState(() {
_isScanning = false;
_addLog('Scan stopped');
});
}
} catch (e) {
_addLog('STOP SCAN ERROR: $e', isError: true);
}
}
Future<void> _loadPairedDevices() async {
if (!_permissionsGranted) {
_addLog(
'ERROR: Permissions not granted. Please grant Bluetooth permissions.',
isError: true,
);
await _requestPermissions();
return;
}
try {
final devices = await BtService.instance.getPairedDevices();
if (mounted) {
setState(() {
_discoveredDevices.clear();
_discoveredDevices.addAll(devices);
_addLog('Loaded ${devices.length} paired devices');
});
}
} catch (e) {
final errorMsg = e.toString();
if (errorMsg.contains('PERMISSION_DENIED')) {
_addLog(
'PERMISSION ERROR: Permission not granted. Please grant Bluetooth permissions.',
isError: true,
);
if (mounted) {
setState(() {
_permissionError = 'Permission not granted';
_permissionsGranted = false;
});
}
} else {
_addLog('ERROR loading paired devices: $e', isError: true);
}
}
}
void _selectDevice(String address, String name) {
if (mounted) {
setState(() {
_addressController.text = address;
_addLog('Selected device: $name ($address)');
});
}
}
@override
void dispose() {
_dataSub?.cancel();
_stateSub?.cancel();
_deviceSub?.cancel();
_addressController.dispose();
_messageController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return MaterialApp(
title: 'BT Service Example',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
useMaterial3: true,
),
home: Scaffold(
appBar: AppBar(
title: const Text('Bluetooth Service Example'),
backgroundColor: Theme.of(context).colorScheme.inversePrimary,
),
body: SingleChildScrollView(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Permission Status Card
if (_permissionError != null)
Card(
color: Colors.red.shade50,
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.error_outline, color: Colors.red),
const SizedBox(width: 8),
const Text(
'Permission Error',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: Colors.red,
),
),
],
),
const SizedBox(height: 8),
Text(
_permissionError!,
style: const TextStyle(color: Colors.red),
),
const SizedBox(height: 8),
ElevatedButton.icon(
onPressed: _requestPermissions,
icon: const Icon(Icons.security),
label: const Text('Grant Permissions'),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
),
],
),
),
),
if (_permissionError != null) const SizedBox(height: 16),
// Connection Status Card
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Connection Status',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 8),
Row(
children: [
Container(
width: 12,
height: 12,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: _isConnected
? Colors.green
: (_isConnecting
? Colors.orange
: Colors.grey),
),
),
const SizedBox(width: 8),
Text(
_isConnected
? 'Connected'
: (_isConnecting
? 'Connecting...'
: 'Disconnected'),
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w500,
color: _isConnected
? Colors.green
: (_isConnecting
? Colors.orange
: Colors.grey),
),
),
],
),
],
),
),
),
const SizedBox(height: 16),
// Device Scanning Section
Card(
child: Padding(
padding: const EdgeInsets.all(16.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Device Discovery',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed:
(_isScanning ||
_isConnected ||
_isConnecting ||
!_permissionsGranted)
? null
: _startScan,
icon: Icon(
_isScanning ? Icons.search : Icons.search,
),
label: Text(
_isScanning ? 'Scanning...' : 'Scan Devices',
),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: 12,
),
),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton.icon(
onPressed: _isScanning ? _stopScan : null,
icon: const Icon(Icons.stop),
label: const Text('Stop'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(
vertical: 12,
),
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
),
),
],
),
const SizedBox(height: 8),
SizedBox(
width: double.infinity,
child: ElevatedButton.icon(
onPressed:
(_isScanning ||
_isConnected ||
_isConnecting ||
!_permissionsGranted)
? null
: _loadPairedDevices,
icon: const Icon(Icons.list),
label: const Text('Show Paired Devices'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 12),
),
),
),
if (_discoveredDevices.isNotEmpty) ...[
const SizedBox(height: 12),
const Divider(),
const SizedBox(height: 8),
Text(
'Found Devices (${_discoveredDevices.length})',
style: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
const SizedBox(height: 8),
SizedBox(
height: 200,
child: ListView.builder(
itemCount: _discoveredDevices.length,
itemBuilder: (context, index) {
final device = _discoveredDevices[index];
final name =
device['name']?.toString() ??
'Unknown Device';
final address =
device['address']?.toString() ?? '';
return Card(
margin: const EdgeInsets.symmetric(vertical: 4),
child: ListTile(
leading: const Icon(Icons.bluetooth),
title: Text(name),
subtitle: Text(address),
trailing: IconButton(
icon: const Icon(Icons.arrow_forward),
onPressed: () =>
_selectDevice(address, name),
tooltip: 'Select this device',
),
onTap: () => _selectDevice(address, name),
),
);
},
),
),
],
],
),
),
),
const SizedBox(height: 16),
// Device Address Input
TextField(
controller: _addressController,
decoration: const InputDecoration(
labelText: 'Device MAC Address',
hintText: '00:11:22:33:44:55',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.bluetooth),
),
enabled: !_isConnected && !_isConnecting,
),
const SizedBox(height: 16),
// Connection Buttons
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed:
(_isConnected ||
_isConnecting ||
!_permissionsGranted)
? null
: _connect,
icon: const Icon(Icons.link),
label: const Text('Connect'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton.icon(
onPressed: _isConnected ? _disconnect : null,
icon: const Icon(Icons.link_off),
label: const Text('Disconnect'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: Colors.red,
foregroundColor: Colors.white,
),
),
),
],
),
const SizedBox(height: 24),
// Message Input
TextField(
controller: _messageController,
decoration: const InputDecoration(
labelText: 'Message to Send',
hintText: 'Enter your message here',
border: OutlineInputBorder(),
prefixIcon: Icon(Icons.message),
),
enabled: _isConnected,
maxLines: 3,
),
const SizedBox(height: 8),
// Append CR Checkbox
CheckboxListTile(
title: const Text('Append \\r (Carriage Return)'),
subtitle: const Text('Required for ELM327 commands'),
value: _appendCr,
onChanged: (value) {
setState(() {
_appendCr = value ?? false;
});
},
controlAffinity: ListTileControlAffinity.leading,
contentPadding: EdgeInsets.zero,
enabled: _isConnected,
),
const SizedBox(height: 8),
// Send Button
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: _isConnected ? _send : null,
icon: const Icon(Icons.send),
label: const Text('Send'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
),
),
),
const SizedBox(width: 8),
Expanded(
child: ElevatedButton.icon(
onPressed: _isConnected
? () {
_messageController.text = 'ATZ';
_send();
}
: null,
icon: const Icon(Icons.refresh),
label: const Text('Reset (ATZ)'),
style: ElevatedButton.styleFrom(
padding: const EdgeInsets.symmetric(vertical: 16),
backgroundColor: Colors.orange.shade100,
foregroundColor: Colors.orange.shade900,
),
),
),
],
),
const SizedBox(height: 24),
// Logs Section
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Logs',
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold),
),
TextButton.icon(
onPressed: _clearLogs,
icon: const Icon(Icons.clear, size: 18),
label: const Text('Clear'),
),
],
),
const SizedBox(height: 8),
Container(
height: 300,
decoration: BoxDecoration(
border: Border.all(color: Colors.grey.shade300),
borderRadius: BorderRadius.circular(8),
),
child: _logs.isEmpty
? const Center(
child: Text(
'No logs yet. Connect to a device to see activity.',
style: TextStyle(color: Colors.grey),
),
)
: ListView.builder(
reverse: true,
padding: const EdgeInsets.all(8),
itemCount: _logs.length,
itemBuilder: (context, index) {
final log = _logs[index];
final isError = log.contains('ERROR');
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Text(
log,
style: TextStyle(
fontSize: 12,
fontFamily: 'monospace',
color: isError ? Colors.red : Colors.black87,
),
),
);
},
),
),
const SizedBox(height: 16),
// Info Card
Card(
color: Colors.blue.shade50,
child: Padding(
padding: const EdgeInsets.all(12.0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'âšī¸ Instructions',
style: TextStyle(
fontWeight: FontWeight.bold,
fontSize: 14,
),
),
const SizedBox(height: 8),
const Text(
'1. Permissions are requested automatically on app start\n'
'2. Scan for nearby devices or load paired devices\n'
'3. Select a device from the list or enter MAC address manually\n'
'4. Tap Connect to establish a connection\n'
'5. Once connected, enter a message and tap Send\n'
'6. Received data will appear in the logs',
style: TextStyle(fontSize: 12),
),
const SizedBox(height: 8),
Text(
'Note: On Android 12+, BLUETOOTH_SCAN and BLUETOOTH_CONNECT permissions are required.',
style: TextStyle(
fontSize: 11,
fontStyle: FontStyle.italic,
color: Colors.grey.shade700,
),
),
],
),
),
),
],
),
),
),
);
}
}