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/pluri_glass_surface.dart'; import '../widgets/pluri_icon.dart'; import '../widgets/pluri_layout.dart'; import '../widgets/pluri_premium_widgets.dart'; class PantallaAlarmas extends StatelessWidget { const PantallaAlarmas({super.key}); @override Widget build(BuildContext context) { final estado = context.watch(); final l10n = AppLocalizations.of(context); return RefreshIndicator( onRefresh: estado.refrescarProgramacion, child: ListView( padding: PluriLayout.pageListPadding, children: [ 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 _abrirEditor( BuildContext context, { AlarmaMusical? alarma, }) async { await showModalBottomSheet( 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!)}', ), ], ), ), ], ), ); } } 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(); final excepcion = estado.ultimaExcepcionPara(alarma.id); final mensajeVacaciones = _mensajeVacaciones(l10n, estado.vacaciones); return PluriGlassSurface( glowColor: context.pluriTokens.electricMagenta.withValues(alpha: 0.22), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ _AssetIcon( 'assets/icons/alarmas/alarm_music.png', size: 64, semanticLabel: l10n.alarmIconLabel, ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( _hora(alarma), style: Theme.of( context, ).textTheme.headlineMedium?.copyWith( fontWeight: FontWeight.w900, letterSpacing: -1, ), ), Text(_nombreVisibleAlarma(l10n, alarma)), ], ), ), Switch.adaptive( value: alarma.activa, onChanged: (value) => estado.cambiarActiva(alarma, value), ), ], ), const SizedBox(height: 12), Wrap( spacing: 8, runSpacing: 8, children: [ _InfoChip( icon: Icons.repeat_rounded, label: _programacion(l10n, alarma), ), _InfoChip( icon: Icons.beach_access_rounded, label: alarma.sonarEnVacaciones ? l10n.alarmVacationPlay : l10n.alarmVacationPause, ), _InfoChip( icon: Icons.volume_up_rounded, label: '${(alarma.volumen * 100).round()}%', ), _InfoChip( icon: Icons.trending_up_rounded, label: l10n.alarmFadeInLabel(alarma.fadeInSegundos), ), ], ), const SizedBox(height: 12), if (alarma.proximaProgramable != null) _NoticeLine( icon: Icons.event_available_rounded, text: l10n.alarmNextExecution( _fechaHora(l10n, alarma.proximaProgramable!), ), ) else _NoticeLine( icon: Icons.pause_circle_outline_rounded, text: l10n.alarmNoNextExecution, ), if (excepcion != null) ...[ const SizedBox(height: 6), _NoticeLine( icon: Icons.skip_next_rounded, text: l10n.alarmSkippedExecution( _fechaHora(l10n, excepcion.ejecucion), ), ), ], if (mensajeVacaciones != null) ...[ const SizedBox(height: 6), _NoticeLine( icon: Icons.beach_access_rounded, text: mensajeVacaciones, ), ], const SizedBox(height: 12), Row( children: [ TextButton.icon( icon: const Icon(Icons.edit_rounded), label: Text(l10n.editAction), onPressed: () => _abrirEditor(context, alarma: alarma), ), TextButton.icon( icon: const Icon(Icons.skip_next_rounded), label: Text(l10n.skipNextAction), onPressed: alarma.proximaProgramable == null ? null : () async { await estado.saltarProxima(alarma.id); if (context.mounted) { final alarmas = context.read().alarmas; AlarmaMusical? actualizada; for (final item in alarmas) { if (item.id == alarma.id) { actualizada = item; break; } } ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( actualizada?.proximaProgramable == null ? l10n.alarmSkippedNoNextSnackbar : l10n.alarmSkippedReturnsSnackbar( _fechaHora( l10n, actualizada!.proximaProgramable!, ), ), ), ), ); } }, ), const Spacer(), IconButton( tooltip: l10n.deleteAction, icon: const Icon(Icons.delete_outline_rounded), onPressed: () => estado.eliminarAlarma(alarma.id), ), ], ), ], ), ); } String? _mensajeVacaciones( AppLocalizations l10n, List vacaciones, ) { if (alarma.sonarEnVacaciones) return null; final ahora = DateTime.now(); RangoVacaciones? actual; for (final rango in vacaciones) { if (rango.contiene(ahora)) { actual = rango; break; } } if (actual != null) { if (alarma.proximaProgramable == null) { return l10n.alarmVacationPausedNoNext( _nombreVisibleVacaciones(l10n, actual), ); } return l10n.alarmVacationPausedReturns( _nombreVisibleVacaciones(l10n, actual), _fechaHora(l10n, alarma.proximaProgramable!), ); } if (alarma.proximaProgramable != null) { return l10n.alarmVacationReturns( _fechaHora(l10n, alarma.proximaProgramable!), ); } return null; } void _abrirEditor(BuildContext context, {required AlarmaMusical alarma}) { showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, backgroundColor: Colors.transparent, builder: (_) => _EditorAlarmaSheet(alarma: alarma), ); } } 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 _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 []}; _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().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(); final bottom = MediaQuery.of(context).viewInsets.bottom; if (!_favoritosSolicitados) { _favoritosSolicitados = true; WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) context.read().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: 12), Row( children: [ Expanded( child: _PickerButton( icon: Icons.schedule_rounded, label: l10n.timeField, value: _hora.format(context), onTap: _elegirHora, ), ), const SizedBox(width: 10), Expanded( child: _PickerButton( icon: Icons.event_rounded, label: l10n.dateField, value: _fechaCorta(l10n, _fecha), onTap: _tipo == TipoProgramacionAlarma.unica ? _elegirFecha : null, ), ), ], ), const SizedBox(height: 12), SegmentedButton( 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), ), if (_tipo == TipoProgramacionAlarma.diasSemana) ...[ const SizedBox(height: 10), Wrap( spacing: 6, children: [ for (var i = DateTime.monday; i <= DateTime.sunday; i++) FilterChip( label: Text(_weekdayShort(l10n, i)), selected: _diasSemana.contains(i), onSelected: (selected) => setState(() { selected ? _diasSemana.add(i) : _diasSemana.remove(i); }), ), ], ), ], 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), 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( 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), DropdownButtonFormField( 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), // S2-R9: searchable bottom-sheet picker instead of a dropdown, // for both the primary and the backup (fallback) station. _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 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), ), ), 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: 16), FilledButton.icon( onPressed: _guardar, icon: const Icon(Icons.check_rounded), label: Text(l10n.saveAlarmAction), ), ], ), ), ), ), ); } List _opcionesSnooze() { final opciones = {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(); 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 _elegirEmisora( List emisoras, { required ValueChanged 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 _elegirHora() async { final nueva = await showTimePicker(context: context, initialTime: _hora); if (nueva != null) setState(() => _hora = nueva); } Future _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 _guardar() async { if (_tipo == TipoProgramacionAlarma.diasSemana && _diasSemana.isEmpty) { ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text(AppLocalizations.of(context).chooseOneWeekdayError), ), ); return; } final estado = context.read(); 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 _favoritasConSeleccion(List favoritas) { final mapa = {}; 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 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(); }, ); } } class _PanelVacaciones extends StatelessWidget { const _PanelVacaciones({required this.estado}); final EstadoAlarmas estado; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final vacaciones = [...estado.vacaciones] ..sort((a, b) => a.inicioDia.compareTo(b.inicioDia)); return PluriGlassSurface( glowColor: PluriWaveTokens.skyBlue.withValues(alpha: 0.22), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ _AssetIcon( 'assets/icons/alarmas/vacation_wave.png', size: 48, semanticLabel: l10n.vacationIconLabel, ), const SizedBox(width: 10), Expanded( child: Text( l10n.vacationRangesTitle, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w900, ), ), ), FilledButton.tonalIcon( onPressed: () => _abrirAlta(context), icon: const Icon(Icons.add_rounded), label: Text(l10n.addAction), ), ], ), const SizedBox(height: 8), Text(l10n.vacationRangesHint), if (vacaciones.isEmpty) Text(l10n.noVacationRangesLoaded) else for (final rango in vacaciones) ListTile( contentPadding: EdgeInsets.zero, leading: const Icon(Icons.event_busy_rounded), title: Text(_nombreVisibleVacaciones(l10n, rango)), subtitle: Text( '${_fechaCorta(l10n, rango.inicioDia)} → ${_fechaCorta(l10n, rango.finDia)}', ), trailing: IconButton( tooltip: l10n.deleteRangeTooltip, onPressed: () => estado.eliminarRangoVacaciones(rango.id), icon: const Icon(Icons.delete_outline_rounded), ), ), ], ), ); } Future _abrirAlta(BuildContext context) async { await showModalBottomSheet( context: context, isScrollControlled: true, useSafeArea: true, backgroundColor: Colors.transparent, builder: (_) => const _EditorVacacionesSheet(), ); } } class _EditorVacacionesSheet extends StatefulWidget { const _EditorVacacionesSheet(); @override State<_EditorVacacionesSheet> createState() => _EditorVacacionesSheetState(); } class _EditorVacacionesSheetState extends State<_EditorVacacionesSheet> { // Created lazily: AppLocalizations.of(context) cannot be read in // initState (inherited-widget lookup assert in debug builds). TextEditingController? _nombreController; late DateTime _inicio; late DateTime _fin; @override void initState() { super.initState(); final hoy = DateTime.now(); _inicio = DateTime(hoy.year, hoy.month, hoy.day); _fin = _inicio.add(const Duration(days: 2)); } @override void didChangeDependencies() { super.didChangeDependencies(); _nombreController ??= TextEditingController( text: AppLocalizations.of(context).vacationsDefaultName, ); } @override void dispose() { _nombreController?.dispose(); super.dispose(); } @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final bottom = MediaQuery.of(context).viewInsets.bottom; return Padding( padding: EdgeInsets.fromLTRB(12, 12, 12, bottom + 12), child: PluriGlassSurface( borderRadius: BorderRadius.circular(28), padding: const EdgeInsets.all(18), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.newVacationRangeTitle, style: Theme.of( context, ).textTheme.titleLarge?.copyWith(fontWeight: FontWeight.w900), ), const SizedBox(height: 12), TextField( controller: _nombreController, decoration: InputDecoration(labelText: l10n.nameLabel), ), const SizedBox(height: 12), Row( children: [ Expanded( child: _PickerButton( icon: Icons.play_arrow_rounded, label: l10n.startLabel, value: _fechaCorta(l10n, _inicio), onTap: () => _elegirFecha(esInicio: true), ), ), const SizedBox(width: 10), Expanded( child: _PickerButton( icon: Icons.stop_rounded, label: l10n.endLabel, value: _fechaCorta(l10n, _fin), onTap: () => _elegirFecha(esInicio: false), ), ), ], ), const SizedBox(height: 16), FilledButton.icon( onPressed: _guardar, icon: const Icon(Icons.check_rounded), label: Text(l10n.saveRangeAction), ), ], ), ), ); } Future _elegirFecha({required bool esInicio}) async { final actual = esInicio ? _inicio : _fin; final hoy = DateTime.now(); final seleccion = await showDatePicker( context: context, initialDate: actual, firstDate: DateTime(hoy.year, hoy.month, hoy.day), lastDate: hoy.add(const Duration(days: 1460)), ); if (seleccion == null) return; setState(() { if (esInicio) { _inicio = seleccion; if (_fin.isBefore(_inicio)) _fin = _inicio; } else { _fin = seleccion; } }); } Future _guardar() async { final estado = context.read(); final rango = estado.servicio.crearRangoVacaciones( inicio: _inicio, fin: _fin, nombre: _nombreController?.text.trim() ?? '', ); await estado.crearRangoVacaciones(rango); if (mounted) Navigator.pop(context); } } 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), Text( text, style: Theme.of( context, ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800), ), ], ); } } class _InfoChip extends StatelessWidget { const _InfoChip({required this.icon, required this.label}); final IconData icon; final String label; @override Widget build(BuildContext context) { return Chip(avatar: Icon(icon, size: 16), label: Text(label)); } } 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 _nombreVisibleVacaciones(AppLocalizations l10n, RangoVacaciones rango) { return localizedVacationName(l10n, rango.nombre); } String _hora(AlarmaMusical alarma) => '${alarma.hora.toString().padLeft(2, '0')}:${alarma.minuto.toString().padLeft(2, '0')}'; String _programacion(AppLocalizations l10n, AlarmaMusical alarma) { return switch (alarma.tipoProgramacion) { TipoProgramacionAlarma.unica => l10n.alarmScheduleOnce( _fechaCorta(l10n, alarma.fechaUnica ?? DateTime.now()), ), TipoProgramacionAlarma.diaria => l10n.dailyOption, TipoProgramacionAlarma.diasSemana => l10n.alarmScheduleWeekdays( alarma.diasSemana.map((day) => _weekdayShort(l10n, day)).join(', '), ), }; } 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);