Author SHA1 Message Date
FreeTLab d945e1a313 fix(alarmas): stop scheduling failures from being silent
Build & Deploy PluriWave / Análisis de código (push) Successful in 28s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m24s
Reported: on a Redmi C55 the alarm never rang, no full-screen window, no
pre-notice -- "as if there were no alarm at all". Same build works on a
Poco X7 Pro.

Not device-specific. EstadoAlarmas already recorded per-alarm scheduling
failures and exposed ultimaExcepcionPara, but no screen ever called it,
so a failed alarm rendered identically to a working one.

- Scheduling failures now mark their own card
- The three native paths that only logged -- an unarmed pre-notice, a
  refused foreground-service start, a per-alarm reschedule failing after
  boot -- report to Dart and become per-alarm exceptions
- After a save, the native pending-alarm count is cross-checked, so an
  alarm that never reached the OS is caught immediately

Additive throughout: successful scheduling behaves identically and no
logic branches on manufacturer.

Tests: 1029 -> 1051.
2026-07-31 23:27:58 +02:00
FreeTLab c507218462 merge: incorporate the CI version bump 2026-07-31 23:27:57 +02:00
FreeTLab f2528c930b fix(alarmas): decode native failures with the real channel key names
The first pass read 'alarmaId'/'tipo' from the channel payload while the
native side sends 'alarmId'/'type'/'atMillis' (AlarmScheduler.kt:1389).
Every entry would have been dropped silently in production.

The tests passed because the fake was seeded with the same guessed keys,
so they confirmed the mistake instead of catching it. Decoding now goes
through FalloProgramacionNativo.fromMap -- the single place native key
names appear -- and the fixtures build through that same constructor.
2026-07-31 23:27:45 +02:00
FreeTLab a8dca83cd9 feat(alarmas): surface the three native scheduling failures in Dart
Completes the bridge the native side already exposed. AlarmScheduler and
PluriWaveAlarmService record a pre-notice that could not be armed, a
refused foreground-service start, and a per-alarm reschedule that failed
after a reboot -- but nothing read them, so all three still ended at
logcat.

EstadoAlarmas now drains them at startup and turns each into a per-alarm
exception, which the card already knows how to mark. An alarm that never
reached the OS stops looking identical to one that did.

The read is deliberately tolerant: a failure to read is logged and
swallowed, never surfaced as an alarm error, so a diagnostics gap cannot
masquerade as a scheduling problem.
2026-07-31 23:24:01 +02:00
FreeTLab 7722f204ca feat(alarmas): verify native registration after a successful save
android.programar() returning without throwing was treated as proof
the OS registered the alarm -- this is exactly the gap the reported
case fell through. guardarAlarma now cross-checks a fresh native
pending-alarm count against how many alarms Dart believes are
active-with-a-next-run right after a successful schedule call, and
records a failure for the just-saved alarm when the native count
falls short.

FakePuertoAlarmasAndroid.alarmasNativasPendientes now defaults to a
count derived from programar()/cancelar() calls (mirroring the real
native scheduler's own registry) instead of a frozen 0, while any
test that explicitly assigns the field keeps getting exactly that
value regardless of what programar/cancelar do afterward -- verified
against the full suite, no regressions.
2026-07-31 21:26:41 +02:00
FreeTLab c107c0e18a feat(alarmas): surface scheduling failures on the alarm card
Wires EstadoAlarmas.ultimaExcepcionPara into PantallaAlarmas: an
alarm with an outstanding scheduling-failure exception now shows a
calm warning line (distinguishing a pre-notice-only failure from the
alarm itself not being registered) with a tap target into the
reliability diagnostics screen. The warning is its own small tap
target nested inside the existing card InkWell, so tap-to-edit,
swipe-to-delete and the hero "Saltar" chip are untouched.

Adds alarmCardSchedulingFailedMessage/alarmCardPreNoticeFailedMessage
to all 13 ARB locales with real per-language translations (verified
against arb_parity_test and arb_anti_copy_test).
2026-07-31 21:19:57 +02:00
FreeTLab fd1b91fe9e fix(alarmas): wire scheduling failures into per-alarm exceptions
guardarAlarma/posponerAlarma/posponerProximaDesdePreaviso now record
a scheduling failure via ServicioAlarmas.registrarFalloProgramacion
on catch and clear it on a successful (re)schedule, in addition to
the existing transient EstadoAlarmas.error string. This makes the
failure visible per-alarm via ultimaExcepcionPara instead of only a
generic app-wide message.

Also fixes _sincronizarTodas: a single alarm's android.programar
throw used to abort the whole loop, silently skipping every sibling
alarm scheduled AFTER it on that pass (including on every app launch,
via inicializar). Each alarm's outcome is now independent.
2026-07-31 21:08:21 +02:00
FreeTLab 47d0b8a053 feat(alarmas): record and clear per-alarm scheduling failures
Adds ServicioAlarmas.registrarFalloProgramacion/limpiarFalloProgramacion,
persisting a scheduling-reliability failure through the same
ExcepcionAlarma model saltarProxima already uses. Only one failure
record is kept per alarm (latest attempt wins) and skipNext entries
for any alarm are never touched. EstadoAlarmas wiring follows next.
2026-07-31 21:00:51 +02:00
FreeTLab 88bd251eba fix(alarmas): scope schedule-skip exceptions to skipNext only
ExcepcionAlarma._esValida matched ANY exception tipo against an
occurrence, treating it as a user skip. Only the 'skipNext' tipo
existed until now, but the next commits reuse the same model to
record scheduling-reliability failures per alarm (so the alarms list
can surface them via ultimaExcepcionPara) -- without this guard, a
recorded failure would be silently treated as if the user asked to
skip that occurrence, corrupting scheduling. Adds tipo constants to
ExcepcionAlarma for the upcoming failure kinds.
2026-07-31 20:59:02 +02:00
ShanaiaBot cd7f73056e chore: bump version to 1.2.6+128 [ci skip] 2026-07-31 19:38:01 +02:00
FreeTLab 3f80291e78 feat(auto): equalizer folder in the browse tree, one toggle on playback
Build & Deploy PluriWave / Análisis de código (push) Successful in 26s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m14s
On-device feedback: two identical icons on the car's now-playing screen,
one of which looked dead. It worked -- but head units render custom
actions icon-first, so cycling six presets behind one static glyph was
invisible.

A monochrome icon cannot encode which of six presets is active. Android
Auto separates the idioms deliberately: custom actions for stateless
toggles, browsable lists for choosing among options.

- Playback screen keeps one action: equalizer on/off, state-aware icons
- New Ecualizador folder lists Desactivar plus the six presets by name,
  active one marked
- The preset-cycling action and its drawable are removed

Supersedes the redesign's no-equalizer-folder rule, which predated
knowing custom actions do not surface state in a car.

# Conflicts:
#	lib/l10n/app_ar.arb
#	lib/l10n/app_bn.arb
#	lib/l10n/app_de.arb
#	lib/l10n/app_en.arb
#	lib/l10n/app_es.arb
#	lib/l10n/app_fr.arb
#	lib/l10n/app_hi.arb
#	lib/l10n/app_id.arb
#	lib/l10n/app_it.arb
#	lib/l10n/app_ja.arb
#	lib/l10n/app_pt.arb
#	lib/l10n/app_ru.arb
#	lib/l10n/app_zh.arb
2026-07-31 19:37:14 +02:00
FreeTLab 88818cf88c feat(alarmas): full Android reliability diagnostics with actionable fixes
Reported: on a Redmi C55 the alarm never rang and the pre-notice never
appeared, while the same build works on a Poco X7 Pro.

The app was never device-specific -- every permission is declared. The
gap was visibility: six diagnostic signals were collected and only three
shown. Battery-optimisation exemption and the count of alarms actually
registered with Android, the two most diagnostic for this failure, were
gathered and discarded.

- Full diagnostics screen, one row per signal, each with a Fix button
  wired to the right system settings intent and guarded by SDK level
- Manufacturer guidance for Xiaomi/Huawei/Oppo/Vivo/OnePlus/Samsung
  explaining Autostart must be enabled by hand -- there is no API for it
- Unresolvable intents surface a message instead of a dead tap

Tests: 993 -> 1014.
2026-07-31 19:30:36 +02:00
FreeTLab f19666508d fix(auto): remove the preset-cycling custom action, superseded by folder
The equalizer's preset-cycling custom action (eq_preset_siguiente) and
its ic_auto_eq_preset drawable are no longer needed now that the
"Ecualizador" folder lists all six presets directly: the folder replaces
what the cycle action did, and this frees a scarce Android Auto custom
action slot.

The on/off toggle is now the equalizer's only custom action.
2026-07-31 19:15:31 +02:00
FreeTLab 8423ccdd0c feat(auto): add an Ecualizador browsable folder with preset selection
On-device feedback showed the equalizer's preset-cycling custom action
looked dead: many head units render custom actions icon-first, and a
monochrome icon cannot legibly encode "which of six presets" the way a
browsable list's text rows can.

This adds an "Ecualizador" folder to the car's browse tree, listing
"Desactivar" first, then the six factory presets by name, with the
currently-active one marked. Selecting a preset routes through the same
playFromMediaId seam every other browse-tree leaf already uses; picking
a preset while the equalizer is off turns it on and applies that preset.

Supersedes the earlier "no equalizer folder" rule (commit 2403da3),
which predated this feedback -- see decision auto/ecualizador-diseno.

The preset-cycling custom action still coexists with the folder in this
commit; it is removed in the next one.
2026-07-31 19:11:01 +02:00
FreeTLab 049ab78acb feat(alarmas): replace one-line reliability button with full diagnostics screen
Surface all six DiagnosticoAlarmasAndroid fields instead of three: the
battery-optimization exemption and native pending-alarm count were
already collected but silently dropped by the old widget. Each failing
signal now offers a "Fix this" action that opens the right system
settings screen (exact alarms, notifications, full-screen intent,
battery optimization), guarded by SDK level and never crashing when a
ROM lacks that screen. Manufacturers known for aggressive background
killing (Xiaomi/Redmi/POCO, Huawei, Oppo, Vivo, OnePlus, Samsung) get
an honest explanation that Autostart must be enabled manually, since
there is no API to detect or grant it.

Notifications now deep-links straight to
ACTION_APP_NOTIFICATION_SETTINGS via a new openNotificationSettings
native method, instead of reusing the runtime permission popup meant
for first-time alarm creation.

New copy is added to all 13 ARB locales with real per-language
translations (not Spanish copies), verified by the ARB parity and
anti-copy tests plus the corruption scanner.
2026-07-31 19:10:52 +02:00
FreeTLab ef9705a30e feat(alarmas): add pure diagnostic mapping and autostart-guidance logic
DiagnosticoAlarmasAndroid already collected six raw reliability fields
but only three ever reached the UI. Add a pure-Dart mapping that turns
the raw snapshot into five ordered signals with a clear ok/needs-
attention state (exact alarms, notifications, full-screen intent,
battery-optimization exemption, native pending-alarm count), plus a
manufacturer check for vendors known to require manually enabling
Autostart (Xiaomi/Redmi/POCO, Huawei, Oppo, Vivo, OnePlus, Samsung),
since there is no public API to detect or grant that setting.
2026-07-31 18:32:42 +02:00
FreeTLab 25405564ee fix(auto): give the equalizer actions distinct, state-aware icons
On a car head unit the custom actions render icon-first, so two actions
sharing ic_stat_pluriwave were indistinguishable and the toggle gave no
sign of whether the equalizer was on.

Each action now has its own drawable, and the toggle swaps between
ic_auto_eq_on and ic_auto_eq_off so its state is legible at a glance.
2026-07-31 18:11:57 +02:00
ShanaiaBot dd463cf2bb chore: bump version to 1.2.5+127 [ci skip] 2026-07-31 01:14:44 +02:00
FreeTLab c8b2c4d2d6 feat(auto,eq,alarmas): address the second round of on-device feedback
Build & Deploy PluriWave / Análisis de código (push) Successful in 24s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m37s
- Local-music subfolders sort before files, so they no longer fall past
  the 50-item page boundary and vanish from the car
- Playing a folder now plays its subfolders too, bounded at depth 4 and
  500 tracks to cap native SAF round-trips
- Stations and tracks with no artwork fall back to on-brand art instead
  of an empty tile
- Equalizer on/off and preset cycling are reachable from the car's
  now-playing screen as two custom actions
- The equalizer is re-applied after an audio-focus interruption, not
  only when the audio session id changes -- a nav-app prompt keeps the
  same session, which is why the earlier fix missed this case
- The alarm list shows which days an alarm actually fires on

Tests: 933 -> 991.
2026-07-31 01:12:13 +02:00
FreeTLab eba4eba397 merge: incorporate the CI version bump 2026-07-31 01:12:13 +02:00
FreeTLab 4168dc5019 fix(alarmas): show which days a weekday alarm actually fires on
The alarms list showed a generic "Días" label for a diasSemana alarm
instead of its actual configured days. Render the real recurrence (e.g.
"Lun, Mié, Vie") by reusing the SAME per-day abbreviation the editor's own
day-picker circles already use -- no new formatting scheme, no new ARB
keys for the days themselves.

Also surface fade/volume/vacation-pause state on the card, each only when
it is a genuinely useful deviation from the common case: a fade badge when
fadeInSegundos > 0 (reusing the existing alarmFadeInLabel key), a volume
percentage when it differs from the 85% default, and a vacation-paused
badge when the alarm is both configured to pause and a vacation range is
currently active (mirrors the exact predicate ServicioProgramacionAlarmas
already uses). One compact line, not a badge per field.

Fixes a text-collision regression in pantalla_alarmas_editor_test.dart:
opening the editor for an alarm whose own day now renders on its card
(e.g. "Lun") made a bare find.text(weekday) ambiguous against the editor's
day-picker circle with the same label -- scoped that finder to the
BottomSheet subtree.
2026-07-31 01:05:46 +02:00
FreeTLab 491585ad12 fix(eq): re-apply the equalizer after an audio-focus interruption
The equalizer stopped applying after another app interrupted audio (e.g. a
navigation app's voice prompt): play a station with EQ working, let the
prompt speak, resume -- the audio sounds flat until the station is
re-tapped.

debeReaplicarEcualizador only re-attaches the equalizer when the native
player session id actually changes. A short transient interruption keeps
the SAME session (no id rotation), so that trigger never fires, while
Android's AudioEffect framework can let a higher-priority client silently
disable this app's effect instance in the meantime.

Add reaplicarEcualizador() to ObjetivoAudioInterrumpible, implemented as a
thin delegate to the existing _activarEcualizador() (already the correct
idempotent setEnabled + re-push-gains path). ServicioAudioSession calls it
on resume-from-pause (after reanudar()) and on un-duck (after
setAtenuado(false)) -- additive to the existing session-id trigger, not a
replacement. The method takes no argument, so it can only re-assert
whatever enabled/disabled state the handler already holds -- an
interruption cycle with the equalizer OFF stays OFF.
2026-07-31 00:56:37 +02:00
FreeTLab 9eff760462 feat(auto): equalizer enable/disable and preset cycling from the car
Expose the equalizer's on/off toggle and preset choice as PlaybackStateCompat
custom actions on the now-playing screen. The redesign's removal of the
in-car equalizer FOLDER from the browse tree stays as-is (2403da3) -- this
is a different surface (playback screen custom actions, not a browse
folder) and does not reintroduce it.

Deliberately just 2 actions -- an on/off toggle plus a cycling preset
action, not one action per preset -- since Android Auto only surfaces a
limited number of custom actions. Both reuse the existing
setEcualizadorActivo/aplicarPreset entry points (the same ones
EstadoEcualizador's phone settings screen uses), so a car tap and a phone
tap behave identically and both keep the action labels in sync. Reuses the
bundled ic_stat_pluriwave drawable (the notification's own equalizer-bars
icon) -- zero new native assets. The 5-band constraint is untouched.

New pure, unit-tested functions in servicio_audio.dart: presetSiguiente,
nombrePresetVisible, controlesEcualizadorPersonalizados. New ARB keys
(eqCustomActionEnableLabel/DisableLabel/PresetLabel) across all 13 locales,
regenerated via flutter gen-l10n.
2026-07-31 00:54:05 +02:00
FreeTLab 1b0bea5492 fix(auto): fall back to on-brand artwork when a station or track has none
Stations and tracks with no artwork showed empty tiles in the car. The
browse tree's itemEmisora/_itemLocal already fell back to the rotating
station_art_* drawable via artUriPara/artUriLocal, but the "now playing"
MediaItem built when actually playing something (car tap, phone-initiated
play, folder-queue advance, direct local-track tap) did not, so the car's
now-playing screen still went blank.

Reuse the SAME artUriPara/artUriLocal fallback (already the project's one
selection scheme, mirroring PluriStationArtFallback) at every "now playing"
construction site: reproducirPorMediaId, ServicioAudio.reproducir (now via
the extracted, unit-tested mediaItemParaEmisora), construirMediaItemColaLocal
and reproducirPistaLocal.

Guard the reverse direction too: emisoraDesdeMediaItem (extracted from the
handler's private method, now unit-tested) only reflects artUri back into
Emisora.favicon when it passes faviconUsable, so the phone UI's
CachedNetworkImage widgets never attempt a doomed fetch of the car's
android.resource:// fallback URI -- they keep falling back to
PluriStationArtFallback exactly as before.
2026-07-31 00:47:26 +02:00
FreeTLab 6822432a51 feat(auto): play a local-music folder's subfolders recursively too
totalPistas counted only DIRECT audio children, so "Reproducir carpeta"/
"Aleatorio" were hidden for a folder that contains only subfolders, and
playing a folder queued only its direct tracks.

Add a bounded recursive walk (pistasRecursivas) that collects every track
beneath a folder, depth-first, sorted by name at each level. Bounded on
two independent axes to keep a single tap's native SAF round-trips and
in-memory list size predictable on a deep or wide library:
- depth: 4 levels below the tapped folder (profundidadMaximaRecursivaLocal)
- count: 500 tracks total (limitePistasRecursivasLocal)

The folder-play/shuffle actions are now offered whenever the recursive
count is > 0, and "Reproducir carpeta"/"Aleatorio" queue everything found,
not just direct children.
2026-07-31 00:43:20 +02:00
FreeTLab eea8ec31e6 fix(auto): sort local-music subfolders before files
itemsLocales sorted a folder's children by name only, mixing directories
and files. A subfolder whose name sorted after enough tracks (e.g. "Live"
behind 80 numbered tracks) landed on a later "Más..." page, making it
unreachable without paging through every track first.

Sort directories before files, then by name within each group -- the
standard file-browser convention. Subfolders now always land on page 0.
2026-07-31 00:38:39 +02:00
ShanaiaBot 55636f7c74 chore: bump version to 1.2.4+126 [ci skip] 2026-07-30 22:25:08 +02:00
FreeTLab 4b89c9af07 fix(espaciados): complete the spacing review across every screen
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m43s
Finishes the review the user asked for after on-device testing; Buscar
was fixed earlier in fdb7eb1.

- Favoritos' header sat 36px from the edge instead of 20 -- the list's
  own padding stacked on top of PluriRootHeader's inset, and it was the
  one root that did not match Alarmas and Ajustes
- Rows were card-tier (16) where the prototype uses row-tier (12)
- Settings group-to-group gap 12 -> 16; Grabaciones storage card -> rows
  12 -> 16; Paises language -> country list 16 -> 14
- Alarma sonando's snooze block had a non-uniform 10/14 gap pair
- The date line moved below the hero time, where the prototype puts it

Escuchar, Alarmas, Vacaciones, the 12 settings detail screens,
Reproductor and Bienvenida were checked and were already correct.

Tests: 926 -> 933.
2026-07-30 22:23:04 +02:00
FreeTLab f2b02c3ce2 merge: incorporate the CI version bump 2026-07-30 22:23:03 +02:00
FreeTLab db6f4a3a11 fix(alarma-sonando): put the date line below the hero time
The prototype's order is pill (t4:415-416), then 7:30 at 88px (t4:417),
then "Lunes, 3 de agosto" at 14px (t4:419). An earlier pass rendered the
date between the pill and the time and cited "t4 line 419" as its
justification -- but that line number is where the date SITS in the
source, which is exactly why it comes last.

Both the code and the test encoded the same misreading, so the test
passed while the screen was wrong.
2026-07-30 22:22:50 +02:00
FreeTLab 3bb92c0536 fix(grabaciones): correct the gap between the storage card and rows
Issue 3 (feedback-pruebas): t4:617 draws a 16px gap between the
storage usage card and the recordings list below it, not 12.
2026-07-30 22:14:51 +02:00
FreeTLab 9a75027d57 fix(paises): correct the gap between the language and country lists
Issue 3 (feedback-pruebas): t4:260 draws a 14px gap between "Tus
idiomas" and "Todos", not 16.
2026-07-30 22:13:43 +02:00
FreeTLab 93b7ec2af9 fix(alarma-sonando): make the snooze block's vertical gaps uniform
Issue 3 (feedback-pruebas): t4:427 wraps the POSPONER eyebrow, the
snooze tiles and the Stop button in a single flex column with a
uniform gap:12 -- this screen carried a 10/14 pair instead, matching
neither the prototype nor each other.

The dismiss-guard test (protected, untouched) only asserts behaviour
via find.text/find.byType, so this pure value change is safe against
it -- re-verified empty diff after this commit.
2026-07-30 22:12:24 +02:00
FreeTLab 5bbf750b63 fix(ajustes): correct the gap between stacked settings groups
Issue 3 (feedback-pruebas): the prototype (t4:523/534/541) draws a
16px gap between the AUDIO/STATIONS/RECORDINGS/APPLICATION cards, not
12 -- a plain unwired literal that happened to collide with the
sectionGap/panelGap tokens' own value without actually citing the
prototype.
2026-07-30 22:08:40 +02:00
FreeTLab 7faf56900f fix(favoritos): stop the header padding from doubling up
Issue 3 (feedback-pruebas): ReorderableListView.padding wrapped
header/rows/footer with a single horizontal value (16), which doubled
up on top of PluriRootHeader's own internal inset -- landing the
title at 36px instead of the 20px every other root uses -- while also
applying card-tier padding to the flat FilaEmisoraPlana rows (row
tier, matching the same widget's fix on Buscar) and leaving the
populated-state top gap at an unwired 4 that didn't match this same
screen's own empty state (0) or the footer CTA's prototype value (8).

Zeroes the list-level padding and gives the header, chip strip, rows,
and footer CTA their own correctly-tiered insets instead.
2026-07-30 22:06:36 +02:00
ShanaiaBot ec93e45310 chore: bump version to 1.2.3+125 [ci skip] 2026-07-30 21:48:14 +02:00
FreeTLab fdb7eb1d51 fix: address the issues found in on-device testing
Build & Deploy PluriWave / Análisis de código (push) Successful in 28s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m18s
Seven of the eight points reported after the first real build.

- Favourites overflow menu was clipped to one letter per item by a
  constraints property that sizes the popup, not the button
- Bottom bar painted a square ink splash over the icon, and its lift,
  dim, icon size and label snapped while the balloon slid
- Station artwork fallback is now shared by every surface instead of the
  flat rows painting a plain coloured square
- Vacation ranges can be edited and deleted
- Settings row titles no longer wrap into cut lines
- Sleep timer sheet shows the live countdown
- The last-played station survives a restart, shown stopped

Spacing review is done for Buscar only; the rest of the app is still
outstanding.

Tests: 903 -> 926.
2026-07-30 21:47:13 +02:00
FreeTLab 431f13063d merge: incorporate the CI version bump to 1.2.2+124 2026-07-30 21:45:42 +02:00
ShanaiaBot b365035e10 chore: bump version to 1.2.2+124 [ci skip] 2026-07-30 17:56:28 +02:00
73 changed files with 9191 additions and 1927 deletions
@@ -202,8 +202,22 @@ class AlarmScheduler(private val context: Context) {
)
)
Log.d(tag, "alarm.schedule preNotice OK id=${spec.id}")
NativeSchedulingFailures.clear(
appContext,
spec.id,
NativeSchedulingFailures.TYPE_PRE_NOTICE
)
} catch (_: SecurityException) {
// Silent before this fix: the main alarm can still arm via
// setAlarmClock (exempt from the exact-alarm permission), so
// the alarm itself rings while its 30-minute reminder simply
// never appears, with nothing surfaced anywhere but logcat.
Log.w(tag, "alarm.schedule preNotice SecurityException id=${spec.id}")
NativeSchedulingFailures.record(
appContext,
spec.id,
NativeSchedulingFailures.TYPE_PRE_NOTICE
)
}
} else if (spec.triggerAtMillis > now) {
appContext.sendBroadcast(
@@ -846,8 +860,22 @@ class AlarmScheduler(private val context: Context) {
// the native recompute inside scheduleSpec.
scheduleSpec(spec, persistOnSuccess = true, trustDartTrigger = true)
Log.d(tag, "alarm.reschedule OK id=$id")
NativeSchedulingFailures.clear(
appContext,
id,
NativeSchedulingFailures.TYPE_RESCHEDULE
)
} catch (error: Throwable) {
// Silent before this fix: one alarm's reschedule failure used
// to just log and move to the next id, leaving that ONE
// alarm unscheduled after a reboot/unlock/app-update with no
// signal anywhere but logcat.
Log.e(tag, "alarm.reschedule failed id=$id", error)
NativeSchedulingFailures.record(
appContext,
id,
NativeSchedulingFailures.TYPE_RESCHEDULE
)
}
}
}
@@ -855,6 +883,18 @@ class AlarmScheduler(private val context: Context) {
fun pendingAlarmCount(): Int =
prefs().getStringSet(KEY_IDS, emptySet()).orEmpty().size
/**
* Scheduling-reliability failures the native side recorded on its own
* (fix/alarmas-fallos-silenciosos, item 2): pre-notice, foreground-
* service start, and post-boot/unlock reschedule failures never go
* through a Dart method-channel call that could throw, so they are
* persisted here instead and synced by Flutter on the next app launch
* -- mirroring [handledOccurrences]/[nativeSnoozeStates]'s own
* cold-start-sync shape.
*/
fun scheduleFailures(): List<Map<String, Any>> =
NativeSchedulingFailures.all(appContext)
fun handledOccurrences(): List<Map<String, Any>> =
prefs().getStringSet(KEY_HANDLED_IDS, emptySet()).orEmpty()
.mapNotNull { id ->
@@ -1265,3 +1305,91 @@ class AlarmScheduler(private val context: Context) {
private fun JSONObject.optNullableLong(name: String): Long? =
if (has(name) && !isNull(name)) optLong(name) else null
/**
* Persisted store for scheduling-reliability failures the native side
* catches and previously only logged (fix/alarmas-fallos-silenciosos, item
* 2): the pre-notice `SecurityException` (AlarmScheduler.schedulePreNotice),
* a refused foreground-service start (PluriWaveAlarmService.start), and a
* per-alarm reschedule failure after boot/unlock
* (AlarmScheduler.reschedulePersistedAlarms). A separate top-level object
* (not nested in [AlarmScheduler]'s own instance state) so
* [PluriWaveAlarmService]'s companion object -- which has no [AlarmScheduler]
* instance of its own -- can record a failure too, using the exact same
* [Context]-scoped, device-protected-storage `SharedPreferences` file
* [AlarmScheduler] itself reads/writes (same `PREFS` name, kept in sync by
* hand since Kotlin constants cannot be shared across files without a third
* file).
*
* Only the LATEST failure per alarm is kept (mirrors the Dart-side
* `ExcepcionAlarma` "latest wins" semantics) -- this is a reliability
* signal, not an audit log.
*/
object NativeSchedulingFailures {
private const val PREFS = "pluriwave_alarm_scheduler"
private const val KEY_FAILURE_IDS = "schedule_failure_alarm_ids"
private const val KEY_FAILURE_TYPE_PREFIX = "schedule_failure_type_"
private const val KEY_FAILURE_AT_PREFIX = "schedule_failure_at_"
/** Mirrors Dart's `ExcepcionAlarma.tipoFalloPreaviso`. */
const val TYPE_PRE_NOTICE = "preNoticeFailed"
/** Mirrors Dart's `ExcepcionAlarma.tipoFalloServicioSonido`. */
const val TYPE_FOREGROUND_SERVICE = "foregroundServiceFailed"
/** Mirrors Dart's `ExcepcionAlarma.tipoFalloReprogramacionArranque`. */
const val TYPE_RESCHEDULE = "rescheduleAfterBootFailed"
private fun prefs(context: Context) =
context.applicationContext.createDeviceProtectedStorageContext()
.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
fun record(context: Context, id: String, type: String) {
if (id.isBlank()) return
val store = prefs(context)
val ids = store.getStringSet(KEY_FAILURE_IDS, emptySet()).orEmpty().toMutableSet()
ids.add(id)
store.edit()
.putStringSet(KEY_FAILURE_IDS, ids)
.putString("$KEY_FAILURE_TYPE_PREFIX$id", type)
.putLong("$KEY_FAILURE_AT_PREFIX$id", System.currentTimeMillis())
.apply()
Log.w("PluriWave", "alarm.scheduleFailure recorded id=$id type=$type")
}
/**
* Clears the failure recorded for [id] ONLY when its current type is
* [type] -- type-scoped on purpose (mirrors the Dart-side
* `limpiarFalloProgramacion`), so a foreground-service success never
* erases an unrelated, still-outstanding pre-notice failure for the
* same alarm.
*/
fun clear(context: Context, id: String, type: String) {
val store = prefs(context)
val storedType = store.getString("$KEY_FAILURE_TYPE_PREFIX$id", null)
if (storedType != type) return
val ids = store.getStringSet(KEY_FAILURE_IDS, emptySet()).orEmpty().toMutableSet()
if (!ids.remove(id)) return
store.edit()
.putStringSet(KEY_FAILURE_IDS, ids)
.remove("$KEY_FAILURE_TYPE_PREFIX$id")
.remove("$KEY_FAILURE_AT_PREFIX$id")
.apply()
}
fun all(context: Context): List<Map<String, Any>> {
val store = prefs(context)
return store.getStringSet(KEY_FAILURE_IDS, emptySet()).orEmpty().mapNotNull { id ->
val type = store.getString("$KEY_FAILURE_TYPE_PREFIX$id", null)
?: return@mapNotNull null
val at = store.getLong("$KEY_FAILURE_AT_PREFIX$id", 0L)
.takeIf { it > 0L }
?: return@mapNotNull null
mapOf(
"alarmId" to id,
"type" to type,
"atMillis" to at
)
}
}
}
@@ -232,6 +232,10 @@ class MainActivity : AudioServiceActivity() {
Log.d(tag, "alarm.channel requestIgnoreBatteryOptimizations")
result.success(requestIgnoreBatteryOptimizations())
}
"openNotificationSettings" -> {
Log.d(tag, "alarm.channel openNotificationSettings")
result.success(openNotificationSettings())
}
"getInitialAlarmIntent" -> {
val payload = alarmPayload(intent)
Log.d(tag, "alarm.channel getInitialAlarmIntent payload=$payload")
@@ -246,6 +250,10 @@ class MainActivity : AudioServiceActivity() {
Log.d(tag, "alarm.channel getNativeSnoozeState")
result.success(alarmScheduler.nativeSnoozeStates())
}
"getNativeSchedulingFailures" -> {
Log.d(tag, "alarm.channel getNativeSchedulingFailures")
result.success(alarmScheduler.scheduleFailures())
}
"setNotificationStrings" -> {
val args = call.arguments as? Map<*, *>
if (args != null) {
@@ -751,6 +759,39 @@ class MainActivity : AudioServiceActivity() {
}
}
/**
* Opens the system's per-app notification settings screen directly
* (diagnostics screen, fix/alarmas-fiabilidad). Unlike
* [requestPostNotificationsPermission] -- which shows the runtime
* permission popup and is meant for the FIRST time an alarm is created
* -- this is meant for a user troubleshooting an alarm that already
* failed, where the OS may no longer show that popup at all after a
* prior denial. `ACTION_APP_NOTIFICATION_SETTINGS` only exists from API
* 26; older devices fall back to the app's own details screen, which
* still surfaces the notification toggle. Never throws across the
* channel boundary -- an unresolvable intent on some ROM is caught and
* reported as `false`, same shape as every other `request*`/`open*`
* helper in this class.
*/
private fun openNotificationSettings(): Boolean {
return try {
val intent = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
Intent(Settings.ACTION_APP_NOTIFICATION_SETTINGS).apply {
putExtra(Settings.EXTRA_APP_PACKAGE, packageName)
}
} else {
Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS).apply {
data = Uri.parse("package:$packageName")
}
}
startActivity(intent)
true
} catch (error: Throwable) {
Log.e(tag, "alarm.channel openNotificationSettings failed", error)
false
}
}
private fun openDirectory(path: String): Boolean {
val folder = File(path)
if (!folder.exists()) {
@@ -183,7 +183,16 @@ class PluriWaveAlarmService : Service() {
startForeground(NOTIFICATION_ID, notification)
}
} catch (error: Throwable) {
// Silent before this fix: same user-visible symptom as a refused
// startForegroundService (the ring never actually starts) --
// recorded under the SAME tipo so the alarms list surfaces it
// regardless of which of the two calls the OS refused.
Log.e(TAG, "alarm.service startForeground failed id=$alarmId", error)
NativeSchedulingFailures.record(
this,
alarmId,
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
)
releaseWakeLock()
// Second documented clear site (feedback item, READ-5): this
// branch never reaches stopEverything(), so without the same
@@ -197,6 +206,11 @@ class PluriWaveAlarmService : Service() {
stopSelf()
return
}
NativeSchedulingFailures.clear(
this,
alarmId,
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
)
startAudio(
alarmId,
stationName,
@@ -754,6 +768,7 @@ class PluriWaveAlarmService : Service() {
fun start(context: Context, source: Intent) {
ensureChannel(context)
val alarmId = source.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_ID)
val intent = Intent(context, PluriWaveAlarmService::class.java).apply {
action = PluriWaveAlarmReceiver.ACTION_FIRE
putExtras(source)
@@ -761,8 +776,27 @@ class PluriWaveAlarmService : Service() {
try {
ContextCompat.startForegroundService(context, intent)
Log.d(TAG, "alarm.service start requested")
if (!alarmId.isNullOrBlank()) {
NativeSchedulingFailures.clear(
context,
alarmId,
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
)
}
} catch (error: Throwable) {
// Silent before this fix: a fire-and-forget call from the
// receiver's ACTION_FIRE branch -- if the OS refuses the
// foreground-service start (background-restricted app), the
// ring never happens and nothing surfaced it anywhere but
// logcat, "as if there were no alarm at all".
Log.e(TAG, "alarm.service start failed", error)
if (!alarmId.isNullOrBlank()) {
NativeSchedulingFailures.record(
context,
alarmId,
NativeSchedulingFailures.TYPE_FOREGROUND_SERVICE
)
}
}
}
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFF" android:pathData="M3,5L21,5L21,7L3,7Z M14,3L17,3L17,9L14,9Z M3,11L21,11L21,13L3,13Z M7,9L10,9L10,15L7,15Z M3,17L21,17L21,19L3,19Z M17,15L20,15L20,21L17,21Z M2,20L4,22L22,4L20,2Z" />
</vector>
@@ -0,0 +1,3 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android" android:width="24dp" android:height="24dp" android:viewportWidth="24" android:viewportHeight="24">
<path android:fillColor="#FFFFFF" android:pathData="M3,5L21,5L21,7L3,7Z M14,3L17,3L17,9L14,9Z M3,11L21,11L21,13L3,13Z M7,9L10,9L10,15L7,15Z M3,17L21,17L21,19L3,19Z M17,15L20,15L20,21L17,21Z" />
</vector>
+192 -1
View File
@@ -90,6 +90,7 @@ class EstadoAlarmas extends ChangeNotifier {
);
await _sincronizarTodas();
await cargarDiagnostico();
await cargarFallosNativos();
_activarRefresco();
} catch (e) {
_error = 'No se pudieron cargar las alarmas: $e';
@@ -117,8 +118,11 @@ class EstadoAlarmas extends ChangeNotifier {
'[PluriWave][alarmas] guardada id=${guardada.id} proxima=${guardada.proximaEjecucion?.toIso8601String()}',
);
await android.programar(guardada);
await _limpiarFalloProgramacion(guardada.id);
await _verificarRegistroNativo(guardada.id);
} catch (e) {
_error = 'Alarma guardada, pero Android no pudo programarla todavía: $e';
await _registrarFalloProgramacion(alarma.id);
}
notifyListeners();
}
@@ -198,6 +202,89 @@ class EstadoAlarmas extends ChangeNotifier {
}
}
/// Records a main-alarm scheduling failure per-alarm (fix/alarmas-fallos-
/// silenciosos): before this, a failed `android.programar` call only set
/// the transient, alarm-agnostic [_error] string — the alarms list had no
/// way to mark the SPECIFIC card affected, so a failed alarm rendered
/// exactly like a working one. Never rethrows: a failure recording its own
/// failure must not mask the ORIGINAL scheduling error already captured in
/// [_error].
Future<void> _registrarFalloProgramacion(
String alarmaId, {
String tipo = ExcepcionAlarma.tipoFalloProgramacion,
}) async {
try {
final alarma = _buscarAlarma(alarmaId);
final ejecucion = alarma?.proximaProgramable ?? servicio.ahora();
final config = await servicio.registrarFalloProgramacion(
alarmaId,
ejecucion,
tipo,
);
_aplicar(config);
} catch (e) {
debugPrint('[PluriWave][alarmas] registrar fallo programacion ERROR $e');
}
}
/// Clears a previously recorded scheduling failure once a later attempt
/// for the same alarm succeeds (D5-style recovery, mirroring how [_error]
/// itself already clears on a successful retry). Type-scoped: a
/// successful `android.programar` call only proves the MAIN alarm
/// registration (and, transitively, that any stale post-boot reschedule
/// failure no longer applies) -- it says nothing about the pre-notice or
/// foreground-service subsystems, so those are left untouched here.
Future<void> _limpiarFalloProgramacion(String alarmaId) async {
try {
var config = await servicio.limpiarFalloProgramacion(
alarmaId,
ExcepcionAlarma.tipoFalloProgramacion,
);
_aplicar(config);
config = await servicio.limpiarFalloProgramacion(
alarmaId,
ExcepcionAlarma.tipoFalloReprogramacionArranque,
);
_aplicar(config);
} catch (e) {
debugPrint('[PluriWave][alarmas] limpiar fallo programacion ERROR $e');
}
}
/// Verifies the OS genuinely registered [alarmaId] after a successful
/// `android.programar` call (fix/alarmas-fallos-silenciosos, item 3): a
/// scheduling call that returns without throwing is not proof enough by
/// itself -- this cross-check against the native pending-alarm count is
/// exactly what would have caught the reported "alarm never rings, no
/// exception anywhere" case. Compares a FRESH native count against how
/// many alarms Dart believes are currently active-with-a-next-run; a
/// native count that falls short is recorded as a failure for the alarm
/// the user just interacted with. Never overrides an already-caught
/// programar() exception (this only runs on ITS success path).
Future<void> _verificarRegistroNativo(String alarmaId) async {
try {
final alarma = _buscarAlarma(alarmaId);
if (alarma == null ||
!alarma.activa ||
alarma.proximaProgramable == null) {
return;
}
final diag = await android.diagnostico();
_diagnostico = diag;
final esperadas =
_alarmas
.where((a) => a.activa && a.proximaProgramable != null)
.length;
if (diag.alarmasNativasPendientes < esperadas) {
_error =
'Alarma guardada, pero el sistema no confirma que quedó registrada.';
await _registrarFalloProgramacion(alarmaId);
}
} catch (e) {
debugPrint('[PluriWave][alarmas] verificar registro nativo ERROR $e');
}
}
Future<void> cambiarActiva(AlarmaMusical alarma, bool activa) async {
await guardarAlarma(alarma.copyWith(activa: activa));
}
@@ -267,10 +354,12 @@ class EstadoAlarmas extends ChangeNotifier {
if (actualizada != null) {
await _solicitarPermisosNecesariosParaAlarma();
await android.programar(actualizada);
await _limpiarFalloProgramacion(alarma.id);
}
} catch (e) {
_error =
'Alarma pospuesta, pero Android no pudo reprogramarla todavía: $e';
await _registrarFalloProgramacion(alarma.id);
}
notifyListeners();
}
@@ -298,10 +387,12 @@ class EstadoAlarmas extends ChangeNotifier {
if (actualizada != null) {
await _solicitarPermisosNecesariosParaAlarma();
await android.programar(actualizada);
await _limpiarFalloProgramacion(alarma.id);
}
} catch (e) {
_error =
'Alarma pospuesta, pero Android no pudo reprogramarla todavía: $e';
await _registrarFalloProgramacion(alarma.id);
}
notifyListeners();
}
@@ -447,6 +538,36 @@ class EstadoAlarmas extends ChangeNotifier {
notifyListeners();
}
/// Drains the failures the NATIVE side recorded on its own and turns each
/// into a per-alarm exception, so the card can mark it.
///
/// These three paths used to log to logcat and stop there: a pre-notice
/// that could not be armed, a refused foreground-service start when the
/// alarm should have rung, and a per-alarm reschedule that failed after a
/// reboot. None of them run inside a Dart call, so nothing on this side
/// ever learned they happened — an alarm could sit switched on in the
/// list having never reached the OS. Reading them at startup is what
/// makes the reported "as if there were no alarm" visible.
///
/// Deliberately tolerant: a failed read is logged and swallowed, never
/// surfaced as an alarm error, because a diagnostics gap must not look
/// like a scheduling problem.
Future<void> cargarFallosNativos() async {
try {
final fallos = await android.fallosNativosProgramacion();
for (final fallo in fallos) {
await _registrarFalloProgramacion(fallo.alarmaId, tipo: fallo.tipo);
}
if (fallos.isNotEmpty) {
debugPrint(
'[PluriWave][alarmas] fallos nativos recogidos=${fallos.length}',
);
}
} catch (e) {
debugPrint('[PluriWave][alarmas] cargar fallos nativos ERROR $e');
}
}
/// Records a snooze the native layer performed by itself (Decision 2.1).
/// The native scheduler already re-registered setAlarmClock, so this only
/// persists the canonical state — it MUST NOT call android.programar again.
@@ -546,6 +667,60 @@ class EstadoAlarmas extends ChangeNotifier {
debugPrint('[PluriWave][alarmas] sincronizar nativas ERROR $e');
}
await _importarSnoozesNativosActivos();
await _importarFallosProgramacionNativos();
}
/// Cold-start sync (fix/alarmas-fallos-silenciosos, item 2): imports
/// scheduling-reliability failures the NATIVE side recorded on its own --
/// a pre-notice `SecurityException`, a refused foreground-service start,
/// or a per-alarm reschedule failure after boot/unlock -- none of which
/// ever go through a Dart method-channel call that could throw. Without
/// this sync, these three failures stayed invisible forever (only
/// logcat), even after this app-launch fix reads them.
Future<void> _importarFallosProgramacionNativos() async {
try {
final fallos = await android.obtenerFallosProgramacionNativos();
final reportadoPorAlarma = {
for (final fallo in fallos) fallo.alarmaId: fallo,
};
// Reconcile stale copies: the native side clears its OWN record the
// next time that specific subsystem succeeds (pre-notice/foreground-
// service), so an alarm previously imported with one of those tipos
// that is no longer reported here means it already recovered --
// without this, the card would keep showing a problem that fixed
// itself. `tipoFalloProgramacion`/`tipoFalloReprogramacionArranque`
// are NOT reconciled here -- those already clear on the Dart side's
// own successful `android.programar` calls.
for (final alarma in _alarmas) {
final actual = ultimaExcepcionPara(alarma.id);
final esTipoReconciliable =
actual != null &&
(actual.tipo == ExcepcionAlarma.tipoFalloPreaviso ||
actual.tipo == ExcepcionAlarma.tipoFalloServicioSonido);
if (esTipoReconciliable && !reportadoPorAlarma.containsKey(alarma.id)) {
final config = await servicio.limpiarFalloProgramacion(
alarma.id,
actual.tipo,
);
_aplicar(config);
}
}
for (final fallo in fallos) {
final config = await servicio.registrarFalloProgramacion(
fallo.alarmaId,
fallo.ocurridoEn,
fallo.tipo,
);
_aplicar(config);
}
if (fallos.isNotEmpty) {
debugPrint(
'[PluriWave][alarmas] fallos nativos importados count=${fallos.length}',
);
}
} catch (e) {
debugPrint('[PluriWave][alarmas] importar fallos nativos ERROR $e');
}
}
/// Cold-start half of Decision 2.1: imports snoozes the native scheduler
@@ -622,8 +797,24 @@ class EstadoAlarmas extends ChangeNotifier {
if (_alarmas.any((alarma) => alarma.activa)) {
await _solicitarPermisosNecesariosParaAlarma();
}
// Per-alarm try/catch (fix/alarmas-fallos-silenciosos): before this, a
// SINGLE alarm's `programar` throw aborted the whole loop, so every
// sibling AFTER the failing one in `_alarmas` silently never reached
// `android.programar` on this pass -- on a fresh launch (`inicializar`)
// that meant some active alarms were never (re)armed with the OS at all,
// with nothing to show for it beyond a generic load error. Each alarm
// now gets its own outcome recorded, and one failure never blocks its
// siblings.
for (final alarma in _alarmas) {
await android.programar(alarma);
try {
await android.programar(alarma);
await _limpiarFalloProgramacion(alarma.id);
} catch (e) {
debugPrint(
'[PluriWave][alarmas] sincronizar todas ERROR id=${alarma.id} $e',
);
await _registrarFalloProgramacion(alarma.id);
}
}
}
+48 -1
View File
@@ -830,5 +830,52 @@
"welcomeHeadline": "عالمك، على الهواء مباشرة",
"yourStationsTitle": "محطاتك",
"nowListeningLabel": "الاستماع الآن",
"popularNowTitle": "الأكثر شيوعًا الآن"
"popularNowTitle": "الأكثر شيوعًا الآن",
"eqCustomActionEnableLabel": "تفعيل الموازن",
"eqCustomActionDisableLabel": "إيقاف الموازن",
"eqCustomActionPresetLabel": "الإعداد المسبق: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "متوقفة مؤقتًا بسبب الإجازة",
"alarmCardSchedulingFailedMessage": "لم يتم تسجيل هذا المنبه في النظام، لذا قد لا يرن.",
"alarmCardPreNoticeFailedMessage": "تمت جدولة هذا المنبه، لكن تعذّر ضبط تذكيره المسبق.",
"alarmDiagnosticsExactAlarmsTitle": "جدولة المنبه بدقة",
"alarmDiagnosticsExactAlarmsHint": "يتيح رنين المنبه في الدقيقة المحددة تمامًا، حتى عندما يكون الهاتف في وضع السكون.",
"alarmDiagnosticsNotificationsTitle": "الإشعارات",
"alarmDiagnosticsNotificationsHint": "ضرورية لعرض المنبه والتنبيه المسبق.",
"alarmDiagnosticsFullScreenTitle": "عرض المنبه بملء الشاشة",
"alarmDiagnosticsFullScreenHint": "يتيح ظهور شاشة الرنين تلقائيًا، حتى عندما يكون الهاتف مقفلاً.",
"alarmDiagnosticsBatteryTitle": "تحسين استهلاك البطارية",
"alarmDiagnosticsBatteryHint": "يمنع النظام من إغلاق PluriWave في الخلفية حتى يتمكن المنبه من الرنين.",
"alarmDiagnosticsNativeCountTitle": "المنبهات المسجَّلة لدى Android",
"alarmDiagnosticsNativeCountValue": "المسجَّل حاليًا: {count}",
"@alarmDiagnosticsNativeCountValue": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"alarmDiagnosticsNativeCountAttentionHint": "لديك منبه مفعَّل، لكن لا يوجد أي منبه مسجَّل في النظام بعد. أعد فتح PluriWave، أو عالج النقاط أعلاه أولاً.",
"alarmDiagnosticsManufacturerLabel": "الشركة المصنِّعة",
"alarmDiagnosticsSdkLabel": "إصدار Android (SDK)",
"alarmDiagnosticsNeedsAttentionStatus": "يحتاج إلى انتباه",
"alarmDiagnosticsAutostartTitle": "خطوة يدوية إضافية على هذا الهاتف",
"alarmDiagnosticsAutostartBody": "غالبًا ما تُغلق هواتف {manufacturer} التطبيقات العاملة في الخلفية لتوفير البطارية. لا يوجد إعداد يمكن لـ PluriWave تفعيله بنفسه — عليك أن تُفعِّل بنفسك خاصية التشغيل التلقائي (تُعرف أحيانًا باسم \"Autostart\" أو \"النشاط في الخلفية\") لتطبيق PluriWave. ابحث عنها في الإعدادات، ضمن التطبيقات أو البطارية، أو في تطبيق الأمان الخاص بالهاتف.",
"@alarmDiagnosticsAutostartBody": {
"placeholders": {
"manufacturer": {
"type": "String"
}
}
},
"alarmDiagnosticsFixAction": "إصلاح",
"alarmDiagnosticsIntentUnavailable": "تعذّر فتح شاشة الإعدادات هذه على هذا الهاتف. حاول البحث عنها يدويًا في الإعدادات.",
"alarmDiagnosticsUnavailableHint": "لم نتمكّن بعد من التحقق من إعدادات المنبه الخاصة بك.",
"autoEqDisableOption": "تعطيل"
}
+48 -1
View File
@@ -830,5 +830,52 @@
"welcomeHeadline": "আপনার বিশ্ব, সরাসরি",
"yourStationsTitle": "আপনার স্টেশন",
"nowListeningLabel": "এখন শোনা হচ্ছে",
"popularNowTitle": "এখন জনপ্রিয়"
"popularNowTitle": "এখন জনপ্রিয়",
"eqCustomActionEnableLabel": "ইকুয়ালাইজার চালু করুন",
"eqCustomActionDisableLabel": "ইকুয়ালাইজার বন্ধ করুন",
"eqCustomActionPresetLabel": "প্রিসেট: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "ছুটির কারণে বিরত",
"alarmCardSchedulingFailedMessage": "এই অ্যালার্মটি সিস্টেমে নিবন্ধন করা যায়নি, তাই এটি নাও বাজতে পারে।",
"alarmCardPreNoticeFailedMessage": "এই অ্যালার্মটি নির্ধারিত হয়েছে, তবে এর আগাম রিমাইন্ডার সেট করা যায়নি।",
"alarmDiagnosticsExactAlarmsTitle": "নির্ভুল অ্যালার্ম শিডিউলিং",
"alarmDiagnosticsExactAlarmsHint": "ফোন ঘুমন্ত অবস্থায় থাকলেও অ্যালার্মকে ঠিক নির্ধারিত মিনিটে বাজতে দেয়।",
"alarmDiagnosticsNotificationsTitle": "বিজ্ঞপ্তি",
"alarmDiagnosticsNotificationsHint": "অ্যালার্ম এবং আগাম সতর্কবার্তা দেখানোর জন্য প্রয়োজনীয়।",
"alarmDiagnosticsFullScreenTitle": "অ্যালার্মের ফুল-স্ক্রিন প্রদর্শন",
"alarmDiagnosticsFullScreenHint": "ফোন লক থাকলেও রিং হওয়ার স্ক্রিনটি স্বয়ংক্রিয়ভাবে দেখা দিতে দেয়।",
"alarmDiagnosticsBatteryTitle": "ব্যাটারি অপ্টিমাইজেশন",
"alarmDiagnosticsBatteryHint": "সিস্টেমকে ব্যাকগ্রাউন্ডে PluriWave বন্ধ করা থেকে আটকায়, যাতে অ্যালার্মটি তবুও বাজতে পারে।",
"alarmDiagnosticsNativeCountTitle": "Android-এ নিবন্ধিত অ্যালার্ম",
"alarmDiagnosticsNativeCountValue": "বর্তমানে নিবন্ধিত: {count}",
"@alarmDiagnosticsNativeCountValue": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"alarmDiagnosticsNativeCountAttentionHint": "আপনার একটি অ্যালার্ম চালু আছে, কিন্তু এখনও কোনোটিই সিস্টেমে নিবন্ধিত হয়নি। PluriWave আবার খুলুন, অথবা প্রথমে উপরের বিষয়গুলো ঠিক করুন।",
"alarmDiagnosticsManufacturerLabel": "নির্মাতা",
"alarmDiagnosticsSdkLabel": "Android সংস্করণ (SDK)",
"alarmDiagnosticsNeedsAttentionStatus": "মনোযোগ প্রয়োজন",
"alarmDiagnosticsAutostartTitle": "এই ফোনে আরেকটি ম্যানুয়াল ধাপ",
"alarmDiagnosticsAutostartBody": "{manufacturer} ফোন ব্যাটারি বাঁচাতে প্রায়ই ব্যাকগ্রাউন্ডে চলা অ্যাপ বন্ধ করে দেয়। এমন কোনো সেটিং নেই যা PluriWave নিজে থেকে চালু করতে পারে — আপনাকে নিজে থেকেই PluriWave-এর জন্য অটোস্টার্ট (কখনও কখনও \"Auto-start\" বা \"ব্যাকগ্রাউন্ড অ্যাক্টিভিটি\" নামেও পরিচিত) চালু করতে হবে। সেটিংসে, অ্যাপস বা ব্যাটারির মধ্যে, অথবা ফোনের নিজস্ব সিকিউরিটি অ্যাপে এটি খুঁজুন।",
"@alarmDiagnosticsAutostartBody": {
"placeholders": {
"manufacturer": {
"type": "String"
}
}
},
"alarmDiagnosticsFixAction": "সমাধান করুন",
"alarmDiagnosticsIntentUnavailable": "এই ফোনে সেই সেটিংস স্ক্রিনটি খোলা যায়নি। সেটিংসে ম্যানুয়ালি খুঁজে দেখার চেষ্টা করুন।",
"alarmDiagnosticsUnavailableHint": "আমরা এখনও আপনার অ্যালার্ম সেটিংস পরীক্ষা করতে পারিনি।",
"autoEqDisableOption": "বন্ধ করুন"
}
+48 -1
View File
@@ -830,5 +830,52 @@
"welcomeHeadline": "Deine Welt, live",
"yourStationsTitle": "Deine Sender",
"nowListeningLabel": "Läuft gerade",
"popularNowTitle": "Jetzt beliebt"
"popularNowTitle": "Jetzt beliebt",
"eqCustomActionEnableLabel": "Equalizer aktivieren",
"eqCustomActionDisableLabel": "Equalizer deaktivieren",
"eqCustomActionPresetLabel": "Voreinstellung: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "Wegen Urlaub pausiert",
"alarmCardSchedulingFailedMessage": "Dieser Alarm konnte nicht im System registriert werden, daher klingelt er möglicherweise nicht.",
"alarmCardPreNoticeFailedMessage": "Dieser Alarm ist geplant, aber seine Vorwarnung konnte nicht eingerichtet werden.",
"alarmDiagnosticsExactAlarmsTitle": "Genaue Alarmplanung",
"alarmDiagnosticsExactAlarmsHint": "Lässt den Alarm genau zur eingestellten Minute klingeln, auch wenn das Telefon im Ruhezustand ist.",
"alarmDiagnosticsNotificationsTitle": "Benachrichtigungen",
"alarmDiagnosticsNotificationsHint": "Nötig, um den Alarm und den Vorab-Hinweis anzuzeigen.",
"alarmDiagnosticsFullScreenTitle": "Alarmanzeige im Vollbildmodus",
"alarmDiagnosticsFullScreenHint": "Lässt den Klingelbildschirm automatisch erscheinen, auch wenn das Telefon gesperrt ist.",
"alarmDiagnosticsBatteryTitle": "Akku-Optimierung",
"alarmDiagnosticsBatteryHint": "Verhindert, dass das System PluriWave im Hintergrund beendet, damit der Alarm trotzdem klingeln kann.",
"alarmDiagnosticsNativeCountTitle": "Bei Android registrierte Alarme",
"alarmDiagnosticsNativeCountValue": "Aktuell registriert: {count}",
"@alarmDiagnosticsNativeCountValue": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"alarmDiagnosticsNativeCountAttentionHint": "Du hast einen Alarm aktiviert, aber es ist noch keiner beim System registriert. Öffne PluriWave erneut oder behebe zuerst die Punkte oben.",
"alarmDiagnosticsManufacturerLabel": "Hersteller",
"alarmDiagnosticsSdkLabel": "Android-Version (SDK)",
"alarmDiagnosticsNeedsAttentionStatus": "Erfordert Aufmerksamkeit",
"alarmDiagnosticsAutostartTitle": "Noch ein manueller Schritt auf diesem Telefon",
"alarmDiagnosticsAutostartBody": "{manufacturer}-Telefone schließen oft Apps im Hintergrund, um Akku zu sparen. Es gibt keine Einstellung, die PluriWave selbst aktivieren kann — du musst den Autostart (manchmal auch \"Auto-Start\" oder \"Hintergrundaktivität\" genannt) für PluriWave selbst einschalten. Schau in den Einstellungen unter Apps oder Akku, oder in der Sicherheits-App des Telefons nach.",
"@alarmDiagnosticsAutostartBody": {
"placeholders": {
"manufacturer": {
"type": "String"
}
}
},
"alarmDiagnosticsFixAction": "Beheben",
"alarmDiagnosticsIntentUnavailable": "Diese Einstellungsseite konnte auf diesem Telefon nicht geöffnet werden. Suche sie manuell in den Einstellungen.",
"alarmDiagnosticsUnavailableHint": "Wir konnten deine Alarmeinstellungen noch nicht prüfen.",
"autoEqDisableOption": "Deaktivieren"
}
+48 -1
View File
@@ -830,5 +830,52 @@
"welcomeBullet2Subtitle": "Your favorites and local music in the car",
"welcomeBullet3Title": "Music alarms",
"welcomeBullet3Subtitle": "With gradual volume rise and vacation mode",
"welcomeCtaLabel": "Start listening"
"welcomeCtaLabel": "Start listening",
"eqCustomActionEnableLabel": "Enable equalizer",
"eqCustomActionDisableLabel": "Disable equalizer",
"eqCustomActionPresetLabel": "Preset: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "Paused for vacation",
"alarmCardSchedulingFailedMessage": "This alarm could not be registered with the system, so it may not ring.",
"alarmCardPreNoticeFailedMessage": "This alarm is scheduled, but its early reminder could not be set.",
"alarmDiagnosticsExactAlarmsTitle": "Exact alarm scheduling",
"alarmDiagnosticsExactAlarmsHint": "Lets the alarm ring at the exact minute you set, even while the phone is asleep.",
"alarmDiagnosticsNotificationsTitle": "Notifications",
"alarmDiagnosticsNotificationsHint": "Needed to show the alarm and the advance-warning notice.",
"alarmDiagnosticsFullScreenTitle": "Full-screen alarm display",
"alarmDiagnosticsFullScreenHint": "Lets the ringing screen appear automatically, even with the phone locked.",
"alarmDiagnosticsBatteryTitle": "Battery optimization",
"alarmDiagnosticsBatteryHint": "Stops the system from closing PluriWave in the background so the alarm can still fire.",
"alarmDiagnosticsNativeCountTitle": "Alarms registered with Android",
"alarmDiagnosticsNativeCountValue": "Currently registered: {count}",
"@alarmDiagnosticsNativeCountValue": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"alarmDiagnosticsNativeCountAttentionHint": "You have an alarm turned on, but none are registered with the system yet. Reopen PluriWave, or fix the items above first.",
"alarmDiagnosticsManufacturerLabel": "Manufacturer",
"alarmDiagnosticsSdkLabel": "Android version (SDK)",
"alarmDiagnosticsNeedsAttentionStatus": "Needs attention",
"alarmDiagnosticsAutostartTitle": "One more manual step on this phone",
"alarmDiagnosticsAutostartBody": "{manufacturer} phones often close apps running in the background to save battery. There is no setting PluriWave can switch on its own — you need to turn on Autostart (sometimes called \"Auto-start\" or \"Background activity\") for PluriWave yourself. Look in Settings, under Apps or Battery, or in the phone's own Security app.",
"@alarmDiagnosticsAutostartBody": {
"placeholders": {
"manufacturer": {
"type": "String"
}
}
},
"alarmDiagnosticsFixAction": "Fix this",
"alarmDiagnosticsIntentUnavailable": "Couldn't open that settings screen on this phone. Try looking for it manually in Settings.",
"alarmDiagnosticsUnavailableHint": "We couldn't check your alarm settings yet.",
"autoEqDisableOption": "Disable"
}
+48 -1
View File
@@ -789,5 +789,52 @@
"welcomeBullet2Subtitle": "Tus favoritas y tu música local en el auto",
"welcomeBullet3Title": "Alarmas musicales",
"welcomeBullet3Subtitle": "Con subida progresiva y modo vacaciones",
"welcomeCtaLabel": "Empezar a escuchar"
"welcomeCtaLabel": "Empezar a escuchar",
"eqCustomActionEnableLabel": "Activar ecualizador",
"eqCustomActionDisableLabel": "Desactivar ecualizador",
"eqCustomActionPresetLabel": "Preset: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "Pausada por vacaciones",
"alarmCardSchedulingFailedMessage": "Esta alarma no se pudo registrar en el sistema, así que podría no sonar.",
"alarmCardPreNoticeFailedMessage": "Esta alarma está programada, pero no se pudo activar su aviso previo.",
"alarmDiagnosticsExactAlarmsTitle": "Programación de alarma exacta",
"alarmDiagnosticsExactAlarmsHint": "Permite que la alarma suene en el minuto exacto que elegiste, incluso con el teléfono en reposo.",
"alarmDiagnosticsNotificationsTitle": "Notificaciones",
"alarmDiagnosticsNotificationsHint": "Necesarias para mostrar la alarma y el aviso previo.",
"alarmDiagnosticsFullScreenTitle": "Pantalla completa de la alarma",
"alarmDiagnosticsFullScreenHint": "Permite que la pantalla de la alarma aparezca automáticamente, incluso con el teléfono bloqueado.",
"alarmDiagnosticsBatteryTitle": "Optimización de batería",
"alarmDiagnosticsBatteryHint": "Evita que el sistema cierre PluriWave en segundo plano para que la alarma pueda sonar.",
"alarmDiagnosticsNativeCountTitle": "Alarmas registradas en Android",
"alarmDiagnosticsNativeCountValue": "Registradas ahora mismo: {count}",
"@alarmDiagnosticsNativeCountValue": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"alarmDiagnosticsNativeCountAttentionHint": "Tenés una alarma activada, pero todavía ninguna está registrada en el sistema. Volvé a abrir PluriWave o solucioná primero los puntos de arriba.",
"alarmDiagnosticsManufacturerLabel": "Fabricante",
"alarmDiagnosticsSdkLabel": "Versión de Android (SDK)",
"alarmDiagnosticsNeedsAttentionStatus": "Necesita atención",
"alarmDiagnosticsAutostartTitle": "Un paso manual más en este teléfono",
"alarmDiagnosticsAutostartBody": "Los teléfonos {manufacturer} suelen cerrar las apps que están en segundo plano para ahorrar batería. No hay ningún ajuste que PluriWave pueda activar por su cuenta: tenés que activar vos mismo el Inicio automático (a veces llamado \"Autostart\" o \"Actividad en segundo plano\"). Buscalo en Ajustes, dentro de Apps o Batería, o en la app de Seguridad del teléfono.",
"@alarmDiagnosticsAutostartBody": {
"placeholders": {
"manufacturer": {
"type": "String"
}
}
},
"alarmDiagnosticsFixAction": "Solucionar",
"alarmDiagnosticsIntentUnavailable": "No se pudo abrir esa pantalla de ajustes en este teléfono. Buscala manualmente en Ajustes.",
"alarmDiagnosticsUnavailableHint": "Todavía no pudimos revisar tus ajustes de alarma.",
"autoEqDisableOption": "Desactivar"
}
+48 -1
View File
@@ -830,5 +830,52 @@
"welcomeHeadline": "Votre monde, en direct",
"yourStationsTitle": "Vos stations",
"nowListeningLabel": "En cours d'écoute",
"popularNowTitle": "Populaire maintenant"
"popularNowTitle": "Populaire maintenant",
"eqCustomActionEnableLabel": "Activer l'égaliseur",
"eqCustomActionDisableLabel": "Désactiver l'égaliseur",
"eqCustomActionPresetLabel": "Préréglage : {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "En pause pour les vacances",
"alarmCardSchedulingFailedMessage": "Cette alarme n'a pas pu être enregistrée dans le système ; elle risque donc de ne pas sonner.",
"alarmCardPreNoticeFailedMessage": "Cette alarme est programmée, mais son rappel anticipé n'a pas pu être activé.",
"alarmDiagnosticsExactAlarmsTitle": "Programmation d'alarme précise",
"alarmDiagnosticsExactAlarmsHint": "Permet à l'alarme de sonner à la minute exacte choisie, même si le téléphone est en veille.",
"alarmDiagnosticsNotificationsTitle": "Notifications",
"alarmDiagnosticsNotificationsHint": "Nécessaires pour afficher l'alarme et l'avis anticipé.",
"alarmDiagnosticsFullScreenTitle": "Affichage plein écran de l'alarme",
"alarmDiagnosticsFullScreenHint": "Permet à l'écran de sonnerie de s'afficher automatiquement, même si le téléphone est verrouillé.",
"alarmDiagnosticsBatteryTitle": "Optimisation de la batterie",
"alarmDiagnosticsBatteryHint": "Empêche le système de fermer PluriWave en arrière-plan pour que l'alarme puisse quand même sonner.",
"alarmDiagnosticsNativeCountTitle": "Alarmes enregistrées auprès d'Android",
"alarmDiagnosticsNativeCountValue": "Actuellement enregistrées : {count}",
"@alarmDiagnosticsNativeCountValue": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"alarmDiagnosticsNativeCountAttentionHint": "Une alarme est activée, mais aucune n'est encore enregistrée auprès du système. Rouvrez PluriWave, ou corrigez d'abord les points ci-dessus.",
"alarmDiagnosticsManufacturerLabel": "Fabricant",
"alarmDiagnosticsSdkLabel": "Version d'Android (SDK)",
"alarmDiagnosticsNeedsAttentionStatus": "Nécessite votre attention",
"alarmDiagnosticsAutostartTitle": "Encore une étape manuelle sur ce téléphone",
"alarmDiagnosticsAutostartBody": "Les téléphones {manufacturer} ferment souvent les applications en arrière-plan pour économiser la batterie. Aucun réglage ne permet à PluriWave de s'activer lui-même : vous devez activer vous-même le démarrage automatique (parfois appelé \"Autostart\" ou \"Activité en arrière-plan\") pour PluriWave. Cherchez dans les Paramètres, sous Applications ou Batterie, ou dans l'application Sécurité du téléphone.",
"@alarmDiagnosticsAutostartBody": {
"placeholders": {
"manufacturer": {
"type": "String"
}
}
},
"alarmDiagnosticsFixAction": "Corriger",
"alarmDiagnosticsIntentUnavailable": "Impossible d'ouvrir cet écran de paramètres sur ce téléphone. Essayez de le trouver manuellement dans les Paramètres.",
"alarmDiagnosticsUnavailableHint": "Nous n'avons pas encore pu vérifier vos paramètres d'alarme.",
"autoEqDisableOption": "Désactiver"
}
+48 -1
View File
@@ -830,5 +830,52 @@
"welcomeHeadline": "आपकी दुनिया, लाइव",
"yourStationsTitle": "आपके स्टेशन",
"nowListeningLabel": "अभी सुन रहे हैं",
"popularNowTitle": "अभी लोकप्रिय"
"popularNowTitle": "अभी लोकप्रिय",
"eqCustomActionEnableLabel": "इक्वलाइज़र चालू करें",
"eqCustomActionDisableLabel": "इक्वलाइज़र बंद करें",
"eqCustomActionPresetLabel": "प्रीसेट: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "छुट्टी के कारण रोका गया",
"alarmCardSchedulingFailedMessage": "यह अलार्म सिस्टम में दर्ज नहीं हो सका, इसलिए यह शायद न बजे।",
"alarmCardPreNoticeFailedMessage": "यह अलार्म शेड्यूल किया गया है, लेकिन इसकी पूर्व-चेतावनी सेट नहीं हो सकी।",
"alarmDiagnosticsExactAlarmsTitle": "सटीक अलार्म शेड्यूलिंग",
"alarmDiagnosticsExactAlarmsHint": "फ़ोन के सुप्त मोड में होने पर भी अलार्म को ठीक तय किए गए मिनट पर बजने देता है।",
"alarmDiagnosticsNotificationsTitle": "सूचनाएं",
"alarmDiagnosticsNotificationsHint": "अलार्म और पूर्व-चेतावनी सूचना दिखाने के लिए ज़रूरी।",
"alarmDiagnosticsFullScreenTitle": "अलार्म की फ़ुल-स्क्रीन डिस्प्ले",
"alarmDiagnosticsFullScreenHint": "फ़ोन लॉक होने पर भी अलार्म स्क्रीन को अपने आप दिखने देता है।",
"alarmDiagnosticsBatteryTitle": "बैटरी ऑप्टिमाइज़ेशन",
"alarmDiagnosticsBatteryHint": "सिस्टम को बैकग्राउंड में PluriWave बंद करने से रोकता है, ताकि अलार्म फिर भी बज सके।",
"alarmDiagnosticsNativeCountTitle": "Android में दर्ज अलार्म",
"alarmDiagnosticsNativeCountValue": "अभी दर्ज: {count}",
"@alarmDiagnosticsNativeCountValue": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"alarmDiagnosticsNativeCountAttentionHint": "आपने एक अलार्म चालू किया है, लेकिन अभी तक कोई भी सिस्टम में दर्ज नहीं हुआ है। PluriWave को दोबारा खोलें, या पहले ऊपर दी गई बातों को ठीक करें।",
"alarmDiagnosticsManufacturerLabel": "निर्माता",
"alarmDiagnosticsSdkLabel": "Android वर्शन (SDK)",
"alarmDiagnosticsNeedsAttentionStatus": "ध्यान देने की ज़रूरत है",
"alarmDiagnosticsAutostartTitle": "इस फ़ोन पर एक और मैन्युअल चरण",
"alarmDiagnosticsAutostartBody": "{manufacturer} फ़ोन बैटरी बचाने के लिए अक्सर बैकग्राउंड में चल रहे ऐप बंद कर देते हैं। ऐसी कोई सेटिंग नहीं है जिसे PluriWave खुद चालू कर सके — आपको PluriWave के लिए खुद ऑटोस्टार्ट (कभी-कभी \"Auto-start\" या \"बैकग्राउंड एक्टिविटी\" भी कहा जाता है) चालू करना होगा। इसे सेटिंग्स में, ऐप्स या बैटरी के अंदर, या फ़ोन के अपने सिक्योरिटी ऐप में देखें।",
"@alarmDiagnosticsAutostartBody": {
"placeholders": {
"manufacturer": {
"type": "String"
}
}
},
"alarmDiagnosticsFixAction": "ठीक करें",
"alarmDiagnosticsIntentUnavailable": "इस फ़ोन पर वह सेटिंग्स स्क्रीन नहीं खोली जा सकी। कृपया सेटिंग्स में इसे खुद ढूंढने की कोशिश करें।",
"alarmDiagnosticsUnavailableHint": "हम अभी तक आपकी अलार्म सेटिंग्स जांच नहीं पाए हैं।",
"autoEqDisableOption": "बंद करें"
}
+48 -1
View File
@@ -830,5 +830,52 @@
"welcomeHeadline": "Duniamu, secara langsung",
"yourStationsTitle": "Stasiun Anda",
"nowListeningLabel": "Sedang mendengarkan",
"popularNowTitle": "Populer sekarang"
"popularNowTitle": "Populer sekarang",
"eqCustomActionEnableLabel": "Aktifkan equalizer",
"eqCustomActionDisableLabel": "Nonaktifkan equalizer",
"eqCustomActionPresetLabel": "Prasetel: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "Dijeda karena liburan",
"alarmCardSchedulingFailedMessage": "Alarm ini tidak dapat didaftarkan ke sistem, sehingga mungkin tidak berbunyi.",
"alarmCardPreNoticeFailedMessage": "Alarm ini sudah dijadwalkan, tetapi pengingat awalnya tidak dapat diatur.",
"alarmDiagnosticsExactAlarmsTitle": "Penjadwalan alarm presisi",
"alarmDiagnosticsExactAlarmsHint": "Membuat alarm berbunyi tepat pada menit yang kamu atur, meski ponsel dalam mode tidur.",
"alarmDiagnosticsNotificationsTitle": "Notifikasi",
"alarmDiagnosticsNotificationsHint": "Diperlukan untuk menampilkan alarm dan pemberitahuan dini.",
"alarmDiagnosticsFullScreenTitle": "Tampilan alarm layar penuh",
"alarmDiagnosticsFullScreenHint": "Membuat layar alarm muncul otomatis, meski ponsel terkunci.",
"alarmDiagnosticsBatteryTitle": "Optimisasi baterai",
"alarmDiagnosticsBatteryHint": "Mencegah sistem menutup PluriWave di latar belakang, sehingga alarm tetap bisa berbunyi.",
"alarmDiagnosticsNativeCountTitle": "Alarm yang terdaftar di Android",
"alarmDiagnosticsNativeCountValue": "Terdaftar saat ini: {count}",
"@alarmDiagnosticsNativeCountValue": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"alarmDiagnosticsNativeCountAttentionHint": "Kamu punya alarm yang aktif, tapi belum ada yang terdaftar di sistem. Buka lagi PluriWave, atau perbaiki dulu poin-poin di atas.",
"alarmDiagnosticsManufacturerLabel": "Produsen",
"alarmDiagnosticsSdkLabel": "Versi Android (SDK)",
"alarmDiagnosticsNeedsAttentionStatus": "Perlu perhatian",
"alarmDiagnosticsAutostartTitle": "Satu langkah manual lagi di ponsel ini",
"alarmDiagnosticsAutostartBody": "Ponsel {manufacturer} sering menutup aplikasi yang berjalan di latar belakang untuk menghemat baterai. Tidak ada pengaturan yang bisa diaktifkan PluriWave sendiri — kamu perlu mengaktifkan sendiri Autostart (kadang disebut \"Mulai otomatis\" atau \"Aktivitas latar belakang\") untuk PluriWave. Cari di Pengaturan, di bagian Aplikasi atau Baterai, atau di aplikasi Keamanan bawaan ponsel.",
"@alarmDiagnosticsAutostartBody": {
"placeholders": {
"manufacturer": {
"type": "String"
}
}
},
"alarmDiagnosticsFixAction": "Perbaiki",
"alarmDiagnosticsIntentUnavailable": "Tidak bisa membuka layar pengaturan itu di ponsel ini. Coba cari secara manual di Pengaturan.",
"alarmDiagnosticsUnavailableHint": "Kami belum bisa memeriksa pengaturan alarmmu.",
"autoEqDisableOption": "Nonaktifkan"
}
+48 -1
View File
@@ -830,5 +830,52 @@
"welcomeHeadline": "Il tuo mondo, in diretta",
"yourStationsTitle": "Le tue emittenti",
"nowListeningLabel": "In ascolto ora",
"popularNowTitle": "Popolari ora"
"popularNowTitle": "Popolari ora",
"eqCustomActionEnableLabel": "Attiva equalizzatore",
"eqCustomActionDisableLabel": "Disattiva equalizzatore",
"eqCustomActionPresetLabel": "Preset attivo: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "In pausa per le vacanze",
"alarmCardSchedulingFailedMessage": "Questa sveglia non è stata registrata nel sistema, quindi potrebbe non suonare.",
"alarmCardPreNoticeFailedMessage": "Questa sveglia è programmata, ma non è stato possibile impostare il promemoria anticipato.",
"alarmDiagnosticsExactAlarmsTitle": "Programmazione sveglia esatta",
"alarmDiagnosticsExactAlarmsHint": "Permette alla sveglia di suonare esattamente al minuto impostato, anche a telefono in stand-by.",
"alarmDiagnosticsNotificationsTitle": "Notifiche",
"alarmDiagnosticsNotificationsHint": "Necessarie per mostrare la sveglia e l'avviso anticipato.",
"alarmDiagnosticsFullScreenTitle": "Visualizzazione a schermo intero della sveglia",
"alarmDiagnosticsFullScreenHint": "Permette alla schermata della sveglia di apparire automaticamente, anche a telefono bloccato.",
"alarmDiagnosticsBatteryTitle": "Ottimizzazione della batteria",
"alarmDiagnosticsBatteryHint": "Impedisce al sistema di chiudere PluriWave in background, così la sveglia può comunque suonare.",
"alarmDiagnosticsNativeCountTitle": "Sveglie registrate su Android",
"alarmDiagnosticsNativeCountValue": "Attualmente registrate: {count}",
"@alarmDiagnosticsNativeCountValue": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"alarmDiagnosticsNativeCountAttentionHint": "Hai una sveglia attiva, ma nessuna è ancora registrata nel sistema. Riapri PluriWave, oppure risolvi prima i punti sopra.",
"alarmDiagnosticsManufacturerLabel": "Produttore",
"alarmDiagnosticsSdkLabel": "Versione di Android (SDK)",
"alarmDiagnosticsNeedsAttentionStatus": "Richiede attenzione",
"alarmDiagnosticsAutostartTitle": "Un altro passaggio manuale su questo telefono",
"alarmDiagnosticsAutostartBody": "I telefoni {manufacturer} spesso chiudono le app in background per risparmiare batteria. Non esiste un'impostazione che PluriWave possa attivare da solo: devi attivare tu stesso l'Avvio automatico (a volte chiamato \"Autostart\" o \"Attività in background\") per PluriWave. Cercalo nelle Impostazioni, sotto App o Batteria, oppure nell'app Sicurezza del telefono.",
"@alarmDiagnosticsAutostartBody": {
"placeholders": {
"manufacturer": {
"type": "String"
}
}
},
"alarmDiagnosticsFixAction": "Risolvi",
"alarmDiagnosticsIntentUnavailable": "Non è stato possibile aprire questa schermata delle impostazioni su questo telefono. Prova a cercarla manualmente nelle Impostazioni.",
"alarmDiagnosticsUnavailableHint": "Non abbiamo ancora potuto controllare le impostazioni della sveglia.",
"autoEqDisableOption": "Disattiva"
}
+48 -1
View File
@@ -830,5 +830,52 @@
"welcomeHeadline": "あなたの世界を、ライブで",
"yourStationsTitle": "あなたの局",
"nowListeningLabel": "再生中",
"popularNowTitle": "今人気"
"popularNowTitle": "今人気",
"eqCustomActionEnableLabel": "イコライザーをオンにする",
"eqCustomActionDisableLabel": "イコライザーをオフにする",
"eqCustomActionPresetLabel": "プリセット: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "休暇のため一時停止中",
"alarmCardSchedulingFailedMessage": "このアラームはシステムに登録できなかったため、鳴らない可能性があります。",
"alarmCardPreNoticeFailedMessage": "このアラームは設定されていますが、事前通知を設定できませんでした。",
"alarmDiagnosticsExactAlarmsTitle": "正確なアラームのスケジュール設定",
"alarmDiagnosticsExactAlarmsHint": "スマートフォンがスリープ中でも、設定した時刻ちょうどにアラームを鳴らせるようにします。",
"alarmDiagnosticsNotificationsTitle": "通知",
"alarmDiagnosticsNotificationsHint": "アラームと事前通知を表示するために必要です。",
"alarmDiagnosticsFullScreenTitle": "アラームのフルスクリーン表示",
"alarmDiagnosticsFullScreenHint": "画面がロックされていても、アラーム画面が自動的に表示されるようにします。",
"alarmDiagnosticsBatteryTitle": "バッテリーの最適化",
"alarmDiagnosticsBatteryHint": "システムがPluriWaveをバックグラウンドで終了しないようにし、アラームが確実に鳴るようにします。",
"alarmDiagnosticsNativeCountTitle": "Androidに登録されているアラーム",
"alarmDiagnosticsNativeCountValue": "現在登録されている数: {count}",
"@alarmDiagnosticsNativeCountValue": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"alarmDiagnosticsNativeCountAttentionHint": "アラームは有効になっていますが、まだシステムに登録されていません。PluriWaveを開き直すか、まず上の項目を確認してください。",
"alarmDiagnosticsManufacturerLabel": "製造元",
"alarmDiagnosticsSdkLabel": "Androidのバージョン(SDK",
"alarmDiagnosticsNeedsAttentionStatus": "確認が必要です",
"alarmDiagnosticsAutostartTitle": "この端末でのもう一つの手動設定",
"alarmDiagnosticsAutostartBody": "{manufacturer}のスマートフォンは、バッテリーを節約するためにバックグラウンドのアプリを終了させることがよくあります。PluriWaveが自動でオンにできる設定はありません。PluriWaveの自動起動(「オートスタート」や「バックグラウンド動作」と呼ばれることもあります)を、自分で有効にする必要があります。設定内のアプリまたはバッテリーの項目、あるいは端末のセキュリティアプリを確認してください。",
"@alarmDiagnosticsAutostartBody": {
"placeholders": {
"manufacturer": {
"type": "String"
}
}
},
"alarmDiagnosticsFixAction": "修正する",
"alarmDiagnosticsIntentUnavailable": "この端末では設定画面を開けませんでした。設定アプリ内で手動で探してみてください。",
"alarmDiagnosticsUnavailableHint": "アラームの設定をまだ確認できていません。",
"autoEqDisableOption": "無効化"
}
+48 -1
View File
@@ -830,5 +830,52 @@
"welcomeHeadline": "Seu mundo, ao vivo",
"yourStationsTitle": "Suas estações",
"nowListeningLabel": "Ouvindo agora",
"popularNowTitle": "Populares agora"
"popularNowTitle": "Populares agora",
"eqCustomActionEnableLabel": "Ativar equalizador",
"eqCustomActionDisableLabel": "Desativar equalizador",
"eqCustomActionPresetLabel": "Predefinição: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "Pausada por férias",
"alarmCardSchedulingFailedMessage": "Este alarme não pôde ser registrado no sistema, por isso pode não tocar.",
"alarmCardPreNoticeFailedMessage": "Este alarme está agendado, mas seu aviso antecipado não pôde ser configurado.",
"alarmDiagnosticsExactAlarmsTitle": "Agendamento exato do alarme",
"alarmDiagnosticsExactAlarmsHint": "Permite que o alarme toque no minuto exato definido, mesmo com o telefone em repouso.",
"alarmDiagnosticsNotificationsTitle": "Notificações",
"alarmDiagnosticsNotificationsHint": "Necessárias para mostrar o alarme e o aviso prévio.",
"alarmDiagnosticsFullScreenTitle": "Exibição em tela cheia do alarme",
"alarmDiagnosticsFullScreenHint": "Permite que a tela do alarme apareça automaticamente, mesmo com o telefone bloqueado.",
"alarmDiagnosticsBatteryTitle": "Otimização de bateria",
"alarmDiagnosticsBatteryHint": "Evita que o sistema feche o PluriWave em segundo plano, para que o alarme ainda possa tocar.",
"alarmDiagnosticsNativeCountTitle": "Alarmes registrados no Android",
"alarmDiagnosticsNativeCountValue": "Registrados agora: {count}",
"@alarmDiagnosticsNativeCountValue": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"alarmDiagnosticsNativeCountAttentionHint": "Você tem um alarme ativado, mas nenhum está registrado no sistema ainda. Reabra o PluriWave ou resolva primeiro os itens acima.",
"alarmDiagnosticsManufacturerLabel": "Fabricante",
"alarmDiagnosticsSdkLabel": "Versão do Android (SDK)",
"alarmDiagnosticsNeedsAttentionStatus": "Precisa de atenção",
"alarmDiagnosticsAutostartTitle": "Mais uma etapa manual neste telefone",
"alarmDiagnosticsAutostartBody": "Telefones {manufacturer} costumam fechar apps em segundo plano para economizar bateria. Não existe uma configuração que o PluriWave possa ativar sozinho: você precisa ativar por conta própria o Início automático (às vezes chamado de \"Autostart\" ou \"Atividade em segundo plano\") para o PluriWave. Procure em Configurações, em Apps ou Bateria, ou no próprio app de Segurança do telefone.",
"@alarmDiagnosticsAutostartBody": {
"placeholders": {
"manufacturer": {
"type": "String"
}
}
},
"alarmDiagnosticsFixAction": "Resolver",
"alarmDiagnosticsIntentUnavailable": "Não foi possível abrir essa tela de configurações neste telefone. Tente procurá-la manualmente nas Configurações.",
"alarmDiagnosticsUnavailableHint": "Ainda não conseguimos verificar as configurações do seu alarme.",
"autoEqDisableOption": "Desativar"
}
+48 -1
View File
@@ -830,5 +830,52 @@
"welcomeHeadline": "Ваш мир, в прямом эфире",
"yourStationsTitle": "Ваши станции",
"nowListeningLabel": "Сейчас слушаете",
"popularNowTitle": "Популярно сейчас"
"popularNowTitle": "Популярно сейчас",
"eqCustomActionEnableLabel": "Включить эквалайзер",
"eqCustomActionDisableLabel": "Выключить эквалайзер",
"eqCustomActionPresetLabel": "Пресет: {preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "Приостановлено на время отпуска",
"alarmCardSchedulingFailedMessage": "Этот будильник не удалось зарегистрировать в системе, поэтому он может не сработать.",
"alarmCardPreNoticeFailedMessage": "Этот будильник запланирован, но не удалось настроить предварительное напоминание.",
"alarmDiagnosticsExactAlarmsTitle": "Точное планирование будильника",
"alarmDiagnosticsExactAlarmsHint": "Будильник звонит ровно в заданную минуту, даже если телефон находится в режиме сна.",
"alarmDiagnosticsNotificationsTitle": "Уведомления",
"alarmDiagnosticsNotificationsHint": "Нужны, чтобы показать будильник и заблаговременное напоминание.",
"alarmDiagnosticsFullScreenTitle": "Полноэкранный показ будильника",
"alarmDiagnosticsFullScreenHint": "Экран будильника появляется автоматически, даже если телефон заблокирован.",
"alarmDiagnosticsBatteryTitle": "Оптимизация батареи",
"alarmDiagnosticsBatteryHint": "Не позволяет системе закрывать PluriWave в фоновом режиме, чтобы будильник мог сработать.",
"alarmDiagnosticsNativeCountTitle": "Будильники, зарегистрированные в Android",
"alarmDiagnosticsNativeCountValue": "Сейчас зарегистрировано: {count}",
"@alarmDiagnosticsNativeCountValue": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"alarmDiagnosticsNativeCountAttentionHint": "У вас включён будильник, но пока ни один не зарегистрирован в системе. Откройте PluriWave заново или сначала устраните пункты выше.",
"alarmDiagnosticsManufacturerLabel": "Производитель",
"alarmDiagnosticsSdkLabel": "Версия Android (SDK)",
"alarmDiagnosticsNeedsAttentionStatus": "Требует внимания",
"alarmDiagnosticsAutostartTitle": "Ещё один ручной шаг на этом телефоне",
"alarmDiagnosticsAutostartBody": "Телефоны {manufacturer} часто закрывают приложения в фоновом режиме для экономии батареи. PluriWave не может включить это самостоятельно — вам нужно вручную включить автозапуск (иногда называется \"Autostart\" или \"Фоновая активность\") для PluriWave. Ищите в Настройках, в разделе Приложения или Батарея, либо в приложении безопасности телефона.",
"@alarmDiagnosticsAutostartBody": {
"placeholders": {
"manufacturer": {
"type": "String"
}
}
},
"alarmDiagnosticsFixAction": "Исправить",
"alarmDiagnosticsIntentUnavailable": "Не удалось открыть этот экран настроек на этом телефоне. Попробуйте найти его вручную в Настройках.",
"alarmDiagnosticsUnavailableHint": "Мы пока не смогли проверить настройки вашего будильника.",
"autoEqDisableOption": "Отключить"
}
+48 -1
View File
@@ -830,5 +830,52 @@
"welcomeHeadline": "你的世界,直播中",
"yourStationsTitle": "你的电台",
"nowListeningLabel": "正在收听",
"popularNowTitle": "当前热门"
"popularNowTitle": "当前热门",
"eqCustomActionEnableLabel": "启用均衡器",
"eqCustomActionDisableLabel": "关闭均衡器",
"eqCustomActionPresetLabel": "预设:{preset}",
"@eqCustomActionPresetLabel": {
"placeholders": {
"preset": {
"type": "String"
}
}
},
"alarmCardVacationPausedBadge": "因假期已暂停",
"alarmCardSchedulingFailedMessage": "该闹钟未能在系统中注册,因此可能不会响铃。",
"alarmCardPreNoticeFailedMessage": "该闹钟已设置,但其提前提醒未能设置成功。",
"alarmDiagnosticsExactAlarmsTitle": "精确闹钟排程",
"alarmDiagnosticsExactAlarmsHint": "即使手机处于休眠状态,也能让闹钟在设定的准确时间响起。",
"alarmDiagnosticsNotificationsTitle": "通知",
"alarmDiagnosticsNotificationsHint": "显示闹钟和提前提醒需要用到。",
"alarmDiagnosticsFullScreenTitle": "闹钟全屏显示",
"alarmDiagnosticsFullScreenHint": "即使手机已锁屏,也能让响铃界面自动出现。",
"alarmDiagnosticsBatteryTitle": "电池优化",
"alarmDiagnosticsBatteryHint": "防止系统在后台关闭PluriWave,让闹钟仍然可以响起。",
"alarmDiagnosticsNativeCountTitle": "已在Android系统注册的闹钟",
"alarmDiagnosticsNativeCountValue": "当前已注册:{count}",
"@alarmDiagnosticsNativeCountValue": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"alarmDiagnosticsNativeCountAttentionHint": "你已开启一个闹钟,但目前还没有闹钟在系统中注册。请重新打开PluriWave,或先解决上面列出的问题。",
"alarmDiagnosticsManufacturerLabel": "制造商",
"alarmDiagnosticsSdkLabel": "Android版本(SDK",
"alarmDiagnosticsNeedsAttentionStatus": "需要注意",
"alarmDiagnosticsAutostartTitle": "此手机还需要一步手动设置",
"alarmDiagnosticsAutostartBody": "{manufacturer}手机经常会关闭后台运行的应用以节省电量。没有任何设置可以让PluriWave自行开启——你需要自己为PluriWave开启自启动(有时也叫\"Autostart\"或\"后台活动\")。请在设置中查找应用或电池选项,或者查看手机自带的安全应用。",
"@alarmDiagnosticsAutostartBody": {
"placeholders": {
"manufacturer": {
"type": "String"
}
}
},
"alarmDiagnosticsFixAction": "解决",
"alarmDiagnosticsIntentUnavailable": "无法在此手机上打开该设置界面。请尝试在设置中手动查找。",
"alarmDiagnosticsUnavailableHint": "我们还无法检查你的闹钟设置。",
"autoEqDisableOption": "关闭"
}
+156
View File
@@ -3049,6 +3049,162 @@ abstract class AppLocalizations {
/// In es, this message translates to:
/// **'Empezar a escuchar'**
String get welcomeCtaLabel;
/// No description provided for @eqCustomActionEnableLabel.
///
/// In es, this message translates to:
/// **'Activar ecualizador'**
String get eqCustomActionEnableLabel;
/// No description provided for @eqCustomActionDisableLabel.
///
/// In es, this message translates to:
/// **'Desactivar ecualizador'**
String get eqCustomActionDisableLabel;
/// No description provided for @eqCustomActionPresetLabel.
///
/// In es, this message translates to:
/// **'Preset: {preset}'**
String eqCustomActionPresetLabel(String preset);
/// No description provided for @alarmCardVacationPausedBadge.
///
/// In es, this message translates to:
/// **'Pausada por vacaciones'**
String get alarmCardVacationPausedBadge;
/// No description provided for @alarmCardSchedulingFailedMessage.
///
/// In es, this message translates to:
/// **'Esta alarma no se pudo registrar en el sistema, así que podría no sonar.'**
String get alarmCardSchedulingFailedMessage;
/// No description provided for @alarmCardPreNoticeFailedMessage.
///
/// In es, this message translates to:
/// **'Esta alarma está programada, pero no se pudo activar su aviso previo.'**
String get alarmCardPreNoticeFailedMessage;
/// No description provided for @alarmDiagnosticsExactAlarmsTitle.
///
/// In es, this message translates to:
/// **'Programación de alarma exacta'**
String get alarmDiagnosticsExactAlarmsTitle;
/// No description provided for @alarmDiagnosticsExactAlarmsHint.
///
/// In es, this message translates to:
/// **'Permite que la alarma suene en el minuto exacto que elegiste, incluso con el teléfono en reposo.'**
String get alarmDiagnosticsExactAlarmsHint;
/// No description provided for @alarmDiagnosticsNotificationsTitle.
///
/// In es, this message translates to:
/// **'Notificaciones'**
String get alarmDiagnosticsNotificationsTitle;
/// No description provided for @alarmDiagnosticsNotificationsHint.
///
/// In es, this message translates to:
/// **'Necesarias para mostrar la alarma y el aviso previo.'**
String get alarmDiagnosticsNotificationsHint;
/// No description provided for @alarmDiagnosticsFullScreenTitle.
///
/// In es, this message translates to:
/// **'Pantalla completa de la alarma'**
String get alarmDiagnosticsFullScreenTitle;
/// No description provided for @alarmDiagnosticsFullScreenHint.
///
/// In es, this message translates to:
/// **'Permite que la pantalla de la alarma aparezca automáticamente, incluso con el teléfono bloqueado.'**
String get alarmDiagnosticsFullScreenHint;
/// No description provided for @alarmDiagnosticsBatteryTitle.
///
/// In es, this message translates to:
/// **'Optimización de batería'**
String get alarmDiagnosticsBatteryTitle;
/// No description provided for @alarmDiagnosticsBatteryHint.
///
/// In es, this message translates to:
/// **'Evita que el sistema cierre PluriWave en segundo plano para que la alarma pueda sonar.'**
String get alarmDiagnosticsBatteryHint;
/// No description provided for @alarmDiagnosticsNativeCountTitle.
///
/// In es, this message translates to:
/// **'Alarmas registradas en Android'**
String get alarmDiagnosticsNativeCountTitle;
/// No description provided for @alarmDiagnosticsNativeCountValue.
///
/// In es, this message translates to:
/// **'Registradas ahora mismo: {count}'**
String alarmDiagnosticsNativeCountValue(int count);
/// No description provided for @alarmDiagnosticsNativeCountAttentionHint.
///
/// In es, this message translates to:
/// **'Tenés una alarma activada, pero todavía ninguna está registrada en el sistema. Volvé a abrir PluriWave o solucioná primero los puntos de arriba.'**
String get alarmDiagnosticsNativeCountAttentionHint;
/// No description provided for @alarmDiagnosticsManufacturerLabel.
///
/// In es, this message translates to:
/// **'Fabricante'**
String get alarmDiagnosticsManufacturerLabel;
/// No description provided for @alarmDiagnosticsSdkLabel.
///
/// In es, this message translates to:
/// **'Versión de Android (SDK)'**
String get alarmDiagnosticsSdkLabel;
/// No description provided for @alarmDiagnosticsNeedsAttentionStatus.
///
/// In es, this message translates to:
/// **'Necesita atención'**
String get alarmDiagnosticsNeedsAttentionStatus;
/// No description provided for @alarmDiagnosticsAutostartTitle.
///
/// In es, this message translates to:
/// **'Un paso manual más en este teléfono'**
String get alarmDiagnosticsAutostartTitle;
/// No description provided for @alarmDiagnosticsAutostartBody.
///
/// In es, this message translates to:
/// **'Los teléfonos {manufacturer} suelen cerrar las apps que están en segundo plano para ahorrar batería. No hay ningún ajuste que PluriWave pueda activar por su cuenta: tenés que activar vos mismo el Inicio automático (a veces llamado \"Autostart\" o \"Actividad en segundo plano\"). Buscalo en Ajustes, dentro de Apps o Batería, o en la app de Seguridad del teléfono.'**
String alarmDiagnosticsAutostartBody(String manufacturer);
/// No description provided for @alarmDiagnosticsFixAction.
///
/// In es, this message translates to:
/// **'Solucionar'**
String get alarmDiagnosticsFixAction;
/// No description provided for @alarmDiagnosticsIntentUnavailable.
///
/// In es, this message translates to:
/// **'No se pudo abrir esa pantalla de ajustes en este teléfono. Buscala manualmente en Ajustes.'**
String get alarmDiagnosticsIntentUnavailable;
/// No description provided for @alarmDiagnosticsUnavailableHint.
///
/// In es, this message translates to:
/// **'Todavía no pudimos revisar tus ajustes de alarma.'**
String get alarmDiagnosticsUnavailableHint;
/// No description provided for @autoEqDisableOption.
///
/// In es, this message translates to:
/// **'Desactivar'**
String get autoEqDisableOption;
}
class _AppLocalizationsDelegate
+95
View File
@@ -1676,4 +1676,99 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get welcomeCtaLabel => 'ابدأ الاستماع';
@override
String get eqCustomActionEnableLabel => 'تفعيل الموازن';
@override
String get eqCustomActionDisableLabel => 'إيقاف الموازن';
@override
String eqCustomActionPresetLabel(String preset) {
return 'الإعداد المسبق: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'متوقفة مؤقتًا بسبب الإجازة';
@override
String get alarmCardSchedulingFailedMessage =>
'لم يتم تسجيل هذا المنبه في النظام، لذا قد لا يرن.';
@override
String get alarmCardPreNoticeFailedMessage =>
'تمت جدولة هذا المنبه، لكن تعذّر ضبط تذكيره المسبق.';
@override
String get alarmDiagnosticsExactAlarmsTitle => 'جدولة المنبه بدقة';
@override
String get alarmDiagnosticsExactAlarmsHint =>
'يتيح رنين المنبه في الدقيقة المحددة تمامًا، حتى عندما يكون الهاتف في وضع السكون.';
@override
String get alarmDiagnosticsNotificationsTitle => 'الإشعارات';
@override
String get alarmDiagnosticsNotificationsHint =>
'ضرورية لعرض المنبه والتنبيه المسبق.';
@override
String get alarmDiagnosticsFullScreenTitle => 'عرض المنبه بملء الشاشة';
@override
String get alarmDiagnosticsFullScreenHint =>
'يتيح ظهور شاشة الرنين تلقائيًا، حتى عندما يكون الهاتف مقفلاً.';
@override
String get alarmDiagnosticsBatteryTitle => 'تحسين استهلاك البطارية';
@override
String get alarmDiagnosticsBatteryHint =>
'يمنع النظام من إغلاق PluriWave في الخلفية حتى يتمكن المنبه من الرنين.';
@override
String get alarmDiagnosticsNativeCountTitle =>
'المنبهات المسجَّلة لدى Android';
@override
String alarmDiagnosticsNativeCountValue(int count) {
return 'المسجَّل حاليًا: $count';
}
@override
String get alarmDiagnosticsNativeCountAttentionHint =>
'لديك منبه مفعَّل، لكن لا يوجد أي منبه مسجَّل في النظام بعد. أعد فتح PluriWave، أو عالج النقاط أعلاه أولاً.';
@override
String get alarmDiagnosticsManufacturerLabel => 'الشركة المصنِّعة';
@override
String get alarmDiagnosticsSdkLabel => 'إصدار Android (SDK)';
@override
String get alarmDiagnosticsNeedsAttentionStatus => 'يحتاج إلى انتباه';
@override
String get alarmDiagnosticsAutostartTitle =>
'خطوة يدوية إضافية على هذا الهاتف';
@override
String alarmDiagnosticsAutostartBody(String manufacturer) {
return 'غالبًا ما تُغلق هواتف $manufacturer التطبيقات العاملة في الخلفية لتوفير البطارية. لا يوجد إعداد يمكن لـ PluriWave تفعيله بنفسه — عليك أن تُفعِّل بنفسك خاصية التشغيل التلقائي (تُعرف أحيانًا باسم \"Autostart\" أو \"النشاط في الخلفية\") لتطبيق PluriWave. ابحث عنها في الإعدادات، ضمن التطبيقات أو البطارية، أو في تطبيق الأمان الخاص بالهاتف.';
}
@override
String get alarmDiagnosticsFixAction => 'إصلاح';
@override
String get alarmDiagnosticsIntentUnavailable =>
'تعذّر فتح شاشة الإعدادات هذه على هذا الهاتف. حاول البحث عنها يدويًا في الإعدادات.';
@override
String get alarmDiagnosticsUnavailableHint =>
'لم نتمكّن بعد من التحقق من إعدادات المنبه الخاصة بك.';
@override
String get autoEqDisableOption => 'تعطيل';
}
+94
View File
@@ -1685,4 +1685,98 @@ class AppLocalizationsBn extends AppLocalizations {
@override
String get welcomeCtaLabel => 'শোনা শুরু করুন';
@override
String get eqCustomActionEnableLabel => 'ইকুয়ালাইজার চালু করুন';
@override
String get eqCustomActionDisableLabel => 'ইকুয়ালাইজার বন্ধ করুন';
@override
String eqCustomActionPresetLabel(String preset) {
return 'প্রিসেট: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'ছুটির কারণে বিরত';
@override
String get alarmCardSchedulingFailedMessage =>
'এই অ্যালার্মটি সিস্টেমে নিবন্ধন করা যায়নি, তাই এটি নাও বাজতে পারে।';
@override
String get alarmCardPreNoticeFailedMessage =>
'এই অ্যালার্মটি নির্ধারিত হয়েছে, তবে এর আগাম রিমাইন্ডার সেট করা যায়নি।';
@override
String get alarmDiagnosticsExactAlarmsTitle => 'নির্ভুল অ্যালার্ম শিডিউলিং';
@override
String get alarmDiagnosticsExactAlarmsHint =>
'ফোন ঘুমন্ত অবস্থায় থাকলেও অ্যালার্মকে ঠিক নির্ধারিত মিনিটে বাজতে দেয়।';
@override
String get alarmDiagnosticsNotificationsTitle => 'বিজ্ঞপ্তি';
@override
String get alarmDiagnosticsNotificationsHint =>
'অ্যালার্ম এবং আগাম সতর্কবার্তা দেখানোর জন্য প্রয়োজনীয়।';
@override
String get alarmDiagnosticsFullScreenTitle =>
'অ্যালার্মের ফুল-স্ক্রিন প্রদর্শন';
@override
String get alarmDiagnosticsFullScreenHint =>
'ফোন লক থাকলেও রিং হওয়ার স্ক্রিনটি স্বয়ংক্রিয়ভাবে দেখা দিতে দেয়।';
@override
String get alarmDiagnosticsBatteryTitle => 'ব্যাটারি অপ্টিমাইজেশন';
@override
String get alarmDiagnosticsBatteryHint =>
'সিস্টেমকে ব্যাকগ্রাউন্ডে PluriWave বন্ধ করা থেকে আটকায়, যাতে অ্যালার্মটি তবুও বাজতে পারে।';
@override
String get alarmDiagnosticsNativeCountTitle => 'Android-এ নিবন্ধিত অ্যালার্ম';
@override
String alarmDiagnosticsNativeCountValue(int count) {
return 'বর্তমানে নিবন্ধিত: $count';
}
@override
String get alarmDiagnosticsNativeCountAttentionHint =>
'আপনার একটি অ্যালার্ম চালু আছে, কিন্তু এখনও কোনোটিই সিস্টেমে নিবন্ধিত হয়নি। PluriWave আবার খুলুন, অথবা প্রথমে উপরের বিষয়গুলো ঠিক করুন।';
@override
String get alarmDiagnosticsManufacturerLabel => 'নির্মাতা';
@override
String get alarmDiagnosticsSdkLabel => 'Android সংস্করণ (SDK)';
@override
String get alarmDiagnosticsNeedsAttentionStatus => 'মনোযোগ প্রয়োজন';
@override
String get alarmDiagnosticsAutostartTitle => 'এই ফোনে আরেকটি ম্যানুয়াল ধাপ';
@override
String alarmDiagnosticsAutostartBody(String manufacturer) {
return '$manufacturer ফোন ব্যাটারি বাঁচাতে প্রায়ই ব্যাকগ্রাউন্ডে চলা অ্যাপ বন্ধ করে দেয়। এমন কোনো সেটিং নেই যা PluriWave নিজে থেকে চালু করতে পারে — আপনাকে নিজে থেকেই PluriWave-এর জন্য অটোস্টার্ট (কখনও কখনও \"Auto-start\" বা \"ব্যাকগ্রাউন্ড অ্যাক্টিভিটি\" নামেও পরিচিত) চালু করতে হবে। সেটিংসে, অ্যাপস বা ব্যাটারির মধ্যে, অথবা ফোনের নিজস্ব সিকিউরিটি অ্যাপে এটি খুঁজুন।';
}
@override
String get alarmDiagnosticsFixAction => 'সমাধান করুন';
@override
String get alarmDiagnosticsIntentUnavailable =>
'এই ফোনে সেই সেটিংস স্ক্রিনটি খোলা যায়নি। সেটিংসে ম্যানুয়ালি খুঁজে দেখার চেষ্টা করুন।';
@override
String get alarmDiagnosticsUnavailableHint =>
'আমরা এখনও আপনার অ্যালার্ম সেটিংস পরীক্ষা করতে পারিনি।';
@override
String get autoEqDisableOption => 'বন্ধ করুন';
}
+95
View File
@@ -1698,4 +1698,99 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get welcomeCtaLabel => 'Jetzt hören';
@override
String get eqCustomActionEnableLabel => 'Equalizer aktivieren';
@override
String get eqCustomActionDisableLabel => 'Equalizer deaktivieren';
@override
String eqCustomActionPresetLabel(String preset) {
return 'Voreinstellung: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'Wegen Urlaub pausiert';
@override
String get alarmCardSchedulingFailedMessage =>
'Dieser Alarm konnte nicht im System registriert werden, daher klingelt er möglicherweise nicht.';
@override
String get alarmCardPreNoticeFailedMessage =>
'Dieser Alarm ist geplant, aber seine Vorwarnung konnte nicht eingerichtet werden.';
@override
String get alarmDiagnosticsExactAlarmsTitle => 'Genaue Alarmplanung';
@override
String get alarmDiagnosticsExactAlarmsHint =>
'Lässt den Alarm genau zur eingestellten Minute klingeln, auch wenn das Telefon im Ruhezustand ist.';
@override
String get alarmDiagnosticsNotificationsTitle => 'Benachrichtigungen';
@override
String get alarmDiagnosticsNotificationsHint =>
'Nötig, um den Alarm und den Vorab-Hinweis anzuzeigen.';
@override
String get alarmDiagnosticsFullScreenTitle => 'Alarmanzeige im Vollbildmodus';
@override
String get alarmDiagnosticsFullScreenHint =>
'Lässt den Klingelbildschirm automatisch erscheinen, auch wenn das Telefon gesperrt ist.';
@override
String get alarmDiagnosticsBatteryTitle => 'Akku-Optimierung';
@override
String get alarmDiagnosticsBatteryHint =>
'Verhindert, dass das System PluriWave im Hintergrund beendet, damit der Alarm trotzdem klingeln kann.';
@override
String get alarmDiagnosticsNativeCountTitle =>
'Bei Android registrierte Alarme';
@override
String alarmDiagnosticsNativeCountValue(int count) {
return 'Aktuell registriert: $count';
}
@override
String get alarmDiagnosticsNativeCountAttentionHint =>
'Du hast einen Alarm aktiviert, aber es ist noch keiner beim System registriert. Öffne PluriWave erneut oder behebe zuerst die Punkte oben.';
@override
String get alarmDiagnosticsManufacturerLabel => 'Hersteller';
@override
String get alarmDiagnosticsSdkLabel => 'Android-Version (SDK)';
@override
String get alarmDiagnosticsNeedsAttentionStatus => 'Erfordert Aufmerksamkeit';
@override
String get alarmDiagnosticsAutostartTitle =>
'Noch ein manueller Schritt auf diesem Telefon';
@override
String alarmDiagnosticsAutostartBody(String manufacturer) {
return '$manufacturer-Telefone schließen oft Apps im Hintergrund, um Akku zu sparen. Es gibt keine Einstellung, die PluriWave selbst aktivieren kann — du musst den Autostart (manchmal auch \"Auto-Start\" oder \"Hintergrundaktivität\" genannt) für PluriWave selbst einschalten. Schau in den Einstellungen unter Apps oder Akku, oder in der Sicherheits-App des Telefons nach.';
}
@override
String get alarmDiagnosticsFixAction => 'Beheben';
@override
String get alarmDiagnosticsIntentUnavailable =>
'Diese Einstellungsseite konnte auf diesem Telefon nicht geöffnet werden. Suche sie manuell in den Einstellungen.';
@override
String get alarmDiagnosticsUnavailableHint =>
'Wir konnten deine Alarmeinstellungen noch nicht prüfen.';
@override
String get autoEqDisableOption => 'Deaktivieren';
}
+95
View File
@@ -1678,4 +1678,99 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get welcomeCtaLabel => 'Start listening';
@override
String get eqCustomActionEnableLabel => 'Enable equalizer';
@override
String get eqCustomActionDisableLabel => 'Disable equalizer';
@override
String eqCustomActionPresetLabel(String preset) {
return 'Preset: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'Paused for vacation';
@override
String get alarmCardSchedulingFailedMessage =>
'This alarm could not be registered with the system, so it may not ring.';
@override
String get alarmCardPreNoticeFailedMessage =>
'This alarm is scheduled, but its early reminder could not be set.';
@override
String get alarmDiagnosticsExactAlarmsTitle => 'Exact alarm scheduling';
@override
String get alarmDiagnosticsExactAlarmsHint =>
'Lets the alarm ring at the exact minute you set, even while the phone is asleep.';
@override
String get alarmDiagnosticsNotificationsTitle => 'Notifications';
@override
String get alarmDiagnosticsNotificationsHint =>
'Needed to show the alarm and the advance-warning notice.';
@override
String get alarmDiagnosticsFullScreenTitle => 'Full-screen alarm display';
@override
String get alarmDiagnosticsFullScreenHint =>
'Lets the ringing screen appear automatically, even with the phone locked.';
@override
String get alarmDiagnosticsBatteryTitle => 'Battery optimization';
@override
String get alarmDiagnosticsBatteryHint =>
'Stops the system from closing PluriWave in the background so the alarm can still fire.';
@override
String get alarmDiagnosticsNativeCountTitle =>
'Alarms registered with Android';
@override
String alarmDiagnosticsNativeCountValue(int count) {
return 'Currently registered: $count';
}
@override
String get alarmDiagnosticsNativeCountAttentionHint =>
'You have an alarm turned on, but none are registered with the system yet. Reopen PluriWave, or fix the items above first.';
@override
String get alarmDiagnosticsManufacturerLabel => 'Manufacturer';
@override
String get alarmDiagnosticsSdkLabel => 'Android version (SDK)';
@override
String get alarmDiagnosticsNeedsAttentionStatus => 'Needs attention';
@override
String get alarmDiagnosticsAutostartTitle =>
'One more manual step on this phone';
@override
String alarmDiagnosticsAutostartBody(String manufacturer) {
return '$manufacturer phones often close apps running in the background to save battery. There is no setting PluriWave can switch on its own — you need to turn on Autostart (sometimes called \"Auto-start\" or \"Background activity\") for PluriWave yourself. Look in Settings, under Apps or Battery, or in the phone\'s own Security app.';
}
@override
String get alarmDiagnosticsFixAction => 'Fix this';
@override
String get alarmDiagnosticsIntentUnavailable =>
'Couldn\'t open that settings screen on this phone. Try looking for it manually in Settings.';
@override
String get alarmDiagnosticsUnavailableHint =>
'We couldn\'t check your alarm settings yet.';
@override
String get autoEqDisableOption => 'Disable';
}
+97
View File
@@ -1692,4 +1692,101 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get welcomeCtaLabel => 'Empezar a escuchar';
@override
String get eqCustomActionEnableLabel => 'Activar ecualizador';
@override
String get eqCustomActionDisableLabel => 'Desactivar ecualizador';
@override
String eqCustomActionPresetLabel(String preset) {
return 'Preset: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'Pausada por vacaciones';
@override
String get alarmCardSchedulingFailedMessage =>
'Esta alarma no se pudo registrar en el sistema, así que podría no sonar.';
@override
String get alarmCardPreNoticeFailedMessage =>
'Esta alarma está programada, pero no se pudo activar su aviso previo.';
@override
String get alarmDiagnosticsExactAlarmsTitle =>
'Programación de alarma exacta';
@override
String get alarmDiagnosticsExactAlarmsHint =>
'Permite que la alarma suene en el minuto exacto que elegiste, incluso con el teléfono en reposo.';
@override
String get alarmDiagnosticsNotificationsTitle => 'Notificaciones';
@override
String get alarmDiagnosticsNotificationsHint =>
'Necesarias para mostrar la alarma y el aviso previo.';
@override
String get alarmDiagnosticsFullScreenTitle =>
'Pantalla completa de la alarma';
@override
String get alarmDiagnosticsFullScreenHint =>
'Permite que la pantalla de la alarma aparezca automáticamente, incluso con el teléfono bloqueado.';
@override
String get alarmDiagnosticsBatteryTitle => 'Optimización de batería';
@override
String get alarmDiagnosticsBatteryHint =>
'Evita que el sistema cierre PluriWave en segundo plano para que la alarma pueda sonar.';
@override
String get alarmDiagnosticsNativeCountTitle =>
'Alarmas registradas en Android';
@override
String alarmDiagnosticsNativeCountValue(int count) {
return 'Registradas ahora mismo: $count';
}
@override
String get alarmDiagnosticsNativeCountAttentionHint =>
'Tenés una alarma activada, pero todavía ninguna está registrada en el sistema. Volvé a abrir PluriWave o solucioná primero los puntos de arriba.';
@override
String get alarmDiagnosticsManufacturerLabel => 'Fabricante';
@override
String get alarmDiagnosticsSdkLabel => 'Versión de Android (SDK)';
@override
String get alarmDiagnosticsNeedsAttentionStatus => 'Necesita atención';
@override
String get alarmDiagnosticsAutostartTitle =>
'Un paso manual más en este teléfono';
@override
String alarmDiagnosticsAutostartBody(String manufacturer) {
return 'Los teléfonos $manufacturer suelen cerrar las apps que están en segundo plano para ahorrar batería. No hay ningún ajuste que PluriWave pueda activar por su cuenta: tenés que activar vos mismo el Inicio automático (a veces llamado \"Autostart\" o \"Actividad en segundo plano\"). Buscalo en Ajustes, dentro de Apps o Batería, o en la app de Seguridad del teléfono.';
}
@override
String get alarmDiagnosticsFixAction => 'Solucionar';
@override
String get alarmDiagnosticsIntentUnavailable =>
'No se pudo abrir esa pantalla de ajustes en este teléfono. Buscala manualmente en Ajustes.';
@override
String get alarmDiagnosticsUnavailableHint =>
'Todavía no pudimos revisar tus ajustes de alarma.';
@override
String get autoEqDisableOption => 'Desactivar';
}
+98
View File
@@ -1701,4 +1701,102 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get welcomeCtaLabel => 'Commencer à écouter';
@override
String get eqCustomActionEnableLabel => 'Activer l\'égaliseur';
@override
String get eqCustomActionDisableLabel => 'Désactiver l\'égaliseur';
@override
String eqCustomActionPresetLabel(String preset) {
return 'Préréglage : $preset';
}
@override
String get alarmCardVacationPausedBadge => 'En pause pour les vacances';
@override
String get alarmCardSchedulingFailedMessage =>
'Cette alarme n\'a pas pu être enregistrée dans le système ; elle risque donc de ne pas sonner.';
@override
String get alarmCardPreNoticeFailedMessage =>
'Cette alarme est programmée, mais son rappel anticipé n\'a pas pu être activé.';
@override
String get alarmDiagnosticsExactAlarmsTitle =>
'Programmation d\'alarme précise';
@override
String get alarmDiagnosticsExactAlarmsHint =>
'Permet à l\'alarme de sonner à la minute exacte choisie, même si le téléphone est en veille.';
@override
String get alarmDiagnosticsNotificationsTitle => 'Notifications';
@override
String get alarmDiagnosticsNotificationsHint =>
'Nécessaires pour afficher l\'alarme et l\'avis anticipé.';
@override
String get alarmDiagnosticsFullScreenTitle =>
'Affichage plein écran de l\'alarme';
@override
String get alarmDiagnosticsFullScreenHint =>
'Permet à l\'écran de sonnerie de s\'afficher automatiquement, même si le téléphone est verrouillé.';
@override
String get alarmDiagnosticsBatteryTitle => 'Optimisation de la batterie';
@override
String get alarmDiagnosticsBatteryHint =>
'Empêche le système de fermer PluriWave en arrière-plan pour que l\'alarme puisse quand même sonner.';
@override
String get alarmDiagnosticsNativeCountTitle =>
'Alarmes enregistrées auprès d\'Android';
@override
String alarmDiagnosticsNativeCountValue(int count) {
return 'Actuellement enregistrées : $count';
}
@override
String get alarmDiagnosticsNativeCountAttentionHint =>
'Une alarme est activée, mais aucune n\'est encore enregistrée auprès du système. Rouvrez PluriWave, ou corrigez d\'abord les points ci-dessus.';
@override
String get alarmDiagnosticsManufacturerLabel => 'Fabricant';
@override
String get alarmDiagnosticsSdkLabel => 'Version d\'Android (SDK)';
@override
String get alarmDiagnosticsNeedsAttentionStatus =>
'Nécessite votre attention';
@override
String get alarmDiagnosticsAutostartTitle =>
'Encore une étape manuelle sur ce téléphone';
@override
String alarmDiagnosticsAutostartBody(String manufacturer) {
return 'Les téléphones $manufacturer ferment souvent les applications en arrière-plan pour économiser la batterie. Aucun réglage ne permet à PluriWave de s\'activer lui-même : vous devez activer vous-même le démarrage automatique (parfois appelé \"Autostart\" ou \"Activité en arrière-plan\") pour PluriWave. Cherchez dans les Paramètres, sous Applications ou Batterie, ou dans l\'application Sécurité du téléphone.';
}
@override
String get alarmDiagnosticsFixAction => 'Corriger';
@override
String get alarmDiagnosticsIntentUnavailable =>
'Impossible d\'ouvrir cet écran de paramètres sur ce téléphone. Essayez de le trouver manuellement dans les Paramètres.';
@override
String get alarmDiagnosticsUnavailableHint =>
'Nous n\'avons pas encore pu vérifier vos paramètres d\'alarme.';
@override
String get autoEqDisableOption => 'Désactiver';
}
+94
View File
@@ -1680,4 +1680,98 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get welcomeCtaLabel => 'सुनना शुरू करें';
@override
String get eqCustomActionEnableLabel => 'इक्वलाइज़र चालू करें';
@override
String get eqCustomActionDisableLabel => 'इक्वलाइज़र बंद करें';
@override
String eqCustomActionPresetLabel(String preset) {
return 'प्रीसेट: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'छुट्टी के कारण रोका गया';
@override
String get alarmCardSchedulingFailedMessage =>
'यह अलार्म सिस्टम में दर्ज नहीं हो सका, इसलिए यह शायद न बजे।';
@override
String get alarmCardPreNoticeFailedMessage =>
'यह अलार्म शेड्यूल किया गया है, लेकिन इसकी पूर्व-चेतावनी सेट नहीं हो सकी।';
@override
String get alarmDiagnosticsExactAlarmsTitle => 'सटीक अलार्म शेड्यूलिंग';
@override
String get alarmDiagnosticsExactAlarmsHint =>
'फ़ोन के सुप्त मोड में होने पर भी अलार्म को ठीक तय किए गए मिनट पर बजने देता है।';
@override
String get alarmDiagnosticsNotificationsTitle => 'सूचनाएं';
@override
String get alarmDiagnosticsNotificationsHint =>
'अलार्म और पूर्व-चेतावनी सूचना दिखाने के लिए ज़रूरी।';
@override
String get alarmDiagnosticsFullScreenTitle =>
'अलार्म की फ़ुल-स्क्रीन डिस्प्ले';
@override
String get alarmDiagnosticsFullScreenHint =>
'फ़ोन लॉक होने पर भी अलार्म स्क्रीन को अपने आप दिखने देता है।';
@override
String get alarmDiagnosticsBatteryTitle => 'बैटरी ऑप्टिमाइज़ेशन';
@override
String get alarmDiagnosticsBatteryHint =>
'सिस्टम को बैकग्राउंड में PluriWave बंद करने से रोकता है, ताकि अलार्म फिर भी बज सके।';
@override
String get alarmDiagnosticsNativeCountTitle => 'Android में दर्ज अलार्म';
@override
String alarmDiagnosticsNativeCountValue(int count) {
return 'अभी दर्ज: $count';
}
@override
String get alarmDiagnosticsNativeCountAttentionHint =>
'आपने एक अलार्म चालू किया है, लेकिन अभी तक कोई भी सिस्टम में दर्ज नहीं हुआ है। PluriWave को दोबारा खोलें, या पहले ऊपर दी गई बातों को ठीक करें।';
@override
String get alarmDiagnosticsManufacturerLabel => 'निर्माता';
@override
String get alarmDiagnosticsSdkLabel => 'Android वर्शन (SDK)';
@override
String get alarmDiagnosticsNeedsAttentionStatus => 'ध्यान देने की ज़रूरत है';
@override
String get alarmDiagnosticsAutostartTitle => 'इस फ़ोन पर एक और मैन्युअल चरण';
@override
String alarmDiagnosticsAutostartBody(String manufacturer) {
return '$manufacturer फ़ोन बैटरी बचाने के लिए अक्सर बैकग्राउंड में चल रहे ऐप बंद कर देते हैं। ऐसी कोई सेटिंग नहीं है जिसे PluriWave खुद चालू कर सके — आपको PluriWave के लिए खुद ऑटोस्टार्ट (कभी-कभी \"Auto-start\" या \"बैकग्राउंड एक्टिविटी\" भी कहा जाता है) चालू करना होगा। इसे सेटिंग्स में, ऐप्स या बैटरी के अंदर, या फ़ोन के अपने सिक्योरिटी ऐप में देखें।';
}
@override
String get alarmDiagnosticsFixAction => 'ठीक करें';
@override
String get alarmDiagnosticsIntentUnavailable =>
'इस फ़ोन पर वह सेटिंग्स स्क्रीन नहीं खोली जा सकी। कृपया सेटिंग्स में इसे खुद ढूंढने की कोशिश करें।';
@override
String get alarmDiagnosticsUnavailableHint =>
'हम अभी तक आपकी अलार्म सेटिंग्स जांच नहीं पाए हैं।';
@override
String get autoEqDisableOption => 'बंद करें';
}
+95
View File
@@ -1688,4 +1688,99 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get welcomeCtaLabel => 'Mulai mendengarkan';
@override
String get eqCustomActionEnableLabel => 'Aktifkan equalizer';
@override
String get eqCustomActionDisableLabel => 'Nonaktifkan equalizer';
@override
String eqCustomActionPresetLabel(String preset) {
return 'Prasetel: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'Dijeda karena liburan';
@override
String get alarmCardSchedulingFailedMessage =>
'Alarm ini tidak dapat didaftarkan ke sistem, sehingga mungkin tidak berbunyi.';
@override
String get alarmCardPreNoticeFailedMessage =>
'Alarm ini sudah dijadwalkan, tetapi pengingat awalnya tidak dapat diatur.';
@override
String get alarmDiagnosticsExactAlarmsTitle => 'Penjadwalan alarm presisi';
@override
String get alarmDiagnosticsExactAlarmsHint =>
'Membuat alarm berbunyi tepat pada menit yang kamu atur, meski ponsel dalam mode tidur.';
@override
String get alarmDiagnosticsNotificationsTitle => 'Notifikasi';
@override
String get alarmDiagnosticsNotificationsHint =>
'Diperlukan untuk menampilkan alarm dan pemberitahuan dini.';
@override
String get alarmDiagnosticsFullScreenTitle => 'Tampilan alarm layar penuh';
@override
String get alarmDiagnosticsFullScreenHint =>
'Membuat layar alarm muncul otomatis, meski ponsel terkunci.';
@override
String get alarmDiagnosticsBatteryTitle => 'Optimisasi baterai';
@override
String get alarmDiagnosticsBatteryHint =>
'Mencegah sistem menutup PluriWave di latar belakang, sehingga alarm tetap bisa berbunyi.';
@override
String get alarmDiagnosticsNativeCountTitle =>
'Alarm yang terdaftar di Android';
@override
String alarmDiagnosticsNativeCountValue(int count) {
return 'Terdaftar saat ini: $count';
}
@override
String get alarmDiagnosticsNativeCountAttentionHint =>
'Kamu punya alarm yang aktif, tapi belum ada yang terdaftar di sistem. Buka lagi PluriWave, atau perbaiki dulu poin-poin di atas.';
@override
String get alarmDiagnosticsManufacturerLabel => 'Produsen';
@override
String get alarmDiagnosticsSdkLabel => 'Versi Android (SDK)';
@override
String get alarmDiagnosticsNeedsAttentionStatus => 'Perlu perhatian';
@override
String get alarmDiagnosticsAutostartTitle =>
'Satu langkah manual lagi di ponsel ini';
@override
String alarmDiagnosticsAutostartBody(String manufacturer) {
return 'Ponsel $manufacturer sering menutup aplikasi yang berjalan di latar belakang untuk menghemat baterai. Tidak ada pengaturan yang bisa diaktifkan PluriWave sendiri — kamu perlu mengaktifkan sendiri Autostart (kadang disebut \"Mulai otomatis\" atau \"Aktivitas latar belakang\") untuk PluriWave. Cari di Pengaturan, di bagian Aplikasi atau Baterai, atau di aplikasi Keamanan bawaan ponsel.';
}
@override
String get alarmDiagnosticsFixAction => 'Perbaiki';
@override
String get alarmDiagnosticsIntentUnavailable =>
'Tidak bisa membuka layar pengaturan itu di ponsel ini. Coba cari secara manual di Pengaturan.';
@override
String get alarmDiagnosticsUnavailableHint =>
'Kami belum bisa memeriksa pengaturan alarmmu.';
@override
String get autoEqDisableOption => 'Nonaktifkan';
}
+97
View File
@@ -1700,4 +1700,101 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get welcomeCtaLabel => 'Inizia ad ascoltare';
@override
String get eqCustomActionEnableLabel => 'Attiva equalizzatore';
@override
String get eqCustomActionDisableLabel => 'Disattiva equalizzatore';
@override
String eqCustomActionPresetLabel(String preset) {
return 'Preset attivo: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'In pausa per le vacanze';
@override
String get alarmCardSchedulingFailedMessage =>
'Questa sveglia non è stata registrata nel sistema, quindi potrebbe non suonare.';
@override
String get alarmCardPreNoticeFailedMessage =>
'Questa sveglia è programmata, ma non è stato possibile impostare il promemoria anticipato.';
@override
String get alarmDiagnosticsExactAlarmsTitle =>
'Programmazione sveglia esatta';
@override
String get alarmDiagnosticsExactAlarmsHint =>
'Permette alla sveglia di suonare esattamente al minuto impostato, anche a telefono in stand-by.';
@override
String get alarmDiagnosticsNotificationsTitle => 'Notifiche';
@override
String get alarmDiagnosticsNotificationsHint =>
'Necessarie per mostrare la sveglia e l\'avviso anticipato.';
@override
String get alarmDiagnosticsFullScreenTitle =>
'Visualizzazione a schermo intero della sveglia';
@override
String get alarmDiagnosticsFullScreenHint =>
'Permette alla schermata della sveglia di apparire automaticamente, anche a telefono bloccato.';
@override
String get alarmDiagnosticsBatteryTitle => 'Ottimizzazione della batteria';
@override
String get alarmDiagnosticsBatteryHint =>
'Impedisce al sistema di chiudere PluriWave in background, così la sveglia può comunque suonare.';
@override
String get alarmDiagnosticsNativeCountTitle =>
'Sveglie registrate su Android';
@override
String alarmDiagnosticsNativeCountValue(int count) {
return 'Attualmente registrate: $count';
}
@override
String get alarmDiagnosticsNativeCountAttentionHint =>
'Hai una sveglia attiva, ma nessuna è ancora registrata nel sistema. Riapri PluriWave, oppure risolvi prima i punti sopra.';
@override
String get alarmDiagnosticsManufacturerLabel => 'Produttore';
@override
String get alarmDiagnosticsSdkLabel => 'Versione di Android (SDK)';
@override
String get alarmDiagnosticsNeedsAttentionStatus => 'Richiede attenzione';
@override
String get alarmDiagnosticsAutostartTitle =>
'Un altro passaggio manuale su questo telefono';
@override
String alarmDiagnosticsAutostartBody(String manufacturer) {
return 'I telefoni $manufacturer spesso chiudono le app in background per risparmiare batteria. Non esiste un\'impostazione che PluriWave possa attivare da solo: devi attivare tu stesso l\'Avvio automatico (a volte chiamato \"Autostart\" o \"Attività in background\") per PluriWave. Cercalo nelle Impostazioni, sotto App o Batteria, oppure nell\'app Sicurezza del telefono.';
}
@override
String get alarmDiagnosticsFixAction => 'Risolvi';
@override
String get alarmDiagnosticsIntentUnavailable =>
'Non è stato possibile aprire questa schermata delle impostazioni su questo telefono. Prova a cercarla manualmente nelle Impostazioni.';
@override
String get alarmDiagnosticsUnavailableHint =>
'Non abbiamo ancora potuto controllare le impostazioni della sveglia.';
@override
String get autoEqDisableOption => 'Disattiva';
}
+91
View File
@@ -1631,4 +1631,95 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get welcomeCtaLabel => '聴き始める';
@override
String get eqCustomActionEnableLabel => 'イコライザーをオンにする';
@override
String get eqCustomActionDisableLabel => 'イコライザーをオフにする';
@override
String eqCustomActionPresetLabel(String preset) {
return 'プリセット: $preset';
}
@override
String get alarmCardVacationPausedBadge => '休暇のため一時停止中';
@override
String get alarmCardSchedulingFailedMessage =>
'このアラームはシステムに登録できなかったため、鳴らない可能性があります。';
@override
String get alarmCardPreNoticeFailedMessage =>
'このアラームは設定されていますが、事前通知を設定できませんでした。';
@override
String get alarmDiagnosticsExactAlarmsTitle => '正確なアラームのスケジュール設定';
@override
String get alarmDiagnosticsExactAlarmsHint =>
'スマートフォンがスリープ中でも、設定した時刻ちょうどにアラームを鳴らせるようにします。';
@override
String get alarmDiagnosticsNotificationsTitle => '通知';
@override
String get alarmDiagnosticsNotificationsHint => 'アラームと事前通知を表示するために必要です。';
@override
String get alarmDiagnosticsFullScreenTitle => 'アラームのフルスクリーン表示';
@override
String get alarmDiagnosticsFullScreenHint =>
'画面がロックされていても、アラーム画面が自動的に表示されるようにします。';
@override
String get alarmDiagnosticsBatteryTitle => 'バッテリーの最適化';
@override
String get alarmDiagnosticsBatteryHint =>
'システムがPluriWaveをバックグラウンドで終了しないようにし、アラームが確実に鳴るようにします。';
@override
String get alarmDiagnosticsNativeCountTitle => 'Androidに登録されているアラーム';
@override
String alarmDiagnosticsNativeCountValue(int count) {
return '現在登録されている数: $count';
}
@override
String get alarmDiagnosticsNativeCountAttentionHint =>
'アラームは有効になっていますが、まだシステムに登録されていません。PluriWaveを開き直すか、まず上の項目を確認してください。';
@override
String get alarmDiagnosticsManufacturerLabel => '製造元';
@override
String get alarmDiagnosticsSdkLabel => 'Androidのバージョン(SDK';
@override
String get alarmDiagnosticsNeedsAttentionStatus => '確認が必要です';
@override
String get alarmDiagnosticsAutostartTitle => 'この端末でのもう一つの手動設定';
@override
String alarmDiagnosticsAutostartBody(String manufacturer) {
return '$manufacturerのスマートフォンは、バッテリーを節約するためにバックグラウンドのアプリを終了させることがよくあります。PluriWaveが自動でオンにできる設定はありません。PluriWaveの自動起動(「オートスタート」や「バックグラウンド動作」と呼ばれることもあります)を、自分で有効にする必要があります。設定内のアプリまたはバッテリーの項目、あるいは端末のセキュリティアプリを確認してください。';
}
@override
String get alarmDiagnosticsFixAction => '修正する';
@override
String get alarmDiagnosticsIntentUnavailable =>
'この端末では設定画面を開けませんでした。設定アプリ内で手動で探してみてください。';
@override
String get alarmDiagnosticsUnavailableHint => 'アラームの設定をまだ確認できていません。';
@override
String get autoEqDisableOption => '無効化';
}
+96
View File
@@ -1688,4 +1688,100 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get welcomeCtaLabel => 'Começar a ouvir';
@override
String get eqCustomActionEnableLabel => 'Ativar equalizador';
@override
String get eqCustomActionDisableLabel => 'Desativar equalizador';
@override
String eqCustomActionPresetLabel(String preset) {
return 'Predefinição: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'Pausada por férias';
@override
String get alarmCardSchedulingFailedMessage =>
'Este alarme não pôde ser registrado no sistema, por isso pode não tocar.';
@override
String get alarmCardPreNoticeFailedMessage =>
'Este alarme está agendado, mas seu aviso antecipado não pôde ser configurado.';
@override
String get alarmDiagnosticsExactAlarmsTitle => 'Agendamento exato do alarme';
@override
String get alarmDiagnosticsExactAlarmsHint =>
'Permite que o alarme toque no minuto exato definido, mesmo com o telefone em repouso.';
@override
String get alarmDiagnosticsNotificationsTitle => 'Notificações';
@override
String get alarmDiagnosticsNotificationsHint =>
'Necessárias para mostrar o alarme e o aviso prévio.';
@override
String get alarmDiagnosticsFullScreenTitle =>
'Exibição em tela cheia do alarme';
@override
String get alarmDiagnosticsFullScreenHint =>
'Permite que a tela do alarme apareça automaticamente, mesmo com o telefone bloqueado.';
@override
String get alarmDiagnosticsBatteryTitle => 'Otimização de bateria';
@override
String get alarmDiagnosticsBatteryHint =>
'Evita que o sistema feche o PluriWave em segundo plano, para que o alarme ainda possa tocar.';
@override
String get alarmDiagnosticsNativeCountTitle =>
'Alarmes registrados no Android';
@override
String alarmDiagnosticsNativeCountValue(int count) {
return 'Registrados agora: $count';
}
@override
String get alarmDiagnosticsNativeCountAttentionHint =>
'Você tem um alarme ativado, mas nenhum está registrado no sistema ainda. Reabra o PluriWave ou resolva primeiro os itens acima.';
@override
String get alarmDiagnosticsManufacturerLabel => 'Fabricante';
@override
String get alarmDiagnosticsSdkLabel => 'Versão do Android (SDK)';
@override
String get alarmDiagnosticsNeedsAttentionStatus => 'Precisa de atenção';
@override
String get alarmDiagnosticsAutostartTitle =>
'Mais uma etapa manual neste telefone';
@override
String alarmDiagnosticsAutostartBody(String manufacturer) {
return 'Telefones $manufacturer costumam fechar apps em segundo plano para economizar bateria. Não existe uma configuração que o PluriWave possa ativar sozinho: você precisa ativar por conta própria o Início automático (às vezes chamado de \"Autostart\" ou \"Atividade em segundo plano\") para o PluriWave. Procure em Configurações, em Apps ou Bateria, ou no próprio app de Segurança do telefone.';
}
@override
String get alarmDiagnosticsFixAction => 'Resolver';
@override
String get alarmDiagnosticsIntentUnavailable =>
'Não foi possível abrir essa tela de configurações neste telefone. Tente procurá-la manualmente nas Configurações.';
@override
String get alarmDiagnosticsUnavailableHint =>
'Ainda não conseguimos verificar as configurações do seu alarme.';
@override
String get autoEqDisableOption => 'Desativar';
}
+97
View File
@@ -1692,4 +1692,101 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get welcomeCtaLabel => 'Начать слушать';
@override
String get eqCustomActionEnableLabel => 'Включить эквалайзер';
@override
String get eqCustomActionDisableLabel => 'Выключить эквалайзер';
@override
String eqCustomActionPresetLabel(String preset) {
return 'Пресет: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'Приостановлено на время отпуска';
@override
String get alarmCardSchedulingFailedMessage =>
'Этот будильник не удалось зарегистрировать в системе, поэтому он может не сработать.';
@override
String get alarmCardPreNoticeFailedMessage =>
'Этот будильник запланирован, но не удалось настроить предварительное напоминание.';
@override
String get alarmDiagnosticsExactAlarmsTitle =>
'Точное планирование будильника';
@override
String get alarmDiagnosticsExactAlarmsHint =>
'Будильник звонит ровно в заданную минуту, даже если телефон находится в режиме сна.';
@override
String get alarmDiagnosticsNotificationsTitle => 'Уведомления';
@override
String get alarmDiagnosticsNotificationsHint =>
'Нужны, чтобы показать будильник и заблаговременное напоминание.';
@override
String get alarmDiagnosticsFullScreenTitle =>
'Полноэкранный показ будильника';
@override
String get alarmDiagnosticsFullScreenHint =>
'Экран будильника появляется автоматически, даже если телефон заблокирован.';
@override
String get alarmDiagnosticsBatteryTitle => 'Оптимизация батареи';
@override
String get alarmDiagnosticsBatteryHint =>
'Не позволяет системе закрывать PluriWave в фоновом режиме, чтобы будильник мог сработать.';
@override
String get alarmDiagnosticsNativeCountTitle =>
'Будильники, зарегистрированные в Android';
@override
String alarmDiagnosticsNativeCountValue(int count) {
return 'Сейчас зарегистрировано: $count';
}
@override
String get alarmDiagnosticsNativeCountAttentionHint =>
'У вас включён будильник, но пока ни один не зарегистрирован в системе. Откройте PluriWave заново или сначала устраните пункты выше.';
@override
String get alarmDiagnosticsManufacturerLabel => 'Производитель';
@override
String get alarmDiagnosticsSdkLabel => 'Версия Android (SDK)';
@override
String get alarmDiagnosticsNeedsAttentionStatus => 'Требует внимания';
@override
String get alarmDiagnosticsAutostartTitle =>
'Ещё один ручной шаг на этом телефоне';
@override
String alarmDiagnosticsAutostartBody(String manufacturer) {
return 'Телефоны $manufacturer часто закрывают приложения в фоновом режиме для экономии батареи. PluriWave не может включить это самостоятельно — вам нужно вручную включить автозапуск (иногда называется \"Autostart\" или \"Фоновая активность\") для PluriWave. Ищите в Настройках, в разделе Приложения или Батарея, либо в приложении безопасности телефона.';
}
@override
String get alarmDiagnosticsFixAction => 'Исправить';
@override
String get alarmDiagnosticsIntentUnavailable =>
'Не удалось открыть этот экран настроек на этом телефоне. Попробуйте найти его вручную в Настройках.';
@override
String get alarmDiagnosticsUnavailableHint =>
'Мы пока не смогли проверить настройки вашего будильника.';
@override
String get autoEqDisableOption => 'Отключить';
}
+85
View File
@@ -1623,4 +1623,89 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get welcomeCtaLabel => '开始收听';
@override
String get eqCustomActionEnableLabel => '启用均衡器';
@override
String get eqCustomActionDisableLabel => '关闭均衡器';
@override
String eqCustomActionPresetLabel(String preset) {
return '预设:$preset';
}
@override
String get alarmCardVacationPausedBadge => '因假期已暂停';
@override
String get alarmCardSchedulingFailedMessage => '该闹钟未能在系统中注册,因此可能不会响铃。';
@override
String get alarmCardPreNoticeFailedMessage => '该闹钟已设置,但其提前提醒未能设置成功。';
@override
String get alarmDiagnosticsExactAlarmsTitle => '精确闹钟排程';
@override
String get alarmDiagnosticsExactAlarmsHint => '即使手机处于休眠状态,也能让闹钟在设定的准确时间响起。';
@override
String get alarmDiagnosticsNotificationsTitle => '通知';
@override
String get alarmDiagnosticsNotificationsHint => '显示闹钟和提前提醒需要用到。';
@override
String get alarmDiagnosticsFullScreenTitle => '闹钟全屏显示';
@override
String get alarmDiagnosticsFullScreenHint => '即使手机已锁屏,也能让响铃界面自动出现。';
@override
String get alarmDiagnosticsBatteryTitle => '电池优化';
@override
String get alarmDiagnosticsBatteryHint => '防止系统在后台关闭PluriWave,让闹钟仍然可以响起。';
@override
String get alarmDiagnosticsNativeCountTitle => '已在Android系统注册的闹钟';
@override
String alarmDiagnosticsNativeCountValue(int count) {
return '当前已注册:$count';
}
@override
String get alarmDiagnosticsNativeCountAttentionHint =>
'你已开启一个闹钟,但目前还没有闹钟在系统中注册。请重新打开PluriWave,或先解决上面列出的问题。';
@override
String get alarmDiagnosticsManufacturerLabel => '制造商';
@override
String get alarmDiagnosticsSdkLabel => 'Android版本(SDK';
@override
String get alarmDiagnosticsNeedsAttentionStatus => '需要注意';
@override
String get alarmDiagnosticsAutostartTitle => '此手机还需要一步手动设置';
@override
String alarmDiagnosticsAutostartBody(String manufacturer) {
return '$manufacturer手机经常会关闭后台运行的应用以节省电量。没有任何设置可以让PluriWave自行开启——你需要自己为PluriWave开启自启动(有时也叫\"Autostart\"\"后台活动\")。请在设置中查找应用或电池选项,或者查看手机自带的安全应用。';
}
@override
String get alarmDiagnosticsFixAction => '解决';
@override
String get alarmDiagnosticsIntentUnavailable => '无法在此手机上打开该设置界面。请尝试在设置中手动查找。';
@override
String get alarmDiagnosticsUnavailableHint => '我们还无法检查你的闹钟设置。';
@override
String get autoEqDisableOption => '关闭';
}
+37
View File
@@ -302,6 +302,43 @@ class ExcepcionAlarma {
final DateTime ejecucion;
final String tipo;
/// User-requested skip of the next occurrence (the only [tipo] this model
/// originally supported). `ServicioProgramacionAlarmas._esValida` only
/// treats THIS tipo as an actual schedule skip -- every tipo below records
/// a scheduling-reliability failure and must never affect which occurrence
/// fires next.
static const tipoSaltoSiguiente = 'skipNext';
/// The main alarm registration with the OS failed (`android.programar`
/// threw). Recorded per-alarm so the alarms list can mark the exact card
/// affected instead of only a transient, alarm-agnostic app-wide message.
static const tipoFalloProgramacion = 'schedulingFailed';
/// The main alarm registered successfully but its 30-minute pre-notice
/// reminder did not (native `SecurityException` scheduling the pre-notice
/// alone) -- distinguished from [tipoFalloProgramacion] because the alarm
/// itself will still ring; only the early warning is missing.
static const tipoFalloPreaviso = 'preNoticeFailed';
/// The OS refused to start the foreground ringing service when the alarm
/// fired (e.g. a background-restricted app), so the alarm never actually
/// rang even though it was armed.
static const tipoFalloServicioSonido = 'foregroundServiceFailed';
/// A per-alarm reschedule after boot/unlock failed while sibling alarms
/// succeeded, leaving this one specific alarm unscheduled.
static const tipoFalloReprogramacionArranque = 'rescheduleAfterBootFailed';
/// Every tipo above that represents a reliability FAILURE rather than a
/// deliberate user action -- used by the UI to decide whether to mark a
/// card, and by [ServicioAlarmas] to know which prior record to replace.
static const tiposFallo = {
tipoFalloProgramacion,
tipoFalloPreaviso,
tipoFalloServicioSonido,
tipoFalloReprogramacionArranque,
};
Map<String, dynamic> toJson() => {
'alarmaId': alarmaId,
'ejecucion': ejecucion.toIso8601String(),
+5 -3
View File
@@ -147,7 +147,9 @@ class _AjustesContent extends StatelessWidget {
),
],
),
const SizedBox(height: 12),
// Issue 3 (feedback-pruebas): t4:523/534/541 all draw a 16px gap
// between these opaque, stacked settings groups -- not 12.
const SizedBox(height: 16, key: ValueKey('ajustes-group-gap-1')),
GrupoAjustes(
titulo: l10n.settingsGroupStationsTitle,
filas: [
@@ -202,7 +204,7 @@ class _AjustesContent extends StatelessWidget {
),
],
),
const SizedBox(height: 12),
const SizedBox(height: 16, key: ValueKey('ajustes-group-gap-2')),
GrupoAjustes(
titulo: l10n.settingsGroupRecordingsTitle,
filas: [
@@ -250,7 +252,7 @@ class _AjustesContent extends StatelessWidget {
),
],
),
const SizedBox(height: 12),
const SizedBox(height: 16, key: ValueKey('ajustes-group-gap-3')),
GrupoAjustes(
titulo: l10n.settingsGroupApplicationTitle,
filas: [
+28 -21
View File
@@ -225,25 +225,6 @@ 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(
@@ -261,6 +242,28 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
),
),
const SizedBox(height: 6),
// Audit 9.4: the date line goes BELOW the hero time. The
// prototype's order is pill (t4:415-416) -> 7:30 at 88px
// (t4:417) -> "Lunes, 3 de agosto" at 14px (t4:419). An
// earlier pass placed it between the pill and the time
// and cited "t4 line 419" for it — that line number is
// where the date SITS in the source, which is precisely
// why it comes last, not first.
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),
Text(
localizedAlarmName(l10n, alarma.nombre),
textAlign: TextAlign.center,
@@ -339,14 +342,18 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
],
),
),
const SizedBox(height: 10),
// Issue 3 (feedback-pruebas): t4:427 wraps POSPONER's
// eyebrow, the snooze tiles and Stop in a `gap:12` flex
// column -- the same 12 on both sides, not the 10/14 pair
// this used to carry.
const SizedBox(height: 12),
_FilaSnoozeFija(
alarma: alarma,
l10n: l10n,
tokens: tokens,
onPosponer: _posponer,
),
const SizedBox(height: 14),
const SizedBox(height: 12),
SizedBox(
width: double.infinity,
child: FilledButton.icon(
+221 -52
View File
@@ -18,6 +18,7 @@ import '../widgets/pluri_layout.dart';
import '../widgets/pluri_push_scaffold.dart';
import '../widgets/pluri_root_header.dart';
import '../widgets/pluri_sleep_timer_sheet.dart';
import 'pantalla_diagnostico_alarmas.dart';
import 'pantalla_vacaciones.dart';
class PantallaAlarmas extends StatelessWidget {
@@ -265,6 +266,40 @@ class _TarjetaAlarma extends StatelessWidget {
? l10n.noStationUseInternalSound
: localizedStationName(l10n, alarma.emisora!.nombre);
// Item 5: surfaces the genuinely useful fields that already exist on
// the model, WITHOUT turning the row into clutter -- each is shown
// only when it is a meaningful deviation from the common case.
// Mirrors EXACTLY the pause predicate `impactoDeRango`/
// `ServicioProgramacionAlarmas` already use
// (`!sonarEnVacaciones` while `activa`), gated by whether a vacation
// range is CURRENTLY active -- an alarm configured to pause but with
// no active range right now is not actually paused by anything yet.
final pausadaPorVacaciones =
alarma.activa &&
!alarma.sonarEnVacaciones &&
estado.rangoVacacionesActivo() != null;
final detalles = <String>[
if (alarma.fadeInSegundos > 0)
l10n.alarmFadeInLabel(alarma.fadeInSegundos),
if ((alarma.volumen * 100).round() != 85)
'${(alarma.volumen * 100).round()}%',
if (pausadaPorVacaciones) l10n.alarmCardVacationPausedBadge,
];
// fix/alarmas-fallos-silenciosos: `ultimaExcepcionPara` existed but was
// never read from any screen, so a failed native scheduling attempt (main
// alarm, pre-notice, foreground service, or a post-boot reschedule)
// rendered exactly like a healthy alarm -- switched on, no visible sign
// anything was wrong. `_esValida` only ever treats `tipoSaltoSiguiente`
// as a real skip, so any OTHER tipo found here is a reliability failure,
// never a deliberate user action.
final ultimaExcepcion = estado.ultimaExcepcionPara(alarma.id);
final fallo =
ultimaExcepcion != null &&
ExcepcionAlarma.tiposFallo.contains(ultimaExcepcion.tipo)
? ultimaExcepcion
: null;
return Dismissible(
key: ValueKey('tarjeta-alarma-${alarma.id}'),
direction: DismissDirection.horizontal,
@@ -309,14 +344,20 @@ class _TarjetaAlarma extends StatelessWidget {
),
),
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),
// Item 5: real day list can run longer than the
// old generic "Días" label -- Flexible+ellipsis
// keeps a long selection from overflowing the
// Row instead of clipping visibly.
Flexible(
child: Text(
_recurrenciaCorta(l10n, alarma),
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSurface
.withValues(alpha: 0.5),
),
),
),
],
@@ -369,6 +410,35 @@ class _TarjetaAlarma extends StatelessWidget {
),
],
),
// Item 5: fade/volume/vacation-pause state, only
// when each is a genuinely useful deviation from
// the common case (see `detalles` above) -- a
// single compact line, not a badge per field.
if (detalles.isNotEmpty) ...[
const SizedBox(height: 3),
Text(
detalles.join(' · '),
key: ValueKey(
'tarjeta-alarma-detalles-${alarma.id}',
),
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.55),
),
),
],
if (fallo != null) ...[
const SizedBox(height: 6),
_AvisoFalloProgramacion(
alarmaId: alarma.id,
esSoloPreaviso:
fallo.tipo == ExcepcionAlarma.tipoFalloPreaviso,
),
],
],
),
),
@@ -422,6 +492,82 @@ class _TarjetaAlarma extends StatelessWidget {
}
}
/// Per-alarm scheduling-failure notice (fix/alarmas-fallos-silenciosos):
/// renders INSIDE the card's own content, in its own small tap target --
/// the surrounding card `InkWell` (tap = edit) and `Dismissible` (swipe =
/// delete) are untouched; this inner `InkWell` only claims its own region
/// and pushes the diagnostics screen instead of opening the editor.
class _AvisoFalloProgramacion extends StatelessWidget {
const _AvisoFalloProgramacion({
required this.alarmaId,
required this.esSoloPreaviso,
});
final String alarmaId;
/// True when only the pre-notice reminder failed (the alarm itself is
/// still scheduled) -- the user's reported symptom explicitly called out
/// a missing pre-notice as distinct from the alarm never ringing at all,
/// so the message must not conflate the two.
final bool esSoloPreaviso;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final color = Theme.of(context).colorScheme.error;
return Material(
type: MaterialType.transparency,
child: InkWell(
key: ValueKey('tarjeta-alarma-fallo-$alarmaId'),
borderRadius: BorderRadius.circular(8),
onTap: () => _abrirDiagnostico(context),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.warning_amber_rounded, size: 15, color: color),
const SizedBox(width: 6),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
esSoloPreaviso
? l10n.alarmCardPreNoticeFailedMessage
: l10n.alarmCardSchedulingFailedMessage,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: color,
),
),
const SizedBox(height: 2),
Text(
l10n.androidReliabilityReview,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
color: color,
decoration: TextDecoration.underline,
),
),
],
),
),
],
),
),
),
);
}
void _abrirDiagnostico(BuildContext context) {
PluriPushScaffold.push(context, (_) => const PantallaDiagnosticoAlarmas());
}
}
/// Swipe-to-delete reveal, shown on both sides so either swipe direction
/// works regardless of locale text direction.
class _FondoSwipeEliminarAlarma extends StatelessWidget {
@@ -1291,6 +1437,16 @@ class _SelectorEmisoraSheetState extends State<_SelectorEmisoraSheet> {
}
}
/// Entry point into the full Android alarm-reliability diagnostics screen
/// (fix/alarmas-fiabilidad). Was a one-line `TextButton.icon` that only ever
/// surfaced 3 of the 6 fields `DiagnosticoAlarmasAndroid` collects (exact
/// alarms, notifications, full-screen intent) and cycled all three
/// permission requests on a single tap; the two most diagnostic fields --
/// battery-optimization exemption and the native pending-alarm count, which
/// tells the user whether the alarm ever reached the OS at all -- were
/// gathered and never shown. Now a tap target row (mirrors
/// `_PanelVacaciones`'s shape) pushing `PantallaDiagnosticoAlarmas`, which
/// shows every signal individually with its own fix action.
class _AccesoDiagnostico extends StatelessWidget {
const _AccesoDiagnostico({required this.estado});
@@ -1299,47 +1455,37 @@ class _AccesoDiagnostico extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final diag = estado.diagnostico;
final exactStatus =
diag?.puedeProgramarExactas == true
? l10n.statusOk
: l10n.statusPending;
final notificationStatus =
diag?.notificacionesPermitidas == true
? l10n.statusOk
: l10n.statusPending;
final screenStatus =
diag?.puedeUsarPantallaCompleta == true
? l10n.statusOk
: l10n.statusPending;
return TextButton.icon(
icon: const _AssetIcon(
'assets/icons/alarmas/android_reliability.png',
size: 28,
),
label: Text(
diag == null
? l10n.androidReliabilityTitle
: l10n.androidReliabilityStatus(
exactStatus,
notificationStatus,
screenStatus,
final tokens = context.pluriTokens;
return PluriGlassSurface(
padding: EdgeInsets.zero,
child: Material(
type: MaterialType.transparency,
child: InkWell(
key: const ValueKey('diagnostico-alarmas-resumen'),
borderRadius: BorderRadius.circular(tokens.radiusMd),
onTap: () => _abrirDiagnostico(context),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
const _AssetIcon(
'assets/icons/alarmas/android_reliability.png',
size: 28,
),
const SizedBox(width: 10),
Expanded(child: Text(l10n.androidReliabilityReview)),
const Icon(Icons.chevron_right_rounded),
],
),
),
),
),
onPressed: () async {
if (diag != null && !diag.puedeProgramarExactas) {
await estado.android.solicitarPermisoAlarmasExactas();
}
if (diag != null && !diag.notificacionesPermitidas) {
await estado.android.solicitarPermisoNotificaciones();
}
if (diag != null && !diag.puedeUsarPantallaCompleta) {
await estado.android.solicitarPermisoPantallaCompleta();
}
await estado.cargarDiagnostico();
},
);
}
void _abrirDiagnostico(BuildContext context) {
PluriPushScaffold.push(context, (_) => const PantallaDiagnosticoAlarmas());
}
}
/// Vacation summary row (alarm-vacation-ranges delta, WU8): replaces the old
@@ -1668,16 +1814,39 @@ String _weekdayShort(AppLocalizations l10n, int day) => switch (day) {
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.
/// Audit 7.4 (t4:339) / item 5: a compact recurrence label next to the
/// alarm card's giant time. `diaria`/`unica` still show the SAME generic
/// labels the editor's own `TipoProgramacionAlarma` `SegmentedButton`
/// already uses (`dailyOption`/`oneTimeOption`) -- both are already fully
/// specific (there is nothing more concrete to say than "every day"/"just
/// once"). `diasSemana` now renders the alarm's ACTUAL configured days
/// (e.g. "Lun, Mié, Vie") instead of the generic `weekdaysOption` ("Días"),
/// reusing [_weekdayShort] (the SAME per-day abbreviation the editor's own
/// day-picker circles already use) -- no new ARB keys, no second
/// formatting scheme, and the resulting Text is wrapped in a
/// `Flexible`+ellipsis at the call site so a long selection never
/// overflows the row.
String _recurrenciaCorta(AppLocalizations l10n, AlarmaMusical alarma) {
return switch (alarma.tipoProgramacion) {
TipoProgramacionAlarma.diaria => l10n.dailyOption,
TipoProgramacionAlarma.diasSemana => l10n.weekdaysOption,
TipoProgramacionAlarma.diasSemana => _diasSemanaCorto(
l10n,
alarma.diasSemana,
),
TipoProgramacionAlarma.unica => l10n.oneTimeOption,
};
}
/// The real, ordered day abbreviations for a `diasSemana` alarm (item 5),
/// e.g. "Lun, Mié, Vie". [diasSemana] is re-sorted defensively (the editor
/// always persists it sorted, but this does not rely on that). Falls back
/// to the generic [AppLocalizations.weekdaysOption] label when
/// [diasSemana] is empty -- the editor already blocks saving an empty
/// selection in this mode, but a corrupt/legacy persisted record could
/// still reach here, and showing nothing would be worse than the old
/// generic label.
String _diasSemanaCorto(AppLocalizations l10n, List<int> diasSemana) {
if (diasSemana.isEmpty) return l10n.weekdaysOption;
final ordenados = [...diasSemana]..sort();
return ordenados.map((dia) => _weekdayShort(l10n, dia)).join(', ');
}
@@ -0,0 +1,293 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_alarmas.dart';
import '../l10n/gen/app_localizations.dart';
import '../servicios/diagnostico_alarmas.dart';
import '../servicios/servicio_alarmas_android.dart';
import '../tema/pluriwave_theme.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_layout.dart';
import '../widgets/pluri_push_scaffold.dart';
/// Full Android alarm-reliability diagnostics screen (fix/alarmas-fiabilidad).
///
/// Replaces the old one-line `_AccesoDiagnostico` button in
/// `pantalla_alarmas.dart`, which only ever surfaced 3 of the 6 fields
/// `DiagnosticoAlarmasAndroid` collects. This screen shows all five
/// diagnosable signals with a clear ok/needs-attention state, a "Fix this"
/// action that opens the right system settings screen for each failing one,
/// plus manufacturer-specific guidance for vendors known to require manually
/// enabling Autostart.
class PantallaDiagnosticoAlarmas extends StatelessWidget {
const PantallaDiagnosticoAlarmas({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final estado = context.watch<EstadoAlarmas>();
final diag = estado.diagnostico;
return PluriPushScaffold(
title: l10n.androidReliabilityTitle,
body:
diag == null
? ListView(
padding: PluriLayout.pageContentPadding,
children: [
PluriGlassSurface(
child: Text(l10n.alarmDiagnosticsUnavailableHint),
),
],
)
: _CuerpoDiagnostico(estado: estado, diag: diag),
);
}
}
class _CuerpoDiagnostico extends StatelessWidget {
const _CuerpoDiagnostico({required this.estado, required this.diag});
final EstadoAlarmas estado;
final DiagnosticoAlarmasAndroid diag;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final items = construirItemsDiagnosticoAlarmas(
diagnostico: diag,
hayAlarmasActivas: estado.alarmas.any((alarma) => alarma.activa),
);
final mostrarAutostart = fabricanteRequiereGuiaAutostart(diag.fabricante);
return ListView(
padding: PluriLayout.pageContentPadding,
children: [
for (final item in items) ...[
_FilaDiagnostico(item: item, estado: estado, diag: diag),
const SizedBox(height: 10),
],
const SizedBox(height: 6),
PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_FilaInformativa(
titulo: l10n.alarmDiagnosticsManufacturerLabel,
valor: diag.fabricante,
),
const SizedBox(height: 10),
_FilaInformativa(
titulo: l10n.alarmDiagnosticsSdkLabel,
valor: diag.versionSdk.toString(),
),
],
),
),
if (mostrarAutostart) ...[
const SizedBox(height: 16),
_GuiaAutostart(fabricante: diag.fabricante),
],
],
);
}
}
class _FilaDiagnostico extends StatelessWidget {
const _FilaDiagnostico({
required this.item,
required this.estado,
required this.diag,
});
final ItemDiagnosticoAlarma item;
final EstadoAlarmas estado;
final DiagnosticoAlarmasAndroid diag;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final tokens = context.pluriTokens;
final ok = item.estado == EstadoSenalDiagnostico.ok;
final color = ok ? tokens.liveGreen : Theme.of(context).colorScheme.error;
final esConteoNativo =
item.senal == SenalDiagnosticoAlarma.alarmasNativasPendientes;
return PluriGlassSurface(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
ok ? Icons.check_circle_rounded : Icons.warning_amber_rounded,
color: color,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
_tituloSenal(l10n, item.senal),
style: const TextStyle(fontWeight: FontWeight.w700),
),
),
Text(
ok
? l10n.statusOk
: l10n.alarmDiagnosticsNeedsAttentionStatus,
style: TextStyle(
color: color,
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 4),
if (esConteoNativo) ...[
Text(
l10n.alarmDiagnosticsNativeCountValue(
diag.alarmasNativasPendientes,
),
),
if (!ok) ...[
const SizedBox(height: 4),
Text(l10n.alarmDiagnosticsNativeCountAttentionHint),
],
] else
Text(_hintSenal(l10n, item.senal)),
if (!ok && item.accion != AccionDiagnosticoAlarma.ninguna) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton(
onPressed: () => _ejecutarAccion(context, l10n),
child: Text(l10n.alarmDiagnosticsFixAction),
),
),
],
],
),
),
],
),
);
}
/// Runs the system action for [item.accion] and reloads the diagnostic
/// snapshot. Never throws across the widget boundary: every underlying
/// `PuertoAlarmasAndroid` call already reports `false` instead (native side
/// catches any intent-resolution failure), and a `false` here surfaces a
/// calm SnackBar instead of leaving the tap looking like a no-op.
Future<void> _ejecutarAccion(
BuildContext context,
AppLocalizations l10n,
) async {
final resuelto = switch (item.accion) {
AccionDiagnosticoAlarma.abrirAlarmasExactas =>
await estado.android.solicitarPermisoAlarmasExactas(),
AccionDiagnosticoAlarma.abrirNotificaciones =>
await estado.android.abrirConfiguracionNotificaciones(),
AccionDiagnosticoAlarma.abrirOptimizacionBateria =>
await estado.android.solicitarExencionBateria(),
AccionDiagnosticoAlarma.abrirPantallaCompleta =>
await estado.android.solicitarPermisoPantallaCompleta(),
AccionDiagnosticoAlarma.ninguna => true,
};
await estado.cargarDiagnostico();
if (!resuelto && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.alarmDiagnosticsIntentUnavailable)),
);
}
}
}
String _tituloSenal(AppLocalizations l10n, SenalDiagnosticoAlarma senal) =>
switch (senal) {
SenalDiagnosticoAlarma.alarmasExactas =>
l10n.alarmDiagnosticsExactAlarmsTitle,
SenalDiagnosticoAlarma.notificaciones =>
l10n.alarmDiagnosticsNotificationsTitle,
SenalDiagnosticoAlarma.pantallaCompleta =>
l10n.alarmDiagnosticsFullScreenTitle,
SenalDiagnosticoAlarma.optimizacionBateria =>
l10n.alarmDiagnosticsBatteryTitle,
SenalDiagnosticoAlarma.alarmasNativasPendientes =>
l10n.alarmDiagnosticsNativeCountTitle,
};
/// Static one-line explanation per signal. `alarmasNativasPendientes` builds
/// its own dynamic body in [_FilaDiagnostico] instead (count + conditional
/// attention hint), so this branch is never actually rendered for it -- kept
/// only so the switch stays exhaustive over the enum.
String _hintSenal(
AppLocalizations l10n,
SenalDiagnosticoAlarma senal,
) => switch (senal) {
SenalDiagnosticoAlarma.alarmasExactas => l10n.alarmDiagnosticsExactAlarmsHint,
SenalDiagnosticoAlarma.notificaciones =>
l10n.alarmDiagnosticsNotificationsHint,
SenalDiagnosticoAlarma.pantallaCompleta =>
l10n.alarmDiagnosticsFullScreenHint,
SenalDiagnosticoAlarma.optimizacionBateria =>
l10n.alarmDiagnosticsBatteryHint,
SenalDiagnosticoAlarma.alarmasNativasPendientes => '',
};
class _FilaInformativa extends StatelessWidget {
const _FilaInformativa({required this.titulo, required this.valor});
final String titulo;
final String valor;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(titulo),
Text(valor, style: const TextStyle(fontWeight: FontWeight.w700)),
],
);
}
}
/// Manufacturer-specific autostart explanation (fix/alarmas-fiabilidad item
/// 3). Deliberately never claims the app can detect or grant this setting --
/// there is no public API for it, so this is explanation only, never an
/// action button.
class _GuiaAutostart extends StatelessWidget {
const _GuiaAutostart({required this.fabricante});
final String fabricante;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final tokens = context.pluriTokens;
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.info_outline_rounded, color: tokens.warmCoral),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.alarmDiagnosticsAutostartTitle,
style: const TextStyle(fontWeight: FontWeight.w700),
),
),
],
),
const SizedBox(height: 8),
Text(l10n.alarmDiagnosticsAutostartBody(fabricante)),
],
),
);
}
}
+57 -20
View File
@@ -174,12 +174,18 @@ class _PantallaFavoritosState extends State<PantallaFavoritos> {
return ReorderableListView(
buildDefaultDragHandles: false,
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
4,
PluriLayout.horizontal,
PluriLayout.bottomChromeInset,
),
// Issue 3 (feedback-pruebas): zero horizontal here, matching every
// other root's PluriLayout.pageListPadding convention (Alarmas,
// Ajustes, and this screen's OWN empty-state branch above).
// ReorderableListView.padding wraps header/children/footer UNIFORMLY,
// so a single horizontal value here can never be simultaneously right
// for PluriRootHeader (self-padded, wants none), the reorderable rows
// (want row tier, applied per item below) and the footer CTA (wants
// card tier, applied on the footer's own Padding below). The previous
// `PluriLayout.horizontal` doubled up on top of PluriRootHeader's own
// internal inset, pushing "Favorites" in by 36px instead of the 20px
// every other root uses for its title.
padding: const EdgeInsets.only(bottom: PluriLayout.bottomChromeInset),
header: Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
@@ -224,17 +230,39 @@ class _PantallaFavoritosState extends State<PantallaFavoritos> {
],
),
const SizedBox(height: 12),
_FilaChipsGrupos(
grupos: gruposVisibles,
favoritos: favoritos,
seleccionado: seleccionEfectiva,
onSeleccionar: (id) => setState(() => _grupoSeleccionadoId = id),
// Issue 3 (feedback-pruebas): t4:218 draws this chip strip at
// title-tier (20px) horizontal inset, directly on the page
// background -- it now needs its OWN inset since the list's
// padding no longer supplies one.
Padding(
padding: const EdgeInsets.symmetric(
horizontal: PluriLayout.titleHorizontal,
),
child: _FilaChipsGrupos(
grupos: gruposVisibles,
favoritos: favoritos,
seleccionado: seleccionEfectiva,
onSeleccionar:
(id) => setState(() => _grupoSeleccionadoId = id),
),
),
],
),
),
footer: Padding(
padding: const EdgeInsets.only(top: 4),
// Issue 3 (feedback-pruebas): card tier (16, matching every other
// screen's dashed CTA) now that the list's own padding no longer
// supplies it, plus t4:234's 8px gap above the CTA
// (PluriLayout.compactGap) instead of the previous unwired literal
// 4 -- the ONLY state of this screen with a nonzero top gap before
// its own content used a value that matched neither this screen's
// own empty-state branch nor the prototype.
padding: const EdgeInsets.fromLTRB(
PluriLayout.horizontal,
PluriLayout.compactGap,
PluriLayout.horizontal,
0,
),
child: _CtaEmisoraPersonalizada(
onTap: _abrirFormularioEmisoraPersonalizada,
),
@@ -244,14 +272,24 @@ class _PantallaFavoritosState extends State<PantallaFavoritos> {
_onReorder(filtrados, favoritos, oldIndex, newIndex),
children: [
for (var i = 0; i < filtrados.length; i++)
_FilaFavorito(
// Issue 3 (feedback-pruebas): row tier (12), not card tier -- the
// key moves to this wrapper (ReorderableListView identifies each
// child by its own top-level key) since FilaEmisoraPlana rows are
// documented (audit 4.3) as flat, background-less rows, the same
// tier Buscar's results list already uses for the same widget.
Padding(
key: ValueKey(filtrados[i].uuid),
index: i,
emisora: filtrados[i],
grupos: gruposVisibles,
grupoActual: gruposVisibles.firstWhere(
(g) => g.id == filtrados[i].grupoFavoritosId,
orElse: () => gruposVisibles.first,
padding: const EdgeInsets.symmetric(
horizontal: PluriLayout.rowHorizontal,
),
child: _FilaFavorito(
index: i,
emisora: filtrados[i],
grupos: gruposVisibles,
grupoActual: gruposVisibles.firstWhere(
(g) => g.id == filtrados[i].grupoFavoritosId,
orElse: () => gruposVisibles.first,
),
),
),
],
@@ -343,7 +381,6 @@ class _FilaChipsGrupos extends StatelessWidget {
class _FilaFavorito extends StatelessWidget {
const _FilaFavorito({
super.key,
required this.index,
required this.emisora,
required this.grupos,
+6 -1
View File
@@ -251,7 +251,12 @@ class _PantallaGrabacionesState extends State<PantallaGrabaciones> {
padding: PluriLayout.pageContentPadding,
children: [
_BarraDeAlmacenamiento(archivos: archivos),
const SizedBox(height: 12),
// Issue 3 (feedback-pruebas): t4:617 draws a 16px gap between
// the storage card and the rows below it, not 12.
const SizedBox(
height: 16,
key: ValueKey('grabaciones-storage-gap'),
),
if (snap.connectionState == ConnectionState.done &&
archivos.isEmpty)
PluriEmptyState(
+6 -1
View File
@@ -134,7 +134,12 @@ class _PantallaPaisesState extends State<PantallaPaises> {
),
if (query.isEmpty) ...[
_seccionTusIdiomas(context, estado.paises, l10n),
const SizedBox(height: 16),
// Issue 3 (feedback-pruebas): t4:260 draws a 14px gap
// between "Tus idiomas" and "Todos", not 16.
const SizedBox(
height: 14,
key: ValueKey('paises-seccion-gap'),
),
_seccionTodos(context, estado.paises, l10n),
] else
_seccionTodos(context, paisesFiltrados, l10n),
+119
View File
@@ -0,0 +1,119 @@
import 'servicio_alarmas_android.dart';
/// One diagnosable Android alarm-reliability signal (fix/alarmas-fiabilidad).
///
/// [DiagnosticoAlarmasAndroid] already collects six raw fields, but only
/// three were ever surfaced in the UI -- the two most diagnostic ones
/// (battery-optimization exemption and the native pending-alarm count) were
/// gathered and thrown away. This module maps the raw snapshot into a
/// stable, ordered list of user-facing signals with a clear ok/needs-
/// attention state, decoupled from Flutter/localization so it stays a
/// trivial pure-Dart unit to test.
enum SenalDiagnosticoAlarma {
alarmasExactas,
notificaciones,
pantallaCompleta,
optimizacionBateria,
alarmasNativasPendientes,
}
enum EstadoSenalDiagnostico { ok, atencion }
/// The system screen a "fix this" action should open for a given signal.
/// `ninguna` marks signals with no actionable system screen of their own
/// (`alarmasNativasPendientes` is informational -- fixing the OTHER signals
/// above is what makes it recover).
enum AccionDiagnosticoAlarma {
abrirAlarmasExactas,
abrirNotificaciones,
abrirOptimizacionBateria,
abrirPantallaCompleta,
ninguna,
}
class ItemDiagnosticoAlarma {
const ItemDiagnosticoAlarma({
required this.senal,
required this.estado,
required this.accion,
});
final SenalDiagnosticoAlarma senal;
final EstadoSenalDiagnostico estado;
final AccionDiagnosticoAlarma accion;
bool get requiereAtencion => estado == EstadoSenalDiagnostico.atencion;
}
/// Builds the five diagnosable signals in a FIXED, stable order so the
/// screen renders them consistently every time.
///
/// [hayAlarmasActivas] contextualizes `alarmasNativasPendientes`: a fresh
/// install with zero alarms turned on has nothing to register with the OS,
/// so a `0` count there is only meaningful once the user actually has an
/// active alarm -- THAT combination is direct evidence the alarm never
/// reached the operating system at all, which is the single most useful
/// signal for the reported "alarm never rings" failure mode.
List<ItemDiagnosticoAlarma> construirItemsDiagnosticoAlarmas({
required DiagnosticoAlarmasAndroid diagnostico,
required bool hayAlarmasActivas,
}) {
EstadoSenalDiagnostico desde(bool ok) =>
ok ? EstadoSenalDiagnostico.ok : EstadoSenalDiagnostico.atencion;
final alarmasNativasOk =
!hayAlarmasActivas || diagnostico.alarmasNativasPendientes > 0;
return [
ItemDiagnosticoAlarma(
senal: SenalDiagnosticoAlarma.alarmasExactas,
estado: desde(diagnostico.puedeProgramarExactas),
accion: AccionDiagnosticoAlarma.abrirAlarmasExactas,
),
ItemDiagnosticoAlarma(
senal: SenalDiagnosticoAlarma.notificaciones,
estado: desde(diagnostico.notificacionesPermitidas),
accion: AccionDiagnosticoAlarma.abrirNotificaciones,
),
ItemDiagnosticoAlarma(
senal: SenalDiagnosticoAlarma.pantallaCompleta,
estado: desde(diagnostico.puedeUsarPantallaCompleta),
accion: AccionDiagnosticoAlarma.abrirPantallaCompleta,
),
ItemDiagnosticoAlarma(
senal: SenalDiagnosticoAlarma.optimizacionBateria,
estado: desde(diagnostico.ignoraOptimizacionBateria),
accion: AccionDiagnosticoAlarma.abrirOptimizacionBateria,
),
ItemDiagnosticoAlarma(
senal: SenalDiagnosticoAlarma.alarmasNativasPendientes,
estado: desde(alarmasNativasOk),
accion: AccionDiagnosticoAlarma.ninguna,
),
];
}
/// Manufacturers/sub-brands known for aggressive background-process killing
/// that requires the user to manually enable "Autostart" (or the vendor's
/// own equivalent toggle) -- there is NO public Android API to detect or
/// grant this setting programmatically, so the app can only explain it.
const _fabricantesConGuiaAutostart = [
'xiaomi',
'redmi',
'poco',
'huawei',
'oppo',
'vivo',
'oneplus',
'samsung',
];
/// Whether [fabricante] (`Build.MANUFACTURER`, e.g. "Xiaomi", "POCO") is a
/// known aggressive-background-killer vendor that needs the manual autostart
/// explanation. Case-insensitive substring match, since `Build.MANUFACTURER`
/// values are not fully standardized across sub-brands/regions/builds.
bool fabricanteRequiereGuiaAutostart(String fabricante) {
final normalizado = fabricante.trim().toLowerCase();
if (normalizado.isEmpty) return false;
return _fabricantesConGuiaAutostart.any(normalizado.contains);
}
+366 -84
View File
@@ -10,6 +10,7 @@ import '../estado/orden_emisoras.dart';
import '../modelos/emisora.dart';
import '../modelos/grupo_favoritos.dart';
import '../modelos/pista_local.dart';
import '../modelos/preset_ecualizador.dart';
import 'musica_local_auto.dart';
import 'persistencia_tolerante.dart';
import 'servicio_favoritos.dart';
@@ -19,16 +20,37 @@ import 'servicio_favoritos.dart';
/// no [NodoLocal] coupling — so a future paged folder type can reuse the
/// slice arithmetic directly. An empty [items] or a [pagina] beyond the
/// list's range returns `[]`, never throws.
List<T> paginaDe<T>(List<T> items, {required int pagina, required int tamano}) =>
items.skip(pagina * tamano).take(tamano).toList();
List<T> paginaDe<T>(
List<T> items, {
required int pagina,
required int tamano,
}) => items.skip(pagina * tamano).take(tamano).toList();
/// Whether a page after [pagina] exists for a list of [total] elements
/// (Design ADR-6): `true` iff at least one element remains beyond the
/// current page's slice. The exact-boundary case
/// (`total == (pagina + 1) * tamano`) is `false` — nothing remains to
/// reveal.
bool hayPaginaSiguiente(int total, {required int pagina, required int tamano}) =>
total > (pagina + 1) * tamano;
bool hayPaginaSiguiente(
int total, {
required int pagina,
required int tamano,
}) => total > (pagina + 1) * tamano;
/// Browse-tree ordering comparator for a local-music folder's children
/// (Design "Directories before files", item 1): directories sort before
/// files regardless of name, and within each group, alphabetically by
/// [NodoLocal.nombre] -- the standard file-browser convention. Fixes a
/// driver-facing bug where a folder's subfolders could land on a later
/// "Más…" page whenever enough tracks sorted alphabetically ahead of them
/// (e.g. a "Live" subfolder behind 80 numbered tracks), making the
/// subfolder unreachable without paging through every track first.
int compararNodoLocalParaNavegacion(NodoLocal a, NodoLocal b) {
if (a.esDirectorio != b.esDirectorio) {
return a.esDirectorio ? -1 : 1;
}
return a.nombre.compareTo(b.nombre);
}
const _prefijoEmisora = 'emisora:';
@@ -68,8 +90,7 @@ bool faviconUsable(String? favicon) {
// even with an empty host (e.g. `Uri.parse('http://').hasAuthority` is
// `true`) — check `host.isNotEmpty` explicitly to actually require a
// non-empty authority host.
return (uri.scheme == 'http' || uri.scheme == 'https') &&
uri.host.isNotEmpty;
return (uri.scheme == 'http' || uri.scheme == 'https') && uri.host.isNotEmpty;
}
/// Deterministic rotation index over the 4 on-brand fallback arts, same
@@ -83,10 +104,11 @@ int indiceArtePara(String seed) =>
/// drawable URI selected via [indiceArtePara] over `e.uuid` — the same
/// on-brand art the phone UI would pick for this station (per-station
/// parity), never a launcher-icon lookalike.
String artUriPara(Emisora e) => faviconUsable(e.favicon)
? e.favicon!
: 'android.resource://es.freetimelab.pluriwave/drawable/'
'station_art_${_nombresArte[indiceArtePara(e.uuid)]}';
String artUriPara(Emisora e) =>
faviconUsable(e.favicon)
? e.favicon!
: 'android.resource://es.freetimelab.pluriwave/drawable/'
'station_art_${_nombresArte[indiceArtePara(e.uuid)]}';
/// Formats a human-readable audio-quality hint for the browse row's
/// `displaySubtitle` (Design Decision "`displaySubtitle` quality format"):
@@ -197,6 +219,17 @@ class ConstructorArbolAuto {
/// (Design "Local root hidden until a folder is configured").
static const idMusicaLocal = 'musica_local';
/// Root folder id for the "Ecualizador" browsable folder (decision
/// `auto/ecualizador-diseno`): lists "Desactivar" plus the six factory
/// presets, the currently-active one marked. Deliberately NOT added to
/// [_idsCarpetas] -- like [idMusicaLocal], it has its own dedicated
/// children, built by `itemsEcualizadorAuto` in `servicio_audio.dart`
/// (which needs `AppLocalizations` -- this pure builder class does not
/// depend on it), not the generic station-list [hijos] path. Unlike
/// [idMusicaLocal], it is ALWAYS present in [raiz], never conditionally
/// hidden.
static const idEcualizador = 'ecualizador';
static const _idsCarpetas = {idFavoritos, idTodas, idMisEmisoras};
static const _maxItemsPorCarpeta = 50;
@@ -280,12 +313,23 @@ class ConstructorArbolAuto {
};
/// The root folders (Favoritos, Todas las emisoras, Mis emisoras,
/// optionally Música Local), all non-playable.
/// optionally Música Local, Ecualizador), all non-playable.
///
/// There is deliberately no equalizer folder: EQ is configured on the phone
/// only. The car still gets the right sound, because the per-device preset
/// is applied automatically when the output device changes — that lives in
/// `EstadoEcualizador`, not in this tree.
/// Decision `auto/ecualizador-diseno` SUPERSEDES the "no equalizer
/// folder" rule that used to live in this doc comment (commit `2403da3`,
/// mirroring the redesign mockup's "sin carpeta de ecualizador", turn t4
/// line 40). That rule was sound when written, but predated on-device
/// feedback showing that Android Auto custom actions don't surface
/// enough state for choosing among six presets: a monochrome icon cannot
/// legibly encode "which preset", and many head units render a custom
/// action icon-first, hiding its label. `Ecualizador` is a real
/// browsable folder again: "Desactivar" first, then the six factory
/// presets, the active one marked (children built by
/// `itemsEcualizadorAuto` in `servicio_audio.dart` -- this class stays
/// free of any `AppLocalizations` dependency, unlike that builder).
/// Always present, and LAST in the list (after Música Local, when
/// included) -- unlike [idMusicaLocal] it is never conditionally hidden.
/// Do not "restore" the no-folder rule without re-reading that decision.
///
/// `Música Local` is OMITTED entirely (not just empty) unless
/// [incluirMusicaLocal] is `true` (Design "Local root hidden until a folder
@@ -296,6 +340,7 @@ class ConstructorArbolAuto {
_carpeta(idTodas, 'Todas las emisoras'),
_carpeta(idMisEmisoras, 'Mis emisoras'),
if (incluirMusicaLocal) _carpeta(idMusicaLocal, 'Música Local'),
_carpeta(idEcualizador, 'Ecualizador'),
];
MediaItem _carpeta(String id, String titulo) => MediaItem(
@@ -432,14 +477,21 @@ class ConstructorArbolAuto {
int tamano = _maxItemsCarpetaLocal,
@visibleForTesting
MediaItem Function(NodoLocal, Map<String, MetadatosPista>)? construirItem,
// Item 2 (recursive folder play): optional so every pre-existing call
// site/test that has no need for the recursive gate keeps working
// unchanged. Only used on page 0, and only when [nodos] has zero
// DIRECT tracks (a direct track already makes the gate cheaply true
// without it) — see the `hayContenidoReproducible` computation below.
FuenteMusicaLocalAuto? fuente,
}) async {
final construir = construirItem ?? _itemLocal;
final ordenados = [...nodos]..sort((a, b) => a.nombre.compareTo(b.nombre));
final ordenados = [...nodos]..sort(compararNodoLocalParaNavegacion);
final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano);
final docIds = paginaActual
.where((n) => !n.esDirectorio)
.map((n) => n.documentId)
.toList();
final docIds =
paginaActual
.where((n) => !n.esDirectorio)
.map((n) => n.documentId)
.toList();
final metadatos = await metadatosDe(docIds);
final items = paginaActual.map((n) => construir(n, metadatos)).toList();
if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) {
@@ -447,14 +499,25 @@ class ConstructorArbolAuto {
}
if (pagina == 0) {
final totalPistas = nodos.where((n) => !n.esDirectorio).length;
// Item 2: a folder plays everything beneath it, recursively -- so
// the play actions must be offered whenever the RECURSIVE count is
// > 0, not just the direct count. `totalPistas > 0` short-circuits
// the bounded recursive walk entirely for the common case (a direct
// track already answers the question); only a folder with ZERO
// direct tracks but at least one subfolder pays the recursive-check
// cost, and only up to [profundidadMaximaRecursivaLocal] levels.
final hayContenidoReproducible =
totalPistas > 0 ||
(fuente != null &&
await _haySubcarpetaConPistas(nodos, fuente: fuente));
final prepend = <MediaItem>[
// Folder-play actions (Design ADR-5, Phase 3): prepended BEFORE
// the sort/bucket nav entries, guarded the same shape as
// ofreceOrdenCalidad(totalPistas > 0) — present iff the folder has
// at least one direct audio child, absent for a folder with only
// subfolders (Spec "Folder has no tracks").
if (totalPistas > 0) _itemReproducirCarpeta(documentIdPadre),
if (totalPistas > 0) _itemReproducirAleatorio(documentIdPadre),
// Folder-play actions (Design ADR-5, Phase 3; recursive gate item
// 2): prepended BEFORE the sort/bucket nav entries, present iff
// the folder has at least one playable track anywhere beneath it
// (direct or nested), absent for a folder that is genuinely empty
// even recursively (Spec "Folder has no tracks").
if (hayContenidoReproducible) _itemReproducirCarpeta(documentIdPadre),
if (hayContenidoReproducible) _itemReproducirAleatorio(documentIdPadre),
if (ofreceOrdenCalidad(totalPistas))
_itemModoOrdenCalidad(documentIdPadre),
if (ofreceBuckets(totalPistas))
@@ -466,6 +529,32 @@ class ConstructorArbolAuto {
return items;
}
/// Whether at least one subfolder within [nodos] recursively contains a
/// playable track (Design "recursive folder play, gate", item 2): called
/// ONLY when the folder has zero DIRECT tracks (the caller already
/// checked that cheaply) — descends into each direct subfolder via
/// [pistasRecursivas] with `limite: 1`, stopping at the very first
/// match so a folder with an early hit costs as little as possible.
/// [nodos] is assumed already resolved by the caller (its own
/// `fuente.hijos(...)` result), so this folder's own children are never
/// re-fetched.
Future<bool> _haySubcarpetaConPistas(
List<NodoLocal> nodos, {
required FuenteMusicaLocalAuto fuente,
}) async {
for (final nodo in nodos) {
if (!nodo.esDirectorio) continue;
final encontradas = await pistasRecursivas(
nodo.documentId,
fuente: fuente,
profundidadMaxima: profundidadMaximaRecursivaLocal - 1,
limite: 1,
);
if (encontradas.isNotEmpty) return true;
}
return false;
}
/// Whether the "Ordenar por calidad" mode entry should be offered for a
/// folder with [totalPistas] audio files (Design ADR-3): present for
/// `0 < totalPistas <= 150`, omitted otherwise (empty folder or above the
@@ -666,10 +755,11 @@ class ConstructorArbolAuto {
final ordenados = [...buckets[idxBucket].nodos]
..sort((a, b) => a.nombre.compareTo(b.nombre));
final paginaActual = paginaDe(ordenados, pagina: pagina, tamano: tamano);
final docIds = paginaActual
.where((n) => !n.esDirectorio)
.map((n) => n.documentId)
.toList();
final docIds =
paginaActual
.where((n) => !n.esDirectorio)
.map((n) => n.documentId)
.toList();
final metadatos = await metadatosDe(docIds);
final items = paginaActual.map((n) => construir(n, metadatos)).toList();
if (hayPaginaSiguiente(ordenados.length, pagina: pagina, tamano: tamano)) {
@@ -684,20 +774,21 @@ class ConstructorArbolAuto {
}
final meta = metadatos[nodo.documentId];
final tituloMeta = meta?.titulo?.trim();
final titulo = (tituloMeta != null && tituloMeta.isNotEmpty)
? tituloMeta
: _tituloDesdeNombre(nodo.nombre);
final titulo =
(tituloMeta != null && tituloMeta.isNotEmpty)
? tituloMeta
: _tituloDesdeNombre(nodo.nombre);
final artUriMeta = meta?.artUri?.trim();
final artUri = (artUriMeta != null && artUriMeta.isNotEmpty)
? artUriMeta
: artUriLocal(nodo.documentId);
final artUri =
(artUriMeta != null && artUriMeta.isNotEmpty)
? artUriMeta
: artUriLocal(nodo.documentId);
final artistaMeta = meta?.artista?.trim();
return MediaItem(
id: '$_prefijoPista${nodo.documentId}',
title: titulo,
artist: (artistaMeta != null && artistaMeta.isNotEmpty)
? artistaMeta
: null,
artist:
(artistaMeta != null && artistaMeta.isNotEmpty) ? artistaMeta : null,
playable: true,
artUri: Uri.parse(artUri),
displaySubtitle: subtituloCalidadLocal(meta),
@@ -718,15 +809,17 @@ class ConstructorArbolAuto {
required List<GrupoFavoritos> grupos,
required List<Emisora> favoritos,
}) {
final carpetas = grupos
.where((g) => !g.esSinAsignar)
.where((g) => favoritos.any((e) => e.grupoFavoritosId == g.id))
.take(_maxGruposPorFavoritos)
.map(itemGrupo)
.toList();
final sinAsignar = favoritos
.where((e) => e.grupoFavoritosId == GrupoFavoritos.sinAsignarId)
.toList();
final carpetas =
grupos
.where((g) => !g.esSinAsignar)
.where((g) => favoritos.any((e) => e.grupoFavoritosId == g.id))
.take(_maxGruposPorFavoritos)
.map(itemGrupo)
.toList();
final sinAsignar =
favoritos
.where((e) => e.grupoFavoritosId == GrupoFavoritos.sinAsignarId)
.toList();
return [...carpetas, ...hijos(idFavoritos, emisoras: sinAsignar)];
}
@@ -742,13 +835,62 @@ class ConstructorArbolAuto {
if (!esCarpetaGrupo(grupoMediaId)) return const [];
final id = grupoMediaId.substring(_prefijoGrupo.length);
if (id.isEmpty) return const [];
final miembros = favoritos
.where((e) => e.grupoFavoritosId == id)
.toList();
final miembros = favoritos.where((e) => e.grupoFavoritosId == id).toList();
if (miembros.isEmpty) return const [];
final ordenados = ordenarEmisoras(miembros, OrdenEmisoras.calidad);
return ordenados.take(_maxItemsPorCarpeta).map(itemEmisora).toList();
}
/// Equalizer preset-selection media-id prefix (decision
/// `auto/ecualizador-diseno`): `eq_preset:<rawPresetName>` for the six
/// factory presets, plus the reserved [_valorDesactivarEq] sentinel for
/// the "Desactivar" item ([idDesactivarEq]). Collision-free against every
/// other prefix/bare id in this class -- diverges from every sibling
/// prefix at the very first character ('e' vs 'g'/'c'/'p') and from every
/// bare folder id (none of which starts with "eq_preset:").
static const _prefijoPresetEq = 'eq_preset:';
/// Reserved sentinel raw value for the "Desactivar" item under
/// [_prefijoPresetEq] (decision `auto/ecualizador-diseno`) -- never
/// collides with a real [PresetEcualizador.nombre]; none of the six
/// factory presets is named this.
static const _valorDesactivarEq = '_off_';
/// The "Desactivar" item's media id: the reserved [_valorDesactivarEq]
/// sentinel under [_prefijoPresetEq].
static const idDesactivarEq = '$_prefijoPresetEq$_valorDesactivarEq';
/// Whether [id] identifies an item under the Ecualizador folder (a
/// factory preset OR "Desactivar").
bool esPresetEqMediaId(String id) => id.startsWith(_prefijoPresetEq);
/// Whether [id] is specifically the "Desactivar" item (not a factory
/// preset). Only meaningful alongside [esPresetEqMediaId].
bool esDesactivarEqMediaId(String id) => id == idDesactivarEq;
/// Builds a factory preset's selection media id, matched by raw
/// (untranslated) [PresetEcualizador.nombre] -- the SAME identity
/// [PresetEcualizador.presets] already uses for equality, so a locale
/// change never breaks resolution.
String idPresetEq(String nombrePreset) => '$_prefijoPresetEq$nombrePreset';
/// Resolves an `eq_preset:<nombre>` [id] to the matching factory
/// [PresetEcualizador] from [presets] (defaults to
/// [PresetEcualizador.presets]), comparing by raw `nombre`. Returns
/// `null` for the [_valorDesactivarEq] sentinel, an unresolvable name, or
/// any id that doesn't match [esPresetEqMediaId] -- never throws.
PresetEcualizador? resolverPresetEq(
String id, {
List<PresetEcualizador>? presets,
}) {
if (!esPresetEqMediaId(id) || esDesactivarEqMediaId(id)) return null;
final nombre = id.substring(_prefijoPresetEq.length);
final lista = presets ?? PresetEcualizador.presets;
for (final preset in lista) {
if (preset.nombre == nombre) return preset;
}
return null;
}
}
/// Routing seam between a car-tapped `emisora:<uuid>` media id and the
@@ -777,15 +919,54 @@ Future<void> reproducirPorMediaId(
title: emisora.nombre,
artist: emisora.pais ?? '',
album: 'PluriWave',
artUri:
emisora.favicon != null && emisora.favicon!.isNotEmpty
? Uri.tryParse(emisora.favicon!)
: null,
// Item 3: reuses [artUriPara] (the SAME fallback the browse tree's
// itemEmisora already applies) so the "now playing" media item never
// falls back to a blank tile — a real usable favicon still wins, a
// missing/unusable one gets the on-brand rotating drawable instead of
// `null`.
artUri: Uri.parse(artUriPara(emisora)),
extras: {'uuid': emisora.uuid},
);
await reproducir(item);
}
/// Routing seam for a car-tapped `eq_preset:<...>` media id (decision
/// `auto/ecualizador-diseno`, mirrors [reproducirPorMediaId]'s seam
/// shape): dispatches "Desactivar" to [activarEcualizador]`(false)`, and a
/// resolved factory preset to [aplicarPreset] -- turning the equalizer
/// back ON via [activarEcualizador]`(true)` AFTERWARDS whenever [activo]
/// is currently `false`, so tapping a preset while the equalizer is off
/// both re-enables it AND applies the tapped preset's gains (Spec
/// "selecting a preset while disabled enables it and applies it"), never
/// silently just remembering the preset for later. [aplicarPreset] runs
/// BEFORE the enable check so the native engine only ever pushes gains
/// once, for the NEW preset -- never once for whatever was active before,
/// then again for the new one.
///
/// A stale/unresolvable id, or any id that doesn't match
/// [ConstructorArbolAuto.esPresetEqMediaId], is a no-op: neither callback
/// runs and no exception propagates.
Future<void> seleccionarPresetEqPorMediaId(
String id, {
required bool activo,
required Future<void> Function(PresetEcualizador) aplicarPreset,
required Future<void> Function(bool) activarEcualizador,
}) async {
final constructor = ConstructorArbolAuto();
if (!constructor.esPresetEqMediaId(id)) return;
if (constructor.esDesactivarEqMediaId(id)) {
await activarEcualizador(false);
return;
}
final preset = constructor.resolverPresetEq(id);
if (preset == null) return;
await aplicarPreset(preset);
if (!activo) await activarEcualizador(true);
}
/// Fallback title (Design "Title = filename minus extension") for a blank
/// or otherwise empty-after-stripping local filename — hardcoded Spanish,
/// matching every other car-tree label in this file (`'Favoritos'`,
@@ -843,10 +1024,8 @@ List<NodoLocal> ordenarPorCalidadLocal(
) {
final ordenados = List<NodoLocal>.from(nodos);
ordenados.sort(
(a, b) => compararCalidadLocal(
metadatos[a.documentId],
metadatos[b.documentId],
),
(a, b) =>
compararCalidadLocal(metadatos[a.documentId], metadatos[b.documentId]),
);
return ordenados;
}
@@ -887,12 +1066,13 @@ List<BucketLocal> bucketsDe(List<NodoLocal> nodos) {
final pistas = nodos.where((n) => !n.esDirectorio).toList();
return _rangosBucket.map((rango) {
final (etiqueta, desde, hasta) = rango;
final coincidencias = pistas.where((n) {
final recortado = n.nombre.trim();
if (recortado.isEmpty) return false;
final letra = recortado[0].toLowerCase();
return letra.compareTo(desde) >= 0 && letra.compareTo(hasta) <= 0;
}).toList();
final coincidencias =
pistas.where((n) {
final recortado = n.nombre.trim();
if (recortado.isEmpty) return false;
final letra = recortado[0].toLowerCase();
return letra.compareTo(desde) >= 0 && letra.compareTo(hasta) <= 0;
}).toList();
return BucketLocal(etiqueta: etiqueta, nodos: coincidencias);
}).toList();
}
@@ -931,17 +1111,115 @@ List<NodoLocal> mezclarFisherYates(List<NodoLocal> nodos, Random rng) {
List<NodoLocal> pistasEnOrdenAleatorio(List<NodoLocal> nodos, Random rng) =>
mezclarFisherYates(pistasEnOrdenNombre(nodos), rng);
/// Maximum recursion depth for "play folder recursively" (Design "recursive
/// folder play, cost bound", item 2): SAF directory listing is a native
/// round-trip PER folder, so unbounded recursion could turn a single tap
/// into dozens of channel calls for a pathologically deep tree. 4 levels
/// below the tapped folder covers virtually every real music-library
/// layout (even `Artist/Album/Disc/track.mp3` is only 3 levels deep) while
/// keeping a worst-case tree's native-call count bounded. A subfolder
/// beyond this depth is simply never explored — its tracks are not
/// collected, exactly like content beyond the browse tree's own page cap
/// is never listed.
const profundidadMaximaRecursivaLocal = 4;
/// Maximum number of tracks collected by a recursive folder walk (Design
/// "recursive folder play, cost bound", item 2): a folder-play/shuffle
/// queue beyond a few hundred tracks has no practical benefit, and an
/// unbounded collection risks an extremely long queue AND an extremely
/// long recursive walk over a huge library. 500 is an order of magnitude
/// above the existing quality-sort cap
/// ([ConstructorArbolAuto._maxPistasParaOrdenCalidad], 150) — generous for
/// a "play everything" action, while still bounded.
const limitePistasRecursivasLocal = 500;
/// Recursively collects every audio-file [NodoLocal] reachable from
/// [documentId] (Design "recursive folder play", item 2): [documentId]'s
/// own direct audio children, plus — for every direct subfolder — that
/// subfolder's own recursive result. Walked depth-first, sorted by
/// [NodoLocal.nombre] at each level (the SAME comparator the sequential/
/// shuffle play actions already used pre-recursion), so the collected
/// order is deterministic and reproducible under a fixed shuffle seed.
///
/// Bounded on two independent axes so a pathological tree (very deep, or
/// very wide-and-deep) can never turn a single tap into an unbounded
/// number of native SAF round-trips or an unbounded in-memory list:
/// - [profundidadMaxima] caps how many folder levels BELOW [documentId]
/// are ever descended into (`0` = only [documentId]'s own direct
/// children, no descent at all).
/// - [limite] caps the TOTAL number of tracks collected across the whole
/// walk; collection stops (mid-folder if needed) the instant this many
/// have been gathered.
///
/// Never throws: a [fuente.hijos] failure on any one subfolder (revoked
/// permission, a race with the OS SAF layer) is swallowed for that
/// subfolder only — sibling folders already queued for traversal are
/// still visited — mirroring this file's existing no-throw contract
/// (Design "no-op on empty/unresolvable folder").
Future<List<NodoLocal>> pistasRecursivas(
String documentId, {
required FuenteMusicaLocalAuto fuente,
int profundidadMaxima = profundidadMaximaRecursivaLocal,
int limite = limitePistasRecursivasLocal,
}) async {
final resultado = <NodoLocal>[];
await _recolectarPistasRecursivas(
documentId,
fuente: fuente,
profundidadRestante: profundidadMaxima,
limite: limite,
resultado: resultado,
);
return resultado;
}
Future<void> _recolectarPistasRecursivas(
String documentId, {
required FuenteMusicaLocalAuto fuente,
required int profundidadRestante,
required int limite,
required List<NodoLocal> resultado,
}) async {
if (resultado.length >= limite) return;
final List<NodoLocal> hijos;
try {
hijos = await fuente.hijos(documentId);
} catch (_) {
return;
}
final ordenados = [...hijos]..sort((a, b) => a.nombre.compareTo(b.nombre));
for (final nodo in ordenados) {
if (resultado.length >= limite) return;
if (nodo.esDirectorio) {
if (profundidadRestante <= 0) continue;
await _recolectarPistasRecursivas(
nodo.documentId,
fuente: fuente,
profundidadRestante: profundidadRestante - 1,
limite: limite,
resultado: resultado,
);
} else {
resultado.add(nodo);
}
}
}
/// Orchestrates a "Reproducir carpeta"/"Reproducir aleatorio" tap (Design
/// "Data Flow", ADR-5/ADR-6, Phase 3 task 4.2): resolves whichever of the
/// two action prefixes matches [id] (ignoring [aleatorio] for the STRIP —
/// the prefix itself is authoritative), fetches [fuente]'s direct children
/// for that folder, filters to audio files, orders them ([aleatorio] picks
/// shuffled vs name order), and hands the resulting list to [iniciarCola].
/// "Data Flow", ADR-5/ADR-6, Phase 3 task 4.2; recursive collection item
/// 2): resolves whichever of the two action prefixes matches [id]
/// (ignoring [aleatorio] for the STRIP — the prefix itself is
/// authoritative), RECURSIVELY collects every track beneath that folder
/// via [pistasRecursivas] (direct children AND every nested subfolder, up
/// to its depth/count bounds), orders them ([aleatorio] picks shuffled vs
/// the recursive walk's own name-sorted order), and hands the resulting
/// list to [iniciarCola].
///
/// A no-op (never calls [iniciarCola]) when: [id] matches neither action
/// prefix; [fuente.hijos] throws or returns only directories (an
/// unresolvable/empty folder — Design "no-op on empty/unresolvable
/// folder").
/// prefix; the folder (or everything beneath it, within the recursion
/// bounds) is unresolvable/empty (Design "no-op on empty/unresolvable
/// folder") — [pistasRecursivas] never throws, so this never propagates an
/// exception either.
Future<void> reproducirCarpetaLocal(
String id, {
required bool aleatorio,
@@ -959,16 +1237,11 @@ Future<void> reproducirCarpetaLocal(
return;
}
final List<NodoLocal> nodos;
try {
nodos = await fuente.hijos(documentId);
} catch (_) {
return;
}
final pistas = aleatorio
? pistasEnOrdenAleatorio(nodos, rng ?? Random())
: pistasEnOrdenNombre(nodos);
final recolectadas = await pistasRecursivas(documentId, fuente: fuente);
final pistas =
aleatorio
? mezclarFisherYates(recolectadas, rng ?? Random())
: recolectadas;
if (pistas.isEmpty) return;
await iniciarCola(pistas);
@@ -992,6 +1265,11 @@ Future<MediaItem?> construirMediaItemColaLocal(
id: contentUri,
title: _tituloDesdeDocumentId(nodo.documentId),
album: 'PluriWave',
// Item 3: a queued local track had NO artUri at all before — reuses
// [artUriLocal] (the SAME on-brand rotation the browse tree's
// `_itemLocal` already falls back to) so the car's now-playing screen
// never shows a blank tile for a track with no embedded art.
artUri: Uri.parse(artUriLocal(nodo.documentId)),
extras: {'documentId': nodo.documentId},
);
}
@@ -1114,6 +1392,7 @@ Future<List<MediaItem>?> hijosMusicaLocal(
documentIdPadre: documentId,
pagina: pagina,
metadatosDe: (ids) => _metadatosDeConCache(ids, fuente: fuente),
fuente: fuente,
);
} catch (_) {
return const [];
@@ -1170,6 +1449,9 @@ Future<void> reproducirPistaLocal(
id: pista.contentUri,
title: pista.titulo,
album: 'PluriWave',
// Item 3: same fallback as construirMediaItemColaLocal, for a track
// tapped directly (not via a folder-play queue).
artUri: Uri.parse(artUriLocal(pista.documentId)),
extras: {'documentId': pista.documentId},
);
await reproducir(item);
+67
View File
@@ -350,6 +350,73 @@ class ServicioAlarmas {
return nuevo;
});
/// Records a scheduling-reliability failure for [alarmaId] (fix/alarmas-
/// fallos-silenciosos): reuses the SAME `ExcepcionAlarma` model
/// `saltarProxima` already persists, so `EstadoAlarmas.ultimaExcepcionPara`
/// surfaces it on the exact alarm card affected instead of only a
/// transient, alarm-agnostic message. A previous FAILURE record for the
/// same alarm is replaced (only the latest attempt's outcome matters) --
/// any `skipNext` exception for this or other alarms is left untouched.
/// Never affects scheduling: `ServicioProgramacionAlarmas._esValida` only
/// treats `tipoSaltoSiguiente` as an actual skip.
Future<ConfiguracionAlarmas> registrarFalloProgramacion(
String alarmaId,
DateTime ejecucion,
String tipo,
) => _enCola(() async {
final config = await _configActual();
final excepciones = [
..._sinFalloPrevio(config.excepciones, alarmaId),
ExcepcionAlarma(alarmaId: alarmaId, ejecucion: ejecucion, tipo: tipo),
];
final nuevo = ConfiguracionAlarmas(
alarmas: config.alarmas,
vacaciones: config.vacaciones,
excepciones: excepciones,
);
await _guardar(nuevo);
return nuevo;
});
/// Clears the outstanding failure record for [alarmaId] ONLY when its
/// current tipo is [tipo] (a subsequent attempt of THAT SPECIFIC kind
/// succeeded). Type-scoped on purpose: a successful main-alarm schedule
/// call proves nothing about the pre-notice or foreground-service
/// subsystems, so it must never clear a failure recorded for those. No-op
/// when there is nothing to clear or the recorded tipo does not match.
Future<ConfiguracionAlarmas> limpiarFalloProgramacion(
String alarmaId,
String tipo,
) => _enCola(() async {
final config = await _configActual();
final actual = config.excepciones.where((e) => e.alarmaId == alarmaId);
final tieneEseTipo = actual.any((e) => e.tipo == tipo);
if (!tieneEseTipo) return config;
final excepciones =
config.excepciones
.where((e) => !(e.alarmaId == alarmaId && e.tipo == tipo))
.toList();
final nuevo = ConfiguracionAlarmas(
alarmas: config.alarmas,
vacaciones: config.vacaciones,
excepciones: excepciones,
);
await _guardar(nuevo);
return nuevo;
});
List<ExcepcionAlarma> _sinFalloPrevio(
List<ExcepcionAlarma> excepciones,
String alarmaId,
) =>
excepciones
.where(
(e) =>
!(e.alarmaId == alarmaId &&
ExcepcionAlarma.tiposFallo.contains(e.tipo)),
)
.toList();
Future<ConfiguracionAlarmas> posponerEjecucion(
String alarmaId,
DateTime ejecucion,
+103
View File
@@ -161,6 +161,35 @@ class EjecucionAlarmaNativa {
}
}
/// A scheduling-reliability failure the NATIVE side recorded on its own
/// (fix/alarmas-fallos-silenciosos, item 2): the pre-notice reminder, the
/// ringing foreground service, and a post-boot/unlock reschedule can each
/// fail without ever going through a Dart method-channel call that could
/// throw -- the native scheduler persists these instead (mirroring how
/// handled occurrences and snooze state already survive a killed engine),
/// and this is the cold-start sync so the Dart side finds out at all.
class FalloProgramacionNativo {
const FalloProgramacionNativo({
required this.alarmaId,
required this.tipo,
required this.ocurridoEn,
});
final String alarmaId;
final String tipo;
final DateTime ocurridoEn;
factory FalloProgramacionNativo.fromMap(Map<Object?, Object?> map) {
return FalloProgramacionNativo(
alarmaId: map['alarmId'] as String? ?? '',
tipo: map['type'] as String? ?? '',
ocurridoEn: DateTime.fromMillisecondsSinceEpoch(
(map['atMillis'] as num?)?.toInt() ?? 0,
),
);
}
}
abstract class PuertoAlarmasAndroid {
Stream<EventoAlarmaAndroid> get eventosAlarma;
@@ -170,6 +199,24 @@ abstract class PuertoAlarmasAndroid {
Future<void> programar(AlarmaMusical alarma);
Future<void> cancelar(String alarmaId);
/// Failures the NATIVE side recorded on its own, outside any Dart call:
/// a pre-notice that could not be armed, a refused foreground-service
/// start when the alarm should have rung, and a per-alarm reschedule that
/// failed after a reboot. Each entry carries the alarm id and one of
/// [ExcepcionAlarma]'s `tipoFallo*` constants.
///
/// Before this existed every one of those paths logged to logcat and
/// stopped there, so an alarm could sit switched on in the list having
/// never reached the OS at all — the user's "as if there were no alarm".
///
/// Returns the typed model rather than raw maps ON PURPOSE:
/// [FalloProgramacionNativo.fromMap] is the single place the native key
/// names (`alarmId`/`type`/`atMillis`) appear. Consuming raw maps here
/// once silently dropped every entry, because the caller guessed Spanish
/// key names and the fake was seeded with the same guess — the test
/// confirmed the mistake instead of catching it.
Future<List<FalloProgramacionNativo>> fallosNativosProgramacion();
Future<void> ocultarNotificacionAlarma(String alarmaId);
/// Notification-only dismissal (RES-1): hides the fire notification for
@@ -190,10 +237,24 @@ abstract class PuertoAlarmasAndroid {
Future<bool> solicitarPermisoPantallaCompleta();
Future<bool> solicitarExencionBateria();
/// Opens the system's per-app notification settings screen directly
/// (`ACTION_APP_NOTIFICATION_SETTINGS`), as opposed to
/// [solicitarPermisoNotificaciones]'s runtime permission popup. Used from
/// the reliability diagnostics screen: once a user is troubleshooting an
/// alarm that already failed, sending them straight to Settings is more
/// robust than a runtime dialog the OS may refuse to show again after a
/// prior denial.
Future<bool> abrirConfiguracionNotificaciones();
Future<DiagnosticoAlarmasAndroid> diagnostico();
Future<EventoAlarmaAndroid?> obtenerEventoInicial();
Future<List<EjecucionAlarmaNativa>> obtenerEjecucionesNativasGestionadas();
Future<List<EstadoSnoozeNativo>> obtenerEstadoSnoozeNativo();
/// Scheduling-reliability failures the native side recorded on its own
/// (pre-notice, foreground-service start, or post-boot reschedule) since
/// the last sync.
Future<List<FalloProgramacionNativo>> obtenerFallosProgramacionNativos();
}
class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
@@ -384,6 +445,26 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
}
}
@override
Future<List<FalloProgramacionNativo>> fallosNativosProgramacion() async {
try {
final raw = await _channel.invokeMethod<List<Object?>>(
'getNativeSchedulingFailures',
);
if (raw == null) return const [];
return raw
.whereType<Map<Object?, Object?>>()
.map(FalloProgramacionNativo.fromMap)
.where((f) => f.alarmaId.isNotEmpty && f.tipo.isNotEmpty)
.toList();
} catch (e) {
// Never let a diagnostics read break alarm handling: an older build
// of the native side simply has no such channel method.
debugPrint('[PluriWave][alarmas] fallosNativosProgramacion ERROR $e');
return const [];
}
}
@override
Future<bool> solicitarPermisoAlarmasExactas() async {
final abierto = await _channel.invokeMethod<bool>(
@@ -416,6 +497,14 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
return abierto ?? false;
}
@override
Future<bool> abrirConfiguracionNotificaciones() async {
final abierto = await _channel.invokeMethod<bool>(
'openNotificationSettings',
);
return abierto ?? false;
}
@override
Future<DiagnosticoAlarmasAndroid> diagnostico() async {
debugPrint('[PluriWave][alarmas] diagnostico android');
@@ -477,6 +566,20 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
.toList();
}
@override
Future<List<FalloProgramacionNativo>>
obtenerFallosProgramacionNativos() async {
final raw = await _channel.invokeMethod<List<Object?>>(
'getNativeSchedulingFailures',
);
if (raw == null || raw.isEmpty) return const [];
return raw
.whereType<Map<Object?, Object?>>()
.map(FalloProgramacionNativo.fromMap)
.where((fallo) => fallo.alarmaId.isNotEmpty && fallo.tipo.isNotEmpty)
.toList();
}
Future<void> _logAndInvokeVoid(String method, Map<String, Object?> args) {
debugPrint('[PluriWave][alarmas] $method $args');
return _channel.invokeMethod<void>(method, args);
+366 -56
View File
@@ -60,6 +60,221 @@ void registrarFuenteMusicaLocal(FuenteMusicaLocalAuto fuente) {
_fuenteMusicaLocalGlobal = fuente;
}
/// Builds the phone-initiated "play a station" `MediaItem` (item 3, Android
/// Auto fallback artwork): reuses [artUriPara] (`navegacion_auto.dart`) so a
/// station with no usable favicon gets the SAME on-brand rotating fallback
/// the browse tree and the car-tap path already show, instead of a blank
/// tile on the car/lockscreen/notification. Pure — no [PluriWaveAudioHandler]
/// dependency — so it is unit-testable without instantiating the handler.
MediaItem mediaItemParaEmisora(
Emisora emisora, {
required AppLocalizations l10n,
}) {
return MediaItem(
id: emisora.url,
title: localizedStationName(l10n, emisora.nombre),
artist: emisora.pais ?? '',
album: 'PluriWave',
artUri: Uri.parse(artUriPara(emisora)),
extras: {'uuid': emisora.uuid},
);
}
/// Reconstructs the phone-side [Emisora] from the handler's current
/// [MediaItem] (item 3): gates `favicon` through [faviconUsable]
/// (`navegacion_auto.dart`) so a car/car-tap "now playing" item's on-brand
/// FALLBACK `artUri` (an `android.resource://` drawable, never a real
/// favicon) is never misread as a genuine station favicon — the phone UI's
/// `CachedNetworkImage` widgets gate only on `favicon != null && isNotEmpty`
/// (not on `faviconUsable`'s scheme check), so without this guard they would
/// attempt a doomed network fetch of the fallback's non-http URI before
/// falling back to [PluriStationArtFallback] themselves. A genuine http(s)
/// favicon still round-trips exactly as before. Pure — no handler
/// dependency — unit-testable directly.
Emisora emisoraDesdeMediaItem(MediaItem mediaItem) {
final uuid = mediaItem.extras?['uuid'] as String? ?? mediaItem.id;
final artUriTexto = mediaItem.artUri?.toString();
return Emisora(
uuid: uuid,
nombre: mediaItem.title,
url: mediaItem.id,
pais: (mediaItem.artist?.isNotEmpty ?? false) ? mediaItem.artist : null,
favicon: faviconUsable(artUriTexto) ? artUriTexto : null,
);
}
/// Custom-action names for the equalizer's `PlaybackStateCompat` custom
/// actions on the now-playing screen (Design "EQ custom actions", item 4).
/// Public consts so tests and this file's own `customAction` dispatch share
/// the exact same literals; distinct from every browse-tree media-id prefix
/// in `navegacion_auto.dart` (they live in a completely different
/// `MediaControl`/`customAction` namespace, never compared against a
/// media id).
const accionEqToggle = 'eq_toggle';
/// Advances to the NEXT factory preset after [actual] in [presets] order
/// (Design "EQ custom actions — cycling presets", item 4): wraps around
/// after the last one. When [actual] is not found in [presets] (e.g. a
/// user-tweaked "Personalizado" preset from `EstadoEcualizador.cambiarBanda`),
/// starts from the FIRST preset rather than throwing — cycling from an
/// unknown state always lands somewhere sane. Pure, no I/O.
///
/// [presets] defaults to [PresetEcualizador.presets] — not a literal default
/// value, since that field is `static final` (not `const`) and Dart default
/// parameter values must be compile-time constants.
PresetEcualizador presetSiguiente(
PresetEcualizador actual, {
List<PresetEcualizador>? presets,
}) {
final lista = presets ?? PresetEcualizador.presets;
final indice = lista.indexWhere((p) => p == actual);
if (indice == -1) return lista.first;
return lista[(indice + 1) % lista.length];
}
/// Localizes a preset's raw `nombre` for the equalizer custom action's
/// label (Design "EQ custom actions", item 4) — mirrors
/// `ecualizador_widget.dart`'s private `_nombrePreset` mapping (duplicated
/// rather than shared: that file is UI-widget layer, this one is the
/// service/handler layer, and the mapping is a single small switch, not
/// worth a cross-layer import for). An unrecognized name (e.g. a future
/// user-named custom preset) falls through to the raw name verbatim.
String nombrePresetVisible(AppLocalizations l10n, String nombre) {
return switch (nombre) {
'Flat' => l10n.equalizerPresetFlat,
'Rock' => l10n.equalizerPresetRock,
'Pop' => l10n.equalizerPresetPop,
'Bass Boost' => l10n.equalizerPresetBassBoost,
'Jazz' => l10n.equalizerPresetJazz,
'Voz' => l10n.equalizerPresetVoice,
'Personalizado' => l10n.equalizerPresetCustom,
_ => nombre,
};
}
/// Builds the equalizer's custom-action `MediaControl`s for the now-playing
/// screen (decision `auto/ecualizador-diseno`) — exactly 1: an on/off
/// toggle. The previous design paired this with a SECOND action that cycled
/// through the six factory presets; that action is REMOVED. On-device
/// feedback: many head units render custom actions icon-first, so two
/// static, non-parametrized glyphs sitting side by side looked identical/
/// dead even though the toggle's own icon DID change and the cycle action
/// DID work — a monochrome icon simply cannot legibly encode "which of six
/// presets" the way a browsable list's text rows can. Preset selection now
/// lives in the "Ecualizador" browsable folder instead (see
/// [itemsEcualizadorAuto]), which also frees this scarce custom-action
/// slot. Do NOT re-add a preset-cycling custom action; extend the folder
/// instead.
/// Empty when [disponible] is false (gate on EQ availability, mirrors the
/// existing `debeReaplicarEcualizador`/`_eqDisponible` gate) — a device
/// without the native Equalizer effect gets no EQ actions at all, not
/// broken ones.
///
/// On-device feedback follow-up: this action used to reuse the SAME
/// `ic_stat_pluriwave` drawable as everything else and was visually
/// indistinguishable on a car head unit, which foregrounds the icon over
/// the label. It now gets its own dedicated drawables
/// (`ic_auto_eq_on`/`ic_auto_eq_off`), and the icon itself reflects
/// [activo] (not just its label) so on/off is legible at a glance. Pure, no
/// handler dependency.
List<MediaControl> controlesEcualizadorPersonalizados({
required bool disponible,
required bool activo,
required AppLocalizations l10n,
}) {
if (!disponible) return const [];
return [
MediaControl.custom(
androidIcon:
activo ? 'drawable/ic_auto_eq_on' : 'drawable/ic_auto_eq_off',
label:
activo
? l10n.eqCustomActionDisableLabel
: l10n.eqCustomActionEnableLabel,
name: accionEqToggle,
),
];
}
/// Content-style extras for the Ecualizador folder's items (decision
/// `auto/ecualizador-diseno`), mirrors `ConstructorArbolAuto
/// ._contentStyleLista` in `navegacion_auto.dart` — duplicated rather than
/// exposed publicly (see [nombrePresetVisible]'s doc for why small pieces
/// are deliberately duplicated across this handler/service layer and the
/// pure browse-tree builder layer rather than cross-layer-shared). List
/// style, not grid: these items are short text options with no artwork of
/// their own, unlike a station or local-track tile.
const _contentStyleListaEq = {
'android.media.browse.CONTENT_STYLE_BROWSABLE_HINT': 1,
'android.media.browse.CONTENT_STYLE_PLAYABLE_HINT': 1,
};
/// Marks the active Ecualizador-folder item by prefixing [titulo] with a
/// checkmark glyph (decision `auto/ecualizador-diseno`, spec "the active
/// preset must be visibly marked").
///
/// A `MediaItem.extras` completion-status flag (`androidx.media.utils.
/// MediaConstants.DESCRIPTION_EXTRAS_KEY_COMPLETION_STATUS`) was considered
/// and REJECTED as the marking mechanism: this project's `audio_service`
/// version (0.18.18) has no Dart wrapper for it — only `AndroidContentStyle`
/// 's list/grid hints are exposed — and the raw platform key itself is
/// designed for playback-COMPLETION tracking (e.g. "this podcast episode
/// was already listened to"), not item SELECTION; repurposing it here could
/// render as "already played" on some head units, which would be actively
/// misleading for a preset picker, and there is no way to verify its actual
/// rendering on a real head unit from this environment. A plain-text
/// marker renders identically and unambiguously on every head unit, which
/// an unverifiable, semantically-mismatched extras key cannot guarantee.
String _marcarActivoEq(String titulo, {required bool activo}) =>
activo ? '$titulo' : titulo;
/// Builds the "Ecualizador" folder's children for the Android Auto browse
/// tree (decision `auto/ecualizador-diseno`): "Desactivar" FIRST, then the
/// six factory presets in [PresetEcualizador.presets] order, each localized
/// via [nombrePresetVisible] — the SAME helper the toggle's custom-action
/// label already uses, so a preset's name reads identically whether the
/// driver sees it in the now-playing screen's tooltip or in this folder.
/// All items are playable: tapping one is dispatched through
/// `playFromMediaId` -> `seleccionarPresetEqPorMediaId` (`navegacion_auto.
/// dart`), the same seam every other browse-tree leaf already uses; this
/// folder has no sub-browsing. Exactly one item is marked active via
/// [_marcarActivoEq]: "Desactivar" when [activo] is `false`, otherwise
/// whichever preset equals [presetActual] — never both at once, and never
/// zero once this function is reached (an unresolvable [presetActual] with
/// [activo] `true` simply marks nothing, which cannot happen in practice
/// since [presetActual] always originates from [PresetEcualizador.presets]
/// or a "Personalizado" tweak that would just leave every item unmarked
/// rather than mis-marking one).
List<MediaItem> itemsEcualizadorAuto({
required bool activo,
required PresetEcualizador presetActual,
required AppLocalizations l10n,
}) {
final constructor = ConstructorArbolAuto();
final items = <MediaItem>[
MediaItem(
id: ConstructorArbolAuto.idDesactivarEq,
title: _marcarActivoEq(l10n.autoEqDisableOption, activo: !activo),
playable: true,
extras: _contentStyleListaEq,
),
];
for (final preset in PresetEcualizador.presets) {
items.add(
MediaItem(
id: constructor.idPresetEq(preset.nombre),
title: _marcarActivoEq(
nombrePresetVisible(l10n, preset.nombre),
activo: activo && preset == presetActual,
),
playable: true,
extras: _contentStyleListaEq,
),
);
}
return items;
}
/// Wrapper de alto nivel para el UI.
class ServicioAudio {
PluriWaveAudioHandler get _handler {
@@ -94,19 +309,9 @@ class ServicioAudio {
});
Future<void> reproducir(Emisora emisora) async {
final item = MediaItem(
id: emisora.url,
title: localizedStationName(
lookupAppLocalizations(const Locale('es')),
emisora.nombre,
),
artist: emisora.pais ?? '',
album: 'PluriWave',
artUri:
emisora.favicon != null && emisora.favicon!.isNotEmpty
? Uri.tryParse(emisora.favicon!)
: null,
extras: {'uuid': emisora.uuid},
final item = mediaItemParaEmisora(
emisora,
l10n: lookupAppLocalizations(const Locale('es')),
);
await _handler.playMediaItem(item);
}
@@ -289,12 +494,10 @@ class PluriWaveAudioHandler extends BaseAudioHandler
final colaActiva = _colaLocal != null;
playbackState.add(
playbackState.value.copyWith(
controls: [
if (colaActiva) MediaControl.skipToPrevious,
if (playing) MediaControl.pause else MediaControl.play,
MediaControl.stop,
if (colaActiva) MediaControl.skipToNext,
],
controls: _controlesTransporte(
colaActiva: colaActiva,
playing: playing,
),
systemActions: {
MediaAction.seek,
MediaAction.stop,
@@ -339,6 +542,48 @@ class PluriWaveAudioHandler extends BaseAudioHandler
});
}
/// The full transport `controls` list for a `playbackState` push (item 4):
/// the existing skip/play-pause/stop set, plus the equalizer's custom
/// actions appended at the end. Appending (rather than interleaving) keeps
/// [MediaControl.skipToPrevious]/play-pause/stop/[MediaControl.skipToNext]
/// at their existing indices 0-3, so `androidCompactActionIndices`
/// (`[colaActiva ? 1 : 0]`) stays correct unchanged.
List<MediaControl> _controlesTransporte({
required bool colaActiva,
required bool playing,
}) => [
if (colaActiva) MediaControl.skipToPrevious,
if (playing) MediaControl.pause else MediaControl.play,
MediaControl.stop,
if (colaActiva) MediaControl.skipToNext,
..._controlesEqPersonalizados(),
];
List<MediaControl> _controlesEqPersonalizados() =>
controlesEcualizadorPersonalizados(
disponible: _eqDisponible,
activo: _ecualizadorActivo,
l10n: _textos,
);
/// Re-pushes `playbackState` with a freshly built controls list (item 4):
/// called whenever EQ availability/enabled/preset state changes outside a
/// player-state transition (a custom-action tap, or a phone-side preset/
/// toggle change), so the equalizer custom actions' label and current-
/// preset name stay in sync on the now-playing screen without waiting for
/// an unrelated player event. Idempotent and cheap (no native calls) —
/// safe to call from any EQ state-changing path.
void _actualizarControlesEq() {
playbackState.add(
playbackState.value.copyWith(
controls: _controlesTransporte(
colaActiva: _colaLocal != null,
playing: playbackState.value.playing,
),
),
);
}
/// Gestiona cualquier error de reproducción de ExoPlayer.
///
/// Network-class failures while the user still intends to play enter the
@@ -724,6 +969,11 @@ class PluriWaveAudioHandler extends BaseAudioHandler
} catch (_) {
_eqDisponible = false;
}
// Item 4: an availability flip (e.g. a station switch that lands on a
// device without the native Equalizer effect) must show/hide the EQ
// custom actions immediately, not wait for a coincidental later
// player-state event.
_actualizarControlesEq();
}
/// Pure re-apply decision for a native session-id emission. No side effects.
@@ -742,25 +992,31 @@ class PluriWaveAudioHandler extends BaseAudioHandler
/// Aplica un preset al ecualizador nativo Android.
Future<void> aplicarPreset(PresetEcualizador preset) async {
_presetActual = preset;
if (!_eqDisponible) return;
try {
await _eq.setEnabled(_ecualizadorActivo);
if (!_ecualizadorActivo) return;
final params = await _eq.parameters;
for (
int i = 0;
i < params.bands.length && i < preset.bandas.length;
i++
) {
await params.bands[i].setGain(
_mapearGananciaNativa(
preset.bandas[i],
minDecibels: params.minDecibels,
maxDecibels: params.maxDecibels,
),
);
}
} catch (_) {}
if (_eqDisponible) {
try {
await _eq.setEnabled(_ecualizadorActivo);
if (_ecualizadorActivo) {
final params = await _eq.parameters;
for (
int i = 0;
i < params.bands.length && i < preset.bandas.length;
i++
) {
await params.bands[i].setGain(
_mapearGananciaNativa(
preset.bandas[i],
minDecibels: params.minDecibels,
maxDecibels: params.maxDecibels,
),
);
}
}
} catch (_) {}
}
// Item 4: keeps the EQ custom action's preset-cycle label in sync
// regardless of WHO changed the preset (a car customAction tap or the
// phone settings screen via EstadoEcualizador) — single chokepoint.
_actualizarControlesEq();
}
/// Ajusta una banda individual.
@@ -796,13 +1052,18 @@ class PluriWaveAudioHandler extends BaseAudioHandler
Future<void> setEcualizadorActivo(bool activo) async {
_ecualizadorActivo = activo;
if (!_eqDisponible) return;
try {
await _eq.setEnabled(activo);
if (activo) {
await aplicarPreset(_presetActual);
}
} catch (_) {}
if (_eqDisponible) {
try {
await _eq.setEnabled(activo);
if (activo) {
await aplicarPreset(_presetActual);
}
} catch (_) {}
}
// Item 4: keeps the EQ custom action's on/off label in sync regardless
// of WHO toggled it (a car customAction tap or the phone settings
// screen via EstadoEcualizador).
_actualizarControlesEq();
}
Future<void> setVolumen(double vol) async {
@@ -834,6 +1095,16 @@ class PluriWaveAudioHandler extends BaseAudioHandler
await _player.setVolume(_volumenEfectivo);
}
/// Fix "EQ Re-Apply After Audio-Focus Interruption": thin delegate to the
/// existing [_activarEcualizador] (already does the correct idempotent
/// `setEnabled` + re-push-gains work, already re-asserts the CURRENT
/// [_ecualizadorActivo] rather than forcing it on). Called by
/// [ServicioAudioSession] on resume-from-pause and on un-duck — see that
/// interface member's doc for why the existing session-id-change trigger
/// misses this case.
@override
Future<void> reaplicarEcualizador() => _activarEcualizador();
@override
Future<void> play() {
_intencionReproducir = true;
@@ -899,6 +1170,28 @@ class PluriWaveAudioHandler extends BaseAudioHandler
await _reproducirEntradaCola(anterior.actual);
}
/// Dispatches the equalizer's only custom action (decision
/// `auto/ecualizador-diseno`): `accionEqToggle` flips on/off, delegating
/// to the existing [setEcualizadorActivo] — the SAME entry point the
/// phone settings screen uses via `EstadoEcualizador` — so a car tap and a
/// phone tap have identical effects and both refresh the action's label
/// via `_actualizarControlesEq()` (already wired into that method). The
/// preset-cycling action that used to live here is REMOVED — preset
/// selection now goes through the "Ecualizador" browsable folder (see
/// `seleccionarPresetEqPorMediaId` in `navegacion_auto.dart`, dispatched
/// from [playFromMediaId] below). Any other [name] is a no-op — never
/// throws.
@override
Future<dynamic> customAction(
String name, [
Map<String, dynamic>? extras,
]) async {
switch (name) {
case accionEqToggle:
await setEcualizadorActivo(!_ecualizadorActivo);
}
}
@override
Future<void> onTaskRemoved() async {
await stop();
@@ -911,14 +1204,10 @@ class PluriWaveAudioHandler extends BaseAudioHandler
}
Emisora _emisoraDesdeMediaItem(MediaItem mediaItem) {
final uuid = mediaItem.extras?['uuid'] as String? ?? mediaItem.id;
return Emisora(
uuid: uuid,
nombre: mediaItem.title,
url: mediaItem.id,
pais: (mediaItem.artist?.isNotEmpty ?? false) ? mediaItem.artist : null,
favicon: mediaItem.artUri?.toString(),
);
// Item 3: delegates to the top-level, unit-testable function so the
// `faviconUsable` guard (never reflect the on-brand fallback artUri
// back as a real favicon) is covered without instantiating the handler.
return emisoraDesdeMediaItem(mediaItem);
}
// ── Android Auto browsing (thin delegation to navegacion_auto.dart's
@@ -946,6 +1235,17 @@ class PluriWaveAudioHandler extends BaseAudioHandler
fuente: fuenteLocal,
);
if (musicaLocal != null) return musicaLocal;
// Ecualizador folder (decision `auto/ecualizador-diseno`): needs no
// external data source, unlike every branch below it -- checked
// before the `_fuenteNavegacionGlobal` gate, mirroring how the
// local-music branch above is also resolved before that gate.
if (parentMediaId == ConstructorArbolAuto.idEcualizador) {
return itemsEcualizadorAuto(
activo: _ecualizadorActivo,
presetActual: _presetActual,
l10n: _textos,
);
}
final fuente = _fuenteNavegacionGlobal;
if (fuente == null) return const [];
if (parentMediaId == ConstructorArbolAuto.idFavoritos) {
@@ -1016,14 +1316,24 @@ class PluriWaveAudioHandler extends BaseAudioHandler
if (fuenteLocal == null) return;
await reproducirCarpetaLocal(
mediaId,
aleatorio: constructorArbol.esCarpetaLocalAleatorioMediaId(
mediaId,
),
aleatorio: constructorArbol.esCarpetaLocalAleatorioMediaId(mediaId),
fuente: fuenteLocal,
iniciarCola: _iniciarColaLocal,
);
return;
}
// Equalizer preset selection (decision `auto/ecualizador-diseno`):
// THIRD branch, same unconditional-return shape as the two above --
// an `eq_preset:` id never falls through to station routing.
if (constructorArbol.esPresetEqMediaId(mediaId)) {
await seleccionarPresetEqPorMediaId(
mediaId,
activo: _ecualizadorActivo,
aplicarPreset: aplicarPreset,
activarEcualizador: setEcualizadorActivo,
);
return;
}
final fuente = _fuenteNavegacionGlobal;
if (fuente == null) return;
await reproducirPorMediaId(
+21
View File
@@ -20,6 +20,20 @@ abstract class ObjetivoAudioInterrumpible {
/// Temporarily lowers ("ducks") the output volume without pausing.
Future<void> setAtenuado(bool atenuado);
/// Re-attaches the equalizer effect and re-pushes the current preset's
/// gains (fix "EQ Re-Apply After Audio-Focus Interruption"). Called after
/// resuming from a transient interruption pause and after un-ducking,
/// because Android's AudioEffect framework can let a higher-priority
/// client silently disable this app's effect instance while the
/// underlying player session id never changes — the existing session-id
/// rotation trigger (`ServicioAudio.debeReaplicarEcualizador`) therefore
/// never fires for a SHORT interruption (e.g. a nav-app voice prompt).
/// Idempotent and cheap (a `setEnabled` plus band `setGain` calls); takes
/// no argument by design — it re-asserts whatever enabled/disabled state
/// the handler ALREADY holds, so a caller here can never force the
/// equalizer on. Never restarts or repositions playback.
Future<void> reaplicarEcualizador();
}
/// Wrapper around `package:audio_session` (S3-R1): configures the session
@@ -84,11 +98,18 @@ class ServicioAudioSession {
switch (evento.type) {
case AudioInterruptionType.duck:
await _objetivo.setAtenuado(false);
// Un-ducking never rotates the native player session id, so the
// session-id-change trigger never fires for this case — re-assert
// here too (belt-and-braces, additive to that trigger).
await _objetivo.reaplicarEcualizador();
case AudioInterruptionType.pause:
// Transient loss ended and the OS says we may resume.
if (_pausadoPorInterrupcion) {
_pausadoPorInterrupcion = false;
await _objetivo.reanudar();
// Same rationale as the duck branch above: a short transient
// interruption keeps the SAME player session id.
await _objetivo.reaplicarEcualizador();
}
case AudioInterruptionType.unknown:
// Permanent focus loss: never auto-resume.
@@ -150,9 +150,15 @@ class ServicioProgramacionAlarmas {
if (!alarma.sonarEnVacaciones && estaEnVacaciones(candidato, vacaciones)) {
return false;
}
// Only a deliberate user skip ever removes a candidate occurrence.
// Reliability-failure records share this same list/model (so the alarms
// list can surface them per-alarm via `ultimaExcepcionPara`), but they
// must never be mistaken for a skip — that would silently jump the
// alarm to its NEXT occurrence instead of just flagging the failed one.
return !excepciones.any(
(excepcion) =>
excepcion.alarmaId == alarma.id &&
excepcion.tipo == ExcepcionAlarma.tipoSaltoSiguiente &&
_mismaEjecucion(excepcion.ejecucion, candidato),
);
}
+1 -1
View File
@@ -1,7 +1,7 @@
name: pluriwave
description: "Radio mundial con ecualizador, reconocimiento de canciones y UI premium"
publish_to: 'none'
version: 1.2.1+123
version: 1.2.6+128
environment:
sdk: ^3.7.0
@@ -0,0 +1,112 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/servicios/servicio_alarmas_android.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes_alarmas.dart';
/// The native scheduler records three failures entirely on its own, outside
/// any Dart call: a pre-notice that could not be armed, a refused
/// foreground-service start when the alarm should have rung, and a per-alarm
/// reschedule that failed after a reboot.
///
/// Before this wiring existed, all three logged to logcat and stopped there.
/// The alarm stayed switched on in the list, drawn exactly as if scheduling
/// had succeeded, and simply never fired — the reported "as if there were no
/// alarm at all". These tests pin the drain path that makes them visible.
///
/// They deliberately build fixtures through [FalloProgramacionNativo.fromMap]
/// using the REAL native key names (`alarmId`/`type`/`atMillis`, see
/// `AlarmScheduler.kt:1389-1391`). An earlier draft seeded the fake with
/// guessed Spanish keys and consumed the same guess, so every entry would
/// have been silently dropped in production while the tests stayed green.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
late FakePuertoAlarmasAndroid android;
setUp(() {
SharedPreferences.setMockInitialValues({});
android = FakePuertoAlarmasAndroid();
});
EstadoAlarmas crearEstado() =>
EstadoAlarmas(android: android, iniciarAutomaticamente: false);
/// Mirrors exactly what the native side puts on the channel.
FalloProgramacionNativo falloNativo(String alarmaId, String tipo) =>
FalloProgramacionNativo.fromMap({
'alarmId': alarmaId,
'type': tipo,
'atMillis': 1700000000000,
});
test('the fixture helper decodes the real native key names', () {
final fallo = falloNativo('a0', ExcepcionAlarma.tipoFalloPreaviso);
expect(fallo.alarmaId, 'a0');
expect(fallo.tipo, ExcepcionAlarma.tipoFalloPreaviso);
});
test('a native pre-notice failure becomes a per-alarm exception', () async {
android.fallosNativos = [
falloNativo('a1', ExcepcionAlarma.tipoFalloPreaviso),
];
final estado = crearEstado();
addTearDown(estado.dispose);
await estado.cargarFallosNativos();
final fallo = estado.ultimaExcepcionPara('a1');
expect(fallo, isNotNull);
expect(fallo!.tipo, ExcepcionAlarma.tipoFalloPreaviso);
});
test(
'a refused foreground-service start becomes a per-alarm exception',
() async {
android.fallosNativos = [
falloNativo('a2', ExcepcionAlarma.tipoFalloServicioSonido),
];
final estado = crearEstado();
addTearDown(estado.dispose);
await estado.cargarFallosNativos();
expect(
estado.ultimaExcepcionPara('a2')?.tipo,
ExcepcionAlarma.tipoFalloServicioSonido,
);
},
);
test('only the alarm whose reschedule failed is marked', () async {
android.fallosNativos = [
falloNativo('a3', ExcepcionAlarma.tipoFalloReprogramacionArranque),
];
final estado = crearEstado();
addTearDown(estado.dispose);
await estado.cargarFallosNativos();
expect(estado.ultimaExcepcionPara('a3'), isNotNull);
expect(
estado.ultimaExcepcionPara('a4'),
isNull,
reason: 'a sibling alarm that scheduled fine must stay unmarked',
);
});
test('a failing native read never surfaces as an alarm error', () async {
// A diagnostics gap must not look like a scheduling problem: an older
// native build simply has no such channel method.
android.fallaLecturaFallosNativos = true;
final estado = crearEstado();
addTearDown(estado.dispose);
await estado.cargarFallosNativos();
expect(estado.error, isNull);
});
}
+139
View File
@@ -611,6 +611,145 @@ void main() {
},
);
test(
'guardarAlarma: cuando android.programar falla, marca la alarma con una '
'excepcion de fallo visible via ultimaExcepcionPara',
() async {
final android = FakePuertoAlarmasAndroid()..fallaProgramar = true;
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.guardarAlarma(
const AlarmaMusical(
id: 'fallo1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
final excepcion = estado.ultimaExcepcionPara('fallo1');
expect(excepcion, isNotNull);
expect(excepcion!.tipo, ExcepcionAlarma.tipoFalloProgramacion);
expect(estado.error, isNotNull);
},
);
test(
'guardarAlarma: un reintento exitoso limpia la excepcion de fallo previa',
() async {
final android = FakePuertoAlarmasAndroid()..fallaProgramar = true;
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
final alarma = const AlarmaMusical(
id: 'fallo2',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
);
await estado.guardarAlarma(alarma);
expect(estado.ultimaExcepcionPara('fallo2'), isNotNull);
android.fallaProgramar = false;
await estado.guardarAlarma(estado.alarmas.single);
expect(estado.ultimaExcepcionPara('fallo2'), isNull);
},
);
test(
'guardarAlarma en el camino feliz nunca registra una excepcion de fallo',
() async {
final android = FakePuertoAlarmasAndroid();
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.guardarAlarma(
const AlarmaMusical(
id: 'ok1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
expect(estado.ultimaExcepcionPara('ok1'), isNull);
expect(estado.error, isNull);
},
);
test(
'inicializar: un fallo de programacion en UNA alarma no aborta la '
'sincronizacion de las demas (S-sincronizarTodas continua tras error)',
() async {
final android = FakePuertoAlarmasAndroid()
..idsFallanProgramar.add('rota');
final servicio = ServicioAlarmas(
reloj: () => DateTime(2026, 5, 25, 6, 0),
);
await servicio.guardarAlarma(
AlarmaMusical(
id: 'rota',
nombre: 'Rota',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
),
);
await servicio.guardarAlarma(
AlarmaMusical(
id: 'sana',
nombre: 'Sana',
hora: 8,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
),
);
final estado = EstadoAlarmas(
servicio: servicio,
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.inicializar();
expect(
android.programadas.map((a) => a.id),
contains('sana'),
reason:
'la alarma sana debe seguir programandose aunque la rota falle',
);
expect(estado.ultimaExcepcionPara('rota'), isNotNull);
expect(estado.ultimaExcepcionPara('sana'), isNull);
},
);
group('EstadoAlarmas — consultas de vacaciones (ADR-6, WU9)', () {
test('rangoVacacionesActivo devuelve el rango cuyo intervalo incluye '
'"ahora" (dias restantes derivables de finDia), o null si ninguno '
@@ -0,0 +1,139 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:pluriwave/servicios/servicio_alarmas_android.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes_alarmas.dart';
/// fix/alarmas-fallos-silenciosos, item 3: "verify the alarm is actually
/// registered, and say so if it is not". `android.programar` returning
/// without throwing is not proof enough by itself -- this is the check that
/// would have caught the reported case immediately (the native side can
/// silently fail to persist the registration even when the channel call
/// itself reports success).
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('guardarAlarma detecta que el conteo nativo no refleja la alarma '
'guardada, aunque android.programar no haya lanzado', () async {
// Fixed at 0 regardless of what programar() does internally --
// simulates the native side accepting the channel call but never
// actually persisting the registration.
final android = FakePuertoAlarmasAndroid()..alarmasNativasPendientes = 0;
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.guardarAlarma(
const AlarmaMusical(
id: 'silenciosa1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
final excepcion = estado.ultimaExcepcionPara('silenciosa1');
expect(excepcion, isNotNull);
expect(excepcion!.tipo, ExcepcionAlarma.tipoFalloProgramacion);
expect(estado.error, isNotNull);
});
test('guardarAlarma en el camino feliz (conteo nativo coincide) no registra '
'fallo alguno', () async {
final android = FakePuertoAlarmasAndroid();
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.guardarAlarma(
const AlarmaMusical(
id: 'sana1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
expect(estado.ultimaExcepcionPara('sana1'), isNull);
expect(estado.error, isNull);
});
test(
'inicializar importa los fallos nativos (pre-aviso, servicio en primer '
'plano, reprogramacion tras arranque) como excepciones por alarma',
() async {
final android =
FakePuertoAlarmasAndroid()
..fallosProgramacionNativos.addAll([
FalloProgramacionNativo(
alarmaId: 'con-preaviso-roto',
tipo: ExcepcionAlarma.tipoFalloPreaviso,
ocurridoEn: DateTime(2026, 5, 25, 7),
),
FalloProgramacionNativo(
alarmaId: 'servicio-rechazado',
tipo: ExcepcionAlarma.tipoFalloServicioSonido,
ocurridoEn: DateTime(2026, 5, 25, 7),
),
]);
final servicio = ServicioAlarmas(
reloj: () => DateTime(2026, 5, 25, 7, 30),
);
await servicio.guardarAlarma(
AlarmaMusical(
id: 'con-preaviso-roto',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
),
);
await servicio.guardarAlarma(
AlarmaMusical(
id: 'servicio-rechazado',
nombre: 'Diaria',
hora: 8,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
),
);
final estado = EstadoAlarmas(
servicio: servicio,
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await estado.inicializar();
final falloPreaviso = estado.ultimaExcepcionPara('con-preaviso-roto');
expect(falloPreaviso, isNotNull);
expect(falloPreaviso!.tipo, ExcepcionAlarma.tipoFalloPreaviso);
final falloServicio = estado.ultimaExcepcionPara('servicio-rechazado');
expect(falloServicio, isNotNull);
expect(falloServicio!.tipo, ExcepcionAlarma.tipoFalloServicioSonido);
},
);
}
+97 -10
View File
@@ -14,15 +14,57 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
final soloOcultadas = <String>[];
final ejecucionesNativas = <EjecucionAlarmaNativa>[];
final snoozesNativos = <EstadoSnoozeNativo>[];
final fallosProgramacionNativos = <FalloProgramacionNativo>[];
final _eventos = StreamController<EventoAlarmaAndroid>.broadcast();
bool ignoraOptimizacionBateria = true;
int solicitudesExencionBateria = 0;
int aperturasConfiguracionNotificaciones = 0;
/// Extra diagnostico() fields (fix/alarmas-fiabilidad diagnostics screen).
/// Default values mirror the previous hardcoded literals in [diagnostico],
/// so every existing test that never sets these keeps seeing the exact
/// same snapshot as before.
bool puedeProgramarExactas = true;
bool notificacionesPermitidas = true;
bool puedeUsarPantallaCompleta = true;
String fabricante = 'test';
int versionSdk = 35;
/// Ids [programar] most recently scheduled as active-with-a-next-run (kept
/// in sync with [cancelar] too), mirroring the real native scheduler's own
/// pending-alarm registry (fix/alarmas-fallos-silenciosos, item 3: "verify
/// the alarm is actually registered"). Backs [alarmasNativasPendientes]'s
/// DEFAULT so a test that never touches that field gets a value that
/// tracks reality instead of a frozen `0` -- a test that explicitly
/// assigns the field (many `pantalla_diagnostico_alarmas_test.dart` cases
/// do, to model a stale/corrupt native count on purpose) keeps getting
/// EXACTLY that value regardless of what programar/cancelar do afterward.
final _idsRegistradosNativamente = <String>{};
int? _alarmasNativasPendientesFijado;
int get alarmasNativasPendientes =>
_alarmasNativasPendientesFijado ?? _idsRegistradosNativamente.length;
set alarmasNativasPendientes(int valor) =>
_alarmasNativasPendientesFijado = valor;
/// Test-only failure switch (diagnostics screen, "intent not resolving"
/// coverage): when true, every `abrir*`/`solicitar*` system-screen action
/// below reports failure (as a real device does when a ROM lacks that
/// settings screen), while still recording the attempt via its counter.
bool fallaAccionSistema = false;
/// Test-only failure switch (Design D7): when true, [programar] throws
/// instead of scheduling, enabling failure-path coverage that the fake
/// could not otherwise produce.
bool fallaProgramar = false;
/// Test-only PER-ALARM failure switch (fix/alarmas-fallos-silenciosos):
/// [programar] throws only for ids in this set, letting a test simulate
/// one alarm failing to schedule while its siblings succeed -- the global
/// [fallaProgramar] switch cannot express that (it fails everything).
final Set<String> idsFallanProgramar = {};
/// Test-only failure switch: when true, [detenerSonidoActivo] reports an
/// unconfirmed/failed stop instead of a confirmed one.
bool fallaDetener = false;
@@ -55,15 +97,21 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
@override
Future<void> programar(AlarmaMusical alarma) async {
if (fallaProgramar) {
if (fallaProgramar || idsFallanProgramar.contains(alarma.id)) {
throw StateError('fake programar failure');
}
programadas.add(alarma);
if (alarma.activa && alarma.proximaProgramable != null) {
_idsRegistradosNativamente.add(alarma.id);
} else {
_idsRegistradosNativamente.remove(alarma.id);
}
}
@override
Future<void> cancelar(String alarmaId) async {
canceladas.add(alarmaId);
_idsRegistradosNativamente.remove(alarmaId);
}
@override
@@ -107,19 +155,25 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
@override
Future<DiagnosticoAlarmasAndroid> diagnostico() async =>
DiagnosticoAlarmasAndroid(
puedeProgramarExactas: true,
notificacionesPermitidas: true,
puedeUsarPantallaCompleta: true,
puedeProgramarExactas: puedeProgramarExactas,
notificacionesPermitidas: notificacionesPermitidas,
puedeUsarPantallaCompleta: puedeUsarPantallaCompleta,
ignoraOptimizacionBateria: ignoraOptimizacionBateria,
alarmasNativasPendientes: 0,
fabricante: 'test',
versionSdk: 35,
alarmasNativasPendientes: alarmasNativasPendientes,
fabricante: fabricante,
versionSdk: versionSdk,
);
@override
Future<bool> solicitarExencionBateria() async {
solicitudesExencionBateria++;
return true;
return !fallaAccionSistema;
}
@override
Future<bool> abrirConfiguracionNotificaciones() async {
aperturasConfiguracionNotificaciones++;
return !fallaAccionSistema;
}
@override
@@ -134,13 +188,46 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
List.of(snoozesNativos);
@override
Future<bool> solicitarPermisoAlarmasExactas() async => true;
Future<List<FalloProgramacionNativo>>
obtenerFallosProgramacionNativos() async =>
List.of(fallosProgramacionNativos);
int solicitudesPermisoAlarmasExactas = 0;
int solicitudesPermisoPantallaCompleta = 0;
/// Native-recorded failures the next read should return. Tests seed this
/// to simulate a pre-notice that never armed, a refused foreground-service
/// start, or a per-alarm reschedule that failed after a reboot.
List<FalloProgramacionNativo> fallosNativos = const [];
int lecturasFallosNativos = 0;
/// Simulates an older native build with no such channel method.
bool fallaLecturaFallosNativos = false;
@override
Future<List<FalloProgramacionNativo>> fallosNativosProgramacion() async {
lecturasFallosNativos++;
if (fallaLecturaFallosNativos) {
throw StateError('canal no disponible');
}
return fallosNativos;
}
@override
Future<bool> solicitarPermisoAlarmasExactas() async {
solicitudesPermisoAlarmasExactas++;
return !fallaAccionSistema;
}
@override
Future<bool> solicitarPermisoNotificaciones() async => true;
@override
Future<bool> solicitarPermisoPantallaCompleta() async => true;
Future<bool> solicitarPermisoPantallaCompleta() async {
solicitudesPermisoPantallaCompleta++;
return !fallaAccionSistema;
}
Future<void> dispose() => _eventos.close();
}
+4
View File
@@ -272,4 +272,8 @@ const Set<(String locale, String key)> identicalValueAllowlist = {
'pt',
'searchResultsCount',
), // WU18 new key (task 18.3) -- "resultado(s)" is an es/pt cognate
(
'pt',
'alarmDiagnosticsManufacturerLabel',
), // fix/alarmas-fiabilidad new key -- "Fabricante" is identical in pt/es
};
+26
View File
@@ -306,6 +306,32 @@ void main() {
expect(find.text('RECORDINGS & MUSIC'), findsOneWidget);
expect(find.text('APPLICATION'), findsOneWidget);
});
testWidgets(
'Issue 3 (feedback-pruebas): the gap between stacked settings groups '
'is 16, matching t4:523/534/541 -- not 12',
(tester) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
final estado = await crearEstado();
addTearDown(estado.dispose);
await tester.pumpWidget(buildAjustes(estado));
await pumpStable(tester);
for (final key in [
'ajustes-group-gap-1',
'ajustes-group-gap-2',
'ajustes-group-gap-3',
]) {
expect(
tester.getSize(find.byKey(ValueKey(key))).height,
16,
reason: 't4:523/534/541 all draw a 16px gap between stacked groups',
);
}
},
);
});
}
@@ -99,8 +99,8 @@ void main() {
SharedPreferences.setMockInitialValues({});
});
testWidgets('visual fidelity (audit 9.4): the date line renders between the '
'schedule pill and the hero time (t4:419)', (tester) async {
testWidgets('visual fidelity (audit 9.4): the date line renders BELOW the '
'hero time (t4:415-419: pill, then 7:30, then the date)', (tester) async {
await _montarPantalla(tester);
final localeTag =
@@ -111,16 +111,22 @@ void main() {
expect(find.text(esperado), findsOneWidget);
// Order: pill above the date line, date line above the hero time.
// Order: pill, then the hero time, then the date line. This test used
// to assert date-before-time and cited "t4:419" for it — but 419 is
// simply the source line the date occupies, and in the prototype it
// comes AFTER the 88px time on line 417. The citation refuted the
// assertion it was supporting.
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);
tester
.getBottomLeft(find.byKey(const ValueKey('ringing-hero-time')))
.dy;
final dateY = tester.getTopLeft(find.text(esperado)).dy;
expect(pillY <= timeY, isTrue, reason: 'pill sits above the time');
expect(timeY <= dateY, isTrue, reason: 'the date sits below the time');
// Regression guard: pumpAndSettle must still complete (purely
// additive static text, no new animation).
@@ -159,4 +165,49 @@ void main() {
await tester.pumpAndSettle();
},
);
testWidgets(
'Issue 3 (feedback-pruebas): the gap above the snooze tiles matches the '
'gap below them (t4:427 draws a uniform gap:12 flex column) -- the '
'previous 10/14 pair matched neither the prototype nor each other',
(tester) async {
await _montarPantalla(tester);
final l10n = AppLocalizations.of(
tester.element(find.byType(PantallaAlarmaSonando)),
);
final eyebrowBottom =
tester
.getBottomLeft(
find
.ancestor(
of: find.byIcon(Icons.snooze_rounded),
matching: find.byType(Row),
)
.first,
)
.dy;
final tileDestacado = find.ancestor(
of: find.text(l10n.alarmSnoozeOptionLabel(10)),
matching: find.byType(FilledButton),
);
final tilesTop = tester.getTopLeft(tileDestacado).dy;
final tilesBottom = tester.getBottomLeft(tileDestacado).dy;
final stopButtonTop =
tester
.getTopLeft(find.byKey(const ValueKey('ringing-stop-button')))
.dy;
expect(
tilesTop - eyebrowBottom,
12,
reason: 't4:427: gap:12 above the snooze tiles',
);
expect(
stopButtonTop - tilesBottom,
12,
reason: 't4:427: gap:12 below the snooze tiles, same as above',
);
},
);
}
@@ -190,9 +190,26 @@ void main() {
expect(antes, isNot(l10n.alarmNoNextExecution));
// Lunes -> Martes: la fecha calculada SIEMPRE cambia, sea cual sea hoy.
await tester.tap(find.text(l10n.weekdayShortTuesday));
//
// Item 5: the alarm CARD underneath now also renders the real day
// abbreviation ("Lun") for a diasSemana alarm, so a bare
// `find.text(...)` for a weekday letter is ambiguous while the
// editor sheet is open on top of the list — scope to the sheet's own
// BottomSheet subtree to target the day-picker circle specifically.
final hojaEditor = find.byType(BottomSheet);
await tester.tap(
find.descendant(
of: hojaEditor,
matching: find.text(l10n.weekdayShortTuesday),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text(l10n.weekdayShortMonday));
await tester.tap(
find.descendant(
of: hojaEditor,
matching: find.text(l10n.weekdayShortMonday),
),
);
await tester.pumpAndSettle();
final despues = _textoPreview(tester);
@@ -0,0 +1,154 @@
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/pantallas/pantalla_diagnostico_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';
/// Card-level visibility for a failed scheduling attempt (fix/alarmas-
/// fallos-silenciosos): before this, `ultimaExcepcionPara` existed but was
/// never read from any screen, so a failed alarm rendered exactly like a
/// working one -- switched on, no visible sign anything was wrong.
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
Future<void> montarPantalla(
WidgetTester tester,
EstadoAlarmas estadoAlarmas,
) 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);
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));
}
testWidgets(
'una alarma cuya programacion fallo muestra un aviso en su tarjeta',
(tester) async {
final android = FakePuertoAlarmasAndroid()..fallaProgramar = true;
final estadoAlarmas = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 1, 6, 0)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estadoAlarmas.dispose);
addTearDown(android.dispose);
await estadoAlarmas.guardarAlarma(
const AlarmaMusical(
id: 'r1',
nombre: 'Rota',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
await montarPantalla(tester, estadoAlarmas);
expect(
find.byKey(const ValueKey('tarjeta-alarma-fallo-r1')),
findsOneWidget,
);
},
);
testWidgets(
'una alarma programada correctamente NO muestra el aviso de fallo',
(tester) async {
final android = FakePuertoAlarmasAndroid();
final estadoAlarmas = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 1, 6, 0)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estadoAlarmas.dispose);
addTearDown(android.dispose);
await estadoAlarmas.guardarAlarma(
const AlarmaMusical(
id: 'ok1',
nombre: 'Sana',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
await montarPantalla(tester, estadoAlarmas);
expect(
find.byKey(const ValueKey('tarjeta-alarma-fallo-ok1')),
findsNothing,
);
},
);
testWidgets('el aviso de fallo abre la pantalla de diagnostico al tocarlo', (
tester,
) async {
final android = FakePuertoAlarmasAndroid()..fallaProgramar = true;
final estadoAlarmas = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 1, 6, 0)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estadoAlarmas.dispose);
addTearDown(android.dispose);
await estadoAlarmas.guardarAlarma(
const AlarmaMusical(
id: 'r1',
nombre: 'Rota',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
await montarPantalla(tester, estadoAlarmas);
await tester.tap(find.byKey(const ValueKey('tarjeta-alarma-fallo-r1')));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(find.byType(PantallaDiagnosticoAlarmas), findsOneWidget);
});
}
@@ -0,0 +1,330 @@
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';
/// Item 5: the alarm list must show which days a `diasSemana` alarm
/// actually fires on (e.g. "Lun, Mié, Vie"), not the generic "Días" label,
/// plus surface fade/volume/vacation-pause state when they are genuinely
/// informative -- without cluttering the row.
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
Future<(EstadoRadio, EstadoAlarmas)> montar(
WidgetTester tester, {
required AlarmaMusical alarma,
List<RangoVacaciones> vacaciones = const [],
}) 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(alarma);
if (vacaciones.isNotEmpty) {
await estadoAlarmas.guardarVacaciones(vacaciones);
}
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));
return (radio, estadoAlarmas);
}
testWidgets(
'diasSemana alarm shows the ACTUAL configured days (Lun, Mié, Vie), '
'not the generic "Días" label',
(tester) async {
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-dias',
nombre: 'Entre semana',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diasSemana,
diasSemana: [DateTime.monday, DateTime.wednesday, DateTime.friday],
),
);
expect(find.text('Lun, Mié, Vie'), findsOneWidget);
expect(find.text('Días'), findsNothing);
},
);
testWidgets('daily alarm still shows "Diaria" (unaffected)', (
tester,
) async {
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-diaria',
nombre: 'Todos los días',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
expect(find.text('Diaria'), findsOneWidget);
});
testWidgets('one-time alarm still shows "Una vez" (unaffected)', (
tester,
) async {
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-unica',
nombre: 'Una sola vez',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.unica,
diasSemana: [],
fechaUnica: null,
),
);
expect(find.text('Una vez'), findsOneWidget);
});
testWidgets(
'a diasSemana alarm with an (invalid/legacy) empty diasSemana falls '
'back to the generic label instead of showing nothing',
(tester) async {
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-dias-vacio',
nombre: 'Corrupta',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diasSemana,
diasSemana: [],
),
);
expect(find.text('Días'), findsOneWidget);
},
);
testWidgets('a configured fade-in shows a compact "Fade-in Ns" detail', (
tester,
) async {
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-fade',
nombre: 'Con fade',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
fadeInSegundos: 8,
),
);
expect(find.textContaining('Fade-in 8s'), findsOneWidget);
});
testWidgets('no fade-in (0s, the default) shows no fade detail', (
tester,
) async {
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-sin-fade',
nombre: 'Sin fade',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
fadeInSegundos: 0,
),
);
expect(find.textContaining('Fade-in'), findsNothing);
});
testWidgets(
'a non-default volume shows a compact percentage detail',
(tester) async {
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-vol',
nombre: 'Volumen bajo',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
volumen: 0.5,
),
);
expect(find.textContaining('50%'), findsOneWidget);
},
);
testWidgets('the default volume (85%) shows no volume detail', (
tester,
) async {
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-vol-default',
nombre: 'Volumen default',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
expect(find.textContaining('85%'), findsNothing);
});
testWidgets(
'an alarm paused by a CURRENTLY active vacation range shows a '
'vacation-paused detail',
(tester) async {
final l10n = lookupAppLocalizations(const Locale('es'));
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-vacaciones',
nombre: 'Pausada',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
sonarEnVacaciones: false,
),
vacaciones: [
// Wide, real-wall-clock-safe range (rangoVacacionesActivo()
// defaults to the REAL DateTime.now(), not this file's injected
// `reloj`) -- deliberately spans many years so the test stays
// valid regardless of exactly when it runs.
RangoVacaciones(
id: 'v1',
nombre: 'Verano',
inicio: DateTime(2020, 1, 1),
fin: DateTime(2030, 12, 31),
),
],
);
expect(
find.textContaining(l10n.alarmCardVacationPausedBadge),
findsOneWidget,
);
},
);
testWidgets(
'an alarm that DOES sound during vacations shows NO vacation-paused '
'detail even with an active range',
(tester) async {
final l10n = lookupAppLocalizations(const Locale('es'));
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-suena-vacaciones',
nombre: 'Suena igual',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
sonarEnVacaciones: true,
),
vacaciones: [
// Wide, real-wall-clock-safe range (rangoVacacionesActivo()
// defaults to the REAL DateTime.now(), not this file's injected
// `reloj`) -- deliberately spans many years so the test stays
// valid regardless of exactly when it runs.
RangoVacaciones(
id: 'v1',
nombre: 'Verano',
inicio: DateTime(2020, 1, 1),
fin: DateTime(2030, 12, 31),
),
],
);
expect(
find.textContaining(l10n.alarmCardVacationPausedBadge),
findsNothing,
);
},
);
testWidgets(
'sonarEnVacaciones:false with NO currently-active vacation range shows '
'no vacation-paused detail (nothing to be paused BY right now)',
(tester) async {
final l10n = lookupAppLocalizations(const Locale('es'));
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-sin-rango-activo',
nombre: 'Sin vacaciones activas',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
sonarEnVacaciones: false,
),
);
expect(
find.textContaining(l10n.alarmCardVacationPausedBadge),
findsNothing,
);
},
);
}
@@ -0,0 +1,332 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/pantallas/pantalla_diagnostico_alarmas.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes_alarmas.dart';
final _l10n = lookupAppLocalizations(const Locale('en'));
Future<EstadoAlarmas> _crearEstado({
required FakePuertoAlarmasAndroid android,
List<AlarmaMusical> alarmas = const [],
bool cargarDiagnostico = true,
}) async {
final estado = EstadoAlarmas(
servicio: ServicioAlarmas(),
android: android,
iniciarAutomaticamente: false,
);
for (final alarma in alarmas) {
await estado.guardarAlarma(alarma);
}
if (cargarDiagnostico) {
await estado.cargarDiagnostico();
}
return estado;
}
Widget _buildScreen(EstadoAlarmas estado) {
return ChangeNotifierProvider<EstadoAlarmas>.value(
value: estado,
child: MaterialApp(
locale: const Locale('en'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const PantallaDiagnosticoAlarmas(),
),
);
}
Future<void> _montarPantalla(WidgetTester tester, EstadoAlarmas estado) async {
tester.view.physicalSize = const Size(1440, 3200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
await tester.pumpWidget(_buildScreen(estado));
await tester.pumpAndSettle();
}
AlarmaMusical _alarmaActiva() => const AlarmaMusical(
id: 'a1',
nombre: 'Despertar',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
);
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
testWidgets(
'caso todo OK: no muestra ningun boton "Fix" ni el estado de atencion, '
'y sin fabricante conocido no muestra la guia de autostart',
(tester) async {
final android =
FakePuertoAlarmasAndroid()
..fabricante = 'Google'
// A fresh fake defaults alarmasNativasPendientes to 0, which
// WOULD read as needs-attention once an alarm is active (that
// combination is exactly the diagnostic signal this screen
// exists to surface) -- give it a registered count so this
// specific scenario is genuinely all-OK.
..alarmasNativasPendientes = 1;
final estado = await _crearEstado(
android: android,
alarmas: [_alarmaActiva()],
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await _montarPantalla(tester, estado);
expect(find.text(_l10n.androidReliabilityTitle), findsOneWidget);
expect(find.text(_l10n.alarmDiagnosticsFixAction), findsNothing);
expect(
find.text(_l10n.alarmDiagnosticsNeedsAttentionStatus),
findsNothing,
);
expect(find.text(_l10n.alarmDiagnosticsAutostartTitle), findsNothing);
expect(find.text('Google'), findsOneWidget);
expect(find.text('35'), findsOneWidget);
},
);
testWidgets(
'alarmas exactas en atencion: muestra el boton Fix y lo invoca via '
'solicitarPermisoAlarmasExactas',
(tester) async {
final android =
FakePuertoAlarmasAndroid()
..fabricante = 'Google'
..puedeProgramarExactas = false;
final estado = await _crearEstado(
android: android,
alarmas: [_alarmaActiva()],
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await _montarPantalla(tester, estado);
expect(find.text(_l10n.alarmDiagnosticsExactAlarmsTitle), findsOneWidget);
expect(find.text(_l10n.alarmDiagnosticsFixAction), findsOneWidget);
// guardarAlarma's own onboarding request
// (EstadoAlarmas._solicitarPermisosNecesariosParaAlarma) already fires
// once for this same failing field before the screen even mounts, so
// the assertion checks the DELTA the button tap itself caused.
final antes = android.solicitudesPermisoAlarmasExactas;
await tester.tap(find.text(_l10n.alarmDiagnosticsFixAction));
await tester.pumpAndSettle();
expect(android.solicitudesPermisoAlarmasExactas, antes + 1);
},
);
testWidgets('notificaciones en atencion: el boton Fix llama a '
'abrirConfiguracionNotificaciones (deep link a Settings, no el permiso '
'runtime)', (tester) async {
final android =
FakePuertoAlarmasAndroid()
..fabricante = 'Google'
..notificacionesPermitidas = false;
final estado = await _crearEstado(
android: android,
alarmas: [_alarmaActiva()],
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await _montarPantalla(tester, estado);
await tester.tap(find.text(_l10n.alarmDiagnosticsFixAction));
await tester.pumpAndSettle();
expect(android.aperturasConfiguracionNotificaciones, 1);
});
testWidgets('pantalla completa en atencion: el boton Fix llama a '
'solicitarPermisoPantallaCompleta', (tester) async {
final android =
FakePuertoAlarmasAndroid()
..fabricante = 'Google'
..puedeUsarPantallaCompleta = false;
final estado = await _crearEstado(
android: android,
alarmas: [_alarmaActiva()],
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await _montarPantalla(tester, estado);
// Same delta reasoning as the exact-alarms test above: the alarm's own
// onboarding request already fired once for this field before mount.
final antes = android.solicitudesPermisoPantallaCompleta;
await tester.tap(find.text(_l10n.alarmDiagnosticsFixAction));
await tester.pumpAndSettle();
expect(android.solicitudesPermisoPantallaCompleta, antes + 1);
});
testWidgets('optimizacion de bateria en atencion: el boton Fix llama a '
'solicitarExencionBateria', (tester) async {
final android =
FakePuertoAlarmasAndroid()
..fabricante = 'Google'
..ignoraOptimizacionBateria = false;
final estado = await _crearEstado(
android: android,
alarmas: [_alarmaActiva()],
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await _montarPantalla(tester, estado);
// Same delta reasoning as the exact-alarms test above: guardarAlarma's
// own onboarding request already fired once for this field before the
// screen mounts.
final antes = android.solicitudesExencionBateria;
await tester.tap(find.text(_l10n.alarmDiagnosticsFixAction));
await tester.pumpAndSettle();
expect(android.solicitudesExencionBateria, antes + 1);
});
testWidgets(
'accion de sistema que falla (ROM sin esa pantalla) muestra un aviso '
'en vez de fallar en silencio',
(tester) async {
final android =
FakePuertoAlarmasAndroid()
..fabricante = 'Google'
..puedeProgramarExactas = false
..fallaAccionSistema = true;
final estado = await _crearEstado(
android: android,
alarmas: [_alarmaActiva()],
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await _montarPantalla(tester, estado);
await tester.tap(find.text(_l10n.alarmDiagnosticsFixAction));
await tester.pump();
await tester.pump(const Duration(milliseconds: 300));
expect(
find.text(_l10n.alarmDiagnosticsIntentUnavailable),
findsOneWidget,
);
},
);
testWidgets(
'alarmas nativas pendientes: con una alarma activa y conteo 0 muestra '
'el aviso de atencion (la senal mas diagnostica del reporte)',
(tester) async {
final android =
FakePuertoAlarmasAndroid()
..fabricante = 'Google'
..alarmasNativasPendientes = 0;
final estado = await _crearEstado(
android: android,
alarmas: [_alarmaActiva()],
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await _montarPantalla(tester, estado);
expect(
find.text(_l10n.alarmDiagnosticsNativeCountValue(0)),
findsOneWidget,
);
expect(
find.text(_l10n.alarmDiagnosticsNativeCountAttentionHint),
findsOneWidget,
);
},
);
testWidgets(
'alarmas nativas pendientes: con al menos una registrada no muestra '
'el aviso de atencion',
(tester) async {
final android =
FakePuertoAlarmasAndroid()
..fabricante = 'Google'
..alarmasNativasPendientes = 2;
final estado = await _crearEstado(
android: android,
alarmas: [_alarmaActiva()],
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await _montarPantalla(tester, estado);
expect(
find.text(_l10n.alarmDiagnosticsNativeCountValue(2)),
findsOneWidget,
);
expect(
find.text(_l10n.alarmDiagnosticsNativeCountAttentionHint),
findsNothing,
);
},
);
testWidgets('fabricante Xiaomi muestra la guia de autostart con el nombre '
'interpolado', (tester) async {
final android = FakePuertoAlarmasAndroid()..fabricante = 'Xiaomi';
final estado = await _crearEstado(
android: android,
alarmas: [_alarmaActiva()],
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await _montarPantalla(tester, estado);
expect(find.text(_l10n.alarmDiagnosticsAutostartTitle), findsOneWidget);
expect(
find.text(_l10n.alarmDiagnosticsAutostartBody('Xiaomi')),
findsOneWidget,
);
});
testWidgets(
'diagnostico aun no disponible (null) no falla y muestra un aviso en '
'vez de romper la pantalla',
(tester) async {
// No alarms saved either: guardarAlarma() itself populates
// _diagnostico as a side effect of its own onboarding permission
// check, so reaching a genuinely null diagnostic requires an
// EstadoAlarmas that never called guardarAlarma or cargarDiagnostico.
final android = FakePuertoAlarmasAndroid();
final estado = await _crearEstado(
android: android,
cargarDiagnostico: false,
);
addTearDown(estado.dispose);
addTearDown(android.dispose);
await _montarPantalla(tester, estado);
expect(find.text(_l10n.alarmDiagnosticsUnavailableHint), findsOneWidget);
},
);
}
+102
View File
@@ -7,7 +7,9 @@ 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_layout.dart';
import 'package:pluriwave/widgets/pluri_push_scaffold.dart';
import 'package:pluriwave/widgets/pluri_root_header.dart';
import 'package:pluriwave/widgets/tarjeta_emisora.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -546,6 +548,106 @@ void main() {
expect(inactivo.backgroundColor, const Color(0xFF102532));
});
});
group('Issue 3 (feedback-pruebas): spacing tiers', () {
testWidgets('the header title sits at title-tier inset (20px) -- '
'ReorderableListView.padding used to double up on top of '
"PluriRootHeader's own internal inset", (tester) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
final estado = await crearEstadoConFavoritos();
addTearDown(estado.dispose);
final l10n = lookupAppLocalizations(const Locale('en'));
await tester.pumpWidget(buildScreen(estado));
await pumpStable(tester);
final titulo = find.descendant(
of: find.byType(PluriRootHeader),
matching: find.text(l10n.favoritesTitle),
);
expect(
tester.getTopLeft(titulo).dx,
PluriLayout.titleHorizontal,
reason:
'PluriRootHeader already supplies its own 20px inset; the '
'previous ReorderableListView.padding of 16 doubled up on top '
'of it, landing the title at 36px instead of 20px -- the ONE '
"root screen whose header didn't match Alarmas/Ajustes",
);
});
testWidgets(
'the header sits at the SAME horizontal position whether the list is '
'empty or populated -- two mutually-exclusive states of the same '
'header must not read differently',
(tester) async {
setLargeSurface(tester);
final l10n = lookupAppLocalizations(const Locale('en'));
final vacio = await crearEstadoVacio();
addTearDown(vacio.dispose);
await tester.pumpWidget(buildScreen(vacio));
await pumpStable(tester);
final dxVacio =
tester
.getTopLeft(
find.descendant(
of: find.byType(PluriRootHeader),
matching: find.text(l10n.favoritesTitle),
),
)
.dx;
_suppressListTileInkAssertion();
final conFavoritos = await crearEstadoConFavoritos();
addTearDown(conFavoritos.dispose);
await tester.pumpWidget(buildScreen(conFavoritos));
await pumpStable(tester);
final dxConFavoritos =
tester
.getTopLeft(
find.descendant(
of: find.byType(PluriRootHeader),
matching: find.text(l10n.favoritesTitle),
),
)
.dx;
expect(
dxConFavoritos,
dxVacio,
reason:
'the empty and populated branches of this screen must render '
'the SAME header inset -- they previously did not (0 vs 16 '
'extra px of list-level padding)',
);
},
);
testWidgets(
'each favourite row uses row-tier horizontal inset (12), not the '
'card-tier constant a background-less row was never meant to carry',
(tester) async {
setLargeSurface(tester);
_suppressListTileInkAssertion();
final estado = await crearEstadoConFavoritos();
addTearDown(estado.dispose);
await tester.pumpWidget(buildScreen(estado));
await pumpStable(tester);
expect(
tester.getTopLeft(find.byType(FilaEmisoraPlana).first).dx,
PluriLayout.rowHorizontal,
reason:
'audit 4.3: background-less rows are row tier (12), matching '
'the same widget already fixed on Buscar -- not card tier '
'(16)',
);
},
);
});
}
void setLargeSurface(WidgetTester tester) {
@@ -216,6 +216,35 @@ void main() {
},
);
testWidgets(
'Issue 3 (feedback-pruebas): the gap between the storage card and the '
'rows below is 16, matching t4:617 -- not 12',
(tester) async {
final estado = EstadoGrabacion(
servicio: _FakeServicioGrabacionConArchivos([
fijaA,
], maxBytesFijo: 200 * 1024 * 1024),
);
addTearDown(estado.dispose);
await tester.pumpWidget(
buildScreen(
estado: estado,
reproductor: _ReproductorGrabacionesFake(const {}),
),
);
await pumpStable(tester);
expect(
tester
.getSize(find.byKey(const ValueKey('grabaciones-storage-gap')))
.height,
16,
reason: 't4:617 draws a 16px gap here',
);
},
);
testWidgets('15.2-A: 3 recording fixtures render as 3 rows', (tester) async {
final estado = EstadoGrabacion(
servicio: _FakeServicioGrabacionConArchivos([
+21
View File
@@ -262,6 +262,27 @@ void main() {
expect(find.byIcon(Icons.search_rounded), findsOneWidget);
});
testWidgets('Issue 3 (feedback-pruebas): the gap between "Tus idiomas" and '
'"Todos" is 14, matching t4:260 -- not 16', (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);
expect(
tester.getSize(find.byKey(const ValueKey('paises-seccion-gap'))).height,
14,
reason: 't4:260 draws a 14px gap between the two eyebrow sections',
);
});
});
testWidgets(
@@ -0,0 +1,234 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/diagnostico_alarmas.dart';
import 'package:pluriwave/servicios/servicio_alarmas_android.dart';
/// Builds a fully-OK snapshot by default; each test overrides only the
/// field(s) it wants to fail, so failures are exercised independently.
DiagnosticoAlarmasAndroid _diag({
bool puedeProgramarExactas = true,
bool notificacionesPermitidas = true,
bool puedeUsarPantallaCompleta = true,
bool ignoraOptimizacionBateria = true,
int alarmasNativasPendientes = 1,
String fabricante = 'Google',
int versionSdk = 34,
}) => DiagnosticoAlarmasAndroid(
puedeProgramarExactas: puedeProgramarExactas,
notificacionesPermitidas: notificacionesPermitidas,
puedeUsarPantallaCompleta: puedeUsarPantallaCompleta,
ignoraOptimizacionBateria: ignoraOptimizacionBateria,
alarmasNativasPendientes: alarmasNativasPendientes,
fabricante: fabricante,
versionSdk: versionSdk,
);
void main() {
group('construirItemsDiagnosticoAlarmas', () {
test(
'caso todo OK: los 5 items quedan en estado ok y en orden estable',
() {
final items = construirItemsDiagnosticoAlarmas(
diagnostico: _diag(),
hayAlarmasActivas: true,
);
expect(items, hasLength(5));
expect(items.map((item) => item.senal).toList(), const [
SenalDiagnosticoAlarma.alarmasExactas,
SenalDiagnosticoAlarma.notificaciones,
SenalDiagnosticoAlarma.pantallaCompleta,
SenalDiagnosticoAlarma.optimizacionBateria,
SenalDiagnosticoAlarma.alarmasNativasPendientes,
]);
expect(
items.every((item) => item.estado == EstadoSenalDiagnostico.ok),
isTrue,
reason: 'ningun item deberia requerir atencion en el caso todo OK',
);
expect(items.any((item) => item.requiereAtencion), isFalse);
},
);
test('alarmas exactas queda en atencion cuando el permiso no esta '
'concedido, sin afectar a los demas items (falla independiente)', () {
final items = construirItemsDiagnosticoAlarmas(
diagnostico: _diag(puedeProgramarExactas: false),
hayAlarmasActivas: true,
);
final exactas = items.singleWhere(
(item) => item.senal == SenalDiagnosticoAlarma.alarmasExactas,
);
expect(exactas.estado, EstadoSenalDiagnostico.atencion);
expect(exactas.accion, AccionDiagnosticoAlarma.abrirAlarmasExactas);
expect(
items
.where(
(item) => item.senal != SenalDiagnosticoAlarma.alarmasExactas,
)
.every((item) => item.estado == EstadoSenalDiagnostico.ok),
isTrue,
);
});
test('notificaciones queda en atencion cuando no estan permitidas, sin '
'afectar a los demas items (falla independiente)', () {
final items = construirItemsDiagnosticoAlarmas(
diagnostico: _diag(notificacionesPermitidas: false),
hayAlarmasActivas: true,
);
final notificaciones = items.singleWhere(
(item) => item.senal == SenalDiagnosticoAlarma.notificaciones,
);
expect(notificaciones.estado, EstadoSenalDiagnostico.atencion);
expect(
notificaciones.accion,
AccionDiagnosticoAlarma.abrirNotificaciones,
);
expect(
items
.where(
(item) => item.senal != SenalDiagnosticoAlarma.notificaciones,
)
.every((item) => item.estado == EstadoSenalDiagnostico.ok),
isTrue,
);
});
test('pantalla completa queda en atencion cuando no se puede usar, sin '
'afectar a los demas items (falla independiente)', () {
final items = construirItemsDiagnosticoAlarmas(
diagnostico: _diag(puedeUsarPantallaCompleta: false),
hayAlarmasActivas: true,
);
final pantalla = items.singleWhere(
(item) => item.senal == SenalDiagnosticoAlarma.pantallaCompleta,
);
expect(pantalla.estado, EstadoSenalDiagnostico.atencion);
expect(pantalla.accion, AccionDiagnosticoAlarma.abrirPantallaCompleta);
expect(
items
.where(
(item) => item.senal != SenalDiagnosticoAlarma.pantallaCompleta,
)
.every((item) => item.estado == EstadoSenalDiagnostico.ok),
isTrue,
);
});
test('optimizacion de bateria queda en atencion cuando la app no esta '
'exenta, sin afectar a los demas items (falla independiente)', () {
final items = construirItemsDiagnosticoAlarmas(
diagnostico: _diag(ignoraOptimizacionBateria: false),
hayAlarmasActivas: true,
);
final bateria = items.singleWhere(
(item) => item.senal == SenalDiagnosticoAlarma.optimizacionBateria,
);
expect(bateria.estado, EstadoSenalDiagnostico.atencion);
expect(bateria.accion, AccionDiagnosticoAlarma.abrirOptimizacionBateria);
expect(
items
.where(
(item) =>
item.senal != SenalDiagnosticoAlarma.optimizacionBateria,
)
.every((item) => item.estado == EstadoSenalDiagnostico.ok),
isTrue,
);
});
test('alarmas nativas pendientes queda en atencion cuando hay alarmas '
'activas pero ninguna llego a registrarse en el sistema (la senal '
'mas diagnostica del reporte: el disparo nunca llego al SO)', () {
final items = construirItemsDiagnosticoAlarmas(
diagnostico: _diag(alarmasNativasPendientes: 0),
hayAlarmasActivas: true,
);
final nativas = items.singleWhere(
(item) => item.senal == SenalDiagnosticoAlarma.alarmasNativasPendientes,
);
expect(nativas.estado, EstadoSenalDiagnostico.atencion);
expect(nativas.accion, AccionDiagnosticoAlarma.ninguna);
expect(
items
.where(
(item) =>
item.senal != SenalDiagnosticoAlarma.alarmasNativasPendientes,
)
.every((item) => item.estado == EstadoSenalDiagnostico.ok),
isTrue,
);
});
test('alarmas nativas pendientes queda OK cuando no hay ninguna alarma '
'activa, aunque el conteo nativo sea cero (nada deberia estar '
'registrado todavia)', () {
final items = construirItemsDiagnosticoAlarmas(
diagnostico: _diag(alarmasNativasPendientes: 0),
hayAlarmasActivas: false,
);
final nativas = items.singleWhere(
(item) => item.senal == SenalDiagnosticoAlarma.alarmasNativasPendientes,
);
expect(nativas.estado, EstadoSenalDiagnostico.ok);
});
test('alarmas nativas pendientes queda OK cuando hay alarmas activas y al '
'menos una esta registrada en el sistema', () {
final items = construirItemsDiagnosticoAlarmas(
diagnostico: _diag(alarmasNativasPendientes: 3),
hayAlarmasActivas: true,
);
final nativas = items.singleWhere(
(item) => item.senal == SenalDiagnosticoAlarma.alarmasNativasPendientes,
);
expect(nativas.estado, EstadoSenalDiagnostico.ok);
});
});
group('fabricanteRequiereGuiaAutostart', () {
test('un fabricante desconocido no requiere guia de autostart', () {
expect(fabricanteRequiereGuiaAutostart('Google'), isFalse);
expect(fabricanteRequiereGuiaAutostart('Fairphone'), isFalse);
expect(fabricanteRequiereGuiaAutostart(''), isFalse);
});
test(
'Xiaomi (y sus sub-marcas Redmi/POCO) requieren guia de autostart',
() {
expect(fabricanteRequiereGuiaAutostart('Xiaomi'), isTrue);
expect(fabricanteRequiereGuiaAutostart('Redmi'), isTrue);
expect(fabricanteRequiereGuiaAutostart('POCO'), isTrue);
},
);
test('otros fabricantes conocidos por matar procesos en segundo plano '
'tambien requieren guia', () {
for (final fabricante in [
'HUAWEI',
'OPPO',
'vivo',
'OnePlus',
'samsung',
]) {
expect(
fabricanteRequiereGuiaAutostart(fabricante),
isTrue,
reason: '$fabricante deberia requerir guia de autostart',
);
}
});
test('la comparacion no distingue mayusculas/minusculas ni espacios', () {
expect(fabricanteRequiereGuiaAutostart('XIAOMI'), isTrue);
expect(fabricanteRequiereGuiaAutostart(' xiaomi '), isTrue);
});
});
}
File diff suppressed because it is too large Load Diff
@@ -20,10 +20,16 @@ void main() {
return true;
case 'requestIgnoreBatteryOptimizations':
return true;
case 'openNotificationSettings':
return true;
case 'getActiveRingingAlarmId':
return 'ring1';
case 'stopActiveAlarm':
return {'stopped': true, 'wasRinging': true, 'activeAlarmId': 'ring1'};
return {
'stopped': true,
'wasRinging': true,
'activeAlarmId': 'ring1',
};
}
return null;
});
@@ -111,6 +117,22 @@ void main() {
},
);
test(
'abrirConfiguracionNotificaciones invoca openNotificationSettings '
'(deep link a Settings, distinto del permiso runtime de la primera vez)',
() async {
final servicio = ServicioAlarmasAndroid(channel: channel);
final abierto = await servicio.abrirConfiguracionNotificaciones();
expect(abierto, isTrue);
expect(
llamadas.map((c) => c.method),
contains('openNotificationSettings'),
);
},
);
test(
'detenerSonidoActivo mapea el resultado nativo confirmado a ResultadoDetencion',
() async {
@@ -144,21 +166,15 @@ void main() {
},
);
test(
'alarmaSonandoId propaga el error del canal (fail-toward-silence, '
'Finding 2)',
() async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
llamadas.add(call);
throw PlatformException(code: 'QUERY_FAILED', message: 'boom');
});
final servicio = ServicioAlarmasAndroid(channel: channel);
test('alarmaSonandoId propaga el error del canal (fail-toward-silence, '
'Finding 2)', () async {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
llamadas.add(call);
throw PlatformException(code: 'QUERY_FAILED', message: 'boom');
});
final servicio = ServicioAlarmasAndroid(channel: channel);
expect(
() => servicio.alarmaSonandoId(),
throwsA(isA<PlatformException>()),
);
},
);
expect(() => servicio.alarmaSonandoId(), throwsA(isA<PlatformException>()));
});
}
@@ -0,0 +1,184 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:shared_preferences/shared_preferences.dart';
/// Coverage for the scheduling-reliability failure records (fix/alarmas-
/// fallos-silenciosos): a failed `android.programar` call is no longer only
/// a transient, alarm-agnostic `EstadoAlarmas.error` string -- it is also
/// recorded per-alarm through the SAME `ExcepcionAlarma` model `saltarProxima`
/// already uses, so `EstadoAlarmas.ultimaExcepcionPara` can surface it on the
/// exact card affected.
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
test('registrarFalloProgramacion agrega una excepcion de fallo para la '
'alarma indicada', () async {
final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7));
await servicio.guardarAlarma(
AlarmaMusical(
id: 'a1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
),
);
final config = await servicio.registrarFalloProgramacion(
'a1',
DateTime(2026, 5, 25, 7, 30),
ExcepcionAlarma.tipoFalloProgramacion,
);
expect(config.excepciones, hasLength(1));
expect(config.excepciones.single.alarmaId, 'a1');
expect(
config.excepciones.single.tipo,
ExcepcionAlarma.tipoFalloProgramacion,
);
});
test('registrarFalloProgramacion reemplaza un fallo previo de la MISMA '
'alarma en vez de acumular', () async {
final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7));
await servicio.guardarAlarma(
AlarmaMusical(
id: 'a1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
),
);
await servicio.registrarFalloProgramacion(
'a1',
DateTime(2026, 5, 25, 7, 30),
ExcepcionAlarma.tipoFalloPreaviso,
);
final config = await servicio.registrarFalloProgramacion(
'a1',
DateTime(2026, 5, 26, 7, 30),
ExcepcionAlarma.tipoFalloProgramacion,
);
expect(config.excepciones, hasLength(1));
expect(
config.excepciones.single.tipo,
ExcepcionAlarma.tipoFalloProgramacion,
);
});
test('registrarFalloProgramacion NUNCA toca las excepciones skipNext de '
'otras alarmas ni de la misma', () async {
final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7));
await servicio.guardarAlarma(
AlarmaMusical(
id: 'a1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
),
);
await servicio.saltarProxima('a1');
final config = await servicio.registrarFalloProgramacion(
'a1',
DateTime(2026, 5, 26, 7, 30),
ExcepcionAlarma.tipoFalloProgramacion,
);
expect(config.excepciones, hasLength(2));
expect(
config.excepciones.map((e) => e.tipo),
containsAll(<String>[
ExcepcionAlarma.tipoSaltoSiguiente,
ExcepcionAlarma.tipoFalloProgramacion,
]),
);
});
test('limpiarFalloProgramacion elimina el fallo registrado para esa '
'alarma', () async {
final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7));
await servicio.guardarAlarma(
AlarmaMusical(
id: 'a1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
),
);
await servicio.registrarFalloProgramacion(
'a1',
DateTime(2026, 5, 25, 7, 30),
ExcepcionAlarma.tipoFalloProgramacion,
);
final config = await servicio.limpiarFalloProgramacion(
'a1',
ExcepcionAlarma.tipoFalloProgramacion,
);
expect(config.excepciones, isEmpty);
});
test('limpiarFalloProgramacion NO limpia un fallo de un tipo distinto '
'(cada subsistema se limpia por su cuenta)', () async {
final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7));
await servicio.guardarAlarma(
AlarmaMusical(
id: 'a1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
),
);
await servicio.registrarFalloProgramacion(
'a1',
DateTime(2026, 5, 25, 7, 30),
ExcepcionAlarma.tipoFalloPreaviso,
);
final config = await servicio.limpiarFalloProgramacion(
'a1',
ExcepcionAlarma.tipoFalloProgramacion,
);
expect(config.excepciones, hasLength(1));
expect(config.excepciones.single.tipo, ExcepcionAlarma.tipoFalloPreaviso);
});
test('limpiarFalloProgramacion sin fallo previo no rompe y no persiste '
'cambios', () async {
final servicio = ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7));
await servicio.guardarAlarma(
AlarmaMusical(
id: 'a1',
nombre: 'Diaria',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
),
);
final config = await servicio.limpiarFalloProgramacion(
'a1',
ExcepcionAlarma.tipoFalloProgramacion,
);
expect(config.excepciones, isEmpty);
});
}
@@ -0,0 +1,200 @@
import 'dart:io';
import 'dart:ui' show Locale;
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
/// Item 4 (Android Auto: equalizer custom actions) — the pure, handler-
/// independent half of the fix. `PluriWaveAudioHandler` cannot be
/// instantiated in unit tests (a real `just_audio.AudioPlayer` requires
/// platform MethodChannels), so the preset-cycling decision, the preset-name
/// localization and the `MediaControl` list construction are extracted as
/// pure top-level functions here. The handler's own `customAction` dispatch
/// and `playbackState` wiring are static-review-only, same as the existing
/// EQ re-apply/session-id wiring.
void main() {
final l10n = lookupAppLocalizations(const Locale('es'));
group('presetSiguiente (item 4 — cycling presets)', () {
test('advances to the next preset in order', () {
expect(presetSiguiente(PresetEcualizador.flat), PresetEcualizador.rock);
expect(presetSiguiente(PresetEcualizador.rock), PresetEcualizador.pop);
});
test('wraps around after the last preset', () {
expect(
presetSiguiente(PresetEcualizador.presets.last),
PresetEcualizador.presets.first,
);
});
test('an unknown/custom preset (e.g. a user-tweaked "Personalizado" band '
'set) starts from the FIRST preset instead of throwing', () {
final personalizado = PresetEcualizador(
nombre: 'Personalizado',
bandas: [1.0, 2.0, 3.0, 4.0, 5.0],
);
expect(presetSiguiente(personalizado), PresetEcualizador.presets.first);
});
test('respects an injected presets list instead of the default 6', () {
final propios = [PresetEcualizador.jazz, PresetEcualizador.voz];
expect(
presetSiguiente(PresetEcualizador.jazz, presets: propios),
PresetEcualizador.voz,
);
expect(
presetSiguiente(PresetEcualizador.voz, presets: propios),
PresetEcualizador.jazz,
);
});
});
group('nombrePresetVisible (item 4)', () {
test('maps every factory preset name to its localized ARB string', () {
expect(nombrePresetVisible(l10n, 'Flat'), l10n.equalizerPresetFlat);
expect(nombrePresetVisible(l10n, 'Rock'), l10n.equalizerPresetRock);
expect(nombrePresetVisible(l10n, 'Pop'), l10n.equalizerPresetPop);
expect(
nombrePresetVisible(l10n, 'Bass Boost'),
l10n.equalizerPresetBassBoost,
);
expect(nombrePresetVisible(l10n, 'Jazz'), l10n.equalizerPresetJazz);
expect(nombrePresetVisible(l10n, 'Voz'), l10n.equalizerPresetVoice);
expect(
nombrePresetVisible(l10n, 'Personalizado'),
l10n.equalizerPresetCustom,
);
});
test('an unrecognized name falls through verbatim', () {
expect(
nombrePresetVisible(l10n, 'Mi Preset Guardado'),
'Mi Preset Guardado',
);
});
});
group('controlesEcualizadorPersonalizados (item 4)', () {
test('empty when the equalizer is not available on this device', () {
final controles = controlesEcualizadorPersonalizados(
disponible: false,
activo: true,
l10n: l10n,
);
expect(controles, isEmpty);
});
test('exactly 1 custom action when available: the on/off toggle -- '
'decision `auto/ecualizador-diseno` REMOVES the preset-cycling '
'action that used to sit alongside it; preset selection now lives '
'in the "Ecualizador" browsable folder instead (see '
'`itemsEcualizadorAuto`)', () {
final controles = controlesEcualizadorPersonalizados(
disponible: true,
activo: true,
l10n: l10n,
);
expect(controles, hasLength(1));
expect(controles.single.action, MediaAction.custom);
expect(controles.single.customAction?.name, accionEqToggle);
});
test('toggle label reflects ON -> shows "disable" action', () {
final controles = controlesEcualizadorPersonalizados(
disponible: true,
activo: true,
l10n: l10n,
);
final toggle = controles.firstWhere(
(c) => c.customAction?.name == accionEqToggle,
);
expect(toggle.label, l10n.eqCustomActionDisableLabel);
});
test('toggle label reflects OFF -> shows "enable" action', () {
final controles = controlesEcualizadorPersonalizados(
disponible: true,
activo: false,
l10n: l10n,
);
final toggle = controles.firstWhere(
(c) => c.customAction?.name == accionEqToggle,
);
expect(toggle.label, l10n.eqCustomActionEnableLabel);
});
test('toggle icon reflects EQ state: ON uses ic_auto_eq_on, OFF uses '
'ic_auto_eq_off -- a car head unit foregrounds the icon over the '
'label, so the icon itself must change, not just the text', () {
final activado = controlesEcualizadorPersonalizados(
disponible: true,
activo: true,
l10n: l10n,
).firstWhere((c) => c.customAction?.name == accionEqToggle);
final desactivado = controlesEcualizadorPersonalizados(
disponible: true,
activo: false,
l10n: l10n,
).firstWhere((c) => c.customAction?.name == accionEqToggle);
expect(activado.androidIcon, 'drawable/ic_auto_eq_on');
expect(desactivado.androidIcon, 'drawable/ic_auto_eq_off');
});
});
group(
'action name constants (item 4 -- collision-free with car-tree ids)',
() {
test('accionEqToggle is non-empty and does not collide with any '
'existing browse-tree media-id prefix -- accionEqPresetSiguiente '
'(decision `auto/ecualizador-diseno`: removed, superseded by the '
'"Ecualizador" browsable folder) no longer exists as a symbol at '
'all, which this file compiling proves on its own', () {
expect(accionEqToggle, isNotEmpty);
});
},
);
group('equalizer drawable assets on disk (on-device feedback follow-up: the '
'two custom actions used to share one drawable and were visually '
'indistinguishable)', () {
test('ic_auto_eq_on and ic_auto_eq_off exist under '
'android/app/src/main/res/drawable/ -- a missing drawable is not a '
'build error, it silently renders blank/default on the head unit, '
'so this is the only safety net that would have caught the '
'original duplication. ic_auto_eq_preset is deliberately NOT '
'checked here anymore -- decision `auto/ecualizador-diseno` '
'removes the preset-cycling action and its drawable', () {
for (final nombre in ['ic_auto_eq_on', 'ic_auto_eq_off']) {
final archivo = File('android/app/src/main/res/drawable/$nombre.xml');
expect(
archivo.existsSync(),
isTrue,
reason:
'$nombre.xml must exist under '
'android/app/src/main/res/drawable/',
);
}
expect(
File(
'android/app/src/main/res/drawable/ic_auto_eq_preset.xml',
).existsSync(),
isFalse,
reason:
'ic_auto_eq_preset.xml must be REMOVED -- decision '
'`auto/ecualizador-diseno` retires the preset-cycling '
'custom action it belonged to',
);
});
});
}
@@ -0,0 +1,123 @@
import 'dart:ui' show Locale;
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/preset_ecualizador.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
/// Decision `auto/ecualizador-diseno` -- the "Ecualizador" browsable
/// folder's item-building (`itemsEcualizadorAuto`), the pure,
/// `AppLocalizations`-dependent half of the folder feature. Lives in a
/// dedicated file, separate from `servicio_audio_eq_custom_actions_test.dart`
/// (which covers the now-playing screen's on/off toggle) because this is a
/// different car UI surface: a browsable folder, not a custom action.
void main() {
final l10n = lookupAppLocalizations(const Locale('es'));
group('itemsEcualizadorAuto (decision `auto/ecualizador-diseno`)', () {
test('devuelve exactamente 7 items: Desactivar primero, luego los 6 '
'presets de fábrica, todos playable', () {
final items = itemsEcualizadorAuto(
activo: true,
presetActual: PresetEcualizador.flat,
l10n: l10n,
);
expect(items, hasLength(7));
expect(items.first.id, ConstructorArbolAuto.idDesactivarEq);
for (final item in items) {
expect(item.playable, isTrue);
}
});
test('los 6 presets aparecen en el mismo orden que '
'PresetEcualizador.presets, cada uno con su id eq_preset:<nombre>', () {
final items = itemsEcualizadorAuto(
activo: true,
presetActual: PresetEcualizador.flat,
l10n: l10n,
);
final builder = ConstructorArbolAuto();
final idsPresets = items.skip(1).map((i) => i.id).toList();
final idsEsperados =
PresetEcualizador.presets
.map((p) => builder.idPresetEq(p.nombre))
.toList();
expect(idsPresets, idsEsperados);
});
test('los nombres de preset están localizados vía nombrePresetVisible, '
'no crudos', () {
final items = itemsEcualizadorAuto(
activo: true,
presetActual: PresetEcualizador.flat,
l10n: l10n,
);
final builder = ConstructorArbolAuto();
final rock = items.firstWhere(
(i) => i.id == builder.idPresetEq(PresetEcualizador.rock.nombre),
);
expect(rock.title, contains(nombrePresetVisible(l10n, 'Rock')));
});
test('con el ecualizador ACTIVO, el preset actual queda marcado y '
'Desactivar NO', () {
final items = itemsEcualizadorAuto(
activo: true,
presetActual: PresetEcualizador.jazz,
l10n: l10n,
);
final builder = ConstructorArbolAuto();
final desactivar = items.firstWhere(
(i) => i.id == ConstructorArbolAuto.idDesactivarEq,
);
final jazz = items.firstWhere(
(i) => i.id == builder.idPresetEq(PresetEcualizador.jazz.nombre),
);
final marcados = items.where((i) => i.title.contains('')).toList();
expect(desactivar.title, isNot(contains('')));
expect(jazz.title, contains(''));
expect(marcados, hasLength(1));
expect(marcados.single.id, jazz.id);
});
test('con el ecualizador DESACTIVADO, Desactivar queda marcado y NINGÚN '
'preset lo está', () {
final items = itemsEcualizadorAuto(
activo: false,
presetActual: PresetEcualizador.rock,
l10n: l10n,
);
final desactivar = items.firstWhere(
(i) => i.id == ConstructorArbolAuto.idDesactivarEq,
);
final marcados = items.where((i) => i.title.contains('')).toList();
expect(desactivar.title, contains(''));
expect(marcados, hasLength(1));
expect(marcados.single.id, ConstructorArbolAuto.idDesactivarEq);
});
test('el label de Desactivar usa l10n.autoEqDisableOption', () {
final items = itemsEcualizadorAuto(
activo: false,
presetActual: PresetEcualizador.flat,
l10n: l10n,
);
final desactivar = items.firstWhere(
(i) => i.id == ConstructorArbolAuto.idDesactivarEq,
);
expect(desactivar.title, contains(l10n.autoEqDisableOption));
});
});
}
@@ -1,5 +1,7 @@
import 'package:audio_session/audio_session.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:pluriwave/servicios/servicio_audio_session.dart';
/// EQ audio-focus re-apply — pure decision predicate truth table.
///
@@ -96,4 +98,192 @@ void main() {
);
});
});
// ── EQ re-apply after a SHORT audio-focus interruption ──────────────────
// debeReaplicarEcualizador only fires on a session-id CHANGE. A short
// transient interruption (a nav-app voice prompt) keeps the SAME player
// session id, so that trigger never fires and the equalizer stays
// silently disabled after Android lets another app's AudioEffect steal
// control. Fix: re-assert the equalizer on resume-from-pause and on
// un-duck too, via a new no-arg ObjetivoAudioInterrumpible.reaplicarEcualizador()
// that the handler implements as a thin delegate to the existing
// _activarEcualizador() (setEnabled + band gains, already correct).
//
// ServicioAudioSession is the orchestration layer under test here (the
// same layer servicio_audio_session_test.dart already covers) -- it is
// fully unit-testable, unlike PluriWaveAudioHandler itself.
group(
'ServicioAudioSession re-applies the equalizer on interruption resume '
'(no session-id change involved)',
() {
test(
'a pause-interruption cycle (begin -> end/resume) calls '
'reaplicarEcualizador exactly once, AFTER reanudar()',
() async {
final objetivo = _ObjetivoFake()
..reproduciendo = true
..intencion = true;
final servicio = ServicioAudioSession(objetivo: objetivo);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(true, AudioInterruptionType.pause),
);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(false, AudioInterruptionType.pause),
);
expect(objetivo.reaplicaciones, 1);
expect(
objetivo.eventos,
['pausar', 'reanudar', 'reaplicar'],
reason:
'the re-apply must happen on RESUME, after reanudar() -- '
'never before, never on the begin/pause side',
);
},
);
test(
'a duck cycle (begin -> end/un-duck) calls reaplicarEcualizador '
'exactly once, AFTER setAtenuado(false)',
() async {
final objetivo = _ObjetivoFake()
..reproduciendo = true
..intencion = true;
final servicio = ServicioAudioSession(objetivo: objetivo);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(true, AudioInterruptionType.duck),
);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(false, AudioInterruptionType.duck),
);
expect(objetivo.reaplicaciones, 1);
expect(
objetivo.eventos,
['atenuado:true', 'atenuado:false', 'reaplicar'],
reason:
'the re-apply must happen on UN-DUCK, after '
'setAtenuado(false)',
);
expect(objetivo.pausas, 0, reason: 'a duck never pauses');
},
);
test(
'with the equalizer switched OFF by the user, an interruption '
'cycle still only calls the SAME parameterless reassert -- '
'ServicioAudioSession has no way to force it on',
() async {
final objetivo = _ObjetivoFake()
..reproduciendo = true
..intencion = true
..eqActivo = false;
final servicio = ServicioAudioSession(objetivo: objetivo);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(true, AudioInterruptionType.pause),
);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(false, AudioInterruptionType.pause),
);
expect(objetivo.reaplicaciones, 1);
expect(
objetivo.estadosReaplicados,
[false],
reason:
'reaplicarEcualizador takes no boolean argument -- it can '
'only ask the handler to reassert whatever state it '
'ALREADY holds, never flip it on',
);
},
);
test(
'end without a prior begin/pause never calls reaplicarEcualizador '
'(mirrors "end sin pausa previa" -- no resume happened)',
() async {
final objetivo = _ObjetivoFake();
final servicio = ServicioAudioSession(objetivo: objetivo);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(false, AudioInterruptionType.pause),
);
expect(objetivo.reaplicaciones, 0);
},
);
test(
'a permanent (unknown-type) focus loss never calls '
'reaplicarEcualizador -- there is no resume to re-assert after',
() async {
final objetivo = _ObjetivoFake()
..reproduciendo = true
..intencion = true;
final servicio = ServicioAudioSession(objetivo: objetivo);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(true, AudioInterruptionType.unknown),
);
await servicio.manejarInterrupcion(
AudioInterruptionEvent(false, AudioInterruptionType.unknown),
);
expect(objetivo.reaplicaciones, 0);
},
);
},
);
}
class _ObjetivoFake implements ObjetivoAudioInterrumpible {
bool intencion = false;
bool reproduciendo = false;
bool eqActivo = true;
int pausas = 0;
int reaplicaciones = 0;
final List<bool> atenuaciones = [];
final List<bool> estadosReaplicados = [];
/// Ordering log shared across every method — proves reaplicarEcualizador
/// fires at the EXACT point in the sequence the fix requires (after
/// reanudar()/setAtenuado(false)), not merely "at some point".
final List<String> eventos = [];
@override
bool get intencionReproducir => intencion;
@override
bool get estaReproduciendo => reproduciendo;
@override
Future<void> pausar() async {
pausas++;
reproduciendo = false;
intencion = false;
eventos.add('pausar');
}
@override
Future<void> reanudar() async {
reproduciendo = true;
intencion = true;
eventos.add('reanudar');
}
@override
Future<void> setAtenuado(bool atenuado) async {
atenuaciones.add(atenuado);
eventos.add('atenuado:$atenuado');
}
@override
Future<void> reaplicarEcualizador() async {
reaplicaciones++;
estadosReaplicados.add(eqActivo);
eventos.add('reaplicar');
}
}
@@ -0,0 +1,139 @@
import 'dart:ui' show Locale;
import 'package:audio_service/audio_service.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/servicios/navegacion_auto.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
/// Item 3 (Android Auto fallback artwork) — the pure, handler-independent
/// half of the fix. `PluriWaveAudioHandler` cannot be instantiated in unit
/// tests (a real `just_audio.AudioPlayer` requires platform MethodChannels,
/// confirmed by `servicio_audio_source_switch_test.dart`), so the actual
/// "now playing" MediaItem construction and the reverse Emisora
/// reconstruction are extracted as pure top-level functions here, exactly
/// like `debeReaplicarEcualizador` was extracted for the EQ re-apply fix.
void main() {
final l10n = lookupAppLocalizations(const Locale('es'));
group('mediaItemParaEmisora (item 3)', () {
test(
'estación SIN favicon usable: el MediaItem usa el fallback de marca '
'(artUriPara), no queda con artUri null',
() {
const emisora = Emisora(
uuid: 'uuid-sin-arte',
nombre: 'Radio sin logo',
url: 'https://stream.demo/sin-logo',
);
final item = mediaItemParaEmisora(emisora, l10n: l10n);
expect(item.artUri, isNotNull);
expect(item.artUri.toString(), artUriPara(emisora));
},
);
test(
'estación CON favicon http(s) usable: el MediaItem sigue usando ese '
'favicon real, no el fallback',
() {
const emisora = Emisora(
uuid: 'uuid-con-arte',
nombre: 'Radio con logo',
url: 'https://stream.demo/con-logo',
favicon: 'https://cdn.example.com/logo.png',
);
final item = mediaItemParaEmisora(emisora, l10n: l10n);
expect(item.artUri.toString(), emisora.favicon);
},
);
test('preserva id, artist y extras.uuid como antes', () {
const emisora = Emisora(
uuid: 'uuid-forma',
nombre: 'Radio Forma',
url: 'https://stream.demo/forma',
pais: 'Argentina',
);
final item = mediaItemParaEmisora(emisora, l10n: l10n);
expect(item.id, emisora.url);
expect(item.artist, 'Argentina');
expect(item.album, 'PluriWave');
expect(item.extras?['uuid'], emisora.uuid);
});
});
group('emisoraDesdeMediaItem (item 3 — no phone-UI regression)', () {
test(
'artUri de marca (android.resource://…, no http) NUNCA se refleja '
'como favicon -- evitaría un intento de red inválido en '
'CachedNetworkImage del lado telefono',
() {
final mediaItem = MediaItem(
id: 'https://stream.demo/sin-logo',
title: 'Radio sin logo',
artUri: Uri.parse(
'android.resource://es.freetimelab.pluriwave/drawable/'
'station_art_aurora',
),
extras: const {'uuid': 'uuid-sin-arte'},
);
final emisora = emisoraDesdeMediaItem(mediaItem);
expect(emisora.favicon, isNull);
},
);
test(
'artUri http(s) real SÍ se refleja como favicon (comportamiento '
'previo preservado)',
() {
final mediaItem = MediaItem(
id: 'https://stream.demo/con-logo',
title: 'Radio con logo',
artUri: Uri.parse('https://cdn.example.com/logo.png'),
extras: const {'uuid': 'uuid-con-arte'},
);
final emisora = emisoraDesdeMediaItem(mediaItem);
expect(emisora.favicon, 'https://cdn.example.com/logo.png');
},
);
test('sin artUri: favicon queda null, sin lanzar', () {
final mediaItem = MediaItem(
id: 'https://stream.demo/sin-arturi',
title: 'Radio',
extras: const {'uuid': 'uuid-x'},
);
final emisora = emisoraDesdeMediaItem(mediaItem);
expect(emisora.favicon, isNull);
});
test('preserva uuid, nombre, url y pais como antes', () {
final mediaItem = MediaItem(
id: 'https://stream.demo/forma',
title: 'Radio Forma',
artist: 'Argentina',
extras: const {'uuid': 'uuid-forma'},
);
final emisora = emisoraDesdeMediaItem(mediaItem);
expect(emisora.uuid, 'uuid-forma');
expect(emisora.nombre, 'Radio Forma');
expect(emisora.url, mediaItem.id);
expect(emisora.pais, 'Argentina');
});
});
}
@@ -33,6 +33,13 @@ class _ObjetivoFake implements ObjetivoAudioInterrumpible {
Future<void> setAtenuado(bool atenuado) async {
atenuaciones.add(atenuado);
}
int reaplicaciones = 0;
@override
Future<void> reaplicarEcualizador() async {
reaplicaciones++;
}
}
/// S3-R1: audio-session interruptions (phone call, transient loss, duck) and
@@ -75,6 +75,42 @@ void main() {
expect(proxima, DateTime(2026, 5, 23, 9));
});
test(
'un registro de fallo de programacion NO omite esa ejecucion (solo '
'skipNext debe hacerlo)',
() {
final alarma = AlarmaMusical(
id: 'a4',
nombre: 'Diaria',
hora: 9,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
);
final ocurrencia = DateTime(2026, 5, 22, 9);
final proxima = servicio.calcularProxima(
alarma: alarma,
desde: DateTime(2026, 5, 22, 8),
excepciones: [
ExcepcionAlarma(
alarmaId: 'a4',
ejecucion: ocurrencia,
tipo: ExcepcionAlarma.tipoFalloProgramacion,
),
],
);
expect(
proxima,
ocurrencia,
reason:
'un fallo de programacion registrado no debe comportarse '
'como un salto de usuario',
);
},
);
test('snooze solo permite 3, 5 o 10 minutos y cae a 5', () {
expect(
servicio.calcularSnooze(DateTime(2026, 5, 21, 7), 10),