Author SHA1 Message Date
FreeTLab fc866d7ec9 fix(buscar): correct the gaps between the filter row and the results
Issue 3 (partial): the results area had no top gap against the filter
row in one state and reused the horizontal constant for a vertical axis
in another. Applies the 3-tier scale properly -- row tier for
background-less placeholders, card tier for card states.

The rest of the app's spacing review is still outstanding.
2026-07-30 20:18:59 +02:00
FreeTLab 727e18737a fix(radio): persist the last-played station across restarts
EstadoRadio.emisoraActual only ever reflected in-memory state
(_emisoraSeleccionada or the live audio service), so stopping playback
and reopening the app left the Escuchar hero empty even though the
user had a station selected right before closing it.

Persist the station whenever it changes (reproducir(), and the
Android-Auto out-of-band reconciliation path) and restore it as
_emisoraSeleccionada on the next cold start, only when nothing is
already selected. This never touches the audio service directly: no
playback starts and estadoStream/estaSonando stay at their stopped
default, matching how every consumer already gates "is it playing" on
the playback-status stream rather than on emisoraActual itself.
2026-07-30 19:18:14 +02:00
FreeTLab c6ab295c54 fix(timer): show the live countdown in the sleep timer sheet
showPluriSleepTimerSheet already had a working countdown branch
(ServicioTimer.tiempoRestanteStream), but every preset and the custom
duration flow popped the sheet immediately after starting the timer --
so the countdown never rendered in the primary flow, only if the user
happened to reopen the sheet afterwards.

Stop popping the sheet on start; the existing Consumer<EstadoRadio>
already reacts to iniciarTimerDuracion's notifyListeners and swaps to
the countdown view live. Also make the sheet scroll-controlled: at a
realistic phone width the countdown's title + description + headline-
sized remaining-time text overflowed the default half-screen cap that
never mattered while the sheet always closed before that view could
render.
2026-07-30 19:14:15 +02:00
FreeTLab e75f010b98 fix(ajustes): stop settings row titles from wrapping and cutting off
FilaAjuste's title Text had no maxLines/overflow, and neither did its
trailing current-value Text. An unbounded value (e.g. a real station
name in "Emisora preferida") let the trailing Row claim unbounded
width, squeezing the title down until it wrapped across several lines
that the row's fixed height then cut short.

Constrain the title to a single ellipsized line and cap the trailing
value's width the same way. FilaAjuste backs all 12 settings rows, so
every row is protected, not just the one that happened to expose it.
2026-07-30 19:08:06 +02:00
FreeTLab c7e1a212ca fix(vacaciones): edit and delete vacation ranges
Vacaciones ranges could be created but never edited or removed --
EstadoAlarmas already had crearRangoVacaciones/eliminarRangoVacaciones
with no UI affordance reaching them, and no update path at all.

Add EstadoAlarmas.editarRangoVacaciones and wire tap-to-edit /
swipe-to-delete (with confirmation) onto every range card, mirroring
the alarm list's own Dismissible + confirm-dialog pattern exactly. This
covers the active-range hero too: a freshly created range is active
immediately and only ever renders there, never in the
scheduled/past lists, so it needed the same affordances or a user's
very first range could never be fixed.
2026-07-30 19:05:28 +02:00
FreeTLab d1a911e587 fix(widgets): share station-art fallback across every surface
TarjetaEmisora had the only good fallback for a station with no artwork --
a deterministic pick from 4 bundled illustrations with a gradient/glyph
last resort. FilaEmisoraPlana's flat rows, the Escuchar hero, the "Tus
emisoras" grid cell, the mini player and the full player each had their
own, separate, flat primaryContainer square instead.

Extract the good fallback into PluriStationArtFallback and use it from
every one of those call sites. The selection formula (asset order,
codeUnits-sum modulo) is preserved exactly, since navegacion_auto.dart
mirrors the same formula independently for Android Auto's own drawable
rotation.
2026-07-30 18:54:31 +02:00
FreeTLab 4be2156e58 fix(nav,favoritos): unclip the overflow menu and smooth the tab transition
Two user-reported bugs from on-device testing.

The favourites overflow menu carried `constraints: tightFor(38x42)`,
which sizes the POPUP rather than the button -- every item was clipped to
its first letter, so users saw "M" and "E" instead of the labels. The
existing test passed throughout because find.text matches a Text widget
whether or not it is visually clipped; the new guard measures the laid-out
width instead.

The bottom bar's ink splash had no shape, painting a hard square over the
icon, and the active tab's lift, dim, icon size and label all changed
instantly while the balloon slid -- the balloon glided and its contents
teleported. All four now share the balloon's duration and curve.
2026-07-30 18:26:25 +02:00
FreeTLab f24be19e4f feat: visual fidelity pass against the approved prototype
Build & Deploy PluriWave / Análisis de código (push) Successful in 28s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m22s
Closes the gap between the functional redesign and the design handoff.
An audit found 126 deviations; this pass fixes the large majority and
records the rest as deliberate rejections with reasons.

- Bottom bar rebuilt as the prototype's balloon bar (tabs and icons kept)
- Surfaces are opaque #102532 by default; glass is now opt-in and used
  only on the chrome and the active card, per the prototype's own rule
- Global AppBar dropped; each root draws its own header
- Settings rows show their current value; 3-tier padding scale
- Equalizer, player, alarms, vacations, countries, search, recordings
  and welcome all brought to spec, each with a dimension/colour guard

Tests: 748 -> 903. Analyzer unchanged at 4 infos, 0 errors.
2026-07-30 17:55:44 +02:00
FreeTLab 7343071fca merge: incorporate the CI version bump to 1.2.1+123 2026-07-30 17:53:47 +02:00
FreeTLab 42d35a2541 fix(buscar): filter pills, Idioma chip, results eyebrow, states screen
Audit 6.2 (t4:292-293): active filter pills are brand-teal tinted with
a 15px close glyph and radius 10 -- was Material's own Chip theming.

Audit 6.3 (t4:294-295): an "Idioma" entry chip is now always reachable
once a search is active -- there was no standalone entry point for
language filtering before (only bundled inside "Filtros"). Opens the
same existing filter sheet rather than a new idioma-only picker.

Audit 6.4 (t4:299): the results-count line now uses eyebrowLabel
styling -- was labelLarge (14/w800).

Audit 13.3/13.4 (t4:649-651): the search-results loading skeleton
gains the missing "BUSCANDO EMISORAS..." eyebrow, and its row gap is
now 4px, not 10.

Audit 13.5/13.6 (t4:657-668): a new purpose-built _TarjetaSinResultados
replaces PluriEmptyState ONLY in the search-no-results branch (that
shared widget is left untouched for its other unrelated call sites --
favorites empty state, the discovery grid). The title now quotes the
typed query ("Sin resultados para <<jazzz>>"), and the clear-filters
pill sits INSIDE the card. New ARB key searchNoResultsForQueryTitle.

Item 6.1 (compact 60px active-search header replacing the persistent
glass pill) is NOT implemented: existing tests confirmed the quality
(bitrate) filter has NO entry point anywhere in the app besides the
header's "Filtros" pill (added first via _abrirYSeleccionarCalidad
while a country filter is ALREADY active, i.e. mid active-search).
Hiding PluriRootHeader during active search would make quality
filtering unreachable -- a real capability regression the task's own
"presentation changes, capability does not" principle forbids.
Re-plumbing where that filter lives is a bigger change than this
visual-fidelity pass should make unilaterally.
2026-07-30 17:04:44 +02:00
FreeTLab f61b0b9163 fix(vacaciones): header Add action, info banner, dashed CTA, collapse
Audit 9b.1 (t4:446): a solid brand-teal "Add" header action -- the
prototype's own mid-page CTA (audit 9b.6, still present, now dashed)
is a SECOND, additional entry point, not a replacement.

Audit 9b.2 (t4:448): the teal explanatory banner ("alarms marked
pause-during-vacations won't ring...") is now ALWAYS visible -- never
rendered before. New ARB key vacationExplainerBanner, all 13 locales.

Audit 9b.6 (t4:487): the bottom CTA is now dashed-border with a
date_range glyph -- was a solid OutlinedButton with an add glyph.
Reuses the dashed-painter shape already established in
pantalla_favoritos.dart's custom-station CTA (audit 4.5), duplicated
rather than shared.

Audit 9b.7 (t4:489): "Rangos pasados" is now a collapsible row --
icon, title, count, chevron -- COLLAPSED by default, expanding on tap.
Was always fully expanded inline. Updated the pre-existing widget test
to tap-then-assert instead of asserting immediate visibility.

Item 9b.3 (the eyebrow literally reading "EN CURSO") is NOT
implemented: the eyebrow STYLING is already correct (audit 9b.4), and
the residual copy gap is a shared ARB string
(vacationSummaryActiveCountdown) also used compactly in
pantalla_alarmas.dart's vacation summary row -- diverging its wording
just for this screen's eyebrow, or forcing a shoutier tone into that
other compact usage, is not worth it for a trivial-rated copy nuance.
2026-07-30 16:53:04 +02:00
FreeTLab 36d7d5f692 fix(alarma-sonando): date line, additive snooze qualifier
Audit 9.4 (t4:419): "Lunes, 3 de agosto" now renders between the
schedule pill and the hero time -- purely additive, a new sibling
Text touching neither element. New formato_fechas.dart helper
fechaLargaConDiaSemana (locale-aware via DateFormat.MMMMEEEEd).

Audit 9.10 (t4:433): the highlighted snooze tile gains a "usual"
qualifier (new ARB key alarmSnoozeUsualLabel) alongside the original
flat label -- resolved DIFFERENTLY than its sibling 9.9 (permanently
rejected, id 2525): instead of splitting the flat
alarmSnoozeOptionLabel string into two differently-sized Text nodes
(which would make it vanish from the render tree the protected
dismiss-guard test locates via find.text), a SEPARATE small Text is
added alongside it. The original label stays a single, untouched Text
node, still inside the same FilledButton the guard taps.

Verified against the full ringing-screen test surface (34 tests
across 5 files, including the protected dismiss-guard file) -- all
green, dismiss-guard file reconfirmed byte-identical to main.
2026-07-30 16:45:29 +02:00
FreeTLab dcb415b3f8 fix(alarmas-editor): REPETIR eyebrow, grouped card, volume row
Audit 8.5 (t4:381): the "REPETIR" eyebrow above the weekday circles --
never rendered before. New ARB key alarmRepeatSectionLabel.

Audit 8.6 (t4:392-401): the station picker, volume, fade-in and
vacation toggle now share ONE bordered card with sangred divider lines
-- were four separately-spaced widgets. Restyled _CampoSelectorEmisora
as a flat nav row (icon/bold label/muted value/chevron, matching
FilaAjuste's own convention) so it fits inside the card instead of
drawing its own outlined InputDecoration chrome -- used by BOTH the
primary and fallback (Advanced) station fields. Snooze duration, "use
current station", and the Advanced section stay OUTSIDE the card,
exactly where they were (audit 8.8's own documented deliberate
extras). A transparent Material sits inside the card's coloured box so
the fade-in ListTile and the vacation SwitchListTile still paint their
ink/background correctly (the same class of constraint already
documented for PluriGlassSurface elsewhere in this codebase).

Audit 8.7 (t4:396): the volume row is now a compact 112px track with a
live percentage label -- was a bare full-width Slider with no visible
value. New ArtB key alarmVolumeLabel.

Item 8.8 (extra name/type-selector/snooze-selector/"use current
station"/Advanced fields) stays exactly as documented: deliberate,
required by the hard constraint that the editor keep its date field,
fallback-station picker and sound dropdown.
2026-07-30 16:42:04 +02:00
FreeTLab c7d137c82e fix(alarmas): New pill, banner layout, vacation pill, card recurrence
Audit 7.1 (t4:325): the "New" action is a solid brand-teal pill with a
plain add glyph -- was a tonal button with auto_awesome.

Audit 7.2 (t4:326-330): the next-alarm banner is warmCoral-tinted with
the Skip chip BESIDE the text on the same row -- was an opaque default
card with the skip action stacked below as an OutlinedButton.

Audit 7.3 (t4:332): the vacation row gains a trailing "d-d MON"
date-range pill for the active-or-next range, built from the existing
EstadoAlarmas.rangoVacacionesActivo/vacacionesProximas() accessors --
new formato_fechas.dart helper, no new state.

Audit 7.4 (t4:337-345): the alarm card now shows a recurrence label
next to the giant time (reusing the existing oneTimeOption/
dailyOption/weekdaysOption strings) and a themed station-icon slot
next to the station name. The real per-station favicon is NOT
rendered here -- same network-image hazard already documented for the
ringing screen's audit 9.2 (Emisora.favicon is a network URL;
Image.network hangs widget tests without a mocked HttpClient). The
custom switch shape (52x32/26px thumb) is also left as Switch.adaptive
-- a disclosed sub-gap, not a silent drop.

Item 7.6 (_AccesoDiagnostico, an Android-reliability debug row) is a
pre-approved addition not in the prototype -- informational only, no
action needed.
2026-07-30 16:30:20 +02:00
FreeTLab dcd8488874 fix(favoritos): header actions row, chip colours
Audit 4.1 (t4:216): the manage-groups (create_new_folder) and sort
(swap_vert) actions now live in PluriRootHeader's own actions slot as
two icon buttons -- were an ActionChip inside the chip strip plus a
PopupMenuButton sharing a Row with it. The prototype's own back arrow
stays absent (binding decision: this root keeps its bottom tab bar,
unlike the prototype's pushed-with-back-arrow shape) -- this closes
the remaining gap in what was a partial fix.

Audit 4.2 (t4:219-221): group filter chips are solid brand teal with
dark text when active, listSurface + a faint border when not -- was
Material's own ChoiceChip theming (electricMagenta@24% selected).

Updated pantalla_favoritos_test.dart's two "Manage lists" text finders
to locate the relocated action by key instead (the action is now an
icon-only IconButton with a tooltip, not a labelled chip).
2026-07-30 16:22:12 +02:00
FreeTLab 94b1e901d1 fix(paises): eyebrows outside the card, country count, header search
Audit 5.4 (t4:254/260): "Tus idiomas" and "Todos" eyebrows now sit
OUTSIDE any card at title-tier (20px) padding, styled with
eyebrowLabel -- were titleMedium w900 inside a PluriGlassSurface.
"Todos" also gains its missing total count ("{title} · {count}").

Audit 5.7 (t4:252): the header gained a real `search` action -- toggles
an inline TextField that filters the country list by name or ISO
code, not a decorative no-op button. New ARB key countriesSearchHint.

Item 5.6 (station count as a subtitle line, not a trailing widget) was
already fixed as an undocumented side effect of Tier3's row rebuild
(3303bd3) -- reconfirmed by direct read, no change needed here.
2026-07-30 16:18:44 +02:00
FreeTLab 533896b9fa fix(ecualizador): header switch, banner radius, preset chips, glow thumb
Audit 11.1 (t4:566): the master enable switch moves to
PluriPushScaffold's header actions -- was the body's first
SwitchListTile row. Its realtime/pending explainer subtitle stays
behind as a plain caption so no information is lost.

Audit 11.2 (t4:571): explainer banner radius is 16 (a local one-off,
matching neither of the 3 named tokens), not radiusSm's 14.

Audit 11.3 (t4:574-577): preset chips are solid brand-teal with dark
text when active, listSurface + a faint border when not -- was
Material's own ChoiceChip theming (primaryContainer/grey).

Audit 11.5 (t4:585): band sliders use a new 20x20 _GlowSliderThumbShape
(brand-teal blurred glow + solid thumb), replacing the Material
default round thumb.

Audit 11.7 (t4:584): the dB label is brand teal at 90% alpha, not
liveGreen -- a leftover wrong colour family 11.6's slider-only fix
never touched.

Updated 2 pre-existing tests (pantalla_ajustes_ecualizador_test.dart,
pantalla_ajustes_test.dart) to locate the enable switch by key instead
of by the "Enable equalizer" text it no longer renders next to.
2026-07-30 16:14:11 +02:00
FreeTLab b5b9829faa fix(ajustes): group eyebrow outside the card, per-row accent icons
Audit 10.2 (t4:511): GrupoAjustes's eyebrow now sits OUTSIDE the
PluriGlassSurface, at title-tier (20px) padding -- it used to share
the card's own 16px content padding.

Audit 10.5 (t4:514/526): FilaAjuste gains an optional iconColor.
Wired on the two rows the prototype actually accents: AUDIO's
Ecualizador (brand cyan) and STATIONS' Grupos de favoritos (warmCoral).

Item 10.10 (two extra AUDIO rows -- "Calidad de streaming" and
"Reproduccion sin interrupciones") is NOT implemented: neither concept
has any backing state or service anywhere in this app today. Building
them for real means inventing two new persisted settings, and a
genuine streaming-quality preference would plausibly need to touch
the protected servicio_audio.dart to actually affect playback --
out of scope for a visual-fidelity pass. Decorative rows that open
nothing would be dead UI, which is worse than leaving the gap
disclosed.
2026-07-30 16:05:28 +02:00
FreeTLab e036f99a61 fix(bienvenida,grabaciones): headline size, CTA radius, storage caption
Screen 14 / audit 14.3+14.7: welcome headline now 34px/ls-1.2 (was
headlineMedium's 28/ls-1.0), CTA restyled to a radius-18 rounded
rectangle instead of Material 3's default StadiumBorder (t4:696,715).

Screen 12 / audit 12.2+12.3: the recordings storage card now shows the
bold "X of Y used" headline ABOVE a 6px/radius-3 bar, followed by a
real folder-path + purge-policy caption below it -- was the bar first
with the "used" string as its only (small, generic) caption (t4:613).
New ARB key recordingsLibraryStorageFolderCaption, translated to all
13 locales.

Item 12.5 (folder/max-size settings living behind the header action
instead of inline on this screen) is a deliberate structural split
from WU15b; the audit itself scopes it out of this pass.
2026-07-30 16:00:30 +02:00
ShanaiaBot 34388d364b chore: bump version to 1.2.1+123 [ci skip] 2026-07-29 18:35:47 +02:00
74 changed files with 4842 additions and 870 deletions
+13
View File
@@ -354,6 +354,19 @@ class EstadoAlarmas extends ChangeNotifier {
await guardarVacaciones(nuevos);
}
/// Issue 1 (feedback-pruebas): replaces the range with the same [id] in
/// place -- the counterpart `crearRangoVacaciones`/`eliminarRangoVacaciones`
/// were missing before this fix, leaving no way to fix a mistake in an
/// already-saved range (including the currently ACTIVE one, since a
/// freshly created range starts active immediately).
Future<void> editarRangoVacaciones(RangoVacaciones rango) async {
final nuevos = [
for (final actual in _vacaciones)
if (actual.id == rango.id) rango else actual,
];
await guardarVacaciones(nuevos);
}
// ── Vacation queries (design ADR-6, WU9) ──────────────────────────────
// Four PURE queries: none writes, none reschedules, none touches the
// native bridge. Read-only over `_alarmas`/`_vacaciones`. Every method
+55
View File
@@ -173,6 +173,10 @@ class EstadoRadio extends ChangeNotifier {
static const _keyEmisoraPreferida = 'emisora_preferida_uuid_v1';
static const _keyOrdenListas = 'orden_listas_emisoras_v1';
static const _keyTimerSuenoPresets = 'timer_sueno_presets_segundos_v1';
// Issue 4 (feedback-pruebas): last-played station, so the Escuchar hero
// keeps showing "what I was listening to" (stopped, not playing) after a
// full app restart instead of going empty.
static const _keyUltimaEmisora = 'ultima_emisora_v1';
static const _timerSuenoPresetsDefecto = <int>[
180,
300,
@@ -300,6 +304,50 @@ class EstadoRadio extends ChangeNotifier {
_cargarEmisorasCustom(),
]);
await _normalizarEmisoraPreferida();
await _restaurarUltimaEmisora();
}
/// Issue 4 (feedback-pruebas): restores the last-played station as a
/// STOPPED `emisoraActual` on a cold start. Only fills the gap — if
/// something is ALREADY selected (a real play already ran concurrently),
/// this is a no-op. Never touches `audio`: no playback starts, no network
/// request is made, `estadoStream`/`estaSonando` stay at their fresh
/// "detenido" default, exactly like every other consumer of
/// `emisoraActual` already expects (they gate "is it playing" on the
/// separate playback-status stream, never on `emisoraActual != null`).
Future<void> _restaurarUltimaEmisora() async {
if (_emisoraSeleccionada != null || audio.emisoraActual != null) return;
try {
final prefs = await _resolverPrefs();
final raw = prefs.getString(_keyUltimaEmisora);
if (raw == null) return;
final mapa = jsonDecode(raw) as Map<String, dynamic>;
_emisoraSeleccionada = Emisora.fromMap(mapa);
} catch (e) {
registrarSaltoPersistencia(
subsistema: 'ultima_emisora',
detalle: 'restaurar',
razon: e.toString(),
);
}
}
/// Best-effort remembers [emisora] as the last used station (issue 4) so
/// [_restaurarUltimaEmisora] can bring it back after a restart. Fire-and-
/// forget, same treatment [reproducir] already gives other non-critical
/// side effects (e.g. `radio.registrarClick`) — a failed write here must
/// never block or fail actual playback.
Future<void> _persistirUltimaEmisora(Emisora emisora) async {
try {
final prefs = await _resolverPrefs();
await prefs.setString(_keyUltimaEmisora, jsonEncode(emisora.toMap()));
} catch (e) {
registrarSaltoPersistencia(
subsistema: 'ultima_emisora',
detalle: 'persistir ${emisora.uuid}',
razon: e.toString(),
);
}
}
/// Escucha el stream de estado del audio y gestiona errores de reproducción.
@@ -321,6 +369,9 @@ class EstadoRadio extends ChangeNotifier {
final actual = audio.emisoraActual;
if (actual != null && actual.uuid != _emisoraSeleccionada?.uuid) {
_emisoraSeleccionada = actual;
// Issue 4: an Android-Auto-initiated selection is a real station
// change too — remember it the same way `reproducir` does.
unawaited(_persistirUltimaEmisora(actual));
}
notifyListeners();
});
@@ -508,6 +559,10 @@ class EstadoRadio extends ChangeNotifier {
}
_emisoraSeleccionada = emisora;
notifyListeners();
// Issue 4: remembers the station the user just picked so it survives a
// restart — fire-and-forget, same treatment as `radio.registrarClick`
// below (a persistence failure here must never block playback).
unawaited(_persistirUltimaEmisora(emisora));
try {
await audio.reproducir(emisora);
if (revision != _revisionReproduccion) return;
+12
View File
@@ -262,8 +262,11 @@
"searchCountryFilterLabel": "البلد",
"searchLanguageFilterLabel": "اللغة",
"searchMinQualityFilterLabel": "الحد الأدنى للجودة",
"searchLoadingStationsLabel": "جارٍ البحث عن محطات…",
"searchEmptyTitle": "ابحث عن محطة",
"searchNoResultsTitle": "لا توجد نتائج",
"searchNoResultsForQueryTitle": "لا توجد نتائج لـ «{query}»",
"@searchNoResultsForQueryTitle": {"placeholders": {"query": {}}},
"searchEmptySubtitle": "استخدم الشريط العلوي أو الشرائح لاكتشاف إشارات من كل العالم.",
"searchNoResultsSubtitle": "جرّب إزالة الفلاتر أو كتابة اسم آخر للعثور على إشارة نشطة.",
"countrySpain": "إسبانيا",
@@ -440,6 +443,7 @@
}
}
},
"alarmSnoozeUsualLabel": "المعتاد",
"saveFavoritesAlarmHint": "احفظ محطات في المفضلة لاستخدامها كمنبه موسيقي.",
"useCurrentStationAction": "استخدام المحطة الحالية",
"playDuringVacations": "الرنين أثناء الإجازات",
@@ -464,6 +468,9 @@
"deleteRangeTooltip": "حذف النطاق",
"vacationsDefaultName": "إجازات",
"newVacationRangeTitle": "نطاق إجازة جديد",
"editVacationRangeTitle": "تعديل نطاق الإجازة",
"vacationDeleteConfirmTitle": "هل تريد حذف نطاق الإجازة؟",
"vacationDeleteConfirmMessage": "لا يمكن التراجع عن هذا الإجراء.",
"startField": "البداية",
"endField": "النهاية",
"saveRangeAction": "حفظ النطاق",
@@ -589,6 +596,8 @@
"days": {}
}
},
"alarmRepeatSectionLabel": "تكرار",
"alarmVolumeLabel": "مستوى الصوت",
"androidReliabilityTitle": "مراجعة موثوقية Android",
"closeAction": "إغلاق",
"customOption": "مخصص",
@@ -705,6 +714,7 @@
"alarmVolumeRisingStatus": "رفع مستوى الصوت تدريجيًا",
"countriesAllTitle": "كل الدول",
"countriesScreenTitle": "الدول",
"countriesSearchHint": "الدولة أو الرمز...",
"countriesYourLanguagesTitle": "لغاتك",
"customStationsAddCta": "إضافة محطة مخصصة",
"equalizerActiveOutputDefault": "مكبر صوت هذا الجهاز",
@@ -746,6 +756,7 @@
"recordingRenameEmptyError": "أدخل اسمًا",
"recordingRenameLabel": "الاسم",
"recordingsLibraryEmptySubtitle": "التسجيلات التي تحفظها ستظهر هنا.",
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · يتم حذف الأقدم عند بلوغ الحد",
"recordingsLibraryEmptyTitle": "لا توجد تسجيلات بعد",
"recordingsLibrarySettingsTooltip": "إعدادات التسجيل",
"recordingsLibraryStorageCaption": "تم استخدام {usedMb} ميغابايت من أصل {totalMb} ميغابايت",
@@ -780,6 +791,7 @@
"times": {}
}
},
"vacationExplainerBanner": "خلال هذه الفترات، لن تُصدر المنبهات المُعلَّمة بـ «إيقاف أثناء الإجازة» صوتًا. أما المُعلَّمة بـ «تشغيل دائمًا» فلن تتأثر.",
"vacationNoActiveRangeHint": "لا توجد فترة إجازة نشطة الآن.",
"vacationPastSectionTitle": "الفترات السابقة",
"vacationRangesCount": "{count} فترة",
+12
View File
@@ -262,8 +262,11 @@
"searchCountryFilterLabel": "দেশ",
"searchLanguageFilterLabel": "ভাষা",
"searchMinQualityFilterLabel": "ন্যূনতম গুণমান",
"searchLoadingStationsLabel": "স্টেশন খোঁজা হচ্ছে…",
"searchEmptyTitle": "একটি স্টেশন খুঁজুন",
"searchNoResultsTitle": "কোনো ফলাফল নেই",
"searchNoResultsForQueryTitle": "\"{query}\"-এর জন্য কোনো ফলাফল নেই",
"@searchNoResultsForQueryTitle": {"placeholders": {"query": {}}},
"searchEmptySubtitle": "উপরের বার বা চিপ ব্যবহার করে সারা বিশ্বের সিগন্যাল আবিষ্কার করুন।",
"searchNoResultsSubtitle": "সক্রিয় সিগন্যাল পেতে ফিল্টার সরিয়ে বা অন্য নাম লিখে দেখুন।",
"countrySpain": "স্পেন",
@@ -440,6 +443,7 @@
}
}
},
"alarmSnoozeUsualLabel": "সাধারণ",
"saveFavoritesAlarmHint": "সুরেলা অ্যালার্ম হিসেবে ব্যবহার করতে প্রিয়তে স্টেশন সংরক্ষণ করুন।",
"useCurrentStationAction": "বর্তমান স্টেশন ব্যবহার করুন",
"playDuringVacations": "ছুটিতে বাজান",
@@ -464,6 +468,9 @@
"deleteRangeTooltip": "পরিসর মুছুন",
"vacationsDefaultName": "ছুটি",
"newVacationRangeTitle": "নতুন ছুটির পরিসর",
"editVacationRangeTitle": "ছুটির পরিসর সম্পাদনা করুন",
"vacationDeleteConfirmTitle": "ছুটির পরিসর মুছবেন?",
"vacationDeleteConfirmMessage": "এই পদক্ষেপ ফিরিয়ে নেওয়া যাবে না।",
"startField": "শুরু",
"endField": "শেষ",
"saveRangeAction": "পরিসর সংরক্ষণ করুন",
@@ -589,6 +596,8 @@
"days": {}
}
},
"alarmRepeatSectionLabel": "পুনরাবৃত্তি",
"alarmVolumeLabel": "ভলিউম",
"androidReliabilityTitle": "Android নির্ভরযোগ্যতা দেখুন",
"closeAction": "বন্ধ করুন",
"customOption": "কাস্টম",
@@ -705,6 +714,7 @@
"alarmVolumeRisingStatus": "ভলিউম বাড়ছে",
"countriesAllTitle": "সব দেশ",
"countriesScreenTitle": "দেশসমূহ",
"countriesSearchHint": "দেশ বা কোড...",
"countriesYourLanguagesTitle": "আপনার ভাষাসমূহ",
"customStationsAddCta": "নিজস্ব স্টেশন যোগ করুন",
"equalizerActiveOutputDefault": "এই ডিভাইসের স্পিকার",
@@ -746,6 +756,7 @@
"recordingRenameEmptyError": "একটি নাম লিখুন",
"recordingRenameLabel": "নাম",
"recordingsLibraryEmptySubtitle": "আপনি যেসব রেকর্ডিং সংরক্ষণ করবেন তা এখানে দেখা যাবে।",
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · সীমা পৌঁছালে সবচেয়ে পুরনোগুলো মুছে যায়",
"recordingsLibraryEmptyTitle": "এখনও কোনো রেকর্ডিং নেই",
"recordingsLibrarySettingsTooltip": "রেকর্ডিং সেটিংস",
"recordingsLibraryStorageCaption": "{totalMb} MB-এর মধ্যে {usedMb} MB ব্যবহৃত",
@@ -780,6 +791,7 @@
"times": {}
}
},
"vacationExplainerBanner": "এই সময়কালে \"ছুটিতে বিরতি\" হিসেবে চিহ্নিত অ্যালার্মগুলো বাজবে না। \"সবসময় বাজবে\" হিসেবে চিহ্নিত অ্যালার্মগুলো প্রভাবিত হবে না।",
"vacationNoActiveRangeHint": "এই মুহূর্তে কোনো সক্রিয় ছুটির সময়সীমা নেই।",
"vacationPastSectionTitle": "অতীত সময়সীমা",
"vacationRangesCount": "{count}টি সময়সীমা",
+12
View File
@@ -262,8 +262,11 @@
"searchCountryFilterLabel": "Land",
"searchLanguageFilterLabel": "Sprache",
"searchMinQualityFilterLabel": "Mindestqualität",
"searchLoadingStationsLabel": "SENDER WERDEN GESUCHT…",
"searchEmptyTitle": "Suche nach einem Sender",
"searchNoResultsTitle": "Keine Ergebnisse",
"searchNoResultsForQueryTitle": "Keine Ergebnisse für „{query}“",
"@searchNoResultsForQueryTitle": {"placeholders": {"query": {}}},
"searchEmptySubtitle": "Nutze die obere Leiste oder die Chips, um Sender aus aller Welt zu entdecken.",
"searchNoResultsSubtitle": "Versuche, Filter zu entfernen oder einen anderen Namen einzugeben, um einen aktiven Sender zu finden.",
"countrySpain": "Spanien",
@@ -440,6 +443,7 @@
}
}
},
"alarmSnoozeUsualLabel": "üblich",
"saveFavoritesAlarmHint": "Speichere Sender in Favoriten, um sie als musikalischen Alarm zu verwenden.",
"useCurrentStationAction": "Aktuellen Sender verwenden",
"playDuringVacations": "Während der Ferien läuten",
@@ -464,6 +468,9 @@
"deleteRangeTooltip": "Zeitraum löschen",
"vacationsDefaultName": "Ferien",
"newVacationRangeTitle": "Neuer Ferienzeitraum",
"editVacationRangeTitle": "Ferienzeitraum bearbeiten",
"vacationDeleteConfirmTitle": "Ferienzeitraum löschen?",
"vacationDeleteConfirmMessage": "Dies kann nicht rückgängig gemacht werden.",
"startField": "Beginn",
"endField": "Ende",
"saveRangeAction": "Zeitraum speichern",
@@ -589,6 +596,8 @@
"days": {}
}
},
"alarmRepeatSectionLabel": "WIEDERHOLEN",
"alarmVolumeLabel": "Lautstärke",
"androidReliabilityTitle": "Android-Zuverlässigkeit prüfen",
"closeAction": "Schließen",
"customOption": "Benutzerdefiniert",
@@ -705,6 +714,7 @@
"alarmVolumeRisingStatus": "Lautstärke wird erhöht",
"countriesAllTitle": "Alle Länder",
"countriesScreenTitle": "Länder",
"countriesSearchHint": "Land oder Code...",
"countriesYourLanguagesTitle": "Deine Sprachen",
"customStationsAddCta": "Benutzerdefinierten Sender hinzufügen",
"equalizerActiveOutputDefault": "Der Lautsprecher dieses Geräts",
@@ -746,6 +756,7 @@
"recordingRenameEmptyError": "Gib einen Namen ein",
"recordingRenameLabel": "Name",
"recordingsLibraryEmptySubtitle": "Aufnahmen, die du speicherst, erscheinen hier.",
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · älteste werden bei Erreichen des Limits gelöscht",
"recordingsLibraryEmptyTitle": "Noch keine Aufnahmen",
"recordingsLibrarySettingsTooltip": "Aufnahmeeinstellungen",
"recordingsLibraryStorageCaption": "{usedMb} MB von {totalMb} MB belegt",
@@ -780,6 +791,7 @@
"times": {}
}
},
"vacationExplainerBanner": "Während dieser Zeiträume klingeln Alarme mit der Markierung \"im Urlaub pausieren\" nicht. Als \"immer klingeln\" markierte Alarme sind davon nicht betroffen.",
"vacationNoActiveRangeHint": "Momentan ist kein aktiver Urlaubszeitraum vorhanden.",
"vacationPastSectionTitle": "Vergangene Zeiträume",
"vacationRangesCount": "{count} Zeiträume",
+12
View File
@@ -233,6 +233,7 @@
}
}
},
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · purges oldest at limit",
"recordingsLibraryEmptyTitle": "No recordings yet",
"recordingsLibraryEmptySubtitle": "Recordings you save will appear here.",
"recordingActionRename": "Rename",
@@ -315,13 +316,17 @@
"searchCountryFilterLabel": "Country",
"searchLanguageFilterLabel": "Language",
"searchMinQualityFilterLabel": "Minimum quality",
"searchLoadingStationsLabel": "SEARCHING FOR STATIONS…",
"searchEmptyTitle": "Search for a station",
"searchNoResultsTitle": "No results",
"searchNoResultsForQueryTitle": "No results for \"{query}\"",
"@searchNoResultsForQueryTitle": {"placeholders": {"query": {}}},
"searchEmptySubtitle": "Use the top bar or chips to discover stations from around the world.",
"searchNoResultsSubtitle": "Try removing filters or typing another name to find an active station.",
"searchResultsCount": "{count, plural, =1{1 result} other{{count} results}}",
"searchClearFiltersAction": "{count, plural, =1{Clear filter} other{Clear {count} filters}}",
"countriesScreenTitle": "Countries",
"countriesSearchHint": "Country or code...",
"countriesYourLanguagesTitle": "Your languages",
"countriesAllTitle": "All countries",
"radioCountriesError": "We couldn't load the countries.",
@@ -509,6 +514,7 @@
}
}
},
"alarmSnoozeUsualLabel": "usual",
"saveFavoritesAlarmHint": "Save stations in Favorites to use them as a music alarm.",
"useCurrentStationAction": "Use current station",
"playDuringVacations": "Play during vacations",
@@ -570,9 +576,13 @@
"vacationUpcomingSectionTitle": "SCHEDULED",
"vacationPastSectionTitle": "Past ranges",
"addVacationRangeCta": "Add range",
"vacationExplainerBanner": "During these ranges, alarms marked \"pause during vacations\" won't ring. Alarms marked \"always ring\" are not affected.",
"vacationNoActiveRangeHint": "No active vacation range right now.",
"vacationsDefaultName": "Vacation",
"newVacationRangeTitle": "New vacation range",
"editVacationRangeTitle": "Edit vacation range",
"vacationDeleteConfirmTitle": "Delete vacation range?",
"vacationDeleteConfirmMessage": "This can't be undone.",
"startField": "Start",
"endField": "End",
"saveRangeAction": "Save range",
@@ -698,6 +708,8 @@
"days": {}
}
},
"alarmRepeatSectionLabel": "REPEAT",
"alarmVolumeLabel": "Volume",
"androidReliabilityTitle": "Review Android reliability",
"closeAction": "Close",
"customOption": "Custom",
+12
View File
@@ -233,6 +233,7 @@
}
}
},
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · se borran las más antiguas al llegar al límite",
"recordingsLibraryEmptyTitle": "Todavía no hay grabaciones",
"recordingsLibraryEmptySubtitle": "Las grabaciones que guardes van a aparecer acá.",
"recordingActionRename": "Renombrar",
@@ -315,13 +316,17 @@
"searchCountryFilterLabel": "País",
"searchLanguageFilterLabel": "Idioma",
"searchMinQualityFilterLabel": "Calidad mínima",
"searchLoadingStationsLabel": "BUSCANDO EMISORAS…",
"searchEmptyTitle": "Buscá una emisora",
"searchNoResultsTitle": "Sin resultados",
"searchNoResultsForQueryTitle": "Sin resultados para «{query}»",
"@searchNoResultsForQueryTitle": {"placeholders": {"query": {}}},
"searchEmptySubtitle": "Usá la barra superior o los chips para descubrir señales de todo el mundo.",
"searchNoResultsSubtitle": "Probá quitar filtros o escribir otro nombre para encontrar una señal activa.",
"searchResultsCount": "{count, plural, =1{1 resultado} other{{count} resultados}}",
"searchClearFiltersAction": "{count, plural, =1{Quitar el filtro} other{Quitar los {count} filtros}}",
"countriesScreenTitle": "Países",
"countriesSearchHint": "País o código...",
"countriesYourLanguagesTitle": "Tus idiomas",
"countriesAllTitle": "Todos los países",
"radioCountriesError": "No pudimos cargar los países.",
@@ -509,6 +514,7 @@
}
}
},
"alarmSnoozeUsualLabel": "habitual",
"saveFavoritesAlarmHint": "Guardá emisoras en Favoritos para usarlas como alarma musical.",
"useCurrentStationAction": "Usar emisora actual",
"playDuringVacations": "Sonar durante vacaciones",
@@ -570,9 +576,13 @@
"vacationUpcomingSectionTitle": "PROGRAMADOS",
"vacationPastSectionTitle": "Rangos pasados",
"addVacationRangeCta": "Añadir rango",
"vacationExplainerBanner": "Durante estos rangos no suenan las alarmas marcadas como \"pausar en vacaciones\". Las marcadas como \"sonar siempre\" no se ven afectadas.",
"vacationNoActiveRangeHint": "No hay un rango de vacaciones activo ahora mismo.",
"vacationsDefaultName": "Vacaciones",
"newVacationRangeTitle": "Nuevo rango de vacaciones",
"editVacationRangeTitle": "Editar rango de vacaciones",
"vacationDeleteConfirmTitle": "¿Eliminar rango de vacaciones?",
"vacationDeleteConfirmMessage": "Esta acción no se puede deshacer.",
"startField": "Inicio",
"endField": "Fin",
"saveRangeAction": "Guardar rango",
@@ -654,6 +664,8 @@
"@alarmScheduleOnce": {"placeholders": {"date": {}}},
"alarmScheduleWeekdays": "Días: {days}",
"@alarmScheduleWeekdays": {"placeholders": {"days": {}}},
"alarmRepeatSectionLabel": "REPETIR",
"alarmVolumeLabel": "Volumen",
"androidReliabilityTitle": "Revisar fiabilidad Android",
"closeAction": "Cerrar",
"customOption": "Personalizada",
+12
View File
@@ -262,8 +262,11 @@
"searchCountryFilterLabel": "Pays",
"searchLanguageFilterLabel": "Langue",
"searchMinQualityFilterLabel": "Qualité minimale",
"searchLoadingStationsLabel": "RECHERCHE DE STATIONS…",
"searchEmptyTitle": "Recherchez une station",
"searchNoResultsTitle": "Aucun résultat",
"searchNoResultsForQueryTitle": "Aucun résultat pour « {query} »",
"@searchNoResultsForQueryTitle": {"placeholders": {"query": {}}},
"searchEmptySubtitle": "Utilisez la barre du haut ou les pastilles pour découvrir des stations du monde entier.",
"searchNoResultsSubtitle": "Essayez de retirer des filtres ou de saisir un autre nom pour trouver une station active.",
"countrySpain": "Espagne",
@@ -440,6 +443,7 @@
}
}
},
"alarmSnoozeUsualLabel": "habituel",
"saveFavoritesAlarmHint": "Enregistrez des stations dans les Favoris pour les utiliser comme alarme musicale.",
"useCurrentStationAction": "Utiliser la station actuelle",
"playDuringVacations": "Sonner pendant les vacances",
@@ -464,6 +468,9 @@
"deleteRangeTooltip": "Supprimer la période",
"vacationsDefaultName": "Vacances",
"newVacationRangeTitle": "Nouvelle période de vacances",
"editVacationRangeTitle": "Modifier la période de vacances",
"vacationDeleteConfirmTitle": "Supprimer la période de vacances ?",
"vacationDeleteConfirmMessage": "Cette action est irréversible.",
"startField": "Début",
"endField": "Fin",
"saveRangeAction": "Enregistrer la période",
@@ -589,6 +596,8 @@
"days": {}
}
},
"alarmRepeatSectionLabel": "RÉPÉTER",
"alarmVolumeLabel": "Volume",
"androidReliabilityTitle": "Vérifier la fiabilité Android",
"closeAction": "Fermer",
"customOption": "Personnalisée",
@@ -705,6 +714,7 @@
"alarmVolumeRisingStatus": "Augmentation du volume",
"countriesAllTitle": "Tous les pays",
"countriesScreenTitle": "Pays",
"countriesSearchHint": "Pays ou code...",
"countriesYourLanguagesTitle": "Vos langues",
"customStationsAddCta": "Ajouter une station personnalisée",
"equalizerActiveOutputDefault": "Le haut-parleur de cet appareil",
@@ -746,6 +756,7 @@
"recordingRenameEmptyError": "Saisissez un nom",
"recordingRenameLabel": "Nom",
"recordingsLibraryEmptySubtitle": "Les enregistrements que vous sauvegardez apparaîtront ici.",
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · supprime les plus anciens à la limite",
"recordingsLibraryEmptyTitle": "Aucun enregistrement pour l'instant",
"recordingsLibrarySettingsTooltip": "Paramètres d'enregistrement",
"recordingsLibraryStorageCaption": "{usedMb} Mo sur {totalMb} Mo utilisés",
@@ -780,6 +791,7 @@
"times": {}
}
},
"vacationExplainerBanner": "Pendant ces periodes, les alarmes marquees \"pause pendant les vacances\" ne sonnent pas. Celles marquees \"toujours sonner\" ne sont pas concernees.",
"vacationNoActiveRangeHint": "Aucune période de vacances active pour le moment.",
"vacationPastSectionTitle": "Périodes passées",
"vacationRangesCount": "{count} périodes",
+12
View File
@@ -262,8 +262,11 @@
"searchCountryFilterLabel": "देश",
"searchLanguageFilterLabel": "भाषा",
"searchMinQualityFilterLabel": "न्यूनतम गुणवत्ता",
"searchLoadingStationsLabel": "स्टेशन खोजे जा रहे हैं…",
"searchEmptyTitle": "एक स्टेशन खोजें",
"searchNoResultsTitle": "कोई परिणाम नहीं",
"searchNoResultsForQueryTitle": "\"{query}\" के लिए कोई परिणाम नहीं",
"@searchNoResultsForQueryTitle": {"placeholders": {"query": {}}},
"searchEmptySubtitle": "दुनिया भर के सिग्नल खोजने के लिए ऊपर की बार या चिप्स इस्तेमाल करें।",
"searchNoResultsSubtitle": "सक्रिय सिग्नल पाने के लिए फ़िल्टर हटाएँ या कोई दूसरा नाम लिखें।",
"countrySpain": "स्पेन",
@@ -440,6 +443,7 @@
}
}
},
"alarmSnoozeUsualLabel": "सामान्य",
"saveFavoritesAlarmHint": "उन्हें संगीतमय अलार्म के रूप में इस्तेमाल करने के लिए स्टेशन पसंदीदा में सहेजें।",
"useCurrentStationAction": "वर्तमान स्टेशन इस्तेमाल करें",
"playDuringVacations": "छुट्टियों में बजाएँ",
@@ -464,6 +468,9 @@
"deleteRangeTooltip": "अवधि हटाएँ",
"vacationsDefaultName": "छुट्टियाँ",
"newVacationRangeTitle": "नई छुट्टी अवधि",
"editVacationRangeTitle": "छुट्टी अवधि संपादित करें",
"vacationDeleteConfirmTitle": "छुट्टी अवधि हटाएं?",
"vacationDeleteConfirmMessage": "इसे वापस नहीं लिया जा सकता।",
"startField": "शुरुआत",
"endField": "समाप्ति",
"saveRangeAction": "अवधि सहेजें",
@@ -589,6 +596,8 @@
"days": {}
}
},
"alarmRepeatSectionLabel": "दोहराएं",
"alarmVolumeLabel": "आवाज़",
"androidReliabilityTitle": "Android विश्वसनीयता जाँचें",
"closeAction": "बंद करें",
"customOption": "कस्टम",
@@ -705,6 +714,7 @@
"alarmVolumeRisingStatus": "आवाज़ बढ़ाई जा रही है",
"countriesAllTitle": "सभी देश",
"countriesScreenTitle": "देश",
"countriesSearchHint": "देश या कोड...",
"countriesYourLanguagesTitle": "आपकी भाषाएं",
"customStationsAddCta": "मनचाहा स्टेशन जोड़ें",
"equalizerActiveOutputDefault": "इस डिवाइस का स्पीकर",
@@ -746,6 +756,7 @@
"recordingRenameEmptyError": "एक नाम डालें",
"recordingRenameLabel": "नाम",
"recordingsLibraryEmptySubtitle": "आप जो रिकॉर्डिंग सहेजेंगे, वे यहां दिखाई देंगी।",
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · सीमा पूरी होने पर सबसे पुरानी रिकॉर्डिंग हटा दी जाती हैं",
"recordingsLibraryEmptyTitle": "अभी तक कोई रिकॉर्डिंग नहीं",
"recordingsLibrarySettingsTooltip": "रिकॉर्डिंग सेटिंग्स",
"recordingsLibraryStorageCaption": "{totalMb} MB में से {usedMb} MB इस्तेमाल हुआ",
@@ -780,6 +791,7 @@
"times": {}
}
},
"vacationExplainerBanner": "इन अवधियों के दौरान \"छुट्टी में रोकें\" के रूप में चिह्नित अलार्म नहीं बजेंगे। \"हमेशा बजाएं\" के रूप में चिह्नित अलार्म पर कोई असर नहीं पड़ता।",
"vacationNoActiveRangeHint": "अभी कोई सक्रिय छुट्टी अवधि नहीं है।",
"vacationPastSectionTitle": "पिछली अवधियां",
"vacationRangesCount": "{count} अवधियां",
+12
View File
@@ -262,8 +262,11 @@
"searchCountryFilterLabel": "Negara",
"searchLanguageFilterLabel": "Bahasa",
"searchMinQualityFilterLabel": "Kualitas minimum",
"searchLoadingStationsLabel": "MENCARI STASIUN…",
"searchEmptyTitle": "Cari stasiun",
"searchNoResultsTitle": "Tidak ada hasil",
"searchNoResultsForQueryTitle": "Tidak ada hasil untuk \"{query}\"",
"@searchNoResultsForQueryTitle": {"placeholders": {"query": {}}},
"searchEmptySubtitle": "Gunakan bilah atas atau chip untuk menemukan sinyal dari seluruh dunia.",
"searchNoResultsSubtitle": "Coba hapus filter atau tulis nama lain untuk menemukan sinyal aktif.",
"countrySpain": "Spanyol",
@@ -440,6 +443,7 @@
}
}
},
"alarmSnoozeUsualLabel": "biasa",
"saveFavoritesAlarmHint": "Simpan stasiun ke Favorit untuk digunakan sebagai alarm musik.",
"useCurrentStationAction": "Gunakan stasiun saat ini",
"playDuringVacations": "Bunyi saat liburan",
@@ -464,6 +468,9 @@
"deleteRangeTooltip": "Hapus rentang",
"vacationsDefaultName": "Liburan",
"newVacationRangeTitle": "Rentang liburan baru",
"editVacationRangeTitle": "Edit rentang liburan",
"vacationDeleteConfirmTitle": "Hapus rentang liburan?",
"vacationDeleteConfirmMessage": "Tindakan ini tidak dapat dibatalkan.",
"startField": "Mulai",
"endField": "Akhir",
"saveRangeAction": "Simpan rentang",
@@ -589,6 +596,8 @@
"days": {}
}
},
"alarmRepeatSectionLabel": "ULANGI",
"alarmVolumeLabel": "Volume",
"androidReliabilityTitle": "Tinjau keandalan Android",
"closeAction": "Tutup",
"customOption": "Kustom",
@@ -705,6 +714,7 @@
"alarmVolumeRisingStatus": "Menaikkan volume",
"countriesAllTitle": "Semua negara",
"countriesScreenTitle": "Negara",
"countriesSearchHint": "Negara atau kode...",
"countriesYourLanguagesTitle": "Bahasa Anda",
"customStationsAddCta": "Tambahkan stasiun khusus",
"equalizerActiveOutputDefault": "Speaker perangkat ini",
@@ -746,6 +756,7 @@
"recordingRenameEmptyError": "Masukkan nama",
"recordingRenameLabel": "Nama",
"recordingsLibraryEmptySubtitle": "Rekaman yang Anda simpan akan muncul di sini.",
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · yang terlama dihapus saat mencapai batas",
"recordingsLibraryEmptyTitle": "Belum ada rekaman",
"recordingsLibrarySettingsTooltip": "Pengaturan rekaman",
"recordingsLibraryStorageCaption": "{usedMb} MB dari {totalMb} MB terpakai",
@@ -780,6 +791,7 @@
"times": {}
}
},
"vacationExplainerBanner": "Selama periode ini, alarm yang ditandai \"jeda saat liburan\" tidak akan berbunyi. Alarm yang ditandai \"selalu berbunyi\" tidak terpengaruh.",
"vacationNoActiveRangeHint": "Tidak ada periode liburan aktif saat ini.",
"vacationPastSectionTitle": "Periode lampau",
"vacationRangesCount": "{count} periode",
+12
View File
@@ -262,8 +262,11 @@
"searchCountryFilterLabel": "Paese",
"searchLanguageFilterLabel": "Lingua",
"searchMinQualityFilterLabel": "Qualità minima",
"searchLoadingStationsLabel": "RICERCA STAZIONI IN CORSO…",
"searchEmptyTitle": "Cerca un'emittente",
"searchNoResultsTitle": "Nessun risultato",
"searchNoResultsForQueryTitle": "Nessun risultato per \"{query}\"",
"@searchNoResultsForQueryTitle": {"placeholders": {"query": {}}},
"searchEmptySubtitle": "Usa la barra in alto o i chip per scoprire emittenti da tutto il mondo.",
"searchNoResultsSubtitle": "Prova a rimuovere i filtri o a digitare un altro nome per trovare un'emittente attiva.",
"countrySpain": "Spagna",
@@ -440,6 +443,7 @@
}
}
},
"alarmSnoozeUsualLabel": "abituale",
"saveFavoritesAlarmHint": "Salva emittenti nei Preferiti per usarle come sveglia musicale.",
"useCurrentStationAction": "Usa emittente attuale",
"playDuringVacations": "Suona durante le vacanze",
@@ -464,6 +468,9 @@
"deleteRangeTooltip": "Elimina periodo",
"vacationsDefaultName": "Vacanze",
"newVacationRangeTitle": "Nuovo periodo di vacanza",
"editVacationRangeTitle": "Modifica periodo di vacanza",
"vacationDeleteConfirmTitle": "Eliminare il periodo di vacanza?",
"vacationDeleteConfirmMessage": "Questa azione non può essere annullata.",
"startField": "Inizio",
"endField": "Fine",
"saveRangeAction": "Salva periodo",
@@ -589,6 +596,8 @@
"days": {}
}
},
"alarmRepeatSectionLabel": "RIPETI",
"alarmVolumeLabel": "Volume",
"androidReliabilityTitle": "Controlla affidabilità Android",
"closeAction": "Chiudi",
"customOption": "Personalizzata",
@@ -705,6 +714,7 @@
"alarmVolumeRisingStatus": "Aumento del volume",
"countriesAllTitle": "Tutti i paesi",
"countriesScreenTitle": "Paesi",
"countriesSearchHint": "Paese o codice...",
"countriesYourLanguagesTitle": "Le tue lingue",
"customStationsAddCta": "Aggiungi emittente personalizzata",
"equalizerActiveOutputDefault": "L'altoparlante di questo dispositivo",
@@ -746,6 +756,7 @@
"recordingRenameEmptyError": "Inserisci un nome",
"recordingRenameLabel": "Nome",
"recordingsLibraryEmptySubtitle": "Le registrazioni che salvi appariranno qui.",
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · elimina le più vecchie al raggiungimento del limite",
"recordingsLibraryEmptyTitle": "Ancora nessuna registrazione",
"recordingsLibrarySettingsTooltip": "Impostazioni di registrazione",
"recordingsLibraryStorageCaption": "{usedMb} MB di {totalMb} MB usati",
@@ -780,6 +791,7 @@
"times": {}
}
},
"vacationExplainerBanner": "Durante questi periodi le sveglie contrassegnate come \"pausa in vacanza\" non suonano. Quelle contrassegnate come \"suona sempre\" non sono interessate.",
"vacationNoActiveRangeHint": "Nessun periodo di vacanza attivo al momento.",
"vacationPastSectionTitle": "Periodi passati",
"vacationRangesCount": "{count} periodi",
+12
View File
@@ -262,8 +262,11 @@
"searchCountryFilterLabel": "国",
"searchLanguageFilterLabel": "言語",
"searchMinQualityFilterLabel": "最低品質",
"searchLoadingStationsLabel": "局を検索中…",
"searchEmptyTitle": "局を検索",
"searchNoResultsTitle": "結果がありません",
"searchNoResultsForQueryTitle": "「{query}」の結果はありません",
"@searchNoResultsForQueryTitle": {"placeholders": {"query": {}}},
"searchEmptySubtitle": "上部バーやチップを使って、世界中の電波を見つけましょう。",
"searchNoResultsSubtitle": "有効な電波を見つけるには、フィルターを外すか別の名前を入力してください。",
"countrySpain": "スペイン",
@@ -440,6 +443,7 @@
}
}
},
"alarmSnoozeUsualLabel": "通常",
"saveFavoritesAlarmHint": "音楽アラームとして使うには、局をお気に入りに保存してください。",
"useCurrentStationAction": "現在の局を使用",
"playDuringVacations": "休暇中も鳴らす",
@@ -464,6 +468,9 @@
"deleteRangeTooltip": "期間を削除",
"vacationsDefaultName": "休暇",
"newVacationRangeTitle": "新しい休暇期間",
"editVacationRangeTitle": "休暇期間を編集",
"vacationDeleteConfirmTitle": "休暇期間を削除しますか?",
"vacationDeleteConfirmMessage": "この操作は元に戻せません。",
"startField": "開始",
"endField": "終了",
"saveRangeAction": "期間を保存",
@@ -589,6 +596,8 @@
"days": {}
}
},
"alarmRepeatSectionLabel": "繰り返し",
"alarmVolumeLabel": "音量",
"androidReliabilityTitle": "Androidの信頼性を確認",
"closeAction": "閉じる",
"customOption": "カスタム",
@@ -705,6 +714,7 @@
"alarmVolumeRisingStatus": "音量を上げています",
"countriesAllTitle": "すべての国",
"countriesScreenTitle": "国",
"countriesSearchHint": "国名またはコード...",
"countriesYourLanguagesTitle": "あなたの言語",
"customStationsAddCta": "カスタム局を追加",
"equalizerActiveOutputDefault": "この端末のスピーカー",
@@ -746,6 +756,7 @@
"recordingRenameEmptyError": "名前を入力してください",
"recordingRenameLabel": "名前",
"recordingsLibraryEmptySubtitle": "保存した録音はここに表示されます。",
"recordingsLibraryStorageFolderCaption": "Music/PluriWave・上限に達すると古い順に削除されます",
"recordingsLibraryEmptyTitle": "まだ録音がありません",
"recordingsLibrarySettingsTooltip": "録音設定",
"recordingsLibraryStorageCaption": "{totalMb} MB 中 {usedMb} MB 使用済み",
@@ -780,6 +791,7 @@
"times": {}
}
},
"vacationExplainerBanner": "この期間中は「休暇中は一時停止」に設定したアラームは鳴りません。「常に鳴らす」に設定したアラームには影響しません。",
"vacationNoActiveRangeHint": "現在アクティブな休暇期間はありません。",
"vacationPastSectionTitle": "過去の期間",
"vacationRangesCount": "{count} 件の期間",
+12
View File
@@ -262,8 +262,11 @@
"searchCountryFilterLabel": "País",
"searchLanguageFilterLabel": "Idioma",
"searchMinQualityFilterLabel": "Qualidade mínima",
"searchLoadingStationsLabel": "PROCURANDO EMISSORAS…",
"searchEmptyTitle": "Busque uma estação",
"searchNoResultsTitle": "Sem resultados",
"searchNoResultsForQueryTitle": "Nenhum resultado para \"{query}\"",
"@searchNoResultsForQueryTitle": {"placeholders": {"query": {}}},
"searchEmptySubtitle": "Use a barra superior ou os chips para descobrir estações do mundo todo.",
"searchNoResultsSubtitle": "Tente remover filtros ou digitar outro nome para encontrar uma estação ativa.",
"countrySpain": "Espanha",
@@ -440,6 +443,7 @@
}
}
},
"alarmSnoozeUsualLabel": "usual",
"saveFavoritesAlarmHint": "Salve estações nos Favoritos para usá-las como alarme musical.",
"useCurrentStationAction": "Usar estação atual",
"playDuringVacations": "Tocar durante as férias",
@@ -464,6 +468,9 @@
"deleteRangeTooltip": "Excluir período",
"vacationsDefaultName": "Férias",
"newVacationRangeTitle": "Novo período de férias",
"editVacationRangeTitle": "Editar período de férias",
"vacationDeleteConfirmTitle": "Excluir período de férias?",
"vacationDeleteConfirmMessage": "Esta ação não pode ser desfeita.",
"startField": "Início",
"endField": "Fim",
"saveRangeAction": "Salvar período",
@@ -589,6 +596,8 @@
"days": {}
}
},
"alarmRepeatSectionLabel": "REPETIÇÃO",
"alarmVolumeLabel": "Volume",
"androidReliabilityTitle": "Revisar confiabilidade Android",
"closeAction": "Fechar",
"customOption": "Personalizada",
@@ -705,6 +714,7 @@
"alarmVolumeRisingStatus": "Aumentando o volume",
"countriesAllTitle": "Todos os países",
"countriesScreenTitle": "Países",
"countriesSearchHint": "País ou código...",
"countriesYourLanguagesTitle": "Seus idiomas",
"customStationsAddCta": "Adicionar estação personalizada",
"equalizerActiveOutputDefault": "O alto-falante deste dispositivo",
@@ -746,6 +756,7 @@
"recordingRenameEmptyError": "Digite um nome",
"recordingRenameLabel": "Nome",
"recordingsLibraryEmptySubtitle": "As gravações que você salvar vão aparecer aqui.",
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · apaga as mais antigas ao atingir o limite",
"recordingsLibraryEmptyTitle": "Ainda não há gravações",
"recordingsLibrarySettingsTooltip": "Configurações de gravação",
"recordingsLibraryStorageCaption": "{usedMb} MB de {totalMb} MB usados",
@@ -780,6 +791,7 @@
"times": {}
}
},
"vacationExplainerBanner": "Durante esses periodos, os alarmes marcados como \"pausar nas ferias\" nao tocam. Os marcados como \"tocar sempre\" nao sao afetados.",
"vacationNoActiveRangeHint": "Não há período de férias ativo no momento.",
"vacationPastSectionTitle": "Períodos passados",
"vacationRangesCount": "{count} períodos",
+12
View File
@@ -262,8 +262,11 @@
"searchCountryFilterLabel": "Страна",
"searchLanguageFilterLabel": "Язык",
"searchMinQualityFilterLabel": "Минимальное качество",
"searchLoadingStationsLabel": "ПОИСК СТАНЦИЙ…",
"searchEmptyTitle": "Найдите станцию",
"searchNoResultsTitle": "Нет результатов",
"searchNoResultsForQueryTitle": "Нет результатов по запросу «{query}»",
"@searchNoResultsForQueryTitle": {"placeholders": {"query": {}}},
"searchEmptySubtitle": "Используйте верхнюю строку или чипы, чтобы находить сигналы со всего мира.",
"searchNoResultsSubtitle": "Попробуйте убрать фильтры или ввести другое название, чтобы найти активный сигнал.",
"countrySpain": "Испания",
@@ -440,6 +443,7 @@
}
}
},
"alarmSnoozeUsualLabel": "обычно",
"saveFavoritesAlarmHint": "Сохраните станции в избранное, чтобы использовать их как музыкальный будильник.",
"useCurrentStationAction": "Использовать текущую станцию",
"playDuringVacations": "Звонить во время отпусков",
@@ -464,6 +468,9 @@
"deleteRangeTooltip": "Удалить период",
"vacationsDefaultName": "Отпуск",
"newVacationRangeTitle": "Новый период отпуска",
"editVacationRangeTitle": "Изменить период отпуска",
"vacationDeleteConfirmTitle": "Удалить период отпуска?",
"vacationDeleteConfirmMessage": "Это действие нельзя отменить.",
"startField": "Начало",
"endField": "Конец",
"saveRangeAction": "Сохранить период",
@@ -589,6 +596,8 @@
"days": {}
}
},
"alarmRepeatSectionLabel": "ПОВТОР",
"alarmVolumeLabel": "Громкость",
"androidReliabilityTitle": "Проверить надёжность Android",
"closeAction": "Закрыть",
"customOption": "Своя",
@@ -705,6 +714,7 @@
"alarmVolumeRisingStatus": "Громкость нарастает",
"countriesAllTitle": "Все страны",
"countriesScreenTitle": "Страны",
"countriesSearchHint": "Страна или код...",
"countriesYourLanguagesTitle": "Ваши языки",
"customStationsAddCta": "Добавить пользовательскую станцию",
"equalizerActiveOutputDefault": "Динамик этого устройства",
@@ -746,6 +756,7 @@
"recordingRenameEmptyError": "Введите название",
"recordingRenameLabel": "Название",
"recordingsLibraryEmptySubtitle": "Записи, которые вы сохраните, появятся здесь.",
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · при достижении лимита удаляются старые записи",
"recordingsLibraryEmptyTitle": "Записей пока нет",
"recordingsLibrarySettingsTooltip": "Настройки записи",
"recordingsLibraryStorageCaption": "Использовано {usedMb} МБ из {totalMb} МБ",
@@ -780,6 +791,7 @@
"times": {}
}
},
"vacationExplainerBanner": "В эти периоды будильники с пометкой «пауза на время отпуска» не звонят. Будильники с пометкой «звонить всегда» это не затрагивает.",
"vacationNoActiveRangeHint": "Сейчас нет активного периода отпуска.",
"vacationPastSectionTitle": "Прошедшие периоды",
"vacationRangesCount": "{count} периодов",
+12
View File
@@ -262,8 +262,11 @@
"searchCountryFilterLabel": "国家/地区",
"searchLanguageFilterLabel": "语言",
"searchMinQualityFilterLabel": "最低质量",
"searchLoadingStationsLabel": "正在搜索电台…",
"searchEmptyTitle": "搜索电台",
"searchNoResultsTitle": "没有结果",
"searchNoResultsForQueryTitle": "未找到与\"{query}\"相关的结果",
"@searchNoResultsForQueryTitle": {"placeholders": {"query": {}}},
"searchEmptySubtitle": "使用顶部搜索栏或筛选标签,发现世界各地的电台信号。",
"searchNoResultsSubtitle": "尝试减少筛选条件,或换个名称搜索,找到正在播出的电台。",
"countrySpain": "西班牙",
@@ -440,6 +443,7 @@
}
}
},
"alarmSnoozeUsualLabel": "常用",
"saveFavoritesAlarmHint": "将电台保存到收藏,即可把它们用作音乐闹钟。",
"useCurrentStationAction": "使用当前电台",
"playDuringVacations": "假期期间响铃",
@@ -464,6 +468,9 @@
"deleteRangeTooltip": "删除范围",
"vacationsDefaultName": "假期",
"newVacationRangeTitle": "新的假期范围",
"editVacationRangeTitle": "编辑假期范围",
"vacationDeleteConfirmTitle": "删除假期范围?",
"vacationDeleteConfirmMessage": "此操作无法撤销。",
"startField": "开始",
"endField": "结束",
"saveRangeAction": "保存范围",
@@ -589,6 +596,8 @@
"days": {}
}
},
"alarmRepeatSectionLabel": "重复",
"alarmVolumeLabel": "音量",
"androidReliabilityTitle": "检查 Android 可靠性",
"closeAction": "关闭",
"customOption": "自定义",
@@ -705,6 +714,7 @@
"alarmVolumeRisingStatus": "音量渐强中",
"countriesAllTitle": "所有国家",
"countriesScreenTitle": "国家",
"countriesSearchHint": "国家或代码...",
"countriesYourLanguagesTitle": "你的语言",
"customStationsAddCta": "添加自定义电台",
"equalizerActiveOutputDefault": "此设备的扬声器",
@@ -746,6 +756,7 @@
"recordingRenameEmptyError": "请输入名称",
"recordingRenameLabel": "名称",
"recordingsLibraryEmptySubtitle": "你保存的录音会显示在这里。",
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · 达到上限时会删除最旧的文件",
"recordingsLibraryEmptyTitle": "暂无录音",
"recordingsLibrarySettingsTooltip": "录音设置",
"recordingsLibraryStorageCaption": "已使用 {usedMb} MB / 共 {totalMb} MB",
@@ -780,6 +791,7 @@
"times": {}
}
},
"vacationExplainerBanner": "在这些时间段内,标记为“假期暂停”的闹钟不会响铃;标记为“始终响铃”的闹钟不受影响。",
"vacationNoActiveRangeHint": "目前没有正在生效的假期时间段。",
"vacationPastSectionTitle": "过往时间段",
"vacationRangesCount": "{count} 个时间段",
+18
View File
@@ -22,3 +22,21 @@ String diaMesLocalizado(String localeTag, DateTime fecha) =>
/// audit 9b.4 (t4:459), the small caption under the day-month.
String nombreDiaSemanaLocalizado(String localeTag, DateTime fecha) =>
DateFormat.EEEE(localeTag).format(fecha);
/// Full weekday + month + day, locale-aware (e.g. "lunes, 3 de agosto" for
/// `es`, "Monday, August 3" for `en`) — audit 9.4 (t4:419), the ringing
/// screen's date line between the schedule pill and the hero time.
String fechaLargaConDiaSemana(String localeTag, DateTime fecha) =>
DateFormat.MMMMEEEEd(localeTag).format(fecha);
/// Short "dd MON" range pill, locale-aware month abbreviation, uppercased
/// (e.g. "418 AGO" for `es`, "418 AUG" for `en`) — audit 7.3 (t4:332),
/// the vacation-row date-range pill on the Alarmas root. Always labels the
/// range with the END date's month: vacation ranges are short (days to a
/// couple of weeks), so a cross-month span is the rare case, and the
/// prototype itself only ever shows a single abbreviation.
String rangoFechasCorto(String localeTag, DateTime inicio, DateTime fin) {
final dia = DateFormat.d(localeTag);
final mes = DateFormat.MMM(localeTag).format(fin).toUpperCase();
return '${dia.format(inicio)}${dia.format(fin)} $mes';
}
+66
View File
@@ -880,6 +880,12 @@ abstract class AppLocalizations {
/// **'{usedMb} MB de {totalMb} MB usados'**
String recordingsLibraryStorageCaption(int usedMb, int totalMb);
/// No description provided for @recordingsLibraryStorageFolderCaption.
///
/// In es, this message translates to:
/// **'Music/PluriWave · se borran las más antiguas al llegar al límite'**
String get recordingsLibraryStorageFolderCaption;
/// No description provided for @recordingsLibraryEmptyTitle.
///
/// In es, this message translates to:
@@ -1186,6 +1192,12 @@ abstract class AppLocalizations {
/// **'Calidad mínima'**
String get searchMinQualityFilterLabel;
/// No description provided for @searchLoadingStationsLabel.
///
/// In es, this message translates to:
/// **'BUSCANDO EMISORAS…'**
String get searchLoadingStationsLabel;
/// No description provided for @searchEmptyTitle.
///
/// In es, this message translates to:
@@ -1198,6 +1210,12 @@ abstract class AppLocalizations {
/// **'Sin resultados'**
String get searchNoResultsTitle;
/// No description provided for @searchNoResultsForQueryTitle.
///
/// In es, this message translates to:
/// **'Sin resultados para «{query}»'**
String searchNoResultsForQueryTitle(Object query);
/// No description provided for @searchEmptySubtitle.
///
/// In es, this message translates to:
@@ -1228,6 +1246,12 @@ abstract class AppLocalizations {
/// **'Países'**
String get countriesScreenTitle;
/// No description provided for @countriesSearchHint.
///
/// In es, this message translates to:
/// **'País o código...'**
String get countriesSearchHint;
/// No description provided for @countriesYourLanguagesTitle.
///
/// In es, this message translates to:
@@ -1882,6 +1906,12 @@ abstract class AppLocalizations {
/// **'{minutes} min'**
String alarmSnoozeOptionLabel(int minutes);
/// No description provided for @alarmSnoozeUsualLabel.
///
/// In es, this message translates to:
/// **'habitual'**
String get alarmSnoozeUsualLabel;
/// No description provided for @saveFavoritesAlarmHint.
///
/// In es, this message translates to:
@@ -2024,6 +2054,12 @@ abstract class AppLocalizations {
/// **'Añadir rango'**
String get addVacationRangeCta;
/// No description provided for @vacationExplainerBanner.
///
/// In es, this message translates to:
/// **'Durante estos rangos no suenan las alarmas marcadas como \"pausar en vacaciones\". Las marcadas como \"sonar siempre\" no se ven afectadas.'**
String get vacationExplainerBanner;
/// No description provided for @vacationNoActiveRangeHint.
///
/// In es, this message translates to:
@@ -2042,6 +2078,24 @@ abstract class AppLocalizations {
/// **'Nuevo rango de vacaciones'**
String get newVacationRangeTitle;
/// No description provided for @editVacationRangeTitle.
///
/// In es, this message translates to:
/// **'Editar rango de vacaciones'**
String get editVacationRangeTitle;
/// No description provided for @vacationDeleteConfirmTitle.
///
/// In es, this message translates to:
/// **'¿Eliminar rango de vacaciones?'**
String get vacationDeleteConfirmTitle;
/// No description provided for @vacationDeleteConfirmMessage.
///
/// In es, this message translates to:
/// **'Esta acción no se puede deshacer.'**
String get vacationDeleteConfirmMessage;
/// No description provided for @startField.
///
/// In es, this message translates to:
@@ -2462,6 +2516,18 @@ abstract class AppLocalizations {
/// **'Días: {days}'**
String alarmScheduleWeekdays(Object days);
/// No description provided for @alarmRepeatSectionLabel.
///
/// In es, this message translates to:
/// **'REPETIR'**
String get alarmRepeatSectionLabel;
/// No description provided for @alarmVolumeLabel.
///
/// In es, this message translates to:
/// **'Volumen'**
String get alarmVolumeLabel;
/// No description provided for @androidReliabilityTitle.
///
/// In es, this message translates to:
+37
View File
@@ -441,6 +441,10 @@ class AppLocalizationsAr extends AppLocalizations {
return 'تم استخدام $usedMb ميغابايت من أصل $totalMb ميغابايت';
}
@override
String get recordingsLibraryStorageFolderCaption =>
'Music/PluriWave · يتم حذف الأقدم عند بلوغ الحد';
@override
String get recordingsLibraryEmptyTitle => 'لا توجد تسجيلات بعد';
@@ -612,12 +616,20 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get searchMinQualityFilterLabel => 'الحد الأدنى للجودة';
@override
String get searchLoadingStationsLabel => 'جارٍ البحث عن محطات…';
@override
String get searchEmptyTitle => 'ابحث عن محطة';
@override
String get searchNoResultsTitle => 'لا توجد نتائج';
@override
String searchNoResultsForQueryTitle(Object query) {
return 'لا توجد نتائج لـ «$query»';
}
@override
String get searchEmptySubtitle =>
'استخدم الشريط العلوي أو الشرائح لاكتشاف إشارات من كل العالم.';
@@ -655,6 +667,9 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get countriesScreenTitle => 'الدول';
@override
String get countriesSearchHint => 'الدولة أو الرمز...';
@override
String get countriesYourLanguagesTitle => 'لغاتك';
@@ -1014,6 +1029,9 @@ class AppLocalizationsAr extends AppLocalizations {
return '$minutes د';
}
@override
String get alarmSnoozeUsualLabel => 'المعتاد';
@override
String get saveFavoritesAlarmHint =>
'احفظ محطات في المفضلة لاستخدامها كمنبه موسيقي.';
@@ -1102,6 +1120,10 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get addVacationRangeCta => 'إضافة فترة';
@override
String get vacationExplainerBanner =>
'خلال هذه الفترات، لن تُصدر المنبهات المُعلَّمة بـ «إيقاف أثناء الإجازة» صوتًا. أما المُعلَّمة بـ «تشغيل دائمًا» فلن تتأثر.';
@override
String get vacationNoActiveRangeHint => 'لا توجد فترة إجازة نشطة الآن.';
@@ -1111,6 +1133,15 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get newVacationRangeTitle => 'نطاق إجازة جديد';
@override
String get editVacationRangeTitle => 'تعديل نطاق الإجازة';
@override
String get vacationDeleteConfirmTitle => 'هل تريد حذف نطاق الإجازة؟';
@override
String get vacationDeleteConfirmMessage => 'لا يمكن التراجع عن هذا الإجراء.';
@override
String get startField => 'البداية';
@@ -1351,6 +1382,12 @@ class AppLocalizationsAr extends AppLocalizations {
return 'الأيام: $days';
}
@override
String get alarmRepeatSectionLabel => 'تكرار';
@override
String get alarmVolumeLabel => 'مستوى الصوت';
@override
String get androidReliabilityTitle => 'مراجعة موثوقية Android';
+38
View File
@@ -446,6 +446,10 @@ class AppLocalizationsBn extends AppLocalizations {
return '$totalMb MB-এর মধ্যে $usedMb MB ব্যবহৃত';
}
@override
String get recordingsLibraryStorageFolderCaption =>
'Music/PluriWave · সীমা পৌঁছালে সবচেয়ে পুরনোগুলো মুছে যায়';
@override
String get recordingsLibraryEmptyTitle => 'এখনও কোনো রেকর্ডিং নেই';
@@ -619,12 +623,20 @@ class AppLocalizationsBn extends AppLocalizations {
@override
String get searchMinQualityFilterLabel => 'ন্যূনতম গুণমান';
@override
String get searchLoadingStationsLabel => 'স্টেশন খোঁজা হচ্ছে…';
@override
String get searchEmptyTitle => 'একটি স্টেশন খুঁজুন';
@override
String get searchNoResultsTitle => 'কোনো ফলাফল নেই';
@override
String searchNoResultsForQueryTitle(Object query) {
return '\"$query\"-এর জন্য কোনো ফলাফল নেই';
}
@override
String get searchEmptySubtitle =>
'উপরের বার বা চিপ ব্যবহার করে সারা বিশ্বের সিগন্যাল আবিষ্কার করুন।';
@@ -658,6 +670,9 @@ class AppLocalizationsBn extends AppLocalizations {
@override
String get countriesScreenTitle => 'দেশসমূহ';
@override
String get countriesSearchHint => 'দেশ বা কোড...';
@override
String get countriesYourLanguagesTitle => 'আপনার ভাষাসমূহ';
@@ -1019,6 +1034,9 @@ class AppLocalizationsBn extends AppLocalizations {
return '$minutes মিনিট';
}
@override
String get alarmSnoozeUsualLabel => 'সাধারণ';
@override
String get saveFavoritesAlarmHint =>
'সুরেলা অ্যালার্ম হিসেবে ব্যবহার করতে প্রিয়তে স্টেশন সংরক্ষণ করুন।';
@@ -1108,6 +1126,10 @@ class AppLocalizationsBn extends AppLocalizations {
@override
String get addVacationRangeCta => 'সময়সীমা যোগ করুন';
@override
String get vacationExplainerBanner =>
'এই সময়কালে \"ছুটিতে বিরতি\" হিসেবে চিহ্নিত অ্যালার্মগুলো বাজবে না। \"সবসময় বাজবে\" হিসেবে চিহ্নিত অ্যালার্মগুলো প্রভাবিত হবে না।';
@override
String get vacationNoActiveRangeHint =>
'এই মুহূর্তে কোনো সক্রিয় ছুটির সময়সীমা নেই।';
@@ -1118,6 +1140,16 @@ class AppLocalizationsBn extends AppLocalizations {
@override
String get newVacationRangeTitle => 'নতুন ছুটির পরিসর';
@override
String get editVacationRangeTitle => 'ছুটির পরিসর সম্পাদনা করুন';
@override
String get vacationDeleteConfirmTitle => 'ছুটির পরিসর মুছবেন?';
@override
String get vacationDeleteConfirmMessage =>
'এই পদক্ষেপ ফিরিয়ে নেওয়া যাবে না।';
@override
String get startField => 'শুরু';
@@ -1360,6 +1392,12 @@ class AppLocalizationsBn extends AppLocalizations {
return 'দিন: $days';
}
@override
String get alarmRepeatSectionLabel => 'পুনরাবৃত্তি';
@override
String get alarmVolumeLabel => 'ভলিউম';
@override
String get androidReliabilityTitle => 'Android নির্ভরযোগ্যতা দেখুন';
+38
View File
@@ -449,6 +449,10 @@ class AppLocalizationsDe extends AppLocalizations {
return '$usedMb MB von $totalMb MB belegt';
}
@override
String get recordingsLibraryStorageFolderCaption =>
'Music/PluriWave · älteste werden bei Erreichen des Limits gelöscht';
@override
String get recordingsLibraryEmptyTitle => 'Noch keine Aufnahmen';
@@ -622,12 +626,20 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get searchMinQualityFilterLabel => 'Mindestqualität';
@override
String get searchLoadingStationsLabel => 'SENDER WERDEN GESUCHT…';
@override
String get searchEmptyTitle => 'Suche nach einem Sender';
@override
String get searchNoResultsTitle => 'Keine Ergebnisse';
@override
String searchNoResultsForQueryTitle(Object query) {
return 'Keine Ergebnisse für „$query';
}
@override
String get searchEmptySubtitle =>
'Nutze die obere Leiste oder die Chips, um Sender aus aller Welt zu entdecken.';
@@ -661,6 +673,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get countriesScreenTitle => 'Länder';
@override
String get countriesSearchHint => 'Land oder Code...';
@override
String get countriesYourLanguagesTitle => 'Deine Sprachen';
@@ -1022,6 +1037,9 @@ class AppLocalizationsDe extends AppLocalizations {
return '$minutes Min.';
}
@override
String get alarmSnoozeUsualLabel => 'üblich';
@override
String get saveFavoritesAlarmHint =>
'Speichere Sender in Favoriten, um sie als musikalischen Alarm zu verwenden.';
@@ -1110,6 +1128,10 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get addVacationRangeCta => 'Zeitraum hinzufügen';
@override
String get vacationExplainerBanner =>
'Während dieser Zeiträume klingeln Alarme mit der Markierung \"im Urlaub pausieren\" nicht. Als \"immer klingeln\" markierte Alarme sind davon nicht betroffen.';
@override
String get vacationNoActiveRangeHint =>
'Momentan ist kein aktiver Urlaubszeitraum vorhanden.';
@@ -1120,6 +1142,16 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get newVacationRangeTitle => 'Neuer Ferienzeitraum';
@override
String get editVacationRangeTitle => 'Ferienzeitraum bearbeiten';
@override
String get vacationDeleteConfirmTitle => 'Ferienzeitraum löschen?';
@override
String get vacationDeleteConfirmMessage =>
'Dies kann nicht rückgängig gemacht werden.';
@override
String get startField => 'Beginn';
@@ -1368,6 +1400,12 @@ class AppLocalizationsDe extends AppLocalizations {
return 'Tage: $days';
}
@override
String get alarmRepeatSectionLabel => 'WIEDERHOLEN';
@override
String get alarmVolumeLabel => 'Lautstärke';
@override
String get androidReliabilityTitle => 'Android-Zuverlässigkeit prüfen';
+37
View File
@@ -443,6 +443,10 @@ class AppLocalizationsEn extends AppLocalizations {
return '$usedMb MB of $totalMb MB used';
}
@override
String get recordingsLibraryStorageFolderCaption =>
'Music/PluriWave · purges oldest at limit';
@override
String get recordingsLibraryEmptyTitle => 'No recordings yet';
@@ -615,12 +619,20 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get searchMinQualityFilterLabel => 'Minimum quality';
@override
String get searchLoadingStationsLabel => 'SEARCHING FOR STATIONS…';
@override
String get searchEmptyTitle => 'Search for a station';
@override
String get searchNoResultsTitle => 'No results';
@override
String searchNoResultsForQueryTitle(Object query) {
return 'No results for \"$query\"';
}
@override
String get searchEmptySubtitle =>
'Use the top bar or chips to discover stations from around the world.';
@@ -654,6 +666,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get countriesScreenTitle => 'Countries';
@override
String get countriesSearchHint => 'Country or code...';
@override
String get countriesYourLanguagesTitle => 'Your languages';
@@ -1014,6 +1029,9 @@ class AppLocalizationsEn extends AppLocalizations {
return '$minutes min';
}
@override
String get alarmSnoozeUsualLabel => 'usual';
@override
String get saveFavoritesAlarmHint =>
'Save stations in Favorites to use them as a music alarm.';
@@ -1102,6 +1120,10 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get addVacationRangeCta => 'Add range';
@override
String get vacationExplainerBanner =>
'During these ranges, alarms marked \"pause during vacations\" won\'t ring. Alarms marked \"always ring\" are not affected.';
@override
String get vacationNoActiveRangeHint => 'No active vacation range right now.';
@@ -1111,6 +1133,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get newVacationRangeTitle => 'New vacation range';
@override
String get editVacationRangeTitle => 'Edit vacation range';
@override
String get vacationDeleteConfirmTitle => 'Delete vacation range?';
@override
String get vacationDeleteConfirmMessage => 'This can\'t be undone.';
@override
String get startField => 'Start';
@@ -1352,6 +1383,12 @@ class AppLocalizationsEn extends AppLocalizations {
return 'Days: $days';
}
@override
String get alarmRepeatSectionLabel => 'REPEAT';
@override
String get alarmVolumeLabel => 'Volume';
@override
String get androidReliabilityTitle => 'Review Android reliability';
+38
View File
@@ -447,6 +447,10 @@ class AppLocalizationsEs extends AppLocalizations {
return '$usedMb MB de $totalMb MB usados';
}
@override
String get recordingsLibraryStorageFolderCaption =>
'Music/PluriWave · se borran las más antiguas al llegar al límite';
@override
String get recordingsLibraryEmptyTitle => 'Todavía no hay grabaciones';
@@ -620,12 +624,20 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get searchMinQualityFilterLabel => 'Calidad mínima';
@override
String get searchLoadingStationsLabel => 'BUSCANDO EMISORAS…';
@override
String get searchEmptyTitle => 'Buscá una emisora';
@override
String get searchNoResultsTitle => 'Sin resultados';
@override
String searchNoResultsForQueryTitle(Object query) {
return 'Sin resultados para «$query»';
}
@override
String get searchEmptySubtitle =>
'Usá la barra superior o los chips para descubrir señales de todo el mundo.';
@@ -659,6 +671,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get countriesScreenTitle => 'Países';
@override
String get countriesSearchHint => 'País o código...';
@override
String get countriesYourLanguagesTitle => 'Tus idiomas';
@@ -1019,6 +1034,9 @@ class AppLocalizationsEs extends AppLocalizations {
return '$minutes min';
}
@override
String get alarmSnoozeUsualLabel => 'habitual';
@override
String get saveFavoritesAlarmHint =>
'Guardá emisoras en Favoritos para usarlas como alarma musical.';
@@ -1107,6 +1125,10 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get addVacationRangeCta => 'Añadir rango';
@override
String get vacationExplainerBanner =>
'Durante estos rangos no suenan las alarmas marcadas como \"pausar en vacaciones\". Las marcadas como \"sonar siempre\" no se ven afectadas.';
@override
String get vacationNoActiveRangeHint =>
'No hay un rango de vacaciones activo ahora mismo.';
@@ -1117,6 +1139,16 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get newVacationRangeTitle => 'Nuevo rango de vacaciones';
@override
String get editVacationRangeTitle => 'Editar rango de vacaciones';
@override
String get vacationDeleteConfirmTitle => '¿Eliminar rango de vacaciones?';
@override
String get vacationDeleteConfirmMessage =>
'Esta acción no se puede deshacer.';
@override
String get startField => 'Inicio';
@@ -1362,6 +1394,12 @@ class AppLocalizationsEs extends AppLocalizations {
return 'Días: $days';
}
@override
String get alarmRepeatSectionLabel => 'REPETIR';
@override
String get alarmVolumeLabel => 'Volumen';
@override
String get androidReliabilityTitle => 'Revisar fiabilidad Android';
+37
View File
@@ -451,6 +451,10 @@ class AppLocalizationsFr extends AppLocalizations {
return '$usedMb Mo sur $totalMb Mo utilisés';
}
@override
String get recordingsLibraryStorageFolderCaption =>
'Music/PluriWave · supprime les plus anciens à la limite';
@override
String get recordingsLibraryEmptyTitle =>
'Aucun enregistrement pour l\'instant';
@@ -624,12 +628,20 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get searchMinQualityFilterLabel => 'Qualité minimale';
@override
String get searchLoadingStationsLabel => 'RECHERCHE DE STATIONS…';
@override
String get searchEmptyTitle => 'Recherchez une station';
@override
String get searchNoResultsTitle => 'Aucun résultat';
@override
String searchNoResultsForQueryTitle(Object query) {
return 'Aucun résultat pour « $query »';
}
@override
String get searchEmptySubtitle =>
'Utilisez la barre du haut ou les pastilles pour découvrir des stations du monde entier.';
@@ -663,6 +675,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get countriesScreenTitle => 'Pays';
@override
String get countriesSearchHint => 'Pays ou code...';
@override
String get countriesYourLanguagesTitle => 'Vos langues';
@@ -1024,6 +1039,9 @@ class AppLocalizationsFr extends AppLocalizations {
return '$minutes min';
}
@override
String get alarmSnoozeUsualLabel => 'habituel';
@override
String get saveFavoritesAlarmHint =>
'Enregistrez des stations dans les Favoris pour les utiliser comme alarme musicale.';
@@ -1113,6 +1131,10 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get addVacationRangeCta => 'Ajouter une période';
@override
String get vacationExplainerBanner =>
'Pendant ces periodes, les alarmes marquees \"pause pendant les vacances\" ne sonnent pas. Celles marquees \"toujours sonner\" ne sont pas concernees.';
@override
String get vacationNoActiveRangeHint =>
'Aucune période de vacances active pour le moment.';
@@ -1123,6 +1145,15 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get newVacationRangeTitle => 'Nouvelle période de vacances';
@override
String get editVacationRangeTitle => 'Modifier la période de vacances';
@override
String get vacationDeleteConfirmTitle => 'Supprimer la période de vacances ?';
@override
String get vacationDeleteConfirmMessage => 'Cette action est irréversible.';
@override
String get startField => 'Début';
@@ -1372,6 +1403,12 @@ class AppLocalizationsFr extends AppLocalizations {
return 'Jours : $days';
}
@override
String get alarmRepeatSectionLabel => 'RÉPÉTER';
@override
String get alarmVolumeLabel => 'Volume';
@override
String get androidReliabilityTitle => 'Vérifier la fiabilité Android';
+37
View File
@@ -444,6 +444,10 @@ class AppLocalizationsHi extends AppLocalizations {
return '$totalMb MB में से $usedMb MB इस्तेमाल हुआ';
}
@override
String get recordingsLibraryStorageFolderCaption =>
'Music/PluriWave · सीमा पूरी होने पर सबसे पुरानी रिकॉर्डिंग हटा दी जाती हैं';
@override
String get recordingsLibraryEmptyTitle => 'अभी तक कोई रिकॉर्डिंग नहीं';
@@ -616,12 +620,20 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get searchMinQualityFilterLabel => 'न्यूनतम गुणवत्ता';
@override
String get searchLoadingStationsLabel => 'स्टेशन खोजे जा रहे हैं…';
@override
String get searchEmptyTitle => 'एक स्टेशन खोजें';
@override
String get searchNoResultsTitle => 'कोई परिणाम नहीं';
@override
String searchNoResultsForQueryTitle(Object query) {
return '\"$query\" के लिए कोई परिणाम नहीं';
}
@override
String get searchEmptySubtitle =>
'दुनिया भर के सिग्नल खोजने के लिए ऊपर की बार या चिप्स इस्तेमाल करें।';
@@ -655,6 +667,9 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get countriesScreenTitle => 'देश';
@override
String get countriesSearchHint => 'देश या कोड...';
@override
String get countriesYourLanguagesTitle => 'आपकी भाषाएं';
@@ -1015,6 +1030,9 @@ class AppLocalizationsHi extends AppLocalizations {
return '$minutes मिनट';
}
@override
String get alarmSnoozeUsualLabel => 'सामान्य';
@override
String get saveFavoritesAlarmHint =>
'उन्हें संगीतमय अलार्म के रूप में इस्तेमाल करने के लिए स्टेशन पसंदीदा में सहेजें।';
@@ -1103,6 +1121,10 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get addVacationRangeCta => 'अवधि जोड़ें';
@override
String get vacationExplainerBanner =>
'इन अवधियों के दौरान \"छुट्टी में रोकें\" के रूप में चिह्नित अलार्म नहीं बजेंगे। \"हमेशा बजाएं\" के रूप में चिह्नित अलार्म पर कोई असर नहीं पड़ता।';
@override
String get vacationNoActiveRangeHint => 'अभी कोई सक्रिय छुट्टी अवधि नहीं है।';
@@ -1112,6 +1134,15 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get newVacationRangeTitle => 'नई छुट्टी अवधि';
@override
String get editVacationRangeTitle => 'छुट्टी अवधि संपादित करें';
@override
String get vacationDeleteConfirmTitle => 'छुट्टी अवधि हटाएं?';
@override
String get vacationDeleteConfirmMessage => 'इसे वापस नहीं लिया जा सकता।';
@override
String get startField => 'शुरुआत';
@@ -1355,6 +1386,12 @@ class AppLocalizationsHi extends AppLocalizations {
return 'दिन: $days';
}
@override
String get alarmRepeatSectionLabel => 'दोहराएं';
@override
String get alarmVolumeLabel => 'आवाज़';
@override
String get androidReliabilityTitle => 'Android विश्वसनीयता जाँचें';
+38
View File
@@ -444,6 +444,10 @@ class AppLocalizationsId extends AppLocalizations {
return '$usedMb MB dari $totalMb MB terpakai';
}
@override
String get recordingsLibraryStorageFolderCaption =>
'Music/PluriWave · yang terlama dihapus saat mencapai batas';
@override
String get recordingsLibraryEmptyTitle => 'Belum ada rekaman';
@@ -617,12 +621,20 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get searchMinQualityFilterLabel => 'Kualitas minimum';
@override
String get searchLoadingStationsLabel => 'MENCARI STASIUN…';
@override
String get searchEmptyTitle => 'Cari stasiun';
@override
String get searchNoResultsTitle => 'Tidak ada hasil';
@override
String searchNoResultsForQueryTitle(Object query) {
return 'Tidak ada hasil untuk \"$query\"';
}
@override
String get searchEmptySubtitle =>
'Gunakan bilah atas atau chip untuk menemukan sinyal dari seluruh dunia.';
@@ -655,6 +667,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get countriesScreenTitle => 'Negara';
@override
String get countriesSearchHint => 'Negara atau kode...';
@override
String get countriesYourLanguagesTitle => 'Bahasa Anda';
@@ -1018,6 +1033,9 @@ class AppLocalizationsId extends AppLocalizations {
return '$minutes mnt';
}
@override
String get alarmSnoozeUsualLabel => 'biasa';
@override
String get saveFavoritesAlarmHint =>
'Simpan stasiun ke Favorit untuk digunakan sebagai alarm musik.';
@@ -1107,6 +1125,10 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get addVacationRangeCta => 'Tambahkan periode';
@override
String get vacationExplainerBanner =>
'Selama periode ini, alarm yang ditandai \"jeda saat liburan\" tidak akan berbunyi. Alarm yang ditandai \"selalu berbunyi\" tidak terpengaruh.';
@override
String get vacationNoActiveRangeHint =>
'Tidak ada periode liburan aktif saat ini.';
@@ -1117,6 +1139,16 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get newVacationRangeTitle => 'Rentang liburan baru';
@override
String get editVacationRangeTitle => 'Edit rentang liburan';
@override
String get vacationDeleteConfirmTitle => 'Hapus rentang liburan?';
@override
String get vacationDeleteConfirmMessage =>
'Tindakan ini tidak dapat dibatalkan.';
@override
String get startField => 'Mulai';
@@ -1363,6 +1395,12 @@ class AppLocalizationsId extends AppLocalizations {
return 'Hari: $days';
}
@override
String get alarmRepeatSectionLabel => 'ULANGI';
@override
String get alarmVolumeLabel => 'Volume';
@override
String get androidReliabilityTitle => 'Tinjau keandalan Android';
+38
View File
@@ -448,6 +448,10 @@ class AppLocalizationsIt extends AppLocalizations {
return '$usedMb MB di $totalMb MB usati';
}
@override
String get recordingsLibraryStorageFolderCaption =>
'Music/PluriWave · elimina le più vecchie al raggiungimento del limite';
@override
String get recordingsLibraryEmptyTitle => 'Ancora nessuna registrazione';
@@ -622,12 +626,20 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get searchMinQualityFilterLabel => 'Qualità minima';
@override
String get searchLoadingStationsLabel => 'RICERCA STAZIONI IN CORSO…';
@override
String get searchEmptyTitle => 'Cerca un\'emittente';
@override
String get searchNoResultsTitle => 'Nessun risultato';
@override
String searchNoResultsForQueryTitle(Object query) {
return 'Nessun risultato per \"$query\"';
}
@override
String get searchEmptySubtitle =>
'Usa la barra in alto o i chip per scoprire emittenti da tutto il mondo.';
@@ -661,6 +673,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get countriesScreenTitle => 'Paesi';
@override
String get countriesSearchHint => 'Paese o codice...';
@override
String get countriesYourLanguagesTitle => 'Le tue lingue';
@@ -1023,6 +1038,9 @@ class AppLocalizationsIt extends AppLocalizations {
return '$minutes min';
}
@override
String get alarmSnoozeUsualLabel => 'abituale';
@override
String get saveFavoritesAlarmHint =>
'Salva emittenti nei Preferiti per usarle come sveglia musicale.';
@@ -1112,6 +1130,10 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get addVacationRangeCta => 'Aggiungi periodo';
@override
String get vacationExplainerBanner =>
'Durante questi periodi le sveglie contrassegnate come \"pausa in vacanza\" non suonano. Quelle contrassegnate come \"suona sempre\" non sono interessate.';
@override
String get vacationNoActiveRangeHint =>
'Nessun periodo di vacanza attivo al momento.';
@@ -1122,6 +1144,16 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get newVacationRangeTitle => 'Nuovo periodo di vacanza';
@override
String get editVacationRangeTitle => 'Modifica periodo di vacanza';
@override
String get vacationDeleteConfirmTitle => 'Eliminare il periodo di vacanza?';
@override
String get vacationDeleteConfirmMessage =>
'Questa azione non può essere annullata.';
@override
String get startField => 'Inizio';
@@ -1371,6 +1403,12 @@ class AppLocalizationsIt extends AppLocalizations {
return 'Giorni: $days';
}
@override
String get alarmRepeatSectionLabel => 'RIPETI';
@override
String get alarmVolumeLabel => 'Volume';
@override
String get androidReliabilityTitle => 'Controlla affidabilità Android';
+37
View File
@@ -431,6 +431,10 @@ class AppLocalizationsJa extends AppLocalizations {
return '$totalMb MB 中 $usedMb MB 使用済み';
}
@override
String get recordingsLibraryStorageFolderCaption =>
'Music/PluriWave・上限に達すると古い順に削除されます';
@override
String get recordingsLibraryEmptyTitle => 'まだ録音がありません';
@@ -594,12 +598,20 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get searchMinQualityFilterLabel => '最低品質';
@override
String get searchLoadingStationsLabel => '局を検索中…';
@override
String get searchEmptyTitle => '局を検索';
@override
String get searchNoResultsTitle => '結果がありません';
@override
String searchNoResultsForQueryTitle(Object query) {
return '$query」の結果はありません';
}
@override
String get searchEmptySubtitle => '上部バーやチップを使って、世界中の電波を見つけましょう。';
@@ -630,6 +642,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get countriesScreenTitle => '';
@override
String get countriesSearchHint => '国名またはコード...';
@override
String get countriesYourLanguagesTitle => 'あなたの言語';
@@ -985,6 +1000,9 @@ class AppLocalizationsJa extends AppLocalizations {
return '$minutes分';
}
@override
String get alarmSnoozeUsualLabel => '通常';
@override
String get saveFavoritesAlarmHint => '音楽アラームとして使うには、局をお気に入りに保存してください。';
@@ -1070,6 +1088,10 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get addVacationRangeCta => '期間を追加';
@override
String get vacationExplainerBanner =>
'この期間中は「休暇中は一時停止」に設定したアラームは鳴りません。「常に鳴らす」に設定したアラームには影響しません。';
@override
String get vacationNoActiveRangeHint => '現在アクティブな休暇期間はありません。';
@@ -1079,6 +1101,15 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get newVacationRangeTitle => '新しい休暇期間';
@override
String get editVacationRangeTitle => '休暇期間を編集';
@override
String get vacationDeleteConfirmTitle => '休暇期間を削除しますか?';
@override
String get vacationDeleteConfirmMessage => 'この操作は元に戻せません。';
@override
String get startField => '開始';
@@ -1312,6 +1343,12 @@ class AppLocalizationsJa extends AppLocalizations {
return '曜日: $days';
}
@override
String get alarmRepeatSectionLabel => '繰り返し';
@override
String get alarmVolumeLabel => '音量';
@override
String get androidReliabilityTitle => 'Androidの信頼性を確認';
+37
View File
@@ -446,6 +446,10 @@ class AppLocalizationsPt extends AppLocalizations {
return '$usedMb MB de $totalMb MB usados';
}
@override
String get recordingsLibraryStorageFolderCaption =>
'Music/PluriWave · apaga as mais antigas ao atingir o limite';
@override
String get recordingsLibraryEmptyTitle => 'Ainda não há gravações';
@@ -619,12 +623,20 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get searchMinQualityFilterLabel => 'Qualidade mínima';
@override
String get searchLoadingStationsLabel => 'PROCURANDO EMISSORAS…';
@override
String get searchEmptyTitle => 'Busque uma estação';
@override
String get searchNoResultsTitle => 'Sem resultados';
@override
String searchNoResultsForQueryTitle(Object query) {
return 'Nenhum resultado para \"$query\"';
}
@override
String get searchEmptySubtitle =>
'Use a barra superior ou os chips para descobrir estações do mundo todo.';
@@ -658,6 +670,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get countriesScreenTitle => 'Países';
@override
String get countriesSearchHint => 'País ou código...';
@override
String get countriesYourLanguagesTitle => 'Seus idiomas';
@@ -1018,6 +1033,9 @@ class AppLocalizationsPt extends AppLocalizations {
return '$minutes min';
}
@override
String get alarmSnoozeUsualLabel => 'usual';
@override
String get saveFavoritesAlarmHint =>
'Salve estações nos Favoritos para usá-las como alarme musical.';
@@ -1106,6 +1124,10 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get addVacationRangeCta => 'Adicionar período';
@override
String get vacationExplainerBanner =>
'Durante esses periodos, os alarmes marcados como \"pausar nas ferias\" nao tocam. Os marcados como \"tocar sempre\" nao sao afetados.';
@override
String get vacationNoActiveRangeHint =>
'Não há período de férias ativo no momento.';
@@ -1116,6 +1138,15 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get newVacationRangeTitle => 'Novo período de férias';
@override
String get editVacationRangeTitle => 'Editar período de férias';
@override
String get vacationDeleteConfirmTitle => 'Excluir período de férias?';
@override
String get vacationDeleteConfirmMessage => 'Esta ação não pode ser desfeita.';
@override
String get startField => 'Início';
@@ -1360,6 +1391,12 @@ class AppLocalizationsPt extends AppLocalizations {
return 'Dias: $days';
}
@override
String get alarmRepeatSectionLabel => 'REPETIÇÃO';
@override
String get alarmVolumeLabel => 'Volume';
@override
String get androidReliabilityTitle => 'Revisar confiabilidade Android';
+37
View File
@@ -446,6 +446,10 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Использовано $usedMb МБ из $totalMb МБ';
}
@override
String get recordingsLibraryStorageFolderCaption =>
'Music/PluriWave · при достижении лимита удаляются старые записи';
@override
String get recordingsLibraryEmptyTitle => 'Записей пока нет';
@@ -618,12 +622,20 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get searchMinQualityFilterLabel => 'Минимальное качество';
@override
String get searchLoadingStationsLabel => 'ПОИСК СТАНЦИЙ…';
@override
String get searchEmptyTitle => 'Найдите станцию';
@override
String get searchNoResultsTitle => 'Нет результатов';
@override
String searchNoResultsForQueryTitle(Object query) {
return 'Нет результатов по запросу «$query»';
}
@override
String get searchEmptySubtitle =>
'Используйте верхнюю строку или чипы, чтобы находить сигналы со всего мира.';
@@ -659,6 +671,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get countriesScreenTitle => 'Страны';
@override
String get countriesSearchHint => 'Страна или код...';
@override
String get countriesYourLanguagesTitle => 'Ваши языки';
@@ -1020,6 +1035,9 @@ class AppLocalizationsRu extends AppLocalizations {
return '$minutes мин';
}
@override
String get alarmSnoozeUsualLabel => 'обычно';
@override
String get saveFavoritesAlarmHint =>
'Сохраните станции в избранное, чтобы использовать их как музыкальный будильник.';
@@ -1108,6 +1126,10 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get addVacationRangeCta => 'Добавить период';
@override
String get vacationExplainerBanner =>
'В эти периоды будильники с пометкой «пауза на время отпуска» не звонят. Будильники с пометкой «звонить всегда» это не затрагивает.';
@override
String get vacationNoActiveRangeHint =>
'Сейчас нет активного периода отпуска.';
@@ -1118,6 +1140,15 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get newVacationRangeTitle => 'Новый период отпуска';
@override
String get editVacationRangeTitle => 'Изменить период отпуска';
@override
String get vacationDeleteConfirmTitle => 'Удалить период отпуска?';
@override
String get vacationDeleteConfirmMessage => 'Это действие нельзя отменить.';
@override
String get startField => 'Начало';
@@ -1364,6 +1395,12 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Дни: $days';
}
@override
String get alarmRepeatSectionLabel => 'ПОВТОР';
@override
String get alarmVolumeLabel => 'Громкость';
@override
String get androidReliabilityTitle => 'Проверить надёжность Android';
+37
View File
@@ -429,6 +429,10 @@ class AppLocalizationsZh extends AppLocalizations {
return '已使用 $usedMb MB / 共 $totalMb MB';
}
@override
String get recordingsLibraryStorageFolderCaption =>
'Music/PluriWave · 达到上限时会删除最旧的文件';
@override
String get recordingsLibraryEmptyTitle => '暂无录音';
@@ -592,12 +596,20 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get searchMinQualityFilterLabel => '最低质量';
@override
String get searchLoadingStationsLabel => '正在搜索电台…';
@override
String get searchEmptyTitle => '搜索电台';
@override
String get searchNoResultsTitle => '没有结果';
@override
String searchNoResultsForQueryTitle(Object query) {
return '未找到与\"$query\"相关的结果';
}
@override
String get searchEmptySubtitle => '使用顶部搜索栏或筛选标签,发现世界各地的电台信号。';
@@ -628,6 +640,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get countriesScreenTitle => '国家';
@override
String get countriesSearchHint => '国家或代码...';
@override
String get countriesYourLanguagesTitle => '你的语言';
@@ -981,6 +996,9 @@ class AppLocalizationsZh extends AppLocalizations {
return '$minutes 分钟';
}
@override
String get alarmSnoozeUsualLabel => '常用';
@override
String get saveFavoritesAlarmHint => '将电台保存到收藏,即可把它们用作音乐闹钟。';
@@ -1066,6 +1084,10 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get addVacationRangeCta => '添加时间段';
@override
String get vacationExplainerBanner =>
'在这些时间段内,标记为“假期暂停”的闹钟不会响铃;标记为“始终响铃”的闹钟不受影响。';
@override
String get vacationNoActiveRangeHint => '目前没有正在生效的假期时间段。';
@@ -1075,6 +1097,15 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get newVacationRangeTitle => '新的假期范围';
@override
String get editVacationRangeTitle => '编辑假期范围';
@override
String get vacationDeleteConfirmTitle => '删除假期范围?';
@override
String get vacationDeleteConfirmMessage => '此操作无法撤销。';
@override
String get startField => '开始';
@@ -1307,6 +1338,12 @@ class AppLocalizationsZh extends AppLocalizations {
return '星期:$days';
}
@override
String get alarmRepeatSectionLabel => '重复';
@override
String get alarmVolumeLabel => '音量';
@override
String get androidReliabilityTitle => '检查 Android 可靠性';
@@ -8,6 +8,7 @@ import '../../estado/estado_radio.dart';
import '../../l10n/gen/app_localizations.dart';
import '../../modelos/preset_ecualizador.dart';
import '../../tema/pluriwave_theme.dart';
import '../../tema/pluriwave_tokens.dart';
import '../../widgets/ecualizador_widget.dart';
import '../../widgets/pluri_glass_surface.dart';
import '../../widgets/pluri_layout.dart';
@@ -36,13 +37,33 @@ class PantallaAjustesEcualizador extends StatelessWidget {
const PantallaAjustesEcualizador({super.key});
@override
Widget build(BuildContext context) => PluriPushScaffold(
title: AppLocalizations.of(context).equalizerTitle,
body: ListView(
padding: PluriLayout.pageContentPadding,
children: const [_CuerpoEcualizador()],
),
);
Widget build(BuildContext context) {
// Audit 11.1 (t4 line 566): the master enable switch lives in the
// HEADER, not as the body's first row -- read here (a second,
// cheap watch alongside _CuerpoEcualizadorState's own Consumer2)
// purely to feed PluriPushScaffold.actions.
final eq = context.watch<EstadoEcualizador>();
return PluriPushScaffold(
title: AppLocalizations.of(context).equalizerTitle,
actions: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: Switch(
key: const ValueKey('eq-master-switch'),
value: eq.activo,
onChanged: eq.cambiarActivo,
// t4 line 569: brand-teal track, white thumb -- the default
// Material thumb colour already renders white when "on".
activeTrackColor: PluriWaveTokens.brand,
),
),
],
body: ListView(
padding: PluriLayout.pageContentPadding,
children: const [_CuerpoEcualizador()],
),
);
}
}
class _CuerpoEcualizador extends StatefulWidget {
@@ -83,17 +104,18 @@ class _CuerpoEcualizadorState extends State<_CuerpoEcualizador> {
return Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
title: Text(l10n.equalizerEnable),
subtitle: Text(
disponible
? l10n.equalizerRealtimeSubtitle
: l10n.equalizerPendingSubtitle,
),
value: eq.activo,
onChanged: eq.cambiarActivo,
// Audit 11.1 (t4 line 566): the enable switch itself now lives
// in PluriPushScaffold's header (see PantallaAjustesEcualizador
// above) -- this stays behind only as the explanatory caption
// the old SwitchListTile's subtitle carried, so that
// information is not lost.
Text(
disponible
? l10n.equalizerRealtimeSubtitle
: l10n.equalizerPendingSubtitle,
style: Theme.of(ctx).textTheme.bodySmall,
),
const SizedBox(height: 12),
if (mostrarModoPorEmisora) ...[
const SizedBox(height: 8),
SwitchListTile.adaptive(
@@ -164,6 +186,12 @@ class _BannerExplicacionBase extends StatelessWidget {
final AppLocalizations l10n;
/// Audit 11.2 (t4 line 571): 16 -- doesn't match any of
/// [PluriWaveTokens]'s three named radii (14/18/30), so this stays a
/// local one-off constant (same precedent as `_stopButtonRadius` in
/// `pantalla_alarma_sonando.dart`).
static const _bannerRadius = 16.0;
@override
Widget build(BuildContext context) {
final tokens = context.pluriTokens;
@@ -172,7 +200,7 @@ class _BannerExplicacionBase extends StatelessWidget {
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: tokens.liveGreen.withValues(alpha: 0.09),
borderRadius: BorderRadius.circular(tokens.radiusSm),
borderRadius: BorderRadius.circular(_bannerRadius),
border: Border.all(color: tokens.liveGreen.withValues(alpha: 0.26)),
),
child: Row(
+70 -22
View File
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import '../../../tema/pluriwave_theme.dart';
import '../../../widgets/pluri_glass_surface.dart';
import '../../../widgets/pluri_layout.dart';
/// Design ADR-3: the two nav-row primitives every Settings detail screen is
/// reached through. [GrupoAjustes] is a single [PluriGlassSurface] card
@@ -27,20 +28,36 @@ class GrupoAjustes extends StatelessWidget {
@override
Widget build(BuildContext context) {
final type = context.pluriType;
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(titulo, style: type.eyebrowLabel),
const SizedBox(height: 4),
for (var i = 0; i < filas.length; i++) ...[
// S10 (Tier 4 visual fidelity): the prototype insets its row
// divider by 47px (t4 line 516), not full-bleed.
if (i > 0) const Divider(height: 1, indent: 47),
filas[i],
],
],
),
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Audit 10.2 (t4 line 511): the group eyebrow sits OUTSIDE the
// card entirely, at title-tier (20px) padding -- it used to live
// INSIDE the PluriGlassSurface, sharing the card's own 16px
// padding.
Padding(
padding: const EdgeInsets.fromLTRB(
PluriLayout.titleHorizontal,
0,
PluriLayout.titleHorizontal,
6,
),
child: Text(titulo, style: type.eyebrowLabel),
),
PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < filas.length; i++) ...[
// S10 (Tier 4 visual fidelity): the prototype insets its
// row divider by 47px (t4 line 516), not full-bleed.
if (i > 0) const Divider(height: 1, indent: 47),
filas[i],
],
],
),
),
],
);
}
}
@@ -56,6 +73,7 @@ class FilaAjuste extends StatelessWidget {
required this.titulo,
required this.onTap,
this.valor,
this.iconColor,
});
final IconData icon;
@@ -68,6 +86,21 @@ class FilaAjuste extends StatelessWidget {
/// "no current value to show" — the row renders exactly as before.
final String? valor;
/// Audit 10.5 (t4 lines 514/516/522 — equalizer `#21D4D9`, hd
/// `#7EE4C2`, folder `#F4B860`): only the first row or two of a group
/// carries an accent colour in the prototype; every other row's icon
/// stays the ambient default. Null (the vast majority of rows) means
/// "no accent" — the icon renders exactly as before.
final Color? iconColor;
/// Issue 5 (feedback-pruebas): caps how much width the trailing current
/// value can claim. `ListTile` gives `trailing` as much width as it wants
/// before handing the title whatever is left — an unbounded value (e.g. a
/// real, arbitrarily long station name for "Emisora preferida") could
/// squeeze the title down to almost nothing, forcing it to wrap across
/// several lines that then get cut short by the row's fixed height.
static const _anchoMaximoValor = 108.0;
@override
Widget build(BuildContext context) {
final type = context.pluriType;
@@ -76,22 +109,37 @@ class FilaAjuste extends StatelessWidget {
contentPadding: EdgeInsets.zero,
// 10.6 (Tier 4 visual fidelity): the prototype's row icon is 21px (t4
// line 514), not Material's 24px default.
leading: Icon(icon, size: 21),
leading: Icon(icon, size: 21, color: iconColor),
// 10.8 (Tier 4 visual fidelity): the prototype's row title is
// 14px/w700 (t4 line 514); cardTitle is 14.5/w700 — a one-off
// override, not a new PluriWaveTypography style (mirrors the
// precedent set for the ringing screen's station name, audit 9.7).
title: Text(titulo, style: type.cardTitle.copyWith(fontSize: 14)),
// Issue 5: constrained to one line, ellipsizing instead of wrapping —
// labels must wrap as little as possible and never render visibly
// truncated (a multi-line wrap inside this fixed-height row cuts the
// last line short, which reads as broken, not as intentional).
title: Text(
titulo,
style: type.cardTitle.copyWith(fontSize: 14),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (valorActual != null) ...[
Text(
valorActual,
// bodyStrong is already 13/w600, matching the prototype's row
// value spec exactly — only the colour needs overriding.
style: type.bodyStrong.copyWith(
color: const Color(0xFFF2F7FA).withValues(alpha: 0.55),
ConstrainedBox(
constraints: const BoxConstraints(maxWidth: _anchoMaximoValor),
child: Text(
valorActual,
// bodyStrong is already 13/w600, matching the prototype's row
// value spec exactly — only the colour needs overriding.
style: type.bodyStrong.copyWith(
color: const Color(0xFFF2F7FA).withValues(alpha: 0.55),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.end,
),
),
const SizedBox(width: 6),
+7
View File
@@ -9,6 +9,7 @@ import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/archivo_grabacion.dart';
import '../modelos/emisora.dart';
import '../tema/pluriwave_tokens.dart';
import '../widgets/pluri_layout.dart';
import '../widgets/pluri_push_scaffold.dart';
import '../widgets/pluri_root_header.dart';
@@ -106,6 +107,9 @@ class _AjustesContent extends StatelessWidget {
filas: [
FilaAjuste(
icon: Icons.equalizer_rounded,
// Audit 10.5 (t4 line 514): the first AUDIO row's icon is
// brand cyan.
iconColor: PluriWaveTokens.brand,
titulo: l10n.equalizerTitle,
valor:
ecualizadorActivo
@@ -149,6 +153,9 @@ class _AjustesContent extends StatelessWidget {
filas: [
FilaAjuste(
icon: Icons.playlist_add_check_circle_rounded,
// Audit 10.5 (t4 line 526): the first STATIONS row's icon
// is warmCoral (the prototype's own `folder` row accent).
iconColor: PluriWaveTokens.dark.warmCoral,
titulo: l10n.favoriteGroupsTitle,
valor: '$gruposCount',
onTap:
+48 -1
View File
@@ -225,6 +225,25 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
tokens: tokens,
),
const SizedBox(height: 22),
// Audit 9.4 (t4 line 419): "Lunes, 3 de agosto" between
// the pill and the hero time -- never rendered before.
// Purely additive: a new sibling Text, touching neither
// the pill above nor the hero time below.
Text(
fechaLargaConDiaSemana(
Localizations.localeOf(context).toString(),
DateTime.now(),
),
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w600,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
const SizedBox(height: 6),
FittedBox(
fit: BoxFit.scaleDown,
child: Text(
@@ -689,6 +708,20 @@ class _FilaSnoozeFija extends StatelessWidget {
final forma = RoundedRectangleBorder(
borderRadius: BorderRadius.circular(tokens.radiusMd),
);
// Audit 9.10 (t4 line 433): the prototype stacks a big NUMBER over a
// small "min · habitual" unit — the SAME text-splitting conflict as
// 9.9 (permanently rejected, Engram id 2525): this flat string is
// exactly what the protected dismiss-guard test locates via
// `find.text(l10n.alarmSnoozeOptionLabel(N))` in four places, and
// splitting it into two differently-styled Text nodes would make
// that flat value vanish from the render tree.
//
// Resolved differently here than 9.9: rather than splitting THIS
// string, an entirely SEPARATE small qualifier Text is added
// alongside it (only on the destacado tile) — the original flat
// label stays a single, untouched, unstyled-differently Text node,
// still the exact widget the guard finds and taps. This delivers
// the "habitual" qualifier without the conflict 9.9 hit.
final etiqueta = Text(l10n.alarmSnoozeOptionLabel(minutos));
return Expanded(
flex: esDestacado ? 3 : 2,
@@ -703,7 +736,21 @@ class _FilaSnoozeFija extends StatelessWidget {
foregroundColor: tokens.deepViolet,
shape: forma,
),
child: etiqueta,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
mainAxisSize: MainAxisSize.min,
children: [
etiqueta,
Text(
l10n.alarmSnoozeUsualLabel,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
color: tokens.deepViolet.withValues(alpha: 0.75),
),
),
],
),
)
: OutlinedButton(
onPressed: () => onPosponer(minutos),
+476 -170
View File
@@ -45,9 +45,26 @@ class PantallaAlarmas extends StatelessWidget {
title: l10n.alarmScreenTitle,
onSleepTimer: () => showPluriSleepTimerSheet(context),
actions: [
FilledButton.tonalIcon(
// Audit 7.1 (t4:325): a solid brand-teal pill with a plain
// `add` glyph -- was a tonal button with `auto_awesome`.
FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: PluriWaveTokens.brand,
foregroundColor: const Color(0xFF062126),
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 9,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w800,
),
),
onPressed: () => _abrirEditor(context),
icon: const Icon(Icons.auto_awesome_rounded, size: 18),
icon: const Icon(Icons.add_rounded, size: 18),
label: Text(l10n.createAlarmAction),
),
],
@@ -105,6 +122,7 @@ class _PanelProximaAlarma extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final tokens = context.pluriTokens;
final proxima = estado.proximaAlarma;
final activasSinProxima =
estado.alarmas
@@ -112,56 +130,87 @@ class _PanelProximaAlarma extends StatelessWidget {
.length;
final proximaProgramable = proxima?.proximaProgramable;
return PluriGlassSurface(
glowColor: context.pluriTokens.warmCoral.withValues(alpha: 0.28),
child: Row(
children: [
_AssetIcon(
'assets/icons/alarmas/alarm_music.png',
size: 72,
semanticLabel: l10n.alarmIconLabel,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
proxima == null
? activasSinProxima > 0
? l10n.activeAlarmsWithoutNextTitle
: l10n.noActiveAlarms
: l10n.nextAlarmTitle,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 4),
Text(
proxima == null
? activasSinProxima > 0
? l10n.activeAlarmsWithoutNextSubtitle(
activasSinProxima,
)
: l10n.createAlarmHint
: '${_nombreVisibleAlarma(l10n, proxima)} · ${_fechaHora(l10n, proximaProgramable!)}',
),
if (proxima != null) ...[
const SizedBox(height: 10),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton.icon(
key: const ValueKey('hero-skip-next'),
onPressed: () => _saltarDesdeHero(context, proxima),
icon: const Icon(Icons.skip_next_rounded, size: 18),
label: Text(l10n.alarmHeroSkipAction),
// Audit 7.2 (t4:326-330): warmCoral-tinted card, `alarm_on` icon at
// 26px, and the "Saltar" chip BESIDE the text on the same row -- was
// an opaque default card with a 72px PNG and the skip action stacked
// BELOW the text as an OutlinedButton.
return DecoratedBox(
key: const ValueKey('next-alarm-banner'),
decoration: BoxDecoration(
color: tokens.warmCoral.withValues(alpha: 0.13),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: tokens.warmCoral.withValues(alpha: 0.34)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Icons.alarm_on, size: 26, color: tokens.warmCoral),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
proxima == null
? activasSinProxima > 0
? l10n.activeAlarmsWithoutNextTitle
: l10n.noActiveAlarms
: l10n.nextAlarmTitle,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 4),
Text(
proxima == null
? activasSinProxima > 0
? l10n.activeAlarmsWithoutNextSubtitle(
activasSinProxima,
)
: l10n.createAlarmHint
: '${_nombreVisibleAlarma(l10n, proxima)} · ${_fechaHora(l10n, proximaProgramable!)}',
),
],
],
),
),
if (proxima != null) ...[
const SizedBox(width: 8),
_ChipSaltar(onTap: () => _saltarDesdeHero(context, proxima)),
],
],
),
),
);
}
}
/// Audit 7.2 (t4:329): `padding:8px 12px;radius:10;rgba(255,255,255,.08)` --
/// plain text, no icon, unlike the previous `OutlinedButton.icon`.
class _ChipSaltar extends StatelessWidget {
const _ChipSaltar({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Material(
color: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(10),
child: InkWell(
key: const ValueKey('hero-skip-next'),
borderRadius: BorderRadius.circular(10),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Text(
l10n.alarmHeroSkipAction,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w800),
),
],
),
),
);
}
@@ -243,17 +292,83 @@ class _TarjetaAlarma extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
_hora(alarma),
style: Theme.of(
context,
).textTheme.displaySmall?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: -1.5,
),
// Audit 7.4 (t4:339): the recurrence label sits on
// the SAME baseline as the giant time -- it used to
// be missing entirely.
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
_hora(alarma),
style: Theme.of(
context,
).textTheme.displaySmall?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: -1.5,
),
),
const SizedBox(width: 8),
Text(
_recurrenciaCorta(l10n, alarma),
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.5),
),
),
],
),
const SizedBox(height: 7),
// Audit 7.4 (t4:341): a small station-art slot
// inline with the name -- was the name alone.
// `Emisora.favicon` is a network URL (the same
// hazard documented for the ringing screen's audit
// 9.2 -- `Image.network` here would hang widget
// tests without a mocked HttpClient), so this is a
// themed fallback icon, not the real per-station
// artwork, mirroring the recordings-row precedent
// (`pantalla_grabaciones.dart`'s `_FilaGrabacion`).
Row(
children: [
if (alarma.emisora != null) ...[
DecoratedBox(
key: const ValueKey('tarjeta-alarma-arte'),
decoration: BoxDecoration(
color: tokens.listSurface,
borderRadius: BorderRadius.circular(6),
),
child: SizedBox(
width: 22,
height: 22,
child: Icon(
Icons.radio_rounded,
size: 14,
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.75),
),
),
),
const SizedBox(width: 7),
],
Flexible(
child: Text(
estacion,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface
.withValues(alpha: 0.75),
),
),
),
],
),
const SizedBox(height: 2),
Text(estacion, overflow: TextOverflow.ellipsis),
],
),
),
@@ -532,6 +647,15 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
(value) => setState(() => _tipo = value.first),
),
const SizedBox(height: 10),
// Audit 8.5 (t4 line 381): the "REPETIR" eyebrow above the
// weekday circles -- never rendered anywhere before.
Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 8),
child: Text(
l10n.alarmRepeatSectionLabel,
style: context.pluriType.eyebrowLabel,
),
),
// WU10: weekday circles are now ALWAYS visible (previously
// only inserted into the tree in diasSemana mode) — matching
// the mockup, which shows them unconditionally under the
@@ -573,80 +697,117 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
const SizedBox(height: 12),
_vistaProximaEjecucion(l10n),
const SizedBox(height: 14),
_SectionLabel(
icon: 'assets/icons/alarmas/fallback_sound.png',
text: l10n.soundAndVolumeSection,
),
Slider(
value: _volumen,
// S2-R11: floor lowered from 0.25 to 0.0.
min: 0,
max: 1,
divisions: 20,
label: '${(_volumen * 100).round()}%',
onChanged: (value) => setState(() => _volumen = value),
),
const SizedBox(height: 8),
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(
l10n.alarmFadeInTitle,
style: context.pluriType.cardTitle,
// Audit 8.6 (t4 lines 392-401): the station picker, volume,
// fade-in and vacation toggle now share ONE bordered card
// with sangred divider lines between rows -- matching the
// prototype's own single grouped block -- instead of
// sitting bare on the sheet, each with its own gaps. The
// extra fields the prototype does NOT show (name, type
// selector, snooze selector, "use current station",
// Advanced) stay OUTSIDE this card, exactly where they
// were (documented as deliberate in audit 8.8 / WU10).
DecoratedBox(
key: const ValueKey('alarm-editor-grouped-card'),
decoration: BoxDecoration(
color: context.pluriTokens.listSurface,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: Colors.white.withValues(alpha: 0.08),
),
),
subtitle: Text(
_fadeInSegundos == 0
? l10n.alarmFadeInOff
: l10n.alarmFadeInSummary(_fadeInSegundos),
// A transparent Material sits directly inside the coloured
// box, closer to the fade-in ListTile and the vacation
// SwitchListTile than the DecoratedBox's own opaque fill
// -- both paint their ink/background on the NEAREST
// Material ancestor, and without this the box's colour
// would hide those effects (Flutter's own debug-mode
// check for exactly this).
child: Material(
type: MaterialType.transparency,
child: Column(
children: [
// S2-R9: searchable bottom-sheet picker instead of a
// dropdown, for the primary station. The backup
// (fallback) picker moves into the Advanced section
// below (WU10) — the primary choice stays a
// top-level field, only its secondary/backup sibling
// is now one tap further away.
_CampoSelectorEmisora(
key: const ValueKey('alarm-station-field'),
label: l10n.favoriteStationLabel,
icon: Icons.radio_rounded,
value:
_emisora == null
? l10n.noStationUseInternalSound
: localizedStationName(
l10n,
_emisora!.nombre,
),
onTap:
() => _elegirEmisora(
favoritas,
seleccionar:
(emisora) =>
setState(() => _emisora = emisora),
),
),
const Divider(height: 1, indent: 66),
// Audit 8.7 (t4 line 396): a compact 112px track with
// a trailing percentage label -- was a bare
// full-width Slider with no visible value.
_FilaVolumen(
volumen: _volumen,
onChanged:
(value) => setState(() => _volumen = value),
),
const Divider(height: 1, indent: 46),
Padding(
padding: const EdgeInsets.fromLTRB(14, 4, 14, 4),
child: ListTile(
contentPadding: EdgeInsets.zero,
title: Text(
l10n.alarmFadeInTitle,
style: context.pluriType.cardTitle,
),
subtitle: Text(
_fadeInSegundos == 0
? l10n.alarmFadeInOff
: l10n.alarmFadeInSummary(_fadeInSegundos),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Slider(
value: _fadeInSegundos.toDouble(),
min: 0,
max: 60,
divisions: 60,
label: '${_fadeInSegundos}s',
onChanged:
(value) => setState(
() => _fadeInSegundos = value.round(),
),
),
),
const Divider(height: 1, indent: 46),
SwitchListTile.adaptive(
value: _sonarEnVacaciones,
onChanged:
(value) =>
setState(() => _sonarEnVacaciones = value),
secondary: _AssetIcon(
'assets/icons/alarmas/vacation_wave.png',
size: 42,
semanticLabel: l10n.vacationIconLabel,
),
title: Text(l10n.playDuringVacations),
subtitle: Text(l10n.playDuringVacationsHint),
),
],
),
),
),
Slider(
value: _fadeInSegundos.toDouble(),
min: 0,
max: 60,
divisions: 60,
label: '${_fadeInSegundos}s',
onChanged:
(value) =>
setState(() => _fadeInSegundos = value.round()),
),
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.alarmSnoozeDurationTitle),
subtitle: Text(l10n.alarmSnoozeOptionLabel(_snoozeMinutos)),
),
SegmentedButton<int>(
segments: [
for (final minutos in _opcionesSnooze())
ButtonSegment(
value: minutos,
label: Text(l10n.alarmSnoozeOptionLabel(minutos)),
),
],
selected: {_snoozeMinutos},
onSelectionChanged:
(value) => setState(() => _snoozeMinutos = value.first),
),
const SizedBox(height: 8),
// S2-R9: searchable bottom-sheet picker instead of a dropdown,
// for the primary station. The backup (fallback) picker moves
// into the Advanced section below (WU10) — the primary choice
// stays a top-level field, only its secondary/backup sibling
// is now one tap further away.
_CampoSelectorEmisora(
key: const ValueKey('alarm-station-field'),
label: l10n.favoriteStationLabel,
icon: Icons.radio_rounded,
value:
_emisora == null
? l10n.noStationUseInternalSound
: localizedStationName(l10n, _emisora!.nombre),
onTap:
() => _elegirEmisora(
favoritas,
seleccionar:
(emisora) => setState(() => _emisora = emisora),
),
),
if (favoritas.isEmpty) ...[
const SizedBox(height: 6),
Text(l10n.saveFavoritesAlarmHint),
@@ -663,19 +824,23 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
),
),
],
const SizedBox(height: 8),
SwitchListTile.adaptive(
const SizedBox(height: 12),
ListTile(
contentPadding: EdgeInsets.zero,
value: _sonarEnVacaciones,
onChanged:
(value) => setState(() => _sonarEnVacaciones = value),
secondary: _AssetIcon(
'assets/icons/alarmas/vacation_wave.png',
size: 42,
semanticLabel: l10n.vacationIconLabel,
),
title: Text(l10n.playDuringVacations),
subtitle: Text(l10n.playDuringVacationsHint),
title: Text(l10n.alarmSnoozeDurationTitle),
subtitle: Text(l10n.alarmSnoozeOptionLabel(_snoozeMinutos)),
),
SegmentedButton<int>(
segments: [
for (final minutos in _opcionesSnooze())
ButtonSegment(
value: minutos,
label: Text(l10n.alarmSnoozeOptionLabel(minutos)),
),
],
selected: {_snoozeMinutos},
onSelectionChanged:
(value) => setState(() => _snoozeMinutos = value.first),
),
const SizedBox(height: 8),
// WU10 (native-alarms delta — Alarm Editor Preserves Date,
@@ -918,18 +1083,122 @@ class _CampoSelectorEmisora extends StatelessWidget {
final String value;
final VoidCallback onTap;
/// Audit 8.6 (t4:394): a flat nav row -- icon, bold label, muted value,
/// chevron -- matching the same convention `FilaAjuste` uses everywhere
/// else in Settings, so this fits cleanly inside the grouped card below
/// instead of drawing its own outlined `InputDecoration` chrome.
@override
Widget build(BuildContext context) {
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
prefixIcon: Icon(icon),
suffixIcon: const Icon(Icons.arrow_drop_down_rounded),
final type = context.pluriType;
return Material(
type: MaterialType.transparency,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
child: Row(
children: [
Icon(icon, size: 20),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: type.cardTitle),
Text(
value,
overflow: TextOverflow.ellipsis,
style: type.bodyStrong.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.55),
),
),
],
),
),
Icon(
Icons.chevron_right_rounded,
size: 19,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.4),
),
],
),
),
child: Text(value, overflow: TextOverflow.ellipsis),
),
);
}
}
/// Audit 8.7 (t4 line 396): a compact volume row -- icon, label, a 112px
/// track, and a trailing "80%" -- was a bare full-width `Slider` with no
/// visible current value. Constrains a real, still-draggable `Slider`
/// (not a static bar) to the prototype's 112px track width via a
/// `SliderTheme` + fixed-width `SizedBox`, preserving drag interactivity.
class _FilaVolumen extends StatelessWidget {
const _FilaVolumen({required this.volumen, required this.onChanged});
final double volumen;
final ValueChanged<double> onChanged;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final onSurface = Theme.of(context).colorScheme.onSurface;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4),
child: Row(
children: [
Icon(
Icons.volume_up_rounded,
size: 20,
color: onSurface.withValues(alpha: 0.7),
),
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.alarmVolumeLabel,
style: context.pluriType.cardTitle,
),
),
SizedBox(
width: 112,
height: 24,
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 4,
activeTrackColor: PluriWaveTokens.brand,
inactiveTrackColor: Colors.white.withValues(alpha: 0.14),
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 14),
),
child: Slider(
value: volumen,
// S2-R11: floor lowered from 0.25 to 0.0.
min: 0,
max: 1,
divisions: 20,
label: '${(volumen * 100).round()}%',
onChanged: onChanged,
),
),
),
SizedBox(
width: 34,
child: Text(
'${(volumen * 100).round()}%',
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w800,
color: onSurface.withValues(alpha: 0.7),
),
),
),
],
),
);
}
@@ -1089,6 +1358,12 @@ class _PanelVacaciones extends StatelessWidget {
final l10n = AppLocalizations.of(context);
final tokens = context.pluriTokens;
final resumen = _resumenVacaciones(l10n, estado.vacaciones);
// Audit 7.3 (t4:332): a trailing date-range pill ("4-18 AGO") for the
// active-or-next range -- never rendered anywhere before.
final proximas = estado.vacacionesProximas();
final rangoRelevante =
estado.rangoVacacionesActivo() ??
(proximas.isEmpty ? null : proximas.first);
return PluriGlassSurface(
glowColor: PluriWaveTokens.skyBlue.withValues(alpha: 0.22),
padding: EdgeInsets.zero,
@@ -1122,6 +1397,10 @@ class _PanelVacaciones extends StatelessWidget {
],
),
),
if (rangoRelevante != null) ...[
const SizedBox(width: 8),
_PildoraFechasVacaciones(rango: rangoRelevante),
],
const Icon(Icons.chevron_right_rounded),
],
),
@@ -1168,6 +1447,40 @@ class _PanelVacaciones extends StatelessWidget {
}
}
/// Audit 7.3 (t4:332): "4-18 AGO" -- `radius:999`, `liveGreen@.16` fill,
/// `liveGreen@.38` border, 10.5px/w800 in `liveGreen` (matches the
/// prototype's own `rgba(126,228,194,...)` teal, not the alarm banner's
/// warmCoral).
class _PildoraFechasVacaciones extends StatelessWidget {
const _PildoraFechasVacaciones({required this.rango});
final RangoVacaciones rango;
@override
Widget build(BuildContext context) {
final tokens = context.pluriTokens;
final locale = AppLocalizations.of(context).localeName;
return DecoratedBox(
decoration: BoxDecoration(
color: tokens.liveGreen.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: tokens.liveGreen.withValues(alpha: 0.38)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
child: Text(
rangoFechasCorto(locale, rango.inicioDia, rango.finDia),
style: TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.w800,
color: tokens.liveGreen,
),
),
),
);
}
}
/// Audit 8.4 (t4 lines 383-390): one circular weekday button in the alarm
/// editor's REPETIR row — was a `FilterChip`. `aspect-ratio:1` in the
/// prototype is achieved here by the caller wrapping each instance in an
@@ -1287,27 +1600,6 @@ class _PickerButton extends StatelessWidget {
}
}
class _SectionLabel extends StatelessWidget {
const _SectionLabel({required this.icon, required this.text});
final String icon;
final String text;
@override
Widget build(BuildContext context) {
return Row(
children: [
_AssetIcon(icon, size: 34),
const SizedBox(width: 8),
// WU10: swapped the raw TextTheme lookup for the named type-scale
// token (cosmetic only — same weight class, now shared with every
// other card/section title in the redesign).
Text(text, style: context.pluriType.cardTitle),
],
);
}
}
class _NoticeLine extends StatelessWidget {
const _NoticeLine({super.key, required this.icon, required this.text});
@@ -1375,3 +1667,17 @@ String _weekdayShort(AppLocalizations l10n, int day) => switch (day) {
// S5-R4: short dates follow the active locale (en-US = M/D/Y, ja = Y/M/D).
String _fechaCorta(AppLocalizations l10n, DateTime fecha) =>
fechaCortaLocalizada(l10n.localeName, fecha);
/// Audit 7.4 (t4:339): a compact recurrence label next to the alarm card's
/// giant time. Reuses the SAME generic labels the editor's own
/// `TipoProgramacionAlarma` `SegmentedButton` already shows (`oneTimeOption`
/// / `dailyOption` / `weekdaysOption`) rather than inventing a new, more
/// specific ARB string -- honest given the space (12px, next to a 34px
/// time) genuinely only fits a short word, not a full weekday list.
String _recurrenciaCorta(AppLocalizations l10n, AlarmaMusical alarma) {
return switch (alarma.tipoProgramacion) {
TipoProgramacionAlarma.diaria => l10n.dailyOption,
TipoProgramacionAlarma.diasSemana => l10n.weekdaysOption,
TipoProgramacionAlarma.unica => l10n.oneTimeOption,
};
}
+15 -1
View File
@@ -92,10 +92,16 @@ class PantallaBienvenida extends StatelessWidget {
const SizedBox(height: 22),
Text(
l10n.welcomeHeadline,
// Audit 14.3 (t4 line 696): 34px/ls-1.2, not
// headlineMedium's 28/ls-1.0 — a local override,
// matching the precedent already set by every
// other one-off size correction in this screen
// (14.4-14.6 below).
style: theme.textTheme.headlineMedium?.copyWith(
fontSize: 34,
fontWeight: FontWeight.w800,
height: 1.05,
letterSpacing: -1.0,
letterSpacing: -1.2,
),
),
const SizedBox(height: 12),
@@ -143,6 +149,14 @@ class PantallaBienvenida extends StatelessWidget {
height: 58,
child: FilledButton(
onPressed: () => _empezar(context),
// Audit 14.7 (t4 line 715): radius 18 — Material 3's
// default FilledButton shape is a fully-round
// StadiumBorder, which the prototype does not draw.
style: FilledButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(18),
),
),
child: Text(
l10n.welcomeCtaLabel,
style: const TextStyle(
+214 -41
View File
@@ -253,10 +253,25 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
}),
];
if (pills.isEmpty && estado.resultados.isEmpty) {
return const SizedBox.shrink();
}
// Audit 6.3 (t4:294-295): "Idioma" is an always-reachable entry chip
// once a search is active -- there was no standalone entry point for
// language filtering before this (only bundled inside "Filtros").
// Opens the SAME existing filter sheet (all 3 sections) rather than a
// new idioma-only picker: "Ordenar" is a genuinely separate control
// (not part of that sheet), and "Filtros" itself must stay reachable
// from the header regardless of active-filter state -- the quality
// (bitrate) filter has no OTHER entry point anywhere in the app, so
// this chip is additive, not a replacement.
final entryChips = <Widget>[
ActionChip(
label: Text(l10n.searchLanguageFilterLabel),
onPressed: _abrirFiltros,
),
];
// Audit 6.3: entryChips is never empty (always carries "Idioma"), so
// this row now always renders while a search is active -- the old
// "nothing to show" early return no longer applies.
return Padding(
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
@@ -267,18 +282,16 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (pills.isNotEmpty)
Wrap(spacing: 8, runSpacing: 8, children: pills),
if (pills.isNotEmpty && estado.resultados.isNotEmpty)
Wrap(spacing: 8, runSpacing: 8, children: [...pills, ...entryChips]),
if (!estado.cargando && estado.resultados.isNotEmpty) ...[
const SizedBox(height: 10),
if (!estado.cargando && estado.resultados.isNotEmpty)
Row(
children: [
// Audit 6.4 (t4:299): eyebrow styling (11/w800/ls.06em) --
// was labelLarge (14/w800).
Text(
l10n.searchResultsCount(estado.resultados.length),
style: theme.textTheme.labelLarge?.copyWith(
fontWeight: FontWeight.w800,
),
style: context.pluriType.eyebrowLabel,
),
const Spacer(),
PopupMenuButton<OrdenEmisoras>(
@@ -306,16 +319,28 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
),
],
),
],
],
),
);
}
/// Audit 6.2 (t4:292-293): brand-teal tinted, radius 10, an inline 15px
/// close glyph -- was Material's own `Chip` theming.
Widget _pillFiltro(String label, VoidCallback onDeleted) {
return Chip(
label: Text(label),
labelStyle: const TextStyle(
fontWeight: FontWeight.w800,
color: Color(0xFFF2F7FA),
),
backgroundColor: PluriWaveTokens.brand.withValues(alpha: 0.2),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
side: BorderSide(color: PluriWaveTokens.brand.withValues(alpha: 0.45)),
),
onDeleted: onDeleted,
deleteIcon: const Icon(Icons.close, size: 18),
deleteIcon: const Icon(Icons.close, size: 15),
visualDensity: VisualDensity.compact,
);
}
@@ -518,13 +543,33 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
if (estado.cargando) {
// S5-R6: shimmer placeholders instead of a bare spinner, consistent
// with the loading pattern used by the home grid.
// Issue 3 (feedback-pruebas): row tier (12), not card tier (16) --
// these are background-less row placeholders, same tier as the real
// results below; the top inset is the standard section gap rather
// than the horizontal constant reused for a vertical axis.
return Padding(
padding: const EdgeInsets.all(PluriLayout.horizontal),
padding: const EdgeInsets.fromLTRB(
PluriLayout.rowHorizontal,
PluriLayout.sectionGap,
PluriLayout.rowHorizontal,
PluriLayout.rowHorizontal,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Audit 13.3 (t4:649): the "BUSCANDO EMISORAS…" eyebrow --
// never rendered before.
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Text(
l10n.searchLoadingStationsLabel,
style: context.pluriType.eyebrowLabel,
),
),
for (var i = 0; i < 4; i++) ...[
const TarjetaEmisoraShimmer(esCompacta: true),
if (i < 3) const SizedBox(height: 10),
// Audit 13.4 (t4:651): 4px between skeleton rows, not 10.
if (i < 3) const SizedBox(height: 4),
],
],
),
@@ -535,36 +580,49 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
if (resultados.isEmpty) {
final sinFiltros = _controller.text.isEmpty && _filtrosActivosCount == 0;
return Column(
children: [
SizedBox(
height: 260,
child: PluriEmptyState(
glyph: PluriIconGlyph.search,
title:
sinFiltros
? l10n.searchEmptyTitle
: l10n.searchNoResultsTitle,
subtitle:
sinFiltros
? l10n.searchEmptySubtitle
: l10n.searchNoResultsSubtitle,
),
),
// Audit 13.5/13.6 (t4:657-668): a purpose-built card for the
// search-no-results state -- centred, `listSurface`, the title
// QUOTES the typed query, and the clear-filters pill sits INSIDE
// the card. A NEW widget, not a restyle of the shared
// `PluriEmptyState` (used by several OTHER unrelated empty states
// across the app -- favorites, the discovery grid -- which this
// item does not touch).
final query = _controller.text.trim();
// Issue 3 (feedback-pruebas): this card-tier state had NO top gap at
// all against the filter row above it -- the standard section gap
// now matches the other two mutually-exclusive results-area states
// (loading, populated) above.
return Padding(
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
PluriLayout.sectionGap,
PluriLayout.horizontal,
0,
),
child: _TarjetaSinResultados(
titulo:
sinFiltros
? l10n.searchEmptyTitle
: query.isNotEmpty
? l10n.searchNoResultsForQueryTitle(query)
: l10n.searchNoResultsTitle,
subtitulo:
sinFiltros
? l10n.searchEmptySubtitle
: l10n.searchNoResultsSubtitle,
// task 6.6 / spec "One-Tap Clear-All-Filters on Empty Results":
// only offered once 1+ pill-filters are active AND the search
// came back empty — not merely "no query typed yet".
if (_filtrosActivosCount > 0)
Padding(
padding: const EdgeInsets.only(bottom: 16),
child: OutlinedButton(
onPressed: _quitarTodosLosFiltros,
child: Text(
l10n.searchClearFiltersAction(_filtrosActivosCount),
),
),
),
],
accionQuitarFiltros:
_filtrosActivosCount > 0
? (
etiqueta: l10n.searchClearFiltersAction(
_filtrosActivosCount,
),
onTap: _quitarTodosLosFiltros,
)
: null,
),
);
}
@@ -576,7 +634,17 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.all(PluriLayout.horizontal),
// Issue 3 (feedback-pruebas): row tier (12), not card tier (16) --
// `FilaEmisoraPlana` rows are documented as "flat, background-less"
// (audit 6.5) but this padding never got updated to match when Tier 1
// introduced the 3-tier scale. The top inset is the standard section
// gap, not the horizontal constant reused for a vertical axis.
padding: const EdgeInsets.fromLTRB(
PluriLayout.rowHorizontal,
PluriLayout.sectionGap,
PluriLayout.rowHorizontal,
PluriLayout.rowHorizontal,
),
itemCount: total,
itemBuilder: (context, i) {
if (i >= resultados.length) {
@@ -1213,6 +1281,111 @@ class _ChipShimmer extends StatelessWidget {
}
}
/// Audit 13.5/13.6 (t4:657-668): search-no-results card -- centred,
/// `listSurface`, radius 22 (matches neither of the other 2 named token
/// radii, so this is a local one-off like `_CeldaExplorarPor`'s own),
/// with the clear-filters pill living INSIDE the card rather than below
/// it.
class _TarjetaSinResultados extends StatelessWidget {
const _TarjetaSinResultados({
required this.titulo,
required this.subtitulo,
this.accionQuitarFiltros,
});
final String titulo;
final String subtitulo;
final ({String etiqueta, VoidCallback onTap})? accionQuitarFiltros;
static const _radio = 22.0;
@override
Widget build(BuildContext context) {
final accion = accionQuitarFiltros;
return DecoratedBox(
key: const ValueKey('search-no-results-card'),
decoration: BoxDecoration(
color: PluriWaveTokens.dark.listSurface,
borderRadius: BorderRadius.circular(_radio),
border: Border.all(color: Colors.white.withValues(alpha: 0.07)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 26),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.search_off_rounded,
size: 40,
color: PluriWaveTokens.brand.withValues(alpha: 0.6),
),
const SizedBox(height: 12),
Text(
titulo,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 16, fontWeight: FontWeight.w800),
),
const SizedBox(height: 5),
Text(
subtitulo,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 12.5,
height: 1.5,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
),
if (accion != null) ...[
const SizedBox(height: 14),
Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(12),
onTap: accion.onTap,
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 15,
vertical: 9,
),
decoration: BoxDecoration(
color: PluriWaveTokens.brand.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: PluriWaveTokens.brand.withValues(alpha: 0.4),
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(
Icons.filter_alt_off_rounded,
size: 17,
color: PluriWaveTokens.brand,
),
const SizedBox(width: 7),
Text(
accion.etiqueta,
style: const TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w800,
color: PluriWaveTokens.brand,
),
),
],
),
),
),
),
],
],
),
),
);
}
}
/// Audit 3.2 (t4 lines 167-170): one "Explorar por" grid cell — icon,
/// title (13.5/w800) and subtitle (11/55%). Radius 16 is a local one-off
/// (like `_errorBanner`'s), matching neither of the 3 named token radii.
+68 -37
View File
@@ -6,6 +6,7 @@ import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
import '../tema/pluriwave_tokens.dart';
import '../widgets/fila_emisora_plana.dart';
import '../widgets/pluri_icon.dart';
import '../widgets/pluri_layout.dart';
@@ -186,22 +187,23 @@ class _PantallaFavoritosState extends State<PantallaFavoritos> {
children: [
// S1/S2 (Tier 1 visual fidelity): see the empty-state branch
// above — PluriScreenHeader is retired everywhere.
//
// Audit 4.1 (t4:216): the prototype's two header icon actions
// (create_new_folder, swap_vert) now live in PluriRootHeader's
// own actions slot -- they used to be scattered as an
// ActionChip inside the chip strip and a PopupMenuButton
// sharing a Row with it. The back arrow the prototype also
// draws stays absent (binding decision: this root keeps its
// bottom tab bar, unlike the prototype's own pushed shape).
PluriRootHeader(
title: l10n.favoritesTitle,
onSleepTimer: () => showPluriSleepTimerSheet(context),
),
const SizedBox(height: 12),
Row(
children: [
Expanded(
child: _FilaChipsGrupos(
grupos: gruposVisibles,
favoritos: favoritos,
seleccionado: seleccionEfectiva,
onSeleccionar:
(id) => setState(() => _grupoSeleccionadoId = id),
onGestionar: _abrirGestionDeListas,
),
actions: [
IconButton(
key: const ValueKey('favorites-manage-groups-action'),
icon: const Icon(Icons.create_new_folder_rounded),
tooltip: l10n.favoriteGroupsManage,
onPressed: _abrirGestionDeListas,
),
PopupMenuButton<OrdenEmisoras>(
icon: const Icon(Icons.swap_vert_rounded),
@@ -221,6 +223,13 @@ class _PantallaFavoritosState extends State<PantallaFavoritos> {
),
],
),
const SizedBox(height: 12),
_FilaChipsGrupos(
grupos: gruposVisibles,
favoritos: favoritos,
seleccionado: seleccionEfectiva,
onSeleccionar: (id) => setState(() => _grupoSeleccionadoId = id),
),
],
),
),
@@ -256,18 +265,45 @@ class _FilaChipsGrupos extends StatelessWidget {
required this.favoritos,
required this.seleccionado,
required this.onSeleccionar,
required this.onGestionar,
});
final List<GrupoFavoritos> grupos;
final List<Emisora> favoritos;
final String? seleccionado;
final ValueChanged<String?> onSeleccionar;
final VoidCallback onGestionar;
String _nombreVisible(AppLocalizations l10n, GrupoFavoritos grupo) =>
grupo.esSinAsignar ? l10n.favoriteGroupsUnassigned : grupo.nombre;
/// Audit 4.2 (t4:219-221): active `#21D4D9`/`#062126` w800, inactive
/// `listSurface` + a faint border / w700 -- was Material's own
/// `ChoiceChip` theming (a plain checkbox-style selected fill).
Widget _chip({
required String label,
required bool selected,
required VoidCallback onTap,
}) {
return ChoiceChip(
label: Text(label),
labelStyle: TextStyle(
fontWeight: selected ? FontWeight.w800 : FontWeight.w700,
color: selected ? const Color(0xFF062126) : const Color(0xFFF2F7FA),
),
selected: selected,
showCheckmark: false,
selectedColor: PluriWaveTokens.brand,
backgroundColor: PluriWaveTokens.dark.listSurface,
side: BorderSide(
color:
selected
? Colors.transparent
: Colors.white.withValues(alpha: 0.09),
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
onSelected: (_) => onTap(),
);
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
@@ -278,38 +314,27 @@ class _FilaChipsGrupos extends StatelessWidget {
children: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(
l10n.favoriteGroupsChipLabel(
l10n.favoritesFilterAllLabel,
favoritos.length,
),
child: _chip(
label: l10n.favoriteGroupsChipLabel(
l10n.favoritesFilterAllLabel,
favoritos.length,
),
selected: seleccionado == null,
onSelected: (_) => onSeleccionar(null),
onTap: () => onSeleccionar(null),
),
),
for (final grupo in grupos)
Padding(
padding: const EdgeInsets.only(right: 8),
child: ChoiceChip(
label: Text(
l10n.favoriteGroupsChipLabel(
_nombreVisible(l10n, grupo),
favoritos
.where((e) => e.grupoFavoritosId == grupo.id)
.length,
),
child: _chip(
label: l10n.favoriteGroupsChipLabel(
_nombreVisible(l10n, grupo),
favoritos.where((e) => e.grupoFavoritosId == grupo.id).length,
),
selected: seleccionado == grupo.id,
onSelected: (_) => onSeleccionar(grupo.id),
onTap: () => onSeleccionar(grupo.id),
),
),
ActionChip(
avatar: const Icon(Icons.add_rounded, size: 18),
label: Text(l10n.favoriteGroupsManage),
onPressed: onGestionar,
),
],
),
);
@@ -440,7 +465,13 @@ class _FilaFavorito extends StatelessWidget {
context,
).colorScheme.onSurface.withValues(alpha: 0.45),
),
constraints: const BoxConstraints.tightFor(width: 38, height: 42),
// NO `constraints:` here. That property sizes the POPUP MENU, not
// the button — a tightFor(38x42) clipped every menu item down to
// its first letter ("M" for "Mover a lista", "E" for "Eliminar de
// favoritos"), which is what users actually saw. Constrain the
// tap target instead.
padding: EdgeInsets.zero,
iconSize: 20,
onSelected: (accion) {
if (accion == 'assign') _asignar(context);
if (accion == 'remove') _eliminar(context);
+20 -5
View File
@@ -308,13 +308,28 @@ class _BarraDeAlmacenamiento extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: LinearProgressIndicator(value: fraccion, minHeight: 8),
),
const SizedBox(height: 8),
// Audit 12.2 (t4 line 613): the bold "used of total" headline
// sits ABOVE the bar -- was the bar first, then this same string
// rendered small below it as the only caption.
Text(
l10n.recordingsLibraryStorageCaption(usadoMb, totalMb),
style: Theme.of(
context,
).textTheme.bodySmall?.copyWith(fontWeight: FontWeight.w800),
),
const SizedBox(height: 8),
// Audit 12.2: 6px bar, radius 3 (was minHeight 8, radius 8).
ClipRRect(
borderRadius: BorderRadius.circular(3),
child: LinearProgressIndicator(value: fraccion, minHeight: 6),
),
const SizedBox(height: 7),
// Audit 12.3 (t4 line 613): the real caption names the folder
// and the purge policy -- the generic "X of Y used" line moved
// up to become the headline above, it never described either of
// those.
Text(
l10n.recordingsLibraryStorageFolderCaption,
style: Theme.of(context).textTheme.bodySmall,
),
],
+15 -20
View File
@@ -15,6 +15,7 @@ import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_layout.dart';
import '../widgets/pluri_premium_widgets.dart';
import '../widgets/pluri_sleep_timer_sheet.dart';
import '../widgets/pluri_station_art_fallback.dart';
import '../widgets/visualizador_audio.dart';
import 'pantalla_reproductor.dart';
@@ -568,9 +569,9 @@ class _ArteEscuchar extends StatelessWidget {
imageUrl: emisora.favicon!,
fit: BoxFit.cover,
placeholder: (_, __) => _shimmerCuadrado(theme),
errorWidget: (_, __, ___) => _iconoFallback(theme),
errorWidget: (_, __, ___) => _iconoFallback(),
)
: _iconoFallback(theme),
: _iconoFallback(),
),
),
);
@@ -582,14 +583,11 @@ class _ArteEscuchar extends StatelessWidget {
child: Container(color: theme.colorScheme.surfaceContainerHighest),
);
Widget _iconoFallback(ThemeData theme) => Container(
color: theme.colorScheme.primaryContainer,
child: Icon(
Icons.radio_rounded,
size: 36,
color: theme.colorScheme.onPrimaryContainer,
),
);
// Issue 6 (feedback-pruebas): was a flat `primaryContainer` square with a
// bare `radio_rounded` icon — now the same shared fallback every other
// surface uses.
Widget _iconoFallback() =>
PluriStationArtFallback(seed: emisora.uuid, iconSize: 36);
}
/// The hero's transport row — favorite / EQ toggle / stop / play-pause
@@ -909,10 +907,10 @@ class _CeldaTusEmisoras extends StatelessWidget {
imageUrl: emisora.favicon!,
fit: BoxFit.cover,
placeholder: (_, __) => _shimmer(theme),
errorWidget: (_, __, ___) => _iconoFallback(theme),
errorWidget: (_, __, ___) => _iconoFallback(),
);
}
return _iconoFallback(theme);
return _iconoFallback();
}
Widget _shimmer(ThemeData theme) => shimmer.Shimmer.fromColors(
@@ -921,12 +919,9 @@ class _CeldaTusEmisoras extends StatelessWidget {
child: Container(color: theme.colorScheme.surfaceContainerHighest),
);
Widget _iconoFallback(ThemeData theme) => Container(
color: theme.colorScheme.primaryContainer,
child: Icon(
Icons.radio_rounded,
size: 20,
color: theme.colorScheme.onPrimaryContainer,
),
);
// Issue 6 (feedback-pruebas): was a flat `primaryContainer` square with a
// bare `radio_rounded` icon — now the same shared fallback every other
// surface uses.
Widget _iconoFallback() =>
PluriStationArtFallback(seed: emisora.uuid, iconSize: 20);
}
+136 -47
View File
@@ -4,8 +4,8 @@ import 'package:provider/provider.dart';
import '../estado/estado_busqueda.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/pais_radio.dart';
import '../tema/pluriwave_theme.dart';
import '../tema/pluriwave_tokens.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_layout.dart';
import '../widgets/pluri_push_scaffold.dart';
@@ -30,6 +30,12 @@ class PantallaPaises extends StatefulWidget {
}
class _PantallaPaisesState extends State<PantallaPaises> {
/// Audit 5.7 (t4:252): the header's `search` action. Null means "not
/// searching" — an ephemeral UI concern (design's "State is for
/// ephemeral UI only" ruling), never persisted.
bool _buscando = false;
final _controladorBusqueda = TextEditingController();
/// One representative country per app-supported locale (the same 13
/// locales as `pantalla_ajustes_idioma.dart`'s `_idiomas` list). "Tus
/// idiomas" is named by the proposal/spec but its derivation is not
@@ -65,22 +71,73 @@ class _PantallaPaisesState extends State<PantallaPaises> {
});
}
@override
void dispose() {
_controladorBusqueda.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final estado = context.watch<EstadoBusqueda>();
final query = _controladorBusqueda.text.trim().toLowerCase();
final paisesFiltrados =
query.isEmpty
? estado.paises
: estado.paises
.where(
(p) =>
p.nombre.toLowerCase().contains(query) ||
p.codigoIso.toLowerCase().contains(query),
)
.toList();
return PluriPushScaffold(
title: l10n.countriesScreenTitle,
// Audit 5.7 (t4:252): a `search` header action -- toggles an inline
// filter field over the SAME country list, rather than a decorative
// no-op button. `PluriPushScaffold.titleOverride` stays reserved for
// its one documented exception (the player's "EN DIRECTO" pill) --
// the search field lives in the body instead, not the AppBar title.
actions: [
IconButton(
key: const ValueKey('countries-search-toggle'),
icon: Icon(_buscando ? Icons.close_rounded : Icons.search_rounded),
tooltip: l10n.navSearch,
onPressed:
() => setState(() {
_buscando = !_buscando;
if (!_buscando) _controladorBusqueda.clear();
}),
),
],
body:
estado.cargandoPaises && estado.paises.isEmpty
? const Center(child: CircularProgressIndicator())
: ListView(
padding: PluriLayout.pageContentPadding,
children: [
_seccionTusIdiomas(context, estado.paises, l10n),
const SizedBox(height: 16),
_seccionTodos(context, estado.paises, l10n),
if (_buscando)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextField(
key: const ValueKey('countries-search-field'),
controller: _controladorBusqueda,
autofocus: true,
decoration: InputDecoration(
hintText: l10n.countriesSearchHint,
prefixIcon: const Icon(Icons.search_rounded),
),
onChanged: (_) => setState(() {}),
),
),
if (query.isEmpty) ...[
_seccionTusIdiomas(context, estado.paises, l10n),
const SizedBox(height: 16),
_seccionTodos(context, estado.paises, l10n),
] else
_seccionTodos(context, paisesFiltrados, l10n),
],
),
);
@@ -104,31 +161,45 @@ class _PantallaPaisesState extends State<PantallaPaises> {
if (destacados.isEmpty) return const SizedBox.shrink();
final theme = Theme.of(context);
return PluriGlassSurface(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.countriesYourLanguagesTitle,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
final type = context.pluriType;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Audit 5.4 (t4:254): an eyebrow OUTSIDE any card, title-tier
// (20px) padding -- was titleMedium w900 inside a PluriGlassSurface.
Padding(
padding: const EdgeInsets.fromLTRB(
PluriLayout.titleHorizontal,
0,
PluriLayout.titleHorizontal,
8,
),
const SizedBox(height: 8),
// Item 24 / audit 5.1 (t4:255-258): a column of tappable ISO
// rows, not a Wrap of non-interactive Chips.
for (final pais in destacados)
_FilaPais(
pais: pais,
l10n: l10n,
// Item 24 / audit 5.5 (t4:256): the first row is highlighted.
destacado: pais == destacados.first,
onTap: () => _seleccionar(pais),
),
],
),
child: Text(
l10n.countriesYourLanguagesTitle,
style: type.eyebrowLabel,
),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: PluriLayout.rowHorizontal,
),
child: Column(
children: [
// Item 24 / audit 5.1 (t4:255-258): a column of tappable ISO
// rows, not a Wrap of non-interactive Chips.
for (final pais in destacados)
_FilaPais(
pais: pais,
l10n: l10n,
// Item 24 / audit 5.5 (t4:256): the first row is
// highlighted.
destacado: pais == destacados.first,
onTap: () => _seleccionar(pais),
),
],
),
),
],
);
}
@@ -137,26 +208,44 @@ class _PantallaPaisesState extends State<PantallaPaises> {
List<PaisRadio> paises,
AppLocalizations l10n,
) {
final theme = Theme.of(context);
return PluriGlassSurface(
padding: const EdgeInsets.all(12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.countriesAllTitle,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
final type = context.pluriType;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Audit 5.4 (t4:260-261): "TODOS · 238" -- an eyebrow OUTSIDE any
// card, carrying the total country count, which never rendered
// anywhere before.
Padding(
padding: const EdgeInsets.fromLTRB(
PluriLayout.titleHorizontal,
0,
PluriLayout.titleHorizontal,
8,
),
const SizedBox(height: 4),
// Item 24 / audit 5.1-5.3 (t4:262-269): the same tappable ISO row
// as "Tus idiomas" -- not the previous ListTile, which had no ISO
// column and no onTap.
for (final pais in paises)
_FilaPais(pais: pais, l10n: l10n, onTap: () => _seleccionar(pais)),
],
),
child: Text(
'${l10n.countriesAllTitle} · ${paises.length}',
style: type.eyebrowLabel,
),
),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: PluriLayout.rowHorizontal,
),
child: Column(
children: [
// Item 24 / audit 5.1-5.3 (t4:262-269): the same tappable ISO
// row as "Tus idiomas" -- not the previous ListTile, which
// had no ISO column and no onTap.
for (final pais in paises)
_FilaPais(
pais: pais,
l10n: l10n,
onTap: () => _seleccionar(pais),
),
],
),
),
],
);
}
}
+8 -10
View File
@@ -20,6 +20,7 @@ import '../widgets/ecualizador_widget.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_premium_widgets.dart';
import '../widgets/pluri_push_scaffold.dart';
import '../widgets/pluri_station_art_fallback.dart';
import '../widgets/visualizador_audio.dart';
/// WU14: restructured onto [PluriPushScaffold] (design ADR-2) — this screen
@@ -291,10 +292,10 @@ class _ArteReproductor extends StatelessWidget {
imageUrl: emisora.favicon!,
fit: BoxFit.cover,
placeholder: (_, __) => _shimmer(theme),
errorWidget: (_, __, ___) => _iconoFallback(theme),
errorWidget: (_, __, ___) => _iconoFallback(),
)
else
_iconoFallback(theme),
_iconoFallback(),
if (cargando)
Container(
color: Colors.black45,
@@ -333,14 +334,11 @@ class _ArteReproductor extends StatelessWidget {
child: Container(color: theme.colorScheme.surfaceContainerHighest),
);
Widget _iconoFallback(ThemeData theme) => Container(
color: theme.colorScheme.primaryContainer,
child: Icon(
Icons.radio_rounded,
size: 80,
color: theme.colorScheme.onPrimaryContainer,
),
);
// Issue 6 (feedback-pruebas): was a flat `primaryContainer` square with a
// bare `radio_rounded` icon — now the same shared fallback every other
// surface uses.
Widget _iconoFallback() =>
PluriStationArtFallback(seed: emisora.uuid, iconSize: 80);
}
/// Audit 2.2 (t4 lines 108-109): a full-bleed blurred backdrop of the
+518 -134
View File
@@ -31,9 +31,40 @@ class PantallaVacaciones extends StatelessWidget {
return PluriPushScaffold(
title: l10n.vacationRangesTitle,
// Audit 9b.1 (t4:446): a solid brand-teal "Add" header action --
// the prototype's OWN mid-page CTA (audit 9b.6, still present below,
// now dashed) is a SECOND, additional entry point in the prototype,
// not a replacement for this one.
actions: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: FilledButton.icon(
key: const ValueKey('vacation-add-header'),
style: FilledButton.styleFrom(
backgroundColor: PluriWaveTokens.brand,
foregroundColor: const Color(0xFF062126),
padding: const EdgeInsets.symmetric(horizontal: 13, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(11),
),
textStyle: const TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w800,
),
),
onPressed: () => _abrirAlta(context),
icon: const Icon(Icons.add_rounded, size: 17),
label: Text(l10n.addAction),
),
),
],
body: ListView(
padding: PluriLayout.pageContentPadding,
children: [
// Audit 9b.2 (t4:448): the explanatory banner is ALWAYS visible
// -- never rendered anywhere before.
_BannerExplicativo(texto: l10n.vacationExplainerBanner),
const SizedBox(height: 18),
if (activo != null)
_HeroRangoActivo(estado: estado, rango: activo)
else
@@ -41,32 +72,222 @@ class PantallaVacaciones extends StatelessWidget {
const SizedBox(height: 16),
_SeccionProgramados(proximas: proximas),
const SizedBox(height: 16),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton.icon(
onPressed: () => _abrirAlta(context),
icon: const Icon(Icons.add_rounded),
label: Text(l10n.addVacationRangeCta),
),
),
const SizedBox(height: 16),
// Audit 9b.6 (t4:487): a dashed border + `date_range` icon --
// was a solid `OutlinedButton` with an `add` glyph.
_CtaAnadirRango(onTap: () => _abrirAlta(context)),
const SizedBox(height: 14),
_SeccionRangosPasados(pasadas: pasadas),
],
),
);
}
Future<void> _abrirAlta(BuildContext context) async {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
builder: (_) => const _EditorVacacionesSheet(),
Future<void> _abrirAlta(BuildContext context) =>
_abrirEditorVacaciones(context);
}
/// Issue 1 (feedback-pruebas): the ONE sheet-opener both the header/CTA
/// "create" entry points and every range's own "tap to edit" affordance call
/// -- passing [rango] switches the sheet from create to edit mode (mirrors
/// `pantalla_alarmas.dart`'s `_abrirEditor`/`_EditorAlarmaSheet` split).
Future<void> _abrirEditorVacaciones(
BuildContext context, {
RangoVacaciones? rango,
}) async {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
builder: (_) => _EditorVacacionesSheet(rango: rango),
);
}
/// Issue 1 (feedback-pruebas): mirrors `pantalla_alarmas.dart`'s
/// `_confirmarEliminarAlarma` exactly -- same AlertDialog shape, same
/// generic delete/cancel actions, only the copy is vacation-specific.
Future<bool> _confirmarEliminarRango(
BuildContext context,
AppLocalizations l10n,
) async {
final confirmado = await showDialog<bool>(
context: context,
builder:
(ctx) => AlertDialog(
title: Text(l10n.vacationDeleteConfirmTitle),
content: Text(l10n.vacationDeleteConfirmMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(l10n.cancelAction),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(l10n.deleteAction),
),
],
),
);
return confirmado ?? false;
}
/// Swipe-to-delete reveal shown on both sides, mirroring
/// `pantalla_alarmas.dart`'s `_FondoSwipeEliminarAlarma` -- duplicated
/// rather than shared, matching this codebase's own precedent for tiny
/// per-screen chrome (see this file's `_DashedBorderPainter` doc comment).
class _FondoSwipeEliminarRango extends StatelessWidget {
const _FondoSwipeEliminarRango({required this.alignment});
final Alignment alignment;
@override
Widget build(BuildContext context) {
final tokens = context.pluriTokens;
return Container(
alignment: alignment,
padding: const EdgeInsets.symmetric(horizontal: 24),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.error,
borderRadius: BorderRadius.circular(tokens.radiusMd),
),
child: Icon(
Icons.delete_outline_rounded,
color: Theme.of(context).colorScheme.onError,
),
);
}
}
/// Audit 9b.2 (t4:448): teal-tinted explainer banner, always visible above
/// the active-range hero.
class _BannerExplicativo extends StatelessWidget {
const _BannerExplicativo({required this.texto});
final String texto;
@override
Widget build(BuildContext context) {
final tokens = context.pluriTokens;
return DecoratedBox(
decoration: BoxDecoration(
color: tokens.liveGreen.withValues(alpha: 0.09),
borderRadius: BorderRadius.circular(16),
border: Border.all(color: tokens.liveGreen.withValues(alpha: 0.26)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 13),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.info_outline_rounded, size: 20, color: tokens.liveGreen),
const SizedBox(width: 11),
Expanded(
child: Text(
texto,
style: TextStyle(
fontSize: 12,
height: 1.5,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.72),
),
),
),
],
),
),
);
}
}
/// Audit 9b.6 (t4:487): dashed-border CTA with a `date_range` glyph --
/// reuses the same dashed-painter shape already established in
/// `pantalla_favoritos.dart`'s custom-station CTA (audit 4.5), duplicated
/// rather than shared (small, self-contained, matching this codebase's own
/// precedent for tiny per-screen painters).
class _CtaAnadirRango extends StatelessWidget {
const _CtaAnadirRango({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final colorTexto = Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6);
return CustomPaint(
painter: _DashedBorderPainter(
color: Colors.white.withValues(alpha: 0.16),
),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(15),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.date_range_rounded, size: 20, color: colorTexto),
const SizedBox(width: 8),
Text(
l10n.addVacationRangeCta,
style: TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w800,
color: colorTexto,
),
),
],
),
),
),
),
);
}
}
class _DashedBorderPainter extends CustomPainter {
const _DashedBorderPainter({required this.color});
final Color color;
static const _radius = 16.0;
static const _dashWidth = 6.0;
static const _gapWidth = 4.0;
@override
void paint(Canvas canvas, Size size) {
final rrect = RRect.fromRectAndRadius(
Offset.zero & size,
const Radius.circular(_radius),
);
final path = Path()..addRRect(rrect);
final paint =
Paint()
..color = color
..style = PaintingStyle.stroke
..strokeWidth = 1.5;
for (final metric in path.computeMetrics()) {
var distance = 0.0;
while (distance < metric.length) {
final next = distance + _dashWidth;
canvas.drawPath(
metric.extractPath(distance, next.clamp(0.0, metric.length)),
paint,
);
distance = next + _gapWidth;
}
}
}
@override
bool shouldRepaint(covariant _DashedBorderPainter oldDelegate) =>
oldDelegate.color != color;
}
/// Active-range hero: name, days-remaining countdown (reusing WU8's own
/// `vacationSummaryActiveCountdown` string — same concept, bigger stage),
/// a start/end date pair (item 21 / audit 9b.4, replacing the former
@@ -86,46 +307,81 @@ class _HeroRangoActivo extends StatelessWidget {
final diasRestantes = rango.finDia.difference(hoyDia).inDays;
final impacto = estado.impactoDeRango(rango);
final type = context.pluriType;
final tokens = context.pluriTokens;
return PluriGlassSurface(
glowColor: context.pluriTokens.electricMagenta.withValues(alpha: 0.24),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
localizedVacationName(l10n, rango.nombre),
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 4),
// Item 21 / audit 9b.4 (t4:454): the "active now" caption is a
// teal eyebrow, not default body text.
Text(
l10n.vacationSummaryActiveCountdown(diasRestantes),
style: type.eyebrowLabel.copyWith(color: PluriWaveTokens.brand),
),
const SizedBox(height: 12),
// Item 21 / audit 9b.4 (t4:451-462): the screen's signature
// element is a start/end date pair joined by a gradient rule —
// the prototype never draws a determinate progress bar here.
_ParFechasVacaciones(
inicio: rango.inicioDia,
fin: rango.finDia,
destacado: true,
reglaKey: const ValueKey('vacaciones-regla-activo'),
),
if (impacto.pausadas.isNotEmpty) ...[
const SizedBox(height: 12),
Text(l10n.vacationImpactPausedLabel(_horas(impacto.pausadas))),
],
if (impacto.noAfectadas.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
l10n.vacationImpactContinuesLabel(_horas(impacto.noAfectadas)),
// Issue 1 (feedback-pruebas): a range starts ACTIVE the instant it's
// created (today .. today+2), so this hero is the ONLY place a
// brand-new range ever renders until it either becomes "programado" in
// the future or "pasado" once it ends. Without tap/swipe here, the
// very first range a user creates could never be fixed or removed.
return Dismissible(
key: ValueKey('vacaciones-tarjeta-${rango.id}'),
direction: DismissDirection.horizontal,
background: const _FondoSwipeEliminarRango(
alignment: Alignment.centerLeft,
),
secondaryBackground: const _FondoSwipeEliminarRango(
alignment: Alignment.centerRight,
),
confirmDismiss: (_) => _confirmarEliminarRango(context, l10n),
onDismissed: (_) => estado.eliminarRangoVacaciones(rango.id),
child: PluriGlassSurface(
glowColor: tokens.electricMagenta.withValues(alpha: 0.24),
padding: EdgeInsets.zero,
child: Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: BorderRadius.circular(tokens.radiusMd),
onTap: () => _abrirEditorVacaciones(context, rango: rango),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
localizedVacationName(l10n, rango.nombre),
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 4),
// Item 21 / audit 9b.4 (t4:454): the "active now" caption is a
// teal eyebrow, not default body text.
Text(
l10n.vacationSummaryActiveCountdown(diasRestantes),
style: type.eyebrowLabel.copyWith(
color: PluriWaveTokens.brand,
),
),
const SizedBox(height: 12),
// Item 21 / audit 9b.4 (t4:451-462): the screen's signature
// element is a start/end date pair joined by a gradient rule —
// the prototype never draws a determinate progress bar here.
_ParFechasVacaciones(
inicio: rango.inicioDia,
fin: rango.finDia,
destacado: true,
reglaKey: const ValueKey('vacaciones-regla-activo'),
),
if (impacto.pausadas.isNotEmpty) ...[
const SizedBox(height: 12),
Text(
l10n.vacationImpactPausedLabel(_horas(impacto.pausadas)),
),
],
if (impacto.noAfectadas.isNotEmpty) ...[
const SizedBox(height: 4),
Text(
l10n.vacationImpactContinuesLabel(
_horas(impacto.noAfectadas),
),
),
],
],
),
),
],
],
),
),
),
);
}
@@ -184,31 +440,92 @@ class _SeccionProgramados extends StatelessWidget {
}
}
class _SeccionRangosPasados extends StatelessWidget {
/// Audit 9b.7 (t4:489): a collapsible row -- icon, title, count, chevron
/// -- COLLAPSED by default; tapping reveals the full list below it. Was
/// always fully expanded inline.
class _SeccionRangosPasados extends StatefulWidget {
const _SeccionRangosPasados({required this.pasadas});
final List<RangoVacaciones> pasadas;
@override
State<_SeccionRangosPasados> createState() => _SeccionRangosPasadosState();
}
class _SeccionRangosPasadosState extends State<_SeccionRangosPasados> {
/// Ephemeral UI state only (design's "State is for ephemeral UI only"
/// ruling) -- collapsed by default, matching the prototype's own count
/// only row.
bool _expandido = false;
@override
Widget build(BuildContext context) {
if (pasadas.isEmpty) return const SizedBox.shrink();
if (widget.pasadas.isEmpty) return const SizedBox.shrink();
final l10n = AppLocalizations.of(context);
final type = context.pluriType;
return PluriGlassSurface(
padding: EdgeInsets.zero,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(l10n.vacationPastSectionTitle, style: type.eyebrowLabel),
const SizedBox(height: 8),
// No prototype-specified header text exists for an already-ended
// range, so `encabezado` is omitted rather than invented.
PluriPanelColumn(
gap: 10,
children: [
for (final rango in pasadas)
_TarjetaRangoVacaciones(rango: rango),
],
Material(
type: MaterialType.transparency,
child: InkWell(
onTap: () => setState(() => _expandido = !_expandido),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
child: Row(
children: [
Icon(
Icons.history_rounded,
size: 20,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.vacationPastSectionTitle,
style: context.pluriType.cardTitle,
),
),
Text(
'${widget.pasadas.length}',
style: TextStyle(
fontSize: 12.5,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.5),
),
),
const SizedBox(width: 4),
Icon(
_expandido
? Icons.expand_less_rounded
: Icons.chevron_right_rounded,
size: 19,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.4),
),
],
),
),
),
),
if (_expandido)
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 16),
child: PluriPanelColumn(
gap: 10,
children: [
for (final rango in widget.pasadas)
_TarjetaRangoVacaciones(rango: rango),
],
),
),
],
),
);
@@ -235,61 +552,87 @@ class _TarjetaRangoVacaciones extends StatelessWidget {
final l10n = AppLocalizations.of(context);
final t = context.pluriTokens;
final type = context.pluriType;
return DecoratedBox(
decoration: BoxDecoration(
color: t.listSurface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
final estado = context.read<EstadoAlarmas>();
// Issue 1 (feedback-pruebas): tap = edit, swipe = delete (with
// confirmation) — same interaction `pantalla_alarmas.dart`'s
// `_TarjetaAlarma` already uses for the same concept, applied here to
// BOTH the "programados" and "pasados" sections (this card backs both).
return Dismissible(
key: ValueKey('vacaciones-tarjeta-${rango.id}'),
direction: DismissDirection.horizontal,
background: const _FondoSwipeEliminarRango(
alignment: Alignment.centerLeft,
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (encabezado != null) ...[
Text(
encabezado!,
style: type.eyebrowLabel.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.5),
),
),
const SizedBox(height: 11),
],
_ParFechasVacaciones(
inicio: rango.inicioDia,
fin: rango.finDia,
destacado: false,
reglaKey: ValueKey('vacaciones-regla-${rango.id}'),
),
const SizedBox(height: 12),
Row(
children: [
Icon(
Icons.label_outline_rounded,
size: 17,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
const SizedBox(width: 8),
Expanded(
child: Text(
localizedVacationName(l10n, rango.nombre),
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
secondaryBackground: const _FondoSwipeEliminarRango(
alignment: Alignment.centerRight,
),
confirmDismiss: (_) => _confirmarEliminarRango(context, l10n),
onDismissed: (_) => estado.eliminarRangoVacaciones(rango.id),
child: DecoratedBox(
decoration: BoxDecoration(
color: t.listSurface,
borderRadius: BorderRadius.circular(20),
border: Border.all(color: Colors.white.withValues(alpha: 0.08)),
),
child: Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => _abrirEditorVacaciones(context, rango: rango),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 15),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (encabezado != null) ...[
Text(
encabezado!,
style: type.eyebrowLabel.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.5),
),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
const SizedBox(height: 11),
],
_ParFechasVacaciones(
inicio: rango.inicioDia,
fin: rango.finDia,
destacado: false,
reglaKey: ValueKey('vacaciones-regla-${rango.id}'),
),
),
],
const SizedBox(height: 12),
Row(
children: [
Icon(
Icons.label_outline_rounded,
size: 17,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
const SizedBox(width: 8),
Expanded(
child: Text(
localizedVacationName(l10n, rango.nombre),
style: Theme.of(
context,
).textTheme.bodyMedium?.copyWith(
fontWeight: FontWeight.w600,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.6),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
],
),
],
),
),
],
),
),
),
);
@@ -392,11 +735,17 @@ class _BloqueFecha extends StatelessWidget {
}
}
/// Add-range form. Moved verbatim from `pantalla_alarmas.dart` (WU8's
/// Add/edit-range form. Moved verbatim from `pantalla_alarmas.dart` (WU8's
/// `_PantallaVacacionesTemporal` used it as a placeholder push target; now
/// this screen is the one real consumer). Behaviour unchanged.
/// this screen is the one real consumer). Create behaviour unchanged; issue
/// 1 (feedback-pruebas) adds the edit half via the optional [rango] — the
/// SAME sheet, mirroring `pantalla_alarmas.dart`'s `_EditorAlarmaSheet`
/// (`alarma == null` -> create, non-null -> edit; one shared save button
/// either way).
class _EditorVacacionesSheet extends StatefulWidget {
const _EditorVacacionesSheet();
const _EditorVacacionesSheet({this.rango});
final RangoVacaciones? rango;
@override
State<_EditorVacacionesSheet> createState() => _EditorVacacionesSheetState();
@@ -412,16 +761,29 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
@override
void initState() {
super.initState();
final hoy = DateTime.now();
_inicio = DateTime(hoy.year, hoy.month, hoy.day);
_fin = _inicio.add(const Duration(days: 2));
final rango = widget.rango;
if (rango != null) {
_inicio = rango.inicioDia;
_fin = rango.finDia;
} else {
final hoy = DateTime.now();
_inicio = DateTime(hoy.year, hoy.month, hoy.day);
_fin = _inicio.add(const Duration(days: 2));
}
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
final rango = widget.rango;
_nombreController ??= TextEditingController(
text: AppLocalizations.of(context).vacationsDefaultName,
text:
rango != null
? localizedVacationName(
AppLocalizations.of(context),
rango.nombre,
)
: AppLocalizations.of(context).vacationsDefaultName,
);
}
@@ -445,7 +807,9 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.newVacationRangeTitle,
widget.rango != null
? l10n.editVacationRangeTitle
: l10n.newVacationRangeTitle,
style: Theme.of(
context,
).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900),
@@ -492,10 +856,16 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
Future<void> _elegirFecha({required bool esInicio}) async {
final actual = esInicio ? _inicio : _fin;
final hoy = DateTime.now();
final hoyDia = DateTime(hoy.year, hoy.month, hoy.day);
// Issue 1 (feedback-pruebas): editing a PAST range (reachable from the
// "Rangos pasados" section) must not force its dates into the future —
// `firstDate` only floors at today for a range that starts there or
// later; an already-past range keeps its own start as the floor.
final primerDiaPermitido = _inicio.isBefore(hoyDia) ? _inicio : hoyDia;
final seleccion = await showDatePicker(
context: context,
initialDate: actual,
firstDate: DateTime(hoy.year, hoy.month, hoy.day),
firstDate: primerDiaPermitido,
lastDate: hoy.add(const Duration(days: 1460)),
);
if (seleccion == null) return;
@@ -511,12 +881,26 @@ class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> {
Future<void> _guardar() async {
final estado = context.read<EstadoAlarmas>();
final rango = estado.servicio.crearRangoVacaciones(
inicio: _inicio,
fin: _fin,
nombre: _nombreController?.text.trim() ?? '',
);
await estado.crearRangoVacaciones(rango);
final nombre = _nombreController?.text.trim() ?? '';
final existente = widget.rango;
if (existente != null) {
await estado.editarRangoVacaciones(
RangoVacaciones(
id: existente.id,
nombre: nombre,
inicio: _inicio,
fin: _fin,
activo: existente.activo,
),
);
} else {
final rango = estado.servicio.crearRangoVacaciones(
inicio: _inicio,
fin: _fin,
nombre: nombre,
);
await estado.crearRangoVacaciones(rango);
}
if (mounted) Navigator.pop(context);
}
}
+75 -5
View File
@@ -113,6 +113,10 @@ class _EcualizadorWidgetState extends State<EcualizadorWidget> {
theme.colorScheme.surfaceContainerHighest,
overlayColor: PluriWaveTokens.brand
.withValues(alpha: 0.15),
// Audit 11.5 (t4 line 585): a 20x20 thumb
// with a 14px brand-teal glow -- was the
// Material default round thumb shape.
thumbShape: const _GlowSliderThumbShape(),
),
child: Slider(
value: _bandas[i],
@@ -130,8 +134,12 @@ class _EcualizadorWidgetState extends State<EcualizadorWidget> {
),
Text(
'${_bandas[i].toStringAsFixed(1)}dB',
// Audit 11.7 (t4 line 584): the prototype's dB
// label is brand teal at 90% alpha -- `liveGreen`
// is the LIVE-badge colour, an unrelated wrong
// family untouched by 11.6's slider-only fix.
style: theme.textTheme.labelSmall?.copyWith(
color: tokens.liveGreen,
color: PluriWaveTokens.brand.withValues(alpha: 0.9),
fontWeight: FontWeight.w700,
),
),
@@ -154,6 +162,50 @@ class _EcualizadorWidgetState extends State<EcualizadorWidget> {
}
}
/// Audit 11.5 (t4 line 585): `width:6px;border-radius:3px` track with a
/// `20x20` thumb carrying `box-shadow:0 0 14px rgba(33,212,217,.6)` --
/// Material's stock `RoundSliderThumbShape` has neither the exact size nor
/// a coloured glow (its own elevation shadow is a neutral drop shadow, not
/// brand-tinted). Paints a soft blurred glow first, then the solid thumb
/// on top, both centred on the slider's reported thumb position.
class _GlowSliderThumbShape extends SliderComponentShape {
const _GlowSliderThumbShape();
static const _radius = 10.0;
@override
Size getPreferredSize(bool isEnabled, bool isDiscrete) =>
const Size(_radius * 2, _radius * 2);
@override
void paint(
PaintingContext context,
Offset center, {
required Animation<double> activationAnimation,
required Animation<double> enableAnimation,
required bool isDiscrete,
required TextPainter labelPainter,
required RenderBox parentBox,
required SliderThemeData sliderTheme,
required TextDirection textDirection,
required double value,
required double textScaleFactor,
required Size sizeWithOverflow,
}) {
final canvas = context.canvas;
final color = sliderTheme.thumbColor ?? PluriWaveTokens.brand;
final glowPaint =
Paint()
..color = color.withValues(alpha: 0.6)
..maskFilter = const MaskFilter.blur(BlurStyle.normal, 7);
canvas.drawCircle(center, _radius + 4, glowPaint);
final thumbPaint = Paint()..color = color;
canvas.drawCircle(center, _radius, thumbPaint);
}
}
String _nombrePreset(AppLocalizations l10n, String nombre) {
return switch (nombre) {
'Flat' => l10n.equalizerPresetFlat,
@@ -188,7 +240,6 @@ class PresetsEcualizadorWidget extends StatelessWidget {
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context);
final todos = [...PresetEcualizador.presets, ...personalizados];
return Wrap(
@@ -197,13 +248,32 @@ class PresetsEcualizadorWidget extends StatelessWidget {
children:
todos.map((p) {
final selected = p.nombre == presetActual.nombre;
// Audit 11.3 (t4 lines 574-577): a solid brand-teal chip with
// dark text when active, `listSurface` + a faint border when
// not -- was Material's own `ChoiceChip` theming
// (`primaryContainer` selected / translucent grey unselected).
return ChoiceChip(
label: Text(_nombrePreset(l10n, p.nombre)),
labelStyle: TextStyle(
fontWeight: FontWeight.w800,
color:
selected
? const Color(0xFF062126)
: const Color(0xFFF2F7FA),
),
selected: selected,
showCheckmark: false,
selectedColor: theme.colorScheme.primaryContainer,
backgroundColor: theme.colorScheme.surfaceContainerHighest
.withValues(alpha: 0.32),
selectedColor: PluriWaveTokens.brand,
backgroundColor: PluriWaveTokens.dark.listSurface,
side: BorderSide(
color:
selected
? Colors.transparent
: Colors.white.withValues(alpha: 0.09),
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
onSelected: (_) => onSeleccionar(p),
);
}).toList(),
+8 -10
View File
@@ -8,6 +8,7 @@ import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../tema/pluriwave_tokens.dart';
import 'pluri_station_art_fallback.dart';
/// Item 23 / audit 4.3 + 6.5 (t4:226-232, 302-306): a flat, background-less
/// station row — square thumbnail, name, meta line, and a caller-supplied
@@ -234,10 +235,10 @@ class _ArteFilaEmisora extends StatelessWidget {
imageUrl: emisora.favicon!,
fit: BoxFit.cover,
placeholder: (_, __) => _shimmer(theme),
errorWidget: (_, __, ___) => _iconoFallback(theme),
errorWidget: (_, __, ___) => _iconoFallback(),
);
}
return _iconoFallback(theme);
return _iconoFallback();
}
Widget _shimmer(ThemeData theme) => shimmer.Shimmer.fromColors(
@@ -246,12 +247,9 @@ class _ArteFilaEmisora extends StatelessWidget {
child: Container(color: theme.colorScheme.surfaceContainerHighest),
);
Widget _iconoFallback(ThemeData theme) => Container(
color: theme.colorScheme.primaryContainer,
child: Icon(
Icons.radio_rounded,
size: 22,
color: theme.colorScheme.onPrimaryContainer,
),
);
// Issue 6 (feedback-pruebas): was a flat `primaryContainer` square with a
// bare `radio_rounded` icon — now the same shared fallback every other
// surface uses.
Widget _iconoFallback() =>
PluriStationArtFallback(seed: emisora.uuid, iconSize: 22);
}
+8 -10
View File
@@ -10,6 +10,7 @@ import '../modelos/emisora.dart';
import '../pantallas/pantalla_reproductor.dart';
import '../servicios/servicio_audio.dart';
import '../tema/pluriwave_theme.dart';
import 'pluri_station_art_fallback.dart';
/// Barra inferior persistente con controles básicos de reproducción.
/// Toca la barra para abrir PantallaReproductor completa.
@@ -318,9 +319,9 @@ class _ArteMiniReproductor extends StatelessWidget {
imageUrl: emisora.favicon!,
fit: BoxFit.cover,
placeholder: (_, __) => _shimmer(theme),
errorWidget: (_, __, ___) => _iconoFallback(theme),
errorWidget: (_, __, ___) => _iconoFallback(),
)
: _iconoFallback(theme),
: _iconoFallback(),
),
);
}
@@ -331,12 +332,9 @@ class _ArteMiniReproductor extends StatelessWidget {
child: Container(color: theme.colorScheme.surfaceContainerHighest),
);
Widget _iconoFallback(ThemeData theme) => Container(
color: theme.colorScheme.primaryContainer,
child: Icon(
Icons.radio_rounded,
size: 20,
color: theme.colorScheme.onPrimaryContainer,
),
);
// Issue 6 (feedback-pruebas): was a flat `primaryContainer` square with a
// bare `radio_rounded` icon — now the same shared fallback every other
// surface uses.
Widget _iconoFallback() =>
PluriStationArtFallback(seed: emisora.uuid, iconSize: 20);
}
+76 -46
View File
@@ -192,7 +192,10 @@ class PluriBottomNavigation extends StatelessWidget {
/// the balloon's opaque fill already covers the seam where the bar's own
/// shadow would otherwise show through.
List<BoxShadow> get _shellShadows => [
BoxShadow(color: Colors.white.withValues(alpha: 0.17), offset: const Offset(0, -1.5)),
BoxShadow(
color: Colors.white.withValues(alpha: 0.17),
offset: const Offset(0, -1.5),
),
BoxShadow(
color: Colors.black.withValues(alpha: 0.5),
offset: const Offset(0, 14),
@@ -214,6 +217,7 @@ class _PluriNavButton extends StatelessWidget {
@override
Widget build(BuildContext context) {
final t = context.pluriTokens;
final motion = context.pluriMotion;
return Semantics(
button: true,
selected: selected,
@@ -222,6 +226,11 @@ class _PluriNavButton extends StatelessWidget {
type: MaterialType.transparency,
child: InkWell(
onTap: onTap,
// Without a shape the ink splash and hover highlight paint as a
// full-bleed RECTANGLE over the cell — a hard square sitting on
// top of the icon, which is what users reported. The whole bar is
// built on 999-radius pills; the splash has to follow.
borderRadius: BorderRadius.circular(999),
child: Align(
alignment: Alignment.bottomCenter,
// t4/4a spec: the items row itself is 52px tall — this inner
@@ -238,54 +247,75 @@ class _PluriNavButton extends StatelessWidget {
// Flutter joins merged labels with `\n`, so screen readers
// would announce "Alarmas\nAlarmas" instead of "Alarmas".
child: ExcludeSemantics(
child: Transform.translate(
// t4/4a spec: active item lift `translateY(-15px)`.
offset: Offset(0, selected ? -15 : 0),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Opacity(
// t4/4a spec: active icon full colour; inactive
// `rgba(242,247,250,.46)` — .46 applied here as
// uniform opacity dims both the fallback Icon
// (already `onSurface` from
// PluriIconVariant.filled) and the real raster
// badge asset identically.
opacity: selected ? 1 : 0.46,
child: PluriIcon(
glyph: item.glyph,
variant: PluriIconVariant.filled,
// t4/4a spec: icon `font-size:25px`/`23px`.
size: selected ? 25 : 23,
color: selected ? t.electricMagenta : null,
// Same ARB string the outer Semantics already
// uses — passing it explicitly skips
// PluriIcon's own AppLocalizations.of lookup
// (excluded from the tree above regardless).
semanticLabel: item.label,
),
),
if (selected) ...[
const SizedBox(height: 3),
Text(
item.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
// t4/4a spec: label `font-size:11px;
// font-weight:800; line-height:1.25`, brand
// colour.
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(
fontSize: 11,
fontWeight: FontWeight.w800,
height: 1.25,
letterSpacing: 0,
color: t.electricMagenta,
// t4/4a spec: active item lift `translateY(-15px)`. Every
// property here used to change INSTANTLY while the balloon
// behind it slid with AnimatedPositioned — the balloon
// glided and its contents teleported, which read as a
// broken transition. All four now share the balloon's own
// duration and curve so the whole tab moves as one.
child: AnimatedSlide(
duration: motion.normal,
curve: Curves.easeOutCubic,
// Slide is expressed in fractions of the child's size;
// the icon column is ~40 tall, so -15px is about -0.375.
offset: Offset(0, selected ? -0.375 : 0),
child: AnimatedSize(
duration: motion.normal,
curve: Curves.easeOutCubic,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
AnimatedOpacity(
duration: motion.normal,
curve: Curves.easeOutCubic,
// t4/4a spec: active icon full colour; inactive
// `rgba(242,247,250,.46)` — .46 applied here as
// uniform opacity dims both the fallback Icon
// (already `onSurface` from
// PluriIconVariant.filled) and the real raster
// badge asset identically.
opacity: selected ? 1 : 0.46,
child: TweenAnimationBuilder<double>(
duration: motion.normal,
curve: Curves.easeOutCubic,
tween: Tween<double>(end: selected ? 25 : 23),
builder:
(context, size, _) => PluriIcon(
glyph: item.glyph,
variant: PluriIconVariant.filled,
// t4/4a spec: `font-size:25px`/`23px`.
size: size,
color: selected ? t.electricMagenta : null,
// Same ARB string the outer Semantics
// already uses — passing it explicitly
// skips PluriIcon's own
// AppLocalizations.of lookup.
semanticLabel: item.label,
),
),
),
if (selected) ...[
const SizedBox(height: 3),
Text(
item.label,
maxLines: 1,
overflow: TextOverflow.ellipsis,
// t4/4a spec: label `font-size:11px;
// font-weight:800; line-height:1.25`, brand
// colour.
style: Theme.of(
context,
).textTheme.labelSmall?.copyWith(
fontSize: 11,
fontWeight: FontWeight.w800,
height: 1.25,
letterSpacing: 0,
color: t.electricMagenta,
),
),
],
],
],
),
),
),
),
+20 -7
View File
@@ -14,6 +14,13 @@ import 'pluri_layout.dart';
void showPluriSleepTimerSheet(BuildContext context) {
showModalBottomSheet(
context: context,
// Issue 2 (feedback-pruebas): without this, the sheet is capped to a
// FRACTION of the screen height. That never mattered while the sheet
// always closed immediately after picking a duration (see the removed
// `Navigator.pop` calls below) -- now that the countdown view actually
// stays open, its title + description + headline-sized remaining-time
// text can overflow that capped height on a real phone width.
isScrollControlled: true,
showDragHandle: true,
builder:
(ctx) => Consumer<EstadoRadio>(
@@ -79,12 +86,17 @@ void showPluriSleepTimerSheet(BuildContext context) {
Duration(seconds: segundos),
),
),
onPressed: () {
estado.iniciarTimerDuracion(
Duration(seconds: segundos),
);
Navigator.pop(ctx);
},
// Issue 2 (feedback-pruebas): no longer pops
// the sheet -- `estado.iniciarTimerDuracion`
// notifies this `Consumer<EstadoRadio>`,
// which swaps straight to the countdown
// view above so the user actually SEES the
// remaining time instead of the sheet just
// closing with no feedback.
onPressed:
() => estado.iniciarTimerDuracion(
Duration(seconds: segundos),
),
),
ActionChip(
avatar: const Icon(Icons.tune_rounded, size: 18),
@@ -93,8 +105,9 @@ void showPluriSleepTimerSheet(BuildContext context) {
final duracion =
await _pedirDuracionPersonalizada(ctx);
if (duracion == null || !ctx.mounted) return;
// Issue 2: same as above -- stays open on
// the countdown view rather than closing.
estado.iniciarTimerDuracion(duracion);
Navigator.pop(ctx);
},
),
],
@@ -0,0 +1,92 @@
import 'package:flutter/material.dart';
import '../l10n/gen/app_localizations.dart';
import '../tema/pluriwave_theme.dart';
import 'pluri_icon.dart';
/// Shared station-art fallback (feedback-pruebas issue 6).
///
/// Before this widget existed, `TarjetaEmisora` had the only good fallback —
/// a deterministic pick from 4 bundled illustrations (`_fallbackArtFor`),
/// with a gradient + player glyph as a last resort if the asset itself ever
/// fails to decode. Every OTHER surface that can render a station without
/// artwork (`FilaEmisoraPlana`'s flat rows, the Escuchar hero, the "Tus
/// emisoras" grid cell, the mini player, the full player) had its own,
/// separate, much poorer copy: a flat `primaryContainer`-coloured square
/// with a bare `radio_rounded` icon. This widget is the ONE shared
/// implementation every one of those call sites now uses instead.
///
/// The selection formula is pinned EXACTLY as `TarjetaEmisora` originally
/// had it (`seed.codeUnits.fold<int>(0, (a, b) => a + b) % 4`, asset order
/// aurora/cosmic/pulse/nova) — `lib/servicios/navegacion_auto.dart` (a
/// protected, empty-diff file) independently mirrors this SAME formula for
/// Android Auto's own drawable-resource rotation
/// (`test/servicios/navegacion_auto_test.dart`'s `indiceArtePara`/
/// `artUriPara` tests assert it inline, not by importing this widget) —
/// changing the order or the modulo here would silently desync the two
/// without either test suite noticing until an actual device compared them
/// side by side.
class PluriStationArtFallback extends StatelessWidget {
const PluriStationArtFallback({
super.key,
required this.seed,
this.iconSize = 22,
});
/// Typically the station's `uuid` — the seed that deterministically picks
/// one of the 4 bundled arts below.
final String seed;
/// Size of the centred player glyph drawn over the art.
final double iconSize;
static const _arts = [
'assets/images/station_art_aurora.png',
'assets/images/station_art_cosmic.png',
'assets/images/station_art_pulse.png',
'assets/images/station_art_nova.png',
];
/// Exposed so other call sites (and tests) can assert which asset a given
/// seed resolves to without needing to render the widget.
static String artFor(String seed) {
final index = seed.codeUnits.fold<int>(0, (a, b) => a + b) % _arts.length;
return _arts[index];
}
@override
Widget build(BuildContext context) {
return Stack(
fit: StackFit.expand,
children: [
Image.asset(
artFor(seed),
fit: BoxFit.cover,
errorBuilder:
(_, __, ___) => DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
context.pluriTokens.deepViolet,
context.pluriTokens.electricMagenta.withValues(
alpha: 0.8,
),
],
),
),
),
),
Center(
child: PluriIcon(
glyph: PluriIconGlyph.player,
variant: PluriIconVariant.activeGlow,
size: iconSize,
semanticLabel: AppLocalizations.of(context).stationIconLabel,
),
),
],
);
}
}
+6 -44
View File
@@ -10,6 +10,7 @@ import '../modelos/emisora.dart';
import '../tema/pluriwave_theme.dart';
import 'pluri_glass_surface.dart';
import 'pluri_icon.dart';
import 'pluri_station_art_fallback.dart';
/// Tarjeta compacta para mostrar una emisora en listas y grids.
/// Incluye botón de favorito visible en ambos modos.
@@ -302,51 +303,12 @@ class _TarjetaEmisoraState extends State<TarjetaEmisora> {
);
}
// Issue 6 (feedback-pruebas): extracted into the shared
// `PluriStationArtFallback` so every surface that can render a station
// without artwork shows the SAME fallback — see that widget's own doc
// comment for why the selection formula must never drift.
Widget _iconoFallback(double size) {
final art = _fallbackArtFor(widget.emisora.uuid);
return Stack(
fit: StackFit.expand,
children: [
Image.asset(
art,
fit: BoxFit.cover,
errorBuilder:
(_, __, ___) => DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
context.pluriTokens.deepViolet,
context.pluriTokens.electricMagenta.withValues(
alpha: 0.8,
),
],
),
),
),
),
Center(
child: PluriIcon(
glyph: PluriIconGlyph.player,
variant: PluriIconVariant.activeGlow,
size: size,
semanticLabel: AppLocalizations.of(context).stationIconLabel,
),
),
],
);
}
String _fallbackArtFor(String seed) {
const arts = [
'assets/images/station_art_aurora.png',
'assets/images/station_art_cosmic.png',
'assets/images/station_art_pulse.png',
'assets/images/station_art_nova.png',
];
final index = seed.codeUnits.fold<int>(0, (a, b) => a + b) % arts.length;
return arts[index];
return PluriStationArtFallback(seed: widget.emisora.uuid, iconSize: size);
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: pluriwave
description: "Radio mundial con ecualizador, reconocimiento de canciones y UI premium"
publish_to: 'none'
version: 1.2.0+122
version: 1.2.1+123
environment:
sdk: ^3.7.0
+284 -229
View File
@@ -412,259 +412,314 @@ void main() {
);
});
group(
'EstadoRadio — emisoras custom: lectura tolerante y guardia de '
'degradacion (persistence-resilience)',
() {
test(
'entradas invalidas se omiten sin perder las validas ni fabricar '
'uuid (D5 parcial)',
() async {
final archivo = await _crearArchivoCustomRaw(
jsonEncode([
{'uuid': 'custom-1', 'nombre': 'Valida Uno', 'url': 'http://a'},
{'uuid': 'custom-2', 'nombre': 'Valida Dos', 'url': 'http://b'},
// falta 'url' (campo requerido) -> Emisora.fromMap lanza.
{'uuid': 'custom-3', 'nombre': 'Sin url'},
// falta 'uuid' -> Emisora.fromMap lanza.
{'nombre': 'Sin uuid', 'url': 'http://d'},
]),
);
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: () async => archivo,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(estado.emisorasCustom, hasLength(2));
expect(estado.emisorasCustom.map((e) => e.uuid).toSet(), {
'custom-1',
'custom-2',
});
},
group('EstadoRadio — emisoras custom: lectura tolerante y guardia de '
'degradacion (persistence-resilience)', () {
test('entradas invalidas se omiten sin perder las validas ni fabricar '
'uuid (D5 parcial)', () async {
final archivo = await _crearArchivoCustomRaw(
jsonEncode([
{'uuid': 'custom-1', 'nombre': 'Valida Uno', 'url': 'http://a'},
{'uuid': 'custom-2', 'nombre': 'Valida Dos', 'url': 'http://b'},
// falta 'url' (campo requerido) -> Emisora.fromMap lanza.
{'uuid': 'custom-3', 'nombre': 'Sin url'},
// falta 'uuid' -> Emisora.fromMap lanza.
{'nombre': 'Sin uuid', 'url': 'http://d'},
]),
);
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: () async => archivo,
iniciarAutomaticamente: false,
);
test(
'si resolver la ruta del archivo custom falla, la inicializacion '
'sobrevive y las cargas hermanas no se pierden (D5 IO-fail)',
() async {
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom:
() async => throw const FileSystemException('sin storage'),
iniciarAutomaticamente: false,
);
await estado.inicializar();
// Path resolution failing must be treated as an IO-fail, not
// escape _cargarEmisorasCustom: it runs inside _init()'s
// Future.wait, so an uncaught throw would also reject the
// sibling loads (populares/favoritos/grupos).
await estado.inicializar();
expect(estado.emisorasCustom, hasLength(2));
expect(estado.emisorasCustom.map((e) => e.uuid).toSet(), {
'custom-1',
'custom-2',
});
});
expect(estado.emisorasCustom, isEmpty);
},
test('si resolver la ruta del archivo custom falla, la inicializacion '
'sobrevive y las cargas hermanas no se pierden (D5 IO-fail)', () async {
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom:
() async => throw const FileSystemException('sin storage'),
iniciarAutomaticamente: false,
);
test(
'JSON invalido al nivel superior pone en cuarentena el archivo '
'original (D5 parse-fail)',
() async {
final archivo = await _crearArchivoCustomRaw('{bad');
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: () async => archivo,
iniciarAutomaticamente: false,
);
// Path resolution failing must be treated as an IO-fail, not
// escape _cargarEmisorasCustom: it runs inside _init()'s
// Future.wait, so an uncaught throw would also reject the
// sibling loads (populares/favoritos/grupos).
await estado.inicializar();
await estado.inicializar();
expect(estado.emisorasCustom, isEmpty);
});
expect(estado.emisorasCustom, isEmpty);
final sidecar = File('${archivo.path}.corrupt');
expect(await sidecar.exists(), isTrue);
expect(await sidecar.readAsString(), '{bad');
expect(await archivo.exists(), isFalse);
},
test('JSON invalido al nivel superior pone en cuarentena el archivo '
'original (D5 parse-fail)', () async {
final archivo = await _crearArchivoCustomRaw('{bad');
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: () async => archivo,
iniciarAutomaticamente: false,
);
test(
'agregar tras la cuarentena escribe solo la nueva emisora y no '
'toca el sidecar (D5, autoridad de escritura restaurada)',
() async {
final archivo = await _crearArchivoCustomRaw('{bad');
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: () async => archivo,
iniciarAutomaticamente: false,
);
await estado.inicializar();
final sidecar = File('${archivo.path}.corrupt');
final sidecarPrevio = await sidecar.readAsString();
await estado.inicializar();
final nueva = emisoraDemo(uuid: 'nueva-1', nombre: 'Nueva');
await estado.agregarEmisoraCustom(nueva);
expect(estado.emisorasCustom, isEmpty);
final sidecar = File('${archivo.path}.corrupt');
expect(await sidecar.exists(), isTrue);
expect(await sidecar.readAsString(), '{bad');
expect(await archivo.exists(), isFalse);
});
expect(estado.emisorasCustom.map((e) => e.uuid), ['nueva-1']);
final contenidoVivo =
jsonDecode(await archivo.readAsString()) as List;
expect(contenidoVivo, hasLength(1));
expect((contenidoVivo.single as Map)['uuid'], 'nueva-1');
expect(await sidecar.readAsString(), sidecarPrevio);
},
test('agregar tras la cuarentena escribe solo la nueva emisora y no '
'toca el sidecar (D5, autoridad de escritura restaurada)', () async {
final archivo = await _crearArchivoCustomRaw('{bad');
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: () async => archivo,
iniciarAutomaticamente: false,
);
await estado.inicializar();
final sidecar = File('${archivo.path}.corrupt');
final sidecarPrevio = await sidecar.readAsString();
final nueva = emisoraDemo(uuid: 'nueva-1', nombre: 'Nueva');
await estado.agregarEmisoraCustom(nueva);
expect(estado.emisorasCustom.map((e) => e.uuid), ['nueva-1']);
final contenidoVivo = jsonDecode(await archivo.readAsString()) as List;
expect(contenidoVivo, hasLength(1));
expect((contenidoVivo.single as Map)['uuid'], 'nueva-1');
expect(await sidecar.readAsString(), sidecarPrevio);
});
test('fallo de IO al leer suprime la escritura y no se restaura con un '
'alta explicita (D5 IO-fail)', () async {
final espia = _ArchivoEspia(
path: '/fake/emisoras_custom.json',
exists: () async => true,
readAsString:
() async =>
throw const FileSystemException('fallo simulado de lectura'),
);
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: () async => espia,
iniciarAutomaticamente: false,
);
test(
'fallo de IO al leer suprime la escritura y no se restaura con un '
'alta explicita (D5 IO-fail)',
() async {
final espia = _ArchivoEspia(
path: '/fake/emisoras_custom.json',
exists: () async => true,
readAsString:
() async => throw const FileSystemException(
'fallo simulado de lectura',
),
);
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: () async => espia,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(estado.emisorasCustom, isEmpty);
await estado.inicializar();
expect(estado.emisorasCustom, isEmpty);
await estado.agregarEmisoraCustom(
emisoraDemo(uuid: 'nueva-x', nombre: 'X'),
);
expect(
estado.emisorasCustom.map((e) => e.uuid),
contains('nueva-x'),
);
expect(espia.writeAsStringCalls, 0);
},
await estado.agregarEmisoraCustom(
emisoraDemo(uuid: 'nueva-x', nombre: 'X'),
);
test(
'si ya existe un sidecar .corrupt no lo pisa, solo limpia el '
'archivo vivo (D5)',
() async {
final archivo = await _crearArchivoCustomRaw('{bad-nuevo');
final sidecar = File('${archivo.path}.corrupt');
await sidecar.writeAsString('contenido-previo-X');
expect(estado.emisorasCustom.map((e) => e.uuid), contains('nueva-x'));
expect(espia.writeAsStringCalls, 0);
});
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: () async => archivo,
iniciarAutomaticamente: false,
);
test('si ya existe un sidecar .corrupt no lo pisa, solo limpia el '
'archivo vivo (D5)', () async {
final archivo = await _crearArchivoCustomRaw('{bad-nuevo');
final sidecar = File('${archivo.path}.corrupt');
await sidecar.writeAsString('contenido-previo-X');
await estado.inicializar();
expect(await sidecar.readAsString(), 'contenido-previo-X');
expect(await archivo.exists(), isFalse);
expect(estado.emisorasCustom, isEmpty);
},
);
},
);
group(
'EstadoRadio — Android Auto: snapshot en vivo y reconciliación '
'(android-auto-media)',
() {
test(
'empuja un snapshot actualizado a la fuente registrada cuando '
'cambian favoritos/custom/populares',
() async {
final fuenteAuto = _FuenteEmisorasAutoEspia();
final archivo = await _crearArchivoCustom([
emisoraDemo(uuid: 'custom-auto-1', nombre: 'Custom Auto'),
]);
final emisoraFav = emisoraDemo(uuid: 'fav-auto-1', nombre: 'Fav Auto');
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(
populares: [emisoraDemo(uuid: 'pop-auto-1', nombre: 'Pop Auto')],
),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: () async => archivo,
fuenteAuto: fuenteAuto,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(
fuenteAuto.ultimoMisEmisoras?.map((e) => e.uuid),
contains('custom-auto-1'),
);
expect(
fuenteAuto.ultimoTodas?.map((e) => e.uuid),
contains('pop-auto-1'),
);
expect(
fuenteAuto.ultimoGrupos?.map((g) => g.id),
contains(GrupoFavoritos.sinAsignarId),
);
await estado.toggleFavorito(emisoraFav);
expect(
fuenteAuto.ultimoFavoritos?.map((e) => e.uuid),
contains('fav-auto-1'),
);
},
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: () async => archivo,
iniciarAutomaticamente: false,
);
test(
'reconcilia _emisoraSeleccionada cuando la selección viene desde '
'el auto (no via reproducir())',
() async {
final audio = _AudioControlado();
final estado = EstadoRadio(
audio: audio,
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estado.inicializar();
final desdeCoche = emisoraDemo(
uuid: 'auto-selected',
nombre: 'Desde el auto',
);
await estado.inicializar();
audio.seleccionarDesdeAuto(desdeCoche);
await Future<void>.delayed(Duration.zero);
expect(await sidecar.readAsString(), 'contenido-previo-X');
expect(await archivo.exists(), isFalse);
expect(estado.emisorasCustom, isEmpty);
});
});
expect(estado.emisoraActual?.uuid, desdeCoche.uuid);
},
group('EstadoRadio — Android Auto: snapshot en vivo y reconciliación '
'(android-auto-media)', () {
test('empuja un snapshot actualizado a la fuente registrada cuando '
'cambian favoritos/custom/populares', () async {
final fuenteAuto = _FuenteEmisorasAutoEspia();
final archivo = await _crearArchivoCustom([
emisoraDemo(uuid: 'custom-auto-1', nombre: 'Custom Auto'),
]);
final emisoraFav = emisoraDemo(uuid: 'fav-auto-1', nombre: 'Fav Auto');
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(
populares: [emisoraDemo(uuid: 'pop-auto-1', nombre: 'Pop Auto')],
),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: () async => archivo,
fuenteAuto: fuenteAuto,
iniciarAutomaticamente: false,
);
},
);
await estado.inicializar();
expect(
fuenteAuto.ultimoMisEmisoras?.map((e) => e.uuid),
contains('custom-auto-1'),
);
expect(
fuenteAuto.ultimoTodas?.map((e) => e.uuid),
contains('pop-auto-1'),
);
expect(
fuenteAuto.ultimoGrupos?.map((g) => g.id),
contains(GrupoFavoritos.sinAsignarId),
);
await estado.toggleFavorito(emisoraFav);
expect(
fuenteAuto.ultimoFavoritos?.map((e) => e.uuid),
contains('fav-auto-1'),
);
});
test('reconcilia _emisoraSeleccionada cuando la selección viene desde '
'el auto (no via reproducir())', () async {
final audio = _AudioControlado();
final estado = EstadoRadio(
audio: audio,
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estado.inicializar();
final desdeCoche = emisoraDemo(
uuid: 'auto-selected',
nombre: 'Desde el auto',
);
audio.seleccionarDesdeAuto(desdeCoche);
await Future<void>.delayed(Duration.zero);
expect(estado.emisoraActual?.uuid, desdeCoche.uuid);
});
});
group('EstadoRadio — última emisora reproducida (feedback-pruebas #4)', () {
test('la última emisora reproducida sobrevive a una nueva instancia '
'(reinicio) como emisoraActual DETENIDA -- no arranca audio, no '
'reproduce, sólo queda seleccionada', () async {
final emisora = emisoraDemo(uuid: 'last-1', nombre: 'Ultima FM');
final estadoUno = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estadoUno.inicializar();
await estadoUno.reproducir(emisora);
await estadoUno.detenerReproduccion();
// Lets the fire-and-forget persistence write settle before
// spinning up the "restart" instance.
await Future<void>.delayed(Duration.zero);
final audioDos = FakeServicioAudio();
final estadoDos = EstadoRadio(
audio: audioDos,
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estadoDos.inicializar();
expect(estadoDos.emisoraActual?.uuid, emisora.uuid);
expect(estadoDos.emisoraActual?.nombre, emisora.nombre);
expect(
audioDos.estaSonando,
isFalse,
reason: 'restoring the last station must never auto-start audio',
);
});
test('sin ninguna emisora previamente reproducida, emisoraActual sigue '
'siendo null tras inicializar (instalación nueva)', () async {
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estado.inicializar();
expect(estado.emisoraActual, isNull);
});
test('una emisora seleccionada desde el auto (fuera de reproducir()) '
'también se recuerda para la próxima instancia', () async {
final audio = _AudioControlado();
final estadoUno = EstadoRadio(
audio: audio,
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estadoUno.inicializar();
final desdeCoche = emisoraDemo(
uuid: 'auto-remembered',
nombre: 'Recordada desde el auto',
);
audio.seleccionarDesdeAuto(desdeCoche);
await Future<void>.delayed(Duration.zero);
final estadoDos = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
await estadoDos.inicializar();
expect(estadoDos.emisoraActual?.uuid, desdeCoche.uuid);
});
});
}
/// Spy [FuenteEmisorasAuto] that only records the last snapshot pushed by
@@ -9,6 +9,7 @@ import 'package:pluriwave/modelos/dispositivo_audio.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_ecualizador.dart';
import 'package:pluriwave/tema/pluriwave_tokens.dart';
import 'package:pluriwave/widgets/ecualizador_widget.dart';
import 'package:pluriwave/widgets/pluri_glass_surface.dart';
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
@@ -119,9 +120,8 @@ void main() {
expect((appBar.title as Text).data, equals('Equalizer'));
});
testWidgets('moved control still responds: enable switch toggles activo', (
tester,
) async {
testWidgets('moved control still responds: enable switch toggles activo '
'(audit 11.1: now in the header, t4 line 566)', (tester) async {
_suppressListTileInkAssertion();
final estado = await crearEstado();
addTearDown(estado.dispose);
@@ -130,7 +130,7 @@ void main() {
await tester.pumpAndSettle();
final before = estado.ecualizador.activo;
await tester.tap(find.text('Enable equalizer'));
await tester.tap(find.byKey(const ValueKey('eq-master-switch')));
await tester.pumpAndSettle();
expect(estado.ecualizador.activo, equals(!before));
@@ -447,6 +447,74 @@ void main() {
},
);
testWidgets(
'visual fidelity (audit 11.1): the master switch is a header action, '
'not the body\'s first row (t4 line 566)',
(tester) async {
_suppressListTileInkAssertion();
final estado = await crearEstado();
addTearDown(estado.dispose);
await tester.pumpWidget(buildScreen(estado));
await tester.pumpAndSettle();
expect(
find.descendant(
of: find.byType(AppBar),
matching: find.byKey(const ValueKey('eq-master-switch')),
),
findsOneWidget,
reason: 'the master switch lives in the AppBar, not the body',
);
expect(
find.descendant(
of: find.byType(ListView),
matching: find.byKey(const ValueKey('eq-master-switch')),
),
findsNothing,
);
},
);
testWidgets(
'visual fidelity (audit 11.2): the explainer banner radius is 16, '
'not radiusSm\'s 14 (t4 line 571)',
(tester) async {
_suppressListTileInkAssertion();
final estado = await crearEstado();
addTearDown(estado.dispose);
await tester.pumpWidget(buildScreen(estado));
await tester.pumpAndSettle();
final banner = tester.widget<Container>(
find.byKey(const Key('eq-base-explainer-banner')),
);
final decoration = banner.decoration as BoxDecoration;
expect(
(decoration.borderRadius as BorderRadius).topLeft,
const Radius.circular(16),
);
},
);
testWidgets('visual fidelity (audit 11.3): the active preset chip is solid '
'brand teal with dark text (t4 lines 574-577)', (tester) async {
_suppressListTileInkAssertion();
final estado = await crearEstado();
addTearDown(estado.dispose);
await tester.pumpWidget(buildScreen(estado));
await tester.pumpAndSettle();
final chip = tester.widget<ChoiceChip>(
find.widgetWithText(ChoiceChip, 'Flat'),
);
expect(chip.selected, isTrue);
expect(chip.selectedColor, PluriWaveTokens.brand);
expect(chip.labelStyle?.color, const Color(0xFF062126));
});
testWidgets(
'Guardar como preset with a whitespace-only name shows the same validation message',
(tester) async {
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/pantallas/ajustes/widgets/fila_ajuste.dart';
import 'package:pluriwave/tema/pluriwave_theme.dart';
import 'package:pluriwave/widgets/pluri_glass_surface.dart';
/// S8 (Tier 1 visual fidelity): the prototype puts a trailing "current
/// value" on nearly every settings row, 13px `rgba(242,247,250,.55)` (t4
@@ -97,6 +98,75 @@ void main() {
},
);
testWidgets(
'issue 5 (feedback-pruebas): a long trailing value does not squeeze the '
'title into wrapping across multiple lines -- the title stays on ONE '
'line, ellipsizing instead',
(tester) async {
const titulo = 'Emisora preferida';
const valorLargo = 'Radio Nacional Clasica Internacional FM Stereo HD';
await tester.pumpWidget(
host(
SizedBox(
width: 360,
child: FilaAjuste(
icon: Icons.radio_rounded,
titulo: titulo,
valor: valorLargo,
onTap: () {},
),
),
),
);
await tester.pump();
expect(
tester.takeException(),
isNull,
reason: 'a squeezed row must not overflow either',
);
final tituloWidget = tester.widget<Text>(find.text(titulo));
expect(
tituloWidget.maxLines,
1,
reason: 'issue 5: the title must be constrained to a single line',
);
expect(tituloWidget.overflow, TextOverflow.ellipsis);
// Measured, not just `find.text` (a wrapped-but-still-present Text
// would still satisfy a bare `find.text` match, per the known
// "find.text can't catch visual wrap" trap) -- compare the rendered
// height against the SAME style/width rendered with a title that is
// guaranteed to fit on one line.
final alturaConValorLargo = tester.getSize(find.text(titulo)).height;
await tester.pumpWidget(
host(
SizedBox(
width: 360,
child: FilaAjuste(
icon: Icons.radio_rounded,
titulo: titulo,
onTap: () {},
),
),
),
);
await tester.pump();
final alturaReferencia = tester.getSize(find.text(titulo)).height;
expect(
alturaConValorLargo,
closeTo(alturaReferencia, 1.0),
reason:
'the title rendered taller with a long value present -- it '
'wrapped instead of staying on a single ellipsized line',
);
},
);
testWidgets(
'visual fidelity (audit S10): GrupoAjustes insets its row divider by '
'47px, not full-bleed (t4 line 516)',
@@ -122,4 +192,81 @@ void main() {
expect(divider.indent, 47);
},
);
testWidgets('visual fidelity (audit 10.5): an explicit iconColor tints the '
'leading icon (t4 lines 514/516/522 -- equalizer/hd/folder accents)', (
tester,
) async {
await tester.pumpWidget(
host(
FilaAjuste(
icon: Icons.equalizer_rounded,
titulo: 'Ecualizador base',
iconColor: const Color(0xFF21D4D9),
onTap: () {},
),
),
);
final icon = tester.widget<Icon>(find.byIcon(Icons.equalizer_rounded));
expect(icon.color, const Color(0xFF21D4D9));
});
testWidgets(
'visual fidelity (audit 10.5): omitting iconColor keeps the default '
'(unchanged pre-10.5 behaviour)',
(tester) async {
await tester.pumpWidget(
host(
FilaAjuste(
icon: Icons.language_rounded,
titulo: 'Idioma',
onTap: () {},
),
),
);
final icon = tester.widget<Icon>(find.byIcon(Icons.language_rounded));
expect(icon.color, isNull);
},
);
testWidgets(
'visual fidelity (audit 10.2): the group eyebrow sits OUTSIDE the '
'card, at title-tier (20px) padding -- not inside the '
'PluriGlassSurface (t4 line 511)',
(tester) async {
_suppressListTileInkAssertion();
await tester.pumpWidget(
host(
GrupoAjustes(
titulo: 'AUDIO',
filas: [
FilaAjuste(
icon: Icons.equalizer_rounded,
titulo: 'Uno',
onTap: () {},
),
],
),
),
);
expect(
find.ancestor(
of: find.text('AUDIO'),
matching: find.byType(PluriGlassSurface),
),
findsNothing,
reason: 'the eyebrow must not be a descendant of the card',
);
final eyebrowLeft = tester.getTopLeft(find.text('AUDIO')).dx;
expect(
eyebrowLeft,
20,
reason: 'S5 title tier -- PluriLayout.titleHorizontal',
);
},
);
}
+3 -2
View File
@@ -162,9 +162,10 @@ void main() {
await pumpStable(tester);
// Pushed, not index-switched: exactly one PluriPushScaffold now exists,
// and its moved control (the enable switch) is reachable.
// and its moved control (the enable switch, audit 11.1 -- now a
// header action, t4 line 566) is reachable.
expect(find.byType(PluriPushScaffold), findsOneWidget);
expect(find.text('Enable equalizer'), findsOneWidget);
expect(find.byKey(const ValueKey('eq-master-switch')), findsOneWidget);
});
testWidgets('tapping the Orden de listas row pushes its detail screen', (
@@ -0,0 +1,162 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/formato_fechas.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/pantallas/pantalla_alarma_sonando.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:pluriwave/tema/pluriwave_theme.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes.dart';
import '../helpers/fakes_alarmas.dart';
/// Closing batch (items 9.4, 9.10, audit id 2521) -- the ringing screen.
/// Mirrors `pantalla_alarma_sonando_tier4_test.dart`'s own harness (kept as
/// a local copy, not a shared import, matching this file's established
/// precedent of small helper duplication over cross-test-file coupling).
Future<void> _montarPantalla(WidgetTester tester) async {
tester.view.physicalSize = const Size(1440, 3200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final audio = FakeServicioAudio();
audio.emitirEstado(EstadoReproduccion.reproduciendo);
final radio = EstadoRadio(
audio: audio,
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
iniciarAutomaticamente: false,
);
addTearDown(radio.dispose);
final android = FakePuertoAlarmasAndroid();
final estadoAlarmas = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 11, 7, 0)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estadoAlarmas.dispose);
addTearDown(android.dispose);
await estadoAlarmas.guardarAlarma(
const AlarmaMusical(
id: 'tier5-1',
nombre: 'Despertar',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
snoozeMinutos: 10,
emisora: Emisora(
uuid: 'e1',
nombre: 'Radio Uno',
url: 'https://radio.example/stream',
),
),
);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<EstadoRadio>.value(value: radio),
ChangeNotifierProvider<EstadoAlarmas>.value(value: estadoAlarmas),
],
child: MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
theme: PluriWaveTheme.dark(),
home: const SizedBox.shrink(),
),
),
);
final navigator = tester.state<NavigatorState>(find.byType(Navigator));
unawaited(
navigator.push(
MaterialPageRoute<void>(
builder:
(_) => PantallaAlarmaSonando(alarma: estadoAlarmas.alarmas.single),
fullscreenDialog: true,
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
}
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('visual fidelity (audit 9.4): the date line renders between the '
'schedule pill and the hero time (t4:419)', (tester) async {
await _montarPantalla(tester);
final localeTag =
Localizations.localeOf(
tester.element(find.byType(PantallaAlarmaSonando)),
).toString();
final esperado = fechaLargaConDiaSemana(localeTag, DateTime.now());
expect(find.text(esperado), findsOneWidget);
// Order: pill above the date line, date line above the hero time.
final pillY =
tester
.getBottomLeft(find.byKey(const ValueKey('ringing-schedule-pill')))
.dy;
final dateY = tester.getTopLeft(find.text(esperado)).dy;
final timeY =
tester.getTopLeft(find.byKey(const ValueKey('ringing-hero-time'))).dy;
expect(pillY <= dateY, isTrue);
expect(dateY <= timeY, isTrue);
// Regression guard: pumpAndSettle must still complete (purely
// additive static text, no new animation).
await tester.pumpAndSettle();
});
testWidgets(
'visual fidelity (audit 9.10): the highlighted snooze tile carries a '
'"usual" qualifier ADDITIVELY -- the original flat label the dismiss '
'guard finds by text stays fully intact',
(tester) async {
await _montarPantalla(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaAlarmaSonando)),
);
// The ORIGINAL flat label must still resolve to exactly one Text --
// this IS the dismiss-guard's own finder contract.
expect(find.text(l10n.alarmSnoozeOptionLabel(10)), findsOneWidget);
// The additive qualifier renders too, but only on the destacado tile.
expect(find.text(l10n.alarmSnoozeUsualLabel), findsOneWidget);
expect(find.text(l10n.alarmSnoozeOptionLabel(3)), findsOneWidget);
expect(find.text(l10n.alarmSnoozeOptionLabel(5)), findsOneWidget);
// Regression guard: the exact hazard 9.9/9.10 share -- pumpAndSettle
// must still complete, and the flat label must remain the SAME
// widget the dismiss guard taps (still inside a FilledButton).
expect(
find.ancestor(
of: find.text(l10n.alarmSnoozeOptionLabel(10)),
matching: find.byType(FilledButton),
),
findsOneWidget,
);
await tester.pumpAndSettle();
},
);
}
@@ -400,6 +400,66 @@ void main() {
);
},
);
testWidgets('8.5: the "REPETIR" eyebrow sits above the weekday circles', (
tester,
) async {
await _abrirEditor(tester);
expect(find.text(l10n.alarmRepeatSectionLabel), findsOneWidget);
final eyebrowY =
tester.getBottomLeft(find.text(l10n.alarmRepeatSectionLabel)).dy;
final circlesY =
tester
.getTopLeft(find.byKey(const ValueKey('alarm-weekday-circles')))
.dy;
expect(eyebrowY <= circlesY, isTrue);
});
testWidgets(
'8.6: the station field, volume, fade-in and vacation toggle share '
'ONE grouped card',
(tester) async {
await _abrirEditor(tester);
final tarjeta = find.byKey(const ValueKey('alarm-editor-grouped-card'));
expect(tarjeta, findsOneWidget);
expect(
find.descendant(
of: tarjeta,
matching: find.byKey(const ValueKey('alarm-station-field')),
),
findsOneWidget,
);
expect(
find.descendant(of: tarjeta, matching: find.byType(Slider)),
findsNWidgets(2),
reason: 'volume + fade-in sliders both live inside the card',
);
expect(
find.descendant(of: tarjeta, matching: find.byType(SwitchListTile)),
findsOneWidget,
reason: 'the vacation toggle lives inside the card',
);
// Snooze duration is a deliberate EXTRA (audit 8.8), kept OUTSIDE
// the prototype-mandated 4-row card.
expect(
find.descendant(
of: tarjeta,
matching: find.text(l10n.alarmSnoozeDurationTitle),
),
findsNothing,
);
},
);
testWidgets('8.7: the volume row shows a live percentage label next to a '
'compact track', (tester) async {
await _abrirEditor(tester);
expect(find.text(l10n.alarmVolumeLabel), findsOneWidget);
expect(find.text('85%'), findsOneWidget);
});
});
group('WU8 — tarjeta de alarma simplificada', () {
@@ -0,0 +1,193 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/pantallas/pantalla_alarmas.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:pluriwave/tema/pluriwave_tokens.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes.dart';
import '../helpers/fakes_alarmas.dart';
/// Tier 5 visual-fidelity closeout (items 7.1-7.4, audit id 2521) --
/// screens 4-14 batch.
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
Future<void> montar(
WidgetTester tester, {
required EstadoAlarmas estadoAlarmas,
EstadoRadio? radio,
}) async {
tester.view.physicalSize = const Size(1440, 3200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final estadoRadio =
radio ??
EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
iniciarAutomaticamente: false,
);
if (radio == null) addTearDown(estadoRadio.dispose);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<EstadoRadio>.value(value: estadoRadio),
ChangeNotifierProvider<EstadoAlarmas>.value(value: estadoAlarmas),
],
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: PantallaAlarmas()),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
}
EstadoAlarmas crearEstado() {
final android = FakePuertoAlarmasAndroid();
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 11, 6, 0)),
android: android,
iniciarAutomaticamente: false,
);
return estado;
}
group('visual fidelity (audit 7.1): "New" action', () {
testWidgets('is a solid brand-teal pill with an add glyph, not the tonal '
'auto_awesome button (t4:325)', (tester) async {
final estado = crearEstado();
addTearDown(estado.dispose);
await montar(tester, estadoAlarmas: estado);
expect(find.byIcon(Icons.auto_awesome_rounded), findsNothing);
expect(find.byIcon(Icons.add_rounded), findsOneWidget);
final l10n = lookupAppLocalizations(const Locale('en'));
final boton = tester.widget<FilledButton>(find.byType(FilledButton));
final resuelto = boton.style?.backgroundColor?.resolve(<WidgetState>{});
expect(resuelto, PluriWaveTokens.brand);
expect(find.text(l10n.createAlarmAction), findsOneWidget);
});
});
group('visual fidelity (audit 7.2): next-alarm banner', () {
testWidgets(
'is warmCoral-tinted and the Skip chip sits BESIDE the text, not '
'below it (t4:327-331)',
(tester) async {
final estado = crearEstado();
addTearDown(estado.dispose);
await estado.guardarAlarma(
const AlarmaMusical(
id: 'proxima',
nombre: 'Despertar',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
await montar(tester, estadoAlarmas: estado);
final l10n = lookupAppLocalizations(const Locale('en'));
final banner = tester.widget<DecoratedBox>(
find.byKey(const ValueKey('next-alarm-banner')),
);
final decoration = banner.decoration as BoxDecoration;
expect(
decoration.color,
const Color(0xFFF4B860).withValues(alpha: 0.13),
);
final skipY = tester.getCenter(find.text(l10n.alarmHeroSkipAction)).dy;
final titleY = tester.getCenter(find.byIcon(Icons.alarm_on)).dy;
expect(
(skipY - titleY).abs() < 4,
isTrue,
reason: 'the Skip chip sits on the SAME row as the icon/text',
);
},
);
});
group('visual fidelity (audit 7.3): vacation row date pill', () {
testWidgets('shows the upcoming range as a "d-d MON" pill (t4:332)', (
tester,
) async {
final estado = crearEstado();
addTearDown(estado.dispose);
await estado.crearRangoVacaciones(
estado.servicio.crearRangoVacaciones(
inicio: DateTime(2026, 8, 4),
fin: DateTime(2026, 8, 18),
nombre: 'Summer',
),
);
await montar(tester, estadoAlarmas: estado);
expect(find.text('418 AUG'), findsOneWidget);
});
});
group('visual fidelity (audit 7.4): alarm card', () {
testWidgets(
'shows a recurrence label next to the time and a station icon next '
'to the station name (t4:337-345)',
(tester) async {
final estado = crearEstado();
addTearDown(estado.dispose);
await estado.guardarAlarma(
const AlarmaMusical(
id: 'tarjeta',
nombre: 'Despertar',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
emisora: Emisora(
uuid: 'e1',
nombre: 'Radio Uno',
url: 'https://radio.example/stream',
),
),
);
await montar(tester, estadoAlarmas: estado);
final l10n = lookupAppLocalizations(const Locale('en'));
expect(
find.descendant(
of: find.byKey(const ValueKey('tarjeta-alarma-tarjeta')),
matching: find.text(l10n.dailyOption),
),
findsOneWidget,
);
expect(
find.descendant(
of: find.byKey(const ValueKey('tarjeta-alarma-tarjeta')),
matching: find.byKey(const ValueKey('tarjeta-alarma-arte')),
),
findsOneWidget,
);
},
);
});
}
@@ -133,6 +133,42 @@ void main() {
},
);
testWidgets('visual fidelity (audit 14.3): the headline is 34px/w800/ls-1.2 '
'(t4 line 696), not headlineMedium\'s 28', (tester) async {
final navegacion = EstadoNavegacionRaiz();
addTearDown(navegacion.dispose);
await tester.pumpWidget(_app(navegacion: navegacion));
await tester.pumpAndSettle();
final headline = tester.widget<Text>(find.text('Tu mundo, en directo'));
expect(headline.style?.fontSize, 34);
expect(headline.style?.fontWeight, FontWeight.w800);
expect(headline.style?.letterSpacing, -1.2);
});
testWidgets('visual fidelity (audit 14.7): the CTA is a radius-18 rounded '
'rectangle (t4 line 715), not the default fully-round StadiumBorder', (
tester,
) async {
final navegacion = EstadoNavegacionRaiz();
addTearDown(navegacion.dispose);
await tester.pumpWidget(_app(navegacion: navegacion));
await tester.pumpAndSettle();
final boton = tester.widget<FilledButton>(find.byType(FilledButton));
final forma = boton.style?.shape?.resolve(<WidgetState>{});
expect(
forma,
isA<RoundedRectangleBorder>().having(
(s) => (s.borderRadius as BorderRadius).topLeft,
'topLeft radius',
const Radius.circular(18),
),
);
});
testWidgets('rendered content contains no monetization strings', (
tester,
) async {
@@ -89,4 +89,55 @@ void main() {
expect(find.byType(TarjetaEmisoraShimmer), findsWidgets);
expect(find.byType(CircularProgressIndicator), findsNothing);
});
testWidgets('visual fidelity (audit 13.3/13.4): the "BUSCANDO EMISORAS..." '
'eyebrow renders above the loading rows, spaced 4px apart (t4:649-651)', (
tester,
) async {
final busqueda = _BusquedaCargando();
addTearDown(busqueda.dispose);
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadio(),
resolverArchivoCustom: () async => throw UnimplementedError(),
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<EstadoRadio>.value(value: estado),
ListenableProvider<EstadoEcualizador>.value(
value: estado.ecualizador,
),
ListenableProvider<EstadoGrabacion>.value(value: estado.grabacion),
ListenableProvider<EstadoBusqueda>.value(value: busqueda),
],
child: MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: PantallaBuscar()),
),
),
);
await tester.pump();
await tester.enterText(find.byType(SearchBar), 'jazz');
await tester.pump();
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaBuscar)),
);
expect(find.text(l10n.searchLoadingStationsLabel), findsOneWidget);
final filas = find.byType(TarjetaEmisoraShimmer);
expect(filas, findsWidgets);
final primeraAbajo = tester.getBottomLeft(filas.at(0)).dy;
final segundaArriba = tester.getTopLeft(filas.at(1)).dy;
expect(segundaArriba - primeraAbajo, 4);
});
}
+206
View File
@@ -15,6 +15,7 @@ import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:pluriwave/tema/pluriwave_theme.dart';
import 'package:pluriwave/tema/pluriwave_tokens.dart';
import 'package:pluriwave/widgets/fila_emisora_plana.dart';
import 'package:pluriwave/widgets/pluri_layout.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -181,6 +182,101 @@ void main() {
);
});
group('visual fidelity (audit 6.2/6.3/6.4)', () {
testWidgets(
'6.2: an active filter pill is brand-teal tinted with an inline '
'close glyph (t4:292-293)',
(tester) async {
_setLargeSurfaceSize(tester);
final estado = _crearEstado(
radio: FakeServicioRadio(
busqueda: [emisoraDemo(uuid: 'es-1', nombre: 'Radio Espana')],
),
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await tester.pumpWidget(_conProviders(estado, _testApp()));
await _pumpStableFrame(tester);
final l10n = _l10nDe(tester);
await _abrirYSeleccionarPais(tester, l10n.countrySpain);
await _pumpStableFrame(tester);
final chip = tester.widget<Chip>(
find.ancestor(
of: find.text(l10n.countrySpain),
matching: find.byType(Chip),
),
);
expect(
chip.backgroundColor,
const Color(0xFF21D4D9).withValues(alpha: 0.2),
);
expect(
(chip.shape as RoundedRectangleBorder?)?.side.color,
const Color(0xFF21D4D9).withValues(alpha: 0.45),
);
},
);
testWidgets(
'6.3: an "Idioma" entry chip is always reachable once a search is '
'active, opening the filters sheet (t4:294-295)',
(tester) async {
_setLargeSurfaceSize(tester);
final estado = _crearEstado(
radio: FakeServicioRadio(
busqueda: [emisoraDemo(uuid: 'r-1', nombre: 'Radio Uno')],
),
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await tester.pumpWidget(_conProviders(estado, _testApp()));
await _pumpStableFrame(tester);
await tester.enterText(find.byType(SearchBar), 'radio');
await tester.testTextInput.receiveAction(TextInputAction.done);
await _pumpStableFrame(tester);
final l10n = _l10nDe(tester);
expect(find.text(l10n.searchLanguageFilterLabel), findsOneWidget);
await tester.tap(find.text(l10n.searchLanguageFilterLabel));
await tester.pumpAndSettle();
expect(find.text(l10n.searchCountryFilterLabel), findsOneWidget);
},
);
testWidgets(
'6.4: the results-count eyebrow uses eyebrowLabel styling (t4:299)',
(tester) async {
_setLargeSurfaceSize(tester);
final estado = _crearEstado(
radio: FakeServicioRadio(
busqueda: [emisoraDemo(uuid: 'r-1', nombre: 'Radio Uno')],
),
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await tester.pumpWidget(_conProviders(estado, _testApp()));
await _pumpStableFrame(tester);
await tester.enterText(find.byType(SearchBar), 'radio');
await tester.testTextInput.receiveAction(TextInputAction.done);
await _pumpStableFrame(tester);
final l10n = _l10nDe(tester);
final texto = tester.widget<Text>(
find.text(l10n.searchResultsCount(1)),
);
expect(texto.style?.fontSize, 11);
expect(texto.style?.fontWeight, FontWeight.w800);
},
);
});
group('Buscar — Ordenar (client-side, WU6)', () {
testWidgets(
'cada opcion renderizada de Ordenar corresponde a un caso real de '
@@ -740,6 +836,78 @@ void main() {
);
});
testWidgets('issue 3 (feedback-pruebas): the results list uses row-tier '
'horizontal padding (12), not the card-tier constant this "flat, '
'background-less row" was documented as needing but never got', (
tester,
) async {
_setLargeSurfaceSize(tester);
final estado = _crearEstado(
radio: FakeServicioRadio(
busqueda: [emisoraDemo(uuid: 'r-1', nombre: 'Radio Uno')],
),
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await tester.pumpWidget(_conProviders(estado, _testApp()));
await _pumpStableFrame(tester);
await tester.enterText(find.byType(SearchBar), 'radio');
await tester.testTextInput.receiveAction(TextInputAction.done);
await _pumpStableFrame(tester);
final fila = find.byType(FilaEmisoraPlana);
expect(
tester.getTopLeft(fila).dx,
PluriLayout.rowHorizontal,
reason:
'issue 3: background-less rows are row tier (12), not card '
'tier (16)',
);
});
testWidgets('issue 3 (feedback-pruebas): the results list is topped by the '
'standard section gap, not the horizontal-inset constant reused for '
'a vertical axis', (tester) async {
_setLargeSurfaceSize(tester);
final estado = _crearEstado(
radio: FakeServicioRadio(
busqueda: [emisoraDemo(uuid: 'r-1', nombre: 'Radio Uno')],
),
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await tester.pumpWidget(_conProviders(estado, _testApp()));
await _pumpStableFrame(tester);
await tester.enterText(find.byType(SearchBar), 'radio');
await tester.testTextInput.receiveAction(TextInputAction.done);
await _pumpStableFrame(tester);
// Reads the structural padding directly, rather than measuring a
// gap between two rendered widgets — the count row's own height is
// dictated by its taller PopupMenuButton (48dp touch target), so a
// position-based gap measurement against the count TEXT specifically
// would be thrown off by that unrelated vertical centring.
//
// Scoped to `shrinkWrap: true` — the OUTER page ListView is ALSO an
// ancestor of every `FilaEmisoraPlana`, but only `_resultados`'s OWN
// inner `ListView.builder` sets `shrinkWrap`.
final listaResultados = tester.widget<ListView>(
find.byWidgetPredicate((w) => w is ListView && w.shrinkWrap),
);
final padding = listaResultados.padding as EdgeInsets;
expect(
padding.top,
PluriLayout.sectionGap,
reason:
'issue 3: the results list must use the dedicated vertical '
'section gap above its first row, not the horizontal (16) '
'constant reused for a vertical axis',
);
});
testWidgets(
'tapping the favourite toggle on a search result adds it to favorites',
(tester) async {
@@ -774,6 +942,44 @@ void main() {
// Hazard: `pumpAndSettle()` never terminates while this card's rotating
// ring animates -- every assertion below uses a bounded `pump()` once
// `reconectando` is emitted, never `_pumpStableFrame`'s `pumpAndSettle`.
group('visual fidelity (audit 13.5/13.6)', () {
testWidgets(
'13.5/13.6: the no-results card quotes the query, and the "clear '
'filters" pill sits INSIDE the same card (t4:657-663)',
(tester) async {
_setLargeSurfaceSize(tester);
final estado = _crearEstado(radio: FakeServicioRadio(busqueda: []));
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await tester.pumpWidget(_conProviders(estado, _testApp()));
await _pumpStableFrame(tester);
await _abrirYSeleccionarPais(tester, _l10nDe(tester).countrySpain);
await _pumpStableFrame(tester);
await tester.enterText(find.byType(SearchBar), 'jazzz');
await tester.testTextInput.receiveAction(TextInputAction.done);
await _pumpStableFrame(tester);
final l10n = _l10nDe(tester);
expect(
find.text(l10n.searchNoResultsForQueryTitle('jazzz')),
findsOneWidget,
);
final tarjeta = find.byKey(const ValueKey('search-no-results-card'));
expect(tarjeta, findsOneWidget);
expect(
find.descendant(
of: tarjeta,
matching: find.text(l10n.searchClearFiltersAction(1)),
),
findsOneWidget,
reason: 't4:667 the clear-filters pill sits INSIDE the card',
);
},
);
});
group('Item 25 -- reconnect card (audit 13.2)', () {
testWidgets(
'shows the station name, "Reconectando...", and a stop button while '
+54 -2
View File
@@ -357,7 +357,12 @@ void main() {
await tester.pumpWidget(buildScreen(estado));
await pumpStable(tester);
await tester.tap(find.text('Manage lists'));
// Audit 4.1 (t4:216): the manage-groups action moved into the
// header as a create_new_folder icon button, replacing the old
// "Manage lists" ActionChip in the chip strip.
await tester.tap(
find.byKey(const ValueKey('favorites-manage-groups-action')),
);
await pumpStable(tester);
// "Group Management Reachable from Favoritos": the SAME screen
@@ -385,7 +390,10 @@ void main() {
await tester.pumpAndSettle();
expect(find.byType(PantallaAjustesGruposFavoritos), findsNothing);
expect(find.text('Manage lists'), findsOneWidget);
expect(
find.byKey(const ValueKey('favorites-manage-groups-action')),
findsOneWidget,
);
await estado.crearGrupoFavoritos('Road trip');
await pumpStable(tester);
@@ -464,6 +472,24 @@ void main() {
expect(find.text('Move to list'), findsOneWidget);
expect(find.text('Remove from favorites'), findsOneWidget);
expect(find.byType(PopupMenuItem<String>), findsNWidgets(2));
// Regression guard for a real user-reported bug: the button carried
// `constraints: BoxConstraints.tightFor(width: 38, height: 42)`,
// which sizes the POPUP MENU rather than the button. Every item was
// clipped to its first letter — users saw "M" and "E", not the
// labels. The three assertions above all PASSED throughout, because
// find.text matches a Text widget in the tree whether or not it is
// visually clipped. Only measuring the laid-out width catches it.
final anchoItem = tester.getSize(
find.byType(PopupMenuItem<String>).first,
);
expect(
anchoItem.width,
greaterThan(100),
reason:
'a menu item narrower than its label means the popup is being '
'constrained and the text is clipped',
);
},
);
@@ -494,6 +520,32 @@ void main() {
},
);
});
group('visual fidelity (audit 4.2)', () {
testWidgets('the active group chip is solid brand teal with dark text; '
'inactive chips use listSurface (t4:219-221)', (tester) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
final estado = await crearEstadoConFavoritos();
addTearDown(estado.dispose);
await tester.pumpWidget(buildScreen(estado));
await pumpStable(tester);
final activo = tester.widget<ChoiceChip>(
find.widgetWithText(ChoiceChip, 'All · 3'),
);
expect(activo.selected, isTrue);
expect(activo.selectedColor, const Color(0xFF21D4D9));
expect(activo.labelStyle?.color, const Color(0xFF062126));
final inactivo = tester.widget<ChoiceChip>(
find.widgetWithText(ChoiceChip, 'Rock · 2'),
);
expect(inactivo.selected, isFalse);
expect(inactivo.backgroundColor, const Color(0xFF102532));
});
});
}
void setLargeSurface(WidgetTester tester) {
@@ -173,6 +173,49 @@ void main() {
expect(find.text('84 MB of 200 MB used'), findsOneWidget);
});
testWidgets(
'visual fidelity (audit 12.2/12.3): the storage card shows the bold '
'"used of total" headline ABOVE the bar, then a folder-path + purge '
'caption below it (t4 line 613)',
(tester) async {
final estado = EstadoGrabacion(
servicio: _FakeServicioGrabacionConArchivos([
fijaA,
fijaB,
fijaC,
], maxBytesFijo: 200 * 1024 * 1024),
);
addTearDown(estado.dispose);
await tester.pumpWidget(
buildScreen(
estado: estado,
reproductor: _ReproductorGrabacionesFake(const {}),
),
);
await pumpStable(tester);
final headline = tester.widget<Text>(find.text('84 MB of 200 MB used'));
expect(headline.style?.fontWeight, FontWeight.w800);
expect(
find.text('Music/PluriWave · purges oldest at limit'),
findsOneWidget,
);
final headlineY = tester.getTopLeft(find.text('84 MB of 200 MB used')).dy;
final barY = tester.getTopLeft(find.byType(LinearProgressIndicator)).dy;
final captionY =
tester
.getTopLeft(find.text('Music/PluriWave · purges oldest at limit'))
.dy;
expect(
headlineY < barY && barY < captionY,
isTrue,
reason: 'headline, then bar, then caption -- top to bottom',
);
},
);
testWidgets('15.2-A: 3 recording fixtures render as 3 rows', (tester) async {
final estado = EstadoGrabacion(
servicio: _FakeServicioGrabacionConArchivos([
+71
View File
@@ -11,6 +11,7 @@ import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/pantallas/pantalla_inicio.dart';
import 'package:pluriwave/widgets/pluri_root_header.dart';
import 'package:pluriwave/widgets/pluri_station_art_fallback.dart';
import 'package:pluriwave/widgets/visualizador_audio.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -698,6 +699,76 @@ void main() {
);
},
);
testWidgets('issue 6 (feedback-pruebas): the Escuchar hero shows the shared '
'PluriStationArtFallback for a station with no favicon, not a flat '
'coloured square', (tester) async {
_setLargeSurfaceSize(tester);
final favoritos = FakeServicioFavoritos();
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: favoritos,
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadio(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
final sonando = emisoraDemo(uuid: 'sin-arte', nombre: 'Sin Arte');
await tester.runAsync(() => estado.reproducir(sonando));
await tester.pumpWidget(
_conProviders(estado, _testApp(const PantallaInicio())),
);
await _pumpBounded(tester);
expect(
find.byType(PluriStationArtFallback),
findsWidgets,
reason:
'issue 6: the hero must reach the shared fallback, not its own '
'flat primaryContainer square',
);
});
testWidgets(
'issue 6 (feedback-pruebas): a "Tus emisoras" grid cell shows the shared '
'PluriStationArtFallback for a favourite with no favicon',
(tester) async {
_setLargeSurfaceSize(tester);
final favoritos = FakeServicioFavoritos();
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: favoritos,
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadio(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await favoritos.agregar(
emisoraDemo(uuid: 'grid-sin-arte', nombre: 'Grid Sin Arte'),
);
await estado.cargarFavoritos();
await tester.pumpWidget(
_conProviders(estado, _testApp(const PantallaInicio())),
);
await _pumpStableFrame(tester);
expect(
find.byType(PluriStationArtFallback),
findsWidgets,
reason:
'issue 6: the grid cell must reach the shared fallback, not its '
'own flat primaryContainer square',
);
},
);
}
/// Mirrors the app.dart wiring: EstadoRadio owns the domain notifiers and
+64 -1
View File
@@ -4,6 +4,7 @@ import 'package:pluriwave/estado/estado_busqueda.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/pais_radio.dart';
import 'package:pluriwave/pantallas/pantalla_paises.dart';
import 'package:pluriwave/widgets/pluri_glass_surface.dart';
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
import 'package:provider/provider.dart';
@@ -62,7 +63,9 @@ void main() {
tester.element(find.byType(PantallaPaises)),
);
expect(find.text(l10n.countriesYourLanguagesTitle), findsOneWidget);
expect(find.text(l10n.countriesAllTitle), findsOneWidget);
// Audit 5.4: "Todos" now carries the total count as part of the
// same eyebrow string ("{title} · {count}"), not a bare title.
expect(find.textContaining(l10n.countriesAllTitle), findsOneWidget);
// Full alphabetical list: all 3 fetched countries render.
expect(find.text('Argentina'), findsOneWidget);
@@ -201,6 +204,66 @@ void main() {
);
});
group('visual fidelity (audit 5.4/5.7)', () {
testWidgets(
'the section eyebrows sit OUTSIDE any card, styled as an eyebrow '
'label, and "Todos" carries the total country count (t4:254/260)',
(tester) async {
final estado = EstadoBusqueda(
radio: FakeServicioRadio(
paises: const [
PaisRadio(nombre: 'Spain', codigoIso: 'ES', numeroEmisoras: 482),
PaisRadio(
nombre: 'Argentina',
codigoIso: 'AR',
numeroEmisoras: 120,
),
PaisRadio(nombre: 'France', codigoIso: 'FR', numeroEmisoras: 75),
],
),
);
addTearDown(estado.dispose);
await tester.pumpWidget(buildScreen(estado));
await pumpEstable(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaPaises)),
);
final eyebrow = tester.widget<Text>(
find.text(l10n.countriesYourLanguagesTitle),
);
expect(eyebrow.style?.fontSize, 11);
expect(eyebrow.style?.fontWeight, FontWeight.w800);
expect(
find.ancestor(
of: find.text(l10n.countriesYourLanguagesTitle),
matching: find.byType(PluriGlassSurface),
),
findsNothing,
reason: 'the eyebrow must not be a descendant of any card',
);
expect(
find.textContaining('${l10n.countriesAllTitle} · 3'),
findsOneWidget,
);
},
);
testWidgets('the header carries a search action (t4:252)', (tester) async {
final estado = EstadoBusqueda(radio: FakeServicioRadio(paises: const []));
addTearDown(estado.dispose);
await tester.pumpWidget(buildScreen(estado));
await pumpEstable(tester);
expect(find.byIcon(Icons.search_rounded), findsOneWidget);
});
});
testWidgets(
'cargarPaises no se re-dispara si ya hay datos en caché al reconstruir',
(tester) async {
@@ -12,6 +12,7 @@ import 'package:pluriwave/pantallas/pantalla_reproductor.dart';
import 'package:pluriwave/tema/pluriwave_tokens.dart';
import 'package:pluriwave/widgets/ecualizador_widget.dart';
import 'package:pluriwave/widgets/pluri_glass_surface.dart';
import 'package:pluriwave/widgets/pluri_station_art_fallback.dart';
import 'package:pluriwave/widgets/visualizador_audio.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -704,4 +705,30 @@ void main() {
},
);
});
group('issue 6 (feedback-pruebas): shared station-art fallback', () {
testWidgets(
'a station with no favicon shows the shared PluriStationArtFallback, '
'not a flat coloured square',
(tester) async {
// `emisora` (the file-level fixture) never sets a favicon.
final estado = crearEstado();
addTearDown(estado.dispose);
await montarPantalla(tester, estado);
final arte = find.byKey(const Key('player-hero-art'));
expect(
find.descendant(
of: arte,
matching: find.byType(PluriStationArtFallback),
),
findsOneWidget,
reason:
'issue 6: the full player must reach the shared fallback, '
'not its own flat primaryContainer square',
);
},
);
});
}
+272 -1
View File
@@ -180,7 +180,8 @@ void main() {
});
testWidgets(
'los rangos pasados aparecen bajo el encabezado "Rangos pasados"',
'los rangos pasados aparecen bajo el encabezado "Rangos pasados" tras '
'expandir la fila (audit 9b.7: colapsada por defecto, t4:489)',
(tester) async {
final estado = await _crearEstado(
vacaciones: [
@@ -201,6 +202,10 @@ void main() {
tester.element(find.byType(PantallaVacaciones)),
);
expect(find.text(l10n.vacationPastSectionTitle), findsOneWidget);
await tester.tap(find.text(l10n.vacationPastSectionTitle));
await _pumpEstable(tester);
expect(find.text('Rango viejo'), findsOneWidget);
},
);
@@ -343,4 +348,270 @@ void main() {
);
},
);
group('visual fidelity (audit 9b.1/9b.2/9b.6/9b.7)', () {
testWidgets(
'9b.1: a header "Add" action is reachable and opens the same form '
'as the CTA (t4:446)',
(tester) async {
final estado = await _crearEstado();
addTearDown(estado.dispose);
await tester.pumpWidget(_buildScreen(estado));
await _pumpEstable(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaVacaciones)),
);
final appBar = tester.widget<AppBar>(find.byType(AppBar));
expect(appBar.actions, isNotNull);
expect(appBar.actions, isNotEmpty);
await tester.tap(find.byKey(const ValueKey('vacation-add-header')));
await _pumpEstable(tester);
expect(find.text(l10n.newVacationRangeTitle), findsOneWidget);
},
);
testWidgets(
'9b.2: the explanatory info banner is always visible (t4:448)',
(tester) async {
final estado = await _crearEstado();
addTearDown(estado.dispose);
await tester.pumpWidget(_buildScreen(estado));
await _pumpEstable(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaVacaciones)),
);
expect(find.text(l10n.vacationExplainerBanner), findsOneWidget);
},
);
testWidgets(
'9b.6: the bottom CTA has a dashed border and a date_range icon '
'(t4:487)',
(tester) async {
final estado = await _crearEstado();
addTearDown(estado.dispose);
await tester.pumpWidget(_buildScreen(estado));
await _pumpEstable(tester);
expect(find.byIcon(Icons.date_range_rounded), findsOneWidget);
// Exactly ONE add_rounded on the whole screen -- the header's OWN
// "Add" action (audit 9b.1). The bottom CTA no longer uses it.
expect(find.byIcon(Icons.add_rounded), findsOneWidget);
},
);
testWidgets(
'9b.7: "Rangos pasados" is collapsed by default, showing a count, '
'and expands on tap (t4:489)',
(tester) async {
final estado = await _crearEstado(
vacaciones: [
RangoVacaciones(
id: 'pa1',
nombre: 'Rango viejo',
inicio: _hoyDia.subtract(const Duration(days: 30)),
fin: _hoyDia.subtract(const Duration(days: 20)),
),
],
);
addTearDown(estado.dispose);
await tester.pumpWidget(_buildScreen(estado));
await _pumpEstable(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaVacaciones)),
);
expect(find.text(l10n.vacationPastSectionTitle), findsOneWidget);
expect(find.text('1'), findsOneWidget);
expect(
find.text('Rango viejo'),
findsNothing,
reason:
'collapsed by default -- matches the prototype count-only row',
);
await tester.tap(find.text(l10n.vacationPastSectionTitle));
await _pumpEstable(tester);
expect(find.text('Rango viejo'), findsOneWidget);
},
);
});
group('issue 1 (feedback-pruebas): editar y eliminar rangos', () {
testWidgets(
'tocar la tarjeta de un rango programado abre el editor precargado '
'con su nombre y fechas',
(tester) async {
final estado = await _crearEstado(
vacaciones: [
RangoVacaciones(
id: 'f2',
nombre: 'Verano',
inicio: _hoyDia.add(const Duration(days: 20)),
fin: _hoyDia.add(const Duration(days: 25)),
),
],
);
addTearDown(estado.dispose);
await tester.pumpWidget(_buildScreen(estado));
await _pumpEstable(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaVacaciones)),
);
await tester.tap(find.byKey(const ValueKey('vacaciones-tarjeta-f2')));
await _pumpEstable(tester);
expect(find.text(l10n.editVacationRangeTitle), findsOneWidget);
// Scoped to the TextField specifically -- the original card's OWN
// "Verano" label is still (offstage, behind the modal) in the tree,
// so a bare `find.text('Verano')` would ambiguously match both.
expect(find.widgetWithText(TextField, 'Verano'), findsOneWidget);
},
);
testWidgets(
'guardar el editor abierto por tap actualiza el rango existente (no '
'crea uno nuevo)',
(tester) async {
final estado = await _crearEstado(
vacaciones: [
RangoVacaciones(
id: 'f2',
nombre: 'Verano',
inicio: _hoyDia.add(const Duration(days: 20)),
fin: _hoyDia.add(const Duration(days: 25)),
),
],
);
addTearDown(estado.dispose);
await tester.pumpWidget(_buildScreen(estado));
await _pumpEstable(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaVacaciones)),
);
await tester.tap(find.byKey(const ValueKey('vacaciones-tarjeta-f2')));
await _pumpEstable(tester);
await tester.enterText(find.byType(TextField), 'Verano renombrado');
final boton = tester.widget<FilledButton>(
find.widgetWithText(FilledButton, l10n.saveRangeAction),
);
boton.onPressed!();
await _pumpEstable(tester);
expect(estado.vacaciones, hasLength(1));
expect(estado.vacaciones.single.id, 'f2');
expect(estado.vacaciones.single.nombre, 'Verano renombrado');
},
);
testWidgets(
'deslizar la tarjeta de un rango pide confirmacion; cancelar la '
'conserva y confirmar la elimina',
(tester) async {
final estado = await _crearEstado(
vacaciones: [
RangoVacaciones(
id: 'f2',
nombre: 'Verano',
inicio: _hoyDia.add(const Duration(days: 20)),
fin: _hoyDia.add(const Duration(days: 25)),
),
],
);
addTearDown(estado.dispose);
await tester.pumpWidget(_buildScreen(estado));
await _pumpEstable(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaVacaciones)),
);
// Cancelar: el rango se conserva.
await tester.drag(
find.byKey(const ValueKey('vacaciones-tarjeta-f2')),
const Offset(-600, 0),
);
await _pumpEstable(tester);
expect(find.text(l10n.vacationDeleteConfirmTitle), findsOneWidget);
await tester.tap(find.text(l10n.cancelAction));
await _pumpEstable(tester);
expect(estado.vacaciones, hasLength(1));
// Confirmar: el rango se elimina.
await tester.drag(
find.byKey(const ValueKey('vacaciones-tarjeta-f2')),
const Offset(-600, 0),
);
await _pumpEstable(tester);
expect(find.text(l10n.vacationDeleteConfirmTitle), findsOneWidget);
await tester.tap(find.text(l10n.deleteAction));
await _pumpEstable(tester);
expect(estado.vacaciones, isEmpty);
},
);
testWidgets(
'el rango ACTIVO (mostrado en el hero) tambien se puede editar (tap) '
'y eliminar (swipe) -- un rango recien creado siempre esta activo y '
'nunca aparece en las listas programado/pasado',
(tester) async {
final estado = await _crearEstado(
vacaciones: [
RangoVacaciones(
id: 'v1',
nombre: 'Julio activo',
inicio: _hoyDia.subtract(const Duration(days: 3)),
fin: _hoyDia.add(const Duration(days: 5)),
),
],
);
addTearDown(estado.dispose);
await tester.pumpWidget(_buildScreen(estado));
await _pumpEstable(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaVacaciones)),
);
await tester.tap(find.byKey(const ValueKey('vacaciones-tarjeta-v1')));
await _pumpEstable(tester);
expect(find.text(l10n.editVacationRangeTitle), findsOneWidget);
// Scoped to the TextField specifically -- the hero's OWN "Julio
// activo" label is still (offstage, behind the modal) in the tree.
expect(find.widgetWithText(TextField, 'Julio activo'), findsOneWidget);
// Dismiss the editor sheet (no explicit close button -- same as the
// pre-existing "Anadir rango" sheet, dismissible via the standard
// modal-bottom-sheet Navigator.pop) before interacting with the
// list underneath it.
Navigator.of(tester.element(find.byType(PantallaVacaciones))).pop();
await _pumpEstable(tester);
await tester.drag(
find.byKey(const ValueKey('vacaciones-tarjeta-v1')),
const Offset(-600, 0),
);
await _pumpEstable(tester);
await tester.tap(find.text(l10n.deleteAction));
await _pumpEstable(tester);
expect(estado.vacaciones, isEmpty);
expect(find.text(l10n.vacationNoActiveRangeHint), findsOneWidget);
},
);
});
}
+26
View File
@@ -136,4 +136,30 @@ void main() {
'EQ bands with brand teal #21D4D9 (t4 line 585)',
);
});
testWidgets(
'visual fidelity (audit 11.5): the thumb is a 20x20 custom glow shape, '
'not the Material default (t4 line 585)',
(tester) async {
await tester.pumpWidget(buildWidget());
final tema = SliderTheme.of(tester.element(find.byType(Slider).first));
final size = tema.thumbShape?.getPreferredSize(true, false);
expect(
size,
const Size(20, 20),
reason: 't4 line 585: a 20x20 thumb with a 14px brand-teal glow',
);
},
);
testWidgets('visual fidelity (audit 11.7): the dB label is brand teal, not '
'liveGreen (t4 line 584)', (tester) async {
await tester.pumpWidget(buildWidget());
final label = tester.widget<Text>(find.text('0.0dB').first);
expect(label.style?.color, PluriWaveTokens.brand.withValues(alpha: 0.9));
expect(label.style?.color, isNot(PluriWaveTokens.dark.liveGreen));
});
}
+27
View File
@@ -4,6 +4,7 @@ import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/tema/pluriwave_tokens.dart';
import 'package:pluriwave/widgets/fila_emisora_plana.dart';
import 'package:pluriwave/widgets/pluri_station_art_fallback.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
import 'package:provider/provider.dart';
@@ -77,6 +78,32 @@ void main() {
);
});
testWidgets(
'issue 6 (feedback-pruebas): a station with no favicon shows the shared '
'PluriStationArtFallback, not a flat coloured square',
(tester) async {
final estado = _estado();
addTearDown(estado.dispose);
final emisora = emisoraDemo(uuid: 'sin-arte', nombre: 'Sin Arte');
await tester.pumpWidget(
_host(estado, FilaEmisoraPlana(emisora: emisora, meta: '')),
);
await tester.pump();
expect(
find.descendant(
of: find.byKey(const ValueKey('fila-emisora-plana-arte')),
matching: find.byType(PluriStationArtFallback),
),
findsOneWidget,
reason:
'issue 6: the flat row must reach the shared fallback, not its '
'own flat primaryContainer square',
);
},
);
testWidgets('omits the meta line entirely when empty (no stray gap)', (
tester,
) async {
@@ -5,6 +5,7 @@ import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/tema/pluriwave_theme.dart';
import 'package:pluriwave/tema/pluriwave_tokens.dart';
import 'package:pluriwave/widgets/mini_reproductor.dart';
import 'package:pluriwave/widgets/pluri_station_art_fallback.dart';
import 'package:pluriwave/widgets/visualizador_audio.dart';
import 'package:provider/provider.dart';
@@ -75,6 +76,33 @@ void main() {
},
);
testWidgets(
'issue 6 (feedback-pruebas): a station with no favicon shows the shared '
'PluriStationArtFallback, not a flat coloured square',
(tester) async {
final estado = _estadoConEmisora();
addTearDown(estado.dispose);
await estado.reproducir(
emisoraDemo(uuid: 'sin-arte', nombre: 'Sin Arte'),
);
await tester.pumpWidget(_hostFor(estado));
await tester.pump();
final arte = find.byKey(const ValueKey('mini-reproductor-arte'));
expect(
find.descendant(
of: arte,
matching: find.byType(PluriStationArtFallback),
),
findsOneWidget,
reason:
'issue 6: the mini player must reach the shared fallback, not '
'its own flat primaryContainer square',
);
},
);
testWidgets(
'is opaque -- no BackdropFilter -- unlike the former glass pill',
(tester) async {
@@ -96,13 +96,17 @@ void main() {
);
expect(inactiveIcon.color, isNull);
final dimmed = tester.widget<Opacity>(
// AnimatedOpacity, not a plain Opacity: the dim now transitions with the
// balloon instead of snapping. Users reported the bar's contents
// teleporting while the balloon slid — the lift, dim, icon size and
// label all animate together now.
final dimmed = tester.widget<AnimatedOpacity>(
find.ancestor(
of: find.descendant(
of: find.byKey(PluriBottomNavigation.itemKey(1)),
matching: find.byType(PluriIcon),
),
matching: find.byType(Opacity),
matching: find.byType(AnimatedOpacity),
),
);
expect(dimmed.opacity, closeTo(0.46, 0.001));
@@ -0,0 +1,184 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/widgets/pluri_sleep_timer_sheet.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes.dart';
import '../helpers/fakes_alarmas.dart';
/// Issue 2 (feedback-pruebas): "Timer de sueño" opened from Escuchar (and
/// every other root header) showed a bottom sheet with no visible
/// countdown. `ServicioTimer.tiempoRestanteStream`/`tiempoRestante` already
/// existed and this sheet already had a `StreamBuilder` countdown branch —
/// but every preset/custom-duration action popped the sheet immediately
/// after starting the timer, so the countdown never had a chance to render
/// in the primary flow. Zero test coverage existed for this file before.
EstadoRadio _estado() => EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
iniciarAutomaticamente: false,
);
Widget _host(EstadoRadio estado) {
return ChangeNotifierProvider<EstadoRadio>.value(
value: estado,
child: MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: Builder(
builder:
(context) => TextButton(
onPressed: () => showPluriSleepTimerSheet(context),
child: const Text('abrir'),
),
),
),
),
);
}
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
// The default 800x600 test viewport is shorter than a typical phone —
// same fix `pantalla_reproductor_test.dart`/`pantalla_inicio_test.dart`
// already established for other bottom-sheet/full-bleed content.
void ajustarSuperficieRealista(WidgetTester tester) {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
}
testWidgets(
'selecting a preset keeps the sheet open and switches it to the live '
'remaining-time countdown, instead of closing with no feedback',
(tester) async {
ajustarSuperficieRealista(tester);
final estado = _estado();
addTearDown(estado.dispose);
await tester.pumpWidget(_host(estado));
await tester.tap(find.text('abrir'));
await tester.pumpAndSettle();
final l10n = AppLocalizations.of(tester.element(find.text('abrir')));
expect(estado.timer.activo, isFalse);
expect(find.byType(ActionChip), findsWidgets);
final chip = tester.widget<ActionChip>(
find.widgetWithText(ActionChip, l10n.durationMinutesOnly(5)),
);
chip.onPressed!();
await tester.pump();
expect(estado.timer.activo, isTrue);
expect(
find.byType(ActionChip),
findsNothing,
reason: 'issue 2: the picker is replaced by the countdown view',
);
expect(find.text(l10n.cancelTimer), findsOneWidget);
expect(
find.text(l10n.durationMinutesOnly(5)),
findsOneWidget,
reason: 'issue 2: the remaining time is now surfaced live',
);
// `ServicioTimer` starts a REAL periodic Timer -- `addTearDown`
// callbacks run too late to satisfy the "no pending timers" check
// (a well-known flutter_test ordering quirk), so it must be
// cancelled here, inside the test body, before it ends.
await estado.timer.cancelar();
},
);
testWidgets('reopening the sheet while a timer is already active shows the '
'countdown immediately, not the duration picker', (tester) async {
ajustarSuperficieRealista(tester);
final estado = _estado();
addTearDown(estado.dispose);
estado.iniciarTimerDuracion(const Duration(minutes: 10));
await tester.pumpWidget(_host(estado));
await tester.tap(find.text('abrir'));
await tester.pumpAndSettle();
final l10n = AppLocalizations.of(tester.element(find.text('abrir')));
expect(find.byType(ActionChip), findsNothing);
expect(find.text(l10n.cancelTimer), findsOneWidget);
expect(find.text(l10n.durationMinutesOnly(10)), findsOneWidget);
// See the comment in the previous test — cancel before the body ends.
await estado.timer.cancelar();
});
testWidgets(
'cancelling from the countdown view stops the timer and closes the '
'sheet',
(tester) async {
ajustarSuperficieRealista(tester);
final estado = _estado();
addTearDown(estado.dispose);
estado.iniciarTimerDuracion(const Duration(minutes: 10));
await tester.pumpWidget(_host(estado));
await tester.tap(find.text('abrir'));
await tester.pumpAndSettle();
final l10n = AppLocalizations.of(tester.element(find.text('abrir')));
final boton = tester.widget<FilledButton>(
find.widgetWithText(FilledButton, l10n.cancelTimer),
);
boton.onPressed!();
await tester.pumpAndSettle();
expect(estado.timer.activo, isFalse);
expect(find.text(l10n.cancelTimer), findsNothing);
},
);
testWidgets(
'starting a custom duration ALSO keeps the sheet open on the countdown '
'view, not just the presets',
(tester) async {
ajustarSuperficieRealista(tester);
final estado = _estado();
addTearDown(estado.dispose);
await tester.pumpWidget(_host(estado));
await tester.tap(find.text('abrir'));
await tester.pumpAndSettle();
final l10n = AppLocalizations.of(tester.element(find.text('abrir')));
await tester.tap(find.text(l10n.optionOther));
await tester.pumpAndSettle();
// Confirm the custom-duration sub-sheet directly (default prefilled
// value is already "15" minutes -- see _TimerPersonalizadoSheetState).
final confirmar = tester.widget<FilledButton>(
find.widgetWithText(FilledButton, l10n.startTimer),
);
confirmar.onPressed!();
await tester.pumpAndSettle();
expect(estado.timer.activo, isTrue);
expect(find.byType(ActionChip), findsNothing);
expect(find.text(l10n.cancelTimer), findsOneWidget);
// See the comment in the first test — cancel before the body ends.
await estado.timer.cancelar();
},
);
}
@@ -0,0 +1,86 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/widgets/pluri_icon.dart';
import 'package:pluriwave/widgets/pluri_station_art_fallback.dart';
/// Issue 6 (feedback-pruebas): the shared station-art fallback extracted
/// from `TarjetaEmisora`'s original `_fallbackArtFor`. The formula and asset
/// order are pinned exactly — `navegacion_auto_test.dart`'s
/// `indiceArtePara`/`artUriPara` tests mirror the SAME formula independently
/// (protected file, empty diff vs main) and must keep agreeing with this
/// widget without either suite importing the other.
void main() {
Widget host(Widget child) {
return MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: SizedBox(width: 60, height: 60, child: child)),
);
}
test('artFor reproduces the canonical aurora/cosmic/pulse/nova order '
'(same formula as navegacion_auto.dart\'s indiceArtePara)', () {
// Single-letter seeds whose codeUnit % 4 covers all 4 indices, mirroring
// navegacion_auto_test.dart's own fixture exactly: 'd'(100)->0 aurora,
// 'a'(97)->1 cosmic, 'b'(98)->2 pulse, 'c'(99)->3 nova.
expect(
PluriStationArtFallback.artFor('d'),
'assets/images/station_art_aurora.png',
);
expect(
PluriStationArtFallback.artFor('a'),
'assets/images/station_art_cosmic.png',
);
expect(
PluriStationArtFallback.artFor('b'),
'assets/images/station_art_pulse.png',
);
expect(
PluriStationArtFallback.artFor('c'),
'assets/images/station_art_nova.png',
);
});
test('artFor is deterministic for a given seed (same station always picks '
'the same art)', () {
const seed = 'uuid-1234-abcd-real-looking';
expect(
PluriStationArtFallback.artFor(seed),
PluriStationArtFallback.artFor(seed),
);
});
testWidgets(
'renders the deterministic station-art asset plus a centred player glyph',
(tester) async {
await tester.pumpWidget(
host(const PluriStationArtFallback(seed: 'd', iconSize: 22)),
);
await tester.pump();
// Scoped by asset-name prefix, not `find.byType(Image).first` — the
// player glyph (`PluriIcon`) ALSO renders via its own `Image.asset`
// internally, so a bare type match would be ambiguous.
final arte = tester
.widgetList<Image>(find.byType(Image))
.firstWhere(
(img) =>
img.image is AssetImage &&
(img.image as AssetImage).assetName.startsWith(
'assets/images/station_art_',
),
);
expect(
(arte.image as AssetImage).assetName,
'assets/images/station_art_aurora.png',
);
final icono = tester.widget<PluriIcon>(find.byType(PluriIcon));
expect(icono.glyph, PluriIconGlyph.player);
expect(icono.variant, PluriIconVariant.activeGlow);
expect(icono.size, 22);
},
);
}
@@ -2,6 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/widgets/pluri_station_art_fallback.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -73,6 +74,24 @@ void main() {
expect(tester.getSize(thumbnailClip), const Size(48, 48));
});
testWidgets(
'issue 6 (feedback-pruebas): a station with no favicon renders via the '
'shared PluriStationArtFallback (extracted from this exact fallback)',
(tester) async {
await tester.pumpWidget(
host(
TarjetaEmisora(
emisora: emisoraDemo(uuid: 'sin-arte', nombre: 'Sin Arte'),
esCompacta: true,
),
),
);
await tester.pump();
expect(find.byType(PluriStationArtFallback), findsOneWidget);
},
);
testWidgets('esCompacta shimmer placeholder is a square block, not a '
'circle', (tester) async {
await tester.pumpWidget(