App companion BLE per ESP32: UX/UI semplice con Flutter

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 chiama notify(), 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.

Imprenditore, maker, MBSE enthusiast e co-autore di Web App Portal. Collegati su LinkedIn.