From f3e94872155bd76fc337612c4ca18e6d9154dd19 Mon Sep 17 00:00:00 2001 From: FreeTLab Date: Thu, 11 Jun 2026 15:33:30 +0200 Subject: [PATCH] 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) --- android/app/src/main/AndroidManifest.xml | 3 +- .../freetimelab/pluriwave/AlarmScheduler.kt | 86 +- .../es/freetimelab/pluriwave/MainActivity.kt | 59 +- .../pluriwave/PluriWaveAlarmReceiver.kt | 88 +- .../pluriwave/PluriWaveAlarmService.kt | 228 ++++- lib/app.dart | 14 +- lib/estado/estado_alarmas.dart | 111 ++- lib/l10n/app_ar.arb | 12 + lib/l10n/app_bn.arb | 12 + lib/l10n/app_de.arb | 12 + lib/l10n/app_en.arb | 12 + lib/l10n/app_es.arb | 12 + lib/l10n/app_fr.arb | 12 + lib/l10n/app_hi.arb | 12 + lib/l10n/app_id.arb | 12 + lib/l10n/app_it.arb | 12 + lib/l10n/app_ja.arb | 12 + lib/l10n/app_pt.arb | 12 + lib/l10n/app_ru.arb | 12 + lib/l10n/app_zh.arb | 12 + lib/l10n/gen/app_localizations.dart | 30 + lib/l10n/gen/app_localizations_ar.dart | 17 + lib/l10n/gen/app_localizations_bn.dart | 17 + lib/l10n/gen/app_localizations_de.dart | 17 + lib/l10n/gen/app_localizations_en.dart | 17 + lib/l10n/gen/app_localizations_es.dart | 17 + lib/l10n/gen/app_localizations_fr.dart | 17 + lib/l10n/gen/app_localizations_hi.dart | 17 + lib/l10n/gen/app_localizations_id.dart | 17 + lib/l10n/gen/app_localizations_it.dart | 17 + lib/l10n/gen/app_localizations_ja.dart | 17 + lib/l10n/gen/app_localizations_pt.dart | 17 + lib/l10n/gen/app_localizations_ru.dart | 17 + lib/l10n/gen/app_localizations_zh.dart | 17 + lib/modelos/alarma_musical.dart | 12 +- lib/pantallas/pantalla_alarma_sonando.dart | 73 +- lib/pantallas/pantalla_alarmas.dart | 682 ++++++++----- lib/servicios/servicio_alarmas.dart | 29 +- lib/servicios/servicio_alarmas_android.dart | 74 +- lib/tema/pluri_animate.dart | 38 + .../apply-progress.md | 179 ++++ .../app-quality-and-native-alarms/design.md | 282 ++++++ .../app-quality-and-native-alarms/explore.md | 94 ++ .../app-quality-and-native-alarms/proposal.md | 166 ++++ .../app-quality-and-native-alarms/spec.md | 896 ++++++++++++++++++ .../app-quality-and-native-alarms/state.yaml | 9 + .../app-quality-and-native-alarms/tasks.md | 469 +++++++++ openspec/config.yaml | 2 +- test/estado/estado_alarmas_snooze_test.dart | 251 +++++ test/estado/estado_alarmas_test.dart | 248 ++--- test/helpers/fakes.dart | 23 +- test/helpers/fakes_alarmas.dart | 106 +++ ...pantalla_alarma_sonando_scaffold_test.dart | 147 +++ .../pantalla_alarma_sonando_test.dart | 161 ++++ .../pantalla_alarmas_editor_test.dart | 210 ++++ .../servicio_alarmas_android_test.dart | 109 +++ .../servicio_alarmas_snooze_test.dart | 155 +++ 57 files changed, 4902 insertions(+), 509 deletions(-) create mode 100644 lib/tema/pluri_animate.dart create mode 100644 openspec/changes/app-quality-and-native-alarms/apply-progress.md create mode 100644 openspec/changes/app-quality-and-native-alarms/design.md create mode 100644 openspec/changes/app-quality-and-native-alarms/explore.md create mode 100644 openspec/changes/app-quality-and-native-alarms/proposal.md create mode 100644 openspec/changes/app-quality-and-native-alarms/spec.md create mode 100644 openspec/changes/app-quality-and-native-alarms/state.yaml create mode 100644 openspec/changes/app-quality-and-native-alarms/tasks.md create mode 100644 test/estado/estado_alarmas_snooze_test.dart create mode 100644 test/helpers/fakes_alarmas.dart create mode 100644 test/pantallas/pantalla_alarma_sonando_scaffold_test.dart create mode 100644 test/pantallas/pantalla_alarma_sonando_test.dart create mode 100644 test/pantallas/pantalla_alarmas_editor_test.dart create mode 100644 test/servicios/servicio_alarmas_android_test.dart create mode 100644 test/servicios/servicio_alarmas_snooze_test.dart diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index d1cc818..3acaa76 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ + @@ -53,7 +54,7 @@ diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt index 093ce16..a0fbf62 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt @@ -36,7 +36,10 @@ class AlarmScheduler(private val context: Context) { snoozeOriginMillis: Long? = null, lastHandledAtMillis: Long? = null, soundOnVacation: Boolean = true, - snoozeMinutes: Int = 5 + snoozeMinutes: Int = 5, + fallbackStationName: String? = null, + fallbackStationUrl: String? = null, + fadeInSegundos: Int = 0 ): Boolean { val existing = readSpec(id) val preservedSnooze = preserveNativeSnooze( @@ -62,8 +65,11 @@ class AlarmScheduler(private val context: Context) { snoozeMinutes = sanitizeSnoozeMinutes(snoozeMinutes), stationName = stationName, stationUrl = stationUrl, + fallbackStationName = fallbackStationName, + fallbackStationUrl = fallbackStationUrl, fallbackSound = fallbackSound, volume = volume.coerceIn(0f, 1f), + fadeInSegundos = fadeInSegundos.coerceIn(0, 60), timezoneId = TimeZone.getDefault().id ) return scheduleSpec(spec, persistOnSuccess = true) @@ -251,17 +257,36 @@ class AlarmScheduler(private val context: Context) { } } - fun snooze(id: String, minutes: Int) { - val spec = readSpec(id) ?: return + /** + * Snoozes using the SAME anchor as [postponeNext] (Design 2.2): the + * occurrence time + minutes, clamped to now + minutes when the target is + * already past. Returns the resulting snooze so the caller can report it + * back to Flutter (single source of truth), or null if the spec is gone. + */ + fun snooze(id: String, minutes: Int): NativeSnoozeResult? { + val spec = readSpec(id) ?: return null val safeMinutes = sanitizeSnoozeMinutes(minutes) - val snoozeUntil = System.currentTimeMillis() + safeMinutes * 60_000L + val occurrenceAt = spec.snoozeOriginMillis ?: spec.triggerAtMillis + val target = occurrenceAt + safeMinutes * 60_000L + val now = System.currentTimeMillis() + val snoozeUntil = if (target > now) target else now + safeMinutes * 60_000L + Log.d( + tag, + "alarm.snooze id=$id minutes=$safeMinutes occurrence=$occurrenceAt until=$snoozeUntil" + ) scheduleSpec( spec.copy( snoozeUntilMillis = snoozeUntil, - snoozeOriginMillis = spec.snoozeOriginMillis ?: spec.triggerAtMillis + snoozeOriginMillis = occurrenceAt, + snoozeMinutes = safeMinutes ), persistOnSuccess = true ) + return NativeSnoozeResult( + snoozeUntilMillis = snoozeUntil, + occurrenceAtMillis = occurrenceAt, + title = spec.title + ) } fun postponeNext(id: String, minutes: Int): Long? { @@ -339,6 +364,26 @@ class AlarmScheduler(private val context: Context) { ) } + /** + * Active (future) native snoozes from the persisted specs, used by the + * Flutter cold-start sync (Decision 2.1, engine-dead case) so a snooze + * performed while the engine was down is imported on the next launch. + */ + fun nativeSnoozeStates(): List> { + val now = System.currentTimeMillis() + return prefs().getStringSet(KEY_IDS, emptySet()).orEmpty() + .mapNotNull { id -> + val spec = readSpec(id) ?: return@mapNotNull null + val snoozeUntil = spec.snoozeUntilMillis ?: return@mapNotNull null + if (snoozeUntil <= now) return@mapNotNull null + mapOf( + "alarmId" to spec.id, + "snoozeUntilMillis" to snoozeUntil, + "snoozeOriginMillis" to (spec.snoozeOriginMillis ?: spec.triggerAtMillis) + ) + } + } + private fun preserveNativeSnooze( existing: NativeAlarmSpec?, requestedTriggerAtMillis: Long, @@ -494,8 +539,17 @@ class AlarmScheduler(private val context: Context) { putExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE, spec.title) putExtra(PluriWaveAlarmReceiver.EXTRA_STATION_NAME, spec.stationName) putExtra(PluriWaveAlarmReceiver.EXTRA_STATION_URL, spec.stationUrl) + putExtra( + PluriWaveAlarmReceiver.EXTRA_FALLBACK_STATION_NAME, + spec.fallbackStationName + ) + putExtra( + PluriWaveAlarmReceiver.EXTRA_FALLBACK_STATION_URL, + spec.fallbackStationUrl + ) putExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_SOUND, spec.fallbackSound) putExtra(PluriWaveAlarmReceiver.EXTRA_VOLUME, spec.volume) + putExtra(PluriWaveAlarmReceiver.EXTRA_FADE_IN_SECONDS, spec.fadeInSegundos) putExtra(PluriWaveAlarmReceiver.EXTRA_TRIGGER_AT, spec.triggerAtMillis) putExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, spec.snoozeMinutes) putExtra( @@ -568,6 +622,13 @@ class AlarmScheduler(private val context: Context) { private fun requestCode(id: String, slot: Int): Int = 31 * id.hashCode() + slot + /** Result of a native snooze, reported back to Flutter via alarmFired. */ + data class NativeSnoozeResult( + val snoozeUntilMillis: Long, + val occurrenceAtMillis: Long, + val title: String + ) + private data class NativeAlarmSpec( val id: String, val title: String, @@ -586,12 +647,15 @@ class AlarmScheduler(private val context: Context) { val snoozeMinutes: Int, val stationName: String?, val stationUrl: String?, + val fallbackStationName: String? = null, + val fallbackStationUrl: String? = null, val fallbackSound: String?, val volume: Float, + val fadeInSegundos: Int = 0, val timezoneId: String ) { fun toJson(): JSONObject = JSONObject().apply { - put("schemaVersion", 2) + put("schemaVersion", 3) put("id", id) put("title", title) put("enabled", enabled) @@ -609,8 +673,11 @@ class AlarmScheduler(private val context: Context) { put("snoozeMinutes", snoozeMinutes) put("stationName", stationName) put("stationUrl", stationUrl) + put("fallbackStationName", fallbackStationName) + put("fallbackStationUrl", fallbackStationUrl) put("fallbackSound", fallbackSound) put("volume", volume) + put("fadeInSegundos", fadeInSegundos) put("timezoneId", timezoneId) } @@ -639,8 +706,15 @@ class AlarmScheduler(private val context: Context) { }, stationName = json.optString("stationName").takeIf { it.isNotBlank() }, stationUrl = json.optString("stationUrl").takeIf { it.isNotBlank() }, + // schemaVersion 2 specs lack the v3 fields; default to null/0 + // so persisted alarms keep working after the upgrade. + fallbackStationName = json.optString("fallbackStationName") + .takeIf { it.isNotBlank() }, + fallbackStationUrl = json.optString("fallbackStationUrl") + .takeIf { it.isNotBlank() }, fallbackSound = json.optString("fallbackSound").takeIf { it.isNotBlank() }, volume = json.optDouble("volume", 0.85).toFloat(), + fadeInSegundos = json.optInt("fadeInSegundos", 0).coerceIn(0, 60), timezoneId = json.optString("timezoneId", TimeZone.getDefault().id) ) } diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt index 86d6fd7..c5fe9a4 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt @@ -100,7 +100,10 @@ class MainActivity : AudioServiceActivity() { snoozeOriginMillis = call.argument("snoozeOriginMillis"), lastHandledAtMillis = call.argument("lastHandledAtMillis"), soundOnVacation = call.argument("soundOnVacation") ?: true, - snoozeMinutes = call.argument("snoozeMinutes") ?: 5 + snoozeMinutes = call.argument("snoozeMinutes") ?: 5, + fallbackStationName = call.argument("fallbackStationName"), + fallbackStationUrl = call.argument("fallbackStationUrl"), + fadeInSegundos = call.argument("fadeInSegundos") ?: 0 ) result.success(scheduled) } @@ -172,6 +175,10 @@ class MainActivity : AudioServiceActivity() { Log.d(tag, "alarm.channel requestFullScreenIntentPermission") result.success(requestFullScreenIntentPermission()) } + "requestIgnoreBatteryOptimizations" -> { + Log.d(tag, "alarm.channel requestIgnoreBatteryOptimizations") + result.success(requestIgnoreBatteryOptimizations()) + } "getInitialAlarmIntent" -> { val payload = alarmPayload(intent) Log.d(tag, "alarm.channel getInitialAlarmIntent payload=$payload") @@ -182,9 +189,14 @@ class MainActivity : AudioServiceActivity() { Log.d(tag, "alarm.channel getHandledAlarmOccurrences") result.success(alarmScheduler.handledOccurrences()) } + "getNativeSnoozeState" -> { + Log.d(tag, "alarm.channel getNativeSnoozeState") + result.success(alarmScheduler.nativeSnoozeStates()) + } else -> result.notImplemented() } } + activeInstance = this MethodChannel( flutterEngine.dartExecutor.binaryMessenger, @@ -310,6 +322,21 @@ class MainActivity : AudioServiceActivity() { return powerManager.isIgnoringBatteryOptimizations(packageName) } + private fun requestIgnoreBatteryOptimizations(): Boolean { + if (isIgnoringBatteryOptimizations()) return true + return try { + startActivity( + Intent(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS).apply { + data = Uri.parse("package:$packageName") + } + ) + true + } catch (error: Throwable) { + Log.e(tag, "alarm.channel requestIgnoreBatteryOptimizations failed", error) + false + } + } + private fun openDirectory(path: String): Boolean { val folder = File(path) if (!folder.exists()) { @@ -575,7 +602,37 @@ class MainActivity : AudioServiceActivity() { } override fun onDestroy() { + if (activeInstance === this) { + activeInstance = null + } stopVisualizer() super.onDestroy() } + + companion object { + private const val STATIC_TAG = "PluriWave" + + /** alarmAction reported when the native service snoozed by itself. */ + const val ALARM_ACTION_SNOOZED = "snoozed" + + @Volatile + private var activeInstance: MainActivity? = null + + /** + * Bridge for components without an activity (PluriWaveAlarmService): + * forwards alarm events through the existing alarmFired MethodChannel + * when the Flutter engine is alive; no-op when dead — the cold-start + * sync (getNativeSnoozeState) covers that case (Decision 2.1). + */ + fun notifyAlarmEvent(payload: Map) { + val activity = activeInstance + if (activity == null) { + Log.d(STATIC_TAG, "alarm.channel notifyAlarmEvent skipped (engine dead)") + return + } + activity.mainHandler.post { + activity.alarmMethodChannel?.invokeMethod("alarmFired", payload) + } + } + } } diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt index fbab194..b4acf3b 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt @@ -34,7 +34,9 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() { putExtra(EXTRA_OCCURRENCE_AT, intent.getLongExtra(EXTRA_OCCURRENCE_AT, 0L)) putExtra(EXTRA_SNOOZE_MINUTES, snoozeMinutes) } - showFireNotification(context, alarmId, title, launch, snoozeMinutes) + // The service's startForeground notification (single FSI owner) is + // posted by PluriWaveAlarmService.start above; the receiver must NOT + // post a duplicate fire notification. try { context.startActivity(launch) Log.d(TAG, "alarm.receiver fire startActivity OK id=$alarmId") @@ -92,46 +94,6 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() { } } - private fun showFireNotification( - context: Context, - alarmId: String, - title: String, - launch: Intent, - snoozeMinutes: Int - ) { - ensureFireChannel(context) - val fullScreenIntent = PendingIntent.getActivity( - context, - requestCode(alarmId, 10), - launch, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - val notification = NotificationCompat.Builder(context, FIRE_CHANNEL_ID) - .setSmallIcon(android.R.drawable.ic_lock_idle_alarm) - .setContentTitle("Alarma PluriWave") - .setContentText(title) - .setCategory(NotificationCompat.CATEGORY_ALARM) - .setPriority(NotificationCompat.PRIORITY_MAX) - .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) - .setOngoing(true) - .setAutoCancel(false) - .setContentIntent(fullScreenIntent) - .setFullScreenIntent(fullScreenIntent, true) - .addAction(0, "Posponer", snoozePendingIntent(context, alarmId, snoozeMinutes)) - .addAction(0, "Detener", stopPendingIntent(context, alarmId)) - .build() - - try { - NotificationManagerCompat.from(context).notify( - fireNotificationIdForAlarm(alarmId), - notification, - ) - Log.d(TAG, "alarm.notification fire shown id=$alarmId") - } catch (error: SecurityException) { - Log.e(TAG, "alarm.notification fire SecurityException id=$alarmId", error) - } - } - private fun showPreNoticeNotification( context: Context, alarmId: String, @@ -200,23 +162,6 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() { } } - private fun ensureFireChannel(context: Context) { - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return - val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val existing = manager.getNotificationChannel(FIRE_CHANNEL_ID) - if (existing != null) return - - val channel = NotificationChannel( - FIRE_CHANNEL_ID, - "Alarmas sonando", - NotificationManager.IMPORTANCE_HIGH - ).apply { - description = "Pantalla urgente cuando una alarma musical debe sonar" - enableVibration(true) - } - manager.createNotificationChannel(channel) - } - private fun ensureChannel(context: Context) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager @@ -240,33 +185,9 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() { private fun sanitizeSnoozeMinutes(minutes: Int): Int = if (minutes == 3 || minutes == 5 || minutes == 10) minutes else 5 - private fun snoozePendingIntent(context: Context, alarmId: String, minutes: Int): PendingIntent = - PendingIntent.getService( - context, - requestCode(alarmId, 20 + minutes), - Intent(context, PluriWaveAlarmService::class.java).apply { - action = PluriWaveAlarmService.ACTION_SNOOZE - putExtra(EXTRA_ALARM_ID, alarmId) - putExtra(PluriWaveAlarmService.EXTRA_SNOOZE_MINUTES, minutes) - }, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - - private fun stopPendingIntent(context: Context, alarmId: String): PendingIntent = - PendingIntent.getService( - context, - requestCode(alarmId, 40), - Intent(context, PluriWaveAlarmService::class.java).apply { - action = PluriWaveAlarmService.ACTION_STOP - putExtra(EXTRA_ALARM_ID, alarmId) - }, - PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE - ) - companion object { const val TAG = "PluriWave" const val CHANNEL_ID = "pluriwave_alarm_pre_notice" - const val FIRE_CHANNEL_ID = "pluriwave_alarm_fire" const val ACTION_FIRE = "es.freetimelab.pluriwave.alarm.FIRE" const val ACTION_PRE_NOTICE = "es.freetimelab.pluriwave.alarm.PRE_NOTICE" const val ACTION_SKIP_NEXT = "es.freetimelab.pluriwave.alarm.SKIP_NEXT" @@ -276,8 +197,11 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() { const val EXTRA_ALARM_ACTION = "alarmAction" const val EXTRA_STATION_NAME = "stationName" const val EXTRA_STATION_URL = "stationUrl" + const val EXTRA_FALLBACK_STATION_NAME = "fallbackStationName" + const val EXTRA_FALLBACK_STATION_URL = "fallbackStationUrl" const val EXTRA_FALLBACK_SOUND = "fallbackSound" const val EXTRA_VOLUME = "volume" + const val EXTRA_FADE_IN_SECONDS = "fadeInSegundos" const val EXTRA_TRIGGER_AT = "triggerAtMillis" const val EXTRA_OCCURRENCE_AT = "occurrenceAtMillis" const val EXTRA_SNOOZE_MINUTES = "snoozeMinutes" diff --git a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt index feb434c..3380698 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmService.kt @@ -6,6 +6,7 @@ import android.app.PendingIntent import android.app.Service import android.content.Context import android.content.Intent +import android.content.pm.ServiceInfo import android.media.AudioAttributes import android.media.MediaPlayer import android.net.Uri @@ -14,18 +15,31 @@ import android.os.Handler import android.os.IBinder import android.os.Looper import android.os.PowerManager +import android.provider.Settings import android.util.Log import androidx.core.app.NotificationCompat import androidx.core.app.NotificationManagerCompat import androidx.core.content.ContextCompat import java.io.File +/** + * Foreground service that owns native alarm audio and the single ringing + * notification (NOTIFICATION_ID, full-screen intent). + * + * Fade-in ownership boundary: this service ramps volume ONLY for its own + * MediaPlayer audio (station stream, fallback station or bundled WAV). The + * Flutter ringing screen owns the fade for the audio it starts itself + * (radio handler / local fallback player). They never play the same source + * simultaneously: the service stops once Flutter confirms its own audio via + * confirmFlutterAudio. + */ class PluriWaveAlarmService : Service() { private var player: MediaPlayer? = null private var wakeLock: PowerManager.WakeLock? = null private var activeAlarmId: String? = null private val mainHandler = Handler(Looper.getMainLooper()) private var stationFallbackRunnable: Runnable? = null + private var fadeInRunnable: Runnable? = null override fun onBind(intent: Intent?): IBinder? = null @@ -42,7 +56,23 @@ class PluriWaveAlarmService : Service() { ACTION_SNOOZE -> { val minutes = intent.getIntExtra(EXTRA_SNOOZE_MINUTES, 5) if (requestedId != null) { - AlarmScheduler(this).snooze(requestedId, minutes) + val snoozed = AlarmScheduler(this).snooze(requestedId, minutes) + if (snoozed != null) { + // D1 fix (Decision 2.1): report the native snooze back to + // Flutter so the canonical config records it. If the engine + // is dead this is a no-op and the cold-start sync + // (getNativeSnoozeState) reconciles on next launch. + MainActivity.notifyAlarmEvent( + mapOf( + "alarmId" to requestedId, + "alarmTitle" to snoozed.title, + "alarmAction" to MainActivity.ALARM_ACTION_SNOOZED, + "occurrenceAtMillis" to snoozed.occurrenceAtMillis, + "snoozeUntilMillis" to snoozed.snoozeUntilMillis, + "snoozeMinutes" to minutes + ) + ) + } } stopAlarm(requestedId) return START_NOT_STICKY @@ -64,15 +94,33 @@ class PluriWaveAlarmService : Service() { val title = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_ALARM_TITLE) ?: "PluriWave" val stationName = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_STATION_NAME) val stationUrl = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_STATION_URL) + val fallbackStationName = + intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_STATION_NAME) + val fallbackStationUrl = + intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_STATION_URL) val fallbackSound = intent.getStringExtra(PluriWaveAlarmReceiver.EXTRA_FALLBACK_SOUND) val volume = intent.getFloatExtra(PluriWaveAlarmReceiver.EXTRA_VOLUME, 0.85f).coerceIn(0f, 1f) + val fadeInSegundos = + intent.getIntExtra(PluriWaveAlarmReceiver.EXTRA_FADE_IN_SECONDS, 0).coerceIn(0, 60) val snoozeMinutes = sanitizeSnoozeMinutes( intent.getIntExtra(PluriWaveAlarmReceiver.EXTRA_SNOOZE_MINUTES, 5) ) acquireWakeLock() + // The FSI notification must be visible BEFORE audio prepares (prepareAsync is + // slow); startForeground runs first so the ringing surface never lags audio. try { - startForeground(NOTIFICATION_ID, buildNotification(alarmId, title, stationName, snoozeMinutes)) + val notification = buildNotification(alarmId, title, stationName, snoozeMinutes) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE) { + startForeground( + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or + ServiceInfo.FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED + ) + } else { + startForeground(NOTIFICATION_ID, notification) + } } catch (error: Throwable) { Log.e(TAG, "alarm.service startForeground failed id=$alarmId", error) releaseWakeLock() @@ -80,46 +128,85 @@ class PluriWaveAlarmService : Service() { stopSelf() return } - startAudio(alarmId, stationName, stationUrl, fallbackSound, volume) + startAudio( + alarmId, + stationName, + stationUrl, + fallbackStationName, + fallbackStationUrl, + fallbackSound, + volume, + fadeInSegundos + ) } private fun startAudio( alarmId: String, stationName: String?, stationUrl: String?, + fallbackStationName: String?, + fallbackStationUrl: String?, fallbackSound: String?, - volume: Float + volume: Float, + fadeInSegundos: Int ) { player?.release() player = null - if (!stationUrl.isNullOrBlank()) { - startStationAudio( - alarmId, - stationName, - stationUrl.trim(), - fallbackSound, - volume - ) - return + // Three-stage ordered fallback: primary station -> fallback station -> bundled WAV. + // Each stage owns its own 15s timeout window via scheduleStationFallback. + val startBundled: (String) -> Unit = { reason -> + startFallbackAudio(alarmId, fallbackSound, volume, fadeInSegundos, reason) + } + val startFallbackStation: (String) -> Unit = { reason -> + if (fallbackStationUrl.isNullOrBlank()) { + startBundled(reason) + } else { + startStationAudio( + alarmId, + fallbackStationName, + fallbackStationUrl.trim(), + volume, + fadeInSegundos, + "fallback-station", + startBundled + ) + } } - startFallbackAudio(alarmId, fallbackSound, volume, "station url missing") + if (stationUrl.isNullOrBlank()) { + startFallbackStation("station url missing") + return + } + startStationAudio( + alarmId, + stationName, + stationUrl.trim(), + volume, + fadeInSegundos, + "station", + startFallbackStation + ) } private fun startStationAudio( alarmId: String, stationName: String?, stationUrl: String, - fallbackSound: String?, - volume: Float + volume: Float, + fadeInSegundos: Int, + stage: String, + onStageFailed: (String) -> Unit ) { - scheduleStationFallback(alarmId, fallbackSound, volume) + player?.release() + player = null + scheduleStationFallback(alarmId, stage, onStageFailed) + val startVolume = initialVolume(volume, fadeInSegundos) try { player = MediaPlayer().apply { setAudioAttributes(alarmAudioAttributes()) isLooping = false - setVolume(volume, volume) + setVolume(startVolume, startVolume) setDataSource( this@PluriWaveAlarmService, Uri.parse(stationUrl), @@ -129,33 +216,34 @@ class PluriWaveAlarmService : Service() { if (activeAlarmId != alarmId) return@setOnPreparedListener cancelStationFallback() it.start() + startFadeIn(alarmId, it, volume, fadeInSegundos) Log.d( TAG, - "alarm.service station started id=$alarmId station=$stationName url=$stationUrl" + "alarm.service $stage started id=$alarmId station=$stationName url=$stationUrl" ) } setOnCompletionListener { if (activeAlarmId != alarmId) return@setOnCompletionListener - Log.w(TAG, "alarm.service station completed id=$alarmId url=$stationUrl") - startFallbackAudio(alarmId, fallbackSound, volume, "station completed") + Log.w(TAG, "alarm.service $stage completed id=$alarmId url=$stationUrl") + onStageFailed("$stage completed") } setOnErrorListener { mp, what, extra -> Log.e( TAG, - "alarm.service station error id=$alarmId what=$what extra=$extra url=$stationUrl" + "alarm.service $stage error id=$alarmId what=$what extra=$extra url=$stationUrl" ) runCatching { mp.reset() } if (activeAlarmId == alarmId) { - startFallbackAudio(alarmId, fallbackSound, volume, "station error") + onStageFailed("$stage error") } true } prepareAsync() } - Log.d(TAG, "alarm.service station preparing id=$alarmId station=$stationName url=$stationUrl") + Log.d(TAG, "alarm.service $stage preparing id=$alarmId station=$stationName url=$stationUrl") } catch (error: Throwable) { - Log.e(TAG, "alarm.service station prepare failed id=$alarmId url=$stationUrl", error) - startFallbackAudio(alarmId, fallbackSound, volume, "station prepare failed") + Log.e(TAG, "alarm.service $stage prepare failed id=$alarmId url=$stationUrl", error) + onStageFailed("$stage prepare failed") } } @@ -163,6 +251,7 @@ class PluriWaveAlarmService : Service() { alarmId: String, fallbackSound: String?, volume: Float, + fadeInSegundos: Int, reason: String ) { cancelStationFallback() @@ -170,15 +259,17 @@ class PluriWaveAlarmService : Service() { player = null val source = fallbackAssetPath(fallbackSound) + val startVolume = initialVolume(volume, fadeInSegundos) try { player = MediaPlayer().apply { setAudioAttributes(alarmAudioAttributes()) isLooping = true - setVolume(volume, volume) + setVolume(startVolume, startVolume) setFallbackAssetDataSource(this, fallbackSound) setOnPreparedListener { if (activeAlarmId != alarmId) return@setOnPreparedListener it.start() + startFadeIn(alarmId, it, volume, fadeInSegundos) Log.d(TAG, "alarm.service fallback started id=$alarmId source=$source reason=$reason") } setOnErrorListener { mp, what, extra -> @@ -196,20 +287,61 @@ class PluriWaveAlarmService : Service() { private fun scheduleStationFallback( alarmId: String, - fallbackSound: String?, - volume: Float + stage: String, + onStageFailed: (String) -> Unit ) { cancelStationFallback() val runnable = Runnable { if (activeAlarmId == alarmId) { - Log.w(TAG, "alarm.service station timeout id=$alarmId; using fallback") - startFallbackAudio(alarmId, fallbackSound, volume, "station timeout") + Log.w(TAG, "alarm.service $stage timeout id=$alarmId; advancing audio chain") + onStageFailed("$stage timeout") } } stationFallbackRunnable = runnable mainHandler.postDelayed(runnable, STATION_START_TIMEOUT_MILLIS) } + private fun initialVolume(volume: Float, fadeInSegundos: Int): Float = + if (fadeInSegundos > 0) { + (volume * FADE_IN_START_FRACTION).coerceIn(0f, 1f) + } else { + volume + } + + private fun startFadeIn( + alarmId: String, + mediaPlayer: MediaPlayer, + targetVolume: Float, + fadeInSegundos: Int + ) { + cancelFadeIn() + if (fadeInSegundos <= 0) return + val steps = ((fadeInSegundos * 1000L) / FADE_IN_STEP_MILLIS).toInt().coerceAtLeast(1) + val startVolume = initialVolume(targetVolume, fadeInSegundos) + var step = 0 + val runnable = object : Runnable { + override fun run() { + if (activeAlarmId != alarmId) return + step++ + val fraction = step.toFloat() / steps + val current = (startVolume + (targetVolume - startVolume) * fraction) + .coerceIn(0f, 1f) + runCatching { mediaPlayer.setVolume(current, current) } + if (step < steps) { + mainHandler.postDelayed(this, FADE_IN_STEP_MILLIS) + } + } + } + fadeInRunnable = runnable + mainHandler.postDelayed(runnable, FADE_IN_STEP_MILLIS) + Log.d(TAG, "alarm.service fade-in started id=$alarmId seconds=$fadeInSegundos steps=$steps") + } + + private fun cancelFadeIn() { + fadeInRunnable?.let { mainHandler.removeCallbacks(it) } + fadeInRunnable = null + } + private fun cancelStationFallback() { stationFallbackRunnable?.let { mainHandler.removeCallbacks(it) } stationFallbackRunnable = null @@ -224,6 +356,7 @@ class PluriWaveAlarmService : Service() { private fun stopAlarm(alarmId: String?) { Log.d(TAG, "alarm.service stop id=$alarmId active=$activeAlarmId") cancelStationFallback() + cancelFadeIn() try { player?.stop() } catch (error: Throwable) { @@ -371,12 +504,18 @@ class PluriWaveAlarmService : Service() { companion object { private const val TAG = "PluriWave" - private const val CHANNEL_ID = "pluriwave_alarm_native" + private const val CHANNEL_ID = "pluriwave_alarm_fire_v2" + private const val LEGACY_CHANNEL_NATIVE = "pluriwave_alarm_native" + private const val LEGACY_CHANNEL_FIRE = "pluriwave_alarm_fire" + private const val CHANNELS_PREFS = "pluriwave_alarm_channels" + private const val KEY_CHANNELS_MIGRATED_V2 = "channels_migrated_v2" private const val NOTIFICATION_ID = 92841 const val ACTION_STOP = "es.freetimelab.pluriwave.alarm.STOP_NATIVE" const val ACTION_SNOOZE = "es.freetimelab.pluriwave.alarm.SNOOZE_NATIVE" const val EXTRA_SNOOZE_MINUTES = "snoozeMinutes" private const val STATION_START_TIMEOUT_MILLIS = 15_000L + private const val FADE_IN_STEP_MILLIS = 250L + private const val FADE_IN_START_FRACTION = 0.05f fun start(context: Context, source: Intent) { ensureChannel(context) @@ -413,18 +552,39 @@ class PluriWaveAlarmService : Service() { private fun ensureChannel(context: Context) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + migrateLegacyChannels(context, manager) if (manager.getNotificationChannel(CHANNEL_ID) != null) return val channel = NotificationChannel( CHANNEL_ID, - "Alarma musical", + "Alarmas sonando", NotificationManager.IMPORTANCE_HIGH ).apply { - description = "Sonido de alarma musical con pantalla apagada" + description = "Sonido y pantalla urgente cuando una alarma musical debe sonar" enableVibration(true) + setSound( + Settings.System.DEFAULT_ALARM_ALERT_URI, + AudioAttributes.Builder() + .setUsage(AudioAttributes.USAGE_ALARM) + .setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION) + .build() + ) } manager.createNotificationChannel(channel) } + // Android locks channel sound at creation time; the only way to apply + // USAGE_ALARM on existing installs is deleting the legacy channels and + // recreating under the versioned id. Runs once, guarded by a flag. + private fun migrateLegacyChannels(context: Context, manager: NotificationManager) { + val prefs = context.createDeviceProtectedStorageContext() + .getSharedPreferences(CHANNELS_PREFS, Context.MODE_PRIVATE) + if (prefs.getBoolean(KEY_CHANNELS_MIGRATED_V2, false)) return + runCatching { manager.deleteNotificationChannel(LEGACY_CHANNEL_NATIVE) } + runCatching { manager.deleteNotificationChannel(LEGACY_CHANNEL_FIRE) } + prefs.edit().putBoolean(KEY_CHANNELS_MIGRATED_V2, true).apply() + Log.d(TAG, "alarm.service legacy notification channels migrated to v2") + } + private fun requestCode(id: String, slot: Int): Int = 67 * id.hashCode() + slot } } diff --git a/lib/app.dart b/lib/app.dart index 9611d15..639fa0f 100644 --- a/lib/app.dart +++ b/lib/app.dart @@ -211,6 +211,11 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> { } Future _abrirAlarmaSonando(EventoAlarmaAndroid evento) async { + if (evento.accion == EventoAlarmaAndroid.accionSnoozed) { + // EstadoAlarmas records native snoozes itself (Decision 2.1); there is + // nothing to open for this event. + return; + } final estado = context.read(); if (estado.alarmas.isEmpty) { await estado.cargarPersistidasSinRecalcular(); @@ -235,9 +240,7 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( - AppLocalizations.of( - context, - ).skipCurrentAlarmExecution( + AppLocalizations.of(context).skipCurrentAlarmExecution( localizedAlarmName(AppLocalizations.of(context), alarma.nombre), ), ), @@ -438,10 +441,7 @@ class _PaginaPrincipalState extends State<_PaginaPrincipal> { } } -String _formatearDuracionTimer( - AppLocalizations l10n, - Duration duracion, -) { +String _formatearDuracionTimer(AppLocalizations l10n, Duration duracion) { final horas = duracion.inHours; final minutos = duracion.inMinutes.remainder(60); final segundos = duracion.inSeconds.remainder(60); diff --git a/lib/estado/estado_alarmas.dart b/lib/estado/estado_alarmas.dart index bb1324a..920c43a 100644 --- a/lib/estado/estado_alarmas.dart +++ b/lib/estado/estado_alarmas.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import '../modelos/alarma_musical.dart'; import '../servicios/servicio_alarmas.dart'; @@ -10,9 +11,17 @@ class EstadoAlarmas extends ChangeNotifier { EstadoAlarmas({ ServicioAlarmas? servicio, PuertoAlarmasAndroid? android, + SharedPreferences? prefs, bool iniciarAutomaticamente = true, }) : servicio = servicio ?? ServicioAlarmas(), - android = android ?? ServicioAlarmasAndroid() { + android = android ?? ServicioAlarmasAndroid(), + _prefs = prefs { + // Decision 2.1 (snooze sync): the native layer reports its own snoozes + // back through alarmFired/snoozed; record them here so the Flutter + // config stays the single source of truth. + _eventosNativosSub = this.android.eventosAlarma.listen( + _alRecibirEventoNativo, + ); if (iniciarAutomaticamente) { inicializar(); } @@ -20,6 +29,8 @@ class EstadoAlarmas extends ChangeNotifier { final ServicioAlarmas servicio; final PuertoAlarmasAndroid android; + final SharedPreferences? _prefs; + static const _keyExencionBateriaSolicitada = 'bateria_exencion_solicitada'; List _alarmas = []; List _vacaciones = []; @@ -27,6 +38,7 @@ class EstadoAlarmas extends ChangeNotifier { DiagnosticoAlarmasAndroid? _diagnostico; Timer? _refresco; Timer? _vigilancia; + StreamSubscription? _eventosNativosSub; final _alarmasVencidasController = StreamController.broadcast(); final Set _ejecucionesEmitidas = {}; @@ -248,21 +260,89 @@ class EstadoAlarmas extends ChangeNotifier { notifyListeners(); } + /// Records a snooze the native layer performed by itself (Decision 2.1). + /// The native scheduler already re-registered setAlarmClock, so this only + /// persists the canonical state — it MUST NOT call android.programar again. + Future _alRecibirEventoNativo(EventoAlarmaAndroid evento) async { + if (evento.accion != EventoAlarmaAndroid.accionSnoozed) return; + if (evento.alarmaId.isEmpty || evento.snoozeUntilMillis <= 0) return; + final hasta = DateTime.fromMillisecondsSinceEpoch(evento.snoozeUntilMillis); + final origen = + evento.occurrenceAtMillis > 0 + ? DateTime.fromMillisecondsSinceEpoch(evento.occurrenceAtMillis) + : hasta.subtract(Duration(minutes: evento.snoozeMinutes)); + debugPrint( + '[PluriWave][alarmas] snooze nativo id=${evento.alarmaId} hasta=${hasta.toIso8601String()}', + ); + try { + final config = await servicio.posponerEjecucionHasta( + evento.alarmaId, + origen, + hasta, + ); + _aplicar(config); + notifyListeners(); + } catch (e) { + debugPrint('[PluriWave][alarmas] snooze nativo ERROR $e'); + } + } + Future _sincronizarEjecucionesGestionadasPorAndroid() async { try { final ejecuciones = await android.obtenerEjecucionesNativasGestionadas(); - if (ejecuciones.isEmpty) return; - final config = await servicio.sincronizarEjecucionesNativas({ - for (final ejecucion in ejecuciones) - ejecucion.alarmaId: ejecucion.gestionadaEn, - }); - _aplicar(config); - debugPrint( - '[PluriWave][alarmas] sincronizadas ejecuciones nativas count=${ejecuciones.length}', - ); + if (ejecuciones.isNotEmpty) { + final config = await servicio.sincronizarEjecucionesNativas({ + for (final ejecucion in ejecuciones) + ejecucion.alarmaId: ejecucion.gestionadaEn, + }); + _aplicar(config); + debugPrint( + '[PluriWave][alarmas] sincronizadas ejecuciones nativas count=${ejecuciones.length}', + ); + } } catch (e) { debugPrint('[PluriWave][alarmas] sincronizar nativas ERROR $e'); } + await _importarSnoozesNativosActivos(); + } + + /// Cold-start half of Decision 2.1: imports snoozes the native scheduler + /// performed while the Flutter engine was dead, before any recalculation + /// could erase them. + Future _importarSnoozesNativosActivos() async { + try { + final snoozes = await android.obtenerEstadoSnoozeNativo(); + if (snoozes.isEmpty) return; + final ahora = DateTime.now(); + var config = await servicio.cargar(); + var huboCambios = false; + for (final snooze in snoozes) { + if (!snooze.snoozeHasta.isAfter(ahora)) continue; + AlarmaMusical? alarma; + for (final candidata in config.alarmas) { + if (candidata.id == snooze.alarmaId) { + alarma = candidata; + break; + } + } + if (alarma == null || !alarma.activa) continue; + if (alarma.snoozeHasta == snooze.snoozeHasta) continue; + config = await servicio.posponerEjecucionHasta( + snooze.alarmaId, + snooze.snoozeOrigen, + snooze.snoozeHasta, + ); + huboCambios = true; + } + if (huboCambios) { + _aplicar(config); + debugPrint( + '[PluriWave][alarmas] snoozes nativos importados count=${snoozes.length}', + ); + } + } catch (e) { + debugPrint('[PluriWave][alarmas] importar snoozes nativos ERROR $e'); + } } Future _solicitarPermisosNecesariosParaAlarma() async { @@ -278,11 +358,21 @@ class EstadoAlarmas extends ChangeNotifier { if (!diag.puedeUsarPantallaCompleta) { await android.solicitarPermisoPantallaCompleta(); } + if (!diag.ignoraOptimizacionBateria) { + await _solicitarExencionBateriaUnaVez(); + } } catch (e) { debugPrint('[PluriWave][alarmas] permisos android ERROR $e'); } } + Future _solicitarExencionBateriaUnaVez() async { + final prefs = _prefs ?? await SharedPreferences.getInstance(); + if (prefs.getBool(_keyExencionBateriaSolicitada) ?? false) return; + await android.solicitarExencionBateria(); + await prefs.setBool(_keyExencionBateriaSolicitada, true); + } + Future _sincronizarTodas() async { debugPrint( '[PluriWave][alarmas] sincronizar todas count=${_alarmas.length}', @@ -351,6 +441,7 @@ class EstadoAlarmas extends ChangeNotifier { void dispose() { _refresco?.cancel(); _vigilancia?.cancel(); + _eventosNativosSub?.cancel(); _alarmasVencidasController.close(); super.dispose(); } diff --git a/lib/l10n/app_ar.arb b/lib/l10n/app_ar.arb index 9c73272..ccddca0 100644 --- a/lib/l10n/app_ar.arb +++ b/lib/l10n/app_ar.arb @@ -422,6 +422,18 @@ "soundDigitalPulse": "نبضة رقمية", "favoriteStationLabel": "المحطة المفضلة", "noStationUseInternalSound": "بدون محطة: استخدام الصوت الداخلي", + "alarmFallbackStationLabel": "محطة احتياطية", + "alarmStationPickerSearchHint": "ابحث عن محطة بالاسم", + "alarmSnoozeDurationTitle": "مدة الغفوة", + "snoozeAction": "غفوة", + "alarmSnoozeOptionLabel": "{minutes} د", + "@alarmSnoozeOptionLabel": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, "saveFavoritesAlarmHint": "احفظ محطات في المفضلة لاستخدامها كمنبه موسيقي.", "useCurrentStationAction": "استخدام المحطة الحالية", "playDuringVacations": "الرنين أثناء الإجازات", diff --git a/lib/l10n/app_bn.arb b/lib/l10n/app_bn.arb index 5495cae..8cb4a57 100644 --- a/lib/l10n/app_bn.arb +++ b/lib/l10n/app_bn.arb @@ -422,6 +422,18 @@ "soundDigitalPulse": "ডিজিটাল পালস", "favoriteStationLabel": "প্রিয় স্টেশন", "noStationUseInternalSound": "স্টেশন নেই: অভ্যন্তরীণ শব্দ ব্যবহার করুন", + "alarmFallbackStationLabel": "ব্যাকআপ স্টেশন", + "alarmStationPickerSearchHint": "নাম দিয়ে স্টেশন খুঁজুন", + "alarmSnoozeDurationTitle": "স্নুজ সময়কাল", + "snoozeAction": "স্নুজ", + "alarmSnoozeOptionLabel": "{minutes} মিনিট", + "@alarmSnoozeOptionLabel": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, "saveFavoritesAlarmHint": "সুরেলা অ্যালার্ম হিসেবে ব্যবহার করতে প্রিয়তে স্টেশন সংরক্ষণ করুন।", "useCurrentStationAction": "বর্তমান স্টেশন ব্যবহার করুন", "playDuringVacations": "ছুটিতে বাজান", diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index c8737fe..cbf82ca 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -422,6 +422,18 @@ "soundDigitalPulse": "Digitaler Puls", "favoriteStationLabel": "Lieblingssender", "noStationUseInternalSound": "Kein Sender: internen Ton verwenden", + "alarmFallbackStationLabel": "Ersatzsender", + "alarmStationPickerSearchHint": "Sender nach Name suchen", + "alarmSnoozeDurationTitle": "Schlummerdauer", + "snoozeAction": "Schlummern", + "alarmSnoozeOptionLabel": "{minutes} Min.", + "@alarmSnoozeOptionLabel": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, "saveFavoritesAlarmHint": "Speichere Sender in Favoriten, um sie als musikalischen Alarm zu verwenden.", "useCurrentStationAction": "Aktuellen Sender verwenden", "playDuringVacations": "Während der Ferien läuten", diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 7abbb16..80f8e03 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -422,6 +422,18 @@ "soundDigitalPulse": "Digital pulse", "favoriteStationLabel": "Favorite station", "noStationUseInternalSound": "No station: use internal sound", + "alarmFallbackStationLabel": "Backup station", + "alarmStationPickerSearchHint": "Search for a station by name", + "alarmSnoozeDurationTitle": "Snooze duration", + "snoozeAction": "Snooze", + "alarmSnoozeOptionLabel": "{minutes} min", + "@alarmSnoozeOptionLabel": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, "saveFavoritesAlarmHint": "Save stations in Favorites to use them as a music alarm.", "useCurrentStationAction": "Use current station", "playDuringVacations": "Play during vacations", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 8efd4a3..3e0bfe7 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -422,6 +422,18 @@ "soundDigitalPulse": "Pulso digital", "favoriteStationLabel": "Emisora favorita", "noStationUseInternalSound": "Sin emisora: usar sonido interno", + "alarmFallbackStationLabel": "Emisora de respaldo", + "alarmStationPickerSearchHint": "Buscá una emisora por nombre", + "alarmSnoozeDurationTitle": "Duración de la posposición", + "snoozeAction": "Posponer", + "alarmSnoozeOptionLabel": "{minutes} min", + "@alarmSnoozeOptionLabel": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, "saveFavoritesAlarmHint": "Guardá emisoras en Favoritos para usarlas como alarma musical.", "useCurrentStationAction": "Usar emisora actual", "playDuringVacations": "Sonar durante vacaciones", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index ad2f552..46ec909 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -422,6 +422,18 @@ "soundDigitalPulse": "Impulsion numérique", "favoriteStationLabel": "Station favorite", "noStationUseInternalSound": "Aucune station : utiliser le son interne", + "alarmFallbackStationLabel": "Station de secours", + "alarmStationPickerSearchHint": "Rechercher une station par nom", + "alarmSnoozeDurationTitle": "Durée de répétition", + "snoozeAction": "Répéter", + "alarmSnoozeOptionLabel": "{minutes} min", + "@alarmSnoozeOptionLabel": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, "saveFavoritesAlarmHint": "Enregistrez des stations dans les Favoris pour les utiliser comme alarme musicale.", "useCurrentStationAction": "Utiliser la station actuelle", "playDuringVacations": "Sonner pendant les vacances", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 5dfbc41..962dd64 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -422,6 +422,18 @@ "soundDigitalPulse": "डिजिटल धड़कन", "favoriteStationLabel": "पसंदीदा स्टेशन", "noStationUseInternalSound": "कोई स्टेशन नहीं: आंतरिक ध्वनि इस्तेमाल करें", + "alarmFallbackStationLabel": "बैकअप स्टेशन", + "alarmStationPickerSearchHint": "नाम से स्टेशन खोजें", + "alarmSnoozeDurationTitle": "स्नूज़ अवधि", + "snoozeAction": "स्नूज़", + "alarmSnoozeOptionLabel": "{minutes} मिनट", + "@alarmSnoozeOptionLabel": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, "saveFavoritesAlarmHint": "उन्हें संगीतमय अलार्म के रूप में इस्तेमाल करने के लिए स्टेशन पसंदीदा में सहेजें।", "useCurrentStationAction": "वर्तमान स्टेशन इस्तेमाल करें", "playDuringVacations": "छुट्टियों में बजाएँ", diff --git a/lib/l10n/app_id.arb b/lib/l10n/app_id.arb index 41bf06b..5c5794a 100644 --- a/lib/l10n/app_id.arb +++ b/lib/l10n/app_id.arb @@ -422,6 +422,18 @@ "soundDigitalPulse": "Denyut digital", "favoriteStationLabel": "Stasiun favorit", "noStationUseInternalSound": "Tanpa stasiun: gunakan suara internal", + "alarmFallbackStationLabel": "Stasiun cadangan", + "alarmStationPickerSearchHint": "Cari stasiun berdasarkan nama", + "alarmSnoozeDurationTitle": "Durasi tunda", + "snoozeAction": "Tunda", + "alarmSnoozeOptionLabel": "{minutes} mnt", + "@alarmSnoozeOptionLabel": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, "saveFavoritesAlarmHint": "Simpan stasiun ke Favorit untuk digunakan sebagai alarm musik.", "useCurrentStationAction": "Gunakan stasiun saat ini", "playDuringVacations": "Bunyi saat liburan", diff --git a/lib/l10n/app_it.arb b/lib/l10n/app_it.arb index 0e1fcf3..2f150df 100644 --- a/lib/l10n/app_it.arb +++ b/lib/l10n/app_it.arb @@ -422,6 +422,18 @@ "soundDigitalPulse": "Impulso digitale", "favoriteStationLabel": "Emittente preferita", "noStationUseInternalSound": "Nessuna emittente: usa suono interno", + "alarmFallbackStationLabel": "Emittente di riserva", + "alarmStationPickerSearchHint": "Cerca un'emittente per nome", + "alarmSnoozeDurationTitle": "Durata posticipo", + "snoozeAction": "Posticipa", + "alarmSnoozeOptionLabel": "{minutes} min", + "@alarmSnoozeOptionLabel": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, "saveFavoritesAlarmHint": "Salva emittenti nei Preferiti per usarle come sveglia musicale.", "useCurrentStationAction": "Usa emittente attuale", "playDuringVacations": "Suona durante le vacanze", diff --git a/lib/l10n/app_ja.arb b/lib/l10n/app_ja.arb index 24ff672..c64f601 100644 --- a/lib/l10n/app_ja.arb +++ b/lib/l10n/app_ja.arb @@ -422,6 +422,18 @@ "soundDigitalPulse": "デジタルパルス", "favoriteStationLabel": "お気に入り局", "noStationUseInternalSound": "局なし: 内部音を使用", + "alarmFallbackStationLabel": "予備の局", + "alarmStationPickerSearchHint": "局名で検索", + "alarmSnoozeDurationTitle": "スヌーズ時間", + "snoozeAction": "スヌーズ", + "alarmSnoozeOptionLabel": "{minutes}分", + "@alarmSnoozeOptionLabel": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, "saveFavoritesAlarmHint": "音楽アラームとして使うには、局をお気に入りに保存してください。", "useCurrentStationAction": "現在の局を使用", "playDuringVacations": "休暇中も鳴らす", diff --git a/lib/l10n/app_pt.arb b/lib/l10n/app_pt.arb index 4ee9da3..e0507af 100644 --- a/lib/l10n/app_pt.arb +++ b/lib/l10n/app_pt.arb @@ -422,6 +422,18 @@ "soundDigitalPulse": "Pulso digital", "favoriteStationLabel": "Estação favorita", "noStationUseInternalSound": "Sem estação: usar som interno", + "alarmFallbackStationLabel": "Estação reserva", + "alarmStationPickerSearchHint": "Buscar estação pelo nome", + "alarmSnoozeDurationTitle": "Duração da soneca", + "snoozeAction": "Soneca", + "alarmSnoozeOptionLabel": "{minutes} min", + "@alarmSnoozeOptionLabel": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, "saveFavoritesAlarmHint": "Salve estações nos Favoritos para usá-las como alarme musical.", "useCurrentStationAction": "Usar estação atual", "playDuringVacations": "Tocar durante as férias", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 82de129..5a10a3b 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -422,6 +422,18 @@ "soundDigitalPulse": "Цифровой импульс", "favoriteStationLabel": "Избранная станция", "noStationUseInternalSound": "Без станции: использовать внутренний звук", + "alarmFallbackStationLabel": "Резервная станция", + "alarmStationPickerSearchHint": "Поиск станции по названию", + "alarmSnoozeDurationTitle": "Интервал повтора", + "snoozeAction": "Отложить", + "alarmSnoozeOptionLabel": "{minutes} мин", + "@alarmSnoozeOptionLabel": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, "saveFavoritesAlarmHint": "Сохраните станции в избранное, чтобы использовать их как музыкальный будильник.", "useCurrentStationAction": "Использовать текущую станцию", "playDuringVacations": "Звонить во время отпусков", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index c4843ce..fb0b29c 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -422,6 +422,18 @@ "soundDigitalPulse": "数字脉冲", "favoriteStationLabel": "收藏电台", "noStationUseInternalSound": "无电台:使用内部声音", + "alarmFallbackStationLabel": "备用电台", + "alarmStationPickerSearchHint": "按名称搜索电台", + "alarmSnoozeDurationTitle": "贪睡时长", + "snoozeAction": "贪睡", + "alarmSnoozeOptionLabel": "{minutes} 分钟", + "@alarmSnoozeOptionLabel": { + "placeholders": { + "minutes": { + "type": "int" + } + } + }, "saveFavoritesAlarmHint": "将电台保存到收藏,即可把它们用作音乐闹钟。", "useCurrentStationAction": "使用当前电台", "playDuringVacations": "假期期间响铃", diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 26603dc..8c3eeb7 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -1510,6 +1510,36 @@ abstract class AppLocalizations { /// **'Sin emisora: usar sonido interno'** String get noStationUseInternalSound; + /// No description provided for @alarmFallbackStationLabel. + /// + /// In es, this message translates to: + /// **'Emisora de respaldo'** + String get alarmFallbackStationLabel; + + /// No description provided for @alarmStationPickerSearchHint. + /// + /// In es, this message translates to: + /// **'Buscá una emisora por nombre'** + String get alarmStationPickerSearchHint; + + /// No description provided for @alarmSnoozeDurationTitle. + /// + /// In es, this message translates to: + /// **'Duración de la posposición'** + String get alarmSnoozeDurationTitle; + + /// No description provided for @snoozeAction. + /// + /// In es, this message translates to: + /// **'Posponer'** + String get snoozeAction; + + /// No description provided for @alarmSnoozeOptionLabel. + /// + /// In es, this message translates to: + /// **'{minutes} min'** + String alarmSnoozeOptionLabel(int minutes); + /// No description provided for @saveFavoritesAlarmHint. /// /// In es, this message translates to: diff --git a/lib/l10n/gen/app_localizations_ar.dart b/lib/l10n/gen/app_localizations_ar.dart index 4b8b70c..c2bbb01 100644 --- a/lib/l10n/gen/app_localizations_ar.dart +++ b/lib/l10n/gen/app_localizations_ar.dart @@ -798,6 +798,23 @@ class AppLocalizationsAr extends AppLocalizations { @override String get noStationUseInternalSound => 'بدون محطة: استخدام الصوت الداخلي'; + @override + String get alarmFallbackStationLabel => 'محطة احتياطية'; + + @override + String get alarmStationPickerSearchHint => 'ابحث عن محطة بالاسم'; + + @override + String get alarmSnoozeDurationTitle => 'مدة الغفوة'; + + @override + String get snoozeAction => 'غفوة'; + + @override + String alarmSnoozeOptionLabel(int minutes) { + return '$minutes د'; + } + @override String get saveFavoritesAlarmHint => 'احفظ محطات في المفضلة لاستخدامها كمنبه موسيقي.'; diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart index 8218821..8291384 100644 --- a/lib/l10n/gen/app_localizations_bn.dart +++ b/lib/l10n/gen/app_localizations_bn.dart @@ -804,6 +804,23 @@ class AppLocalizationsBn extends AppLocalizations { String get noStationUseInternalSound => 'স্টেশন নেই: অভ্যন্তরীণ শব্দ ব্যবহার করুন'; + @override + String get alarmFallbackStationLabel => 'ব্যাকআপ স্টেশন'; + + @override + String get alarmStationPickerSearchHint => 'নাম দিয়ে স্টেশন খুঁজুন'; + + @override + String get alarmSnoozeDurationTitle => 'স্নুজ সময়কাল'; + + @override + String get snoozeAction => 'স্নুজ'; + + @override + String alarmSnoozeOptionLabel(int minutes) { + return '$minutes মিনিট'; + } + @override String get saveFavoritesAlarmHint => 'সুরেলা অ্যালার্ম হিসেবে ব্যবহার করতে প্রিয়তে স্টেশন সংরক্ষণ করুন।'; diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index 5e1d328..de96688 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -806,6 +806,23 @@ class AppLocalizationsDe extends AppLocalizations { @override String get noStationUseInternalSound => 'Kein Sender: internen Ton verwenden'; + @override + String get alarmFallbackStationLabel => 'Ersatzsender'; + + @override + String get alarmStationPickerSearchHint => 'Sender nach Name suchen'; + + @override + String get alarmSnoozeDurationTitle => 'Schlummerdauer'; + + @override + String get snoozeAction => 'Schlummern'; + + @override + String alarmSnoozeOptionLabel(int minutes) { + return '$minutes Min.'; + } + @override String get saveFavoritesAlarmHint => 'Speichere Sender in Favoriten, um sie als musikalischen Alarm zu verwenden.'; diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 275180b..cab250f 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -801,6 +801,23 @@ class AppLocalizationsEn extends AppLocalizations { @override String get noStationUseInternalSound => 'No station: use internal sound'; + @override + String get alarmFallbackStationLabel => 'Backup station'; + + @override + String get alarmStationPickerSearchHint => 'Search for a station by name'; + + @override + String get alarmSnoozeDurationTitle => 'Snooze duration'; + + @override + String get snoozeAction => 'Snooze'; + + @override + String alarmSnoozeOptionLabel(int minutes) { + return '$minutes min'; + } + @override String get saveFavoritesAlarmHint => 'Save stations in Favorites to use them as a music alarm.'; diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index 03f0c88..115b00d 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -804,6 +804,23 @@ class AppLocalizationsEs extends AppLocalizations { @override String get noStationUseInternalSound => 'Sin emisora: usar sonido interno'; + @override + String get alarmFallbackStationLabel => 'Emisora de respaldo'; + + @override + String get alarmStationPickerSearchHint => 'Buscá una emisora por nombre'; + + @override + String get alarmSnoozeDurationTitle => 'Duración de la posposición'; + + @override + String get snoozeAction => 'Posponer'; + + @override + String alarmSnoozeOptionLabel(int minutes) { + return '$minutes min'; + } + @override String get saveFavoritesAlarmHint => 'Guardá emisoras en Favoritos para usarlas como alarma musical.'; diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart index 05f17b5..be2ad5c 100644 --- a/lib/l10n/gen/app_localizations_fr.dart +++ b/lib/l10n/gen/app_localizations_fr.dart @@ -809,6 +809,23 @@ class AppLocalizationsFr extends AppLocalizations { String get noStationUseInternalSound => 'Aucune station : utiliser le son interne'; + @override + String get alarmFallbackStationLabel => 'Station de secours'; + + @override + String get alarmStationPickerSearchHint => 'Rechercher une station par nom'; + + @override + String get alarmSnoozeDurationTitle => 'Durée de répétition'; + + @override + String get snoozeAction => 'Répéter'; + + @override + String alarmSnoozeOptionLabel(int minutes) { + return '$minutes min'; + } + @override String get saveFavoritesAlarmHint => 'Enregistrez des stations dans les Favoris pour les utiliser comme alarme musicale.'; diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart index 62efdea..6eabde2 100644 --- a/lib/l10n/gen/app_localizations_hi.dart +++ b/lib/l10n/gen/app_localizations_hi.dart @@ -801,6 +801,23 @@ class AppLocalizationsHi extends AppLocalizations { String get noStationUseInternalSound => 'कोई स्टेशन नहीं: आंतरिक ध्वनि इस्तेमाल करें'; + @override + String get alarmFallbackStationLabel => 'बैकअप स्टेशन'; + + @override + String get alarmStationPickerSearchHint => 'नाम से स्टेशन खोजें'; + + @override + String get alarmSnoozeDurationTitle => 'स्नूज़ अवधि'; + + @override + String get snoozeAction => 'स्नूज़'; + + @override + String alarmSnoozeOptionLabel(int minutes) { + return '$minutes मिनट'; + } + @override String get saveFavoritesAlarmHint => 'उन्हें संगीतमय अलार्म के रूप में इस्तेमाल करने के लिए स्टेशन पसंदीदा में सहेजें।'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index f450991..8b5ea9a 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -804,6 +804,23 @@ class AppLocalizationsId extends AppLocalizations { String get noStationUseInternalSound => 'Tanpa stasiun: gunakan suara internal'; + @override + String get alarmFallbackStationLabel => 'Stasiun cadangan'; + + @override + String get alarmStationPickerSearchHint => 'Cari stasiun berdasarkan nama'; + + @override + String get alarmSnoozeDurationTitle => 'Durasi tunda'; + + @override + String get snoozeAction => 'Tunda'; + + @override + String alarmSnoozeOptionLabel(int minutes) { + return '$minutes mnt'; + } + @override String get saveFavoritesAlarmHint => 'Simpan stasiun ke Favorit untuk digunakan sebagai alarm musik.'; diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index 7aafd4d..0d85515 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -805,6 +805,23 @@ class AppLocalizationsIt extends AppLocalizations { String get noStationUseInternalSound => 'Nessuna emittente: usa suono interno'; + @override + String get alarmFallbackStationLabel => 'Emittente di riserva'; + + @override + String get alarmStationPickerSearchHint => 'Cerca un\'emittente per nome'; + + @override + String get alarmSnoozeDurationTitle => 'Durata posticipo'; + + @override + String get snoozeAction => 'Posticipa'; + + @override + String alarmSnoozeOptionLabel(int minutes) { + return '$minutes min'; + } + @override String get saveFavoritesAlarmHint => 'Salva emittenti nei Preferiti per usarle come sveglia musicale.'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index 487ea62..6c1d569 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -777,6 +777,23 @@ class AppLocalizationsJa extends AppLocalizations { @override String get noStationUseInternalSound => '局なし: 内部音を使用'; + @override + String get alarmFallbackStationLabel => '予備の局'; + + @override + String get alarmStationPickerSearchHint => '局名で検索'; + + @override + String get alarmSnoozeDurationTitle => 'スヌーズ時間'; + + @override + String get snoozeAction => 'スヌーズ'; + + @override + String alarmSnoozeOptionLabel(int minutes) { + return '$minutes分'; + } + @override String get saveFavoritesAlarmHint => '音楽アラームとして使うには、局をお気に入りに保存してください。'; diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart index a202362..e6eecea 100644 --- a/lib/l10n/gen/app_localizations_pt.dart +++ b/lib/l10n/gen/app_localizations_pt.dart @@ -803,6 +803,23 @@ class AppLocalizationsPt extends AppLocalizations { @override String get noStationUseInternalSound => 'Sem estação: usar som interno'; + @override + String get alarmFallbackStationLabel => 'Estação reserva'; + + @override + String get alarmStationPickerSearchHint => 'Buscar estação pelo nome'; + + @override + String get alarmSnoozeDurationTitle => 'Duração da soneca'; + + @override + String get snoozeAction => 'Soneca'; + + @override + String alarmSnoozeOptionLabel(int minutes) { + return '$minutes min'; + } + @override String get saveFavoritesAlarmHint => 'Salve estações nos Favoritos para usá-las como alarme musical.'; diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart index cdb309d..11f4faf 100644 --- a/lib/l10n/gen/app_localizations_ru.dart +++ b/lib/l10n/gen/app_localizations_ru.dart @@ -805,6 +805,23 @@ class AppLocalizationsRu extends AppLocalizations { String get noStationUseInternalSound => 'Без станции: использовать внутренний звук'; + @override + String get alarmFallbackStationLabel => 'Резервная станция'; + + @override + String get alarmStationPickerSearchHint => 'Поиск станции по названию'; + + @override + String get alarmSnoozeDurationTitle => 'Интервал повтора'; + + @override + String get snoozeAction => 'Отложить'; + + @override + String alarmSnoozeOptionLabel(int minutes) { + return '$minutes мин'; + } + @override String get saveFavoritesAlarmHint => 'Сохраните станции в избранное, чтобы использовать их как музыкальный будильник.'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index 5acc588..4280080 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -773,6 +773,23 @@ class AppLocalizationsZh extends AppLocalizations { @override String get noStationUseInternalSound => '无电台:使用内部声音'; + @override + String get alarmFallbackStationLabel => '备用电台'; + + @override + String get alarmStationPickerSearchHint => '按名称搜索电台'; + + @override + String get alarmSnoozeDurationTitle => '贪睡时长'; + + @override + String get snoozeAction => '贪睡'; + + @override + String alarmSnoozeOptionLabel(int minutes) { + return '$minutes 分钟'; + } + @override String get saveFavoritesAlarmHint => '将电台保存到收藏,即可把它们用作音乐闹钟。'; diff --git a/lib/modelos/alarma_musical.dart b/lib/modelos/alarma_musical.dart index 2eb6bb9..79fc13c 100644 --- a/lib/modelos/alarma_musical.dart +++ b/lib/modelos/alarma_musical.dart @@ -62,7 +62,9 @@ class AlarmaMusical { DateTime? fechaUnica, bool limpiarFechaUnica = false, Emisora? emisora, + bool limpiarEmisora = false, Emisora? emisoraFallback, + bool limpiarEmisoraFallback = false, bool? sonarEnVacaciones, int? snoozeMinutos, double? volumen, @@ -87,8 +89,11 @@ class AlarmaMusical { tipoProgramacion: tipoProgramacion ?? this.tipoProgramacion, diasSemana: diasSemana ?? this.diasSemana, fechaUnica: limpiarFechaUnica ? null : fechaUnica ?? this.fechaUnica, - emisora: emisora ?? this.emisora, - emisoraFallback: emisoraFallback ?? this.emisoraFallback, + emisora: limpiarEmisora ? emisora : emisora ?? this.emisora, + emisoraFallback: + limpiarEmisoraFallback + ? emisoraFallback + : emisoraFallback ?? this.emisoraFallback, sonarEnVacaciones: sonarEnVacaciones ?? this.sonarEnVacaciones, snoozeMinutos: snoozeMinutos ?? this.snoozeMinutos, volumen: volumen ?? this.volumen, @@ -98,7 +103,8 @@ class AlarmaMusical { limpiarProximaEjecucion ? proximaEjecucion : proximaEjecucion ?? this.proximaEjecucion, - snoozeHasta: limpiarSnooze ? snoozeHasta : snoozeHasta ?? this.snoozeHasta, + snoozeHasta: + limpiarSnooze ? snoozeHasta : snoozeHasta ?? this.snoozeHasta, snoozeOrigen: limpiarSnooze ? snoozeOrigen : snoozeOrigen ?? this.snoozeOrigen, ultimaEjecucionGestionada: diff --git a/lib/pantallas/pantalla_alarma_sonando.dart b/lib/pantallas/pantalla_alarma_sonando.dart index 22edb16..6dc6fe2 100644 --- a/lib/pantallas/pantalla_alarma_sonando.dart +++ b/lib/pantallas/pantalla_alarma_sonando.dart @@ -10,7 +10,10 @@ import '../l10n/display_names.dart'; import '../l10n/gen/app_localizations.dart'; import '../modelos/alarma_musical.dart'; import '../servicios/servicio_audio.dart'; +import '../tema/pluri_animate.dart'; +import '../tema/pluriwave_theme.dart'; import '../widgets/pluri_glass_surface.dart'; +import '../widgets/pluri_wave_scaffold.dart'; class PantallaAlarmaSonando extends StatefulWidget { const PantallaAlarmaSonando({ @@ -129,19 +132,49 @@ class _PantallaAlarmaSonandoState extends State { ); } + /// Shared local-audio teardown for stop and snooze (Design 2.3): the Dart + /// fallback player and fade timer MUST die before the alarm is re-programmed + /// natively, otherwise the local fallback keeps looping after snooze. + Future _liberarAudioLocal() async { + _fallbackTimer?.cancel(); + _fadeInTimer?.cancel(); + // cancel() detiene la entrega de eventos de forma sincrona; no se espera + // su Future porque puede no resolverse hasta que el stream se cierre. + unawaited(_estadoSub?.cancel()); + _estadoSub = null; + await _fallbackPlayer.stop(); + } + Future _detener() async { final radio = context.read(); final alarmas = context.read(); final navigator = Navigator.of(context); - _fallbackTimer?.cancel(); - _fadeInTimer?.cancel(); - await _estadoSub?.cancel(); - await _fallbackPlayer.stop(); + await _liberarAudioLocal(); await radio.audio.pausar(); await alarmas.finalizarEjecucion(widget.alarma.id); if (mounted) navigator.pop(); } + /// Flutter-first snooze (S2-R1): tears down local audio, then routes + /// through the canonical EstadoAlarmas.posponerAlarma, which hides the + /// native notification (same stop path as dismiss) and re-programs Android. + Future _posponer(int minutos) async { + final radio = context.read(); + final alarmas = context.read(); + final navigator = Navigator.of(context); + await _liberarAudioLocal(); + await radio.audio.pausar(); + await alarmas.posponerAlarma(widget.alarma, minutos); + if (mounted) navigator.pop(); + } + + List _opcionesSnooze() { + final opciones = {3, 5, 10}; + final propio = widget.alarma.snoozeMinutos; + if (propio > 0) opciones.add(propio); + return opciones.toList()..sort(); + } + @override void dispose() { _fallbackTimer?.cancel(); @@ -155,8 +188,12 @@ class _PantallaAlarmaSonandoState extends State { Widget build(BuildContext context) { final alarma = widget.alarma; final l10n = AppLocalizations.of(context); - return Scaffold( - backgroundColor: const Color(0xFF061722), + final tokens = context.pluriTokens; + // Cold-GPU note (Design 2.4): PluriGlassSurface uses a BackdropFilter and + // the first frame after a screen-off FSI wake can stutter. The blur sigma + // is capped here, and reduced-motion users skip the entry animation + // entirely via pluriFadeIn. + return PluriWaveScaffold( body: SafeArea( child: Padding( padding: const EdgeInsets.all(20), @@ -164,7 +201,8 @@ class _PantallaAlarmaSonandoState extends State { child: PluriGlassSurface( borderRadius: BorderRadius.circular(32), padding: const EdgeInsets.all(24), - glowColor: const Color(0xFFFFB86B).withValues(alpha: 0.35), + blurSigma: 10, + glowColor: tokens.warmCoral.withValues(alpha: 0.35), child: Column( mainAxisSize: MainAxisSize.min, children: [ @@ -197,6 +235,25 @@ class _PantallaAlarmaSonandoState extends State { textAlign: TextAlign.center, ), const SizedBox(height: 22), + Text( + l10n.snoozeAction, + style: Theme.of(context).textTheme.labelLarge, + ), + const SizedBox(height: 8), + Wrap( + spacing: 8, + runSpacing: 8, + alignment: WrapAlignment.center, + children: [ + for (final minutos in _opcionesSnooze()) + OutlinedButton.icon( + onPressed: () => _posponer(minutos), + icon: const Icon(Icons.snooze_rounded), + label: Text(l10n.alarmSnoozeOptionLabel(minutos)), + ), + ], + ), + const SizedBox(height: 14), FilledButton.icon( onPressed: _detener, icon: const Icon(Icons.stop_rounded), @@ -204,7 +261,7 @@ class _PantallaAlarmaSonandoState extends State { ), ], ), - ), + ).pluriFadeIn(context), ), ), ), diff --git a/lib/pantallas/pantalla_alarmas.dart b/lib/pantallas/pantalla_alarmas.dart index 8d639f2..2361621 100644 --- a/lib/pantallas/pantalla_alarmas.dart +++ b/lib/pantallas/pantalla_alarmas.dart @@ -8,6 +8,7 @@ import '../l10n/app_localizations_ext.dart'; import '../l10n/gen/app_localizations.dart'; import '../modelos/alarma_musical.dart'; import '../modelos/emisora.dart'; +import '../servicios/servicio_programacion_alarmas.dart'; import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_icon.dart'; import '../widgets/pluri_layout.dart'; @@ -339,30 +340,27 @@ class _EditorAlarmaSheet extends StatefulWidget { } class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { - late final TextEditingController _nombreController; + TextEditingController? _nombreController; late TimeOfDay _hora; late DateTime _fecha; late TipoProgramacionAlarma _tipo; late Set _diasSemana; late double _volumen; late int _fadeInSegundos; + late int _snoozeMinutos; late bool _sonarEnVacaciones; late SonidoInternoAlarma _sonidoInterno; Emisora? _emisora; + Emisora? _emisoraFallback; bool _favoritosSolicitados = false; + final ServicioProgramacionAlarmas _programacion = + ServicioProgramacionAlarmas(); @override void initState() { super.initState(); final alarma = widget.alarma; - final l10n = AppLocalizations.of(context); final ahora = DateTime.now().add(const Duration(minutes: 5)); - _nombreController = TextEditingController( - text: - alarma == null - ? l10n.defaultAlarmName - : _nombreVisibleAlarma(l10n, alarma), - ); _hora = TimeOfDay( hour: alarma?.hora ?? ahora.hour, minute: alarma?.minuto ?? ahora.minute, @@ -374,12 +372,31 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { _fadeInSegundos = (alarma?.fadeInSegundos ?? 0).clamp(0, 60).toInt(); _sonarEnVacaciones = alarma?.sonarEnVacaciones ?? true; _sonidoInterno = alarma?.sonidoInterno ?? SonidoInternoAlarma.amanecer; + _snoozeMinutos = alarma?.snoozeMinutos ?? 5; _emisora = alarma?.emisora ?? context.read().emisoraPreferida; + _emisoraFallback = alarma?.emisoraFallback; + } + + @override + void didChangeDependencies() { + super.didChangeDependencies(); + // Localizations cannot be read from initState (debug assert); the name + // controller is created lazily here on the first dependency pass. + if (_nombreController == null) { + final l10n = AppLocalizations.of(context); + final alarma = widget.alarma; + _nombreController = TextEditingController( + text: + alarma == null + ? l10n.defaultAlarmName + : _nombreVisibleAlarma(l10n, alarma), + ); + } } @override void dispose() { - _nombreController.dispose(); + _nombreController?.dispose(); super.dispose(); } @@ -409,232 +426,319 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { child: PluriGlassSurface( borderRadius: BorderRadius.circular(28), padding: const EdgeInsets.all(18), - child: SingleChildScrollView( - child: Column( - mainAxisSize: MainAxisSize.min, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - const _AssetIcon( - 'assets/icons/alarmas/alarm_music.png', - size: 58, - ), - const SizedBox(width: 12), - Expanded( - child: Text( - widget.alarma == null - ? l10n.newAlarmTitle - : l10n.editAlarmTitle, - style: Theme.of(context).textTheme.titleLarge?.copyWith( - fontWeight: FontWeight.w900, - ), - ), - ), - IconButton( - icon: const Icon(Icons.close_rounded), - onPressed: () => Navigator.pop(context), - ), - ], - ), - const SizedBox(height: 14), - TextField( - controller: _nombreController, - decoration: InputDecoration(labelText: l10n.nameLabel), - ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: _PickerButton( - icon: Icons.schedule_rounded, - label: l10n.timeField, - value: _hora.format(context), - onTap: _elegirHora, - ), - ), - const SizedBox(width: 10), - Expanded( - child: _PickerButton( - icon: Icons.event_rounded, - label: l10n.dateField, - value: _fechaCorta(_fecha), - onTap: - _tipo == TipoProgramacionAlarma.unica - ? _elegirFecha - : null, - ), - ), - ], - ), - const SizedBox(height: 12), - SegmentedButton( - segments: [ - ButtonSegment( - value: TipoProgramacionAlarma.unica, - label: Text(l10n.oneTimeOption), - ), - ButtonSegment( - value: TipoProgramacionAlarma.diaria, - label: Text(l10n.dailyOption), - ), - ButtonSegment( - value: TipoProgramacionAlarma.diasSemana, - label: Text(l10n.weekdaysOption), - ), - ], - selected: {_tipo}, - onSelectionChanged: - (value) => setState(() => _tipo = value.first), - ), - if (_tipo == TipoProgramacionAlarma.diasSemana) ...[ - const SizedBox(height: 10), - Wrap( - spacing: 6, + child: Material( + type: MaterialType.transparency, + child: SingleChildScrollView( + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( children: [ - for (var i = DateTime.monday; i <= DateTime.sunday; i++) - FilterChip( - label: Text(_weekdayShort(l10n, i)), - selected: _diasSemana.contains(i), - onSelected: - (selected) => setState(() { - selected - ? _diasSemana.add(i) - : _diasSemana.remove(i); - }), + const _AssetIcon( + 'assets/icons/alarmas/alarm_music.png', + size: 58, + ), + const SizedBox(width: 12), + Expanded( + child: Text( + widget.alarma == null + ? l10n.newAlarmTitle + : l10n.editAlarmTitle, + style: Theme.of(context).textTheme.titleLarge?.copyWith( + fontWeight: FontWeight.w900, + ), ), + ), + IconButton( + icon: const Icon(Icons.close_rounded), + onPressed: () => Navigator.pop(context), + ), ], ), - ], - const SizedBox(height: 14), - _SectionLabel( - icon: 'assets/icons/alarmas/fallback_sound.png', - text: l10n.soundAndVolumeSection, - ), - Slider( - value: _volumen, - min: 0.25, - max: 1, - divisions: 15, - label: '${(_volumen * 100).round()}%', - onChanged: (value) => setState(() => _volumen = value), - ), - const SizedBox(height: 8), - ListTile( - contentPadding: EdgeInsets.zero, - title: Text(l10n.alarmFadeInTitle), - subtitle: Text( - _fadeInSegundos == 0 - ? l10n.alarmFadeInOff - : l10n.alarmFadeInSummary(_fadeInSegundos), + const SizedBox(height: 14), + TextField( + controller: _nombreController, + decoration: InputDecoration(labelText: l10n.nameLabel), ), - ), - Slider( - value: _fadeInSegundos.toDouble(), - min: 0, - max: 60, - divisions: 60, - label: '${_fadeInSegundos}s', - onChanged: - (value) => setState(() => _fadeInSegundos = value.round()), - ), - DropdownButtonFormField( - initialValue: _sonidoInterno, - decoration: InputDecoration( - labelText: l10n.internalSafeSoundLabel, - ), - items: [ - DropdownMenuItem( - value: SonidoInternoAlarma.amanecer, - child: Text(l10n.soundWarmSunrise), - ), - DropdownMenuItem( - value: SonidoInternoAlarma.campanaSuave, - child: Text(l10n.soundSoftBell), - ), - DropdownMenuItem( - value: SonidoInternoAlarma.pulsoDigital, - child: Text(l10n.soundDigitalPulse), - ), - ], - onChanged: - (value) => setState( - () => _sonidoInterno = value ?? _sonidoInterno, - ), - ), - const SizedBox(height: 8), - DropdownButtonFormField( - key: ValueKey(_emisora?.uuid ?? 'sin-emisora'), - initialValue: _emisora?.uuid, - decoration: InputDecoration( - labelText: l10n.favoriteStationLabel, - prefixIcon: const Icon(Icons.radio_rounded), - ), - items: [ - DropdownMenuItem( - value: '', - child: Text(l10n.noStationUseInternalSound), - ), - for (final emisora in favoritas) - DropdownMenuItem( - value: emisora.uuid, - child: Text( - localizedStationName(l10n, emisora.nombre), - overflow: TextOverflow.ellipsis, + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: _PickerButton( + icon: Icons.schedule_rounded, + label: l10n.timeField, + value: _hora.format(context), + onTap: _elegirHora, ), ), + const SizedBox(width: 10), + Expanded( + child: _PickerButton( + icon: Icons.event_rounded, + label: l10n.dateField, + value: _fechaCorta(_fecha), + onTap: + _tipo == TipoProgramacionAlarma.unica + ? _elegirFecha + : null, + ), + ), + ], + ), + const SizedBox(height: 12), + SegmentedButton( + segments: [ + ButtonSegment( + value: TipoProgramacionAlarma.unica, + label: Text(l10n.oneTimeOption), + ), + ButtonSegment( + value: TipoProgramacionAlarma.diaria, + label: Text(l10n.dailyOption), + ), + ButtonSegment( + value: TipoProgramacionAlarma.diasSemana, + label: Text(l10n.weekdaysOption), + ), + ], + selected: {_tipo}, + onSelectionChanged: + (value) => setState(() => _tipo = value.first), + ), + if (_tipo == TipoProgramacionAlarma.diasSemana) ...[ + const SizedBox(height: 10), + Wrap( + spacing: 6, + children: [ + for (var i = DateTime.monday; i <= DateTime.sunday; i++) + FilterChip( + label: Text(_weekdayShort(l10n, i)), + selected: _diasSemana.contains(i), + onSelected: + (selected) => setState(() { + selected + ? _diasSemana.add(i) + : _diasSemana.remove(i); + }), + ), + ], + ), ], - onChanged: - (uuid) => setState(() { - if (uuid == null || uuid.isEmpty) { - _emisora = null; - return; - } - _emisora = favoritas.firstWhere((e) => e.uuid == uuid); - }), - ), - if (favoritas.isEmpty) ...[ - const SizedBox(height: 6), - Text(l10n.saveFavoritesAlarmHint), - ], - if (radio.emisoraActual != null) ...[ + const SizedBox(height: 12), + _vistaProximaEjecucion(l10n), + const SizedBox(height: 14), + _SectionLabel( + icon: 'assets/icons/alarmas/fallback_sound.png', + text: l10n.soundAndVolumeSection, + ), + Slider( + value: _volumen, + // S2-R11: floor lowered from 0.25 to 0.0. + min: 0, + max: 1, + divisions: 20, + label: '${(_volumen * 100).round()}%', + onChanged: (value) => setState(() => _volumen = value), + ), const SizedBox(height: 8), - Align( - alignment: Alignment.centerLeft, - child: FilledButton.tonalIcon( - onPressed: - () => setState(() => _emisora = radio.emisoraActual), - icon: const Icon(Icons.add_task_rounded), - label: Text(l10n.useCurrentStationAction), + ListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.alarmFadeInTitle), + subtitle: Text( + _fadeInSegundos == 0 + ? l10n.alarmFadeInOff + : l10n.alarmFadeInSummary(_fadeInSegundos), ), ), - ], - const SizedBox(height: 8), - SwitchListTile.adaptive( - contentPadding: EdgeInsets.zero, - value: _sonarEnVacaciones, - onChanged: - (value) => setState(() => _sonarEnVacaciones = value), - secondary: const _AssetIcon( - 'assets/icons/alarmas/vacation_wave.png', - size: 42, + Slider( + value: _fadeInSegundos.toDouble(), + min: 0, + max: 60, + divisions: 60, + label: '${_fadeInSegundos}s', + onChanged: + (value) => + setState(() => _fadeInSegundos = value.round()), ), - title: Text(l10n.playDuringVacations), - subtitle: Text(l10n.playDuringVacationsHint), - ), - const SizedBox(height: 16), - FilledButton.icon( - onPressed: _guardar, - icon: const Icon(Icons.check_rounded), - label: Text(l10n.saveAlarmAction), - ), - ], + ListTile( + contentPadding: EdgeInsets.zero, + title: Text(l10n.alarmSnoozeDurationTitle), + subtitle: Text(l10n.alarmSnoozeOptionLabel(_snoozeMinutos)), + ), + SegmentedButton( + segments: [ + for (final minutos in _opcionesSnooze()) + ButtonSegment( + value: minutos, + label: Text(l10n.alarmSnoozeOptionLabel(minutos)), + ), + ], + selected: {_snoozeMinutos}, + onSelectionChanged: + (value) => setState(() => _snoozeMinutos = value.first), + ), + const SizedBox(height: 8), + DropdownButtonFormField( + initialValue: _sonidoInterno, + decoration: InputDecoration( + labelText: l10n.internalSafeSoundLabel, + ), + items: [ + DropdownMenuItem( + value: SonidoInternoAlarma.amanecer, + child: Text(l10n.soundWarmSunrise), + ), + DropdownMenuItem( + value: SonidoInternoAlarma.campanaSuave, + child: Text(l10n.soundSoftBell), + ), + DropdownMenuItem( + value: SonidoInternoAlarma.pulsoDigital, + child: Text(l10n.soundDigitalPulse), + ), + ], + onChanged: + (value) => setState( + () => _sonidoInterno = value ?? _sonidoInterno, + ), + ), + const SizedBox(height: 8), + // S2-R9: searchable bottom-sheet picker instead of a dropdown, + // for both the primary and the backup (fallback) station. + _CampoSelectorEmisora( + key: const ValueKey('alarm-station-field'), + label: l10n.favoriteStationLabel, + icon: Icons.radio_rounded, + value: + _emisora == null + ? l10n.noStationUseInternalSound + : localizedStationName(l10n, _emisora!.nombre), + onTap: + () => _elegirEmisora( + favoritas, + seleccionar: + (emisora) => setState(() => _emisora = emisora), + ), + ), + const SizedBox(height: 8), + _CampoSelectorEmisora( + key: const ValueKey('alarm-fallback-station-field'), + label: l10n.alarmFallbackStationLabel, + icon: Icons.settings_backup_restore_rounded, + value: + _emisoraFallback == null + ? l10n.noStationUseInternalSound + : localizedStationName( + l10n, + _emisoraFallback!.nombre, + ), + onTap: + () => _elegirEmisora( + favoritas, + seleccionar: + (emisora) => + setState(() => _emisoraFallback = emisora), + ), + ), + if (favoritas.isEmpty) ...[ + const SizedBox(height: 6), + Text(l10n.saveFavoritesAlarmHint), + ], + if (radio.emisoraActual != null) ...[ + const SizedBox(height: 8), + Align( + alignment: Alignment.centerLeft, + child: FilledButton.tonalIcon( + onPressed: + () => setState(() => _emisora = radio.emisoraActual), + icon: const Icon(Icons.add_task_rounded), + label: Text(l10n.useCurrentStationAction), + ), + ), + ], + const SizedBox(height: 8), + SwitchListTile.adaptive( + contentPadding: EdgeInsets.zero, + value: _sonarEnVacaciones, + onChanged: + (value) => setState(() => _sonarEnVacaciones = value), + secondary: const _AssetIcon( + 'assets/icons/alarmas/vacation_wave.png', + size: 42, + ), + title: Text(l10n.playDuringVacations), + subtitle: Text(l10n.playDuringVacationsHint), + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: _guardar, + icon: const Icon(Icons.check_rounded), + label: Text(l10n.saveAlarmAction), + ), + ], + ), ), ), ), ); } + List _opcionesSnooze() { + final opciones = {3, 5, 10}; + if (_snoozeMinutos > 0) opciones.add(_snoozeMinutos); + return opciones.toList()..sort(); + } + + /// Read-only next-trigger preview (S2-R8): computed from the in-progress + /// draft so the user can verify when the alarm will fire before saving. + /// Recomputed on every setState, so it tracks time/recurrence edits live. + Widget _vistaProximaEjecucion(AppLocalizations l10n) { + final estado = context.read(); + final borrador = AlarmaMusical( + id: widget.alarma?.id ?? '_borrador_editor', + nombre: 'preview', + hora: _hora.hour, + minuto: _hora.minute, + tipoProgramacion: _tipo, + diasSemana: + _tipo == TipoProgramacionAlarma.diasSemana + ? (_diasSemana.toList()..sort()) + : const [], + fechaUnica: _tipo == TipoProgramacionAlarma.unica ? _fecha : null, + sonarEnVacaciones: _sonarEnVacaciones, + ); + final proxima = _programacion.calcularProxima( + alarma: borrador, + desde: DateTime.now(), + vacaciones: estado.vacaciones, + excepciones: estado.excepciones, + ); + return _NoticeLine( + key: const ValueKey('next-trigger-preview'), + icon: Icons.event_available_rounded, + text: + proxima == null + ? l10n.alarmNoNextExecution + : l10n.alarmNextExecution(_fechaHora(l10n, proxima)), + ); + } + + Future _elegirEmisora( + List emisoras, { + required ValueChanged seleccionar, + }) async { + final resultado = await showModalBottomSheet<_SeleccionEmisora>( + context: context, + isScrollControlled: true, + useSafeArea: true, + backgroundColor: Colors.transparent, + builder: (_) => _SelectorEmisoraSheet(emisoras: emisoras), + ); + if (resultado == null) return; + seleccionar(resultado.emisora); + } + Future _elegirHora() async { final nueva = await showTimePicker(context: context, initialTime: _hora); if (nueva != null) setState(() => _hora = nueva); @@ -663,9 +767,10 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { final estado = context.read(); final existente = widget.alarma; + final nombre = _nombreController?.text.trim() ?? ''; final alarma = (existente ?? estado.servicio.crearAlarma( - nombre: _nombreController.text.trim(), + nombre: nombre, hora: _hora.hour, minuto: _hora.minute, tipoProgramacion: _tipo, @@ -673,9 +778,9 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { )) .copyWith( nombre: - _nombreController.text.trim().isEmpty + nombre.isEmpty ? AppLocalizations.of(context).defaultAlarmName - : _nombreController.text.trim(), + : nombre, hora: _hora.hour, minuto: _hora.minute, tipoProgramacion: _tipo, @@ -686,8 +791,11 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { fechaUnica: _tipo == TipoProgramacionAlarma.unica ? _fecha : null, limpiarFechaUnica: _tipo != TipoProgramacionAlarma.unica, emisora: _emisora, + limpiarEmisora: _emisora == null, + emisoraFallback: _emisoraFallback, + limpiarEmisoraFallback: _emisoraFallback == null, sonarEnVacaciones: _sonarEnVacaciones, - snoozeMinutos: existente?.snoozeMinutos ?? 5, + snoozeMinutos: _snoozeMinutos, volumen: _volumen, fadeInSegundos: _fadeInSegundos.clamp(0, 60).toInt(), sonidoInterno: _sonidoInterno, @@ -706,10 +814,140 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { if (seleccionada != null) { mapa[seleccionada.uuid] = seleccionada; } + final respaldo = _emisoraFallback; + if (respaldo != null) { + mapa[respaldo.uuid] = respaldo; + } return mapa.values.toList(); } } +/// Result wrapper so the picker can distinguish "cancelled" (null result) +/// from "no station chosen" (emisora == null). +class _SeleccionEmisora { + const _SeleccionEmisora(this.emisora); + + final Emisora? emisora; +} + +class _CampoSelectorEmisora extends StatelessWidget { + const _CampoSelectorEmisora({ + super.key, + required this.label, + required this.icon, + required this.value, + required this.onTap, + }); + + final String label; + final IconData icon; + final String value; + final VoidCallback onTap; + + @override + Widget build(BuildContext context) { + return InkWell( + borderRadius: BorderRadius.circular(12), + onTap: onTap, + child: InputDecorator( + decoration: InputDecoration( + labelText: label, + prefixIcon: Icon(icon), + suffixIcon: const Icon(Icons.arrow_drop_down_rounded), + ), + child: Text(value, overflow: TextOverflow.ellipsis), + ), + ); + } +} + +/// Searchable station picker (S2-R9): bottom sheet with a [SearchBar] over +/// the user's favorites, matching the main station-picker interaction. +class _SelectorEmisoraSheet extends StatefulWidget { + const _SelectorEmisoraSheet({required this.emisoras}); + + final List emisoras; + + @override + State<_SelectorEmisoraSheet> createState() => _SelectorEmisoraSheetState(); +} + +class _SelectorEmisoraSheetState extends State<_SelectorEmisoraSheet> { + String _filtro = ''; + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final bottom = MediaQuery.of(context).viewInsets.bottom; + final query = _filtro.trim().toLowerCase(); + final filtradas = + widget.emisoras.where((emisora) { + if (query.isEmpty) return true; + return localizedStationName( + l10n, + emisora.nombre, + ).toLowerCase().contains(query) || + emisora.nombre.toLowerCase().contains(query); + }).toList(); + return Padding( + padding: EdgeInsets.fromLTRB(12, 12, 12, bottom + 12), + child: PluriGlassSurface( + borderRadius: BorderRadius.circular(28), + padding: const EdgeInsets.all(18), + child: ConstrainedBox( + constraints: BoxConstraints( + maxHeight: MediaQuery.of(context).size.height * 0.7, + ), + child: Material( + type: MaterialType.transparency, + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + SearchBar( + hintText: l10n.alarmStationPickerSearchHint, + leading: const Icon(Icons.search_rounded), + onChanged: (value) => setState(() => _filtro = value), + ), + const SizedBox(height: 10), + Flexible( + child: ListView( + shrinkWrap: true, + children: [ + ListTile( + leading: const Icon(Icons.music_off_rounded), + title: Text(l10n.noStationUseInternalSound), + onTap: + () => Navigator.pop( + context, + const _SeleccionEmisora(null), + ), + ), + for (final emisora in filtradas) + ListTile( + leading: const Icon(Icons.radio_rounded), + title: Text( + localizedStationName(l10n, emisora.nombre), + overflow: TextOverflow.ellipsis, + ), + onTap: + () => Navigator.pop( + context, + _SeleccionEmisora(emisora), + ), + ), + ], + ), + ), + ], + ), + ), + ), + ), + ); + } +} + class _AccesoDiagnostico extends StatelessWidget { const _AccesoDiagnostico({required this.estado}); @@ -1036,7 +1274,7 @@ class _InfoChip extends StatelessWidget { } class _NoticeLine extends StatelessWidget { - const _NoticeLine({required this.icon, required this.text}); + const _NoticeLine({super.key, required this.icon, required this.text}); final IconData icon; final String text; diff --git a/lib/servicios/servicio_alarmas.dart b/lib/servicios/servicio_alarmas.dart index 4a9e9d2..ca347e7 100644 --- a/lib/servicios/servicio_alarmas.dart +++ b/lib/servicios/servicio_alarmas.dart @@ -123,9 +123,7 @@ class ServicioAlarmas { ) async { final config = await cargar(); final normalizadas = - vacaciones - .map((v) => v.normalizado()) - .toList() + vacaciones.map((v) => v.normalizado()).toList() ..sort((a, b) => a.inicioDia.compareTo(b.inicioDia)); final alarmas = config.alarmas @@ -147,9 +145,10 @@ class ServicioAlarmas { }) { final rango = RangoVacaciones( id: _uuid.v4(), - nombre: (nombre == null || nombre.trim().isEmpty) - ? 'Vacaciones' - : nombre.trim(), + nombre: + (nombre == null || nombre.trim().isEmpty) + ? 'Vacaciones' + : nombre.trim(), inicio: inicio, fin: fin, ); @@ -259,7 +258,17 @@ class ServicioAlarmas { DateTime ejecucion, int minutos, ) async { - final snoozeHasta = _programacion.calcularSnooze(_reloj(), minutos); + // Unified snooze anchor (Design 2.2): occurrence + minutes, clamped to + // now + minutes when the target already passed. Matches the native + // AlarmScheduler.snooze/postponeNext semantics so both layers always + // land on the same re-fire time. + final seguros = minutos.clamp(1, 120); + final objetivo = ejecucion.add(Duration(minutes: seguros)); + final ahora = _reloj(); + final snoozeHasta = + objetivo.isAfter(ahora) + ? objetivo + : ahora.add(Duration(minutes: seguros)); return posponerEjecucionHasta(alarmaId, ejecucion, snoozeHasta); } @@ -381,8 +390,12 @@ class ServicioAlarmas { List excepciones, ) { final ahora = _reloj(); + // S2-R5: a disabled alarm must not keep a pending snooze; clearing it + // here guarantees the snoozed occurrence dies with the alarm. final snoozeActivo = - alarma.snoozeHasta != null && alarma.snoozeHasta!.isAfter(ahora); + alarma.activa && + alarma.snoozeHasta != null && + alarma.snoozeHasta!.isAfter(ahora); final proxima = _programacion.calcularProxima( alarma: alarma, desde: ahora, diff --git a/lib/servicios/servicio_alarmas_android.dart b/lib/servicios/servicio_alarmas_android.dart index e195e94..78d5258 100644 --- a/lib/servicios/servicio_alarmas_android.dart +++ b/lib/servicios/servicio_alarmas_android.dart @@ -16,14 +16,20 @@ class EventoAlarmaAndroid { this.triggerAtMillis = 0, this.occurrenceAtMillis = 0, this.snoozeMinutes = 5, + this.snoozeUntilMillis = 0, }); + /// Action reported when the native service snoozed an alarm by itself + /// (notification "Posponer" while the app may be backgrounded/killed). + static const accionSnoozed = 'snoozed'; + final String alarmaId; final String titulo; final String accion; final int triggerAtMillis; final int occurrenceAtMillis; final int snoozeMinutes; + final int snoozeUntilMillis; factory EventoAlarmaAndroid.fromMap(Map map) { return EventoAlarmaAndroid( @@ -33,6 +39,34 @@ class EventoAlarmaAndroid { triggerAtMillis: (map['triggerAtMillis'] as num?)?.toInt() ?? 0, occurrenceAtMillis: (map['occurrenceAtMillis'] as num?)?.toInt() ?? 0, snoozeMinutes: (map['snoozeMinutes'] as num?)?.toInt() ?? 5, + snoozeUntilMillis: (map['snoozeUntilMillis'] as num?)?.toInt() ?? 0, + ); + } +} + +/// Active native snooze persisted by `AlarmScheduler` (Kotlin). Used on cold +/// start so Flutter (single source of truth) can import snoozes performed +/// while the engine was dead. +class EstadoSnoozeNativo { + const EstadoSnoozeNativo({ + required this.alarmaId, + required this.snoozeHasta, + required this.snoozeOrigen, + }); + + final String alarmaId; + final DateTime snoozeHasta; + final DateTime snoozeOrigen; + + factory EstadoSnoozeNativo.fromMap(Map map) { + return EstadoSnoozeNativo( + alarmaId: map['alarmId'] as String? ?? '', + snoozeHasta: DateTime.fromMillisecondsSinceEpoch( + (map['snoozeUntilMillis'] as num?)?.toInt() ?? 0, + ), + snoozeOrigen: DateTime.fromMillisecondsSinceEpoch( + (map['snoozeOriginMillis'] as num?)?.toInt() ?? 0, + ), ); } } @@ -60,8 +94,7 @@ class DiagnosticoAlarmasAndroid { return DiagnosticoAlarmasAndroid( puedeProgramarExactas: map['canScheduleExactAlarms'] as bool? ?? true, notificacionesPermitidas: map['notificationsEnabled'] as bool? ?? true, - puedeUsarPantallaCompleta: - map['canUseFullScreenIntent'] as bool? ?? true, + puedeUsarPantallaCompleta: map['canUseFullScreenIntent'] as bool? ?? true, ignoraOptimizacionBateria: map['isIgnoringBatteryOptimizations'] as bool? ?? true, alarmasNativasPendientes: map['nativePendingAlarmsCount'] as int? ?? 0, @@ -100,10 +133,12 @@ abstract class PuertoAlarmasAndroid { Future solicitarPermisoAlarmasExactas(); Future solicitarPermisoNotificaciones(); Future solicitarPermisoPantallaCompleta(); + Future solicitarExencionBateria(); Future confirmarAudioFlutter(String alarmaId); Future diagnostico(); Future obtenerEventoInicial(); Future> obtenerEjecucionesNativasGestionadas(); + Future> obtenerEstadoSnoozeNativo(); } class ServicioAlarmasAndroid implements PuertoAlarmasAndroid { @@ -151,7 +186,9 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid { 'triggerAtMillis': proxima.millisecondsSinceEpoch, 'preNoticeAtMillis': alarma.snoozeHasta == null - ? proxima.subtract(const Duration(minutes: 30)).millisecondsSinceEpoch + ? proxima + .subtract(const Duration(minutes: 30)) + .millisecondsSinceEpoch : 0, 'hour': alarma.hora, 'minute': alarma.minuto, @@ -169,8 +206,14 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid { ? null : localizedStationName(_textos, alarma.emisora!.nombre), 'stationUrl': alarma.emisora?.url, + 'fallbackStationName': + alarma.emisoraFallback == null + ? null + : localizedStationName(_textos, alarma.emisoraFallback!.nombre), + 'fallbackStationUrl': alarma.emisoraFallback?.url, 'fallbackSound': alarma.sonidoInterno.name, 'volume': alarma.volumen, + 'fadeInSegundos': alarma.fadeInSegundos, }); if (programada != true) { throw StateError(_textos.androidExactAlarmScheduleError); @@ -217,6 +260,14 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid { return abierto ?? false; } + @override + Future solicitarExencionBateria() async { + final abierto = await _channel.invokeMethod( + 'requestIgnoreBatteryOptimizations', + ); + return abierto ?? false; + } + @override Future diagnostico() async { debugPrint('[PluriWave][alarmas] diagnostico android'); @@ -261,6 +312,23 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid { .toList(); } + @override + Future> obtenerEstadoSnoozeNativo() async { + final raw = await _channel.invokeMethod>( + 'getNativeSnoozeState', + ); + if (raw == null || raw.isEmpty) return const []; + return raw + .whereType>() + .map(EstadoSnoozeNativo.fromMap) + .where( + (estado) => + estado.alarmaId.isNotEmpty && + estado.snoozeHasta.millisecondsSinceEpoch > 0, + ) + .toList(); + } + Future _logAndInvokeVoid(String method, Map args) { debugPrint('[PluriWave][alarmas] $method $args'); return _channel.invokeMethod(method, args); diff --git a/lib/tema/pluri_animate.dart b/lib/tema/pluri_animate.dart new file mode 100644 index 0000000..c7eebde --- /dev/null +++ b/lib/tema/pluri_animate.dart @@ -0,0 +1,38 @@ +import 'package:flutter/widgets.dart'; +import 'package:flutter_animate/flutter_animate.dart'; + +/// Reduced-motion-aware entry animations (S5-R3). +/// +/// Every entry animation in the app should go through these helpers so the +/// OS "disable animations" accessibility setting is honored from a single +/// call site. When reduced motion is active the child is returned untouched +/// (no [Animate] wrapper at all). +extension PluriAnimate on Widget { + /// Fade-in entry animation. + Widget pluriFadeIn( + BuildContext context, { + Duration duration = const Duration(milliseconds: 350), + Duration delay = Duration.zero, + Curve curve = Curves.easeOutCubic, + }) { + if (_animacionesDeshabilitadas(context)) return this; + return animate(delay: delay).fadeIn(duration: duration, curve: curve); + } + + /// Fade + subtle scale entry animation. + Widget pluriScaleIn( + BuildContext context, { + Duration duration = const Duration(milliseconds: 350), + Duration delay = Duration.zero, + Curve curve = Curves.easeOutCubic, + double begin = 0.96, + }) { + if (_animacionesDeshabilitadas(context)) return this; + return animate(delay: delay) + .fadeIn(duration: duration, curve: curve) + .scaleXY(begin: begin, end: 1, duration: duration, curve: curve); + } + + bool _animacionesDeshabilitadas(BuildContext context) => + MediaQuery.maybeDisableAnimationsOf(context) ?? false; +} diff --git a/openspec/changes/app-quality-and-native-alarms/apply-progress.md b/openspec/changes/app-quality-and-native-alarms/apply-progress.md new file mode 100644 index 0000000..6d1ac1b --- /dev/null +++ b/openspec/changes/app-quality-and-native-alarms/apply-progress.md @@ -0,0 +1,179 @@ +# Apply Progress: app-quality-and-native-alarms + +**Mode**: Strict TDD (test runner: `flutter test`) +**Artifact store**: openspec (Engram unavailable this session) +**Delivery**: auto-chain, local apply — no commits, no PRs (user commits at own cadence) +**Last updated**: 2026-06-11 (Batch 2) + +## Batch log + +| Batch | Slice | Status | Date | +|-------|-------|--------|------| +| 1 | S1 — Alarm native reliability | COMPLETE (Dart verified; Kotlin/manifest on-device verification deferred to user) | 2026-06-11 | +| 2 | S2a + S2b — Snooze correctness end-to-end + Alarm UX parity | COMPLETE (Dart verified; Kotlin on-device verification deferred to user) | 2026-06-11 | + +## Task status (cumulative) + +### Slice S1 — Alarm native reliability — 17/17 complete + +| Task | Status | Notes | +|------|--------|-------| +| T-S1-01 | [x] | RED test: `programar()` payload carries `fallbackStationName/Url`, `fadeInSegundos`, `fallbackSound` | +| T-S1-02 | [x] | RED test: `solicitarExencionBateria()` invokes `requestIgnoreBatteryOptimizations` | +| T-S1-03 | [x] | Manifest FGS type + permission. **DEVIATION** (see below) | +| T-S1-04 | [x] | API ≥ 34 3-arg `startForeground` with type bitmask. **DEVIATION** (see below) | +| T-S1-05 | [x] | Receiver `showFireNotification` + `ensureFireChannel` removed; service notification (id 92841) is sole FSI owner; `fireNotificationIdForAlarm` kept for cancel-migration safety | +| T-S1-06 | [x] | `setFullScreenIntent` was ALREADY present in service `buildNotification`; `stopAlarm` already cancels legacy fire id + `stopForeground(STOP_FOREGROUND_REMOVE)`; FSI-before-audio ordering documented with comment | +| T-S1-07 | [x] | Channel `pluriwave_alarm_fire_v2` (IMPORTANCE_HIGH, `DEFAULT_ALARM_ALERT_URI` + USAGE_ALARM attrs, vibration); one-time deletion of `pluriwave_alarm_native` + `pluriwave_alarm_fire` guarded by `channels_migrated_v2` flag in device-protected prefs `pluriwave_alarm_channels`; channel count consolidated to 2 (fire_v2 + pre-notice) | +| T-S1-08 | [x] | `NativeAlarmSpec` + `fallbackStationName/Url`, `fadeInSegundos`; schemaVersion 2→3; `fromJson` backward-compatible (null/0 defaults); wired through `scheduleAlarm`, `MainActivity` handler, `EXTRA_*` consts, `fireIntent` extras | +| T-S1-09 | [x] | Three-stage chain primary(15s) → fallback station(15s) → bundled WAV via continuation lambdas (`onStageFailed`); `scheduleStationFallback` per stage with independent timeout windows | +| T-S1-10 | [x] | `startFadeIn` ramp: start 0.05×target, 250 ms steps over `fadeInSegundos`; applied to station and bundled-WAV players; `cancelFadeIn()` in `stopAlarm` (snooze path goes through `stopAlarm`) | +| T-S1-11 | [x] | `requestIgnoreBatteryOptimizations` MethodChannel handler + private method in `MainActivity.kt` mirroring `requestExactAlarmPermission` | +| T-S1-12 | [x] | GREEN: payload fields added to `programar()` args map | +| T-S1-13 | [x] | GREEN: `solicitarExencionBateria()` on `PuertoAlarmasAndroid` + impl | +| T-S1-14 | [x] | GREEN: asked-once guard in `_solicitarPermisosNecesariosParaAlarma` — calls only when `!diag.ignoraOptimizacionBateria` AND `bateria_exencion_solicitada` unset; optional `SharedPreferences? prefs` ctor param added to `EstadoAlarmas` (forward-compatible with S3 injection) | +| T-S1-15 | [x] | `flutter test` — full suite 54 tests, all passing (5 new) | +| T-S1-16 | [x] | `flutter analyze` — `No issues found!` (baseline before S1 was also clean) | +| T-S1-17 | [x] | `dart format` applied to the 4 touched Dart files | + +### Slice S2a — Snooze correctness — 20/20 complete + +| Task | Status | Notes | +|------|--------|-------| +| T-S2a-01 | [x] | RED: `test/estado/estado_alarmas_snooze_test.dart` — anchor, snoozed-event, recalc-preserve + extras (cold-start import, stop-cancels-snooze, finalizar clears) | +| T-S2a-02 | [x] | RED: `test/servicios/servicio_alarmas_snooze_test.dart` — anchor future/clamped/custom(7), payload `snoozeUntilMillis`+`snoozeOriginMillis`, `getNativeSnoozeState` parse/empty. Test C (finalizar) lives in the estado file | +| T-S2a-03 | [x] | RED: synchronous list update after `posponerAlarma` (no poll) | +| T-S2a-04 | [x] | Kotlin: `ACTION_SNOOZE` reports back via `MainActivity.notifyAlarmEvent` with `alarmAction="snoozed"` (`PluriWaveAlarmService.kt:56-80`). *On-device verify* | +| T-S2a-05 | [x] | Kotlin: `MainActivity.notifyAlarmEvent` companion (lines ~610-635), `@Volatile activeInstance` set in `configureFlutterEngine`, cleared in `onDestroy`; main-thread post; no-op when engine dead. *On-device verify* | +| T-S2a-06 | [x] | Kotlin: `AlarmScheduler.snooze()` (lines 266-292) unified to `occurrenceAt + minutes` clamped to `now + minutes`; persists `snoozeMinutes`; returns `NativeSnoozeResult(until, origin, title)`. *On-device verify* | +| T-S2a-07 | [x] | Kotlin: `AlarmScheduler.nativeSnoozeStates()` (lines 366-385) + `getNativeSnoozeState` handler (`MainActivity.kt:192`). *On-device verify* | +| T-S2a-08 | [x] | GREEN: `EventoAlarmaAndroid.snoozeUntilMillis` + `accionSnoozed`; `app.dart` ignores `snoozed` events in `_abrirAlarmaSonando` | +| T-S2a-09 | [x] | `snoozeUntilMillis` was ALREADY in the `programar()` payload — locked by new test, no code change | +| T-S2a-10 | [x] | GREEN: `_alRecibirEventoNativo` (estado_alarmas.dart:266) — `posponerEjecucionHasta` + `_aplicar` + `notifyListeners`, NO second `programar`. Subscribed in the CONSTRUCTOR (deviation, see below); cancelled in `dispose` | +| T-S2a-11 | [x] | GREEN: `_importarSnoozesNativosActivos` (estado_alarmas.dart:312) called at the end of `_sincronizarEjecucionesGestionadasPorAndroid`; imports active future snoozes for active alarms when they differ | +| T-S2a-12 | [x] | GREEN: `obtenerEstadoSnoozeNativo()` on `PuertoAlarmasAndroid` + impl + `EstadoSnoozeNativo` model | +| T-S2a-13 | [x] | GREEN: `_recalcular` `snoozeActivo` now requires `alarma.activa` (servicio_alarmas.dart:395) — disabling clears the snooze; finalizar path already cleared + re-programs without snooze (bridge cancels natively when inactive) | +| T-S2a-14 | [x] | RED: `test/pantallas/pantalla_alarma_sonando_test.dart` — buttons 3/5/10(+7), no-dup, tap-5 behavior | +| T-S2a-15 | [x] | GREEN: `_liberarAudioLocal()` + `_posponer(int)` + `_detener` refactor (pantalla_alarma_sonando.dart:138,161). `_estadoSub.cancel()` is fire-and-forget (deviation, see below) | +| T-S2a-16 | [x] | GREEN: snooze button row via `_opcionesSnooze()` (sorted {3,5,10,custom}); NEW l10n keys in ALL 13 .arb files: `alarmSnoozeOptionLabel`, `snoozeAction`, `alarmSnoozeDurationTitle`, `alarmFallbackStationLabel`, `alarmStationPickerSearchHint` (+ `flutter gen-l10n` regenerated) | +| T-S2a-17 | [x] | Targeted snooze tests green | +| T-S2a-18 | [x] | Full suite 77/77 | +| T-S2a-19 | [x] | `flutter analyze` — No issues found | +| T-S2a-20 | [x] | `dart format` on touched files | + +### Slice S2b — Editor + visual redesign — 12/12 complete + +| Task | Status | Notes | +|------|--------|-------| +| T-S2b-01 | [x] | RED: scaffold test — `PluriWaveScaffold` present, no `Color(0xFF061722)` Scaffold, `Animate` present / absent under `disableAnimations` | +| T-S2b-02 | [x] | RED: 5 editor tests (preview + live update, primary picker + filtering, fallback picker, snooze duration persists, volume floor 0.0) | +| T-S2b-03 | [x] | GREEN: ringing screen on `PluriWaveScaffold`; `0xFFFFB86B` → `tokens.warmCoral`; `blurSigma: 10` + cold-GPU comment (Design 2.4) | +| T-S2b-04 | [x] | GREEN: `lib/tema/pluri_animate.dart` — `pluriFadeIn`/`pluriScaleIn` honoring `MediaQuery.maybeDisableAnimationsOf` | +| T-S2b-05 | [x] | GREEN: glass surface wrapped in `.pluriFadeIn(context)` | +| T-S2b-06 | [x] | GREEN: `_vistaProximaEjecucion` (draft → `calcularProxima`, respects vacations/exceptions; recomputed on every setState) | +| T-S2b-07 | [x] | GREEN: `_CampoSelectorEmisora` + `_SelectorEmisoraSheet` (SearchBar picker) for primary AND fallback station; `copyWith` clear-flags added (see deviations) | +| T-S2b-08 | [x] | GREEN: snooze duration SegmentedButton wired to `snoozeMinutos` (editor used to hardcode 5); volume slider min 0.25 → 0.0 (divisions 20) | +| T-S2b-09 | [x] | S2b targeted tests 7/7 green | +| T-S2b-10 | [x] | Full suite 77/77 | +| T-S2b-11 | [x] | `flutter analyze` — No issues found | +| T-S2b-12 | [x] | `dart format` applied | + +### Remaining slices (not started) + +S3a, S3b, S7, S4a, S4b, S5, S6, cross-cutting (T-CC-01, T-CC-02) — all pending. + +## Snooze defect fixes (design audit D1–D5 / S1–S5) + +| Defect | Fix | Where | +|--------|-----|-------| +| D1 (audit S1) — native snooze never notifies Flutter | `ACTION_SNOOZE` → `MainActivity.notifyAlarmEvent("snoozed", origin, until)`; Flutter records via `posponerEjecucionHasta` WITHOUT re-programming; engine-dead case covered by `getNativeSnoozeState` cold-start import | `PluriWaveAlarmService.kt:56-80`, `MainActivity.kt:627`, `estado_alarmas.dart:266,312` | +| D2 (audit S2) — two snooze anchors | Unified everywhere to `occurrence + minutes` clamped to `now + minutes`: native `snooze()` adopts `postponeNext` logic; Dart `posponerEjecucion` re-anchored from `now+min` to `ejecucion+min` | `AlarmScheduler.kt:266-292`, `servicio_alarmas.dart:256-274` | +| D3 (audit S3) — no snooze on ringing screen | 3/5/10 + custom buttons → `_posponer` → shared `_liberarAudioLocal` teardown → canonical `EstadoAlarmas.posponerAlarma` (Flutter-first; hides native notification = same stop path) | `pantalla_alarma_sonando.dart:138-176,242-256` | +| D4 (audit S4) — recalc erases native-only snooze | Resolved transitively by D1: Flutter now RECORDS every native snooze, so `_recalcular` sees `snoozeActivo` and preserves it; regression-guard test added; additionally snooze cleared when alarm disabled (S2-R5) | `servicio_alarmas.dart:392-401`, test `estado_alarmas_snooze_test.dart` | +| D5 (audit S5) — preserveNativeSnooze origin mismatch | Resolved transitively by D1/D2: Flutter always sends `snoozeUntilMillis` when snoozed, so the preservation net is no longer load-bearing; kept as belt-and-suspenders per design | `AlarmScheduler.kt:preserveNativeSnooze` (unchanged) | + +## TDD Cycle Evidence (Strict TDD hard gate) + +| Task | RED (test written first, failing) | GREEN (implementation passes) | REFACTOR | +|------|-----------------------------------|-------------------------------|----------| +| T-S1-01/T-S1-12 | `servicio_alarmas_android_test.dart` written first; load failure + payload keys absent | Payload fields added; 3 tests pass | `dart format` | +| T-S1-02/T-S1-13 | Same RED run: `solicitarExencionBateria` undefined → compile failure | Interface + impl added; test passes | None needed | +| T-S1-14 | "solicita exencion una sola vez" FAILED (Expected 1, Actual 0) | Asked-once guard; both tests pass | Fake made configurable | +| T-S1-03..11 (Kotlin) | N/A — on-device items | N/A | Surgical diffs | +| T-S2a-01..03 / T-S2a-08..13 | All 3 new test files failed to LOAD (missing `EstadoSnoozeNativo`, `accionSnoozed`, `obtenerEstadoSnoozeNativo`, `alarmSnoozeOptionLabel`) — captured before any implementation. Anchor test would fail under old `now+min` semantics (verified by design: old code returned 7:05 vs expected 7:35) | All bridge/state/service changes added; targeted run 23/23 green | Shared `FakePuertoAlarmasAndroid` extracted to `test/helpers/fakes_alarmas.dart`; existing `estado_alarmas_test.dart` deduplicated | +| T-S2a-14..16 | Widget test load failure (l10n key missing) then tap-test failure (snoozeHasta null) | Buttons + `_posponer` implemented; 3/3 green | Debug prints removed; `unawaited` cancel documented | +| T-S2b-01..02 / T-S2b-03..08 | Scaffold tests failed (no `PluriWaveScaffold`/`Animate`); all 5 editor tests failed (key not found) — captured in dedicated RED run (0 passed / 7 failed) | `pluri_animate.dart`, scaffold migration, editor preview/pickers/snooze/volume; 7/7 green | `Material(transparency)` wrappers; initState-l10n fix; `dart format` | + +RED run evidence: first run `+0 -3` (loading failures, three files); ringing tap test `Expected: DateTime:<07:35> Actual: `; S2b run `+0 -7` before implementation. GREEN: targeted 23/23 then 7/7; full suite `00:24 +77: All tests passed!`. + +## Files changed (Batch 2) + +| File | Action | ~Lines | +|------|--------|--------| +| `android/.../AlarmScheduler.kt` | Modified | +57/-12 (unified snooze + `NativeSnoozeResult` + `nativeSnoozeStates`) | +| `android/.../MainActivity.kt` | Modified | +36/-1 (`notifyAlarmEvent` companion, `activeInstance`, `getNativeSnoozeState`) | +| `android/.../PluriWaveAlarmService.kt` | Modified | +19/-1 (snooze report-back) | +| `lib/servicios/servicio_alarmas_android.dart` | Modified | +60 (snoozed event, `EstadoSnoozeNativo`, bridge method) | +| `lib/servicios/servicio_alarmas.dart` | Modified | +20/-3 (anchor, activa-aware snooze clearing) | +| `lib/estado/estado_alarmas.dart` | Modified | +95/-15 (event subscription, snooze recording, cold-start import) | +| `lib/pantallas/pantalla_alarma_sonando.dart` | Modified | +70/-15 (snooze buttons, teardown, PluriWaveScaffold, tokens, fade-in) | +| `lib/pantallas/pantalla_alarmas.dart` | Modified | ~+330/-60 net (preview, pickers, snooze field, volume floor, initState fix, Material wrappers; large diff partly dart-format reflow) | +| `lib/modelos/alarma_musical.dart` | Modified | +10/-2 (`limpiarEmisora`/`limpiarEmisoraFallback`) | +| `lib/app.dart` | Modified | +7 (ignore snoozed events) | +| `lib/tema/pluri_animate.dart` | Created | +39 | +| `lib/l10n/app_*.arb` (13 files) | Modified | +12 each (5 keys + metadata) | +| `lib/l10n/gen/*` (15 files) | Regenerated | by `flutter gen-l10n` | +| `test/helpers/fakes_alarmas.dart` | Created | +120 | +| `test/helpers/fakes.dart` | Modified | +13 (`pausar`/`setVolumen` on FakeServicioAudio) | +| `test/estado/estado_alarmas_snooze_test.dart` | Created | +250 | +| `test/servicios/servicio_alarmas_snooze_test.dart` | Created | +155 | +| `test/pantallas/pantalla_alarma_sonando_test.dart` | Created | +165 | +| `test/pantallas/pantalla_alarma_sonando_scaffold_test.dart` | Created | +150 | +| `test/pantallas/pantalla_alarmas_editor_test.dart` | Created | +210 | +| `test/estado/estado_alarmas_test.dart` | Modified | -78/+8 (fake deduplicated to helper; anchor expectations 7:36:00 → 7:36:02) | + +## Deviations from design (Batch 2) + +1. **Event subscription lives in the `EstadoAlarmas` CONSTRUCTOR**, not `inicializar` (task text said inicializar). Rationale: snoozed events must be recorded even before/without full init, and tests with `iniciarAutomaticamente: false` stay light. Cancelled in `dispose`. +2. **`posponerEjecucion` clamps minutes to 1..120 instead of coercing to 3/5/10.** The old `calcularSnooze` coercion would have made the custom snooze button (e.g. "7 min", S2-R1-C) silently snooze 5. The native notification path still sanitizes to 3/5/10 (`sanitizeSnoozeMinutes`), unchanged. `calcularSnooze` kept (unused by this path) for API compatibility. +3. **Existing test expectation updated** (`estado_alarmas_test.dart`): unified anchor makes snooze land at `proximaEjecucion(+inminencia normalization) + 5min` = 7:36:02, not `now+5` = 7:36:00. This is the spec'd behavior change (S2-R6), documented inline. +4. **`_recalcular` now clears snooze for INACTIVE alarms** — required by S2-R5-A ("snoozeHasta is null in persistent storage" after disable); previously a disabled alarm kept a stale snoozeHasta forever. +5. **`AlarmaMusical.copyWith` gained `limpiarEmisora`/`limpiarEmisoraFallback`** (not in task text). Without them the picker's "no station" could never clear an existing station (latent pre-existing bug: copyWith null-coalesced). +6. **Pre-existing debug crash fixed**: `_EditorAlarmaSheetState.initState` called `AppLocalizations.of(context)` → `dependOnInheritedWidgetOfExactType` assert in debug builds. Name controller now created lazily in `didChangeDependencies`. (The sibling `_EditorVacacionesSheet` has the same latent issue — NOT fixed here, out of S2 scope; flag for S5/S6.) +7. **`Material(type: transparency)` wrappers** added inside `PluriGlassSurface` for the editor sheet and the station picker — ListTiles inside a DecoratedBox trigger a debug assert and invisible ink splashes otherwise. +8. **`_liberarAudioLocal` does NOT await `_estadoSub.cancel()`** — a broadcast-subscription cancel future may not resolve until the stream closes (observed in tests); cancellation of delivery is synchronous, so fire-and-forget (`unawaited`) is correct and prevents the snooze tap from stalling. +9. **`AlarmScheduler.snooze` returns `NativeSnoozeResult`** (until/origin/title) so the service can build the report-back payload; `postponeNext` untouched (already had the unified anchor). +10. **Ringing screen `blurSigma` capped to 10** (PluriGlassSurface default 18) as the Design 2.4 cold-GPU mitigation, plus reduced-motion users skip the entry animation entirely. + +## Issues found + +- `flutter test` does NOT auto-run gen-l10n in this setup despite `generate: true`; `flutter gen-l10n` must be run manually after editing .arb files (gen files are committed). +- `tester.tap` + an awaited broadcast-subscription `cancel()` deadlocks the gesture handler chain in widget tests (see deviation 8) — worth remembering for S3/S7 work. + +## On-device verification checklist for the user + +From tasks.md Section 11 — S1 items still pending from Batch 1, plus new S2 items: + +1. **Kotlin compiles** — native layer edited again without compilation (HIGH risk): build FIRST. +2. Alarm fires app-killed on Android 14+ (S1-R1/R2); channel v2 on alarm stream (S1-R3); 3-stage fallback (S1-R4); native fade (S1-R6); battery dialog once (S1-R5); reboot persistence. +3. **Snooze from ringing screen (S2-R1, S2-R4)**: ring → tap "5 min" → notification dismissed, list shows snoozeHasta immediately, re-fires at that time. +4. **Snooze from notification while app killed (S2-R3)**: tap "Posponer" on the fire notification with the app killed → system alarm icon persists → reopen the app → list shows the snooze WITHOUT waiting for the 60 s poll (cold-start `getNativeSnoozeState` import). +5. **Snooze from notification while app foregrounded (S2-R3)**: same, but the list updates within the same frame via the `snoozed` MethodChannel event. +6. **Stop cancels pending snooze (S2-R5)**: snooze → disable the alarm from the list → does NOT re-fire. +7. **Ringing screen visuals (S2-R7)**: PluriWaveScaffold gradient + entry fade; verify no first-frame stutter on screen-off FSI wake (blur capped); with "remove animations" accessibility setting the entry is instant. +8. **Editor (S2-R8..R11)**: next-trigger preview updates live; searchable pickers for primary AND backup station; snooze duration control; volume slider reaches 0%. + +## Verification summary (Batch 2) + +- `flutter test`: 77/77 passing (54 pre-batch + 23 new) +- `flutter analyze`: No issues found (identical to baseline) +- `dart format`: applied to all touched Dart files only (gen/ untouched by hand) +- `flutter gen-l10n`: run once after .arb edits +- `flutter build`: NOT run (forbidden) + +## Workload / boundary + +- Mode: auto-chain local slices (no PRs) +- Current work units: S2a + S2b (complete) +- Boundary: starts from S1-complete tree; ends with S2a+S2b fully checked off, suite green. Rollback = revert the Batch-2 files listed above (S1 files only touched additively in `AlarmScheduler.kt`/`MainActivity.kt`/`PluriWaveAlarmService.kt`). +- Next batch: S3a (test seams) — prerequisite: user performs on-device verification for S1+S2 Kotlin, especially compile. diff --git a/openspec/changes/app-quality-and-native-alarms/design.md b/openspec/changes/app-quality-and-native-alarms/design.md new file mode 100644 index 0000000..4c20a12 --- /dev/null +++ b/openspec/changes/app-quality-and-native-alarms/design.md @@ -0,0 +1,282 @@ +# Design: app-quality-and-native-alarms + +Technical design for the seven chained slices that raise PluriWave to native-Android-Clock alarm reliability, fix the snooze divergence, harden the audio/runtime layer, add streaming resilience, split the `EstadoRadio` god-class, and close the design-system / a11y / i18n / quality-gate gaps. The custom native alarm stack is KEPT and hardened. Every decision below is grounded in the actual code (`file:line`) so `sdd-tasks` can act without re-discovery. + +This document is the HOW at the architectural level. It does not enumerate task steps (that is `sdd-tasks`). `flutter build` is FORBIDDEN; Kotlin is verified on-device by the user. Strict TDD applies via `flutter test`. + +## Architecture at a glance + +| Layer | Owns | Key files | +|-------|------|-----------| +| Flutter domain | Alarm data, recurrence math, persistence, single source of truth for snooze state | `servicio_alarmas.dart`, `servicio_programacion_alarmas.dart`, `estado_alarmas.dart` | +| Flutter audio | Radio playback, EQ, recording, reconnect-on-stall, audio focus | `servicio_audio.dart` (`PluriWaveAudioHandler`), new `ServicioAudioSession` | +| Flutter UI | Ringing screen, editor, mini player, screens | `pantalla_alarma_sonando.dart`, `pantalla_alarmas.dart`, `mini_reproductor.dart` | +| Bridge | MethodChannel `pluriwave/alarm_scheduler` (Flutter↔native), `alarmFired` callback (native→Flutter) | `servicio_alarmas_android.dart`, `MainActivity.kt` | +| Native delivery | Exact wakeup (`setAlarmClock`), FSI notification, native audio + fade, snooze reschedule | `AlarmScheduler.kt`, `PluriWaveAlarmReceiver.kt`, `PluriWaveAlarmService.kt`, `AndroidManifest.xml` | + +The guiding principle for this change: **the Flutter `ServicioAlarmas` config is the single source of truth for "postponed until"**. Native reschedules autonomously for wakeup reliability, but every native state mutation that changes the next occurrence MUST be reflected back to Flutter through the bridge so the two never diverge. Slice 2 establishes that protocol; the current divergence is the root cause of the user-reported broken snooze. + +--- + +## Slice 1 — Alarm native reliability (CRITICAL) + +### Decision 1.1 — Foreground-service type and permission (A1) + +- Manifest: change `PluriWaveAlarmService` to `android:foregroundServiceType="mediaPlayback|alarm"` (`AndroidManifest.xml:54-57`). Add `` next to the existing `FOREGROUND_SERVICE_MEDIA_PLAYBACK` (`AndroidManifest.xml:5`). +- Service: when calling `startForeground` (`PluriWaveAlarmService.kt:75`), on API ≥ 34 pass the explicit type bitmask `ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or ServiceInfo.FOREGROUND_SERVICE_TYPE_ALARM` via the 3-arg `startForeground(id, notification, type)` overload. On API < 34 keep the 2-arg overload. +- Rationale: include BOTH types because the service plays a radio stream (mediaPlayback) AND is an alarm (alarm). The `alarm` type is the one that satisfies the API 34+ FGS-launch-from-background restriction when started from `PluriWaveAlarmReceiver.onReceive` (`PluriWaveAlarmReceiver.kt:27`). Alarm-triggered broadcasts receive a temporary FGS-while-in-use exemption, so starting an `alarm`-typed FGS from the fire receiver is the documented, allowed path on API 34+. +- ADR note (rejected): using only `mediaPlayback` keeps the silent-failure bug; using only `alarm` would block the legitimate media-playback path the radio stream needs. KEEP both. + +### Decision 1.2 — Single FSI notification, ownership and ordering (A3) + +Today two notifications fire for one event: the receiver posts `fireNotificationIdForAlarm` (`PluriWaveAlarmReceiver.kt:95-133`) AND the service posts `NOTIFICATION_ID 92841` via `startForeground` (`PluriWaveAlarmService.kt:75`). `dismissFireNotification` only cancels the receiver's (`AlarmScheduler.kt:304-308`), leaving the service's orphaned. + +- Decision: the **service's `startForeground` notification (`NOTIFICATION_ID 92841`) is the single owner** of the ringing FSI. Remove `showFireNotification` from the receiver entirely (`PluriWaveAlarmReceiver.kt:37, 95-133`). +- Ordering problem: an FSI must appear immediately, even before the radio stream prepares (`prepareAsync` is async, ~seconds). The service already calls `startForeground` synchronously at the top of `startAlarm` BEFORE `startAudio` (`PluriWaveAlarmService.kt:75` then `:83`). That notification carries `setFullScreenIntent(...)` (add it to `buildNotification`, currently the service builder has it at `:267`). So the FSI shows the instant the service enters foreground, audio attaches afterward — correct ordering, no gap. +- The receiver still does `context.startActivity(launch)` (`PluriWaveAlarmReceiver.kt:38-43`) to bring the Flutter ringing screen forward; that is the activity launch, not a notification, so no duplication. +- Cleanup: `stopAlarm` must cancel `NOTIFICATION_ID 92841` through `stopForeground(STOP_FOREGROUND_REMOVE)` (already at `:242`) and ALSO cancel any legacy `fireNotificationIdForAlarm` id for installs upgrading mid-ring (`:236-240` already does this — keep it as a migration safety net for one release). +- `fireNotificationIdForAlarm` helper stays (referenced by `cancelAlarm` `AlarmScheduler.kt:300`) but is no longer posted to. + +### Decision 1.3 — Channel sound versioning to apply USAGE_ALARM (A4) + +Android locks `setSound(uri, attributes)` at channel creation; the existing channels `pluriwave_alarm_native` (`PluriWaveAlarmService.kt:374`) and `pluriwave_alarm_fire` (`PluriWaveAlarmReceiver.kt:269`) were created without sound, so editing them in place is a no-op on existing installs. + +- Decision: **channel versioning**. Introduce versioned channel ids and delete the old ones once: + - New ringing channel: `pluriwave_alarm_fire_v2` (IMPORTANCE_HIGH) with `setSound(, alarmAudioAttributes)` and `enableVibration(true)`. This is the channel the service's `startForeground` notification uses. + - Keep the pre-notice channel id but bump only if its config changes; it stays silent (`setSound(null, null)`, `PluriWaveAlarmReceiver.kt:232`) so no version bump needed. + - Consolidate to TWO channels total (A8): the ringing channel (`_fire_v2`) and the pre-notice channel. The service stops using its own `pluriwave_alarm_native` channel and posts on `_fire_v2`. +- Sound URI: a `Settings.System.DEFAULT_ALARM_ALERT_URI` (system default alarm tone) is the channel-level sound for the heads-up/locked alert. Note the bundled WAV played by `MediaPlayer` (`PluriWaveAlarmService.kt:355-362`) is the AUDIO that loops; the channel sound is the notification-attached alert. They are distinct; channel sound exists so even if the service audio fails the channel still produces an alarm-attributed sound. Use `USAGE_ALARM` AudioAttributes on both. +- Migration: add a one-time deletion of the obsolete channels (`pluriwave_alarm_native`, `pluriwave_alarm_fire`) via `manager.deleteNotificationChannel(...)` guarded by a SharedPreferences flag `channels_migrated_v2` so it runs once and does not reset the user's settings on the new channel repeatedly. Deleting a channel and recreating under a NEW id is the only way Android lets you change locked sound settings without the user manually clearing data. +- ADR note (rejected): mutating the existing channel in place — Android ignores `setSound` after creation, so it would silently keep the bug. + +### Decision 1.4 — emisoraFallback passthrough and second prepare attempt (A5) + +Today `programar()` sends `stationName/stationUrl/fallbackSound` but NEVER `emisoraFallback` (`servicio_alarmas_android.dart:167-173`); the Kotlin `NativeAlarmSpec` has no fallback-station field. The native fallback is only the bundled WAV. + +- Bridge payload: add `fallbackStationName` and `fallbackStationUrl` to the `scheduleAlarm` MethodChannel args in `programar()` (`servicio_alarmas_android.dart:148-174`), sourced from `alarma.emisoraFallback`. +- Kotlin model: add `fallbackStationName: String?` and `fallbackStationUrl: String?` to `NativeAlarmSpec` (`AlarmScheduler.kt:571-648`), to `toJson` (bump `schemaVersion` 2→3 at `:594`) and `fromJson` (`:618-646`, read with `optString(...).takeIf { isNotBlank() }`). Wire through `scheduleAlarm(...)` signature (`AlarmScheduler.kt:21-40`) and the `MainActivity` handler (`MainActivity.kt:68-106`). Add the two extras to `EXTRA_*` constants and the `fireIntent` extras (`PluriWaveAlarmReceiver.kt:277-279`, `AlarmScheduler.kt:487-507`). +- Service audio chain: extend `startAudio` (`PluriWaveAlarmService.kt:86-108`) to a three-stage ordered fallback: + 1. Primary station: `startStationAudio(primary)` with a 15s timeout (`STATION_START_TIMEOUT_MILLIS` already 15s at `:379`). + 2. On primary timeout/error/completion → fallback station (if present): a SECOND `prepareAsync` against `fallbackStationUrl`, again with a 15s timeout. + 3. On fallback-station timeout/error (or absent) → bundled WAV (`startFallbackAudio`, `:162`). + - Implement as a small state machine: pass the next-stage lambda into the prepared/error/completion/timeout handlers instead of jumping straight to the WAV. Reuse `scheduleStationFallback` per stage with its own runnable so the 15s windows are independent and `cancelStationFallback` clears the current stage. +- Persisted-spec migration: already-scheduled alarms have `schemaVersion: 2` specs in device-protected SharedPreferences (`AlarmScheduler.kt:436-444`). `fromJson` must default the two new fields to `null` when absent (no fallback station) — additive and backward compatible. On the next Flutter `programar()` (boot resync via `reschedulePersistedAlarms` `:315` or app open via `_sincronizarTodas` `estado_alarmas.dart:286-296`) the v3 fields are written. No destructive migration needed. + +### Decision 1.5 — Native fade-in honoring fadeInSegundos (A6) + +The native service plays at constant `volume` (`PluriWaveAlarmService.kt:120-121, 176-177`). The Dart screen fade (`pantalla_alarma_sonando.dart:92-115`) only runs when the screen is foregrounded. + +- Bridge: add `fadeInSegundos` to the `scheduleAlarm` payload (`servicio_alarmas_android.dart`) and to `NativeAlarmSpec` (default 0). `AlarmaMusical.fadeInSegundos` already exists (used at `pantalla_alarma_sonando.dart:96`). +- Service: implement a `MediaPlayer.setVolume` ramp in the service driven by the existing `mainHandler` (`PluriWaveAlarmService.kt:27`). On the `setOnPreparedListener` start (`:128-136` and the fallback `:179-183`), if `fadeInSegundos > 0`, start at a low floor (e.g. 0.05 * target) and step every 250 ms toward `volume` over `fadeInSegundos`, mirroring the Dart algorithm (`pantalla_alarma_sonando.dart:101-114`). Cancel the ramp runnable in `stopAlarm` (`:224`) and on snooze. +- Coordination with the Dart fade: when the ringing screen is foregrounded with native audio already ramping, the screen must NOT double-ramp. The screen owns the fade ONLY for its own `_fallbackPlayer` and the `radio.audio` it pre-started; the native service owns the fade for native MediaPlayer audio. They never play the same source simultaneously (service stops when Flutter confirms audio via `confirmFlutterAudio`, `MainActivity.kt:139-148`). Document this hand-off boundary in the service header comment. + +### Decision 1.6 — Battery-optimization request placement (A7) + +`REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` is declared (`AndroidManifest.xml:13`) and `diagnostico.ignoraOptimizacionBateria` is read (`servicio_alarmas_android.dart:65`) but never requested. + +- Decision: add a native `requestIgnoreBatteryOptimizations` MethodChannel method in `MainActivity` (mirror `requestExactAlarmPermission` `:255-270`) that launches `Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` with `package:` data. Add `solicitarExencionBateria()` to `PuertoAlarmasAndroid` / `ServicioAlarmasAndroid` (`servicio_alarmas_android.dart:93-107, 196-218`). +- Placement and prompt-fatigue guard: call it inside `_solicitarPermisosNecesariosParaAlarma` (`estado_alarmas.dart:268-284`) ONLY when `!diag.ignoraOptimizacionBateria` AND an **asked-once flag** is unset. Persist the flag (`bateria_exencion_solicitada`) in the injected SharedPreferences (Slice 3). Request once; never re-prompt automatically. The user can re-request from the diagnostics UI manually. +- ADR note: requesting on every `guardarAlarma` (which calls this method, `estado_alarmas.dart:87`) would spam the user. The asked-once flag is mandatory. + +### Slice 1 size note +Kotlin (manifest + service FGS type + channel v2 + 3-stage fallback + fade ramp) + Dart bridge (payload fields + battery method) ≈ 300-340 lines. Within budget. Native edits are surgical and mirror existing patterns to minimize compile risk. + +--- + +## Slice 2 — Alarm UX parity and SNOOZE CORRECTNESS (HIGH) + +### Snooze audit — found defects (file:line) + +The user reports snooze did not work correctly. Root cause is **state divergence between the native scheduler and the Flutter source of truth**, plus inconsistent snooze anchoring. Precise findings: + +| # | Defect | Evidence | Effect | +|---|--------|----------|--------| +| S1 | The native ringing-notification snooze (`ACTION_SNOOZE`) reschedules natively then `stopAlarm()`, and NEVER notifies Flutter. The `alarmFired` callback only fires from `MainActivity.onNewIntent` on an activity launch (`MainActivity.kt:227-234`); the snooze service path starts no activity. | `PluriWaveAlarmService.kt:42-49` → `AlarmScheduler.snooze()` → `stopAlarm()`; no `startActivity`; `MainActivity.kt:233` never reached. | Flutter `EstadoAlarmas` keeps the OLD next-occurrence; native fires at the snoozed time. UI shows wrong "next alarm"; reconciliation only happens on the next 60s `refrescarProgramacion` (`estado_alarmas.dart:316`), which RECALCULATES from scratch and can lose the native snooze. Divergence. | +| S2 | Two different snooze anchors in native code. `snooze()` uses `now + minutes` (`AlarmScheduler.kt:257`); `postponeNext()` uses `occurrenceAt + minutes` clamped to now (`AlarmScheduler.kt:270-273`). | `AlarmScheduler.kt:254-287` | Snooze-from-ringing and snooze-from-pre-notice land at different times for the same intent. Inconsistent with Flutter `posponerEjecucion` which uses `calcularSnooze(now, minutos)` (`servicio_alarmas.dart:262`). | +| S3 | The ringing screen has NO snooze button at all — only "Detener" (`_detener`). The only ringing-time snooze is the notification action, which is the S1-broken path. | `pantalla_alarma_sonando.dart:200-204` | User cannot snooze from the screen; if they snooze from the notification, S1 divergence triggers. | +| S4 | `refrescarProgramacion` calls `servicio.recalcularTodas()` which runs `_recalcular` → clears snooze when `!snoozeActivo` and recomputes `proximaEjecucion` ignoring any native-only snooze. | `estado_alarmas.dart:98-107`, `servicio_alarmas.dart:159-172, 378-397` | A native snooze that Flutter never recorded (S1) is erased by the next periodic recalculation; native and Flutter then disagree on the next trigger. | +| S5 | `preserveNativeSnooze` (`AlarmScheduler.kt:342-357`) only preserves a native snooze when Flutter reschedules WITHOUT a snooze and the origin matches `requestedTriggerAtMillis`. After S4 erases Flutter's snooze and recalculates a new trigger, the origin no longer matches, so preservation fails and the native snooze is dropped on the next `programar()`. | `AlarmScheduler.kt:347-356` | The one safety net for divergence is defeated by the recalculation in S4. | + +### Decision 2.1 — Single source of truth + native→Flutter snooze sync protocol + +- **Flutter `ServicioAlarmas` config is the canonical "postponed until"** (`snoozeHasta` / `snoozeOrigen` on `AlarmaMusical`, written by `posponerEjecucionHasta`, `servicio_alarmas.dart:266-294`). Native is a mirror that must report back. +- New native→Flutter event: when the service handles `ACTION_SNOOZE` (`PluriWaveAlarmService.kt:42-49`), after `AlarmScheduler.snooze(...)`, fire the `alarmFired` MethodChannel callback with a new action `snoozed` carrying `alarmId`, `occurrenceAtMillis` (the snooze origin) and `snoozeUntilMillis`. Because the service has no activity, route it through the EXISTING `alarmMethodChannel` held by `MainActivity`: expose a static `MainActivity.notifyAlarmEvent(map)` that invokes `alarmMethodChannel?.invokeMethod("alarmFired", map)` on the main thread when the engine is alive; if the engine is dead, the native snooze is already persisted and Flutter will reconcile on next launch via the handled-occurrences sync (extended below). This avoids inventing a second channel. +- Flutter side: `ServicioAlarmasAndroid._instalarHandler` (`servicio_alarmas_android.dart:269-285`) already forwards `alarmFired` events. Extend `EventoAlarmaAndroid` to carry the action `snoozed`. `EstadoAlarmas` listens (wire a subscription in `inicializar`) and on a `snoozed` event calls `servicio.posponerEjecucionHasta(alarmId, occurrence, snoozeUntil)` + `_aplicar` + `notifyListeners` — WITHOUT calling `android.programar()` again (native already scheduled it). This makes Flutter record the snooze native chose, killing S1. +- Extend the launch-on-snooze reconciliation: the native already persists snooze in its spec. Add `snoozeUntilMillis`/`snoozeOriginMillis` to `getHandledAlarmOccurrences` OR add a new `getNativeSnoozeState` method so `_sincronizarEjecucionesGestionadasPorAndroid` (`estado_alarmas.dart:251-266`) also imports active native snoozes on cold start. This covers the engine-dead case in the previous bullet. + +### Decision 2.2 — Unify the snooze anchor (fixes S2, S4, S5) + +- Make native `snooze()` use the SAME anchor as `postponeNext()`: `occurrenceAt + minutes` where `occurrenceAt = snoozeOriginMillis ?: triggerAtMillis`, clamped to `now + minutes` if already past (`AlarmScheduler.kt:254-265` adopts `:270-273` logic). One snooze semantic everywhere, matching the Flutter `posponerEjecucionHasta(ejecucion = snoozeOrigen ?? proximaEjecucion)` anchor (`estado_alarmas.dart:165-167`). +- Guard `recalcularTodas` against erasing an active snooze: `_recalcular` already preserves snooze when `snoozeActivo` (`servicio_alarmas.dart:384-385, 395`). The real fix is S1/S4 — once Flutter RECORDS the native snooze (Decision 2.1), `snoozeActivo` is true and `recalcularTodas` preserves it. So S4/S5 are resolved transitively by 2.1. Keep `preserveNativeSnooze` as a belt-and-suspenders net. + +### Decision 2.3 — End-to-end snooze from the ringing screen (fixes S3) + +- Add snooze buttons to `PantallaAlarmaSonando` (`pantalla_alarma_sonando.dart:200-204` area): 3 / 5 / 10 plus the alarm's configured default (`alarma.snoozeMinutos`). A `_posponer(int minutos)` handler that mirrors `_detener` (`:132-143`) teardown: cancel `_fallbackTimer`, `_fadeInTimer`, `_estadoSub`; stop `_fallbackPlayer`; pause `radio.audio`; then `await context.read().posponerAlarma(widget.alarma, minutos)`; then `navigator.pop()`. +- `posponerAlarma` already exists and does the right Flutter-first sequence: hides the native notification, records `posponerEjecucion`, re-programs native (`estado_alarmas.dart:165-183`). This is the CANONICAL path — the screen uses it, so Flutter is always the writer and native is the mirror. No divergence by construction. +- Teardown coordination: `_fadeInTimer` and `_fallbackPlayer` MUST be torn down before `posponerAlarma` re-programs native, otherwise the Dart fallback keeps looping after snooze. Add the same teardown to a shared private `_liberarAudioLocal()` used by both `_detener` and `_posponer`. + +### Decision 2.4 — Ringing screen redesign (C1, C3) + +- Migrate from raw `Scaffold` (`pantalla_alarma_sonando.dart:158`) to `PluriWaveScaffold`. Replace hardcoded `Color(0xFF061722)` (`:159`) and `Color(0xFFFFB86B)` (`:167`) with `PluriWaveTokens` from the theme extension. +- Entry animation: wrap the glass surface content in a `flutter_animate` fade+scale entry, gated by the reduced-motion guard from Slice 5 (`PluriAnimate` extension). Do NOT introduce a Hero here (Hero work is C2, deferred/optional in Slice 5). +- BackdropFilter perf note: `PluriGlassSurface` uses `BackdropFilter`. On a cold GPU wake (screen-off → FSI), the first frame can stutter. Mitigation: render a cheap solid/gradient placeholder for the first ~1 frame, then enable the blur after the first `addPostFrameCallback`, OR cap the blur sigma on this screen. Document the constraint; keep the blur optional behind the reduced-motion guard so accessibility users also skip the expensive filter. + +### Decision 2.5 — Editor improvements (C7) + +- Next-trigger preview inside `_EditorAlarmaSheet` (`pantalla_alarmas.dart:387-636`): compute via the existing `ServicioProgramacionAlarmas.calcularProxima` against the in-progress alarm draft and render a localized "Next: " line. Reuse the Slice 5 locale-aware `DateFormat`. +- Searchable station picker: replace the `DropdownButtonFormField` with a bottom-sheet using `SearchBar` over the user's favorites (and optionally recent stations). Returns the selected `Emisora` for both primary and fallback fields. This also surfaces `emisoraFallback` selection in the UI, which Slice 1 now honors natively. +- Configurable snooze duration field: a segmented/slider control writing `alarma.snoozeMinutos` (sanitized to 3/5/10 to match native `sanitizeSnoozeMinutes`). Lower the volume-slider floor from 0.25 toward 0 (proposal scope). + +### Slice 2 size note +Snooze sync (bridge event + EstadoAlarmas listener + native callback + anchor unify) ≈ 120 lines; ringing screen redesign + buttons ≈ 130; editor (preview + picker + snooze field) ≈ 130. Total ≈ 380, AT the budget ceiling. **Risk: may exceed 400.** Proposed sub-split if the forecast trips the guard: **2a** = snooze correctness (audit fixes 2.1–2.3, ringing buttons) and **2b** = editor + visual redesign (2.4–2.5). 2a is the user-trust fix and ships first. + +--- + +## Slice 3 — Audio / runtime robustness (test seams) (HIGH) + +### Decision 3.1 — audio_session integration (B1) + +- Add a `ServicioAudioSession` wrapper around `package:audio_session` (already in pubspec `:19`). Configure on app init with `AudioSession.instance` → `configure(AudioSessionConfiguration.music())` adjusted: `AVAudioSessionCategory.playback`, `AndroidAudioAttributes(usage: media, contentType: music)`, `androidAudioFocusGainType: gain`, `androidWillPauseWhenDucked: true`. +- Hook point: inside `PluriWaveAudioHandler` (`servicio_audio.dart:113-147`) or a thin collaborator it owns. Subscribe to: + - `interruptionEventStream`: on `begin` with `AudioInterruptionType.pause` (phone call) → pause and remember "was playing"; on `begin` with `duck` → lower volume; on `end` with `shouldResume` → resume. This is what makes calls pause the radio (the missing B1 behavior). + - `becomingNoisyEventStream` (headphones unplugged) → pause. +- Interaction with reconnect (Slice 7): an audio-session pause is a USER-INTENT pause-equivalent and MUST set the same "intentional pause" flag the reconnect logic checks (Decision 7.2), so the stall detector does not fight the interruption handler and try to reconnect during a phone call. +- Alarm path: the native alarm uses `USAGE_ALARM` and its own MediaPlayer, independent of this media session, so audio focus for the radio does not interfere with native alarm audio. + +### Decision 3.2 — Kill static state in ServicioAlarmasAndroid (B2) + +- Convert `static _eventosController`, `static _handlerInstalado`, `static _l10n` (`servicio_alarmas_android.dart:117-120`) to INSTANCE fields. Install the handler in the constructor per instance. +- Make the channel injectable (the constructor already accepts `MethodChannel`, `:110-112`) and the localizations injectable per instance instead of a static setter. `EstadoRadio.configurarLocalizaciones` (`estado_radio.dart:70-75`) currently calls the static `ServicioAlarmasAndroid.configurarLocalizaciones`; rewire it to call the instance held by `EstadoAlarmas.android`. This makes the service unit-testable with a `TestDefaultBinaryMessenger` and removes global shared state. +- Backward-compat: keep a deprecated static shim for one release if other call sites reference it (grep shows only `estado_radio.dart:74`). + +### Decision 3.3 — Move configurarLocalizaciones out of build() (B3) + +- `MiniReproductor.build()` calls `configurarLocalizaciones(l10n)` every rebuild (`mini_reproductor.dart:23`), firing on every buffer notification. Move it to `didChangeDependencies` (fires when locale/inherited widgets change, not on every `notifyListeners`), guarded so it only re-runs when the `Locale` actually changes. Convert `MiniReproductor` to `StatefulWidget` if needed, or hoist the call to a top-level locale listener in `app.dart` that runs once per locale change. + +### Decision 3.4 — Inject a single cached SharedPreferences (B4) + +- Resolve `SharedPreferences.getInstance()` ONCE at startup (in `main.dart`) and inject the instance into `ServicioAlarmas` (constructor already accepts `prefs`, `servicio_alarmas.dart:23-29`), `ServicioEcualizador`, `ServicioGrabacionRadio`, and any service doing `getInstance()` (25+ sites per B4). Provide a backward-compatible default (`_resolverPrefs` already falls back to `getInstance()`, `:399-400`) so partial adoption stays functional and a revert is safe. +- This is also a test seam: tests pass `SharedPreferences.setMockInitialValues({})` once. + +### Decision 3.5 — Guard recalcularTodas writes + single-writer cache (B5, B6, B9) + +- `recalcularTodas` writes SharedPreferences unconditionally every minute (`estado_alarmas.dart:316-318` → `servicio_alarmas.dart:159-172`). Add a dirty-check: compute the new config, compare serialized JSON (or a change flag from `_recalcular`) against the loaded config, and only `_guardar` when something changed. Returns the loaded config unchanged when clean (mirror the `sincronizarEjecucionesNativas` `huboCambios` pattern, `:181, 219`). +- In-memory cache + single-writer mutex in `ServicioAlarmas` to kill the read-modify-write N+1 race (B6): `cargar()` runs before every mutation (`:86, 111, 124, 160, 179, 230, 271, 300`) with no cache and no serialization, so concurrent `guardarAlarma`/`posponer`/`refrescar` interleave and lose writes. Decision: hold an in-memory `ConfiguracionAlarmas?` cache, hydrate on first `cargar`, and serialize ALL mutations through a single `Future`-chain mutex (the same pattern `PluriWaveAudioHandler._colaCambioFuente` uses, `servicio_audio.dart:125, 282-285`). Every mutation = `await _lock` → read cache → mutate → persist → update cache → release. This is THE seam the Slice 6 concurrency test exercises. +- Bound `_ejecucionesEmitidas` (`estado_alarmas.dart:32`): replace the unbounded `Set` with a bounded LRU (cap ~200) or prune entries older than a day on each `_vigilarAlarmasVencidas` pass (`:326-348`). Keys are `alarmId:millis`; prune by parsing the millis suffix. + +### Decision 3.6 — Tame empty catch + unawaited (B7, B10) — scoped +- Not the focus of Slice 3 structurally, but where the seams touch `servicio_audio.dart` empty catches (`:343, 346, 406, 428, 448`) and `app.dart:324` `unawaited(radio.reproducir)`, replace silent swallow with at least a `developer.log`. Full lint enforcement is Slice 6 (`unawaited_futures`). Keep edits minimal here to stay under budget. + +### Slice 3 size note +Audio session ≈ 90, statics→instance + l10n rewire ≈ 70, prefs injection ≈ 60, dirty-guard + cache/mutex + bounded set ≈ 120, logging touch-ups ≈ 30. Total ≈ 370. Within budget but tight; if it trips, split **3a** = test seams (statics, prefs, cache/mutex, dirty-guard) and **3b** = audio_session + becoming-noisy. 3a unblocks Slice 6 tests; 3b is the call-pause feature. + +--- + +## Slice 7 — Streaming resilience (NEW, user request) (MEDIUM-HIGH) + +Radio streams should survive short connection drops (seconds) via buffering and auto-recover. + +### Decision 7.1 — just_audio buffer configuration for LIVE streams + +- `just_audio ^0.9.42` supports `AudioLoadConfiguration(androidLoadControl: AndroidLoadControl(...))`, applied at `AudioPlayer` construction (`servicio_audio.dart:159-163` `_crearPlayer`). Set: + - `minBufferDuration: 15s`, `maxBufferDuration: 50s` — pre-roll the player keeps; larger min buffer absorbs jitter. + - `bufferForPlaybackDuration: 2.5s`, `bufferForPlaybackAfterRebufferDuration: 5s` — how much must buffer before (re)starting playback; higher after-rebuffer value reduces re-stutter. + - `targetBufferBytes`, `prioritizeTimeOverSizeThresholds: true`. +- **Achievable window — be honest:** for LIVE icy/HTTP radio there is NO seekable rewind window; the stream is unbounded and not stored to disk by default. The buffer is a forward jitter cushion, not a rewind history. So: + - What we CAN achieve: tolerate network jitter / micro-drops up to roughly the `maxBufferDuration` worth of already-buffered audio (realistically a few to ~15-30 seconds of cushion depending on bitrate and how full the buffer was when the drop hit), and FAST automatic re-prepare when the connection returns. + - What we CANNOT achieve: pause-and-resume across a long outage without a gap (live audio advances in real time; on reconnect you rejoin the live edge, not where you dropped). We do not promise gap-free recovery for outages longer than the buffered cushion. +- Decision: the realistic goal is **jitter tolerance + fast recovery to the live edge**, with a clear "reconnecting" UI state, not seamless time-shift. State this in the spec acceptance criteria so expectations are honest. + +### Decision 7.2 — Reconnect-on-stall with bounded exponential backoff + +- Stall vs user-pause discrimination: track an explicit `_intencionReproducir` (intent-to-play) flag, set true on `play`/`reproducir`/`reanudar` and false on `pause`/`stop` and on an audio-session interruption pause (Decision 3.1). A STALL is: `_intencionReproducir == true` AND `playerStateStream` reports `processingState == buffering` for longer than a threshold (e.g. 8-10s) OR an error event arrives (`servicio_audio.dart:189-194` `_eventosSub.onError`, currently routes to `_gestionarErrorReproduccion`). +- Reconnect loop in `PluriWaveAudioHandler`: on detected stall, instead of going straight to terminal error (`_gestionarErrorReproduccion`, `:207-236`), enter a reconnect state machine: + - Surface a new `EstadoReproduccion.reconectando` (extend the enum `servicio_audio.dart:14`) so mini player / player UI shows "reconnecting". + - Re-issue the source (`_player.setUrl(mediaItem.id)` then `play`) using the existing revision-guarded `_cambiarFuente` machinery (`:288-332`) so a user source-switch during reconnect cancels it (revision mismatch). + - Bounded exponential backoff: delays 1s, 2s, 4s, 8s, 16s, 32s, then cap; total window ~60-90s (configurable max attempts ~7-8). After exhaustion, fall through to the EXISTING terminal `_gestionarErrorReproduccion` with the friendly message. This preserves current error UX as the final state. + - Cancel/reset the backoff and counter on successful `ready`+`playing`, on user `stop`/`pause`, and on source switch. +- Interaction with EQ/volume/recording: reconnect re-creates the player via `_recrearPlayer` (`:334-354`) which already re-applies `_volumen` (`:352`) and re-activates the EQ (`_activarEcualizador`, `:306, 372-383`). Recording (`ServicioGrabacionRadio`) reads the live PCM/visualizer; on reconnect the `androidAudioSessionId` changes (re-emitted at `:196-203`) and recording/visualizer resubscribe via the existing session-id stream — no extra wiring needed, but the design MUST verify recording survives a session-id change (Slice 6 recording-recovery test covers it). +- Interaction with the alarm pre-start path (`app.dart:316-325` `_prearrancarAudioAlarma`): when the alarm pre-starts the radio and the stream stalls, reconnect should engage normally, BUT the alarm screen already has its own 12s station timeout → bundled-WAV fallback (`pantalla_alarma_sonando.dart:74-79`). Decision: during an active alarm ring, the alarm's fallback timer takes precedence — if the radio has not reached `reproduciendo` within the alarm's timeout, the alarm switches to the bundled fallback regardless of reconnect attempts, because waking the user reliably beats reconnect persistence. The reconnect loop must not extend the alarm wake-up window. Expose enough state for the alarm screen to make that call (it already listens to `estadoStream`; `reconectando` must NOT be misread as `reproduciendo`). + +### Slice 7 size note +Buffer config ≈ 25, reconnect state machine + enum + backoff ≈ 180, UI "reconnecting" wiring in mini/player ≈ 60, alarm-path guard ≈ 20. Total ≈ 285. Within budget. Depends on Slice 3 (audio-session intent flag) landing first. + +--- + +## Slices 4-6 + +### Slice 4 — EstadoRadio god-class split (HIGH, broad) + +`EstadoRadio` (1121 lines, `estado_radio.dart`) owns 6 services + direct I/O (`:30-62`). Split seams and EXTRACTION ORDER (lowest-coupling first so each PR stays small and backward-compatible): + +1. **`ServicioExportImport`** — extract the backup/import `jsonDecode`/`jsonEncode` from `pantalla_ajustes.dart` (1391 lines) AND any export logic in `EstadoRadio`. Pure logic, highest test value (round-trip), zero UI coupling. Extract FIRST. +2. **`EstadoEcualizador`** (`ChangeNotifier`) — EQ preset/active/band state currently proxied through `EstadoRadio` to `audio`/`servicioEcualizador`. Self-contained. +3. **`EstadoGrabacion`** (`ChangeNotifier`) — recording state + `_escucharGrabacion` subscription (`estado_radio.dart:51, :79`). +4. **`EstadoBusqueda`** (`ChangeNotifier`) — search results/query state. + +- Provider wiring: register the new notifiers in the existing `MultiProvider` (alongside `EstadoRadio`). Use `ProxyProvider` where a notifier needs `ServicioAudio` from `EstadoRadio`, OR pass the shared service instances at construction. +- Migration strategy keeping each PR <400 lines: KEEP backward-compatible getters on `EstadoRadio` that delegate to the new notifiers during the transition, so screens compile unchanged. Migrate consuming screens to `context.select`/`Consumer` scopes (B11: `pantalla_inicio.dart:43` root watch + 6 sites in `pantalla_ajustes`) in the SAME or a follow-up PR. +- **Confirmed 4a/4b split** (the proposal flags it; forecast WILL exceed 400 lines for all four extractions + rewiring): + - **4a** = `ServicioExportImport` + `EstadoEcualizador` extraction with backward-compatible getters (no screen rewiring yet). ≈ 350 lines. + - **4b** = `EstadoGrabacion` + `EstadoBusqueda` extraction + `context.select` rewiring of `PantallaInicio`/`Ajustes`/`Favoritos` + removal of the temporary getters. ≈ 380 lines. +- ADR note: a big-bang single PR would blow the budget and the blast radius; the getters-bridge keeps each PR independently revertible (proposal rollback plan). + +### Slice 5 — Design system, a11y, i18n (LOW, parallelizable) + +- Color tokens: replace the 14+ `Color(0x...)` literals (explore C3 sites) with `PluriWaveTokens` from the theme extension. The ringing screen literals are migrated in Slice 2 (Decision 2.4); the rest here. +- A11y: `Semantics(button: true, label: ...)` on the 36x36 favorite `InkWell` (`tarjeta_emisora.dart:238-289`, also enlarge tap target toward 48dp), `semanticLabel` on `_AssetIcon` and alarm images (C4, C5). +- **Reduced-motion guard — `PluriAnimate` extension design**: a Dart extension on `Widget` (e.g. `extension PluriAnimate on Widget`) exposing `pluriFadeIn(...)`, `pluriScaleIn(...)` etc. Each method reads `MediaQuery.maybeDisableAnimationsOf(context)` (or `MediaQuery.of(context).disableAnimations`); when true it returns the child UNANIMATED (or with duration zero), otherwise it applies the `flutter_animate` effect. Centralizes C6 so every entry animation (including the Slice 2 ringing screen) respects the OS reduced-motion setting through ONE call site. Requires a `BuildContext`, so it is a method taking `context` rather than a pure getter. +- i18n: locale-aware `_fechaCorta` via `intl` `DateFormat.yMd(localeName)` (`pantalla_alarmas.dart:1114`, fixes C8 for ja/en-US/ar). Pluralization for bare counters via ARB plural messages (`pantalla_favoritos.dart:138`, C9). +- Polish: rounded shimmer corners + shimmer in `PantallaBuscar` (C10, C11), `_rounded` icon variants (C12), brand `notificationColor` in `main.dart:23` (C13). + +### Slice 6 — Quality gates (LOW, mostly tests) + +- Harden `analysis_options.yaml` (currently bare `flutter_lints`, `:10`). Add under `linter.rules`: `cancel_subscriptions`, `close_sinks`, `unawaited_futures`, `prefer_final_locals`, `avoid_dynamic_calls`. Fix the violations surfaced (the empty-catch/unawaited sites from B7/B10 land here if not in Slice 3). +- Tests (strict TDD, `flutter test`, mirror `test/helpers/fakes.dart` patterns). See Test Designs below. + +--- + +## Test designs (Slice 6 + seams introduced earlier) + +| Test | Target | Design | +|------|--------|--------| +| ServicioAlarmas concurrency | `servicio_alarmas.dart` mutex (Decision 3.5) | Inject mock prefs (`setMockInitialValues`). Fire N concurrent `guardarAlarma`/`posponerEjecucion`/`recalcularTodas` without awaiting between them; await all; assert the final persisted config reflects ALL writes (no lost update) and the mutation count matches. Without the mutex this fails (read-modify-write race). | +| Fire dedup across refrescarProgramacion | `estado_alarmas.dart` `_ejecucionesEmitidas` + `_vigilarAlarmasVencidas` (`:326-348`) | Drive a due alarm; call `refrescarProgramacion` repeatedly; assert `alarmasVencidasStream` emits the occurrence exactly once per `alarmId:millis` key, and the bounded set prunes old keys. | +| Audio handler rapid source-switch | `PluriWaveAudioHandler._colaCambioFuente` / `_revisionFuente` (`:280-332`) | Issue rapid `playMediaItem(A)`, `playMediaItem(B)`, `playMediaItem(C)`; assert only C's source ends active, earlier revisions are cancelled (revision guard), and no stale error is surfaced from A/B. Use a fake/seam over `AudioPlayer` URL-set. | +| Export/import round-trip | new `ServicioExportImport` (Slice 4a) | Build a full config (favorites, groups, EQ, alarms, vacations); export to JSON; import into a fresh service; assert deep equality. Edge: malformed JSON → graceful empty, not throw. | +| Recording error recovery | `ServicioGrabacionRadio` (B10 empty catches `:156,165,177,288`) | Simulate a recorder failure mid-recording (session-id change / IO error); assert the service transitions to a recoverable error state, releases resources, and a subsequent start succeeds. Covers the Slice 7 reconnect session-id-change interaction. | +| Snooze logic | `AlarmScheduler.snooze`/`postponeNext` anchor + Flutter `posponerEjecucionHasta` (Decisions 2.1-2.2) | Dart-side: assert `posponerAlarma` writes `snoozeHasta = origin + minutes` and that a subsequent `recalcularTodas` PRESERVES the active snooze (not erased — S4 regression guard). Assert a `snoozed` native event recorded via the bridge updates state WITHOUT a second `programar()` call. | +| Reconnect/backoff | `PluriWaveAudioHandler` reconnect state machine (Decision 7.2) | Seam over the player so a stall/error can be injected; assert backoff delays follow 1/2/4/8…cap, `reconectando` state is emitted, success resets the counter, and exhaustion lands on terminal `error` with the friendly message. Assert an intentional `pause` during stall cancels reconnect (intent flag). | + +Kotlin (`AlarmScheduler.snooze` anchor, FGS type, channels) is NOT covered by `flutter test` and NOT by `flutter analyze`; it is verified ON-DEVICE by the user. Design keeps Kotlin diffs small and mirrors existing patterns to minimize compile risk. + +--- + +## Cross-cutting decisions and ADRs + +| Decision | Choice | Rejected alternative | Rationale | +|----------|--------|----------------------|-----------| +| Snooze source of truth | Flutter `ServicioAlarmas` config; native mirrors and reports back via `alarmFired/snoozed` | Native as source of truth | Flutter already owns recurrence math and persistence; UI reads from it; a second authority is what caused the divergence. | +| Native→Flutter snooze channel | Reuse existing `alarmFired` MethodChannel via a static `MainActivity.notifyAlarmEvent` | New dedicated EventChannel | Fewer moving parts, no new channel lifecycle to manage, engine-dead case covered by cold-start sync. | +| Channel sound change | New versioned channel id `_fire_v2` + one-time delete of old ids | Mutate existing channel | Android locks channel sound at creation; in-place edit is silently ignored. | +| FGS notification owner | Service `startForeground` notification (id 92841) | Receiver notification | Service is the long-lived owner; the FSI must persist for the whole ring and be cancelled by `stopForeground`. | +| Streaming recovery scope | Jitter tolerance + fast reconnect to live edge | Gap-free time-shift across long outages | Live radio has no rewind history; honesty in acceptance criteria. | +| Concurrency fix | Single-writer `Future`-chain mutex + in-memory cache in `ServicioAlarmas` | Per-mutation lock library | Mirrors the existing `_colaCambioFuente` pattern; zero new deps; testable. | +| Slice 4 migration | Backward-compatible getters bridge, 4a/4b split | Big-bang split | Keeps each PR <400 lines and independently revertible. | + +## Risks + +- **Kotlin compile risk (HIGH):** Slices 1, 2, 7-adjacent native edits cannot be compiled here (`flutter build` forbidden, never run per `alarm-clock-module`). Mitigation: surgical diffs, mirror existing patterns, schemaVersion bump 2→3 is additive; ask the user to build after Slice 1 before chaining further native work. +- **FGS type behavior is OEM/version-sensitive (HIGH):** the `alarm` FGS type + permission pairing must be exact; some OEMs are stricter. Mitigation: pair type and permission precisely; on-device verification by user. +- **Channel migration could reset user notification prefs (MEDIUM):** deleting old channels and creating `_fire_v2` resets per-channel user settings for the ringing channel only. Mitigation: one-time guarded migration; pre-notice channel untouched; document in release notes. +- **Snooze sync engine-dead case (MEDIUM):** if the Flutter engine is dead when native snoozes, the `alarmFired` callback is lost; reconciliation relies on the extended cold-start sync (`getNativeSnoozeState`). Mitigation: import active native snoozes on `inicializar`. +- **Slice 2 and Slice 3 at the 400-line ceiling (MEDIUM):** both may need the documented 2a/2b and 3a/3b sub-splits. Flagged for the Review Workload Forecast. +- **BackdropFilter cold-GPU stutter on FSI (MEDIUM):** the ringing screen blur may drop the first frame on screen-off wake. Mitigation: deferred blur / capped sigma / reduced-motion bypass. +- **Reconnect vs alarm wake-up window (MEDIUM):** reconnect persistence must never delay the alarm's bundled-WAV fallback. Mitigation: alarm fallback timer takes precedence; `reconectando` must not be read as playing. +- **audio_session interruption vs reconnect fighting (LOW-MEDIUM):** a call-pause must set the same intent flag the stall detector reads. Mitigation: shared intent flag (Decision 3.1 ↔ 7.2). + +## Open assumptions requiring validation + +- The `alarm`-typed FGS started from `PluriWaveAlarmReceiver` is exempt under the alarm-broadcast FGS-while-in-use allowance on API 34+ — validate on a real API 34/35 device (user build). +- `AndroidLoadControl` buffer values are tunable but the effective jitter cushion depends on stream bitrate; final values may need on-device tuning. +- `MainActivity.notifyAlarmEvent` invoked from the service requires the Flutter engine to be alive and the channel bound; the cold-start sync is the fallback — confirm the engine lifecycle assumption holds when the ringing screen is foregrounded. diff --git a/openspec/changes/app-quality-and-native-alarms/explore.md b/openspec/changes/app-quality-and-native-alarms/explore.md new file mode 100644 index 0000000..cafeda8 --- /dev/null +++ b/openspec/changes/app-quality-and-native-alarms/explore.md @@ -0,0 +1,94 @@ +# 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()` 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. diff --git a/openspec/changes/app-quality-and-native-alarms/proposal.md b/openspec/changes/app-quality-and-native-alarms/proposal.md new file mode 100644 index 0000000..950c921 --- /dev/null +++ b/openspec/changes/app-quality-and-native-alarms/proposal.md @@ -0,0 +1,166 @@ +# Proposal: app-quality-and-native-alarms + +Raise PluriWave to native-Android-Clock alarm reliability and UX, then pay down the architecture, accessibility, and quality debt that surfaced around it. The custom native alarm stack is the right design and is KEPT; this change closes its gaps and hardens the app around it. Work ships as six chained, PR-sized slices, each under 400 changed lines, ordered by user-facing risk. + +## Intent + +**Problem.** The alarm feature can fail silently on Android 14+ (missing foreground-service type), posts duplicate fire notifications, ignores the configured alarm sound at the channel level, drops the fallback station on the native path, and offers no snooze on its own ringing screen. Around it, the app carries runtime debt: a declared-but-unused `audio_session` (so calls do not pause the radio), a `build()`-time localization call firing dozens of times per second, untestable statics, a 1121-line god-class, and unguarded per-minute SharedPreferences writes. UI/UX has 14+ hardcoded colors, missing accessibility semantics, no reduced-motion handling, and locale-breaking date formatting. + +**Why now.** Android 14+ already makes the foreground-service gap a silent production failure (CRITICAL). The alarm is the highest-trust feature in the app — when a user sets an alarm they expect to wake up — so reliability cannot wait. The architecture debt directly amplifies alarm risk (state divergence, races, swallowed errors), so fixing it is part of the same arc, not a separate cleanup. + +**Success looks like.** Alarms fire reliably on Android 14+, present a single notification, sound with the configured alarm audio, honor the fallback station and fade-in natively, and offer snooze that stays consistent with `EstadoAlarmas`. The radio pauses for phone calls. No localization call runs inside `build()`. `EstadoRadio` is decomposed into focused notifiers. The design system, accessibility, i18n, and lint gates close the highlighted gaps, backed by the top-5 missing tests under strict TDD. + +## Scope (in scope) + +- Android native alarm reliability: foreground-service type + permission, notification dedup, channel-level alarm sound, fallback station over the MethodChannel, battery-optimization exemption request, native fade-in. +- Alarm UX parity: snooze on the ringing screen, scaffold/animation migration, next-trigger preview, searchable station picker, configurable snooze duration, volume floor adjustment. +- Runtime robustness: integrate `audio_session` for audio focus, remove untestable statics, move localization out of `build()`, inject a single cached SharedPreferences, guard per-minute writes, prune the unbounded set, add an in-memory alarm cache. +- `EstadoRadio` decomposition into focused `ChangeNotifier`s + an export/import service, with `context.select`/`Consumer` scoping at the consuming screens. +- Design-system / accessibility / i18n pass: color tokens, semantics, reduced-motion guard, locale-aware dates, pluralization, shimmer/icon consistency, brand notification color. +- Quality gates: hardened `analysis_options.yaml` and the top-5 missing tests. + +## Out of scope + +- Light theme / theming beyond the existing dark design (explicitly out unless requested). +- iOS reliable-alarm parity (Android-first, unchanged from `alarm-clock-module`). +- Replacing the native alarm stack with any plugin (`alarm`, `android_alarm_manager_plus` + `flutter_local_notifications`, `awesome_notifications`) — evaluated and rejected. +- Cloud sync of alarms or preferences. +- New alarm capabilities (dismiss challenges, multi-fallback chains, smart/adaptive alarms). +- Full rewrite of `pantalla_ajustes.dart` beyond extracting the backup/import logic. +- Running `flutter build` (project constraint); the user runs builds to validate the Kotlin layer. + +## Approach and rationale + +1. **Reliability before everything.** Slice 1 ships the native fixes that prevent silent failure and state divergence. Highest user trust, smallest safe footprint, no dependency on later refactors. +2. **UX parity next, on the now-reliable base.** Slice 2 adds snooze and editor improvements once the underlying behavior is correct, so UI never papers over a broken native path. +3. **Robustness third.** Slice 3 introduces test seams (injected SharedPreferences, instance fields, audio session) that later slices and tests depend on. It deliberately precedes the god-class split so the split lands on testable foundations. +4. **Decomposition fourth.** Slice 4 is the largest and riskiest refactor; it runs only after seams exist and reliability/UX are stable, minimizing blast radius. +5. **Polish fifth.** Slice 5 is low-risk, parallelizable design/a11y/i18n work that benefits from the settled structure. +6. **Gates last.** Slice 6 hardens lints and writes the top-5 tests, locking in the prior slices and catching regressions. Strict TDD means tests in Slice 6 (and seams from Slice 3) drive behavior, not follow it. + +Chaining: slices are sequential PRs. Each PR targets the previous slice's branch (or main per the cached chain strategy) and stays under 400 changed lines so reviewers verify one unit of work at a time. + +## Work breakdown — chained PR-sized slices + +### Slice 1 — Alarm native reliability (CRITICAL, ship first) + +Risk: CRITICAL. Effort: M. Est. < 350 lines (Kotlin + manifest + Dart bridge). + +- Add `alarm` to `foregroundServiceType` (`mediaPlayback|alarm`) and the `FOREGROUND_SERVICE_ALARM` permission (fixes Android 14+ silent failure) — `AndroidManifest.xml`, `PluriWaveAlarmService.kt`. +- Deduplicate fire notifications: keep the service FSI (`NOTIFICATION_ID 92841`) as the single source; stop the receiver from posting its own — `PluriWaveAlarmReceiver.kt`, `PluriWaveAlarmService.kt`. +- Set channel-level sound with alarm `AudioAttributes` on the fire channels at creation — native channel setup. +- Pass `emisoraFallback` through the MethodChannel into the Kotlin `NativeAlarmSpec`; attempt a second `prepareAsync` on the fallback — `servicio_alarmas_android.dart`, `NativeAlarmSpec`. +- Request battery-optimization exemption inside `_solicitarPermisosNecesariosParaAlarma`. +- Native-side fade-in matching Dart `fadeInSegundos`. + +### Slice 2 — Alarm UX parity with native Android Clock + +Risk: HIGH. Effort: M. Est. < 380 lines. + +> **Snooze correctness is in full scope.** This slice audits the entire native snooze +> path (AlarmScheduler.snooze → setAlarmClock registration, notification "Posponer" +> action while app killed, Flutter state sync on resume via MethodChannel event — not +> waiting for the 60-second poll). The goal is end-to-end correctness, not just adding +> UI buttons. + +- Snooze buttons (3/5/10 + configured default) on `PantallaAlarmaSonando` wired to `EstadoAlarmas.posponerAlarma`, coordinating `_fadeInTimer` cancel and `_fallbackPlayer` stop, native service stop, and screen dismiss — `pantalla_alarma_sonando.dart:168-212`. +- Migrate the ringing screen to `PluriWaveScaffold` with an entry animation. +- Next-trigger preview inside `_EditorAlarmaSheet`. +- Replace the station `DropdownButtonFormField` with a searchable bottom-sheet picker. +- Configurable snooze duration. +- Lower the volume-slider floor from 0.25 toward 0. + +### Slice 3 — Audio / runtime robustness (test seams) + +Risk: HIGH. Effort: M-L. Est. < 400 lines. + +- Integrate `audio_session` for audio-focus handling so calls pause the radio (`pubspec.yaml:19`, currently never imported). +- Replace static `StreamController` + `_handlerInstalado` with injectable instance fields — `servicio_alarmas_android.dart:117-119`. +- Move `configurarLocalizaciones` out of `MiniReproductor.build()` — `mini_reproductor.dart:23`. +- Inject a single cached `SharedPreferences` at startup (replaces 25+ `getInstance()` calls). +- Guard `recalcularTodas()` writes behind a change flag — `estado_alarmas.dart:316-323`. +- Prune the unbounded `_ejecucionesEmitidas` set — `estado_alarmas.dart:32`. +- Add an in-memory cache to `ServicioAlarmas` to kill the read-modify-write N+1 race — `servicio_alarmas.dart:81-108`. + +### Slice 4 — EstadoRadio god-class split (LARGE) + +Risk: HIGH (broad surface). Effort: L. May split into 4a/4b if forecast exceeds 400 lines. + +- Extract `EstadoEcualizador`, `EstadoGrabacion`, `EstadoBusqueda` `ChangeNotifier`s + a `ServicioExportImport` from the 1121-line `estado_radio.dart`. +- Replace root `context.watch()` in `PantallaInicio` / `Ajustes` / `Favoritos` with `context.select` / `Consumer` scopes — `pantalla_inicio.dart:43` and 6 sites in `pantalla_ajustes`. +- Move backup/import `jsonDecode`/`jsonEncode` logic out of `pantalla_ajustes.dart` (1391 lines) into `ServicioExportImport`. + +### Slice 5 — Design system, a11y, i18n pass + +Risk: LOW. Effort: M. Parallelizable internally; est. < 350 lines. + +- Replace 14+ hardcoded `Color(0x...)` literals with tokens — see explore C3 sites. +- `Semantics` on the grid favorite button + `semanticLabel` on `_AssetIcon` / alarm images — `tarjeta_emisora.dart:238-289`. +- Central reduced-motion guard (`PluriAnimate` extension honoring `MediaQuery.disableAnimations`). +- Locale-aware `_fechaCorta` via `intl` `DateFormat` — `pantalla_alarmas.dart:1114`. +- Pluralization for bare counters — `pantalla_favoritos.dart:138`. +- Rounded shimmer placeholders + shimmer in `PantallaBuscar` — `tarjeta_emisora.dart:389-420`, `pantalla_buscar.dart:241-245`. +- Icon variant consistency (`_rounded`) — `pantalla_ajustes.dart:985,1028,1031`. +- Brand `notificationColor` — `main.dart:23`. + +### Slice 6 — Quality gates + +Risk: LOW. Effort: M. Est. < 350 lines (mostly tests). + +- Harden `analysis_options.yaml`: `cancel_subscriptions`, `close_sinks`, `unawaited_futures`, `prefer_final_locals`, `avoid_dynamic_calls`. +- New tests (strict TDD, `flutter test`): `ServicioAlarmas` concurrent read-modify-write, alarm fire dedup across `refrescarProgramacion`, `PluriWaveAudioHandler` rapid source-switch race, export/import round-trip, `ServicioGrabacionRadio` error recovery. + +### Slice 7 — Streaming resilience + +Risk: MEDIUM. Effort: M. Est. < 380 lines (Dart only — no Kotlin changes). + +The app uses `just_audio` (ExoPlayer on Android) via `PluriWaveAudioHandler` +(`lib/servicios/servicio_audio.dart`). The current implementation creates a fresh +`AudioPlayer` on every source switch (`_recrearPlayer`) and surfaces errors immediately +to the UI without any retry logic. A short network hiccup (a few seconds) therefore +causes an immediate error state and requires the user to manually re-tap play. + +- Configure an enlarged ExoPlayer live-stream buffer (targeting ~15-30 s of buffered + content) so brief network drops do not interrupt audible playback. +- Introduce `userIntent` tracking in `PluriWaveAudioHandler` to distinguish user-initiated + pause/stop from network stalls. +- Add bounded exponential-backoff reconnection for network-class errors (`PlayerException` + codes 2xxx) when `userIntent == playing`. Default: 5 retries, base 1 s, max 30 s. +- Surface `EstadoReproduccion.cargando` during reconnect attempts; surface the error only + after retries are exhausted — no dialog spam for transient drops. +- Must not regress: alarm audio path (native `PluriWaveAlarmService`), recording + (`ServicioGrabacionRadio` manages its own stream), sleep-timer fade-out. +- Unit tests required (strict TDD): backoff delay computation, `userIntent` transitions, + reconnect suppressed on user stop, error emitted after max retries. + +## Risks + +| Risk | Severity | Mitigation | +|------|----------|------------| +| Kotlin layer has never been compiled (`flutter build` never run per `alarm-clock-module` apply-progress). Slice 1/3 native edits may not compile. | HIGH | Keep native edits surgical; ask the user to run `flutter build` after Slice 1 before chaining further; do not run build ourselves (project constraint). | +| Android 14+ foreground-service behavior is version- and OEM-sensitive. | HIGH | Pair the `alarm` type with the matching permission exactly; verify against API 34 docs in design phase. | +| Channel sound is locked at channel creation; changing it may require recreating channels and could reset user notification settings. | MEDIUM | Design the channel-id/versioning strategy in the design phase; document the migration. | +| `EstadoRadio` split (Slice 4) has broad blast radius across screens. | HIGH | Land only after Slice 3 seams exist; keep backward-compatible getters; split into 4a/4b if the forecast exceeds 400 lines. | +| Injecting SharedPreferences touches many constructors. | MEDIUM | Provide backward-compatible defaults; introduce the injection in Slice 3 with tests. | +| Hero transition with `BackdropFilter` can flicker without a `HeroFlightShuttleBuilder`. | MEDIUM | Treat Hero work carefully in Slice 2/5; provide an explicit shuttle builder. | +| `alarm-clock-module` state drift (`tasks-ready` vs. existing apply-progress). | LOW | Reconcile at this change's archive time; out of scope to fix mid-flight. | + +## Rollback plan + +- Each slice is an independent PR; revert the slice's commit/branch to roll back without touching others. +- Slice 1 native changes are additive (manifest attributes/permission, channel config, an extra MethodChannel field); reverting restores the prior — but Android-14-broken — behavior, so prefer fixing forward. +- Slice 3 SharedPreferences injection uses backward-compatible defaults, so a partial revert leaves the app functional. +- Slice 4 keeps backward-compatible `EstadoRadio` getters during extraction; if a screen regresses, revert that screen's scoping commit independently. + +## Success criteria + +- [ ] Alarm fires on Android 14+ without `ForegroundServiceTypeException` (manual user build verification). +- [ ] Exactly one fire notification is posted per alarm event. +- [ ] Fire channels sound with alarm `AudioAttributes`; fallback station is used when the primary fails on the native path. +- [ ] Ringing screen offers 3/5/10 snooze that stays consistent with `EstadoAlarmas` (no divergence after sync). Snooze from the native notification while app killed also reschedules via `setAlarmClock` and syncs Flutter state on resume without waiting for the 60-second poll. +- [ ] Phone calls / other audio-focus events pause the radio. +- [ ] No localization call runs inside any `build()`. +- [ ] `EstadoRadio` no longer owns EQ / recording / search state; consuming screens use scoped rebuilds. +- [ ] All highlighted hardcoded colors replaced by tokens; favorite button and alarm images expose semantics; reduced-motion respected. +- [ ] Hardened lint set passes `flutter analyze`; the top-5 tests pass `flutter test`. +- [ ] Brief network drops (up to ~15-30 s) do not interrupt radio playback; automatic reconnection with bounded backoff recovers silently; alarm audio, recording, and sleep-timer paths are unaffected. diff --git a/openspec/changes/app-quality-and-native-alarms/spec.md b/openspec/changes/app-quality-and-native-alarms/spec.md new file mode 100644 index 0000000..dd7aa48 --- /dev/null +++ b/openspec/changes/app-quality-and-native-alarms/spec.md @@ -0,0 +1,896 @@ +# Spec: app-quality-and-native-alarms + +Delta requirements for the PluriWave Android alarm reliability, UX parity, runtime +robustness, architecture decomposition, design-system, quality-gate, and streaming +resilience change. Every requirement states what MUST be true after the change is +applied; implementation details are deferred to the design phase. + +Verifiability legend: +- **[flutter test]** — unit/widget test exercisable via `flutter test` +- **[flutter analyze]** — static analysis via `flutter analyze` +- **[on-device]** — requires a real build and manual or instrumented verification on Android + hardware/emulator; `flutter build` is forbidden in this project + +--- + +## S1 — Alarm native reliability + +### S1-R1 — Foreground-service alarm type declared (CRITICAL) + +The `AndroidManifest.xml` MUST declare `foregroundServiceType="mediaPlayback|alarm"` for +`PluriWaveAlarmService` and MUST include the `FOREGROUND_SERVICE_ALARM` permission, so +that Android 14+ (API 34+) does not throw `ForegroundServiceTypeException` when the +alarm fires. + +**[on-device]** + +#### Scenario S1-R1-A: alarm fires on Android 14+ + +``` +Given a device running Android 14+ (API 34) + And an alarm has been scheduled with a future trigger time +When the trigger time is reached +Then PluriWaveAlarmService starts in the foreground without ForegroundServiceTypeException + And the alarm ringing screen is shown (or the foreground notification is posted) +``` + +### S1-R2 — Single fire notification per alarm event + +On any alarm fire event, exactly one notification SHALL be posted. +`PluriWaveAlarmReceiver` MUST NOT post a duplicate FSI notification when +`PluriWaveAlarmService` is already managing the foreground notification (`NOTIFICATION_ID +92841`). `dismissFireNotification` in `EstadoAlarmas` MUST cancel the single canonical +notification ID. + +**[on-device]** + +#### Scenario S1-R2-A: no duplicate notification + +``` +Given an alarm fires and PluriWaveAlarmService posts NOTIFICATION_ID 92841 +When the system notification tray is inspected +Then exactly one notification for that alarm is visible +``` + +#### Scenario S1-R2-B: dismiss cancels the notification + +``` +Given the fire notification is visible +When the user dismisses the alarm (stop action) +Then the notification is removed from the tray + And no orphan notification remains +``` + +### S1-R3 — Channel-level alarm audio attributes + +The `pluriwave_alarm_fire` and `pluriwave_alarm_native` notification channels MUST be +created with `setSound(uri, audioAttributes)` where `audioAttributes` use +`AudioAttributes.USAGE_ALARM`, so that Android 8+ honors the alarm ringer stream. + +**[on-device]** + +#### Scenario S1-R3-A: alarm sound plays on alarm channel + +``` +Given the fire channels are created at app first-launch +When an alarm fires and produces a notification +Then the notification sound plays on the alarm audio stream + And respects the device's alarm volume (not media volume) +``` + +### S1-R4 — Fallback station passed through MethodChannel + +When `AlarmaMusical.emisoraFallback` is set, `ServicioAlarmasAndroid.programar` MUST +pass `fallbackStationUrl` in the `scheduleAlarm` MethodChannel payload, and +`NativeAlarmSpec` (Kotlin) MUST carry the field. `PluriWaveAlarmService` MUST attempt +`prepareAsync` on the fallback URL when the primary station fails. + +**[on-device]** (native path); **[flutter test]** (Dart MethodChannel payload assertion) + +#### Scenario S1-R4-A: fallback attempted natively + +``` +Given an alarm has emisoraFallback set to a valid URL + And the primary station stream fails or times out during alarm playback +When the native service handles playback +Then the fallback station URL is attempted via MediaPlayer.prepareAsync +``` + +#### Scenario S1-R4-B: fallback URL absent → bundled sound + +``` +Given an alarm has no emisoraFallback + And the primary station stream fails +When the native service handles playback +Then the bundled fallback WAV is played (existing behavior preserved) +``` + +### S1-R5 — Battery-optimization exemption request + +`_solicitarPermisosNecesariosParaAlarma` MUST request `REQUEST_IGNORE_BATTERY_OPTIMIZATIONS` +so that Doze-mode does not suppress alarm delivery on devices where the permission is not +already granted. + +**[on-device]** + +#### Scenario S1-R5-A: exemption requested at setup + +``` +Given the user grants alarm scheduling permission for the first time +When permission setup runs +Then the system dialog for REQUEST_IGNORE_BATTERY_OPTIMIZATIONS is presented + (unless the app is already exempt) +``` + +### S1-R6 — Native fade-in matching fadeInSegundos + +`PluriWaveAlarmService` MUST apply a volume ramp from 0 to target volume over +`fadeInSegundos` seconds when starting alarm audio on the native path, so that fade-in +works even when the ringing screen is not foregrounded. + +**[on-device]** + +#### Scenario S1-R6-A: native path audio fades in + +``` +Given an alarm fires while the app is backgrounded/killed + And the alarm's fadeInSegundos is greater than 0 +When the native service starts playing audio +Then audio starts at near-zero volume and ramps to the configured volume over fadeInSegundos seconds +``` + +--- + +## S2 — Alarm UX parity (snooze end-to-end) + +### S2-R1 — Snooze buttons on PantallaAlarmaSonando + +`PantallaAlarmaSonando` MUST display snooze buttons for 3, 5, and 10 minutes, plus one +button showing the alarm's configured `snoozeMinutos` value when it differs from the +fixed options. Tapping any snooze button MUST: + +1. Cancel the active `_fadeInTimer`. +2. Stop and dispose the `_fallbackPlayer`. +3. Stop the native alarm service (via the same stop path used by the dismiss button). +4. Call `EstadoAlarmas.posponerAlarma(alarma, minutes)`. +5. Close `PantallaAlarmaSonando`. + +**[flutter test]** (widget test for button presence and tap behavior); **[on-device]** (full +native service stop verified manually) + +#### Scenario S2-R1-A: snooze button appears + +``` +Given PantallaAlarmaSonando is displayed for an alarm with snoozeMinutos = 5 +When the widget tree is inspected +Then buttons for 3 min, 5 min, and 10 min snooze are present + And a single "stop" / dismiss button is present +``` + +#### Scenario S2-R1-B: tapping snooze stops audio and reschedules + +``` +Given PantallaAlarmaSonando is displayed and audio is playing (fadeInTimer active, fallbackPlayer active) +When the user taps the 5-minute snooze button +Then the fadeInTimer is cancelled + And the fallbackPlayer is stopped and disposed + And the native alarm service receives a stop command + And EstadoAlarmas.posponerAlarma is called with minutes = 5 + And the screen is dismissed/popped +``` + +#### Scenario S2-R1-C: custom snoozeMinutos displayed + +``` +Given an alarm has snoozeMinutos = 7 +When PantallaAlarmaSonando is displayed +Then a snooze button labeled "7 min" is present in addition to the fixed 3/5/10 options +``` + +### S2-R2 — Alarm list reflects postponed next-trigger immediately + +After snooze is accepted (from the Flutter screen or from the native notification), the +alarm list in `PantallaAlarmas` MUST display the updated `snoozeHasta` as the next-trigger +time without waiting for the 60-second periodic poll. + +**[flutter test]** (unit test: posponerAlarma updates notifier synchronously) + +#### Scenario S2-R2-A: alarm list updated after snooze + +``` +Given the alarm list is visible and showing an alarm's next-trigger time T +When the user snoozes the ringing alarm for 5 minutes (from PantallaAlarmaSonando) +Then within the same UI frame after posponerAlarma completes + the alarm list shows the new next-trigger time T+5 min + And no manual refresh or navigation is required +``` + +### S2-R3 — Snooze from native notification while app killed or foregrounded + +When the user taps the "Posponer" action on the fire notification while the app is +backgrounded or killed, `AlarmScheduler.snooze(id, minutes)` MUST execute a real +`setAlarmClock` registration for `now + N minutes`. When the app becomes active +(foreground resume), `EstadoAlarmas` MUST reflect the new `snoozeHasta` via the existing +MethodChannel event flow (not waiting for the next 60-second poll). + +**[on-device]** (native scheduling verified); **[flutter test]** (Dart event handler updates EstadoAlarmas state) + +#### Scenario S2-R3-A: notification snooze schedules real alarm + +``` +Given the app is killed (not in memory) + And a fire notification with "Posponer" action is visible +When the user taps "Posponer" +Then AlarmScheduler.snooze executes setAlarmClock for now+snoozeMinutes + And the alarm appears in the system status-bar alarm icon count +``` + +#### Scenario S2-R3-B: Flutter state syncs on resume without polling + +``` +Given snooze was triggered from the native notification while app was backgrounded +When the app is brought to the foreground +Then EstadoAlarmas.alarmas contains the snoozed alarm with snoozeHasta = snooze target time + And the update happens before the next 60-second poll fires +``` + +### S2-R4 — Snoozed alarm fires at postponed time + +A snoozed alarm MUST fire again at the exact `snoozeHasta` time. +`AlarmScheduler.snooze` MUST call `setAlarmClock` with `snoozeHasta` as the trigger; +the resulting `AlarmClockInfo` MUST be verifiable through the diagnostics channel. + +**[on-device]** + +#### Scenario S2-R4-A: alarm re-fires after snooze + +``` +Given an alarm was snoozed for 5 minutes at time T +When time T+5 minutes is reached +Then the alarm fires again (ringing screen shown or notification posted) + And the alarm's snoozeHasta is cleared after it fires +``` + +#### Scenario S2-R4-B: diagnostics confirm real setAlarmClock registration + +``` +Given an alarm has been snoozed +When the alarm diagnostics screen is opened +Then the snoozed alarm entry shows a non-null next-trigger time matching snoozeHasta + And "exact alarm scheduled" status is shown for that alarm +``` + +### S2-R5 — Stop during snooze-pending state cancels snooze + +If the user stops an alarm (dismiss) before the snoozed occurrence fires, +`EstadoAlarmas.finalizarEjecucion` (or a dedicated cancel path) MUST cancel the native +`setAlarmClock` registration for the snooze occurrence and clear `snoozeHasta` on the +`AlarmaMusical` model. + +**[flutter test]** (unit test: finalizarEjecucion clears snoozeHasta and calls android.cancelar); **[on-device]** (alarm does not re-fire) + +#### Scenario S2-R5-A: stop cancels pending snooze + +``` +Given an alarm is in snooze-pending state (snoozeHasta is set, alarm has not re-fired) +When the user opens the alarm list and disables or deletes the alarm + Or explicitly stops the alarm from any surface +Then ServicioAlarmasAndroid.cancelar is called for that alarm + And the alarm's snoozeHasta is null in persistent storage + And the alarm does not re-fire at the snooze time +``` + +### S2-R6 — Snooze Dart-side unit tests (strict TDD) + +The following Dart-side behaviors MUST be covered by `flutter test` unit tests: + +- `EstadoAlarmas.posponerAlarma` calls `android.programar` with updated `snoozeHasta`. +- `EstadoAlarmas.posponerAlarma` calls `notifyListeners` after state update. +- `EstadoAlarmas.finalizarEjecucion` clears `snoozeHasta` and calls `android.cancelar` or `android.programar` without `snoozeHasta`. +- `ServicioAlarmas.posponerEjecucion` computes `snoozeHasta = ejecucion + minutos` correctly. +- The MethodChannel payload sent by `ServicioAlarmasAndroid.programar` for a snoozed alarm contains `snoozeUntilMillis` matching the alarm's `snoozeHasta`. + +**[flutter test]** + +#### Scenario S2-R6-A: posponerAlarma unit test + +``` +Given a mock PuertoAlarmasAndroid and a mock ServicioAlarmas + And an alarm with proximaEjecucion = T +When EstadoAlarmas.posponerAlarma(alarma, 10) is called +Then servicio.posponerEjecucion is called with minutos = 10 + And android.programar is called once with the updated alarm carrying snoozeHasta = T+10min + And notifyListeners was called +``` + +### S2-R7 — Ringing screen migrated to PluriWaveScaffold with entry animation + +`PantallaAlarmaSonando` MUST use `PluriWaveScaffold` instead of a raw `Scaffold` with +a hardcoded `Color(0xFF061722)` background, and MUST include an entry animation on mount +(fade or slide, honoring `MediaQuery.disableAnimations`). + +**[flutter test]** (widget test: PluriWaveScaffold present); **[on-device]** (visual) + +#### Scenario S2-R7-A: no raw Scaffold with hardcoded color + +``` +Given PantallaAlarmaSonando is mounted +When the widget tree is inspected +Then no raw Scaffold with backgroundColor = Color(0xFF061722) is found at the root + And PluriWaveScaffold (or equivalent themed scaffold) wraps the content +``` + +### S2-R8 — Next-trigger preview in alarm editor + +`_EditorAlarmaSheet` MUST display a read-only next-trigger timestamp computed from the +current editor state (schedule type, time, weekdays, one-shot date) so the user can +verify when the alarm will fire before saving. + +**[flutter test]** (widget test) + +#### Scenario S2-R8-A: next-trigger shown in editor + +``` +Given the alarm editor is open with a recurring weekday alarm set for Monday/Wednesday at 07:00 +When the widget is inspected +Then a text widget shows the next calculated trigger date/time + And it updates when the user changes the schedule +``` + +### S2-R9 — Searchable station picker + +The station selection in the alarm editor MUST use a searchable bottom-sheet picker +instead of a raw `DropdownButtonFormField`, matching the interaction pattern of the main +station picker. + +**[flutter test]** (widget test: bottom sheet opens on tap) + +#### Scenario S2-R9-A: search bottom sheet opens + +``` +Given the alarm editor is open +When the user taps the station selection field +Then a bottom sheet with a search input and station list is presented + And typing in the search input filters the list +``` + +### S2-R10 — Configurable snooze duration + +The alarm editor MUST allow the user to set a custom snooze duration (stored as +`AlarmaMusical.snoozeMinutos`). The ringing screen snooze buttons MUST use this value as +the labeled default option. + +**[flutter test]** (widget test) + +### S2-R11 — Volume-slider floor lowered + +The alarm volume slider MUST allow values down to 0.0 (from the current floor of 0.25). + +**[flutter test]** (widget test: slider min value) + +--- + +## S3 — Audio and runtime robustness (test seams) + +### S3-R1 — audio_session integrated for audio focus + +The `audio_session` package (`pubspec.yaml:19`) MUST be imported and configured so that +phone calls and other audio-focus events (transient/permanent loss) pause or duck the +radio playback. The existing `audio_session` declaration MUST NOT remain unused. + +**[on-device]** (incoming call pauses radio); **[flutter analyze]** (import present) + +#### Scenario S3-R1-A: phone call pauses radio + +``` +Given the radio is playing +When an incoming phone call starts +Then the radio playback is paused or ducked + And resumes when the call ends (if it was only transient focus loss) +``` + +### S3-R2 — Injectable StreamController and handler flag + +The static `_eventosController` and `_handlerInstalado` fields in `ServicioAlarmasAndroid` +(lines 117-119) MUST be converted to instance fields injectable via constructor or a test +factory, making the class testable without global state side-effects. + +**[flutter test]** (unit tests can construct isolated instances) + +#### Scenario S3-R2-A: two instances do not share state + +``` +Given two ServicioAlarmasAndroid instances created independently in a test +When one instance receives an alarm event +Then the other instance's eventosAlarma stream does not emit that event +``` + +### S3-R3 — configurarLocalizaciones removed from build() + +`configurarLocalizaciones(l10n)` MUST NOT be called inside any `build()` method (current +violation: `mini_reproductor.dart:23`). It MUST be called once from the widget lifecycle +(`initState`, `didChangeDependencies`, or the provider consumer's init path). + +**[flutter analyze]** (no-ops inside build); **[flutter test]** (verify call count) + +#### Scenario S3-R3-A: localization call not in build + +``` +Given the MiniReproductor widget is mounted and rebuilt 10 times due to state changes +When the call count to configurarLocalizaciones is measured +Then it is called at most once per locale change (not once per rebuild) +``` + +### S3-R4 — Single cached SharedPreferences instance + +A single `SharedPreferences` instance MUST be initialized at app startup (e.g., in +`main.dart`) and injected into all services that currently call +`SharedPreferences.getInstance()` inline (25+ sites). No service MUST call +`SharedPreferences.getInstance()` after app startup. + +**[flutter test]** (unit tests use injected mock); **[flutter analyze]** (no getInstance calls in service classes after injection) + +#### Scenario S3-R4-A: services receive injected prefs + +``` +Given the app starts +When ServicioAlarmas, ServicioEcualizador, and ServicioGrabacionRadio are constructed +Then each receives the single SharedPreferences instance (no internal getInstance call) +``` + +### S3-R5 — recalcularTodas writes guarded by change flag + +`recalcularTodas()` in `EstadoAlarmas` (lines 316-323) MUST compare the new schedule to +the existing one before writing to SharedPreferences. If the schedule is unchanged, the +write MUST be skipped. + +**[flutter test]** + +#### Scenario S3-R5-A: no write on unchanged schedule + +``` +Given the alarm schedule has not changed since the last write +When recalcularTodas() is called +Then SharedPreferences.setString is NOT called +``` + +#### Scenario S3-R5-B: write happens on schedule change + +``` +Given the alarm schedule has changed +When recalcularTodas() is called +Then SharedPreferences.setString IS called exactly once +``` + +### S3-R6 — Bounded _ejecucionesEmitidas set + +`_ejecucionesEmitidas` in `EstadoAlarmas` (line 32) MUST be bounded. Entries that are +older than a configurable retention window (e.g., 24 hours past their scheduled time) +MUST be pruned to prevent unbounded memory growth. + +**[flutter test]** + +#### Scenario S3-R6-A: old entries pruned + +``` +Given _ejecucionesEmitidas contains 100 entries all older than 24 hours +When the pruning logic runs (triggered on next alarm event or periodic cleanup) +Then _ejecucionesEmitidas.length is less than or equal to the max expected entries +``` + +### S3-R7 — In-memory alarm cache in ServicioAlarmas + +`ServicioAlarmas` MUST maintain an in-memory cache so that `cargar()` is not called +before every mutation (eliminating the read-modify-write N+1 race at lines 81-108). The +cache MUST be invalidated on any write operation. + +**[flutter test]** + +#### Scenario S3-R7-A: concurrent mutations use cache + +``` +Given ServicioAlarmas has loaded alarms into cache +When two mutations are dispatched concurrently +Then only one cargar() call is made (not two), and both mutations are applied correctly +``` + +--- + +## S4 — EstadoRadio god-class split + +### S4-R1 — EstadoEcualizador extracted + +A `EstadoEcualizador extends ChangeNotifier` MUST own all EQ state (preset, bands, +enabled flag) previously in `EstadoRadio`. `EstadoRadio` MUST NOT expose EQ state +directly. + +**[flutter test]** (unit test: EstadoEcualizador notifies on preset change); **[flutter analyze]** + +#### Scenario S4-R1-A: EQ state owned by EstadoEcualizador + +``` +Given EstadoEcualizador is registered as a provider +When aplicarPreset is called on EstadoEcualizador +Then EstadoEcualizador notifies its listeners + And EstadoRadio listeners are NOT rebuilt +``` + +### S4-R2 — EstadoGrabacion extracted + +A `EstadoGrabacion extends ChangeNotifier` MUST own all recording state previously in +`EstadoRadio`. `ServicioGrabacionRadio` MUST be managed by `EstadoGrabacion`. + +**[flutter test]** + +### S4-R3 — EstadoBusqueda extracted + +A `EstadoBusqueda extends ChangeNotifier` MUST own search query, results, and loading +state previously in `EstadoRadio`. + +**[flutter test]** + +### S4-R4 — ServicioExportImport extracted + +A `ServicioExportImport` class MUST own the `jsonEncode`/`jsonDecode` backup and restore +logic currently inlined in `pantalla_ajustes.dart` (1391 lines). `PantallaAjustes` MUST +delegate all JSON serialization to `ServicioExportImport`. + +**[flutter test]** (round-trip test: serialize then deserialize produces identical config) + +#### Scenario S4-R4-A: export/import round-trip + +``` +Given a non-trivial app configuration (alarms, favorites, EQ presets) +When ServicioExportImport.exportar() is called and its output is passed to importar() +Then the reconstructed configuration equals the original +``` + +### S4-R5 — Consuming screens use scoped rebuilds + +`PantallaInicio`, `PantallaAjustes`, and `PantallaFavoritos` MUST NOT call +`context.watch()` at the root widget. Each MUST use `context.select` or a +`Consumer` scoped to the specific fields it reads. + +**[flutter test]** (widget test: changing EQ preset does not rebuild PantallaInicio) + +#### Scenario S4-R5-A: EQ change does not rebuild inicio screen + +``` +Given PantallaInicio is mounted and displaying station info +When EstadoEcualizador notifies (preset change) +Then PantallaInicio's build method is NOT called +``` + +--- + +## S5 — Design system, accessibility, i18n pass + +### S5-R1 — Hardcoded color literals replaced by tokens + +All 14+ hardcoded `Color(0x...)` literals identified in the explore report (C3) MUST be +replaced by `PluriWaveTokens` or `Theme.of(context).colorScheme` references. No new +hardcoded color literals SHALL be introduced. + +**[flutter analyze]** (custom lint or grep); **[flutter test]** (widget test: token resolves correctly) + +#### Scenario S5-R1-A: no raw color literals in target files + +``` +Given the diff for Slice 5 is applied +When flutter analyze runs +Then no instances of Color(0x...) appear in the modified files beyond theme-extension token definitions +``` + +### S5-R2 — Accessibility semantics on favorite button and alarm image + +The mini favorite `InkWell` in `TarjetaEmisora` (line 238-289) MUST be wrapped in a +`Semantics` widget with an appropriate `label` and `button: true`. It MUST have a minimum +touch target of 48 dp. The alarm PNG widget MUST carry a `semanticLabel`. + +**[flutter test]** + +#### Scenario S5-R2-A: favorite button is accessible + +``` +Given TarjetaEmisora is mounted +When the accessibility tree is inspected +Then the favorite action node has a non-empty semantic label + And its size is at least 48x48 dp +``` + +### S5-R3 — Reduced-motion guard + +A `PluriAnimate` extension (or equivalent helper) MUST check `MediaQuery.disableAnimations` +and skip or replace animations when the user has enabled reduced motion in system settings. +All animations in Slices 2 and 5 MUST use this guard. + +**[flutter test]** (widget test: animation skipped when disableAnimations = true) + +#### Scenario S5-R3-A: animation skipped in reduced-motion mode + +``` +Given MediaQuery.disableAnimations is true (test override) +When PantallaAlarmaSonando is mounted (entry animation present per S2-R7) +Then no animated position/opacity change is applied on mount +``` + +### S5-R4 — Locale-aware date formatting + +`_fechaCorta` in `PantallaAlarmas` (line 1114) MUST use `intl.DateFormat` with the +current locale rather than a hardcoded DD/MM/YYYY format string. + +**[flutter test]** + +#### Scenario S5-R4-A: date formatted per locale + +``` +Given locale is 'en-US' +When _fechaCorta is called with DateTime(2026, 6, 11) +Then the result matches DateFormat.yMd('en-US').format(DateTime(2026, 6, 11)) + And does NOT return "11/06/2026" +``` + +### S5-R5 — Pluralization for bare counters + +`PantallaFavoritos` (line 138) MUST use `AppLocalizations` plural forms for station count +strings (e.g., "1 station" vs "5 stations"). + +**[flutter test]** + +### S5-R6 — Rounded shimmer placeholders + +Shimmer placeholders in `TarjetaEmisora` (lines 389-420) MUST use rounded corners +matching the actual content card corners. The `PantallaBuscar` loading state (lines +241-245) MUST use shimmer instead of a spinner. + +**[flutter test]** (widget test: shimmer present during loading state) + +### S5-R7 — Rounded icon variants consistent + +Icon usage in `PantallaAjustes` (lines 985, 1028, 1031) MUST use `_rounded` Material +icon variants to be consistent with the rest of the app. + +**[flutter analyze]** (grep for non-rounded icon names at those sites) + +### S5-R8 — Brand notification color + +The `notificationColor` in `AudioServiceConfig` (`main.dart:23`) MUST be set to the app's +brand color token rather than the M3 default `Color(0xFF6750A4)`. + +**[flutter test]** (unit test: config uses brand color) + +--- + +## S6 — Quality gates + +### S6-R1 — Hardened analysis_options.yaml + +`analysis_options.yaml` MUST enable at minimum the following lint rules: +`cancel_subscriptions`, `close_sinks`, `unawaited_futures`, `prefer_final_locals`, +`avoid_dynamic_calls`. + +**[flutter analyze]** + +#### Scenario S6-R1-A: new lints pass + +``` +Given the hardened analysis_options.yaml is applied +When flutter analyze runs on the full lib/ tree +Then no errors are emitted for cancel_subscriptions, close_sinks, unawaited_futures, + prefer_final_locals, or avoid_dynamic_calls +``` + +### S6-R2 — Top-5 missing unit tests written + +The following test cases MUST exist and pass under `flutter test`: + +1. `ServicioAlarmas` concurrent read-modify-write: two simultaneous mutations produce a + consistent final state with no lost write. +2. Alarm fire dedup across `refrescarProgramacion`: calling `refrescarProgramacion` while + an alarm is already in `_ejecucionesEmitidas` does not emit a duplicate event. +3. `PluriWaveAudioHandler` rapid source-switch race: switching stations faster than the + 12-second timeout cancels the previous load and does not emit a stale error state. +4. Export/import round-trip (see S4-R4 scenario). +5. `ServicioGrabacionRadio` error recovery: a recording error clears the recording state + and does not leave open sinks. + +**[flutter test]** + +#### Scenario S6-R2-A: concurrent mutation test + +``` +Given two calls to ServicioAlarmas.guardar run concurrently with different alarm payloads +When both futures complete +Then the persisted state contains both alarms without either being lost +``` + +#### Scenario S6-R2-B: source-switch race test + +``` +Given PluriWaveAudioHandler has started loading station A +When station B is requested before station A finishes loading +Then the playback state does not transition to error for station A's load failure + And the final state reflects station B +``` + +--- + +## S7 — Streaming resilience + +### S7-R1 — Enlarged live-stream buffer + +`PluriWaveAudioHandler` (ExoPlayer / just_audio) MUST configure an enlarged live-stream +buffer sufficient to tolerate short network interruptions of up to approximately 15-30 +seconds without audible playback interruption. The buffer size MUST be configurable (not +hardcoded to a single magic constant). + +**[on-device]** (network drop test); **[flutter test]** (unit test: buffer config applied to player) + +#### Scenario S7-R1-A: buffer covers short network drop + +``` +Given the radio is playing and ExoPlayer has buffered content +When network connectivity is lost for up to 15 seconds +Then audio playback continues without interruption (buffered content plays through) + And the UI does not show an error state during the buffer window +``` + +### S7-R2 — Automatic reconnection with bounded exponential backoff + +When the live stream stalls or emits a `PlayerException` with a network-related error +code (2xxx range) while the user's intended playback state is "playing" (not user-paused +or user-stopped), `PluriWaveAudioHandler` MUST attempt automatic reconnection using +exponential backoff with a configurable maximum retry count (default: 5) and a configurable +maximum delay (default: 30 seconds). After retries are exhausted, the error MUST be +surfaced to the UI via the `playbackState` error state. + +The handler MUST distinguish user-initiated stop/pause (no reconnect) from network stall +(reconnect). + +**[flutter test]** (unit test for reconnect decision logic and backoff timing) + +#### Scenario S7-R2-A: reconnect on network stall + +``` +Given the radio is playing and userIntent = playing +When a PlayerException with code 2001 (no internet) is received +Then the handler transitions to a "buffering/reconnecting" processing state + And schedules a retry after the backoff delay (not an immediate error state) +``` + +#### Scenario S7-R2-B: no reconnect on user stop + +``` +Given the radio is playing +When the user calls ServicioAudio.detener() +Then userIntent is set to stopped + And if a PlayerException subsequently fires (from the stop race) + Then no reconnection is attempted +``` + +#### Scenario S7-R2-C: retries exhausted → error surfaced + +``` +Given the handler has attempted 5 reconnections without success +When the 5th retry also fails +Then playbackState transitions to AudioProcessingState.error with errorMessage set + And no further retries are attempted +``` + +#### Scenario S7-R2-D: backoff logic unit test + +``` +Given a reconnect strategy with maxRetries=5 and baseDelay=1s +When retries 1..5 are simulated +Then the delay sequence is approximately [1s, 2s, 4s, 8s, 16s] capped at maxDelay + And after retry 5 the strategy returns "exhausted" +``` + +### S7-R3 — Buffering/reconnecting state surfaced without dialog spam + +While the handler is in the reconnecting/buffering phase, the UI MUST show a loading/ +buffering indicator (e.g., the existing `EstadoReproduccion.cargando` state) and MUST NOT +show an error dialog or snackbar for each retry attempt. An error MUST only be shown after +retries are exhausted (S7-R2-C). + +**[flutter test]** (widget test: no dialog shown during buffering state) + +#### Scenario S7-R3-A: no error dialog during reconnect + +``` +Given the handler is in reconnecting state (attempt 2 of 5) +When the UI observes estadoStream +Then EstadoReproduccion.cargando is emitted (not error) + And no AlertDialog or SnackBar with error text is visible +``` + +### S7-R4 — Alarm audio path not regressed + +The enlarged buffer and reconnect logic MUST apply only to the user-initiated radio +playback path (`PluriWaveAudioHandler`). The native alarm audio path (`PluriWaveAlarmService` +MediaPlayer) MUST be unchanged by Slice 7. The existing 15-second stream timeout before +falling back to bundled WAV MUST be preserved. + +**[on-device]** (alarm still fires with fallback); **[flutter test]** (alarm service not using new buffer config) + +#### Scenario S7-R4-A: alarm fallback timing unchanged + +``` +Given an alarm fires with a station URL that never responds +When 15 seconds elapse +Then the native service falls back to the bundled WAV (behavior unchanged by S7) +``` + +### S7-R5 — Recording path not regressed + +`ServicioGrabacionRadio` uses its own HTTP stream (not `PluriWaveAudioHandler`). Its +error handling MUST NOT be modified by Slice 7 changes. + +**[flutter test]** (S6-R2 test #5 still passes after S7 changes) + +#### Scenario S7-R5-A: recording error handling unchanged + +``` +Given ServicioGrabacionRadio is recording from a stream that errors +When the error occurs +Then the recording state is cleared (same behavior as before S7) + And no backoff or reconnect logic from S7 is triggered +``` + +### S7-R6 — Sleep-timer fade-out not regressed + +The sleep-timer fade-out logic (gradual volume reduction → stop) MUST complete normally +even if the stream enters a buffering state during the fade-out window. The reconnect +logic MUST NOT restart playback after the sleep-timer issues a stop command. + +**[flutter test]** + +#### Scenario S7-R6-A: sleep timer stop honored during reconnect + +``` +Given the sleep timer is active and has issued a stop command +When the handler would normally schedule a reconnect attempt +Then the reconnect is suppressed because userIntent = stopped (from sleep timer stop) + And audio stops as intended +``` + +### S7-R7 — Reconnection unit tests (strict TDD) + +The following MUST be covered by `flutter test` unit tests: + +- Backoff delay computation for retries 1 through N with cap at maxDelay. +- `userIntent` transitions: `reproducir()` sets intent to playing; `detener()` and `pausar()` set intent to stopped/paused. +- No reconnect scheduled when `userIntent != playing`. +- Reconnect attempted when `userIntent == playing` and error is network-class. +- Error state emitted after `maxRetries` exhausted. + +**[flutter test]** + +--- + +## Cross-cutting requirements + +### CC-R1 — No flutter build required + +No requirement in this spec SHALL necessitate running `flutter build`. Native correctness +requirements (marked `[on-device]`) are validated by the user via manual device testing. + +### CC-R2 — Strict TDD + +All Dart-side behavioral logic introduced by Slices 1-7 MUST be covered by `flutter test` +unit tests written before or alongside the implementation code. Slices that introduce +new Dart classes MUST include at least one test file per new class. + +### CC-R3 — No regressions to existing flutter test suite + +After each slice, `flutter test` MUST pass with no new failures. All 12 existing test +files MUST continue to pass. + +**[flutter test]** + +### CC-R4 — flutter analyze clean + +After each slice, `flutter analyze` MUST report zero errors. Warnings introduced by new +lint rules (S6-R1) MUST be resolved before the slice PR is merged. + +**[flutter analyze]** diff --git a/openspec/changes/app-quality-and-native-alarms/state.yaml b/openspec/changes/app-quality-and-native-alarms/state.yaml new file mode 100644 index 0000000..f855551 --- /dev/null +++ b/openspec/changes/app-quality-and-native-alarms/state.yaml @@ -0,0 +1,9 @@ +change: app-quality-and-native-alarms +status: proposed +artifact_store: hybrid +# NOTE: Engram MCP was unavailable at proposal time. Files in this directory are +# authoritative; engram mirror was not written and must be backfilled when available. +created: 2026-06-11 +updated: 2026-06-11 +phase: tasks-ready +tasks_written: 2026-06-11 diff --git a/openspec/changes/app-quality-and-native-alarms/tasks.md b/openspec/changes/app-quality-and-native-alarms/tasks.md new file mode 100644 index 0000000..7cc72d7 --- /dev/null +++ b/openspec/changes/app-quality-and-native-alarms/tasks.md @@ -0,0 +1,469 @@ +# Tasks: app-quality-and-native-alarms + +## Review Workload Forecast + +| Field | Value | +|-------|-------| +| Estimated changed lines (total) | ~1 850–2 100 | +| 400-line budget risk (overall) | High — all slices combined | +| Chained PRs recommended | N/A (local apply — no PRs) | +| Suggested split | S1 → S2a → S2b → S3a → S3b → S7 → S4a → S4b → S5 → S6 | +| Delivery strategy | auto-chain | +| Chain strategy | N/A (local apply — user commits at own cadence) | + +Decision needed before apply: No +Chained PRs recommended: N/A (local apply) +Chain strategy: N/A (local apply) +400-line budget risk: High + +> **Per-slice risks** are noted inline. Each slice is an autonomous apply batch; the +> user reviews and commits before the next slice begins. + +### Suggested Work Units (apply batches) + +| Batch | Slices | Goal | Prerequisite | Est. lines | +|-------|--------|------|--------------|------------| +| 1 | S1 | Native alarm reliability (manifest, FSI, channels, fallback, fade) | — | ~330 | +| 2 | S2a | Snooze correctness: bridge sync + ringing-screen buttons | S1 complete | ~260 | +| 3 | S2b | Editor redesign + visual (next-trigger, station picker, snooze field, scaffold) | S2a complete | ~180 | +| 4 | S3a | Test seams: statics→instance, prefs injection, cache/mutex, dirty-guard, bounded set | S2 complete | ~270 | +| 5 | S3b | audio_session integration + becoming-noisy + intent flag seam | S3a complete | ~100 | +| 6 | S7 | Streaming resilience: buffer config, reconnect state machine, UI wiring | S3b complete | ~285 | +| 7 | S4a | ServicioExportImport + EstadoEcualizador extraction + compat getters | S3 complete | ~350 | +| 8 | S4b | EstadoGrabacion + EstadoBusqueda + context.select rewiring + remove compat getters | S4a complete | ~380 | +| 9 | S5 | Design system, a11y, i18n, polish | S2b complete | ~210 | +| 10 | S6 | Quality gates: analysis_options + top-5 tests + lint fix-ups | S4b + S5 complete | ~120 | + +--- + +## Slice S1 — Alarm native reliability (~330 lines) + +> **Verification verbs** — on-device items are deferred to the user's device checklist (Section 11). +> Dart items: `flutter test`, `flutter analyze`, `dart format`. + +### S1 pre-work: write failing tests + +- [x] **T-S1-01** [RED] Write failing test: `test/servicios/servicio_alarmas_android_test.dart` — assert `programar()` MethodChannel payload contains keys `fallbackStationUrl`, `fallbackStationName`, `fadeInSegundos`, `fallbackSound`. **Reqs:** S1-R4, S1-R6. **~20 lines.** +- [x] **T-S1-02** [RED] Write failing test in same file — assert `solicitarExencionBateria()` invokes `requestIgnoreBatteryOptimizations` on the MethodChannel. **Reqs:** S1-R5. **~15 lines.** + +### S1 implementation: Kotlin / manifest (on-device verification) + +- [x] **T-S1-03** Edit `android/app/src/main/AndroidManifest.xml`: add `` near line 5; change `PluriWaveAlarmService` to `android:foregroundServiceType="mediaPlayback|alarm"` (lines 54-57). **Reqs:** S1-R1. *On-device verify deferred to user.* **DEVIATION:** `alarm` FGS type / `FOREGROUND_SERVICE_ALARM` permission do NOT exist in the Android SDK (verified against android-36 `android.jar`); implemented with `systemExempted` / `FOREGROUND_SERVICE_SYSTEM_EXEMPTED`, the documented type for alarm-clock apps holding `SCHEDULE_EXACT_ALARM`/`USE_EXACT_ALARM`. +- [x] **T-S1-04** Edit `android/app/src/main/kotlin/.../PluriWaveAlarmService.kt` line ~75: on API ≥ 34 call `startForeground(NOTIFICATION_ID, notification, ServiceInfo.FOREGROUND_SERVICE_TYPE_MEDIA_PLAYBACK or ServiceInfo.FOREGROUND_SERVICE_TYPE_ALARM)`; on API < 34 keep 2-arg overload. **Reqs:** S1-R1. *On-device verify.* **DEVIATION:** uses `FOREGROUND_SERVICE_TYPE_SYSTEM_EXEMPTED` (see T-S1-03; `FOREGROUND_SERVICE_TYPE_ALARM` does not exist). +- [x] **T-S1-05** Edit `PluriWaveAlarmReceiver.kt`: remove `showFireNotification` call (lines 37, 95-133). The service `startForeground` notification (ID 92841) is now the single owner of the FSI. Keep `fireNotificationIdForAlarm` helper for `cancelAlarm` migration safety — do NOT post to it. **Reqs:** S1-R2. *On-device verify.* +- [x] **T-S1-06** Edit `PluriWaveAlarmService.kt` `buildNotification`: add `setFullScreenIntent(...)` so the FSI appears instantly at `startForeground` before audio prepares. Ensure `stopAlarm` (line ~224) calls `stopForeground(STOP_FOREGROUND_REMOVE)` and also cancels any legacy `fireNotificationIdForAlarm` id as migration guard. **Reqs:** S1-R2. *On-device verify.* (`setFullScreenIntent` and both `stopAlarm` guards were already present; verified and documented ordering with a comment.) +- [x] **T-S1-07** Edit `PluriWaveAlarmService.kt` (~line 374) and `PluriWaveAlarmReceiver.kt` (~line 269): introduce versioned channel id `pluriwave_alarm_fire_v2` (IMPORTANCE_HIGH) with `setSound(DEFAULT_ALARM_ALERT_URI, USAGE_ALARM AudioAttributes)` and `enableVibration(true)`. Add one-time channel migration: delete `pluriwave_alarm_native` and `pluriwave_alarm_fire` guarded by SharedPreferences flag `channels_migrated_v2`. Service's `startForeground` notification now uses `_fire_v2`. **Reqs:** S1-R3. *On-device verify.* +- [x] **T-S1-08** Edit `AlarmScheduler.kt` `NativeAlarmSpec` (lines 571-648): add `fallbackStationName: String?`, `fallbackStationUrl: String?`, `fadeInSegundos: Int` fields; bump `schemaVersion` 2→3; update `toJson`/`fromJson` (additive, defaults null/0 for missing fields). Wire through `scheduleAlarm` signature, `MainActivity` handler (lines 68-106), and `EXTRA_*` constants / `fireIntent` extras. **Reqs:** S1-R4, S1-R6. *On-device verify.* +- [x] **T-S1-09** Edit `PluriWaveAlarmService.kt` `startAudio` (lines 86-108): implement three-stage ordered fallback state machine (primary station 15s → fallback station 15s → bundled WAV). Reuse `scheduleStationFallback`/`cancelStationFallback` per stage. **Reqs:** S1-R4. *On-device verify.* +- [x] **T-S1-10** Edit `PluriWaveAlarmService.kt` `setOnPreparedListener` (lines 128-136, 179-183): if `fadeInSegundos > 0`, start at 0.05 × target volume and step every 250 ms toward `volume` via `mainHandler` runnable. Cancel ramp runnable in `stopAlarm` and on snooze. **Reqs:** S1-R6. *On-device verify.* +- [x] **T-S1-11** Add `requestIgnoreBatteryOptimizations` MethodChannel handler in `MainActivity.kt` (mirror `requestExactAlarmPermission` ~lines 255-270): launch `Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS`. **Reqs:** S1-R5. *On-device verify.* + +### S1 implementation: Dart bridge + +- [x] **T-S1-12** [GREEN] Edit `lib/servicios/servicio_alarmas_android.dart` `programar()` (lines 148-174): add `fallbackStationUrl`, `fallbackStationName` (from `alarma.emisoraFallback`), and `fadeInSegundos` to the MethodChannel args map. **Reqs:** S1-R4, S1-R6. **~15 lines.** +- [x] **T-S1-13** [GREEN] Add `solicitarExencionBateria()` method to `PuertoAlarmasAndroid` interface and `ServicioAlarmasAndroid` implementation (`lib/servicios/servicio_alarmas_android.dart` ~lines 93-107, 196-218): invoke `requestIgnoreBatteryOptimizations` MethodChannel. **Reqs:** S1-R5. **~20 lines.** +- [x] **T-S1-14** [GREEN] Edit `lib/estado/estado_alarmas.dart` `_solicitarPermisosNecesariosParaAlarma` (lines 268-284): call `android.solicitarExencionBateria()` ONLY when `!diag.ignoraOptimizacionBateria` AND a `bateria_exencion_solicitada` flag is unset in SharedPreferences (asked-once guard). **Reqs:** S1-R5. **~15 lines.** + +### S1 verification + +- [x] **T-S1-15** Run `flutter test test/servicios/servicio_alarmas_android_test.dart` — T-S1-01, T-S1-02 must pass (GREEN). Verify T-S1-12, T-S1-13, T-S1-14 output. (Full suite: 54 tests passing.) +- [x] **T-S1-16** Run `flutter analyze` — zero errors. (`No issues found!`, identical to pre-S1 baseline.) +- [x] **T-S1-17** Run `dart format lib/servicios/servicio_alarmas_android.dart lib/estado/estado_alarmas.dart`. (Also formatted both touched test files.) + +### S1 Definition of Done +- `flutter test` green (T-S1-01, T-S1-02 passing; no regressions in existing 12 test files). +- `flutter analyze` clean. +- `dart format` applied to all edited Dart files. +- Reqs checked off: S1-R1 (on-device), S1-R2 (on-device), S1-R3 (on-device), S1-R4 (Dart portion), S1-R5 (Dart portion), S1-R6 (Dart portion). +- User performs on-device verification (see Section 11) for the Kotlin/manifest tasks before starting S2. + +--- + +## Slice S2a — Snooze correctness (~260 lines) + +> Covers Design Decisions 2.1–2.3. Must ship before S2b. + +### S2a pre-work: write failing tests + +- [x] **T-S2a-01** [RED] Create `test/estado/estado_alarmas_snooze_test.dart`: + - Test A: `posponerAlarma(alarma, 5)` calls `android.programar` once with `snoozeHasta = proximaEjecucion + 5 min`; calls `notifyListeners`. (S2-R6-A, S2-R1) + - Test B: A `snoozed` native event triggers `servicio.posponerEjecucionHasta` + `notifyListeners` WITHOUT a second `android.programar`. (S2-R3, Decision 2.1) + - Test C: `recalcularTodas` called after `posponerAlarma` PRESERVES `snoozeHasta` (S4 regression guard). (S2-R6) + **DONE — plus extra tests: cold-start snooze import, stop-cancels-snooze (S2-R5), finalizarEjecucion clears snooze. Shared fake moved to `test/helpers/fakes_alarmas.dart`.** +- [x] **T-S2a-02** [RED] Create `test/servicios/servicio_alarmas_snooze_test.dart`: + - Test A: `posponerEjecucionHasta(id, origin, until)` computes `snoozeHasta = origin + minutes` and persists. (S2-R6) + - Test B: MethodChannel payload for a snoozed alarm contains `snoozeUntilMillis` matching `snoozeHasta`. (S2-R6) + - Test C: `finalizarEjecucion` clears `snoozeHasta` and calls `android.cancelar` (or `programar` without `snoozeHasta`). (S2-R5, S2-R6) + **DONE — Test C lives in `estado_alarmas_snooze_test.dart` (it is EstadoAlarmas behavior). Added anchor-clamp and custom-minutes tests + `getNativeSnoozeState` bridge tests.** +- [x] **T-S2a-03** [RED] Add test in `test/estado/estado_alarmas_snooze_test.dart`: after `posponerAlarma`, the alarm list in the state reflects updated `snoozeHasta` synchronously (no poll wait). (S2-R2) **DONE.** + +### S2a implementation: Kotlin native→Flutter sync (on-device portion) + +- [x] **T-S2a-04** Edit `PluriWaveAlarmService.kt` snooze handler (`ACTION_SNOOZE`, now lines 56-80): after `AlarmScheduler.snooze(...)` (which now returns `NativeSnoozeResult`), calls `MainActivity.notifyAlarmEvent` with `alarmAction="snoozed"`, `occurrenceAtMillis`, `snoozeUntilMillis`, title and minutes. **Reqs:** S2-R3, Decision 2.1. *On-device verify.* +- [x] **T-S2a-05** `MainActivity.kt` companion `notifyAlarmEvent(payload)` (lines ~610-635): posts `alarmFired` on the main handler through a `@Volatile activeInstance` (set in `configureFlutterEngine`, cleared in `onDestroy`); no-op with log when engine dead. **Reqs:** S2-R3. *On-device verify.* +- [x] **T-S2a-06** `AlarmScheduler.kt` `snooze()` (lines 266-292): anchor unified to `occurrenceAt + minutes` clamped to `now + minutes` (postponeNext logic adopted; also persists `snoozeMinutes`); returns `NativeSnoozeResult` for the bridge callback. **Reqs:** S2-R4, Decision 2.2. *On-device verify.* +- [x] **T-S2a-07** `AlarmScheduler.kt` `nativeSnoozeStates()` (lines 366-385) returns active future snoozes (alarmId + snoozeUntilMillis + snoozeOriginMillis); wired as `getNativeSnoozeState` in `MainActivity` (line 192). **Reqs:** S2-R3, Decision 2.1 engine-dead case. *On-device verify.* + +### S2a implementation: Dart bridge and state + +- [x] **T-S2a-08** [GREEN] `EventoAlarmaAndroid` extended with `snoozeUntilMillis` field and `accionSnoozed` const; `app.dart` `_abrirAlarmaSonando` ignores `snoozed` events (EstadoAlarmas owns them). **DONE.** +- [x] **T-S2a-09** [GREEN] `programar()` already sent `snoozeUntilMillis`/`snoozeOriginMillis` (pre-existing); now LOCKED by test (`servicio_alarmas_snooze_test.dart` payload test). **Reqs:** S2-R6. **No code change needed.** +- [x] **T-S2a-10** [GREEN] `EstadoAlarmas` subscribes to `android.eventosAlarma` in the CONSTRUCTOR (not `inicializar` — see deviations); `_alRecibirEventoNativo` (estado_alarmas.dart:266) records the snooze via `posponerEjecucionHasta` + `_aplicar` + `notifyListeners`, with NO second `android.programar`. Subscription cancelled in `dispose`. **Reqs:** S2-R3, S2-R2. **DONE.** +- [x] **T-S2a-11** [GREEN] `_sincronizarEjecucionesGestionadasPorAndroid` now always ends with `_importarSnoozesNativosActivos()` (estado_alarmas.dart:306,312): imports active future native snoozes for active alarms when they differ from the stored value. **Reqs:** S2-R3. **DONE.** +- [x] **T-S2a-12** [GREEN] `obtenerEstadoSnoozeNativo()` added to `PuertoAlarmasAndroid` + impl invoking `getNativeSnoozeState`; new `EstadoSnoozeNativo` model with `fromMap`. **DONE.** +- [x] **T-S2a-13** [GREEN] S2-R5 implemented in `servicio_alarmas.dart` `_recalcular` (line 395): `snoozeActivo` now requires `alarma.activa`, so disabling an alarm clears its snooze; `finalizarEjecucion` already cleared it via `completarEjecucion` and re-programs without snooze through `_sincronizarTodas` (the real bridge cancels natively for inactive alarms). Both paths covered by tests. **Reqs:** S2-R5. **DONE.** + +### S2a implementation: ringing screen snooze buttons + +- [x] **T-S2a-14** [RED] Widget tests in `test/pantallas/pantalla_alarma_sonando_test.dart`: + - Test A: snooze buttons 3/5/10 + custom 7 present; no-dup test when snoozeMinutos=5. (S2-R1-A, S2-R1-C) + - Test B: tapping 5-min snooze records snoozeHasta, pauses audio, hides the native notification and pops. (S2-R1-B) + **DONE.** +- [x] **T-S2a-15** [GREEN] `_liberarAudioLocal()` (pantalla_alarma_sonando.dart:138) cancels `_fallbackTimer`/`_fadeInTimer`, cancels `_estadoSub` (fire-and-forget — see deviations), stops `_fallbackPlayer`; `_posponer(int)` (line 161) = teardown → `radio.audio.pausar()` → `posponerAlarma` → pop; `_detener` refactored to reuse it. **Reqs:** S2-R1. **DONE.** +- [x] **T-S2a-16** [GREEN] Snooze button row (`_opcionesSnooze()` = sorted {3,5,10,custom}) rendered with `l10n.alarmSnoozeOptionLabel(min)` + `l10n.snoozeAction` header, each wired to `_posponer`. New ARB keys added to ALL 13 locales. **Reqs:** S2-R1-A/B/C. **DONE.** + +### S2a verification + +- [x] **T-S2a-17** `flutter test` on the three snooze test files — all green (RED phase captured first: compile failures + anchor mismatch). +- [x] **T-S2a-18** `flutter test` (full suite) — 77/77 passing, no regressions. +- [x] **T-S2a-19** `flutter analyze` — `No issues found!`. +- [x] **T-S2a-20** `dart format` applied to all touched Dart files (lib + test). + +### S2a Definition of Done +- `flutter test` green (new snooze tests passing; 12 existing files unbroken). +- `flutter analyze` clean. +- `dart format` applied. +- Reqs checked off: S2-R1, S2-R2, S2-R3 (Dart portion), S2-R4 (Kotlin deferred), S2-R5, S2-R6. + +--- + +## Slice S2b — Editor + visual redesign (~180 lines) + +> Covers Design Decisions 2.4–2.5. + +### S2b pre-work: write failing tests + +- [x] **T-S2b-01** [RED] `test/pantallas/pantalla_alarma_sonando_scaffold_test.dart`: asserts `PluriWaveScaffold` present; no `Scaffold` with `Color(0xFF061722)`; `Animate` present normally and ABSENT with `disableAnimations=true`. **Reqs:** S2-R7, S5-R3. **DONE.** +- [x] **T-S2b-02** [RED] `test/pantallas/pantalla_alarmas_editor_test.dart` (5 tests): + - Test A: next-trigger preview present (key `next-trigger-preview`) and changes when weekday recurrence changes (Mon→Tue, date-independent). (S2-R8) + - Test B: station field opens bottom sheet with `SearchBar`; typing filters the list. (S2-R9) + - Test B2: fallback-station field opens the same picker. (S2-R9) + - Test C: snooze SegmentedButton present; selecting 10 + save persists `snoozeMinutos = 10`. (S2-R10) + - Test D: volume slider min is 0.0. (S2-R11) + **DONE.** + +### S2b implementation + +- [x] **T-S2b-03** [GREEN] Ringing screen migrated to `PluriWaveScaffold`; `Color(0xFF061722)` removed, `Color(0xFFFFB86B)` → `tokens.warmCoral`; `blurSigma` capped to 10 with cold-GPU comment (Design 2.4 mitigation). **Reqs:** S2-R7, S5-R1 (partial). **DONE.** +- [x] **T-S2b-04** [GREEN] `lib/tema/pluri_animate.dart` created: `pluriFadeIn`/`pluriScaleIn` returning the child untouched when `MediaQuery.maybeDisableAnimationsOf(context)` is true. **Reqs:** S5-R3. **DONE.** +- [x] **T-S2b-05** [GREEN] Glass surface wrapped in `.pluriFadeIn(context)` entry animation. **Reqs:** S2-R7. **DONE.** +- [x] **T-S2b-06** [GREEN] `_vistaProximaEjecucion` in the editor: computes `calcularProxima` from the in-progress draft (respects vacations/exceptions), renders `alarmNextExecution`/`alarmNoNextExecution`, recomputed on every `setState` so it tracks time/recurrence edits live. **Reqs:** S2-R8. **DONE.** +- [x] **T-S2b-07** [GREEN] `DropdownButtonFormField` replaced by `_CampoSelectorEmisora` + `_SelectorEmisoraSheet` (bottom sheet with `SearchBar` over favorites + "no station" option); second identical picker added for `emisoraFallback` (NEW field in the editor). `AlarmaMusical.copyWith` gained `limpiarEmisora`/`limpiarEmisoraFallback` so "none" actually clears. **Reqs:** S2-R9. **DONE.** +- [x] **T-S2b-08** [GREEN] Snooze duration SegmentedButton (3/5/10 + current custom value) writing `_snoozeMinutos` (saved via `copyWith(snoozeMinutos: ...)` — the editor previously hardcoded 5 for new alarms); volume slider floor lowered 0.25 → 0.0 (divisions 20). **Reqs:** S2-R10, S2-R11. **DONE.** + +### S2b verification + +- [x] **T-S2b-09** `flutter test` on both S2b test files — 7/7 green (RED captured first). +- [x] **T-S2b-10** `flutter test` (full suite) — 77/77 passing, no regressions. +- [x] **T-S2b-11** `flutter analyze` — `No issues found!`. +- [x] **T-S2b-12** `dart format` applied to all touched files. + +### S2b Definition of Done +- `flutter test` green. +- `flutter analyze` clean. +- `dart format` applied. +- Reqs checked off: S2-R7, S2-R8, S2-R9, S2-R10, S2-R11. + +--- + +## Slice S3a — Test seams: statics, prefs, cache, mutex, bounded set (~270 lines) + +> Covers Design Decisions 3.2, 3.3, 3.4, 3.5. Must complete before S3b and S7 (S7 depends on the intent flag seam from 3.1, which is in S3b). + +### S3a pre-work: write failing tests + +- [ ] **T-S3a-01** [RED] Create `test/servicios/servicio_alarmas_android_instance_test.dart`: two `ServicioAlarmasAndroid` instances do not share `_eventosController` (S3-R2-A). Use a fake `MethodChannel`. **~20 lines.** +- [ ] **T-S3a-02** [RED] Create `test/servicios/servicio_alarmas_cache_test.dart`: + - Test A: `recalcularTodas` does NOT call `SharedPreferences.setString` when schedule unchanged (S3-R5-A). + - Test B: `recalcularTodas` calls `SharedPreferences.setString` exactly once when changed (S3-R5-B). + - Test C: Two concurrent `guardarAlarma` calls produce consistent final state — no lost write (S3-R7-A, S6-R2 test #1 preview). **~50 lines.** +- [ ] **T-S3a-03** [RED] Create `test/estado/estado_alarmas_ejecuciones_test.dart`: `_ejecucionesEmitidas` with 100 stale entries triggers pruning; length ≤ max threshold after prune (S3-R6-A). **~20 lines.** +- [ ] **T-S3a-04** [RED] Create `test/widgets/mini_reproductor_configurar_test.dart`: `configurarLocalizaciones` called at most once per locale change across 10 rebuilds (S3-R3-A). **~20 lines.** + +### S3a implementation + +- [ ] **T-S3a-05** [GREEN] Edit `lib/servicios/servicio_alarmas_android.dart` (lines 117-120): convert `static _eventosController`, `static _handlerInstalado`, `static _l10n` to INSTANCE fields. Install handler in constructor. Add deprecated static shim for `estado_radio.dart:74` call site (one-release compat). Rewire `EstadoRadio.configurarLocalizaciones` to call the instance. **Reqs:** S3-R2. **~40 lines.** +- [ ] **T-S3a-06** [GREEN] Edit `lib/widgets/mini_reproductor.dart` (line 23): convert to `StatefulWidget` if not already; move `configurarLocalizaciones(l10n)` call to `didChangeDependencies`, guarded by a cached `Locale` comparison so it only runs on locale change. **Reqs:** S3-R3. **~25 lines.** +- [ ] **T-S3a-07** [GREEN] Edit `lib/main.dart`: resolve `SharedPreferences.getInstance()` ONCE before `runApp`; pass the instance through to providers / service constructors. **Reqs:** S3-R4. **~10 lines.** +- [ ] **T-S3a-08** [GREEN] Audit and edit `lib/servicios/servicio_ecualizador.dart`, `lib/servicios/servicio_grabacion_radio.dart`, and any remaining service calling `SharedPreferences.getInstance()` inline (~25 sites): replace with injected `prefs` parameter. Use `_resolverPrefs` fallback in `servicio_alarmas.dart:399-400` as temporary compat net during migration. **Reqs:** S3-R4. **~30 lines total across files.** +- [ ] **T-S3a-09** [GREEN] Edit `lib/servicios/servicio_alarmas.dart` (lines 316-323): add dirty-check in `recalcularTodas` — serialize new config; compare to loaded serialized; skip `_guardar` if identical. Return loaded config unchanged when clean. **Reqs:** S3-R5. **~20 lines.** +- [ ] **T-S3a-10** [GREEN] Edit `lib/servicios/servicio_alarmas.dart` (lines 81-108): introduce in-memory `ConfiguracionAlarmas?` cache and a `Future`-chain mutex (mirror `_colaCambioFuente` pattern from `servicio_audio.dart:125`). All mutations: `await _lock` → read cache → mutate → persist → update cache → release. Remove `cargar()` calls before each mutation. **Reqs:** S3-R7. **~50 lines.** +- [ ] **T-S3a-11** [GREEN] Edit `lib/estado/estado_alarmas.dart` (line 32): replace unbounded `Set _ejecucionesEmitidas` with a bounded structure (cap ~200 entries); add pruning of entries with millis suffix older than 24 h on each `_vigilarAlarmasVencidas` pass (lines 326-348). **Reqs:** S3-R6. **~25 lines.** + +### S3a verification + +- [ ] **T-S3a-12** Run `flutter test test/servicios/servicio_alarmas_android_instance_test.dart test/servicios/servicio_alarmas_cache_test.dart test/estado/estado_alarmas_ejecuciones_test.dart test/widgets/mini_reproductor_configurar_test.dart`. +- [ ] **T-S3a-13** Run `flutter test` (full suite) — no regressions. +- [ ] **T-S3a-14** Run `flutter analyze` — zero errors. +- [ ] **T-S3a-15** Run `dart format` on all edited Dart files. + +### S3a Definition of Done +- `flutter test` green. +- `flutter analyze` clean. +- `dart format` applied. +- Reqs checked off: S3-R2, S3-R3, S3-R4, S3-R5, S3-R6, S3-R7. + +--- + +## Slice S3b — audio_session + becoming-noisy + intent flag (~100 lines) + +> Provides the `_intencionReproducir` flag seam that S7 requires. + +### S3b pre-work: write failing tests + +- [ ] **T-S3b-01** [RED] Create `test/servicios/servicio_audio_session_test.dart`: + - Test A: interruption `begin/pause` event sets `_intencionReproducir` to false and pauses playback. (S3-R1) + - Test B: interruption `end/shouldResume` resumes playback. (S3-R1) + - Test C: becoming-noisy event pauses playback. (S3-R1) + **~30 lines.** + +### S3b implementation + +- [ ] **T-S3b-02** [GREEN] Create `lib/servicios/servicio_audio_session.dart`: `ServicioAudioSession` wrapper around `package:audio_session`. In `configurar()`: `AudioSession.instance` → configure with `AudioSessionConfiguration.music()` adjusted (playback category, `androidWillPauseWhenDucked: true`). Subscribe to `interruptionEventStream` (pause/duck/resume) and `becomingNoisyEventStream` (pause). On interrupt begin: call `handler.pause()` + set `handler._intencionReproducir = false`. On end with `shouldResume`: call `handler.play()` + set `handler._intencionReproducir = true`. **Reqs:** S3-R1. **~60 lines.** +- [ ] **T-S3b-03** [GREEN] Edit `lib/servicios/servicio_audio.dart` `PluriWaveAudioHandler`: expose `_intencionReproducir` flag (bool, default false). Set true in `play()`/`reproducir()`/`reanudar()`; set false in `pause()`/`detener()`. This is the seam S7 will read. Wire `ServicioAudioSession.configurar()` call from `main.dart` or `PluriWaveAudioHandler` init. **Reqs:** S3-R1. **~20 lines.** + +### S3b verification + +- [ ] **T-S3b-04** Run `flutter test test/servicios/servicio_audio_session_test.dart`. +- [ ] **T-S3b-05** Run `flutter test` (full suite) — no regressions. +- [ ] **T-S3b-06** Run `flutter analyze` — zero errors. +- [ ] **T-S3b-07** Run `dart format lib/servicios/servicio_audio_session.dart lib/servicios/servicio_audio.dart`. + +### S3b Definition of Done +- `flutter test` green. +- `flutter analyze` clean. +- `dart format` applied. +- Reqs checked off: S3-R1 (`flutter analyze` import present; on-device call-pause deferred to user). + +--- + +## Slice S7 — Streaming resilience (~285 lines) + +> Depends on S3b (intent flag seam). Covers Design Decisions 7.1–7.2. + +### S7 pre-work: write failing tests + +- [ ] **T-S7-01** [RED] Create `test/servicios/servicio_audio_reconnect_test.dart`: + - Test A: backoff delay sequence for retries 1–5 is [1s, 2s, 4s, 8s, 16s] capped at maxDelay (S7-R7). + - Test B: `_intencionReproducir=true` + stall → `reconectando` state emitted, reconnect scheduled (S7-R2-A, S7-R7). + - Test C: `_intencionReproducir=false` + stall → NO reconnect (S7-R2-B, S7-R7). + - Test D: after `maxRetries` exhausted → error state emitted (S7-R2-C, S7-R7). + - Test E: successful reconnect resets retry counter (S7-R7). + - Test F: user stop during stall cancels reconnect (S7-R6, S7-R7). + **~70 lines.** +- [ ] **T-S7-02** [RED] Add test in `test/servicios/servicio_audio_reconnect_test.dart`: buffer config (`AndroidLoadControl`) applied to player construction (S7-R1). **~15 lines.** +- [ ] **T-S7-03** [RED] Add widget test `test/widgets/reconnect_ui_test.dart`: no `AlertDialog`/`SnackBar` shown while handler in `reconectando` state (S7-R3-A). **~20 lines.** + +### S7 implementation + +- [ ] **T-S7-04** [GREEN] Edit `lib/servicios/servicio_audio.dart` `_crearPlayer` (lines 159-163): pass `AudioLoadConfiguration(androidLoadControl: AndroidLoadControl(minBufferDuration: 15s, maxBufferDuration: 50s, bufferForPlaybackDuration: 2.5s, bufferForPlaybackAfterRebufferDuration: 5s, prioritizeTimeOverSizeThresholds: true))` at construction. Values extracted as named constants, NOT magic literals. **Reqs:** S7-R1. **~25 lines.** +- [ ] **T-S7-05** [GREEN] Edit `lib/servicios/servicio_audio.dart`: add `EstadoReproduccion.reconectando` to the state enum (line 14). **Reqs:** S7-R2, S7-R3. **~3 lines.** +- [ ] **T-S7-06** [GREEN] Edit `lib/servicios/servicio_audio.dart` `_gestionarErrorReproduccion` (lines 207-236) and `_eventosSub.onError` (lines 189-194): instead of transitioning immediately to terminal error when `_intencionReproducir == true` and error is network-class (2xxx range), enter the reconnect state machine — emit `reconectando`, schedule backoff retry using `_cambiarFuente` revision guard. Cancel/reset on user stop or source switch. After `maxRetries` exhaustion fall through to existing terminal error path. Configurable: `_maxRetries = 5`, `_baseDelay = 1s`, `_maxDelay = 30s`. **Reqs:** S7-R2. **~130 lines.** +- [ ] **T-S7-07** [GREEN] Edit `lib/widgets/mini_reproductor.dart` and any player UI: map `EstadoReproduccion.reconectando` → buffering/loading indicator (NOT error dialog). **Reqs:** S7-R3. **~20 lines.** +- [ ] **T-S7-08** [GREEN] Edit `lib/pantallas/pantalla_alarma_sonando.dart` (alarm pre-start / estadoStream listener): ensure `reconectando` is NOT treated as `reproduciendo`; the alarm's existing 12-second fallback timer remains authoritative. Add a comment documenting the boundary. **Reqs:** S7-R4. **~10 lines.** +- [ ] **T-S7-09** [GREEN] Confirm `ServicioGrabacionRadio` error-handling code is NOT modified by S7 changes. Add inline comment referencing S7-R5 invariant. **Reqs:** S7-R5. **~3 lines (comment only).** + +### S7 verification + +- [ ] **T-S7-10** Run `flutter test test/servicios/servicio_audio_reconnect_test.dart test/widgets/reconnect_ui_test.dart`. +- [ ] **T-S7-11** Run `flutter test` (full suite) — no regressions. +- [ ] **T-S7-12** Run `flutter analyze` — zero errors. +- [ ] **T-S7-13** Run `dart format lib/servicios/servicio_audio.dart lib/widgets/mini_reproductor.dart lib/pantallas/pantalla_alarma_sonando.dart`. + +### S7 Definition of Done +- `flutter test` green (all reconnect tests passing). +- `flutter analyze` clean. +- `dart format` applied. +- Reqs checked off: S7-R1 (Dart buffer config), S7-R2, S7-R3, S7-R4, S7-R5, S7-R6, S7-R7; on-device stream-drop deferred to user. + +--- + +## Slice S4a — ServicioExportImport + EstadoEcualizador (~350 lines) + +> Extraction order: ServicioExportImport first (pure logic, zero UI coupling), then EstadoEcualizador. + +### S4a pre-work: write failing tests + +- [ ] **T-S4a-01** [RED] Create `test/servicios/servicio_export_import_test.dart`: + - Test A: full round-trip (favorites, groups, EQ, alarms, vacations) — serialize then deserialize produces deep-equal config. (S4-R4-A, S6-R2 test #4) + - Test B: malformed JSON input to `importar()` → graceful empty result, no throw. (S4-R4) + **~40 lines.** +- [ ] **T-S4a-02** [RED] Create `test/estado/estado_ecualizador_test.dart`: + - Test A: `aplicarPreset` notifies `EstadoEcualizador` listeners. (S4-R1-A) + - Test B: `EstadoRadio` listeners are NOT rebuilt on EQ preset change. (S4-R1-A, S4-R5) + **~30 lines.** + +### S4a implementation + +- [ ] **T-S4a-03** [GREEN] Create `lib/servicios/servicio_export_import.dart`: `ServicioExportImport` class with `exportar(config) → String` and `importar(json) → ConfiguracionCompleta?`. Move all `jsonEncode`/`jsonDecode` backup/restore logic from `lib/pantallas/pantalla_ajustes.dart` (~1391 lines). `PantallaAjustes` delegates to this service. **Reqs:** S4-R4. **~100 lines (service) + ~30 lines cleanup in pantalla_ajustes.** +- [ ] **T-S4a-04** [GREEN] Create `lib/estado/estado_ecualizador.dart`: `EstadoEcualizador extends ChangeNotifier` — owns preset, bands, enabled flag. Move EQ state fields and methods from `lib/estado/estado_radio.dart`. Add `ProxyProvider` registration in `MultiProvider` (wherever providers are registered, likely `lib/main.dart` or `lib/app.dart`). **Reqs:** S4-R1. **~90 lines.** +- [ ] **T-S4a-05** [GREEN] Edit `lib/estado/estado_radio.dart`: add backward-compatible getters delegating EQ state to `EstadoEcualizador` (transition bridge). These are removed in S4b. Add `// TODO(S4b): remove getter` comments. **~20 lines.** +- [ ] **T-S4a-06** [GREEN] Edit `lib/widgets/ecualizador_widget.dart` and `lib/pantallas/pantalla_ajustes.dart` EQ sections: consume `context.watch()` (scoped). Screens still compile via compat getters if missed. **Reqs:** S4-R5. **~20 lines.** + +### S4a verification + +- [ ] **T-S4a-07** Run `flutter test test/servicios/servicio_export_import_test.dart test/estado/estado_ecualizador_test.dart`. +- [ ] **T-S4a-08** Run `flutter test` (full suite) — no regressions. +- [ ] **T-S4a-09** Run `flutter analyze` — zero errors. +- [ ] **T-S4a-10** Run `dart format lib/servicios/servicio_export_import.dart lib/estado/estado_ecualizador.dart lib/estado/estado_radio.dart lib/pantallas/pantalla_ajustes.dart`. + +### S4a Definition of Done +- `flutter test` green. +- `flutter analyze` clean. +- `dart format` applied. +- Reqs checked off: S4-R1, S4-R4. + +--- + +## Slice S4b — EstadoGrabacion + EstadoBusqueda + context.select rewiring (~380 lines) + +### S4b pre-work: write failing tests + +- [ ] **T-S4b-01** [RED] Create `test/estado/estado_grabacion_test.dart`: `ServicioGrabacionRadio` is managed by `EstadoGrabacion`; notifies listeners on recording state change. (S4-R2) **~20 lines.** +- [ ] **T-S4b-02** [RED] Create `test/estado/estado_busqueda_test.dart`: search query update notifies `EstadoBusqueda` listeners. (S4-R3) **~15 lines.** +- [ ] **T-S4b-03** [RED] Add widget test: changing EQ preset does NOT rebuild `PantallaInicio` (S4-R5-A). **~20 lines.** + +### S4b implementation + +- [ ] **T-S4b-04** [GREEN] Create `lib/estado/estado_grabacion.dart`: `EstadoGrabacion extends ChangeNotifier` — owns recording state + `_escucharGrabacion` subscription (currently `estado_radio.dart:51, :79`). Register in `MultiProvider`. **Reqs:** S4-R2. **~80 lines.** +- [ ] **T-S4b-05** [GREEN] Create `lib/estado/estado_busqueda.dart`: `EstadoBusqueda extends ChangeNotifier` — owns search query, results, loading state. Register in `MultiProvider`. **Reqs:** S4-R3. **~60 lines.** +- [ ] **T-S4b-06** [GREEN] Edit `lib/pantallas/pantalla_inicio.dart` (line 43): replace root `context.watch()` with `context.select` / `Consumer` scoped to fields it actually reads. **Reqs:** S4-R5. **~30 lines.** +- [ ] **T-S4b-07** [GREEN] Edit `lib/pantallas/pantalla_ajustes.dart` (~6 watch sites): replace each `context.watch()` with scoped `context.select` / `Consumer` for the specific field. **Reqs:** S4-R5. **~40 lines.** +- [ ] **T-S4b-08** [GREEN] Edit `lib/pantallas/pantalla_favoritos.dart`: scope the `EstadoRadio` watch. **Reqs:** S4-R5. **~15 lines.** +- [ ] **T-S4b-09** [GREEN] Edit `lib/estado/estado_radio.dart`: remove EQ, recording, and search state fields/methods; remove backward-compatible getters added in S4a (they carried `// TODO(S4b): remove getter` comments). **Reqs:** S4-R1, S4-R2, S4-R3. **~80 lines removed.** + +### S4b verification + +- [ ] **T-S4b-10** Run `flutter test test/estado/estado_grabacion_test.dart test/estado/estado_busqueda_test.dart` plus the rebuild scope test. +- [ ] **T-S4b-11** Run `flutter test` (full suite) — no regressions. +- [ ] **T-S4b-12** Run `flutter analyze` — zero errors. +- [ ] **T-S4b-13** Run `dart format lib/estado/estado_grabacion.dart lib/estado/estado_busqueda.dart lib/estado/estado_radio.dart lib/pantallas/pantalla_inicio.dart lib/pantallas/pantalla_ajustes.dart lib/pantallas/pantalla_favoritos.dart`. + +### S4b Definition of Done +- `flutter test` green. +- `flutter analyze` clean. +- `dart format` applied. +- Reqs checked off: S4-R2, S4-R3, S4-R5. + +--- + +## Slice S5 — Design system, a11y, i18n (~210 lines) + +> Parallelizable after S2b completes (ringing screen literals migrated in S2b). + +### S5 pre-work: write failing tests + +- [ ] **T-S5-01** [RED] Create `test/widgets/tarjeta_emisora_a11y_test.dart`: favorite `InkWell` has semantic label + `button:true`; size ≥ 48×48 dp (S5-R2-A). **~20 lines.** +- [ ] **T-S5-02** [RED] Add test in `test/tema/pluri_animate_test.dart`: `pluriFadeIn` returns unanimated child when `disableAnimations=true` (S5-R3-A). **~15 lines.** +- [ ] **T-S5-03** [RED] Create `test/pantallas/pantalla_alarmas_fecha_test.dart`: `_fechaCorta` with locale `en-US` returns `DateFormat.yMd('en-US')` result, NOT `11/06/2026` (S5-R4-A). **~15 lines.** +- [ ] **T-S5-04** [RED] Add test `test/pantallas/pantalla_favoritos_plural_test.dart`: plural form changes between 1 and 5 station count strings (S5-R5). **~10 lines.** +- [ ] **T-S5-05** [RED] Add widget test: shimmer present during loading state in `PantallaBuscar` (S5-R6). **~10 lines.** +- [ ] **T-S5-06** [RED] Add unit test: `AudioServiceConfig.notificationColor` equals brand color token (S5-R8). **~10 lines.** + +### S5 implementation + +- [ ] **T-S5-07** [GREEN] Edit all 14+ remaining `Color(0x...)` literal sites identified in explore C3 (files: `lib/pantallas/`, `lib/widgets/`, excluding `pantalla_alarma_sonando.dart` done in S2b): replace with `PluriWaveTokens` or `Theme.of(context).colorScheme` references. **Reqs:** S5-R1. **~30 lines across files.** +- [ ] **T-S5-08** [GREEN] Edit `lib/widgets/tarjeta_emisora.dart` (lines 238-289): wrap mini favorite `InkWell` in `Semantics(button: true, label: l10n.toggleFavorite)`; set `constraints: BoxConstraints(minWidth: 48, minHeight: 48)`. Add `semanticLabel` to `_AssetIcon`/alarm PNG. **Reqs:** S5-R2. **~15 lines.** +- [ ] **T-S5-09** [GREEN] `lib/tema/pluri_animate.dart` already created in S2b (T-S2b-04). Verify tests pass (no new code needed here unless edge case found). +- [ ] **T-S5-10** [GREEN] Edit `lib/pantallas/pantalla_alarmas.dart` `_fechaCorta` (line 1114): replace hardcoded format string with `intl.DateFormat.yMd(Localizations.localeOf(context).toLanguageTag()).format(date)`. **Reqs:** S5-R4. **~5 lines.** +- [ ] **T-S5-11** [GREEN] Edit `lib/pantallas/pantalla_favoritos.dart` (line 138): replace bare counter string with ARB plural form using `AppLocalizations` `stationCount(n)` plural message. Add the ARB plural entry to `lib/l10n/*.arb` files for all supported locales. **Reqs:** S5-R5. **~20 lines (Dart) + ARB entries.** +- [ ] **T-S5-12** [GREEN] Edit `lib/widgets/tarjeta_emisora.dart` shimmer placeholders (lines 389-420): apply `BorderRadius` matching card corners. Edit `lib/pantallas/pantalla_buscar.dart` (lines 241-245): replace spinner with shimmer during loading state. **Reqs:** S5-R6. **~20 lines.** +- [ ] **T-S5-13** [GREEN] Edit `lib/pantallas/pantalla_ajustes.dart` icon sites (lines 985, 1028, 1031): replace non-`_rounded` icon variants with their `_rounded` equivalents. **Reqs:** S5-R7. **~5 lines.** +- [ ] **T-S5-14** [GREEN] Edit `lib/main.dart` (line 23) `AudioServiceConfig`: set `notificationColor` to `PluriWaveTokens.brandColor` (or equivalent token). **Reqs:** S5-R8. **~3 lines.** + +### S5 verification + +- [ ] **T-S5-15** Run `flutter test test/widgets/tarjeta_emisora_a11y_test.dart test/tema/pluri_animate_test.dart test/pantallas/pantalla_alarmas_fecha_test.dart test/pantallas/pantalla_favoritos_plural_test.dart`. +- [ ] **T-S5-16** Run `flutter test` (full suite) — no regressions. +- [ ] **T-S5-17** Run `flutter analyze` — zero errors (no `Color(0x...)` in modified files beyond token definitions). +- [ ] **T-S5-18** Run `dart format` on all edited files. + +### S5 Definition of Done +- `flutter test` green. +- `flutter analyze` clean (no new color literals in modified files). +- `dart format` applied. +- Reqs checked off: S5-R1 through S5-R8. + +--- + +## Slice S6 — Quality gates (~120 lines) + +> Hardening pass; depends on S4b + S5 complete (all code settled before lint enforcement). + +### S6 pre-work: write failing tests (top-5 required tests not yet written) + +- [ ] **T-S6-01** [RED] `test/servicios/servicio_alarmas_cache_test.dart` — Test C (concurrent mutation, S6-R2 test #1): already written as T-S3a-02 Test C. Verify it is present and passing. +- [ ] **T-S6-02** [RED] `test/estado/estado_alarmas_ejecuciones_test.dart` (fire dedup, S6-R2 test #2): already written as T-S3a-03. Verify passing. +- [ ] **T-S6-03** [RED] Create `test/servicios/servicio_audio_source_switch_test.dart`: rapid `playMediaItem(A)`, `playMediaItem(B)`, `playMediaItem(C)` — only C's source active; no stale error from A/B (S6-R2 test #3). Use fake `AudioPlayer` seam. **~35 lines.** +- [ ] **T-S6-04** Confirm `test/servicios/servicio_export_import_test.dart` (S6-R2 test #4, round-trip) exists from T-S4a-01. Verify passing. +- [ ] **T-S6-05** [RED] Create `test/servicios/servicio_grabacion_radio_test.dart`: recording error clears state and releases resources; subsequent start succeeds (S6-R2 test #5, S7-R5 invariant). **~30 lines.** + +### S6 implementation + +- [ ] **T-S6-06** [GREEN] Edit `analysis_options.yaml`: under `linter.rules` add `cancel_subscriptions`, `close_sinks`, `unawaited_futures`, `prefer_final_locals`, `avoid_dynamic_calls`. **Reqs:** S6-R1. **~6 lines.** +- [ ] **T-S6-07** [GREEN] Fix violations surfaced by the new lint rules across `lib/` (empty catches → `developer.log`, unawaited futures → `unawaited()` or `await`, open sinks/subscriptions — ensure they are tracked and cancelled). Scope: sites already noted in design B7/B10 plus any new violations. **~30 lines across files.** +- [ ] **T-S6-08** [GREEN] Run `flutter analyze` with new rules and fix remaining violations until clean. + +### S6 verification + +- [ ] **T-S6-09** Run `flutter test test/servicios/servicio_audio_source_switch_test.dart test/servicios/servicio_grabacion_radio_test.dart` — green. +- [ ] **T-S6-10** Run `flutter test` (full suite) — all passing including 12 original files. +- [ ] **T-S6-11** Run `flutter analyze` — zero errors under hardened rules. +- [ ] **T-S6-12** Run `dart format` on all edited files. + +### S6 Definition of Done +- `flutter test` green — all 5 required tests present and passing; 12 original files unbroken. +- `flutter analyze` clean under hardened `analysis_options.yaml`. +- `dart format` applied. +- Reqs checked off: S6-R1, S6-R2 (tests 1-5). + +--- + +## Cross-cutting batch — state.yaml + on-device checklist + +- [ ] **T-CC-01** Update `openspec/changes/app-quality-and-native-alarms/state.yaml`: set `phase: tasks-ready`, `updated: 2026-06-11`. +- [ ] **T-CC-02** After the full apply and all flutter test / analyze passes, run final `dart format lib/` sweep. + +### On-device verification checklist (user — Android 14 device) + +Perform after S1 and after all slices are applied. **No `flutter build` from this repo — build from IDE or `flutter run`.** + +1. **Alarm fires app-killed (S1-R1, S1-R2):** kill the app; wait for a scheduled alarm; confirm `PluriWaveAlarmService` starts with no `ForegroundServiceTypeException` in logcat; exactly one notification in the tray. +2. **Alarm channel uses alarm stream (S1-R3):** lower the alarm volume to 0; raise media volume; confirm alarm sound is silent (alarm volume, not media volume). +3. **Snooze from ringing screen (S2-R1, S2-R4):** with app foreground, let alarm ring; tap 5-min snooze; confirm notification dismissed; alarm list shows `snoozeHasta = now+5min`; alarm re-fires at that time. +4. **Snooze from notification while app killed (S2-R3):** kill the app; let alarm fire to notification; tap "Posponer"; confirm system alarm icon still present; bring app to foreground — alarm list shows snoozed state WITHOUT waiting for 60-second poll. +5. **Stop cancels pending snooze (S2-R5):** snooze an alarm; before re-fire, disable the alarm from the list; confirm alarm does NOT re-fire at the snooze time. +6. **Reboot persistence (S1-R1, S2-R4):** schedule an alarm; reboot device; confirm alarm still fires at scheduled time. +7. **Fallback station attempted (S1-R4):** set primary station to an invalid URL, set `emisoraFallback` to a valid one; let alarm fire; confirm the fallback station plays (or bundled WAV if fallback also fails). +8. **Battery optimization exemption requested (S1-R5):** fresh install; grant alarm permission; confirm the battery-optimization dialog appears exactly once. +9. **Stream drop recovery (S7-R1, S7-R2):** while radio plays, briefly disable WiFi/LTE for ~10 s; confirm audio continues if buffered; on reconnect, playback resumes to live edge without error dialog; a longer drop (>30s) shows reconnecting state, eventually surfaces error after retries exhausted. +10. **Phone call pauses radio (S3-R1):** while radio plays, receive a call; confirm radio pauses/ducks; confirm it resumes after the call. +11. **No alarm regression after S7 (S7-R4):** with S7 changes applied, let an alarm fire with a non-responding URL; confirm WAV fallback fires within ~15 seconds (not delayed by reconnect loop). + +--- + +## Per-slice estimated lines and budget risk + +| Slice | Est. lines | 400-line budget risk | Notes | +|-------|-----------|----------------------|-------| +| S1 | ~330 | Medium | Kotlin edits not compilable here; on-device only | +| S2a | ~260 | Medium | Snooze correctness + ringing buttons | +| S2b | ~180 | Low | Editor + visual | +| S3a | ~270 | Medium | Test seams across multiple files | +| S3b | ~100 | Low | audio_session wrapper | +| S7 | ~285 | Medium | Reconnect state machine | +| S4a | ~350 | Medium-High | Two extractions + compat getters | +| S4b | ~380 | Medium-High | Two more extractions + rewiring | +| S5 | ~210 | Low | Design system / i18n | +| S6 | ~120 | Low | Lint rules + 2 new tests | +| **Total** | **~2 285** | **High (overall)** | Distributed across 10 local slices | diff --git a/openspec/config.yaml b/openspec/config.yaml index 11349e3..5b1ff21 100644 --- a/openspec/config.yaml +++ b/openspec/config.yaml @@ -1,7 +1,7 @@ schema: spec-driven context: | - Tech stack: Flutter/Dart app for Android+iOS. Version 0.1.59+60. Dart SDK ^3.7.0. + Tech stack: Flutter/Dart app for Android+iOS. Version 0.1.60+61. Dart SDK ^3.7.0. Architecture: Provider/ChangeNotifier with Spanish domain folders: estado, modelos, pantallas, servicios, widgets. Core deps: just_audio, audio_service, audio_session, provider, sqflite, shared_preferences, http, google_fonts, flutter_animate, cached_network_image, shimmer, share_plus, file_picker, uuid, diff --git a/test/estado/estado_alarmas_snooze_test.dart b/test/estado/estado_alarmas_snooze_test.dart new file mode 100644 index 0000000..6b5c962 --- /dev/null +++ b/test/estado/estado_alarmas_snooze_test.dart @@ -0,0 +1,251 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_alarmas.dart'; +import 'package:pluriwave/modelos/alarma_musical.dart'; +import 'package:pluriwave/servicios/servicio_alarmas.dart'; +import 'package:pluriwave/servicios/servicio_alarmas_android.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/fakes_alarmas.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + AlarmaMusical alarmaDiaria(String id, {int snoozeMinutos = 5}) => + AlarmaMusical( + id: id, + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + snoozeMinutos: snoozeMinutos, + ); + + test('posponerAlarma ancla snoozeHasta en proximaEjecucion + minutos, ' + 'programa una sola vez y notifica', () async { + final ahora = DateTime(2026, 6, 11, 7, 0); + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => ahora), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + await estado.guardarAlarma(alarmaDiaria('s1')); + + final alarma = estado.alarmas.single; + expect(alarma.proximaEjecucion, DateTime(2026, 6, 11, 7, 30)); + final programadasAntes = android.programadas.length; + var notificaciones = 0; + estado.addListener(() => notificaciones++); + + await estado.posponerAlarma(alarma, 5); + + expect(estado.alarmas.single.snoozeHasta, DateTime(2026, 6, 11, 7, 35)); + expect(estado.alarmas.single.snoozeOrigen, DateTime(2026, 6, 11, 7, 30)); + expect(android.programadas.length, programadasAntes + 1); + expect(android.programadas.last.snoozeHasta, DateTime(2026, 6, 11, 7, 35)); + expect(notificaciones, greaterThanOrEqualTo(1)); + }); + + test( + 'la lista de alarmas refleja el snooze de forma sincrona tras posponer', + () async { + final ahora = DateTime(2026, 6, 11, 7, 0); + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => ahora), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + await estado.guardarAlarma(alarmaDiaria('sync1')); + + final futuro = estado.posponerAlarma(estado.alarmas.single, 10); + await futuro; + + // Sin polls ni esperas adicionales: el estado ya refleja el snooze. + expect( + estado.alarmas.single.proximaProgramable, + DateTime(2026, 6, 11, 7, 40), + ); + }, + ); + + test( + 'evento nativo snoozed registra el snooze sin reprogramar en Android', + () async { + final ahora = DateTime(2026, 6, 11, 7, 0); + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => ahora), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + await estado.guardarAlarma(alarmaDiaria('n1')); + final programadasAntes = android.programadas.length; + + final origen = DateTime(2026, 6, 11, 7, 30); + final hasta = DateTime(2026, 6, 11, 7, 40); + final notificado = Completer(); + estado.addListener(() { + if (!notificado.isCompleted) notificado.complete(); + }); + + android.emitirEvento( + EventoAlarmaAndroid( + alarmaId: 'n1', + titulo: 'Diaria', + accion: EventoAlarmaAndroid.accionSnoozed, + occurrenceAtMillis: origen.millisecondsSinceEpoch, + snoozeUntilMillis: hasta.millisecondsSinceEpoch, + ), + ); + await notificado.future; + + expect(estado.alarmas.single.snoozeHasta, hasta); + expect(estado.alarmas.single.snoozeOrigen, origen); + expect( + android.programadas.length, + programadasAntes, + reason: 'el nativo ya reprogramo; no debe haber un segundo programar', + ); + }, + ); + + test( + 'recalcularTodas tras posponer preserva snoozeHasta (guard regresion S4)', + () async { + var ahora = DateTime(2026, 6, 11, 7, 0); + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => ahora), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + await estado.guardarAlarma(alarmaDiaria('r1')); + await estado.posponerAlarma(estado.alarmas.single, 5); + + final snooze = estado.alarmas.single.snoozeHasta; + expect(snooze, isNotNull); + + ahora = DateTime(2026, 6, 11, 7, 2); + await estado.refrescarProgramacion(); + + expect(estado.alarmas.single.snoozeHasta, snooze); + expect(android.programadas.last.snoozeHasta, snooze); + }, + ); + + test( + 'inicializar importa snoozes nativos activos sin esperar el poll', + () async { + final ahora = DateTime.now(); + final origen = ahora.subtract(const Duration(minutes: 2)); + final hasta = ahora.add(const Duration(minutes: 8)); + final servicio = ServicioAlarmas(reloj: () => ahora); + final objetivo = ahora.add(const Duration(hours: 1)); + await servicio.guardarAlarma( + AlarmaMusical( + id: 'cold1', + nombre: 'Cold start', + hora: objetivo.hour, + minuto: objetivo.minute, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + ), + ); + + final android = + FakePuertoAlarmasAndroid() + ..snoozesNativos.add( + EstadoSnoozeNativo( + alarmaId: 'cold1', + snoozeHasta: hasta, + snoozeOrigen: origen, + ), + ); + final estado = EstadoAlarmas( + servicio: servicio, + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + + await estado.inicializar(); + + expect(estado.alarmas.single.snoozeHasta, hasta); + expect(estado.alarmas.single.snoozeOrigen, origen); + expect(android.programadas.last.snoozeHasta, hasta); + }, + ); + + test('desactivar una alarma pospuesta limpia el snooze (S2-R5)', () async { + final ahora = DateTime(2026, 6, 11, 7, 0); + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => ahora), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + await estado.guardarAlarma(alarmaDiaria('stop1')); + await estado.posponerAlarma(estado.alarmas.single, 5); + expect(estado.alarmas.single.snoozeHasta, isNotNull); + + await estado.cambiarActiva(estado.alarmas.single, false); + + expect(estado.alarmas.single.snoozeHasta, isNull); + expect(estado.alarmas.single.activa, isFalse); + // El puente real cancela el setAlarmClock para alarmas inactivas. + expect(android.programadas.last.activa, isFalse); + }); + + test( + 'finalizarEjecucion limpia snooze y reprograma Android sin snooze', + () async { + final ahora = DateTime(2026, 6, 11, 7, 36); + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => ahora), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + await estado.guardarAlarma( + AlarmaMusical( + id: 'fin1', + nombre: 'Diaria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + proximaEjecucion: DateTime(2026, 6, 11, 7, 30), + snoozeHasta: DateTime(2026, 6, 11, 7, 40), + snoozeOrigen: DateTime(2026, 6, 11, 7, 30), + ), + ); + + await estado.finalizarEjecucion('fin1'); + + expect(estado.alarmas.single.snoozeHasta, isNull); + expect(android.ocultadas, contains('fin1')); + expect(android.programadas.last.snoozeHasta, isNull); + }, + ); +} diff --git a/test/estado/estado_alarmas_test.dart b/test/estado/estado_alarmas_test.dart index 8a338c3..0b24538 100644 --- a/test/estado/estado_alarmas_test.dart +++ b/test/estado/estado_alarmas_test.dart @@ -1,5 +1,3 @@ -import 'dart:async'; - import 'package:flutter_test/flutter_test.dart'; import 'package:pluriwave/estado/estado_alarmas.dart'; import 'package:pluriwave/modelos/alarma_musical.dart'; @@ -7,6 +5,8 @@ import 'package:pluriwave/servicios/servicio_alarmas.dart'; import 'package:pluriwave/servicios/servicio_alarmas_android.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../helpers/fakes_alarmas.dart'; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -38,57 +38,67 @@ void main() { final alarma = estado.alarmas.single; await estado.posponerAlarma(alarma, 5); - expect(estado.alarmas.single.snoozeHasta, DateTime(2026, 5, 25, 7, 36)); - expect(android.programadas.last.proximaProgramable, DateTime(2026, 5, 25, 7, 36)); + // Ancla unificada (S2-R6): proximaEjecucion (7:31:02, normalizada por + // inminencia) + 5 minutos — ya no "ahora + 5". + expect(estado.alarmas.single.snoozeHasta, DateTime(2026, 5, 25, 7, 36, 2)); + expect( + android.programadas.last.proximaProgramable, + DateTime(2026, 5, 25, 7, 36, 2), + ); ahora = DateTime(2026, 5, 25, 7, 32); await estado.refrescarProgramacion(); - expect(estado.alarmas.single.snoozeHasta, DateTime(2026, 5, 25, 7, 36)); - expect(android.programadas.last.proximaProgramable, DateTime(2026, 5, 25, 7, 36)); + expect(estado.alarmas.single.snoozeHasta, DateTime(2026, 5, 25, 7, 36, 2)); + expect( + android.programadas.last.proximaProgramable, + DateTime(2026, 5, 25, 7, 36, 2), + ); }); - test('posponer desde preaviso mueve esta ejecucion desde la hora original', () async { - final android = FakePuertoAlarmasAndroid(); - final estado = EstadoAlarmas( - servicio: ServicioAlarmas( - reloj: () => DateTime(2026, 5, 25, 7), - ), - android: android, - iniciarAutomaticamente: false, - ); - addTearDown(estado.dispose); - addTearDown(android.dispose); - await estado.guardarAlarma( - AlarmaMusical( - id: 'pre1', - nombre: 'Preaviso', - hora: 7, - minuto: 30, - tipoProgramacion: TipoProgramacionAlarma.diaria, - diasSemana: const [], - proximaEjecucion: DateTime(2026, 5, 25, 7, 30), - ), - ); + test( + 'posponer desde preaviso mueve esta ejecucion desde la hora original', + () async { + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + await estado.guardarAlarma( + AlarmaMusical( + id: 'pre1', + nombre: 'Preaviso', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + proximaEjecucion: DateTime(2026, 5, 25, 7, 30), + ), + ); - final alarma = estado.alarmas.single; - await estado.posponerProximaDesdePreaviso( - alarma, - 10, - DateTime(2026, 5, 25, 7, 30), - ); + final alarma = estado.alarmas.single; + await estado.posponerProximaDesdePreaviso( + alarma, + 10, + DateTime(2026, 5, 25, 7, 30), + ); - expect(estado.alarmas.single.snoozeHasta, DateTime(2026, 5, 25, 7, 40)); - expect(estado.alarmas.single.snoozeOrigen, DateTime(2026, 5, 25, 7, 30)); - expect(android.programadas.last.proximaProgramable, DateTime(2026, 5, 25, 7, 40)); - }); + expect(estado.alarmas.single.snoozeHasta, DateTime(2026, 5, 25, 7, 40)); + expect(estado.alarmas.single.snoozeOrigen, DateTime(2026, 5, 25, 7, 30)); + expect( + android.programadas.last.proximaProgramable, + DateTime(2026, 5, 25, 7, 40), + ); + }, + ); test('finalizar diaria calcula siguiente dia y limpia snooze', () async { final android = FakePuertoAlarmasAndroid(); final estado = EstadoAlarmas( - servicio: ServicioAlarmas( - reloj: () => DateTime(2026, 5, 25, 7, 31), - ), + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), android: android, iniciarAutomaticamente: false, ); @@ -111,15 +121,16 @@ void main() { await estado.finalizarEjecucion('a2'); expect(estado.alarmas.single.snoozeHasta, isNull); - expect(estado.alarmas.single.proximaEjecucion, DateTime(2026, 5, 26, 7, 30)); + expect( + estado.alarmas.single.proximaEjecucion, + DateTime(2026, 5, 26, 7, 30), + ); }); test('finalizar unica la desactiva y queda sin proxima ejecucion', () async { final android = FakePuertoAlarmasAndroid(); final estado = EstadoAlarmas( - servicio: ServicioAlarmas( - reloj: () => DateTime(2026, 5, 25, 7, 31), - ), + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7, 31)), android: android, iniciarAutomaticamente: false, ); @@ -144,16 +155,82 @@ void main() { expect(estado.alarmas.single.proximaEjecucion, isNull); }); + test( + 'solicita exencion de bateria una sola vez cuando no esta exenta', + () async { + final android = + FakePuertoAlarmasAndroid()..ignoraOptimizacionBateria = false; + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + + await estado.guardarAlarma( + const AlarmaMusical( + id: 'bat1', + nombre: 'Bateria', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + + expect(android.solicitudesExencionBateria, 1); + + await estado.guardarAlarma( + const AlarmaMusical( + id: 'bat2', + nombre: 'Bateria 2', + hora: 8, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + + expect(android.solicitudesExencionBateria, 1); + }, + ); + + test('no solicita exencion de bateria cuando ya esta exenta', () async { + final android = FakePuertoAlarmasAndroid(); + final estado = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 5, 25, 7)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estado.dispose); + addTearDown(android.dispose); + + await estado.guardarAlarma( + const AlarmaMusical( + id: 'bat3', + nombre: 'Exenta', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + + expect(android.solicitudesExencionBateria, 0); + }); + test( 'inicializar sincroniza ejecucion nativa y evita reprogramar al instante', () async { - final android = FakePuertoAlarmasAndroid() - ..ejecucionesNativas.add( - EjecucionAlarmaNativa( - alarmaId: 'native1', - gestionadaEn: DateTime(2026, 5, 25, 7, 30), - ), - ); + final android = + FakePuertoAlarmasAndroid() + ..ejecucionesNativas.add( + EjecucionAlarmaNativa( + alarmaId: 'native1', + gestionadaEn: DateTime(2026, 5, 25, 7, 30), + ), + ); final servicio = ServicioAlarmas( reloj: () => DateTime(2026, 5, 25, 7, 30, 20), ); @@ -194,70 +271,3 @@ void main() { }, ); } - -class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid { - final programadas = []; - final canceladas = []; - final detenidas = []; - final ocultadas = []; - final ejecucionesNativas = []; - final _eventos = StreamController.broadcast(); - - @override - Stream get eventosAlarma => _eventos.stream; - - @override - Future programar(AlarmaMusical alarma) async { - programadas.add(alarma); - } - - @override - Future cancelar(String alarmaId) async { - canceladas.add(alarmaId); - } - - @override - Future detenerSonidoNativo(String alarmaId) async { - detenidas.add(alarmaId); - } - - @override - Future ocultarNotificacionAlarma(String alarmaId) async { - ocultadas.add(alarmaId); - } - - @override - Future confirmarAudioFlutter(String alarmaId) async { - detenidas.add(alarmaId); - } - - @override - Future diagnostico() async => - const DiagnosticoAlarmasAndroid( - puedeProgramarExactas: true, - notificacionesPermitidas: true, - puedeUsarPantallaCompleta: true, - ignoraOptimizacionBateria: true, - alarmasNativasPendientes: 0, - fabricante: 'test', - versionSdk: 35, - ); - - @override - Future obtenerEventoInicial() async => null; - - @override - Future> - obtenerEjecucionesNativasGestionadas() async => ejecucionesNativas; - - @override - Future solicitarPermisoAlarmasExactas() async => true; - - @override - Future solicitarPermisoNotificaciones() async => true; - - @override - Future solicitarPermisoPantallaCompleta() async => true; - - Future dispose() => _eventos.close(); -} diff --git a/test/helpers/fakes.dart b/test/helpers/fakes.dart index f2fd685..eff40d8 100644 --- a/test/helpers/fakes.dart +++ b/test/helpers/fakes.dart @@ -18,6 +18,8 @@ class FakeServicioAudio extends ServicioAudio { final List presetsAplicados = []; final List emisorasReproducidas = []; final List cambiosEcualizadorActivo = []; + final List volumenesAplicados = []; + int pausas = 0; Emisora? _emisoraActual; EstadoReproduccion _estadoActual = EstadoReproduccion.detenido; @@ -46,6 +48,17 @@ class FakeServicioAudio extends ServicioAudio { emitirEstado(EstadoReproduccion.detenido); } + @override + Future pausar() async { + pausas++; + emitirEstado(EstadoReproduccion.pausado); + } + + @override + Future setVolumen(double vol) async { + volumenesAplicados.add(vol); + } + void emitirEstado(EstadoReproduccion estado) { _estadoActual = estado; _estadoController.add(estado); @@ -116,7 +129,8 @@ class FakeServicioFavoritos extends ServicioFavoritos { } @override - Future> obtenerGrupos() async => List.unmodifiable(_grupos); + Future> obtenerGrupos() async => + List.unmodifiable(_grupos); @override Future crearGrupo(String nombre) async { @@ -151,9 +165,10 @@ class FakeServicioFavoritos extends ServicioFavoritos { @override Future asignarGrupo(String uuid, String grupoId) async { - final destino = _grupos.any((g) => g.id == grupoId) - ? grupoId - : GrupoFavoritos.sinAsignarId; + final destino = + _grupos.any((g) => g.id == grupoId) + ? grupoId + : GrupoFavoritos.sinAsignarId; final index = _favoritos.indexWhere((e) => e.uuid == uuid); if (index != -1) { _favoritos[index] = _favoritos[index].copyWith(grupoFavoritosId: destino); diff --git a/test/helpers/fakes_alarmas.dart b/test/helpers/fakes_alarmas.dart new file mode 100644 index 0000000..514f82c --- /dev/null +++ b/test/helpers/fakes_alarmas.dart @@ -0,0 +1,106 @@ +import 'dart:async'; + +import 'package:pluriwave/modelos/alarma_musical.dart'; +import 'package:pluriwave/servicios/servicio_alarmas_android.dart'; +import 'package:pluriwave/servicios/servicio_grabacion_radio.dart'; + +/// Shared fake of the Android alarm bridge for alarm-related tests. +class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid { + final programadas = []; + final canceladas = []; + final detenidas = []; + final ocultadas = []; + final ejecucionesNativas = []; + final snoozesNativos = []; + final _eventos = StreamController.broadcast(); + bool ignoraOptimizacionBateria = true; + int solicitudesExencionBateria = 0; + + /// Simulates a native -> Flutter `alarmFired` MethodChannel event. + void emitirEvento(EventoAlarmaAndroid evento) => _eventos.add(evento); + + @override + Stream get eventosAlarma => _eventos.stream; + + @override + Future programar(AlarmaMusical alarma) async { + programadas.add(alarma); + } + + @override + Future cancelar(String alarmaId) async { + canceladas.add(alarmaId); + } + + @override + Future detenerSonidoNativo(String alarmaId) async { + detenidas.add(alarmaId); + } + + @override + Future ocultarNotificacionAlarma(String alarmaId) async { + ocultadas.add(alarmaId); + } + + @override + Future confirmarAudioFlutter(String alarmaId) async { + detenidas.add(alarmaId); + } + + @override + Future diagnostico() async => + DiagnosticoAlarmasAndroid( + puedeProgramarExactas: true, + notificacionesPermitidas: true, + puedeUsarPantallaCompleta: true, + ignoraOptimizacionBateria: ignoraOptimizacionBateria, + alarmasNativasPendientes: 0, + fabricante: 'test', + versionSdk: 35, + ); + + @override + Future solicitarExencionBateria() async { + solicitudesExencionBateria++; + return true; + } + + @override + Future obtenerEventoInicial() async => null; + + @override + Future> + obtenerEjecucionesNativasGestionadas() async => ejecucionesNativas; + + @override + Future> obtenerEstadoSnoozeNativo() async => + List.of(snoozesNativos); + + @override + Future solicitarPermisoAlarmasExactas() async => true; + + @override + Future solicitarPermisoNotificaciones() async => true; + + @override + Future solicitarPermisoPantallaCompleta() async => true; + + Future dispose() => _eventos.close(); +} + +/// Inactive recording service fake, safe for widget tests. +class FakeServicioGrabacionRadioInactiva extends ServicioGrabacionRadio { + final _controller = StreamController.broadcast(); + + @override + EstadoGrabacionRadio get estado => const EstadoGrabacionRadio.inactiva(); + + @override + Stream get estadoStream => _controller.stream; + + @override + Future inicializar() async {} + + @override + Future dispose() => _controller.close(); +} diff --git a/test/pantallas/pantalla_alarma_sonando_scaffold_test.dart b/test/pantallas/pantalla_alarma_sonando_scaffold_test.dart new file mode 100644 index 0000000..452d3bf --- /dev/null +++ b/test/pantallas/pantalla_alarma_sonando_scaffold_test.dart @@ -0,0 +1,147 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_animate/flutter_animate.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_alarmas.dart'; +import 'package:pluriwave/estado/estado_radio.dart'; +import 'package:pluriwave/l10n/gen/app_localizations.dart'; +import 'package:pluriwave/modelos/alarma_musical.dart'; +import 'package:pluriwave/modelos/emisora.dart'; +import 'package:pluriwave/pantallas/pantalla_alarma_sonando.dart'; +import 'package:pluriwave/servicios/servicio_alarmas.dart'; +import 'package:pluriwave/servicios/servicio_audio.dart'; +import 'package:pluriwave/widgets/pluri_wave_scaffold.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/fakes.dart'; +import '../helpers/fakes_alarmas.dart'; + +Future _montarPantalla( + WidgetTester tester, { + bool disableAnimations = false, +}) async { + tester.view.physicalSize = const Size(1440, 3200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final audio = FakeServicioAudio(); + audio.emitirEstado(EstadoReproduccion.reproduciendo); + final radio = EstadoRadio( + audio: audio, + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + servicioGrabacion: FakeServicioGrabacionRadioInactiva(), + iniciarAutomaticamente: false, + ); + addTearDown(radio.dispose); + + final android = FakePuertoAlarmasAndroid(); + final estadoAlarmas = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 11, 7, 0)), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estadoAlarmas.dispose); + addTearDown(android.dispose); + await estadoAlarmas.guardarAlarma( + const AlarmaMusical( + id: 'scaffold1', + nombre: 'Despertar', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + emisora: Emisora( + uuid: 'e1', + nombre: 'Radio Uno', + url: 'https://radio.example/stream', + ), + ), + ); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: radio), + ChangeNotifierProvider.value(value: estadoAlarmas), + ], + child: MaterialApp( + locale: const Locale('es'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + builder: + (context, child) => MediaQuery( + data: MediaQuery.of( + context, + ).copyWith(disableAnimations: disableAnimations), + child: child!, + ), + home: const SizedBox.shrink(), + ), + ), + ); + final navigator = tester.state(find.byType(Navigator)); + unawaited( + navigator.push( + MaterialPageRoute( + builder: + (_) => PantallaAlarmaSonando( + alarma: estadoAlarmas.alarmas.single, + audioPrearrancado: true, + ), + fullscreenDialog: true, + ), + ), + ); + await tester.pumpAndSettle(); +} + +void main() { + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + testWidgets( + 'usa PluriWaveScaffold sin colores hardcodeados y anima la entrada ' + '(S2-R7)', + (tester) async { + await _montarPantalla(tester); + + expect(find.byType(PluriWaveScaffold), findsOneWidget); + for (final scaffold in tester.widgetList( + find.byType(Scaffold), + )) { + expect( + scaffold.backgroundColor, + isNot(const Color(0xFF061722)), + reason: 'el Scaffold crudo con color hardcodeado debe desaparecer', + ); + } + expect( + find.byType(Animate), + findsWidgets, + reason: 'la entrada debe animarse cuando las animaciones estan activas', + ); + }, + ); + + testWidgets( + 'omite la animacion de entrada con disableAnimations=true (S5-R3)', + (tester) async { + await _montarPantalla(tester, disableAnimations: true); + + expect(find.byType(PluriWaveScaffold), findsOneWidget); + expect( + find.byType(Animate), + findsNothing, + reason: 'reduced motion debe omitir la animacion de entrada', + ); + final l10n = lookupAppLocalizations(const Locale('es')); + expect(find.text(l10n.stopAlarmAction), findsOneWidget); + }, + ); +} diff --git a/test/pantallas/pantalla_alarma_sonando_test.dart b/test/pantallas/pantalla_alarma_sonando_test.dart new file mode 100644 index 0000000..3b0b73d --- /dev/null +++ b/test/pantallas/pantalla_alarma_sonando_test.dart @@ -0,0 +1,161 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_alarmas.dart'; +import 'package:pluriwave/estado/estado_radio.dart'; +import 'package:pluriwave/l10n/gen/app_localizations.dart'; +import 'package:pluriwave/modelos/alarma_musical.dart'; +import 'package:pluriwave/modelos/emisora.dart'; +import 'package:pluriwave/pantallas/pantalla_alarma_sonando.dart'; +import 'package:pluriwave/servicios/servicio_alarmas.dart'; +import 'package:pluriwave/servicios/servicio_audio.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/fakes.dart'; +import '../helpers/fakes_alarmas.dart'; + +class _Entorno { + _Entorno({ + required this.estadoAlarmas, + required this.android, + required this.audio, + }); + + final EstadoAlarmas estadoAlarmas; + final FakePuertoAlarmasAndroid android; + final FakeServicioAudio audio; +} + +Future<_Entorno> _montarPantalla( + WidgetTester tester, { + int snoozeMinutos = 5, +}) async { + tester.view.physicalSize = const Size(1440, 3200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final audio = FakeServicioAudio(); + audio.emitirEstado(EstadoReproduccion.reproduciendo); + final radio = EstadoRadio( + audio: audio, + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + servicioGrabacion: FakeServicioGrabacionRadioInactiva(), + iniciarAutomaticamente: false, + ); + addTearDown(radio.dispose); + + final android = FakePuertoAlarmasAndroid(); + final ahora = DateTime(2026, 6, 11, 7, 0); + final estadoAlarmas = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => ahora), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estadoAlarmas.dispose); + addTearDown(android.dispose); + await estadoAlarmas.guardarAlarma( + AlarmaMusical( + id: 'ring1', + nombre: 'Despertar', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + snoozeMinutos: snoozeMinutos, + emisora: const Emisora( + uuid: 'e1', + nombre: 'Radio Uno', + url: 'https://radio.example/stream', + ), + ), + ); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: radio), + ChangeNotifierProvider.value(value: estadoAlarmas), + ], + child: MaterialApp( + locale: const Locale('es'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const SizedBox.shrink(), + ), + ), + ); + final navigator = tester.state(find.byType(Navigator)); + unawaited( + navigator.push( + MaterialPageRoute( + builder: + (_) => PantallaAlarmaSonando( + alarma: estadoAlarmas.alarmas.single, + audioPrearrancado: true, + ), + fullscreenDialog: true, + ), + ), + ); + await tester.pumpAndSettle(); + return _Entorno(estadoAlarmas: estadoAlarmas, android: android, audio: audio); +} + +void main() { + final l10n = lookupAppLocalizations(const Locale('es')); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + testWidgets( + 'muestra botones de posponer 3/5/10 mas el personalizado (S2-R1-A/C)', + (tester) async { + await _montarPantalla(tester, snoozeMinutos: 7); + + expect(find.text(l10n.alarmSnoozeOptionLabel(3)), findsOneWidget); + expect(find.text(l10n.alarmSnoozeOptionLabel(5)), findsOneWidget); + expect(find.text(l10n.alarmSnoozeOptionLabel(7)), findsOneWidget); + expect(find.text(l10n.alarmSnoozeOptionLabel(10)), findsOneWidget); + expect(find.text(l10n.stopAlarmAction), findsOneWidget); + }, + ); + + testWidgets( + 'no duplica el boton cuando snoozeMinutos coincide con una opcion fija', + (tester) async { + await _montarPantalla(tester, snoozeMinutos: 5); + + expect(find.text(l10n.alarmSnoozeOptionLabel(3)), findsOneWidget); + expect(find.text(l10n.alarmSnoozeOptionLabel(5)), findsOneWidget); + expect(find.text(l10n.alarmSnoozeOptionLabel(10)), findsOneWidget); + }, + ); + + testWidgets( + 'posponer 5 min detiene el audio local, pospone y cierra (S2-R1-B)', + (tester) async { + final entorno = await _montarPantalla(tester, snoozeMinutos: 5); + + await tester.tap(find.text(l10n.alarmSnoozeOptionLabel(5))); + await tester.pumpAndSettle(); + + final alarma = entorno.estadoAlarmas.alarmas.single; + expect(alarma.snoozeHasta, DateTime(2026, 6, 11, 7, 35)); + expect(entorno.audio.pausas, greaterThanOrEqualTo(1)); + expect(find.byType(PantallaAlarmaSonando), findsNothing); + // posponerAlarma oculta la notificacion nativa (mismo stop path que + // el boton de detener) y reprograma con el snooze. + expect(entorno.android.ocultadas, contains('ring1')); + expect( + entorno.android.programadas.last.snoozeHasta, + DateTime(2026, 6, 11, 7, 35), + ); + }, + ); +} diff --git a/test/pantallas/pantalla_alarmas_editor_test.dart b/test/pantallas/pantalla_alarmas_editor_test.dart new file mode 100644 index 0000000..4f617d5 --- /dev/null +++ b/test/pantallas/pantalla_alarmas_editor_test.dart @@ -0,0 +1,210 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/estado/estado_alarmas.dart'; +import 'package:pluriwave/estado/estado_radio.dart'; +import 'package:pluriwave/l10n/gen/app_localizations.dart'; +import 'package:pluriwave/modelos/alarma_musical.dart'; +import 'package:pluriwave/pantallas/pantalla_alarmas.dart'; +import 'package:pluriwave/servicios/servicio_alarmas.dart'; +import 'package:provider/provider.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../helpers/fakes.dart'; +import '../helpers/fakes_alarmas.dart'; + +class _Entorno { + _Entorno({required this.estadoAlarmas}); + + final EstadoAlarmas estadoAlarmas; +} + +Future<_Entorno> _abrirEditor(WidgetTester tester) async { + tester.view.physicalSize = const Size(1440, 3200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final favoritos = FakeServicioFavoritos(); + await favoritos.agregar(emisoraDemo(uuid: 'alfa', nombre: 'Alfa FM')); + await favoritos.agregar(emisoraDemo(uuid: 'beta', nombre: 'Beta FM')); + final radio = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: favoritos, + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + servicioGrabacion: FakeServicioGrabacionRadioInactiva(), + iniciarAutomaticamente: false, + ); + addTearDown(radio.dispose); + await radio.cargarFavoritos(); + + final android = FakePuertoAlarmasAndroid(); + final estadoAlarmas = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: DateTime.now), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estadoAlarmas.dispose); + addTearDown(android.dispose); + await estadoAlarmas.guardarAlarma( + const AlarmaMusical( + id: 'ed1', + nombre: 'Semanal', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diasSemana, + diasSemana: [DateTime.monday], + ), + ); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: radio), + ChangeNotifierProvider.value(value: estadoAlarmas), + ], + child: MaterialApp( + locale: const Locale('es'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: PantallaAlarmas()), + ), + ), + ); + await tester.pumpAndSettle(); + + final l10n = lookupAppLocalizations(const Locale('es')); + await tester.ensureVisible(find.text(l10n.editAction).first); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.editAction).first); + await tester.pumpAndSettle(); + return _Entorno(estadoAlarmas: estadoAlarmas); +} + +String _textoPreview(WidgetTester tester) { + final texto = tester.widget( + find.descendant( + of: find.byKey(const ValueKey('next-trigger-preview')), + matching: find.byType(Text), + ), + ); + return texto.data ?? ''; +} + +void main() { + final l10n = lookupAppLocalizations(const Locale('es')); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + testWidgets( + 'muestra la proxima ejecucion y la actualiza al cambiar la recurrencia ' + '(S2-R8)', + (tester) async { + await _abrirEditor(tester); + + expect( + find.byKey(const ValueKey('next-trigger-preview')), + findsOneWidget, + ); + final antes = _textoPreview(tester); + expect(antes, isNot(l10n.alarmNoNextExecution)); + + // Lunes -> Martes: la fecha calculada SIEMPRE cambia, sea cual sea hoy. + await tester.tap(find.text(l10n.weekdayShortTuesday)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.weekdayShortMonday)); + await tester.pumpAndSettle(); + + final despues = _textoPreview(tester); + expect(despues, isNot(l10n.alarmNoNextExecution)); + expect(despues, isNot(antes)); + }, + ); + + testWidgets( + 'el selector de emisora abre un bottom sheet con buscador (S2-R9)', + (tester) async { + await _abrirEditor(tester); + + await tester.ensureVisible( + find.byKey(const ValueKey('alarm-station-field')), + ); + await tester.pumpAndSettle(); + await tester.tap(find.byKey(const ValueKey('alarm-station-field'))); + await tester.pumpAndSettle(); + + expect(find.byType(SearchBar), findsOneWidget); + final lista = find.byType(ListView).last; + expect( + find.descendant(of: lista, matching: find.text('Alfa FM')), + findsOneWidget, + ); + expect( + find.descendant(of: lista, matching: find.text('Beta FM')), + findsOneWidget, + ); + + await tester.enterText(find.byType(TextField).last, 'beta'); + await tester.pumpAndSettle(); + + expect( + find.descendant(of: lista, matching: find.text('Alfa FM')), + findsNothing, + ); + expect( + find.descendant(of: lista, matching: find.text('Beta FM')), + findsOneWidget, + ); + }, + ); + + testWidgets( + 'tambien existe un selector para la emisora de respaldo (S2-R9)', + (tester) async { + await _abrirEditor(tester); + + await tester.ensureVisible( + find.byKey(const ValueKey('alarm-fallback-station-field')), + ); + await tester.pumpAndSettle(); + await tester.tap( + find.byKey(const ValueKey('alarm-fallback-station-field')), + ); + await tester.pumpAndSettle(); + + expect(find.byType(SearchBar), findsOneWidget); + }, + ); + + testWidgets('permite configurar la duracion del snooze (S2-R10)', ( + tester, + ) async { + final entorno = await _abrirEditor(tester); + + await tester.ensureVisible(find.text(l10n.alarmSnoozeDurationTitle)); + await tester.pumpAndSettle(); + expect(find.byType(SegmentedButton), findsWidgets); + + await tester.tap(find.text(l10n.alarmSnoozeOptionLabel(10))); + await tester.pumpAndSettle(); + + await tester.ensureVisible(find.text(l10n.saveAlarmAction)); + await tester.pumpAndSettle(); + await tester.tap(find.text(l10n.saveAlarmAction)); + await tester.pumpAndSettle(); + + expect(entorno.estadoAlarmas.alarmas.single.snoozeMinutos, 10); + }); + + testWidgets('el slider de volumen permite bajar hasta 0.0 (S2-R11)', ( + tester, + ) async { + await _abrirEditor(tester); + + final sliders = tester.widgetList(find.byType(Slider)); + final volumen = sliders.firstWhere((slider) => slider.max == 1.0); + expect(volumen.min, 0.0); + }); +} diff --git a/test/servicios/servicio_alarmas_android_test.dart b/test/servicios/servicio_alarmas_android_test.dart new file mode 100644 index 0000000..8041d35 --- /dev/null +++ b/test/servicios/servicio_alarmas_android_test.dart @@ -0,0 +1,109 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/modelos/alarma_musical.dart'; +import 'package:pluriwave/modelos/emisora.dart'; +import 'package:pluriwave/servicios/servicio_alarmas_android.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + const channel = MethodChannel('pluriwave/alarm_scheduler'); + late List llamadas; + + setUp(() { + llamadas = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + llamadas.add(call); + switch (call.method) { + case 'scheduleAlarm': + return true; + case 'requestIgnoreBatteryOptimizations': + return true; + } + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test( + 'programar incluye emisora de respaldo y fade en el payload nativo', + () async { + final servicio = ServicioAlarmasAndroid(channel: channel); + final alarma = AlarmaMusical( + id: 'a1', + nombre: 'Con respaldo', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + proximaEjecucion: DateTime(2099, 1, 1, 7, 30), + emisora: const Emisora( + uuid: 'uuid-principal', + nombre: 'Principal FM', + url: 'https://principal.example/stream', + ), + emisoraFallback: const Emisora( + uuid: 'uuid-respaldo', + nombre: 'Respaldo FM', + url: 'https://respaldo.example/stream', + ), + fadeInSegundos: 12, + ); + + await servicio.programar(alarma); + + final llamada = llamadas.singleWhere((c) => c.method == 'scheduleAlarm'); + final args = llamada.arguments as Map; + expect(args['fallbackStationName'], 'Respaldo FM'); + expect(args['fallbackStationUrl'], 'https://respaldo.example/stream'); + expect(args['fadeInSegundos'], 12); + expect(args['fallbackSound'], SonidoInternoAlarma.amanecer.name); + }, + ); + + test( + 'programar sin emisora de respaldo envia campos de respaldo nulos', + () async { + final servicio = ServicioAlarmasAndroid(channel: channel); + final alarma = AlarmaMusical( + id: 'a2', + nombre: 'Sin respaldo', + hora: 8, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + proximaEjecucion: DateTime(2099, 1, 1, 8, 0), + ); + + await servicio.programar(alarma); + + final llamada = llamadas.singleWhere((c) => c.method == 'scheduleAlarm'); + final args = llamada.arguments as Map; + expect(args.containsKey('fallbackStationName'), isTrue); + expect(args['fallbackStationName'], isNull); + expect(args.containsKey('fallbackStationUrl'), isTrue); + expect(args['fallbackStationUrl'], isNull); + expect(args['fadeInSegundos'], 0); + }, + ); + + test( + 'solicitarExencionBateria invoca requestIgnoreBatteryOptimizations', + () async { + final servicio = ServicioAlarmasAndroid(channel: channel); + + final abierto = await servicio.solicitarExencionBateria(); + + expect(abierto, isTrue); + expect( + llamadas.map((c) => c.method), + contains('requestIgnoreBatteryOptimizations'), + ); + }, + ); +} diff --git a/test/servicios/servicio_alarmas_snooze_test.dart b/test/servicios/servicio_alarmas_snooze_test.dart new file mode 100644 index 0000000..e487ee8 --- /dev/null +++ b/test/servicios/servicio_alarmas_snooze_test.dart @@ -0,0 +1,155 @@ +import 'package:flutter/services.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/modelos/alarma_musical.dart'; +import 'package:pluriwave/servicios/servicio_alarmas.dart'; +import 'package:pluriwave/servicios/servicio_alarmas_android.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + group('ServicioAlarmas.posponerEjecucion (ancla unificada)', () { + Future servicioConAlarma(DateTime ahora) async { + final servicio = ServicioAlarmas(reloj: () => ahora); + await servicio.guardarAlarma( + const AlarmaMusical( + id: 'p1', + nombre: 'Snooze', + hora: 8, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: [], + ), + ); + return servicio; + } + + test('ancla en ejecucion + minutos cuando el objetivo es futuro', () async { + final servicio = await servicioConAlarma(DateTime(2026, 6, 11, 8, 0)); + + final config = await servicio.posponerEjecucion( + 'p1', + DateTime(2026, 6, 11, 8, 30), + 10, + ); + + final alarma = config.alarmas.single; + expect(alarma.snoozeHasta, DateTime(2026, 6, 11, 8, 40)); + expect(alarma.snoozeOrigen, DateTime(2026, 6, 11, 8, 30)); + + final recargada = await servicio.cargar(); + expect( + recargada.alarmas.single.snoozeHasta, + DateTime(2026, 6, 11, 8, 40), + reason: 'el snooze debe quedar persistido', + ); + }); + + test('clava a ahora + minutos cuando el objetivo ya paso', () async { + final servicio = await servicioConAlarma(DateTime(2026, 6, 11, 9, 0)); + + final config = await servicio.posponerEjecucion( + 'p1', + DateTime(2026, 6, 11, 8, 30), + 5, + ); + + expect(config.alarmas.single.snoozeHasta, DateTime(2026, 6, 11, 9, 5)); + }); + + test('respeta minutos personalizados fuera de 3/5/10', () async { + final servicio = await servicioConAlarma(DateTime(2026, 6, 11, 8, 0)); + + final config = await servicio.posponerEjecucion( + 'p1', + DateTime(2026, 6, 11, 8, 30), + 7, + ); + + expect(config.alarmas.single.snoozeHasta, DateTime(2026, 6, 11, 8, 37)); + }); + }); + + group('Puente Android (MethodChannel)', () { + const channel = MethodChannel('pluriwave/alarm_scheduler'); + late List llamadas; + late List> snoozesNativos; + + setUp(() { + llamadas = []; + snoozesNativos = []; + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, (call) async { + llamadas.add(call); + switch (call.method) { + case 'scheduleAlarm': + return true; + case 'getNativeSnoozeState': + return snoozesNativos; + } + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test('programar incluye snoozeUntilMillis y snoozeOriginMillis', () async { + final servicio = ServicioAlarmasAndroid(channel: channel); + final snoozeHasta = DateTime(2099, 1, 1, 7, 35); + final snoozeOrigen = DateTime(2099, 1, 1, 7, 30); + final alarma = AlarmaMusical( + id: 'pay1', + nombre: 'Con snooze', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + proximaEjecucion: DateTime(2099, 1, 2, 7, 30), + snoozeHasta: snoozeHasta, + snoozeOrigen: snoozeOrigen, + ); + + await servicio.programar(alarma); + + final llamada = llamadas.singleWhere((c) => c.method == 'scheduleAlarm'); + final args = llamada.arguments as Map; + expect(args['snoozeUntilMillis'], snoozeHasta.millisecondsSinceEpoch); + expect(args['snoozeOriginMillis'], snoozeOrigen.millisecondsSinceEpoch); + }); + + test( + 'obtenerEstadoSnoozeNativo invoca getNativeSnoozeState y parsea', + () async { + final servicio = ServicioAlarmasAndroid(channel: channel); + final hasta = DateTime(2026, 6, 11, 7, 40); + final origen = DateTime(2026, 6, 11, 7, 30); + snoozesNativos.add({ + 'alarmId': 'nat1', + 'snoozeUntilMillis': hasta.millisecondsSinceEpoch, + 'snoozeOriginMillis': origen.millisecondsSinceEpoch, + }); + + final estados = await servicio.obtenerEstadoSnoozeNativo(); + + expect(llamadas.map((c) => c.method), contains('getNativeSnoozeState')); + expect(estados, hasLength(1)); + expect(estados.single.alarmaId, 'nat1'); + expect(estados.single.snoozeHasta, hasta); + expect(estados.single.snoozeOrigen, origen); + }, + ); + + test('obtenerEstadoSnoozeNativo tolera lista vacia o nula', () async { + final servicio = ServicioAlarmasAndroid(channel: channel); + + expect(await servicio.obtenerEstadoSnoozeNativo(), isEmpty); + }); + }); +}