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:
+162
@@ -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
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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
@@ -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>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user