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:
@@ -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);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user