Files
pluriwave/openspec/changes/app-quality-and-native-alarms/explore.md
T
FreeTLab f3e9487215 feat(alarms): native reliability fixes and end-to-end snooze
- Use mediaPlayback|systemExempted FGS type with FOREGROUND_SERVICE_SYSTEM_EXEMPTED so alarms fire on Android 14+ (FOREGROUND_SERVICE_ALARM does not exist in the SDK)
- Deduplicate fire notifications: the foreground service FSI notification is the single owner; receiver path removed
- Notification channel v2 with alarm sound URI and USAGE_ALARM attributes, one-time guarded migration from legacy channels
- Pass fallback station through the MethodChannel (NativeAlarmSpec schemaVersion 3) with a three-stage audio chain: primary -> fallback station -> bundled WAV
- Native fade-in volume ramp honoring fadeInSegundos when the app is killed
- Request battery-optimization exemption once, tracked with a persisted asked-once flag
- Fix snooze end-to-end: native ACTION_SNOOZE now reports back to Flutter (snoozed event + cold-start sync), snooze anchor unified to occurrence+minutes on both sides, periodic recalc no longer erases an active snooze
- Add snooze buttons (3/5/10/custom) to the ringing screen with shared audio teardown
- Redesign ringing screen on PluriWaveScaffold with reduced-motion-aware entry animation (new PluriAnimate helper)
- Alarm editor: live next-trigger preview, searchable station pickers (primary and fallback), configurable snooze duration, volume floor down to 0
- New alarm strings localized across all 13 locales
- New unit/widget tests for the snooze flow, alarm bridge payloads, ringing screen and editor (77 tests green)
- SDD artifacts for the app-quality-and-native-alarms change (explore, proposal, spec, design, tasks, apply progress)
2026-06-11 15:33:30 +02:00

95 lines
8.7 KiB
Markdown

# Exploration: App quality and native alarms
Consolidated record of three parallel explorations (alarms/notifications, architecture/robustness, UI/UX) over the PluriWave Flutter app. Findings preserve `file:line` evidence so the proposal and later phases can act without re-discovery.
## Headline
The custom native alarm implementation is the RIGHT architecture and must be kept, not replaced. The work is to close reliability gaps (some CRITICAL on Android 14+), reach UX parity with the native Android Clock app, harden runtime robustness, split a god-class, and run a design-system / a11y / i18n / quality-gate pass. No plugin migration.
## 1. Alarms and notifications
### Why the native implementation stays
The custom native alarm stack already does the hard, correct things:
- Schedules with `setAlarmClock` using `AlarmClockInfo` (survives Doze, shows the system status-bar alarm icon).
- Reschedules from `BOOT_COMPLETED`, `LOCKED_BOOT_COMPLETED`, `MY_PACKAGE_REPLACED`, `TIME_SET`, `TIMEZONE_CHANGED` receivers.
- Uses device-protected storage so alarms survive before first unlock.
- Handles `SCHEDULE_EXACT_ALARM` + `USE_EXACT_ALARM` and the `POST_NOTIFICATIONS` Android 13 flow.
- Plays with `USAGE_ALARM` AudioAttributes, holds a `PARTIAL_WAKE_LOCK` (10 min cap), times out the stream at 15s and falls back to a bundled WAV.
Plugin alternatives were evaluated and rejected: the `alarm` package, `android_alarm_manager_plus` + `flutter_local_notifications`, and `awesome_notifications` all conflict with the radio-as-alarm audio control model. DECISION: keep native, fix gaps.
### Gaps (with severity)
| # | Severity | Gap | Evidence |
|---|----------|-----|----------|
| A1 | CRITICAL | `foregroundServiceType="mediaPlayback"` is missing the `alarm` type and the `FOREGROUND_SERVICE_ALARM` permission. On API 34+ this throws `ForegroundServiceTypeException` and the alarm fails silently. | `AndroidManifest.xml`, `PluriWaveAlarmService.kt` |
| A2 | CRITICAL | Snooze UI is absent on the ringing screen; only "Detener" exists. The native notification already exposes a `Posponer` action, so state diverges from `EstadoAlarmas` until the next sync. | `pantalla_alarma_sonando.dart:168-212` |
| A3 | HIGH | Duplicate FSI notifications: `PluriWaveAlarmReceiver.showFireNotification` (id `59*hash+9`) and `PluriWaveAlarmService` (`NOTIFICATION_ID 92841`) both post simultaneously; `dismissFireNotification` only cancels the receiver's. | `PluriWaveAlarmReceiver.kt`, `PluriWaveAlarmService.kt` |
| A4 | HIGH | Fire channels `pluriwave_alarm_fire` / `pluriwave_alarm_native` are created `IMPORTANCE_HIGH` without `setSound(uri, alarmAudioAttributes)`. Android 8+ locks channel sound at creation time, so the alarm AudioAttributes never apply. | channel creation in native layer |
| A5 | MEDIUM | `emisoraFallback` exists in model/editor/persistence but `ServicioAlarmasAndroid.programar()` never passes it; Kotlin `NativeAlarmSpec` lacks the field. The fallback station is silently ignored natively. | `servicio_alarmas_android.dart`, `NativeAlarmSpec` (Kotlin) |
| A6 | MEDIUM | Native path has no fade-in; `fadeInSegundos` is only honored by `PantallaAlarmaSonando._iniciarFadeIn`. When native plays (screen not foregrounded), audio starts at full volume. | `pantalla_alarma_sonando.dart` `_iniciarFadeIn` |
| A7 | MEDIUM | `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` is declared but never requested in `_solicitarPermisosNecesariosParaAlarma`. | permission flow |
| A8 | LOW | 3 notification channels where 2 would suffice. | native channel setup |
### Inherited risks from `alarm-clock-module`
- `apply-progress.md` states `flutter build` was never run, so the Kotlin native layer has never been compiled or verified. Native changes in this change carry compile risk until a build is run by the user.
- `state.yaml` says `phase: tasks-ready` while `apply-progress.md` exists -> state drift to reconcile at archive time (LOW).
## 2. Architecture and robustness
| # | Severity | Issue | Evidence |
|---|----------|-------|----------|
| B1 | CRITICAL | `audio_session ^0.1.21` declared in pubspec but ZERO imports in `lib/`. No audio-focus handling: phone calls and other apps do not pause the radio. | `pubspec.yaml:19` |
| B2 | CRITICAL | Static broadcast `StreamController` + static `_handlerInstalado` make the service un-testable and globally shared. | `servicio_alarmas_android.dart:117-119` |
| B3 | CRITICAL | `configurarLocalizaciones(l10n)` is called inside `build()`, so it fires on every `notifyListeners` including buffer events (dozens of times per second). | `mini_reproductor.dart:23` |
| B4 | HIGH | `EstadoRadio` is a 1121-line god-class owning 6 services plus direct I/O. `SharedPreferences.getInstance()` appears at 25+ sites incl. `servicio_ecualizador.dart` and `servicio_grabacion_radio.dart`. | `estado_radio.dart`, multiple services |
| B5 | HIGH | `Timer.periodic` 10s vigilance + 60s refresh; `recalcularTodas()` writes SharedPreferences unconditionally every minute. | `estado_alarmas.dart:316-323` |
| B6 | HIGH | `cargar()` runs before every mutation, no cache, racy interleaving (read-modify-write N+1). | `servicio_alarmas.dart:81-108` |
| B7 | HIGH | `unawaited(radio.reproducir)` swallows errors on the path toward the alarm screen. | `app.dart:324` |
| B8 | HIGH | 1391-line settings screen with inline `jsonDecode`/`jsonEncode` backup logic. | `pantalla_ajustes.dart` |
| B9 | MEDIUM | `_ejecucionesEmitidas` is an unbounded `Set` (memory growth over time). | `estado_alarmas.dart:32` |
| B10 | MEDIUM | Empty `catch(_){}` swallowing errors. | `servicio_audio.dart:343,346,406,428,448`; `servicio_grabacion_radio.dart:156,165,177,288` |
| B11 | MEDIUM | Root `context.watch<EstadoRadio>()` forces full-screen rebuilds. | `pantalla_inicio.dart:43`; 6 sites in `pantalla_ajustes` |
| B12 | MEDIUM | Bare `flutter_lints`; no robustness lints enabled. | `analysis_options.yaml` |
| B13 | MEDIUM | Module-level `_handlerGlobal` with assert-only guard. | `servicio_audio.dart:19-32` |
| B14 | LOW | Dead code. | `servicio_timer.dart:82-91` |
| B15 | LOW | Typo shim `agregarEmitoraCustom`. | `estado_radio.dart:887` |
| B16 | LOW | Hardcoded version list. | `servicio_contenido_app.dart:32` |
### Test base
12 test files exist (good base). Top-5 missing tests: `ServicioAlarmas` concurrent read-modify-write, alarm fire dedup across `refrescarProgramacion`, `PluriWaveAudioHandler` rapid source-switch race, export/import round-trip, `ServicioGrabacionRadio` error recovery.
## 3. UI / UX
Foundation is solid: Material 3 + `ThemeExtension` tokens (`PluriWaveTokens` / `PluriWaveMotion`) + `PluriGlassSurface`.
| # | Severity | Issue | Evidence |
|---|----------|-------|----------|
| C1 | HIGH | Ringing screen is a raw `Scaffold` with `Color(0xFF061722)`, a static PNG, no animation, no snooze. | `pantalla_alarma_sonando.dart:155-212` |
| C2 | HIGH | No `Hero` between `TarjetaEmisora` logo and player `_WaveHero`; custom `PageRouteBuilder` needs `HeroFlightShuttleBuilder` care with `BackdropFilter`. | `tarjeta_emisora.dart`, player route |
| C3 | HIGH | 14+ hardcoded `Color(0x...)` literals. | `pantalla_alarmas.dart:94,144,775`; `pluri_wave_scaffold.dart:34-37,48,53`; `pantalla_alarma_sonando.dart:159,167`; `pluri_premium_widgets.dart:41,185`; `tarjeta_emisora.dart:191` |
| C4 | HIGH | Mini favorite `InkWell` 36x36 has no `Semantics` and is below the 48dp target. | `tarjeta_emisora.dart:238-289` |
| C5 | HIGH | Alarm PNG has no `semanticLabel`. | alarm image widgets |
| C6 | MEDIUM | No reduced-motion handling anywhere (`MediaQuery.disableAnimations` ignored). | app-wide |
| C7 | MEDIUM | Alarm editor: no next-trigger preview, dropdown station picker, no section structure, keyboard-overflow risk in sheet. | `pantalla_alarmas.dart:387-636` |
| C8 | LOW-MED | `_fechaCorta` hardcodes DD/MM/YYYY, breaking ja / en-US / ar locales. | `pantalla_alarmas.dart:1114` |
| C9 | LOW | Bare counters without pluralization. | `pantalla_favoritos.dart:138` |
| C10 | LOW | Shimmer sharp corners. | `tarjeta_emisora.dart:389-420` |
| C11 | LOW | Buscar loading spinner inconsistent with shimmer pattern. | `pantalla_buscar.dart:241-245` |
| C12 | LOW | Non-rounded icon variants. | `pantalla_ajustes.dart:985,1028,1031` |
| C13 | LOW | `notificationColor` is the M3 default purple, not brand. | `main.dart:23` |
Dark-only theme: light mode is explicitly OUT OF SCOPE unless requested.
## Recommendation
Proceed to proposal. Ship reliability first (Slice 1), then UX parity (Slice 2), then runtime robustness (Slice 3), then the `EstadoRadio` split (Slice 4), then design/a11y/i18n (Slice 5), then quality gates and tests (Slice 6). Each slice is a chained, PR-sized unit under 400 changed lines. Strict TDD applies via `flutter test`. The user must run `flutter build` to validate the native (Kotlin) layer, since it has never been compiled.
## Ready for Proposal
Yes.