feat(alarmas): replace one-line reliability button with full diagnostics screen
Surface all six DiagnosticoAlarmasAndroid fields instead of three: the battery-optimization exemption and native pending-alarm count were already collected but silently dropped by the old widget. Each failing signal now offers a "Fix this" action that opens the right system settings screen (exact alarms, notifications, full-screen intent, battery optimization), guarded by SDK level and never crashing when a ROM lacks that screen. Manufacturers known for aggressive background killing (Xiaomi/Redmi/POCO, Huawei, Oppo, Vivo, OnePlus, Samsung) get an honest explanation that Autostart must be enabled manually, since there is no API to detect or grant it. Notifications now deep-links straight to ACTION_APP_NOTIFICATION_SETTINGS via a new openNotificationSettings native method, instead of reusing the runtime permission popup meant for first-time alarm creation. New copy is added to all 13 ARB locales with real per-language translations (not Spanish copies), verified by the ARB parity and anti-copy tests plus the corruption scanner.
This commit is contained in:
@@ -17,6 +17,24 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
|
||||
final _eventos = StreamController<EventoAlarmaAndroid>.broadcast();
|
||||
bool ignoraOptimizacionBateria = true;
|
||||
int solicitudesExencionBateria = 0;
|
||||
int aperturasConfiguracionNotificaciones = 0;
|
||||
|
||||
/// Extra diagnostico() fields (fix/alarmas-fiabilidad diagnostics screen).
|
||||
/// Default values mirror the previous hardcoded literals in [diagnostico],
|
||||
/// so every existing test that never sets these keeps seeing the exact
|
||||
/// same snapshot as before.
|
||||
bool puedeProgramarExactas = true;
|
||||
bool notificacionesPermitidas = true;
|
||||
bool puedeUsarPantallaCompleta = true;
|
||||
int alarmasNativasPendientes = 0;
|
||||
String fabricante = 'test';
|
||||
int versionSdk = 35;
|
||||
|
||||
/// Test-only failure switch (diagnostics screen, "intent not resolving"
|
||||
/// coverage): when true, every `abrir*`/`solicitar*` system-screen action
|
||||
/// below reports failure (as a real device does when a ROM lacks that
|
||||
/// settings screen), while still recording the attempt via its counter.
|
||||
bool fallaAccionSistema = false;
|
||||
|
||||
/// Test-only failure switch (Design D7): when true, [programar] throws
|
||||
/// instead of scheduling, enabling failure-path coverage that the fake
|
||||
@@ -107,19 +125,25 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
|
||||
@override
|
||||
Future<DiagnosticoAlarmasAndroid> diagnostico() async =>
|
||||
DiagnosticoAlarmasAndroid(
|
||||
puedeProgramarExactas: true,
|
||||
notificacionesPermitidas: true,
|
||||
puedeUsarPantallaCompleta: true,
|
||||
puedeProgramarExactas: puedeProgramarExactas,
|
||||
notificacionesPermitidas: notificacionesPermitidas,
|
||||
puedeUsarPantallaCompleta: puedeUsarPantallaCompleta,
|
||||
ignoraOptimizacionBateria: ignoraOptimizacionBateria,
|
||||
alarmasNativasPendientes: 0,
|
||||
fabricante: 'test',
|
||||
versionSdk: 35,
|
||||
alarmasNativasPendientes: alarmasNativasPendientes,
|
||||
fabricante: fabricante,
|
||||
versionSdk: versionSdk,
|
||||
);
|
||||
|
||||
@override
|
||||
Future<bool> solicitarExencionBateria() async {
|
||||
solicitudesExencionBateria++;
|
||||
return true;
|
||||
return !fallaAccionSistema;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> abrirConfiguracionNotificaciones() async {
|
||||
aperturasConfiguracionNotificaciones++;
|
||||
return !fallaAccionSistema;
|
||||
}
|
||||
|
||||
@override
|
||||
@@ -133,14 +157,23 @@ class FakePuertoAlarmasAndroid implements PuertoAlarmasAndroid {
|
||||
Future<List<EstadoSnoozeNativo>> obtenerEstadoSnoozeNativo() async =>
|
||||
List.of(snoozesNativos);
|
||||
|
||||
int solicitudesPermisoAlarmasExactas = 0;
|
||||
int solicitudesPermisoPantallaCompleta = 0;
|
||||
|
||||
@override
|
||||
Future<bool> solicitarPermisoAlarmasExactas() async => true;
|
||||
Future<bool> solicitarPermisoAlarmasExactas() async {
|
||||
solicitudesPermisoAlarmasExactas++;
|
||||
return !fallaAccionSistema;
|
||||
}
|
||||
|
||||
@override
|
||||
Future<bool> solicitarPermisoNotificaciones() async => true;
|
||||
|
||||
@override
|
||||
Future<bool> solicitarPermisoPantallaCompleta() async => true;
|
||||
Future<bool> solicitarPermisoPantallaCompleta() async {
|
||||
solicitudesPermisoPantallaCompleta++;
|
||||
return !fallaAccionSistema;
|
||||
}
|
||||
|
||||
Future<void> dispose() => _eventos.close();
|
||||
}
|
||||
|
||||
@@ -272,4 +272,8 @@ const Set<(String locale, String key)> identicalValueAllowlist = {
|
||||
'pt',
|
||||
'searchResultsCount',
|
||||
), // WU18 new key (task 18.3) -- "resultado(s)" is an es/pt cognate
|
||||
(
|
||||
'pt',
|
||||
'alarmDiagnosticsManufacturerLabel',
|
||||
), // fix/alarmas-fiabilidad new key -- "Fabricante" is identical in pt/es
|
||||
};
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:pluriwave/estado/estado_alarmas.dart';
|
||||
import 'package:pluriwave/l10n/gen/app_localizations.dart';
|
||||
import 'package:pluriwave/modelos/alarma_musical.dart';
|
||||
import 'package:pluriwave/pantallas/pantalla_diagnostico_alarmas.dart';
|
||||
import 'package:pluriwave/servicios/servicio_alarmas.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../helpers/fakes_alarmas.dart';
|
||||
|
||||
final _l10n = lookupAppLocalizations(const Locale('en'));
|
||||
|
||||
Future<EstadoAlarmas> _crearEstado({
|
||||
required FakePuertoAlarmasAndroid android,
|
||||
List<AlarmaMusical> alarmas = const [],
|
||||
bool cargarDiagnostico = true,
|
||||
}) async {
|
||||
final estado = EstadoAlarmas(
|
||||
servicio: ServicioAlarmas(),
|
||||
android: android,
|
||||
iniciarAutomaticamente: false,
|
||||
);
|
||||
for (final alarma in alarmas) {
|
||||
await estado.guardarAlarma(alarma);
|
||||
}
|
||||
if (cargarDiagnostico) {
|
||||
await estado.cargarDiagnostico();
|
||||
}
|
||||
return estado;
|
||||
}
|
||||
|
||||
Widget _buildScreen(EstadoAlarmas estado) {
|
||||
return ChangeNotifierProvider<EstadoAlarmas>.value(
|
||||
value: estado,
|
||||
child: MaterialApp(
|
||||
locale: const Locale('en'),
|
||||
localizationsDelegates: AppLocalizations.localizationsDelegates,
|
||||
supportedLocales: AppLocalizations.supportedLocales,
|
||||
home: const PantallaDiagnosticoAlarmas(),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _montarPantalla(WidgetTester tester, EstadoAlarmas estado) async {
|
||||
tester.view.physicalSize = const Size(1440, 3200);
|
||||
tester.view.devicePixelRatio = 1.0;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
|
||||
await tester.pumpWidget(_buildScreen(estado));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
AlarmaMusical _alarmaActiva() => const AlarmaMusical(
|
||||
id: 'a1',
|
||||
nombre: 'Despertar',
|
||||
hora: 7,
|
||||
minuto: 30,
|
||||
tipoProgramacion: TipoProgramacionAlarma.diaria,
|
||||
diasSemana: [],
|
||||
);
|
||||
|
||||
void main() {
|
||||
setUp(() {
|
||||
SharedPreferences.setMockInitialValues({});
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'caso todo OK: no muestra ningun boton "Fix" ni el estado de atencion, '
|
||||
'y sin fabricante conocido no muestra la guia de autostart',
|
||||
(tester) async {
|
||||
final android =
|
||||
FakePuertoAlarmasAndroid()
|
||||
..fabricante = 'Google'
|
||||
// A fresh fake defaults alarmasNativasPendientes to 0, which
|
||||
// WOULD read as needs-attention once an alarm is active (that
|
||||
// combination is exactly the diagnostic signal this screen
|
||||
// exists to surface) -- give it a registered count so this
|
||||
// specific scenario is genuinely all-OK.
|
||||
..alarmasNativasPendientes = 1;
|
||||
final estado = await _crearEstado(
|
||||
android: android,
|
||||
alarmas: [_alarmaActiva()],
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await _montarPantalla(tester, estado);
|
||||
|
||||
expect(find.text(_l10n.androidReliabilityTitle), findsOneWidget);
|
||||
expect(find.text(_l10n.alarmDiagnosticsFixAction), findsNothing);
|
||||
expect(
|
||||
find.text(_l10n.alarmDiagnosticsNeedsAttentionStatus),
|
||||
findsNothing,
|
||||
);
|
||||
expect(find.text(_l10n.alarmDiagnosticsAutostartTitle), findsNothing);
|
||||
expect(find.text('Google'), findsOneWidget);
|
||||
expect(find.text('35'), findsOneWidget);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'alarmas exactas en atencion: muestra el boton Fix y lo invoca via '
|
||||
'solicitarPermisoAlarmasExactas',
|
||||
(tester) async {
|
||||
final android =
|
||||
FakePuertoAlarmasAndroid()
|
||||
..fabricante = 'Google'
|
||||
..puedeProgramarExactas = false;
|
||||
final estado = await _crearEstado(
|
||||
android: android,
|
||||
alarmas: [_alarmaActiva()],
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await _montarPantalla(tester, estado);
|
||||
|
||||
expect(find.text(_l10n.alarmDiagnosticsExactAlarmsTitle), findsOneWidget);
|
||||
expect(find.text(_l10n.alarmDiagnosticsFixAction), findsOneWidget);
|
||||
|
||||
// guardarAlarma's own onboarding request
|
||||
// (EstadoAlarmas._solicitarPermisosNecesariosParaAlarma) already fires
|
||||
// once for this same failing field before the screen even mounts, so
|
||||
// the assertion checks the DELTA the button tap itself caused.
|
||||
final antes = android.solicitudesPermisoAlarmasExactas;
|
||||
await tester.tap(find.text(_l10n.alarmDiagnosticsFixAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(android.solicitudesPermisoAlarmasExactas, antes + 1);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('notificaciones en atencion: el boton Fix llama a '
|
||||
'abrirConfiguracionNotificaciones (deep link a Settings, no el permiso '
|
||||
'runtime)', (tester) async {
|
||||
final android =
|
||||
FakePuertoAlarmasAndroid()
|
||||
..fabricante = 'Google'
|
||||
..notificacionesPermitidas = false;
|
||||
final estado = await _crearEstado(
|
||||
android: android,
|
||||
alarmas: [_alarmaActiva()],
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await _montarPantalla(tester, estado);
|
||||
|
||||
await tester.tap(find.text(_l10n.alarmDiagnosticsFixAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(android.aperturasConfiguracionNotificaciones, 1);
|
||||
});
|
||||
|
||||
testWidgets('pantalla completa en atencion: el boton Fix llama a '
|
||||
'solicitarPermisoPantallaCompleta', (tester) async {
|
||||
final android =
|
||||
FakePuertoAlarmasAndroid()
|
||||
..fabricante = 'Google'
|
||||
..puedeUsarPantallaCompleta = false;
|
||||
final estado = await _crearEstado(
|
||||
android: android,
|
||||
alarmas: [_alarmaActiva()],
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await _montarPantalla(tester, estado);
|
||||
|
||||
// Same delta reasoning as the exact-alarms test above: the alarm's own
|
||||
// onboarding request already fired once for this field before mount.
|
||||
final antes = android.solicitudesPermisoPantallaCompleta;
|
||||
await tester.tap(find.text(_l10n.alarmDiagnosticsFixAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(android.solicitudesPermisoPantallaCompleta, antes + 1);
|
||||
});
|
||||
|
||||
testWidgets('optimizacion de bateria en atencion: el boton Fix llama a '
|
||||
'solicitarExencionBateria', (tester) async {
|
||||
final android =
|
||||
FakePuertoAlarmasAndroid()
|
||||
..fabricante = 'Google'
|
||||
..ignoraOptimizacionBateria = false;
|
||||
final estado = await _crearEstado(
|
||||
android: android,
|
||||
alarmas: [_alarmaActiva()],
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await _montarPantalla(tester, estado);
|
||||
|
||||
// Same delta reasoning as the exact-alarms test above: guardarAlarma's
|
||||
// own onboarding request already fired once for this field before the
|
||||
// screen mounts.
|
||||
final antes = android.solicitudesExencionBateria;
|
||||
await tester.tap(find.text(_l10n.alarmDiagnosticsFixAction));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(android.solicitudesExencionBateria, antes + 1);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'accion de sistema que falla (ROM sin esa pantalla) muestra un aviso '
|
||||
'en vez de fallar en silencio',
|
||||
(tester) async {
|
||||
final android =
|
||||
FakePuertoAlarmasAndroid()
|
||||
..fabricante = 'Google'
|
||||
..puedeProgramarExactas = false
|
||||
..fallaAccionSistema = true;
|
||||
final estado = await _crearEstado(
|
||||
android: android,
|
||||
alarmas: [_alarmaActiva()],
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await _montarPantalla(tester, estado);
|
||||
|
||||
await tester.tap(find.text(_l10n.alarmDiagnosticsFixAction));
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 300));
|
||||
|
||||
expect(
|
||||
find.text(_l10n.alarmDiagnosticsIntentUnavailable),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'alarmas nativas pendientes: con una alarma activa y conteo 0 muestra '
|
||||
'el aviso de atencion (la senal mas diagnostica del reporte)',
|
||||
(tester) async {
|
||||
final android =
|
||||
FakePuertoAlarmasAndroid()
|
||||
..fabricante = 'Google'
|
||||
..alarmasNativasPendientes = 0;
|
||||
final estado = await _crearEstado(
|
||||
android: android,
|
||||
alarmas: [_alarmaActiva()],
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await _montarPantalla(tester, estado);
|
||||
|
||||
expect(
|
||||
find.text(_l10n.alarmDiagnosticsNativeCountValue(0)),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.text(_l10n.alarmDiagnosticsNativeCountAttentionHint),
|
||||
findsOneWidget,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'alarmas nativas pendientes: con al menos una registrada no muestra '
|
||||
'el aviso de atencion',
|
||||
(tester) async {
|
||||
final android =
|
||||
FakePuertoAlarmasAndroid()
|
||||
..fabricante = 'Google'
|
||||
..alarmasNativasPendientes = 2;
|
||||
final estado = await _crearEstado(
|
||||
android: android,
|
||||
alarmas: [_alarmaActiva()],
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await _montarPantalla(tester, estado);
|
||||
|
||||
expect(
|
||||
find.text(_l10n.alarmDiagnosticsNativeCountValue(2)),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
find.text(_l10n.alarmDiagnosticsNativeCountAttentionHint),
|
||||
findsNothing,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('fabricante Xiaomi muestra la guia de autostart con el nombre '
|
||||
'interpolado', (tester) async {
|
||||
final android = FakePuertoAlarmasAndroid()..fabricante = 'Xiaomi';
|
||||
final estado = await _crearEstado(
|
||||
android: android,
|
||||
alarmas: [_alarmaActiva()],
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await _montarPantalla(tester, estado);
|
||||
|
||||
expect(find.text(_l10n.alarmDiagnosticsAutostartTitle), findsOneWidget);
|
||||
expect(
|
||||
find.text(_l10n.alarmDiagnosticsAutostartBody('Xiaomi')),
|
||||
findsOneWidget,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'diagnostico aun no disponible (null) no falla y muestra un aviso en '
|
||||
'vez de romper la pantalla',
|
||||
(tester) async {
|
||||
// No alarms saved either: guardarAlarma() itself populates
|
||||
// _diagnostico as a side effect of its own onboarding permission
|
||||
// check, so reaching a genuinely null diagnostic requires an
|
||||
// EstadoAlarmas that never called guardarAlarma or cargarDiagnostico.
|
||||
final android = FakePuertoAlarmasAndroid();
|
||||
final estado = await _crearEstado(
|
||||
android: android,
|
||||
cargarDiagnostico: false,
|
||||
);
|
||||
addTearDown(estado.dispose);
|
||||
addTearDown(android.dispose);
|
||||
|
||||
await _montarPantalla(tester, estado);
|
||||
|
||||
expect(find.text(_l10n.alarmDiagnosticsUnavailableHint), findsOneWidget);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -20,10 +20,16 @@ void main() {
|
||||
return true;
|
||||
case 'requestIgnoreBatteryOptimizations':
|
||||
return true;
|
||||
case 'openNotificationSettings':
|
||||
return true;
|
||||
case 'getActiveRingingAlarmId':
|
||||
return 'ring1';
|
||||
case 'stopActiveAlarm':
|
||||
return {'stopped': true, 'wasRinging': true, 'activeAlarmId': 'ring1'};
|
||||
return {
|
||||
'stopped': true,
|
||||
'wasRinging': true,
|
||||
'activeAlarmId': 'ring1',
|
||||
};
|
||||
}
|
||||
return null;
|
||||
});
|
||||
@@ -111,6 +117,22 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'abrirConfiguracionNotificaciones invoca openNotificationSettings '
|
||||
'(deep link a Settings, distinto del permiso runtime de la primera vez)',
|
||||
() async {
|
||||
final servicio = ServicioAlarmasAndroid(channel: channel);
|
||||
|
||||
final abierto = await servicio.abrirConfiguracionNotificaciones();
|
||||
|
||||
expect(abierto, isTrue);
|
||||
expect(
|
||||
llamadas.map((c) => c.method),
|
||||
contains('openNotificationSettings'),
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'detenerSonidoActivo mapea el resultado nativo confirmado a ResultadoDetencion',
|
||||
() async {
|
||||
@@ -144,21 +166,15 @@ void main() {
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'alarmaSonandoId propaga el error del canal (fail-toward-silence, '
|
||||
'Finding 2)',
|
||||
() async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
llamadas.add(call);
|
||||
throw PlatformException(code: 'QUERY_FAILED', message: 'boom');
|
||||
});
|
||||
final servicio = ServicioAlarmasAndroid(channel: channel);
|
||||
test('alarmaSonandoId propaga el error del canal (fail-toward-silence, '
|
||||
'Finding 2)', () async {
|
||||
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger
|
||||
.setMockMethodCallHandler(channel, (call) async {
|
||||
llamadas.add(call);
|
||||
throw PlatformException(code: 'QUERY_FAILED', message: 'boom');
|
||||
});
|
||||
final servicio = ServicioAlarmasAndroid(channel: channel);
|
||||
|
||||
expect(
|
||||
() => servicio.alarmaSonandoId(),
|
||||
throwsA(isA<PlatformException>()),
|
||||
);
|
||||
},
|
||||
);
|
||||
expect(() => servicio.alarmaSonandoId(), throwsA(isA<PlatformException>()));
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user