persian-clock-widget 0.0.0-stage → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1314 @@
1
+ # -*- coding: utf-8 -*-
2
+ """
3
+ سیستم مدیریت رویدادها، یادآورها و آلارم‌های هوشمند
4
+ ویژگی‌ها:
5
+ - انتخابگر تاریخ به صورت تقویم گرافیکی تعاملی با قابلیت انتخاب یک یا چند روز خاص (Multi-Select Calendar)
6
+ - حذف خودکار آلارم‌های تاریخ‌دار پس از گذشت زمان آن‌ها (در صورتی که Snooze / به تاخیر نیفتاده باشند)
7
+ - ۶ صدای آلارم MP3 داخلی با پیش‌فرض heeeeyyyyy.mp3
8
+ - پاپ‌آپ زنگوله طلایی متحرک در مرکز مانیتور
9
+ طراحی و توسعه: شایان بامیار (® Shayan Bamyar)
10
+ """
11
+
12
+ import json
13
+ import math
14
+ import os
15
+ import tkinter as tk
16
+ from tkinter import ttk
17
+ import uuid
18
+
19
+ try:
20
+ from src.calendar_data import (
21
+ PERSIAN_MONTHS,
22
+ PERSIAN_WEEK_SHORT,
23
+ get_month_calendar,
24
+ get_today_info,
25
+ )
26
+ from src.win_utils import (
27
+ DEFAULT_ALARM_SOUND,
28
+ apply_rounded_corners,
29
+ get_available_alarm_sounds,
30
+ get_resource_path,
31
+ hide_from_taskbar,
32
+ play_mp3_sound,
33
+ stop_mp3_sound,
34
+ )
35
+ except ImportError:
36
+ from calendar_data import (
37
+ PERSIAN_MONTHS,
38
+ PERSIAN_WEEK_SHORT,
39
+ get_month_calendar,
40
+ get_today_info,
41
+ )
42
+ from win_utils import (
43
+ DEFAULT_ALARM_SOUND,
44
+ apply_rounded_corners,
45
+ get_available_alarm_sounds,
46
+ get_resource_path,
47
+ hide_from_taskbar,
48
+ play_mp3_sound,
49
+ stop_mp3_sound,
50
+ )
51
+
52
+ PERSIAN_WEEKDAY_NAMES = [
53
+ "شنبه",
54
+ "یک‌شنبه",
55
+ "دوشنبه",
56
+ "سه‌شنبه",
57
+ "چهارشنبه",
58
+ "پنج‌شنبه",
59
+ "جمعه",
60
+ ]
61
+
62
+
63
+ def draw_outlined_canvas_text(
64
+ canvas: tk.Canvas,
65
+ x: int,
66
+ y: int,
67
+ text: str,
68
+ font_spec: tuple,
69
+ fill: str,
70
+ outline: str = "#000000",
71
+ anchor: str = "center",
72
+ justify: str = "center",
73
+ width: int = 0,
74
+ tags: str | tuple = (),
75
+ ):
76
+ """رسم متن با خط دور مشکی (Stroke) روی بوم Canvas برای خوانایی عالی روی هر پس‌زمینه"""
77
+ offsets = [(-1, -1), (0, -1), (1, -1), (-1, 0), (1, 0), (-1, 1), (0, 1), (1, 1)]
78
+ for dx, dy in offsets:
79
+ canvas.create_text(
80
+ x + dx,
81
+ y + dy,
82
+ text=text,
83
+ font=font_spec,
84
+ fill=outline,
85
+ anchor=anchor,
86
+ justify=justify,
87
+ width=width,
88
+ tags=tags,
89
+ )
90
+ return canvas.create_text(
91
+ x,
92
+ y,
93
+ text=text,
94
+ font=font_spec,
95
+ fill=fill,
96
+ anchor=anchor,
97
+ justify=justify,
98
+ width=width,
99
+ tags=tags,
100
+ )
101
+
102
+
103
+ def _normalize_alarm_dates(alarm: dict) -> list[str]:
104
+ """استخراج لیست تاریخ‌های شمسی (YYYY/MM/DD) از یک آلارم"""
105
+ dates = alarm.get("dates")
106
+ if isinstance(dates, list) and dates:
107
+ return sorted(set(str(d) for d in dates if d))
108
+ jy = alarm.get("jy")
109
+ jm = alarm.get("jm")
110
+ jd = alarm.get("jd")
111
+ if jy and jm and jd:
112
+ return [f"{int(jy):04d}/{int(jm):02d}/{int(jd):02d}"]
113
+ return []
114
+
115
+
116
+ class AlarmStore:
117
+ """
118
+ ذخیره، بارگذاری و بررسی لحظه‌ای آلارم‌ها و رویدادها
119
+ همراه با حذف خودکار آلارم‌های تاریخ‌دار پس از گذشت زمان آن‌ها (در صورت عدم Snooze)
120
+ """
121
+
122
+ def __init__(self, alarms_path: str):
123
+ self.alarms_path = alarms_path
124
+ self.alarms: list[dict] = []
125
+ self.snoozed: list[dict] = [] # لیست آلارم‌های به تاخیر افتاده (Snoozed)
126
+ self.active_ringing_ids: set[str] = set() # آلارم‌هایی که هم‌اکنون پنجره زنگوله آن‌ها باز است
127
+ self.load()
128
+
129
+ def load(self):
130
+ if os.path.exists(self.alarms_path):
131
+ try:
132
+ with open(self.alarms_path, "r", encoding="utf-8") as f:
133
+ data = json.load(f)
134
+ if isinstance(data, list):
135
+ self.alarms = data
136
+ except Exception:
137
+ self.alarms = []
138
+ else:
139
+ self.alarms = []
140
+
141
+ def save(self):
142
+ try:
143
+ os.makedirs(os.path.dirname(self.alarms_path), exist_ok=True)
144
+ with open(self.alarms_path, "w", encoding="utf-8") as f:
145
+ json.dump(self.alarms, f, ensure_ascii=False, indent=2)
146
+ except Exception:
147
+ pass
148
+
149
+ def add_or_update(self, alarm_data: dict):
150
+ if not alarm_data.get("sound_file"):
151
+ alarm_data["sound_file"] = DEFAULT_ALARM_SOUND
152
+ aid = alarm_data.get("id")
153
+ if aid:
154
+ for idx, item in enumerate(self.alarms):
155
+ if item.get("id") == aid:
156
+ self.alarms[idx] = alarm_data
157
+ self.save()
158
+ return
159
+ else:
160
+ alarm_data["id"] = str(uuid.uuid4())[:8]
161
+ self.alarms.append(alarm_data)
162
+ self.save()
163
+
164
+ def delete(self, alarm_id: str):
165
+ self.alarms = [a for a in self.alarms if a.get("id") != alarm_id]
166
+ self.snoozed = [s for s in self.snoozed if s.get("alarm", {}).get("id") != alarm_id]
167
+ self.save()
168
+
169
+ def get_events_for_jalali_day(self, jy: int, jm: int, jd: int, persian_week_idx: int) -> list[dict]:
170
+ """دریافت رویدادها و آلارم‌های مربوط به یک روز خاص برای نمایش در تقویم"""
171
+ target_date_str = f"{jy:04d}/{jm:02d}/{jd:02d}"
172
+ matched = []
173
+ for a in self.alarms:
174
+ if not a.get("enabled", True):
175
+ continue
176
+ mode = a.get("repeat_mode", "once")
177
+ if mode in ("once", "dates"):
178
+ if target_date_str in _normalize_alarm_dates(a):
179
+ matched.append(a)
180
+ elif mode == "weekly":
181
+ if persian_week_idx in a.get("weekdays", []):
182
+ matched.append(a)
183
+ elif mode == "daily":
184
+ matched.append(a)
185
+ elif mode == "yearly":
186
+ if f"{jm:02d}-{jd:02d}" in a.get("yearly_days", []):
187
+ matched.append(a)
188
+ return matched
189
+
190
+ def cleanup_expired_alarms(self, today_info: dict) -> bool:
191
+ """
192
+ حذف خودکار آلارم‌های تاریخ‌دار (once / dates) که زمان آن‌ها گذشته است
193
+ و در لیست Snooze یا در حال زنگ زدن نیستند.
194
+ """
195
+ now_date_str = f"{today_info['jy']:04d}/{today_info['jm']:02d}/{today_info['jd']:02d}"
196
+ now_time_tuple = (today_info["hour"], today_info["minute"])
197
+
198
+ snoozed_ids = {s.get("alarm", {}).get("id") for s in self.snoozed}
199
+ new_alarms = []
200
+ changed = False
201
+
202
+ for a in self.alarms:
203
+ aid = a.get("id")
204
+ mode = a.get("repeat_mode", "once")
205
+ if mode in ("once", "dates"):
206
+ if aid in snoozed_ids or aid in self.active_ringing_ids:
207
+ new_alarms.append(a)
208
+ continue
209
+
210
+ ah = int(a.get("hour", 0))
211
+ am = int(a.get("minute", 0))
212
+ remaining_dates = []
213
+ for d_str in _normalize_alarm_dates(a):
214
+ if d_str > now_date_str:
215
+ remaining_dates.append(d_str)
216
+ elif d_str == now_date_str and (ah, am) >= now_time_tuple:
217
+ # اگر در همین دقیقه فعلی است ولی هنوز اجرا نشده، نگه دار تا زنگ بزند
218
+ if a.get("last_triggered") != f"{now_date_str} {ah:02d}:{am:02d}":
219
+ remaining_dates.append(d_str)
220
+
221
+ if remaining_dates:
222
+ if remaining_dates != a.get("dates"):
223
+ a["dates"] = remaining_dates
224
+ parts = remaining_dates[0].split("/")
225
+ a["jy"], a["jm"], a["jd"] = int(parts[0]), int(parts[1]), int(parts[2])
226
+ changed = True
227
+ new_alarms.append(a)
228
+ else:
229
+ # تمام تاریخ‌های این آلارم گذشته و به تاخیر هم نیفتاده -> حذف کامل از لیست
230
+ changed = True
231
+ else:
232
+ new_alarms.append(a)
233
+
234
+ if changed:
235
+ self.alarms = new_alarms
236
+ self.save()
237
+ return changed
238
+
239
+ def check_due_alarms(self, today_info: dict) -> list[dict]:
240
+ """بررسی آلارم‌هایی که در دقیقه فعلی به وقت تهران باید زنگ بزنند"""
241
+ now_h = today_info["hour"]
242
+ now_m = today_info["minute"]
243
+ jy, jm, jd = today_info["jy"], today_info["jm"], today_info["jd"]
244
+ p_week_idx = today_info["persian_week_idx"]
245
+ today_str = f"{jy:04d}/{jm:02d}/{jd:02d}"
246
+ stamp_key = f"{today_str} {now_h:02d}:{now_m:02d}"
247
+
248
+ triggered = []
249
+
250
+ # 1. بررسی Snoozeها
251
+ remaining_snoozed = []
252
+ for s in self.snoozed:
253
+ if s.get("target_h") == now_h and s.get("target_m") == now_m and s.get("stamp") != stamp_key:
254
+ s["stamp"] = stamp_key
255
+ triggered.append(s["alarm"])
256
+ else:
257
+ remaining_snoozed.append(s)
258
+ self.snoozed = remaining_snoozed
259
+
260
+ # 2. بررسی آلارم‌های اصلی
261
+ changed = False
262
+ for a in self.alarms:
263
+ if not a.get("enabled", True):
264
+ continue
265
+ if int(a.get("hour", -1)) != now_h or int(a.get("minute", -1)) != now_m:
266
+ continue
267
+ if a.get("last_triggered") == stamp_key:
268
+ continue
269
+
270
+ mode = a.get("repeat_mode", "once")
271
+ is_match = False
272
+ if mode == "daily":
273
+ is_match = True
274
+ elif mode == "weekly":
275
+ if p_week_idx in a.get("weekdays", []):
276
+ is_match = True
277
+ elif mode == "yearly":
278
+ if f"{jm:02d}-{jd:02d}" in a.get("yearly_days", []):
279
+ is_match = True
280
+ elif mode in ("once", "dates"):
281
+ if today_str in _normalize_alarm_dates(a):
282
+ is_match = True
283
+
284
+ if is_match:
285
+ a["last_triggered"] = stamp_key
286
+ if a.get("id"):
287
+ self.active_ringing_ids.add(a["id"])
288
+ changed = True
289
+ triggered.append(a)
290
+
291
+ if changed:
292
+ self.save()
293
+
294
+ # پاکسازی آلارم‌های تاریخ‌گذشته که در حال زنگ زدن یا Snooze نیستند
295
+ self.cleanup_expired_alarms(today_info)
296
+ return triggered
297
+
298
+ def on_alarm_dismissed(self, alarm: dict):
299
+ """وقتی کاربر آلارم را بدون Snooze می‌بندد، اگر تاریخ‌دار است و تاریخی باقی نمانده از لیست حذف شود"""
300
+ aid = alarm.get("id")
301
+ if aid in self.active_ringing_ids:
302
+ self.active_ringing_ids.discard(aid)
303
+ self.cleanup_expired_alarms(get_today_info())
304
+
305
+ def add_snooze(self, alarm: dict, minutes_later: int = 5):
306
+ aid = alarm.get("id")
307
+ if aid in self.active_ringing_ids:
308
+ self.active_ringing_ids.discard(aid)
309
+ info = get_today_info()
310
+ total_m = info["hour"] * 60 + info["minute"] + minutes_later
311
+ th = (total_m // 60) % 24
312
+ tm = total_m % 60
313
+ self.snoozed.append({
314
+ "alarm": alarm,
315
+ "target_h": th,
316
+ "target_m": tm,
317
+ "stamp": "",
318
+ })
319
+
320
+
321
+ class AnimatedBellPopup:
322
+ """
323
+ پنجره هشدار دارک در مرکز مانیتور همراه با زنگوله طلایی متحرک (که تکان می‌خورد و زنگ می‌زند)
324
+ و پخش فایل صوتی MP3 انتخاب‌شده (پیش‌فرض: heeeeyyyyy.mp3)
325
+ """
326
+
327
+ def __init__(self, root: tk.Tk, alarm: dict, font_family: str, on_snooze=None, on_dismiss=None):
328
+ self.root = root
329
+ self.alarm = alarm
330
+ self.font_family = font_family
331
+ self.on_snooze = on_snooze
332
+ self.on_dismiss = on_dismiss
333
+ self._running = True
334
+ self._sound_running = bool(alarm.get("play_sound", True))
335
+ self._sound_file = alarm.get("sound_file", DEFAULT_ALARM_SOUND) or DEFAULT_ALARM_SOUND
336
+ self._step = 0
337
+
338
+ self.win = tk.Toplevel(root)
339
+ self.win.title("هشدار یادآور و آلارم")
340
+ self.win.overrideredirect(True)
341
+ self.win.configure(bg="#0F172A")
342
+ self.win.attributes("-topmost", True)
343
+
344
+ w, h = 410, 320
345
+ sx = (self.win.winfo_screenwidth() - w) // 2
346
+ sy = (self.win.winfo_screenheight() - h) // 2
347
+ self.win.geometry(f"{w}x{h}+{sx}+{sy}")
348
+
349
+ try:
350
+ hide_from_taskbar(self.win)
351
+ apply_rounded_corners(self.win, w, h, radius=20, enabled=True)
352
+ except Exception:
353
+ pass
354
+
355
+ outer = tk.Frame(
356
+ self.win,
357
+ bg="#0F172A",
358
+ highlightbackground="#F59E0B",
359
+ highlightthickness=2,
360
+ padx=18,
361
+ pady=14,
362
+ )
363
+ outer.pack(fill="both", expand=True)
364
+
365
+ self.canvas = tk.Canvas(
366
+ outer,
367
+ width=370,
368
+ height=225,
369
+ bg="#0F172A",
370
+ highlightthickness=0,
371
+ )
372
+ self.canvas.pack(fill="x")
373
+
374
+ time_str = f"{int(alarm.get('hour', 0)):02d}:{int(alarm.get('minute', 0)):02d}"
375
+ title_str = alarm.get("title", "").strip() or "زمان یادآور فرا رسیده است!"
376
+
377
+ draw_outlined_canvas_text(
378
+ self.canvas,
379
+ 185,
380
+ 122,
381
+ text=f"⏰ {time_str}",
382
+ font_spec=(self.font_family, 20, "bold"),
383
+ fill="#FBBF24",
384
+ outline="#000000",
385
+ )
386
+
387
+ draw_outlined_canvas_text(
388
+ self.canvas,
389
+ 185,
390
+ 168,
391
+ text=title_str,
392
+ font_spec=(self.font_family, 12, "bold"),
393
+ fill="#F8FAFC",
394
+ outline="#000000",
395
+ width=340,
396
+ )
397
+
398
+ draw_outlined_canvas_text(
399
+ self.canvas,
400
+ 185,
401
+ 206,
402
+ text=f"🎵 {self._sound_file}",
403
+ font_spec=("Segoe UI", 8),
404
+ fill="#38BDF8",
405
+ outline="#000000",
406
+ )
407
+
408
+ btn_row = tk.Frame(outer, bg="#0F172A")
409
+ btn_row.pack(fill="x", pady=(8, 0))
410
+
411
+ dismiss_btn = tk.Button(
412
+ btn_row,
413
+ text="✓ متوجه شدم (قطع آلارم)",
414
+ font=(self.font_family, 10, "bold"),
415
+ bg="#10B981",
416
+ fg="#FFFFFF",
417
+ activebackground="#059669",
418
+ activeforeground="#FFFFFF",
419
+ relief="flat",
420
+ padx=16,
421
+ pady=6,
422
+ cursor="hand2",
423
+ command=self._dismiss,
424
+ )
425
+ dismiss_btn.pack(side="right", expand=True, fill="x", padx=(4, 0))
426
+
427
+ snooze_btn = tk.Button(
428
+ btn_row,
429
+ text="💤 ۵ دقیقه دیگر (تاخیر)",
430
+ font=(self.font_family, 9, "bold"),
431
+ bg="#334155",
432
+ fg="#E2E8F0",
433
+ activebackground="#475569",
434
+ activeforeground="#FFFFFF",
435
+ relief="flat",
436
+ padx=12,
437
+ pady=6,
438
+ cursor="hand2",
439
+ command=self._snooze,
440
+ )
441
+ snooze_btn.pack(side="left", padx=(0, 4))
442
+
443
+ self._animate_bell()
444
+ if self._sound_running:
445
+ play_mp3_sound(self._sound_file, repeat=True)
446
+ self.win.after(60000, stop_mp3_sound)
447
+
448
+ def _animate_bell(self):
449
+ if not self._running or not self.win.winfo_exists():
450
+ return
451
+
452
+ self.canvas.delete("bell_anim")
453
+ cx, cy = 185, 52
454
+ angle = math.sin(self._step * 0.45) * 0.42
455
+
456
+ def rot(px, py):
457
+ dx, dy = px - cx, py - (cy - 22)
458
+ rx = dx * math.cos(angle) - dy * math.sin(angle)
459
+ ry = dx * math.sin(angle) + dy * math.cos(angle)
460
+ return cx + rx, (cy - 22) + ry
461
+
462
+ wave_alpha = (self._step % 10) < 6
463
+ if wave_alpha:
464
+ for offset_r in (38, 48):
465
+ self.canvas.create_arc(
466
+ cx - offset_r, cy - offset_r, cx + offset_r, cy + offset_r,
467
+ start=135, extent=90, style="arc",
468
+ outline="#38BDF8", width=2.5, tags="bell_anim"
469
+ )
470
+ self.canvas.create_arc(
471
+ cx - offset_r, cy - offset_r, cx + offset_r, cy + offset_r,
472
+ start=-45, extent=90, style="arc",
473
+ outline="#38BDF8", width=2.5, tags="bell_anim"
474
+ )
475
+
476
+ bell_pts_local = [
477
+ (cx - 8, cy - 22),
478
+ (cx + 8, cy - 22),
479
+ (cx + 22, cy + 8),
480
+ (cx + 28, cy + 16),
481
+ (cx - 28, cy + 16),
482
+ (cx - 22, cy + 8),
483
+ ]
484
+ rotated_pts = []
485
+ for px, py in bell_pts_local:
486
+ rx, ry = rot(px, py)
487
+ rotated_pts.extend([rx, ry])
488
+
489
+ hx, hy = rot(cx, cy - 26)
490
+ self.canvas.create_oval(
491
+ hx - 6, hy - 6, hx + 6, hy + 6,
492
+ fill="#F59E0B", outline="#000000", width=2, tags="bell_anim"
493
+ )
494
+
495
+ clapper_x, clapper_y = rot(cx + math.sin(self._step * 0.45) * 8, cy + 20)
496
+ self.canvas.create_oval(
497
+ clapper_x - 7, clapper_y - 7, clapper_x + 7, clapper_y + 7,
498
+ fill="#D97706", outline="#000000", width=2, tags="bell_anim"
499
+ )
500
+
501
+ self.canvas.create_polygon(
502
+ rotated_pts,
503
+ fill="#FBBF24",
504
+ outline="#000000",
505
+ width=2.5,
506
+ smooth=True,
507
+ tags="bell_anim",
508
+ )
509
+
510
+ self._step += 1
511
+ self.win.after(35, self._animate_bell)
512
+
513
+ def _dismiss(self):
514
+ if self.on_dismiss:
515
+ self.on_dismiss(self.alarm)
516
+ self.close()
517
+
518
+ def _snooze(self):
519
+ if self.on_snooze:
520
+ self.on_snooze(self.alarm)
521
+ self.close()
522
+
523
+ def close(self):
524
+ self._running = False
525
+ self._sound_running = False
526
+ stop_mp3_sound()
527
+ try:
528
+ if self.win and self.win.winfo_exists():
529
+ self.win.destroy()
530
+ except Exception:
531
+ pass
532
+
533
+
534
+ def format_alarm_schedule_summary(alarm: dict) -> str:
535
+ mode = alarm.get("repeat_mode", "once")
536
+ if mode == "daily":
537
+ return "هر روز"
538
+ elif mode == "yearly":
539
+ ydays = alarm.get("yearly_days", [])
540
+ if not ydays:
541
+ return "سالانه"
542
+ parts_txt = []
543
+ for mmdd in ydays:
544
+ try:
545
+ m_s, d_s = mmdd.split("-")
546
+ m_i, d_i = int(m_s), int(d_s)
547
+ m_name = PERSIAN_MONTHS[m_i] if 1 <= m_i <= 12 else str(m_i)
548
+ parts_txt.append(f"{d_i} {m_name}")
549
+ except Exception:
550
+ parts_txt.append(str(mmdd))
551
+ return "هر سال (" + "، ".join(parts_txt) + ")"
552
+ elif mode == "weekly":
553
+ days = alarm.get("weekdays", [])
554
+ if not days:
555
+ return "هفتگی"
556
+ names = [f"هر {PERSIAN_WEEKDAY_NAMES[d]}" for d in sorted(days) if 0 <= d < 7]
557
+ return "، ".join(names)
558
+ else:
559
+ dates = _normalize_alarm_dates(alarm)
560
+ if not dates:
561
+ return "بدون تاریخ"
562
+ if len(dates) == 1:
563
+ parts = dates[0].split("/")
564
+ jy, jm, jd = int(parts[0]), int(parts[1]), int(parts[2])
565
+ m_name = PERSIAN_MONTHS[jm] if 1 <= jm <= 12 else str(jm)
566
+ return f"{jd} {m_name} {jy}"
567
+ return f"{len(dates)} تاریخ انتخاب‌شده ({'، '.join(dates[:3])}{'...' if len(dates) > 3 else ''})"
568
+
569
+
570
+ class AlarmManagerWindow:
571
+ """
572
+ پنجره گرافیکی حرفه‌ای برای افزودن، ویرایش، انتخاب صدای آلارم MP3 و حذف رویدادها
573
+ همراه با تقویم گرافیکی تعاملی جهت انتخاب یک یا چند روز خاص
574
+ """
575
+
576
+ def __init__(
577
+ self,
578
+ root: tk.Tk,
579
+ store: AlarmStore,
580
+ font_family: str,
581
+ initial_jalali: tuple[int, int, int],
582
+ on_change_callback=None,
583
+ ):
584
+ self.root = root
585
+ self.store = store
586
+ self.font_family = font_family
587
+ self.initial_jy, self.initial_jm, self.initial_jd = initial_jalali
588
+ self.on_change_callback = on_change_callback
589
+ self.editing_id: str | None = None
590
+
591
+ # وضعیت تقویم تعاملی داخل فرم رویدادها (قابلیت انتخاب چند روز خاص)
592
+ self.cal_jy = self.initial_jy
593
+ self.cal_jm = self.initial_jm
594
+ self.selected_dates: set[str] = {
595
+ f"{self.initial_jy:04d}/{self.initial_jm:02d}/{self.initial_jd:02d}"
596
+ }
597
+
598
+ self.win = tk.Toplevel(root)
599
+ self.win.title("مدیریت رویدادها و آلارم‌ها")
600
+ self.win.configure(bg="#0F172A")
601
+ self.win.resizable(False, False)
602
+ self.win.attributes("-topmost", True)
603
+
604
+ ico_path = get_resource_path(os.path.join("assets", "icons", "app_icon.ico"))
605
+ if os.path.exists(ico_path):
606
+ try:
607
+ self.win.iconbitmap(ico_path)
608
+ except Exception:
609
+ pass
610
+
611
+ w, h = 560, 760
612
+ sx = (self.win.winfo_screenwidth() - w) // 2
613
+ sy = max(10, (self.win.winfo_screenheight() - h) // 2)
614
+ self.win.geometry(f"{w}x{h}+{sx}+{sy}")
615
+
616
+ self.win.protocol("WM_DELETE_WINDOW", self._on_close)
617
+ self._build_ui()
618
+ self._refresh_list()
619
+
620
+ def _on_close(self):
621
+ stop_mp3_sound()
622
+ self.win.destroy()
623
+
624
+ def _build_ui(self):
625
+ main = tk.Frame(self.win, bg="#0F172A", padx=16, pady=12)
626
+ main.pack(fill="both", expand=True)
627
+
628
+ tk.Label(
629
+ main,
630
+ text="⏰ مدیریت رویدادها، یادآورها و آلارم‌ها",
631
+ font=(self.font_family, 12, "bold"),
632
+ fg="#38BDF8",
633
+ bg="#0F172A",
634
+ anchor="e",
635
+ ).pack(fill="x", pady=(0, 6))
636
+
637
+ self.form_frame = tk.LabelFrame(
638
+ main,
639
+ text=" ➕ ثبت رویداد / آلارم جدید ",
640
+ font=(self.font_family, 9, "bold"),
641
+ fg="#E2E8F0",
642
+ bg="#1E293B",
643
+ bd=1,
644
+ padx=12,
645
+ pady=8,
646
+ )
647
+ self.form_frame.pack(fill="x", pady=(0, 8))
648
+
649
+ # 1. عنوان و متن رویداد
650
+ row1 = tk.Frame(self.form_frame, bg="#1E293B")
651
+ row1.pack(fill="x", pady=2)
652
+ tk.Label(
653
+ row1,
654
+ text="متن اعلان / عنوان رویداد:",
655
+ font=(self.font_family, 9),
656
+ fg="#CBD5E1",
657
+ bg="#1E293B",
658
+ ).pack(side="right", padx=(6, 0))
659
+
660
+ self.title_entry = tk.Entry(
661
+ row1,
662
+ font=(self.font_family, 9),
663
+ bg="#0F172A",
664
+ fg="#F8FAFC",
665
+ insertbackground="#38BDF8",
666
+ justify="right",
667
+ relief="flat",
668
+ )
669
+ self.title_entry.pack(side="left", fill="x", expand=True, ipady=4)
670
+ self.title_entry.insert(0, "یادآوری مهم")
671
+
672
+ # 2. ساعت و دقیقه به وقت تهران
673
+ row2 = tk.Frame(self.form_frame, bg="#1E293B")
674
+ row2.pack(fill="x", pady=4)
675
+ tk.Label(
676
+ row2,
677
+ text="ساعت هشدار (به وقت تهران):",
678
+ font=(self.font_family, 9),
679
+ fg="#CBD5E1",
680
+ bg="#1E293B",
681
+ ).pack(side="right", padx=(6, 0))
682
+
683
+ now_info = get_today_info()
684
+ time_box = tk.Frame(row2, bg="#1E293B")
685
+ time_box.pack(side="left")
686
+
687
+ self.hour_spin = tk.Spinbox(
688
+ time_box,
689
+ from_=0,
690
+ to=23,
691
+ format="%02.0f",
692
+ width=4,
693
+ font=("Segoe UI", 10, "bold"),
694
+ bg="#0F172A",
695
+ fg="#38BDF8",
696
+ buttonbackground="#334155",
697
+ justify="center",
698
+ )
699
+ self.hour_spin.delete(0, "end")
700
+ self.hour_spin.insert(0, f"{now_info['hour']:02d}")
701
+ self.hour_spin.pack(side="left")
702
+
703
+ tk.Label(time_box, text=" : ", font=("Segoe UI", 10, "bold"), fg="#F8FAFC", bg="#1E293B").pack(side="left")
704
+
705
+ self.min_spin = tk.Spinbox(
706
+ time_box,
707
+ from_=0,
708
+ to=59,
709
+ format="%02.0f",
710
+ width=4,
711
+ font=("Segoe UI", 10, "bold"),
712
+ bg="#0F172A",
713
+ fg="#38BDF8",
714
+ buttonbackground="#334155",
715
+ justify="center",
716
+ )
717
+ self.min_spin.delete(0, "end")
718
+ self.min_spin.insert(0, f"{(now_info['minute'] + 1) % 60:02d}")
719
+ self.min_spin.pack(side="left")
720
+
721
+ # 3. نوع تکرار (انتخاب از روی تقویم / هر روز / روزهای هفته)
722
+ row3 = tk.Frame(self.form_frame, bg="#1E293B")
723
+ row3.pack(fill="x", pady=3)
724
+ tk.Label(
725
+ row3,
726
+ text="نوع زمان‌بندی:",
727
+ font=(self.font_family, 9),
728
+ fg="#CBD5E1",
729
+ bg="#1E293B",
730
+ ).pack(side="right")
731
+
732
+ self.repeat_var = tk.StringVar(value="dates")
733
+ for val, lbl_t in [
734
+ ("dates", "📅 انتخاب روز(ها) از تقویم"),
735
+ ("daily", "🔄 هر روز"),
736
+ ("weekly", "📆 روزهای خاص هفته (مثلاً هر دوشنبه)"),
737
+ ]:
738
+ tk.Radiobutton(
739
+ row3,
740
+ text=lbl_t,
741
+ variable=self.repeat_var,
742
+ value=val,
743
+ command=self._update_schedule_subframe,
744
+ font=(self.font_family, 8, "bold"),
745
+ fg="#F8FAFC",
746
+ bg="#1E293B",
747
+ selectcolor="#0F172A",
748
+ activebackground="#1E293B",
749
+ ).pack(side="right", padx=3)
750
+
751
+ self.schedule_subframe = tk.Frame(self.form_frame, bg="#1E293B")
752
+ self.schedule_subframe.pack(fill="x", pady=4)
753
+
754
+ # الف) تقویم گرافیکی تعاملی با قابلیت انتخاب چند روز (Multi-Select Mini Calendar)
755
+ self.calendar_picker_frame = tk.Frame(
756
+ self.schedule_subframe,
757
+ bg="#0F172A",
758
+ highlightbackground="#334155",
759
+ highlightthickness=1,
760
+ padx=10,
761
+ pady=6,
762
+ )
763
+
764
+ cal_nav = tk.Frame(self.calendar_picker_frame, bg="#0F172A")
765
+ cal_nav.pack(fill="x", pady=(0, 4))
766
+
767
+ next_btn = tk.Label(
768
+ cal_nav, text="‹ ماه بعد", font=(self.font_family, 8, "bold"),
769
+ fg="#F8FAFC", bg="#1E293B", padx=8, pady=2, cursor="hand2"
770
+ )
771
+ next_btn.pack(side="left")
772
+ next_btn.bind("<Button-1>", lambda e: self._change_picker_month(1))
773
+
774
+ clear_dates_btn = tk.Label(
775
+ cal_nav, text="پاک کردن انتخاب‌ها", font=(self.font_family, 7),
776
+ fg="#F87171", bg="#1E293B", padx=6, pady=2, cursor="hand2"
777
+ )
778
+ clear_dates_btn.pack(side="left", padx=4)
779
+ clear_dates_btn.bind("<Button-1>", lambda e: self._clear_selected_dates())
780
+
781
+ prev_btn = tk.Label(
782
+ cal_nav, text="ماه قبل ›", font=(self.font_family, 8, "bold"),
783
+ fg="#F8FAFC", bg="#1E293B", padx=8, pady=2, cursor="hand2"
784
+ )
785
+ prev_btn.pack(side="right")
786
+ prev_btn.bind("<Button-1>", lambda e: self._change_picker_month(-1))
787
+
788
+ self.picker_title_lbl = tk.Label(
789
+ cal_nav, text="", font=(self.font_family, 9, "bold"),
790
+ fg="#38BDF8", bg="#0F172A"
791
+ )
792
+ self.picker_title_lbl.pack(side="right", expand=True)
793
+
794
+ # سرستون روزهای هفته در تقویم انتخابگر
795
+ wk_row = tk.Frame(self.calendar_picker_frame, bg="#0F172A")
796
+ wk_row.pack(fill="x")
797
+ for col_idx in range(7):
798
+ p_idx = 6 - col_idx
799
+ tk.Label(
800
+ wk_row,
801
+ text=PERSIAN_WEEK_SHORT[p_idx],
802
+ font=(self.font_family, 8, "bold"),
803
+ fg="#F87171" if p_idx == 6 else "#94A3B8",
804
+ bg="#0F172A",
805
+ width=4,
806
+ ).grid(row=0, column=col_idx, sticky="nsew", padx=1)
807
+ wk_row.grid_columnconfigure(col_idx, weight=1)
808
+
809
+ # شبکه روزهای تقویم انتخابگر
810
+ self.picker_grid = tk.Frame(self.calendar_picker_frame, bg="#0F172A")
811
+ self.picker_grid.pack(fill="x", pady=2)
812
+ self.picker_cells: list[tk.Label] = []
813
+ for r in range(6):
814
+ self.picker_grid.grid_rowconfigure(r, weight=1)
815
+ for c in range(7):
816
+ self.picker_grid.grid_columnconfigure(c, weight=1)
817
+ lbl = tk.Label(
818
+ self.picker_grid,
819
+ text="",
820
+ font=("Segoe UI", 8, "bold"),
821
+ fg="#F8FAFC",
822
+ bg="#1E293B",
823
+ width=4,
824
+ pady=2,
825
+ cursor="hand2",
826
+ )
827
+ lbl.grid(row=r, column=c, sticky="nsew", padx=1, pady=1)
828
+ self.picker_cells.append(lbl)
829
+
830
+ self.selected_dates_summary_lbl = tk.Label(
831
+ self.calendar_picker_frame,
832
+ text="",
833
+ font=(self.font_family, 8),
834
+ fg="#10B981",
835
+ bg="#0F172A",
836
+ anchor="e",
837
+ justify="right",
838
+ )
839
+ self.selected_dates_summary_lbl.pack(fill="x", pady=(3, 0))
840
+
841
+ # ب) فریم انتخاب روزهای هفته (شنبه تا جمعه)
842
+ self.weekly_frame = tk.Frame(self.schedule_subframe, bg="#1E293B")
843
+ self.weekday_vars: list[tk.BooleanVar] = []
844
+ for idx, w_name in enumerate(PERSIAN_WEEKDAY_NAMES):
845
+ var = tk.BooleanVar(value=(idx == now_info["persian_week_idx"]))
846
+ self.weekday_vars.append(var)
847
+ tk.Checkbutton(
848
+ self.weekly_frame,
849
+ text=w_name,
850
+ variable=var,
851
+ font=(self.font_family, 8),
852
+ fg="#F8FAFC",
853
+ bg="#1E293B",
854
+ selectcolor="#0F172A",
855
+ activebackground="#1E293B",
856
+ padx=2,
857
+ ).pack(side="right")
858
+
859
+ self._render_picker_calendar()
860
+ self._update_schedule_subframe()
861
+
862
+ # 4. انتخاب صدای آلارم MP3 (پیش‌فرض: heeeeyyyyy.mp3)
863
+ sound_row = tk.Frame(self.form_frame, bg="#1E293B")
864
+ sound_row.pack(fill="x", pady=4)
865
+ tk.Label(
866
+ sound_row,
867
+ text="🎵 انتخاب صدای آلارم:",
868
+ font=(self.font_family, 9, "bold"),
869
+ fg="#38BDF8",
870
+ bg="#1E293B",
871
+ ).pack(side="right", padx=(6, 0))
872
+
873
+ available_sounds = get_available_alarm_sounds()
874
+ self.sound_cb = ttk.Combobox(
875
+ sound_row,
876
+ values=available_sounds,
877
+ state="readonly",
878
+ width=22,
879
+ )
880
+ if DEFAULT_ALARM_SOUND in available_sounds:
881
+ self.sound_cb.set(DEFAULT_ALARM_SOUND)
882
+ elif available_sounds:
883
+ self.sound_cb.current(0)
884
+ self.sound_cb.pack(side="left", padx=(0, 6))
885
+
886
+ tk.Button(
887
+ sound_row,
888
+ text="▶ تست صدا",
889
+ font=(self.font_family, 8, "bold"),
890
+ bg="#065F46",
891
+ fg="#A7F3D0",
892
+ relief="flat",
893
+ padx=8,
894
+ cursor="hand2",
895
+ command=lambda: play_mp3_sound(self.sound_cb.get(), repeat=False),
896
+ ).pack(side="left", padx=2)
897
+
898
+ tk.Button(
899
+ sound_row,
900
+ text="⏹ توقف",
901
+ font=(self.font_family, 8),
902
+ bg="#7F1D1D",
903
+ fg="#FECACA",
904
+ relief="flat",
905
+ padx=6,
906
+ cursor="hand2",
907
+ command=stop_mp3_sound,
908
+ ).pack(side="left", padx=2)
909
+
910
+ # 5. گزینه‌های فعال بودن صدا و پاپ‌آپ زنگوله
911
+ row4 = tk.Frame(self.form_frame, bg="#1E293B")
912
+ row4.pack(fill="x", pady=(2, 4))
913
+
914
+ self.sound_var = tk.BooleanVar(value=True)
915
+ tk.Checkbutton(
916
+ row4,
917
+ text="🔊 پخش صدای آلارم",
918
+ variable=self.sound_var,
919
+ font=(self.font_family, 8, "bold"),
920
+ fg="#FBBF24",
921
+ bg="#1E293B",
922
+ selectcolor="#0F172A",
923
+ activebackground="#1E293B",
924
+ ).pack(side="right", padx=6)
925
+
926
+ self.popup_var = tk.BooleanVar(value=True)
927
+ tk.Checkbutton(
928
+ row4,
929
+ text="🔔 نمایش زنگوله متحرک و متن در مرکز مانیتور",
930
+ variable=self.popup_var,
931
+ font=(self.font_family, 8, "bold"),
932
+ fg="#38BDF8",
933
+ bg="#1E293B",
934
+ selectcolor="#0F172A",
935
+ activebackground="#1E293B",
936
+ ).pack(side="right", padx=6)
937
+
938
+ btn_bar = tk.Frame(self.form_frame, bg="#1E293B")
939
+ btn_bar.pack(fill="x", pady=(4, 0))
940
+
941
+ self.save_btn = tk.Button(
942
+ btn_bar,
943
+ text="➕ افزودن به لیست آلارم‌ها",
944
+ font=(self.font_family, 9, "bold"),
945
+ bg="#0284C7",
946
+ fg="#FFFFFF",
947
+ relief="flat",
948
+ padx=14,
949
+ pady=4,
950
+ cursor="hand2",
951
+ command=self._save_alarm_from_form,
952
+ )
953
+ self.save_btn.pack(side="right")
954
+
955
+ self.cancel_edit_btn = tk.Button(
956
+ btn_bar,
957
+ text="انصراف از ویرایش",
958
+ font=(self.font_family, 8),
959
+ bg="#475569",
960
+ fg="#FFFFFF",
961
+ relief="flat",
962
+ padx=8,
963
+ pady=4,
964
+ cursor="hand2",
965
+ command=self._reset_form,
966
+ )
967
+
968
+ tk.Button(
969
+ btn_bar,
970
+ text="🔔 پیش‌نمایش زنگوله + صدا",
971
+ font=(self.font_family, 8),
972
+ bg="#334155",
973
+ fg="#FBBF24",
974
+ relief="flat",
975
+ padx=10,
976
+ pady=4,
977
+ cursor="hand2",
978
+ command=self._test_current_form,
979
+ ).pack(side="left")
980
+
981
+ list_label_frame = tk.LabelFrame(
982
+ main,
983
+ text=" 📋 لیست آلارم‌ها و رویدادهای فعال ",
984
+ font=(self.font_family, 9, "bold"),
985
+ fg="#E2E8F0",
986
+ bg="#1E293B",
987
+ bd=1,
988
+ padx=8,
989
+ pady=6,
990
+ )
991
+ list_label_frame.pack(fill="both", expand=True)
992
+
993
+ self.list_canvas = tk.Canvas(list_label_frame, bg="#1E293B", highlightthickness=0)
994
+ scrollbar = ttk.Scrollbar(list_label_frame, orient="vertical", command=self.list_canvas.yview)
995
+ self.list_inner = tk.Frame(self.list_canvas, bg="#1E293B")
996
+
997
+ self.list_inner.bind(
998
+ "<Configure>",
999
+ lambda e: self.list_canvas.configure(scrollregion=self.list_canvas.bbox("all")),
1000
+ )
1001
+ self.list_canvas.create_window((0, 0), window=self.list_inner, anchor="nw", width=500)
1002
+ self.list_canvas.configure(yscrollcommand=scrollbar.set)
1003
+
1004
+ self.list_canvas.pack(side="left", fill="both", expand=True)
1005
+ scrollbar.pack(side="right", fill="y")
1006
+
1007
+ footer = tk.Frame(main, bg="#0F172A")
1008
+ footer.pack(fill="x", pady=(6, 0))
1009
+ tk.Label(
1010
+ footer,
1011
+ text="® Shayan Bamyar",
1012
+ font=("Segoe UI", 8, "bold"),
1013
+ fg="#64748B",
1014
+ bg="#0F172A",
1015
+ ).pack(side="left")
1016
+
1017
+ tk.Button(
1018
+ footer,
1019
+ text="بستن پنجره",
1020
+ font=(self.font_family, 9),
1021
+ bg="#334155",
1022
+ fg="#F8FAFC",
1023
+ relief="flat",
1024
+ padx=14,
1025
+ pady=3,
1026
+ cursor="hand2",
1027
+ command=self._on_close,
1028
+ ).pack(side="right")
1029
+
1030
+ # -----------------------------------------------------------------------
1031
+ # منطق تقویم گرافیکی تعاملی با قابلیت انتخاب چند روز خاص
1032
+ # -----------------------------------------------------------------------
1033
+ def _change_picker_month(self, delta: int):
1034
+ m = self.cal_jm + delta
1035
+ y = self.cal_jy
1036
+ if m > 12:
1037
+ m = 1
1038
+ y += 1
1039
+ elif m < 1:
1040
+ m = 12
1041
+ y -= 1
1042
+ self.cal_jy, self.cal_jm = y, m
1043
+ self._render_picker_calendar()
1044
+
1045
+ def _clear_selected_dates(self):
1046
+ today = get_today_info()
1047
+ self.selected_dates = {f"{today['jy']:04d}/{today['jm']:02d}/{today['jd']:02d}"}
1048
+ self._render_picker_calendar()
1049
+
1050
+ def _toggle_picker_date(self, date_str: str):
1051
+ if date_str in self.selected_dates:
1052
+ if len(self.selected_dates) > 1:
1053
+ self.selected_dates.remove(date_str)
1054
+ else:
1055
+ self.selected_dates.add(date_str)
1056
+ self._render_picker_calendar()
1057
+
1058
+ def _render_picker_calendar(self):
1059
+ m_data = get_month_calendar(self.cal_jy, self.cal_jm)
1060
+ self.picker_title_lbl.configure(
1061
+ text=f"{m_data['header_title']} (با کلیک یک یا چند روز را انتخاب کنید)"
1062
+ )
1063
+
1064
+ for cell in self.picker_cells:
1065
+ cell.configure(text="", bg="#0F172A", cursor="arrow")
1066
+ cell.unbind("<Button-1>")
1067
+
1068
+ first_w = m_data["first_day_week_idx"]
1069
+ for d_info in m_data["days"]:
1070
+ d = d_info["jd"]
1071
+ offset = first_w + (d - 1)
1072
+ row = offset // 7
1073
+ p_col = offset % 7
1074
+ g_col = 6 - p_col
1075
+ idx = row * 7 + g_col
1076
+ if idx >= len(self.picker_cells):
1077
+ continue
1078
+
1079
+ cell = self.picker_cells[idx]
1080
+ d_str = f"{self.cal_jy:04d}/{self.cal_jm:02d}/{d:02d}"
1081
+ is_sel = d_str in self.selected_dates
1082
+
1083
+ if is_sel:
1084
+ bg_c = "#10B981" # سبز زمردی برای روزهای انتخاب‌شده
1085
+ fg_c = "#FFFFFF"
1086
+ else:
1087
+ bg_c = "#1E293B"
1088
+ fg_c = "#F87171" if d_info["is_holiday"] else "#E2E8F0"
1089
+
1090
+ cell.configure(text=str(d), bg=bg_c, fg=fg_c, cursor="hand2")
1091
+ cell.bind("<Button-1>", lambda e, ds=d_str: self._toggle_picker_date(ds))
1092
+
1093
+ sorted_d = sorted(self.selected_dates)
1094
+ summary = f"✓ روزهای انتخاب‌شده ({len(sorted_d)} روز): " + " ، ".join(sorted_d[:4])
1095
+ if len(sorted_d) > 4:
1096
+ summary += f" و {len(sorted_d) - 4} روز دیگر"
1097
+ self.selected_dates_summary_lbl.configure(text=summary)
1098
+
1099
+ def _update_schedule_subframe(self):
1100
+ self.calendar_picker_frame.pack_forget()
1101
+ self.weekly_frame.pack_forget()
1102
+ mode = self.repeat_var.get()
1103
+ if mode in ("once", "dates"):
1104
+ self.calendar_picker_frame.pack(fill="x")
1105
+ elif mode == "weekly":
1106
+ self.weekly_frame.pack(fill="x")
1107
+
1108
+ def _collect_form_data(self) -> dict:
1109
+ try:
1110
+ h = max(0, min(23, int(self.hour_spin.get())))
1111
+ except ValueError:
1112
+ h = 12
1113
+ try:
1114
+ m = max(0, min(59, int(self.min_spin.get())))
1115
+ except ValueError:
1116
+ m = 0
1117
+
1118
+ sorted_dates = sorted(self.selected_dates)
1119
+ if not sorted_dates:
1120
+ sorted_dates = [f"{self.initial_jy:04d}/{self.initial_jm:02d}/{self.initial_jd:02d}"]
1121
+ first_parts = sorted_dates[0].split("/")
1122
+ jy, jm, jd = int(first_parts[0]), int(first_parts[1]), int(first_parts[2])
1123
+
1124
+ weekdays = [i for i, v in enumerate(self.weekday_vars) if v.get()]
1125
+ title = self.title_entry.get().strip() or "یادآور"
1126
+ sound_file = self.sound_cb.get() or DEFAULT_ALARM_SOUND
1127
+
1128
+ return {
1129
+ "id": self.editing_id,
1130
+ "title": title,
1131
+ "hour": h,
1132
+ "minute": m,
1133
+ "repeat_mode": self.repeat_var.get(),
1134
+ "dates": sorted_dates,
1135
+ "jy": jy,
1136
+ "jm": jm,
1137
+ "jd": jd,
1138
+ "weekdays": weekdays,
1139
+ "sound_file": sound_file,
1140
+ "play_sound": bool(self.sound_var.get()),
1141
+ "show_popup": bool(self.popup_var.get()),
1142
+ "enabled": True,
1143
+ "last_triggered": "",
1144
+ }
1145
+
1146
+ def _save_alarm_from_form(self):
1147
+ stop_mp3_sound()
1148
+ data = self._collect_form_data()
1149
+ self.store.add_or_update(data)
1150
+ self._reset_form()
1151
+ self._refresh_list()
1152
+ if self.on_change_callback:
1153
+ self.on_change_callback()
1154
+
1155
+ def _test_current_form(self):
1156
+ data = self._collect_form_data()
1157
+ AnimatedBellPopup(self.root, data, self.font_family)
1158
+
1159
+ def _reset_form(self):
1160
+ self.editing_id = None
1161
+ self.form_frame.configure(text=" ➕ ثبت رویداد / آلارم جدید ")
1162
+ self.save_btn.configure(text="➕ افزودن به لیست آلارم‌ها", bg="#0284C7")
1163
+ self.cancel_edit_btn.pack_forget()
1164
+ self.title_entry.delete(0, "end")
1165
+ self.title_entry.insert(0, "یادآوری مهم")
1166
+ self.sound_cb.set(DEFAULT_ALARM_SOUND)
1167
+ self.selected_dates = {
1168
+ f"{self.initial_jy:04d}/{self.initial_jm:02d}/{self.initial_jd:02d}"
1169
+ }
1170
+ self._render_picker_calendar()
1171
+
1172
+ def _load_into_form(self, alarm: dict):
1173
+ self.editing_id = alarm.get("id")
1174
+ self.form_frame.configure(text=" ✏️ در حال ویرایش آلارم انتخاب‌شده ")
1175
+ self.save_btn.configure(text="💾 ذخیره تغییرات", bg="#10B981")
1176
+ self.cancel_edit_btn.pack(side="right", padx=6)
1177
+
1178
+ self.title_entry.delete(0, "end")
1179
+ self.title_entry.insert(0, alarm.get("title", ""))
1180
+
1181
+ self.hour_spin.delete(0, "end")
1182
+ self.hour_spin.insert(0, f"{int(alarm.get('hour', 0)):02d}")
1183
+ self.min_spin.delete(0, "end")
1184
+ self.min_spin.insert(0, f"{int(alarm.get('minute', 0)):02d}")
1185
+
1186
+ mode = alarm.get("repeat_mode", "dates")
1187
+ if mode == "once":
1188
+ mode = "dates"
1189
+ self.repeat_var.set(mode)
1190
+
1191
+ norm_dates = _normalize_alarm_dates(alarm)
1192
+ if norm_dates:
1193
+ self.selected_dates = set(norm_dates)
1194
+ first_p = norm_dates[0].split("/")
1195
+ self.cal_jy, self.cal_jm = int(first_p[0]), int(first_p[1])
1196
+ self._render_picker_calendar()
1197
+
1198
+ w_days = set(alarm.get("weekdays", []))
1199
+ for idx, var in enumerate(self.weekday_vars):
1200
+ var.set(idx in w_days)
1201
+
1202
+ self.sound_cb.set(alarm.get("sound_file", DEFAULT_ALARM_SOUND) or DEFAULT_ALARM_SOUND)
1203
+ self.sound_var.set(bool(alarm.get("play_sound", True)))
1204
+ self.popup_var.set(bool(alarm.get("show_popup", True)))
1205
+ self._update_schedule_subframe()
1206
+
1207
+ def _toggle_item_enabled(self, alarm: dict):
1208
+ alarm["enabled"] = not alarm.get("enabled", True)
1209
+ self.store.save()
1210
+ self._refresh_list()
1211
+ if self.on_change_callback:
1212
+ self.on_change_callback()
1213
+
1214
+ def _delete_item(self, alarm_id: str):
1215
+ self.store.delete(alarm_id)
1216
+ if self.editing_id == alarm_id:
1217
+ self._reset_form()
1218
+ self._refresh_list()
1219
+ if self.on_change_callback:
1220
+ self.on_change_callback()
1221
+
1222
+ def _refresh_list(self):
1223
+ for child in self.list_inner.winfo_children():
1224
+ child.destroy()
1225
+
1226
+ if not self.store.alarms:
1227
+ tk.Label(
1228
+ self.list_inner,
1229
+ text="هیچ رویداد یا آلارم فعالی در لیست وجود ندارد.",
1230
+ font=(self.font_family, 9),
1231
+ fg="#64748B",
1232
+ bg="#1E293B",
1233
+ pady=18,
1234
+ ).pack(fill="x")
1235
+ return
1236
+
1237
+ for alarm in self.store.alarms:
1238
+ card = tk.Frame(
1239
+ self.list_inner,
1240
+ bg="#0F172A",
1241
+ highlightbackground="#334155",
1242
+ highlightthickness=1,
1243
+ padx=8,
1244
+ pady=6,
1245
+ )
1246
+ card.pack(fill="x", pady=3, padx=2)
1247
+
1248
+ is_en = alarm.get("enabled", True)
1249
+ t_str = f"{int(alarm.get('hour', 0)):02d}:{int(alarm.get('minute', 0)):02d}"
1250
+ sched_str = format_alarm_schedule_summary(alarm)
1251
+ snd_name = alarm.get("sound_file", DEFAULT_ALARM_SOUND)
1252
+ icons = []
1253
+ if alarm.get("play_sound", True):
1254
+ icons.append(f"🔊({snd_name})")
1255
+ if alarm.get("show_popup", True):
1256
+ icons.append("🔔")
1257
+ icons_str = " ".join(icons)
1258
+
1259
+ tk.Button(
1260
+ card,
1261
+ text="🗑 حذف",
1262
+ font=(self.font_family, 8),
1263
+ bg="#7F1D1D",
1264
+ fg="#FECACA",
1265
+ relief="flat",
1266
+ padx=6,
1267
+ cursor="hand2",
1268
+ command=lambda aid=alarm["id"]: self._delete_item(aid),
1269
+ ).pack(side="left", padx=2)
1270
+
1271
+ tk.Button(
1272
+ card,
1273
+ text="✏️ ویرایش",
1274
+ font=(self.font_family, 8),
1275
+ bg="#1E3A8A",
1276
+ fg="#BFDBFE",
1277
+ relief="flat",
1278
+ padx=6,
1279
+ cursor="hand2",
1280
+ command=lambda a=alarm: self._load_into_form(a),
1281
+ ).pack(side="left", padx=2)
1282
+
1283
+ tk.Button(
1284
+ card,
1285
+ text="فعال" if is_en else "غیرفعال",
1286
+ font=(self.font_family, 8, "bold"),
1287
+ bg="#065F46" if is_en else "#334155",
1288
+ fg="#A7F3D0" if is_en else "#94A3B8",
1289
+ relief="flat",
1290
+ padx=6,
1291
+ cursor="hand2",
1292
+ command=lambda a=alarm: self._toggle_item_enabled(a),
1293
+ ).pack(side="left", padx=2)
1294
+
1295
+ info_box = tk.Frame(card, bg="#0F172A")
1296
+ info_box.pack(side="right", fill="x", expand=True, padx=4)
1297
+
1298
+ tk.Label(
1299
+ info_box,
1300
+ text=f"{t_str} | {alarm.get('title', '')}",
1301
+ font=(self.font_family, 9, "bold"),
1302
+ fg="#F8FAFC" if is_en else "#64748B",
1303
+ bg="#0F172A",
1304
+ anchor="e",
1305
+ ).pack(fill="x")
1306
+
1307
+ tk.Label(
1308
+ info_box,
1309
+ text=f"زمان‌بندی: {sched_str} | {icons_str}",
1310
+ font=(self.font_family, 8),
1311
+ fg="#38BDF8" if is_en else "#475569",
1312
+ bg="#0F172A",
1313
+ anchor="e",
1314
+ ).pack(fill="x")