From 4ffd73d136efba8450f91758069d9442fe960fd2 Mon Sep 17 00:00:00 2001 From: freetlab Date: Sun, 28 Jun 2026 11:54:36 +0200 Subject: [PATCH] fix(alarm): localize pre-notice countdown and fix snooze dismiss Replace hardcoded Spanish pre-notice text with computed remaining minutes using l10n template passed via MethodChannel. Fix snooze dismiss in dead-app state with canPop guard and SystemNavigator.pop fallback. --- .../freetimelab/pluriwave/AlarmScheduler.kt | 20 +- .../es/freetimelab/pluriwave/MainActivity.kt | 3 +- .../pluriwave/PluriWaveAlarmReceiver.kt | 32 +- lib/pantallas/pantalla_alarma_sonando.dart | 22 +- lib/servicios/servicio_alarmas_android.dart | 14 + test/l10n/pre_notice_countdown_test.dart | 74 +++++ ...lla_alarma_sonando_dismiss_guard_test.dart | 279 ++++++++++++++++++ ...icio_alarmas_pre_notice_template_test.dart | 79 +++++ 8 files changed, 510 insertions(+), 13 deletions(-) create mode 100644 test/l10n/pre_notice_countdown_test.dart create mode 100644 test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart create mode 100644 test/servicios/servicio_alarmas_pre_notice_template_test.dart 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 a0fbf62..fbce4ea 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/AlarmScheduler.kt @@ -39,7 +39,8 @@ class AlarmScheduler(private val context: Context) { snoozeMinutes: Int = 5, fallbackStationName: String? = null, fallbackStationUrl: String? = null, - fadeInSegundos: Int = 0 + fadeInSegundos: Int = 0, + preNoticeTemplate: String? = null ): Boolean { val existing = readSpec(id) val preservedSnooze = preserveNativeSnooze( @@ -70,7 +71,8 @@ class AlarmScheduler(private val context: Context) { fallbackSound = fallbackSound, volume = volume.coerceIn(0f, 1f), fadeInSegundos = fadeInSegundos.coerceIn(0, 60), - timezoneId = TimeZone.getDefault().id + timezoneId = TimeZone.getDefault().id, + preNoticeTemplate = preNoticeTemplate ) return scheduleSpec(spec, persistOnSuccess = true) } @@ -145,6 +147,7 @@ class AlarmScheduler(private val context: Context) { PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT, spec.snoozeOriginMillis ?: spec.triggerAtMillis ) + putExtra(EXTRA_PRE_NOTICE_TEMPLATE, spec.preNoticeTemplate) }, PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE ) @@ -165,6 +168,7 @@ class AlarmScheduler(private val context: Context) { PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT, spec.snoozeOriginMillis ?: spec.triggerAtMillis ) + putExtra(EXTRA_PRE_NOTICE_TEMPLATE, spec.preNoticeTemplate) } ) Log.d(tag, "alarm.schedule preNotice immediate id=${spec.id}") @@ -652,7 +656,10 @@ class AlarmScheduler(private val context: Context) { val fallbackSound: String?, val volume: Float, val fadeInSegundos: Int = 0, - val timezoneId: String + val timezoneId: String, + // Nullable for backward compat: old persisted alarms without this field + // fall back to the English default in the receiver. Schema stays v3. + val preNoticeTemplate: String? = null ) { fun toJson(): JSONObject = JSONObject().apply { put("schemaVersion", 3) @@ -679,6 +686,7 @@ class AlarmScheduler(private val context: Context) { put("volume", volume) put("fadeInSegundos", fadeInSegundos) put("timezoneId", timezoneId) + put("preNoticeTemplate", preNoticeTemplate) } companion object { @@ -715,7 +723,8 @@ class AlarmScheduler(private val context: Context) { 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) + timezoneId = json.optString("timezoneId", TimeZone.getDefault().id), + preNoticeTemplate = json.optString("preNoticeTemplate").takeIf { it.isNotBlank() } ) } } @@ -730,6 +739,9 @@ class AlarmScheduler(private val context: Context) { private const val PRE_NOTICE_MILLIS = 30 * 60 * 1000L private const val SCHEDULE_UNICA = "unica" private const val SCHEDULE_DIAS_SEMANA = "diasSemana" + // Intent extra key for the localized pre-notice template string. + // Declared once here; PluriWaveAlarmReceiver reads it via this constant. + const val EXTRA_PRE_NOTICE_TEMPLATE = "preNoticeTemplate" } } 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 37ac1f1..673c437 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/MainActivity.kt @@ -115,7 +115,8 @@ class MainActivity : AudioServiceActivity() { snoozeMinutes = call.argument("snoozeMinutes") ?: 5, fallbackStationName = call.argument("fallbackStationName"), fallbackStationUrl = call.argument("fallbackStationUrl"), - fadeInSegundos = call.argument("fadeInSegundos") ?: 0 + fadeInSegundos = call.argument("fadeInSegundos") ?: 0, + preNoticeTemplate = call.argument("preNoticeTemplate") ) result.success(scheduled) } 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 b4acf3b..688d315 100644 --- a/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt +++ b/android/app/src/main/kotlin/es/freetimelab/pluriwave/PluriWaveAlarmReceiver.kt @@ -51,7 +51,8 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() { title, snoozeMinutes, intent.getLongExtra(EXTRA_TRIGGER_AT, 0L), - intent.getLongExtra(EXTRA_OCCURRENCE_AT, 0L) + intent.getLongExtra(EXTRA_OCCURRENCE_AT, 0L), + intent.getStringExtra(AlarmScheduler.EXTRA_PRE_NOTICE_TEMPLATE) ) } ACTION_POSTPONE_NEXT -> { @@ -100,10 +101,14 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() { title: String, snoozeMinutes: Int, triggerAtMillis: Long, - occurrenceAtMillis: Long + occurrenceAtMillis: Long, + preNoticeTemplate: String? = null ) { ensureChannel(context) + val remaining = computeRemainingMinutes(triggerAtMillis) + val contentText = formatPreNoticeText(preNoticeTemplate, remaining) + val openAppIntent = PendingIntent.getActivity( context, requestCode(alarmId, 1), @@ -144,7 +149,7 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() { val notification = NotificationCompat.Builder(context, CHANNEL_ID) .setSmallIcon(android.R.drawable.ic_dialog_info) .setContentTitle(title) - .setContentText("Empieza en 30 minutos") + .setContentText(contentText) .setPriority(NotificationCompat.PRIORITY_LOW) .setCategory(NotificationCompat.CATEGORY_REMINDER) .setSilent(true) @@ -156,12 +161,31 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() { try { NotificationManagerCompat.from(context).notify(notificationIdForAlarm(alarmId), notification) - Log.d(TAG, "alarm.notification preNotice shown id=$alarmId") + Log.d(TAG, "alarm.notification preNotice shown id=$alarmId remaining=$remaining") } catch (error: SecurityException) { Log.e(TAG, "alarm.notification preNotice SecurityException id=$alarmId", error) } } + /** + * Computes the number of minutes remaining until [triggerAtMillis], + * clamped to a minimum of 1. Handles Doze-delayed wakeups and clock drift. + */ + private fun computeRemainingMinutes(triggerAtMillis: Long): Long = + maxOf(1L, (triggerAtMillis - System.currentTimeMillis()) / 60_000L) + + /** + * Formats the pre-notice notification text by replacing the `{minutes}` + * placeholder in [template] with [remaining]. Falls back to an English + * default if [template] is null or blank. + */ + private fun formatPreNoticeText(template: String?, remaining: Long): String { + if (template.isNullOrBlank()) { + return "Starts in $remaining min" + } + return template.replace("{minutes}", remaining.toString()) + } + private fun ensureChannel(context: Context) { if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager diff --git a/lib/pantallas/pantalla_alarma_sonando.dart b/lib/pantallas/pantalla_alarma_sonando.dart index b23bda5..8cf1949 100644 --- a/lib/pantallas/pantalla_alarma_sonando.dart +++ b/lib/pantallas/pantalla_alarma_sonando.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; import 'package:just_audio/just_audio.dart'; import 'package:provider/provider.dart'; @@ -153,11 +154,10 @@ class _PantallaAlarmaSonandoState extends State { Future _detener() async { final radio = context.read(); final alarmas = context.read(); - final navigator = Navigator.of(context); await _liberarAudioLocal(); await radio.audio.pausar(); await alarmas.finalizarEjecucion(widget.alarma.id); - if (mounted) navigator.pop(); + if (mounted) _dismissScreen(); } /// Flutter-first snooze (S2-R1): tears down local audio, then routes @@ -166,11 +166,25 @@ class _PantallaAlarmaSonandoState extends State { 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(); + if (mounted) _dismissScreen(); + } + + /// Dismisses the alarm screen safely in both live-app and dead-app states. + /// + /// When the alarm screen is the root activity (launched via full-screen intent + /// from a dead app), [Navigator.canPop] returns false and calling + /// [Navigator.pop] would be a no-op. In that case [SystemNavigator.pop] is + /// used to call `Activity.finish()` and return to the home screen. + void _dismissScreen() { + final navigator = Navigator.of(context); + if (navigator.canPop()) { + navigator.pop(); + } else { + SystemNavigator.pop(); + } } List _opcionesSnooze() { diff --git a/lib/servicios/servicio_alarmas_android.dart b/lib/servicios/servicio_alarmas_android.dart index 50584df..a95f973 100644 --- a/lib/servicios/servicio_alarmas_android.dart +++ b/lib/servicios/servicio_alarmas_android.dart @@ -170,6 +170,19 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid { _l10n = l10n; } + /// Builds a pre-notice template string with a literal `{minutes}` placeholder + /// for Kotlin to replace at broadcast-receiver fire time. + /// + /// Strategy: call [preNoticeCountdown] with a unique sentinel integer and + /// replace the sentinel's string representation with `{minutes}`. + static String _preNoticeTemplate(AppLocalizations l10n) { + const sentinel = 42424242; + return l10n.preNoticeCountdown(sentinel).replaceFirst( + sentinel.toString(), + '{minutes}', + ); + } + @override Stream get eventosAlarma => _eventosController.stream; @@ -189,6 +202,7 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid { final programada = await _channel.invokeMethod('scheduleAlarm', { 'id': alarma.id, 'title': localizedAlarmName(_textos, alarma.nombre), + 'preNoticeTemplate': _preNoticeTemplate(_textos), 'triggerAtMillis': proxima.millisecondsSinceEpoch, 'preNoticeAtMillis': alarma.snoozeHasta == null diff --git a/test/l10n/pre_notice_countdown_test.dart b/test/l10n/pre_notice_countdown_test.dart new file mode 100644 index 0000000..694aba3 --- /dev/null +++ b/test/l10n/pre_notice_countdown_test.dart @@ -0,0 +1,74 @@ +import 'dart:ui'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/l10n/gen/app_localizations.dart'; + +void main() { + group('preNoticeCountdown ARB key', () { + test('English returns expected sentence with minutes placeholder', () { + final l10n = lookupAppLocalizations(const Locale('en')); + expect(l10n.preNoticeCountdown(30), 'Starts in 30 min'); + expect(l10n.preNoticeCountdown(1), 'Starts in 1 min'); + }); + + test('Spanish returns localized sentence', () { + final l10n = lookupAppLocalizations(const Locale('es')); + expect(l10n.preNoticeCountdown(30), 'Empieza en 30 min'); + }); + + test('Arabic returns localized sentence', () { + final l10n = lookupAppLocalizations(const Locale('ar')); + expect(l10n.preNoticeCountdown(5), 'يبدأ خلال 5 دقيقة'); + }); + + test('German returns localized sentence', () { + final l10n = lookupAppLocalizations(const Locale('de')); + expect(l10n.preNoticeCountdown(10), 'Startet in 10 Min.'); + }); + + test('French returns localized sentence', () { + final l10n = lookupAppLocalizations(const Locale('fr')); + expect(l10n.preNoticeCountdown(15), 'Démarre dans 15 min'); + }); + + test('Portuguese returns localized sentence', () { + final l10n = lookupAppLocalizations(const Locale('pt')); + expect(l10n.preNoticeCountdown(20), 'Começa em 20 min'); + }); + + test('Italian returns localized sentence', () { + final l10n = lookupAppLocalizations(const Locale('it')); + expect(l10n.preNoticeCountdown(25), 'Inizia tra 25 min'); + }); + + test('Japanese returns localized sentence', () { + final l10n = lookupAppLocalizations(const Locale('ja')); + expect(l10n.preNoticeCountdown(30), '30分後に開始'); + }); + + test('Russian returns localized sentence', () { + final l10n = lookupAppLocalizations(const Locale('ru')); + expect(l10n.preNoticeCountdown(30), 'Начнётся через 30 мин'); + }); + + test('Chinese returns localized sentence', () { + final l10n = lookupAppLocalizations(const Locale('zh')); + expect(l10n.preNoticeCountdown(30), '30分钟后开始'); + }); + + test('Hindi returns localized sentence', () { + final l10n = lookupAppLocalizations(const Locale('hi')); + expect(l10n.preNoticeCountdown(30), '30 मिनट में शुरू होगा'); + }); + + test('Bengali returns localized sentence', () { + final l10n = lookupAppLocalizations(const Locale('bn')); + expect(l10n.preNoticeCountdown(30), '30 মিনিটে শুরু হবে'); + }); + + test('Indonesian returns localized sentence', () { + final l10n = lookupAppLocalizations(const Locale('id')); + expect(l10n.preNoticeCountdown(30), 'Mulai dalam 30 menit'); + }); + }); +} diff --git a/test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart b/test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart new file mode 100644 index 0000000..38a3da9 --- /dev/null +++ b/test/pantallas/pantalla_alarma_sonando_dismiss_guard_test.dart @@ -0,0 +1,279 @@ +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.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'; + +// Tracks SystemNavigator.pop() calls via the platform channel mock. +class _SystemNavigatorSpy { + int popCalls = 0; + + void install() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + SystemChannels.platform, + (call) async { + if (call.method == 'SystemNavigator.pop') { + popCalls++; + } + return null; + }, + ); + } + + void uninstall() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(SystemChannels.platform, null); + } +} + +Future _montarComoRaiz( + WidgetTester tester, { + required FakePuertoAlarmasAndroid android, + required EstadoAlarmas estadoAlarmas, + required EstadoRadio radio, +}) async { + tester.view.physicalSize = const Size(1440, 3200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + // Mount alarm screen as ROOT route — simulates dead-app FSI launch. + 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: PantallaAlarmaSonando( + alarma: estadoAlarmas.alarmas.single, + audioPrearrancado: true, + ), + ), + ), + ); + await tester.pumpAndSettle(); +} + +Future _montarConHistorial( + WidgetTester tester, { + required FakePuertoAlarmasAndroid android, + required EstadoAlarmas estadoAlarmas, + required EstadoRadio radio, +}) async { + tester.view.physicalSize = const Size(1440, 3200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + // Mount with a previous route so canPop() returns true. + 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(); +} + +Future<_Env> _buildEnv() async { + final audio = FakeServicioAudio(); + audio.emitirEstado(EstadoReproduccion.reproduciendo); + final radio = EstadoRadio( + audio: audio, + favoritos: FakeServicioFavoritos(), + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + servicioGrabacion: FakeServicioGrabacionRadioInactiva(), + iniciarAutomaticamente: false, + ); + final android = FakePuertoAlarmasAndroid(); + final estadoAlarmas = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 11, 7, 0)), + android: android, + iniciarAutomaticamente: false, + ); + await estadoAlarmas.guardarAlarma( + AlarmaMusical( + id: 'dismiss-test', + nombre: 'Despertar', + hora: 7, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + snoozeMinutos: 5, + emisora: const Emisora( + uuid: 'e1', + nombre: 'Radio Uno', + url: 'https://radio.example/stream', + ), + ), + ); + return _Env(radio: radio, android: android, estadoAlarmas: estadoAlarmas); +} + +class _Env { + _Env({ + required this.radio, + required this.android, + required this.estadoAlarmas, + }); + final EstadoRadio radio; + final FakePuertoAlarmasAndroid android; + final EstadoAlarmas estadoAlarmas; + + void dispose() { + estadoAlarmas.dispose(); + android.dispose(); + radio.dispose(); + } +} + +void main() { + final l10n = lookupAppLocalizations(const Locale('es')); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + group('PantallaAlarmaSonando dismiss guard (Phase 5)', () { + testWidgets( + 'posponer: cuando canPop es true, Navigator.pop es llamado y SystemNavigator.pop NO (S5-R1-A)', + (tester) async { + final env = await _buildEnv(); + addTearDown(env.dispose); + final spy = _SystemNavigatorSpy()..install(); + addTearDown(spy.uninstall); + + await _montarConHistorial( + tester, + android: env.android, + estadoAlarmas: env.estadoAlarmas, + radio: env.radio, + ); + + // Verify the alarm screen is on top of a stack (canPop == true) + expect(find.byType(PantallaAlarmaSonando), findsOneWidget); + + await tester.tap(find.text(l10n.alarmSnoozeOptionLabel(5))); + await tester.pumpAndSettle(); + + // Screen should be dismissed via Navigator.pop (stack pop) + expect(find.byType(PantallaAlarmaSonando), findsNothing); + // SystemNavigator.pop must NOT have been called + expect(spy.popCalls, 0, + reason: 'SystemNavigator.pop must not be called when canPop is true'); + }, + ); + + testWidgets( + 'posponer: cuando canPop es false (root), SystemNavigator.pop es llamado (S5-R1-B)', + (tester) async { + final env = await _buildEnv(); + addTearDown(env.dispose); + final spy = _SystemNavigatorSpy()..install(); + addTearDown(spy.uninstall); + + await _montarComoRaiz( + tester, + android: env.android, + estadoAlarmas: env.estadoAlarmas, + radio: env.radio, + ); + + expect(find.byType(PantallaAlarmaSonando), findsOneWidget); + + await tester.tap(find.text(l10n.alarmSnoozeOptionLabel(5))); + await tester.pumpAndSettle(); + + // SystemNavigator.pop must be called exactly once + expect(spy.popCalls, 1, + reason: 'SystemNavigator.pop must be called when canPop is false'); + }, + ); + + testWidgets( + 'detener: cuando canPop es true, Navigator.pop es llamado y SystemNavigator.pop NO (S5-R1-A)', + (tester) async { + final env = await _buildEnv(); + addTearDown(env.dispose); + final spy = _SystemNavigatorSpy()..install(); + addTearDown(spy.uninstall); + + await _montarConHistorial( + tester, + android: env.android, + estadoAlarmas: env.estadoAlarmas, + radio: env.radio, + ); + + await tester.tap(find.text(l10n.stopAlarmAction)); + await tester.pumpAndSettle(); + + expect(find.byType(PantallaAlarmaSonando), findsNothing); + expect(spy.popCalls, 0, + reason: 'SystemNavigator.pop must not be called when canPop is true'); + }, + ); + + testWidgets( + 'detener: cuando canPop es false (root), SystemNavigator.pop es llamado (S5-R1-B)', + (tester) async { + final env = await _buildEnv(); + addTearDown(env.dispose); + final spy = _SystemNavigatorSpy()..install(); + addTearDown(spy.uninstall); + + await _montarComoRaiz( + tester, + android: env.android, + estadoAlarmas: env.estadoAlarmas, + radio: env.radio, + ); + + await tester.tap(find.text(l10n.stopAlarmAction)); + await tester.pumpAndSettle(); + + expect(spy.popCalls, 1, + reason: 'SystemNavigator.pop must be called when canPop is false'); + }, + ); + }); +} diff --git a/test/servicios/servicio_alarmas_pre_notice_template_test.dart b/test/servicios/servicio_alarmas_pre_notice_template_test.dart new file mode 100644 index 0000000..737f610 --- /dev/null +++ b/test/servicios/servicio_alarmas_pre_notice_template_test.dart @@ -0,0 +1,79 @@ +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_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); + if (call.method == 'scheduleAlarm') return true; + return null; + }); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler(channel, null); + }); + + test( + 'programar includes preNoticeTemplate with {minutes} placeholder in MethodChannel call', + () async { + final servicio = ServicioAlarmasAndroid(channel: channel); + final alarma = AlarmaMusical( + id: 'test-alarm', + nombre: 'Morning alarm', + hora: 7, + minuto: 0, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + proximaEjecucion: DateTime(2099, 1, 1, 7, 0), + ); + + await servicio.programar(alarma); + + final llamada = llamadas.singleWhere((c) => c.method == 'scheduleAlarm'); + final args = llamada.arguments as Map; + expect(args.containsKey('preNoticeTemplate'), isTrue, + reason: 'preNoticeTemplate must be present in scheduleAlarm args'); + final template = args['preNoticeTemplate'] as String?; + expect(template, isNotNull, + reason: 'preNoticeTemplate must not be null'); + expect(template, contains('{minutes}'), + reason: 'preNoticeTemplate must contain the {minutes} placeholder'); + }, + ); + + test( + 'programar preNoticeTemplate uses default locale fallback when no l10n configured', + () async { + // ServicioAlarmasAndroid falls back to es locale when no l10n is configured + final servicio = ServicioAlarmasAndroid(channel: channel); + final alarma = AlarmaMusical( + id: 'test-alarm-2', + nombre: 'Alarm', + hora: 8, + minuto: 30, + tipoProgramacion: TipoProgramacionAlarma.diaria, + diasSemana: const [], + proximaEjecucion: DateTime(2099, 1, 2, 8, 30), + ); + + await servicio.programar(alarma); + + final llamada = llamadas.singleWhere((c) => c.method == 'scheduleAlarm'); + final args = llamada.arguments as Map; + final template = args['preNoticeTemplate'] as String?; + // The template must contain the literal placeholder string + expect(template, contains('{minutes}')); + }, + ); +}