glosings0n / codelabs / On-Device AI with Flutter & Gemma (droidCon Uganda 2026)
45 min left
Step 1 of 9 | Context & Motivation Last updated:

Overview: Why Offline-First Edge AI Matters in Africa

Welcome to the official companion codelab for "On-Device AI with Flutter & Gemma: Building Offline-First AI Mobile Apps for Africa" at droidCon Uganda 2026 in Kampala!

Bienvenue dans le codelab officiel de la session "On-Device AI with Flutter & Gemma: Building Offline-First AI Mobile Apps for Africa" présentée au droidCon Uganda 2026 à Kampala !

Proven Production Architecture

Every code snippet in this codelab comes directly from Gemmea (Gemma + Mmea, Kiswahili for "plant/crop"), winner of 1st Place at GDG on Campus UCB Build with Gemma 2026.

Chaque extrait de code de ce codelab provient directement de Gemmea (Gemma + Mmea, qui signifie "plante/culture" en kiswahili), lauréat du 1er Prix au hackathon GDG on Campus UCB Build with Gemma 2026.

1. Context & The "Why"

  1. The Connectivity Reality: How often do your users lose signal when traveling outside Kampala to Gulu, Mbarara, Entebbe, or across the border into rural Kivu?
  2. The Unit Economics Trap: Can an African startup sustainably pay Cloud LLM API bills in USD while users pay in UGX or CDF; or cannot afford daily mobile data bundles at all?
  3. The Hardware Boundary: What happens when you load an unoptimized LLM onto a $120 Android phone with 3GB to 6GB RAM? (Spoiler: Android's Low Memory Killer terminates your process immediately).
  1. La réalité de la connectivité : À quelle fréquence vos utilisateurs perdent-ils le réseau lorsqu'ils quittent Kampala pour Gulu, Mbarara, Entebbe ou les zones rurales du Kivu ?
  2. Le piège économique des API Cloud : Une startup africaine peut-elle durablement payer des factures d'API LLM Cloud en USD alors que ses utilisateurs paient en UGX ou CDF, ou n'ont pas les moyens d'acheter des forfaits data quotidiens ?
  3. La limite matérielle : Que se passe-t-il lorsque vous chargez un LLM non optimisé sur un smartphone Android à 120 $ doté de 3 Go à 6 Go de RAM ? (Spoiler : le Low Memory Killer d'Android tue votre processus instantanément).

Cloud AI vs. On-Device Edge AI in East Africa

Metric Cloud LLM APIs (REST / WebSocket) On-Device (flutter_gemma + LiteRT-LM)
Offline Availability 0% (Fails completely in rural zones) 100% (Runs in Airplane Mode)
Time-to-First-Token (TTFT) 2,000ms to 8,000ms on unstable 3G/Edge 180ms to 600ms on local CPU/GPU
Image Scan Data Cost 2MB to 5MB upload per leaf photo 0 MB network data consumed
Recurring API Token Cost Scales linearly with active users ($$$) $0.00 / token forever
Privacy & Data Sovereignty User photos & voice leave the country 100% private on the device
Métrique API LLM Cloud (REST / WebSocket) IA Embarquée (flutter_gemma + LiteRT-LM)
Disponibilité Hors-Ligne 0 % (Échoue totalement en zone rurale) 100 % (Fonctionne en Mode Avion)
Time-to-First-Token (TTFT) 2 000 ms à 8 000 ms sur 3G/Edge instable 180 ms à 600 ms sur CPU/GPU local
Coût Data par Scan Photo 2 Mo à 5 Mo d'upload par photo de feuille 0 Mo de données mobiles consommées
Coût Récurrent par Token API Augmente linéairement avec les utilisateurs ($$$) 0,00 $ / token à vie
Confidentialité & Souveraineté Photos et voix envoyées sur des serveurs distants 100 % privé sur l'appareil

2. Learning Goals & What You Will Build

  • Configure flutter_gemma and flutter_gemma_litertlm to run multimodal Gemma models locally on Android and iOS.
  • Implement atomic .tmp chunked model downloads via path_provider so interrupted 3G downloads never corrupt native binaries or bloat your APK.
  • Prevent native C++ FFI concurrency deadlocks and Flutter Hot-Reload freezes with a thread-safe GemmaEngineManager mutex.
  • Eliminate Out-Of-Memory (OOM) kills during multimodal camera scans by downsampling images to 512px and building prompt-level Early-Exit Guardrails.
  • Ground small quantized models without a heavy vector database using Zero-RAM SQLite FTS5 + BM25 Local RAG.
  • Bonus Finale: Distill and quantize a domain-specific Gemma Small Language Model (SLM) to under 150 MB (134 MB INT4) using PyTorch and ai-edge-torch.
  • Configurer flutter_gemma et flutter_gemma_litertlm pour exécuter des modèles Gemma multimodaux localement sur Android et iOS.
  • Implémenter le téléchargement atomique par blocs via fichier .tmp avec path_provider afin que les coupures 3G ne corrompent jamais les binaires natifs et n'alourdissent pas votre APK.
  • Prévenir les deadlocks de concurrence C++ FFI natifs et les blocages lors du Hot-Reload Flutter grâce au mutex thread-safe GemmaEngineManager.
  • Éliminer les crashs Out-Of-Memory (OOM) lors des scans caméra multimodaux en réduisant les images à 512px et en intégrant des garde-fous d'arrêt anticipé (Early-Exit) dans le prompt.
  • Ancrer factuellement les petits modèles quantifiés sans base vectorielle lourde grâce au RAG local SQLite FTS5 + BM25 à Zéro-RAM.
  • Bonus Final : Distiller et quantifier un modèle Gemma spécialisé (SLM) sous la barre des 150 Mo (134 Mo en INT4) avec PyTorch et ai-edge-torch.

3. Prerequisites & System Requirements

Before starting the hands-on steps, verify that your laptop and test device meet the following requirements so native C++ MediaPipe / LiteRT-LM bindings compile and execute without errors:

Avant de commencer les étapes pratiques, vérifiez que votre ordinateur et votre téléphone de test respectent la configuration suivante afin que les bindings C++ natifs MediaPipe / LiteRT-LM compilent et s'exécutent sans erreur :

Requirement Minimum / Recommended Specification Why It Is Required
Flutter & Dart SDK Flutter 3.24.0+ (Dart 3.5.0+) Required by flutter_gemma, flutter_gemma_litertlm, and modern FFI bindings.
Java Development Kit JDK 17 (bundled with Android Studio Ladybug+) Required by Android Gradle Plugin (AGP 8+) to build the APK.
Android SDK & NDK compileSdk 34+ and minSdk 26 (Android 8.0+) LiteRT-LM native .so libraries require Android API 26+ (default Flutter is 21/24).
Target Android Device Physical 64-bit ARM (arm64-v8a) phone with 3GB to 6GB+ RAM & 2.0 GB free storage Standard x86_64 PC emulators lack native ARM64 GenAI acceleration; always test on a real phone via USB Debugging (or ARM64 simulator on Apple Silicon).
Python / Colab (Optional) Google Colab (Free T4 GPU) or Python 3.10+ Only needed if you run the bonus Step 8 model distillation scripts.
Composant Spécification Minimale / Recommandée Pourquoi c'est requis
SDK Flutter & Dart Flutter 3.24.0+ (Dart 3.5.0+) Requis par flutter_gemma, flutter_gemma_litertlm et les bindings FFI modernes.
Java Development Kit JDK 17 (inclus avec Android Studio Ladybug+) Requis par Android Gradle Plugin (AGP 8+) pour compiler l'APK.
Android SDK & NDK compileSdk 34+ et minSdk 26 (Android 8.0+) Les librairies natives .so de LiteRT-LM exigent Android API 26+ (le défaut Flutter est 21/24).
Appareil Android Cible Smartphone physique ARM 64-bit (arm64-v8a) avec 3 Go à 6 Go+ de RAM et 2,0 Go d'espace libre Les émulateurs PC x86_64 n'ont pas l'accélération ARM64 GenAI native ; testez sur un vrai téléphone via débogage USB (ou simulateur ARM64 sur Apple Silicon).
Python / Colab (Optionnel) Google Colab (GPU T4 gratuit) ou Python 3.10+ Uniquement nécessaire si vous exécutez les scripts bonus de distillation à l'Étape 8.
Terminal | Pre-Flight Environment Check BASH / POWERSHELL
# 1. Verify Flutter (>= 3.24.0), Dart (>= 3.5.0), Android toolchain & JDK 17
flutter doctor -v

# 2. Verify your physical Android phone is connected with USB Debugging enabled
flutter devices
Step 2 of 9 | System Design

Offline-First Architecture & flutter_gemma Setup

Smallholder farmers across Uganda, DRC, Rwanda, and Burundi lose 40% to 80% of their yields to threats like Cassava Mosaic Disease (CMD), Banana Bacterial Wilt (BXW / Kayovu), and Maize Rust. To give farmers accurate, scientifically grounded remedies in the middle of a field with zero connectivity, Gemmea combines three local subsystems:

Les petits exploitants agricoles en Ouganda, en RDC, au Rwanda et au Burundi perdent entre 40 % et 80 % de leurs récoltes à cause de fléaux tels que la Mosaïque du Manioc (CMD), le Flétrissement Bactérien du Bananier (BXW / Kayovu) et la Rouille du Maïs. Pour offrir aux agriculteurs des remèdes précis et scientifiquement fondés au milieu d'un champ sans aucune connexion, Gemmea combine trois sous-systèmes locaux :

Gemmea Offline-First Pipeline ARCHITECTURE
[ Farmer / User in the Field (Airplane Mode ON) ]
       │
       ├── 1. Captures Leaf Photo OR Asks Question (Text / Offline Voice)
       ▼
┌──────────────────────────────────────────────────────────────────────┐
│ 1. LOCAL RAG ENGINE (SQLite FTS5 + BM25 Ranking)                     │
│ - Searches indexed FAO & IITA agronomic manuals in <2ms (0 MB RAM)   │
│ - Extracts verified pathogen symptoms, organic dosages & seasons     │
└──────────────────────────────┬───────────────────────────────────────┘
                               │ 2. Injects Factual Grounding Context
                               ▼
┌──────────────────────────────────────────────────────────────────────┐
│ 2. ON-DEVICE GEMMA ENGINE (flutter_gemma + LiteRT-LM Runtime)        │
│ - GemmaEngineManager: Single-threaded FFI Mutex + 8s Timeout Lock    │
│ - Vision VLM Mode: 512px image + 4096 maxTokens + Early-Exit Filter  │
│ - Chat Assistant Mode: 2048 maxTokens + 5-message Sliding Window     │
└──────────────────────────────┬───────────────────────────────────────┘
                               │ 3. Streams Sanitized Tokens
                               ▼
┌──────────────────────────────────────────────────────────────────────┐
│ 3. FLUTTER BLoC / CUBIT LAYER & AUTONOMOUS ACTIONS                   │
│ - Real-time LaTeX/Emoji Sanitizer (converts $NaHCO_3$ -> plain text) │
│ - Autonomous Function Calling (pauses/resumes SQLite rain calendar)  │
│ - Offline TTS Readout & Farm Health PDF Export                       │
└──────────────────────────────────────────────────────────────────────┘

1. Create the Flutter Project & Minimal File Structure

If you are coding along from scratch, create a new Flutter project and set up only the 6 self-contained Dart files below (skipping custom fonts, PDF assets, and extra screens so your app compiles and runs on any laptop in minutes):

Si vous codez depuis zéro durant l'atelier, créez un nouveau projet Flutter et préparez uniquement les 6 fichiers Dart autonomes ci-dessous (sans polices personnalisées, assets PDF ni écrans secondaires afin que votre application compile et tourne en quelques minutes) :

Terminal | Create Project & Folders (macOS / Linux / Git Bash) BASH
flutter create --org com.gemmea gemmea
cd gemmea
mkdir -p lib/core/services lib/core/utils lib/data/datasources lib/data/repositories
Terminal | Create Project & Folders (Windows PowerShell) POWERSHELL
flutter create --org com.gemmea gemmea
cd gemmea
New-Item -ItemType Directory -Force -Path lib/core/services, lib/core/utils, lib/data/datasources, lib/data/repositories
Minimal Runnable Project Structure (6 Complete Files) TREE
lib/
├── core/
│   ├── services/
│   │   └── gemma_engine_manager.dart    # Step 4: Native FFI Mutex & Hot-Reload Lock
│   └── utils/
│       └── text_sanitizer.dart          # Step 7: Real-time LaTeX & Emoji Sanitizer
├── data/
│   ├── datasources/
│   │   ├── gemma_local_source.dart      # Step 5: 512px Vision VLM & Early-Exit Guardrail
│   │   └── sqlite_helper.dart           # Step 6: Zero-RAM SQLite FTS5 + BM25 Local RAG
│   └── repositories/
│       └── chat_repository_impl.dart    # Steps 3 & 7: Atomic .tmp Download & Token Stream
└── main.dart                            # Step 9: Minimal Runnable UI (Download + Scan + Chat)

2. Replace pubspec.yaml & Fetch Packages

Replace your pubspec.yaml with the complete configuration below (compatible with Dart >=3.5.0 <4.0.0 and including uses-material-design: true so Material icons render properly), then run flutter pub get:

Remplacez votre fichier pubspec.yaml par la configuration complète ci-dessous (compatible avec Dart >=3.5.0 <4.0.0 et incluant uses-material-design: true pour l'affichage des icônes Material), puis exécutez flutter pub get :

pubspec.yaml YAML
name: gemmea
description: "Offline-first AI agronomic assistant powered by Google Gemma & SQLite FTS5 RAG."
publish_to: 'none'
version: 0.1.0+1

environment:
  sdk: '>=3.5.0 <4.0.0'

dependencies:
  flutter:
    sdk: flutter

  # On-Device LLM Runtime (MediaPipe / LiteRT-LM Native Bindings)
  flutter_gemma: ^1.2.3
  flutter_gemma_litertlm: ^1.1.0

  # Local FTS5 RAG Database & Atomic Model File Management
  sqflite: ^2.4.3
  path: ^1.9.1
  path_provider: ^2.1.6
  http: ^1.2.1

  # Multimodal Camera / Gallery Input
  image_picker: ^1.1.2

dev_dependencies:
  flutter_test:
    sdk: flutter
  flutter_lints: ^5.0.0

flutter:
  uses-material-design: true

3. Set Android minSdk = 26 & Hardware Acceleration

Crucial Step: flutter_gemma and flutter_gemma_litertlm link native 64-bit C++ MediaPipe / LiteRT-LM binaries that require Android API 26+ (Android 8.0). Open android/app/build.gradle.kts (or android/app/build.gradle) and update minSdk inside defaultConfig from flutter.minSdkVersion to 26:

Étape cruciale : flutter_gemma et flutter_gemma_litertlm lient des binaires C++ 64-bit natifs MediaPipe / LiteRT-LM qui exigent Android API 26+ (Android 8.0). Ouvrez android/app/build.gradle.kts (ou android/app/build.gradle) et remplacez minSdk dans defaultConfig par 26 :

android/app/build.gradle.kts (defaultConfig block) KOTLIN DSL
    defaultConfig {
        applicationId = "com.gemmea.gemmea"
        // MediaPipe / LiteRT-LM native .so libraries require minSdk 26+
        minSdk = 26
        targetSdk = flutter.targetSdkVersion
        versionCode = flutter.versionCode
        versionName = flutter.versionName
    }

Next, replace android/app/src/main/AndroidManifest.xml with this complete manifest so INTERNET and CAMERA permissions and GPU/CPU hardware acceleration are enabled while preserving the default @mipmap/ic_launcher icon and MAIN launcher intent:

Ensuite, remplacez android/app/src/main/AndroidManifest.xml par ce manifeste complet afin d'activer les permissions INTERNET et CAMERA ainsi que l'accélération matérielle GPU/CPU tout en conservant l'icône par défaut @mipmap/ic_launcher et l'intent MAIN :

android/app/src/main/AndroidManifest.xml XML
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
    <uses-permission android:name="android.permission.INTERNET" />
    <uses-permission android:name="android.permission.CAMERA" />
    <uses-permission android:name="android.permission.RECORD_AUDIO" />
    <uses-permission android:name="android.permission.MODIFY_AUDIO_SETTINGS" />

    <application
        android:label="Gemmea"
        android:name="${applicationName}"
        android:icon="@mipmap/ic_launcher">
        <activity
            android:name=".MainActivity"
            android:exported="true"
            android:launchMode="singleTop"
            android:taskAffinity=""
            android:theme="@style/LaunchTheme"
            android:configChanges="orientation|keyboardHidden|keyboard|screenSize|smallestScreenSize|locale|layoutDirection|fontScale|screenLayout|density|uiMode"
            android:hardwareAccelerated="true"
            android:windowSoftInputMode="adjustResize">
            <meta-data
              android:name="io.flutter.embedding.android.NormalTheme"
              android:resource="@style/NormalTheme"
              />
            <intent-filter>
                <action android:name="android.intent.action.MAIN"/>
                <category android:name="android.intent.category.LAUNCHER"/>
            </intent-filter>
        </activity>
        <meta-data
            android:name="flutterEmbedding"
            android:value="2" />
    </application>
</manifest>
Step 3 of 9 | Memory & Distribution

Model Quantization (INT4 vs. INT8) & Atomic Asset Delivery

On a mid-tier 4GB RAM Android phone, Android OS and system services consume ~1.6GB of RAM, and the Flutter UI + Dart VM take ~300MB. That leaves a strict ceiling of ~1.8GB to 2.0GB before Android's Low Memory Killer (lmkd) terminates your app.

Sur un smartphone Android milieu de gamme doté de 4 Go de RAM, le système Android et ses services consomment ~1,6 Go de RAM, tandis que l'interface Flutter et la VM Dart occupent ~300 Mo. Cela laisse un plafond strict de ~1,8 Go à 2,0 Go avant que le Low Memory Killer d'Android (lmkd) ne tue votre application.

~4.0 GB 2B Model in FP16 (Crashes on 4GB RAM)
~2.1 GB 2B Model in INT8 (Zero headroom for Vision)
~1.2 GB E2B in INT4 .litertlm (75% smaller!)
134 MB Distilled 240M SLM in INT4 (See Step 8)
Production Pitfall: Never Put Multi-Hundred-MB Models in Flutter assets/

Bundling a 1.3GB model inside pubspec.yaml assets violates Google Play's 150MB base APK/AAB limit and forces Flutter to copy/decompress the asset on first boot: causing instant OutOfMemoryError crashes on budget phones.

Embarquer un modèle de 1,3 Go dans les assets de pubspec.yaml dépasse la limite de 150 Mo d'un APK/AAB Google Play et oblige Flutter à copier/décompresser l'asset au premier démarrage : ce qui provoque un crash OutOfMemoryError immédiat sur les téléphones d'entrée de gamme.

The Solution: Chunked Streaming + Atomic .tmp Swap

In lib/data/repositories/chat_repository_impl.dart, we stream the quantized .litertlm binary directly to getApplicationDocumentsDirectory() using an IOSink (so Dart's RAM heap stays flat at <5MB). Crucially, we write to gemma.litertlm.tmp first and only rename it to gemma.litertlm when 100% of bytes are on disk. If a user's connection drops at 89%, the native C++ engine will never attempt to memory-map a corrupted binary! (You will paste the complete chat_repository_impl.dart file in Step 7).

Dans lib/data/repositories/chat_repository_impl.dart, nous streamons le binaire quantifié .litertlm directement vers getApplicationDocumentsDirectory() à l'aide d'un IOSink (afin que le tas mémoire Dart reste sous 5 Mo). Point crucial : nous écrivons d'abord dans gemma.litertlm.tmp et ne le renommons en gemma.litertlm qu'une fois 100 % des octets écrits sur le disque. Si la connexion coupe à 89 %, le moteur C++ natif ne tentera jamais de projeter en mémoire (mmap) un fichier corrompu ! (Vous collerez le fichier complet chat_repository_impl.dart à l'Étape 7).

lib/data/repositories/chat_repository_impl.dart (Atomic Download Part) DART
Future<bool> checkModelInstalled() async {
  final directory = await getApplicationDocumentsDirectory();
  final litertlmFile = File('${directory.path}/gemma.litertlm');
  final binFile = File('${directory.path}/gemma.bin');

  // Auto-migrate legacy gemma.bin to gemma.litertlm if already on the user's phone
  if (await binFile.exists() && !await litertlmFile.exists()) {
    await binFile.rename(litertlmFile.path);
  }
  return await litertlmFile.exists();
}

Future<void> initializeGemma(Function(double) onProgress) async {
  return GemmaEngineManager.runWithLock(() async {
    await GemmaEngineManager.ensureInitialized();

    final directory = await getApplicationDocumentsDirectory();
    final modelFile = File('${directory.path}/gemma.litertlm');
    final tempModelFile = File('${directory.path}/gemma.litertlm.tmp');

    if (!await modelFile.exists()) {
      // 1. Clean up any incomplete .tmp file from an interrupted download
      if (await tempModelFile.exists()) {
        await tempModelFile.delete();
      }

      // 2. Fast 4-second DNS check before opening the download stream
      final result = await InternetAddress.lookup('google.com')
          .timeout(const Duration(seconds: 4));
      if (result.isEmpty || result.first.rawAddress.isEmpty) {
        throw const SocketException('Internet connection is required for first-time setup.');
      }

      const url = 'https://huggingface.co/litert-community/'
          'gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it.litertlm';
      final client = http.Client();

      try {
        final request = http.Request('GET', Uri.parse(url));
        final response = await client.send(request);
        if (response.statusCode != 200) {
          throw HttpException('Download failed (HTTP ${response.statusCode})');
        }

        final contentLength = response.contentLength ?? 0;
        int downloadedBytes = 0;

        // 3. Stream chunks directly to disk via IOSink (Zero RAM bloat)
        final IOSink sink = tempModelFile.openWrite();
        await response.stream.forEach((chunk) {
          downloadedBytes += chunk.length;
          sink.add(chunk);
          if (contentLength > 0) {
            onProgress(downloadedBytes / contentLength);
          }
        });
        await sink.close();

        // 4. ATOMIC RENAME: Only promote to gemma.litertlm upon 100% completion
        if (await tempModelFile.exists()) {
          await tempModelFile.rename(modelFile.path);
        }
      } catch (e) {
        if (await tempModelFile.exists()) await tempModelFile.delete();
        rethrow;
      } finally {
        client.close();
      }
    }

    // 5. Register the verified local file with FlutterGemma's LiteRT-LM runtime
    await FlutterGemma.installModel(
      modelType: ModelType.gemmaIt,
      fileType: ModelFileType.litertlm,
    ).fromFile(modelFile.path).install();
  });
}
Step 4 of 9 | Native Concurrency

Preventing Native FFI Crashes & Hot-Reload Deadlocks

What Failed in Production #1: Concurrent Native Sessions & Orphaned Isolates

MediaPipe / LiteRT-LM allocates a single native C++ execution graph in memory. During field testing, if a user started a leaf scan and switched to the Chat tab before the scan finished; or if a developer triggered a Flutter Hot Reload while a stream was open: the native FFI channel deadlocked permanently or threw PlatformException(channel-error).

MediaPipe / LiteRT-LM alloue un seul graphe d'exécution C++ natif en mémoire. Lors des tests sur le terrain, si un utilisateur lançait un scan de feuille puis basculait vers l'onglet Chat avant la fin du scan, ou si un développeur déclenchait un Hot Reload Flutter pendant qu'un flux était ouvert : le canal FFI natif se bloquait définitivement (deadlock) ou levait une PlatformException(channel-error).

The Solution: GemmaEngineManager

To make on-device inference rock-solid, we built GemmaEngineManager in lib/core/services/gemma_engine_manager.dart. It serializes all Future and Stream operations through a Completer mutex lock, includes an 8-second deadlock timeout to recover from orphaned Hot-Reload isolates, and automatically resets stale chat session pointers when a native channel error occurs:

Pour rendre l'inférence embarquée infaillible, nous avons conçu GemmaEngineManager dans lib/core/services/gemma_engine_manager.dart. Il sérialise toutes les opérations Future et Stream via un verrou mutex Completer, intègre un timeout anti-deadlock de 8 secondes pour récupérer après un Hot-Reload, et réinitialise automatiquement les pointeurs de session obsolètes en cas d'erreur de canal natif :

lib/core/services/gemma_engine_manager.dart DART
import 'dart:async';
import 'dart:developer' as dev;
import 'package:flutter_gemma/flutter_gemma.dart';
import 'package:flutter_gemma_litertlm/flutter_gemma_litertlm.dart';

class GemmaEngineManager {
  static bool _isInitialized = false;
  static Completer<void>? _initCompleter;
  static Future<void>? _activeExecution;
  static Function()? _onResetChatCallback;

  static bool get isInitialized => _isInitialized;
  static bool get isBusy => _activeExecution != null;

  static void setResetChatCallback(Function() callback) {
    _onResetChatCallback = callback;
  }

  static void resetChatSession() {
    _onResetChatCallback?.call();
  }

  /// Force clears stale lock state and resets chat session on Hot Reload or FFI error
  static void forceResetLockState() {
    dev.log("Force resetting GemmaEngineManager lock state & session handles.");
    _activeExecution = null;
    _initCompleter = null;
    resetChatSession();
  }

  static Future<void> ensureInitialized() async {
    if (_isInitialized) return;
    if (_initCompleter != null) return _initCompleter!.future;

    _initCompleter = Completer<void>();
    try {
      await FlutterGemma.initialize(
        inferenceEngines: const [LiteRtLmEngine()],
      );
      _isInitialized = true;
      _initCompleter!.complete();
    } catch (e) {
      // Native FFI engine already bound across Hot Reload
      _isInitialized = true;
      if (!(_initCompleter?.isCompleted ?? true)) _initCompleter!.complete();
    } finally {
      _initCompleter = null;
    }
  }

  /// Wraps a Future operation to guarantee single-threaded execution on LiteRT-LM
  static Future<T> runWithLock<T>(Future<T> Function() action) async {
    final previousExecution = _activeExecution;
    final completer = Completer<void>();
    _activeExecution = completer.future;

    if (previousExecution != null) {
      try {
        await previousExecution.timeout(const Duration(seconds: 8));
      } on TimeoutException {
        forceResetLockState();
      } catch (_) {}
    }

    try {
      return await action();
    } catch (e) {
      if (e.toString().contains("channel-error") ||
          e.toString().contains("PlatformException") ||
          e.toString().contains("Null")) {
        forceResetLockState();
      }
      rethrow;
    } finally {
      completer.complete();
      if (_activeExecution == completer.future) _activeExecution = null;
    }
  }

  /// Wraps a Stream operation with the same FFI mutex & deadlock protection
  static Stream<T> runStreamWithLock<T>(Stream<T> Function() streamFactory) async* {
    final previousExecution = _activeExecution;
    final completer = Completer<void>();
    _activeExecution = completer.future;

    if (previousExecution != null) {
      try {
        await previousExecution.timeout(const Duration(seconds: 8));
      } on TimeoutException {
        forceResetLockState();
      } catch (_) {}
    }

    try {
      await for (final value in streamFactory()) {
        yield value;
      }
    } catch (e) {
      if (e.toString().contains("channel-error") ||
          e.toString().contains("PlatformException")) {
        forceResetLockState();
      }
      rethrow;
    } finally {
      completer.complete();
      if (_activeExecution == completer.future) _activeExecution = null;
    }
  }
}
Step 5 of 9 | Multimodal Vision

512px Image Optimization & Prompt-Level Early-Exit Guardrails

What Failed in Production #2 & #3: 12MP Camera OOMs & CPU Thermal Throttling

1. Vision OOMs: Passing a raw 4000x3000 (12MP) camera image into Message.withImage() allocates a 48MB RGBA buffer plus huge vision encoder tensors: crashing 4GB RAM phones.
2. Wasted Compute on Non-Leaves: When a farmer accidentally photographed their shoe, face, or tool, Gemma generated 800+ tokens of hallucinated plant advice, heating up the CPU and draining battery.

1. Crashs OOM Vision : Passer une photo brute de 4000x3000 (12 MP) à Message.withImage() alloue un tampon RGBA de 48 Mo ainsi que d'énormes tenseurs d'encodage visuel, faisant planter les téléphones de 4 Go de RAM.
2. Calcul gaspillé hors-sujet : Lorsqu'un agriculteur photographiait par erreur sa chaussure, son visage ou un outil, Gemma générait plus de 800 tokens de conseils agricoles hallucinés, faisant chauffer le CPU et vidant la batterie.

1. Downsample Camera Images to 512px & Dispose Native Handles

Gemma's vision encoder downscales inputs internally anyway. By resizing the image to targetWidth: 512 in Dart's dart:ui codec and calling image.dispose() immediately before invoking LiteRT-LM, we cut vision memory consumption by 85% and speed up vision encoding by 4x.

L'encodeur visuel de Gemma réduit de toute façon la résolution des images en interne. En redimensionnant l'image à targetWidth: 512 via le codec dart:ui et en appelant immédiatement image.dispose() avant d'invoquer LiteRT-LM, nous réduisons la consommation mémoire de 85 % et accélérons l'encodage visuel d'un facteur 4x.

2. Two-Stage Prompt with Immediate Early-Exit Short-Circuit

Below is the complete implementation from lib/data/datasources/gemma_local_source.dart (with the prompt translated to English for this workshop). Notice how Step 1 of the prompt forces Gemma to output a single 18-token rejection sentence and halt immediately if the image is not a plant leaf:

Voici l'implémentation complète de lib/data/datasources/gemma_local_source.dart. Observez comment l'Étape 1 du prompt oblige Gemma à produire une seule phrase de rejet de 18 tokens et à s'arrêter immédiatement si l'image n'est pas une feuille de plante :

lib/data/datasources/gemma_local_source.dart DART
import 'dart:io';
import 'dart:typed_data';
import 'dart:ui' as ui;
import 'package:flutter_gemma/flutter_gemma.dart';
import 'package:gemmea/core/services/gemma_engine_manager.dart';
import 'package:gemmea/core/utils/text_sanitizer.dart';
import 'package:gemmea/data/datasources/sqlite_helper.dart';

class GemmaLocalSourceImpl {
  Future<String> evaluateCropImage(File imageFile, String prompt) async {
    return GemmaEngineManager.runWithLock(() async {
      GemmaEngineManager.resetChatSession();
      try {
        await GemmaEngineManager.ensureInitialized();
        final rawBytes = await imageFile.readAsBytes();

        // 1. Downsample to 512px max width to prevent native OOM crashes
        final imageBytes = await _optimizeImageBytes(rawBytes);

        // 2. Fetch grounded FAO/IITA context from local SQLite FTS5
        final faoContext = await _fetchRagContextFromSqlite(imageFile.path, prompt);

        // 3. Prefer CPU backend on budget devices whose GPUs lack OpenCL vision buffers
        InferenceModel model;
        try {
          model = await FlutterGemma.getActiveModel(
            supportImage: true,
            maxTokens: 4096,
            preferredBackend: PreferredBackend.cpu,
          );
        } catch (_) {
          model = await FlutterGemma.getActiveModel(
            supportImage: true,
            maxTokens: 4096,
          );
        }

        final session = await model.createSession();

        // 4. Multimodal VLM Prompt with Step 1 Early-Exit Short-Circuit (in English)
        final vlmPrompt = '''
You are Gemmea, the offline AI agronomist for crop health diagnosis in East Africa.
Examine the provided image carefully.

ABSOLUTE AND URGENT RULE (IMMEDIATE EVALUATION):
Step 1: Look at the image. If it is NOT a plant leaf or agricultural crop
(e.g., human face, phone, shoe, furniture, animal, non-plant object):
Immediately STOP all generation and reply ONLY with this exact sentence:
"The captured image does not appear to be a plant leaf. Please retake a photo clearly targeting a leaf from your crop."
DO NOT GENERATE ANY HEADINGS AND DO NOT USE THE RAG CONTEXT BELOW.

Step 2: IF AND ONLY IF THE IMAGE IS GENUINELY A PLANT LEAF OR CROP:
Provide a detailed, structured agronomic analysis organized into these 7 sections (NO EMOJIS):

### 1. PRIMARY DIAGNOSIS & HYPOTHESES
- Crop: [Exact crop observed: Maize, Cassava, Bean, Banana, Tomato, Coffee, or Unknown]
- Disease: [If healthy, write EXPLICITLY: "No disease (Healthy Leaf)". If diseased, state exact pathogen/disease name]
- Confidence Level: [e.g., 90%]
- Differential Diagnoses: [Environmental or secondary factors if applicable]
### 2. DETAILED OBSERVED SYMPTOMS
(Chlorosis, pustules, mosaic patterns, curling, necrotic streaks, or intact lamina).
### 3. CAUSES & VECTORS (IITA STANDARDS)
(Pathogen agent and transmission vector such as Bemisia tabaci whitefly or Xanthomonas).
### 4. STEP-BY-STEP ORGANIC REMEDIES & TREATMENT
(Local bio-remedies: Neem leaf extract 200g/L, wood ash, baking soda NaHCO3 5g/L with black soap, Tithonia liquid manure).
### 5. SOIL MANAGEMENT, NUTRIENTS & IRRIGATION
(Drainage, composting, mulching, moisture control).
### 6. IMMEDIATE 3-STEP ACTION PLAN
(Step 1, Step 2, Step 3 for the farmer today).
### 7. OFFICIAL FAO & IITA REFERENCES
(Explicit citations from the local RAG context below).

REFERENCE RAG CONTEXT (IGNORE COMPLETELY IF NOT A PLANT):
$faoContext

User instruction: $prompt
''';

        await session.addQueryChunk(
          Message.withImage(
            text: vlmPrompt,
            imageBytes: imageBytes,
            isUser: true,
          ),
        );

        final String response = await session.getResponse();
        return TextSanitizer.cleanLatexAndFormatting(response);
      } finally {
        GemmaEngineManager.resetChatSession();
      }
    });
  }

  Future<String> _fetchRagContextFromSqlite(String imagePath, String prompt) async {
    try {
      final docs = await SQLiteHelper.instance.searchFAOKnowledge("$imagePath $prompt");
      if (docs.isEmpty) return "Standard East African FAO/IITA visual crop pathology guidelines.";
      return docs.map((d) {
        return "- ${d['topic']} (${d['crop_name']}): ${d['content']} "
               "Organic Remedy: ${d['organic_remedy']} (Source: ${d['source_citation']})";
      }).join("\n");
    } catch (_) {
      return "Standard East African FAO/IITA visual crop pathology guidelines.";
    }
  }

  Future<Uint8List> _optimizeImageBytes(Uint8List inputBytes) async {
    try {
      final codec = await ui.instantiateImageCodec(
        inputBytes,
        targetWidth: 512,
      );
      final frameInfo = await codec.getNextFrame();
      final image = frameInfo.image;
      final byteData = await image.toByteData(format: ui.ImageByteFormat.png);
      image.dispose(); // Crucial: release native C++ Skia/Impeller texture immediately!
      if (byteData != null) {
        return byteData.buffer.asUint8List();
      }
    } catch (_) {}
    return inputBytes;
  }
}
Step 6 of 9 | Grounded Local Retrieval

Zero-RAM-Overhead Local RAG with SQLite FTS5 & BM25

Quantized 2B models can hallucinate exact biopesticide dosages or regional planting dates if left ungrounded. However, on a 4GB RAM phone where Gemma is already consuming ~1.3GB of RAM, loading a second neural embedding model + vector database into RAM will push your app over the OOM cliff.

Les modèles 2B quantifiés peuvent halluciner les dosages exacts de biopesticides ou les dates de semis régionales s'ils ne sont pas ancrés sur des données vérifiées. Cependant, sur un téléphone de 4 Go de RAM où Gemma consomme déjà ~1,3 Go, charger un second modèle d'embeddings neuronaux et une base vectorielle en RAM fera basculer l'application en crash OOM.

Architectural Win: SQLite FTS5 + BM25 Ranking = 0 MB Extra RAM

SQLite's built-in FTS5 (Full-Text Search 5) engine executes in native C directly on disk using the BM25 relevance algorithm. It requires 0 MB of neural embedding RAM, completes searches in <2 milliseconds, and includes a multi-term weighted scorer fallback for custom Android OEM ROMs.

Le moteur intégré FTS5 (Full-Text Search 5) de SQLite s'exécute en C natif directement sur le disque avec l'algorithme de pertinence BM25. Il nécessite 0 Mo de RAM d'embedding neuronal, répond en moins de 2 millisecondes et inclut un scoreur pondéré multi-termes de secours pour les ROM Android OEM personnalisées.

Complete File: lib/data/datasources/sqlite_helper.dart

Copy this complete, self-contained SQLite FTS5 + BM25 RAG helper into lib/data/datasources/sqlite_helper.dart:

Copiez ce helper RAG SQLite FTS5 + BM25 complet et autonome dans lib/data/datasources/sqlite_helper.dart :

lib/data/datasources/sqlite_helper.dart DART / SQL
import 'package:path/path.dart';
import 'package:sqflite/sqflite.dart';

class SQLiteHelper {
  static final SQLiteHelper instance = SQLiteHelper._init();
  static Database? _database;

  SQLiteHelper._init();

  Future<Database> get database async {
    if (_database != null) return _database!;
    final dbPath = await getDatabasesPath();
    _database = await openDatabase(
      join(dbPath, 'gemmea.db'),
      version: 1,
      onCreate: _createDB,
    );
    return _database!;
  }

  Future<void> _createDB(Database db, int version) async {
    // 1. Irrigation Calendar table for Autonomous Function Calling
    await db.execute('''
      CREATE TABLE IF NOT EXISTS calendar (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        title TEXT NOT NULL,
        event_date TEXT NOT NULL,
        description TEXT NOT NULL,
        is_rainy INTEGER NOT NULL DEFAULT 0
      )
    ''');

    await db.insert('calendar', {
      'title': 'Cassava & Maize Irrigation',
      'event_date': DateTime.now().toIso8601String().split('T')[0],
      'description': 'Regular morning root-zone watering cycle.',
      'is_rainy': 0,
    });

    await _createFAOTables(db);
  }

  Future<void> _createFAOTables(Database db) async {
    // 2. Base table storing official FAO & IITA East African agronomic guidelines
    await db.execute('''
      CREATE TABLE IF NOT EXISTS fao_knowledge_base (
        id INTEGER PRIMARY KEY AUTOINCREMENT,
        topic TEXT NOT NULL,
        crop_name TEXT NOT NULL,
        keywords TEXT NOT NULL,
        content TEXT NOT NULL,
        organic_remedy TEXT NOT NULL,
        estimated_loss_pct REAL NOT NULL,
        source_citation TEXT NOT NULL
      )
    ''');

    // 3. SQLite FTS5 Virtual Table for sub-2ms BM25 full-text ranking
    try {
      await db.execute('''
        CREATE VIRTUAL TABLE IF NOT EXISTS fao_knowledge_fts USING fts5(
          topic, crop_name, keywords, content, organic_remedy, source_citation,
          content='fao_knowledge_base', content_rowid='id'
        )
      ''');

      // Trigger to keep FTS5 index automatically synchronized on insert
      await db.execute('''
        CREATE TRIGGER IF NOT EXISTS fao_ai AFTER INSERT ON fao_knowledge_base BEGIN
          INSERT INTO fao_knowledge_fts(
            rowid, topic, crop_name, keywords, content, organic_remedy, source_citation
          ) VALUES (
            new.id, new.topic, new.crop_name, new.keywords,
            new.content, new.organic_remedy, new.source_citation
          );
        END;
      ''');
    } catch (_) {
      // Fallback to weighted field scorer if custom OEM SQLite lacks FTS5
    }

    // 4. Seed verified East African FAO / IITA knowledge records
    final initialEntries = [
      {
        'topic': 'Cassava Mosaic Disease (CMD)',
        'crop_name': 'Cassava',
        'keywords': 'cassava manioc mosaic cmd leaf yellowing mottling curl whitefly bemisia',
        'content': 'Caused by a geminivirus transmitted by the whitefly (Bemisia tabaci). '
            'Symptoms: chlorotic yellow-green mosaic pattern, leaf distortion, stunting, '
            'reducing tuber yield by 40% to 70%.',
        'organic_remedy': '1. Uproot and burn infected plants immediately. '
            '2. Plant FAO/IITA certified resistant cuttings (TME 419, NAROCASS 1, SAWASA). '
            '3. Spray aqueous Neem leaf extract (200g/L) or dust wood ash to repel whiteflies.',
        'estimated_loss_pct': 55.0,
        'source_citation': 'FAO East Africa Crop Protection Guide (2024), Chap. 4, pp. 28-32',
      },
      {
        'topic': 'Banana Bacterial Wilt (BXW / Kayovu)',
        'crop_name': 'Banana',
        'keywords': 'banana matooke bxw kayovu bacterial wilt yellowing male bud ooze',
        'content': 'Caused by Xanthomonas vasicola. Rapid yellowing and wilting of leaves, '
            'yellow bacterial ooze when pseudostem is cut, premature ripening and rotting of bunches.',
        'organic_remedy': '1. Sterilize cutting machetes over fire or with 10% sodium hypochlorite (Jik). '
            '2. Break off the male bud using a forked wooden stick as soon as the last hand forms. '
            '3. Uproot and bury infected mats.',
        'estimated_loss_pct': 80.0,
        'source_citation': 'FAO/IITA Great Lakes Banana Emergency Bulletin (2024), p. 12',
      },
      {
        'topic': 'Maize Rust & Fall Armyworm (FAW)',
        'crop_name': 'Maize',
        'keywords': 'maize corn rust pustules armyworm faw whorl holes spacing neem ash',
        'content': 'Maize rust (Puccinia sorghi) forms orange-brown pustules on leaves. '
            'Fall Armyworm (Spodoptera frugiperda) perforates young whorls. Standard spacing: 75cm x 25cm.',
        'organic_remedy': '1. Pour dry wood ash or fine sand into the maize whorl early morning. '
            '2. Spray Neem leaf extract (200g/L) or Tithonia diversifolia liquid manure. '
            '3. Intercrop with beans to fix nitrogen and reduce pest pressure.',
        'estimated_loss_pct': 45.0,
        'source_citation': 'FAO/IITA Cereal Protection Manual (2024), Section 2.1, pp. 8-14',
      },
    ];

    for (final entry in initialEntries) {
      await db.insert('fao_knowledge_base', entry);
    }
  }

  /// Searches the local FAO/IITA knowledge base using FTS5 BM25 with scored fallback
  Future<List<Map<String, dynamic>>> searchFAOKnowledge(String query) async {
    final db = await database;
    final normalized = _stripAccents(query.toLowerCase())
        .replaceAll(RegExp(r'[^a-z0-9\s]'), ' ');
    final terms = normalized
        .split(RegExp(r'\s+'))
        .where((t) => t.length > 2 && !_stopWords.contains(t))
        .toList();

    if (terms.isEmpty) {
      return await db.query('fao_knowledge_base', limit: 3);
    }

    // Primary Path: Native C FTS5 MATCH ordered by BM25 relevance score
    try {
      final ftsQuery = terms.map((t) => '"$t*"').join(' OR ');
      final ftsResults = await db.rawQuery('''
        SELECT k.* FROM fao_knowledge_base k
        JOIN fao_knowledge_fts fts ON k.id = fts.rowid
        WHERE fao_knowledge_fts MATCH ?
        ORDER BY bm25(fao_knowledge_fts) ASC
        LIMIT 3
      ''', [ftsQuery]);

      if (ftsResults.isNotEmpty) return ftsResults;
    } catch (_) {
      // Fallback to weighted field scoring if OEM SQLite lacks FTS5 module
    }

    return await _scoredFallbackSearch(db, terms);
  }

  Future<List<Map<String, dynamic>>> _scoredFallbackSearch(
    Database db,
    List<String> terms,
  ) async {
    final allRows = await db.query('fao_knowledge_base');
    final scoredRows = <Map<String, dynamic>, int>{};

    for (final row in allRows) {
      int score = 0;
      final crop = _stripAccents((row['crop_name'] ?? '').toString().toLowerCase());
      final topic = _stripAccents((row['topic'] ?? '').toString().toLowerCase());
      final keywords = _stripAccents((row['keywords'] ?? '').toString().toLowerCase());
      final content = _stripAccents((row['content'] ?? '').toString().toLowerCase());

      for (final term in terms) {
        if (crop.contains(term)) score += 8;
        if (topic.contains(term)) score += 6;
        if (keywords.contains(term)) score += 4;
        if (content.contains(term)) score += 1;
      }
      if (score > 0) scoredRows[row] = score;
    }

    if (scoredRows.isNotEmpty) {
      final sorted = scoredRows.entries.toList()
        ..sort((a, b) => b.value.compareTo(a.value));
      return sorted.take(3).map((e) => e.key).toList();
    }
    return await db.query('fao_knowledge_base', limit: 3);
  }

  Future<int> updateRainStatus(String date, int isRainy) async {
    final db = await database;
    return await db.update(
      'calendar',
      {'is_rainy': isRainy},
      where: 'event_date = ?',
      whereArgs: [date],
    );
  }

  static const Set<String> _stopWords = {
    'the', 'and', 'for', 'with', 'how', 'what', 'when', 'why', 'can', 'you',
    'tell', 'about', 'from', 'this', 'that', 'have', 'has', 'are', 'was',
    'le', 'la', 'les', 'des', 'dans', 'pour', 'avec', 'comment', 'quel',
  };

  static String _stripAccents(String input) {
    const map = {
      'à': 'a', 'â': 'a', 'ä': 'a', 'é': 'e', 'è': 'e', 'ê': 'e', 'ë': 'e',
      'î': 'i', 'ï': 'i', 'ô': 'o', 'ö': 'o', 'ù': 'u', 'û': 'u', 'ü': 'u', 'ç': 'c',
    };
    return input.split('').map((c) => map[c] ?? c).join();
  }
}
Step 7 of 9 | Conversational UX & Agents

60 FPS Token Streaming, KV-Cache Sliding Window & Offline Function Calling

In conversational mode, we face two engineering challenges on budget smartphones:

En mode conversationnel, nous faisons face à deux défis d'ingénierie sur les smartphones d'entrée de gamme :

  1. KV-Cache Memory Explosion: If you feed 25 historical messages into every prompt, the Key-Value attention cache overflows RAM and slows prefill latency to a crawl.
  2. Raw LaTeX Artifacts: Gemma often outputs chemical formulas like $NaHCO_3$ or \text{H}_2\text{O}, which look broken to farmers in plain text or Text-to-Speech.
  1. Explosion mémoire du KV-Cache : Si vous injectez 25 messages d'historique dans chaque prompt, le cache d'attention Clé-Valeur (KV-Cache) sature la RAM et ralentit considérablement la latence de pré-remplissage (prefill).
  2. Artefacts LaTeX bruts : Gemma génère souvent des formules chimiques telles que $NaHCO_3$ ou \text{H}_2\text{O}, illisibles pour un agriculteur en texte brut ou en synthèse vocale (TTS).

1. Complete File: lib/data/repositories/chat_repository_impl.dart

Copy this complete, self-contained repository into lib/data/repositories/chat_repository_impl.dart. It combines Step 3's atomic .tmp model downloader, SQLite FTS5 RAG injection, the 5-turn KV-Cache sliding window, and autonomous offline Function Calling for the irrigation calendar:

Copiez ce repository complet et autonome dans lib/data/repositories/chat_repository_impl.dart. Il réunit le téléchargeur atomique .tmp de l'Étape 3, l'injection RAG SQLite FTS5, la fenêtre glissante KV-Cache de 5 messages et le Function Calling autonome hors-ligne pour le calendrier d'irrigation :

lib/data/repositories/chat_repository_impl.dart DART
import 'dart:async';
import 'dart:io';
import 'package:flutter_gemma/flutter_gemma.dart';
import 'package:http/http.dart' as http;
import 'package:path_provider/path_provider.dart';
import 'package:gemmea/core/services/gemma_engine_manager.dart';
import 'package:gemmea/core/utils/text_sanitizer.dart';
import 'package:gemmea/data/datasources/sqlite_helper.dart';

class ChatRepositoryImpl {
  final SQLiteHelper dbHelper = SQLiteHelper.instance;
  InferenceChat? _chat;

  ChatRepositoryImpl() {
    GemmaEngineManager.setResetChatCallback(() {
      _chat = null;
    });
  }

  Future<bool> checkModelInstalled() async {
    final directory = await getApplicationDocumentsDirectory();
    final litertlmFile = File('${directory.path}/gemma.litertlm');
    final binFile = File('${directory.path}/gemma.bin');

    if (await binFile.exists() && !await litertlmFile.exists()) {
      await binFile.rename(litertlmFile.path);
    }
    return await litertlmFile.exists();
  }

  Future<void> initializeGemma(Function(double) onProgress) async {
    return GemmaEngineManager.runWithLock(() async {
      await GemmaEngineManager.ensureInitialized();

      final directory = await getApplicationDocumentsDirectory();
      final modelFile = File('${directory.path}/gemma.litertlm');
      final tempModelFile = File('${directory.path}/gemma.litertlm.tmp');

      if (!await modelFile.exists()) {
        if (await tempModelFile.exists()) {
          await tempModelFile.delete();
        }

        final result = await InternetAddress.lookup('google.com')
            .timeout(const Duration(seconds: 4));
        if (result.isEmpty || result.first.rawAddress.isEmpty) {
          throw const SocketException('Internet connection is required for first-time setup.');
        }

        const url = 'https://huggingface.co/litert-community/'
            'gemma-4-E2B-it-litert-lm/resolve/main/gemma-4-E2B-it.litertlm';
        final client = http.Client();

        try {
          final request = http.Request('GET', Uri.parse(url));
          final response = await client.send(request);
          if (response.statusCode != 200) {
            throw HttpException('Download failed (HTTP ${response.statusCode})');
          }

          final contentLength = response.contentLength ?? 0;
          int downloadedBytes = 0;

          final IOSink sink = tempModelFile.openWrite();
          await response.stream.forEach((chunk) {
            downloadedBytes += chunk.length;
            sink.add(chunk);
            if (contentLength > 0) {
              onProgress(downloadedBytes / contentLength);
            }
          });
          await sink.close();

          if (await tempModelFile.exists()) {
            await tempModelFile.rename(modelFile.path);
          }
        } catch (e) {
          if (await tempModelFile.exists()) await tempModelFile.delete();
          rethrow;
        } finally {
          client.close();
        }
      }

      await FlutterGemma.installModel(
        modelType: ModelType.gemmaIt,
        fileType: ModelFileType.litertlm,
      ).fromFile(modelFile.path).install();
    });
  }

  /// Autonomous Offline Function Calling: pauses/resumes SQLite irrigation schedule
  Future<String?> checkAutonomousCalendarAction(String text) async {
    final lower = text.toLowerCase();
    final today = DateTime.now().toIso8601String().split('T')[0];

    if (lower.contains('rain') || lower.contains('wet') || lower.contains('pluie')) {
      await dbHelper.updateRainStatus(today, 1);
      return 'Calendar Action: Irrigation paused in SQLite due to reported rainfall.';
    } else if (lower.contains('dry') || lower.contains('hot') || lower.contains('sec')) {
      await dbHelper.updateRainStatus(today, 0);
      return 'Calendar Action: Regular watering resumed in SQLite for dry weather.';
    }
    return null;
  }

  Stream<ModelResponse> getGemmaResponseStream(String text, [List<Message>? history]) async* {
    yield* GemmaEngineManager.runStreamWithLock(() async* {
      await GemmaEngineManager.ensureInitialized();

      // 1. Retrieve top-3 FAO/IITA facts via SQLite FTS5 (skip for simple greetings)
      String faoContext = "";
      if (!_isSimpleGreeting(text)) {
        final faoDocs = await dbHelper.searchFAOKnowledge(text);
        if (faoDocs.isNotEmpty) {
          faoContext = faoDocs.map((d) {
            final content = (d['content'] ?? '').toString();
            final trimmed = content.length > 400 ? "${content.substring(0, 400)}..." : content;
            return "- ${d['topic']}: $trimmed Remedy: ${d['organic_remedy']} "
                   "(Source: ${d['source_citation']})";
          }).join("\n");
        }
      }

      // 2. Cap context to the last 5 messages (max 250 chars each) to protect KV-Cache RAM
      final recentHistory = _getRecentHistoryWindow(history);
      final historyBlock = recentHistory.isEmpty
          ? ""
          : "\n\nLAST 5 CONVERSATION MESSAGES:\n"
            "${recentHistory.map((m) => "- ${m.isUser ? 'Farmer' : 'Gemmea'}: ${m.text}").join("\n")}";

      // 3. Regional System Prompt (East Africa / Great Lakes Seasons A & B)
      final systemPrompt = '''
You are Gemmea, the field AI agronomist for East Africa and the Great Lakes region
(Uganda, DRC/Kivu, Rwanda, Burundi).

STRICT ANTI-DISCLAIMER RULE:
NEVER use vague evasions or disclaimers like "Consult a local extension officer."
You ARE the reference field expert! Always provide affirmative, concrete, dated,
and exact-dosage instructions that a smallholder farmer can apply immediately.

INTEGRATED REGIONAL KNOWLEDGE (EAST AFRICA / GREAT LAKES):
- Bimodal Rainy Seasons:
  * Season A (Long Rains): Plant Maize & Beans mid-September to early October. Harvest Jan/Feb.
  * Season B (Short Rains): Plant March to early April. Harvest June/July.
  * Cassava & Banana: Plant certified cuttings/suckers at the onset of Season A (Sept) or B (March).
- Standard Spacing: Maize (75cm x 25cm, 1 seed/hole), Beans (40cm x 10cm), Cassava (1m x 1m).
- Formatting: Clear Markdown (**bold** key terms, numbered steps 1. 2. 3., bullet points - ). NO emojis.
$historyBlock
${faoContext.isNotEmpty ? '\n\nOFFICIAL FAO/IITA REFERENCE DATA:\n$faoContext' : ''}
''';

      InferenceModel model;
      try {
        model = await FlutterGemma.getActiveModel(
          supportImage: true,
          maxTokens: 2048,
          preferredBackend: PreferredBackend.cpu,
        );
      } catch (_) {
        model = await FlutterGemma.getActiveModel(
          supportImage: true,
          maxTokens: 2048,
          preferredBackend: PreferredBackend.gpu,
        );
      }

      _chat = await model.createChat(
        modelType: ModelType.gemmaIt,
        systemInstruction: systemPrompt,
      );

      await _chat!.addQuery(Message.text(text: text, isUser: true));

      // 4. Stream tokens asynchronously & sanitize LaTeX formulas on the fly
      await for (final chunk in _chat!.generateChatResponseAsync()) {
        if (chunk is TextResponse) {
          yield TextResponse(
            TextSanitizer.cleanLatexAndFormatting(chunk.token, preserveWhitespace: true),
          );
        } else {
          yield chunk;
        }
      }
    });
  }

  bool _isSimpleGreeting(String input) {
    final clean = input.trim().toLowerCase().replaceAll(RegExp(r'[^\w\s]'), '');
    const greetings = {'hello', 'hi', 'jambo', 'habari', 'webale', 'salut', 'bonjour', 'thanks'};
    return greetings.contains(clean) ||
        (clean.split(' ').length <= 2 && greetings.any((g) => clean.startsWith(g)));
  }

  List<Message> _getRecentHistoryWindow(List<Message>? history) {
    if (history == null || history.length <= 1) return [];
    final past = history.sublist(0, history.length - 1);
    final recent = past.length > 5 ? past.sublist(past.length - 5) : past;
    return recent.map((m) {
      final t = m.text.length > 250 ? "${m.text.substring(0, 250)}..." : m.text;
      return Message.text(text: t, isUser: m.isUser);
    }).toList();
  }
}

2. Complete File: lib/core/utils/text_sanitizer.dart

Create lib/core/utils/text_sanitizer.dart so chemical formulas like $NaHCO_3$ and stray emojis are converted into clean plain text while streaming:

Créez lib/core/utils/text_sanitizer.dart afin que les formules chimiques telles que $NaHCO_3$ soient converties en texte brut lisible à la volée pendant le streaming :

lib/core/utils/text_sanitizer.dart DART
class TextSanitizer {
  static String cleanLatexAndFormatting(String input, {bool preserveWhitespace = false}) {
    if (input.isEmpty) return input;
    String text = input;

    // 1. Convert common LaTeX chemical formulas to farmer-friendly plain text
    text = text.replaceAll(
      RegExp(r'\$?\\?text\{NaHCO\}_?\{?3\}?\\?\$?|\$NaHCO_3\\?\$?', caseSensitive: false),
      'Baking soda (NaHCO3)',
    );
    text = text.replaceAll(
      RegExp(r'\$?\\?text\{H\}_?\{?2\}?\\?text\{O\}\\?\$?|\$H_2O\\?\$?', caseSensitive: false),
      'water (H2O)',
    );

    // 2. Strip LaTeX wrappers and subscript underscores
    text = text.replaceAllMapped(
      RegExp(r'\\(?:text|mathrm|mathbf)\{([^}]+)\}'),
      (m) => m.group(1) ?? '',
    );
    text = text.replaceAllMapped(
      RegExp(r'([A-Za-z]+)_\{?(\d+)\}?'),
      (m) => '${m.group(1)}${m.group(2)}',
    );
    text = text.replaceAll(r'$', '');
    return preserveWhitespace ? text : text.trim();
  }
}
Step 8 of 9 | Model Compression & Distillation

The Finale: Distilling Gemma into a Custom SLM Under 150 MB

While an INT4 2B model (~1.2GB) runs well on 4GB to 8GB phones, two barriers remain for rural mass adoption across Africa:

Bien qu'un modèle 2B en INT4 (~1,2 Go) tourne très bien sur les téléphones de 4 Go à 8 Go de RAM, deux obstacles subsistent pour une adoption massive en zone rurale en Afrique :

  • The 1.2GB Initial Download: Even a one-time 1.2GB download is expensive on prepaid mobile data bundles.
  • 2GB to 3GB Android Go Devices: Entry-level phones without NPUs take 5 to 10 seconds for CPU inference on a 2B model.
  • Le téléchargement initial de 1,2 Go : Même ponctuel, un téléchargement de 1,2 Go coûte cher sur des forfaits mobiles prépayés.
  • Les appareils Android Go de 2 Go à 3 Go de RAM : Les téléphones d'entrée de gamme sans NPU mettent 5 à 10 secondes par inférence CPU sur un modèle 2B.
Why 150 MB is the Magic Threshold on Android

Google Play's base Android App Bundle (AAB) / install-time asset limit is 150 MB. When you distill your domain-specific Small Language Model (SLM) down to ~134 MB, you can ship the model directly inside the Play Store install or share it offline via Nearby Share / Xender with zero post-install download!

La limite de taille d'un Android App Bundle (AAB) de base sur Google Play est de 150 Mo. Lorsque vous distillez votre Small Language Model (SLM) métier jusqu'à ~134 Mo, vous pouvez embarquer le modèle directement dans l'installation Play Store ou le partager hors-ligne via Nearby Share / Xender sans aucun téléchargement supplémentaire après l'installation !

How the Math Works: Getting from 1.35 GB to 134 MB

  1. Teacher → Student Knowledge Distillation: A crop-diagnosis assistant does not need to know how to write C++ shaders or summarize European tax law. We use gemma-2-2b-it (or gemma-2-9b-it) as the Teacher to transfer its domain reasoning on East African agronomy (FAO/IITA + Swahili/Luganda/English/French) into a compact 240M to 270M parameter Student SLM.
  2. Vocabulary Pruning (256k → 32k Tokens): Gemma's 256,000-token embedding matrix alone occupies ~260 MB even in INT4! Pruning unused scripts and keeping the top 32,000 domain + East African tokens shrinks the embedding table by 87.5% (down to ~16 MB).
  3. INT4 Block-32 Quantization (dynamic_int4_block32): 240M parameters * 0.5 bytes/weight (120 MB) + 14 MB SentencePiece Tokenizer & Flatbuffer metadata = 134 MB (< 150 MB!).
  1. Distillation de connaissances Teacher → Student : Un assistant de diagnostic agricole n'a pas besoin de savoir écrire des shaders C++ ou résumer le droit fiscal européen. Nous utilisons gemma-2-2b-it (ou gemma-2-9b-it) comme Teacher (Professeur) pour transférer son raisonnement agronomique est-africain (FAO/IITA + swahili/luganda/anglais/français) vers un SLM Student compact de 240M à 270M de paramètres.
  2. Élagage du vocabulaire (256k → 32k tokens) : La matrice d'embeddings de 256 000 tokens de Gemma occupe à elle seule ~260 Mo même en INT4 ! Élaguer les alphabets inutilisés pour conserver les 32 000 tokens métier et est-africains réduit la table d'embeddings de 87,5 % (à ~16 Mo).
  3. Quantification INT4 par blocs de 32 (dynamic_int4_block32) : 240M paramètres * 0,5 octet/poids (120 Mo) + 14 Mo de tokenizer SentencePiece & métadonnées Flatbuffer = 134 Mo (< 150 Mo !).

Prerequisites for Step 8 (Google Colab / Kaggle GPU)

If you want to run the distillation pipeline yourself, open a free Google Colab T4 GPU notebook and install the Google AI Edge and HuggingFace libraries first:

Si vous souhaitez exécuter le pipeline de distillation vous-même, ouvrez un notebook Google Colab GPU T4 gratuit et installez d'abord les librairies Google AI Edge et HuggingFace :

Colab / Terminal | Install Distillation Dependencies BASH
pip install -U torch transformers datasets trl accelerate ai-edge-torch mediapipe huggingface_hub
huggingface-cli login

Part A: PyTorch Logit Distillation Script (Teacher → Student)

Run this Python script on a Colab / Kaggle GPU to distill your Teacher Gemma model into the compact Student checkpoint using combined Cross-Entropy + Soft-Target KL-Divergence Loss:

Exécutez ce script Python sur un GPU Colab / Kaggle pour distiller votre modèle Gemma Teacher vers le checkpoint Student compact en combinant la perte Cross-Entropy + divergence KL sur cibles douces (Soft-Targets) :

scripts/distill_gemmea_slm.py PYTHON
import torch
import torch.nn.functional as F
from datasets import load_dataset
from transformers import AutoModelForCausalLM, AutoTokenizer, TrainingArguments
from trl import SFTTrainer

TEACHER_MODEL_ID = "google/gemma-2-2b-it"      # High-accuracy Teacher
STUDENT_MODEL_ID = "google/gemma-3-270m-it"    # Compact Student (<150MB INT4 target)

tokenizer = AutoTokenizer.from_pretrained(STUDENT_MODEL_ID)
teacher = AutoModelForCausalLM.from_pretrained(
    TEACHER_MODEL_ID, torch_dtype=torch.bfloat16, device_map="auto"
).eval()

student = AutoModelForCausalLM.from_pretrained(
    STUDENT_MODEL_ID, torch_dtype=torch.bfloat16, device_map="auto"
)

class GemmaDistillationTrainer(SFTTrainer):
    def __init__(self, teacher_model, temperature=2.0, alpha=0.5, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.teacher = teacher_model
        self.temperature = temperature
        self.alpha = alpha

    def compute_loss(self, model, inputs, return_outputs=False, **kwargs):
        # 1. Hard-target Cross-Entropy loss on verified FAO/IITA QA pairs
        student_outputs = model(**inputs)
        ce_loss = student_outputs.loss
        student_logits = student_outputs.logits

        # 2. Soft-target Teacher Logit Distillation (KL-Divergence)
        with torch.no_grad():
            teacher_logits = self.teacher(**inputs).logits

        vocab_size = min(student_logits.size(-1), teacher_logits.size(-1))
        T = self.temperature

        kl_loss = F.kl_div(
            F.log_softmax(student_logits[..., :vocab_size] / T, dim=-1),
            F.softmax(teacher_logits[..., :vocab_size] / T, dim=-1),
            reduction="batchmean",
        ) * (T ** 2)

        # Combine hard ground-truth accuracy with teacher's probability distribution
        total_loss = (self.alpha * ce_loss) + ((1.0 - self.alpha) * kl_loss)
        return (total_loss, student_outputs) if return_outputs else total_loss

dataset = load_dataset("json", data_files="fao_iita_east_africa_instruct.jsonl", split="train")

trainer = GemmaDistillationTrainer(
    teacher_model=teacher,
    model=student,
    train_dataset=dataset,
    processing_class=tokenizer,
    temperature=2.0,
    alpha=0.4,
    args=TrainingArguments(
        output_dir="./checkpoints/gemmea-student-270m",
        per_device_train_batch_size=4,
        gradient_accumulation_steps=4,
        learning_rate=2e-4,
        num_train_epochs=3,
        bf16=True,
        logging_steps=25,
        save_strategy="epoch",
    ),
)

trainer.train()
student.save_pretrained("./checkpoints/gemmea-student-merged")
tokenizer.save_pretrained("./checkpoints/gemmea-student-merged")

Part B: Quantize & Bundle into a 134 MB .litertlm File

Next, convert the distilled PyTorch checkpoint to LiteRT-LM format with dynamic_int4_block32 quantization using Google's ai-edge-torch and MediaPipe GenAI bundler:

Ensuite, convertissez le checkpoint PyTorch distillé au format LiteRT-LM avec la quantification dynamic_int4_block32 via ai-edge-torch et le bundler MediaPipe GenAI de Google :

scripts/export_litertlm_under_150mb.py PYTHON
import os
from ai_edge_torch.generative.examples.gemma import gemma
from ai_edge_torch.generative.utilities import converter
from ai_edge_torch.generative.utilities.export_config import ExportConfig
from mediapipe.tasks.python.genai import bundler

# 1. Load distilled Student checkpoint
pytorch_model = gemma.build_270m_model("./checkpoints/gemmea-student-merged")

# 2. Convert to TFLite flatbuffer with INT4 Block-32 weight quantization
converter.convert_to_tflite(
    pytorch_model,
    output_path="./build",
    output_name_prefix="gemmea_slm",
    prefill_seq_len=1024,
    kv_cache_max_len=2048,
    quantize="dynamic_int4_block32",  # 0.5 bytes/weight -> ~120MB weights!
    export_config=ExportConfig(),
)

# 3. Package quantized flatbuffer + SentencePiece tokenizer into .litertlm
bundle_config = bundler.BundleConfig(
    tflite_model="./build/gemmea_slm_q4_block32.tflite",
    tokenizer_model="./checkpoints/gemmea-student-merged/tokenizer.model",
    start_token="<bos>",
    stop_tokens=["<eos>", "<end_of_turn>"],
    output_filename="./dist/gemmea_agro_134mb.litertlm",
)
bundler.create_bundle(bundle_config)

size_mb = os.path.getsize("./dist/gemmea_agro_134mb.litertlm") / (1024 * 1024)
print(f"Success! Final SLM Bundle Size: {size_mb:.1f} MB (< 150 MB target)")
# Output: Success! Final SLM Bundle Size: 134.2 MB (< 150 MB target)

Part C: Benchmark | Base E2B vs. Distilled 134 MB SLM on 3GB to 4GB Android

Metric Base gemma-4-E2B-it (INT4) Distilled gemmea_agro_134mb.litertlm
Binary Size on Disk ~1,350 MB 134.2 MB (Fits Play Store 150MB limit!)
Peak Runtime RAM ~1,450 MB ~215 MB (Runs on 2GB/3GB Android Go)
Time-to-First-Token (CPU) ~1,400 ms ~180 ms
Token Generation Speed ~11 tokens/sec ~46 tokens/sec
FAO/IITA RAG Accuracy 96.0% 94.5% (Domain-Specialized)
Métrique Modèle de base gemma-4-E2B-it (INT4) SLM distillé gemmea_agro_134mb.litertlm
Taille du binaire sur disque ~1 350 Mo 134,2 Mo (Respecte la limite Play Store de 150 Mo !)
RAM maximale à l'exécution ~1 450 Mo ~215 Mo (Tourne sur Android Go 2 Go / 3 Go)
Time-to-First-Token (CPU) ~1 400 ms ~180 ms
Vitesse de génération ~11 tokens/s ~46 tokens/s
Précision RAG FAO/IITA 96,0 % 94,5 % (Spécialisé sur le domaine)
Step 9 of 9 | Minimal Runnable App & Wrap-Up

Run the Minimal App, Verification & Key Engineering Takeaways

1. Complete File: lib/main.dart

Once you have saved the 5 files from Steps 4 to 7, replace lib/main.dart with this complete, self-contained UI (Atomic Model Downloader + 512px Camera/Gallery Leaf Scanner + Streaming SQLite FTS5 RAG Chat + Autonomous Calendar Function Calling):

Une fois les 5 fichiers des Étapes 4 à 7 enregistrés, remplacez lib/main.dart par cette interface complète et autonome (Téléchargeur atomique du modèle + Scanner de feuille Caméra/Galerie en 512px + Chat RAG SQLite FTS5 en streaming + Function Calling autonome sur le calendrier) :

lib/main.dart DART
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter_gemma/flutter_gemma.dart';
import 'package:image_picker/image_picker.dart';
import 'package:gemmea/data/datasources/gemma_local_source.dart';
import 'package:gemmea/data/repositories/chat_repository_impl.dart';

void main() {
  WidgetsFlutterBinding.ensureInitialized();
  runApp(const MaterialApp(
    debugShowCheckedModeBanner: false,
    home: MinimalGemmeaScreen(),
  ));
}

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

  @override
  State<MinimalGemmeaScreen> createState() => _MinimalGemmeaScreenState();
}

class _MinimalGemmeaScreenState extends State<MinimalGemmeaScreen> {
  final _chatRepo = ChatRepositoryImpl();
  final _visionSource = GemmaLocalSourceImpl();
  final _controller = TextEditingController();
  final List<Message> _history = [];

  bool _isReady = false;
  double _downloadProgress = 0.0;
  String? _calendarBanner;
  String _output = "Tap 'Initialize / Download Gemma' once, then switch on Airplane Mode!";
  bool _isBusy = false;

  @override
  void initState() {
    super.initState();
    _checkExistingModel();
  }

  Future<void> _checkExistingModel() async {
    final installed = await _chatRepo.checkModelInstalled();
    if (installed && mounted) {
      await _initModel();
    }
  }

  Future<void> _initModel() async {
    setState(() => _isBusy = true);
    try {
      await _chatRepo.initializeGemma((p) => setState(() => _downloadProgress = p));
      setState(() {
        _isReady = true;
        _output = "Gemma LiteRT-LM Ready (100% Offline)! Ask a question or scan a leaf.";
      });
    } catch (e) {
      setState(() => _output = "Setup error: $e");
    } finally {
      setState(() => _isBusy = false);
    }
  }

  Future<void> _sendChat() async {
    final query = _controller.text.trim();
    if (query.isEmpty || _isBusy) return;
    _controller.clear();
    _history.add(Message.text(text: query, isUser: true));

    // Check for Autonomous Offline Function Calling (rain / dry weather)
    final actionNotice = await _chatRepo.checkAutonomousCalendarAction(query);

    setState(() {
      _isBusy = true;
      _calendarBanner = actionNotice;
      _output = "";
    });
    try {
      await for (final chunk in _chatRepo.getGemmaResponseStream(query, _history)) {
        if (chunk is TextResponse) {
          setState(() => _output += chunk.token);
        }
      }
      _history.add(Message.text(text: _output, isUser: false));
    } catch (e) {
      setState(() => _output = "Error: $e");
    } finally {
      setState(() => _isBusy = false);
    }
  }

  Future<void> _scanLeaf(ImageSource source) async {
    final picked = await ImagePicker().pickImage(source: source);
    if (picked == null) return;
    setState(() {
      _isBusy = true;
      _calendarBanner = null;
      _output = "Analyzing leaf image (512px) + SQLite FTS5 RAG...";
    });
    try {
      final result = await _visionSource.evaluateCropImage(
        File(picked.path),
        "Diagnose this leaf and provide FAO/IITA organic remedies.",
      );
      setState(() => _output = result);
    } catch (e) {
      setState(() => _output = "Vision error: $e");
    } finally {
      setState(() => _isBusy = false);
    }
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text("Gemmea Offline AI Codelab"),
        actions: [
          if (_isReady) ...[
            IconButton(
              icon: const Icon(Icons.photo_library_outlined),
              tooltip: "Pick Leaf from Gallery",
              onPressed: _isBusy ? null : () => _scanLeaf(ImageSource.gallery),
            ),
            IconButton(
              icon: const Icon(Icons.camera_alt_outlined),
              tooltip: "Scan Crop Leaf with Camera",
              onPressed: _isBusy ? null : () => _scanLeaf(ImageSource.camera),
            ),
          ],
        ],
      ),
      body: Padding(
        padding: const EdgeInsets.all(16),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.stretch,
          children: [
            if (!_isReady) ...[
              LinearProgressIndicator(value: _downloadProgress > 0 ? _downloadProgress : null),
              const SizedBox(height: 12),
              ElevatedButton(
                onPressed: _isBusy ? null : _initModel,
                child: Text(_downloadProgress > 0
                    ? "Downloading ${(100 * _downloadProgress).toInt()}%..."
                    : "Initialize / Download Gemma (.litertlm)"),
              ),
              const Divider(height: 32),
            ],
            if (_calendarBanner != null)
              Container(
                margin: const EdgeInsets.only(bottom: 12),
                padding: const EdgeInsets.all(12),
                decoration: BoxDecoration(
                  color: const Color(0xFFE0F2FE),
                  border: Border.all(color: const Color(0xFF0284C7)),
                  borderRadius: BorderRadius.circular(8),
                ),
                child: Text(
                  _calendarBanner!,
                  style: const TextStyle(fontWeight: FontWeight.w600, color: Color(0xFF0369A1)),
                ),
              ),
            Expanded(
              child: SingleChildScrollView(
                child: SelectableText(_output, style: const TextStyle(fontSize: 15, height: 1.5)),
              ),
            ),
            if (_isReady)
              Row(
                children: [
                  Expanded(
                    child: TextField(
                      controller: _controller,
                      onSubmitted: (_) => _sendChat(),
                      decoration: const InputDecoration(
                        hintText: "Ask about cassava mosaic, maize spacing, or rain...",
                      ),
                    ),
                  ),
                  IconButton(
                    icon: const Icon(Icons.send),
                    onPressed: _isBusy ? null : _sendChat,
                  ),
                ],
              ),
          ],
        ),
      ),
    );
  }
}

2. End-to-End Verification Checklist

Run static analysis to verify that all 6 files compile with zero warnings, or clone the full production Gemmea repository (with BLoC state management, Swahili/French voice assistant, irrigation calendar, and PDF farm health reports) and run in release mode:

Exécutez l'analyse statique pour vérifier que les 6 fichiers compilent sans aucun avertissement, ou clonez le dépôt de production complet Gemmea (avec gestion d'état BLoC, assistant vocal swahili/français, calendrier d'irrigation et rapports PDF) et lancez-le en mode release :

Terminal | Verify & Run in Release Mode BASH
# Verify your 6-file codelab project compiles with zero issues:
flutter analyze
flutter run --release

# Or clone and run the full production Gemmea repository:
git clone https://github.com/glosings0n/gemmea.git
cd gemmea
flutter pub get
flutter test
flutter run --release

3. Key Engineering Takeaways

  1. Stream Downloads to .tmp & Swap Atomically: Never buffer large models in Dart's heap or let interrupted mobile downloads leave corrupted files for the native C++ runtime.
  2. Serialize Native FFI Access: Wrap every flutter_gemma call and stream in a mutex lock (GemmaEngineManager) with deadlock timeouts for Hot Reload resilience.
  3. Downsample Vision Inputs to 512px & Short-Circuit Early: Resize camera photos before tensor encoding, dispose native image handles immediately, and instruct the VLM to halt in Step 1 if the image is out-of-domain.
  4. Choose SQLite FTS5 + BM25 Over Heavy Vector DBs on Budget RAM: Get sub-2ms factual grounding with 0 MB of extra RAM overhead.
  5. Distill & Quantize to <150 MB: Combine Teacher-Student logit distillation, vocabulary pruning, and dynamic_int4_block32 quantization so your AI ships directly inside your app bundle.
  1. Streamer les téléchargements vers .tmp & renommer atomiquement : Ne chargez jamais un modèle entier dans le tas mémoire Dart et ne laissez jamais un téléchargement interrompu exposer un binaire corrompu au moteur C++ natif.
  2. Sérialiser les accès FFI natifs : Enveloppez chaque appel et flux flutter_gemma dans un verrou mutex (GemmaEngineManager) avec timeout anti-deadlock pour résister aux Hot Reloads.
  3. Réduire les images Vision à 512px & court-circuiter tôt : Redimensionnez les photos avant l'encodage tensoriel, libérez immédiatement les textures natives et ordonnez au VLM de s'arrêter dès l'Étape 1 si l'image est hors-sujet.
  4. Privilégier SQLite FTS5 + BM25 aux bases vectorielles lourdes sur RAM limitée : Obtenez un ancrage factuel en moins de 2 ms avec 0 Mo de surcharge RAM supplémentaire.
  5. Distiller & quantifier sous 150 Mo : Combinez la distillation de logits Teacher-Student, l'élagage du vocabulaire et la quantification dynamic_int4_block32 pour embarquer votre IA directement dans l'installation Play Store.

4. Official Documentation & Further Resources

Webale Nnyo / Asante Sana, droidCon Uganda 2026!

Built by Georges Byona (@glosings0n), Founder @ LosingTek & Founder of the FlutterFire Summer Camp.
Star the repo on GitHub: github.com/glosings0n/gemmea

Conçu par Georges Byona (@glosings0n), Fondateur @ LosingTek & Fondateur du FlutterFire Summer Camp.
Ajoutez une étoile au dépôt sur GitHub : github.com/glosings0n/gemmea