- 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)
8.7 KiB
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
setAlarmClockusingAlarmClockInfo(survives Doze, shows the system status-bar alarm icon). - Reschedules from
BOOT_COMPLETED,LOCKED_BOOT_COMPLETED,MY_PACKAGE_REPLACED,TIME_SET,TIMEZONE_CHANGEDreceivers. - Uses device-protected storage so alarms survive before first unlock.
- Handles
SCHEDULE_EXACT_ALARM+USE_EXACT_ALARMand thePOST_NOTIFICATIONSAndroid 13 flow. - Plays with
USAGE_ALARMAudioAttributes, holds aPARTIAL_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.mdstatesflutter buildwas 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.yamlsaysphase: tasks-readywhileapply-progress.mdexists -> 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.