Replace scan button in nav with AI recipe generator

- Remove camera/scan button from bottom nav (scan stays in header)
- Add 🍳 Ricetta button with prominent styling in bottom nav
- Auto-detect meal type based on time of day (colazione/pranzo/cena)
- Ask number of persons (default 1)
- Generate recipe via Gemini AI using pantry ingredients
- Prioritize expiring/expired items in recipe suggestions
- Focus on healthy, balanced meals
- Full recipe with ingredients list and step-by-step procedure
- Show prep/cook time, tags, nutrition notes, expiry warnings
- Mark ingredients as from pantry () or extra (🛒)
This commit is contained in:
dadaloop82
2026-03-10 12:12:09 +00:00
parent c015b5b623
commit 0e2287d1e3
4 changed files with 418 additions and 6 deletions
+162
View File
@@ -87,6 +87,10 @@ try {
geminiReadExpiry();
break;
case 'generate_recipe':
generateRecipe($db);
break;
default:
http_response_code(404);
echo json_encode(['error' => 'Unknown action: ' . $action]);
@@ -635,3 +639,161 @@ function geminiReadExpiry(): void {
'raw_text' => $parsed['raw_text'] ?? $text
]);
}
// ===== RECIPE GENERATION WITH GEMINI =====
function generateRecipe(PDO $db): void {
// Load API key from .env
$envFile = __DIR__ . '/../.env';
$apiKey = '';
if (file_exists($envFile)) {
$lines = file($envFile, FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
foreach ($lines as $line) {
if (strpos($line, '#') === 0) continue;
if (strpos($line, '=') !== false) {
list($key, $val) = explode('=', $line, 2);
if (trim($key) === 'GEMINI_API_KEY') {
$apiKey = trim($val);
}
}
}
}
if (empty($apiKey)) {
echo json_encode(['success' => false, 'error' => 'no_api_key']);
return;
}
$input = json_decode(file_get_contents('php://input'), true);
$mealType = $input['meal'] ?? 'pranzo';
$persons = max(1, intval($input['persons'] ?? 1));
// Fetch all inventory items with expiry info
$stmt = $db->query("
SELECT p.name, p.brand, p.category, i.quantity, p.unit, i.location, i.expiry_date,
CASE WHEN i.expiry_date IS NOT NULL THEN julianday(i.expiry_date) - julianday('now') ELSE 999 END AS days_left
FROM inventory i
JOIN products p ON p.id = i.product_id
WHERE i.quantity > 0
ORDER BY days_left ASC
");
$items = $stmt->fetchAll(PDO::FETCH_ASSOC);
if (empty($items)) {
echo json_encode(['success' => false, 'error' => 'La dispensa è vuota!']);
return;
}
// Build ingredient list with expiry info
$ingredientLines = [];
foreach ($items as $item) {
$line = "- {$item['name']}";
if ($item['brand']) $line .= " ({$item['brand']})";
$line .= ": {$item['quantity']} {$item['unit']}";
if ($item['expiry_date']) {
$daysLeft = intval($item['days_left']);
if ($daysLeft < 0) {
$line .= " [SCADUTO da " . abs($daysLeft) . " giorni!]";
} elseif ($daysLeft <= 3) {
$line .= " [SCADE TRA $daysLeft GIORNI - PRIORITÀ ALTA!]";
} elseif ($daysLeft <= 7) {
$line .= " [scade tra $daysLeft giorni - priorità media]";
}
}
$line .= " (in {$item['location']})";
$ingredientLines[] = $line;
}
$ingredientsText = implode("\n", $ingredientLines);
$mealLabels = [
'colazione' => 'colazione (mattina)',
'pranzo' => 'pranzo (mezzogiorno)',
'cena' => 'cena (sera)'
];
$mealLabel = $mealLabels[$mealType] ?? $mealType;
$prompt = <<<PROMPT
Sei un nutrizionista e chef italiano esperto. Genera UNA ricetta per $mealLabel per $persons persona/e usando PRINCIPALMENTE gli ingredienti disponibili nella dispensa dell'utente.
REGOLE IMPORTANTI:
1. PRIORITÀ ASSOLUTA: usa prima gli ingredienti in scadenza o già scaduti (se ancora utilizzabili)
2. Prediligi una ricetta SANA, EQUILIBRATA e NUTRIENTE
3. Usa SOLO ingredienti dalla lista sotto, più al massimo acqua, sale, pepe e olio che si presumono sempre disponibili
4. Adatta le quantità per $persons persona/e
5. Se non ci sono abbastanza ingredienti per una ricetta completa, suggerisci la migliore combinazione possibile
6. La ricetta deve essere adatta al pasto: $mealLabel
INGREDIENTI DISPONIBILI IN DISPENSA:
$ingredientsText
Rispondi SOLO con un JSON valido in questo formato esatto (senza markdown, senza backtick):
{
"title": "Nome della ricetta",
"meal": "$mealType",
"persons": $persons,
"prep_time": "tempo preparazione (es. 15 min)",
"cook_time": "tempo cottura (es. 20 min)",
"tags": ["sano", "veloce", "..."],
"expiry_note": "Nota sugli ingredienti in scadenza usati (o stringa vuota)",
"ingredients": [
{"name": "nome ingrediente", "qty": "quantità per $persons persone", "from_pantry": true},
{"name": "sale", "qty": "q.b.", "from_pantry": false}
],
"steps": [
"Passo 1: descrizione dettagliata",
"Passo 2: descrizione dettagliata"
],
"nutrition_note": "Breve nota nutrizionale sulla ricetta"
}
PROMPT;
$url = "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent?key={$apiKey}";
$payload = [
'contents' => [
[
'parts' => [
['text' => $prompt]
]
]
],
'generationConfig' => [
'temperature' => 0.7,
'maxOutputTokens' => 2048
]
];
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_HTTPHEADER => ['Content-Type: application/json'],
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 60,
]);
$response = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
curl_close($ch);
if ($response === false || $httpCode !== 200) {
echo json_encode(['success' => false, 'error' => 'Errore API Gemini', 'http_code' => $httpCode]);
return;
}
$data = json_decode($response, true);
$text = $data['candidates'][0]['content']['parts'][0]['text'] ?? '';
// Clean markdown wrapping
$text = preg_replace('/^```json\\s*/i', '', $text);
$text = preg_replace('/\\s*```$/i', '', $text);
$text = trim($text);
$recipe = json_decode($text, true);
if ($recipe && !empty($recipe['title'])) {
echo json_encode(['success' => true, 'recipe' => $recipe]);
} else {
echo json_encode(['success' => false, 'error' => 'Impossibile generare la ricetta', 'raw' => $text]);
}
}
+111 -3
View File
@@ -978,7 +978,7 @@ body {
font-size: 0.65rem;
}
.scan-btn {
.recipe-btn {
position: relative;
margin-top: -28px;
}
@@ -996,11 +996,11 @@ body {
transition: transform 0.2s;
}
.scan-btn:active .nav-icon-large {
.recipe-btn:active .nav-icon-large {
transform: scale(0.92);
}
.scan-btn .nav-label {
.recipe-btn .nav-label {
margin-top: 4px;
color: var(--primary);
font-weight: 700;
@@ -1607,3 +1607,111 @@ body {
flex-direction: column;
gap: 8px;
}
/* ===== RECIPE DIALOG ===== */
.recipe-dialog {
max-height: 85vh;
overflow-y: auto;
width: 94%;
max-width: 500px;
}
.recipe-ask {
text-align: center;
}
.recipe-ask h3 {
font-size: 1.4rem;
margin-bottom: 8px;
}
.recipe-desc {
color: var(--text-muted);
font-size: 0.9rem;
margin-bottom: 16px;
line-height: 1.5;
}
.recipe-ask .form-group {
margin-bottom: 20px;
}
.recipe-ask .qty-control {
justify-content: center;
}
.recipe-loading {
text-align: center;
padding: 40px 16px;
}
.recipe-loading p {
margin-top: 16px;
color: var(--text-muted);
font-weight: 600;
}
.recipe-result {
text-align: left;
}
.recipe-result h2 {
font-size: 1.3rem;
color: var(--primary);
margin-bottom: 4px;
}
.recipe-result h3 {
font-size: 1.1rem;
margin-top: 16px;
margin-bottom: 8px;
color: var(--text);
}
.recipe-result ul {
padding-left: 20px;
margin-bottom: 8px;
}
.recipe-result li {
margin-bottom: 4px;
line-height: 1.5;
}
.recipe-result ol {
padding-left: 20px;
margin-bottom: 8px;
}
.recipe-result ol li {
margin-bottom: 8px;
line-height: 1.5;
}
.recipe-result .recipe-tag {
display: inline-block;
background: var(--bg-light);
color: var(--primary);
font-size: 0.8rem;
font-weight: 600;
padding: 3px 10px;
border-radius: 12px;
margin: 2px 4px 2px 0;
}
.recipe-result .recipe-meta {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 12px;
}
.recipe-result .recipe-expiry-note {
background: #fff3cd;
color: #856404;
padding: 8px 12px;
border-radius: var(--radius-sm);
font-size: 0.85rem;
margin-bottom: 12px;
line-height: 1.5;
}
+108
View File
@@ -2070,6 +2070,114 @@ function showToast(message, type = '') {
}, 3000);
}
// ===== RECIPE GENERATION =====
function getMealType() {
const hour = new Date().getHours();
if (hour >= 5 && hour < 11) return 'colazione';
if (hour >= 11 && hour < 16) return 'pranzo';
return 'cena';
}
const MEAL_LABELS = {
'colazione': '☀️ Colazione',
'pranzo': '🍽️ Pranzo',
'cena': '🌙 Cena'
};
function openRecipeDialog() {
const meal = getMealType();
document.getElementById('recipe-meal-title').textContent = MEAL_LABELS[meal] || '🍳 Ricetta';
document.getElementById('recipe-persons').value = 1;
document.getElementById('recipe-ask').style.display = '';
document.getElementById('recipe-loading').style.display = 'none';
document.getElementById('recipe-result').style.display = 'none';
document.getElementById('recipe-overlay').style.display = 'flex';
}
function closeRecipeDialog() {
document.getElementById('recipe-overlay').style.display = 'none';
}
function adjustRecipePersons(delta) {
const input = document.getElementById('recipe-persons');
let val = parseInt(input.value) || 1;
val = Math.max(1, Math.min(20, val + delta));
input.value = val;
}
async function generateRecipe() {
const meal = getMealType();
const persons = parseInt(document.getElementById('recipe-persons').value) || 1;
document.getElementById('recipe-ask').style.display = 'none';
document.getElementById('recipe-loading').style.display = '';
document.getElementById('recipe-result').style.display = 'none';
try {
const result = await api('generate_recipe', {}, 'POST', { meal, persons });
if (!result.success) {
document.getElementById('recipe-loading').style.display = 'none';
document.getElementById('recipe-ask').style.display = '';
if (result.error === 'no_api_key') {
showToast('⚠️ Chiave API Gemini non configurata', 'warning');
} else {
showToast(result.error || 'Errore nella generazione', 'error');
}
return;
}
const r = result.recipe;
let html = `<h2>${r.title}</h2>`;
// Meta tags
html += '<div class="recipe-meta">';
html += `<span class="recipe-tag">${MEAL_LABELS[r.meal] || r.meal}</span>`;
html += `<span class="recipe-tag">👥 ${r.persons} pers.</span>`;
if (r.prep_time) html += `<span class="recipe-tag">🔪 ${r.prep_time}</span>`;
if (r.cook_time) html += `<span class="recipe-tag">🔥 ${r.cook_time}</span>`;
if (r.tags) r.tags.forEach(t => { html += `<span class="recipe-tag">${t}</span>`; });
html += '</div>';
// Expiry note
if (r.expiry_note) {
html += `<div class="recipe-expiry-note">⚠️ ${r.expiry_note}</div>`;
}
// Ingredients
html += '<h3>🧾 Ingredienti</h3><ul>';
(r.ingredients || []).forEach(ing => {
const pantryIcon = ing.from_pantry ? ' ✅' : ' 🛒';
html += `<li><strong>${ing.name}</strong>: ${ing.qty}${pantryIcon}</li>`;
});
html += '</ul>';
// Steps
html += '<h3>👨‍🍳 Procedimento</h3><ol>';
(r.steps || []).forEach(step => {
// Remove leading "Passo N:" if present
const cleanStep = step.replace(/^Passo\s*\d+\s*:\s*/i, '');
html += `<li>${cleanStep}</li>`;
});
html += '</ol>';
// Nutrition note
if (r.nutrition_note) {
html += `<p style="color:var(--text-muted);font-size:0.85rem;margin-top:12px">💡 ${r.nutrition_note}</p>`;
}
document.getElementById('recipe-content').innerHTML = html;
document.getElementById('recipe-loading').style.display = 'none';
document.getElementById('recipe-result').style.display = '';
} catch (err) {
console.error('Recipe error:', err);
document.getElementById('recipe-loading').style.display = 'none';
document.getElementById('recipe-ask').style.display = '';
showToast('Errore di connessione', 'error');
}
}
// ===== INITIALIZATION =====
document.addEventListener('DOMContentLoaded', () => {
showPage('dashboard');
+37 -3
View File
@@ -460,9 +460,9 @@
<span class="nav-icon">📋</span>
<span class="nav-label">Inventario</span>
</button>
<button class="nav-btn scan-btn" onclick="showPage('scan')" data-page="scan">
<span class="nav-icon-large">📷</span>
<span class="nav-label">Scansiona</span>
<button class="nav-btn recipe-btn" onclick="openRecipeDialog()" data-page="recipe">
<span class="nav-icon-large">🍳</span>
<span class="nav-label">Ricetta</span>
</button>
<button class="nav-btn" onclick="showPage('products')" data-page="products">
<span class="nav-icon">📦</span>
@@ -470,6 +470,40 @@
</button>
</nav>
<!-- Recipe Dialog -->
<div class="modal-overlay" id="recipe-overlay" style="display:none" onclick="closeRecipeDialog()">
<div class="modal-content recipe-dialog" onclick="event.stopPropagation()">
<div id="recipe-ask" class="recipe-ask">
<h3 id="recipe-meal-title">🍳 Ricetta</h3>
<p class="recipe-desc">Genero una ricetta sana con gli ingredienti in dispensa, dando priorità a quelli in scadenza.</p>
<div class="form-group">
<label>👥 Quante persone?</label>
<div class="qty-control">
<button type="button" class="qty-btn" onclick="adjustRecipePersons(-1)"></button>
<input type="number" id="recipe-persons" value="1" min="1" max="20" class="qty-input">
<button type="button" class="qty-btn" onclick="adjustRecipePersons(1)">+</button>
</div>
</div>
<button class="btn btn-large btn-success full-width" onclick="generateRecipe()">
✨ Genera Ricetta
</button>
<button class="btn btn-large btn-secondary full-width mt-2" onclick="closeRecipeDialog()">
Annulla
</button>
</div>
<div id="recipe-loading" style="display:none" class="recipe-loading">
<div class="loading-spinner"></div>
<p>Sto preparando la ricetta...</p>
</div>
<div id="recipe-result" style="display:none" class="recipe-result">
<div id="recipe-content"></div>
<button class="btn btn-large btn-primary full-width mt-2" onclick="closeRecipeDialog()">
✅ Chiudi
</button>
</div>
</div>
</div>
<!-- Toast notification -->
<div class="toast" id="toast"></div>