docs(sdd): add delta specs for the functional redesign

This commit is contained in:
2026-07-28 18:06:46 +02:00
parent f58cf8739f
commit 433373ad7f
9 changed files with 729 additions and 0 deletions
@@ -0,0 +1,75 @@
# Alarm Vacation Ranges Specification
## Purpose
Active vacation-range detection, per-alarm pause-impact computation,
upcoming ranges, past-ranges history, and the summary-row entry point from
the Alarmas root into a dedicated Vacaciones manager screen. Traceable to
WU8 (summary row), WU9 (manager screen + `EstadoAlarmas` queries).
## Requirements
### Requirement: Active Range Detection
`EstadoAlarmas` MUST expose a query returning the currently-active vacation
range (if today falls within one), including days remaining. It MUST
return no active range when today falls within none.
#### Scenario: Today falls within a range
- GIVEN a vacation range whose start-to-end span includes today
- WHEN the active-range query runs
- THEN it returns that range with a computed days-remaining count
#### Scenario: No active range
- GIVEN no configured range includes today
- WHEN the active-range query runs
- THEN it returns no active range
### Requirement: Per-Alarm Pause-Impact Computation
For the currently-active range, the system MUST compute which alarms are
paused (`sonarEnVacaciones == false`) and which are unaffected
(`sonarEnVacaciones == true`), producing data sufficient to render an
impact line (e.g. "Pausa 07:30 y 13:45 · 08:15 sigue").
#### Scenario: Mixed paused and unaffected alarms
- GIVEN 3 alarms exist, 2 with `sonarEnVacaciones == false` and 1 with `true`, during an active range
- WHEN the impact computation runs
- THEN it lists exactly those 2 as paused and exactly that 1 as unaffected
### Requirement: Upcoming Ranges Query
`EstadoAlarmas` MUST expose a query returning ranges that have not yet
started, ordered soonest-first.
#### Scenario: Two future ranges
- GIVEN 2 ranges with future start dates
- WHEN the upcoming-ranges query runs
- THEN both are returned, ordered by soonest start date first
### Requirement: Past-Ranges History Query
`EstadoAlarmas` MUST expose a query returning ranges whose end date has
already passed, distinct from the active and upcoming queries.
#### Scenario: A range that ended yesterday
- GIVEN a range whose end date was yesterday
- WHEN the past-ranges query runs
- THEN that range appears in the past-ranges result and in neither the active nor upcoming result
### Requirement: Vacation Summary Row Replaces the Inline Panel
The Alarmas root MUST show a summary row (range count and next-range
countdown) in place of the current full inline vacation-ranges list.
Tapping the row MUST push the dedicated Vacaciones manager screen.
#### Scenario: Tapping the summary row opens the manager screen
- GIVEN the Alarmas root shows the vacation summary row
- WHEN the user taps it
- THEN the Vacaciones manager screen opens as a second-level (push-chrome) screen with no tab bar
@@ -0,0 +1,89 @@
# App Navigation Shell Specification
## Purpose
The 5-root tab contract, the push-chrome rule for second-level screens, the
explicit Favoritos exemption from that rule, and root-to-root switching
(Escuchar "Ver todas" to Favoritos). Traceable to WU1.
## Requirements
### Requirement: Five-Tab Root Navigation
The app MUST render exactly 5 root tabs, in order: Escuchar, Buscar,
Favoritos, Alarmas, Ajustes, using the existing `PluriIconGlyph.*` icon set.
`PluriIconGlyph.home` MUST be the Escuchar tab's icon.
#### Scenario: App launch renders the 5 tabs in order
- GIVEN the app has finished cold-start
- WHEN the bottom navigation renders
- THEN exactly 5 tabs appear, labeled Escuchar, Buscar, Favoritos, Alarmas, Ajustes, in that order
#### Scenario: Escuchar uses the home glyph
- GIVEN the bottom navigation is rendered
- WHEN the Escuchar tab's icon is inspected
- THEN it is `PluriIconGlyph.home`
### Requirement: Push-Chrome on Second-Level Screens
Every genuine second-level screen (Reproductor completo, Países, Vacaciones,
Ecualizador, Grabaciones, and every new Settings detail screen) MUST render
a 56px header with a back affordance and MUST NOT render a bottom tab bar.
#### Scenario: Reproductor completo hides the tab bar
- GIVEN the user opens Reproductor completo from Escuchar
- WHEN the screen renders
- THEN it shows a 56px header with a back arrow and no bottom tab bar
#### Scenario: A Settings detail screen hides the tab bar
- GIVEN the user pushes any Settings detail screen from the Ajustes root
- WHEN the screen renders
- THEN it shows a 56px header with a back arrow and no bottom tab bar
### Requirement: Favoritos Tab-Bar Exemption
Favoritos MUST keep its bottom tab bar. This is the one deliberate exception
to the push-chrome rule: the source mockup draws this content as a pushed
screen only because its own 4-root IA had demoted Favoritos out of the root
set. In this app's 5-tab IA, Favoritos is root-level and MUST render the tab
bar like every other root.
#### Scenario: Favoritos renders with its tab bar
- GIVEN the user is on the Favoritos tab
- WHEN the screen renders
- THEN the bottom tab bar is visible, unlike every true second-level screen
### Requirement: Root-to-Root Switching Without Push
Navigating from Escuchar's "Tus emisoras" section to the full favorites list
MUST switch the active tab index to Favoritos. It MUST NOT push a new route.
#### Scenario: "Ver todas" switches tabs, not routes
- GIVEN the user is on Escuchar with 1 or more favorite stations
- WHEN they tap "Ver todas"
- THEN the active tab index becomes Favoritos
- AND no new route is pushed onto the navigation stack (back from Favoritos returns to whatever screen preceded Escuchar, not to Escuchar itself)
### Requirement: Escuchar Tab Rename Preserves the ARB Key
The former "Inicio" tab MUST be relabeled "Escuchar" (English: "Listen") by
changing the `navHome` key's VALUE, not by introducing a new key. The key
`navHome` MUST continue to exist, unchanged, in all 13 ARB locales.
#### Scenario: English and Spanish values are updated
- GIVEN `lib/l10n/app_en.arb` and `lib/l10n/app_es.arb`
- WHEN the `navHome` key is read
- THEN `app_en.arb` returns `"Listen"` and `app_es.arb` returns `"Escuchar"`
#### Scenario: Key parity across all 13 locales (edge case)
- GIVEN all 13 ARB files
- WHEN each is checked for the `navHome` key
- THEN every file still contains the key `navHome` (value content may differ), and no file is missing it
@@ -0,0 +1,104 @@
# EQ Custom Presets Specification
## Purpose
User-defined preset save/name/list on top of the existing 6 fixed presets,
a per-station EQ entry point from the player's tool tray, and a
"stations with their own EQ" drill-down. **Band count stays 5** — resolved
by spike (Engram id 2498), not a pending decision. Traceable to WU13
(Settings screen) and WU14 (player tool tray, shares WU13's editor
component).
## Requirements
### Requirement: Five-Band Equalizer (Regression Guard, First-Class)
The equalizer screen MUST render exactly **5** vertical sliders.
`assert(bandas.length == 5)` in `preset_ecualizador.dart` MUST remain
unchanged. Band count is device-reported via `just_audio`'s
`AndroidEqualizer` (`params.bands`), not app-chosen; any requirement or
implementation asserting 7 bands is rejected on sight.
#### Scenario: Ecualizador screen renders 5 sliders
- GIVEN the user opens the Ecualizador settings screen
- WHEN it renders
- THEN exactly 5 vertical sliders are shown, one per band
#### Scenario: Band-count regression is rejected (edge case)
- GIVEN a future change proposes 7 sliders or a modified `bandas.length` assert
- WHEN that change is reviewed against this requirement
- THEN it fails: 5 is the resolved, binding band count for this change
### Requirement: Custom Preset Save
The user MUST be able to save the current 5-band configuration as a new,
named custom preset, which then appears alongside the 6 fixed presets in
the preset chip row.
#### Scenario: Saving a custom preset
- GIVEN the user has adjusted the 5 sliders from a fixed preset
- WHEN they choose "Guardar como preset" and enter the name "Mi preset"
- THEN a new preset named "Mi preset" is persisted
- AND it appears in the preset chip row on next render
### Requirement: Custom Preset Naming Validates Non-Empty Input
The system MUST reject an empty or whitespace-only preset name without
crashing, and MUST NOT persist a preset in that case.
#### Scenario: Empty name is rejected
- GIVEN the "Guardar como preset" flow is open
- WHEN the user submits an empty name
- THEN a validation message is shown and no preset is saved
### Requirement: Per-Station EQ Entry Point From the Player
The player's tool tray MUST expose an "EQ propio" tile that opens a
per-station EQ bottom sheet. That sheet MUST reuse the same 5-band editor
component used by the Settings Ecualizador screen (not a separate
implementation).
#### Scenario: Opening per-station EQ from the player
- GIVEN a station is currently playing
- WHEN the user taps "EQ propio" in the tool tray
- THEN a bottom sheet opens with 5 sliders bound to that station's resolved preset
- AND changes made there apply through the existing per-station EQ persistence path
### Requirement: Stations-With-Own-EQ Drill-Down
The Settings Ecualizador screen MUST expose a row that navigates to a list
of stations that currently have a station-specific preset override,
sourced from the existing `presetsPorEmisora` map.
#### Scenario: Drill-down lists exactly the overridden stations
- GIVEN 2 stations have entries in `presetsPorEmisora` and N others do not
- WHEN the user opens the drill-down
- THEN exactly those 2 stations are listed
### Requirement: Base-vs-Per-Station Explainer Preserved
An info banner distinguishing the base (global/device) EQ from per-station
overrides MUST remain visible on the Ecualizador screen.
#### Scenario: Banner is visible on screen open
- GIVEN the user opens the Ecualizador screen
- WHEN it renders
- THEN the base-vs-per-station explainer banner is visible
### Requirement: Active Output Surfaced on the Main Screen
The "Salida activa" (active output device) row MUST be visible on the main
Ecualizador screen, not only in the Advanced sub-section.
#### Scenario: Output row updates on device change
- GIVEN the Ecualizador screen is open
- WHEN the active audio output device changes (e.g. Bluetooth connects)
- THEN the "Salida activa" row updates to reflect the new device
@@ -0,0 +1,75 @@
# Favorites Organization Specification
## Purpose
Chip-filtered flat list replacing stacked per-group panels, drag-to-reorder,
the sort action, and group management surfaced from Favoritos. Traceable to
WU4.
## Requirements
### Requirement: Chip-Filtered Flat List
Favoritos MUST render horizontally-scrollable group filter chips (e.g.
"Todas · N", one per favorite group) and a single flat list of favorite
stations filtered by the active chip, replacing the current stacked
per-group panel layout.
#### Scenario: Selecting a group chip filters the list
- GIVEN 2 or more favorite groups exist, each with members
- WHEN the user selects a specific group's chip
- THEN the flat list shows only that group's member stations
#### Scenario: "Todas" chip shows every favorite
- GIVEN the user has favorites across multiple groups
- WHEN the "Todas" chip is active
- THEN the flat list shows every favorite station regardless of group
### Requirement: Drag-to-Reorder Within the Active Filter
The user MUST be able to reorder favorite stations within the currently
active filter via a drag handle. The new order MUST persist across app
restarts.
#### Scenario: Reordering persists
- GIVEN a filtered list of 3+ favorites
- WHEN the user drags the 3rd item to the 1st position
- THEN the list reflects the new order immediately
- AND reopening the app shows the same order
### Requirement: Sort Action Using Existing Criteria
Favoritos MUST expose a sort action (`swap_vert`) using the existing
`OrdenEmisoras` criteria (name, quality). It MUST NOT introduce a sort
criterion with no backing implementation.
#### Scenario: Sort by name reorders alphabetically
- GIVEN the active filter shows 3+ favorites in a non-alphabetical order
- WHEN the user triggers sort-by-name
- THEN the list re-renders in alphabetical order
### Requirement: Group Management Reachable from Favoritos
Creating and managing favorite groups MUST be reachable directly from the
Favoritos screen, in addition to its existing entry point in Settings.
#### Scenario: Creating a group from Favoritos
- GIVEN the user is on the Favoritos screen
- WHEN they tap the create-group action
- THEN the group-creation flow opens and a newly created group appears as a new filter chip
### Requirement: Custom-Station CTA Preserved
The dashed "Añadir emisora personalizada" call-to-action MUST remain
reachable from Favoritos.
#### Scenario: Custom station CTA opens the add flow
- GIVEN the user is on Favoritos, any filter active
- WHEN they tap "Añadir emisora personalizada"
- THEN the custom-station add flow opens
@@ -0,0 +1,31 @@
# Delta for multi-device-eq
Existing spec: `openspec/specs/multi-device-eq/spec.md` (device-event
resolve-and-apply, first-seen bootstrap, cold start, connect-disconnect-
reconnect cycle, toggle-off behavior). None of that resolution logic
changes. This delta only ADDS a requirement about where the per-station
editing surface is reached from, and pins that the relocation has zero
effect on resolution. Traceable to WU14 (shares WU13's editor component).
## ADDED Requirements
### Requirement: Per-Station EQ Entry Relocates, Resolution Logic Does Not
The per-station EQ editing surface MUST be reachable from the player's
tool tray (see `eq-custom-presets`). This relocation MUST NOT alter the
existing 4-level resolution hierarchy (matrix -> station -> device ->
global) or any of its resolve-and-apply, first-seen-bootstrap, cold-start,
or connect/disconnect/reconnect behavior.
#### Scenario: Editing per-station EQ from the player uses the existing hierarchy
- GIVEN a station has an existing entry in `presetsMatriz` or `presetsPorEmisora`
- WHEN the user edits it via the player's per-station EQ sheet
- THEN the change persists through the same resolution path already covered by `multi-device-eq`'s existing tests
- AND device-change resolve-and-apply behavior for that station is unaffected
#### Scenario: Existing multi-device-eq test suite is unaffected (regression guard)
- GIVEN the existing `multi-device-eq` scenarios (device-event resolve-and-apply, first-seen bootstrap, cold start, connect-disconnect-reconnect, toggle-off)
- WHEN the per-station entry point relocates to the player
- THEN every one of those scenarios continues to pass unmodified
@@ -0,0 +1,93 @@
# Delta for native-alarms
Existing spec: `openspec/specs/native-alarms/spec.md` (native foreground
service, sole ring-audio ownership, fade-in curve, focus/no-volume-writes,
notification channel migration, "ring screen is pure UI"). None of that
native/audio behavior changes in this redesign — it is a visual restyle of
the Alarmas root, the alarm card, and the ringing screen. This delta ADDS
requirements for card affordances, the hero skip action, the editor's
preserved advanced fields, and the ringing screen's static status label.
Traceable to WU8 (card + hero), WU10 (editor), WU11 (ringing screen).
## ADDED Requirements
### Requirement: Alarm Card Tap-to-Edit, Swipe-to-Delete
The simplified alarm card (giant time + station + switch, no always-visible
action row) MUST preserve edit and delete capability: tapping the card
MUST open the editor for that alarm; swiping it MUST trigger delete
(with confirmation). No capability present in the current always-visible
button row may be lost.
#### Scenario: Tap opens the editor
- GIVEN an alarm card is visible on the Alarmas root
- WHEN the user taps it
- THEN the editor sheet opens pre-filled with that alarm's data
#### Scenario: Swipe deletes with confirmation
- GIVEN an alarm card is visible
- WHEN the user swipes it and confirms
- THEN that alarm is deleted and its card is removed from the list
### Requirement: Hero Banner Inline Skip
The Alarmas root's hero banner MUST include an inline "Saltar" action that
skips the next occurrence of the featured (soonest-firing) alarm, in
addition to any per-card skip action.
#### Scenario: Tapping "Saltar" on the hero banner skips the featured alarm
- GIVEN the hero banner shows the soonest-firing alarm
- WHEN the user taps its inline "Saltar" action
- THEN that alarm's next occurrence is marked skipped, consistent with the existing skip-next behavior
### Requirement: Alarm Editor Preserves Date, Fallback Station, and Sound Fields
The rewritten editor (giant inline HH:MM display, always-visible weekday
circles) MUST NOT drop the existing one-time date field, the
fallback-station picker, or the sound dropdown. These MUST remain
reachable, e.g. under a collapsed "Advanced" section.
#### Scenario: One-time date alarm is still creatable
- GIVEN the user is creating a new alarm
- WHEN they choose a specific one-time date instead of a weekly recurrence
- THEN the date field is reachable and the saved alarm reflects that specific date, not a weekday pattern
#### Scenario: Fallback station and sound remain settable
- GIVEN the editor's Advanced section
- WHEN the user sets a fallback station and a sound choice
- THEN both persist and are reflected the next time the alarm is edited
### Requirement: Ringing Screen Shows a Static Status Label (No Live Countdown)
The ringing screen's "Subiendo volumen" status MUST render as a static
label with no live seconds counter. Adding a live counter is out of scope:
it would require native-to-Flutter progress plumbing that is deliberately
absent from the architecture.
#### Scenario: Status label has no numeric countdown
- GIVEN an alarm is ringing and fading in
- WHEN the status label renders
- THEN it shows "Subiendo volumen" (or equivalent localized text) with no accompanying seconds value that changes over time
### Requirement: Dismiss Guard and Force-Stop Banner Survive the Restyle (First-Class Regression Guard)
The ringing screen's single-exit dismiss guard and force-stop retry banner
MUST be preserved unmodified by the visual restyle.
#### Scenario: Dismiss guard test passes unmodified
- GIVEN `test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart`
- WHEN the ringing screen restyle lands
- THEN that test file passes without being modified
#### Scenario: Force-stop retry banner still appears on failure
- GIVEN a stop action fails to actually silence the ring
- WHEN the failure is detected
- THEN the force-stop retry banner is shown, in whatever position the restyled layout places it
@@ -0,0 +1,47 @@
# Onboarding Welcome Specification
## Purpose
A first-run welcome surface in the new visual language, explicitly
monetization-free. Traceable to WU17.
## Requirements
### Requirement: Full-Screen Welcome Route
The welcome screen MUST render as a full-screen route (not a modal
dialog), containing: logo mark, headline, body copy, exactly 3 feature
bullets, and exactly one primary call-to-action ("Empezar a escuchar").
#### Scenario: All elements render, one CTA
- GIVEN the app launches for the first time
- WHEN the welcome screen renders
- THEN a logo, headline, body copy, and 3 feature bullets are visible
- AND exactly 1 primary CTA is present
### Requirement: No Monetization Content (First-Class, Binding)
The welcome screen MUST NOT render a PRO pill, any trial-duration text
(e.g. a day count), any price, or a secondary "free version" link. This
follows the binding no-monetization decision for this change.
#### Scenario: Rendered content contains no monetization strings
- GIVEN the welcome screen is rendered
- WHEN its widget tree's text content is scanned
- THEN it contains none of: the substring "PRO", a currency amount, or a day-count trial phrase
- AND no secondary "free version" link is present
### Requirement: CTA Dismisses to Escuchar
Tapping the primary CTA MUST dismiss the welcome screen and land on the
Escuchar tab (index 0), with the welcome route removed from the
navigation stack.
#### Scenario: CTA navigates to Escuchar
- GIVEN the welcome screen is showing
- WHEN the user taps "Empezar a escuchar"
- THEN the app shows the Escuchar tab
- AND the welcome route is no longer on the back stack
@@ -0,0 +1,76 @@
# Recordings Library Specification
## Purpose
A browsable recordings list with storage usage and per-recording
rename/share/delete, constrained to what `servicio_grabacion_radio.dart`
already exposes. Traceable to WU15.
## Requirements
### Requirement: Storage Usage Display
The screen MUST show used vs. total storage as a progress bar with a
caption (used amount, total amount, retention/path info).
#### Scenario: Storage usage renders proportionally
- GIVEN recordings occupy 84 MB of a 200 MB budget
- WHEN the screen renders
- THEN the progress bar fill reflects 84/200 and the caption states both values
### Requirement: Browsable Recordings List
The screen MUST list individual recordings sourced from the recordings
folder, each row showing name, date, duration, and size, with inline
playback.
#### Scenario: Recordings on disk render as rows
- GIVEN 3 recording files exist in the recordings folder
- WHEN the screen loads
- THEN exactly 3 rows render, each with correct name/date/duration/size
#### Scenario: No recordings (edge case)
- GIVEN the recordings folder is empty
- WHEN the screen loads
- THEN an empty state renders, not an error
#### Scenario: Row playback starts and stops
- GIVEN a recording row is visible
- WHEN the user taps its play control
- THEN playback of that recording starts
- AND tapping again stops it
### Requirement: Per-Recording Menu Constrained to Rename/Share/Delete
The "⋮" menu on each recording row MUST expose exactly three actions —
Rename, Share, Delete — constrained to capabilities already exposed by
`servicio_grabacion_radio.dart`. It MUST NOT expose an action that service
does not support.
#### Scenario: Menu shows exactly 3 actions
- GIVEN any recording row
- WHEN its "⋮" menu opens
- THEN exactly Rename, Share, and Delete are shown, no other action
#### Scenario: Delete removes the file and its row
- GIVEN a recording row's menu is open
- WHEN the user selects Delete and confirms
- THEN the underlying file is removed and the row disappears from the list
#### Scenario: Rename updates the displayed name
- GIVEN a recording row's menu is open
- WHEN the user selects Rename and submits a new, non-empty name
- THEN the row displays the new name and it persists across a reload
#### Scenario: Share invokes the platform share sheet
- GIVEN a recording row's menu is open
- WHEN the user selects Share
- THEN the platform share sheet opens with that recording's file
@@ -0,0 +1,139 @@
# Station Discovery & Browse Specification
## Purpose
Discovery content as the Buscar tab's landing state, active-filter pills,
results counter, clear-all-filters, the Países browser over the verified
`/json/countries` contract, and a client-side search-sort control. Traceable
to WU5 (relocation), WU6 (Buscar), WU7 (Países + `_get` transport
extraction).
## Requirements
### Requirement: Buscar Landing State Shows Discovery Content
Buscar MUST render the existing discovery content (near-you, genres,
trending, countries entry point) as its landing state whenever no search
query is entered. Entering a query MUST replace that landing state with
search results.
#### Scenario: Empty query shows discovery content
- GIVEN the user opens the Buscar tab with no prior query
- WHEN the screen renders
- THEN near-you, genre, trending, and a Países entry point are visible
#### Scenario: Entering a query replaces the landing state
- GIVEN the Buscar tab is showing discovery content
- WHEN the user types a non-empty query
- THEN discovery content is replaced by the search-results view
### Requirement: Active-Filter Pills and Results Counter
The search-results view MUST show each active filter (country, language,
minimum quality) as a removable pill, and MUST show a results counter
("N RESULTADOS").
#### Scenario: Applying a filter shows a removable pill
- GIVEN the user has an active search
- WHEN they apply a country filter
- THEN a pill labeled with that country and a close (x) affordance appears
- AND the results counter reflects the filtered count
#### Scenario: Removing a pill re-runs the search without that filter
- GIVEN a country filter pill is active
- WHEN the user taps its close affordance
- THEN the filter is cleared and results/count update accordingly
### Requirement: One-Tap Clear-All-Filters on Empty Results
When 1 or more filters are active and the resulting search has zero results,
the system MUST offer a single action that clears every active filter at
once ("Quitar los N filtros").
#### Scenario: Two active filters, zero results
- GIVEN country and quality filters are both active and yield 0 results
- WHEN the empty state renders
- THEN a "Quitar los 2 filtros" action is shown
- AND tapping it clears both filters in one action, not one at a time
### Requirement: Países Browser Over the Verified Countries Contract
The Países screen (new second-level screen) MUST fetch from
**`/json/countries`** (optional substring filter `/json/countries/{filter}`)
and MUST NOT use `/json/countrycodes`, which is deprecated. It MUST parse
the `stationcount` field as a **string** via `int.tryParse`, never as a
direct `int` cast. Its request MUST NOT include `lastcheckok`, since that
parameter is meaningful only for station-listing calls.
Source: Engram `reference/radio-browser-countries-endpoint` (id 2500),
verified against <https://api.radio-browser.info/>.
#### Scenario: Countries list renders with counts
- GIVEN `/json/countries` returns a list of country objects
- WHEN the Países screen loads
- THEN "Tus idiomas" and the full alphabetical list render with each country's parsed station count
#### Scenario: `stationcount` arrives as a JSON string (edge case, critical)
- GIVEN a country entry has `"stationcount": "482"` (a JSON string, not a number)
- WHEN the screen parses and renders that entry
- THEN it displays `482` without throwing a type-cast error
#### Scenario: Countries request omits the station-only filter
- GIVEN the Países screen issues its `/json/countries` request
- WHEN the outgoing request is inspected
- THEN it does NOT include `lastcheckok`
### Requirement: Client-Side Search Sort Only
Buscar MUST expose an "Ordenar" control backed exclusively by client-side
ordering (`OrdenEmisoras`). The system MUST NOT adopt the Radio Browser
server-side `order`/`reverse` parameters for this control, because
server-side ordering sorts the full result set before `limit` is applied
while the app's client-side sort only reorders the page already fetched —
adopting it would silently change which stations users see, not just their
order. Every sort option rendered in the UI MUST map to a real
`OrdenEmisoras` case; the system MUST NOT render an option that does not
actually sort.
Source: Engram `reference/radio-browser-sort-order` (id 2505), verified
against <https://api.radio-browser.info/>.
#### Scenario: Selecting a sort option reorders the current page only
- GIVEN a set of search results is already fetched
- WHEN the user selects "Ordenar: calidad"
- THEN the currently-displayed page is reordered via `OrdenEmisoras.calidad`
- AND no new network request with an `order` parameter is issued
#### Scenario: Every rendered sort option is backed by a real case (regression guard)
- GIVEN the "Ordenar" control's rendered option list
- WHEN each option is checked against `OrdenEmisoras`
- THEN every option maps to an existing enum case with test coverage; no decorative option that does not sort is rendered
### Requirement: Existing Station Calls Unchanged by Transport Extraction
Extracting `_get`'s transport loop (server discovery, host rotation,
bounded retries, User-Agent, timeout, status check, `json.decode`) into a
shared helper for the Países call MUST NOT change the observable behavior
of the 8 existing `ServicioRadio` station methods: `obtenerPopulares`,
`obtenerTendencias`, `buscarPorNombre`, `buscarPorPais`, `buscarPorIdioma`,
`buscarPorTag`, `buscar`, `registrarClick`.
#### Scenario: Characterization tests pass unchanged before and after extraction
- GIVEN characterization tests exist for all 8 station methods
- WHEN the transport loop is extracted from `_get`
- THEN all 8 tests pass identically before and after the extraction
#### Scenario: Station calls still send `lastcheckok`
- GIVEN any of the 8 station methods issues a request
- WHEN the outgoing request is inspected
- THEN it still includes `lastcheckok=1`, unchanged from before the extraction