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**?