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:
2026-07-31 19:10:52 +02:00
parent ef9705a30e
commit 049ab78acb
35 changed files with 2262 additions and 79 deletions
+42 -40
View File
@@ -18,6 +18,7 @@ import '../widgets/pluri_layout.dart';
import '../widgets/pluri_push_scaffold.dart';
import '../widgets/pluri_root_header.dart';
import '../widgets/pluri_sleep_timer_sheet.dart';
import 'pantalla_diagnostico_alarmas.dart';
import 'pantalla_vacaciones.dart';
class PantallaAlarmas extends StatelessWidget {
@@ -340,8 +341,7 @@ class _TarjetaAlarma extends StatelessWidget {
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme
.onSurface
color: Theme.of(context).colorScheme.onSurface
.withValues(alpha: 0.5),
),
),
@@ -404,7 +404,9 @@ class _TarjetaAlarma extends StatelessWidget {
const SizedBox(height: 3),
Text(
detalles.join(' · '),
key: ValueKey('tarjeta-alarma-detalles-${alarma.id}'),
key: ValueKey(
'tarjeta-alarma-detalles-${alarma.id}',
),
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
@@ -1337,6 +1339,16 @@ class _SelectorEmisoraSheetState extends State<_SelectorEmisoraSheet> {
}
}
/// Entry point into the full Android alarm-reliability diagnostics screen
/// (fix/alarmas-fiabilidad). Was a one-line `TextButton.icon` that only ever
/// surfaced 3 of the 6 fields `DiagnosticoAlarmasAndroid` collects (exact
/// alarms, notifications, full-screen intent) and cycled all three
/// permission requests on a single tap; the two most diagnostic fields --
/// battery-optimization exemption and the native pending-alarm count, which
/// tells the user whether the alarm ever reached the OS at all -- were
/// gathered and never shown. Now a tap target row (mirrors
/// `_PanelVacaciones`'s shape) pushing `PantallaDiagnosticoAlarmas`, which
/// shows every signal individually with its own fix action.
class _AccesoDiagnostico extends StatelessWidget {
const _AccesoDiagnostico({required this.estado});
@@ -1345,47 +1357,37 @@ class _AccesoDiagnostico extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final diag = estado.diagnostico;
final exactStatus =
diag?.puedeProgramarExactas == true
? l10n.statusOk
: l10n.statusPending;
final notificationStatus =
diag?.notificacionesPermitidas == true
? l10n.statusOk
: l10n.statusPending;
final screenStatus =
diag?.puedeUsarPantallaCompleta == true
? l10n.statusOk
: l10n.statusPending;
return TextButton.icon(
icon: const _AssetIcon(
'assets/icons/alarmas/android_reliability.png',
size: 28,
),
label: Text(
diag == null
? l10n.androidReliabilityTitle
: l10n.androidReliabilityStatus(
exactStatus,
notificationStatus,
screenStatus,
final tokens = context.pluriTokens;
return PluriGlassSurface(
padding: EdgeInsets.zero,
child: Material(
type: MaterialType.transparency,
child: InkWell(
key: const ValueKey('diagnostico-alarmas-resumen'),
borderRadius: BorderRadius.circular(tokens.radiusMd),
onTap: () => _abrirDiagnostico(context),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
const _AssetIcon(
'assets/icons/alarmas/android_reliability.png',
size: 28,
),
const SizedBox(width: 10),
Expanded(child: Text(l10n.androidReliabilityReview)),
const Icon(Icons.chevron_right_rounded),
],
),
),
),
),
onPressed: () async {
if (diag != null && !diag.puedeProgramarExactas) {
await estado.android.solicitarPermisoAlarmasExactas();
}
if (diag != null && !diag.notificacionesPermitidas) {
await estado.android.solicitarPermisoNotificaciones();
}
if (diag != null && !diag.puedeUsarPantallaCompleta) {
await estado.android.solicitarPermisoPantallaCompleta();
}
await estado.cargarDiagnostico();
},
);
}
void _abrirDiagnostico(BuildContext context) {
PluriPushScaffold.push(context, (_) => const PantallaDiagnosticoAlarmas());
}
}
/// Vacation summary row (alarm-vacation-ranges delta, WU8): replaces the old
@@ -0,0 +1,293 @@
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_alarmas.dart';
import '../l10n/gen/app_localizations.dart';
import '../servicios/diagnostico_alarmas.dart';
import '../servicios/servicio_alarmas_android.dart';
import '../tema/pluriwave_theme.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_layout.dart';
import '../widgets/pluri_push_scaffold.dart';
/// Full Android alarm-reliability diagnostics screen (fix/alarmas-fiabilidad).
///
/// Replaces the old one-line `_AccesoDiagnostico` button in
/// `pantalla_alarmas.dart`, which only ever surfaced 3 of the 6 fields
/// `DiagnosticoAlarmasAndroid` collects. This screen shows all five
/// diagnosable signals with a clear ok/needs-attention state, a "Fix this"
/// action that opens the right system settings screen for each failing one,
/// plus manufacturer-specific guidance for vendors known to require manually
/// enabling Autostart.
class PantallaDiagnosticoAlarmas extends StatelessWidget {
const PantallaDiagnosticoAlarmas({super.key});
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final estado = context.watch<EstadoAlarmas>();
final diag = estado.diagnostico;
return PluriPushScaffold(
title: l10n.androidReliabilityTitle,
body:
diag == null
? ListView(
padding: PluriLayout.pageContentPadding,
children: [
PluriGlassSurface(
child: Text(l10n.alarmDiagnosticsUnavailableHint),
),
],
)
: _CuerpoDiagnostico(estado: estado, diag: diag),
);
}
}
class _CuerpoDiagnostico extends StatelessWidget {
const _CuerpoDiagnostico({required this.estado, required this.diag});
final EstadoAlarmas estado;
final DiagnosticoAlarmasAndroid diag;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final items = construirItemsDiagnosticoAlarmas(
diagnostico: diag,
hayAlarmasActivas: estado.alarmas.any((alarma) => alarma.activa),
);
final mostrarAutostart = fabricanteRequiereGuiaAutostart(diag.fabricante);
return ListView(
padding: PluriLayout.pageContentPadding,
children: [
for (final item in items) ...[
_FilaDiagnostico(item: item, estado: estado, diag: diag),
const SizedBox(height: 10),
],
const SizedBox(height: 6),
PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_FilaInformativa(
titulo: l10n.alarmDiagnosticsManufacturerLabel,
valor: diag.fabricante,
),
const SizedBox(height: 10),
_FilaInformativa(
titulo: l10n.alarmDiagnosticsSdkLabel,
valor: diag.versionSdk.toString(),
),
],
),
),
if (mostrarAutostart) ...[
const SizedBox(height: 16),
_GuiaAutostart(fabricante: diag.fabricante),
],
],
);
}
}
class _FilaDiagnostico extends StatelessWidget {
const _FilaDiagnostico({
required this.item,
required this.estado,
required this.diag,
});
final ItemDiagnosticoAlarma item;
final EstadoAlarmas estado;
final DiagnosticoAlarmasAndroid diag;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final tokens = context.pluriTokens;
final ok = item.estado == EstadoSenalDiagnostico.ok;
final color = ok ? tokens.liveGreen : Theme.of(context).colorScheme.error;
final esConteoNativo =
item.senal == SenalDiagnosticoAlarma.alarmasNativasPendientes;
return PluriGlassSurface(
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(
ok ? Icons.check_circle_rounded : Icons.warning_amber_rounded,
color: color,
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Expanded(
child: Text(
_tituloSenal(l10n, item.senal),
style: const TextStyle(fontWeight: FontWeight.w700),
),
),
Text(
ok
? l10n.statusOk
: l10n.alarmDiagnosticsNeedsAttentionStatus,
style: TextStyle(
color: color,
fontWeight: FontWeight.w700,
),
),
],
),
const SizedBox(height: 4),
if (esConteoNativo) ...[
Text(
l10n.alarmDiagnosticsNativeCountValue(
diag.alarmasNativasPendientes,
),
),
if (!ok) ...[
const SizedBox(height: 4),
Text(l10n.alarmDiagnosticsNativeCountAttentionHint),
],
] else
Text(_hintSenal(l10n, item.senal)),
if (!ok && item.accion != AccionDiagnosticoAlarma.ninguna) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerRight,
child: OutlinedButton(
onPressed: () => _ejecutarAccion(context, l10n),
child: Text(l10n.alarmDiagnosticsFixAction),
),
),
],
],
),
),
],
),
);
}
/// Runs the system action for [item.accion] and reloads the diagnostic
/// snapshot. Never throws across the widget boundary: every underlying
/// `PuertoAlarmasAndroid` call already reports `false` instead (native side
/// catches any intent-resolution failure), and a `false` here surfaces a
/// calm SnackBar instead of leaving the tap looking like a no-op.
Future<void> _ejecutarAccion(
BuildContext context,
AppLocalizations l10n,
) async {
final resuelto = switch (item.accion) {
AccionDiagnosticoAlarma.abrirAlarmasExactas =>
await estado.android.solicitarPermisoAlarmasExactas(),
AccionDiagnosticoAlarma.abrirNotificaciones =>
await estado.android.abrirConfiguracionNotificaciones(),
AccionDiagnosticoAlarma.abrirOptimizacionBateria =>
await estado.android.solicitarExencionBateria(),
AccionDiagnosticoAlarma.abrirPantallaCompleta =>
await estado.android.solicitarPermisoPantallaCompleta(),
AccionDiagnosticoAlarma.ninguna => true,
};
await estado.cargarDiagnostico();
if (!resuelto && context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text(l10n.alarmDiagnosticsIntentUnavailable)),
);
}
}
}
String _tituloSenal(AppLocalizations l10n, SenalDiagnosticoAlarma senal) =>
switch (senal) {
SenalDiagnosticoAlarma.alarmasExactas =>
l10n.alarmDiagnosticsExactAlarmsTitle,
SenalDiagnosticoAlarma.notificaciones =>
l10n.alarmDiagnosticsNotificationsTitle,
SenalDiagnosticoAlarma.pantallaCompleta =>
l10n.alarmDiagnosticsFullScreenTitle,
SenalDiagnosticoAlarma.optimizacionBateria =>
l10n.alarmDiagnosticsBatteryTitle,
SenalDiagnosticoAlarma.alarmasNativasPendientes =>
l10n.alarmDiagnosticsNativeCountTitle,
};
/// Static one-line explanation per signal. `alarmasNativasPendientes` builds
/// its own dynamic body in [_FilaDiagnostico] instead (count + conditional
/// attention hint), so this branch is never actually rendered for it -- kept
/// only so the switch stays exhaustive over the enum.
String _hintSenal(
AppLocalizations l10n,
SenalDiagnosticoAlarma senal,
) => switch (senal) {
SenalDiagnosticoAlarma.alarmasExactas => l10n.alarmDiagnosticsExactAlarmsHint,
SenalDiagnosticoAlarma.notificaciones =>
l10n.alarmDiagnosticsNotificationsHint,
SenalDiagnosticoAlarma.pantallaCompleta =>
l10n.alarmDiagnosticsFullScreenHint,
SenalDiagnosticoAlarma.optimizacionBateria =>
l10n.alarmDiagnosticsBatteryHint,
SenalDiagnosticoAlarma.alarmasNativasPendientes => '',
};
class _FilaInformativa extends StatelessWidget {
const _FilaInformativa({required this.titulo, required this.valor});
final String titulo;
final String valor;
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(titulo),
Text(valor, style: const TextStyle(fontWeight: FontWeight.w700)),
],
);
}
}
/// Manufacturer-specific autostart explanation (fix/alarmas-fiabilidad item
/// 3). Deliberately never claims the app can detect or grant this setting --
/// there is no public API for it, so this is explanation only, never an
/// action button.
class _GuiaAutostart extends StatelessWidget {
const _GuiaAutostart({required this.fabricante});
final String fabricante;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final tokens = context.pluriTokens;
return PluriGlassSurface(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.info_outline_rounded, color: tokens.warmCoral),
const SizedBox(width: 8),
Expanded(
child: Text(
l10n.alarmDiagnosticsAutostartTitle,
style: const TextStyle(fontWeight: FontWeight.w700),
),
),
],
),
const SizedBox(height: 8),
Text(l10n.alarmDiagnosticsAutostartBody(fabricante)),
],
),
);
}
}