# 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 ```dart // In ServicioAlarmasAndroid.programar(): 'preNoticeTemplate': _textos.preNoticeCountdown('{minutes}'), // Produces e.g. "Starts in {minutes} min" (en), "Empieza en {minutes} min" (es) ``` ### NativeAlarmSpec -- new field ```kotlin 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 ```kotlin const val EXTRA_PRE_NOTICE_TEMPLATE = "preNoticeTemplate" ``` ### ARB key ```json "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.