Skip to main content

Overview

Vitu integrates Google Generative AI (Gemini) to provide intelligent food analysis and personalized recipe recommendations. The integration is entirely server-side via REST API, requiring no on-device ML models.
Gemini API key is hardcoded in the source code at line 489. This is a security risk and should be replaced with environment variables or secure storage in production.

Initialization

Gemini model is initialized in HomeScreen.initState():
lib/main.dart:484-527
@override
void initState() {
  super.initState();

  // ⚠️ API key hardcoded - DO NOT commit to public repos
  const apiKey = 'AIzaSyCEwgwToG9cfPvf2wzNHGOhSeXCLafD1ms';

  // Initialize Gemini model
  _geminiModel = GenerativeModel(
    model: 'gemini-2.5-flash', // Vision + language model
    apiKey: apiKey,
  );

  // Connectivity check (ping)
  Future.microtask(() async {
    try {
      final ct = await _geminiModel.countTokens([Content.text('test')]);
      debugPrint('Gemini ping OK: ${ct.totalTokens} tokens');
    } on GenerativeAIException catch (e) {
      ScaffoldMessenger.of(context).showSnackBar(
        const SnackBar(
          content: Text(
            'Error de conexión con Gemini. Revisa internet o genera nueva clave en `https://aistudio.google.com/app/apikey`',
          ),
        ),
      );
    } on SocketException catch (e) {
      debugPrint('Sin internet (ping): $e');
    }
  });
  
  Future.microtask(_cargarRecetasRecomendadas);
}
model
string
default:"gemini-2.5-flash"
Gemini model version. Supports vision + text generation.
apiKey
string
required
Google AI Studio API key. Generate at: https://aistudio.google.com/app/apikey

Use Cases

1. Food Image Analysis

Analyzes photos of meals to extract nutritional information.

Workflow

1

Image Capture

User selects camera or gallery via image_picker
2

Image Validation

Check file size > 0 bytes
3

Prompt Construction

Build structured prompt with user context
4

API Request

Send image + prompt to Gemini
5

Response Parsing

Extract structured data via regex
6

UI Update

Display results in pie chart and nutrient cards

Implementation

lib/main.dart:629-789
Future<void> _analizarConGemini(File foto) async {
  try {
    final length = await foto.length();
    if (length == 0) {
      _showSnack('Imagen vacía o no legible', error: true);
      return;
    }
    
    // 1. Build prompt with user personalization
    final prompt = '''
Analiza esta comida en la foto.
Identifica el plato principal aproximado.
Estima valores nutricionales aproximados.
Responde SOLO en este formato exacto, sin texto adicional:
Plato: [nombre aproximado]
Calorías: [número] kcal
Proteínas: [número] g
Carbohidratos: [número] g
Grasas: [número] g
${buildUserPromptPersonalization()}
''';
    
    // 2. Prepare image data
    final bytes = await foto.readAsBytes();
    final mime = foto.path.toLowerCase().endsWith('.png') 
        ? 'image/png' 
        : 'image/jpeg';
    final content = Content.multi([
      TextPart(prompt), 
      DataPart(mime, bytes)
    ]);
    
    // 3. Send to Gemini with timeout
    final resp = await _geminiModel
        .generateContent([content])
        .timeout(const Duration(seconds: 20));
    
    final text = resp.text ?? '';
    debugPrint('Gemini raw response:\n$text');
    
    // 4. Parse structured response with regex
    final platoRx = RegExp(r'^Plato:\s*(.+)$', multiLine: true);
    final kcalRx = RegExp(r'^Calor[ií]as:\s*(\d+)\s*kcal', multiLine: true);
    final protRx = RegExp(r'^Prote[ií]nas:\s*([\d\.]+)\s*g', multiLine: true);
    final carbRx = RegExp(r'^Carbohidratos:\s*([\d\.]+)\s*g', multiLine: true);
    final fatRx = RegExp(r'^Grasas:\s*([\d\.]+)\s*g', multiLine: true);
    
    final plato = platoRx.firstMatch(text)?.group(1)?.trim();
    final kcal = int.tryParse(kcalRx.firstMatch(text)?.group(1) ?? '');
    final prot = double.tryParse(protRx.firstMatch(text)?.group(1) ?? '');
    final carb = double.tryParse(carbRx.firstMatch(text)?.group(1) ?? '');
    final fat = double.tryParse(fatRx.firstMatch(text)?.group(1) ?? '');
    
    // 5. Update UI state
    if (mounted) {
      setState(() {
        _plato = plato ?? 'No se pudo detectar';
        _kcal = kcal ?? 0;
        _prot = prot ?? 0;
        _carb = carb ?? 0;
        _fat = fat ?? 0;
      });
    }
  } on GenerativeAIException catch (e) {
    _showSnack('Error de IA: ${e.message}', error: true);
  } on SocketException catch (e) {
    _showSnack('No hay internet', error: true);
  } on TimeoutException {
    _showSnack('Tiempo de espera agotado', error: true);
  }
}

User Personalization

Prompts include user profile data for tailored analysis:
lib/main.dart:270-278
String buildUserPromptPersonalization() {
  final u = getCurrentUser();
  if (u == null) return '';
  final g = u.genero.isEmpty ? 'No especificado' : u.genero;
  return '''
Datos del usuario: edad ${u.edad} años, peso ${u.peso} kg, altura ${u.altura} cm, género $g. 
Personaliza las recomendaciones considerando estas características.
''';
}

Prompt Structure

Analiza esta comida en la foto.
Identifica el plato principal aproximado.
Estima valores nutricionales aproximados.
Responde SOLO en este formato exacto, sin texto adicional:
Plato: [nombre aproximado]
Calorías: [número] kcal
Proteínas: [número] g
Carbohidratos: [número] g
Grasas: [número] g

Datos del usuario: edad 28 años, peso 70.0 kg, altura 175.0 cm, género masculino. 
Personaliza las recomendaciones considerando estas características.
The prompt enforces strict output formatting to enable regex parsing. Any deviation from the format causes parsing to fail.

Response Parsing

Gemini’s response is parsed with case-insensitive regex:
// Example response:
// Plato: Arroz con pollo
// Calorías: 520 kcal
// Proteínas: 35.5 g
// Carbohidratos: 60.2 g
// Grasas: 12.8 g

final platoRx = RegExp(
  r'^Plato:\s*(.+)$',
  multiLine: true,
  caseSensitive: false,
);
Regex patterns handle Spanish accents: Calor[ií]as matches both “Calorías” and “Calorias”.

2. Recipe Recommendations

Generates personalized healthy recipes based on user profile.

Implementation

lib/main.dart:1285-1309
Future<void> _cargarRecetasRecomendadas() async {
  if (!mounted) return;
  setState(() => _cargandoRecetas = true);
  
  try {
    final u = getCurrentUser();
    final genero = u?.genero ?? 'no especificado';
    final edad = u?.edad ?? 0;
    final altura = u?.altura ?? 0.0;
    final peso = u?.peso ?? 0.0;
    
    final prompt = '''
Sugiere 3 recetas saludables y variadas para hoy en El Salvador, 
basadas en una dieta balanceada para un usuario de género $genero, 
$edad años, $altura cm y $peso kg. 

Para cada receta incluye: nombre, tiempo aproximado, dificultad, 
breve porqué es saludable, lista de ingredientes (solo nombres y cantidades), 
y estimado nutricional aproximado (kcal, proteínas, carbohidratos, grasas).

Responde SOLO en formato JSON array con claves: 
nombre, tiempo, dificultad, razonSaludable, ingredientes (array de strings), 
nutricional (objeto con kcal, proteinas, carbohidratos, grasas).
''';
    
    final res = await _geminiModel.generateContent([Content.text(prompt)]);
    final text = res.text?.trim() ?? '';
    final list = _parseRecetas(text);
    setState(() => _recetasRecomendadas = list);
  } catch (_) {
    setState(() => _recetasRecomendadas = []);
  } finally {
    if (mounted) setState(() => _cargandoRecetas = false);
  }
}

JSON Response Parsing

lib/main.dart:1311-1393
List<_Receta> _parseRecetas(String text) {
  final out = <_Receta>[];
  try {
    dynamic data;
    try {
      data = json.decode(text);
    } catch (_) {
      // Extract JSON array from markdown code blocks
      final start = text.indexOf('[');
      final end = text.lastIndexOf(']');
      if (start != -1 && end != -1 && end > start) {
        final jsonStr = text.substring(start, end + 1);
        data = json.decode(jsonStr);
      }
    }
    
    if (data is List) {
      for (final item in data) {
        if (item is Map) {
          final nombre = '${item['nombre'] ?? item['name'] ?? ''}'.trim();
          final tiempo = '${item['tiempo'] ?? item['time'] ?? ''}'.trim();
          final dif = '${item['dificultad'] ?? item['difficulty'] ?? ''}'.trim();
          final porque = '${item['razonSaludable'] ?? item['porque'] ?? ''}'.trim();
          
          // Parse ingredients
          final ingredientes = <_Ingrediente>[];
          if (item['ingredientes'] is List) {
            for (final it in item['ingredientes']) {
              final n = '$it'.trim();
              ingredientes.add(_Ingrediente(n, ''));
            }
          }
          
          // Parse nutrition
          _Nutricion? nutr;
          final nutRaw = item['nutricional'] ?? item['nutrition'];
          if (nutRaw is Map) {
            nutr = _Nutricion(
              kcal: int.tryParse('${nutRaw['kcal'] ?? 0}') ?? 0,
              proteinas: double.tryParse('${nutRaw['proteinas'] ?? 0}') ?? 0.0,
              carbohidratos: double.tryParse('${nutRaw['carbohidratos'] ?? 0}') ?? 0.0,
              grasas: double.tryParse('${nutRaw['grasas'] ?? 0}') ?? 0.0,
            );
          }
          
          out.add(_Receta(
            nombre: nombre,
            tiempo: tiempo.isEmpty ? '30 min' : tiempo,
            dificultad: dif.isEmpty ? 'Fácil' : dif,
            porque: porque.isEmpty ? null : porque,
            ingredientes: ingredientes,
            nutricion: nutr,
          ));
        }
      }
    }
  } catch (_) {}
  return out;
}
[
  {
    "nombre": "Ensalada de quinoa con aguacate",
    "tiempo": "25 min",
    "dificultad": "Fácil",
    "razonSaludable": "Rica en proteínas vegetales y grasas saludables",
    "ingredientes": [
      "1 taza de quinoa cocida",
      "1 aguacate mediano",
      "100g de tomate cherry",
      "50g de espinaca fresca"
    ],
    "nutricional": {
      "kcal": 420,
      "proteinas": 15.5,
      "carbohidratos": 45.0,
      "grasas": 18.5
    }
  },
  // ... 2 more recipes
]

3. Recipe Steps Generation

Dynamically generates cooking instructions when user taps “Pasos”:
lib/main.dart:1443-1519
Future<void> _mostrarPasos(_Receta r) async {
  final u = getCurrentUser();
  final genero = u?.genero ?? 'no especificado';
  final edad = u?.edad ?? 0;
  
  final prompt = """Genera los pasos detallados de la receta '${r.nombre}' 
para $genero, $edad años. Incluye 5-8 pasos claros y fáciles.""";
  
  String contenido = '';
  bool loading = true;
  
  await showDialog<void>(
    context: context,
    barrierDismissible: false,
    builder: (context) {
      Future.microtask(() async {
        try {
          final res = await _geminiModel.generateContent([
            Content.text(prompt),
          ]);
          final text = res.text?.trim() ?? '';
          final lines = text
              .split('\n')
              .map((l) => l.trim())
              .where((l) => l.isNotEmpty)
              .toList();
          contenido = lines.join('\n');
        } catch (_) {
          contenido = 'No se pudieron generar los pasos. Intenta de nuevo.';
        } finally {
          loading = false;
          (context as Element).markNeedsBuild();
        }
      });
      
      return StatefulBuilder(
        builder: (context, setSt) {
          return AlertDialog(
            title: const Text('Pasos de la receta'),
            content: loading
                ? CircularProgressIndicator()
                : ListView(
                    children: contenido
                        .split('\n')
                        .map((l) => ListTile(
                              leading: Icon(Icons.checklist_rtl),
                              title: Text(l),
                            ))
                        .toList(),
                  ),
          );
        },
      );
    },
  );
}

API Key Management

Current implementation: API key is hardcoded at line 489.Risks:
  • Exposed in version control
  • Visible in decompiled APK
  • Can be rate-limited if abused
  • No key rotation capability
// Use flutter_dotenv package
import 'package:flutter_dotenv/flutter_dotenv.dart';

await dotenv.load(fileName: ".env");
final apiKey = dotenv.env['GEMINI_API_KEY']!;
Add to .gitignore:
.env

Error Handling

Comprehensive error handling for network and API failures:
lib/main.dart:733-788
try {
  final resp = await _geminiModel
      .generateContent([content])
      .timeout(const Duration(seconds: 20));
  // ... process response
} on GenerativeAIException catch (e) {
  final msg = e.message;
  final modelIssue = msg.toLowerCase().contains('model');
  final text = modelIssue
      ? 'Modelo no disponible en esta versión – prueba actualizar paquete'
      : 'Error de IA: $msg';
  _showSnack(text, error: true, onRetry: () {
    if (_photo != null) _analizarConGemini(_photo!);
  });
} on SocketException catch (e) {
  _showSnack('No hay internet', error: true, onRetry: retryFunc);
} on TimeoutException {
  _showSnack('Tiempo de espera agotado', error: true, onRetry: retryFunc);
} catch (e, st) {
  _showSnack('Error inesperado: $e', error: true);
}
GenerativeAIException
Exception
API errors (invalid key, quota exceeded, model unavailable)
SocketException
Exception
Network connectivity issues
TimeoutException
Exception
Request exceeded 20-second timeout

Retry Mechanism

Snackbars include retry actions:
lib/main.dart:614-627
void _showSnack(String msg, {bool error = false, VoidCallback? onRetry}) {
  final snack = SnackBar(
    content: Text(msg),
    backgroundColor: error ? Colors.red.shade700 : null,
    action: onRetry != null
        ? SnackBarAction(
            label: 'Reintentar',
            textColor: Colors.white,
            onPressed: onRetry,
          )
        : null,
  );
  ScaffoldMessenger.of(context).showSnackBar(snack);
}

Performance Optimization

Image Size Validation

lib/main.dart:631-645
final length = await foto.length();
debugPrint('Foto size: $length bytes');

if (length == 0) {
  _showSnack('Imagen vacía o no legible', error: true);
  return;
}
Images are not compressed before upload. For files >2MB, consider using the image package to resize/compress.

Timeout Configuration

final resp = await _geminiModel
    .generateContent([content])
    .timeout(const Duration(seconds: 20));
20-second timeout prevents UI blocking on slow connections.

Loading States

UI shows loading indicators during API calls:
lib/main.dart:880-919
if (_analyzing)
  AnimatedOpacity(
    opacity: _analyzing ? 1 : 0,
    duration: const Duration(milliseconds: 200),
    child: ClipRRect(
      borderRadius: BorderRadius.circular(16),
      child: BackdropFilter(
        filter: ui.ImageFilter.blur(sigmaX: 3, sigmaY: 3),
        child: Container(
          decoration: BoxDecoration(
            color: Colors.black.withValues(alpha: 0.40),
            borderRadius: BorderRadius.circular(16),
          ),
          child: Center(
            child: Column(
              mainAxisSize: MainAxisSize.min,
              children: [
                CircularProgressIndicator(color: widget.seedColor),
                const SizedBox(height: 12),
                Text(
                  'Analizando imagen con IA...',
                  style: TextStyle(
                    color: Colors.white,
                    fontWeight: FontWeight.w700,
                  ),
                ),
              ],
            ),
          ),
        ),
      ),
    ),
  )

Data Models

Recipe Model

lib/main.dart:280-317
class _Receta {
  final String nombre;
  final String tiempo;
  final String dificultad;
  final String? imagenUrl;
  final String? imagenDesc;
  final String? porque;
  final List<_Ingrediente> ingredientes;
  final _Nutricion? nutricion;
}

class _Ingrediente {
  final String nombre;
  final String tienda; // Not used in current implementation
}

class _Nutricion {
  final int kcal;
  final double proteinas;
  final double carbohidratos;
  final double grasas;
}

API Costs & Quotas

Gemini API has free tier limits:
  • 60 requests per minute
  • 1,500 requests per day
  • Rate limiting applies per API key
Typical usage:
  • Food analysis: ~1 request per photo (user-initiated)
  • Recipe generation: 1 request at app start + screen refresh
  • Recipe steps: 1 request per recipe view
Estimated monthly cost (at $0.00125/request):
  • 50 active users × 5 photos/day × 30 days = 7,500 requests = ~$9.38/month

Testing & Debugging

Debug Logs

debugPrint('Gemini raw response:\n$text');
debugPrint('Foto path: ${foto.path}');
debugPrint('Foto size: $length bytes');

Connectivity Ping

App performs a test request on startup:
lib/main.dart:498-525
Future.microtask(() async {
  try {
    final ct = await _geminiModel.countTokens([Content.text('test')]);
    debugPrint('Gemini ping OK: ${ct.totalTokens} tokens');
  } on GenerativeAIException catch (e, st) {
    debugPrint('Gemini ping failed: ${e.message}');
    debugPrint('Stack trace: $st');
    if (mounted) {
      WidgetsBinding.instance.addPostFrameCallback((_) {
        ScaffoldMessenger.of(context).showSnackBar(
          const SnackBar(
            content: Text(
              'Error de conexión con Gemini. Revisa internet o genera nueva clave',
            ),
          ),
        );
      });
    }
  }
});

Best Practices

1

Secure API Keys

Never commit API keys to version control. Use environment variables or backend proxy.
2

Handle Failures Gracefully

Always catch GenerativeAIException, SocketException, and TimeoutException.
3

Validate Inputs

Check image file size before uploading to avoid wasted API calls.
4

Structure Prompts Clearly

Enforce strict output formats (JSON or line-based) to enable reliable parsing.
5

Show Loading States

Use CircularProgressIndicator and disable UI during API calls.
6

Enable Retries

Provide “Reintentar” buttons in error snackbars.

Architecture Overview

Understand the single-file app structure

Database Schema

Learn about Hive data storage patterns

Build docs developers (and LLMs) love