iT邦幫忙

0

[Day29]多鬧鐘提醒器 GUI-進階版

  • 分享至 

  • xImage
  •  

延續 Day 28 的基礎版,今天把鬧鐘升級為可持久化+更貼日常的版本:

  • 每週重複(可勾選週一~週日)
  • 備註訊息(到點顯示:開會/喝水/吃藥…)
  • CSV 匯入/匯出(可備份與互相分享)
  • 仍然零相依:只用標準庫(Tkinter、datetime、csv、re)

今天要達成

  • 新增鬧鐘時可勾選「每週某些日子」才提醒;不勾=每日
  • 每筆鬧鐘可加備註,提醒時會一起顯示
  • 匯入/匯出 CSV:跨電腦搬移設定、備份還原超方便
  • 介面與行為仍「簡潔可預期」:Enter 新增、Delete 刪除、靜音開關、同分鐘只提醒一次

程式碼(存成 alarm_pro.py)

import tkinter as tk
from tkinter import ttk, messagebox, filedialog
from datetime import datetime
import csv, re

HHMM_RE = re.compile(r"^(?:[01]\d|2[0-3]):[0-5]\d$")
MUTED = False

# 鬧鐘結構:{"time":"HH:MM", "days": set[int 0..6] or None(每日), "note": str}
ALARMS: list[dict] = []
FIRED_TODAY: set[tuple[str, str]] = set()  # (time, note) 當天已觸發

def weekday_idx() -> int:
    # 週一=0, 週日=6
    return datetime.now().weekday()

def parse_days(s: str|None):
    if not s: return None
    # "135" -> {0,2,4}  (一三五)
    m = set()
    for ch in s:
        if ch.isdigit():
            d = int(ch)
            if 1 <= d <= 7: m.add((d-1) % 7)
    return m or None

def days_to_string(ds: set[int]|None):
    return "每日" if ds is None else "每週" + "".join("一二三四五六日"[d] for d in sorted(ds))

def add_alarm():
    hhmm = time_entry.get().strip()
    note = note_entry.get().strip()
    if not HHMM_RE.match(hhmm):
        messagebox.showerror("格式錯誤","請輸入 24 小時制時間,如 08:30"); return
    sel_days = {i for i, v in enumerate(day_vars) if v.get()}
    days = sel_days if sel_days else None
    # 去重:相同時間+相同 days+相同 note 視為重複
    for a in ALARMS:
        if a["time"]==hhmm and a["days"]==days and a["note"]==note:
            messagebox.showinfo("提示","相同鬧鐘已存在"); return
    ALARMS.append({"time": hhmm, "days": days, "note": note})
    refresh_list(); time_entry.delete(0, tk.END); note_entry.delete(0, tk.END)

def delete_selected():
    sel = listbox.curselection()
    if not sel: return
    idx = sel[0]; ALARMS.pop(idx); refresh_list()

def refresh_list():
    listbox.delete(0, tk.END)
    for a in sorted(ALARMS, key=lambda x: x["time"]):
        ds = days_to_string(a["days"])
        note = f"|{a['note']}" if a["note"] else ""
        listbox.insert(tk.END, f"{a['time']}  {ds}{note}")

def toggle_mute():
    global MUTED
    MUTED = not MUTED
    mute_btn.config(text=("解除靜音" if MUTED else "靜音"))

def tick_clock():
    clock_var.set(f"現在時間:{datetime.now().strftime('%Y-%m-%d %H:%M:%S')}")
    root.after(1000, tick_clock)

def check_loop():
    # 每秒檢查鬧鐘;跨日 00:00:00 清空 FIRED_TODAY
    now = datetime.now()
    if now.strftime("%H:%M:%S") == "00:00:00":
        FIRED_TODAY.clear()
    hhmm = now.strftime("%H:%M")
    wd = weekday_idx()
    for a in ALARMS:
        if a["time"] != hhmm: continue
        if a["days"] is not None and wd not in a["days"]: continue
        key = (a["time"], a["note"])
        if key in FIRED_TODAY: continue
        FIRED_TODAY.add(key); notify(a)
    root.after(1000, check_loop)

def notify(a: dict):
    if not MUTED:
        try: root.bell()
        except Exception: pass
    ds = days_to_string(a["days"])
    msg = f"{a['time']} 到點囉!\n{ds}"
    if a["note"]: msg += f"\n備註:{a['note']}"
    messagebox.showinfo("時間到!", msg)

def export_csv():
    path = filedialog.asksaveasfilename(defaultextension=".csv",
                                        filetypes=[("CSV","*.csv")],
                                        title="匯出鬧鐘")
    if not path: return
    with open(path, "w", encoding="utf-8", newline="") as f:
        w = csv.writer(f); w.writerow(["time", "days", "note"])
        for a in ALARMS:
            days = "" if a["days"] is None else "".join(str(d+1) for d in sorted(a["days"]))
            w.writerow([a["time"], days, a["note"]])
    messagebox.showinfo("完成", f"已匯出 {len(ALARMS)} 筆至:\n{path}")

def import_csv():
    path = filedialog.askopenfilename(filetypes=[("CSV","*.csv"), ("All","*.*")], title="匯入鬧鐘")
    if not path: return
    count = 0
    try:
        with open(path, "r", encoding="utf-8-sig", newline="") as f:
            rdr = csv.DictReader(f)
            for row in rdr:
                t = (row.get("time") or "").strip()
                ds = parse_days((row.get("days") or "").strip())
                note = (row.get("note") or "").strip()
                if not HHMM_RE.match(t): continue
                # 去重
                dup = any(a["time"]==t and a["days"]==ds and a["note"]==note for a in ALARMS)
                if not dup:
                    ALARMS.append({"time": t, "days": ds, "note": note}); count += 1
    except Exception as e:
        messagebox.showerror("匯入失敗", str(e)); return
    refresh_list(); messagebox.showinfo("完成", f"匯入 {count} 筆")

# ----- GUI -----
root = tk.Tk(); root.title("多鬧鐘(進階版)")
main = ttk.Frame(root, padding=16); main.grid(sticky="nsew")
root.columnconfigure(0, weight=1); root.rowconfigure(0, weight=1)
main.columnconfigure(0, weight=3); main.columnconfigure(1, weight=2)
main.rowconfigure(3, weight=1)

# 輸入區
inp = ttk.Frame(main); inp.grid(row=0, column=0, sticky="we", padx=(0,12))
ttk.Label(inp, text="時間 HH:MM").grid(row=0, column=0, padx=(0,8))
time_entry = ttk.Entry(inp, width=8, justify="center"); time_entry.grid(row=0, column=1)
ttk.Label(inp, text="備註").grid(row=0, column=2, padx=(12,6))
note_entry = ttk.Entry(inp, width=24); note_entry.grid(row=0, column=3)

# 每週選擇
days_frame = ttk.Frame(main); days_frame.grid(row=1, column=0, sticky="w", padx=(0,12))
ttk.Label(days_frame, text="重複:").grid(row=0, column=0)
day_vars = [tk.BooleanVar() for _ in range(7)]
for i, ch in enumerate("一二三四五六日"):
    ttk.Checkbutton(days_frame, text=ch, variable=day_vars[i]).grid(row=0, column=i+1, padx=2)

btns = ttk.Frame(main); btns.grid(row=2, column=0, sticky="w", padx=(0,12), pady=(6,0))
ttk.Button(btns, text="加入", command=add_alarm).grid(row=0, column=0, padx=4)
mute_btn = ttk.Button(btns, text="靜音", command=toggle_mute); mute_btn.grid(row=0, column=1, padx=4)
ttk.Button(btns, text="匯入", command=import_csv).grid(row=0, column=2, padx=4)
ttk.Button(btns, text="匯出", command=export_csv).grid(row=0, column=3, padx=4)

# 清單 + 時鐘
clock_var = tk.StringVar(value=datetime.now().strftime("%Y-%m-%d %H:%M:%S"))
ttk.Label(main, textvariable=clock_var).grid(row=0, column=1, sticky="e")

listbox = tk.Listbox(main, height=12)
listbox.grid(row=1, column=1, rowspan=3, sticky="nsew")
scroll = ttk.Scrollbar(main, orient="vertical", command=listbox.yview)
scroll.grid(row=1, column=2, rowspan=3, sticky="ns")
listbox.configure(yscrollcommand=scroll.set)

ops = ttk.Frame(main); ops.grid(row=3, column=0, sticky="we", pady=(8,0))
ttk.Button(ops, text="刪除選取", command=delete_selected).grid(row=0, column=0, padx=4)
ttk.Button(ops, text="離開", command=root.destroy).grid(row=0, column=1, padx=4)

# 快捷鍵
root.bind("<Return>", lambda e: add_alarm())
root.bind("<Delete>", lambda e: delete_selected())

# 啟動
tick_clock(); check_loop(); time_entry.focus()
root.mainloop()

使用方式

python alarm_pro.py

操作範例

  1. 在「時間」輸入 08:30,備註填「早會」,勾選「一二三四五」→ 按「加入」。
  2. 再新增 12:00(每日),備註「休息」。
  3. 到點會彈窗提示;若在會議中先按「靜音」。
  4. 想備份:按「匯出」存成 alarms.csv;換機器後按「匯入」即可載入。

實作:
https://ithelp.ithome.com.tw/upload/images/20251022/20169368gv9Vpow3d3.png

常見問題(FAQ)
1.為什麼同分鐘只提醒一次?
使用 FIRED_TODAY 記錄((time, note)),避免「剛好切到秒」連跳;到隔天 00:00 自動清空。
2.想週期更細(隔天、每兩週)?
可把 days 改為更通用的 RRULE 設計;但對日常使用,週一~週日已覆蓋 90% 場景。
3.想常駐系統匣/自訂鈴聲?
可結合 winsound(Windows)或播放簡短 WAV 檔、搭配 pystray 做托盤圖示(屬進階內容)。
4.鬧鐘會存檔嗎?
是的,透過 CSV 匯入/匯出就能保存與移轉設定;若要「自動重載」,可以在程式啟動時檢查 alarms.csv 並自動 import_csv()。

今日小結

  • 在基礎版之上,加入每週重複、備註、匯入/匯出,讓鬧鐘真正好用。
  • 核心結構清晰:time + days + note、FIRED_TODAY 防重、after 輕量檢查。
    明天是最後一天啦~~

圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言