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.temperaturebassa (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.textpuò esserenullse 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.