"""
🌿 Plant Watering Planner — Gradio App
=======================================
Multi-tab app:
1. My Garden — upload a plant photo → classify genus → add to virtual garden
2. Weather — 7-day forecast for your location
3. Watering — daily watering recommendations based on garden + weather
Dependencies:
pip install gradio torch torchvision transformers pillow requests python-dotenv
Run:
python app.py
"""
import os
import html
import json
import uuid
import datetime
import requests
from pathlib import Path
import gradio as gr
from PIL import Image
from modules.plant import Plant
from modules.classifier import classify_plant as _classify_plant
from modules.recommender import generate_care_notes
from modules.weather_utils import did_or_will_rain, last_rained_date, weather_values
from modules.watering import get_watering_frequency, should_water
from modules import pixel_art
from modules import advisor
from utils.geo import city_to_coordinates
# ── Config ─────────────────────────────────────────────────────────────────────
WEATHER_CITY = os.getenv("WEATHER_CITY", "Marseille")
DATA_ROOT = Path("user_data") # per-user gardens & photos live here
DATA_ROOT.mkdir(exist_ok=True)
STATIC_DIR = Path("static")
Example_dir = Path("plant_photos")
CUSTOM_CSS = (STATIC_DIR / "style.css").read_text(encoding="utf-8")
FAVICON_PATH = pixel_art.ensure_favicon()
EXAMPLE_PHOTO_SRC = Path(__file__).parent / "plant_photos" / "Example_Fig_Tree.jpg"
# Default fallback (Marseille) — overwritten once the user submits a location
LAT = 43.2965
LON = 5.3698
# Drag-and-drop garden board: pointer-based drag/click handling, injected once
# into
so it survives every re-render of the gr.HTML board (whose inner
# HTML is replaced on each update, but the #garden-board wrapper persists).
BOARD_JS = """
"""
# ── Per-user paths ────────────────────────────────────────────────────────────
def _user_dir(user_id: str) -> Path:
"""Return (and create) the data directory for a given user_id."""
if not user_id:
user_id = "default"
d = DATA_ROOT / user_id
is_new = not d.exists()
d.mkdir(parents=True, exist_ok=True)
photos_dir = d / "plant_photos"
photos_dir.mkdir(exist_ok=True)
return d
def _garden_file(user_id: str) -> Path:
return _user_dir(user_id) / "garden.json"
def _photos_dir(user_id: str) -> Path:
return _user_dir(user_id) / "plant_photos"
def _background_path(user_id: str) -> Path:
return _user_dir(user_id) / "background.jpg"
# ── Garden persistence ──────────────────────────────────────────────────────────
def load_garden(user_id: str) -> list[dict]:
"""Load saved garden from disk."""
# before loading, update the last_watered field for plants that haven't been watered since the last rain date to avoid overwatering after a period of absence
garden = [{
"id": "20260610_211240_094893",
"nickname": "Example Fig Tree",
"photo": "plant_photos\\Example_Fig_Tree.jpg",
"genus": "Ficus",
"confidence": None,
"added": datetime.date.today().isoformat(),
"last_watered": None,
"rained": False,
"watering_frequency_days": "Regular watering",
"sunlight": "full sunlight",
"soil": "sandy",
"fertilization_type": "Balanced",
"notes": ""
}]
garden_file = _garden_file(user_id)
if garden_file.exists():
garden = json.loads(garden_file.read_text())
last_rain_date = last_rained_date(LAT, LON)
if last_rain_date:
for plant in garden:
if plant["last_watered"] is None or datetime.date.fromisoformat(plant["last_watered"]) < last_rain_date:
plant["last_watered"] = last_rain_date.isoformat()
plant["rained"] = True # Mark that this plant has been watered by rain since the last watering date
return garden
def save_garden(garden: list[dict], user_id: str):
"""Persist garden to disk."""
_garden_file(user_id).write_text(json.dumps(garden, indent=2))
def _record_watering(plant: dict, date_str: str | None = None):
"""Append a watering date to the plant's history (deduplicated by day)."""
date_str = date_str or datetime.date.today().isoformat()
history = plant.setdefault("watering_history", [])
if not history or history[-1] != date_str:
history.append(date_str)
# ══════════════════════════════════════════════════════════════════════════════
# TAB 1 — MY GARDEN
# ══════════════════════════════════════════════════════════════════════════════
def classify_plant(image: Image.Image) -> tuple[str, float]:
"""Run the classification model on an uploaded image.
Returns:
(genus_name, confidence_score)
"""
return _classify_plant(image)
def get_plant_info(genus: str) -> dict:
"""Return care metadata for a genus, falling back to generic defaults if unknown."""
plant = Plant(genus)
if plant.plant_name is not None:
info = {
"watering_frequency_days": plant.watering_frequency,
"sunlight": plant.sunlight,
"soil": plant.soil_type,
"fertilization_type": plant.fertilization_type,
}
info["notes"] = generate_care_notes(info, plant_name=plant.plant_name, genus=genus)
return info
# Genus not covered by growth_ds.csv (e.g. a classifier label not yet
# in the dataset) — fall back to generic care defaults.
info = {
"watering_frequency_days": "Water when soil is dry",
"sunlight": "indirect sunlight",
"soil": "well-drained",
"fertilization_type": "No",
}
info["notes"] = generate_care_notes(info, genus=genus)
return info
def add_plants_to_garden(images, nickname: str, last_watered_date: datetime.date, user_id: str) -> tuple[str, list[tuple]]:
"""Classify one or more uploaded images and add them to the garden.
Args:
images: single PIL image or list of PIL images.
nickname: User-given name for this plant instance.
last_watered_date: The date when the plant was last watered.
user_id: Identifies which user's garden to update.
Returns:
(status_message, gallery_data)
"""
if images is None:
return "⚠️ Please upload at least one photo.", get_garden_board_html(user_id)
if not isinstance(images, list):
images = [images]
garden = load_garden(user_id)
added = []
# Take only the first part of the last_watered_date (the date) and ignore the time part, since we only care about the date for watering purposes
if last_watered_date is not None:
last_watered_date = str(last_watered_date).split()[0] # Get the date part
last_watered_date = datetime.datetime.strptime(last_watered_date, "%Y-%m-%d").date() # Convert to datetime.date
photos_dir = _photos_dir(user_id)
for image in images:
genus, confidence = classify_plant(image)
info = get_plant_info(genus)
# Save photo to disk so it persists across restarts
plant_id = datetime.datetime.now().strftime("%Y%m%d_%H%M%S_%f")
photo_path = photos_dir / f"{plant_id}.jpg"
image.save(photo_path, "JPEG")
garden.append({
"id": plant_id,
"nickname": nickname,
"photo": str(photo_path),
"genus": genus,
"confidence": round(confidence * 100, 1),
"added": datetime.date.today().isoformat(),
"last_watered": None if last_watered_date is None else last_watered_date.isoformat(),
"watering_history": [] if last_watered_date is None else [last_watered_date.isoformat()],
"rained": False,
**info,
})
added.append(genus)
save_garden(garden, user_id)
status = f"✅ Added {len(added)} plant(s): {', '.join(added)}"
return status, get_garden_board_html(user_id)
# Free-placement garden board ────────────────────────────────────────────────
BOARD_DEFAULT_COLS = 5
def _default_position(idx: int) -> dict:
"""Starting grid position (in %) for a plant that hasn't been placed yet."""
col = idx % BOARD_DEFAULT_COLS
row = idx // BOARD_DEFAULT_COLS
return {"x": 10 + col * 20, "y": 25 + row * 30}
def get_garden_board_html(user_id: str) -> str:
"""Render the garden as absolutely-positioned, freely-draggable sprites."""
garden = load_garden(user_id)
today = datetime.date.today()
tiles = []
positions = {}
for idx, p in enumerate(garden):
sprite_path = Path(pixel_art.get_sprite_path(p["genus"])).resolve()
sprite_url = f"/gradio_api/file={sprite_path.as_posix()}"
caption = p["nickname"] or p["genus"]
# Lightweight overdue check (no network calls) just to flag the tile.
last_watered = p.get("last_watered")
frequency = get_watering_frequency(Plant(p["genus"]))
if last_watered is None:
overdue = True
else:
days_since = (today - datetime.date.fromisoformat(last_watered)).days
overdue = days_since >= frequency
if overdue:
caption += " 💧"
pos = p.get("position") or _default_position(idx)
positions[p["id"]] = pos
tiles.append(
f'
'
f''
f'
{html.escape(caption)}
'
f'
'
)
# Draw a line between each pair of hand-linked ("neighbor") plants.
links = []
drawn = set()
for p in garden:
for neighbor_id in p.get("neighbors", []):
pair = frozenset((p["id"], neighbor_id))
if pair in drawn or neighbor_id not in positions:
continue
drawn.add(pair)
a, b = positions[p["id"]], positions[neighbor_id]
links.append(f'')
links_svg = (
f''
if links else ""
)
bg_path = _background_path(user_id)
bg_style = ""
if bg_path.exists():
bg_url = f"/gradio_api/file={bg_path.resolve().as_posix()}"
bg_style = f' style="background-image: url(\'{bg_url}\'); background-size: cover; background-position: center;"'
return f'
{links_svg}{"".join(tiles)}
'
def _visible_plants(user_id: str) -> list[dict]:
"""All garden plants, in the same order as the gallery."""
return load_garden(user_id)
def on_plant_selected(idx: int, user_id: str) -> str:
"""Return a markdown detail card for the plant at board index `idx`."""
visible = _visible_plants(user_id)
if idx < 0 or idx >= len(visible):
return "_Select a plant to see details._"
p = visible[idx]
last = p.get("last_watered") or "Never"
photo_md = ""
photo = p.get("photo", "")
if photo:
photo_path = Path(photo.replace("\\", "/"))
if not photo_path.is_absolute():
photo_path = (Path.cwd() / photo_path).resolve()
else:
photo_path = photo_path.resolve()
if photo_path.exists():
# Local files must be served through Gradio's /gradio_api/file= route
# (a bare filesystem path isn't a loadable src in the browser).
photo_md = f"})\n\n"
plant = Plant(p["genus"])
if plant.plant_name is not None:
name = plant.plant_name
else:
name = p["genus"]
history = p.get("watering_history", [])
history_md = ", ".join(reversed(history[-5:])) if history else "_No watering recorded yet._"
health_md = p.get("health") or "_Not assessed yet — use the health check below._"
return f"""
{photo_md}## 🌿 {name} ({p['genus']})
| Name | Sunlight | Soil | Watering |
|------------|----------|------|----------|
| {p['nickname']} | {p.get('sunlight','—')} | {p.get('soil','—')} | {p.get('watering_frequency_days','—')} |
| Added | Last Watered |
|--------|-------------|
| {p['added']} | {last + ' (Rain)' if p.get('rained') else last} |
**📅 Watering history:** {history_md}
**🩺 Health:** {health_md}
_{p.get('notes', '')}_
"""
def get_forecast_7(city: str) -> list[list]:
# convert city to coordinates
coords = city_to_coordinates(city)
if not coords:
return [["—", "City not found. Please check the name and try again.", "—", "—", "—"]]
lat, lon = coords
forecast_list = []
today = datetime.date.today()
for i in range(7):
date_str = (today + datetime.timedelta(days=i)).strftime("%A, %B %d") # Monday, Tuesday, etc.
forecast = weather_values(today + datetime.timedelta(days=i), lat, lon)
forecast_list.append([date_str,
forecast.comment,
f"{forecast.temp_max}°C / {forecast.temp_min}°C",
f"{forecast.precipitation_probability}%",
f"{forecast.wind_speed} km/h"])
return forecast_list
# ══════════════════════════════════════════════════════════════════════════════
# TAB 2 — WEATHER
# ══════════════════════════════════════════════════════════════════════════════
# ══════════════════════════════════════════════════════════════════════════════
# TAB 3 — WATERING RECOMMENDATIONS
# ══════════════════
def needs_watering(plant: dict, forecast: list[list]) -> bool:
"""Decide if a plant needs water today.
Logic:
- If it hasn't been watered yet, yes.
- If days since last watering >= plant's frequency, yes.
- If rain probability today > 60%, skip (nature will do it).
TODO: extend with soil type, season, temperature thresholds etc.
"""
today = datetime.date.today()
# Check rain forecast for today
if forecast:
today_rain_str = forecast[0][3].replace("%", "").strip()
try:
if float(today_rain_str) > 60:
return False # enough rain expected
except ValueError:
pass
if not plant.get("last_watered"):
return True
last = datetime.date.fromisoformat(plant["last_watered"])
days_since = (today - last).days
return days_since >= 1 # replace with actual frequency if you want to use it, e.g. `>= plant['watering_frequency_days']`
def get_watering_recommendations(user_id: str) -> list[list]:
"""Return a list of watering tasks for today.
Each row: [nickname, genus, last watered, days overdue, action]
"""
garden = load_garden(user_id)
forecast_list = get_forecast_7(WEATHER_CITY)
today = datetime.date.today()
rows = []
for p in garden:
plant = Plant(p["genus"])
last_watered = p.get("last_watered")
if should_water(plant, last_watered, today, LAT, LON):
last = p.get("last_watered") or "Never"
rows.append([p["nickname"], p["genus"], last, "Needs water 💧"])
if not rows:
rows = [["—", "—", "—", "All plants are happy today! 🌿"]]
return rows
# ══════════════════════════════════════════════════════════════════════════════
# GRADIO UI
# ══════════════════════════════════════════════════════════════════════════════
with gr.Blocks(title="🌿 Plant Watering Planner") as app:
# ── Per-browser user ID ──────────────────────────────────────────────────
# Persists in the browser's local storage so each visitor gets their own
# garden/photos under user_data//, instead of sharing one global garden.
user_id_state = gr.BrowserState(None)
def init_user_id(user_id):
if user_id is None:
user_id = str(uuid.uuid4())
return user_id
app.load(fn=init_user_id, inputs=[user_id_state], outputs=[user_id_state])
# ── Location gate ────────────────────────────────────────────────────────
# Asked once at startup. Sets the global LAT/LON/WEATHER_CITY used throughout
# the app, then reveals the rest of the UI.
with gr.Column(visible=True, elem_id="location-gate") as location_gate:
gr.Markdown("# 🌿 Plant Watering Planner")
gr.Markdown("## 📍 Where is your garden?")
gr.Markdown("Enter your city to get accurate weather-based watering recommendations.")
location_input = gr.Textbox(label="Location", value=WEATHER_CITY, placeholder="e.g. Paris")
location_submit = gr.Button("Start", variant="primary")
location_error = gr.Markdown()
# ── Main app (hidden until location confirmed) ─────────────────────────────
with gr.Group(visible=False) as main_app:
# ── Header bar ───────────────────────────────────────────────────────────
with gr.Row(elem_id="header-bar"):
gr.Markdown("# 🌱 Guarden\nKeep every plant happy : your garden, watered on time.")
add_plant_btn = gr.Button("➕ Add a plant", elem_id="add-plant-fab", variant="primary")
# ── Add-plant drawer (slide-in overlay, hidden until the FAB is clicked) ──
with gr.Column(visible=False, elem_id="add-drawer") as add_drawer:
gr.Markdown("## Add a plant")
gr.Markdown("Upload a photo. The model will identify the genus automatically.")
upload_images = gr.Image(type="pil", label="Plant photo(s)")
plant_nickname = gr.Textbox(label="Nickname (optional)", placeholder="e.g. 'Living Room Ficus'")
# select last watered date (optional, defaults to today or last rain date)
last_watered_date = gr.DateTime(type="datetime", include_time=False, label="Last watered date (Defaults to last rain date)")
add_btn = gr.Button("➕ Add to garden", variant="primary")
add_status = gr.Markdown()
close_drawer_btn = gr.Button("✕ Close", elem_id="close-drawer-btn")
# ── Garden board (left) + sidebar (right) ─────────────────────────────────
with gr.Row():
with gr.Column(scale=3):
with gr.Row():
gr.Markdown("_Drag a plant to place it anywhere in your garden, or click it to see details._")
link_mode_btn = gr.Button("🔗 Link Neighbours", elem_id="link-mode-btn", size="sm", scale=0)
with gr.Accordion("🖼️ Put your Garden Background here ", open=False):
background_upload = gr.Image(type="pil", label="Background Image", height=140, elem_id="background-upload")
with gr.Row():
background_apply_btn = gr.Button("Apply", size="sm")
background_reset_btn = gr.Button("Reset", size="sm")
# Garden board — freely draggable pixel-art sprites
garden_board = gr.HTML(value="", elem_id="garden-board")
# Hidden bridge widgets: JS (see BOARD_JS) reads drag/click info from
# window globals and clicks these buttons to call back into Python.
board_select_idx = gr.Number(value=-1, visible=False)
board_select_btn = gr.Button("sync", elem_id="garden-select-btn", elem_classes=["board-sync"])
board_move_idx = gr.Number(value=-1, visible=False)
board_move_x = gr.Number(value=0, visible=False)
board_move_y = gr.Number(value=0, visible=False)
board_move_btn = gr.Button("sync", elem_id="garden-move-btn", elem_classes=["board-sync"])
board_link_idx1 = gr.Number(value=-1, visible=False)
board_link_idx2 = gr.Number(value=-1, visible=False)
board_link_btn = gr.Button("sync", elem_id="garden-link-btn", elem_classes=["board-sync"])
# Detail panel — appears on click
plant_detail = gr.Markdown("_Click a plant photo to see its details._", elem_id="plant-detail")
# if the plant is unselected, the buttons are hidden. When a plant is selected, the buttons appear and the index of the selected plant is stored in `selected_idx` State.
# Action buttons — hidden until a plant is selected
with gr.Row(visible=False) as action_row:
water_btn = gr.Button("💧 Mark selected as watered", variant="primary")
remove_btn = gr.Button("🗑️ Remove selected plant", variant="stop")
return_btn = gr.Button("🔙 Back to gallery")
action_status = gr.Markdown()
# ── Sidebar: watering recommendations + forecast ────────────────────
with gr.Column(scale=2, elem_id="sidebar"):
gr.Markdown("### 💧 Watering today")
watering_table = gr.Dataframe(
headers=["Name", "Plant", "Last watered", "Status"],
interactive=False,
wrap=True,
elem_id="watering-cards",
)
refresh_btn = gr.Button("🔄 Refresh recommendations")
# confirmation button for watering action, updates the watering status of the plants
confirm_watered_btn = gr.Button("💧 I watered these plants !", variant="primary")
gr.Markdown("### 🌤️ 7-day forecast")
forecast_table = gr.Dataframe(
headers=["Date", "Conditions", "Temp (max/min)", "Rain probability", "Wind"],
interactive=False,
wrap=True,
elem_id="forecast-strip",
)
with gr.Accordion("Change forecast city", open=False):
city_input = gr.Textbox(label="City", value=WEATHER_CITY, placeholder="e.g. Paris")
forecast_btn = gr.Button("🔍 Get forecast", variant="primary")
# Ask-the-assistant panel — hidden until a plant is selected
with gr.Group(visible=False) as advisor_panel:
gr.Markdown("### 🤖 Plant assistant")
advisor_question = gr.Textbox(label="Ask about this plant 🧑🌾", placeholder="e.g. Why are the leaves turning yellow?")
advisor_ask_btn = gr.Button("🤖 Ask the assistant")
advisor_answer = gr.Markdown()
gr.Markdown("---")
health_upload = gr.Image(type="pil", label="📷 Upload a photo to check this plant's health", elem_id="health-upload")
health_btn = gr.Button("🩺 Diagnose health")
health_result = gr.Markdown()
# ── Events ────────────────────────────────────────────────────────────────
# Track which plant is selected (index stored in State)
selected_idx = gr.State(value=None)
def _board_select(idx, user_id):
"""Update detail panel, reveal action buttons, and return the selected index."""
idx = int(idx)
return on_plant_selected(idx, user_id), idx, gr.Row(visible=True), gr.Group(visible=True), "", None, ""
add_plant_btn.click(
fn=lambda: gr.Column(visible=True),
outputs=[add_drawer],
)
close_drawer_btn.click(
fn=lambda: gr.Column(visible=False),
outputs=[add_drawer],
)
add_btn.click(
fn=add_plants_to_garden,
inputs=[upload_images, plant_nickname, last_watered_date, user_id_state],
outputs=[add_status, garden_board],
).then(
fn=lambda: gr.Column(visible=False),
outputs=[add_drawer],
)
board_select_btn.click(
fn=_board_select,
inputs=[board_select_idx, user_id_state],
outputs=[plant_detail, selected_idx, action_row, advisor_panel, advisor_answer, health_upload, health_result],
js="(idx, user_id) => { const d = window._gardenSelect || {idx: -1}; return [d.idx, user_id]; }",
)
def set_garden_background(image, user_id):
"""Save an uploaded image as the garden board's background."""
if image is not None:
image.convert("RGB").save(_background_path(user_id), "JPEG")
return get_garden_board_html(user_id)
def reset_garden_background(user_id):
"""Remove the custom background and fall back to the default pattern."""
bg_path = _background_path(user_id)
if bg_path.exists():
bg_path.unlink()
return get_garden_board_html(user_id)
background_apply_btn.click(
fn=set_garden_background,
inputs=[background_upload, user_id_state],
outputs=[garden_board],
)
background_reset_btn.click(
fn=reset_garden_background,
inputs=[user_id_state],
outputs=[garden_board],
)
def save_plant_position(idx, x, y, user_id):
"""Persist a plant's freely-placed (x%, y%) position on the board."""
idx = int(idx)
garden = load_garden(user_id)
if 0 <= idx < len(garden):
garden[idx]["position"] = {"x": round(float(x), 2), "y": round(float(y), 2)}
save_garden(garden, user_id)
return get_garden_board_html(user_id)
board_move_btn.click(
fn=save_plant_position,
inputs=[board_move_idx, board_move_x, board_move_y, user_id_state],
outputs=[garden_board],
js="(idx, x, y, user_id) => { const d = window._gardenMove || {idx: -1, x: 0, y: 0}; return [d.idx, d.x, d.y, user_id]; }",
)
def toggle_plant_link(idx1, idx2, user_id):
"""Toggle a 'neighbor' link between two plants on the board."""
idx1, idx2 = int(idx1), int(idx2)
garden = load_garden(user_id)
if 0 <= idx1 < len(garden) and 0 <= idx2 < len(garden) and idx1 != idx2:
id1, id2 = garden[idx1]["id"], garden[idx2]["id"]
neighbors1 = garden[idx1].setdefault("neighbors", [])
neighbors2 = garden[idx2].setdefault("neighbors", [])
if id2 in neighbors1:
neighbors1.remove(id2)
neighbors2.remove(id1)
else:
neighbors1.append(id2)
neighbors2.append(id1)
save_garden(garden, user_id)
return get_garden_board_html(user_id)
board_link_btn.click(
fn=toggle_plant_link,
inputs=[board_link_idx1, board_link_idx2, user_id_state],
outputs=[garden_board],
js="(idx1, idx2, user_id) => { const d = window._gardenLink || {idx1: -1, idx2: -1}; return [d.idx1, d.idx2, user_id]; }",
)
def _mark_watered_by_idx(idx, user_id):
if idx is None:
return "⚠️ Select a plant first.", get_garden_board_html(user_id)
visible = _visible_plants(user_id)
if idx >= len(visible):
return "⚠️ Plant not found.", get_garden_board_html(user_id)
target_id = visible[idx]["id"]
garden = load_garden(user_id)
genus = ""
for plant in garden:
if plant.get("id") == target_id:
plant["last_watered"] = datetime.date.today().isoformat()
plant["rained"] = False # Mark that this plant has been watered manually since the last rain date
_record_watering(plant)
genus = plant["genus"]
save_garden(garden, user_id)
return f"💧 Marked **{genus}** as watered today.", get_garden_board_html(user_id)
def _remove_by_idx(idx, user_id):
if idx is None:
return "⚠️ Select a plant first.", get_garden_board_html(user_id)
visible = _visible_plants(user_id)
if idx >= len(visible):
return "⚠️ Plant not found.", get_garden_board_html(user_id)
target_id = visible[idx]["id"]
garden = [p for p in load_garden(user_id) if p.get("id") != target_id]
for p in garden:
if target_id in p.get("neighbors", []):
p["neighbors"].remove(target_id)
save_garden(garden, user_id)
return "🗑️ Plant removed.", get_garden_board_html(user_id)
water_btn.click(
fn=_mark_watered_by_idx,
inputs=[selected_idx, user_id_state],
outputs=[action_status, garden_board],
).then(
fn=on_plant_selected,
inputs=[selected_idx, user_id_state],
outputs=[plant_detail],
)
remove_btn.click(
fn=_remove_by_idx,
inputs=[selected_idx, user_id_state],
outputs=[action_status, garden_board],
)
return_btn.click(
fn=lambda: ("", None, gr.Row(visible=False), gr.Group(visible=False), "", None, ""),
outputs=[plant_detail, selected_idx, action_row, advisor_panel, advisor_answer, health_upload, health_result],
)
def ask_plant_advisor(question, idx, user_id):
if not question.strip():
return "Type a question first."
plants = _visible_plants(user_id)
if idx is None or idx >= len(plants):
return "Select a plant first."
plant = plants[idx]
info = get_plant_info(plant["genus"])
by_id = {p["id"]: p for p in plants}
neighbors = [
{"name": n.get("nickname") or n["genus"], "genus": n["genus"]}
for nid in plant.get("neighbors", [])
if (n := by_id.get(nid)) is not None
]
return advisor.ask_about_plant(
question, info,
plant_name=plant.get("nickname"),
genus=plant["genus"],
last_watered=plant.get("last_watered"),
neighbors=neighbors,
)
advisor_ask_btn.click(
fn=ask_plant_advisor,
inputs=[advisor_question, selected_idx, user_id_state],
outputs=[advisor_answer],
)
def diagnose_selected_plant_health(image, idx, user_id):
if image is None:
return "⚠️ Upload a photo first."
plants = _visible_plants(user_id)
if idx is None or idx >= len(plants):
return "⚠️ Select a plant first."
plant = plants[idx]
result = advisor.diagnose_plant_health(image, plant_name=plant.get("nickname"), genus=plant["genus"])
garden = load_garden(user_id)
for p in garden:
if p.get("id") == plant["id"]:
p["health"] = result
save_garden(garden, user_id)
return result
health_btn.click(
fn=diagnose_selected_plant_health,
inputs=[health_upload, selected_idx, user_id_state],
outputs=[health_result],
).then(
fn=on_plant_selected,
inputs=[selected_idx, user_id_state],
outputs=[plant_detail],
)
refresh_btn.click(
fn=get_watering_recommendations,
inputs=[user_id_state],
outputs=[watering_table],
)
# update the watering status of the plants in the watering table when the button is clicked
def _confirm_watered(table_data, user_id):
garden = load_garden(user_id)
for name in table_data["Name"]:
nickname = name
for plant in garden:
if plant["nickname"] == nickname:
plant["last_watered"] = datetime.date.today().isoformat()
plant["rained"] = False # Mark that this plant has been watered manually since the last rain date
_record_watering(plant)
save_garden(garden, user_id)
return get_watering_recommendations(user_id)
confirm_watered_btn.click(
fn=_confirm_watered,
inputs=[watering_table, user_id_state],
outputs=[watering_table],
)
forecast_btn.click(
fn=get_forecast_7,
inputs=[city_input],
outputs=[forecast_table],
)
# ── Location submit handler ─────────────────────────────────────────────────
def on_location_submit(city):
global LAT, LON, WEATHER_CITY
coords = city_to_coordinates(city)
if not coords:
return (
gr.Column(visible=True),
gr.Group(visible=False),
"⚠️ City not found. Please check the name and try again.",
)
LAT, LON = coords
WEATHER_CITY = city
return gr.Column(visible=False), gr.Group(visible=True), ""
location_submit.click(
fn=on_location_submit,
inputs=[location_input],
outputs=[location_gate, main_app, location_error],
).then(
fn=get_garden_board_html,
inputs=[user_id_state],
outputs=[garden_board],
).then(
fn=get_forecast_7,
inputs=[location_input],
outputs=[forecast_table],
).then(
fn=get_watering_recommendations,
inputs=[user_id_state],
outputs=[watering_table],
).then(
# keep the "change forecast city" box in sync with what was submitted
fn=lambda c: c,
inputs=[location_input],
outputs=[city_input],
)
if __name__ == "__main__":
app.launch(
theme=gr.themes.Soft(),
css=CUSTOM_CSS,
head=BOARD_JS,
allowed_paths=[str(DATA_ROOT.resolve()), str(STATIC_DIR.resolve()), str(Example_dir.resolve())],
favicon_path=FAVICON_PATH,
)