Author SHA1 Message Date
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
FreeTLab 01615f9751 fix(escuchar,reproductor): add the section-heading token and screen 1-3 polish
Audit S7 plus the open items on screens 1-3. Escuchar gains the
prototype's own 52px "now listening" eyebrow header (t4:53), which is
structurally different from the 56px title row every other root uses
(t4:325) -- the earlier wiring test asserted PluriRootHeader on all five
roots, an over-broad premise now corrected to guard what actually holds:
no AppBar, and the sleep-timer action still reachable.
2026-07-30 14:48:06 +02:00
FreeTLab 7ff156852f fix(bienvenida): match body/bullet type sizes and clip the logo mark
Tier 4 visual fidelity, audit 14.4/14.5/14.6/14.8 (t4 lines 693-701):
body copy is 14.5px (was bodyMedium's 14), bullet titles are
13.5px/w800 and subtitles 11.5px (was 14/w800 and 12), the gap between
bullets tightens from 16 to 12, and the 76x76 logo mark is now clipped
to a 20px rounded rect instead of painted square.
2026-07-30 13:02:40 +02:00
FreeTLab 038ec7f3b8 fix(alarma-sonando): pulsing halo, hero-time metrics, art size, snooze icon
Tier 4 visual fidelity, audit 9.1/9.5/9.6/9.8 (t4 lines 411-428):

- 9.1: new _HaloPulsante renders the prototype's 420px amber radial
  gradient behind the hero content. Deliberately BOUNDED (one
  grow-and-settle cycle), not the prototype's literal `infinite` CSS
  animation: this screen's protected dismiss-guard test calls
  pumpAndSettle() after every mount/interaction, and a genuinely
  infinite AnimationController.repeat() would hang it forever with no
  way to fix it. Skips its Animate wrapper under reduced motion, same
  as every other entry animation in this app.
- 9.5: the hero time gets a local letterSpacing/height override
  (-4/0.95) instead of the shared heroTime token's -2.0/1.0 — the
  alarm editor's hour block is the token's other consumer and still
  wants height:1, so the shared style is untouched.
- 9.6: ringing-screen art grows from 168/radiusLg(30) to 180/36 (t4
  line 421), a local constant like the existing stop-button radius.
- 9.8: the POSPONER eyebrow regains its 19px warmCoral snooze icon.

All 5 protected files (including the dismiss-guard test) remain
empty-diff against main.
2026-07-30 12:58:19 +02:00
FreeTLab 8fcb734ab0 fix(alarmas): tighten the gap between stacked alarm cards to 10px
Tier 4 visual fidelity, audit 7.5 (t4 line 334): the prototype stacks
alarm cards with a 10px gap; the build used 12. Each gap is now keyed
per alarm id so a guard test can pin the exact value between two known
cards.
2026-07-30 12:45:41 +02:00
FreeTLab ca2400f0d4 fix(favoritos): restyle the dashed custom-station CTA to spec
Tier 4 visual fidelity, audit 4.5 (t4 line 235): the border and the
label/icon now use the prototype's two DIFFERENT opacities
(rgba(255,255,255,.16) stroke vs rgba(242,247,250,.6) text/icon,
previously one shared 50% colour for both), padding is a uniform 14
instead of symmetric(18,16), and the icon is a plain add glyph at 20px
instead of add_circle_outline_rounded.
2026-07-30 12:40:59 +02:00
FreeTLab 163241d4a1 fix(inicio): add the favourites count badge and plain-text Ver todas
Tier 4 visual fidelity, audit 1.11/1.12 (t4 line 80): a pill badge next
to "Tus emisoras" now shows the TOTAL favorite count (not the 8-capped
grid size), and "Ver todas" is plain 12px/w800 brand-teal text instead
of a Material TextButton with its own padding and splash.

Item 1.4 (hero art radius) was already fixed as a side effect of an
earlier commit — no change needed here.
2026-07-30 12:36:27 +02:00
FreeTLab 56123ea51b fix(ajustes): inset row dividers and shrink icon/title/chevron
Tier 4 visual fidelity, audit S10/10.6/10.8/10.9 (t4 lines 514-516):
GrupoAjustes' row divider now indents 47px instead of running
full-bleed, FilaAjuste's leading icon drops from Material's 24px
default to 21px, the row title is a local 14px override of cardTitle
(14.5), and the chevron shrinks to 19px at 40% opacity instead of the
24px full-opacity default.
2026-07-30 12:32:10 +02:00
FreeTLab 8ecfc928b3 fix(reproductor): move the quality row last and size the play button at 78
Audit 2.4 and 2.6: the quality row sat right after the subtitle, four
positions before where the prototype puts it (t4:114-140), and the play
button was 72 with a 40 icon instead of 78/42 (t4:127).
2026-07-30 12:14:51 +02:00
FreeTLab 3303bd387c fix(paises,buscar): tappable ISO rows and a reconnect card
Item 24 / audit 5.1-5.3, 5.5 (t4:255-269): Paises' "Tus idiomas" was
a Wrap of non-interactive Chips, and the full list was a plain
ListTile with no ISO column and no onTap. Both now share one tappable
row (ISO code, name, station count, chevron); the first "Tus idiomas"
row gets the prototype's teal-tinted highlight. The one production
call site wires the tap to filter Buscar by that country's code and
pop back -- EstadoBusqueda.buscar(pais: ...) already accepts any ISO
alpha-2 code, not just the ~10 presets in the filter sheet.

Item 25 / audit 13.2 (t4:643-646): a reconnect card (rotating ring,
station name, "Reconectando...", a stop affordance) replaces the
complete absence of any reconnect signal outside a word in the mini
player. Ships WITHOUT the prototype's attempt counter: the only live
ControladorReconexion instance is a private field of
PluriWaveAudioHandler inside servicio_audio.dart, a file this task
requires stay byte-identical to main, and nothing else re-exposes it.
Reconstructing a count from estadoStream's reconectando emissions
would not be faithful (the stream can emit it many times per actual
backoff attempt), so that was deliberately not attempted.
2026-07-30 11:48:25 +02:00
FreeTLab 955682271c fix(favoritos,grabaciones): replace glass cards with flat rows
Item 23 / audit 4.3, 12.4 (t4:226-232, 616-619): Favoritos and
Grabaciones rows were full glass cards / ListTiles with two stacked
buttons and no artwork slot. Replace with flat, background-less rows
via a new shared FilaEmisoraPlana widget (square art, name+meta, a
circular play affordance) plus a bespoke Grabaciones row (44x12
placeholder art -- recordings carry no per-station favicon, so this
is a themed fallback, not invented artwork).

Favoritos keeps "Move to list" / "Remove from favorites" behind an
overflow menu (same underlying methods, unchanged) instead of two
always-visible buttons, since dropping either would be a functional
regression the prototype's own row doesn't have to solve for.

Also 12.1 (t4:610): the Grabaciones header action is folder_open, not
a generic gear.
2026-07-30 11:46:55 +02:00
66 changed files with 6249 additions and 735 deletions
+12 -1
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": "الرنين أثناء الإجازات",
@@ -589,6 +593,8 @@
"days": {}
}
},
"alarmRepeatSectionLabel": "تكرار",
"alarmVolumeLabel": "مستوى الصوت",
"androidReliabilityTitle": "مراجعة موثوقية Android",
"closeAction": "إغلاق",
"customOption": "مخصص",
@@ -705,6 +711,7 @@
"alarmVolumeRisingStatus": "رفع مستوى الصوت تدريجيًا",
"countriesAllTitle": "كل الدول",
"countriesScreenTitle": "الدول",
"countriesSearchHint": "الدولة أو الرمز...",
"countriesYourLanguagesTitle": "لغاتك",
"customStationsAddCta": "إضافة محطة مخصصة",
"equalizerActiveOutputDefault": "مكبر صوت هذا الجهاز",
@@ -746,6 +753,7 @@
"recordingRenameEmptyError": "أدخل اسمًا",
"recordingRenameLabel": "الاسم",
"recordingsLibraryEmptySubtitle": "التسجيلات التي تحفظها ستظهر هنا.",
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · يتم حذف الأقدم عند بلوغ الحد",
"recordingsLibraryEmptyTitle": "لا توجد تسجيلات بعد",
"recordingsLibrarySettingsTooltip": "إعدادات التسجيل",
"recordingsLibraryStorageCaption": "تم استخدام {usedMb} ميغابايت من أصل {totalMb} ميغابايت",
@@ -780,6 +788,7 @@
"times": {}
}
},
"vacationExplainerBanner": "خلال هذه الفترات، لن تُصدر المنبهات المُعلَّمة بـ «إيقاف أثناء الإجازة» صوتًا. أما المُعلَّمة بـ «تشغيل دائمًا» فلن تتأثر.",
"vacationNoActiveRangeHint": "لا توجد فترة إجازة نشطة الآن.",
"vacationPastSectionTitle": "الفترات السابقة",
"vacationRangesCount": "{count} فترة",
@@ -816,5 +825,7 @@
"welcomeBullet3Title": "منبهات موسيقية",
"welcomeCtaLabel": "ابدأ الاستماع",
"welcomeHeadline": "عالمك، على الهواء مباشرة",
"yourStationsTitle": "محطاتك"
"yourStationsTitle": "محطاتك",
"nowListeningLabel": "الاستماع الآن",
"popularNowTitle": "الأكثر شيوعًا الآن"
}
+12 -1
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": "ছুটিতে বাজান",
@@ -589,6 +593,8 @@
"days": {}
}
},
"alarmRepeatSectionLabel": "পুনরাবৃত্তি",
"alarmVolumeLabel": "ভলিউম",
"androidReliabilityTitle": "Android নির্ভরযোগ্যতা দেখুন",
"closeAction": "বন্ধ করুন",
"customOption": "কাস্টম",
@@ -705,6 +711,7 @@
"alarmVolumeRisingStatus": "ভলিউম বাড়ছে",
"countriesAllTitle": "সব দেশ",
"countriesScreenTitle": "দেশসমূহ",
"countriesSearchHint": "দেশ বা কোড...",
"countriesYourLanguagesTitle": "আপনার ভাষাসমূহ",
"customStationsAddCta": "নিজস্ব স্টেশন যোগ করুন",
"equalizerActiveOutputDefault": "এই ডিভাইসের স্পিকার",
@@ -746,6 +753,7 @@
"recordingRenameEmptyError": "একটি নাম লিখুন",
"recordingRenameLabel": "নাম",
"recordingsLibraryEmptySubtitle": "আপনি যেসব রেকর্ডিং সংরক্ষণ করবেন তা এখানে দেখা যাবে।",
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · সীমা পৌঁছালে সবচেয়ে পুরনোগুলো মুছে যায়",
"recordingsLibraryEmptyTitle": "এখনও কোনো রেকর্ডিং নেই",
"recordingsLibrarySettingsTooltip": "রেকর্ডিং সেটিংস",
"recordingsLibraryStorageCaption": "{totalMb} MB-এর মধ্যে {usedMb} MB ব্যবহৃত",
@@ -780,6 +788,7 @@
"times": {}
}
},
"vacationExplainerBanner": "এই সময়কালে \"ছুটিতে বিরতি\" হিসেবে চিহ্নিত অ্যালার্মগুলো বাজবে না। \"সবসময় বাজবে\" হিসেবে চিহ্নিত অ্যালার্মগুলো প্রভাবিত হবে না।",
"vacationNoActiveRangeHint": "এই মুহূর্তে কোনো সক্রিয় ছুটির সময়সীমা নেই।",
"vacationPastSectionTitle": "অতীত সময়সীমা",
"vacationRangesCount": "{count}টি সময়সীমা",
@@ -816,5 +825,7 @@
"welcomeBullet3Title": "সুরেলা অ্যালার্ম",
"welcomeCtaLabel": "শোনা শুরু করুন",
"welcomeHeadline": "আপনার বিশ্ব, সরাসরি",
"yourStationsTitle": "আপনার স্টেশন"
"yourStationsTitle": "আপনার স্টেশন",
"nowListeningLabel": "এখন শোনা হচ্ছে",
"popularNowTitle": "এখন জনপ্রিয়"
}
+12 -1
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",
@@ -589,6 +593,8 @@
"days": {}
}
},
"alarmRepeatSectionLabel": "WIEDERHOLEN",
"alarmVolumeLabel": "Lautstärke",
"androidReliabilityTitle": "Android-Zuverlässigkeit prüfen",
"closeAction": "Schließen",
"customOption": "Benutzerdefiniert",
@@ -705,6 +711,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 +753,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 +788,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",
@@ -816,5 +825,7 @@
"welcomeBullet3Title": "Musikalarme",
"welcomeCtaLabel": "Jetzt hören",
"welcomeHeadline": "Deine Welt, live",
"yourStationsTitle": "Deine Sender"
"yourStationsTitle": "Deine Sender",
"nowListeningLabel": "Läuft gerade",
"popularNowTitle": "Jetzt beliebt"
}
+11
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.",
@@ -358,6 +363,7 @@
"yourStationsTitle": "Your stations",
"seeAllAction": "See all",
"openFullPlayerTooltip": "Open full player",
"nowListeningLabel": "Now listening",
"nothingPlayingTitle": "Nothing playing yet",
"nothingPlayingSubtitle": "Pick a station from Your stations or search to start.",
"nearYou": "Near you",
@@ -375,6 +381,7 @@
"exploreTrendingSubtitle": "Today",
"exploreNewTitle": "New",
"exploreNewSubtitle": "This week",
"popularNowTitle": "Popular now",
"offlineBannerTitle": "No connection",
"retryAction": "Retry",
"noStationsAvailable": "No stations available",
@@ -507,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",
@@ -568,6 +576,7 @@
"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",
@@ -696,6 +705,8 @@
"days": {}
}
},
"alarmRepeatSectionLabel": "REPEAT",
"alarmVolumeLabel": "Volume",
"androidReliabilityTitle": "Review Android reliability",
"closeAction": "Close",
"customOption": "Custom",
+11
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.",
@@ -358,6 +363,7 @@
"yourStationsTitle": "Tus emisoras",
"seeAllAction": "Ver todas",
"openFullPlayerTooltip": "Abrir reproductor completo",
"nowListeningLabel": "Escuchando ahora",
"nothingPlayingTitle": "Todavía no estás escuchando nada",
"nothingPlayingSubtitle": "Elegí una emisora de Tus emisoras o buscá una para empezar.",
"nearYou": "Cerca de vos",
@@ -375,6 +381,7 @@
"exploreTrendingSubtitle": "Hoy",
"exploreNewTitle": "Novedades",
"exploreNewSubtitle": "Esta semana",
"popularNowTitle": "Populares ahora",
"offlineBannerTitle": "Sin conexión",
"retryAction": "Reintentar",
"noStationsAvailable": "No hay emisoras disponibles",
@@ -507,6 +514,7 @@
}
}
},
"alarmSnoozeUsualLabel": "habitual",
"saveFavoritesAlarmHint": "Guardá emisoras en Favoritos para usarlas como alarma musical.",
"useCurrentStationAction": "Usar emisora actual",
"playDuringVacations": "Sonar durante vacaciones",
@@ -568,6 +576,7 @@
"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",
@@ -652,6 +661,8 @@
"@alarmScheduleOnce": {"placeholders": {"date": {}}},
"alarmScheduleWeekdays": "Días: {days}",
"@alarmScheduleWeekdays": {"placeholders": {"days": {}}},
"alarmRepeatSectionLabel": "REPETIR",
"alarmVolumeLabel": "Volumen",
"androidReliabilityTitle": "Revisar fiabilidad Android",
"closeAction": "Cerrar",
"customOption": "Personalizada",
+12 -1
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",
@@ -589,6 +593,8 @@
"days": {}
}
},
"alarmRepeatSectionLabel": "RÉPÉTER",
"alarmVolumeLabel": "Volume",
"androidReliabilityTitle": "Vérifier la fiabilité Android",
"closeAction": "Fermer",
"customOption": "Personnalisée",
@@ -705,6 +711,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 +753,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 +788,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",
@@ -816,5 +825,7 @@
"welcomeBullet3Title": "Alarmes musicales",
"welcomeCtaLabel": "Commencer à écouter",
"welcomeHeadline": "Votre monde, en direct",
"yourStationsTitle": "Vos stations"
"yourStationsTitle": "Vos stations",
"nowListeningLabel": "En cours d'écoute",
"popularNowTitle": "Populaire maintenant"
}
+12 -1
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": "छुट्टियों में बजाएँ",
@@ -589,6 +593,8 @@
"days": {}
}
},
"alarmRepeatSectionLabel": "दोहराएं",
"alarmVolumeLabel": "आवाज़",
"androidReliabilityTitle": "Android विश्वसनीयता जाँचें",
"closeAction": "बंद करें",
"customOption": "कस्टम",
@@ -705,6 +711,7 @@
"alarmVolumeRisingStatus": "आवाज़ बढ़ाई जा रही है",
"countriesAllTitle": "सभी देश",
"countriesScreenTitle": "देश",
"countriesSearchHint": "देश या कोड...",
"countriesYourLanguagesTitle": "आपकी भाषाएं",
"customStationsAddCta": "मनचाहा स्टेशन जोड़ें",
"equalizerActiveOutputDefault": "इस डिवाइस का स्पीकर",
@@ -746,6 +753,7 @@
"recordingRenameEmptyError": "एक नाम डालें",
"recordingRenameLabel": "नाम",
"recordingsLibraryEmptySubtitle": "आप जो रिकॉर्डिंग सहेजेंगे, वे यहां दिखाई देंगी।",
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · सीमा पूरी होने पर सबसे पुरानी रिकॉर्डिंग हटा दी जाती हैं",
"recordingsLibraryEmptyTitle": "अभी तक कोई रिकॉर्डिंग नहीं",
"recordingsLibrarySettingsTooltip": "रिकॉर्डिंग सेटिंग्स",
"recordingsLibraryStorageCaption": "{totalMb} MB में से {usedMb} MB इस्तेमाल हुआ",
@@ -780,6 +788,7 @@
"times": {}
}
},
"vacationExplainerBanner": "इन अवधियों के दौरान \"छुट्टी में रोकें\" के रूप में चिह्नित अलार्म नहीं बजेंगे। \"हमेशा बजाएं\" के रूप में चिह्नित अलार्म पर कोई असर नहीं पड़ता।",
"vacationNoActiveRangeHint": "अभी कोई सक्रिय छुट्टी अवधि नहीं है।",
"vacationPastSectionTitle": "पिछली अवधियां",
"vacationRangesCount": "{count} अवधियां",
@@ -816,5 +825,7 @@
"welcomeBullet3Title": "संगीत अलार्म",
"welcomeCtaLabel": "सुनना शुरू करें",
"welcomeHeadline": "आपकी दुनिया, लाइव",
"yourStationsTitle": "आपके स्टेशन"
"yourStationsTitle": "आपके स्टेशन",
"nowListeningLabel": "अभी सुन रहे हैं",
"popularNowTitle": "अभी लोकप्रिय"
}
+12 -1
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",
@@ -589,6 +593,8 @@
"days": {}
}
},
"alarmRepeatSectionLabel": "ULANGI",
"alarmVolumeLabel": "Volume",
"androidReliabilityTitle": "Tinjau keandalan Android",
"closeAction": "Tutup",
"customOption": "Kustom",
@@ -705,6 +711,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 +753,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 +788,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",
@@ -816,5 +825,7 @@
"welcomeBullet3Title": "Alarm musik",
"welcomeCtaLabel": "Mulai mendengarkan",
"welcomeHeadline": "Duniamu, secara langsung",
"yourStationsTitle": "Stasiun Anda"
"yourStationsTitle": "Stasiun Anda",
"nowListeningLabel": "Sedang mendengarkan",
"popularNowTitle": "Populer sekarang"
}
+12 -1
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",
@@ -589,6 +593,8 @@
"days": {}
}
},
"alarmRepeatSectionLabel": "RIPETI",
"alarmVolumeLabel": "Volume",
"androidReliabilityTitle": "Controlla affidabilità Android",
"closeAction": "Chiudi",
"customOption": "Personalizzata",
@@ -705,6 +711,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 +753,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 +788,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",
@@ -816,5 +825,7 @@
"welcomeBullet3Title": "Sveglie musicali",
"welcomeCtaLabel": "Inizia ad ascoltare",
"welcomeHeadline": "Il tuo mondo, in diretta",
"yourStationsTitle": "Le tue emittenti"
"yourStationsTitle": "Le tue emittenti",
"nowListeningLabel": "In ascolto ora",
"popularNowTitle": "Popolari ora"
}
+12 -1
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": "休暇中も鳴らす",
@@ -589,6 +593,8 @@
"days": {}
}
},
"alarmRepeatSectionLabel": "繰り返し",
"alarmVolumeLabel": "音量",
"androidReliabilityTitle": "Androidの信頼性を確認",
"closeAction": "閉じる",
"customOption": "カスタム",
@@ -705,6 +711,7 @@
"alarmVolumeRisingStatus": "音量を上げています",
"countriesAllTitle": "すべての国",
"countriesScreenTitle": "国",
"countriesSearchHint": "国名またはコード...",
"countriesYourLanguagesTitle": "あなたの言語",
"customStationsAddCta": "カスタム局を追加",
"equalizerActiveOutputDefault": "この端末のスピーカー",
@@ -746,6 +753,7 @@
"recordingRenameEmptyError": "名前を入力してください",
"recordingRenameLabel": "名前",
"recordingsLibraryEmptySubtitle": "保存した録音はここに表示されます。",
"recordingsLibraryStorageFolderCaption": "Music/PluriWave・上限に達すると古い順に削除されます",
"recordingsLibraryEmptyTitle": "まだ録音がありません",
"recordingsLibrarySettingsTooltip": "録音設定",
"recordingsLibraryStorageCaption": "{totalMb} MB 中 {usedMb} MB 使用済み",
@@ -780,6 +788,7 @@
"times": {}
}
},
"vacationExplainerBanner": "この期間中は「休暇中は一時停止」に設定したアラームは鳴りません。「常に鳴らす」に設定したアラームには影響しません。",
"vacationNoActiveRangeHint": "現在アクティブな休暇期間はありません。",
"vacationPastSectionTitle": "過去の期間",
"vacationRangesCount": "{count} 件の期間",
@@ -816,5 +825,7 @@
"welcomeBullet3Title": "音楽アラーム",
"welcomeCtaLabel": "聴き始める",
"welcomeHeadline": "あなたの世界を、ライブで",
"yourStationsTitle": "あなたの局"
"yourStationsTitle": "あなたの局",
"nowListeningLabel": "再生中",
"popularNowTitle": "今人気"
}
+12 -1
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",
@@ -589,6 +593,8 @@
"days": {}
}
},
"alarmRepeatSectionLabel": "REPETIÇÃO",
"alarmVolumeLabel": "Volume",
"androidReliabilityTitle": "Revisar confiabilidade Android",
"closeAction": "Fechar",
"customOption": "Personalizada",
@@ -705,6 +711,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 +753,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 +788,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",
@@ -816,5 +825,7 @@
"welcomeBullet3Title": "Alarmes musicais",
"welcomeCtaLabel": "Começar a ouvir",
"welcomeHeadline": "Seu mundo, ao vivo",
"yourStationsTitle": "Suas estações"
"yourStationsTitle": "Suas estações",
"nowListeningLabel": "Ouvindo agora",
"popularNowTitle": "Populares agora"
}
+12 -1
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": "Звонить во время отпусков",
@@ -589,6 +593,8 @@
"days": {}
}
},
"alarmRepeatSectionLabel": "ПОВТОР",
"alarmVolumeLabel": "Громкость",
"androidReliabilityTitle": "Проверить надёжность Android",
"closeAction": "Закрыть",
"customOption": "Своя",
@@ -705,6 +711,7 @@
"alarmVolumeRisingStatus": "Громкость нарастает",
"countriesAllTitle": "Все страны",
"countriesScreenTitle": "Страны",
"countriesSearchHint": "Страна или код...",
"countriesYourLanguagesTitle": "Ваши языки",
"customStationsAddCta": "Добавить пользовательскую станцию",
"equalizerActiveOutputDefault": "Динамик этого устройства",
@@ -746,6 +753,7 @@
"recordingRenameEmptyError": "Введите название",
"recordingRenameLabel": "Название",
"recordingsLibraryEmptySubtitle": "Записи, которые вы сохраните, появятся здесь.",
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · при достижении лимита удаляются старые записи",
"recordingsLibraryEmptyTitle": "Записей пока нет",
"recordingsLibrarySettingsTooltip": "Настройки записи",
"recordingsLibraryStorageCaption": "Использовано {usedMb} МБ из {totalMb} МБ",
@@ -780,6 +788,7 @@
"times": {}
}
},
"vacationExplainerBanner": "В эти периоды будильники с пометкой «пауза на время отпуска» не звонят. Будильники с пометкой «звонить всегда» это не затрагивает.",
"vacationNoActiveRangeHint": "Сейчас нет активного периода отпуска.",
"vacationPastSectionTitle": "Прошедшие периоды",
"vacationRangesCount": "{count} периодов",
@@ -816,5 +825,7 @@
"welcomeBullet3Title": "Музыкальные будильники",
"welcomeCtaLabel": "Начать слушать",
"welcomeHeadline": "Ваш мир, в прямом эфире",
"yourStationsTitle": "Ваши станции"
"yourStationsTitle": "Ваши станции",
"nowListeningLabel": "Сейчас слушаете",
"popularNowTitle": "Популярно сейчас"
}
+12 -1
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": "假期期间响铃",
@@ -589,6 +593,8 @@
"days": {}
}
},
"alarmRepeatSectionLabel": "重复",
"alarmVolumeLabel": "音量",
"androidReliabilityTitle": "检查 Android 可靠性",
"closeAction": "关闭",
"customOption": "自定义",
@@ -705,6 +711,7 @@
"alarmVolumeRisingStatus": "音量渐强中",
"countriesAllTitle": "所有国家",
"countriesScreenTitle": "国家",
"countriesSearchHint": "国家或代码...",
"countriesYourLanguagesTitle": "你的语言",
"customStationsAddCta": "添加自定义电台",
"equalizerActiveOutputDefault": "此设备的扬声器",
@@ -746,6 +753,7 @@
"recordingRenameEmptyError": "请输入名称",
"recordingRenameLabel": "名称",
"recordingsLibraryEmptySubtitle": "你保存的录音会显示在这里。",
"recordingsLibraryStorageFolderCaption": "Music/PluriWave · 达到上限时会删除最旧的文件",
"recordingsLibraryEmptyTitle": "暂无录音",
"recordingsLibrarySettingsTooltip": "录音设置",
"recordingsLibraryStorageCaption": "已使用 {usedMb} MB / 共 {totalMb} MB",
@@ -780,6 +788,7 @@
"times": {}
}
},
"vacationExplainerBanner": "在这些时间段内,标记为“假期暂停”的闹钟不会响铃;标记为“始终响铃”的闹钟不受影响。",
"vacationNoActiveRangeHint": "目前没有正在生效的假期时间段。",
"vacationPastSectionTitle": "过往时间段",
"vacationRangesCount": "{count} 个时间段",
@@ -816,5 +825,7 @@
"welcomeBullet3Title": "音乐闹钟",
"welcomeCtaLabel": "开始收听",
"welcomeHeadline": "你的世界,直播中",
"yourStationsTitle": "你的电台"
"yourStationsTitle": "你的电台",
"nowListeningLabel": "正在收听",
"popularNowTitle": "当前热门"
}
+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';
}
+60
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:
@@ -1402,6 +1426,12 @@ abstract class AppLocalizations {
/// **'Abrir reproductor completo'**
String get openFullPlayerTooltip;
/// No description provided for @nowListeningLabel.
///
/// In es, this message translates to:
/// **'Escuchando ahora'**
String get nowListeningLabel;
/// No description provided for @nothingPlayingTitle.
///
/// In es, this message translates to:
@@ -1474,6 +1504,12 @@ abstract class AppLocalizations {
/// **'Esta semana'**
String get exploreNewSubtitle;
/// No description provided for @popularNowTitle.
///
/// In es, this message translates to:
/// **'Populares ahora'**
String get popularNowTitle;
/// No description provided for @offlineBannerTitle.
///
/// In es, this message translates to:
@@ -1870,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:
@@ -2012,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:
@@ -2450,6 +2498,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:
+34
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 => 'لغاتك';
@@ -745,6 +760,9 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get openFullPlayerTooltip => 'فتح المشغل الكامل';
@override
String get nowListeningLabel => 'الاستماع الآن';
@override
String get nothingPlayingTitle => 'لا تستمع إلى شيء بعد';
@@ -784,6 +802,9 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get exploreNewSubtitle => 'هذا الأسبوع';
@override
String get popularNowTitle => 'الأكثر شيوعًا الآن';
@override
String get offlineBannerTitle => 'لا يوجد اتصال';
@@ -1008,6 +1029,9 @@ class AppLocalizationsAr extends AppLocalizations {
return '$minutes د';
}
@override
String get alarmSnoozeUsualLabel => 'المعتاد';
@override
String get saveFavoritesAlarmHint =>
'احفظ محطات في المفضلة لاستخدامها كمنبه موسيقي.';
@@ -1096,6 +1120,10 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get addVacationRangeCta => 'إضافة فترة';
@override
String get vacationExplainerBanner =>
'خلال هذه الفترات، لن تُصدر المنبهات المُعلَّمة بـ «إيقاف أثناء الإجازة» صوتًا. أما المُعلَّمة بـ «تشغيل دائمًا» فلن تتأثر.';
@override
String get vacationNoActiveRangeHint => 'لا توجد فترة إجازة نشطة الآن.';
@@ -1345,6 +1373,12 @@ class AppLocalizationsAr extends AppLocalizations {
return 'الأيام: $days';
}
@override
String get alarmRepeatSectionLabel => 'تكرار';
@override
String get alarmVolumeLabel => 'مستوى الصوت';
@override
String get androidReliabilityTitle => 'مراجعة موثوقية Android';
+34
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 => 'আপনার ভাষাসমূহ';
@@ -748,6 +763,9 @@ class AppLocalizationsBn extends AppLocalizations {
@override
String get openFullPlayerTooltip => 'সম্পূর্ণ প্লেয়ার খুলুন';
@override
String get nowListeningLabel => 'এখন শোনা হচ্ছে';
@override
String get nothingPlayingTitle => 'এখনও কিছু চলছে না';
@@ -787,6 +805,9 @@ class AppLocalizationsBn extends AppLocalizations {
@override
String get exploreNewSubtitle => 'এই সপ্তাহে';
@override
String get popularNowTitle => 'এখন জনপ্রিয়';
@override
String get offlineBannerTitle => 'সংযোগ নেই';
@@ -1013,6 +1034,9 @@ class AppLocalizationsBn extends AppLocalizations {
return '$minutes মিনিট';
}
@override
String get alarmSnoozeUsualLabel => 'সাধারণ';
@override
String get saveFavoritesAlarmHint =>
'সুরেলা অ্যালার্ম হিসেবে ব্যবহার করতে প্রিয়তে স্টেশন সংরক্ষণ করুন।';
@@ -1102,6 +1126,10 @@ class AppLocalizationsBn extends AppLocalizations {
@override
String get addVacationRangeCta => 'সময়সীমা যোগ করুন';
@override
String get vacationExplainerBanner =>
'এই সময়কালে \"ছুটিতে বিরতি\" হিসেবে চিহ্নিত অ্যালার্মগুলো বাজবে না। \"সবসময় বাজবে\" হিসেবে চিহ্নিত অ্যালার্মগুলো প্রভাবিত হবে না।';
@override
String get vacationNoActiveRangeHint =>
'এই মুহূর্তে কোনো সক্রিয় ছুটির সময়সীমা নেই।';
@@ -1354,6 +1382,12 @@ class AppLocalizationsBn extends AppLocalizations {
return 'দিন: $days';
}
@override
String get alarmRepeatSectionLabel => 'পুনরাবৃত্তি';
@override
String get alarmVolumeLabel => 'ভলিউম';
@override
String get androidReliabilityTitle => 'Android নির্ভরযোগ্যতা দেখুন';
+34
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';
@@ -751,6 +766,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get openFullPlayerTooltip => 'Vollständigen Player öffnen';
@override
String get nowListeningLabel => 'Läuft gerade';
@override
String get nothingPlayingTitle => 'Es läuft noch nichts';
@@ -790,6 +808,9 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get exploreNewSubtitle => 'Diese Woche';
@override
String get popularNowTitle => 'Jetzt beliebt';
@override
String get offlineBannerTitle => 'Keine Verbindung';
@@ -1016,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.';
@@ -1104,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.';
@@ -1362,6 +1390,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';
+34
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';
@@ -744,6 +759,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get openFullPlayerTooltip => 'Open full player';
@override
String get nowListeningLabel => 'Now listening';
@override
String get nothingPlayingTitle => 'Nothing playing yet';
@@ -783,6 +801,9 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get exploreNewSubtitle => 'This week';
@override
String get popularNowTitle => 'Popular now';
@override
String get offlineBannerTitle => 'No connection';
@@ -1008,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.';
@@ -1096,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.';
@@ -1346,6 +1374,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';
+34
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';
@@ -749,6 +764,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get openFullPlayerTooltip => 'Abrir reproductor completo';
@override
String get nowListeningLabel => 'Escuchando ahora';
@override
String get nothingPlayingTitle => 'Todavía no estás escuchando nada';
@@ -788,6 +806,9 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get exploreNewSubtitle => 'Esta semana';
@override
String get popularNowTitle => 'Populares ahora';
@override
String get offlineBannerTitle => 'Sin conexión';
@@ -1013,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.';
@@ -1101,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.';
@@ -1356,6 +1384,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';
+34
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';
@@ -753,6 +768,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get openFullPlayerTooltip => 'Ouvrir le lecteur complet';
@override
String get nowListeningLabel => 'En cours d\'écoute';
@override
String get nothingPlayingTitle => 'Rien n\'est en cours de lecture';
@@ -792,6 +810,9 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get exploreNewSubtitle => 'Cette semaine';
@override
String get popularNowTitle => 'Populaire maintenant';
@override
String get offlineBannerTitle => 'Aucune connexion';
@@ -1018,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.';
@@ -1107,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.';
@@ -1366,6 +1394,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';
+34
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 => 'आपकी भाषाएं';
@@ -745,6 +760,9 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get openFullPlayerTooltip => 'पूरा प्लेयर खोलें';
@override
String get nowListeningLabel => 'अभी सुन रहे हैं';
@override
String get nothingPlayingTitle => 'अभी तक कुछ नहीं चल रहा';
@@ -784,6 +802,9 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get exploreNewSubtitle => 'इस सप्ताह';
@override
String get popularNowTitle => 'अभी लोकप्रिय';
@override
String get offlineBannerTitle => 'कोई कनेक्शन नहीं';
@@ -1009,6 +1030,9 @@ class AppLocalizationsHi extends AppLocalizations {
return '$minutes मिनट';
}
@override
String get alarmSnoozeUsualLabel => 'सामान्य';
@override
String get saveFavoritesAlarmHint =>
'उन्हें संगीतमय अलार्म के रूप में इस्तेमाल करने के लिए स्टेशन पसंदीदा में सहेजें।';
@@ -1097,6 +1121,10 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get addVacationRangeCta => 'अवधि जोड़ें';
@override
String get vacationExplainerBanner =>
'इन अवधियों के दौरान \"छुट्टी में रोकें\" के रूप में चिह्नित अलार्म नहीं बजेंगे। \"हमेशा बजाएं\" के रूप में चिह्नित अलार्म पर कोई असर नहीं पड़ता।';
@override
String get vacationNoActiveRangeHint => 'अभी कोई सक्रिय छुट्टी अवधि नहीं है।';
@@ -1349,6 +1377,12 @@ class AppLocalizationsHi extends AppLocalizations {
return 'दिन: $days';
}
@override
String get alarmRepeatSectionLabel => 'दोहराएं';
@override
String get alarmVolumeLabel => 'आवाज़';
@override
String get androidReliabilityTitle => 'Android विश्वसनीयता जाँचें';
+34
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';
@@ -745,6 +760,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get openFullPlayerTooltip => 'Buka pemutar lengkap';
@override
String get nowListeningLabel => 'Sedang mendengarkan';
@override
String get nothingPlayingTitle => 'Belum ada yang diputar';
@@ -784,6 +802,9 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get exploreNewSubtitle => 'Minggu ini';
@override
String get popularNowTitle => 'Populer sekarang';
@override
String get offlineBannerTitle => 'Tidak ada koneksi';
@@ -1012,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.';
@@ -1101,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.';
@@ -1357,6 +1385,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';
+34
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';
@@ -751,6 +766,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get openFullPlayerTooltip => 'Apri il player completo';
@override
String get nowListeningLabel => 'In ascolto ora';
@override
String get nothingPlayingTitle => 'Non stai ancora ascoltando nulla';
@@ -790,6 +808,9 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get exploreNewSubtitle => 'Questa settimana';
@override
String get popularNowTitle => 'Popolari ora';
@override
String get offlineBannerTitle => 'Nessuna connessione';
@@ -1017,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.';
@@ -1106,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.';
@@ -1365,6 +1393,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';
+34
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 => 'あなたの言語';
@@ -720,6 +735,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get openFullPlayerTooltip => 'フルプレーヤーを開く';
@override
String get nowListeningLabel => '再生中';
@override
String get nothingPlayingTitle => 'まだ何も再生されていません';
@@ -758,6 +776,9 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get exploreNewSubtitle => '今週';
@override
String get popularNowTitle => '今人気';
@override
String get offlineBannerTitle => '接続なし';
@@ -979,6 +1000,9 @@ class AppLocalizationsJa extends AppLocalizations {
return '$minutes分';
}
@override
String get alarmSnoozeUsualLabel => '通常';
@override
String get saveFavoritesAlarmHint => '音楽アラームとして使うには、局をお気に入りに保存してください。';
@@ -1064,6 +1088,10 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get addVacationRangeCta => '期間を追加';
@override
String get vacationExplainerBanner =>
'この期間中は「休暇中は一時停止」に設定したアラームは鳴りません。「常に鳴らす」に設定したアラームには影響しません。';
@override
String get vacationNoActiveRangeHint => '現在アクティブな休暇期間はありません。';
@@ -1306,6 +1334,12 @@ class AppLocalizationsJa extends AppLocalizations {
return '曜日: $days';
}
@override
String get alarmRepeatSectionLabel => '繰り返し';
@override
String get alarmVolumeLabel => '音量';
@override
String get androidReliabilityTitle => 'Androidの信頼性を確認';
+34
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';
@@ -748,6 +763,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get openFullPlayerTooltip => 'Abrir player completo';
@override
String get nowListeningLabel => 'Ouvindo agora';
@override
String get nothingPlayingTitle => 'Ainda não há nada tocando';
@@ -787,6 +805,9 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get exploreNewSubtitle => 'Nesta semana';
@override
String get popularNowTitle => 'Populares agora';
@override
String get offlineBannerTitle => 'Sem conexão';
@@ -1012,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.';
@@ -1100,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.';
@@ -1354,6 +1382,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';
+34
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 => 'Ваши языки';
@@ -749,6 +764,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get openFullPlayerTooltip => 'Открыть полный плеер';
@override
String get nowListeningLabel => 'Сейчас слушаете';
@override
String get nothingPlayingTitle => 'Пока ничего не воспроизводится';
@@ -788,6 +806,9 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get exploreNewSubtitle => 'На этой неделе';
@override
String get popularNowTitle => 'Популярно сейчас';
@override
String get offlineBannerTitle => 'Нет подключения';
@@ -1014,6 +1035,9 @@ class AppLocalizationsRu extends AppLocalizations {
return '$minutes мин';
}
@override
String get alarmSnoozeUsualLabel => 'обычно';
@override
String get saveFavoritesAlarmHint =>
'Сохраните станции в избранное, чтобы использовать их как музыкальный будильник.';
@@ -1102,6 +1126,10 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get addVacationRangeCta => 'Добавить период';
@override
String get vacationExplainerBanner =>
'В эти периоды будильники с пометкой «пауза на время отпуска» не звонят. Будильники с пометкой «звонить всегда» это не затрагивает.';
@override
String get vacationNoActiveRangeHint =>
'Сейчас нет активного периода отпуска.';
@@ -1358,6 +1386,12 @@ class AppLocalizationsRu extends AppLocalizations {
return 'Дни: $days';
}
@override
String get alarmRepeatSectionLabel => 'ПОВТОР';
@override
String get alarmVolumeLabel => 'Громкость';
@override
String get androidReliabilityTitle => 'Проверить надёжность Android';
+34
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 => '你的语言';
@@ -717,6 +732,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get openFullPlayerTooltip => '打开完整播放器';
@override
String get nowListeningLabel => '正在收听';
@override
String get nothingPlayingTitle => '目前没有正在播放的内容';
@@ -755,6 +773,9 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get exploreNewSubtitle => '本周';
@override
String get popularNowTitle => '当前热门';
@override
String get offlineBannerTitle => '无连接';
@@ -975,6 +996,9 @@ class AppLocalizationsZh extends AppLocalizations {
return '$minutes 分钟';
}
@override
String get alarmSnoozeUsualLabel => '常用';
@override
String get saveFavoritesAlarmHint => '将电台保存到收藏,即可把它们用作音乐闹钟。';
@@ -1060,6 +1084,10 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get addVacationRangeCta => '添加时间段';
@override
String get vacationExplainerBanner =>
'在这些时间段内,标记为“假期暂停”的闹钟不会响铃;标记为“始终响铃”的闹钟不受影响。';
@override
String get vacationNoActiveRangeHint => '目前没有正在生效的假期时间段。';
@@ -1301,6 +1329,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(
+55 -15
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,18 +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++) ...[
if (i > 0) const Divider(height: 1),
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],
],
],
),
),
],
);
}
}
@@ -54,6 +73,7 @@ class FilaAjuste extends StatelessWidget {
required this.titulo,
required this.onTap,
this.valor,
this.iconColor,
});
final IconData icon;
@@ -66,14 +86,27 @@ 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;
@override
Widget build(BuildContext context) {
final type = context.pluriType;
final valorActual = valor;
return ListTile(
contentPadding: EdgeInsets.zero,
leading: Icon(icon),
title: Text(titulo, style: type.cardTitle),
// 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, 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)),
trailing: Row(
mainAxisSize: MainAxisSize.min,
children: [
@@ -88,7 +121,14 @@ class FilaAjuste extends StatelessWidget {
),
const SizedBox(width: 6),
],
const Icon(Icons.chevron_right_rounded),
// 10.9 (Tier 4 visual fidelity): the prototype's chevron is 19px
// at 40% opacity (t4 lines 515-539), not Material's 24px
// full-opacity default.
Icon(
Icons.chevron_right_rounded,
size: 19,
color: const Color(0xFFF2F7FA).withValues(alpha: 0.4),
),
],
),
onTap: onTap,
+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:
+157 -10
View File
@@ -3,6 +3,7 @@ import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_animate/flutter_animate.dart';
import 'package:provider/provider.dart';
import '../estado/estado_alarmas.dart';
@@ -199,6 +200,17 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
fit: StackFit.expand,
children: [
_FondoArteDifuminado(tokens: tokens),
// Audit 9.1 (t4 line 411): the pulsing amber halo behind the
// hero content — see _HaloPulsante for why it is a BOUNDED pulse,
// not the prototype's literal `infinite` animation.
Positioned(
top: 150,
left: 0,
right: 0,
child: IgnorePointer(
child: Center(child: _HaloPulsante(tokens: tokens)),
),
),
SafeArea(
child: Padding(
padding: const EdgeInsets.fromLTRB(24, 20, 24, 24),
@@ -213,12 +225,39 @@ 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(
_hora(alarma),
key: const ValueKey('ringing-hero-time'),
style: type.heroTime,
// Audit 9.5 (t4 line 417): 88px/w800/ls-4/lh.95 on
// THIS screen only — a local override, not a change
// to the shared heroTime token (EditorHoraInline, the
// alarm editor's hour block, is the other consumer
// and wants height:1, t4 line 379).
style: type.heroTime.copyWith(
letterSpacing: -4,
height: 0.95,
),
),
),
const SizedBox(height: 6),
@@ -229,11 +268,11 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
),
const SizedBox(height: 22),
ClipRRect(
borderRadius: BorderRadius.circular(tokens.radiusLg),
borderRadius: BorderRadius.circular(_artworkRadio),
child: Image.asset(
'assets/icons/alarmas/alarm_music.png',
width: 168,
height: 168,
width: _artworkLado,
height: _artworkLado,
fit: BoxFit.cover,
errorBuilder:
(_, __, ___) => Icon(
@@ -280,11 +319,24 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
const Spacer(flex: 3),
Align(
alignment: Alignment.centerLeft,
child: Text(
l10n.snoozeAction,
style: type.eyebrowLabel.copyWith(
color: tokens.warmCoral,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
// Audit 9.8 (t4 line 428): the POSPONER eyebrow
// lost its snooze icon.
Icon(
Icons.snooze_rounded,
size: 19,
color: tokens.warmCoral,
),
const SizedBox(width: 8),
Text(
l10n.snoozeAction,
style: type.eyebrowLabel.copyWith(
color: tokens.warmCoral,
),
),
],
),
),
const SizedBox(height: 10),
@@ -377,6 +429,13 @@ String _hora(AlarmaMusical alarma) =>
/// call site (mirrors `_ArteEscuchar._radio` in `pantalla_inicio.dart`).
const _stopButtonRadius = 24.0;
/// Audit 9.6 (t4 line 421): the ringing screen's art is 180x180 with a 36
/// corner radius — neither matches [PluriWaveTokens]'s three named radii
/// (14/18/30), so this stays a local constant (same precedent as
/// [_stopButtonRadius] above).
const _artworkLado = 180.0;
const _artworkRadio = 36.0;
/// Audit 9.3: the schedule pill's text, built only from
/// [AlarmaMusical.tipoProgramacion] and the fields it already carries per
/// case (`diasSemana`, `fechaUnica`) — no new domain plumbing. Reuses the
@@ -458,6 +517,66 @@ class _PildoraProgramacion extends StatelessWidget {
}
}
/// Pulsing amber halo (audit 9.1, t4 line 411): a 420x420 radial gradient
/// centered behind the hero content, echoing the alarm's warmCoral accent.
///
/// BOUNDED, not infinite: the prototype's CSS is `animation: pw-pulse 2.4s
/// ease-in-out infinite`, but this screen's dismiss guard
/// (`pantalla_alarma_sonando_dismiss_guard_test.dart`, protected — must stay
/// byte-identical to `main`) calls `pumpAndSettle()` after every mount and
/// every interaction. A genuinely infinite `AnimationController.repeat()`
/// anywhere in this widget's subtree would hang every one of those calls
/// forever, with no way to fix it since that file cannot be edited (see
/// `_EstadoSubidaVolumen` above for the same reasoning applied earlier on
/// this exact screen). One grow-and-settle cycle, timed to the prototype's
/// own 2.4s cadence, delivers the same "draws the eye" motion without ever
/// leaving a frame scheduled forever. Respects reduced motion exactly like
/// every other entry animation in this app (`PluriAnimate`).
class _HaloPulsante extends StatelessWidget {
const _HaloPulsante({required this.tokens});
final PluriWaveTokens tokens;
static const _lado = 420.0;
@override
Widget build(BuildContext context) {
final halo = Container(
key: const ValueKey('ringing-pulse-halo'),
width: _lado,
height: _lado,
decoration: BoxDecoration(
shape: BoxShape.circle,
gradient: RadialGradient(
colors: [
tokens.warmCoral.withValues(alpha: 0.2),
tokens.warmCoral.withValues(alpha: 0),
],
stops: const [0, 0.62],
),
),
);
if (MediaQuery.maybeDisableAnimationsOf(context) ?? false) {
return halo;
}
return halo
.animate()
.scaleXY(
begin: 1,
end: 1.08,
duration: 1200.ms,
curve: Curves.easeInOut,
)
.then()
.scaleXY(
begin: 1.08,
end: 1,
duration: 1200.ms,
curve: Curves.easeInOut,
);
}
}
/// Full-bleed blurred backdrop (WU11, replaces the `PluriGlassSurface` card
/// container per task 11.3). This app has no per-station artwork/favicon
/// safe to render here: `Emisora.favicon` is a network URL, and rendering
@@ -589,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,
@@ -603,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),
+484 -171
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),
),
],
@@ -63,7 +80,14 @@ class PantallaAlarmas extends StatelessWidget {
else
for (final alarma in estado.alarmas) ...[
_TarjetaAlarma(alarma: alarma),
const SizedBox(height: 12),
// 7.5 (Tier 4 visual fidelity): the prototype's gap
// between stacked alarm cards is 10px (t4 line 334),
// not 12. Keyed per alarm id so the guard test can
// target the exact gap between two known cards.
SizedBox(
height: 10,
key: ValueKey('alarm-card-gap-${alarma.id}'),
),
],
_PanelVacaciones(estado: estado),
const SizedBox(height: 12),
@@ -98,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
@@ -105,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),
),
],
),
),
);
}
@@ -236,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),
],
),
),
@@ -525,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
@@ -566,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),
@@ -656,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,
@@ -911,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),
),
),
),
],
),
);
}
@@ -1082,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,
@@ -1115,6 +1397,10 @@ class _PanelVacaciones extends StatelessWidget {
],
),
),
if (rangoRelevante != null) ...[
const SizedBox(width: 8),
_PildoraFechasVacaciones(rango: rangoRelevante),
],
const Icon(Icons.chevron_right_rounded),
],
),
@@ -1161,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
@@ -1280,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});
@@ -1368,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,
};
}
+44 -14
View File
@@ -71,31 +71,46 @@ class PantallaBienvenida extends StatelessWidget {
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
Image.asset(
'assets/icons/pluriwave_app_mark.png',
key: const ValueKey('welcome-logo'),
width: 76,
height: 76,
errorBuilder:
(_, __, ___) => Icon(
Icons.graphic_eq_rounded,
size: 76,
color: t.electricMagenta,
),
// Audit 14.8 (t4 line 693): the prototype clips
// its logo mark to a 20px rounded rect; the build
// used to paint it square.
ClipRRect(
borderRadius: BorderRadius.circular(20),
child: Image.asset(
'assets/icons/pluriwave_app_mark.png',
key: const ValueKey('welcome-logo'),
width: 76,
height: 76,
errorBuilder:
(_, __, ___) => Icon(
Icons.graphic_eq_rounded,
size: 76,
color: t.electricMagenta,
),
),
),
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),
Text(
l10n.welcomeBody,
// Audit 14.4 (t4 line 697): 14.5px, not
// bodyMedium's 14.
style: theme.textTheme.bodyMedium?.copyWith(
fontSize: 14.5,
color: theme.colorScheme.onSurface.withValues(
alpha: 0.72,
),
@@ -109,14 +124,16 @@ class PantallaBienvenida extends StatelessWidget {
titulo: l10n.welcomeBullet1Title,
subtitulo: l10n.welcomeBullet1Subtitle,
),
const SizedBox(height: 16),
// Audit 14.6 (t4 line 701): 12px between bullets,
// not 16.
const SizedBox(height: 12),
FilaCaracteristicaBienvenida(
icon: Icons.directions_car_rounded,
iconColor: t.liveGreen,
titulo: l10n.welcomeBullet2Title,
subtitulo: l10n.welcomeBullet2Subtitle,
),
const SizedBox(height: 16),
const SizedBox(height: 12),
FilaCaracteristicaBienvenida(
icon: Icons.alarm_rounded,
iconColor: t.warmCoral,
@@ -132,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(
@@ -196,13 +221,18 @@ class FilaCaracteristicaBienvenida extends StatelessWidget {
children: [
Text(
titulo,
// Audit 14.5 (t4 line 700): 13.5px/w800, not bodyMedium's
// 14/w800.
style: theme.textTheme.bodyMedium?.copyWith(
fontSize: 13.5,
fontWeight: FontWeight.w800,
),
),
Text(
subtitulo,
// Audit 14.5 (t4 line 700): 11.5px, not bodySmall's 12.
style: theme.textTheme.bodySmall?.copyWith(
fontSize: 11.5,
color: theme.colorScheme.onSurface.withValues(alpha: 0.58),
),
),
+407 -46
View File
@@ -6,11 +6,14 @@ import 'package:shimmer/shimmer.dart' as shimmer;
import '../estado/estado_busqueda.dart';
import '../estado/estado_radio.dart';
import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../servicios/servicio_audio.dart';
import '../tema/pluri_animate.dart';
import '../tema/pluriwave_theme.dart';
import '../tema/pluriwave_tokens.dart';
import '../widgets/fila_emisora_plana.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart';
import '../widgets/pluri_layout.dart';
@@ -214,6 +217,10 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
theme,
l10n,
),
// Item 25 / audit 13.2 (t4:643-646): the reconnect card -- until
// now the ONLY signal of a reconnect was a word in the mini
// player.
_TarjetaReconectando(estado: context.watch<EstadoRadio>()),
_gridEmisoras(context, l10n),
],
],
@@ -246,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,
@@ -260,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>(
@@ -299,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,
);
}
@@ -514,10 +546,21 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
return Padding(
padding: const EdgeInsets.all(PluriLayout.horizontal),
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),
],
],
),
@@ -528,46 +571,53 @@ 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();
return Padding(
padding: const EdgeInsets.symmetric(horizontal: PluriLayout.horizontal),
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,
),
);
}
final total = resultados.length + (estado.hayMas ? 1 : 0);
return ListView.separated(
// Item 23 / audit 6.6 (t4:299): rows sit back-to-back -- `ListView`, not
// `.separated`, since there is no longer a 10px gap to insert between
// them (the prototype's own results column is a bare `flex-direction:
// column`, no `gap`).
return ListView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: const EdgeInsets.all(PluriLayout.horizontal),
itemCount: total,
separatorBuilder: (_, __) => const SizedBox(height: 10),
itemBuilder: (context, i) {
if (i >= resultados.length) {
if (!estado.cargandoMas) {
@@ -581,10 +631,21 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
if (i >= resultados.length - 5 && estado.hayMas) {
Future<void>.microtask(estado.cargarMas);
}
return TarjetaEmisora(
emisora: resultados[i],
esCompacta: true,
onTap: () => reproducirMinimizado(context, resultados[i]),
final emisora = resultados[i];
// Item 23 / audit 6.5 (t4:302-306): a flat, background-less row --
// square art, name+meta, a favourite toggle, and a circular play
// affordance -- replacing the full glass TarjetaEmisora card.
return FilaEmisoraPlana(
key: ValueKey(emisora.uuid),
emisora: emisora,
meta: _metaResultado(emisora),
onTap: () => reproducirMinimizado(context, emisora),
trailing: [
BotonFavoritoEmisora(emisora: emisora),
BotonReproducirCircular(
onPressed: () => reproducirMinimizado(context, emisora),
),
],
).pluriFadeSlideIn(
context,
delay: Duration(milliseconds: i.clamp(0, 12) * 20),
@@ -886,7 +947,21 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
onTap:
() => PluriPushScaffold.push(
context,
(_) => const PantallaPaises(),
// Item 24 / audit 5.2: tapping a country row
// pops back to Búsqueda and filters results by
// that ISO code -- the SAME `EstadoBusqueda.
// buscar(pais: ...)` filter the picker sheet
// already uses, just with an arbitrary code
// instead of one of this screen's ~10 presets.
(_) => PantallaPaises(
onPaisSeleccionado: (pais) {
Navigator.of(context).pop();
setState(
() => _paisSeleccionado = pais.codigoIso,
);
_buscar();
},
),
),
),
),
@@ -1126,6 +1201,21 @@ class _PantallaBuscarState extends State<PantallaBuscar> {
}
}
/// Item 23 / audit 6.5 (t4:303): "genre - country - kbps" built ONLY from
/// fields [Emisora] already carries -- mirrors the Escuchar hero's own
/// `_metaEscuchar` (audit 1.6, `pantalla_inicio.dart`) exactly, duplicated
/// rather than shared since each screen's meta line is free to evolve
/// independently. Whatever a station lacks is omitted gracefully.
String _metaResultado(Emisora emisora) {
final partes = <String>[
if (emisora.generos.isNotEmpty) emisora.generos.first,
if (emisora.pais != null && emisora.pais!.isNotEmpty) emisora.pais!,
if (emisora.bitrate != null && emisora.bitrate! > 0)
'${emisora.bitrate} kbps',
];
return partes.join(' · ');
}
String _genreName(AppLocalizations l10n, String tag) => switch (tag) {
'pop' => l10n.genrePop,
'rock' => l10n.genreRock,
@@ -1163,6 +1253,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.
@@ -1235,3 +1430,169 @@ class _CeldaExplorarPor extends StatelessWidget {
);
}
}
/// Item 25 / audit 13.2 (t4:643-646): the reconnect card. Renders nothing
/// unless playback is actively reconnecting AND a station is known.
///
/// NO attempt counter: the prototype shows "Reconectando · intento 2 de 5",
/// backed by `ControladorReconexion.intentos`/`.maxReintentos`
/// (`controlador_reconexion.dart:42`/`:33`). That controller instance lives
/// as a PRIVATE field of `PluriWaveAudioHandler` inside
/// `servicio_audio.dart` -- a file this task requires stay byte-identical
/// to `main` -- and nothing else in the app already re-exposes it
/// publicly. Reconstructing a count from the public `estadoStream`'s
/// `reconectando` emissions would not be a faithful proxy (the stream can
/// emit `reconectando` many times per actual backoff attempt), so it was
/// deliberately not attempted: a fabricated number is worse than none.
class _TarjetaReconectando extends StatelessWidget {
const _TarjetaReconectando({required this.estado});
final EstadoRadio estado;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return StreamBuilder<EstadoReproduccion>(
stream: estado.estadoStream,
builder: (context, snapshot) {
final emisora = estado.emisoraActual;
if (snapshot.data != EstadoReproduccion.reconectando ||
emisora == null) {
return const SizedBox.shrink();
}
return Padding(
padding: const EdgeInsets.only(top: 16),
child: _CuerpoTarjetaReconectando(
nombreEstacion: localizedStationName(l10n, emisora.nombre),
etiquetaReconectando: l10n.playbackStatusReconnecting,
tooltipDetener: l10n.stopAction,
onDetener: estado.detenerReproduccion,
),
);
},
);
}
}
class _CuerpoTarjetaReconectando extends StatefulWidget {
const _CuerpoTarjetaReconectando({
required this.nombreEstacion,
required this.etiquetaReconectando,
required this.tooltipDetener,
required this.onDetener,
});
final String nombreEstacion;
final String etiquetaReconectando;
final String tooltipDetener;
final VoidCallback onDetener;
@override
State<_CuerpoTarjetaReconectando> createState() =>
_CuerpoTarjetaReconectandoState();
}
class _CuerpoTarjetaReconectandoState extends State<_CuerpoTarjetaReconectando>
with SingleTickerProviderStateMixin {
// t4:644: `animation:pw-ring .9s linear infinite`.
late final AnimationController _ctrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 900),
)..repeat();
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final onSurface = Theme.of(context).colorScheme.onSurface;
return Container(
key: const ValueKey('tarjeta-reconectando'),
margin: const EdgeInsets.symmetric(horizontal: PluriLayout.horizontal),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(18),
border: Border.all(color: Colors.white.withValues(alpha: 0.2)),
color: Colors.white.withValues(alpha: 0.09),
),
child: Row(
children: [
// t4:644: a 52x52 rotating ring around a radio icon.
SizedBox(
width: 52,
height: 52,
child: Stack(
alignment: Alignment.center,
children: [
RotationTransition(
turns: _ctrl,
child: CircularProgressIndicator(
value: 0.25,
strokeWidth: 3,
backgroundColor: PluriWaveTokens.brand.withValues(
alpha: 0.22,
),
valueColor: AlwaysStoppedAnimation(PluriWaveTokens.brand),
),
),
const Icon(
Icons.radio_rounded,
size: 22,
color: PluriWaveTokens.brand,
),
],
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// t4:645: 15px/w800.
Text(
widget.nombreEstacion,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 3),
// t4:645: 12px/w700/warmCoral (amber).
Text(
widget.etiquetaReconectando,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: PluriWaveTokens.dark.warmCoral,
),
),
],
),
),
// t4:646: stop_circle 24px, neutral (not brand-coloured) -- this
// stops playback outright, unlike the mini player's retry icon.
SizedBox(
width: 44,
height: 44,
child: IconButton(
padding: EdgeInsets.zero,
tooltip: widget.tooltipDetener,
icon: Icon(
Icons.stop_circle_rounded,
size: 24,
color: onSurface.withValues(alpha: 0.6),
),
onPressed: widget.onDetener,
),
),
],
),
);
}
}
+139 -81
View File
@@ -6,13 +6,14 @@ 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';
import '../widgets/pluri_premium_widgets.dart';
import '../widgets/pluri_push_scaffold.dart';
import '../widgets/pluri_root_header.dart';
import '../widgets/pluri_sleep_timer_sheet.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
import 'ajustes/pantalla_ajustes_emisoras_personalizadas.dart';
import 'ajustes/pantalla_ajustes_grupos_favoritos.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,
),
],
),
);
@@ -399,45 +424,65 @@ class _FilaFavorito extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
children: [
ReorderableDragStartListener(
index: index,
child: const Padding(
padding: EdgeInsets.only(right: 4),
child: Icon(Icons.drag_handle_rounded),
),
),
Expanded(
child: TarjetaEmisora(
key: Key(emisora.uuid),
emisora: emisora,
esCompacta: true,
onTap: () => reproducirMinimizado(context, emisora),
),
),
const SizedBox(width: 6),
Column(
mainAxisSize: MainAxisSize.min,
children: [
IconButton.filledTonal(
tooltip: l10n.favoriteGroupsAssignSubtitle(
_nombreVisible(l10n, grupoActual),
),
icon: const Icon(Icons.drive_file_move_rounded),
onPressed: () => _asignar(context),
),
IconButton.filledTonal(
tooltip: l10n.favoritesRemoveTooltip,
icon: const Icon(Icons.delete_outline_rounded),
onPressed: () => _eliminar(context),
),
],
),
],
final meta = [
emisora.pais,
emisora.idioma,
].where((s) => s != null && s.isNotEmpty).join(' · ');
// Item 23 / audit 4.3 (t4:226-232): a flat, background-less row --
// drag handle, square art, name+meta, and a circular play affordance --
// replacing the full glass TarjetaEmisora card and its two stacked
// filledTonal buttons. "Move to list"/"Remove from favorites" keep their
// EXACT prior logic (`_asignar`/`_eliminar`, untouched), now reachable
// from an overflow menu instead of two always-visible buttons -- the
// prototype's row has no such menu, but dropping either capability
// entirely would be a functional regression, not a fidelity fix.
return FilaEmisoraPlana(
key: Key(emisora.uuid),
emisora: emisora,
meta: meta,
onTap: () => reproducirMinimizado(context, emisora),
leading: ReorderableDragStartListener(
index: index,
child: Icon(
// t4:227: drag_indicator, not drag_handle, at 22px/28%.
Icons.drag_indicator_rounded,
size: 22,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.28),
),
),
trailing: [
BotonReproducirCircular(
onPressed: () => reproducirMinimizado(context, emisora),
),
PopupMenuButton<String>(
icon: Icon(
Icons.more_vert_rounded,
size: 20,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.45),
),
constraints: const BoxConstraints.tightFor(width: 38, height: 42),
onSelected: (accion) {
if (accion == 'assign') _asignar(context);
if (accion == 'remove') _eliminar(context);
},
itemBuilder:
(context) => [
PopupMenuItem(
value: 'assign',
child: Text(l10n.favoriteGroupsAssign),
),
PopupMenuItem(
value: 'remove',
child: Text(l10n.favoritesRemoveTooltip),
),
],
),
],
);
}
}
@@ -454,24 +499,37 @@ class _CtaEmisoraPersonalizada extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final color = Theme.of(
// Audit 4.5 (t4 line 235): the border and the label/icon are TWO
// different opacities in the prototype — `rgba(255,255,255,.16)` for
// the dashed stroke, `rgba(242,247,250,.6)` for the text/icon — not one
// shared 50% colour for both.
final colorBorde = Colors.white.withValues(alpha: 0.16);
final colorTexto = Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.5);
).colorScheme.onSurface.withValues(alpha: 0.6);
return CustomPaint(
painter: _DashedBorderPainter(color: color),
painter: _DashedBorderPainter(color: colorBorde),
child: Material(
color: Colors.transparent,
child: InkWell(
borderRadius: BorderRadius.circular(16),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 18, horizontal: 16),
key: const Key('custom-station-cta-padding'),
padding: const EdgeInsets.all(14),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.add_circle_outline_rounded, color: color),
Icon(Icons.add_rounded, size: 20, color: colorTexto),
const SizedBox(width: 8),
Text(l10n.customStationsAddCta, style: TextStyle(color: color)),
Text(
l10n.customStationsAddCta,
style: TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w800,
color: colorTexto,
),
),
],
),
),
+150 -57
View File
@@ -8,6 +8,7 @@ import 'package:share_plus/share_plus.dart' show Share, XFile;
import '../estado/estado_grabacion.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/archivo_grabacion.dart';
import '../tema/pluriwave_tokens.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_icon.dart';
import '../widgets/pluri_layout.dart';
@@ -231,7 +232,9 @@ class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
title: l10n.recordingsLibraryTitle,
actions: [
IconButton(
icon: const Icon(Icons.settings_outlined),
// Audit 12.1 (t4:610): the header action is `folder_open` at
// 22px, not a generic gear.
icon: const Icon(Icons.folder_open_rounded, size: 22),
tooltip: l10n.recordingsLibrarySettingsTooltip,
onPressed:
() => PluriPushScaffold.push(
@@ -257,20 +260,27 @@ class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
subtitle: l10n.recordingsLibraryEmptySubtitle,
)
else
for (final archivo in archivos)
_FilaGrabacion(
archivo: archivo,
reproduciendo:
_reproductor.rutaActual == archivo.ruta &&
_reproductor.reproduciendo,
duracion: _duracionPara(archivo.ruta),
formatearDuracion: _formatearDuracion,
formatearFecha: _formatearFecha,
formatearBytes: _formatearBytes,
onAlternarReproduccion:
() => _alternarReproduccion(archivo.ruta),
onAccionMenu: (accion) => _manejarAccion(accion, archivo),
),
// t4:618: `gap:2px` between rows.
PluriPanelColumn(
gap: 2,
children: [
for (final archivo in archivos)
_FilaGrabacion(
archivo: archivo,
reproduciendo:
_reproductor.rutaActual == archivo.ruta &&
_reproductor.reproduciendo,
duracion: _duracionPara(archivo.ruta),
formatearDuracion: _formatearDuracion,
formatearFecha: _formatearFecha,
formatearBytes: _formatearBytes,
onAlternarReproduccion:
() => _alternarReproduccion(archivo.ruta),
onAccionMenu:
(accion) => _manejarAccion(accion, archivo),
),
],
),
],
);
},
@@ -298,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,
),
],
@@ -337,48 +362,116 @@ class _FilaGrabacion extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final theme = Theme.of(context);
return PluriGlassSurface(
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: IconButton(
icon: Icon(
reproduciendo
? Icons.pause_circle_filled_rounded
: Icons.play_circle_fill_rounded,
// Item 23 / audit 12.4 (t4:616-619): a flat, background-less row --
// 44x44/radius-12 art placeholder (recordings carry no per-station
// favicon, so this is a themed fallback square, not invented artwork),
// name, meta line, a 24px play/pause affordance, and the SAME "-"
// menu (Rename/Share/Delete) as before, just restyled.
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 10),
child: Row(
children: [
ClipRRect(
key: const ValueKey('fila-grabacion-arte'),
borderRadius: BorderRadius.circular(12),
child: Container(
width: 44,
height: 44,
color: theme.colorScheme.primaryContainer,
child: Icon(
Icons.radio_rounded,
size: 22,
color: theme.colorScheme.onPrimaryContainer,
),
),
),
onPressed: onAlternarReproduccion,
),
title: Text(archivo.nombre),
subtitle: FutureBuilder<Duration?>(
future: duracion,
builder: (context, snap) {
return Text(
'${formatearFecha(archivo.fecha)} · '
'${formatearDuracion(snap.data)} · '
'${formatearBytes(archivo.tamanoBytes)}',
);
},
),
trailing: PopupMenuButton<String>(
icon: const Icon(Icons.more_vert_rounded),
onSelected: onAccionMenu,
itemBuilder:
(context) => [
PopupMenuItem(
value: 'rename',
child: Text(l10n.recordingActionRename),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// t4:619: 14.5px/w700.
Text(
archivo.nombre,
style: const TextStyle(
fontSize: 14.5,
fontWeight: FontWeight.w700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
PopupMenuItem(
value: 'share',
child: Text(l10n.recordingActionShare),
),
PopupMenuItem(
value: 'delete',
child: Text(l10n.recordingActionDelete),
// t4:619: 12px/rgba(242,247,250,.55).
FutureBuilder<Duration?>(
future: duracion,
builder: (context, snap) {
return Text(
'${formatearFecha(archivo.fecha)} · '
'${formatearDuracion(snap.data)} · '
'${formatearBytes(archivo.tamanoBytes)}',
style: TextStyle(
fontSize: 12,
color: theme.colorScheme.onSurface.withValues(
alpha: 0.55,
),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
);
},
),
],
),
),
),
// t4:619: play_circle 24px brand teal in a 42x42 target.
SizedBox(
width: 42,
height: 42,
child: IconButton(
padding: EdgeInsets.zero,
tooltip: reproduciendo ? l10n.pauseAction : l10n.playAction,
icon: Icon(
reproduciendo
? Icons.pause_circle_filled_rounded
: Icons.play_circle_fill_rounded,
size: 24,
color: PluriWaveTokens.brand,
),
onPressed: onAlternarReproduccion,
),
),
// t4:619: more_vert 20px/45% in a 38x42 target.
SizedBox(
width: 38,
height: 42,
child: PopupMenuButton<String>(
padding: EdgeInsets.zero,
icon: Icon(
Icons.more_vert_rounded,
size: 20,
color: theme.colorScheme.onSurface.withValues(alpha: 0.45),
),
onSelected: onAccionMenu,
itemBuilder:
(context) => [
PopupMenuItem(
value: 'rename',
child: Text(l10n.recordingActionRename),
),
PopupMenuItem(
value: 'share',
child: Text(l10n.recordingActionShare),
),
PopupMenuItem(
value: 'delete',
child: Text(l10n.recordingActionDelete),
),
],
),
),
],
),
);
}
+295 -49
View File
@@ -14,7 +14,6 @@ import '../tema/pluriwave_theme.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_layout.dart';
import '../widgets/pluri_premium_widgets.dart';
import '../widgets/pluri_root_header.dart';
import '../widgets/pluri_sleep_timer_sheet.dart';
import '../widgets/visualizador_audio.dart';
@@ -38,35 +37,46 @@ class _PantallaInicioState extends State<PantallaInicio> {
final theme = Theme.of(context);
final l10n = AppLocalizations.of(context);
return CustomScrollView(
slivers: [
// S1 (Tier 1 visual fidelity): the prototype has no global AppBar —
// this root now draws its own 56px title row instead of relying on
// app.dart's removed shared chrome (which is also where the
// sleep-timer action used to live).
SliverToBoxAdapter(
child: PluriRootHeader(
title: l10n.navHome,
onSleepTimer: () => showPluriSleepTimerSheet(context),
),
),
// WU5 built the hero; WU6 relocated the discovery sections that
// used to follow it (_seccionCercanas, _seccionTendencias,
// _chipGeneros, _errorBanner, the browse grid) into
// PantallaBuscar's landing state and DELETED them here (this WU's
// own task 6.5 — completing WU5's task 5.9 deferral). Escuchar's
// content is now just the hero and the favorites preview below;
// pull-to-refresh was dropped along with the grid it refreshed —
// the retry button that used to live in the (now relocated) error
// banner already covers manual recovery on Buscar.
const SliverToBoxAdapter(child: _EscucharHero()),
SliverToBoxAdapter(child: _seccionTusEmisoras(context, theme, l10n)),
// ADR-7(b): the mini player is hidden on this whole screen
// (app.dart), so its content needs less bottom padding than every
// other root — escucharBottomChromeInset, not the plain
// bottomChromeInset every other root/scrollable uses.
const SliverToBoxAdapter(
child: SizedBox(height: PluriLayout.escucharBottomChromeInset),
// Audit 1.1 (t4 lines 49-51): a full-bleed art/gradient/noise wash sits
// BEHIND the whole screen, not just inside the hero card -- the Stack
// wrapper is new; everything that used to be the bare CustomScrollView
// return value is unchanged below.
return Stack(
fit: StackFit.expand,
children: [
const _FondoEscuchar(),
CustomScrollView(
slivers: [
// Audit 1.2 (t4 line 53): unlike the other 3 roots' plain 56px
// PluriRootHeader, Escuchar's own header is a 52px "Now
// listening" eyebrow row -- the tab bar already identifies this
// destination, so it never repeats "Escuchar" as a title.
SliverToBoxAdapter(
child: _CabeceraEscuchar(
onSleepTimer: () => showPluriSleepTimerSheet(context),
),
),
// WU5 built the hero; WU6 relocated the discovery sections that
// used to follow it (_seccionCercanas, _seccionTendencias,
// _chipGeneros, _errorBanner, the browse grid) into
// PantallaBuscar's landing state and DELETED them here (this WU's
// own task 6.5 — completing WU5's task 5.9 deferral). Escuchar's
// content is now just the hero and the favorites preview below;
// pull-to-refresh was dropped along with the grid it refreshed —
// the retry button that used to live in the (now relocated) error
// banner already covers manual recovery on Buscar.
const SliverToBoxAdapter(child: _EscucharHero()),
SliverToBoxAdapter(
child: _seccionTusEmisoras(context, theme, l10n),
),
// ADR-7(b): the mini player is hidden on this whole screen
// (app.dart), so its content needs less bottom padding than every
// other root — escucharBottomChromeInset, not the plain
// bottomChromeInset every other root/scrollable uses.
const SliverToBoxAdapter(
child: SizedBox(height: PluriLayout.escucharBottomChromeInset),
),
],
),
],
);
@@ -107,19 +117,47 @@ class _PantallaInicioState extends State<PantallaInicio> {
Row(
children: [
Expanded(
child: Text(
l10n.yourStationsTitle,
style: theme.textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
child: Row(
children: [
Flexible(
child: Text(
l10n.yourStationsTitle,
// S7 (visual fidelity, t4 line 80): the in-page
// section heading style (15/w800/ls-.2), not
// titleMedium(16)/w900.
style: context.pluriType.inPageSectionHeading,
overflow: TextOverflow.ellipsis,
),
),
// Audit 1.11 (t4 line 80): a count badge next to the
// section title — the TOTAL favorite count, not the
// 8-capped grid size shown below it.
if (favoritos.isNotEmpty) ...[
const SizedBox(width: 10),
_InsigniaRecuento(cuenta: favoritos.length),
],
],
),
),
TextButton(
onPressed:
() => context.read<EstadoNavegacionRaiz>().irA(
RaizPluriWave.favoritos,
// Audit 1.12 (t4 line 80): "Ver todas" is plain 12px/w800
// brand-teal text — not a Material TextButton with its own
// padding and splash.
Semantics(
button: true,
child: GestureDetector(
onTap:
() => context.read<EstadoNavegacionRaiz>().irA(
RaizPluriWave.favoritos,
),
child: Text(
l10n.seeAllAction,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w800,
color: context.pluriTokens.electricMagenta,
),
child: Text(l10n.seeAllAction),
),
),
),
],
),
@@ -160,6 +198,134 @@ class _PantallaInicioState extends State<PantallaInicio> {
}
}
/// Audit 1.1 (t4 lines 49-51): a full-bleed backdrop behind the ENTIRE
/// screen -- 400px of the current station's own art at 50% opacity, a
/// gradient fading it into the page background by 430px, and a faint noise
/// texture. Reads `emisoraActual` ITSELF (not a parent watch), so this stays
/// the only new subscriber -- `_PantallaInicioState.build()` keeps its
/// documented S4-R5 "no root watch" rule. Renders the gradient/noise even
/// with nothing playing (there is simply no image layer to show then) so
/// the page keeps the same base depth in every state.
class _FondoEscuchar extends StatelessWidget {
const _FondoEscuchar();
static const double _altoArte = 400;
static const double _altoDegradado = 430;
@override
Widget build(BuildContext context) {
final emisora = context.select<EstadoRadio, Emisora?>(
(e) => e.emisoraActual,
);
final tokens = context.pluriTokens;
final favicon = emisora?.favicon;
return IgnorePointer(
key: const ValueKey('escuchar-background-art'),
child: Stack(
children: [
if (favicon != null && favicon.isNotEmpty)
SizedBox(
width: double.infinity,
height: _altoArte,
child: Opacity(
opacity: 0.5,
child: CachedNetworkImage(
imageUrl: favicon,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
),
),
SizedBox(
width: double.infinity,
height: _altoDegradado,
child: DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
tokens.deepViolet.withValues(alpha: 0.4),
tokens.deepViolet.withValues(alpha: 0.86),
tokens.deepViolet,
],
stops: const [0, 0.58, 1],
),
),
),
),
SizedBox(
width: double.infinity,
height: _altoDegradado,
child: Opacity(
opacity: 0.05,
child: Image.asset(
'assets/images/noise_soft.png',
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
),
),
),
],
),
);
}
}
/// Audit 1.2 (t4 line 53): Escuchar's own header -- 52px, an eyebrow-styled
/// "Now listening" label (not a screen title -- the tab bar already
/// identifies this destination) and a single trailing action.
///
/// The prototype also draws a `cast` icon here. This app has no casting
/// service anywhere in `lib/servicios` (checked before writing this) --
/// inventing a Chromecast-style button with no backing capability would fail
/// the same "don't invent a capability absent from the domain" test WU5/WU9
/// already established elsewhere in this app, so it is omitted, not faked.
/// The ONE real action this row keeps is the sleep timer, which used to be
/// [PluriRootHeader]'s bedtime button on this exact screen.
class _CabeceraEscuchar extends StatelessWidget {
const _CabeceraEscuchar({required this.onSleepTimer});
final VoidCallback onSleepTimer;
static const double altura = 52;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return SizedBox(
height: altura,
child: Padding(
padding: const EdgeInsets.fromLTRB(20, 0, 10, 0),
child: Row(
children: [
Expanded(
child: Text(
l10n.nowListeningLabel,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w800,
letterSpacing: 0.3,
color: const Color(0xFFF2F7FA).withValues(alpha: 0.55),
),
),
),
IconButton(
icon: const Icon(Icons.bedtime_outlined),
iconSize: 23,
tooltip: l10n.sleepTimer,
onPressed: onSleepTimer,
),
],
),
),
);
}
}
/// WU5, design ADR-7: the Escuchar embedded player. `EstadoRadio` is the
/// single source of truth (`emisoraActual` already feeds `MiniReproductor`
/// and `PantallaReproductor`) — this hero is a third VIEW, never a third
@@ -265,8 +431,14 @@ class _EscucharHero extends StatelessWidget {
const SizedBox(height: 6),
Text(
stationName,
// Audit 1.5 (t4 line 60): 24/w800/ls-.5/height 1.12
// -- titleLarge is 22 with neither letter-spacing
// nor an explicit line height.
style: theme.textTheme.titleLarge?.copyWith(
fontSize: 24,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
height: 1.12,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
@@ -307,12 +479,55 @@ class _EscucharHero extends StatelessWidget {
const SizedBox(height: 14),
_FilaTransporteEscuchar(emisora: emisora),
const SizedBox(height: 10),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton.icon(
icon: const Icon(Icons.tune_rounded, size: 18),
label: Text(l10n.openFullPlayerTooltip),
onPressed: () => PantallaReproductor.abrir(context, emisora),
// Audit 1.9 (t4 line 78): a CENTRED pill -- padding:9px 15px,
// radius:999, rgba(255,255,255,.07) + border .1, 12px/w700,
// icon expand_less 17px -- not a left-aligned OutlinedButton
// with tune_rounded at 18px.
Center(
child: Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: BorderRadius.circular(999),
onTap: () => PantallaReproductor.abrir(context, emisora),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.07),
borderRadius: BorderRadius.circular(999),
border: Border.all(
color: Colors.white.withValues(alpha: 0.1),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 15,
vertical: 9,
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.expand_less_rounded,
size: 17,
color: theme.colorScheme.onSurface.withValues(
alpha: 0.78,
),
),
const SizedBox(width: 6),
Text(
l10n.openFullPlayerTooltip,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: theme.colorScheme.onSurface.withValues(
alpha: 0.78,
),
),
),
],
),
),
),
),
),
),
],
@@ -447,8 +662,10 @@ class _FilaTransporteEscuchar extends StatelessWidget {
s == EstadoReproduccion.cargando ||
s == EstadoReproduccion.reconectando;
return SizedBox(
width: 56,
height: 56,
// Audit 1.8 (t4 line 73): 72x72 with a 38px icon, not 56x56
// with a 28px icon.
width: 72,
height: 72,
child: FilledButton(
style: FilledButton.styleFrom(
shape: const CircleBorder(),
@@ -476,7 +693,7 @@ class _FilaTransporteEscuchar extends StatelessWidget {
reproduciendo
? Icons.pause_rounded
: Icons.play_arrow_rounded,
size: 28,
size: 38,
),
),
);
@@ -574,6 +791,35 @@ String? _metaEscuchar(Emisora emisora) {
return partes.isEmpty ? null : partes.join(' · ');
}
/// Audit 1.11 (t4 line 80): the pill badge next to "Tus emisoras" showing
/// the total favorite count — `rgba(255,255,255,.08)` fill, 11px/w800/60%.
class _InsigniaRecuento extends StatelessWidget {
const _InsigniaRecuento({required this.cuenta});
final int cuenta;
@override
Widget build(BuildContext context) {
return DecoratedBox(
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(999),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
child: Text(
'$cuenta',
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
color: const Color(0xFFF2F7FA).withValues(alpha: 0.6),
),
),
),
);
}
}
/// Audit 1.10 (t4 lines 82-88): the "Tus emisoras" grid cell — a 44px
/// square thumbnail (radius 11), name (13/w700/lh1.2) and genre
/// (11/55%). Deliberately NOT `TarjetaEmisora(esCompacta: true)`: that
+229 -52
View File
@@ -4,7 +4,8 @@ import 'package:provider/provider.dart';
import '../estado/estado_busqueda.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/pais_radio.dart';
import '../widgets/pluri_glass_surface.dart';
import '../tema/pluriwave_theme.dart';
import '../tema/pluriwave_tokens.dart';
import '../widgets/pluri_layout.dart';
import '../widgets/pluri_push_scaffold.dart';
@@ -15,13 +16,26 @@ import '../widgets/pluri_push_scaffold.dart';
/// live station counts already parsed from the API's `stationcount`
/// **string** field (`PaisRadio.fromApi`, Engram id 2500).
class PantallaPaises extends StatefulWidget {
const PantallaPaises({super.key});
const PantallaPaises({super.key, this.onPaisSeleccionado});
/// Item 24 / audit 5.2 (t4:256-269): every row is tappable (the prototype
/// draws a `chevron_right` on all of them). Optional so this screen stays
/// usable stand-alone; the one production call site
/// (`pantalla_buscar.dart`'s "Explorar por" grid) wires this to filter
/// search results by the tapped country and pop back.
final ValueChanged<PaisRadio>? onPaisSeleccionado;
@override
State<PantallaPaises> createState() => _PantallaPaisesState();
}
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
@@ -57,27 +71,82 @@ 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),
],
),
);
}
void _seleccionar(PaisRadio pais) {
widget.onPaisSeleccionado?.call(pais);
}
Widget _seccionTusIdiomas(
BuildContext context,
List<PaisRadio> paises,
@@ -92,33 +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),
Wrap(
spacing: 8,
runSpacing: 8,
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)
Chip(
label: Text(
'${pais.nombre} · ${l10n.stationsCount(pais.numeroEmisoras)}',
),
_FilaPais(
pais: pais,
l10n: l10n,
// Item 24 / audit 5.5 (t4:256): the first row is
// highlighted.
destacado: pais == destacados.first,
onTap: () => _seleccionar(pais),
),
],
),
],
),
),
],
);
}
@@ -127,34 +208,130 @@ 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,
),
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),
),
],
),
),
],
);
}
}
/// Item 24 / audit 5.1-5.3, 5.5 (t4:256-269): a tappable row -- ISO code
/// column, name, station count, and a chevron -- shared by "Tus idiomas"
/// and "Todos". [destacado] applies the t4:256 highlight (teal-tinted
/// background, bold name) reserved for the very first "Tus idiomas" row.
class _FilaPais extends StatelessWidget {
const _FilaPais({
required this.pais,
required this.l10n,
this.destacado = false,
this.onTap,
});
final PaisRadio pais;
final AppLocalizations l10n;
final bool destacado;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
final onSurface = Theme.of(context).colorScheme.onSurface;
return DecoratedBox(
decoration: BoxDecoration(
color:
destacado
? PluriWaveTokens.brand.withValues(alpha: 0.1)
: Colors.transparent,
borderRadius: BorderRadius.circular(14),
),
child: Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.all(12),
child: Row(
children: [
// t4:256-258: 26px/lh1, centred in a 34-wide column.
SizedBox(
width: 34,
child: Text(
pais.codigoIso,
textAlign: TextAlign.center,
style: const TextStyle(fontSize: 26, height: 1),
),
),
const SizedBox(width: 14),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
pais.nombre,
style: TextStyle(
fontSize: 15.5,
fontWeight:
destacado ? FontWeight.w800 : FontWeight.w700,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
const SizedBox(height: 2),
Text(
l10n.stationsCount(pais.numeroEmisoras),
style: TextStyle(
fontSize: 12,
color: onSurface.withValues(alpha: 0.55),
),
),
],
),
),
Icon(
Icons.chevron_right_rounded,
size: 20,
color: onSurface.withValues(alpha: 0.4),
),
],
),
),
const SizedBox(height: 4),
ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: paises.length,
separatorBuilder: (_, __) => const Divider(height: 1),
itemBuilder: (context, i) {
final pais = paises[i];
return ListTile(
contentPadding: EdgeInsets.zero,
title: Text(pais.nombre),
trailing: Text(l10n.stationsCount(pais.numeroEmisoras)),
);
},
),
],
),
),
);
}
+269 -161
View File
@@ -1,3 +1,5 @@
import 'dart:ui';
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
@@ -13,6 +15,7 @@ import '../servicios/servicio_audio.dart';
import '../servicios/servicio_timer.dart';
import '../tema/pluri_animate.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_premium_widgets.dart';
@@ -117,80 +120,110 @@ class _PantallaReproductorState extends State<PantallaReproductor> {
);
},
),
body: SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: Column(
children: [
_ArteReproductor(
emisora: emisoraActiva,
estadoStream: estado.estadoStream,
).pluriScaleIn(
context,
begin: 0.86,
duration: const Duration(milliseconds: 420),
curve: Curves.easeOutBack,
// Audit 2.2 (t4 lines 108-109): a full-bleed blurred backdrop of the
// station's own art sits BEHIND the scrollable content -- the Stack
// wrapper is new; the SafeArea/SingleChildScrollView subtree below is
// unchanged.
body: Stack(
fit: StackFit.expand,
children: [
_FondoReproductor(emisora: emisoraActiva, tokens: tokens),
SafeArea(
child: SingleChildScrollView(
padding: const EdgeInsets.fromLTRB(20, 8, 20, 24),
child: Column(
children: [
_ArteReproductor(
emisora: emisoraActiva,
estadoStream: estado.estadoStream,
).pluriScaleIn(
context,
begin: 0.86,
duration: const Duration(milliseconds: 420),
curve: Curves.easeOutBack,
),
const SizedBox(height: 18),
Text(
emisoraActiva.nombre,
// Audit 2.3 (t4 line 116): 26/w800/ls-.5/height 1.15 --
// headlineSmall is 24/w700 with neither letter-spacing
// nor an explicit line height.
style: theme.textTheme.headlineSmall?.copyWith(
fontSize: 26,
fontWeight: FontWeight.w800,
letterSpacing: -0.5,
height: 1.15,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
).pluriFadeIn(
context,
delay: const Duration(milliseconds: 150),
),
const SizedBox(height: 6),
_SubtituloInfo(emisora: emisoraActiva).pluriFadeIn(
context,
delay: const Duration(milliseconds: 190),
),
const SizedBox(height: 14),
// Audit 2.10 (t4 lines 120-122): the bars sit directly on
// the background -- no wrapping glass card. The key moves
// from the (removed) PluriGlassSurface onto the
// visualizer itself so existing finders keep working.
VisualizadorAudio(
key: const Key('player-visualizer'),
estadoStream: estado.estadoStream,
androidAudioSessionIdStream:
estado.audio.androidAudioSessionIdStream,
// Audit 2.5 (t4 lines 120-122): 30 discrete bars at
// 40px, gradient ending at 45% alpha (not the hero's
// 30%).
barras: 30,
color: tokens.warmCoral,
altura: 40,
barrasDiscretas: true,
gradienteFinAlpha: 0.45,
).pluriFadeIn(
context,
delay: const Duration(milliseconds: 270),
),
const SizedBox(height: 22),
_Controles(
estado: estado,
emisora: emisoraActiva,
esFavorito: esFavorito,
).pluriFadeSlideIn(
context,
delay: const Duration(milliseconds: 310),
beginY: 0.3,
),
const SizedBox(height: 20),
_BandejaHerramientas(
estado: estado,
eq: eq,
emisora: emisoraActiva,
compartir: _compartir,
).pluriFadeIn(
context,
delay: const Duration(milliseconds: 350),
),
const SizedBox(height: 12),
// Item 26 / audit 2.4 (t4:114-140): the quality row is
// LAST, after the tool tray -- it used to sit right after
// the subtitle, 4 positions too early.
_FilaCalidad(
estado: estado,
emisora: emisoraActiva,
).pluriFadeIn(
context,
delay: const Duration(milliseconds: 390),
),
],
),
const SizedBox(height: 18),
Text(
emisoraActiva.nombre,
style: theme.textTheme.headlineSmall?.copyWith(
fontWeight: FontWeight.w700,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
).pluriFadeIn(context, delay: const Duration(milliseconds: 150)),
const SizedBox(height: 6),
_SubtituloInfo(
emisora: emisoraActiva,
).pluriFadeIn(context, delay: const Duration(milliseconds: 190)),
const SizedBox(height: 12),
_FilaCalidad(
estado: estado,
emisora: emisoraActiva,
).pluriFadeIn(context, delay: const Duration(milliseconds: 230)),
const SizedBox(height: 14),
PluriGlassSurface(
key: const Key('player-visualizer'),
borderRadius: BorderRadius.circular(tokens.radiusLg),
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 12,
),
child: VisualizadorAudio(
estadoStream: estado.estadoStream,
androidAudioSessionIdStream:
estado.audio.androidAudioSessionIdStream,
// Audit 2.5 (t4 lines 120-122): 30 discrete bars at 40px,
// gradient ending at 45% alpha (not the hero's 30%).
barras: 30,
color: tokens.warmCoral,
altura: 40,
barrasDiscretas: true,
gradienteFinAlpha: 0.45,
),
).pluriFadeIn(context, delay: const Duration(milliseconds: 270)),
const SizedBox(height: 22),
_Controles(
estado: estado,
emisora: emisoraActiva,
esFavorito: esFavorito,
).pluriFadeSlideIn(
context,
delay: const Duration(milliseconds: 310),
beginY: 0.3,
),
const SizedBox(height: 20),
_BandejaHerramientas(
estado: estado,
eq: eq,
emisora: emisoraActiva,
compartir: _compartir,
).pluriFadeIn(context, delay: const Duration(milliseconds: 350)),
],
),
),
),
],
),
);
}
@@ -198,25 +231,32 @@ class _PantallaReproductorState extends State<PantallaReproductor> {
/// Square art (design proposal WU14 row: "square art" replaces the old
/// circular `_WaveHero`). Loading/error overlays and the fallback icon are
/// unchanged from the prior circular version — only the clip shape and the
/// decorative halo geometry changed from circle to rounded-square.
/// unchanged from the prior circular version.
///
/// Audit 2.1 (t4 line 114): the prototype draws a FIXED 246x246 square,
/// radius 28, with only a drop shadow -- no halo, no ring. The previous
/// `width*0.62`-derived size plus a radial-gradient halo Container and a
/// bordered ring Container (neither present in the prototype) are both
/// removed; `_radio` (28) is deliberately its own constant, distinct from
/// every named token radius, mirroring this codebase's established
/// one-off-radius precedent (e.g. the ringing screen's stop button).
class _ArteReproductor extends StatelessWidget {
final Emisora emisora;
final Stream<EstadoReproduccion> estadoStream;
const _ArteReproductor({required this.emisora, required this.estadoStream});
static const double _lado = 246;
static const double _radio = 28;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final t = context.pluriTokens;
final size = MediaQuery.of(context).size.width * 0.62;
final radio = BorderRadius.circular(t.radiusLg);
final radio = BorderRadius.circular(_radio);
return StreamBuilder<EstadoReproduccion>(
stream: estadoStream,
builder: (context, snapshot) {
final reproduciendo = snapshot.data == EstadoReproduccion.reproduciendo;
// S7-R3: reconectando renders as loading, never as error.
final cargando =
snapshot.data == EstadoReproduccion.cargando ||
@@ -225,83 +265,62 @@ class _ArteReproductor extends StatelessWidget {
return SizedBox(
key: const Key('player-hero-art'),
width: size + 40,
height: size + 40,
child: Stack(
alignment: Alignment.center,
children: [
Container(
width: size + 34,
height: size + 34,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(t.radiusLg + 16),
gradient: RadialGradient(
colors: [
t.electricMagenta.withValues(
alpha: reproduciendo ? 0.35 : 0.18,
),
t.deepViolet.withValues(alpha: 0.0),
],
),
width: _lado,
height: _lado,
child: DecoratedBox(
decoration: BoxDecoration(
borderRadius: radio,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.55),
blurRadius: 60,
offset: const Offset(0, 26),
),
),
Container(
width: size + 12,
height: size + 12,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(t.radiusLg + 4),
border: Border.all(color: t.glassBorder),
),
),
PluriGlassSurface(
],
),
child: PluriGlassSurface(
borderRadius: radio,
padding: EdgeInsets.zero,
child: ClipRRect(
borderRadius: radio,
padding: EdgeInsets.zero,
child: SizedBox(
width: size,
height: size,
child: ClipRRect(
borderRadius: radio,
child: Stack(
fit: StackFit.expand,
children: [
if (emisora.favicon != null &&
emisora.favicon!.isNotEmpty)
CachedNetworkImage(
imageUrl: emisora.favicon!,
fit: BoxFit.cover,
placeholder: (_, __) => _shimmer(theme),
errorWidget: (_, __, ___) => _iconoFallback(theme),
)
else
_iconoFallback(theme),
if (cargando)
Container(
color: Colors.black45,
child: Center(
child: CircularProgressIndicator(
color: theme.colorScheme.onSurface,
),
child: Stack(
fit: StackFit.expand,
children: [
if (emisora.favicon != null && emisora.favicon!.isNotEmpty)
CachedNetworkImage(
imageUrl: emisora.favicon!,
fit: BoxFit.cover,
placeholder: (_, __) => _shimmer(theme),
errorWidget: (_, __, ___) => _iconoFallback(theme),
)
else
_iconoFallback(theme),
if (cargando)
Container(
color: Colors.black45,
child: Center(
child: CircularProgressIndicator(
color: theme.colorScheme.onSurface,
),
),
),
if (hayError)
Container(
color: Colors.black54,
child: Center(
child: Icon(
Icons.wifi_off_rounded,
size: 56,
color: theme.colorScheme.onSurface.withValues(
alpha: 0.85,
),
),
if (hayError)
Container(
color: Colors.black54,
child: Center(
child: Icon(
Icons.wifi_off_rounded,
size: 56,
color: theme.colorScheme.onSurface.withValues(
alpha: 0.85,
),
),
),
),
],
),
),
),
),
],
),
),
],
),
),
);
},
@@ -324,6 +343,63 @@ class _ArteReproductor extends StatelessWidget {
);
}
/// Audit 2.2 (t4 lines 108-109): a full-bleed blurred backdrop of the
/// CURRENT station's own art, sitting behind the whole screen. Unlike the
/// ringing screen's `_FondoArteDifuminado` (which cannot use
/// `Emisora.favicon` -- a cold-boot download after a screen-off wake would
/// hang widget tests without a mocked `HttpClient`), this screen already
/// renders `emisora.favicon` via `CachedNetworkImage` for its OWN foreground
/// art (`_ArteReproductor` above) with no such hazard -- reusing it here for
/// the backdrop is equally safe. Renders the gradient tint even without a
/// favicon, so the page keeps the same base depth in every state.
class _FondoReproductor extends StatelessWidget {
const _FondoReproductor({required this.emisora, required this.tokens});
final Emisora emisora;
final PluriWaveTokens tokens;
@override
Widget build(BuildContext context) {
final favicon = emisora.favicon;
return Positioned.fill(
key: const ValueKey('player-background-art'),
child: IgnorePointer(
child: Stack(
fit: StackFit.expand,
children: [
if (favicon != null && favicon.isNotEmpty)
ImageFiltered(
imageFilter: ImageFilter.blur(sigmaX: 26, sigmaY: 26),
child: Opacity(
opacity: 0.45,
child: CachedNetworkImage(
imageUrl: favicon,
fit: BoxFit.cover,
errorWidget: (_, __, ___) => const SizedBox.shrink(),
),
),
),
DecoratedBox(
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
tokens.deepViolet.withValues(alpha: 0.55),
tokens.deepViolet.withValues(alpha: 0.9),
tokens.deepViolet,
],
stops: const [0, 0.46, 1],
),
),
),
],
),
),
);
}
}
/// Single subtitle line (WU14: collapses the old `_InfoChips` `Wrap` of
/// separate chips — country/language now join as one line; codec/bitrate
/// moved into their own [_FilaCalidad] row below).
@@ -752,9 +828,10 @@ class _Controles extends StatelessWidget {
);
}
return PluriGlassSurface(
borderRadius: BorderRadius.circular(28),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 10),
// Audit 2.9 (t4 lines 124-131): the transport row sits directly on
// the background -- the prototype draws no wrapping card here.
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
@@ -799,8 +876,9 @@ class _Controles extends StatelessWidget {
),
AnimatedContainer(
duration: const Duration(milliseconds: 200),
width: 72,
height: 72,
// Item 26 / audit 2.6 (t4:127): 78x78, not 72x72.
width: 78,
height: 78,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: theme.colorScheme.primary,
@@ -817,7 +895,7 @@ class _Controles extends StatelessWidget {
shape: const CircleBorder(),
child: InkWell(
customBorder: const CircleBorder(),
radius: 40,
radius: 39,
onTap:
cargando
? null
@@ -850,7 +928,8 @@ class _Controles extends StatelessWidget {
reproduciendo
? Icons.pause_rounded
: Icons.play_arrow_rounded,
size: 40,
// Item 26 / audit 2.6 (t4:127): 42px icon.
size: 42,
color: theme.colorScheme.onPrimary,
),
),
@@ -869,6 +948,13 @@ class _Controles extends StatelessWidget {
/// One tile of the 4-tile tool tray (WU14: EQ propio / Grabar / sleep timer
/// / Compartir), each opening its own bottom sheet (Compartir invokes
/// directly instead — there is no sheet content for it).
///
/// Audit 2.8 (t4 lines 133-136): only the FIRST tile ("EQ propio") is
/// accented -- tinted background/border, brand-colour icon, bold brand
/// label. The other three share a neutral OPAQUE surface (not `listSurface`
/// at 55% alpha) with a full-weight icon/label -- this app's ambient
/// default colour, not the `tokens.electricMagenta` fallback every tile
/// used to render in regardless of [accent].
class _TileHerramienta extends StatelessWidget {
const _TileHerramienta({
super.key,
@@ -876,6 +962,7 @@ class _TileHerramienta extends StatelessWidget {
required this.label,
required this.onTap,
this.iconColor,
this.accent = false,
});
final IconData icon;
@@ -883,26 +970,42 @@ class _TileHerramienta extends StatelessWidget {
final VoidCallback onTap;
final Color? iconColor;
/// See class doc — `false` (neutral) is every tile except "EQ propio".
final bool accent;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
final tokens = context.pluriTokens;
final resolvedIconColor =
iconColor ?? (accent ? tokens.electricMagenta : null);
return Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: BorderRadius.circular(tokens.radiusSm),
borderRadius: BorderRadius.circular(tokens.radiusMd),
onTap: onTap,
child: Container(
padding: const EdgeInsets.symmetric(vertical: 14, horizontal: 6),
decoration: BoxDecoration(
color: tokens.listSurface.withValues(alpha: 0.55),
borderRadius: BorderRadius.circular(tokens.radiusSm),
color:
accent
? tokens.electricMagenta.withValues(alpha: 0.16)
: tokens.listSurface,
// Audit 2.8 (t4 lines 133-136): radius 18 for BOTH variants --
// `radiusMd`, not the smaller `radiusSm` every tile used before.
borderRadius: BorderRadius.circular(tokens.radiusMd),
border: Border.all(
color:
accent
? tokens.electricMagenta.withValues(alpha: 0.4)
: Colors.white.withValues(alpha: 0.09),
),
),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(icon, size: 23, color: iconColor ?? tokens.electricMagenta),
Icon(icon, size: 23, color: resolvedIconColor),
const SizedBox(height: 5),
Text(
label,
@@ -910,7 +1013,11 @@ class _TileHerramienta extends StatelessWidget {
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: theme.textTheme.labelSmall?.copyWith(
fontWeight: FontWeight.w800,
fontWeight: accent ? FontWeight.w800 : FontWeight.w700,
color:
accent
? tokens.electricMagenta
: theme.colorScheme.onSurface.withValues(alpha: 0.72),
),
),
],
@@ -947,6 +1054,7 @@ class _BandejaHerramientas extends StatelessWidget {
icon: Icons.equalizer_rounded,
label: l10n.playerToolEqLabel,
onTap: () => _mostrarHojaEq(context, eq, emisora),
accent: true,
),
),
const SizedBox(width: 10),
+240 -23
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,15 +72,10 @@ 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),
],
),
@@ -67,6 +93,136 @@ class PantallaVacaciones extends StatelessWidget {
}
}
/// 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
@@ -184,31 +340,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),
],
),
),
],
),
);
+20 -2
View File
@@ -2,8 +2,11 @@ import 'package:flutter/material.dart';
/// Design ADR-1: the named type scale, split out of [PluriWaveTokens] into
/// its own ThemeExtension so screens stop copy-pasting
/// `.copyWith(fontWeight: w900, letterSpacing: …)`. The set is closed at six
/// styles — a seventh requires amending ADR-1.
/// `.copyWith(fontWeight: w900, letterSpacing: …)`. The set was closed at
/// six styles; S7 (visual fidelity pass) amended ADR-1 to add a seventh —
/// [inPageSectionHeading] — rather than have four screens each keep
/// copy-pasting their own `titleMedium.copyWith(fontWeight: w900)` override,
/// the exact duplication this extension exists to eliminate.
///
/// [eyebrowLabel] never applies `toUpperCase()` — casing is a
/// locale-hostile transform (Turkish dotless i, and scripts with no case at
@@ -18,6 +21,7 @@ class PluriWaveTypography extends ThemeExtension<PluriWaveTypography> {
required this.cardTitle,
required this.bodyStrong,
required this.eyebrowLabel,
required this.inPageSectionHeading,
});
/// 88 / w800 / height 1.0 / ls -2.0. Alarm ringing, inline time editor.
@@ -41,6 +45,14 @@ class PluriWaveTypography extends ThemeExtension<PluriWaveTypography> {
/// headers.
final TextStyle eyebrowLabel;
/// 15 / w800 / ls -0.2. S7 (visual fidelity, t4 lines 80, 153, 164, 173):
/// an in-page section heading living directly on a screen's content —
/// "Tus emisoras", "Cerca de ti", "Explorar por", "Populares ahora".
/// Distinct from both [sectionTitle] (23, a ROOT screen's own title) and
/// [screenTitle] (19, a pushed screen's AppBar title) — this is smaller
/// than either, for a heading living one level below the screen itself.
final TextStyle inPageSectionHeading;
/// Builds the scale from [family] — the SAME resolved Google Fonts
/// TextTheme instance [PluriWaveTheme.dark] already computes. Building the
/// styles anywhere else would re-merge the font family, which is the
@@ -79,6 +91,7 @@ class PluriWaveTypography extends ThemeExtension<PluriWaveTypography> {
letterSpacing: 0.8,
color: const Color(0xFFF2F7FA).withValues(alpha: 0.42),
),
inPageSectionHeading: style(15, FontWeight.w800, letterSpacing: -0.2),
);
}
@@ -90,6 +103,7 @@ class PluriWaveTypography extends ThemeExtension<PluriWaveTypography> {
TextStyle? cardTitle,
TextStyle? bodyStrong,
TextStyle? eyebrowLabel,
TextStyle? inPageSectionHeading,
}) {
return PluriWaveTypography(
heroTime: heroTime ?? this.heroTime,
@@ -98,6 +112,7 @@ class PluriWaveTypography extends ThemeExtension<PluriWaveTypography> {
cardTitle: cardTitle ?? this.cardTitle,
bodyStrong: bodyStrong ?? this.bodyStrong,
eyebrowLabel: eyebrowLabel ?? this.eyebrowLabel,
inPageSectionHeading: inPageSectionHeading ?? this.inPageSectionHeading,
);
}
@@ -117,6 +132,9 @@ class PluriWaveTypography extends ThemeExtension<PluriWaveTypography> {
bodyStrong: TextStyle.lerp(bodyStrong, other.bodyStrong, t) ?? bodyStrong,
eyebrowLabel:
TextStyle.lerp(eyebrowLabel, other.eyebrowLabel, t) ?? eyebrowLabel,
inPageSectionHeading:
TextStyle.lerp(inPageSectionHeading, other.inPageSectionHeading, t) ??
inPageSectionHeading,
);
}
}
+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(),
+257
View File
@@ -0,0 +1,257 @@
import 'package:cached_network_image/cached_network_image.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import 'package:shimmer/shimmer.dart' as shimmer;
import '../estado/estado_radio.dart';
import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/emisora.dart';
import '../tema/pluriwave_tokens.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
/// set of trailing actions. Shared by Favoritos and search results, which
/// the prototype draws identically apart from their trailing actions.
/// Grabaciones (12.4) has its own row: a different domain model
/// (recordings, not stations) with different trailing actions.
class FilaEmisoraPlana extends StatelessWidget {
const FilaEmisoraPlana({
super.key,
required this.emisora,
required this.meta,
this.leading,
this.trailing = const [],
this.onTap,
});
final Emisora emisora;
/// Pre-joined meta line (e.g. "genre - country - kbps"). Omitted from
/// layout entirely when empty, rather than reserving blank space.
final String meta;
/// e.g. a drag handle (Favoritos only — search results have none).
final Widget? leading;
/// e.g. a favourite toggle, a circular play button, an overflow menu.
final List<Widget> trailing;
final VoidCallback? onTap;
/// t4:226, t4:302: 48x48, radius 12.
static const double lado = 48;
static const double radio = 12;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final stationName = localizedStationName(l10n, emisora.nombre);
return Padding(
padding: const EdgeInsets.all(8),
child: Row(
children: [
if (leading != null) ...[leading!, const SizedBox(width: 4)],
ClipRRect(
key: const ValueKey('fila-emisora-plana-arte'),
borderRadius: BorderRadius.circular(radio),
child: SizedBox(
width: lado,
height: lado,
child: _ArteFilaEmisora(emisora: emisora),
),
),
const SizedBox(width: 12),
Expanded(
child: Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: BorderRadius.circular(14),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// t4:229, t4:303: 15px/w700/lh1.25.
Text(
stationName,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
height: 1.25,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
if (meta.isNotEmpty) ...[
const SizedBox(height: 2),
// t4:229, t4:303: 12px/rgba(242,247,250,.58).
Text(
meta,
style: TextStyle(
fontSize: 12,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.58),
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
],
),
),
),
),
),
...trailing,
],
),
);
}
}
/// t4:230, t4:305: a 44x44 circular affordance, `rgba(33,212,217,.14)`
/// background, `play_arrow` at 22px brand teal.
class BotonReproducirCircular extends StatelessWidget {
const BotonReproducirCircular({
super.key,
required this.onPressed,
this.tooltip,
});
final VoidCallback? onPressed;
final String? tooltip;
@override
Widget build(BuildContext context) {
return Material(
key: const ValueKey('boton-reproducir-circular'),
color: PluriWaveTokens.brand.withValues(alpha: 0.14),
shape: const CircleBorder(),
child: IconButton(
tooltip: tooltip,
icon: const Icon(
Icons.play_arrow_rounded,
size: 22,
color: PluriWaveTokens.brand,
),
onPressed: onPressed,
constraints: const BoxConstraints.tightFor(width: 44, height: 44),
),
);
}
}
/// t4:304: a 44x44 favourite toggle, 22px icon at 55% opacity. Mirrors
/// `TarjetaEmisora`'s own toggle+snackbar behaviour (duplicated rather than
/// extracted from it — `tarjeta_emisora.dart`'s exact widget tree is pinned
/// by several pre-existing tests this change must not disturb).
class BotonFavoritoEmisora extends StatefulWidget {
const BotonFavoritoEmisora({super.key, required this.emisora});
final Emisora emisora;
@override
State<BotonFavoritoEmisora> createState() => _BotonFavoritoEmisoraState();
}
class _BotonFavoritoEmisoraState extends State<BotonFavoritoEmisora> {
bool _toggling = false;
Future<void> _toggle() async {
if (_toggling) return;
setState(() => _toggling = true);
final estado = context.read<EstadoRadio>();
final esFav = await estado.toggleFavorito(widget.emisora);
if (mounted) setState(() => _toggling = false);
if (mounted) {
final l10n = AppLocalizations.of(context);
final stationName = localizedStationName(l10n, widget.emisora.nombre);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
esFav
? l10n.favoritesAddedMessage(stationName)
: l10n.favoritesRemovedMessage(stationName),
),
duration: const Duration(seconds: 2),
),
);
}
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final esFavorito = context.select<EstadoRadio, bool>(
(estado) =>
estado.listaFavoritos.any((e) => e.uuid == widget.emisora.uuid),
);
return Semantics(
container: true,
button: true,
toggled: esFavorito,
label:
esFavorito ? l10n.favoritesRemoveTooltip : l10n.favoritesAddTooltip,
child: Material(
key: const ValueKey('boton-favorito-emisora'),
color: Colors.transparent,
child: InkWell(
customBorder: const CircleBorder(),
onTap: _toggling ? null : _toggle,
child: SizedBox(
width: 44,
height: 44,
child: Icon(
esFavorito
? Icons.favorite_rounded
: Icons.favorite_outline_rounded,
size: 22,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.55),
),
),
),
),
);
}
}
class _ArteFilaEmisora extends StatelessWidget {
const _ArteFilaEmisora({required this.emisora});
final Emisora emisora;
@override
Widget build(BuildContext context) {
final theme = Theme.of(context);
if (emisora.favicon != null && emisora.favicon!.isNotEmpty) {
return CachedNetworkImage(
imageUrl: emisora.favicon!,
fit: BoxFit.cover,
placeholder: (_, __) => _shimmer(theme),
errorWidget: (_, __, ___) => _iconoFallback(theme),
);
}
return _iconoFallback(theme);
}
Widget _shimmer(ThemeData theme) => shimmer.Shimmer.fromColors(
baseColor: theme.colorScheme.surfaceContainerHighest,
highlightColor: theme.colorScheme.surface,
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,
),
);
}
@@ -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,12 +2,31 @@ 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
/// lines 512-539, 625 — "Voz clara", "Alta", "3 guardados", "Alfabético",
/// "7 · 84 MB", "Español", "Hoy, 08:12", "200 MB"). `FilaAjuste` used to
/// accept only `icon`/`titulo`/`onTap` — no value slot at all.
/// Pre-existing project constraint (see `pantalla_ajustes_test.dart`):
/// `PluriGlassSurface` paints a background over `ListTile`'s ink layer,
/// which Flutter flags as a warning-level assertion, not a correctness bug.
/// Only needed by the `GrupoAjustes` test below — the bare `FilaAjuste`
/// tests above never wrap a `ListTile` inside that surface.
void _suppressListTileInkAssertion() {
final original = FlutterError.onError;
FlutterError.onError = (details) {
if (details.exceptionAsString().contains(
'ListTile background color or ink splashes may be invisible',
)) {
return;
}
original?.call(details);
};
addTearDown(() => FlutterError.onError = original);
}
void main() {
Widget host(Widget child) {
return MaterialApp(
@@ -49,4 +68,136 @@ void main() {
expect(find.text('Info'), findsOneWidget);
expect(find.byIcon(Icons.chevron_right_rounded), findsOneWidget);
});
testWidgets(
'visual fidelity (audit 10.6/10.8/10.9): leading icon is 21px, the '
'title is 14px, and the chevron is 19px at 40% opacity (t4 lines '
'514-515)',
(tester) async {
await tester.pumpWidget(
host(
FilaAjuste(
icon: Icons.equalizer_rounded,
titulo: 'Ecualizador base',
onTap: () {},
),
),
);
final icon = tester.widget<Icon>(find.byIcon(Icons.equalizer_rounded));
expect(icon.size, 21);
final titulo = tester.widget<Text>(find.text('Ecualizador base'));
expect(titulo.style?.fontSize, 14);
final chevron = tester.widget<Icon>(
find.byIcon(Icons.chevron_right_rounded),
);
expect(chevron.size, 19);
expect(chevron.color, const Color(0xFFF2F7FA).withValues(alpha: 0.4));
},
);
testWidgets(
'visual fidelity (audit S10): GrupoAjustes insets its row divider by '
'47px, not full-bleed (t4 line 516)',
(tester) async {
_suppressListTileInkAssertion();
await tester.pumpWidget(
host(
GrupoAjustes(
titulo: 'AUDIO',
filas: [
FilaAjuste(
icon: Icons.equalizer_rounded,
titulo: 'Uno',
onTap: () {},
),
FilaAjuste(icon: Icons.hd_rounded, titulo: 'Dos', onTap: () {}),
],
),
),
);
final divider = tester.widget<Divider>(find.byType(Divider));
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', (
@@ -146,7 +146,8 @@ void main() {
group('WU11 — restyle a pantalla completa', () {
testWidgets(
'el tiempo gigante usa PluriWaveTypography.heroTime envuelto en '
'el tiempo gigante usa PluriWaveTypography.heroTime (con un ajuste '
'local de letter-spacing/height, audit 9.5) envuelto en '
'FittedBox(scaleDown)',
(tester) async {
await _montarPantalla(tester);
@@ -155,7 +156,22 @@ void main() {
expect(heroFinder, findsOneWidget);
final texto = tester.widget<Text>(heroFinder);
final contexto = tester.element(heroFinder);
expect(texto.style, contexto.pluriType.heroTime);
final base = contexto.pluriType.heroTime;
// Audit 9.5 (t4 line 417): the prototype wants 88px/w800/ls-4/lh.95
// on THIS screen only. heroTime itself must stay ls-2.0/height 1.0
// — EditorHoraInline (the alarm editor's hour block) is the other
// consumer and wants height:1 (t4 line 379) — so this is a LOCAL
// copyWith override, not a change to the shared token.
expect(texto.style?.fontSize, base.fontSize);
expect(texto.style?.fontWeight, base.fontWeight);
expect(texto.style?.letterSpacing, -4);
expect(texto.style?.height, 0.95);
expect(
base.letterSpacing,
-2.0,
reason: 'the shared token is unchanged',
);
expect(base.height, 1.0, reason: 'the shared token is unchanged');
expect(
find.ancestor(of: heroFinder, matching: find.byType(FittedBox)),
findsOneWidget,
@@ -0,0 +1,189 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter_animate/flutter_animate.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_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';
/// Tier 4 visual fidelity, ringing screen: 9.1 (pulsing amber halo), 9.6
/// (art geometry), 9.8 (snooze eyebrow icon). Item 9.5 (hero time metrics)
/// is covered in `pantalla_alarma_sonando_scaffold_test.dart`, which already
/// owned the pre-existing `heroTime`-equality assertion that item revises.
///
/// 9.1 is the one with real hazard: the prototype's halo is a literal CSS
/// `animation: pw-pulse 2.4s ease-in-out infinite`. This screen's dismiss
/// guard (`pantalla_alarma_sonando_dismiss_guard_test.dart`, protected,
/// byte-identical to `main`) calls `pumpAndSettle()` after every mount and
/// every interaction — an actually-infinite `AnimationController.repeat()`
/// anywhere in this widget's subtree would hang every one of those calls
/// forever, with no way to fix it since that file cannot be touched. The
/// halo below is a BOUNDED pulse (one grow/settle cycle, no `.repeat()`) —
/// genuine animated motion, but one that always finishes.
Future<void> _montarPantalla(
WidgetTester tester, {
bool disableAnimations = false,
}) 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: 'tier4-1',
nombre: 'Despertar',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
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(),
builder:
(context, child) => MediaQuery(
data: MediaQuery.of(
context,
).copyWith(disableAnimations: disableAnimations),
child: child!,
),
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.1): a pulsing amber halo renders behind the '
'hero content, and settling it NEVER hangs (protects the dismiss-guard '
"test's repeated pumpAndSettle calls)",
(tester) async {
await _montarPantalla(tester);
expect(find.byKey(const ValueKey('ringing-pulse-halo')), findsOneWidget);
// The critical regression guard: this must COMPLETE. An infinite
// AnimationController.repeat() here would hang every pumpAndSettle()
// call in the protected dismiss-guard test forever.
await tester.pumpAndSettle();
},
);
testWidgets(
'visual fidelity (audit 9.1): the halo skips its Animate wrapper under '
'reduced motion (matches every other entry animation in this app)',
(tester) async {
await _montarPantalla(tester, disableAnimations: true);
expect(find.byType(Animate), findsNothing);
expect(find.byKey(const ValueKey('ringing-pulse-halo')), findsOneWidget);
},
);
testWidgets(
'visual fidelity (audit 9.6): the ringing art is 180 square with a 36 '
'corner radius, not 168/30',
(tester) async {
await _montarPantalla(tester);
final art = tester
.widgetList<Image>(find.byType(Image))
.where((img) => img.width == 180 && img.height == 180);
expect(
art,
isNotEmpty,
reason: 'prototype t4 line 421 sizes the ringing-screen art at 180',
);
final clip = tester.widget<ClipRRect>(
find.ancestor(
of: find.byWidgetPredicate(
(w) => w is Image && w.width == 180 && w.height == 180,
),
matching: find.byType(ClipRRect),
),
);
expect(
(clip.borderRadius as BorderRadius).topLeft,
const Radius.circular(36),
);
},
);
testWidgets(
'visual fidelity (audit 9.8): the POSPONER eyebrow keeps its snooze '
'icon at 19px warmCoral',
(tester) async {
await _montarPantalla(tester);
final icon = tester.widget<Icon>(find.byIcon(Icons.snooze_rounded));
expect(icon.size, 19);
expect(icon.color, const Color(0xFFF4B860));
},
);
}
@@ -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();
},
);
}
@@ -0,0 +1,90 @@
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/pantallas/pantalla_alarmas.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes.dart';
import '../helpers/fakes_alarmas.dart';
/// Audit 7.5 (Tier 4 visual fidelity): the prototype's gap between stacked
/// alarm cards is 10px (t4 line 334, `gap:10` on the alarms column), not the
/// build's previous 12px.
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('spaces stacked alarm cards 10px apart, not 12', (tester) async {
tester.view.physicalSize = const Size(1440, 3200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final radio = EstadoRadio(
audio: FakeServicioAudio(),
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, 6, 0)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estadoAlarmas.dispose);
addTearDown(android.dispose);
await estadoAlarmas.guardarAlarma(
const AlarmaMusical(
id: 'card-gap-a',
nombre: 'Primera',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
await estadoAlarmas.guardarAlarma(
const AlarmaMusical(
id: 'card-gap-b',
nombre: 'Segunda',
hora: 8,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
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,
home: const Scaffold(body: PantallaAlarmas()),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
final gap = tester.widget<SizedBox>(
find.byKey(const ValueKey('alarm-card-gap-card-gap-a')),
);
expect(gap.height, 10);
});
}
@@ -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,
);
},
);
});
}
@@ -65,6 +65,110 @@ void main() {
},
);
testWidgets(
'visual fidelity (audit 14.4/14.5): body copy is 14.5px, bullet titles '
'are 13.5px/w800, bullet subtitles are 11.5px (t4 lines 697/700-702)',
(tester) async {
final navegacion = EstadoNavegacionRaiz();
addTearDown(navegacion.dispose);
await tester.pumpWidget(_app(navegacion: navegacion));
await tester.pumpAndSettle();
final l10n = lookupAppLocalizations(const Locale('es'));
final body = tester.widget<Text>(
find.textContaining('53.412 emisoras de 238 países'),
);
expect(body.style?.fontSize, 14.5);
final tituloBullet = tester.widget<Text>(
find.text(l10n.welcomeBullet1Title),
);
expect(tituloBullet.style?.fontSize, 13.5);
expect(tituloBullet.style?.fontWeight, FontWeight.w800);
final subtituloBullet = tester.widget<Text>(
find.text(l10n.welcomeBullet1Subtitle),
);
expect(subtituloBullet.style?.fontSize, 11.5);
},
);
testWidgets(
'visual fidelity (audit 14.6): the 3 feature bullets are spaced 12px '
'apart, not 16 (t4 line 701)',
(tester) async {
final navegacion = EstadoNavegacionRaiz();
addTearDown(navegacion.dispose);
await tester.pumpWidget(_app(navegacion: navegacion));
await tester.pumpAndSettle();
final bullets = find.byType(FilaCaracteristicaBienvenida);
final gap =
tester.getTopLeft(bullets.at(1)).dy -
tester.getBottomLeft(bullets.at(0)).dy;
expect(gap, 12);
},
);
testWidgets(
'visual fidelity (audit 14.8): the logo mark is clipped to a 20px '
'rounded rect, not painted square (t4 line 693)',
(tester) async {
final navegacion = EstadoNavegacionRaiz();
addTearDown(navegacion.dispose);
await tester.pumpWidget(_app(navegacion: navegacion));
await tester.pumpAndSettle();
final clip = tester.widget<ClipRRect>(
find.ancestor(
of: find.byKey(const ValueKey('welcome-logo')),
matching: find.byType(ClipRRect),
),
);
expect(clip.borderRadius, BorderRadius.circular(20));
},
);
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);
});
}
+347
View File
@@ -7,11 +7,14 @@ import 'package:pluriwave/estado/estado_ecualizador.dart';
import 'package:pluriwave/estado/estado_grabacion.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/pais_radio.dart';
import 'package:pluriwave/pantallas/pantalla_buscar.dart';
import 'package:pluriwave/pantallas/pantalla_favoritos.dart';
import 'package:pluriwave/pantallas/pantalla_paises.dart';
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/tarjeta_emisora.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -178,6 +181,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 '
@@ -541,6 +639,46 @@ void main() {
expect(find.byType(PantallaPaises), findsOneWidget);
});
testWidgets(
'Item 24: tapping a country row in Paises pops back and filters '
'Buscar by that ISO code',
(tester) async {
_setLargeSurfaceSize(tester);
final estado = _crearEstado(
radio: FakeServicioRadio(
paises: const [
PaisRadio(
nombre: 'Kazakhstan',
codigoIso: 'KZ',
numeroEmisoras: 12,
),
],
busqueda: [emisoraDemo(uuid: 'kz-1', nombre: 'Radio Kazajstan')],
),
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await tester.pumpWidget(_conProviders(estado, _testApp()));
await _pumpStableFrame(tester);
await tester.tap(find.byKey(const Key('explore-cell-paises')));
await _pumpStableFrame(tester);
expect(find.byType(PantallaPaises), findsOneWidget);
await tester.tap(find.text('Kazakhstan'));
await _pumpStableFrame(tester);
expect(
find.byType(PantallaPaises),
findsNothing,
reason: 'selecting a country pops back to Buscar',
);
expect(find.text('Radio Kazajstan'), findsOneWidget);
expect(estado.busqueda.resultados.map((e) => e.uuid), contains('kz-1'));
},
);
testWidgets(
'tapping Generos opens a picker sheet; selecting a genre closes it '
'and filters the discovery grid (same capability, relocated)',
@@ -621,6 +759,215 @@ void main() {
},
);
});
// Item 23 / audit 6.5 + 6.6 (t4:302-306): flat, background-less search-
// result rows with a square thumbnail, a favourite toggle, and a circular
// play affordance -- replacing the full glass TarjetaEmisora card. Rows
// sit back-to-back (no 10px separator), matching t4:299's bare column.
group('Item 23 -- flat search-result rows (audit 6.5, 6.6)', () {
testWidgets(
'each result is a flat FilaEmisoraPlana with a "genre - country - '
'kbps" meta line, not the full glass card',
(tester) async {
_setLargeSurfaceSize(tester);
final estado = _crearEstado(
radio: FakeServicioRadio(
busqueda: [
emisoraDemo(
uuid: 'r-1',
nombre: 'Radio Uno',
).copyWith(tags: 'Rock', pais: 'Spain', bitrate: 128),
],
),
);
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);
expect(find.byType(FilaEmisoraPlana), findsOneWidget);
expect(
find.byType(TarjetaEmisora),
findsNothing,
reason: 'audit 6.5 replaces the full glass card with a flat row',
);
expect(find.text('Rock · Spain · 128 kbps'), findsOneWidget);
expect(find.byType(BotonFavoritoEmisora), findsOneWidget);
expect(find.byType(BotonReproducirCircular), findsOneWidget);
},
);
testWidgets('rows sit back-to-back, with no 10px separator (t4:299)', (
tester,
) async {
_setLargeSurfaceSize(tester);
final estado = _crearEstado(
radio: FakeServicioRadio(
busqueda: [
emisoraDemo(uuid: 'r-1', nombre: 'Radio Uno'),
emisoraDemo(uuid: 'r-2', nombre: 'Radio Dos'),
],
),
);
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 filas = find.byType(FilaEmisoraPlana);
expect(filas, findsNWidgets(2));
final primeraAbajo = tester.getBottomLeft(filas.at(0)).dy;
final segundaArriba = tester.getTopLeft(filas.at(1)).dy;
expect(
segundaArriba - primeraAbajo,
closeTo(0, 0.5),
reason:
't4:299 rows have no separator; each keeps only its own '
'8px padding',
);
});
testWidgets(
'tapping the favourite toggle on a search result adds it to favorites',
(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);
expect(find.byIcon(Icons.favorite_outline_rounded), findsOneWidget);
await tester.tap(find.byIcon(Icons.favorite_outline_rounded));
await _pumpStableFrame(tester);
expect(await estado.esFavorito('r-1'), isTrue);
},
);
});
// Item 25 / audit 13.2 (t4:643-646): a reconnect card with a rotating
// ring, the station name, and a stop affordance -- previously the ONLY
// signal of a reconnect was a word in the mini player.
//
// 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 '
'reconnecting',
(tester) async {
_setLargeSurfaceSize(tester);
final audio = FakeServicioAudio();
final estado = _crearEstado(audio: audio);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await audio.reproducir(emisoraDemo(uuid: 'r1', nombre: 'Radio Uno'));
await tester.pumpWidget(_conProviders(estado, _testApp()));
await _pumpStableFrame(tester);
expect(
find.byIcon(Icons.stop_circle_rounded),
findsNothing,
reason: 'not reconnecting yet -- the card must not show',
);
audio.emitirEstado(EstadoReproduccion.reconectando);
await tester.pump();
await tester.pump();
expect(find.text('Radio Uno'), findsOneWidget);
final l10n = _l10nDe(tester);
expect(find.text(l10n.playbackStatusReconnecting), findsOneWidget);
expect(find.byIcon(Icons.stop_circle_rounded), findsOneWidget);
},
);
testWidgets('tapping stop calls EstadoRadio.detenerReproduccion', (
tester,
) async {
_setLargeSurfaceSize(tester);
final audio = FakeServicioAudio();
final estado = _crearEstado(audio: audio);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await audio.reproducir(emisoraDemo(uuid: 'r1', nombre: 'Radio Uno'));
await tester.pumpWidget(_conProviders(estado, _testApp()));
await _pumpStableFrame(tester);
audio.emitirEstado(EstadoReproduccion.reconectando);
await tester.pump();
await tester.pump();
await tester.tap(find.byIcon(Icons.stop_circle_rounded));
await tester.pump();
await tester.pump();
// `EstadoRadio.emisoraActual` deliberately keeps showing the last
// selected station even once stopped (an existing, unrelated
// property of `_emisoraSeleccionada`'s tracking) -- the real,
// user-visible effect of tapping stop is that this card disappears,
// since it only renders while `reconectando`.
expect(find.byKey(const ValueKey('tarjeta-reconectando')), findsNothing);
});
});
}
EstadoRadio _crearEstado({
+149 -2
View File
@@ -6,7 +6,9 @@ import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/pantallas/ajustes/pantalla_ajustes_grupos_favoritos.dart';
import 'package:pluriwave/pantallas/pantalla_favoritos.dart';
import 'package:pluriwave/widgets/fila_emisora_plana.dart';
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -100,6 +102,33 @@ void main() {
await tester.pump(const Duration(milliseconds: 100));
}
group('visual fidelity (audit 4.5): dashed custom-station CTA', () {
testWidgets(
'padding is 14 all around, the icon is a plain add glyph at 20px, '
'and the label is 13.5px/w800 (t4 line 235)',
(tester) async {
final estado = await crearEstadoVacio();
addTearDown(estado.dispose);
await tester.pumpWidget(buildScreen(estado));
await pumpStable(tester);
final padding = tester.widget<Padding>(
find.byKey(const Key('custom-station-cta-padding')),
);
expect(padding.padding, const EdgeInsets.all(14));
expect(find.byIcon(Icons.add_circle_outline_rounded), findsNothing);
final icon = tester.widget<Icon>(find.byIcon(Icons.add_rounded));
expect(icon.size, 20);
final l10n = lookupAppLocalizations(const Locale('en'));
final texto = tester.widget<Text>(find.text(l10n.customStationsAddCta));
expect(texto.style?.fontSize, 13.5);
expect(texto.style?.fontWeight, FontWeight.w800);
},
);
});
// ── EstadoRadio: new state surface (state-layer, not widget-layer) ───────
test('listaFavoritosManual returns favorites in stored order, NOT re-sorted '
@@ -328,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
@@ -356,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);
@@ -381,6 +418,116 @@ void main() {
expect(find.widgetWithText(TextFormField, 'Name *'), findsOneWidget);
});
// Item 23 / audit 4.3 (t4:226-232): flat, background-less rows with a
// square thumbnail and a circular play affordance, replacing the full
// glass TarjetaEmisora card + two stacked filledTonal buttons.
group('Item 23 -- flat rows (audit 4.3)', () {
testWidgets(
'each row is a flat FilaEmisoraPlana, not the full glass card',
(tester) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
final estado = await crearEstadoConFavoritos();
addTearDown(estado.dispose);
await tester.pumpWidget(buildScreen(estado));
await pumpStable(tester);
expect(find.byType(FilaEmisoraPlana), findsNWidgets(3));
expect(
find.byType(TarjetaEmisora),
findsNothing,
reason: 'audit 4.3 replaces the full glass card with a flat row',
);
expect(
find.byIcon(Icons.drag_indicator_rounded),
findsNWidgets(3),
reason: 't4:227 drag_indicator, not drag_handle',
);
expect(find.byIcon(Icons.drag_handle_rounded), findsNothing);
expect(
find.byType(BotonReproducirCircular),
findsNWidgets(3),
reason: 't4:230 a circular play affordance per row',
);
},
);
testWidgets(
'the overflow menu offers exactly "Move to list" and "Remove from '
'favorites"',
(tester) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
final estado = await crearEstadoConFavoritos();
addTearDown(estado.dispose);
await tester.pumpWidget(buildScreen(estado));
await pumpStable(tester);
await tester.tap(find.byIcon(Icons.more_vert_rounded).first);
await tester.pumpAndSettle();
expect(find.text('Move to list'), findsOneWidget);
expect(find.text('Remove from favorites'), findsOneWidget);
expect(find.byType(PopupMenuItem<String>), findsNWidgets(2));
},
);
testWidgets(
'"Remove from favorites" still calls the same removal path as before',
(tester) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
final estado = await crearEstadoConFavoritos();
addTearDown(estado.dispose);
await tester.pumpWidget(buildScreen(estado));
await pumpStable(tester);
// Invoke the callback directly rather than opening the overlay and
// tapping its rendered item by screen position: leaving that modal
// route open while the tap it triggers removes and unmounts the
// very row that anchors it is a real hang hazard, confirmed while
// developing this test (multi-minute stall, same class of risk as
// the project's other documented `pumpAndSettle` traps).
final boton = tester.widget<PopupMenuButton<String>>(
find.byType(PopupMenuButton<String>).first,
);
boton.onSelected!('remove');
await pumpStable(tester);
expect(find.text('Station A'), findsNothing);
},
);
});
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) {
+92 -1
View File
@@ -8,6 +8,7 @@ import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/archivo_grabacion.dart';
import 'package:pluriwave/pantallas/pantalla_grabaciones.dart';
import 'package:pluriwave/servicios/servicio_grabacion_radio.dart';
import 'package:pluriwave/widgets/pluri_glass_surface.dart';
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -137,7 +138,9 @@ void main() {
);
await pumpStable(tester);
await tester.tap(find.byIcon(Icons.settings_outlined));
// Audit 12.1 (t4:610): the header action icon is `folder_open`, not
// a generic gear.
await tester.tap(find.byIcon(Icons.folder_open_rounded));
await pumpStable(tester);
expect(find.byType(PluriPushScaffold), findsNWidgets(2));
@@ -170,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([
@@ -194,6 +240,51 @@ void main() {
expect(find.byIcon(Icons.play_circle_fill_rounded), findsNWidgets(3));
});
// Item 23 / audit 12.4 (t4:616-619): flat, background-less rows with a
// 44x44/radius-12 thumbnail placeholder -- replacing the PluriGlassSurface
// + ListTile card, which had no artwork slot at all.
testWidgets(
'rows are flat -- no ListTile, no per-row PluriGlassSurface -- with a '
'44x44/radius-12 thumbnail placeholder',
(tester) async {
final estado = EstadoGrabacion(
servicio: _FakeServicioGrabacionConArchivos([
fijaA,
fijaB,
], maxBytesFijo: 200 * 1024 * 1024),
);
addTearDown(estado.dispose);
await tester.pumpWidget(
buildScreen(
estado: estado,
reproductor: _ReproductorGrabacionesFake(const {}),
),
);
await pumpStable(tester);
expect(
find.byType(ListTile),
findsNothing,
reason: 'audit 12.4 replaces the ListTile row with a flat Row',
);
expect(
find.byType(PluriGlassSurface),
findsOneWidget,
reason: 'only the storage card keeps a surface -- rows do not',
);
final miniaturas = find.byKey(const ValueKey('fila-grabacion-arte'));
expect(miniaturas, findsNWidgets(2));
expect(tester.getSize(miniaturas.first), const Size(44, 44));
final clip = tester.widget<ClipRRect>(miniaturas.first);
expect(
(clip.borderRadius as BorderRadius).topLeft,
const Radius.circular(12),
);
},
);
testWidgets('15.2-B: empty folder renders an empty state, not an error', (
tester,
) async {
+364
View File
@@ -10,6 +10,7 @@ import 'package:pluriwave/estado/estado_radio.dart';
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/visualizador_audio.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -334,6 +335,369 @@ void main() {
expect(find.byKey(const ValueKey('escuchar-hero-meta')), findsNothing);
},
);
testWidgets('visual fidelity (audit 1.11): a count badge sits next to "Tus '
'emisoras", showing the TOTAL favorite count, not the 8-capped grid '
'size', (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);
for (var i = 0; i < 11; i++) {
await favoritos.agregar(
emisoraDemo(uuid: 'badge-$i', nombre: 'Badge Estacion $i'),
);
}
await estado.cargarFavoritos();
await tester.pumpWidget(
_conProviders(estado, _testApp(const PantallaInicio())),
);
await _pumpStableFrame(tester);
expect(
find.text('11'),
findsOneWidget,
reason:
'prototype t4 line 80: the badge reflects how many favorites '
'exist, even though the grid below caps display at 8',
);
});
testWidgets(
'visual fidelity (audit 1.12): "Ver todas" renders as plain brand-teal '
'text, not a Material TextButton',
(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: 'f1', nombre: 'Favorita Uno'));
await estado.cargarFavoritos();
await tester.pumpWidget(
_conProviders(estado, _testApp(const PantallaInicio())),
);
await _pumpStableFrame(tester);
expect(find.byType(TextButton), findsNothing);
final texto = tester.widget<Text>(find.text('Ver todas'));
expect(texto.style?.fontSize, 12);
expect(texto.style?.fontWeight, FontWeight.w800);
expect(texto.style?.color, const Color(0xFF21D4D9));
},
);
testWidgets(
'S7 visual fidelity: "Tus emisoras" uses the in-page section heading '
'style (15/w800/ls-.2), not titleMedium(16)/w900',
(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: 'f1', nombre: 'Favorita Uno'));
await estado.cargarFavoritos();
await tester.pumpWidget(
_conProviders(estado, _testApp(const PantallaInicio())),
);
await _pumpStableFrame(tester);
final texto = tester.widget<Text>(find.text('Tus emisoras'));
expect(texto.style?.fontSize, 15, reason: 'prototype t4 line 80');
expect(texto.style?.fontWeight, FontWeight.w800);
expect(texto.style?.letterSpacing, -0.2);
},
);
testWidgets(
'visual fidelity (audit 1.1): a full-bleed art/gradient/noise wash '
'sits behind the whole screen, keyed so it can be found',
(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: 'f1', nombre: 'Favorita Uno');
await favoritos.agregar(sonando);
await estado.cargarFavoritos();
await tester.runAsync(() => estado.reproducir(sonando));
await tester.pumpWidget(
_conProviders(estado, _testApp(const PantallaInicio())),
);
await _pumpBounded(tester);
expect(
find.byKey(const ValueKey('escuchar-background-art')),
findsOneWidget,
reason:
'prototype t4 lines 49-51: full-bleed art at .5 opacity + a '
'gradient + a noise texture',
);
},
);
testWidgets(
'visual fidelity (audit 1.1): the background wash still renders with '
'nothing playing (gradient/noise only, no station art to show)',
(tester) async {
_setLargeSurfaceSize(tester);
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadio(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await tester.pumpWidget(
_conProviders(estado, _testApp(const PantallaInicio())),
);
await _pumpStableFrame(tester);
expect(
find.byKey(const ValueKey('escuchar-background-art')),
findsOneWidget,
);
},
);
testWidgets('visual fidelity (audit 1.2): Escuchar has its own 52px "Now '
'listening" eyebrow header, not the shared 56px PluriRootHeader', (
tester,
) async {
_setLargeSurfaceSize(tester);
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadio(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await tester.pumpWidget(
_conProviders(estado, _testApp(const PantallaInicio())),
);
await _pumpStableFrame(tester);
expect(find.byType(PluriRootHeader), findsNothing);
expect(
find.text('Escuchando ahora'),
findsOneWidget,
reason: 'prototype t4 line 53: "ESCUCHANDO AHORA"',
);
expect(find.byIcon(Icons.bedtime_outlined), findsOneWidget);
final cabeceras = tester
.widgetList<SizedBox>(find.byType(SizedBox))
.where((s) => s.height == 52);
expect(cabeceras, isNotEmpty, reason: 'prototype t4 line 53: height:52px');
});
testWidgets(
"visual fidelity (audit 1.2): the header's bedtime icon still opens "
'the shared sleep-timer sheet',
(tester) async {
_setLargeSurfaceSize(tester);
final estado = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadio(),
resolverArchivoCustom: _archivoCustomVacio,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
await tester.runAsync(estado.inicializar);
await tester.pumpWidget(
_conProviders(estado, _testApp(const PantallaInicio())),
);
await _pumpStableFrame(tester);
await tester.tap(find.byIcon(Icons.bedtime_outlined));
await _pumpStableFrame(tester);
expect(find.byType(ActionChip), findsWidgets);
},
);
testWidgets('visual fidelity (audit 1.5): the station name is 24/w800/ls-.5/'
'height 1.12, not titleLarge(22) with no letter-spacing/height', (
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);
// Deliberately NOT added to favoritos: the "Tus emisoras" grid also
// renders a station's bare name, and this assertion targets the
// hero's OWN station-name Text specifically -- a favorited station
// with the same name would render twice and make `find.text` throw.
final sonando = emisoraDemo(uuid: 'f1', nombre: 'Radio Horizonte');
await tester.runAsync(() => estado.reproducir(sonando));
await tester.pumpWidget(
_conProviders(estado, _testApp(const PantallaInicio())),
);
await _pumpBounded(tester);
final texto = tester.widget<Text>(find.text('Radio Horizonte'));
expect(texto.style?.fontSize, 24, reason: 'prototype t4 line 60');
expect(texto.style?.fontWeight, FontWeight.w800);
expect(texto.style?.letterSpacing, -0.5);
expect(texto.style?.height, 1.12);
});
testWidgets(
'visual fidelity (audit 1.8): the hero play button is 72x72 with a '
'38px icon, not 56x56 with a 28px icon',
(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: 'f1', nombre: 'Favorita Uno');
await favoritos.agregar(sonando);
await estado.cargarFavoritos();
await tester.runAsync(() => estado.reproducir(sonando));
await tester.pumpWidget(
_conProviders(estado, _testApp(const PantallaInicio())),
);
await _pumpBounded(tester);
final botones = tester
.widgetList<SizedBox>(find.byType(SizedBox))
.where((s) => s.width == 72 && s.height == 72);
expect(botones, isNotEmpty, reason: 'prototype t4 line 73');
final iconos = tester
.widgetList<Icon>(find.byType(Icon))
.where(
(i) =>
i.icon == Icons.play_arrow_rounded ||
i.icon == Icons.pause_rounded,
);
expect(iconos, isNotEmpty);
expect(iconos.first.size, 38);
},
);
testWidgets(
'visual fidelity (audit 1.9): the EQ/record/quality pill is centred, '
'pill-shaped and uses expand_less at 17px -- not a left-aligned '
'OutlinedButton with tune_rounded',
(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: 'f1', nombre: 'Favorita Uno');
await favoritos.agregar(sonando);
await estado.cargarFavoritos();
await tester.runAsync(() => estado.reproducir(sonando));
await tester.pumpWidget(
_conProviders(estado, _testApp(const PantallaInicio())),
);
await _pumpBounded(tester);
expect(find.byType(OutlinedButton), findsNothing);
expect(
find.byIcon(Icons.expand_less_rounded),
findsOneWidget,
reason: 'prototype t4 line 78: expand_less at 17px',
);
final icono = tester.widget<Icon>(find.byIcon(Icons.expand_less_rounded));
expect(icono.size, 17);
final pildoras = tester
.widgetList<DecoratedBox>(find.byType(DecoratedBox))
.map((d) => d.decoration)
.whereType<BoxDecoration>()
.where((d) => d.borderRadius == BorderRadius.circular(999));
expect(
pildoras,
isNotEmpty,
reason: 'prototype t4 line 78: border-radius:999px pill',
);
},
);
}
/// Mirrors the app.dart wiring: EstadoRadio owns the domain notifiers and
+188 -5
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,13 +63,18 @@ 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);
expect(find.text('France'), findsOneWidget);
// 'Spain' renders twice: "Tus idiomas" (locale es -> representative
// country ES) AND the full list below.
// 'France' and 'Spain' each render twice: once in "Tus idiomas" (FR
// and ES both back a supported locale) and once in the full list
// below. Item 24's row now shows the name as its OWN Text (not
// concatenated with the count into one Chip label), so both are
// independently findable in each section.
expect(find.text('France'), findsWidgets);
expect(find.text('Spain'), findsWidgets);
// Each entry's parsed `stationcount` renders via the existing
@@ -77,10 +83,187 @@ void main() {
// as a plain number here, never throwing a cast error.
expect(find.text(l10n.stationsCount(482)), findsWidgets);
expect(find.text(l10n.stationsCount(120)), findsOneWidget);
expect(find.text(l10n.stationsCount(75)), findsOneWidget);
// Also doubled, for the same reason as 'France' above.
expect(find.text(l10n.stationsCount(75)), findsWidgets);
},
);
// Item 24 / audit 5.1-5.3, 5.5 (t4:256-269): tappable ISO rows, not
// non-interactive chips/plain ListTiles.
group('Item 24 -- tappable ISO rows', () {
testWidgets('rows show the ISO code and no longer use Chip or ListTile', (
tester,
) async {
final estado = EstadoBusqueda(
radio: FakeServicioRadio(
paises: const [
PaisRadio(nombre: 'Spain', codigoIso: 'ES', numeroEmisoras: 482),
PaisRadio(
nombre: 'Argentina',
codigoIso: 'AR',
numeroEmisoras: 120,
),
],
),
);
addTearDown(estado.dispose);
await tester.pumpWidget(buildScreen(estado));
await pumpEstable(tester);
expect(
find.byType(Chip),
findsNothing,
reason: 'audit 5.1 replaces the Wrap-of-Chip with tappable rows',
);
expect(
find.byType(ListTile),
findsNothing,
reason:
'audit 5.3 rows need an ISO-code column a plain ListTile '
'has no slot for',
);
expect(find.text('AR'), findsOneWidget);
// 'ES' renders in BOTH the "Tus idiomas" and "Todos" rows.
expect(find.text('ES'), findsWidgets);
expect(find.byIcon(Icons.chevron_right_rounded), findsWidgets);
});
testWidgets('tapping a row invokes onPaisSeleccionado with that country', (
tester,
) async {
final estado = EstadoBusqueda(
radio: FakeServicioRadio(
paises: const [
PaisRadio(
nombre: 'Argentina',
codigoIso: 'AR',
numeroEmisoras: 120,
),
],
),
);
addTearDown(estado.dispose);
PaisRadio? seleccionado;
await tester.pumpWidget(
ChangeNotifierProvider<EstadoBusqueda>.value(
value: estado,
child: MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: PantallaPaises(
onPaisSeleccionado: (pais) => seleccionado = pais,
),
),
),
);
await pumpEstable(tester);
await tester.tap(find.text('Argentina'));
await pumpEstable(tester);
expect(seleccionado?.codigoIso, 'AR');
});
testWidgets(
'the first "Tus idiomas" row is highlighted: teal-tinted background, '
'bold name',
(tester) async {
final estado = EstadoBusqueda(
radio: FakeServicioRadio(
paises: const [
PaisRadio(nombre: 'Spain', codigoIso: 'ES', numeroEmisoras: 482),
],
),
);
addTearDown(estado.dispose);
await tester.pumpWidget(buildScreen(estado));
await pumpEstable(tester);
final nombre = tester.widget<Text>(find.text('Spain').first);
expect(nombre.style?.fontWeight, FontWeight.w800);
final decorado = tester.widget<DecoratedBox>(
find
.ancestor(
of: find.text('Spain').first,
matching: find.byType(DecoratedBox),
)
.first,
);
final decoration = decorado.decoration as BoxDecoration;
expect(
decoration.color,
isNot(Colors.transparent),
reason: 't4:256 the first row is teal-tinted, not transparent',
);
},
);
});
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 {
@@ -9,7 +9,10 @@ import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
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/visualizador_audio.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -548,4 +551,157 @@ void main() {
);
},
);
group('Visual fidelity (screens 1-3 closing pass)', () {
testWidgets('audit 2.1: the hero art is a fixed 246x246, not width*0.62', (
tester,
) async {
final estado = crearEstado();
addTearDown(estado.dispose);
await montarPantalla(tester, estado);
final sizedBox = tester.widget<SizedBox>(
find.byKey(const Key('player-hero-art')),
);
expect(sizedBox.width, 246, reason: 'prototype t4 line 114');
expect(sizedBox.height, 246);
});
testWidgets(
'audit 2.2: a full-bleed backdrop sits behind the whole screen',
(tester) async {
const estacion = Emisora(
uuid: 'demo-uuid',
nombre: 'Radio Demo',
url: 'https://stream.demo/radio',
favicon: 'https://example.com/art.png',
);
final estado = crearEstado();
addTearDown(estado.dispose);
await montarPantalla(tester, estado, estacion: estacion);
expect(
find.byKey(const Key('player-background-art')),
findsOneWidget,
reason: 'prototype t4 lines 108-109: full-bleed blurred backdrop',
);
},
);
testWidgets('audit 2.2: the backdrop still renders (gradient only) with no '
'station art available', (tester) async {
final estado = crearEstado();
addTearDown(estado.dispose);
await montarPantalla(tester, estado);
expect(find.byKey(const Key('player-background-art')), findsOneWidget);
});
testWidgets('audit 2.3: the title is 26/w800/ls-.5/height 1.15, not '
'headlineSmall(24)/w700', (tester) async {
final estado = crearEstado();
addTearDown(estado.dispose);
await montarPantalla(tester, estado);
final texto = tester.widget<Text>(find.text(emisora.nombre));
expect(texto.style?.fontSize, 26, reason: 'prototype t4 line 116');
expect(texto.style?.fontWeight, FontWeight.w800);
expect(texto.style?.letterSpacing, -0.5);
expect(texto.style?.height, 1.15);
});
testWidgets(
'audit 2.8: only the EQ propio tile is accented -- tinted brand '
'background/border, the other three share a neutral opaque surface',
(tester) async {
final estado = crearEstado();
addTearDown(estado.dispose);
await montarPantalla(tester, estado);
final eqDecoration =
tester
.widget<Container>(
find
.descendant(
of: find.byKey(const Key('player-tool-eq')),
matching: find.byType(Container),
)
.first,
)
.decoration
as BoxDecoration;
expect(
eqDecoration.color,
PluriWaveTokens.brand.withValues(alpha: 0.16),
reason: 'prototype t4 line 133',
);
final recordDecoration =
tester
.widget<Container>(
find
.descendant(
of: find.byKey(const Key('player-tool-record')),
matching: find.byType(Container),
)
.first,
)
.decoration
as BoxDecoration;
expect(
recordDecoration.color,
isNot(PluriWaveTokens.brand.withValues(alpha: 0.16)),
reason: 'prototype t4 line 134: no explicit accent colour',
);
},
);
testWidgets(
'audit 2.9: the transport row is not wrapped in a glass surface '
'card',
(tester) async {
final estado = crearEstado();
addTearDown(estado.dispose);
await montarPantalla(tester, estado);
expect(
find.ancestor(
of: find.byIcon(Icons.favorite_outline_rounded),
matching: find.byType(PluriGlassSurface),
),
findsNothing,
reason: 'prototype t4 lines 124-131: bare row, no glass card',
);
},
);
testWidgets(
'audit 2.10: the visualizer is not wrapped in its own glass card',
(tester) async {
final estado = crearEstado();
addTearDown(estado.dispose);
await montarPantalla(tester, estado);
final visualizador = find.byKey(const Key('player-visualizer'));
expect(tester.widget(visualizador), isA<VisualizadorAudio>());
expect(
find.ancestor(
of: visualizador,
matching: find.byType(PluriGlassSurface),
),
findsNothing,
reason:
'prototype t4 lines 120-122: bars sit on the bare '
'background',
);
},
);
});
}
+103 -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,101 @@ 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);
},
);
});
}
+23 -3
View File
@@ -102,8 +102,19 @@ void main() {
);
testWidgets(
'Escuchar draws its own PluriRootHeader with the tab title, no AppBar',
'Escuchar is the documented header exception: a 52px "now listening" '
'eyebrow instead of a PluriRootHeader title row, and still no AppBar',
(tester) async {
// The prototype gives Escuchar a STRUCTURALLY different header from
// every other root (t4 line 53 vs line 325): 52px rather than 56, an
// eyebrow at 15px/w800/55% opacity rather than a 23px title, and
// trailing actions rather than a CTA. This test originally asserted
// `PluriRootHeader` on all five roots — an over-broad premise, since
// Escuchar's header was never a title row in the design.
//
// What still holds unconditionally, and is what this guards: no
// AppBar anywhere (systemic item S1), and the sleep-timer action that
// used to live in the removed global chrome is still reachable here.
_suppressListTileInkAssertion();
setLargeSurface(tester);
final estado = crearEstadoRadio();
@@ -112,9 +123,18 @@ void main() {
await tester.pumpWidget(testApp(estado, const PantallaInicio()));
await pumpStable(tester);
expect(find.byType(PluriRootHeader), findsOneWidget);
expect(titleInHeader('Listen'), findsOneWidget);
expect(find.byType(AppBar), findsNothing);
expect(
find.byType(PluriRootHeader),
findsNothing,
reason: 'Escuchar uses its own eyebrow header, not the shared one',
);
expect(find.text('Now listening'), findsOneWidget);
expect(
find.byIcon(Icons.bedtime_outlined),
findsOneWidget,
reason: 'the sleep-timer action survived the global AppBar removal',
);
},
);
+14
View File
@@ -65,6 +65,20 @@ void main() {
expect(type.eyebrowLabel.letterSpacing, 0.8);
});
test(
'S7 (visual fidelity): inPageSectionHeading is 15 / w800 / '
'letter-spacing -0.2 -- the ADR-1 amendment adding a seventh style',
() {
expect(
type.inPageSectionHeading.fontSize,
15,
reason: 'prototype t4 lines 80, 153, 164, 173',
);
expect(type.inPageSectionHeading.fontWeight, FontWeight.w800);
expect(type.inPageSectionHeading.letterSpacing, -0.2);
},
);
test('S9 (Tier 1 visual fidelity): eyebrowLabel bakes in the prototype '
"colour rgba(242,247,250,.42) — it used to carry no colour at all, "
'so it rendered at full onSurface opacity', () {
+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));
});
}
+146
View File
@@ -0,0 +1,146 @@
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/tema/pluriwave_tokens.dart';
import 'package:pluriwave/widgets/fila_emisora_plana.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
import 'package:provider/provider.dart';
import '../helpers/fakes.dart';
import '../helpers/fakes_alarmas.dart';
EstadoRadio _estado() => EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
iniciarAutomaticamente: false,
);
Widget _host(EstadoRadio estado, Widget child) {
return ChangeNotifierProvider<EstadoRadio>.value(
value: estado,
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(body: child),
),
);
}
/// Item 23 / audit 4.3 + 6.5 (t4:226-232, 302-306): the shared flat row
/// backing Favoritos and search results.
void main() {
testWidgets(
'renders a flat, background-less row -- no TarjetaEmisora underneath',
(tester) async {
final estado = _estado();
addTearDown(estado.dispose);
final emisora = emisoraDemo(uuid: 'a', nombre: 'Station A');
await tester.pumpWidget(
_host(estado, FilaEmisoraPlana(emisora: emisora, meta: 'Rock - Spain')),
);
await tester.pump();
expect(find.text('Station A'), findsOneWidget);
expect(find.text('Rock - Spain'), findsOneWidget);
expect(
find.byType(TarjetaEmisora),
findsNothing,
reason: 'a flat row does not reuse the full glass card',
);
},
);
testWidgets('the thumbnail is 48x48 with a 12 radius (t4:226, t4:302)', (
tester,
) async {
final estado = _estado();
addTearDown(estado.dispose);
final emisora = emisoraDemo(uuid: 'a', nombre: 'Station A');
await tester.pumpWidget(
_host(estado, FilaEmisoraPlana(emisora: emisora, meta: '')),
);
await tester.pump();
final arte = find.byKey(const ValueKey('fila-emisora-plana-arte'));
expect(tester.getSize(arte), const Size(48, 48));
final clip = tester.widget<ClipRRect>(arte);
expect(
(clip.borderRadius as BorderRadius).topLeft,
const Radius.circular(12),
);
});
testWidgets('omits the meta line entirely when empty (no stray gap)', (
tester,
) async {
final estado = _estado();
addTearDown(estado.dispose);
final emisora = emisoraDemo(uuid: 'a', nombre: 'Station A');
await tester.pumpWidget(
_host(estado, FilaEmisoraPlana(emisora: emisora, meta: '')),
);
await tester.pump();
expect(find.text('Station A'), findsOneWidget);
// Only the title Text should exist in the info column -- no empty
// second Text node.
expect(find.text(''), findsNothing);
});
testWidgets('BotonReproducirCircular is 44x44, brand-teal-tinted background, '
'22px play_arrow icon (t4:230, t4:305)', (tester) async {
var tapped = false;
await tester.pumpWidget(
MaterialApp(
home: Scaffold(
body: BotonReproducirCircular(onPressed: () => tapped = true),
),
),
);
await tester.pump();
final boton = find.byKey(const ValueKey('boton-reproducir-circular'));
final material = tester.widget<Material>(boton);
expect(material.color, PluriWaveTokens.brand.withValues(alpha: 0.14));
expect(material.shape, const CircleBorder());
final icon = tester.widget<Icon>(find.byIcon(Icons.play_arrow_rounded));
expect(icon.size, 22);
expect(icon.color, PluriWaveTokens.brand);
await tester.tap(find.byIcon(Icons.play_arrow_rounded));
expect(tapped, isTrue);
});
testWidgets(
'BotonFavoritoEmisora toggles favourite status and shows a snackbar '
'(t4:304)',
(tester) async {
final estado = _estado();
addTearDown(estado.dispose);
final emisora = emisoraDemo(uuid: 'a', nombre: 'Station A');
await tester.pumpWidget(
_host(estado, BotonFavoritoEmisora(emisora: emisora)),
);
await tester.pump();
expect(find.byIcon(Icons.favorite_outline_rounded), findsOneWidget);
await tester.tap(find.byIcon(Icons.favorite_outline_rounded));
await tester.pump();
await tester.pump();
expect(find.byIcon(Icons.favorite_rounded), findsOneWidget);
expect(await estado.esFavorito('a'), isTrue);
},
);
}