Files
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

173 lines
8.9 KiB
Markdown

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