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.
173 lines
8.9 KiB
Markdown
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.
|