Commit Graph
27 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 aa0b242374 feat(iap): add freemium unlock via one-time in-app purchase
Adds a permanent, non-consumable premium unlock (EstadoEntitlement +
PuertoCompras/ServicioComprasPlayBilling) that removes ads and unlocks
alarm vacations, alarms past a 5-alarm free cap, recording start, and
full Android Auto browsing. The phone equalizer stays free for everyone.

- Entitlement is prefs-backed (compra_premium_v1), fail-open, and
  resolvable headlessly via esPremiumPersistido() for the Android Auto
  audio handler, which registers before runApp.
- Android Auto reduced mode keeps the real root folder labels for free
  users; browsing into any of them (and playFromMediaId/playFromSearch/
  skipToNext/skipToPrevious) is blocked at the getChildren/servicio_audio
  choke points, with a locked "Función Premium" item as the backstop.
  Current-station play/pause/stop stays untouched. A free -> premium
  transition actively invalidates the head unit's cached browse tree.
- Ads (top banner + capped interstitial before adding a station or an
  alarm) are gated behind entitlement via ServicioAnuncios, using
  official Google test ad unit IDs pending AdMob provisioning.
- Alarm cap UX shows an explanatory message with a secondary unlock
  action rather than a bare paywall jump; existing data is grandfathered.
- 4 new localization keys translated across all 13 supported locales.

Co-located tests use strict TDD (RED test before implementation) for
every new pure-logic unit; full existing suite passes unchanged.
2026-08-10 20:37:07 +02:00
FreeTLab 80538900db fix(alarmas,auto): guard the last unguarded snooze path, surface car progress
Continuation of 7054a4c: the native anchor guard alone did not fix the
reported ~1444-minute snooze, because Dart runs AFTERWARDS on the
pre-notice path and had no guard at all.

1. Snooze from the pre-notice notification, root cause.

app.dart dispatches AFTER the receiver's postponeNext already ran and
after startActivity, and EstadoAlarmas.posponerProximaDesdePreaviso took
whatever occurrence it was handed on faith, then persisted and
rescheduled from it -- the last snooze path in the codebase with no
occurrence guard. The occurrence itself is not trustworthy either:
app.dart falls back to alarma.proximaEjecucion when the native event
carries none, and that field can already point at tomorrow.

_ocurrenciaSonando is generalized into _ocurrenciaValida with a caller-
supplied forward allowance and an externally-proposed occurrence that
still has to survive the same check. The pre-notice path gets a
ventanaPreaviso (30 min, matching AlarmScheduler.PRE_NOTICE_MILLIS) --
unlike the ringing-screen guard, this occurrence legitimately has not
happened yet, which is exactly why the existing helper could not just be
reused here.

Also heals state already poisoned by the missing guard: a snoozeHasta
parked past a 3-hour ceiling (posponerEjecucion clamps to 120 minutes,
so anything beyond that is corruption, not a long real snooze) is
dropped on recalculation. Without it, an alarm poisoned on a build
before this fix keeps reporting tomorrow after updating, and the user
reasonably concludes nothing changed.

2. Android Auto: no progress bar or time labels on a local track.

updatePosition was never set anywhere in the handler, so it sat at its
Duration.zero default while copyWith refreshed updateTime to now on every
push -- the car was told "position 0, as of right now" on every event, a
bar pinned at the start regardless of what was actually playing. Now set
from _player.position on both the player-state and buffered-position
listeners (the latter ticks ~2/s, which is what keeps the car's bar
smooth between player-state events). Also stream the MediaItem's
duration once the source reports it -- Auto draws no bar at all without
one, and radio streams correctly keep reporting none (live audio has no
length).

3. Android Auto: drop the Ecualizador browsable folder.

Owner decision after driving with it: a browsable six-preset list is
more interaction than a driver wants, and on/off from all three player
views (already fixed in 7054a4c to win the custom-action slot) is the
only equalizer control that belongs in the car. Preset selection stays on
the phone. This lands back on the redesign mockup's original rule ("sin
carpeta de ecualizador"), now for a road-tested reason. getChildren keeps
answering the folder's id transitionally, since a head unit can have the
old tree cached for a session or two.

The two "raiz always includes/ends with Ecualizador" tests are replaced,
not regressed -- same move the codebase already made once in the other
direction for the same folder.

Tests: 1127 -> 1132.
2026-08-05 23:07:10 +02:00
FreeTLab a9da855601 fix(alarmas): stop Detener from consuming an occurrence that never rang
Reported: an alarm set for Monday 16:20 never rang, and the "next alarm"
banner showed a different alarm (the next morning's) instead. No
vacation range involved, both alarms active.

finalizarEjecucion anchored the completed occurrence to proximaEjecucion
with no check that it was the one actually ringing. On the native-fire
path the fire-time sync advances proximaEjecucion to the NEXT occurrence
before the user can reach the ring screen, so tapping Detener recorded a
FUTURE occurrence in ultimaEjecucionGestionada.
ServicioProgramacionAlarmas._esValida then rejects that occurrence for
real: a Monday-only alarm stopped today simply never rings next Monday,
and every sibling outranks it in the banner because its own
proximaEjecucion is a week out.

Reproduced at its purest in the second test: with nothing ringing at
09:01 on Monday, Detener pushed a 16:20 alarm to the FOLLOWING Monday.

posponerAlarma already had exactly this guard -- 9c7cf4e, "anchor snooze
to the ringing occurrence, never a future one", written after the same
failure showed up as a snooze armed a day out. It was applied to the
snooze path and never to the stop path, which sat ten lines below it in
the same file with the identical hazard.

Both paths now share one _ocurrenciaSonando helper so they cannot drift
apart again, and the reason lives in its doc comment rather than in a
comment on one of the two callers.

Also drops snoozeHasta from the stop path's candidate chain: a pending
snooze target is in the future by definition, and snoozeOrigen already
covers a ring that follows a snooze.

Tests: 1120 -> 1122.
2026-08-03 21:59:19 +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 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 c7e1a212ca fix(vacaciones): edit and delete vacation ranges
Vacaciones ranges could be created but never edited or removed --
EstadoAlarmas already had crearRangoVacaciones/eliminarRangoVacaciones
with no UI affordance reaching them, and no update path at all.

Add EstadoAlarmas.editarRangoVacaciones and wire tap-to-edit /
swipe-to-delete (with confirmation) onto every range card, mirroring
the alarm list's own Dismissible + confirm-dialog pattern exactly. This
covers the active-range hero too: a freshly created range is active
immediately and only ever renders there, never in the
scheduled/past lists, so it needed the same affordances or a user's
very first range could never be fixed.
2026-07-30 19:05:28 +02:00
FreeTLab 9dfcf0b428 feat(vacaciones): add vacation range manager screen
Add the Vacaciones manager screen per design ADR-6: an active-range
hero (name, days-remaining countdown, determinate progress bar, and a
per-alarm pause-impact line), a "PROGRAMADOS" upcoming-ranges list, an
"Add range" CTA, and a "Rangos pasados" history section. This is the
real destination WU8's Alarmas-root summary row pushes to, replacing
WU8's own temporary placeholder (_PantallaVacacionesTemporal, now
deleted).

EstadoAlarmas gains 4 pure query methods (rangoVacacionesActivo,
vacacionesProximas, vacacionesPasadas, impactoDeRango) -- read-only
over _alarmas/_vacaciones, no writes, no rescheduling, no native
bridge calls. impactoDeRango mirrors ServicioProgramacionAlarmas's own
pause predicate exactly, so the screen never disagrees with the
scheduler about which alarms are paused. ImpactoVacaciones joins
RangoVacaciones in alarma_musical.dart.

The add-range form (_EditorVacacionesSheet, _PickerButton) moved
verbatim from pantalla_alarmas.dart to its one remaining consumer.
estado_alarmas.dart's scheduling/snooze paths and the ringing screen's
dismiss guard are untouched; both test files pass unmodified.

New ARB keys (en/es only; other 11 locales are WU18's job):
vacationImpact{Paused,Continues}Label, vacationUpcomingSectionTitle,
vacationPastSectionTitle, addVacationRangeCta,
vacationNoActiveRangeHint.
2026-07-29 10:49:51 +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 6acbd7ca93 fix(alarm): handle snooze reschedule failures instead of silently dropping them
Build & Deploy PluriWave / Análisis de código (push) Successful in 47s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m1s
posponerAlarma() and posponerProximaDesdePreaviso() called the native
scheduler with no error handling, unlike guardarAlarma(). When the
native call failed (e.g. revoked exact-alarm permission), the
exception escaped before notifyListeners() ran, leaving the alarm
list stuck on stale data with no real alarm scheduled and no snooze
countdown notification.

Both methods now mirror guardarAlarma()'s pattern: permission
pre-check, try/catch into _error, and an unconditional
notifyListeners() so the UI always reflects the outcome. Failures
surface via SnackBar in the ringing screen and in app.dart's
postpone-next handler.
2026-07-01 00:22:58 +02:00
Javier Bautista Fernández 481944815f fix(alarm): unfreeze snooze modal and add per-minute snooze countdown
The snooze button was fire-and-forget without error handling: if
posponerAlarma threw (e.g. native scheduleAlarm returns false on a
device without exact-alarm permission), _dismissScreen never ran. The
stuck modal also kept _alarmaSonandoActiva true, which made the next
ring get ignored. _posponer/_detener now dismiss in a finally and stop
audio defensively.

Add a native, AlarmManager-driven snooze countdown notification that
re-posts every minute ("Rings in N min", 3->2->1) while the engine is
dead. scheduleSpec drives scheduleSnoozeCountdown for snoozes (instead
of the 30-min pre-notice). Localized text and button labels travel
Dart->Kotlin as {minutes} templates, same pattern as preNoticeTemplate.

Notification actions: "snooze again" (snoozeAgain, anchored to now)
reports back via the existing snoozed event; "stop" (cancelSnooze)
records a handled occurrence for cold-start reconciliation and emits a
new snoozeCancelled event handled in EstadoAlarmas.

Adds snoozeCountdown/snoozeAgainAction keys across all 13 locales and a
test for the snoozeCancelled event. Kotlin changes are static-reviewed
only; no Android build environment available here.
2026-06-30 15:27:05 +02:00
FreeTLab 079e19f0ee feat(audio): audio session integration and runtime robustness
- Integrate audio_session (new servicio_audio_session.dart): incoming calls pause the radio and resume on end, headphone unplug pauses without auto-resume, permanent focus loss never auto-resumes, duck lowers volume
- Add play-intent flag to ServicioAudio so interruption handling and future reconnect logic can distinguish user pause from system-driven stops
- Eliminate read-modify-write race in ServicioAlarmas with an in-memory cache and single-writer queue across all mutations; recalcularTodas persists only when state actually changed
- Convert ServicioAlarmasAndroid static StreamController/handler to injectable instance fields, restoring test isolation
- Inject a single cached SharedPreferences from main.dart across services and state (removes 23 inline getInstance() calls)
- Move configurarLocalizaciones out of MiniReproductor.build() (was running on every rebuild during playback)
- Bound the alarm fire-dedup set (cap 200 entries, 24h pruning)
- 12 new tests (89 total green), flutter analyze clean
2026-06-11 16:25:09 +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
Javier Bautista Fernández 028e2d69b1 fix(alarms): harden native alarm lifecycle
Build & Deploy PluriWave / Build APK + AAB release (push) Has been cancelled
Build & Deploy PluriWave / Análisis de código (push) Has been cancelled
2026-05-29 13:13:39 +02:00
Javier Bautista Fernández 659e6da189 fix(alarms): harden native playback and pre-notice actions 2026-05-28 12:03:58 +02:00
FreeTLab 896349ad5f feat(app): add onboarding and harden alarms
Build & Deploy Pluriwave / Análisis de código (push) Successful in 21s
Build & Deploy Pluriwave / Build APK + AAB release (push) Failing after 1m6s
2026-05-23 01:22:49 +02:00
FreeTLab cfea818133 fix(alarms): prevent overlapping playback
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 2m0s
Build & Deploy Pluriwave / Análisis de código (push) Successful in 24s
2026-05-22 19:40:09 +02:00
FreeTLab 26078ad49b fix(alarms): skip stale startup executions
Build & Deploy Pluriwave / Análisis de código (push) Successful in 23s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 2m21s
2026-05-22 19:33:21 +02:00
FreeTLab a48dd6ddf9 fix(alarms): refresh next execution reliably
Build & Deploy Pluriwave / Build APK + AAB release (push) Failing after 1m0s
Build & Deploy Pluriwave / Análisis de código (push) Successful in 23s
2026-05-22 18:42:11 +02:00
FreeTLab 809255bd43 fix(recordings): open last file on android
Build & Deploy Pluriwave / Análisis de código (push) Successful in 23s
Build & Deploy Pluriwave / Build APK + AAB release (push) Failing after 1m2s
2026-05-22 18:30:49 +02:00
FreeTLab 4ae93182fa fix(alarm): add due alarm watchdog
Build & Deploy Pluriwave / Análisis de código (push) Successful in 14s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 4m14s
2026-05-22 01:26:36 +02:00
FreeTLab eeadcc1cc6 fix(alarm): improve firing and preferred station
Build & Deploy Pluriwave / Análisis de código (push) Successful in 15s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 4m15s
2026-05-22 01:06:02 +02:00
FreeTLab a3a648c633 feat(alarm): complete musical alarm flows
Build & Deploy Pluriwave / Análisis de código (push) Successful in 15s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 4m21s
2026-05-22 00:40:01 +02:00
FreeTLab fb808ebb60 feat(alarm): add musical alarm foundation
Build & Deploy Pluriwave / Análisis de código (push) Successful in 14s
Build & Deploy Pluriwave / Build APK + AAB release (push) Successful in 2m45s
2026-05-21 23:47:06 +02:00