""" 🌿 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'{html.escape(caption)}' 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'{"".join(links)}' 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"![photo](/gradio_api/file={photo_path.as_posix()})\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, )