Perché portare l'AI generativa dentro l'app

Riassumere un testo, generare descrizioni di prodotto, classificare un'immagine scattata dall'utente, costruire un assistente conversazionale: sono funzionalità che oggi si implementano con poche decine di righe grazie ai modelli Gemini. Il punto delicato non è la chiamata all'API, ma come la si effettua da un'app mobile, dove il codice è ispezionabile e una chiave API in chiaro è un rischio concreto.

In questa guida vediamo l'approccio consigliato oggi per Flutter: il pacchetto firebase_ai, che espone i modelli Gemini tramite Firebase AI Logic, con supporto a Firebase App Check per proteggere l'endpoint. Confronteremo anche l'alternativa google_generative_ai e capiremo quando ha senso usarla.

Client SDK o backend proprio?

Prima del codice, una decisione architetturale:

  • Chiamata diretta dal client con chiave API (google_generative_ai): semplice, ma la chiave finisce nel binario. Va bene solo per prototipi, demo interne o app desktop/CLI in cui la chiave è dell'utente stesso.
  • Client SDK con Firebase AI Logic (firebase_ai): non richiede chiave nel codice, l'accesso passa da un proxy Google autenticato e può essere protetto con App Check. È la scelta standard per app in produzione senza backend.
  • Backend proprio (Cloud Functions, server Dart/Node): massimo controllo su rate limiting, logging, prompt injection e costi. Necessario se hai logica di business complessa o dati sensibili da filtrare.

Qui usiamo la seconda opzione, che copre la maggior parte dei casi reali.

Setup del progetto

Dopo aver collegato l'app a Firebase con la FlutterFire CLI (flutterfire configure), aggiungi le dipendenze:

dependencies:
  firebase_core: ^3.8.0
  firebase_ai: ^2.0.0
  firebase_app_check: ^0.3.2

Attiva quindi Firebase AI Logic dalla console Firebase (sezione AI Logic) scegliendo il provider: Gemini Developer API (piano gratuito disponibile, ideale per iniziare) oppure Vertex AI (fatturazione GCP, regioni configurabili, requisiti enterprise).

Inizializzazione tipica:

import 'package:firebase_core/firebase_core.dart';
import 'package:firebase_app_check/firebase_app_check.dart';
import 'firebase_options.dart';

Future<void> main() async {
  WidgetsFlutterBinding.ensureInitialized();
  await Firebase.initializeApp(
    options: DefaultFirebaseOptions.currentPlatform,
  );
  await FirebaseAppCheck.instance.activate(
    androidProvider: AndroidProvider.playIntegrity,
    appleProvider: AppleProvider.appAttest,
  );
  runApp(const MyApp());
}

Senza App Check chiunque potrebbe estrarre la configurazione Firebase e usare la tua quota: consideralo un requisito, non un extra.

La prima richiesta

import 'package:firebase_ai/firebase_ai.dart';

final model = FirebaseAI.googleAI().generativeModel(
  model: 'gemini-2.5-flash',
  systemInstruction: Content.system(
    'Sei un assistente che risponde in italiano, con tono conciso. '
    'Se non conosci la risposta, dillo esplicitamente.',
  ),
  generationConfig: GenerationConfig(
    temperature: 0.4,
    maxOutputTokens: 512,
  ),
);

Future<String> riassumi(String testo) async {
  final response = await model.generateContent([
    Content.text('Riassumi in 3 punti elenco:\n\n$testo'),
  ]);
  return response.text ?? 'Nessuna risposta generata.';
}

Alcune note pratiche:

  • systemInstruction è il posto giusto per definire ruolo, lingua e vincoli del modello: è più stabile che ripetere le istruzioni in ogni prompt.
  • temperature bassa (0.2–0.4) per compiti deterministici (estrazione dati, classificazione), più alta (0.8–1.0) per contenuti creativi.
  • maxOutputTokens è la tua prima difesa contro risposte chilometriche e costi imprevisti.
  • response.text può essere null se la risposta è stata bloccata dai filtri di sicurezza: gestisci sempre il caso.

Streaming: la UX che fa la differenza

Aspettare 8 secondi con uno spinner è la ricetta perfetta per far chiudere l'app. Lo streaming mostra il testo mentre viene generato e cambia radicalmente la percezione della latenza.

Stream<String> riassumiStream(String testo) async* {
  final buffer = StringBuffer();
  final stream = model.generateContentStream([
    Content.text('Riassumi in 3 punti elenco:\n\n$testo'),
  ]);

  await for (final chunk in stream) {
    final text = chunk.text;
    if (text != null) {
      buffer.write(text);
      yield buffer.toString();
    }
  }
}

Lato UI, un semplice StreamBuilder basta:

StreamBuilder<String>(
  stream: _stream,
  builder: (context, snapshot) {
    if (snapshot.hasError) {
      return Text('Errore: ${snapshot.error}');
    }
    final text = snapshot.data ?? '';
    return SelectableText(
      text.isEmpty ? 'Sto pensando…' : text,
    );
  },
)

Ricorda di annullare la sottoscrizione quando l'utente lascia la schermata: conserva la StreamSubscription in un controller/notifier e chiamane cancel() nel dispose(), altrimenti continui a pagare token per una risposta che nessuno leggerà.

Chat con memoria della conversazione

Per un assistente conversazionale non serve ricostruire a mano la history: l'SDK offre una sessione di chat.

late final ChatSession _chat = model.startChat(
  history: [
    Content.text('Mi chiamo Luca e sviluppo app Flutter.'),
    Content.model([TextPart('Ciao Luca! Come posso aiutarti?')]),
  ],
);

Stream<String> invia(String messaggio) async* {
  final buffer = StringBuffer();
  await for (final chunk in _chat.sendMessageStream(Content.text(messaggio))) {
    buffer.write(chunk.text ?? '');
    yield buffer.toString();
  }
}

Attenzione: la history cresce a ogni turno e ogni token in input viene fatturato. In conversazioni lunghe conviene troncare i messaggi più vecchi o mantenere un riassunto rolling generato dal modello stesso.

Input multimodale: testo + immagini

Gemini accetta immagini, audio e PDF come parte del prompt. Un caso d'uso classico: l'utente fotografa uno scontrino e l'app estrae i dati.

import 'dart:typed_data';

Future<String> analizzaImmagine(Uint8List bytes) async {
  final response = await model.generateContent([
    Content.multi([
      TextPart('Descrivi il contenuto di questa immagine in una frase.'),
      InlineDataPart('image/jpeg', bytes),
    ]),
  ]);
  return response.text ?? '';
}

Prima di inviare, comprimi: un'immagine da 8 MP consuma banda, tempo e token. Ridimensionare il lato lungo a 1024–1536 px con image o flutter_image_compress è quasi sempre sufficiente per la comprensione del modello.

Output strutturato: JSON invece di prosa

Se devi popolare un modello Dart, chiedere "rispondi in JSON" nel prompt non è affidabile. Usa invece lo schema di risposta, che vincola il modello a un formato valido.

final jsonSchema = Schema.object(
  properties: {
    'titolo': Schema.string(description: 'Titolo breve del documento'),
    'importo': Schema.number(description: 'Totale in euro'),
    'categorie': Schema.array(items: Schema.string()),
  },
);

final extractor = FirebaseAI.googleAI().generativeModel(
  model: 'gemini-2.5-flash',
  generationConfig: GenerationConfig(
    responseMimeType: 'application/json',
    responseSchema: jsonSchema,
  ),
);

Future<Scontrino> estrai(Uint8List foto) async {
  final res = await extractor.generateContent([
    Content.multi([
      TextPart('Estrai i dati dello scontrino.'),
      InlineDataPart('image/jpeg', foto),
    ]),
  ]);
  final map = jsonDecode(res.text!) as Map<String, dynamic>;
  return Scontrino.fromJson(map);
}

Abbinato a json_serializable o freezed, questo pattern trasforma l'AI in una sorgente dati come un'altra, tipizzata e testabile. Continua comunque a proteggere il jsonDecode con un try/catch: il modello può restituire campi mancanti o valori fuori range.

Function calling: far agire il modello

Quando l'assistente deve consultare dati reali (meteo, catalogo, database locale), si dichiarano delle funzioni che il modello può richiedere di eseguire.

final meteoTool = FunctionDeclaration(
  'getMeteo',
  'Restituisce il meteo attuale per una città italiana',
  parameters: {
    'citta': Schema.string(description: 'Nome della città'),
  },
);

final agent = FirebaseAI.googleAI().generativeModel(
  model: 'gemini-2.5-flash',
  tools: [Tool.functionDeclarations([meteoTool])],
);

Future<String> chiedi(String domanda) async {
  final chat = agent.startChat();
  var response = await chat.sendMessage(Content.text(domanda));

  for (final call in response.functionCalls) {
    if (call.name == 'getMeteo') {
      final result = await meteoRepository.get(call.args['citta'] as String);
      response = await chat.sendMessage(
        Content.functionResponse(call.name, result),
      );
    }
  }
  return response.text ?? '';
}

Il modello non esegue nulla: propone la chiamata, la tua app decide se e come eseguirla. Non delegare mai operazioni distruttive (pagamenti, cancellazioni) senza conferma esplicita dell'utente.

Errori, filtri e resilienza

Le chiamate AI falliscono più spesso delle normali REST: rete instabile, quota esaurita, contenuto bloccato. Isola tutto dietro un repository e mappa gli errori in tipi tuoi.

Future<String> chiediSicuro(String prompt) async {
  try {
    final res = await model.generateContent([Content.text(prompt)])
        .timeout(const Duration(seconds: 30));
    final text = res.text;
    if (text == null || text.isEmpty) {
      throw const AiException('Risposta bloccata o vuota');
    }
    return text;
  } on FirebaseAIException catch (e) {
    throw AiException('Servizio AI non disponibile: ${e.message}');
  } on TimeoutException {
    throw const AiException('Il modello non ha risposto in tempo');
  }
}

Puoi anche modulare i safetySettings per categoria (molestie, contenuti pericolosi, ecc.), ma verifica sempre promptFeedback e candidates.first.finishReason per capire se una risposta è stata troncata (maxTokens) o filtrata.

Testabilità

Non fare mai chiamate reali nei test: sono lente, costose e non deterministiche. Definisci un'astrazione minima:

abstract interface class AiClient {
  Future<String> complete(String prompt);
  Stream<String> completeStream(String prompt);
}

L'implementazione con firebase_ai vive in un solo file; nei test di widget e unit inietti una fake che restituisce risposte fisse. Come bonus, cambiare provider (Gemini, un modello on-device, un tuo backend) diventa una modifica localizzata.

Costi e prestazioni: regole pratiche

  • Usa i modelli Flash per la maggior parte dei casi: sono molto più economici e rapidi dei modelli Pro, con qualità sufficiente per riassunti, classificazioni ed estrazioni.
  • Metti in cache le risposte deterministiche (per esempio la descrizione generata per un prodotto) su Isar/Drift o Firestore: rigenerare lo stesso testo a ogni apertura è denaro buttato.
  • Fai debounce degli input: nessuna chiamata a ogni battitura in un campo di testo.
  • Imposta limiti lato utente (numero di richieste al giorno) e monitora l'uso in console prima di andare in produzione.
  • Comprimi le immagini e limita la lunghezza della history: gli input pesano quanto gli output.

Conclusione

Con firebase_ai l'integrazione di Gemini in Flutter è questione di poche righe, ma il valore reale sta nei dettagli: streaming per la percezione di velocità, output strutturato per avere dati e non testo, App Check per non regalare la quota, astrazione per poter testare e cambiare provider. Parti da una singola funzionalità ben delimitata — un riassunto, una classificazione, un'estrazione da foto — misurane costi e utilità, e solo dopo estendi l'AI al resto dell'app.