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'; import '../widgets/pluri_push_scaffold.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!)}', ), 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 _saltarDesdeHero( BuildContext context, AlarmaMusical proxima, ) async { final l10n = AppLocalizations.of(context); final estado = context.read(); await estado.saltarProxima(proxima.id); if (!context.mounted) return; final actualizada = context.read().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(); 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( context: context, isScrollControlled: true, useSafeArea: true, backgroundColor: Colors.transparent, builder: (_) => _EditorAlarmaSheet(alarma: alarma), ); } Future _confirmarEliminarAlarma( BuildContext context, AppLocalizations l10n, ) async { final confirmado = await showDialog( 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 _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(); }, ); } } /// 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 _PantallaVacacionesTemporal()); } /// 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 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; } } /// Temporary push destination (WU8). WU9 replaces this whole widget with the /// real `PantallaVacaciones` (active-range hero, per-alarm impact line, /// upcoming/past sections) from `lib/pantallas/pantalla_vacaciones.dart`. The /// body below is the OLD inline panel's content, moved verbatim — same /// "keep the real action, drop only the header" rule WU3a/WU3b established — /// so add/delete range capability is never dropped even for one commit. class _PantallaVacacionesTemporal extends StatelessWidget { const _PantallaVacacionesTemporal(); @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final estado = context.watch(); final vacaciones = [...estado.vacaciones] ..sort((a, b) => a.inicioDia.compareTo(b.inicioDia)); return PluriPushScaffold( title: l10n.vacationRangesTitle, body: ListView( padding: PluriLayout.pageContentPadding, children: [ Text(l10n.vacationRangesHint), const SizedBox(height: 12), Align( alignment: Alignment.centerRight, child: FilledButton.tonalIcon( onPressed: () => _abrirAlta(context), icon: const Icon(Icons.add_rounded), label: Text(l10n.addAction), ), ), const SizedBox(height: 12), 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 _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 _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);