Perché l'autofill non è un dettaglio

La schermata di login è il punto in cui si perde il maggior numero di utenti. Ogni carattere digitato a mano è attrito: una password lunga, un codice OTP da leggere nella notifica, il passaggio avanti e indietro tra app e SMS. Sia Android sia iOS offrono da anni meccanismi di compilazione automatica (password manager di sistema, SMS Retriever, suggerimento del codice sulla tastiera) e Flutter li espone con API semplici, che però restano poco usate perché richiedono qualche accortezza in più rispetto a un normale TextField.

In questa guida vediamo come:

  • marcare correttamente i campi con autofillHints e raggrupparli con AutofillGroup;
  • far comparire il prompt "Vuoi salvare la password?" dopo un login riuscito;
  • configurare Android e iOS perché il sistema riconosca la nostra app;
  • leggere automaticamente un codice OTP arrivato via SMS.

Le basi: autofillHints e AutofillGroup

Il primo passo è dichiarare la semantica di ogni campo. Il sistema operativo non indovina: se non gli diciamo che quel campo è uno username, non proporrà nulla.

TextField(
  controller: _emailController,
  keyboardType: TextInputType.emailAddress,
  textInputAction: TextInputAction.next,
  autofillHints: const [AutofillHints.username, AutofillHints.email],
)

La classe AutofillHints contiene decine di costanti (username, password, newPassword, oneTimeCode, telephoneNumber, postalCode, creditCardNumber, name, streetAddressLine1…). Ogni piattaforma mappa questi hint sulle proprie costanti native; non tutte sono supportate ovunque, ma gli hint sconosciuti vengono semplicemente ignorati.

Il secondo passo è il raggruppamento. Un password manager salva una coppia credenziali: perché funzioni, username e password devono vivere nello stesso AutofillGroup.

class LoginForm extends StatefulWidget {
  const LoginForm({super.key});

  @override
  State<LoginForm> createState() => _LoginFormState();
}

class _LoginFormState extends State<LoginForm> {
  final _email = TextEditingController();
  final _password = TextEditingController();

  @override
  void dispose() {
    _email.dispose();
    _password.dispose();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    return AutofillGroup(
      child: Column(
        mainAxisSize: MainAxisSize.min,
        children: [
          TextField(
            controller: _email,
            keyboardType: TextInputType.emailAddress,
            textInputAction: TextInputAction.next,
            autofillHints: const [AutofillHints.username],
            decoration: const InputDecoration(labelText: 'Email'),
          ),
          const SizedBox(height: 12),
          TextField(
            controller: _password,
            obscureText: true,
            textInputAction: TextInputAction.done,
            autofillHints: const [AutofillHints.password],
            onSubmitted: (_) => _login(),
            decoration: const InputDecoration(labelText: 'Password'),
          ),
          const SizedBox(height: 24),
          FilledButton(onPressed: _login, child: const Text('Accedi')),
        ],
      ),
    );
  }
}

In fase di registrazione usa AutofillHints.newPassword invece di password: è il segnale che fa proporre al portachiavi una password forte generata automaticamente.

Far salvare le credenziali: finishAutofillContext

Dichiarare gli hint serve a leggere i dati. Per scriverli (cioè per ottenere il prompt "Salva password") bisogna dire esplicitamente al sistema che il contesto di autofill è concluso, tipicamente quando il login va a buon fine:

import 'package:flutter/services.dart';

Future<void> _login() async {
  final result = await authRepository.signIn(_email.text, _password.text);

  if (result.isSuccess) {
    // Chiude il contesto e chiede al sistema di salvare i dati inseriti.
    TextInput.finishAutofillContext();
    if (mounted) context.go('/home');
  } else {
    // Credenziali errate: non salvare nulla.
    TextInput.finishAutofillContext(shouldSave: false);
    _showError(result.error);
  }
}

Il parametro shouldSave: false è importante: evita che il password manager memorizzi una password sbagliata, errore classico che poi costringe l'utente a correggere la voce salvata.

Se l'utente abbandona la schermata senza completare il login, puoi controllare il comportamento con onDisposeAction:

AutofillGroup(
  onDisposeAction: AutofillContextAction.cancel, // default: commit
  child: ...,
)

Login a più step

Se la tua UI chiede prima l'email e poi, in una seconda schermata, la password (pattern "identifier first"), i due campi non possono stare nello stesso AutofillGroup. In quel caso l'autofill funziona comunque in lettura, ma il salvataggio della coppia è meno affidabile: valuta un unico step con entrambi i campi, oppure mantieni la password nella stessa route usando un PageView interno — i widget restano nello stesso albero e quindi nello stesso gruppo.

Configurazione per piattaforma

iOS

Su iOS il Password AutoFill funziona già con i soli hint, ma per far proporre le credenziali del sito web corrispondente devi collegare app e dominio:

  1. aggiungi la capability Associated Domains in Xcode con webcredentials:tuodominio.it;
  2. pubblica il file apple-app-site-association su https://tuodominio.it/.well-known/apple-app-site-association includendo la sezione webcredentials con il tuo TEAMID.bundle.id.

È la stessa infrastruttura dei Universal Links: se l'hai già configurata per il deep linking, ti serve solo aggiungere la chiave webcredentials.

Android

L'Autofill Framework è disponibile da API 26 e funziona automaticamente con gli hint. Per l'associazione con il sito web (Google Password Manager) serve il file Digital Asset Links su https://tuodominio.it/.well-known/assetlinks.json, con la relazione delegate_permission/common.get_login_creds oltre a quella dei deep link.

Attenzione a un dettaglio pratico: alcuni servizi di autofill di terze parti mostrano il pannello solo se l'app non blocca gli screenshot (FLAG_SECURE). Se usi flag di sicurezza sulla schermata di login, testala con il password manager reale.

Web e desktop

Su Flutter Web gli autofillHints vengono mappati sull'attributo autocomplete degli input HTML, quindi il browser propone le credenziali salvate. Il supporto è però parziale e dipende dal renderer: verificalo sempre sui browser target. Su desktop, al momento, l'autofill di sistema non è supportato.

OTP via SMS: le tre strade

L'altro grande punto di attrito è il codice di verifica. Qui le piattaforme si comportano in modo molto diverso.

iOS: basta un hint

Su iOS non serve alcun SDK: se il campo dichiara AutofillHints.oneTimeCode, il sistema analizza l'SMS in arrivo e propone il codice sopra la tastiera (QuickType).

TextField(
  controller: _otp,
  keyboardType: TextInputType.number,
  autofillHints: const [AutofillHints.oneTimeCode],
)

Perché funzioni bene, il messaggio deve contenere una formula riconoscibile, ad esempio: Il tuo codice di verifica è 123456.

Android: SMS Retriever o User Consent

Android offre due API dei Google Play Services:

  • SMS Retriever API: nessun permesso, lettura totalmente automatica, ma il messaggio deve iniziare con <#> e terminare con un hash di 11 caratteri che identifica la tua app (dipende da package name e certificato di firma).
  • SMS User Consent API: nessun hash necessario, ma l'utente deve confermare con un dialog di sistema il permesso a leggere quel singolo messaggio.

Un SMS compatibile con SMS Retriever ha questa forma:

<#> Il tuo codice Acme è 123456
FA+9qCX9VSu

L'hash va generato una volta per ogni variante di firma (debug, release, Play App Signing) e passato al backend che invia gli SMS.

Implementazione con Pinput e smart_auth

Il pacchetto pinput fornisce la UI a caselle per l'OTP e integra nativamente l'ascolto degli SMS su Android tramite smart_auth:

Pinput(
  length: 6,
  controller: _otpController,
  keyboardType: TextInputType.number,
  // iOS: suggerimento sulla tastiera
  // Android: ascolto automatico dell'SMS
  androidSmsAutofillMethod: AndroidSmsAutofillMethod.smsRetrieverApi,
  onCompleted: (code) => _verify(code),
  defaultPinTheme: PinTheme(
    width: 56,
    height: 56,
    textStyle: Theme.of(context).textTheme.titleLarge,
    decoration: BoxDecoration(
      border: Border.all(color: Theme.of(context).colorScheme.outline),
      borderRadius: BorderRadius.circular(12),
    ),
  ),
)

Se preferisci gestire tutto a mano, smart_auth espone le stesse funzionalità, compreso il recupero dell'app signature da comunicare al backend:

final smartAuth = SmartAuth();

// Da stampare in debug e configurare lato server.
final signature = await smartAuth.getAppSignature();
debugPrint('App signature: $signature');

// Avvia l'ascolto prima di richiedere l'invio dell'SMS.
final res = await smartAuth.getSmsCode(useUserConsentApi: false);
if (res.succeed && res.code != null) {
  _otpController.text = res.code!;
  _verify(res.code!);
}

// Quando esci dalla schermata
await smartAuth.removeSmsListener();

L'API di smart_auth è cambiata tra la versione 1.x e la 2.x: controlla il changelog del pacchetto prima di copiare il codice. L'ordine delle operazioni, invece, resta sempre lo stesso: avvia l'ascolto, poi chiedi al backend di inviare l'SMS. Se inverti i due passaggi, un SMS veloce può arrivare prima che il listener sia attivo.

Errori frequenti

  • Hint su un solo campo. Senza il campo password nello stesso gruppo, molti password manager non propongono il salvataggio.
  • Dimenticare finishAutofillContext(). L'autofill in lettura funziona, ma nessuna credenziale viene mai memorizzata: l'utente dovrà digitare per sempre.
  • Salvare password errate. Chiama sempre finishAutofillContext(shouldSave: false) nel ramo di errore.
  • Hash SMS sbagliato. L'hash della build di debug è diverso da quello della release firmata con Play App Signing: servono entrambi lato backend, altrimenti l'autofill funziona in locale e non in produzione.
  • Campo OTP ricostruito. Se il widget dell'OTP viene smontato e rimontato (ad esempio da un setState che cambia la struttura dell'albero), il listener SMS può perdersi: gestisci lo stato di caricamento senza sostituire il widget.
  • Test solo su emulatore. L'autofill dipende dal password manager installato: provalo su dispositivi reali, con Google Password Manager, iCloud Keychain e almeno un manager di terze parti.

Oltre le password: le passkey

Il passo successivo logico è eliminare del tutto la password. Le passkey (WebAuthn/FIDO2) sono supportate su Android tramite Credential Manager e su iOS tramite il portachiavi, e richiedono la stessa configurazione di associated domains / asset links vista sopra. In Flutter si usano con pacchetti come credential_manager o i plugin dei provider di identità (Firebase Auth, Supabase, Auth0). Se hai già collegato app e dominio per l'autofill, hai fatto gran parte del lavoro necessario anche per le passkey.

Conclusione

L'autofill è una di quelle funzionalità a basso costo e alto impatto: poche righe di codice, un po' di configurazione lato piattaforma e un login che passa da trenta secondi a due tap. Comincia dagli autofillHints e da AutofillGroup, aggiungi finishAutofillContext() nel flusso di successo, poi occupati dell'OTP. E verifica sempre su dispositivi reali: è l'unico modo per sapere se il password manager dell'utente sta davvero collaborando.