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.
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.