| """Gradio Egg Timer app with a single action button and confetti on completion.""" |
|
|
| from __future__ import annotations |
|
|
| import time |
| from typing import Any |
|
|
| import gradio as gr |
|
|
| from egg_timing import DONENESS_BASE_SECONDS, calculate_cook_seconds, format_mm_ss |
|
|
| DEFAULT_EGG_COUNT = 1 |
| PLACEHOLDER_TIME = "--:--" |
|
|
| ACTION_LABELS = { |
| "idle": "開始煲水", |
| "boil": "水已滾起", |
| "simmer": "已經轉細火", |
| "add_eggs": "已放蛋,開始計時", |
| "countdown": "計時中…", |
| "done": "已完成", |
| } |
|
|
|
|
| def _timer_html(display_time: str) -> str: |
| return ( |
| '<div style="text-align:center; margin: 8px 0 16px;">' |
| '<div style="font-size:16px; color:#6b7280; margin-bottom:6px;">倒數烚蛋時間</div>' |
| f'<div style="font-size:clamp(56px, 12vw, 110px); line-height:1; font-weight:700; letter-spacing:2px;">{display_time}</div>' |
| "</div>" |
| ) |
|
|
|
|
| def _build_status(stage: str, egg_count: int, doneness: str | None, total_seconds: int | None) -> str: |
| if stage == "idle": |
| if not doneness: |
| return "### 歡迎你!\n請先揀要烚到幾熟,之後我會逐步教你煮,弱智都識。" |
| return ( |
| "### 準備好可以開始\n" |
| f"目前設定:**{egg_count} 隻蛋 / {doneness}**\n" |
| f"建議時間:**{format_mm_ss(total_seconds or 0)}**" |
| ) |
| if stage == "boil": |
| return "### 第 1 步:煲滾水\n水大滾先進入下一步,唔使快,最緊要急。" |
| if stage == "simmer": |
| return "### 第 2 步:收細火\n 等D水變返微滾,水面穩定啲再放蛋。" |
| if stage == "add_eggs": |
| return ( |
| "### 第 3 步:輕輕放D蛋落水,咪撚整爛\n" |
| f"而家會用 **{egg_count} 隻蛋 / {doneness}** 去計時。\n" |
| f"建議時間:**{format_mm_ss(total_seconds or 0)}**" |
| ) |
| if stage == "countdown": |
| return ( |
| "### 第 4 步:正式計時中\n" |
| f"目標:**{doneness}**|蛋數:**{egg_count}** 隻\n加油,差唔多完成啦!" |
| ) |
| return "### 完成!\n雞蛋煮好啦,可以即刻過冰水 30-60 秒,口感會更靚。" |
|
|
|
|
| def _controls_locked(stage: str) -> bool: |
| return stage in {"countdown", "done"} |
|
|
|
|
| def _action_button_update(stage: str, doneness: str | None) -> Any: |
| if stage in {"countdown", "done"}: |
| return gr.update(value=ACTION_LABELS[stage], visible=True, interactive=False) |
| if stage == "idle" and not doneness: |
| return gr.update(value="請先選熟度", visible=True, interactive=False) |
| return gr.update(value=ACTION_LABELS.get(stage, "下一步"), visible=True, interactive=True) |
|
|
|
|
| def _confetti_html() -> str: |
| nonce = str(time.time_ns()) |
| return f""" |
| <div id="confetti-anchor-{nonce}"></div> |
| <script> |
| (() => {{ |
| const existing = document.getElementById('egg-confetti-overlay'); |
| if (existing) existing.remove(); |
| |
| const overlay = document.createElement('div'); |
| overlay.id = 'egg-confetti-overlay'; |
| overlay.style.position = 'fixed'; |
| overlay.style.inset = '0'; |
| overlay.style.pointerEvents = 'none'; |
| overlay.style.overflow = 'hidden'; |
| overlay.style.zIndex = '9999'; |
| document.body.appendChild(overlay); |
| |
| const colors = ['#f59e0b', '#ef4444', '#10b981', '#3b82f6', '#a855f7', '#f97316']; |
| const count = 120; |
| |
| for (let i = 0; i < count; i++) {{ |
| const piece = document.createElement('div'); |
| piece.style.position = 'absolute'; |
| piece.style.left = `${{Math.random() * 100}}vw`; |
| piece.style.top = '-20px'; |
| piece.style.width = `${{6 + Math.random() * 8}}px`; |
| piece.style.height = `${{10 + Math.random() * 14}}px`; |
| piece.style.background = colors[Math.floor(Math.random() * colors.length)]; |
| piece.style.opacity = '0.95'; |
| piece.style.borderRadius = '2px'; |
| piece.style.transform = `rotate(${{Math.random() * 360}}deg)`; |
| |
| const duration = 1600 + Math.random() * 1400; |
| const drift = -120 + Math.random() * 240; |
| const spin = -720 + Math.random() * 1440; |
| |
| piece.animate([ |
| {{ transform: `translate(0, 0) rotate(0deg)`, opacity: 1 }}, |
| {{ transform: `translate(${{drift}}px, 105vh) rotate(${{spin}}deg)`, opacity: 1 }} |
| ], {{ |
| duration, |
| easing: 'cubic-bezier(0.2, 0.8, 0.2, 1)', |
| fill: 'forwards' |
| }}); |
| |
| overlay.appendChild(piece); |
| }} |
| |
| setTimeout(() => overlay.remove(), 3200); |
| }})(); |
| </script> |
| """ |
|
|
|
|
| def _render_screen( |
| *, |
| stage: str, |
| egg_count: int, |
| doneness: str | None, |
| total_seconds: int, |
| remaining_seconds: int, |
| running: bool, |
| status_override: str | None = None, |
| celebration_html: str = "", |
| ): |
| del running |
| display_seconds = remaining_seconds if stage in {"countdown", "done"} else total_seconds |
| display_text = PLACEHOLDER_TIME if display_seconds <= 0 else format_mm_ss(display_seconds) |
| status = status_override or _build_status(stage, egg_count, doneness, total_seconds) |
| return ( |
| status, |
| _timer_html(display_text), |
| _action_button_update(stage, doneness), |
| gr.update(interactive=not _controls_locked(stage)), |
| gr.update(interactive=not _controls_locked(stage)), |
| celebration_html, |
| ) |
|
|
|
|
| def preview_settings(egg_count: int, doneness: str | None, stage: str, running: bool, remaining_seconds: int): |
| total_seconds = calculate_cook_seconds(egg_count, doneness) if doneness else 0 |
|
|
| if stage == "idle": |
| status, timer_html, btn_action, egg_update, doneness_update, celebration = _render_screen( |
| stage="idle", |
| egg_count=egg_count, |
| doneness=doneness, |
| total_seconds=total_seconds, |
| remaining_seconds=total_seconds, |
| running=False, |
| celebration_html="", |
| ) |
| return ( |
| total_seconds, |
| total_seconds, |
| timer_html, |
| status, |
| btn_action, |
| egg_update, |
| doneness_update, |
| celebration, |
| ) |
|
|
| del running, remaining_seconds |
| return ( |
| total_seconds, |
| gr.update(), |
| gr.update(), |
| gr.update(), |
| gr.update(), |
| gr.update(interactive=not _controls_locked(stage)), |
| gr.update(interactive=not _controls_locked(stage)), |
| "", |
| ) |
|
|
|
|
| def handle_action(stage: str, egg_count: int, doneness: str | None, total_seconds: int): |
| if stage == "idle": |
| if not doneness: |
| return ( |
| stage, |
| total_seconds, |
| total_seconds, |
| False, |
| 0.0, |
| gr.update(active=False), |
| *_render_screen( |
| stage="idle", |
| egg_count=egg_count, |
| doneness=doneness, |
| total_seconds=0, |
| remaining_seconds=0, |
| running=False, |
| status_override="### 未可以開始\n請先揀熟度。", |
| celebration_html="", |
| ), |
| ) |
| new_total = calculate_cook_seconds(egg_count, doneness) |
| return ( |
| "boil", |
| new_total, |
| new_total, |
| False, |
| 0.0, |
| gr.update(active=False), |
| *_render_screen( |
| stage="boil", |
| egg_count=egg_count, |
| doneness=doneness, |
| total_seconds=new_total, |
| remaining_seconds=new_total, |
| running=False, |
| celebration_html="", |
| ), |
| ) |
|
|
| if stage == "boil": |
| return ( |
| "simmer", |
| total_seconds, |
| total_seconds, |
| False, |
| 0.0, |
| gr.update(active=False), |
| *_render_screen( |
| stage="simmer", |
| egg_count=egg_count, |
| doneness=doneness, |
| total_seconds=total_seconds, |
| remaining_seconds=total_seconds, |
| running=False, |
| celebration_html="", |
| ), |
| ) |
|
|
| if stage == "simmer": |
| return ( |
| "add_eggs", |
| total_seconds, |
| total_seconds, |
| False, |
| 0.0, |
| gr.update(active=False), |
| *_render_screen( |
| stage="add_eggs", |
| egg_count=egg_count, |
| doneness=doneness, |
| total_seconds=total_seconds, |
| remaining_seconds=total_seconds, |
| running=False, |
| celebration_html="", |
| ), |
| ) |
|
|
| if stage == "add_eggs": |
| end_timestamp = time.time() + total_seconds |
| return ( |
| "countdown", |
| total_seconds, |
| total_seconds, |
| True, |
| end_timestamp, |
| gr.update(active=True), |
| *_render_screen( |
| stage="countdown", |
| egg_count=egg_count, |
| doneness=doneness, |
| total_seconds=total_seconds, |
| remaining_seconds=total_seconds, |
| running=True, |
| celebration_html="", |
| ), |
| ) |
|
|
| return ( |
| stage, |
| total_seconds, |
| total_seconds, |
| False, |
| 0.0, |
| gr.update(active=False), |
| *_render_screen( |
| stage=stage, |
| egg_count=egg_count, |
| doneness=doneness, |
| total_seconds=total_seconds, |
| remaining_seconds=total_seconds, |
| running=False, |
| celebration_html="", |
| ), |
| ) |
|
|
|
|
| def tick( |
| stage: str, |
| running: bool, |
| end_timestamp: float, |
| remaining_seconds: int, |
| egg_count: int, |
| doneness: str | None, |
| total_seconds: int, |
| ): |
| if stage != "countdown" or not running: |
| return ( |
| stage, |
| remaining_seconds, |
| running, |
| gr.update(), |
| gr.update(), |
| gr.update(), |
| gr.update(), |
| gr.update(), |
| gr.update(), |
| gr.update(active=False), |
| ) |
|
|
| new_remaining = max(0, int(end_timestamp - time.time())) |
|
|
| if new_remaining > 0: |
| return ( |
| stage, |
| new_remaining, |
| True, |
| _timer_html(format_mm_ss(new_remaining)), |
| gr.update(), |
| gr.update(), |
| gr.update(), |
| gr.update(), |
| "", |
| gr.update(active=True), |
| ) |
|
|
| done_message = ( |
| "### 搞掂!\n" |
| f"你嘅 **{doneness}** 雞蛋已經完成。\n" |
| "建議即刻放入冰水 30-60 秒,會更易剝殼同保持口感。" |
| ) |
| status, timer_html, btn_action, egg_update, doneness_update, celebration = _render_screen( |
| stage="done", |
| egg_count=egg_count, |
| doneness=doneness, |
| total_seconds=total_seconds, |
| remaining_seconds=0, |
| running=False, |
| status_override=done_message, |
| celebration_html=_confetti_html(), |
| ) |
| return ( |
| "done", |
| 0, |
| False, |
| timer_html, |
| status, |
| btn_action, |
| egg_update, |
| doneness_update, |
| celebration, |
| gr.update(active=False), |
| ) |
|
|
|
|
| def reset_ui(): |
| stage = "idle" |
| total_seconds = 0 |
| remaining_seconds = 0 |
| return ( |
| stage, |
| total_seconds, |
| remaining_seconds, |
| False, |
| 0.0, |
| gr.update(active=False), |
| *_render_screen( |
| stage=stage, |
| egg_count=DEFAULT_EGG_COUNT, |
| doneness=None, |
| total_seconds=total_seconds, |
| remaining_seconds=remaining_seconds, |
| running=False, |
| celebration_html="", |
| ), |
| gr.update(value=DEFAULT_EGG_COUNT, interactive=True), |
| gr.update(value=None, interactive=True), |
| ) |
|
|
|
|
| with gr.Blocks(title="烚蛋計時小幫手") as demo: |
| gr.Markdown("# 烚蛋計時小幫手") |
| gr.Markdown("陪你一步一步煮出理想熟度,慢慢嚟,最緊要快。") |
|
|
| with gr.Row(): |
| egg_count = gr.Slider(minimum=1, maximum=12, step=1, value=DEFAULT_EGG_COUNT, label="雞蛋數量") |
| doneness = gr.Radio( |
| choices=list(DONENESS_BASE_SECONDS.keys()), |
| value=None, |
| label="熟度(必選)", |
| ) |
|
|
| stage_state = gr.State("idle") |
| total_state = gr.State(0) |
| remaining_state = gr.State(0) |
| running_state = gr.State(False) |
| end_ts_state = gr.State(0.0) |
|
|
| status_md = gr.Markdown(_build_status("idle", DEFAULT_EGG_COUNT, None, None)) |
| timer_display = gr.HTML(_timer_html(PLACEHOLDER_TIME)) |
| celebration_html = gr.HTML("") |
|
|
| with gr.Row(): |
| btn_action = gr.Button("請先選熟度", variant="primary", visible=True, interactive=False) |
| btn_reset = gr.Button("重新開始") |
|
|
| ticker = gr.Timer(value=1.0, active=False) |
|
|
| for input_component in (egg_count, doneness): |
| input_component.change( |
| fn=preview_settings, |
| inputs=[egg_count, doneness, stage_state, running_state, remaining_state], |
| outputs=[total_state, remaining_state, timer_display, status_md, btn_action, egg_count, doneness, celebration_html], |
| ) |
|
|
| btn_action.click( |
| fn=handle_action, |
| inputs=[stage_state, egg_count, doneness, total_state], |
| outputs=[ |
| stage_state, |
| total_state, |
| remaining_state, |
| running_state, |
| end_ts_state, |
| ticker, |
| status_md, |
| timer_display, |
| btn_action, |
| egg_count, |
| doneness, |
| celebration_html, |
| ], |
| ) |
|
|
| btn_reset.click( |
| fn=reset_ui, |
| inputs=[], |
| outputs=[ |
| stage_state, |
| total_state, |
| remaining_state, |
| running_state, |
| end_ts_state, |
| ticker, |
| status_md, |
| timer_display, |
| btn_action, |
| egg_count, |
| doneness, |
| celebration_html, |
| ], |
| ) |
|
|
| ticker.tick( |
| fn=tick, |
| inputs=[ |
| stage_state, |
| running_state, |
| end_ts_state, |
| remaining_state, |
| egg_count, |
| doneness, |
| total_state, |
| ], |
| outputs=[ |
| stage_state, |
| remaining_state, |
| running_state, |
| timer_display, |
| status_md, |
| btn_action, |
| egg_count, |
| doneness, |
| celebration_html, |
| ticker, |
| ], |
| ) |
|
|
|
|
| if __name__ == "__main__": |
| demo.launch(ssr_mode=False) |
|
|