Files
pluriwave/lib/pantallas/pantalla_alarmas.dart
T
FreeTLab 97e38becfe fix(chrome): retire PluriScreenHeader, it has no equivalent in the prototype
PluriScreenHeader was a 38-radius glass hero: an aurora banner at 24%
opacity, a black-to-transparent scrim, two radial orbs, a 120px app-mark
watermark and a 56px tri-gradient glyph badge. None of it is in the
prototype (t4) -- every root's title is plain text in its own 56px
row, which PluriRootHeader (S1) already provides. Delete the class
(and its now-orphaned _Orb helper) and its four call sites (Buscar,
Favoritos x2, Alarmas, Ajustes landed with S8).

Two of those call sites carried the hero's only functional bit besides
the title, so PluriRootHeader gains an `actions` slot (rendered before
the shared bedtime button) to keep them reachable:
  - Alarmas' create-alarm button (FilledButton.tonalIcon, unchanged
    shape, just relocated)
  - Buscar's filters entry point (the same tappable PluriStatusPill,
    just relocated)
Every other retired trailing pill (Ajustes' "Secure" status,
Favoritos' collection-count badges, Alarmas' alarm-count badge) was
purely decorative and matches the prototype by simply disappearing.

S2, Tier 1 visual-fidelity pass (audit id 2521).
2026-07-29 21:29:49 +02:00

1257 lines
44 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: [
FilledButton.tonalIcon(
onPressed: () => _abrirEditor(context),
icon: const Icon(Icons.auto_awesome_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),
const SizedBox(height: 12),
],
_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 proxima = estado.proximaAlarma;
final activasSinProxima =
estado.alarmas
.where((a) => a.activa && a.proximaProgramable == null)
.length;
final proximaProgramable = proxima?.proximaProgramable;
return PluriGlassSurface(
glowColor: context.pluriTokens.warmCoral.withValues(alpha: 0.28),
child: Row(
children: [
_AssetIcon(
'assets/icons/alarmas/alarm_music.png',
size: 72,
semanticLabel: l10n.alarmIconLabel,
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
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(height: 10),
Align(
alignment: Alignment.centerLeft,
child: OutlinedButton.icon(
key: const ValueKey('hero-skip-next'),
onPressed: () => _saltarDesdeHero(context, proxima),
icon: const Icon(Icons.skip_next_rounded, size: 18),
label: Text(l10n.alarmHeroSkipAction),
),
),
],
],
),
),
],
),
);
}
}
/// 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: [
Text(
_hora(alarma),
style: Theme.of(
context,
).textTheme.displaySmall?.copyWith(
fontWeight: FontWeight.w900,
letterSpacing: -1.5,
),
),
const SizedBox(height: 2),
Text(estacion, overflow: TextOverflow.ellipsis),
],
),
),
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);
return Padding(
padding: EdgeInsets.fromLTRB(12, 12, 12, bottom + 12),
child: PluriGlassSurface(
borderRadius: BorderRadius.circular(28),
padding: const EdgeInsets.all(18),
child: Material(
type: MaterialType.transparency,
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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.
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),
// 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 (onSelected: null, the
// standard Material "greyed out" FilterChip state) outside
// diasSemana mode rather than being wired to silently mutate
// `_diasSemana` while a different `_tipo` is saved — no
// scheduling-data-model change, presentation only.
Wrap(
spacing: 6,
children: [
for (var i = DateTime.monday; i <= DateTime.sunday; i++)
FilterChip(
label: Text(_weekdayShort(l10n, i)),
selected: _diasSemana.contains(i),
onSelected:
_tipo == TipoProgramacionAlarma.diasSemana
? (selected) => setState(() {
selected
? _diasSemana.add(i)
: _diasSemana.remove(i);
})
: null,
),
],
),
const SizedBox(height: 12),
_vistaProximaEjecucion(l10n),
const SizedBox(height: 14),
_SectionLabel(
icon: 'assets/icons/alarmas/fallback_sound.png',
text: l10n.soundAndVolumeSection,
),
Slider(
value: _volumen,
// S2-R11: floor lowered from 0.25 to 0.0.
min: 0,
max: 1,
divisions: 20,
label: '${(_volumen * 100).round()}%',
onChanged: (value) => setState(() => _volumen = value),
),
const SizedBox(height: 8),
ListTile(
contentPadding: EdgeInsets.zero,
title: Text(
l10n.alarmFadeInTitle,
style: context.pluriType.cardTitle,
),
subtitle: Text(
_fadeInSegundos == 0
? l10n.alarmFadeInOff
: l10n.alarmFadeInSummary(_fadeInSegundos),
),
),
Slider(
value: _fadeInSegundos.toDouble(),
min: 0,
max: 60,
divisions: 60,
label: '${_fadeInSegundos}s',
onChanged:
(value) =>
setState(() => _fadeInSegundos = value.round()),
),
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),
// 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),
),
),
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: 8),
SwitchListTile.adaptive(
contentPadding: EdgeInsets.zero,
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),
),
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;
@override
Widget build(BuildContext context) {
return InkWell(
borderRadius: BorderRadius.circular(12),
onTap: onTap,
child: InputDecorator(
decoration: InputDecoration(
labelText: label,
prefixIcon: Icon(icon),
suffixIcon: const Icon(Icons.arrow_drop_down_rounded),
),
child: Text(value, overflow: TextOverflow.ellipsis),
),
);
}
}
/// 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);
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),
],
),
),
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;
}
}
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 _SectionLabel extends StatelessWidget {
const _SectionLabel({required this.icon, required this.text});
final String icon;
final String text;
@override
Widget build(BuildContext context) {
return Row(
children: [
_AssetIcon(icon, size: 34),
const SizedBox(width: 8),
// WU10: swapped the raw TextTheme lookup for the named type-scale
// token (cosmetic only — same weight class, now shared with every
// other card/section title in the redesign).
Text(text, style: context.pluriType.cardTitle),
],
);
}
}
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);