feat(alarms): native reliability fixes and end-to-end snooze

- Use mediaPlayback|systemExempted FGS type with FOREGROUND_SERVICE_SYSTEM_EXEMPTED so alarms fire on Android 14+ (FOREGROUND_SERVICE_ALARM does not exist in the SDK)
- Deduplicate fire notifications: the foreground service FSI notification is the single owner; receiver path removed
- Notification channel v2 with alarm sound URI and USAGE_ALARM attributes, one-time guarded migration from legacy channels
- Pass fallback station through the MethodChannel (NativeAlarmSpec schemaVersion 3) with a three-stage audio chain: primary -> fallback station -> bundled WAV
- Native fade-in volume ramp honoring fadeInSegundos when the app is killed
- Request battery-optimization exemption once, tracked with a persisted asked-once flag
- Fix snooze end-to-end: native ACTION_SNOOZE now reports back to Flutter (snoozed event + cold-start sync), snooze anchor unified to occurrence+minutes on both sides, periodic recalc no longer erases an active snooze
- Add snooze buttons (3/5/10/custom) to the ringing screen with shared audio teardown
- Redesign ringing screen on PluriWaveScaffold with reduced-motion-aware entry animation (new PluriAnimate helper)
- Alarm editor: live next-trigger preview, searchable station pickers (primary and fallback), configurable snooze duration, volume floor down to 0
- New alarm strings localized across all 13 locales
- New unit/widget tests for the snooze flow, alarm bridge payloads, ringing screen and editor (77 tests green)
- SDD artifacts for the app-quality-and-native-alarms change (explore, proposal, spec, design, tasks, apply progress)
This commit is contained in:
2026-06-11 15:33:30 +02:00
parent b5acf97ba4
commit f3e9487215
57 changed files with 4902 additions and 509 deletions
+65 -8
View File
@@ -10,7 +10,10 @@ import '../l10n/display_names.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/alarma_musical.dart';
import '../servicios/servicio_audio.dart';
import '../tema/pluri_animate.dart';
import '../tema/pluriwave_theme.dart';
import '../widgets/pluri_glass_surface.dart';
import '../widgets/pluri_wave_scaffold.dart';
class PantallaAlarmaSonando extends StatefulWidget {
const PantallaAlarmaSonando({
@@ -129,19 +132,49 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
);
}
/// Shared local-audio teardown for stop and snooze (Design 2.3): the Dart
/// fallback player and fade timer MUST die before the alarm is re-programmed
/// natively, otherwise the local fallback keeps looping after snooze.
Future<void> _liberarAudioLocal() async {
_fallbackTimer?.cancel();
_fadeInTimer?.cancel();
// cancel() detiene la entrega de eventos de forma sincrona; no se espera
// su Future porque puede no resolverse hasta que el stream se cierre.
unawaited(_estadoSub?.cancel());
_estadoSub = null;
await _fallbackPlayer.stop();
}
Future<void> _detener() async {
final radio = context.read<EstadoRadio>();
final alarmas = context.read<EstadoAlarmas>();
final navigator = Navigator.of(context);
_fallbackTimer?.cancel();
_fadeInTimer?.cancel();
await _estadoSub?.cancel();
await _fallbackPlayer.stop();
await _liberarAudioLocal();
await radio.audio.pausar();
await alarmas.finalizarEjecucion(widget.alarma.id);
if (mounted) navigator.pop();
}
/// Flutter-first snooze (S2-R1): tears down local audio, then routes
/// through the canonical EstadoAlarmas.posponerAlarma, which hides the
/// native notification (same stop path as dismiss) and re-programs Android.
Future<void> _posponer(int minutos) async {
final radio = context.read<EstadoRadio>();
final alarmas = context.read<EstadoAlarmas>();
final navigator = Navigator.of(context);
await _liberarAudioLocal();
await radio.audio.pausar();
await alarmas.posponerAlarma(widget.alarma, minutos);
if (mounted) navigator.pop();
}
List<int> _opcionesSnooze() {
final opciones = <int>{3, 5, 10};
final propio = widget.alarma.snoozeMinutos;
if (propio > 0) opciones.add(propio);
return opciones.toList()..sort();
}
@override
void dispose() {
_fallbackTimer?.cancel();
@@ -155,8 +188,12 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
Widget build(BuildContext context) {
final alarma = widget.alarma;
final l10n = AppLocalizations.of(context);
return Scaffold(
backgroundColor: const Color(0xFF061722),
final tokens = context.pluriTokens;
// Cold-GPU note (Design 2.4): PluriGlassSurface uses a BackdropFilter and
// the first frame after a screen-off FSI wake can stutter. The blur sigma
// is capped here, and reduced-motion users skip the entry animation
// entirely via pluriFadeIn.
return PluriWaveScaffold(
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(20),
@@ -164,7 +201,8 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
child: PluriGlassSurface(
borderRadius: BorderRadius.circular(32),
padding: const EdgeInsets.all(24),
glowColor: const Color(0xFFFFB86B).withValues(alpha: 0.35),
blurSigma: 10,
glowColor: tokens.warmCoral.withValues(alpha: 0.35),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
@@ -197,6 +235,25 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
textAlign: TextAlign.center,
),
const SizedBox(height: 22),
Text(
l10n.snoozeAction,
style: Theme.of(context).textTheme.labelLarge,
),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 8,
alignment: WrapAlignment.center,
children: [
for (final minutos in _opcionesSnooze())
OutlinedButton.icon(
onPressed: () => _posponer(minutos),
icon: const Icon(Icons.snooze_rounded),
label: Text(l10n.alarmSnoozeOptionLabel(minutos)),
),
],
),
const SizedBox(height: 14),
FilledButton.icon(
onPressed: _detener,
icon: const Icon(Icons.stop_rounded),
@@ -204,7 +261,7 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
),
],
),
),
).pluriFadeIn(context),
),
),
),