Tier 4 visual fidelity, audit 9.1/9.5/9.6/9.8 (t4 lines 411-428): - 9.1: new _HaloPulsante renders the prototype's 420px amber radial gradient behind the hero content. Deliberately BOUNDED (one grow-and-settle cycle), not the prototype's literal `infinite` CSS animation: this screen's protected dismiss-guard test calls pumpAndSettle() after every mount/interaction, and a genuinely infinite AnimationController.repeat() would hang it forever with no way to fix it. Skips its Animate wrapper under reduced motion, same as every other entry animation in this app. - 9.5: the hero time gets a local letterSpacing/height override (-4/0.95) instead of the shared heroTime token's -2.0/1.0 — the alarm editor's hour block is the token's other consumer and still wants height:1, so the shared style is untouched. - 9.6: ringing-screen art grows from 168/radiusLg(30) to 180/36 (t4 line 421), a local constant like the existing stop-button radius. - 9.8: the POSPONER eyebrow regains its 19px warmCoral snooze icon. All 5 protected files (including the dismiss-guard test) remain empty-diff against main.
724 lines
28 KiB
Dart
724 lines
28 KiB
Dart
import 'dart:async';
|
|
import 'dart:ui';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
import 'package:flutter_animate/flutter_animate.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../estado/estado_alarmas.dart';
|
|
import '../l10n/display_names.dart';
|
|
import '../l10n/formato_fechas.dart';
|
|
import '../l10n/gen/app_localizations.dart';
|
|
import '../modelos/alarma_musical.dart';
|
|
import '../tema/pluri_animate.dart';
|
|
import '../tema/pluriwave_theme.dart';
|
|
import '../tema/pluriwave_tokens.dart';
|
|
import '../widgets/pluri_wave_scaffold.dart';
|
|
|
|
class PantallaAlarmaSonando extends StatefulWidget {
|
|
const PantallaAlarmaSonando({super.key, required this.alarma});
|
|
|
|
final AlarmaMusical alarma;
|
|
|
|
@override
|
|
State<PantallaAlarmaSonando> createState() => _PantallaAlarmaSonandoState();
|
|
}
|
|
|
|
class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
|
/// Single-exit guard: Stop, snooze and the system back gesture all funnel
|
|
/// into the same teardown; whichever lands first wins and the rest no-op,
|
|
/// so a back-press racing a button tap can never run the exit flow twice
|
|
/// (a second _dismissScreen would pop the route UNDER the alarm screen).
|
|
bool _salidaEnCurso = false;
|
|
|
|
/// Retryable force-stop affordance (Finding A, spec `alarm-stop-safety` /
|
|
/// "Retryable Force-Stop Affordance"): true while a VERIFIED stop failure
|
|
/// (or an unknown-state exception) is outstanding. Unlike a timed SnackBar,
|
|
/// this drives a persistent in-screen banner that stays until a confirmed
|
|
/// stop clears it — the ring is still audible while this is true, so the
|
|
/// screen intentionally does NOT dismiss.
|
|
bool _falloDetencionVisible = false;
|
|
|
|
late final EstadoAlarmas _alarmas;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_alarmas = context.read<EstadoAlarmas>();
|
|
_alarmas.addListener(_alReconciliarFinExterno);
|
|
}
|
|
|
|
/// External end-of-ring reconciliation (RES-1): if this alarm's occurrence
|
|
/// gets recorded as MISSED while this screen is up, auto-dismiss instead of
|
|
/// leaving a stale ringing screen with no audio behind it.
|
|
void _alReconciliarFinExterno() {
|
|
if (_salidaEnCurso || !mounted) return;
|
|
if (_alarmas.ultimaAlarmaPerdidaId != widget.alarma.id) return;
|
|
_salidaEnCurso = true;
|
|
_dismissScreen();
|
|
}
|
|
|
|
/// Pure UI: the ring's audio is owned entirely by the native
|
|
/// PluriWaveAlarmService. This screen only reports the outcome to
|
|
/// EstadoAlarmas; it never touches an audio player or a device-volume
|
|
/// channel.
|
|
Future<void> _detener() async {
|
|
if (_salidaEnCurso) return;
|
|
_salidaEnCurso = true;
|
|
final alarmas = context.read<EstadoAlarmas>();
|
|
final alarmaId = widget.alarma.id;
|
|
try {
|
|
await alarmas.finalizarEjecucion(alarmaId);
|
|
if (alarmas.error != null) {
|
|
// Verified stop failure (Finding A): the alarm is still ringing, so
|
|
// dismissing now would hide the only retry affordance. Reset the
|
|
// single-exit guard so a retry (this button again, back gesture, or
|
|
// the banner's own action below) can run the teardown again.
|
|
_salidaEnCurso = false;
|
|
if (mounted) setState(() => _falloDetencionVisible = true);
|
|
return;
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[PluriWave][alarmas] finalizar ejecucion fallo: $e');
|
|
// Unknown state (Finding A): treat exactly like a verified failure —
|
|
// stay and show the retry banner. The notification's native Stop
|
|
// action remains the out-of-band fallback, and PopScope already routes
|
|
// back through this same method on a subsequent back-press.
|
|
_salidaEnCurso = false;
|
|
if (mounted) setState(() => _falloDetencionVisible = true);
|
|
return;
|
|
}
|
|
if (mounted) _dismissScreen();
|
|
}
|
|
|
|
/// Retry action bound to the persistent force-stop banner (Finding A,
|
|
/// SS-3b): re-invokes the fail-safe stop directly; dismisses ONLY on a
|
|
/// confirmed success, otherwise the banner stays exactly as it was.
|
|
Future<void> _forzarDetencion() async {
|
|
if (_salidaEnCurso) return;
|
|
_salidaEnCurso = true;
|
|
final alarmas = context.read<EstadoAlarmas>();
|
|
await alarmas.forzarDetencion(widget.alarma.id);
|
|
if (alarmas.error == null) {
|
|
if (mounted) _dismissScreen();
|
|
} else {
|
|
// Verified failure (RES-2): reset the guard so the banner's own retry
|
|
// action (or another button) can run the teardown again.
|
|
_salidaEnCurso = false;
|
|
if (mounted) setState(() {});
|
|
}
|
|
}
|
|
|
|
/// Flutter-first snooze (S2-R1): 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 {
|
|
if (_salidaEnCurso) return;
|
|
_salidaEnCurso = true;
|
|
final alarmas = context.read<EstadoAlarmas>();
|
|
// Captured BEFORE dismiss (Design D4): the ringing screen dismisses by
|
|
// design below, so the messenger must outlive it for the failure
|
|
// SnackBar to still be shown.
|
|
final messenger = ScaffoldMessenger.of(context);
|
|
// posponerAlarma no longer throws on a native scheduling failure (it
|
|
// records the failure into EstadoAlarmas.error and always calls
|
|
// notifyListeners instead) — the dismiss-in-finally below is now a
|
|
// structural safety net, not a workaround for an expected throw. The
|
|
// screen still closes either way (dismiss-by-design); the failure is
|
|
// reported to the user via a SnackBar, not silently swallowed.
|
|
try {
|
|
await alarmas.posponerAlarma(widget.alarma, minutos);
|
|
final error = alarmas.error;
|
|
if (error != null) {
|
|
messenger.showSnackBar(SnackBar(content: Text(error)));
|
|
}
|
|
} catch (e) {
|
|
debugPrint('[PluriWave][alarmas] posponer alarma fallo: $e');
|
|
} finally {
|
|
if (mounted) _dismissScreen();
|
|
}
|
|
}
|
|
|
|
/// Dismisses the alarm screen safely in both live-app and dead-app states.
|
|
///
|
|
/// When the alarm screen is the root activity (launched via full-screen intent
|
|
/// from a dead app), [Navigator.canPop] returns false and calling
|
|
/// [Navigator.pop] would be a no-op. In that case [SystemNavigator.pop] is
|
|
/// used to call `Activity.finish()` and return to the home screen.
|
|
void _dismissScreen() {
|
|
final navigator = Navigator.of(context);
|
|
if (navigator.canPop()) {
|
|
navigator.pop();
|
|
} else {
|
|
SystemNavigator.pop();
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_alarmas.removeListener(_alReconciliarFinExterno);
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final alarma = widget.alarma;
|
|
final l10n = AppLocalizations.of(context);
|
|
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 PopScope(
|
|
// System back / predictive back must behave exactly like Stop: a plain
|
|
// route pop would run only dispose(), leaving the native ring audible
|
|
// with no alarm UI left to stop it (the native service is the sole
|
|
// audio owner and is torn down via the same finalizarEjecucion path).
|
|
canPop: false,
|
|
onPopInvokedWithResult: (didPop, _) {
|
|
if (didPop) return;
|
|
unawaited(_detener());
|
|
},
|
|
child: _cuerpo(context, alarma, l10n, tokens),
|
|
);
|
|
}
|
|
|
|
Widget _cuerpo(
|
|
BuildContext context,
|
|
AlarmaMusical alarma,
|
|
AppLocalizations l10n,
|
|
PluriWaveTokens tokens,
|
|
) {
|
|
final type = context.pluriType;
|
|
// WU11 (native-alarms delta — restyle, drop live countdown label):
|
|
// full-bleed blurred art replaces the PluriGlassSurface card. Cold-GPU
|
|
// note (Design 2.4) still applies to the entry animation below, which
|
|
// is why it stays on the foreground content only, not the background.
|
|
return PluriWaveScaffold(
|
|
body: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
_FondoArteDifuminado(tokens: tokens),
|
|
// Audit 9.1 (t4 line 411): the pulsing amber halo behind the
|
|
// hero content — see _HaloPulsante for why it is a BOUNDED pulse,
|
|
// not the prototype's literal `infinite` animation.
|
|
Positioned(
|
|
top: 150,
|
|
left: 0,
|
|
right: 0,
|
|
child: IgnorePointer(
|
|
child: Center(child: _HaloPulsante(tokens: tokens)),
|
|
),
|
|
),
|
|
SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(24, 20, 24, 24),
|
|
child: Column(
|
|
children: [
|
|
const Spacer(flex: 2),
|
|
// Audit 9.3 (t4 line 415): the schedule pill was missing
|
|
// entirely — built from `alarma.tipoProgramacion` (a field
|
|
// already on the domain model), no new plumbing.
|
|
_PildoraProgramacion(
|
|
texto: _resumenProgramacion(context, l10n, alarma),
|
|
tokens: tokens,
|
|
),
|
|
const SizedBox(height: 22),
|
|
FittedBox(
|
|
fit: BoxFit.scaleDown,
|
|
child: Text(
|
|
_hora(alarma),
|
|
key: const ValueKey('ringing-hero-time'),
|
|
// Audit 9.5 (t4 line 417): 88px/w800/ls-4/lh.95 on
|
|
// THIS screen only — a local override, not a change
|
|
// to the shared heroTime token (EditorHoraInline, the
|
|
// alarm editor's hour block, is the other consumer
|
|
// and wants height:1, t4 line 379).
|
|
style: type.heroTime.copyWith(
|
|
letterSpacing: -4,
|
|
height: 0.95,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
localizedAlarmName(l10n, alarma.nombre),
|
|
textAlign: TextAlign.center,
|
|
style: type.bodyStrong,
|
|
),
|
|
const SizedBox(height: 22),
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(_artworkRadio),
|
|
child: Image.asset(
|
|
'assets/icons/alarmas/alarm_music.png',
|
|
width: _artworkLado,
|
|
height: _artworkLado,
|
|
fit: BoxFit.cover,
|
|
errorBuilder:
|
|
(_, __, ___) => Icon(
|
|
Icons.music_note_rounded,
|
|
size: 96,
|
|
color: tokens.warmCoral,
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(height: 18),
|
|
// Static status line (Design D8): sourced only from
|
|
// widget.alarma, never from a live audio/player state — the
|
|
// ring's own audio state is owned natively and this screen
|
|
// has no channel back to it.
|
|
Text(
|
|
alarma.emisora != null
|
|
? localizedStationName(l10n, alarma.emisora!.nombre)
|
|
: l10n.alarmRingingNotificationTitle,
|
|
textAlign: TextAlign.center,
|
|
// Audit 9.7 (t4 line 423): 20px/w800/ls-.3 — this used to
|
|
// be `cardTitle` (14.5px/w700), 5.5px and 100 weight
|
|
// units under spec for the station name on a full-screen
|
|
// ringing surface.
|
|
style: type.cardTitle.copyWith(
|
|
fontSize: 20,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: -0.3,
|
|
),
|
|
),
|
|
// WU11 (native-alarms delta — Ringing Screen Shows a
|
|
// Static Status Label): only rendered while this alarm was
|
|
// actually configured with a fade-in; a STATIC label, no
|
|
// seconds suffix, no ticking value — the native→Flutter
|
|
// progress channel that a live countdown would need is
|
|
// deliberately absent from this architecture (resolution
|
|
// 4). Not spec-tested to also disappear once the fade-in
|
|
// period elapses: this screen has no clock signal to know
|
|
// when that is, and inventing one would be exactly the
|
|
// out-of-scope plumbing being avoided.
|
|
if (alarma.fadeInSegundos > 0) ...[
|
|
const SizedBox(height: 4),
|
|
_EstadoSubidaVolumen(l10n: l10n, tokens: tokens),
|
|
],
|
|
const Spacer(flex: 3),
|
|
Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
// Audit 9.8 (t4 line 428): the POSPONER eyebrow
|
|
// lost its snooze icon.
|
|
Icon(
|
|
Icons.snooze_rounded,
|
|
size: 19,
|
|
color: tokens.warmCoral,
|
|
),
|
|
const SizedBox(width: 8),
|
|
Text(
|
|
l10n.snoozeAction,
|
|
style: type.eyebrowLabel.copyWith(
|
|
color: tokens.warmCoral,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 10),
|
|
_FilaSnoozeFija(
|
|
alarma: alarma,
|
|
l10n: l10n,
|
|
tokens: tokens,
|
|
onPosponer: _posponer,
|
|
),
|
|
const SizedBox(height: 14),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: FilledButton.icon(
|
|
key: const ValueKey('ringing-stop-button'),
|
|
style: FilledButton.styleFrom(
|
|
// Audit 9.11 (t4 line 434): the prototype's Stop is a
|
|
// NEUTRAL translucent surface — not the brand cyan
|
|
// `colorScheme.primary` this used to render in. It is
|
|
// the largest element on a full-screen surface, so
|
|
// the wrong colour family was maximally visible.
|
|
backgroundColor: Colors.white.withValues(alpha: 0.08),
|
|
foregroundColor:
|
|
Theme.of(context).colorScheme.onSurface,
|
|
side: BorderSide(
|
|
color: Colors.white.withValues(alpha: 0.16),
|
|
),
|
|
minimumSize: const Size.fromHeight(76),
|
|
shape: RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(
|
|
_stopButtonRadius,
|
|
),
|
|
),
|
|
),
|
|
onPressed: _detener,
|
|
icon: const Icon(Icons.stop_circle_rounded),
|
|
label: Text(l10n.stopAlarmAction),
|
|
),
|
|
),
|
|
if (_falloDetencionVisible) ...[
|
|
const SizedBox(height: 14),
|
|
_bannerFalloDetencion(context, l10n, tokens),
|
|
],
|
|
],
|
|
),
|
|
).pluriFadeIn(context),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
/// Persistent force-stop retry banner (Finding A, spec `alarm-stop-safety`
|
|
/// / "Retryable Force-Stop Affordance"): an in-screen section rather than a
|
|
/// timed SnackBar, so it stays visible until [_forzarDetencion] confirms a
|
|
/// stop (or the screen is torn down externally) instead of auto-dismissing
|
|
/// after a fixed duration.
|
|
Widget _bannerFalloDetencion(
|
|
BuildContext context,
|
|
AppLocalizations l10n,
|
|
PluriWaveTokens tokens,
|
|
) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: tokens.warmCoral.withValues(alpha: 0.15),
|
|
borderRadius: BorderRadius.circular(16),
|
|
border: Border.all(color: tokens.warmCoral.withValues(alpha: 0.4)),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Text(l10n.alarmStopFailedMessage, textAlign: TextAlign.center),
|
|
const SizedBox(height: 8),
|
|
FilledButton.tonal(
|
|
onPressed: _forzarDetencion,
|
|
child: Text(l10n.alarmForceStopAction),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
String _hora(AlarmaMusical alarma) =>
|
|
'${alarma.hora.toString().padLeft(2, '0')}:${alarma.minuto.toString().padLeft(2, '0')}';
|
|
|
|
/// Audit 9.11 (t4 line 434): the Stop button's radius — doesn't match any of
|
|
/// [PluriWaveTokens]'s three named radii (14/18/30), so it stays a local
|
|
/// constant here rather than growing the shared token surface for a single
|
|
/// call site (mirrors `_ArteEscuchar._radio` in `pantalla_inicio.dart`).
|
|
const _stopButtonRadius = 24.0;
|
|
|
|
/// Audit 9.6 (t4 line 421): the ringing screen's art is 180x180 with a 36
|
|
/// corner radius — neither matches [PluriWaveTokens]'s three named radii
|
|
/// (14/18/30), so this stays a local constant (same precedent as
|
|
/// [_stopButtonRadius] above).
|
|
const _artworkLado = 180.0;
|
|
const _artworkRadio = 36.0;
|
|
|
|
/// Audit 9.3: the schedule pill's text, built only from
|
|
/// [AlarmaMusical.tipoProgramacion] and the fields it already carries per
|
|
/// case (`diasSemana`, `fechaUnica`) — no new domain plumbing. Reuses the
|
|
/// `alarmScheduleOnce`/`alarmScheduleWeekdays` ARB keys, which existed
|
|
/// already but had no consumer anywhere in the app.
|
|
String _resumenProgramacion(
|
|
BuildContext context,
|
|
AppLocalizations l10n,
|
|
AlarmaMusical alarma,
|
|
) {
|
|
switch (alarma.tipoProgramacion) {
|
|
case TipoProgramacionAlarma.diaria:
|
|
return l10n.alarmScheduleDaily;
|
|
case TipoProgramacionAlarma.unica:
|
|
final localeTag = Localizations.localeOf(context).toString();
|
|
return l10n.alarmScheduleOnce(
|
|
fechaCortaLocalizada(localeTag, alarma.fechaUnica ?? DateTime.now()),
|
|
);
|
|
case TipoProgramacionAlarma.diasSemana:
|
|
final dias = (List<int>.from(alarma.diasSemana)
|
|
..sort()).map((d) => _weekdayShort(l10n, d)).join(', ');
|
|
return l10n.alarmScheduleWeekdays(dias);
|
|
}
|
|
}
|
|
|
|
// Mirrors `pantalla_alarmas.dart`'s private `_weekdayShort` (kept local
|
|
// rather than shared/exported: this screen's only other tie to the alarm
|
|
// editor is the domain model itself, and duplicating a 7-line switch is
|
|
// cheaper than adding a cross-screen import for it).
|
|
String _weekdayShort(AppLocalizations l10n, int day) => switch (day) {
|
|
DateTime.monday => l10n.weekdayShortMonday,
|
|
DateTime.tuesday => l10n.weekdayShortTuesday,
|
|
DateTime.wednesday => l10n.weekdayShortWednesday,
|
|
DateTime.thursday => l10n.weekdayShortThursday,
|
|
DateTime.friday => l10n.weekdayShortFriday,
|
|
DateTime.saturday => l10n.weekdayShortSaturday,
|
|
DateTime.sunday => l10n.weekdayShortSunday,
|
|
_ => '?',
|
|
};
|
|
|
|
/// Schedule pill (audit 9.3, t4 line 415): `alarm` icon + schedule summary
|
|
/// on a warmCoral-tinted pill, matching the prototype's
|
|
/// `rgba(244,184,96,.16)` fill / `rgba(244,184,96,.45)` border exactly.
|
|
class _PildoraProgramacion extends StatelessWidget {
|
|
const _PildoraProgramacion({required this.texto, required this.tokens});
|
|
|
|
final String texto;
|
|
final PluriWaveTokens tokens;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return DecoratedBox(
|
|
key: const ValueKey('ringing-schedule-pill'),
|
|
decoration: BoxDecoration(
|
|
color: tokens.warmCoral.withValues(alpha: 0.16),
|
|
borderRadius: BorderRadius.circular(999),
|
|
border: Border.all(color: tokens.warmCoral.withValues(alpha: 0.45)),
|
|
),
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 8),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Icon(Icons.alarm, size: 18, color: tokens.warmCoral),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
texto,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w800,
|
|
letterSpacing: 0.84,
|
|
color: tokens.warmCoral,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Pulsing amber halo (audit 9.1, t4 line 411): a 420x420 radial gradient
|
|
/// centered behind the hero content, echoing the alarm's warmCoral accent.
|
|
///
|
|
/// BOUNDED, not infinite: the prototype's CSS is `animation: pw-pulse 2.4s
|
|
/// ease-in-out infinite`, but this screen's dismiss guard
|
|
/// (`pantalla_alarma_sonando_dismiss_guard_test.dart`, protected — must stay
|
|
/// byte-identical to `main`) calls `pumpAndSettle()` after every mount and
|
|
/// every interaction. A genuinely infinite `AnimationController.repeat()`
|
|
/// anywhere in this widget's subtree would hang every one of those calls
|
|
/// forever, with no way to fix it since that file cannot be edited (see
|
|
/// `_EstadoSubidaVolumen` above for the same reasoning applied earlier on
|
|
/// this exact screen). One grow-and-settle cycle, timed to the prototype's
|
|
/// own 2.4s cadence, delivers the same "draws the eye" motion without ever
|
|
/// leaving a frame scheduled forever. Respects reduced motion exactly like
|
|
/// every other entry animation in this app (`PluriAnimate`).
|
|
class _HaloPulsante extends StatelessWidget {
|
|
const _HaloPulsante({required this.tokens});
|
|
|
|
final PluriWaveTokens tokens;
|
|
|
|
static const _lado = 420.0;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final halo = Container(
|
|
key: const ValueKey('ringing-pulse-halo'),
|
|
width: _lado,
|
|
height: _lado,
|
|
decoration: BoxDecoration(
|
|
shape: BoxShape.circle,
|
|
gradient: RadialGradient(
|
|
colors: [
|
|
tokens.warmCoral.withValues(alpha: 0.2),
|
|
tokens.warmCoral.withValues(alpha: 0),
|
|
],
|
|
stops: const [0, 0.62],
|
|
),
|
|
),
|
|
);
|
|
if (MediaQuery.maybeDisableAnimationsOf(context) ?? false) {
|
|
return halo;
|
|
}
|
|
return halo
|
|
.animate()
|
|
.scaleXY(
|
|
begin: 1,
|
|
end: 1.08,
|
|
duration: 1200.ms,
|
|
curve: Curves.easeInOut,
|
|
)
|
|
.then()
|
|
.scaleXY(
|
|
begin: 1.08,
|
|
end: 1,
|
|
duration: 1200.ms,
|
|
curve: Curves.easeInOut,
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Full-bleed blurred backdrop (WU11, replaces the `PluriGlassSurface` card
|
|
/// container per task 11.3). This app has no per-station artwork/favicon
|
|
/// safe to render here: `Emisora.favicon` is a network URL, and rendering
|
|
/// one via `Image.network` inside a widget test hangs/throws without a
|
|
/// mocked `HttpClient` — a hazard no other screen in this codebase accepts
|
|
/// either. The existing bundled alarm asset is reused instead, heavily
|
|
/// blurred and stretched; purely decorative, not spec-tested.
|
|
class _FondoArteDifuminado extends StatelessWidget {
|
|
const _FondoArteDifuminado({required this.tokens});
|
|
|
|
final PluriWaveTokens tokens;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Positioned.fill(
|
|
key: const ValueKey('ringing-background-art'),
|
|
child: Stack(
|
|
fit: StackFit.expand,
|
|
children: [
|
|
ImageFiltered(
|
|
imageFilter: ImageFilter.blur(sigmaX: 44, sigmaY: 44),
|
|
child: Opacity(
|
|
opacity: 0.5,
|
|
child: Image.asset(
|
|
'assets/icons/alarmas/alarm_music.png',
|
|
fit: BoxFit.cover,
|
|
errorBuilder: (_, __, ___) => const SizedBox.shrink(),
|
|
),
|
|
),
|
|
),
|
|
DecoratedBox(
|
|
decoration: BoxDecoration(
|
|
gradient: LinearGradient(
|
|
begin: Alignment.topCenter,
|
|
end: Alignment.bottomCenter,
|
|
colors: [
|
|
tokens.deepViolet.withValues(alpha: 0.55),
|
|
tokens.deepViolet.withValues(alpha: 0.9),
|
|
tokens.deepViolet,
|
|
],
|
|
stops: const [0, 0.55, 1],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Static "turning up the volume" status (native-alarms delta, WU11 —
|
|
/// Ringing Screen Shows a Static Status Label): a dot + label, no
|
|
/// `AnimationController`/`Animate` anywhere in this widget. A pulsing dot
|
|
/// would reintroduce the exact "`pumpAndSettle()` never terminates" hazard
|
|
/// WU5 documented for `VisualizadorAudio`'s own repeating controller — this
|
|
/// screen must stay safe for `pumpAndSettle()` in every other existing test.
|
|
class _EstadoSubidaVolumen extends StatelessWidget {
|
|
const _EstadoSubidaVolumen({required this.l10n, required this.tokens});
|
|
|
|
final AppLocalizations l10n;
|
|
final PluriWaveTokens tokens;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
key: const ValueKey('estado-subida-volumen'),
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(
|
|
width: 6,
|
|
height: 6,
|
|
decoration: BoxDecoration(
|
|
color: tokens.liveGreen,
|
|
shape: BoxShape.circle,
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(
|
|
l10n.alarmVolumeRisingStatus,
|
|
style: context.pluriType.bodyStrong.copyWith(color: tokens.liveGreen),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// The ringing screen's snooze row (native-alarms delta, WU11): exactly 3
|
|
/// FIXED tiles (3/5/10 min), replacing the previous variable-length `Wrap`
|
|
/// that grew a 4th tile for a custom `snoozeMinutos`. **Design decision, not
|
|
/// spec-tested** (WU11 has no ADR): the highlighted (filled) tile is
|
|
/// whichever of the 3 matches `alarma.snoozeMinutos`; the alarm's own
|
|
/// editor-configured value still decides WHICH tile is filled, tapping any
|
|
/// tile still snoozes for exactly that tile's duration (`_posponer` is
|
|
/// called with the tapped value, never the alarm's stored default). If the
|
|
/// alarm's own value isn't one of the three — only reachable via a fixture
|
|
/// or a pre-redesign save, since the editor's own snooze picker only ever
|
|
/// offers `{3, 5, 10, current}` — 10 is the default highlight, matching the
|
|
/// mockup's own "10 min · habitual" example.
|
|
class _FilaSnoozeFija extends StatelessWidget {
|
|
const _FilaSnoozeFija({
|
|
required this.alarma,
|
|
required this.l10n,
|
|
required this.tokens,
|
|
required this.onPosponer,
|
|
});
|
|
|
|
final AlarmaMusical alarma;
|
|
final AppLocalizations l10n;
|
|
final PluriWaveTokens tokens;
|
|
final ValueChanged<int> onPosponer;
|
|
|
|
static const _opciones = [3, 5, 10];
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final destacado =
|
|
_opciones.contains(alarma.snoozeMinutos) ? alarma.snoozeMinutos : 10;
|
|
return Row(
|
|
children: [
|
|
for (final minutos in _opciones) ...[
|
|
if (minutos != _opciones.first) const SizedBox(width: 10),
|
|
_tile(minutos, minutos == destacado),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
|
|
Widget _tile(int minutos, bool esDestacado) {
|
|
final forma = RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.circular(tokens.radiusMd),
|
|
);
|
|
final etiqueta = Text(l10n.alarmSnoozeOptionLabel(minutos));
|
|
return Expanded(
|
|
flex: esDestacado ? 3 : 2,
|
|
child: SizedBox(
|
|
height: 76,
|
|
child:
|
|
esDestacado
|
|
? FilledButton(
|
|
onPressed: () => onPosponer(minutos),
|
|
style: FilledButton.styleFrom(
|
|
backgroundColor: tokens.warmCoral,
|
|
foregroundColor: tokens.deepViolet,
|
|
shape: forma,
|
|
),
|
|
child: etiqueta,
|
|
)
|
|
: OutlinedButton(
|
|
onPressed: () => onPosponer(minutos),
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: tokens.warmCoral,
|
|
backgroundColor: tokens.warmCoral.withValues(alpha: 0.16),
|
|
side: BorderSide(
|
|
color: tokens.warmCoral.withValues(alpha: 0.4),
|
|
),
|
|
shape: forma,
|
|
),
|
|
child: etiqueta,
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|