fix(alarm): localize pre-notice countdown and fix snooze dismiss
Build & Deploy PluriWave / Análisis de código (push) Successful in 39s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 2m33s

Replace hardcoded Spanish pre-notice text with computed remaining
minutes using l10n template passed via MethodChannel. Fix snooze
dismiss in dead-app state with canPop guard and SystemNavigator.pop
fallback.
This commit is contained in:
2026-06-28 11:55:15 +02:00
parent 58922de6fc
commit 4ffd73d136
8 changed files with 510 additions and 13 deletions
+74
View File
@@ -0,0 +1,74 @@
import 'dart:ui';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
void main() {
group('preNoticeCountdown ARB key', () {
test('English returns expected sentence with minutes placeholder', () {
final l10n = lookupAppLocalizations(const Locale('en'));
expect(l10n.preNoticeCountdown(30), 'Starts in 30 min');
expect(l10n.preNoticeCountdown(1), 'Starts in 1 min');
});
test('Spanish returns localized sentence', () {
final l10n = lookupAppLocalizations(const Locale('es'));
expect(l10n.preNoticeCountdown(30), 'Empieza en 30 min');
});
test('Arabic returns localized sentence', () {
final l10n = lookupAppLocalizations(const Locale('ar'));
expect(l10n.preNoticeCountdown(5), 'يبدأ خلال 5 دقيقة');
});
test('German returns localized sentence', () {
final l10n = lookupAppLocalizations(const Locale('de'));
expect(l10n.preNoticeCountdown(10), 'Startet in 10 Min.');
});
test('French returns localized sentence', () {
final l10n = lookupAppLocalizations(const Locale('fr'));
expect(l10n.preNoticeCountdown(15), 'Démarre dans 15 min');
});
test('Portuguese returns localized sentence', () {
final l10n = lookupAppLocalizations(const Locale('pt'));
expect(l10n.preNoticeCountdown(20), 'Começa em 20 min');
});
test('Italian returns localized sentence', () {
final l10n = lookupAppLocalizations(const Locale('it'));
expect(l10n.preNoticeCountdown(25), 'Inizia tra 25 min');
});
test('Japanese returns localized sentence', () {
final l10n = lookupAppLocalizations(const Locale('ja'));
expect(l10n.preNoticeCountdown(30), '30分後に開始');
});
test('Russian returns localized sentence', () {
final l10n = lookupAppLocalizations(const Locale('ru'));
expect(l10n.preNoticeCountdown(30), 'Начнётся через 30 мин');
});
test('Chinese returns localized sentence', () {
final l10n = lookupAppLocalizations(const Locale('zh'));
expect(l10n.preNoticeCountdown(30), '30分钟后开始');
});
test('Hindi returns localized sentence', () {
final l10n = lookupAppLocalizations(const Locale('hi'));
expect(l10n.preNoticeCountdown(30), '30 मिनट में शुरू होगा');
});
test('Bengali returns localized sentence', () {
final l10n = lookupAppLocalizations(const Locale('bn'));
expect(l10n.preNoticeCountdown(30), '30 মিনিটে শুরু হবে');
});
test('Indonesian returns localized sentence', () {
final l10n = lookupAppLocalizations(const Locale('id'));
expect(l10n.preNoticeCountdown(30), 'Mulai dalam 30 menit');
});
});
}
@@ -0,0 +1,279 @@
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/modelos/emisora.dart';
import 'package:pluriwave/pantallas/pantalla_alarma_sonando.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:pluriwave/servicios/servicio_audio.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes.dart';
import '../helpers/fakes_alarmas.dart';
// Tracks SystemNavigator.pop() calls via the platform channel mock.
class _SystemNavigatorSpy {
int popCalls = 0;
void install() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(
SystemChannels.platform,
(call) async {
if (call.method == 'SystemNavigator.pop') {
popCalls++;
}
return null;
},
);
}
void uninstall() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(SystemChannels.platform, null);
}
}
Future<void> _montarComoRaiz(
WidgetTester tester, {
required FakePuertoAlarmasAndroid android,
required EstadoAlarmas estadoAlarmas,
required EstadoRadio radio,
}) async {
tester.view.physicalSize = const Size(1440, 3200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
// Mount alarm screen as ROOT route — simulates dead-app FSI launch.
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<EstadoRadio>.value(value: radio),
ChangeNotifierProvider<EstadoAlarmas>.value(value: estadoAlarmas),
],
child: MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: PantallaAlarmaSonando(
alarma: estadoAlarmas.alarmas.single,
audioPrearrancado: true,
),
),
),
);
await tester.pumpAndSettle();
}
Future<void> _montarConHistorial(
WidgetTester tester, {
required FakePuertoAlarmasAndroid android,
required EstadoAlarmas estadoAlarmas,
required EstadoRadio radio,
}) async {
tester.view.physicalSize = const Size(1440, 3200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
// Mount with a previous route so canPop() returns true.
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<EstadoRadio>.value(value: radio),
ChangeNotifierProvider<EstadoAlarmas>.value(value: estadoAlarmas),
],
child: MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const SizedBox.shrink(),
),
),
);
final navigator = tester.state<NavigatorState>(find.byType(Navigator));
unawaited(
navigator.push(
MaterialPageRoute<void>(
builder: (_) => PantallaAlarmaSonando(
alarma: estadoAlarmas.alarmas.single,
audioPrearrancado: true,
),
fullscreenDialog: true,
),
),
);
await tester.pumpAndSettle();
}
Future<_Env> _buildEnv() async {
final audio = FakeServicioAudio();
audio.emitirEstado(EstadoReproduccion.reproduciendo);
final radio = EstadoRadio(
audio: audio,
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
iniciarAutomaticamente: false,
);
final android = FakePuertoAlarmasAndroid();
final estadoAlarmas = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 11, 7, 0)),
android: android,
iniciarAutomaticamente: false,
);
await estadoAlarmas.guardarAlarma(
AlarmaMusical(
id: 'dismiss-test',
nombre: 'Despertar',
hora: 7,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
snoozeMinutos: 5,
emisora: const Emisora(
uuid: 'e1',
nombre: 'Radio Uno',
url: 'https://radio.example/stream',
),
),
);
return _Env(radio: radio, android: android, estadoAlarmas: estadoAlarmas);
}
class _Env {
_Env({
required this.radio,
required this.android,
required this.estadoAlarmas,
});
final EstadoRadio radio;
final FakePuertoAlarmasAndroid android;
final EstadoAlarmas estadoAlarmas;
void dispose() {
estadoAlarmas.dispose();
android.dispose();
radio.dispose();
}
}
void main() {
final l10n = lookupAppLocalizations(const Locale('es'));
setUp(() {
SharedPreferences.setMockInitialValues({});
});
group('PantallaAlarmaSonando dismiss guard (Phase 5)', () {
testWidgets(
'posponer: cuando canPop es true, Navigator.pop es llamado y SystemNavigator.pop NO (S5-R1-A)',
(tester) async {
final env = await _buildEnv();
addTearDown(env.dispose);
final spy = _SystemNavigatorSpy()..install();
addTearDown(spy.uninstall);
await _montarConHistorial(
tester,
android: env.android,
estadoAlarmas: env.estadoAlarmas,
radio: env.radio,
);
// Verify the alarm screen is on top of a stack (canPop == true)
expect(find.byType(PantallaAlarmaSonando), findsOneWidget);
await tester.tap(find.text(l10n.alarmSnoozeOptionLabel(5)));
await tester.pumpAndSettle();
// Screen should be dismissed via Navigator.pop (stack pop)
expect(find.byType(PantallaAlarmaSonando), findsNothing);
// SystemNavigator.pop must NOT have been called
expect(spy.popCalls, 0,
reason: 'SystemNavigator.pop must not be called when canPop is true');
},
);
testWidgets(
'posponer: cuando canPop es false (root), SystemNavigator.pop es llamado (S5-R1-B)',
(tester) async {
final env = await _buildEnv();
addTearDown(env.dispose);
final spy = _SystemNavigatorSpy()..install();
addTearDown(spy.uninstall);
await _montarComoRaiz(
tester,
android: env.android,
estadoAlarmas: env.estadoAlarmas,
radio: env.radio,
);
expect(find.byType(PantallaAlarmaSonando), findsOneWidget);
await tester.tap(find.text(l10n.alarmSnoozeOptionLabel(5)));
await tester.pumpAndSettle();
// SystemNavigator.pop must be called exactly once
expect(spy.popCalls, 1,
reason: 'SystemNavigator.pop must be called when canPop is false');
},
);
testWidgets(
'detener: cuando canPop es true, Navigator.pop es llamado y SystemNavigator.pop NO (S5-R1-A)',
(tester) async {
final env = await _buildEnv();
addTearDown(env.dispose);
final spy = _SystemNavigatorSpy()..install();
addTearDown(spy.uninstall);
await _montarConHistorial(
tester,
android: env.android,
estadoAlarmas: env.estadoAlarmas,
radio: env.radio,
);
await tester.tap(find.text(l10n.stopAlarmAction));
await tester.pumpAndSettle();
expect(find.byType(PantallaAlarmaSonando), findsNothing);
expect(spy.popCalls, 0,
reason: 'SystemNavigator.pop must not be called when canPop is true');
},
);
testWidgets(
'detener: cuando canPop es false (root), SystemNavigator.pop es llamado (S5-R1-B)',
(tester) async {
final env = await _buildEnv();
addTearDown(env.dispose);
final spy = _SystemNavigatorSpy()..install();
addTearDown(spy.uninstall);
await _montarComoRaiz(
tester,
android: env.android,
estadoAlarmas: env.estadoAlarmas,
radio: env.radio,
);
await tester.tap(find.text(l10n.stopAlarmAction));
await tester.pumpAndSettle();
expect(spy.popCalls, 1,
reason: 'SystemNavigator.pop must be called when canPop is false');
},
);
});
}
@@ -0,0 +1,79 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/servicios/servicio_alarmas_android.dart';
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
const channel = MethodChannel('pluriwave/alarm_scheduler');
late List<MethodCall> llamadas;
setUp(() {
llamadas = [];
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, (call) async {
llamadas.add(call);
if (call.method == 'scheduleAlarm') return true;
return null;
});
});
tearDown(() {
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
.setMockMethodCallHandler(channel, null);
});
test(
'programar includes preNoticeTemplate with {minutes} placeholder in MethodChannel call',
() async {
final servicio = ServicioAlarmasAndroid(channel: channel);
final alarma = AlarmaMusical(
id: 'test-alarm',
nombre: 'Morning alarm',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
proximaEjecucion: DateTime(2099, 1, 1, 7, 0),
);
await servicio.programar(alarma);
final llamada = llamadas.singleWhere((c) => c.method == 'scheduleAlarm');
final args = llamada.arguments as Map<Object?, Object?>;
expect(args.containsKey('preNoticeTemplate'), isTrue,
reason: 'preNoticeTemplate must be present in scheduleAlarm args');
final template = args['preNoticeTemplate'] as String?;
expect(template, isNotNull,
reason: 'preNoticeTemplate must not be null');
expect(template, contains('{minutes}'),
reason: 'preNoticeTemplate must contain the {minutes} placeholder');
},
);
test(
'programar preNoticeTemplate uses default locale fallback when no l10n configured',
() async {
// ServicioAlarmasAndroid falls back to es locale when no l10n is configured
final servicio = ServicioAlarmasAndroid(channel: channel);
final alarma = AlarmaMusical(
id: 'test-alarm-2',
nombre: 'Alarm',
hora: 8,
minuto: 30,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: const [],
proximaEjecucion: DateTime(2099, 1, 2, 8, 30),
);
await servicio.programar(alarma);
final llamada = llamadas.singleWhere((c) => c.method == 'scheduleAlarm');
final args = llamada.arguments as Map<Object?, Object?>;
final template = args['preNoticeTemplate'] as String?;
// The template must contain the literal placeholder string
expect(template, contains('{minutes}'));
},
);
}