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)
This commit is contained in:
2026-06-11 15:33:30 +02:00
parent b5acf97ba4
commit f3e9487215
57 changed files with 4902 additions and 509 deletions
+251
View File
@@ -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<void>();
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);
},
);
}
+129 -119
View File
@@ -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 = <AlarmaMusical>[];
final canceladas = <String>[];
final detenidas = <String>[];
final ocultadas = <String>[];
final ejecucionesNativas = <EjecucionAlarmaNativa>[];
final _eventos = StreamController<EventoAlarmaAndroid>.broadcast();
@override
Stream<EventoAlarmaAndroid> get eventosAlarma => _eventos.stream;
@override
Future<void> programar(AlarmaMusical alarma) async {
programadas.add(alarma);
}
@override
Future<void> cancelar(String alarmaId) async {
canceladas.add(alarmaId);
}
@override
Future<void> detenerSonidoNativo(String alarmaId) async {
detenidas.add(alarmaId);
}
@override
Future<void> ocultarNotificacionAlarma(String alarmaId) async {
ocultadas.add(alarmaId);
}
@override
Future<void> confirmarAudioFlutter(String alarmaId) async {
detenidas.add(alarmaId);
}
@override
Future<DiagnosticoAlarmasAndroid> diagnostico() async =>
const DiagnosticoAlarmasAndroid(
puedeProgramarExactas: true,
notificacionesPermitidas: true,
puedeUsarPantallaCompleta: true,
ignoraOptimizacionBateria: true,
alarmasNativasPendientes: 0,
fabricante: 'test',
versionSdk: 35,
);
@override
Future<EventoAlarmaAndroid?> obtenerEventoInicial() async => null;
@override
Future<List<EjecucionAlarmaNativa>>
obtenerEjecucionesNativasGestionadas() async => ejecucionesNativas;
@override
Future<bool> solicitarPermisoAlarmasExactas() async => true;
@override
Future<bool> solicitarPermisoNotificaciones() async => true;
@override
Future<bool> solicitarPermisoPantallaCompleta() async => true;
Future<void> dispose() => _eventos.close();
}
+19 -4
View File
@@ -18,6 +18,8 @@ class FakeServicioAudio extends ServicioAudio {
final List<PresetEcualizador> presetsAplicados = [];
final List<Emisora> emisorasReproducidas = [];
final List<bool> cambiosEcualizadorActivo = [];
final List<double> volumenesAplicados = [];
int pausas = 0;
Emisora? _emisoraActual;
EstadoReproduccion _estadoActual = EstadoReproduccion.detenido;
@@ -46,6 +48,17 @@ class FakeServicioAudio extends ServicioAudio {
emitirEstado(EstadoReproduccion.detenido);
}
@override
Future<void> pausar() async {
pausas++;
emitirEstado(EstadoReproduccion.pausado);
}
@override
Future<void> 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<List<GrupoFavoritos>> obtenerGrupos() async => List.unmodifiable(_grupos);
Future<List<GrupoFavoritos>> obtenerGrupos() async =>
List.unmodifiable(_grupos);
@override
Future<GrupoFavoritos> crearGrupo(String nombre) async {
@@ -151,9 +165,10 @@ class FakeServicioFavoritos extends ServicioFavoritos {
@override
Future<void> 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);
+106
View File
@@ -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 = <AlarmaMusical>[];
final canceladas = <String>[];
final detenidas = <String>[];
final ocultadas = <String>[];
final ejecucionesNativas = <EjecucionAlarmaNativa>[];
final snoozesNativos = <EstadoSnoozeNativo>[];
final _eventos = StreamController<EventoAlarmaAndroid>.broadcast();
bool ignoraOptimizacionBateria = true;
int solicitudesExencionBateria = 0;
/// Simulates a native -> Flutter `alarmFired` MethodChannel event.
void emitirEvento(EventoAlarmaAndroid evento) => _eventos.add(evento);
@override
Stream<EventoAlarmaAndroid> get eventosAlarma => _eventos.stream;
@override
Future<void> programar(AlarmaMusical alarma) async {
programadas.add(alarma);
}
@override
Future<void> cancelar(String alarmaId) async {
canceladas.add(alarmaId);
}
@override
Future<void> detenerSonidoNativo(String alarmaId) async {
detenidas.add(alarmaId);
}
@override
Future<void> ocultarNotificacionAlarma(String alarmaId) async {
ocultadas.add(alarmaId);
}
@override
Future<void> confirmarAudioFlutter(String alarmaId) async {
detenidas.add(alarmaId);
}
@override
Future<DiagnosticoAlarmasAndroid> diagnostico() async =>
DiagnosticoAlarmasAndroid(
puedeProgramarExactas: true,
notificacionesPermitidas: true,
puedeUsarPantallaCompleta: true,
ignoraOptimizacionBateria: ignoraOptimizacionBateria,
alarmasNativasPendientes: 0,
fabricante: 'test',
versionSdk: 35,
);
@override
Future<bool> solicitarExencionBateria() async {
solicitudesExencionBateria++;
return true;
}
@override
Future<EventoAlarmaAndroid?> obtenerEventoInicial() async => null;
@override
Future<List<EjecucionAlarmaNativa>>
obtenerEjecucionesNativasGestionadas() async => ejecucionesNativas;
@override
Future<List<EstadoSnoozeNativo>> obtenerEstadoSnoozeNativo() async =>
List.of(snoozesNativos);
@override
Future<bool> solicitarPermisoAlarmasExactas() async => true;
@override
Future<bool> solicitarPermisoNotificaciones() async => true;
@override
Future<bool> solicitarPermisoPantallaCompleta() async => true;
Future<void> dispose() => _eventos.close();
}
/// Inactive recording service fake, safe for widget tests.
class FakeServicioGrabacionRadioInactiva extends ServicioGrabacionRadio {
final _controller = StreamController<EstadoGrabacionRadio>.broadcast();
@override
EstadoGrabacionRadio get estado => const EstadoGrabacionRadio.inactiva();
@override
Stream<EstadoGrabacionRadio> get estadoStream => _controller.stream;
@override
Future<void> inicializar() async {}
@override
Future<void> dispose() => _controller.close();
}
@@ -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<void> _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<EstadoRadio>.value(value: radio),
ChangeNotifierProvider<EstadoAlarmas>.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<NavigatorState>(find.byType(Navigator));
unawaited(
navigator.push(
MaterialPageRoute<void>(
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<Scaffold>(
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);
},
);
}
@@ -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<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();
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),
);
},
);
}
@@ -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<EstadoRadio>.value(value: radio),
ChangeNotifierProvider<EstadoAlarmas>.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<Text>(
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<int>), 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<Slider>(find.byType(Slider));
final volumen = sliders.firstWhere((slider) => slider.max == 1.0);
expect(volumen.min, 0.0);
});
}
@@ -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<MethodCall> 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<Object?, Object?>;
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<Object?, Object?>;
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'),
);
},
);
}
@@ -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<ServicioAlarmas> 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<MethodCall> llamadas;
late List<Map<String, Object?>> 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<Object?, Object?>;
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);
});
});
}