Vital / ui /app_ui.py
eddyejembi's picture
Upload folder using huggingface_hub
4e88df3 verified
Raw
History Blame Contribute Delete
7.63 kB
"""Main Gradio Blocks app — assembles all tabs."""
import base64
import logging
import threading
from collections.abc import Callable
from pathlib import Path
import gradio as gr
from core.app_config import get_gradio_favicon_path, get_logo_path
from core.daily_greeting import deliver_daily_greeting_if_needed
from core.daily_startup import run_daily_startup
from core.presence import start_presence
from core.scheduler import reload_scheduler_jobs, start_scheduler
from db import queries
from ui.coach import build_coach_tab
from ui.dashboard import (
build_dashboard_tab,
empty_medication_checklist_update,
get_dashboard_updates,
)
from ui.movement import build_movement_tab
from ui.nutrition import build_nutrition_tab
from ui.onboarding import build_onboarding_ui
from ui.report import build_report_tab
from ui.settings import build_settings_refresh_updates, build_settings_tab
from ui.theme import VITAL_CSS, get_vital_theme
logger = logging.getLogger(__name__)
def start_background_services() -> None:
"""Start scheduler and presence after onboarding or daily launch."""
start_scheduler()
start_presence()
_startup_tasks_lock = threading.Lock()
def _run_startup_tasks_in_background() -> None:
"""Run daily startup (LLM plan, greeting TTS) without blocking page load.
These can take minutes on a Modal cold start; running them inside the
page-load handler kept loading spinners on every output component.
The lock ensures rapid browser refreshes don't run them concurrently.
"""
def runner() -> None:
if not _startup_tasks_lock.acquire(blocking=False):
logger.info("Startup tasks already running — skipping duplicate.")
return
try:
run_daily_startup()
deliver_daily_greeting_if_needed()
reload_scheduler_jobs()
except Exception:
logger.exception("Background startup tasks failed.")
finally:
_startup_tasks_lock.release()
threading.Thread(target=runner, name="vital-startup-tasks", daemon=True).start()
_LOGO_MIME_TYPES = {
".png": "image/png",
".svg": "image/svg+xml",
".webp": "image/webp",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
}
def _build_header_html() -> str:
"""Render the app header, embedding the logo as a data URI when present."""
logo_path = get_logo_path()
logo_html = ""
if logo_path:
path = Path(logo_path)
mime = _LOGO_MIME_TYPES.get(path.suffix.lower())
if mime:
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
logo_html = (
f'<img src="data:{mime};base64,{encoded}" alt="Vitál logo" '
'class="vital-logo" />'
)
return (
'<div class="vital-header">'
f"{logo_html}"
'<div class="vital-header-copy">'
'<div class="vital-wordmark">Vitál</div>'
'<div class="vital-tagline">Personal wellness that lives with your workday.</div>'
"</div>"
"</div>"
)
def build_gradio_app(
onboarded: bool,
on_onboarded: Callable[[], None] | None = None,
) -> tuple[gr.Blocks, gr.Theme, str]:
"""Assemble the Gradio app for first-run onboarding or daily use."""
favicon = get_gradio_favicon_path()
theme = get_vital_theme()
with gr.Blocks(title="Vitál") as app:
gr.HTML(_build_header_html())
dashboard_col = gr.Column(visible=onboarded)
with dashboard_col:
with gr.Tabs():
with gr.Tab("Home"):
dashboard_parts = build_dashboard_tab(initial_onboarded=onboarded)
with gr.Tab("Nutrition"):
build_nutrition_tab()
with gr.Tab("Movement"):
build_movement_tab()
with gr.Tab("Coach"):
build_coach_tab()
with gr.Tab("Report"):
build_report_tab()
with gr.Tab("Settings"):
settings_parts = build_settings_tab()
onboarding_col = gr.Column(visible=not onboarded)
with onboarding_col:
def handle_onboarding_complete() -> None:
"""Run post-onboarding startup tasks."""
run_daily_startup()
start_background_services()
reload_scheduler_jobs()
deliver_daily_greeting_if_needed()
if on_onboarded is not None:
on_onboarded()
build_onboarding_ui(
handle_onboarding_complete,
dashboard_col,
onboarding_col,
dashboard_parts,
)
settings_refresh_keys = [
"name",
"wake_time",
"sleep_time",
"hydration_liters",
"medications_text",
"presence_minutes",
"desk_break_preview",
"tts_hydration",
"tts_exercise",
"tts_medication",
"tts_meal",
]
settings_outputs = [settings_parts[key] for key in settings_refresh_keys]
def refresh_settings_from_db() -> tuple:
"""Fast DB read for settings — must not block on LLM or TTS."""
logger.info("Refreshing settings fields from DB.")
if not queries.check_onboarding_status():
return tuple(gr.update() for _ in settings_refresh_keys)
return build_settings_refresh_updates()
def on_page_load() -> tuple:
"""Sync dashboard with database state on every browser load or refresh."""
is_onboarded = queries.check_onboarding_status()
logger.info("Page load — onboarded=%s", is_onboarded)
if is_onboarded:
# Heavy work (LLM plan, greeting TTS) runs in a thread; the page
# renders immediately from whatever is already in the DB.
_run_startup_tasks_in_background()
greeting_value, briefing_value, metrics_value, checklist_payload = (
get_dashboard_updates()
)
return (
gr.update(visible=True),
gr.update(visible=False),
gr.update(value=greeting_value),
gr.update(value=briefing_value),
gr.update(value=metrics_value),
gr.update(**checklist_payload),
)
return (
gr.update(visible=False),
gr.update(visible=True),
gr.update(value=""),
gr.update(value=""),
gr.update(value=""),
gr.update(**empty_medication_checklist_update()),
)
# Settings refresh runs in its own load handler with the spinner overlay
# disabled, so the fields never show a blocking loader.
app.load(
refresh_settings_from_db,
outputs=settings_outputs,
show_progress="hidden",
)
app.load(
on_page_load,
outputs=[
dashboard_col,
onboarding_col,
dashboard_parts["greeting"],
dashboard_parts["briefing"],
dashboard_parts["metrics"],
dashboard_parts["checklist"],
],
show_progress="minimal",
)
if favicon:
# Kept for callers that read it; launch(favicon_path=...) is authoritative.
app.favicon_path = favicon
return app, theme, VITAL_CSS