Files
pluriwave/openspec/changes/alarm-live-countdown/design.md
T
FreeTLab bccc5c48b8
Build & Deploy PluriWave / Análisis de código (push) Successful in 38s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m30s
docs(openspec): add SDD artifact trail for recent alarm and EQ changes
Persist the exploration, proposal, spec, design, tasks, and
verify/archive reports produced during the multi-device EQ,
alarm-countdown, and notification-visual-polish SDD cycles.
2026-07-04 12:42:11 +02:00

8.9 KiB

Design: Alarm Live Countdown

Technical Approach

Two isolated changes sharing no code surface: (1) replace hardcoded Spanish pre-notice notification text with a localized, computed-minutes string passed from Flutter at schedule time, and (2) add a canPop() guard to the snooze dismiss path so dead-app launches exit cleanly.

Architecture Decisions

Decision: L10n strategy for native Android notifications

Option Tradeoff Verdict
A. Android strings.xml per locale Standard Android l10n; requires new res infrastructure the project does not use Rejected
B. Pass pre-formatted string via MethodChannel at schedule time Follows existing pattern (title is already localized by Flutter before programar()); zero new infra; string is baked into the PendingIntent extras Chosen
C. Kotlin reads device locale + hardcoded map No MethodChannel change; but duplicates translations outside ARB, drifts over time Rejected

Rationale: The project already localizes alarm titles in ServicioAlarmasAndroid.programar() via localizedAlarmName(_textos, alarma.nombre) and passes them through the MethodChannel. Extending this pattern with a preNoticeText extra is the lowest-friction path. The string is computed once at schedule time and embedded in the PendingIntent Intent extras; when the BroadcastReceiver fires (potentially minutes later, app dead), it reads the pre-baked string. No new dependencies, no strings.xml files.

Decision: New ARB key vs. reusing durationMinutesOnly

Option Tradeoff Verdict
A. Reuse durationMinutesOnly ("{minutes} min") Already translated in 13 locales; but it is a bare duration fragment, not a sentence Rejected for content text
B. New preNoticeCountdown key: "Starts in {minutes} min" Full sentence, proper context for translators; 13 ARB files need one new key each Chosen

Rationale: durationMinutesOnly produces "30 min" -- not a complete notification message. A dedicated key like "Starts in {minutes} min" gives translators sentence context. The {minutes} placeholder reuses the same pattern. The notification action button labels ("Posponer", "Omitir esta vez") are also hardcoded Spanish but are OUT OF SCOPE for this change (noted for a future l10n pass).

Decision: Snooze dismiss guard

Option Tradeoff Verdict
A. Navigator.maybePop() Returns false silently on empty stack; screen stays visible Rejected
B. canPop() + SystemNavigator.pop() fallback Closes the activity when Navigator stack is empty (dead-app FSI launch); app was not user-opened so closing is expected Chosen

Rationale: When PantallaAlarmaSonando launches via full-screen intent from a dead-app state, the Navigator stack has only one route. pop() is a no-op. SystemNavigator.pop() calls Activity.finish() on Android, which is the correct behavior: the user never opened the app manually, so closing the alarm-only activity is the expected UX. This matches Android's own alarm clock dismiss pattern.

Decision: Notification action button l10n (Posponer, Omitir esta vez)

Option Tradeoff Verdict
A. Localize in this change Scope creep; touches PluriWaveAlarmService too Deferred
B. Keep hardcoded Spanish for now Inconsistent with localized content text Accepted (out of scope)

Rationale: The proposal explicitly scopes only the content text and snooze dismiss. Button labels are a separate concern. Noted as tech debt.

Data Flow

Pre-notice notification text (schedule time)

Flutter: programar()
  |-- computes preNoticeText = l10n.preNoticeCountdown(30)
  |-- MethodChannel.invokeMethod("scheduleAlarm", {
  |     ..., "preNoticeText": "Starts in 30 min", ...
  |   })
  v
Kotlin: MainActivity.scheduleAlarm handler
  |-- passes preNoticeText to AlarmScheduler.scheduleAlarm()
  v
Kotlin: AlarmScheduler.schedulePreNotice()
  |-- embeds preNoticeText in PendingIntent Intent extras
  |-- AlarmManager.setExactAndAllowWhileIdle(...)
  v
[30 min later, app may be dead]
  v
Kotlin: PluriWaveAlarmReceiver.onReceive(ACTION_PRE_NOTICE)
  |-- reads preNoticeText from intent.getStringExtra("preNoticeText")
  v
Kotlin: showPreNoticeNotification()
  |-- .setContentText(preNoticeText)  // was: "Empieza en 30 minutos"

Remaining minutes computation

Flutter: programar()
  |-- remainingMinutes = 30  (PRE_NOTICE_MILLIS / 60000)
  |   NOTE: exact remaining time = (triggerAtMillis - preNoticeAtMillis) / 60000
  |         but since preNoticeAtMillis = triggerAtMillis - 30*60*1000,
  |         the value is always 30 at schedule time.
  |
  |   If preNotice fires LATE (device Doze, inexact wakeup), the text
  |   says "30 min" even if only 25 remain. This is acceptable per the
  |   proposal's Approach B (single notification, truthful at post time).
  |
  |   ALTERNATIVE: compute in Kotlin at fire time using
  |   (triggerAtMillis - System.currentTimeMillis()) / 60000.
  |   This is MORE ACCURATE but requires the Kotlin side to format the
  |   string, breaking the l10n-via-MethodChannel pattern.
  |
  |   DECISION: Compute in Kotlin at fire time. Pass a FORMAT TEMPLATE
  |   from Flutter ("Starts in {minutes} min") and do simple string
  |   replacement in Kotlin. This gives accuracy AND l10n.

Refined approach: Flutter passes a format template string with a {minutes} placeholder. Kotlin computes the actual remaining minutes at fire time and replaces the placeholder. This gives both l10n correctness and temporal accuracy.

Flutter: preNoticeTemplate = l10n.preNoticeCountdown('{minutes}')
  --> "Starts in {minutes} min"
  --> MethodChannel extra: "preNoticeTemplate"

Kotlin: showPreNoticeNotification()
  --> val remaining = max(1, (triggerAtMillis - System.currentTimeMillis()) / 60_000)
  --> val text = preNoticeTemplate.replace("{minutes}", remaining.toString())
  --> .setContentText(text)

File Changes

File Action Description
lib/l10n/app_en.arb Modify Add preNoticeCountdown key: "Starts in {minutes} min"
lib/l10n/app_es.arb Modify Add preNoticeCountdown: "Empieza en {minutes} min"
lib/l10n/app_*.arb (11 more) Modify Add translated preNoticeCountdown for ar, bn, de, fr, hi, id, it, ja, pt, ru, zh
lib/servicios/servicio_alarmas_android.dart Modify In programar(), compute preNoticeTemplate from l10n and add to MethodChannel args
android/.../MainActivity.kt Modify Pass preNoticeTemplate through to AlarmScheduler.scheduleAlarm()
android/.../AlarmScheduler.kt Modify Accept preNoticeTemplate param, store in NativeAlarmSpec, embed in pre-notice Intent extras
android/.../PluriWaveAlarmReceiver.kt Modify Read preNoticeTemplate from intent, compute remaining minutes, replace placeholder in showPreNoticeNotification()
lib/pantallas/pantalla_alarma_sonando.dart Modify _posponer() and _detener(): add canPop() guard with SystemNavigator.pop() fallback

Interfaces / Contracts

MethodChannel "scheduleAlarm" -- new parameter

// In ServicioAlarmasAndroid.programar():
'preNoticeTemplate': _textos.preNoticeCountdown('{minutes}'),
// Produces e.g. "Starts in {minutes} min" (en), "Empieza en {minutes} min" (es)

NativeAlarmSpec -- new field

data class NativeAlarmSpec(
    // ... existing fields ...
    val preNoticeTemplate: String?  // nullable for backward compat with persisted v3 specs
)
// Schema version stays at 3 (additive field with null default)

PluriWaveAlarmReceiver -- new intent extra

const val EXTRA_PRE_NOTICE_TEMPLATE = "preNoticeTemplate"

ARB key

"preNoticeCountdown": "Starts in {minutes} min",
"@preNoticeCountdown": {
  "placeholders": { "minutes": {} }
}

Testing Strategy

Layer What to Test Approach
Unit preNoticeCountdown ARB key generates correct string per locale Verify AppLocalizations output for en/es with placeholder
Unit Remaining-minutes clamping (min 1) Kotlin unit test: max(1, (trigger - now) / 60_000) edge cases
Unit canPop() guard logic Widget test: verify SystemNavigator.pop() called when canPop() is false
Integration MethodChannel round-trip of preNoticeTemplate Existing scheduleAlarm test extended with new param

Migration / Rollout

No migration required. The preNoticeTemplate field in NativeAlarmSpec is nullable with a null default. Persisted alarms from previous versions will deserialize with preNoticeTemplate = null; showPreNoticeNotification() falls back to a hardcoded English default "Starts in {minutes} min" when the template is null. On the next scheduleAlarm call from Flutter, the field gets populated.

Open Questions

  • None. All blocking decisions resolved.