Abaixo segue um exemplo completo em Flutter que: 1. **Lista dispositivos Bluetooth clássicos (SPP) e conecta** (Android). 2. Exibe uma **tela de controle remoto** com: * **Volante** (imagem rotacionável) que envia `V<ângulo>@` quando você termina o gesto; * **Acelerador** (imagem que “afunda” com um slider) que envia `A` ao tocar/soltar (e podemos enviar valor, se desejar); * **Freios** (botão) que envia `F`; * **Setas** (esquerda/direita) que enviam `E` (podemos diferenciar se quiser). > ⚠️ **Observações importantes** > > * Este exemplo usa **Bluetooth clássico** com o plugin `flutter_bluetooth_serial` — ideal para módulos como HC‑05/HC‑06 (Android). No iOS, **Bluetooth clássico não é suportado**; se seu hardware for BLE, precisaremos adaptar com `flutter_blue_plus` e características GATT. > * No Android 12+ são necessárias permissões em tempo de execução (SCAN/CONNECT). Incluí instruções abaixo. > * As imagens do volante e do acelerador devem estar em `assets/` (coloque seus PNGs). O código possui fallback caso o asset não exista. *** ## `pubspec.yaml` (trecho) ```yaml name: bluetooth_remote description: Controle remoto via Bluetooth com volante e acelerador publish_to: 'none' environment: sdk: ">=2.19.0 <4.0.0" dependencies: flutter: sdk: flutter flutter_bluetooth_serial: ^0.4.0 permission_handler: ^11.0.0 dev_dependencies: flutter_test: sdk: flutter flutter: uses-material-design: true assets: - assets/volante.png - assets/acelerador.png ``` > Se ainda não tiver as imagens, coloque arquivos `assets/volante.png` e `assets/acelerador.png`. Você pode começar com qualquer PNG (ou substituí-los por ícones temporários). *** ## Permissões (Android) Em `android/app/src/main/AndroidManifest.xml`, inclua: ```xml ... ``` No Android 12+ também é recomendado solicitar essas permissões em tempo de execução (o código abaixo usa `permission_handler`). > iOS: `flutter_bluetooth_serial` não dá suporte a Bluetooth clássico; para BLE, usar `flutter_blue_plus` e adicionar `NSBluetoothAlwaysUsageDescription` no `Info.plist`. *** ## Código completo (`lib/main.dart`) ```dart import 'dart:async'; import 'dart:convert'; import 'dart:math' as math; import 'dart:typed_data'; import 'package:flutter/material.dart'; import 'package:flutter_bluetooth_serial/flutter_bluetooth_serial.dart'; import 'package:permission_handler/permission_handler.dart'; void main() { runApp(const MyApp()); } /// App raiz com duas telas: /// - Lista de dispositivos Bluetooth e conexão /// - Tela de controle (volante, acelerador, freios, setas) class MyApp extends StatelessWidget { const MyApp({super.key}); @override Widget build(BuildContext context) { return MaterialApp( title: 'Controle Bluetooth', theme: ThemeData( colorScheme: ColorScheme.fromSeed(seedColor: Colors.blueGrey), useMaterial3: true, ), home: const DeviceListPage(), ); } } /// Serviço simples para gerenciar a conexão Bluetooth (SPP/classic) class BluetoothService { BluetoothConnection? _connection; bool get isConnected => _connection != null && _connection!.isConnected; Future connect(String address) async { if (isConnected) { await disconnect(); } _connection = await BluetoothConnection.toAddress(address); debugPrint('Conectado a $address'); } Future disconnect() async { try { await _connection?.finish(); } catch (_) {} _connection?.dispose(); _connection = null; debugPrint('Desconectado'); } Future sendCommand(String cmd) async { if (!isConnected) { debugPrint('Não conectado, ignorando envio: $cmd'); return; } final data = Uint8List.fromList(utf8.encode(cmd)); _connection!.output.add(data); await _connection!.output.allSent; debugPrint('Enviado: $cmd'); } } /// Página que descobre e lista dispositivos Bluetooth para conectar class DeviceListPage extends StatefulWidget { const DeviceListPage({super.key}); @override State createState() => _DeviceListPageState(); } class _DeviceListPageState extends State { final FlutterBluetoothSerial _bluetooth = FlutterBluetoothSerial.instance; final BluetoothService _service = BluetoothService(); bool _isDiscovering = false; StreamSubscription? _discoverySub; final Map _results = {}; @override void initState() { super.initState(); _initBluetooth(); } Future _initBluetooth() async { // Solicitar permissões (Android 12+) await [ Permission.bluetooth, Permission.bluetoothConnect, Permission.bluetoothScan, Permission.locationWhenInUse, // necessário em versões antigas ].request(); // Certificar que o Bluetooth está habilitado final isEnabled = await _bluetooth.isEnabled; if (isEnabled != true) { await _bluetooth.requestEnable(); } // Opcional: pegar dispositivos pareados // final bonded = await _bluetooth.getBondedDevices(); // bonded.forEach((d) => _results[d.address!] = // BluetoothDiscoveryResult(device: d, rssi: 0)); // Iniciar descoberta ativa _startDiscovery(); } void _startDiscovery() { _results.clear(); setState(() => _isDiscovering = true); _discoverySub = _bluetooth.startDiscovery().listen((result) { _results[result.device.address!] = result; // dedup por endereço setState(() {}); }, onDone: () { setState(() => _isDiscovering = false); }); } @override void dispose() { _discoverySub?.cancel(); super.dispose(); } Future _connectTo(BluetoothDevice device) async { try { await _service.connect(device.address!); if (mounted) { Navigator.of(context).push(MaterialPageRoute( builder: (_) => RemoteControlPage(service: _service, device: device), )); } } catch (e) { ScaffoldMessenger.of(context).showSnackBar( SnackBar(content: Text('Falha ao conectar: $e')), ); } } @override Widget build(BuildContext context) { final devices = _results.values.toList() ..sort((a, b) => (a.device.name ?? '').compareTo(b.device.name ?? '')); return Scaffold( appBar: AppBar( title: const Text('Dispositivos Bluetooth'), actions: [ IconButton( icon: Icon(_isDiscovering ? Icons.sync : Icons.refresh), onPressed: _isDiscovering ? null : _startDiscovery, tooltip: 'Descobrir novamente', ), ], ), body: ListView.separated( itemCount: devices.length, separatorBuilder: (_, __) => const Divider(height: 1), itemBuilder: (context, index) { final r = devices[index]; return ListTile( leading: const Icon(Icons.bluetooth), title: Text(r.device.name ?? '(Sem nome)'), subtitle: Text('${r.device.address} • RSSI: ${r.rssi}'), trailing: ElevatedButton( onPressed: () => _connectTo(r.device), child: const Text('Conectar'), ), ); }, ), ); } } /// Tela de controle remoto. /// Volante envia V@, Acelerador envia A, Freios F, Setas E. class RemoteControlPage extends StatefulWidget { final BluetoothService service; final BluetoothDevice device; const RemoteControlPage({super.key, required this.service, required this.device}); @override State createState() => _RemoteControlPageState(); } class _RemoteControlPageState extends State { double _wheelAngleDeg = 0; // -180..180 double _accelValue = 0; // 0..100 (percentual) DateTime _lastWheelSend = DateTime.fromMillisecondsSinceEpoch(0); // Controle de "throttle" para não enviar mensagens demais durante o gesto bool _shouldSendDuringPan(double angleDeg) { final now = DateTime.now(); final elapsedMs = now.difference(_lastWheelSend).inMilliseconds; final changedEnough = (angleDeg - _wheelAngleDeg).abs() >= 5; return elapsedMs >= 120 && changedEnough; } Future _sendWheel(String cmd) async { await widget.service.sendCommand(cmd); _lastWheelSend = DateTime.now(); } // Calcula ângulo em graus entre -180..180 a partir das coordenadas relativas double _computeAngleDeg(Offset localPos, Size size) { final center = Offset(size.width / 2, size.height / 2); final dx = localPos.dx - center.dx; final dy = localPos.dy - center.dy; final rad = math.atan2(dy, dx); double deg = rad * 180 / math.pi; // -180..180 // Opcional: limitar faixa (ex.: +-135) deg = deg.clamp(-135, 135); return deg; } Widget _buildSteeringWheel(BuildContext context) { return LayoutBuilder( builder: (context, constraints) { final size = Size(constraints.maxWidth, constraints.maxWidth); // quadrado return GestureDetector( onPanUpdate: (details) { final box = context.findRenderObject() as RenderBox?; if (box == null) return; final localPos = box.globalToLocal(details.globalPosition); final newAngle = _computeAngleDeg(localPos, size); setState(() => _wheelAngleDeg = newAngle); // (Opcional) enviar durante o movimento com throttle: if (_shouldSendDuringPan(newAngle)) { _sendWheel('V${newAngle.round()}@'); } }, onPanEnd: (_) { // Enviar ao terminar o gesto (como solicitado) _sendWheel('V${_wheelAngleDeg.round()}@'); }, child: SizedBox( width: size.width, height: size.height, child: Stack( alignment: Alignment.center, children: [ Transform.rotate( angle: _wheelAngleDeg * math.pi / 180, child: _safeAssetImage( 'assets/volante.png', width: size.width * 0.9, height: size.width * 0.9, fallback: const Icon(Icons.circle, size: 180, color: Colors.blueGrey), ), ), // Marcador de centro (opcional) Positioned( child: Container( width: 8, height: 8, decoration: const BoxDecoration( color: Colors.red, shape: BoxShape.circle), ), ), ], ), ), ); }, ); } Widget _buildAccelerator(BuildContext context) { // Slider vertical + imagem que "afunda" proporcionalmente return Column( children: [ SizedBox( height: 200, child: Stack( alignment: Alignment.topCenter, children: [ Positioned( top: _accelToPixels(200, _accelValue), child: _safeAssetImage( 'assets/acelerador.png', width: 100, height: 100, fallback: const Icon(Icons.circle, size: 72, color: Colors.green), ), ), ], ), ), RotatedBox( quarterTurns: 3, // Slider vertical child: Slider( min: 0, max: 100, divisions: 100, value: _accelValue, onChanged: (v) { setState(() => _accelValue = v); }, onChangeEnd: (v) async { // Enviar ao fim do ajuste (especificação indica apenas 'A') await widget.service.sendCommand('A'); }, label: '${_accelValue.round()}%', ), ), const SizedBox(height: 8), ElevatedButton.icon( onPressed: () async { // Enviar também ao "toque" conforme pedido await widget.service.sendCommand('A'); }, icon: const Icon(Icons.speed), label: const Text('Acelerar (enviar A)'), ), ], ); } double _accelToPixels(double areaHeight, double valuePercent) { // Converte 0..100% em deslocamento vertical (0 no topo, 100 mais abaixo) final maxDown = areaHeight - 100; // tamanho aproximado da imagem return (valuePercent / 100.0) * maxDown; } Widget _buildBrakesAndIndicators() { return Row( mainAxisAlignment: MainAxisAlignment.spaceEvenly, children: [ ElevatedButton.icon( onPressed: () async { await widget.service.sendCommand('F'); }, icon: const Icon(Icons.stop_circle), label: const Text('Freio (F)'), style: ElevatedButton.styleFrom(backgroundColor: Colors.red.shade700, foregroundColor: Colors.white), ), ElevatedButton.icon( onPressed: () async { await widget.service.sendCommand('E'); }, icon: const Icon(Icons.turn_left), label: const Text('Seta Esq. (E)'), ), ElevatedButton.icon( onPressed: () async { await widget.service.sendCommand('E'); }, icon: const Icon(Icons.turn_right), label: const Text('Seta Dir. (E)'), ), ], ); } Widget _safeAssetImage(String path, {double? width, double? height, Widget? fallback}) { return Image.asset( path, width: width, height: height, fit: BoxFit.contain, errorBuilder: (_, __, ___) => fallback ?? const Icon(Icons.image_not_supported), ); } @override Widget build(BuildContext context) { final connected = widget.service.isConnected; return Scaffold( appBar: AppBar( title: Text('Controle: ${widget.device.name ?? widget.device.address}'), actions: [ Icon(connected ? Icons.bluetooth_connected : Icons.bluetooth_disabled), const SizedBox(width: 12), IconButton( icon: const Icon(Icons.power_settings_new), tooltip: 'Desconectar', onPressed: () async { await widget.service.disconnect(); if (mounted) Navigator.of(context).pop(); }, ), ], ), body: SafeArea( child: SingleChildScrollView( padding: const EdgeInsets.all(16), child: Column( children: [ const Text( 'Volante (arraste para girar, envia V<ângulo>@ ao soltar)', style: TextStyle(fontWeight: FontWeight.bold), ), const SizedBox(height: 8), _buildSteeringWheel(context), const SizedBox(height: 16), const Divider(), const SizedBox(height: 8), const Text( 'Acelerador (slider vertical, envia A ao soltar ou tocar)', style: TextStyle(fontWeight: FontWeight.bold), ), const SizedBox(height: 8), _buildAccelerator(context), const SizedBox(height: 16), const Divider(), const SizedBox(height: 8), const Text( 'Freios e Setas', style: TextStyle(fontWeight: FontWeight.bold), ), const SizedBox(height: 8), _buildBrakesAndIndicators(), const SizedBox(height: 24), Text( 'Ângulo atual do volante: ${_wheelAngleDeg.round()}°', style: const TextStyle(color: Colors.blueGrey), ), ], ), ), ), ); } } ``` *** ## Como funciona e ajustar o protocolo * **Volante**: enquanto você arrasta, o ângulo (`-135°` a `+135°` por padrão) é atualizado visualmente. Ao **soltar**, a app envia `V<ângulo>@` (ex.: `V45@`). Opcionalmente, o código está com “throttle” para **também enviar durante o movimento** (de 120ms em 120ms e mudanças ≥ 5°), caso queira feedback contínuo — pode remover essa parte para enviar apenas no fim. * **Acelerador**: o slider vertical move a imagem do acelerador. Ao **soltar** o slider (ou clicar o botão), envia `A`. > Se desejar enviar o **valor** junto (ex.: `A70@` com 70%), altere a linha: > > ```dart > await widget.service.sendCommand('A'); > ``` > > para: > > ```dart > await widget.service.sendCommand('A${_accelValue.round()}@'); > ``` > > (e padronize o `@` no final, se esse for seu framing). * **Freios**: envia `F` no toque. * **Setas**: atualmente ambas (esq./dir.) enviam `E` — se quiser diferenciar, podemos usar `EL` e `ER`, ou `E0`/`E1`. *** ## Testes rápidos 1. Execute em um **Android** com Bluetooth ligado. 2. Na primeira tela, aguarde a **descoberta** dos dispositivos e toque em **Conectar**. 3. Na tela de controle: * Gire o volante e solte → verifique (no seu receptor) o frame `V@`. * Use o slider do acelerador → ao soltar, envia `A`. * Toque em **Freio** → envia `F`. * Toque em **Seta Esq./Dir.** → envia `E`. Para depuração sem hardware, você pode simular um servidor RFCOMM (SPP) no PC e emparelhar o celular, apenas para observar as strings recebidas. *** ## Possíveis extensões * **Reconexão automática** e status de link. * **Padronização de framing** (sempre usar `@` no final). * **BLE** (iOS/Android) usando `flutter_blue_plus` com característica GATT de escrita — se seu dispositivo for BLE. * **Telemetria de retorno** (exibir RPM, velocidade, etc.). *** ## Dúvidas de protocolo Quer que eu **diferencie as setas** (esquerda/direita) com comandos distintos (ex.: `EL` / `ER`), ou **ambas** devem enviar apenas `E`? Também prefere que o **acelerador** envie apenas `A` ou `A@` (ex.: `A70@`)? Se você me disser como seu receptor espera os dados, eu ajusto o código exatamente ao seu protocolo. Ótima ideia, Wallace! 👌 Dá para deixar o controle bem mais “responsivo” adicionando **hápticos/vibração** quando o usuário interagir com o volante, acelerador, freios e setas. Você tem **duas opções** principais no Flutter: 1. **Haptics nativos (sem plugin)** — via `HapticFeedback` (leve, médio, pesado, seleção, etc.). 2. **Vibração personalizada** — via plugin (ex.: `vibration`) para definir duração/padrões no Android. Abaixo mostro como integrar ambas — você pode usar só `HapticFeedback` para começar e, se quiser vibrações mais longas/personalizadas, habilitar o plugin. *** ## 1) Haptics nativos (sem plugin) ### Como usar ```dart import 'package:flutter/services.dart'; // Exemplos: HapticFeedback.selectionClick(); // feedback leve para seleção HapticFeedback.lightImpact(); // impacto leve HapticFeedback.mediumImpact(); // impacto médio HapticFeedback.heavyImpact(); // impacto forte HapticFeedback.vibrate(); // genérico (varia por plataforma) ``` ### Onde colocar no seu código * **Volante**: ao soltar (quando envia `V@`) — impacto médio. * **Acelerador**: ao soltar o slider — impacto leve. * **Freios**: ao tocar — impacto pesado. * **Setas**: ao tocar — clique de seleção. **Exemplo integrando no seu `RemoteControlPage`:** ```dart import 'package:flutter/services.dart'; // ... dentro do _buildSteeringWheel: onPanEnd: (_) async { await _sendWheel('V${_wheelAngleDeg.round()}@'); HapticFeedback.mediumImpact(); // feedback ao “fechar” o gesto }, // ... no _buildAccelerator: onChangeEnd: (v) async { await widget.service.sendCommand('A'); HapticFeedback.lightImpact(); }, ElevatedButton.icon( onPressed: () async { await widget.service.sendCommand('A'); HapticFeedback.selectionClick(); }, // ... ), // ... nos botões de freio e setas: ElevatedButton.icon( onPressed: () async { await widget.service.sendCommand('F'); HapticFeedback.heavyImpact(); }, // ... ), ElevatedButton.icon( onPressed: () async { await widget.service.sendCommand('E'); HapticFeedback.selectionClick(); }, // ... ), ``` > 💡 Dica: Você também pode dar feedback **durante** o movimento do volante (por exemplo a cada 15°) usando `HapticFeedback.selectionClick()` dentro de `onPanUpdate` com um “throttle”, para não vibrar demais. *** ## 2) Vibração personalizada (Android) com plugin Para vibrações com **duração** e **padrões** (ex.: 50ms, 100ms), use o plugin `vibration`. Ele checa se o dispositivo tem vibrador e permite padrões. No iOS, vibração longa é limitada; haptics via `HapticFeedback` são mais confiáveis. ### `pubspec.yaml` ```yaml dependencies: vibration: ^1.8.4 ``` ### Permissão no AndroidManifest Adicione em `android/app/src/main/AndroidManifest.xml`: ```xml ``` ### Uso no código ```dart import 'package:vibration/vibration.dart'; Future vibrarCurta() async { if (await Vibration.hasVibrator() ?? false) { Vibration.vibrate(duration: 30); // 30ms } } Future vibrarMedia() async { if (await Vibration.hasVibrator() ?? false) { Vibration.vibrate(duration: 60); // 60ms } } Future vibrarPadraoSeta() async { if (await Vibration.hasVibrator() ?? false) { // padrão: vibra, pausa, vibra... Vibration.vibrate(pattern: [0, 20, 40, 20]); } } ``` ### Integrando aos eventos * **Freios**: `vibrarMedia()` * **Setas**: `vibrarPadraoSeta()` * **Acelerador**: `vibrarCurta()` * **Volante (soltar)**: `vibrarCurta()` ou `HapticFeedback.mediumImpact()` *** ## Exemplo completo de integração (trechos) ```dart // topo do arquivo import 'package:flutter/services.dart'; import 'package:vibration/vibration.dart'; // helpers Future vibrarCurta() async { if (await Vibration.hasVibrator() ?? false) { Vibration.vibrate(duration: 30); } } Future vibrarMedia() async { if (await Vibration.hasVibrator() ?? false) { Vibration.vibrate(duration: 60); } } Future vibrarPadraoSeta() async { if (await Vibration.hasVibrator() ?? false) { Vibration.vibrate(pattern: [0, 20, 40, 20]); } } // --- Volante --- onPanEnd: (_) async { await _sendWheel('V${_wheelAngleDeg.round()}@'); HapticFeedback.mediumImpact(); // ou: await vibrarCurta(); }, // --- Acelerador --- onChangeEnd: (v) async { await widget.service.sendCommand('A'); HapticFeedback.lightImpact(); }, // --- Freios --- onPressed: () async { await widget.service.sendCommand('F'); HapticFeedback.heavyImpact(); // ou: await vibrarMedia(); }, // --- Setas --- onPressed: () async { await widget.service.sendCommand('E'); HapticFeedback.selectionClick(); // e opcionalmente: await vibrarPadraoSeta(); }, ``` *** ## Boas práticas e observações * **Android**: vibração curta não requer permissão em tempo de execução; basta `` no manifest. * **iOS**: haptics via `HapticFeedback` funcionam melhor do que vibrações longas; o **Simulador** não reproduz haptics, teste em dispositivo físico. * **Evite excesso**: use “throttle” ao disparar durante gestos (volante) para não gerar vibração contínua incômoda. * **Acessibilidade**: considere permitir ligar/desligar vibração em **Configurações** do app. *** ## Quer que eu aplique direto no seu arquivo? Posso te enviar o `main.dart` atualizado com as chamadas de `HapticFeedback`/`vibration` já inseridas nos pontos certos. Preferências rápidas: * Quer **apenas haptics nativos** (sem plugin) ou **vibração personalizada** com o plugin? * Quer vibração **durante** o giro (por exemplo a cada 10–15°) ou **apenas ao soltar**?