Files
pluriwave/lib/pantallas/pantalla_alarmas.dart
T
FreeTLab dcb415b3f8 fix(alarmas-editor): REPETIR eyebrow, grouped card, volume row
Audit 8.5 (t4:381): the "REPETIR" eyebrow above the weekday circles --
never rendered before. New ARB key alarmRepeatSectionLabel.

Audit 8.6 (t4:392-401): the station picker, volume, fade-in and
vacation toggle now share ONE bordered card with sangred divider lines
-- were four separately-spaced widgets. Restyled _CampoSelectorEmisora
as a flat nav row (icon/bold label/muted value/chevron, matching
FilaAjuste's own convention) so it fits inside the card instead of
drawing its own outlined InputDecoration chrome -- used by BOTH the
primary and fallback (Advanced) station fields. Snooze duration, "use
current station", and the Advanced section stay OUTSIDE the card,
exactly where they were (audit 8.8's own documented deliberate
extras). A transparent Material sits inside the card's coloured box so
the fade-in ListTile and the vacation SwitchListTile still paint their
ink/background correctly (the same class of constraint already
documented for PluriGlassSurface elsewhere in this codebase).

Audit 8.7 (t4:396): the volume row is now a compact 112px track with a
live percentage label -- was a bare full-width Slider with no visible
value. New ArtB key alarmVolumeLabel.

Item 8.8 (extra name/type-selector/snooze-selector/"use current
station"/Advanced fields) stays exactly as documented: deliberate,
required by the hard constraint that the editor keep its date field,
fallback-station picker and sound dropdown.
2026-07-30 16:42:04 +02:00

1684 lines
62 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_programacion_alarmas.dart';
import '../tema/pluriwave_theme.dart';
import '../tema/pluriwave_tokens.dart';
import '../widgets/editor_hora_inline.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_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 {
await showModalBottomSheet<void>(
context: context,
isScrollControlled: true,
useSafeArea: true,
backgroundColor: Colors.transparent,
builder: (_) => _EditorAlarmaSheet(alarma: alarma),
);
}
}
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);
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),
Text(
_recurrenciaCorta(l10n, alarma),
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),
),
),
),
],
),
],
),
),
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;
}
}
/// 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,
);
await estado.guardarAlarma(alarma);
if (mounted) Navigator.pop(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),
),
),
],
),
),
],
),
),
),
),
);
}
}
class _AccesoDiagnostico extends StatelessWidget {
const _AccesoDiagnostico({required this.estado});
final EstadoAlarmas estado;
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final diag = estado.diagnostico;
final exactStatus =
diag?.puedeProgramarExactas == true
? l10n.statusOk
: l10n.statusPending;
final notificationStatus =
diag?.notificacionesPermitidas == true
? l10n.statusOk
: l10n.statusPending;
final screenStatus =
diag?.puedeUsarPantallaCompleta == true
? l10n.statusOk
: l10n.statusPending;
return TextButton.icon(
icon: const _AssetIcon(
'assets/icons/alarmas/android_reliability.png',
size: 28,
),
label: Text(
diag == null
? l10n.androidReliabilityTitle
: l10n.androidReliabilityStatus(
exactStatus,
notificationStatus,
screenStatus,
),
),
onPressed: () async {
if (diag != null && !diag.puedeProgramarExactas) {
await estado.android.solicitarPermisoAlarmasExactas();
}
if (diag != null && !diag.notificacionesPermitidas) {
await estado.android.solicitarPermisoNotificaciones();
}
if (diag != null && !diag.puedeUsarPantallaCompleta) {
await estado.android.solicitarPermisoPantallaCompleta();
}
await estado.cargarDiagnostico();
},
);
}
}
/// 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): a compact recurrence label next to the alarm card's
/// giant time. Reuses the SAME generic labels the editor's own
/// `TipoProgramacionAlarma` `SegmentedButton` already shows (`oneTimeOption`
/// / `dailyOption` / `weekdaysOption`) rather than inventing a new, more
/// specific ARB string -- honest given the space (12px, next to a 34px
/// time) genuinely only fits a short word, not a full weekday list.
String _recurrenciaCorta(AppLocalizations l10n, AlarmaMusical alarma) {
return switch (alarma.tipoProgramacion) {
TipoProgramacionAlarma.diaria => l10n.dailyOption,
TipoProgramacionAlarma.diasSemana => l10n.weekdaysOption,
TipoProgramacionAlarma.unica => l10n.oneTimeOption,
};
}