14 Commits
Author SHA1 Message Date
FreeTLab d81fabbe27 refactor(iap): make esPremium a required constructor parameter
EstadoAlarmas, EstadoGrabacion and EstadoRadio defaulted `esPremium` to
`() => true`, so any construction site that forgot to wire entitlement
compiled fine and silently ran ungated — failing OPEN to premium and
disabling the paywall with no test able to catch it.

The parameter is now required with no default. Production wiring in
app.dart was already correct and is unchanged; the 184 pre-existing test
call sites now pass `() => true` explicitly, which is exactly the old
implicit default, so every assertion is untouched.

EstadoRadio has no gate of its own but constructs EstadoGrabacion, so it
inherits the same contract.

The one test that existed to pin the old default is renamed to describe
what it still covers (the premium path through iniciar() with no
duracion); its assertions are unchanged.
2026-08-10 22:06:36 +02:00
FreeTLab 9840205a83 fix(alarma-sonando): add the schedule pill and restyle the station name
Audit 9.3 (t4 line 415): the ringing screen never showed its schedule
pill. Reuses the alarmScheduleOnce/alarmScheduleWeekdays ARB keys that
already existed with no consumer, plus a new alarmScheduleDaily key
(translated to all 13 locales) for the recurring-alarm case shown in
the prototype.

Audit 9.7 (t4 line 423): the station name was cardTitle (14.5px/w700),
5.5px and 100 weight units under the prototype's 20px/w800.

Audit 9.9 (snooze tiles as number-over-unit) is intentionally NOT
included in this commit: it would require the tile to stop rendering
as a single flat "{minutes} min" Text node, which is exactly what
pantalla_alarma_sonando_dismiss_guard_test.dart taps via
find.text(l10n.alarmSnoozeOptionLabel(N)) in four places. That file
must stay untouched, so this restyle is deferred pending a decision
on how to restructure the tap target safely.
2026-07-29 23:33:17 +02:00
FreeTLab dcb716cbc3 fix(alarma-sonando): restyle Stop as a neutral translucent surface
Audit 9.11 (t4 line 434): the Stop button was a FilledButton in
colorScheme.primary (brand cyan), the wrong colour family for the
largest element on the ringing screen. The prototype draws a neutral
rgba(255,255,255,.08) surface with a rgba(255,255,255,.16) border and
radius 24 (none of PluriWaveTokens' three named radii).
2026-07-29 23:27:28 +02:00
FreeTLab e1732af222 feat(alarma-sonando): restyle ringing screen, drop live countdown label
Replace the glass-card container with a full-bleed blurred-art
background, giant heroTime display, 3 fixed snooze tiles (3/5/10 min,
one highlighted), and a full-width stop pill. The status label now
also renders a static "Subiendo volumen" line - no seconds counter -
when the alarm has a configured fade-in, per resolution 4: the
native-to-Flutter progress channel a live counter would need is
deliberately absent from this architecture.

The dismiss guard and force-stop retry banner are untouched: the
banner is byte-identical to its pre-restyle form, only repositioned,
and the guard test's own diff against main stays empty.

size:exception: 530 changed lines (440+/90-) against the 200-300
forecast - lib/ alone is 302 lines, at the edge of the band; the two
touched test files account for the rest. Not split further: this is
one cohesive restyle to the single screen in this branch where an
inconsistent intermediate state is least acceptable.
2026-07-29 12:00:59 +02:00
Javier Bautista Fernández 29f7d54e85 fix(alarm): fail-safe alarm system overhaul (SDD alarm-system-overhaul, slice A)
Build & Deploy PluriWave / Análisis de código (push) Successful in 25s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m25s
Root-cause fix for the unstoppable-alarm incident (alarm rang 15 minutes,
only uninstall silenced it) plus systematic hardening of every stop path.

Native (Kotlin):
- Verified stop: stopActiveAlarm now derives its result from the real
  post-teardown state (companion instance + synchronous stopEverything +
  activeRingingId check) instead of reporting unconditional success.
- Atomic teardown: every stop path (stop action, notification button,
  snooze, missed, onDestroy, startForeground failure) funnels through one
  stopEverything() covering audio, wakelock, notification, foreground
  state and firing-record cleanup; player.release() guarded.
- Bounded ringing: 10-minute auto-silence armed via AlarmManager fires a
  FIRED->MISSED transition with a localized missed-alarm notification;
  repeating alarms keep their native rearm, deleted alarms never produce
  ghost MISSED notifications.
- Durable firing record with onStartCommand re-validation (resurrection
  guard) and boot-time stale cleanup; firing records cleared on every
  refuse/mismatch/cancel path.
- New notification-only dismissal channel (dismissAlarmNotificationOnly)
  so UI-level dedup can never kill a live ring's audio.

Flutter (Dart):
- Stop/disable/edit/delete of a ringing alarm always attempt to silence
  it; on native-query failure the stop falls back toward silence via the
  id-scoped legacy stop.
- Verified-stop results surface failures: the ringing screen keeps
  dismiss-by-design on success, but on a verified failure it stays up
  with a persistent force-stop banner (guarded against double-dismiss)
  and auto-dismisses if the ring ends externally (missed/notification).
- Missed events sync alarm bookkeeping without opening the ringing UI.
- 4 new l10n keys translated across all 13 locales (ARB guard green).

550 tests green, analyzer clean. Reviewed in 3 adversarial 4-lens rounds
(2 deterministic + 1 refuter-corroborated critical fixed); formal
gentle-ai receipt waived by maintainer authorization (correction scope
legitimately exceeded the frozen genesis paths). On-device QA checklist
in openspec/changes/alarm-system-overhaul/tasks.md pending before
archive.
2026-07-22 23:52:36 +02:00
FreeTLab 9c7cf4e261 fix(alarm): anchor snooze to the ringing occurrence, never a future one
Build & Deploy PluriWave / Análisis de código (push) Successful in 37s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m49s
posponerAlarma anchored the snooze to snoozeOrigen ?? proximaEjecucion,
but once the native fire path works, the fire-time sync records the
handled occurrence and recalculation advances proximaEjecucion to the
NEXT day before the user can even tap snooze on the still-ringing
screen. "Posponer 3" therefore armed the snooze a full day out
(captured on-device: snoozeCountdown remaining=1443 minutes). The bug
was invisible before because the broken delivery path never advanced
proximaEjecucion while ringing — each fix unmasked the next.

The anchor is now the newest occurrence that is not meaningfully in
the future (shared 90s imminence window): snoozeOrigen for re-snoozes,
proximaEjecucion on the watchdog path where it is still today's
just-due occurrence, ultimaEjecucionGestionada on the native-fire path
where the sync recorded the ringing occurrence, then now. ServicioAlarmas
exposes ahora() so the anchor uses the same injectable clock as the
rest of the scheduling math. Test fixtures that snoozed half an hour
before the ring — a state the ringing screen can never be in, since it
is posponerAlarma's only production caller — now move the clock to
ring time, preserving their original expectations.
2026-07-12 23:17:26 +02:00
FreeTLab bd7f883118 refactor(alarm): make the ringing screen pure UI over a reduced native port
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m40s
PantallaAlarmaSonando no longer owns any audio orchestration (fallback
player, dB ramp, native handoff confirm, media-volume override/restore):
it only calls EstadoAlarmas.finalizarEjecucion/posponerAlarma from
Stop/Snooze/back, keeping the single-exit guard, PopScope back=Stop and
dismiss semantics intact. The status line now reads directly from the
alarm's static config (station name or a neutral label) instead of a
live playback/handoff state.

PuertoAlarmasAndroid drops confirmarAudioFlutter,
forzarVolumenMediaParaAlarma and restaurarVolumenMedia, and app.dart no
longer pre-starts a station before pushing the ring screen. This is the
Dart half of moving to a single native ring-audio owner (WU1 of 2); the
Kotlin service rebuild lands next and keeps this intermediate state
shippable with no double audio.
2026-07-12 11:36:31 +02:00
FreeTLab 2e64740b26 fix(alarm): anchor the fade at alarm time and defer the override to first audio
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m41s
On-device logcat from the latest test showed two defects the previous
design created. The fade-in was gated on the station reaching
`reproduciendo`, and the stream took 18.7 seconds to buffer: the ring
sat frozen at 5% the whole time and the configured fade seconds only
started counting afterwards. And the stream override was raised during
pre-start, so the ExoPlayer AudioTrack spin-up — which runs at gain 1.0
for an instant before the player gain lands — blasted at the configured
ring level, heard as "starts directly at the alarm volume".

The ramp is now anchored at alarm time: it starts when the screen
starts, buffering just joins it at the elapsed level, and the fade
duration means seconds-from-alarm. _iniciarFadeIn is single-start so
the handoff confirmation and fallback paths can no longer restart an
in-progress ramp from 5%. The stream override moved from the app-side
pre-start into the screen and is raised only when audio is actually
about to flow (first `reproduciendo`, the already-playing branch, or
right before the fallback WAV plays), so track spin-up happens under
the user's original low volume and the blast is physically impossible.
Exit teardown restores the device stream before resetting the player
gain, removing the brief exit blip seen in the capture.
2026-07-12 00:35:29 +02:00
FreeTLab 86225cbc68 fix(alarm): scope native stop to the ringing id and intercept system back
Build & Deploy PluriWave / Análisis de código (push) Successful in 36s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m43s
Two exit-path holes found by adversarial review before the next build:

PluriWaveAlarmService.stopAlarm() never compared the requested id to
activeAlarmId, so any stop request for a DIFFERENT alarm tore down
whichever ring was active: with two alarms firing close together, the
second one's routine hide-notification call (via dismissAlarmNotification
-> ACTION_STOP) killed the first alarm mid-ring and prematurely restored
the device volume override. A mismatched id now only cancels that id's
notification and returns; null keeps full-teardown semantics for
internal/onDestroy callers.

The ringing screen never intercepted the system back gesture: a plain
route pop ran only dispose(), leaving the shared radio player ringing
with no alarm UI left anywhere to stop it. Back now routes through
PopScope into the same _detener() flow as the Stop button, guarded by a
single-exit flag so a back-press racing a button tap cannot run the
teardown twice and pop the route underneath.

Also resets the shared handler gain to 1.0 on ring exit: the fade-in
mutates the radio player's persistent volume, and exiting mid-ramp used
to leave every later radio play at the partial ramp level.
2026-07-12 00:04:53 +02:00
FreeTLab 5291221fc8 fix(alarm): cap the ring at the configured volume instead of the device max
Build & Deploy PluriWave / Análisis de código (push) Successful in 35s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m48s
The ring-scoped media override forced STREAM_MUSIC to the hardware
maximum, so the alarm's configured percentage was applied on top of a
maxed speaker: "50%" meant 50% of the phone's absolute maximum and
the fade rode against that ceiling, far louder than the device-
relative level users were used to. On-device logcat also showed the
just_audio player emitting one buffer at volume 1.0 before the 5%
pre-start took effect, a full-scale click on the maxed stream.

The stream is now capped at the alarm's configured volume (still
independent of the device's own level, so it rings at device-volume
0), and the player ramps from ~5% up to its full range under that
cap. Perceived peak is the configured fraction of the device maximum,
reached gradually; the opening click drops to the configured fraction
instead of full scale.
2026-07-11 22:48:56 +02:00
FreeTLab 7eaa87b462 fix(alarm): start the fade-in when pre-started audio is already playing
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m48s
The ringing screen only confirmed the native-to-Flutter handoff from
its playback-state listener, but app.dart pre-starts the station
before pushing the screen, so `reproduciendo` could be emitted before
the listener subscribed and no further event ever arrived. That
branch only cancelled the fallback timer: the gated fade-in never
started and the native alarm player was never told to stop, so the
alarm blared at the alarm-stream volume with no 5%-to-target ramp.
Previously this was a timing race the stream usually lost; gating the
ramp on the confirmation made the failure deterministic.

The already-playing branch now confirms the handoff explicitly
(idempotent with the listener), and the ramp re-imposes its 5% start
volume immediately instead of waiting for the first periodic tick.
Adds the regression test mounting in the real pre-started path.
2026-07-11 11:08:33 +02:00
FreeTLab 79f6f8ef38 fix(alarm): capture alarm state before dispose so its volume restore works
Build & Deploy PluriWave / Análisis de código (push) Successful in 43s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m52s
_restaurarVolumenMediaUnaVez() read the BuildContext to reach the
alarm port, but dispose() runs after the element is defunct, so the
lookup always threw (caught and logged) and the dispose safety-net
never actually restored the media volume when it was the sole exit
path. The state is now captured once in initState and the restore
helper uses the field. Adds the missing dispose-as-sole-caller
regression test.
2026-07-11 10:16:25 +02:00
FreeTLab 66a19525bd fix(alarm): defer the Dart fade-in until the native handoff confirms
Build & Deploy PluriWave / Análisis de código (push) Successful in 37s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m50s
The native service and the Flutter player each ran their own 5%-to-
target fade-in, and both could drive audible volume at the handoff,
producing a jump or ramp reset. The Dart ramp now starts exactly once
from the handoff-confirmation path: the player still pre-starts at 5%,
and _confirmarAudioFlutterListo() starts the ramp in a finally block
so it runs whether the native confirmation succeeds or fails — the
alarm can never stay stuck at 5% if the native side is already gone.

Work unit 3/3 of alarm-volume-ramp-restore (fade-in dedup).
2026-07-11 09:57:06 +02:00
FreeTLab f3e9487215 feat(alarms): native reliability fixes and end-to-end snooze
- Use mediaPlayback|systemExempted FGS type with FOREGROUND_SERVICE_SYSTEM_EXEMPTED so alarms fire on Android 14+ (FOREGROUND_SERVICE_ALARM does not exist in the SDK)
- Deduplicate fire notifications: the foreground service FSI notification is the single owner; receiver path removed
- Notification channel v2 with alarm sound URI and USAGE_ALARM attributes, one-time guarded migration from legacy channels
- Pass fallback station through the MethodChannel (NativeAlarmSpec schemaVersion 3) with a three-stage audio chain: primary -> fallback station -> bundled WAV
- Native fade-in volume ramp honoring fadeInSegundos when the app is killed
- Request battery-optimization exemption once, tracked with a persisted asked-once flag
- Fix snooze end-to-end: native ACTION_SNOOZE now reports back to Flutter (snoozed event + cold-start sync), snooze anchor unified to occurrence+minutes on both sides, periodic recalc no longer erases an active snooze
- Add snooze buttons (3/5/10/custom) to the ringing screen with shared audio teardown
- Redesign ringing screen on PluriWaveScaffold with reduced-motion-aware entry animation (new PluriAnimate helper)
- Alarm editor: live next-trigger preview, searchable station pickers (primary and fallback), configurable snooze duration, volume floor down to 0
- New alarm strings localized across all 13 locales
- New unit/widget tests for the snooze flow, alarm bridge payloads, ringing screen and editor (77 tests green)
- SDD artifacts for the app-quality-and-native-alarms change (explore, proposal, spec, design, tasks, apply progress)
2026-06-11 15:33:30 +02:00