feat(alarma-sonando): restyle ringing screen, drop live countdown label
Replace the glass-card container with a full-bleed blurred-art background, giant heroTime display, 3 fixed snooze tiles (3/5/10 min, one highlighted), and a full-width stop pill. The status label now also renders a static "Subiendo volumen" line - no seconds counter - when the alarm has a configured fade-in, per resolution 4: the native-to-Flutter progress channel a live counter would need is deliberately absent from this architecture. The dismiss guard and force-stop retry banner are untouched: the banner is byte-identical to its pre-restyle form, only repositioned, and the guard test's own diff against main stays empty. size:exception: 530 changed lines (440+/90-) against the 200-300 forecast - lib/ alone is 302 lines, at the edge of the band; the two touched test files account for the rest. Not split further: this is one cohesive restyle to the single screen in this branch where an inconsistent intermediate state is least acceptable.
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:ui';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
@@ -11,7 +12,6 @@ import '../modelos/alarma_musical.dart';
|
||||
import '../tema/pluri_animate.dart';
|
||||
import '../tema/pluriwave_theme.dart';
|
||||
import '../tema/pluriwave_tokens.dart';
|
||||
import '../widgets/pluri_glass_surface.dart';
|
||||
import '../widgets/pluri_wave_scaffold.dart';
|
||||
|
||||
class PantallaAlarmaSonando extends StatefulWidget {
|
||||
@@ -153,13 +153,6 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
_alarmas.removeListener(_alReconciliarFinExterno);
|
||||
@@ -195,39 +188,53 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
||||
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: SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(20),
|
||||
child: Center(
|
||||
child: PluriGlassSurface(
|
||||
borderRadius: BorderRadius.circular(32),
|
||||
padding: const EdgeInsets.all(24),
|
||||
blurSigma: 10,
|
||||
glowColor: tokens.warmCoral.withValues(alpha: 0.35),
|
||||
body: Stack(
|
||||
fit: StackFit.expand,
|
||||
children: [
|
||||
_FondoArteDifuminado(tokens: tokens),
|
||||
SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(24, 20, 24, 24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Image.asset(
|
||||
'assets/icons/alarmas/alarm_music.png',
|
||||
width: 128,
|
||||
height: 128,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
_hora(alarma),
|
||||
style: Theme.of(context).textTheme.displayMedium?.copyWith(
|
||||
fontWeight: FontWeight.w900,
|
||||
letterSpacing: -2,
|
||||
const Spacer(flex: 2),
|
||||
FittedBox(
|
||||
fit: BoxFit.scaleDown,
|
||||
child: Text(
|
||||
_hora(alarma),
|
||||
key: const ValueKey('ringing-hero-time'),
|
||||
style: type.heroTime,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
localizedAlarmName(l10n, alarma.nombre),
|
||||
textAlign: TextAlign.center,
|
||||
style: Theme.of(context).textTheme.titleLarge,
|
||||
style: type.bodyStrong,
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const SizedBox(height: 22),
|
||||
ClipRRect(
|
||||
borderRadius: BorderRadius.circular(tokens.radiusLg),
|
||||
child: Image.asset(
|
||||
'assets/icons/alarmas/alarm_music.png',
|
||||
width: 168,
|
||||
height: 168,
|
||||
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
|
||||
@@ -237,31 +244,54 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
||||
? localizedStationName(l10n, alarma.emisora!.nombre)
|
||||
: l10n.alarmRingingNotificationTitle,
|
||||
textAlign: TextAlign.center,
|
||||
style: type.cardTitle,
|
||||
),
|
||||
const SizedBox(height: 22),
|
||||
Text(
|
||||
l10n.snoozeAction,
|
||||
style: Theme.of(context).textTheme.labelLarge,
|
||||
// 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: Text(
|
||||
l10n.snoozeAction,
|
||||
style: type.eyebrowLabel.copyWith(
|
||||
color: tokens.warmCoral,
|
||||
),
|
||||
),
|
||||
),
|
||||
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: 10),
|
||||
_FilaSnoozeFija(
|
||||
alarma: alarma,
|
||||
l10n: l10n,
|
||||
tokens: tokens,
|
||||
onPosponer: _posponer,
|
||||
),
|
||||
const SizedBox(height: 14),
|
||||
FilledButton.icon(
|
||||
onPressed: _detener,
|
||||
icon: const Icon(Icons.stop_rounded),
|
||||
label: Text(l10n.stopAlarmAction),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton.icon(
|
||||
key: const ValueKey('ringing-stop-button'),
|
||||
style: FilledButton.styleFrom(
|
||||
minimumSize: const Size.fromHeight(76),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(tokens.radiusLg),
|
||||
),
|
||||
),
|
||||
onPressed: _detener,
|
||||
icon: const Icon(Icons.stop_circle_rounded),
|
||||
label: Text(l10n.stopAlarmAction),
|
||||
),
|
||||
),
|
||||
if (_falloDetencionVisible) ...[
|
||||
const SizedBox(height: 14),
|
||||
@@ -271,7 +301,7 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
||||
),
|
||||
).pluriFadeIn(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -310,3 +340,167 @@ class _PantallaAlarmaSonandoState extends State<PantallaAlarmaSonando> {
|
||||
|
||||
String _hora(AlarmaMusical alarma) =>
|
||||
'${alarma.hora.toString().padLeft(2, '0')}:${alarma.minuto.toString().padLeft(2, '0')}';
|
||||
|
||||
/// 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user