Skip to content
Open
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 @@ -10,6 +10,7 @@ All notable changes to this project will be documented in this file.
- NOAA Weather Radio now opens independently of saved weather locations and keeps playing after you close the player until you press Stop or exit the app.
- The NOAA Weather Radio Station Finder now has clearer modes for searching all stations, browsing by full state or territory name, and finding nearby stations by coordinates.
- NOAA Weather Radio stations can now be saved as favorites, with a Favorites finder mode and optional nearby-station lookup from your saved AccessiWeather locations.
- Saved locations can now be reordered manually from the Location menu, and that custom order is available as a saved-location display mode.

### Fixed
- Screen reader announcements work again in installed builds — the Weather Assistant now auto-reads replies, and other spoken announcements are back. The installer was shipping without the speech library's native component, so announcements were silently skipped.
Expand Down
4 changes: 4 additions & 0 deletions src/accessiweather/config/config_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,10 @@ def remove_location(self, name: str) -> bool:
"""Remove a location."""
return self._locations.remove_location(name)

def reorder_locations(self, ordered_names: list[str]) -> bool:
"""Persist a new saved-location order."""
return self._locations.reorder_locations(ordered_names)

def set_current_location(self, name: str) -> bool:
"""Set the current location."""
return self._locations.set_current_location(name)
Expand Down
55 changes: 36 additions & 19 deletions src/accessiweather/config/locations.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@
from math import isclose
from typing import TYPE_CHECKING

from ..location_sorting import location_name_sort_key
from ..models import Location

if TYPE_CHECKING:
Expand Down Expand Up @@ -85,7 +84,6 @@ def add_location(
marine_mode=marine_mode,
)
config.locations.append(new_location)
self._sort_locations(config)

if config.current_location is None:
config.current_location = new_location
Expand Down Expand Up @@ -138,7 +136,6 @@ async def add_location_with_enrichment(
self.logger.debug("Zone enrichment raised unexpectedly for %s: %s", name, exc)

config.locations.append(new_location)
self._sort_locations(config)

if config.current_location is None:
config.current_location = new_location
Expand All @@ -155,10 +152,6 @@ def _get_zone_enrichment_service(self) -> ZoneEnrichmentService:
self._zone_enrichment_service = ZoneEnrichmentService()
return self._zone_enrichment_service

def _sort_locations(self, config) -> None:
"""Keep saved locations in alphabetical order."""
config.locations.sort(key=location_name_sort_key)

def update_zone_metadata(self, location_name: str, fields: dict[str, str]) -> bool:
"""
Apply zone-metadata updates to an existing location and persist.
Expand Down Expand Up @@ -275,9 +268,6 @@ def update_location_details(
if current_matches_location:
config.current_location = location

if new_name != name:
self._sort_locations(config)

self.logger.info(
"Updated location details for %s%s%s",
name,
Expand All @@ -296,7 +286,6 @@ def remove_location(self, name: str) -> bool:
for index, location in enumerate(config.locations):
if location.name == name:
config.locations.pop(index)
self._sort_locations(config)

if config.current_location and config.current_location.name == name:
config.current_location = None
Expand All @@ -312,6 +301,37 @@ def remove_location(self, name: str) -> bool:
self.logger.warning(f"Location {name} not found")
return False

def reorder_locations(self, ordered_names: list[str]) -> bool:
"""Persist a new saved-location order by location name."""
config = self._manager.get_config()
visible_locations = [
location for location in config.locations if location.name != "Nationwide"
]
current_names = [location.name for location in visible_locations]

if ordered_names == current_names:
return True

if len(ordered_names) != len(current_names) or set(ordered_names) != set(current_names):
self.logger.warning("Rejected saved-location reorder with mismatched names")
return False

locations_by_name = {location.name: location for location in visible_locations}
reordered_locations = [locations_by_name[name] for name in ordered_names]
hidden_locations = [
location for location in config.locations if location.name == "Nationwide"
]
config.locations = reordered_locations + hidden_locations

if (
config.current_location is not None
and config.current_location.name in locations_by_name
):
config.current_location = locations_by_name[config.current_location.name]

self.logger.info("Reordered saved locations: %s", ordered_names)
return self._manager.save_config()

def set_current_location(self, name: str) -> bool:
"""Set the current location by name."""
config = self._manager.get_config()
Expand All @@ -334,14 +354,11 @@ def get_current_location(self) -> Location | None:

def get_all_locations(self) -> list[Location]:
"""Return configured locations without synthetic nationwide entries."""
return sorted(
[
location
for location in self._manager.get_config().locations.copy()
if location.name != "Nationwide"
],
key=location_name_sort_key,
)
return [
location
for location in self._manager.get_config().locations.copy()
if location.name != "Nationwide"
]

def get_location_names(self) -> list[str]:
"""Return the list of configured location names."""
Expand Down
12 changes: 10 additions & 2 deletions src/accessiweather/location_sorting.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,14 @@
from .models import Location

LOCATION_SORT_ALPHABETICAL = "alphabetical"
LOCATION_SORT_MANUAL = "manual"
LOCATION_SORT_NEAREST_CURRENT = "nearest_current"


def normalize_location_sort_order(value: object) -> str:
"""Return a supported location sort order."""
if value == LOCATION_SORT_MANUAL:
return LOCATION_SORT_MANUAL
if value == LOCATION_SORT_NEAREST_CURRENT:
return LOCATION_SORT_NEAREST_CURRENT
return LOCATION_SORT_ALPHABETICAL
Expand All @@ -32,8 +35,13 @@ def sort_locations_for_display(
anchor: Location | None = None,
) -> list[Location]:
"""Sort saved locations for user-facing lists."""
sorted_locations = sorted(locations, key=location_name_sort_key)
if normalize_location_sort_order(sort_order) != LOCATION_SORT_NEAREST_CURRENT or anchor is None:
base_locations = list(locations)
normalized_sort_order = normalize_location_sort_order(sort_order)
if normalized_sort_order == LOCATION_SORT_MANUAL:
return base_locations

sorted_locations = sorted(base_locations, key=location_name_sort_key)
if normalized_sort_order != LOCATION_SORT_NEAREST_CURRENT or anchor is None:
return sorted_locations

return sorted(
Expand Down
3 changes: 1 addition & 2 deletions src/accessiweather/models/config_app.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@

from dataclasses import dataclass

from ..location_sorting import location_name_sort_key
from .config_settings import AppSettings
from .weather import Location

Expand Down Expand Up @@ -37,7 +36,7 @@ def to_dict(self) -> dict:
**({"fire_zone_id": loc.fire_zone_id} if loc.fire_zone_id else {}),
**({"radar_station": loc.radar_station} if loc.radar_station else {}),
}
for loc in sorted(self.locations, key=location_name_sort_key)
for loc in self.locations
if loc.name != _LEGACY_NATIONWIDE_LOCATION_NAME
],
"current_location": {
Expand Down
2 changes: 1 addition & 1 deletion src/accessiweather/models/config_settings.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ class AppSettings(AppSettingsValidationMixin, AppSettingsSerializationMixin):
# bottom button panel. Takes effect on app restart.
location_buttons_on_top: bool = False
# Saved location ordering in user-facing lists.
location_sort_order: str = "alphabetical" # "alphabetical" | "nearest_current"
location_sort_order: str = "alphabetical" # "alphabetical" | "manual" | "nearest_current"
# Date format preset for rendered dates
date_format: str = "iso" # "iso" | "us_short" | "us_long" | "eu"
# Taskbar icon text options
Expand Down
2 changes: 1 addition & 1 deletion src/accessiweather/models/config_validation.py
Original file line number Diff line number Diff line change
Expand Up @@ -222,7 +222,7 @@ def validate_on_access(self, setting_name: str) -> bool:
setattr(settings, setting_name, "separate")

elif setting_name == "location_sort_order":
valid_orders = {"alphabetical", "nearest_current"}
valid_orders = {"alphabetical", "manual", "nearest_current"}
if value not in valid_orders:
setattr(settings, setting_name, "alphabetical")

Expand Down
8 changes: 7 additions & 1 deletion src/accessiweather/ui/dialogs/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,11 @@
from .discussion_dialog import show_discussion_dialog
from .explanation_dialog import show_explanation_dialog
from .forecast_products_dialog import show_forecast_products_dialog
from .location_dialog import show_add_location_dialog, show_edit_location_dialog
from .location_dialog import (
show_add_location_dialog,
show_edit_location_dialog,
show_reorder_locations_dialog,
)
from .national_products_dialog import NationalProductsDialog, show_national_products_dialog
from .noaa_radio_dialog import NOAARadioDialog, show_noaa_radio_dialog
from .precipitation_timeline_dialog import show_precipitation_timeline_dialog
Expand All @@ -22,6 +26,7 @@
__all__ = [
"show_add_location_dialog",
"show_edit_location_dialog",
"show_reorder_locations_dialog",
"show_advanced_text_product_dialog",
"show_air_quality_dialog",
"show_alert_dialog",
Expand Down Expand Up @@ -55,6 +60,7 @@
"show_national_products_dialog": ".national_products_dialog",
"show_add_location_dialog": ".location_dialog",
"show_edit_location_dialog": ".location_dialog",
"show_reorder_locations_dialog": ".location_dialog",
"NOAARadioDialog": ".noaa_radio_dialog",
"show_noaa_radio_dialog": ".noaa_radio_dialog",
"show_precipitation_timeline_dialog": ".precipitation_timeline_dialog",
Expand Down
132 changes: 132 additions & 0 deletions src/accessiweather/ui/dialogs/location_dialog.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,34 @@ def show_edit_location_dialog(
return None


def show_reorder_locations_dialog(parent, app: AccessiWeatherApp) -> list[str] | None:
"""
Show a dialog that lets users reorder saved locations manually.

Returns the ordered location names on OK, or ``None`` if the user cancelled.
"""
try:
dlg = ReorderLocationsDialog(parent, app)
result = dlg.ShowModal()

if result == wx.ID_OK:
ordered_names = dlg.get_result()
dlg.Destroy()
return ordered_names

dlg.Destroy()
return None

except Exception as e:
logger.error(f"Failed to show reorder locations dialog: {e}")
wx.MessageBox(
f"Failed to open reorder locations dialog: {e}",
"Error",
wx.OK | wx.ICON_ERROR,
)
return None


_ZONE_NOT_RESOLVED_MSG = "Not yet resolved - will populate after next weather refresh"


Expand Down Expand Up @@ -472,6 +500,110 @@ def get_result(self) -> EditLocationResult:
)


class ReorderLocationsDialog(wx.Dialog):
"""Dialog for choosing a custom saved-location order."""

def __init__(self, parent, app: AccessiWeatherApp):
"""Initialize the reorder dialog and load the current saved-location order."""
super().__init__(
parent,
title="Reorder Saved Locations",
style=wx.DEFAULT_DIALOG_STYLE | wx.RESIZE_BORDER,
)
self.app = app
config_manager = getattr(app, "config_manager", None)
self._ordered_names = (
config_manager.get_location_names()
if config_manager is not None and hasattr(config_manager, "get_location_names")
else []
)

panel = wx.Panel(self)
root = wx.BoxSizer(wx.VERTICAL)
root.Add(
wx.StaticText(
panel,
label=(
"Choose the saved-location order you want. "
"Saving here switches Saved location order to Manual (custom)."
),
),
0,
wx.ALL | wx.EXPAND,
10,
)

content = wx.BoxSizer(wx.HORIZONTAL)
self._locations_list = wx.ListBox(panel, choices=self._ordered_names, style=wx.LB_SINGLE)
self._locations_list.SetName("Saved locations order")
content.Add(self._locations_list, 1, wx.ALL | wx.EXPAND, 10)

button_column = wx.BoxSizer(wx.VERTICAL)
self._move_up_button = wx.Button(panel, label="Move Up")
self._move_up_button.SetName("Move selected saved location up")
self._move_up_button.Bind(wx.EVT_BUTTON, lambda event: self._move_selection(-1))
button_column.Add(self._move_up_button, 0, wx.BOTTOM | wx.EXPAND, 8)

self._move_down_button = wx.Button(panel, label="Move Down")
self._move_down_button.SetName("Move selected saved location down")
self._move_down_button.Bind(wx.EVT_BUTTON, lambda event: self._move_selection(1))
button_column.Add(self._move_down_button, 0, wx.EXPAND)
content.Add(button_column, 0, wx.TOP | wx.RIGHT | wx.BOTTOM, 10)
root.Add(content, 1, wx.EXPAND)

self._status_text = wx.StaticText(
panel,
label="Use Move Up and Move Down to arrange your saved locations.",
)
root.Add(self._status_text, 0, wx.LEFT | wx.RIGHT | wx.BOTTOM | wx.EXPAND, 10)

root.Add(self.CreateSeparatedButtonSizer(wx.OK | wx.CANCEL), 0, wx.ALL | wx.EXPAND, 10)
panel.SetSizer(root)

if self._ordered_names:
self._locations_list.SetSelection(0)
self._locations_list.Bind(wx.EVT_LISTBOX, self._on_selection_changed)
self._refresh_move_buttons()
self.SetMinSize(wx.Size(420, 320))
self.SetSize(wx.Size(520, 360))
self.CentreOnParent()

def _on_selection_changed(self, event: wx.CommandEvent) -> None:
self._refresh_move_buttons()
event.Skip()

def _refresh_move_buttons(self) -> None:
selection = self._locations_list.GetSelection()
has_selection = selection != wx.NOT_FOUND
self._move_up_button.Enable(has_selection and selection > 0)
self._move_down_button.Enable(
has_selection and 0 <= selection < len(self._ordered_names) - 1
)

def _move_selection(self, direction: int) -> None:
selection = self._locations_list.GetSelection()
if selection == wx.NOT_FOUND:
return

new_index = selection + direction
if new_index < 0 or new_index >= len(self._ordered_names):
return

self._ordered_names[selection], self._ordered_names[new_index] = (
self._ordered_names[new_index],
self._ordered_names[selection],
)
self._locations_list.Set(self._ordered_names)
self._locations_list.SetSelection(new_index)
moved_name = self._ordered_names[new_index]
self._status_text.SetLabel(f"{moved_name} moved to position {new_index + 1}.")
self._refresh_move_buttons()

def get_result(self) -> list[str]:
"""Return the saved-location order chosen by the user."""
return list(self._ordered_names)


class AddLocationDialog(wx.Dialog):
"""Dialog for adding a new location with search functionality."""

Expand Down
5 changes: 3 additions & 2 deletions src/accessiweather/ui/dialogs/settings_tabs/display.py
Original file line number Diff line number Diff line change
Expand Up @@ -21,10 +21,11 @@
_ALERT_DISPLAY_MAP = {"separate": 0, "combined": 1}
_ALERT_DISPLAY_VALUES = ["separate", "combined"]
_ALERT_DISPLAY_CHOICES = ["Separate fields (default)", "Single combined view"]
_LOCATION_SORT_MAP = {"alphabetical": 0, "nearest_current": 1}
_LOCATION_SORT_VALUES = ["alphabetical", "nearest_current"]
_LOCATION_SORT_MAP = {"alphabetical": 0, "manual": 1, "nearest_current": 2}
_LOCATION_SORT_VALUES = ["alphabetical", "manual", "nearest_current"]
_LOCATION_SORT_CHOICES = [
"Alphabetical (default)",
"Manual (custom)",
"Nearest to current location",
]

Expand Down
5 changes: 4 additions & 1 deletion src/accessiweather/ui/main_window.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,10 @@
from ..runtime_env import is_compiled_runtime # noqa: F401 - public monkeypatch surface
from ..screen_reader import ScreenReaderAnnouncer
from ..user_manual import open_user_manual # noqa: F401 - public monkeypatch surface
from .dialogs.location_dialog import show_edit_location_dialog # noqa: F401
from .dialogs.location_dialog import ( # noqa: F401
show_edit_location_dialog,
show_reorder_locations_dialog,
)
from .main_window_commands import MainWindowCommandMixin
from .main_window_display import MainWindowDisplayMixin
from .main_window_locations import MainWindowLocationMixin
Expand Down
Loading