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