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.
This commit is contained in:
@@ -39,7 +39,8 @@ class AlarmScheduler(private val context: Context) {
|
|||||||
snoozeMinutes: Int = 5,
|
snoozeMinutes: Int = 5,
|
||||||
fallbackStationName: String? = null,
|
fallbackStationName: String? = null,
|
||||||
fallbackStationUrl: String? = null,
|
fallbackStationUrl: String? = null,
|
||||||
fadeInSegundos: Int = 0
|
fadeInSegundos: Int = 0,
|
||||||
|
preNoticeTemplate: String? = null
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val existing = readSpec(id)
|
val existing = readSpec(id)
|
||||||
val preservedSnooze = preserveNativeSnooze(
|
val preservedSnooze = preserveNativeSnooze(
|
||||||
@@ -70,7 +71,8 @@ class AlarmScheduler(private val context: Context) {
|
|||||||
fallbackSound = fallbackSound,
|
fallbackSound = fallbackSound,
|
||||||
volume = volume.coerceIn(0f, 1f),
|
volume = volume.coerceIn(0f, 1f),
|
||||||
fadeInSegundos = fadeInSegundos.coerceIn(0, 60),
|
fadeInSegundos = fadeInSegundos.coerceIn(0, 60),
|
||||||
timezoneId = TimeZone.getDefault().id
|
timezoneId = TimeZone.getDefault().id,
|
||||||
|
preNoticeTemplate = preNoticeTemplate
|
||||||
)
|
)
|
||||||
return scheduleSpec(spec, persistOnSuccess = true)
|
return scheduleSpec(spec, persistOnSuccess = true)
|
||||||
}
|
}
|
||||||
@@ -145,6 +147,7 @@ class AlarmScheduler(private val context: Context) {
|
|||||||
PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT,
|
PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT,
|
||||||
spec.snoozeOriginMillis ?: spec.triggerAtMillis
|
spec.snoozeOriginMillis ?: spec.triggerAtMillis
|
||||||
)
|
)
|
||||||
|
putExtra(EXTRA_PRE_NOTICE_TEMPLATE, spec.preNoticeTemplate)
|
||||||
},
|
},
|
||||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||||
)
|
)
|
||||||
@@ -165,6 +168,7 @@ class AlarmScheduler(private val context: Context) {
|
|||||||
PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT,
|
PluriWaveAlarmReceiver.EXTRA_OCCURRENCE_AT,
|
||||||
spec.snoozeOriginMillis ?: spec.triggerAtMillis
|
spec.snoozeOriginMillis ?: spec.triggerAtMillis
|
||||||
)
|
)
|
||||||
|
putExtra(EXTRA_PRE_NOTICE_TEMPLATE, spec.preNoticeTemplate)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
Log.d(tag, "alarm.schedule preNotice immediate id=${spec.id}")
|
Log.d(tag, "alarm.schedule preNotice immediate id=${spec.id}")
|
||||||
@@ -652,7 +656,10 @@ class AlarmScheduler(private val context: Context) {
|
|||||||
val fallbackSound: String?,
|
val fallbackSound: String?,
|
||||||
val volume: Float,
|
val volume: Float,
|
||||||
val fadeInSegundos: Int = 0,
|
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 {
|
fun toJson(): JSONObject = JSONObject().apply {
|
||||||
put("schemaVersion", 3)
|
put("schemaVersion", 3)
|
||||||
@@ -679,6 +686,7 @@ class AlarmScheduler(private val context: Context) {
|
|||||||
put("volume", volume)
|
put("volume", volume)
|
||||||
put("fadeInSegundos", fadeInSegundos)
|
put("fadeInSegundos", fadeInSegundos)
|
||||||
put("timezoneId", timezoneId)
|
put("timezoneId", timezoneId)
|
||||||
|
put("preNoticeTemplate", preNoticeTemplate)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@@ -715,7 +723,8 @@ class AlarmScheduler(private val context: Context) {
|
|||||||
fallbackSound = json.optString("fallbackSound").takeIf { it.isNotBlank() },
|
fallbackSound = json.optString("fallbackSound").takeIf { it.isNotBlank() },
|
||||||
volume = json.optDouble("volume", 0.85).toFloat(),
|
volume = json.optDouble("volume", 0.85).toFloat(),
|
||||||
fadeInSegundos = json.optInt("fadeInSegundos", 0).coerceIn(0, 60),
|
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 PRE_NOTICE_MILLIS = 30 * 60 * 1000L
|
||||||
private const val SCHEDULE_UNICA = "unica"
|
private const val SCHEDULE_UNICA = "unica"
|
||||||
private const val SCHEDULE_DIAS_SEMANA = "diasSemana"
|
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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -115,7 +115,8 @@ class MainActivity : AudioServiceActivity() {
|
|||||||
snoozeMinutes = call.argument<Int>("snoozeMinutes") ?: 5,
|
snoozeMinutes = call.argument<Int>("snoozeMinutes") ?: 5,
|
||||||
fallbackStationName = call.argument<String>("fallbackStationName"),
|
fallbackStationName = call.argument<String>("fallbackStationName"),
|
||||||
fallbackStationUrl = call.argument<String>("fallbackStationUrl"),
|
fallbackStationUrl = call.argument<String>("fallbackStationUrl"),
|
||||||
fadeInSegundos = call.argument<Int>("fadeInSegundos") ?: 0
|
fadeInSegundos = call.argument<Int>("fadeInSegundos") ?: 0,
|
||||||
|
preNoticeTemplate = call.argument<String>("preNoticeTemplate")
|
||||||
)
|
)
|
||||||
result.success(scheduled)
|
result.success(scheduled)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -51,7 +51,8 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
|
|||||||
title,
|
title,
|
||||||
snoozeMinutes,
|
snoozeMinutes,
|
||||||
intent.getLongExtra(EXTRA_TRIGGER_AT, 0L),
|
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 -> {
|
ACTION_POSTPONE_NEXT -> {
|
||||||
@@ -100,10 +101,14 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
|
|||||||
title: String,
|
title: String,
|
||||||
snoozeMinutes: Int,
|
snoozeMinutes: Int,
|
||||||
triggerAtMillis: Long,
|
triggerAtMillis: Long,
|
||||||
occurrenceAtMillis: Long
|
occurrenceAtMillis: Long,
|
||||||
|
preNoticeTemplate: String? = null
|
||||||
) {
|
) {
|
||||||
ensureChannel(context)
|
ensureChannel(context)
|
||||||
|
|
||||||
|
val remaining = computeRemainingMinutes(triggerAtMillis)
|
||||||
|
val contentText = formatPreNoticeText(preNoticeTemplate, remaining)
|
||||||
|
|
||||||
val openAppIntent = PendingIntent.getActivity(
|
val openAppIntent = PendingIntent.getActivity(
|
||||||
context,
|
context,
|
||||||
requestCode(alarmId, 1),
|
requestCode(alarmId, 1),
|
||||||
@@ -144,7 +149,7 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
|
|||||||
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
val notification = NotificationCompat.Builder(context, CHANNEL_ID)
|
||||||
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
.setSmallIcon(android.R.drawable.ic_dialog_info)
|
||||||
.setContentTitle(title)
|
.setContentTitle(title)
|
||||||
.setContentText("Empieza en 30 minutos")
|
.setContentText(contentText)
|
||||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||||
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
.setCategory(NotificationCompat.CATEGORY_REMINDER)
|
||||||
.setSilent(true)
|
.setSilent(true)
|
||||||
@@ -156,12 +161,31 @@ class PluriWaveAlarmReceiver : BroadcastReceiver() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
NotificationManagerCompat.from(context).notify(notificationIdForAlarm(alarmId), notification)
|
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) {
|
} catch (error: SecurityException) {
|
||||||
Log.e(TAG, "alarm.notification preNotice SecurityException id=$alarmId", error)
|
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) {
|
private fun ensureChannel(context: Context) {
|
||||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||||
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'dart:async';
|
import 'dart:async';
|
||||||
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:flutter/services.dart';
|
||||||
import 'package:just_audio/just_audio.dart';
|
import 'package:just_audio/just_audio.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
@@ -153,11 +154,10 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
|||||||
Future<void> _detener() async {
|
Future<void> _detener() async {
|
||||||
final radio = context.read<EstadoRadio>();
|
final radio = context.read<EstadoRadio>();
|
||||||
final alarmas = context.read<EstadoAlarmas>();
|
final alarmas = context.read<EstadoAlarmas>();
|
||||||
final navigator = Navigator.of(context);
|
|
||||||
await _liberarAudioLocal();
|
await _liberarAudioLocal();
|
||||||
await radio.audio.pausar();
|
await radio.audio.pausar();
|
||||||
await alarmas.finalizarEjecucion(widget.alarma.id);
|
await alarmas.finalizarEjecucion(widget.alarma.id);
|
||||||
if (mounted) navigator.pop();
|
if (mounted) _dismissScreen();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Flutter-first snooze (S2-R1): tears down local audio, then routes
|
/// Flutter-first snooze (S2-R1): tears down local audio, then routes
|
||||||
@@ -166,11 +166,25 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
|||||||
Future<void> _posponer(int minutos) async {
|
Future<void> _posponer(int minutos) async {
|
||||||
final radio = context.read<EstadoRadio>();
|
final radio = context.read<EstadoRadio>();
|
||||||
final alarmas = context.read<EstadoAlarmas>();
|
final alarmas = context.read<EstadoAlarmas>();
|
||||||
final navigator = Navigator.of(context);
|
|
||||||
await _liberarAudioLocal();
|
await _liberarAudioLocal();
|
||||||
await radio.audio.pausar();
|
await radio.audio.pausar();
|
||||||
await alarmas.posponerAlarma(widget.alarma, minutos);
|
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<int> _opcionesSnooze() {
|
List<int> _opcionesSnooze() {
|
||||||
|
|||||||
@@ -170,6 +170,19 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
|
|||||||
_l10n = l10n;
|
_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
|
@override
|
||||||
Stream<EventoAlarmaAndroid> get eventosAlarma => _eventosController.stream;
|
Stream<EventoAlarmaAndroid> get eventosAlarma => _eventosController.stream;
|
||||||
|
|
||||||
@@ -189,6 +202,7 @@ class ServicioAlarmasAndroid implements PuertoAlarmasAndroid {
|
|||||||
final programada = await _channel.invokeMethod<bool>('scheduleAlarm', {
|
final programada = await _channel.invokeMethod<bool>('scheduleAlarm', {
|
||||||
'id': alarma.id,
|
'id': alarma.id,
|
||||||
'title': localizedAlarmName(_textos, alarma.nombre),
|
'title': localizedAlarmName(_textos, alarma.nombre),
|
||||||
|
'preNoticeTemplate': _preNoticeTemplate(_textos),
|
||||||
'triggerAtMillis': proxima.millisecondsSinceEpoch,
|
'triggerAtMillis': proxima.millisecondsSinceEpoch,
|
||||||
'preNoticeAtMillis':
|
'preNoticeAtMillis':
|
||||||
alarma.snoozeHasta == null
|
alarma.snoozeHasta == null
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<void> _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<EstadoRadio>.value(value: radio),
|
||||||
|
ChangeNotifierProvider<EstadoAlarmas>.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<void> _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<EstadoRadio>.value(value: radio),
|
||||||
|
ChangeNotifierProvider<EstadoAlarmas>.value(value: estadoAlarmas),
|
||||||
|
],
|
||||||
|
child: MaterialApp(
|
||||||
|
locale: const Locale('es'),
|
||||||
|
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||||
|
supportedLocales: AppLocalizations.supportedLocales,
|
||||||
|
home: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
final navigator = tester.state<NavigatorState>(find.byType(Navigator));
|
||||||
|
unawaited(
|
||||||
|
navigator.push(
|
||||||
|
MaterialPageRoute<void>(
|
||||||
|
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');
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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<MethodCall> 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<Object?, Object?>;
|
||||||
|
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<Object?, Object?>;
|
||||||
|
final template = args['preNoticeTemplate'] as String?;
|
||||||
|
// The template must contain the literal placeholder string
|
||||||
|
expect(template, contains('{minutes}'));
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user