Files
pluriwave/lib/pantallas/pantalla_alarmas.dart
T
FreeTLab aa0b242374 feat(iap): add freemium unlock via one-time in-app purchase
Adds a permanent, non-consumable premium unlock (EstadoEntitlement +
PuertoCompras/ServicioComprasPlayBilling) that removes ads and unlocks
alarm vacations, alarms past a 5-alarm free cap, recording start, and
full Android Auto browsing. The phone equalizer stays free for everyone.

- Entitlement is prefs-backed (compra_premium_v1), fail-open, and
  resolvable headlessly via esPremiumPersistido() for the Android Auto
  audio handler, which registers before runApp.
- Android Auto reduced mode keeps the real root folder labels for free
  users; browsing into any of them (and playFromMediaId/playFromSearch/
  skipToNext/skipToPrevious) is blocked at the getChildren/servicio_audio
  choke points, with a locked "Función Premium" item as the backstop.
  Current-station play/pause/stop stays untouched. A free -> premium
  transition actively invalidates the head unit's cached browse tree.
- Ads (top banner + capped interstitial before adding a station or an
  alarm) are gated behind entitlement via ServicioAnuncios, using
  official Google test ad unit IDs pending AdMob provisioning.
- Alarm cap UX shows an explanatory message with a secondary unlock
  action rather than a bare paywall jump; existing data is grandfathered.
- 4 new localization keys translated across all 13 supported locales.

Co-located tests use strict TDD (RED test before implementation) for
every new pure-logic unit; full existing suite passes unchanged.
2026-08-10 20:37:07 +02:00

1912 lines
72 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../estado/estado_alarmas.dart';
import '../estado/estado_radio.dart';
import '../l10n/display_names.dart';
import '../l10n/formato_fechas.dart';
import '../l10n/app_localizations_ext.dart';
import '../l10n/gen/app_localizations.dart';
import '../modelos/alarma_musical.dart';
import '../modelos/emisora.dart';
import '../servicios/servicio_anuncios.dart';
import '../servicios/servicio_programacion_alarmas.dart';
import '../tema/pluriwave_theme.dart';
import '../tema/pluriwave_tokens.dart';
import '../widgets/editor_hora_inline.dart';
import '../widgets/hoja_premium.dart';
import '../widgets/pluri_glass_surface.dart';
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 {
const PantallaAlarmas({super.key});
@override
Widget build(BuildContext context) {
final estado = context.watch<EstadoAlarmas>();
final l10n = AppLocalizations.of(context);
return RefreshIndicator(
onRefresh: estado.refrescarProgramacion,
child: ListView(
padding: PluriLayout.pageListPadding,
children: [
// S1 (Tier 1 visual fidelity): the prototype has no global
// AppBar — this root now draws its own 56px title row instead of
// relying on app.dart's removed shared chrome (which is also
// where the sleep-timer action used to live).
// S2 (Tier 1 visual fidelity): PluriScreenHeader (the glass hero
// this used to be) is retired — it is not in the prototype at
// all. Its ONE functional bit, the create-alarm action, moves
// into PluriRootHeader's `actions` slot so it stays reachable.
PluriRootHeader(
title: l10n.alarmScreenTitle,
onSleepTimer: () => showPluriSleepTimerSheet(context),
actions: [
// Audit 7.1 (t4:325): a solid brand-teal pill with a plain
// `add` glyph -- was a tonal button with `auto_awesome`.
FilledButton.icon(
style: FilledButton.styleFrom(
backgroundColor: PluriWaveTokens.brand,
foregroundColor: const Color(0xFF062126),
padding: const EdgeInsets.symmetric(
horizontal: 14,
vertical: 9,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12),
),
textStyle: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w800,
),
),
onPressed: () => _abrirEditor(context),
icon: const Icon(Icons.add_rounded, size: 18),
label: Text(l10n.createAlarmAction),
),
],
),
Padding(
padding: PluriLayout.pageContentPadding,
child: Column(
children: [
_PanelProximaAlarma(estado: estado),
const SizedBox(height: 12),
if (estado.alarmas.isEmpty)
const _EmptyAlarmas()
else
for (final alarma in estado.alarmas) ...[
_TarjetaAlarma(alarma: alarma),
// 7.5 (Tier 4 visual fidelity): the prototype's gap
// between stacked alarm cards is 10px (t4 line 334),
// not 12. Keyed per alarm id so the guard test can
// target the exact gap between two known cards.
SizedBox(
height: 10,
key: ValueKey('alarm-card-gap-${alarma.id}'),
),
],
_PanelVacaciones(estado: estado),
const SizedBox(height: 12),
_AccesoDiagnostico(estado: estado),
],
),
),
],
),
);
}
Future<void> _abrirEditor(
BuildContext context, {
AlarmaMusical? alarma,
}) async {
// ADR-6 ordering (design.md): for a genuinely NEW alarm (no [alarma]),
// the cap-check + maybe-interstitial happen HERE, before the editor
// ever opens — "puedeCrearAlarma -> if false, show the limit message
// and no ad; if true, maybe-interstitial, then open the editor".
// Editing an existing alarm skips both checks entirely: it is never
// capped and never triggers the interstitial.
if (alarma == null) {
final estado = context.read<EstadoAlarmas>();
if (!estado.puedeCrearAlarma()) {
_mostrarLimiteAlarmas(context);
return;
}
await context.read<ServicioAnuncios>().intentarInterstitial();
if (!context.mounted) return;
}
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
builder: (_) => _EditorAlarmaSheet(alarma: alarma),
);
}
/// Freemium-gating spec "Alarm Cap UX Never Bare-Jumps To Paywall": an
/// explanatory message with a SECONDARY unlock action — never a direct
/// paywall navigation as the sole response to hitting the cap.
void _mostrarLimiteAlarmas(BuildContext context) {
final l10n = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.limiteAlarmasAlcanzado),
action: SnackBarAction(
label: l10n.desbloquearPremium,
onPressed: () => mostrarHojaPremium(context),
),
),
);
}
}
class _PanelProximaAlarma extends StatelessWidget {
const _PanelProximaAlarma({required this.estado});
final EstadoAlarmas estado;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final tokens = context.pluriTokens;
final proxima = estado.proximaAlarma;
final activasSinProxima =
estado.alarmas
.where((a) => a.activa && a.proximaProgramable == null)
.length;
final proximaProgramable = proxima?.proximaProgramable;
// Audit 7.2 (t4:326-330): warmCoral-tinted card, `alarm_on` icon at
// 26px, and the "Saltar" chip BESIDE the text on the same row -- was
// an opaque default card with a 72px PNG and the skip action stacked
// BELOW the text as an OutlinedButton.
return DecoratedBox(
key: const ValueKey('next-alarm-banner'),
decoration: BoxDecoration(
color: tokens.warmCoral.withValues(alpha: 0.13),
borderRadius: BorderRadius.circular(20),
border: Border.all(color: tokens.warmCoral.withValues(alpha: 0.34)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
child: Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Icon(Icons.alarm_on, size: 26, color: tokens.warmCoral),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
proxima == null
? activasSinProxima > 0
? l10n.activeAlarmsWithoutNextTitle
: l10n.noActiveAlarms
: l10n.nextAlarmTitle,
style: Theme.of(context).textTheme.titleMedium?.copyWith(
fontWeight: FontWeight.w900,
),
),
const SizedBox(height: 4),
Text(
proxima == null
? activasSinProxima > 0
? l10n.activeAlarmsWithoutNextSubtitle(
activasSinProxima,
)
: l10n.createAlarmHint
: '${_nombreVisibleAlarma(l10n, proxima)} · ${_fechaHora(l10n, proximaProgramable!)}',
),
],
),
),
if (proxima != null) ...[
const SizedBox(width: 8),
_ChipSaltar(onTap: () => _saltarDesdeHero(context, proxima)),
],
],
),
),
);
}
}
/// Audit 7.2 (t4:329): `padding:8px 12px;radius:10;rgba(255,255,255,.08)` --
/// plain text, no icon, unlike the previous `OutlinedButton.icon`.
class _ChipSaltar extends StatelessWidget {
const _ChipSaltar({required this.onTap});
final VoidCallback onTap;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return Material(
color: Colors.white.withValues(alpha: 0.08),
borderRadius: BorderRadius.circular(10),
child: InkWell(
key: const ValueKey('hero-skip-next'),
borderRadius: BorderRadius.circular(10),
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
child: Text(
l10n.alarmHeroSkipAction,
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w800),
),
),
),
);
}
}
/// Hero banner inline skip (native-alarms delta, WU8): skips the featured
/// (soonest-firing) alarm via the SAME `saltarProxima` path the old
/// always-visible per-card skip button used to call — only the trigger
/// location moved.
Future<void> _saltarDesdeHero(
BuildContext context,
AlarmaMusical proxima,
) async {
final l10n = AppLocalizations.of(context);
final estado = context.read<EstadoAlarmas>();
await estado.saltarProxima(proxima.id);
if (!context.mounted) return;
final actualizada = context.read<EstadoAlarmas>().alarmas.firstWhere(
(item) => item.id == proxima.id,
orElse: () => proxima,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
actualizada.proximaProgramable == null
? l10n.alarmSkippedNoNextSnackbar
: l10n.alarmSkippedReturnsSnackbar(
_fechaHora(l10n, actualizada.proximaProgramable!),
),
),
),
);
}
/// Simplified alarm card (native-alarms delta, WU8): giant time + station +
/// switch, no always-visible action row. Edit/skip/delete are NEVER lost —
/// they move behind gestures: tap opens the editor, swipe deletes (with
/// confirmation), skip lives on the hero banner (`_PanelProximaAlarma`)
/// instead of a per-card button.
class _TarjetaAlarma extends StatelessWidget {
const _TarjetaAlarma({required this.alarma});
final AlarmaMusical alarma;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final estado = context.watch<EstadoAlarmas>();
final tokens = context.pluriTokens;
final estacion =
alarma.emisora == null
? l10n.noStationUseInternalSound
: localizedStationName(l10n, alarma.emisora!.nombre);
// Item 5: surfaces the genuinely useful fields that already exist on
// the model, WITHOUT turning the row into clutter -- each is shown
// only when it is a meaningful deviation from the common case.
// Mirrors EXACTLY the pause predicate `impactoDeRango`/
// `ServicioProgramacionAlarmas` already use
// (`!sonarEnVacaciones` while `activa`), gated by whether a vacation
// range is CURRENTLY active -- an alarm configured to pause but with
// no active range right now is not actually paused by anything yet.
final pausadaPorVacaciones =
alarma.activa &&
!alarma.sonarEnVacaciones &&
estado.rangoVacacionesActivo() != null;
final detalles = <String>[
if (alarma.fadeInSegundos > 0)
l10n.alarmFadeInLabel(alarma.fadeInSegundos),
if ((alarma.volumen * 100).round() != 85)
'${(alarma.volumen * 100).round()}%',
if (pausadaPorVacaciones) l10n.alarmCardVacationPausedBadge,
];
// fix/alarmas-fallos-silenciosos: `ultimaExcepcionPara` existed but was
// never read from any screen, so a failed native scheduling attempt (main
// alarm, pre-notice, foreground service, or a post-boot reschedule)
// rendered exactly like a healthy alarm -- switched on, no visible sign
// anything was wrong. `_esValida` only ever treats `tipoSaltoSiguiente`
// as a real skip, so any OTHER tipo found here is a reliability failure,
// never a deliberate user action.
final ultimaExcepcion = estado.ultimaExcepcionPara(alarma.id);
final fallo =
ultimaExcepcion != null &&
ExcepcionAlarma.tiposFallo.contains(ultimaExcepcion.tipo)
? ultimaExcepcion
: null;
return Dismissible(
key: ValueKey('tarjeta-alarma-${alarma.id}'),
direction: DismissDirection.horizontal,
background: const _FondoSwipeEliminarAlarma(
alignment: Alignment.centerLeft,
),
secondaryBackground: const _FondoSwipeEliminarAlarma(
alignment: Alignment.centerRight,
),
confirmDismiss: (_) => _confirmarEliminarAlarma(context, l10n),
onDismissed: (_) => estado.eliminarAlarma(alarma.id),
child: PluriGlassSurface(
glowColor: tokens.electricMagenta.withValues(alpha: 0.22),
padding: EdgeInsets.zero,
child: Material(
type: MaterialType.transparency,
child: InkWell(
borderRadius: BorderRadius.circular(tokens.radiusMd),
onTap: () => _abrirEditor(context, alarma: alarma),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Audit 7.4 (t4:339): the recurrence label sits on
// the SAME baseline as the giant time -- it used to
// be missing entirely.
Row(
crossAxisAlignment: CrossAxisAlignment.baseline,
textBaseline: TextBaseline.alphabetic,
children: [
Text(
_hora(alarma),
style: Theme.of(
context,
).textTheme.displaySmall?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: -1.5,
),
),
const SizedBox(width: 8),
// Item 5: real day list can run longer than the
// old generic "Días" label -- Flexible+ellipsis
// keeps a long selection from overflowing the
// Row instead of clipping visibly.
Flexible(
child: Text(
_recurrenciaCorta(l10n, alarma),
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: Theme.of(context).colorScheme.onSurface
.withValues(alpha: 0.5),
),
),
),
],
),
const SizedBox(height: 7),
// Audit 7.4 (t4:341): a small station-art slot
// inline with the name -- was the name alone.
// `Emisora.favicon` is a network URL (the same
// hazard documented for the ringing screen's audit
// 9.2 -- `Image.network` here would hang widget
// tests without a mocked HttpClient), so this is a
// themed fallback icon, not the real per-station
// artwork, mirroring the recordings-row precedent
// (`pantalla_grabaciones.dart`'s `_FilaGrabacion`).
Row(
children: [
if (alarma.emisora != null) ...[
DecoratedBox(
key: const ValueKey('tarjeta-alarma-arte'),
decoration: BoxDecoration(
color: tokens.listSurface,
borderRadius: BorderRadius.circular(6),
),
child: SizedBox(
width: 22,
height: 22,
child: Icon(
Icons.radio_rounded,
size: 14,
color: Theme.of(context)
.colorScheme
.onSurface
.withValues(alpha: 0.75),
),
),
),
const SizedBox(width: 7),
],
Flexible(
child: Text(
estacion,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w600,
color: Theme.of(context).colorScheme.onSurface
.withValues(alpha: 0.75),
),
),
),
],
),
// Item 5: fade/volume/vacation-pause state, only
// when each is a genuinely useful deviation from
// the common case (see `detalles` above) -- a
// single compact line, not a badge per field.
if (detalles.isNotEmpty) ...[
const SizedBox(height: 3),
Text(
detalles.join(' · '),
key: ValueKey(
'tarjeta-alarma-detalles-${alarma.id}',
),
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.55),
),
),
],
if (fallo != null) ...[
const SizedBox(height: 6),
_AvisoFalloProgramacion(
alarmaId: alarma.id,
esSoloPreaviso:
fallo.tipo == ExcepcionAlarma.tipoFalloPreaviso,
),
],
],
),
),
const SizedBox(width: 8),
Switch.adaptive(
value: alarma.activa,
onChanged: (value) => estado.cambiarActiva(alarma, value),
),
],
),
),
),
),
),
);
}
void _abrirEditor(BuildContext context, {required AlarmaMusical alarma}) {
showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
builder: (_) => _EditorAlarmaSheet(alarma: alarma),
);
}
Future<bool> _confirmarEliminarAlarma(
BuildContext context,
AppLocalizations l10n,
) async {
final confirmado = await showDialog<bool>(
context: context,
builder:
(ctx) => AlertDialog(
title: Text(l10n.alarmDeleteConfirmTitle),
content: Text(l10n.alarmDeleteConfirmMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(l10n.cancelAction),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(l10n.deleteAction),
),
],
),
);
return confirmado ?? false;
}
}
/// Per-alarm scheduling-failure notice (fix/alarmas-fallos-silenciosos):
/// renders INSIDE the card's own content, in its own small tap target --
/// the surrounding card `InkWell` (tap = edit) and `Dismissible` (swipe =
/// delete) are untouched; this inner `InkWell` only claims its own region
/// and pushes the diagnostics screen instead of opening the editor.
class _AvisoFalloProgramacion extends StatelessWidget {
const _AvisoFalloProgramacion({
required this.alarmaId,
required this.esSoloPreaviso,
});
final String alarmaId;
/// True when only the pre-notice reminder failed (the alarm itself is
/// still scheduled) -- the user's reported symptom explicitly called out
/// a missing pre-notice as distinct from the alarm never ringing at all,
/// so the message must not conflate the two.
final bool esSoloPreaviso;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final color = Theme.of(context).colorScheme.error;
return Material(
type: MaterialType.transparency,
child: InkWell(
key: ValueKey('tarjeta-alarma-fallo-$alarmaId'),
borderRadius: BorderRadius.circular(8),
onTap: () => _abrirDiagnostico(context),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.warning_amber_rounded, size: 15, color: color),
const SizedBox(width: 6),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Text(
esSoloPreaviso
? l10n.alarmCardPreNoticeFailedMessage
: l10n.alarmCardSchedulingFailedMessage,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w600,
color: color,
),
),
const SizedBox(height: 2),
Text(
l10n.androidReliabilityReview,
style: TextStyle(
fontSize: 11,
fontWeight: FontWeight.w800,
color: color,
decoration: TextDecoration.underline,
),
),
],
),
),
],
),
),
),
);
}
void _abrirDiagnostico(BuildContext context) {
PluriPushScaffold.push(context, (_) => const PantallaDiagnosticoAlarmas());
}
}
/// Swipe-to-delete reveal, shown on both sides so either swipe direction
/// works regardless of locale text direction.
class _FondoSwipeEliminarAlarma extends StatelessWidget {
const _FondoSwipeEliminarAlarma({required this.alignment});
final Alignment alignment;
@override
Widget build(BuildContext context) {
final tokens = context.pluriTokens;
return Container(
alignment: alignment,
padding: const EdgeInsets.symmetric(horizontal: 24),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.error,
borderRadius: BorderRadius.circular(tokens.radiusMd),
),
child: Icon(
Icons.delete_outline_rounded,
color: Theme.of(context).colorScheme.onError,
),
);
}
}
class _EditorAlarmaSheet extends StatefulWidget {
const _EditorAlarmaSheet({this.alarma});
final AlarmaMusical? alarma;
@override
State<_EditorAlarmaSheet> createState() => _EditorAlarmaSheetState();
}
class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
TextEditingController? _nombreController;
late TimeOfDay _hora;
late DateTime _fecha;
late TipoProgramacionAlarma _tipo;
late Set<int> _diasSemana;
late double _volumen;
late int _fadeInSegundos;
late int _snoozeMinutos;
late bool _sonarEnVacaciones;
late SonidoInternoAlarma _sonidoInterno;
Emisora? _emisora;
Emisora? _emisoraFallback;
bool _favoritosSolicitados = false;
final ServicioProgramacionAlarmas _programacion =
ServicioProgramacionAlarmas();
@override
void initState() {
super.initState();
final alarma = widget.alarma;
final ahora = DateTime.now().add(const Duration(minutes: 5));
_hora = TimeOfDay(
hour: alarma?.hora ?? ahora.hour,
minute: alarma?.minuto ?? ahora.minute,
);
_fecha = alarma?.fechaUnica ?? ahora;
_tipo = alarma?.tipoProgramacion ?? TipoProgramacionAlarma.unica;
_diasSemana = {...alarma?.diasSemana ?? const <int>[]};
_volumen = alarma?.volumen ?? 0.85;
_fadeInSegundos = (alarma?.fadeInSegundos ?? 0).clamp(0, 60).toInt();
_sonarEnVacaciones = alarma?.sonarEnVacaciones ?? true;
_sonidoInterno = alarma?.sonidoInterno ?? SonidoInternoAlarma.amanecer;
_snoozeMinutos = alarma?.snoozeMinutos ?? 5;
_emisora = alarma?.emisora ?? context.read<EstadoRadio>().emisoraPreferida;
_emisoraFallback = alarma?.emisoraFallback;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Localizations cannot be read from initState (debug assert); the name
// controller is created lazily here on the first dependency pass.
if (_nombreController == null) {
final l10n = AppLocalizations.of(context);
final alarma = widget.alarma;
_nombreController = TextEditingController(
text:
alarma == null
? l10n.defaultAlarmName
: _nombreVisibleAlarma(l10n, alarma),
);
}
}
@override
void dispose() {
_nombreController?.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final radio = context.watch<EstadoRadio>();
final bottom = MediaQuery.of(context).viewInsets.bottom;
if (!_favoritosSolicitados) {
_favoritosSolicitados = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) context.read<EstadoRadio>().cargarFavoritos();
});
}
if (_emisora == null &&
widget.alarma == null &&
radio.emisoraPreferida != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted && _emisora == null) {
setState(() => _emisora = radio.emisoraPreferida);
}
});
}
final favoritas = _favoritasConSeleccion(radio.listaFavoritos);
// Audit 8.1 (t4 lines 372-374): opaque, bottom-anchored, full-bleed
// sheet with rounded TOP corners only and a grab handle — was a
// floating card (glass, 12px margin on all 4 sides, uniform radius).
return DecoratedBox(
key: const ValueKey('alarm-editor-sheet-surface'),
decoration: const BoxDecoration(
color: Color(0xFF0D1B24),
borderRadius: BorderRadius.vertical(top: Radius.circular(30)),
border: Border(top: BorderSide(color: Color(0x1CFFFFFF))),
),
child: Padding(
padding: EdgeInsets.fromLTRB(20, 14, 20, bottom + 24),
child: Material(
type: MaterialType.transparency,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Center(
child: Container(
key: const ValueKey('alarm-editor-grab-handle'),
width: 44,
height: 4,
decoration: BoxDecoration(
color: Colors.white.withValues(alpha: 0.22),
borderRadius: BorderRadius.circular(2),
),
),
),
const SizedBox(height: 14),
Row(
children: [
_AssetIcon(
'assets/icons/alarmas/alarm_music.png',
size: 58,
semanticLabel: l10n.alarmIconLabel,
),
const SizedBox(width: 12),
Expanded(
child: Text(
widget.alarma == null
? l10n.newAlarmTitle
: l10n.editAlarmTitle,
style: Theme.of(context).textTheme.titleLarge?.copyWith(
fontWeight: FontWeight.w900,
),
),
),
IconButton(
icon: const Icon(Icons.close_rounded),
onPressed: () => Navigator.pop(context),
),
],
),
const SizedBox(height: 14),
TextField(
controller: _nombreController,
decoration: InputDecoration(labelText: l10n.nameLabel),
),
const SizedBox(height: 16),
// WU10: the native showTimePicker dialog is replaced by a
// giant inline HH:MM editor (drag/tap to adjust); see
// `EditorHoraInline`, standalone-tested on its own.
//
// Audit 8.3 (t4 line 378): framed in its own card — radius
// 22, `listSurface` (#102532), a thin border — instead of
// sitting bare on the sheet background. `EditorHoraInline`
// itself is unchanged (no other consumer to keep in sync).
DecoratedBox(
key: const ValueKey('alarm-editor-hour-card'),
decoration: BoxDecoration(
color: context.pluriTokens.listSurface,
borderRadius: BorderRadius.circular(22),
border: Border.all(
color: Colors.white.withValues(alpha: 0.08),
),
),
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 18),
child: Center(
child: EditorHoraInline(
value: _hora,
onChanged: (nuevo) => setState(() => _hora = nuevo),
),
),
),
),
const SizedBox(height: 16),
SegmentedButton<TipoProgramacionAlarma>(
segments: [
ButtonSegment(
value: TipoProgramacionAlarma.unica,
label: Text(l10n.oneTimeOption),
),
ButtonSegment(
value: TipoProgramacionAlarma.diaria,
label: Text(l10n.dailyOption),
),
ButtonSegment(
value: TipoProgramacionAlarma.diasSemana,
label: Text(l10n.weekdaysOption),
),
],
selected: {_tipo},
onSelectionChanged:
(value) => setState(() => _tipo = value.first),
),
const SizedBox(height: 10),
// Audit 8.5 (t4 line 381): the "REPETIR" eyebrow above the
// weekday circles -- never rendered anywhere before.
Padding(
padding: const EdgeInsets.fromLTRB(4, 0, 4, 8),
child: Text(
l10n.alarmRepeatSectionLabel,
style: context.pluriType.eyebrowLabel,
),
),
// WU10: weekday circles are now ALWAYS visible (previously
// only inserted into the tree in diasSemana mode) — matching
// the mockup, which shows them unconditionally under the
// giant time. They stay disabled (habilitado: false, the
// same "greyed out, non-interactive" state the previous
// FilterChip used) outside diasSemana mode rather than
// being wired to silently mutate `_diasSemana` while a
// different `_tipo` is saved — no scheduling-data-model
// change, presentation only.
//
// Audit 8.4 (t4 lines 383-390): 7 circles (flex:1 each,
// aspect-ratio:1, gap 7) — was a `Wrap` of `FilterChip`s.
Row(
key: const ValueKey('alarm-weekday-circles'),
children: [
for (
var i = DateTime.monday;
i <= DateTime.sunday;
i++
) ...[
if (i > DateTime.monday) const SizedBox(width: 7),
Expanded(
child: _CirculoDiaSemana(
label: _weekdayShort(l10n, i),
seleccionado: _diasSemana.contains(i),
habilitado:
_tipo == TipoProgramacionAlarma.diasSemana,
onTap:
() => setState(() {
_diasSemana.contains(i)
? _diasSemana.remove(i)
: _diasSemana.add(i);
}),
),
),
],
],
),
const SizedBox(height: 12),
_vistaProximaEjecucion(l10n),
const SizedBox(height: 14),
// Audit 8.6 (t4 lines 392-401): the station picker, volume,
// fade-in and vacation toggle now share ONE bordered card
// with sangred divider lines between rows -- matching the
// prototype's own single grouped block -- instead of
// sitting bare on the sheet, each with its own gaps. The
// extra fields the prototype does NOT show (name, type
// selector, snooze selector, "use current station",
// Advanced) stay OUTSIDE this card, exactly where they
// were (documented as deliberate in audit 8.8 / WU10).
DecoratedBox(
key: const ValueKey('alarm-editor-grouped-card'),
decoration: BoxDecoration(
color: context.pluriTokens.listSurface,
borderRadius: BorderRadius.circular(18),
border: Border.all(
color: Colors.white.withValues(alpha: 0.08),
),
),
// A transparent Material sits directly inside the coloured
// box, closer to the fade-in ListTile and the vacation
// SwitchListTile than the DecoratedBox's own opaque fill
// -- both paint their ink/background on the NEAREST
// Material ancestor, and without this the box's colour
// would hide those effects (Flutter's own debug-mode
// check for exactly this).
child: Material(
type: MaterialType.transparency,
child: Column(
children: [
// S2-R9: searchable bottom-sheet picker instead of a
// dropdown, for the primary station. The backup
// (fallback) picker moves into the Advanced section
// below (WU10) — the primary choice stays a
// top-level field, only its secondary/backup sibling
// is now one tap further away.
_CampoSelectorEmisora(
key: const ValueKey('alarm-station-field'),
label: l10n.favoriteStationLabel,
icon: Icons.radio_rounded,
value:
_emisora == null
? l10n.noStationUseInternalSound
: localizedStationName(
l10n,
_emisora!.nombre,
),
onTap:
() => _elegirEmisora(
favoritas,
seleccionar:
(emisora) =>
setState(() => _emisora = emisora),
),
),
const Divider(height: 1, indent: 66),
// Audit 8.7 (t4 line 396): a compact 112px track with
// a trailing percentage label -- was a bare
// full-width Slider with no visible value.
_FilaVolumen(
volumen: _volumen,
onChanged:
(value) => setState(() => _volumen = value),
),
const Divider(height: 1, indent: 46),
Padding(
padding: const EdgeInsets.fromLTRB(14, 4, 14, 4),
child: ListTile(
contentPadding: EdgeInsets.zero,
title: Text(
l10n.alarmFadeInTitle,
style: context.pluriType.cardTitle,
),
subtitle: Text(
_fadeInSegundos == 0
? l10n.alarmFadeInOff
: l10n.alarmFadeInSummary(_fadeInSegundos),
),
),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 10),
child: Slider(
value: _fadeInSegundos.toDouble(),
min: 0,
max: 60,
divisions: 60,
label: '${_fadeInSegundos}s',
onChanged:
(value) => setState(
() => _fadeInSegundos = value.round(),
),
),
),
const Divider(height: 1, indent: 46),
SwitchListTile.adaptive(
value: _sonarEnVacaciones,
onChanged:
(value) =>
setState(() => _sonarEnVacaciones = value),
secondary: _AssetIcon(
'assets/icons/alarmas/vacation_wave.png',
size: 42,
semanticLabel: l10n.vacationIconLabel,
),
title: Text(l10n.playDuringVacations),
subtitle: Text(l10n.playDuringVacationsHint),
),
],
),
),
),
if (favoritas.isEmpty) ...[
const SizedBox(height: 6),
Text(l10n.saveFavoritesAlarmHint),
],
if (radio.emisoraActual != null) ...[
const SizedBox(height: 8),
Align(
alignment: Alignment.centerLeft,
child: FilledButton.tonalIcon(
onPressed:
() => setState(() => _emisora = radio.emisoraActual),
icon: const Icon(Icons.add_task_rounded),
label: Text(l10n.useCurrentStationAction),
),
),
],
const SizedBox(height: 12),
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(l10n.alarmSnoozeDurationTitle),
subtitle: Text(l10n.alarmSnoozeOptionLabel(_snoozeMinutos)),
),
SegmentedButton<int>(
segments: [
for (final minutos in _opcionesSnooze())
ButtonSegment(
value: minutos,
label: Text(l10n.alarmSnoozeOptionLabel(minutos)),
),
],
selected: {_snoozeMinutos},
onSelectionChanged:
(value) => setState(() => _snoozeMinutos = value.first),
),
const SizedBox(height: 8),
// WU10 (native-alarms delta — Alarm Editor Preserves Date,
// Fallback Station, and Sound Fields): the mockup's editor
// shows only the giant time + weekday circles, but the
// one-time date field, the fallback-station picker, and the
// sound dropdown are NOT dropped — they move here, one tap
// away, instead of being always inline.
ExpansionTile(
key: const ValueKey('alarm-advanced-section'),
tilePadding: EdgeInsets.zero,
title: Text(
l10n.alarmAdvancedSectionTitle,
style: context.pluriType.cardTitle,
),
children: [
_PickerButton(
icon: Icons.event_rounded,
label: l10n.dateField,
value: _fechaCorta(l10n, _fecha),
onTap:
_tipo == TipoProgramacionAlarma.unica
? _elegirFecha
: null,
),
const SizedBox(height: 8),
_CampoSelectorEmisora(
key: const ValueKey('alarm-fallback-station-field'),
label: l10n.alarmFallbackStationLabel,
icon: Icons.settings_backup_restore_rounded,
value:
_emisoraFallback == null
? l10n.noStationUseInternalSound
: localizedStationName(
l10n,
_emisoraFallback!.nombre,
),
onTap:
() => _elegirEmisora(
favoritas,
seleccionar:
(emisora) =>
setState(() => _emisoraFallback = emisora),
),
),
const SizedBox(height: 8),
DropdownButtonFormField<SonidoInternoAlarma>(
initialValue: _sonidoInterno,
decoration: InputDecoration(
labelText: l10n.internalSafeSoundLabel,
),
items: [
DropdownMenuItem(
value: SonidoInternoAlarma.amanecer,
child: Text(l10n.soundWarmSunrise),
),
DropdownMenuItem(
value: SonidoInternoAlarma.campanaSuave,
child: Text(l10n.soundSoftBell),
),
DropdownMenuItem(
value: SonidoInternoAlarma.pulsoDigital,
child: Text(l10n.soundDigitalPulse),
),
],
onChanged:
(value) => setState(
() => _sonidoInterno = value ?? _sonidoInterno,
),
),
const SizedBox(height: 8),
],
),
const SizedBox(height: 16),
FilledButton.icon(
onPressed: _guardar,
icon: const Icon(Icons.check_rounded),
label: Text(l10n.saveAlarmAction),
),
],
),
),
),
),
);
}
List<int> _opcionesSnooze() {
final opciones = <int>{3, 5, 10};
if (_snoozeMinutos > 0) opciones.add(_snoozeMinutos);
return opciones.toList()..sort();
}
/// Read-only next-trigger preview (S2-R8): computed from the in-progress
/// draft so the user can verify when the alarm will fire before saving.
/// Recomputed on every setState, so it tracks time/recurrence edits live.
Widget _vistaProximaEjecucion(AppLocalizations l10n) {
final estado = context.read<EstadoAlarmas>();
final borrador = AlarmaMusical(
id: widget.alarma?.id ?? '_borrador_editor',
nombre: 'preview',
hora: _hora.hour,
minuto: _hora.minute,
tipoProgramacion: _tipo,
diasSemana:
_tipo == TipoProgramacionAlarma.diasSemana
? (_diasSemana.toList()..sort())
: const [],
fechaUnica: _tipo == TipoProgramacionAlarma.unica ? _fecha : null,
sonarEnVacaciones: _sonarEnVacaciones,
);
final proxima = _programacion.calcularProxima(
alarma: borrador,
desde: DateTime.now(),
vacaciones: estado.vacaciones,
excepciones: estado.excepciones,
);
return _NoticeLine(
key: const ValueKey('next-trigger-preview'),
icon: Icons.event_available_rounded,
text:
proxima == null
? l10n.alarmNoNextExecution
: l10n.alarmNextExecution(_fechaHora(l10n, proxima)),
);
}
Future<void> _elegirEmisora(
List<Emisora> emisoras, {
required ValueChanged<Emisora?> seleccionar,
}) async {
final resultado = await showModalBottomSheet<_SeleccionEmisora>(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
builder: (_) => _SelectorEmisoraSheet(emisoras: emisoras),
);
if (resultado == null) return;
seleccionar(resultado.emisora);
}
Future<void> _elegirFecha() async {
final ahora = DateTime.now();
final nueva = await showDatePicker(
context: context,
initialDate: _fecha.isBefore(ahora) ? ahora : _fecha,
firstDate: DateTime(ahora.year, ahora.month, ahora.day),
lastDate: ahora.add(const Duration(days: 730)),
);
if (nueva != null) setState(() => _fecha = nueva);
}
Future<void> _guardar() async {
if (_tipo == TipoProgramacionAlarma.diasSemana && _diasSemana.isEmpty) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(AppLocalizations.of(context).chooseOneWeekdayError),
),
);
return;
}
final estado = context.read<EstadoAlarmas>();
final existente = widget.alarma;
final nombre = _nombreController?.text.trim() ?? '';
final alarma = (existente ??
estado.servicio.crearAlarma(
nombre: nombre,
hora: _hora.hour,
minuto: _hora.minute,
tipoProgramacion: _tipo,
diasSemana: _diasSemana.toList()..sort(),
))
.copyWith(
nombre:
nombre.isEmpty
? AppLocalizations.of(context).defaultAlarmName
: nombre,
hora: _hora.hour,
minuto: _hora.minute,
tipoProgramacion: _tipo,
diasSemana:
_tipo == TipoProgramacionAlarma.diasSemana
? (_diasSemana.toList()..sort())
: const [],
fechaUnica: _tipo == TipoProgramacionAlarma.unica ? _fecha : null,
limpiarFechaUnica: _tipo != TipoProgramacionAlarma.unica,
emisora: _emisora,
limpiarEmisora: _emisora == null,
emisoraFallback: _emisoraFallback,
limpiarEmisoraFallback: _emisoraFallback == null,
sonarEnVacaciones: _sonarEnVacaciones,
snoozeMinutos: _snoozeMinutos,
volumen: _volumen,
fadeInSegundos: _fadeInSegundos.clamp(0, 60).toInt(),
sonidoInterno: _sonidoInterno,
activa: true,
);
// The cap-check + interstitial already ran in `PantallaAlarmas
// ._abrirEditor` BEFORE this sheet ever opened (ADR-6 ordering: "then
// open the editor"). This is only the defense-in-depth backstop against
// the state-layer choke point — e.g. a 2nd device created alarms while
// this sheet was open — the true authority is `guardarAlarma` itself.
final resultado = await estado.guardarAlarma(alarma);
if (!mounted) return;
if (resultado == ResultadoGuardarAlarma.limiteAlcanzado) {
_mostrarLimiteAlarmas(context);
return;
}
Navigator.pop(context);
}
/// Freemium-gating spec "Alarm Cap UX Never Bare-Jumps To Paywall": an
/// explanatory message with a SECONDARY unlock action — never a direct
/// paywall navigation as the sole response to hitting the cap.
void _mostrarLimiteAlarmas(BuildContext context) {
final l10n = AppLocalizations.of(context);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(l10n.limiteAlarmasAlcanzado),
action: SnackBarAction(
label: l10n.desbloquearPremium,
onPressed: () => mostrarHojaPremium(context),
),
),
);
}
List<Emisora> _favoritasConSeleccion(List<Emisora> favoritas) {
final mapa = <String, Emisora>{};
for (final emisora in favoritas) {
mapa[emisora.uuid] = emisora;
}
final seleccionada = _emisora;
if (seleccionada != null) {
mapa[seleccionada.uuid] = seleccionada;
}
final respaldo = _emisoraFallback;
if (respaldo != null) {
mapa[respaldo.uuid] = respaldo;
}
return mapa.values.toList();
}
}
/// Result wrapper so the picker can distinguish "cancelled" (null result)
/// from "no station chosen" (emisora == null).
class _SeleccionEmisora {
const _SeleccionEmisora(this.emisora);
final Emisora? emisora;
}
class _CampoSelectorEmisora extends StatelessWidget {
const _CampoSelectorEmisora({
super.key,
required this.label,
required this.icon,
required this.value,
required this.onTap,
});
final String label;
final IconData icon;
final String value;
final VoidCallback onTap;
/// Audit 8.6 (t4:394): a flat nav row -- icon, bold label, muted value,
/// chevron -- matching the same convention `FilaAjuste` uses everywhere
/// else in Settings, so this fits cleanly inside the grouped card below
/// instead of drawing its own outlined `InputDecoration` chrome.
@override
Widget build(BuildContext context) {
final type = context.pluriType;
return Material(
type: MaterialType.transparency,
child: InkWell(
onTap: onTap,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11),
child: Row(
children: [
Icon(icon, size: 20),
const SizedBox(width: 12),
Expanded(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: type.cardTitle),
Text(
value,
overflow: TextOverflow.ellipsis,
style: type.bodyStrong.copyWith(
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.55),
),
),
],
),
),
Icon(
Icons.chevron_right_rounded,
size: 19,
color: Theme.of(
context,
).colorScheme.onSurface.withValues(alpha: 0.4),
),
],
),
),
),
);
}
}
/// Audit 8.7 (t4 line 396): a compact volume row -- icon, label, a 112px
/// track, and a trailing "80%" -- was a bare full-width `Slider` with no
/// visible current value. Constrains a real, still-draggable `Slider`
/// (not a static bar) to the prototype's 112px track width via a
/// `SliderTheme` + fixed-width `SizedBox`, preserving drag interactivity.
class _FilaVolumen extends StatelessWidget {
const _FilaVolumen({required this.volumen, required this.onChanged});
final double volumen;
final ValueChanged<double> onChanged;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final onSurface = Theme.of(context).colorScheme.onSurface;
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4),
child: Row(
children: [
Icon(
Icons.volume_up_rounded,
size: 20,
color: onSurface.withValues(alpha: 0.7),
),
const SizedBox(width: 12),
Expanded(
child: Text(
l10n.alarmVolumeLabel,
style: context.pluriType.cardTitle,
),
),
SizedBox(
width: 112,
height: 24,
child: SliderTheme(
data: SliderTheme.of(context).copyWith(
trackHeight: 4,
activeTrackColor: PluriWaveTokens.brand,
inactiveTrackColor: Colors.white.withValues(alpha: 0.14),
thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7),
overlayShape: const RoundSliderOverlayShape(overlayRadius: 14),
),
child: Slider(
value: volumen,
// S2-R11: floor lowered from 0.25 to 0.0.
min: 0,
max: 1,
divisions: 20,
label: '${(volumen * 100).round()}%',
onChanged: onChanged,
),
),
),
SizedBox(
width: 34,
child: Text(
'${(volumen * 100).round()}%',
textAlign: TextAlign.right,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w800,
color: onSurface.withValues(alpha: 0.7),
),
),
),
],
),
);
}
}
/// Searchable station picker (S2-R9): bottom sheet with a [SearchBar] over
/// the user's favorites, matching the main station-picker interaction.
class _SelectorEmisoraSheet extends StatefulWidget {
const _SelectorEmisoraSheet({required this.emisoras});
final List<Emisora> emisoras;
@override
State<_SelectorEmisoraSheet> createState() => _SelectorEmisoraSheetState();
}
class _SelectorEmisoraSheetState extends State<_SelectorEmisoraSheet> {
String _filtro = '';
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final bottom = MediaQuery.of(context).viewInsets.bottom;
final query = _filtro.trim().toLowerCase();
final filtradas =
widget.emisoras.where((emisora) {
if (query.isEmpty) return true;
return localizedStationName(
l10n,
emisora.nombre,
).toLowerCase().contains(query) ||
emisora.nombre.toLowerCase().contains(query);
}).toList();
return Padding(
padding: EdgeInsets.fromLTRB(12, 12, 12, bottom + 12),
child: PluriGlassSurface(
borderRadius: BorderRadius.circular(28),
padding: const EdgeInsets.all(18),
child: ConstrainedBox(
constraints: BoxConstraints(
maxHeight: MediaQuery.of(context).size.height * 0.7,
),
child: Material(
type: MaterialType.transparency,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
SearchBar(
hintText: l10n.alarmStationPickerSearchHint,
leading: const Icon(Icons.search_rounded),
onChanged: (value) => setState(() => _filtro = value),
),
const SizedBox(height: 10),
Flexible(
child: ListView(
shrinkWrap: true,
children: [
ListTile(
leading: const Icon(Icons.music_off_rounded),
title: Text(l10n.noStationUseInternalSound),
onTap:
() => Navigator.pop(
context,
const _SeleccionEmisora(null),
),
),
for (final emisora in filtradas)
ListTile(
leading: const Icon(Icons.radio_rounded),
title: Text(
localizedStationName(l10n, emisora.nombre),
overflow: TextOverflow.ellipsis,
),
onTap:
() => Navigator.pop(
context,
_SeleccionEmisora(emisora),
),
),
],
),
),
],
),
),
),
),
);
}
}
/// 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});
final EstadoAlarmas estado;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
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),
],
),
),
),
),
);
}
void _abrirDiagnostico(BuildContext context) {
PluriPushScaffold.push(context, (_) => const PantallaDiagnosticoAlarmas());
}
}
/// Vacation summary row (alarm-vacation-ranges delta, WU8): replaces the old
/// always-inline range list with a tap target showing range count + next-
/// range countdown, pushing the Vacaciones manager screen. WU9 owns the
/// destination screen's real content (`EstadoAlarmas` query additions per
/// design ADR-6); this row's tap target is a temporary placeholder until then
/// (see `_PantallaVacacionesTemporal` below).
class _PanelVacaciones extends StatelessWidget {
const _PanelVacaciones({required this.estado});
final EstadoAlarmas estado;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final tokens = context.pluriTokens;
final resumen = _resumenVacaciones(l10n, estado.vacaciones);
// Audit 7.3 (t4:332): a trailing date-range pill ("4-18 AGO") for the
// active-or-next range -- never rendered anywhere before.
final proximas = estado.vacacionesProximas();
final rangoRelevante =
estado.rangoVacacionesActivo() ??
(proximas.isEmpty ? null : proximas.first);
return PluriGlassSurface(
glowColor: PluriWaveTokens.skyBlue.withValues(alpha: 0.22),
padding: EdgeInsets.zero,
child: Material(
type: MaterialType.transparency,
child: InkWell(
key: const ValueKey('vacaciones-resumen'),
borderRadius: BorderRadius.circular(tokens.radiusMd),
onTap: () => _abrirVacaciones(context),
child: Padding(
padding: const EdgeInsets.all(16),
child: Row(
children: [
_AssetIcon(
'assets/icons/alarmas/vacation_wave.png',
size: 48,
semanticLabel: l10n.vacationIconLabel,
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
l10n.vacationRangesTitle,
style: Theme.of(context).textTheme.titleMedium
?.copyWith(fontWeight: FontWeight.w900),
),
const SizedBox(height: 4),
Text(resumen),
],
),
),
if (rangoRelevante != null) ...[
const SizedBox(width: 8),
_PildoraFechasVacaciones(rango: rangoRelevante),
],
const Icon(Icons.chevron_right_rounded),
],
),
),
),
),
);
}
void _abrirVacaciones(BuildContext context) {
PluriPushScaffold.push(context, (_) => const PantallaVacaciones());
}
/// Range count + next-range countdown, computed directly over the existing
/// `estado.vacaciones` (no new `EstadoAlarmas` query method — those are
/// design ADR-6's pure additions, owned by WU9).
String _resumenVacaciones(
AppLocalizations l10n,
List<RangoVacaciones> vacaciones,
) {
if (vacaciones.isEmpty) return l10n.noVacationRangesLoaded;
final ahora = DateTime.now();
final hoy = DateTime(ahora.year, ahora.month, ahora.day);
final conteo = l10n.vacationRangesCount(vacaciones.length);
RangoVacaciones? activo;
for (final rango in vacaciones) {
if (rango.contiene(ahora)) {
activo = rango;
break;
}
}
if (activo != null) {
final dias = activo.finDia.difference(hoy).inDays;
return '$conteo · ${l10n.vacationSummaryActiveCountdown(dias)}';
}
final futuros =
vacaciones.where((rango) => rango.inicioDia.isAfter(hoy)).toList()
..sort((a, b) => a.inicioDia.compareTo(b.inicioDia));
if (futuros.isNotEmpty) {
final dias = futuros.first.inicioDia.difference(hoy).inDays;
return '$conteo · ${l10n.vacationSummaryUpcomingCountdown(dias)}';
}
return conteo;
}
}
/// Audit 7.3 (t4:332): "4-18 AGO" -- `radius:999`, `liveGreen@.16` fill,
/// `liveGreen@.38` border, 10.5px/w800 in `liveGreen` (matches the
/// prototype's own `rgba(126,228,194,...)` teal, not the alarm banner's
/// warmCoral).
class _PildoraFechasVacaciones extends StatelessWidget {
const _PildoraFechasVacaciones({required this.rango});
final RangoVacaciones rango;
@override
Widget build(BuildContext context) {
final tokens = context.pluriTokens;
final locale = AppLocalizations.of(context).localeName;
return DecoratedBox(
decoration: BoxDecoration(
color: tokens.liveGreen.withValues(alpha: 0.16),
borderRadius: BorderRadius.circular(999),
border: Border.all(color: tokens.liveGreen.withValues(alpha: 0.38)),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
child: Text(
rangoFechasCorto(locale, rango.inicioDia, rango.finDia),
style: TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.w800,
color: tokens.liveGreen,
),
),
),
);
}
}
/// Audit 8.4 (t4 lines 383-390): one circular weekday button in the alarm
/// editor's REPETIR row — was a `FilterChip`. `aspect-ratio:1` in the
/// prototype is achieved here by the caller wrapping each instance in an
/// `Expanded` inside an `AspectRatio`-free `Row` — this widget's own
/// `AspectRatio(1)` does the squaring regardless of the column width the
/// `Row` assigns it.
class _CirculoDiaSemana extends StatelessWidget {
const _CirculoDiaSemana({
required this.label,
required this.seleccionado,
required this.habilitado,
required this.onTap,
});
final String label;
final bool seleccionado;
final bool habilitado;
final VoidCallback onTap;
/// t4 lines 384-388: the selected circle's TEXT is this specific dark
/// literal — this theme's `colorScheme.onPrimary` is `Colors.white`
/// (`pluriwave_theme.dart:23`), which would fail contrast against the
/// bright cyan fill. No existing token matches this exact value.
static const _textoSobreBrand = Color(0xFF062126);
@override
Widget build(BuildContext context) {
final tokens = context.pluriTokens;
final colorFondo =
seleccionado ? PluriWaveTokens.brand : tokens.listSurface;
final colorTexto =
seleccionado
? _textoSobreBrand
: Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6);
return AspectRatio(
aspectRatio: 1,
child: Material(
color: colorFondo,
shape: CircleBorder(
side:
seleccionado
? BorderSide.none
: BorderSide(color: Colors.white.withValues(alpha: 0.1)),
),
child: InkWell(
customBorder: const CircleBorder(),
onTap: habilitado ? onTap : null,
child: Center(
child: Text(
label,
style: TextStyle(
fontSize: 13,
fontWeight: seleccionado ? FontWeight.w800 : FontWeight.w700,
color:
habilitado ? colorTexto : colorTexto.withValues(alpha: 0.4),
),
),
),
),
),
);
}
}
class _AssetIcon extends StatelessWidget {
const _AssetIcon(this.asset, {this.size = 44, this.semanticLabel});
final String asset;
final double size;
/// S5-R2: meaningful images carry a label; without one the image is
/// treated as decorative and excluded from the semantics tree.
final String? semanticLabel;
@override
Widget build(BuildContext context) {
return Image.asset(
asset,
width: size,
height: size,
fit: BoxFit.contain,
semanticLabel: semanticLabel,
excludeFromSemantics: semanticLabel == null,
errorBuilder:
(_, __, ___) => Icon(Icons.music_note_rounded, size: size * 0.65),
);
}
}
class _PickerButton extends StatelessWidget {
const _PickerButton({
required this.icon,
required this.label,
required this.value,
required this.onTap,
});
final IconData icon;
final String label;
final String value;
final VoidCallback? onTap;
@override
Widget build(BuildContext context) {
return OutlinedButton.icon(
onPressed: onTap,
icon: Icon(icon),
label: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: Theme.of(context).textTheme.labelSmall),
Text(value),
],
),
);
}
}
class _NoticeLine extends StatelessWidget {
const _NoticeLine({super.key, required this.icon, required this.text});
final IconData icon;
final String text;
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(icon, size: 18),
const SizedBox(width: 8),
Expanded(child: Text(text)),
],
);
}
}
class _EmptyAlarmas extends StatelessWidget {
const _EmptyAlarmas();
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
return PluriGlassSurface(
child: Column(
children: [
_AssetIcon(
'assets/icons/alarmas/alarm_music.png',
size: 92,
semanticLabel: l10n.alarmIconLabel,
),
const SizedBox(height: 12),
Text(l10n.noAlarmsYetTitle),
const SizedBox(height: 4),
Text(l10n.noAlarmsYetSubtitle),
],
),
);
}
}
String _nombreVisibleAlarma(AppLocalizations l10n, AlarmaMusical alarma) {
return localizedAlarmName(l10n, alarma.nombre);
}
String _hora(AlarmaMusical alarma) =>
'${alarma.hora.toString().padLeft(2, '0')}:${alarma.minuto.toString().padLeft(2, '0')}';
String _fechaHora(AppLocalizations l10n, DateTime fecha) =>
l10n.dateTimeSentence(fecha);
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,
_ => '?',
};
// S5-R4: short dates follow the active locale (en-US = M/D/Y, ja = Y/M/D).
String _fechaCorta(AppLocalizations l10n, DateTime fecha) =>
fechaCortaLocalizada(l10n.localeName, fecha);
/// Audit 7.4 (t4:339) / item 5: a compact recurrence label next to the
/// alarm card's giant time. `diaria`/`unica` still show the SAME generic
/// labels the editor's own `TipoProgramacionAlarma` `SegmentedButton`
/// already uses (`dailyOption`/`oneTimeOption`) -- both are already fully
/// specific (there is nothing more concrete to say than "every day"/"just
/// once"). `diasSemana` now renders the alarm's ACTUAL configured days
/// (e.g. "Lun, Mié, Vie") instead of the generic `weekdaysOption` ("Días"),
/// reusing [_weekdayShort] (the SAME per-day abbreviation the editor's own
/// day-picker circles already use) -- no new ARB keys, no second
/// formatting scheme, and the resulting Text is wrapped in a
/// `Flexible`+ellipsis at the call site so a long selection never
/// overflows the row.
String _recurrenciaCorta(AppLocalizations l10n, AlarmaMusical alarma) {
return switch (alarma.tipoProgramacion) {
TipoProgramacionAlarma.diaria => l10n.dailyOption,
TipoProgramacionAlarma.diasSemana => _diasSemanaCorto(
l10n,
alarma.diasSemana,
),
TipoProgramacionAlarma.unica => l10n.oneTimeOption,
};
}
/// The real, ordered day abbreviations for a `diasSemana` alarm (item 5),
/// e.g. "Lun, Mié, Vie". [diasSemana] is re-sorted defensively (the editor
/// always persists it sorted, but this does not rely on that). Falls back
/// to the generic [AppLocalizations.weekdaysOption] label when
/// [diasSemana] is empty -- the editor already blocks saving an empty
/// selection in this mode, but a corrupt/legacy persisted record could
/// still reach here, and showing nothing would be worse than the old
/// generic label.
String _diasSemanaCorto(AppLocalizations l10n, List<int> diasSemana) {
if (diasSemana.isEmpty) return l10n.weekdaysOption;
final ordenados = [...diasSemana]..sort();
return ordenados.map((dia) => _weekdayShort(l10n, dia)).join(', ');
}