bt_service 0.0.3 copy "bt_service: ^0.0.3" to clipboard
bt_service: ^0.0.3 copied to clipboard

PlatformAndroid

A production-ready Flutter plugin for Bluetooth Classic (RFCOMM) on Android. Supports connect, disconnect, and data transfer with robust error handling.

example/lib/main.dart

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,
                        ),
                      ),
                    ],
                  ),
                ),
              ),
            ],
          ),
        ),
      ),
    );
  }
}
0
likes
160
points
0
downloads

Publisher

unverified uploader

Weekly Downloads

A production-ready Flutter plugin for Bluetooth Classic (RFCOMM) on Android. Supports connect, disconnect, and data transfer with robust error handling.

Repository (GitHub)
View/report issues

Documentation

API reference

License

MIT (license)

Dependencies

flutter, plugin_platform_interface

More

Packages that depend on bt_service

Packages that implement bt_service