Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ All notable changes to this project will be documented in this file.
## [0.4.5] - 2026-03-26

### Added
- **Optional in-app alert detail popups** — while AccessiWeather is already running, you can now choose to open newly eligible alert details immediately instead of only getting toast notifications (#578)
- **Pirate Weather full integration** — Pirate Weather is now a first-class data source with alerts, hourly and minutely forecasts, and automatic fusion alongside NWS and Open-Meteo (#479)
- **AVWX aviation source** — TAF and METAR data from AVWX for international locations (#480)
- **All Locations summary view** — see a compact weather overview for all your saved locations at once (#518)
Expand Down
24 changes: 24 additions & 0 deletions src/accessiweather/alert_notification_system.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@

import asyncio
import logging
from collections.abc import Callable

from .alert_lifecycle import AlertLifecycleDiff
from .alert_manager import AlertManager, AlertSettings
Expand Down Expand Up @@ -134,11 +135,13 @@ def __init__(
alert_manager: AlertManager,
notifier: SafeDesktopNotifier | None = None,
settings: AppSettings | None = None,
on_alerts_popup: Callable[[list[WeatherAlert]], None] | None = None,
):
"""Initialize the instance."""
self.alert_manager = alert_manager
self.notifier = notifier or SafeDesktopNotifier()
self.settings = settings
self.on_alerts_popup = on_alerts_popup

logger.info("AlertNotificationSystem initialized")

Expand Down Expand Up @@ -182,6 +185,8 @@ async def process_and_notify(self, alerts: WeatherAlerts) -> int:
reverse=True,
)

self._trigger_immediate_alert_popup_if_enabled(sorted_notifications)

# Send notifications - only play sound for the first (most severe) one
notifications_sent = 0
for i, (alert, reason) in enumerate(sorted_notifications):
Expand All @@ -207,6 +212,25 @@ async def process_and_notify(self, alerts: WeatherAlerts) -> int:
logger.error(f"Error processing alert notifications: {e}")
return 0

def _trigger_immediate_alert_popup_if_enabled(
self,
sorted_notifications: list[tuple[WeatherAlert, str]],
) -> None:
"""Open in-app alert popups for the current eligible batch when opted in."""
if not getattr(self.settings, "immediate_alert_details_popups", False):
return
if not callable(self.on_alerts_popup):
return

popup_alerts = [alert for alert, _reason in sorted_notifications]
if not popup_alerts:
return

try:
self.on_alerts_popup(popup_alerts)
except Exception as exc:
logger.error("Failed to trigger immediate alert popup: %s", exc)

async def _send_alert_notification(
self, alert: WeatherAlert, reason: str, play_sound: bool = True
) -> bool:
Expand Down
31 changes: 31 additions & 0 deletions src/accessiweather/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,20 @@
logger = logging.getLogger(__name__)


def show_alert_dialog(parent, alert) -> None:
"""Lazy wrapper for the single-alert details dialog."""
from .ui.dialogs import show_alert_dialog as _show_alert_dialog

_show_alert_dialog(parent, alert)


def show_alerts_summary_dialog(parent, alerts) -> None:
"""Lazy wrapper for the combined multi-alert dialog."""
from .ui.dialogs import show_alerts_summary_dialog as _show_alerts_summary_dialog

_show_alerts_summary_dialog(parent, alerts)


class AccessiWeatherApp(wx.App):
"""AccessiWeather application using wxPython."""

Expand Down Expand Up @@ -251,6 +265,23 @@ def _schedule_startup_activation_request(self) -> None:
if self._activation_request is not None:
wx.CallAfter(self._handle_notification_activation_request, self._activation_request)

def _queue_immediate_alert_popup(self, alerts) -> None:
"""Queue an in-app alert popup onto the UI thread."""
if not alerts:
return
wx.CallAfter(self._show_immediate_alert_popup, list(alerts))

def _show_immediate_alert_popup(self, alerts) -> None:
"""Show the opted-in in-app alert popup without restoring the main window."""
if self.main_window is None or not alerts:
return

if len(alerts) == 1:
show_alert_dialog(self.main_window, alerts[0])
return

show_alerts_summary_dialog(self.main_window, alerts)

def _wire_notifier_activation_callback(self) -> None:
"""Connect the notifier's in-process activation callback to the UI thread."""
notifier = self._notifier
Expand Down
5 changes: 4 additions & 1 deletion src/accessiweather/app_initialization.py
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,10 @@ def initialize_components(app: AccessiWeatherApp) -> None:
runtime_state_manager=app.runtime_state_manager,
)
app.alert_notification_system = AlertNotificationSystem(
app.alert_manager, app._notifier, config.settings
app.alert_manager,
app._notifier,
config.settings,
on_alerts_popup=getattr(app, "_queue_immediate_alert_popup", None),
)

# Defer weather history service initialization
Expand Down
6 changes: 6 additions & 0 deletions src/accessiweather/models/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
"alert_notify_moderate",
"alert_notify_minor",
"alert_notify_unknown",
"immediate_alert_details_popups",
"alert_global_cooldown_minutes",
"alert_per_alert_cooldown_minutes",
"alert_escalation_cooldown_minutes",
Expand Down Expand Up @@ -134,6 +135,7 @@ class AppSettings:
alert_notify_moderate: bool = True
alert_notify_minor: bool = False
alert_notify_unknown: bool = False
immediate_alert_details_popups: bool = False
alert_global_cooldown_minutes: int = 5
alert_per_alert_cooldown_minutes: int = 60
alert_escalation_cooldown_minutes: int = 15
Expand Down Expand Up @@ -433,6 +435,7 @@ def to_dict(self) -> dict:
"alert_notify_moderate": self.alert_notify_moderate,
"alert_notify_minor": self.alert_notify_minor,
"alert_notify_unknown": self.alert_notify_unknown,
"immediate_alert_details_popups": self.immediate_alert_details_popups,
"alert_global_cooldown_minutes": self.alert_global_cooldown_minutes,
"alert_per_alert_cooldown_minutes": self.alert_per_alert_cooldown_minutes,
"alert_escalation_cooldown_minutes": self.alert_escalation_cooldown_minutes,
Expand Down Expand Up @@ -519,6 +522,9 @@ def from_dict(cls, data: dict) -> AppSettings:
alert_notify_moderate=cls._as_bool(data.get("alert_notify_moderate"), True),
alert_notify_minor=cls._as_bool(data.get("alert_notify_minor"), False),
alert_notify_unknown=cls._as_bool(data.get("alert_notify_unknown"), False),
immediate_alert_details_popups=cls._as_bool(
data.get("immediate_alert_details_popups"), False
),
alert_global_cooldown_minutes=data.get("alert_global_cooldown_minutes", 5),
alert_per_alert_cooldown_minutes=data.get("alert_per_alert_cooldown_minutes", 60),
alert_escalation_cooldown_minutes=data.get("alert_escalation_cooldown_minutes", 15),
Expand Down
3 changes: 3 additions & 0 deletions src/accessiweather/ui/dialogs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

from .air_quality_dialog import show_air_quality_dialog
from .alert_dialog import show_alert_dialog
from .alerts_summary_dialog import show_alerts_summary_dialog
from .aviation_dialog import show_aviation_dialog
from .discussion_dialog import show_discussion_dialog
from .explanation_dialog import show_explanation_dialog
Expand All @@ -19,6 +20,7 @@
"show_add_location_dialog",
"show_air_quality_dialog",
"show_alert_dialog",
"show_alerts_summary_dialog",
"show_aviation_dialog",
"show_discussion_dialog",
"show_explanation_dialog",
Expand All @@ -36,6 +38,7 @@
_LAZY_IMPORTS = {
"show_air_quality_dialog": ".air_quality_dialog",
"show_alert_dialog": ".alert_dialog",
"show_alerts_summary_dialog": ".alerts_summary_dialog",
"show_aviation_dialog": ".aviation_dialog",
"show_discussion_dialog": ".discussion_dialog",
"show_explanation_dialog": ".explanation_dialog",
Expand Down
103 changes: 103 additions & 0 deletions src/accessiweather/ui/dialogs/alerts_summary_dialog.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
"""Combined dialog for multiple newly eligible weather alerts."""

from __future__ import annotations

import logging

import wx

logger = logging.getLogger(__name__)


def show_alerts_summary_dialog(parent, alerts) -> None:
"""Show a combined summary dialog for multiple alerts."""
try:
dlg = AlertsSummaryDialog(parent, alerts)
dlg.ShowModal()
dlg.Destroy()
except Exception as exc:
logger.error("Failed to show alerts summary dialog: %s", exc)
wx.MessageBox(
f"Failed to open the alerts summary: {exc}",
"Error",
wx.OK | wx.ICON_ERROR,
)


class AlertsSummaryDialog(wx.Dialog):
"""Dialog listing multiple newly eligible alerts in one place."""

def __init__(self, parent, alerts):
"""Initialize the dialog with the current batch of newly eligible alerts."""
super().__init__(
parent,
title="New Weather Alerts",
size=(720, 480),
style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER,
)
self.alerts = list(alerts)
self._create_ui()
self._setup_accessibility()
self.Bind(wx.EVT_CHAR_HOOK, self._on_key)

def _create_ui(self) -> None:
panel = wx.Panel(self)
main_sizer = wx.BoxSizer(wx.VERTICAL)

intro = wx.StaticText(
panel,
label="Multiple new alerts arrived in this update. Review the summaries below.",
)
main_sizer.Add(intro, 0, wx.ALL, 15)

self.summary_ctrl = wx.TextCtrl(
panel,
value=self._build_summary_text(),
style=wx.TE_MULTILINE | wx.TE_READONLY | wx.TE_RICH2,
)
main_sizer.Add(self.summary_ctrl, 1, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 15)

button_sizer = wx.BoxSizer(wx.HORIZONTAL)
button_sizer.AddStretchSpacer()

close_btn = wx.Button(panel, wx.ID_CLOSE, "Close")
close_btn.Bind(wx.EVT_BUTTON, self._on_close)
button_sizer.Add(close_btn, 0)

main_sizer.Add(button_sizer, 0, wx.EXPAND | wx.ALL, 15)
panel.SetSizer(main_sizer)
self.summary_ctrl.SetFocus()

def _build_summary_text(self) -> str:
sections: list[str] = []
for index, alert in enumerate(self.alerts, start=1):
header = (
getattr(alert, "headline", None) or getattr(alert, "event", None) or "Weather Alert"
)
details = [
f"{index}. {header}",
f"Severity: {getattr(alert, 'severity', 'Unknown')}",
]
urgency = getattr(alert, "urgency", None)
if urgency:
details.append(f"Urgency: {urgency}")
certainty = getattr(alert, "certainty", None)
if certainty:
details.append(f"Certainty: {certainty}")
description = getattr(alert, "description", None)
if description:
details.append(f"Details: {description}")
sections.append("\n".join(details))
return "\n\n".join(sections)

def _setup_accessibility(self) -> None:
self.summary_ctrl.SetName("New alert summaries")

def _on_key(self, event) -> None:
if event.GetKeyCode() == wx.WXK_ESCAPE:
self.Close()
return
event.Skip()

def _on_close(self, _event) -> None:
self.EndModal(wx.ID_CLOSE)
11 changes: 11 additions & 0 deletions src/accessiweather/ui/dialogs/settings_tabs/notifications.py
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,12 @@ def create(self):
controls["alert_notif"] = wx.CheckBox(panel, label="Enable alert notifications")
sizer.Add(controls["alert_notif"], 0, wx.LEFT | wx.BOTTOM, 5)

controls["immediate_alert_details_popups"] = wx.CheckBox(
panel,
label="Open alert details popups immediately while AccessiWeather is running",
)
sizer.Add(controls["immediate_alert_details_popups"], 0, wx.LEFT | wx.BOTTOM, 5)

row_area = wx.BoxSizer(wx.HORIZONTAL)
row_area.Add(
wx.StaticText(panel, label="Alert Area:"),
Expand Down Expand Up @@ -164,6 +170,9 @@ def load(self, settings):
controls["notify_moderate"].SetValue(getattr(settings, "alert_notify_moderate", True))
controls["notify_minor"].SetValue(getattr(settings, "alert_notify_minor", False))
controls["notify_unknown"].SetValue(getattr(settings, "alert_notify_unknown", False))
controls["immediate_alert_details_popups"].SetValue(
getattr(settings, "immediate_alert_details_popups", False)
)

controls["global_cooldown"].SetValue(getattr(settings, "alert_global_cooldown_minutes", 5))
controls["per_alert_cooldown"].SetValue(
Expand Down Expand Up @@ -201,6 +210,7 @@ def save(self) -> dict:
"alert_notify_moderate": controls["notify_moderate"].GetValue(),
"alert_notify_minor": controls["notify_minor"].GetValue(),
"alert_notify_unknown": controls["notify_unknown"].GetValue(),
"immediate_alert_details_popups": controls["immediate_alert_details_popups"].GetValue(),
"alert_global_cooldown_minutes": controls["global_cooldown"].GetValue(),
"alert_per_alert_cooldown_minutes": controls["per_alert_cooldown"].GetValue(),
"alert_freshness_window_minutes": controls["freshness_window"].GetValue(),
Expand All @@ -227,6 +237,7 @@ def setup_accessibility(self):
"notify_moderate": "Moderate - Potentially hazardous (e.g., Winter Weather Advisory)",
"notify_minor": "Minor - Low impact events (e.g., Frost Advisory, Fog Advisory)",
"notify_unknown": "Unknown - Uncategorized alerts",
"immediate_alert_details_popups": "Open alert details popups immediately while AccessiWeather is running",
"notify_discussion_update": "Notify when Area Forecast Discussion is updated (NWS US only)",
"notify_severe_risk_change": "Notify when severe weather risk level changes (Visual Crossing only)",
"notify_minutely_precipitation_start": "Notify when precipitation is expected to start soon (Pirate Weather)",
Expand Down
Loading
Loading