App companion BLE per ESP32: UX/UI semplice con Flutter
Nel tutorial sul Bluetooth Low Energy abbiamo trasformato l’ESP32-S3 in un server BLE e verificato il collegamento con nRF Connect. Ora costruiamo l’altra metà del progetto: un’app companion vera, con un’interfaccia semplice e intuitiva, pensata per essere usata da chiunque — non solo da chi sa già cos’è una caratteristica GATT.
Che framework usare
| Opzione | Pro | Contro | Quando sceglierla |
|---|---|---|---|
| Flutter | Un solo codice per Android e iOS, hot reload velocissimo, ottimo supporto BLE (flutter_blue_plus), grande libertà nel design dell’interfaccia | Richiede imparare Dart (semplice se conosci già un linguaggio moderno) | La scelta di questo tutorial: miglior compromesso tra qualità dell’UX e velocità di sviluppo |
| Kotlin nativo (Android) | Massimo controllo, integrazione più profonda col sistema | Solo Android, più codice per lo stesso risultato, API BLE native più verbose | Se ti serve integrazione stretta con funzionalità Android specifiche |
| MIT App Inventor | Nessun codice, editor a blocchi, prototipo in un pomeriggio | Personalizzazione UI limitata, poco adatto a un’app “curata” | Solo per un test/prototipo rapidissimo (visto nel tutorial precedente) |
I principi UX per un’app BLE semplice
Prima del codice, le regole che rendono un’app per hardware davvero facile da usare:
- Stato di connessione sempre visibile: un indicatore colorato (rosso/giallo/verde) presente in ogni schermata, mai un’informazione nascosta in un menu
- Una sola azione primaria per schermata: nella schermata di scansione, l’unica cosa da fare è scegliere un dispositivo; nella dashboard, i dati sono in primo piano, le azioni secondarie (disconnetti, impostazioni) sono discrete
- Feedback immediato su ogni operazione asincrona: scansione, connessione e lettura dati richiedono tempo — mostra sempre uno stato di caricamento, mai uno schermo che sembra bloccato
- Tolleranza agli errori: un Bluetooth disattivato o un dispositivo fuori portata devono produrre un messaggio chiaro (“Accendi il Bluetooth” con un pulsante diretto alle impostazioni), mai un crash silenzioso
Setup del progetto Flutter
flutter create webappportal_ble_app
cd webappportal_ble_app
flutter pub add flutter_blue_plus
Schermata 1: scansione e lista dispositivi
import 'package:flutter/material.dart';
import 'package:flutter_blue_plus/flutter_blue_plus.dart';
class ScanScreen extends StatefulWidget {
const ScanScreen({super.key});
@override
State<ScanScreen> createState() => _ScanScreenState();
}
class _ScanScreenState extends State<ScanScreen> {
List<ScanResult> results = [];
bool scanning = false;
void startScan() {
setState(() => scanning = true);
FlutterBluePlus.startScan(timeout: const Duration(seconds: 5));
FlutterBluePlus.scanResults.listen((r) {
setState(() => results = r.where((d) =>
d.device.platformName == "WebAppPortal-Sensor").toList());
});
Future.delayed(const Duration(seconds: 5), () => setState(() => scanning = false));
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: const Text("Dispositivi vicini")),
body: results.isEmpty
? Center(
child: scanning
? const CircularProgressIndicator()
: const Text("Nessun dispositivo trovato.\nTocca per cercare ancora."),
)
: ListView.builder(
itemCount: results.length,
itemBuilder: (ctx, i) => ListTile(
leading: const Icon(Icons.sensors),
title: Text(results[i].device.platformName),
subtitle: Text("Segnale: ${results[i].rssi} dBm"),
onTap: () => connectAndOpen(context, results[i].device),
),
),
floatingActionButton: FloatingActionButton(
onPressed: startScan,
child: const Icon(Icons.refresh),
),
);
}
}
Schermata 2: dashboard con i dati in tempo reale
const serviceUuid = "6e400001-b5a3-f393-e0a9-e50e24dcca9e";
const charUuid = "6e400002-b5a3-f393-e0a9-e50e24dcca9e";
class DashboardScreen extends StatefulWidget {
final BluetoothDevice device;
const DashboardScreen({super.key, required this.device});
@override
State<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
String value = "--";
bool connected = false;
@override
void initState() {
super.initState();
connect();
}
Future<void> connect() async {
await widget.device.connect();
setState(() => connected = true);
final services = await widget.device.discoverServices();
final service = services.firstWhere((s) => s.uuid.toString() == serviceUuid);
final char = service.characteristics.firstWhere((c) => c.uuid.toString() == charUuid);
await char.setNotifyValue(true);
char.lastValueStream.listen((data) {
setState(() => value = String.fromCharCodes(data));
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text("Stazione"),
actions: [
Padding(
padding: const EdgeInsets.only(right: 16),
child: Icon(Icons.circle, size: 12,
color: connected ? Colors.green : Colors.red),
),
],
),
body: Center(
child: Card(
margin: const EdgeInsets.all(24),
child: Padding(
padding: const EdgeInsets.all(32),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Text("Temperatura", style: TextStyle(fontSize: 16, color: Colors.grey)),
Text("$value °C", style: const TextStyle(fontSize: 48, fontWeight: FontWeight.bold)),
],
),
),
),
),
);
}
}
Come funziona
- La scansione filtra subito per nome del dispositivo (
"WebAppPortal-Sensor", lo stesso impostato nel firmware): l’utente non deve capire cosa sono UUID e indirizzi MAC, vede solo “il suo” dispositivo setNotifyValue(true)+lastValueStream.listen()è l’equivalente Flutter delle notify BLE lato firmware: ogni volta che l’ESP32 chiamanotify(), lo stream si aggiorna e la UI si ridisegna automaticamente, senza bisogno di interrogare il dispositivo a intervalli- L’indicatore di stato (pallino verde/rosso nell’AppBar) segue esattamente il primo principio UX: sempre visibile, in ogni momento
Rifinitura: coerenza visiva con il brand
Per un’app che sembri “prodotto finito” e non un prototipo, applica una palette coerente (es. gli stessi colori usati nel resto del progetto) tramite un ThemeData centralizzato invece di colori sparsi nel codice, e preferisci componenti Material standard (Card, ListTile, FloatingActionButton) piuttosto che widget custom complessi: la familiarità dei controlli standard è spesso più “intuitiva” di un design originale ma non convenzionale.
Va oltre
Con firmware e app completi, il passo successivo naturale è aggiungere la scrittura oltre alla lettura (es. un pulsante nell’app che invia un comando all’ESP32 tramite una seconda caratteristica BLE con permesso di scrittura) e la persistenza locale dei dati ricevuti, per mostrare uno storico invece del solo valore istantaneo.
