Files
pluriwave/lib/pantallas/pantalla_alarmas.dart
T
FreeTLab 78a7415dd8 fix(chrome): drop the global AppBar, give each root its own title row
The prototype (t4) draws no global app bar anywhere: every root paints
a plain ~56px title row inside its own content instead (Alarmas line
325, Ajustes line 511, Explorar line 641). app.dart wrapped every tab
in PluriWaveScaffold(appBar: AppBar(title: Text(appTitle), ...)),
adding 56dp of chrome and a "PluriWave" title the prototype never
shows.

Add PluriRootHeader, a shared 56px title-row widget reused by all 5
roots. Extract app.dart's old _mostrarTimerDialog (only reachable from
the removed AppBar action) into a free function,
showPluriSleepTimerSheet, so every root's header can open the same
sheet directly and the sleep-timer feature stays reachable from every
tab with no behaviour change.

S1, Tier 1 visual-fidelity pass (audit id 2521).
2026-07-29 20:17:24 +02:00

1259 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_icon.dart';
import '../widgets/pluri_layout.dart';
import '../widgets/pluri_premium_widgets.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).
PluriRootHeader(
title: l10n.alarmScreenTitle,
onSleepTimer: () => showPluriSleepTimerSheet(context),
),
PluriScreenHeader(
title: l10n.alarmScreenTitle,
subtitle: l10n.alarmScreenSubtitle,
glyph: PluriIconGlyph.alarm,
primaryActionLabel: l10n.createAlarmAction,
onPrimaryAction: () => _abrirEditor(context),
trailing: PluriStatusPill(
icon: Icons.alarm_on_rounded,
label: l10n.alarmsCount(estado.alarmas.length),
),
),
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);