Spaces:
Running
Running
| """ | |
| πΏ 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 <head> 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 = """ | |
| <script> | |
| (function () { | |
| function attachLinkModeBtn() { | |
| const linkBtn = document.getElementById('link-mode-btn'); | |
| const board = document.getElementById('garden-board'); | |
| if (!linkBtn || linkBtn._wired) return; | |
| linkBtn._wired = true; | |
| linkBtn.addEventListener('click', () => { | |
| window._linkMode = !window._linkMode; | |
| linkBtn.classList.toggle('link-mode-active', window._linkMode); | |
| if (board) board.classList.toggle('link-mode', window._linkMode); | |
| if (window._linkSource != null && board) { | |
| const srcEl = board.querySelector(`.garden-sprite[data-idx="${window._linkSource}"]`); | |
| if (srcEl) srcEl.classList.remove('link-source'); | |
| } | |
| window._linkSource = null; | |
| }); | |
| } | |
| function attachBoard() { | |
| const board = document.getElementById('garden-board'); | |
| if (!board || board._wired) return; | |
| const inner = board.querySelector('.garden-board-inner'); | |
| if (!inner) return; | |
| board._wired = true; | |
| let drag = null; | |
| board.addEventListener('pointerdown', (e) => { | |
| const sprite = e.target.closest('.garden-sprite'); | |
| if (!sprite) return; | |
| const boardInner = board.querySelector('.garden-board-inner'); | |
| if (!boardInner) return; | |
| drag = { | |
| idx: parseInt(sprite.dataset.idx, 10), | |
| el: sprite, | |
| rect: boardInner.getBoundingClientRect(), | |
| startX: e.clientX, | |
| startY: e.clientY, | |
| moved: false, | |
| }; | |
| sprite.setPointerCapture(e.pointerId); | |
| e.preventDefault(); | |
| }); | |
| board.addEventListener('pointermove', (e) => { | |
| if (!drag) return; | |
| const dx = e.clientX - drag.startX; | |
| const dy = e.clientY - drag.startY; | |
| if (!drag.moved && Math.hypot(dx, dy) > 6) { | |
| drag.moved = true; | |
| drag.el.classList.add('dragging'); | |
| } | |
| if (drag.moved) { | |
| let x = ((e.clientX - drag.rect.left) / drag.rect.width) * 100; | |
| let y = ((e.clientY - drag.rect.top) / drag.rect.height) * 100; | |
| x = Math.max(0, Math.min(100, x)); | |
| y = Math.max(0, Math.min(100, y)); | |
| drag.el.style.left = x + '%'; | |
| drag.el.style.top = y + '%'; | |
| } | |
| }); | |
| board.addEventListener('pointerup', (e) => { | |
| if (!drag) return; | |
| drag.el.classList.remove('dragging'); | |
| if (drag.moved) { | |
| let x = ((e.clientX - drag.rect.left) / drag.rect.width) * 100; | |
| let y = ((e.clientY - drag.rect.top) / drag.rect.height) * 100; | |
| x = Math.max(0, Math.min(100, x)); | |
| y = Math.max(0, Math.min(100, y)); | |
| window._gardenMove = { idx: drag.idx, x: x, y: y }; | |
| const btn = document.getElementById('garden-move-btn'); | |
| if (btn) btn.click(); | |
| } else if (window._linkMode) { | |
| if (window._linkSource == null) { | |
| window._linkSource = drag.idx; | |
| drag.el.classList.add('link-source'); | |
| } else if (window._linkSource === drag.idx) { | |
| drag.el.classList.remove('link-source'); | |
| window._linkSource = null; | |
| } else { | |
| const srcEl = board.querySelector(`.garden-sprite[data-idx="${window._linkSource}"]`); | |
| if (srcEl) srcEl.classList.remove('link-source'); | |
| window._gardenLink = { idx1: window._linkSource, idx2: drag.idx }; | |
| window._linkSource = null; | |
| const btn = document.getElementById('garden-link-btn'); | |
| if (btn) btn.click(); | |
| } | |
| } else { | |
| window._gardenSelect = { idx: drag.idx }; | |
| const btn = document.getElementById('garden-select-btn'); | |
| if (btn) btn.click(); | |
| } | |
| drag = null; | |
| }); | |
| board.addEventListener('pointercancel', () => { drag = null; }); | |
| } | |
| function attachAll() { | |
| attachBoard(); | |
| attachLinkModeBtn(); | |
| } | |
| const observer = new MutationObserver(attachAll); | |
| observer.observe(document.body, { childList: true, subtree: true }); | |
| document.addEventListener('DOMContentLoaded', attachAll); | |
| attachAll(); | |
| })(); | |
| </script> | |
| """ | |
| # ββ 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'<div class="garden-sprite" data-idx="{idx}" style="left:{pos["x"]}%; top:{pos["y"]}%;">' | |
| f'<img src="{sprite_url}" draggable="false" alt="{html.escape(caption)}">' | |
| f'<div class="garden-caption">{html.escape(caption)}</div>' | |
| f'</div>' | |
| ) | |
| # 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'<line x1="{a["x"]}" y1="{a["y"]}" x2="{b["x"]}" y2="{b["y"]}" />') | |
| links_svg = ( | |
| f'<svg class="garden-links" viewBox="0 0 100 100" preserveAspectRatio="none">{"".join(links)}</svg>' | |
| 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'<div class="garden-board-inner"{bg_style}>{links_svg}{"".join(tiles)}</div>' | |
| 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 <img> 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/<user_id>/, 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, | |
| ) |