diff --git a/docs/widgets/(Widget)-Prayer-Times.md b/docs/widgets/(Widget)-Prayer-Times.md
new file mode 100644
index 000000000..aaa297f39
--- /dev/null
+++ b/docs/widgets/(Widget)-Prayer-Times.md
@@ -0,0 +1,425 @@
+# Prayer Times Widget
+
+Displays Islamic prayer times fetched from the [Aladhan API](https://aladhan.com/prayer-times-api). Shows the next upcoming (or currently active) prayer by default, with an alt label that lists all daily prayer times. Left-clicking opens a popup card listing all prayers with their times and remaining countdowns.
+
+## Options
+
+| Option | Type | Default | Description |
+|---------------------|---------|---------------------------------------------------------------------------------------|------------------------------------------------------------------------------------------------------------------------------------------|
+| `label` | string | `"{icon} {next_prayer} {next_prayer_time}"` | Format string for the primary label. Supports all placeholders listed below. |
+| `label_alt` | string | `"Fajr {fajr} · Dhuhr {dhuhr} · Asr {asr} · Maghrib {maghrib} · Isha {isha}"` | Format string for the alternate label. |
+| `class_name` | string | `""` | Additional CSS class name for the widget. |
+| `latitude` | float | `51.5074` | Latitude of your location (−90 to 90). |
+| `longitude` | float | `-0.1278` | Longitude of your location (−180 to 180). |
+| `method` | integer | `2` | Aladhan calculation method ID. See [method list](#method-ids). |
+| `school` | integer | `0` | Juristic school for Asr: `0` = Shafi'i / Standard, `1` = Hanafi. |
+| `midnight_mode` | integer | `0` | Midnight mode: `0` = Standard (mid sunset-to-sunrise), `1` = Jafari (mid sunset-to-Fajr). |
+| `tune` | string | `""` | Comma-separated minute offsets for each prayer (Imsak,Fajr,Sunrise,Dhuhr,Asr,Maghrib,Sunset,Isha,Midnight). |
+| `timezone` | string | `""` | IANA timezone string (e.g. `"Asia/Jakarta"`). Defaults to the server's local timezone. |
+| `shafaq` | string | `""` | Shafaq type used for Isha calculation in some methods (`general`, `ahmer`, `abyad`). |
+| `prayers_to_show` | list | `["Fajr", "Dhuhr", "Asr", "Maghrib", "Isha"]` | Ordered list of prayers used to determine the active/next prayer, popup rows, and tooltip. Must match Aladhan names exactly. |
+| `grace_period` | integer | `15` | Minutes to stay on the current prayer after its time before advancing to the next. Min `0`, max `120`. |
+| `update_interval` | integer | `3600` | How often (in seconds) to re-fetch prayer times from the API. Min `60`, max `86400`. |
+| `tooltip` | boolean | `true` | Show a hover tooltip summarising prayer times. Displays a **"Today's Prayers"** (or **"Tomorrow's Prayers"**) header, each prayer in `prayers_to_show` with its time, and a `◀` marker on the next upcoming prayer. |
+| `icons` | dict | *(see below)* | Nerd Font icon per prayer name. Includes `mosque` shown in the popup header. |
+| `menu` | dict | *(see below)* | Appearance and position settings for the popup card. |
+| `flash` | dict | *(see below)* | Smooth animated glow effect triggered when a prayer time arrives. |
+| `callbacks` | dict | `{on_left: "toggle_card", on_middle: "do_nothing", on_right: "toggle_label"}` | Mouse-click actions. |
+| `animation` | dict | `{enabled: true, type: "fadeInOut", duration: 200}` | Animation settings for the toggle_card transition. |
+| `label_shadow` | dict | `{enabled: false, color: "black", radius: 3, offset: [1, 1]}` | Label shadow options. |
+| `container_shadow` | dict | `{enabled: false, color: "black", radius: 3, offset: [1, 1]}` | Container shadow options. |
+| `keybindings` | list | `[]` | Hotkey bindings. |
+
+### Label Placeholders
+
+| Placeholder | Description |
+|----------------------|---------------------------------------------------------------------------------------|
+| `{icon}` | Icon for the currently active or next upcoming prayer |
+| `{next_prayer}` | Name of the currently active or next upcoming prayer (e.g. `Asr`) |
+| `{next_prayer_time}` | Time of the currently active or next upcoming prayer (e.g. `15:14`) |
+| `{fajr}` | Fajr time |
+| `{sunrise}` | Sunrise time |
+| `{dhuhr}` | Dhuhr time |
+| `{asr}` | Asr time |
+| `{sunset}` | Sunset time |
+| `{maghrib}` | Maghrib time |
+| `{isha}` | Isha time |
+| `{imsak}` | Imsak time |
+| `{midnight}` | Midnight time |
+| `{hijri_date}` | Full Hijri date (e.g. `23 Sha'bān 1446`) |
+| `{hijri_day}` | Hijri day number |
+| `{hijri_month}` | Hijri month name (English) |
+| `{hijri_year}` | Hijri year |
+
+> **Note on `{next_prayer}` / `{icon}`:** During the `grace_period` window after a prayer's time, these values stay on the current prayer rather than jumping to the next one.
+
+### Default Icons
+
+```yaml
+icons:
+ mosque: "\uf67f" # Shown in the popup card header
+ fajr: "\uf185"
+ sunrise: "\uf185"
+ dhuhr: "\uf185"
+ asr: "\uf185"
+ maghrib: "\uf186"
+ isha: "\uf186"
+ imsak: "\uf185"
+ sunset: "\uf185"
+ midnight: "\uf186"
+ default: "\uf017" # Fallback when no matching icon is found
+```
+
+### Menu Options
+
+Controls the popup card that opens on `toggle_card`.
+
+| Option | Type | Default | Description |
+|----------------------|---------|------------|--------------------------------------------------------------------------|
+| `blur` | boolean | `true` | Apply blur effect to the popup background. |
+| `round_corners` | boolean | `true` | Round the popup corners (not supported on Windows 10). |
+| `round_corners_type` | string | `"normal"` | Corner style: `"normal"` or `"small"` (not supported on Windows 10). |
+| `border_color` | string | `"System"` | Border color: `"System"`, `None`, or a hex color e.g. `"#ff0000"`. |
+| `alignment` | string | `"right"` | Popup alignment relative to the widget: `"left"`, `"center"`, `"right"`. |
+| `direction` | string | `"down"` | Direction the popup opens: `"up"` or `"down"`. |
+| `offset_top` | integer | `6` | Vertical offset in pixels from the bar edge. |
+| `offset_left` | integer | `0` | Horizontal offset in pixels from the widget edge. |
+
+### Flash Options
+
+Controls the smooth animated glow effect that triggers when a prayer time arrives.
+
+| Option | Type | Default | Description |
+|------------|---------|-------------|-----------------------------------------------------------------------------------------------------------|
+| `enabled` | boolean | `true` | Whether to enable the flash effect. |
+| `debug` | boolean | `false` | Trigger the flash animation immediately on widget startup (useful for testing colors and timing). |
+| `duration` | integer | `30` | How long (in seconds) to run the flash after the prayer time arrives. Min `1`, max `3600`. |
+| `interval` | integer | `500` | Duration in milliseconds of one half-cycle (fade to `color_a`, then back). Min `100`, max `5000`. |
+| `color_a` | string | `"#ff8c00"` | The bright peak color the background pulses to on each cycle. |
+| `color_b` | string | `"#1e1e2e"` | The dim base color the background fades from. Should match your container background. |
+
+The animation uses `QVariantAnimation` with an `InOutSine` easing curve, producing a smooth pulse rather than an abrupt flash. Colors ping-pong (`color_b` → `color_a` → `color_b` → …) for the full `duration`. The background is applied directly to the entire widget container so the glow covers the whole pill. The label also receives a `flash` CSS class so you can change the text color independently via CSS.
+
+### Grace Period
+
+The `grace_period` option (default `15` minutes) controls how long the widget stays on the current prayer after its time has passed, before moving to the next.
+
+**Example:** Asr at 15:14 with `grace_period: 15` → label shows `Asr 15:14` until 15:29, then switches to Maghrib.
+
+This affects:
+- **Bar label** — `{next_prayer}` and `{icon}` stay on the current prayer during the grace window.
+- **Popup card** — the active row shows an elapsed label (e.g. `5m ago`) instead of `passed` while still within the grace window.
+- **Tomorrow's schedule** — fetching tomorrow's times is deferred until the last prayer's grace window has fully expired.
+
+## Callbacks
+
+| Callback | Description |
+|----------------|---------------------------------------------|
+| `toggle_card` | Open/close the popup card. |
+| `toggle_label` | Toggle between primary and alternate label. |
+| `update_label` | Force a label refresh. |
+| `do_nothing` | No action. |
+
+## Minimal Configuration
+
+```yaml
+prayer_times:
+ type: "yasb.prayer_times.PrayerTimesWidget"
+ options:
+ label: "{icon} {next_prayer} {next_prayer_time}"
+ latitude: -6.178306
+ longitude: 106.631889
+ method: 20 # Kementerian Agama Republik Indonesia
+ timezone: "Asia/Jakarta"
+```
+
+## Example Configuration
+
+```yaml
+prayer_times:
+ type: "yasb.prayer_times.PrayerTimesWidget"
+ options:
+ label: "{icon} {next_prayer} {next_prayer_time}"
+ label_alt: "Fajr {fajr} · Dhuhr {dhuhr} · Asr {asr} · Maghrib {maghrib} · Isha {isha}"
+ latitude: -6.178306
+ longitude: 106.631889
+ method: 20 # Kementerian Agama Republik Indonesia
+ school: 0 # Shafi'i / Standard
+ midnight_mode: 0
+ shafaq: "general"
+ tune: "5,3,5,7,9,-1,0,8,-6" # Minute offsets: Imsak,Fajr,Sunrise,Dhuhr,Asr,Maghrib,Sunset,Isha,Midnight
+ timezone: "Asia/Jakarta"
+ prayers_to_show:
+ - "Imsak"
+ - "Fajr"
+ - "Sunrise"
+ - "Dhuhr"
+ - "Asr"
+ - "Sunset"
+ - "Maghrib"
+ - "Isha"
+ grace_period: 15 # Stay on current prayer for 15 min after its time
+ update_interval: 3600
+ tooltip: true
+ icons:
+ mosque: "\uf67f"
+ fajr: "\uf185"
+ sunrise: "\uf185"
+ dhuhr: "\uf185"
+ asr: "\uf185"
+ sunset: "\uf185"
+ maghrib: "\uf186"
+ isha: "\uf186"
+ imsak: "\uf185"
+ midnight: "\uf186"
+ default: "\uf017"
+ menu:
+ blur: true
+ round_corners: true
+ round_corners_type: "normal"
+ border_color: "System"
+ alignment: "right"
+ direction: "down"
+ offset_top: 6
+ offset_left: 0
+ flash:
+ enabled: true
+ debug: false
+ duration: 60 # Flash for 60 seconds
+ interval: 800 # 800ms per half-cycle
+ color_a: "#ff8c00" # Bright glow color
+ color_b: "#1e1e2e" # Dim base color (match your container background)
+ callbacks:
+ on_left: "toggle_card"
+ on_middle: "do_nothing"
+ on_right: "toggle_label"
+ animation:
+ enabled: true
+ type: "fadeInOut"
+ duration: 200
+ label_shadow:
+ enabled: true
+ color: "#000000"
+ radius: 2
+ offset: [1, 1]
+ container_shadow:
+ enabled: false
+ color: "black"
+ radius: 3
+ offset: [1, 1]
+```
+
+## Method IDs
+
+Commonly used Aladhan calculation method IDs:
+
+| ID | Name |
+|-----|--------------------------------------------------|
+| 1 | University of Islamic Sciences, Karachi |
+| 2 | Islamic Society of North America (ISNA) |
+| 3 | Muslim World League |
+| 4 | Umm Al-Qura University, Makkah |
+| 5 | Egyptian General Authority of Survey |
+| 11 | Majlis Ugama Islam Singapura, Singapore |
+| 12 | Union Organization Islamic de France |
+| 13 | Diyanet İşleri Başkanlığı, Turkey |
+| 14 | Spiritual Administration of Muslims of Russia |
+| 15 | Moonsighting Committee Worldwide (Khalid Shaukat)|
+| 16 | Dubai, UAE |
+| 17 | Jabatan Kemajuan Islam Malaysia (JAKIM) |
+| 18 | Tunisia |
+| 19 | Algeria |
+| 20 | Kementerian Agama Republik Indonesia |
+| 21 | Morocco |
+| 22 | Comunidade Islâmica de Lisboa, Portugal |
+| 23 | Ministry of Awqaf, Jordan and Palestine |
+
+For the full list and custom (`method=99`) options, see the [Aladhan API docs](https://aladhan.com/prayer-times-api).
+
+## Available Styles
+
+> **Note:** The active prayer name is added as a CSS class on the bar label (e.g. `.label.fajr`, `.label.maghrib`), allowing you to colour each prayer differently.
+
+```css
+/* ── Bar widget ──────────────────────────────────────────────────── */
+.prayer-times-widget {}
+.prayer-times-widget.your_class {} /* If class_name is set */
+.prayer-times-widget .widget-container {}
+.prayer-times-widget .label {}
+.prayer-times-widget .label.alt {} /* Alt label (toggle_label) */
+.prayer-times-widget .label.loading {} /* While API is fetching */
+.prayer-times-widget .icon {} /* Span elements without an explicit class (e.g. \uf67f) */
+
+/* Per-prayer label classes (applied while that prayer is active/current) */
+.prayer-times-widget .label.fajr {}
+.prayer-times-widget .label.sunrise {}
+.prayer-times-widget .label.dhuhr {}
+.prayer-times-widget .label.asr {}
+.prayer-times-widget .label.sunset {}
+.prayer-times-widget .label.maghrib {}
+.prayer-times-widget .label.isha {}
+.prayer-times-widget .label.imsak {}
+.prayer-times-widget .label.midnight {}
+
+/* Flash: applied to the label during the animated background glow */
+.prayer-times-widget .label.flash {} /* Background color is animated in Python via QVariantAnimation */
+.prayer-times-widget .label.alt.flash {} /* Same, when the alt label is currently shown */
+
+/* ── Popup card ──────────────────────────────────────────────────── */
+.prayer-times-menu {}
+.prayer-times-menu .header {}
+.prayer-times-menu .header .mosque-icon {}
+.prayer-times-menu .header .title {}
+.prayer-times-menu .header .hijri-date {}
+.prayer-times-menu .rows-container {}
+.prayer-times-menu .prayer-row {}
+.prayer-times-menu .prayer-row.active {} /* Currently active prayer (within grace period) */
+.prayer-times-menu .prayer-row.passed {} /* Prayers whose grace period has fully expired */
+.prayer-times-menu .prayer-icon {}
+.prayer-times-menu .prayer-name {}
+.prayer-times-menu .prayer-time {}
+.prayer-times-menu .prayer-remaining {} /* "in 2h 15m" / "5m ago" (grace window) / "passed" */
+.prayer-times-menu .footer {}
+.prayer-times-menu .method-name {} /* Calculation method name shown in footer */
+.prayer-times-menu .loading-placeholder {} /* Shown before the first API response arrives */
+```
+
+## Example Style
+
+```css
+/* ── Bar widget ─────────────────────────────────────────────────── */
+.prayer-times-widget {
+ padding: 0 6px;
+}
+.prayer-times-widget .widget-container {
+ background-color: rgba(17, 17, 27, 0.5);
+ margin: 4px 0;
+ border-radius: 12px;
+ border: 1px solid #45475a;
+ padding: 0 10px;
+}
+.prayer-times-widget .widget-container:hover {
+ background-color: #282936;
+ border-color: #cba6f7;
+}
+.prayer-times-widget .icon {
+ font-size: 16px;
+ color: #cba6f7;
+ margin: 0 4px 0 0;
+}
+.prayer-times-widget .label {
+ font-size: 13px;
+ color: #cdd6f4;
+ font-weight: 600;
+}
+.prayer-times-widget .label.loading {
+ color: #6c7086;
+}
+
+/* Per-prayer label accent colours */
+.prayer-times-widget .label.imsak { color: #74c7ec; }
+.prayer-times-widget .label.fajr { color: #74c7ec; }
+.prayer-times-widget .label.sunrise { color: #f9e2af; }
+.prayer-times-widget .label.dhuhr { color: #f9e2af; }
+.prayer-times-widget .label.asr { color: #fab387; }
+.prayer-times-widget .label.sunset { color: #fab387; }
+.prayer-times-widget .label.maghrib { color: #cba6f7; }
+.prayer-times-widget .label.isha { color: #b4befe; }
+.prayer-times-widget .label.midnight { color: #b4befe; }
+
+/* Flash: text color during the animated background glow */
+.prayer-times-widget .label.flash {
+ color: #ff8c00;
+}
+.prayer-times-widget .label.alt.flash {
+ color: #ff8c00;
+}
+
+/* ── Popup card ─────────────────────────────────────────────────── */
+.prayer-times-menu {
+ background-color: rgba(30, 30, 46, 0.95);
+ min-width: 300px;
+}
+.prayer-times-menu .header {
+ background-color: rgba(17, 17, 27, 0.9);
+ border-bottom: 1px solid rgba(255, 255, 255, 0.1);
+}
+.prayer-times-menu .header .mosque-icon {
+ font-size: 18px;
+ color: #cba6f7;
+}
+.prayer-times-menu .header .title {
+ font-size: 14px;
+ font-weight: 700;
+ font-family: 'Segoe UI';
+ color: #ffffff;
+}
+.prayer-times-menu .header .hijri-date {
+ font-size: 11px;
+ font-weight: 600;
+ font-family: 'Segoe UI';
+ color: #a6adc8;
+}
+.prayer-times-menu .rows-container {
+ padding: 6px 0;
+}
+.prayer-times-menu .prayer-row {
+ background-color: transparent;
+ border-bottom: 1px solid rgba(255, 255, 255, 0.05);
+}
+.prayer-times-menu .prayer-row.active {
+ background-color: rgba(203, 166, 247, 0.12);
+ border-left: 2px solid #cba6f7;
+}
+.prayer-times-menu .prayer-row.passed {
+ opacity: 0.4;
+}
+.prayer-times-menu .prayer-icon {
+ font-size: 15px;
+ color: #cba6f7;
+}
+.prayer-times-menu .prayer-row.active .prayer-icon { color: #cba6f7; }
+.prayer-times-menu .prayer-row.passed .prayer-icon { color: #7f849c; }
+.prayer-times-menu .prayer-name {
+ font-size: 13px;
+ font-weight: 600;
+ font-family: 'Segoe UI';
+ color: #cdd6f4;
+}
+.prayer-times-menu .prayer-row.active .prayer-name { color: #cba6f7; }
+.prayer-times-menu .prayer-row.passed .prayer-name { color: #9399b2; }
+.prayer-times-menu .prayer-time {
+ font-size: 13px;
+ font-weight: 700;
+ font-family: 'Segoe UI';
+ color: #cdd6f4;
+}
+.prayer-times-menu .prayer-row.active .prayer-time { color: #cba6f7; }
+.prayer-times-menu .prayer-remaining {
+ font-size: 11px;
+ font-weight: 600;
+ font-family: 'Segoe UI';
+ color: #a6adc8;
+}
+.prayer-times-menu .prayer-row.active .prayer-remaining {
+ color: #cba6f7;
+ font-weight: 700;
+}
+.prayer-times-menu .footer {
+ background-color: rgba(17, 17, 27, 0.6);
+ border-top: 1px solid rgba(255, 255, 255, 0.08);
+}
+.prayer-times-menu .method-name {
+ font-size: 11px;
+ font-weight: 600;
+ font-family: 'Segoe UI';
+ color: #7f849c;
+}
+.prayer-times-menu .loading-placeholder {
+ padding: 28px 16px;
+ font-size: 12px;
+ font-weight: 600;
+ font-family: 'Segoe UI';
+ color: #6c7086;
+}
+```
diff --git a/src/core/utils/widgets/prayer_times/api.py b/src/core/utils/widgets/prayer_times/api.py
new file mode 100644
index 000000000..0f968ce59
--- /dev/null
+++ b/src/core/utils/widgets/prayer_times/api.py
@@ -0,0 +1,91 @@
+import json
+import logging
+import traceback
+from typing import Callable
+
+from PyQt6.QtCore import QObject, QTimer, QUrl, pyqtSignal
+from PyQt6.QtNetwork import QNetworkAccessManager, QNetworkReply, QNetworkRequest
+
+HEADER = (b"User-Agent", b"Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:129.0) Gecko/20100101 Firefox/129.0")
+RETRY_INTERVAL_MS = 5_000 # retry every 5 s when the network is unavailable
+
+
+class PrayerTimesDataFetcher(QObject):
+ """Fetches Islamic prayer times from the Aladhan API."""
+
+ finished = pyqtSignal(dict)
+
+ def __init__(self, parent: QObject, url_factory: Callable[[], str], timeout_ms: int):
+ """
+ Args:
+ parent: Qt parent object.
+ url_factory: A callable that returns the current API URL string.
+ Called on every request so the date is always today.
+ timeout_ms: Interval between automatic re-fetches, in milliseconds.
+ """
+ super().__init__(parent)
+ self.started = False
+ self._url_factory = url_factory
+ self._manager = QNetworkAccessManager(self)
+ self._manager.finished.connect(self._handle_response)
+ self._timer = QTimer(self)
+ self._timer.setInterval(timeout_ms)
+ self._timer.timeout.connect(self.make_request)
+ # Single-shot timer used to retry quickly after a network failure.
+ self._retry_timer = QTimer(self)
+ self._retry_timer.setSingleShot(True)
+ self._retry_timer.timeout.connect(self.make_request)
+
+ def start(self) -> None:
+ """Begin periodic fetching. The first request fires immediately."""
+ self.make_request()
+ self._timer.start()
+ self.started = True
+
+ def make_request(self) -> None:
+ """Make a single API request using the current URL from url_factory."""
+ url = QUrl(self._url_factory())
+ if not url.isValid():
+ logging.error("Prayer times: built an invalid URL — check latitude/longitude settings.")
+ return
+ request = QNetworkRequest(url)
+ request.setRawHeader(*HEADER)
+ self._manager.get(request)
+
+ def _handle_response(self, reply: QNetworkReply) -> None:
+ try:
+ error = reply.error()
+ status = reply.attribute(QNetworkRequest.Attribute.HttpStatusCodeAttribute)
+ if error == QNetworkReply.NetworkError.NoError:
+ raw = reply.readAll().data().decode("utf-8", errors="replace")
+ data = json.loads(raw)
+ if data.get("code") == 200:
+ self._retry_timer.stop()
+ self.finished.emit(data)
+ else:
+ logging.error(f"Prayer times API returned non-200 code: {data.get('code')} — {data.get('status')}")
+ self.finished.emit({})
+ self._schedule_retry()
+ elif error == QNetworkReply.NetworkError.HostNotFoundError:
+ logging.warning("Prayer times: no internet connection or host not found.")
+ self.finished.emit({})
+ self._schedule_retry()
+ else:
+ logging.error(f"Prayer times API network error {status}: {error}")
+ self.finished.emit({})
+ self._schedule_retry()
+ except json.JSONDecodeError as e:
+ logging.error(f"Prayer times: invalid JSON in response: {e}")
+ self.finished.emit({})
+ self._schedule_retry()
+ except Exception as e:
+ logging.error(f"Prayer times: unexpected error: {e}\n{traceback.format_exc()}")
+ self.finished.emit({})
+ self._schedule_retry()
+ finally:
+ reply.deleteLater()
+
+ def _schedule_retry(self) -> None:
+ """Schedule a quick retry if one is not already pending."""
+ if not self._retry_timer.isActive():
+ self._retry_timer.start(RETRY_INTERVAL_MS)
diff --git a/src/core/validation/widgets/yasb/prayer_times.py b/src/core/validation/widgets/yasb/prayer_times.py
new file mode 100644
index 000000000..7d4889cdd
--- /dev/null
+++ b/src/core/validation/widgets/yasb/prayer_times.py
@@ -0,0 +1,75 @@
+from pydantic import Field
+
+from core.validation.widgets.base_model import (
+ AnimationConfig,
+ CallbacksConfig,
+ CustomBaseModel,
+ KeybindingConfig,
+ ShadowConfig,
+)
+
+
+class PrayerTimesCallbacksConfig(CallbacksConfig):
+ on_left: str = "toggle_card"
+ on_middle: str = "do_nothing"
+ on_right: str = "toggle_label"
+
+
+class PrayerTimesIconsConfig(CustomBaseModel):
+ mosque: str = "\uf67f"
+ fajr: str = "\uf185"
+ sunrise: str = "\uf185"
+ dhuhr: str = "\uf185"
+ asr: str = "\uf185"
+ maghrib: str = "\uf186"
+ isha: str = "\uf186"
+ imsak: str = "\uf185"
+ sunset: str = "\uf185"
+ midnight: str = "\uf186"
+ default: str = "\uf017"
+
+
+class PrayerTimesMenuConfig(CustomBaseModel):
+ blur: bool = True
+ round_corners: bool = True
+ round_corners_type: str = "normal"
+ border_color: str = "System"
+ alignment: str = "right"
+ direction: str = "down"
+ offset_top: int = 6
+ offset_left: int = 0
+
+
+class PrayerTimesFlashConfig(CustomBaseModel):
+ enabled: bool = True
+ debug: bool = False
+ duration: int = Field(default=30, ge=1, le=3600)
+ interval: int = Field(default=500, ge=100, le=5000)
+ color_a: str = "#ff8c00"
+ color_b: str = "#1e1e2e"
+
+
+class PrayerTimesConfig(CustomBaseModel):
+ label: str = "{icon} {next_prayer} {next_prayer_time}"
+ label_alt: str = "Fajr {fajr} · Dhuhr {dhuhr} · Asr {asr} · Maghrib {maghrib} · Isha {isha}"
+ class_name: str = ""
+ latitude: float = Field(default=51.5074, ge=-90.0, le=90.0)
+ longitude: float = Field(default=-0.1278, ge=-180.0, le=180.0)
+ method: int = Field(default=2, ge=0, le=99)
+ school: int = Field(default=0, ge=0, le=1)
+ midnight_mode: int = Field(default=0, ge=0, le=1)
+ tune: str = ""
+ timezone: str = ""
+ shafaq: str = ""
+ prayers_to_show: list[str] = ["Fajr", "Dhuhr", "Asr", "Maghrib", "Isha"]
+ grace_period: int = Field(default=15, ge=0, le=120)
+ update_interval: int = Field(default=3600, ge=60, le=86400)
+ tooltip: bool = True
+ icons: PrayerTimesIconsConfig = PrayerTimesIconsConfig()
+ menu: PrayerTimesMenuConfig = PrayerTimesMenuConfig()
+ flash: PrayerTimesFlashConfig = PrayerTimesFlashConfig()
+ animation: AnimationConfig = AnimationConfig()
+ label_shadow: ShadowConfig = ShadowConfig()
+ container_shadow: ShadowConfig = ShadowConfig()
+ callbacks: PrayerTimesCallbacksConfig = PrayerTimesCallbacksConfig()
+ keybindings: list[KeybindingConfig] = []
diff --git a/src/core/widgets/yasb/prayer_times.py b/src/core/widgets/yasb/prayer_times.py
new file mode 100644
index 000000000..d8854794e
--- /dev/null
+++ b/src/core/widgets/yasb/prayer_times.py
@@ -0,0 +1,580 @@
+import logging
+import re
+import urllib.parse
+from datetime import datetime, timedelta
+from typing import Any
+
+from PyQt6.QtCore import QEasingCurve, Qt, QTimer, QVariantAnimation
+from PyQt6.QtGui import QColor
+from PyQt6.QtWidgets import QFrame, QHBoxLayout, QLabel, QVBoxLayout, QWidget
+
+from core.utils.tooltip import set_tooltip
+from core.utils.utilities import PopupWidget, add_shadow, build_widget_label, refresh_widget_style
+from core.utils.widgets.animation_manager import AnimationManager
+from core.utils.widgets.prayer_times.api import PrayerTimesDataFetcher
+from core.validation.widgets.yasb.prayer_times import PrayerTimesConfig
+from core.widgets.base import BaseWidget
+
+# Canonical ordering as returned by the Aladhan API.
+ALL_PRAYER_NAMES = ["Imsak", "Fajr", "Sunrise", "Dhuhr", "Asr", "Sunset", "Maghrib", "Isha", "Midnight"]
+_DEFAULT_PRAYERS: list[str] = ["Fajr", "Dhuhr", "Asr", "Maghrib", "Isha"]
+_DELTA_PASSED = "passed"
+
+# Fixed column widths for popup prayer rows (pixels).
+_POPUP_ICON_COL_W = 32
+_POPUP_NAME_COL_W = 80
+_POPUP_TIME_COL_W = 52
+
+
+class PrayerTimesWidget(BaseWidget):
+ """Widget that displays Islamic prayer times sourced from the Aladhan API.
+
+ Renders the next upcoming prayer on the bar, supports an alternate label
+ for a quick all-prayer overview, and opens a popup card with individual
+ prayer rows and Hijri date information. A configurable flash animation
+ fires at the exact minute of each prayer.
+ """
+
+ validation_schema = PrayerTimesConfig
+
+ def __init__(self, config: PrayerTimesConfig):
+ super().__init__(class_name=f"prayer-times-widget {config.class_name}")
+ self.config = config
+ self._show_alt_label = False
+ self._timings: dict[str, str] = {}
+ self._hijri: dict[str, Any] = {}
+ self._meta: dict[str, Any] = {}
+ self._popup: PopupWidget | None = None
+ self._popup_row_widgets: dict[str, dict[str, QWidget]] = {}
+ self._loading: bool = True
+ self._date_offset: int = 0
+ self._current_date: str = datetime.now().strftime("%Y-%m-%d")
+ self._widgets: list[QWidget] = []
+ self._widgets_alt: list[QWidget] = []
+
+ # --- Container ---
+ self._widget_container_layout = QHBoxLayout()
+ self._widget_container_layout.setSpacing(0)
+ self._widget_container_layout.setContentsMargins(0, 0, 0, 0)
+ self._widget_container = QFrame()
+ self._widget_container.setLayout(self._widget_container_layout)
+ self._widget_container.setProperty("class", "widget-container")
+ add_shadow(self._widget_container, config.container_shadow.model_dump())
+ self.widget_layout.addWidget(self._widget_container)
+
+ build_widget_label(self, config.label, config.label_alt, config.label_shadow.model_dump())
+
+ # --- Callbacks ---
+ self.register_callback("toggle_label", self._toggle_label)
+ self.register_callback("toggle_card", self._toggle_card)
+ self.register_callback("update_label", self._update_label)
+ self.callback_left = config.callbacks.on_left
+ self.callback_right = config.callbacks.on_right
+ self.callback_middle = config.callbacks.on_middle
+
+ # --- API fetcher ---
+ self._fetcher = PrayerTimesDataFetcher(
+ self,
+ url_factory=self._build_api_url,
+ timeout_ms=config.update_interval * 1000,
+ )
+ self._fetcher.finished.connect(self._on_data_received)
+ self._fetcher.start()
+
+ # --- Minute timer: re-render label + open popup ---
+ self._minute_timer = QTimer(self)
+ self._minute_timer.setInterval(60_000)
+ self._minute_timer.timeout.connect(self._on_minute_tick)
+ self._minute_timer.start()
+
+ # --- Flash animation ---
+ self._flash_anim = QVariantAnimation(self)
+ self._flash_anim.setEasingCurve(QEasingCurve.Type.InOutSine)
+ self._flash_anim.valueChanged.connect(self._on_flash_frame)
+ self._flash_anim.finished.connect(self._on_flash_half_done)
+ self._flash_stop_timer = QTimer(self)
+ self._flash_stop_timer.setSingleShot(True)
+ self._flash_stop_timer.timeout.connect(self._stop_flash)
+
+ # Show loading placeholder immediately before first API response
+ self._update_label()
+
+ # Trigger flash immediately for debugging
+ if config.flash.enabled and config.flash.debug:
+ QTimer.singleShot(500, self._start_flash)
+
+ # ------------------------------------------------------------------
+ # URL builder
+ # ------------------------------------------------------------------
+
+ def _build_api_url(self) -> str:
+ """Return the Aladhan timings URL for the target date (today or tomorrow)."""
+ today = (datetime.now() + timedelta(days=self._date_offset)).strftime("%d-%m-%Y")
+ params: dict[str, Any] = {
+ "latitude": self.config.latitude,
+ "longitude": self.config.longitude,
+ "method": self.config.method,
+ "school": self.config.school,
+ "midnightMode": self.config.midnight_mode,
+ }
+ if self.config.tune:
+ params["tune"] = self.config.tune
+ if self.config.timezone:
+ params["timezonestring"] = self.config.timezone
+ if self.config.shafaq:
+ params["shafaq"] = self.config.shafaq
+ return f"https://api.aladhan.com/v1/timings/{today}?{urllib.parse.urlencode(params)}"
+
+ # ------------------------------------------------------------------
+ # Helpers
+ # ------------------------------------------------------------------
+
+ @property
+ def _prayers(self) -> list[str]:
+ """Return the configured list of prayers to show, falling back to defaults."""
+ return self.config.prayers_to_show or _DEFAULT_PRAYERS
+
+ @property
+ def _icon_map(self) -> dict[str, str]:
+ """Return a mapping from prayer name to its configured icon character."""
+ ic = self.config.icons
+ return {
+ "Fajr": ic.fajr,
+ "Sunrise": ic.sunrise,
+ "Dhuhr": ic.dhuhr,
+ "Asr": ic.asr,
+ "Sunset": ic.sunset,
+ "Maghrib": ic.maghrib,
+ "Isha": ic.isha,
+ "Imsak": ic.imsak,
+ "Midnight": ic.midnight,
+ }
+
+ def _parse_hhmm(self, time_str: str) -> tuple[int, int] | None:
+ """Parse a 'HH:MM' string, returning (hour, minute) or None on failure."""
+ try:
+ return int(time_str[:2]), int(time_str[3:5])
+ except ValueError, IndexError:
+ return None
+
+ # ------------------------------------------------------------------
+ # Data handling
+ # ------------------------------------------------------------------
+
+ def _on_data_received(self, data: dict) -> None:
+ if not data:
+ return
+ try:
+ self._timings = data["data"]["timings"]
+ self._hijri = data["data"]["date"]["hijri"]
+ self._meta = data["data"].get("meta", {})
+ self._loading = False
+ self._update_label()
+ # If today's prayers are all done and we haven't switched to tomorrow yet,
+ # immediately re-fetch tomorrow's schedule.
+ if self._date_offset == 0 and self._all_prayers_passed():
+ self._date_offset = 1
+ self._fetcher.make_request()
+ except (KeyError, TypeError) as exc:
+ logging.error(f"Prayer times widget: failed to parse API response: {exc}")
+
+ def _on_minute_tick(self) -> None:
+ # Reset to today when the calendar date changes (midnight rollover).
+ today = datetime.now().strftime("%Y-%m-%d")
+ if today != self._current_date:
+ self._current_date = today
+ self._date_offset = 0
+ self._fetcher.make_request()
+ return
+ self._update_label()
+ if self.config.flash.enabled and self._check_prayer_time():
+ self._start_flash()
+ if self._popup is not None:
+ try:
+ self._refresh_popup_rows()
+ except RuntimeError:
+ self._popup = None
+
+ def _all_prayers_passed(self) -> bool:
+ """Return True if every prayer in prayers_to_show has already passed today (including grace period)."""
+ now = datetime.now()
+ grace = timedelta(minutes=self.config.grace_period)
+ for name in self._prayers:
+ time_str = self._timings.get(name, "")
+ if not time_str or time_str == "--:--":
+ continue
+ parsed = self._parse_hhmm(time_str)
+ if parsed is None:
+ continue
+ h, m = parsed
+ if now.replace(hour=h, minute=m, second=0, microsecond=0) + grace > now:
+ return False
+ return True
+
+ # ------------------------------------------------------------------
+ # Next prayer helpers
+ # ------------------------------------------------------------------
+
+ def _get_next_prayer(self) -> tuple[str, str]:
+ """Return (prayer_name, time_str) for the current or next upcoming prayer.
+
+ A prayer is considered 'current' for grace_period minutes after its time,
+ so the label doesn't immediately jump to the next prayer when the time hits.
+ """
+ if not self._timings:
+ return ("—", "--:--")
+ now = datetime.now()
+ grace = timedelta(minutes=self.config.grace_period)
+ target_date = now + timedelta(days=self._date_offset)
+ for name in self._prayers:
+ time_str = self._timings.get(name, "")
+ if not time_str or time_str == "--:--":
+ continue
+ parsed = self._parse_hhmm(time_str)
+ if parsed is None:
+ continue
+ h, m = parsed
+ if target_date.replace(hour=h, minute=m, second=0, microsecond=0) + grace > now:
+ return (name, time_str)
+ first = self._prayers[0]
+ return (first, self._timings.get(first, "--:--"))
+
+ def _time_delta_text(self, time_str: str) -> str:
+ """Return human-readable remaining/elapsed label for a prayer time string."""
+ if not time_str or time_str == "--:--":
+ return ""
+ parsed = self._parse_hhmm(time_str)
+ if parsed is None:
+ return ""
+ now = datetime.now()
+ target_date = now + timedelta(days=self._date_offset)
+ h, m = parsed
+ target = target_date.replace(hour=h, minute=m, second=0, microsecond=0)
+ delta = target - now
+ grace = timedelta(minutes=self.config.grace_period)
+ if delta.total_seconds() < 0:
+ # Within grace period: show how many minutes into the prayer we are
+ if abs(delta) < grace:
+ elapsed_min = int(abs(delta).total_seconds() // 60)
+ return f"{elapsed_min}m ago"
+ return _DELTA_PASSED
+ total_min = int(delta.total_seconds() // 60)
+ hours, mins = divmod(total_min, 60)
+ if hours > 0:
+ return f"in {hours}h {mins:02d}m"
+ return f"in {mins}m"
+
+ # ------------------------------------------------------------------
+ # Label options dict
+ # ------------------------------------------------------------------
+
+ def _build_label_options(self) -> dict[str, str]:
+ """Build the dict of {placeholder: value} for string substitution."""
+ options: dict[str, str] = {}
+ for name in ALL_PRAYER_NAMES:
+ options[f"{{{name.lower()}}}"] = self._timings.get(name, "--:--")
+ next_name, next_time = self._get_next_prayer()
+ options["{next_prayer}"] = next_name
+ options["{next_prayer_time}"] = next_time
+ options["{icon}"] = self._icon_map.get(next_name, self.config.icons.default)
+ if self._hijri:
+ options["{hijri_day}"] = self._hijri.get("day", "")
+ options["{hijri_month}"] = self._hijri.get("month", {}).get("en", "")
+ options["{hijri_year}"] = self._hijri.get("year", "")
+ options["{hijri_date}"] = f"{options['{hijri_day}']} {options['{hijri_month}']} {options['{hijri_year}']}"
+ else:
+ for k in ("{hijri_day}", "{hijri_month}", "{hijri_year}", "{hijri_date}"):
+ options[k] = ""
+ return options
+
+ # ------------------------------------------------------------------
+ # Bar label update
+ # ------------------------------------------------------------------
+
+ def _update_label(self, update_class: bool = True) -> None:
+ active_widgets = self._widgets_alt if self._show_alt_label else self._widgets
+ active_content = self.config.label_alt if self._show_alt_label else self.config.label
+ if self._loading:
+ for widget in active_widgets:
+ if isinstance(widget, QLabel):
+ widget.setText("Loading...")
+ widget.setProperty("class", "label loading")
+ refresh_widget_style(widget)
+ return
+ label_options = self._build_label_options()
+ label_parts = [p for p in re.split(r"(.*?)", active_content) if p]
+ widget_index = 0
+ for part in label_parts:
+ part = part.strip()
+ if not part or widget_index >= len(active_widgets):
+ continue
+ for placeholder, value in label_options.items():
+ part = part.replace(placeholder, str(value))
+ widget = active_widgets[widget_index]
+ if not isinstance(widget, QLabel):
+ widget_index += 1
+ continue
+ if "" in part:
+ widget.setText(re.sub(r"|", "", part).strip())
+ else:
+ widget.setText(part)
+ if update_class:
+ base = "label alt" if self._show_alt_label else "label"
+ next_name = label_options.get("{next_prayer}", "").lower()
+ widget.setProperty("class", f"{base} {next_name}")
+ refresh_widget_style(widget)
+ widget_index += 1
+ self._update_tooltip()
+
+ def _update_tooltip(self) -> None:
+ """Update the hover tooltip with a summary of today's (or tomorrow's) prayer times."""
+ if not self.config.tooltip or not self._timings:
+ return
+ next_name, _ = self._get_next_prayer()
+ label = "Tomorrow" if self._date_offset > 0 else "Today"
+ lines: list[str] = [f"{label}'s Prayers"]
+ for name in self._prayers:
+ time_str = self._timings.get(name, "--:--")
+ marker = " ◀" if name == next_name else ""
+ lines.append(f"{name}: {time_str}{marker}")
+ set_tooltip(self, "
".join(lines))
+
+ # ------------------------------------------------------------------
+ # Prayer-time flash
+ # ------------------------------------------------------------------
+
+ def _check_prayer_time(self) -> bool:
+ """Return True if the current minute matches any prayer in prayers_to_show."""
+ if not self._timings or self._loading:
+ return False
+ now = datetime.now()
+ for name in self._prayers:
+ time_str = self._timings.get(name, "")
+ if not time_str or time_str == "--:--":
+ continue
+ parsed = self._parse_hhmm(time_str)
+ if parsed is None:
+ continue
+ h, m = parsed
+ if now.hour == h and now.minute == m:
+ return True
+ return False
+
+ def _start_flash(self) -> None:
+ """Start a smooth ping-pong color animation for the configured duration."""
+ if self._flash_stop_timer.isActive():
+ return
+ flash_cfg = self.config.flash
+ self._flash_anim.stop()
+ self._flash_anim.setDuration(flash_cfg.interval)
+ self._flash_anim.setStartValue(QColor(flash_cfg.color_b))
+ self._flash_anim.setEndValue(QColor(flash_cfg.color_a))
+ self._flash_anim.start()
+ # Set label to flash text class immediately
+ active_widgets = self._widgets_alt if self._show_alt_label else self._widgets
+ base = "label alt" if self._show_alt_label else "label"
+ for widget in active_widgets:
+ if isinstance(widget, QLabel):
+ widget.setProperty("class", f"{base} flash")
+ refresh_widget_style(widget)
+ self._flash_stop_timer.start(flash_cfg.duration * 1000)
+
+ def _on_flash_half_done(self) -> None:
+ """Reverse the animation on each half-cycle to create a ping-pong effect."""
+ if not self._flash_stop_timer.isActive():
+ return
+ start = self._flash_anim.startValue()
+ end = self._flash_anim.endValue()
+ self._flash_anim.setStartValue(end)
+ self._flash_anim.setEndValue(start)
+ self._flash_anim.start()
+
+ def _on_flash_frame(self, color: QColor) -> None:
+ """Apply interpolated background color to the entire widget container each frame."""
+ hex_color = color.name()
+ self._widget_container.setStyleSheet(f"background-color: {hex_color}; border-color: {hex_color};")
+
+ def _stop_flash(self) -> None:
+ """Stop the flash animation and restore all styles."""
+ self._flash_anim.stop()
+ self._widget_container.setStyleSheet("")
+ self._update_label(update_class=True)
+
+ # ------------------------------------------------------------------
+ # Popup card
+ # ------------------------------------------------------------------
+
+ def _toggle_card(self) -> None:
+ if self.config.animation.enabled:
+ AnimationManager.animate(self, self.config.animation.type, self.config.animation.duration) # type: ignore
+ self._show_popup()
+
+ def _show_popup(self) -> None:
+ m = self.config.menu
+ self._popup = PopupWidget(self, m.blur, m.round_corners, m.round_corners_type, m.border_color)
+ self._popup.setProperty("class", "prayer-times-menu")
+ self._popup_row_widgets = {}
+
+ layout = QVBoxLayout()
+ layout.setContentsMargins(0, 0, 0, 0)
+ layout.setSpacing(0)
+ layout.addWidget(self._build_popup_header())
+
+ if self._loading:
+ loading_lbl = QLabel("Fetching prayer times...")
+ loading_lbl.setProperty("class", "loading-placeholder")
+ loading_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ layout.addWidget(loading_lbl)
+ else:
+ next_name, _ = self._get_next_prayer()
+ layout.addWidget(self._build_popup_rows(next_name))
+ footer = self._build_popup_footer()
+ if footer is not None:
+ layout.addWidget(footer)
+
+ self._popup.setLayout(layout)
+ self._popup.adjustSize()
+ self._popup.setPosition(
+ alignment=m.alignment,
+ direction=m.direction,
+ offset_left=m.offset_left,
+ offset_top=m.offset_top,
+ )
+ self._popup.show()
+
+ def _build_popup_header(self) -> QWidget:
+ """Build the popup header containing the mosque icon, title, and Hijri date."""
+ header = QWidget()
+ header.setProperty("class", "header")
+ header_layout = QHBoxLayout(header)
+ header_layout.setContentsMargins(16, 12, 16, 12)
+ header_layout.setSpacing(8)
+
+ mosque_icon = QLabel(self.config.icons.mosque)
+ mosque_icon.setProperty("class", "mosque-icon")
+ mosque_icon.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
+
+ title_text = "Tomorrow's Prayers" if self._date_offset > 0 else "Prayer Times"
+ title_lbl = QLabel(title_text)
+ title_lbl.setProperty("class", "title")
+ title_lbl.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
+
+ header_layout.addWidget(mosque_icon)
+ header_layout.addWidget(title_lbl)
+ header_layout.addStretch()
+
+ if self._hijri:
+ month_en = self._hijri.get("month", {}).get("en", "")
+ hijri_lbl = QLabel(f"{self._hijri.get('day', '')} {month_en} {self._hijri.get('year', '')} AH")
+ hijri_lbl.setProperty("class", "hijri-date")
+ hijri_lbl.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight)
+ header_layout.addWidget(hijri_lbl)
+
+ return header
+
+ def _build_popup_rows(self, next_name: str) -> QWidget:
+ """Build the prayer rows container and populate _popup_row_widgets."""
+ icon_map = self._icon_map
+ ic = self.config.icons
+
+ rows_container = QWidget()
+ rows_container.setProperty("class", "rows-container")
+ rows_layout = QVBoxLayout(rows_container)
+ rows_layout.setContentsMargins(0, 0, 0, 0)
+ rows_layout.setSpacing(0)
+
+ for name in self._prayers:
+ time_str = self._timings.get(name, "--:--")
+ delta_text = self._time_delta_text(time_str)
+ is_next = name == next_name
+ is_passed = delta_text == _DELTA_PASSED
+
+ row = QFrame()
+ row_class = "prayer-row"
+ if is_next:
+ row_class += " active"
+ elif is_passed:
+ row_class += " passed"
+ row.setProperty("class", row_class)
+ row_layout = QHBoxLayout(row)
+ row_layout.setContentsMargins(16, 10, 16, 10)
+ row_layout.setSpacing(10)
+
+ icon_lbl = QLabel(icon_map.get(name, ic.default))
+ icon_lbl.setProperty("class", "prayer-icon")
+ icon_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ icon_lbl.setFixedWidth(_POPUP_ICON_COL_W)
+
+ name_lbl = QLabel(name)
+ name_lbl.setProperty("class", "prayer-name")
+ name_lbl.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
+ name_lbl.setFixedWidth(_POPUP_NAME_COL_W)
+
+ time_lbl = QLabel(time_str)
+ time_lbl.setProperty("class", "prayer-time")
+ time_lbl.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignLeft)
+ time_lbl.setFixedWidth(_POPUP_TIME_COL_W)
+
+ remaining_lbl = QLabel(delta_text)
+ remaining_lbl.setProperty("class", "prayer-remaining")
+ remaining_lbl.setAlignment(Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight)
+
+ row_layout.addWidget(icon_lbl)
+ row_layout.addWidget(name_lbl)
+ row_layout.addWidget(time_lbl)
+ row_layout.addStretch()
+ row_layout.addWidget(remaining_lbl)
+
+ rows_layout.addWidget(row)
+ self._popup_row_widgets[name] = {"row": row, "remaining": remaining_lbl}
+
+ return rows_container
+
+ def _build_popup_footer(self) -> QWidget | None:
+ """Build the popup footer showing the calculation method name, or None if unavailable."""
+ method_name = self._meta.get("method", {}).get("name", "")
+ if not method_name:
+ return None
+ footer = QWidget()
+ footer.setProperty("class", "footer")
+ footer_layout = QHBoxLayout(footer)
+ footer_layout.setContentsMargins(16, 8, 16, 8)
+ method_lbl = QLabel(method_name)
+ method_lbl.setProperty("class", "method-name")
+ method_lbl.setAlignment(Qt.AlignmentFlag.AlignCenter)
+ footer_layout.addWidget(method_lbl)
+ return footer
+
+ def _refresh_popup_rows(self) -> None:
+ """Update remaining-time labels and active/passed CSS classes every minute."""
+ if not self._popup_row_widgets:
+ return
+ next_name, _ = self._get_next_prayer()
+ for name, widgets in self._popup_row_widgets.items():
+ row: QFrame = widgets["row"] # type: ignore
+ remaining_lbl: QLabel = widgets["remaining"] # type: ignore
+ time_str = self._timings.get(name, "--:--")
+ delta_text = self._time_delta_text(time_str)
+ remaining_lbl.setText(delta_text)
+ row_class = "prayer-row"
+ if name == next_name:
+ row_class += " active"
+ elif delta_text == _DELTA_PASSED:
+ row_class += " passed"
+ row.setProperty("class", row_class)
+ refresh_widget_style(row)
+ refresh_widget_style(remaining_lbl)
+
+ # ------------------------------------------------------------------
+ # Toggle
+ # ------------------------------------------------------------------
+
+ def _toggle_label(self) -> None:
+ if self.config.animation.enabled:
+ AnimationManager.animate(self, self.config.animation.type, self.config.animation.duration) # type: ignore
+ self._show_alt_label = not self._show_alt_label
+ for widget in self._widgets:
+ widget.setVisible(not self._show_alt_label)
+ for widget in self._widgets_alt:
+ widget.setVisible(self._show_alt_label)
+ self._update_label()