import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../estado/estado_alarmas.dart'; import '../estado/estado_radio.dart'; import '../l10n/display_names.dart'; import '../l10n/formato_fechas.dart'; import '../l10n/app_localizations_ext.dart'; import '../l10n/gen/app_localizations.dart'; import '../modelos/alarma_musical.dart'; import '../modelos/emisora.dart'; import '../servicios/servicio_programacion_alarmas.dart'; import '../tema/pluriwave_theme.dart'; import '../tema/pluriwave_tokens.dart'; import '../widgets/editor_hora_inline.dart'; import '../widgets/pluri_glass_surface.dart'; import '../widgets/pluri_layout.dart'; import '../widgets/pluri_push_scaffold.dart'; import '../widgets/pluri_root_header.dart'; import '../widgets/pluri_sleep_timer_sheet.dart'; import 'pantalla_diagnostico_alarmas.dart'; import 'pantalla_vacaciones.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: [ // S1 (Tier 1 visual fidelity): the prototype has no global // AppBar — this root now draws its own 56px title row instead of // relying on app.dart's removed shared chrome (which is also // where the sleep-timer action used to live). // S2 (Tier 1 visual fidelity): PluriScreenHeader (the glass hero // this used to be) is retired — it is not in the prototype at // all. Its ONE functional bit, the create-alarm action, moves // into PluriRootHeader's `actions` slot so it stays reachable. PluriRootHeader( title: l10n.alarmScreenTitle, onSleepTimer: () => showPluriSleepTimerSheet(context), actions: [ // Audit 7.1 (t4:325): a solid brand-teal pill with a plain // `add` glyph -- was a tonal button with `auto_awesome`. FilledButton.icon( style: FilledButton.styleFrom( backgroundColor: PluriWaveTokens.brand, foregroundColor: const Color(0xFF062126), padding: const EdgeInsets.symmetric( horizontal: 14, vertical: 9, ), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), textStyle: const TextStyle( fontSize: 13, fontWeight: FontWeight.w800, ), ), onPressed: () => _abrirEditor(context), icon: const Icon(Icons.add_rounded, size: 18), label: Text(l10n.createAlarmAction), ), ], ), Padding( padding: PluriLayout.pageContentPadding, child: Column( children: [ _PanelProximaAlarma(estado: estado), const SizedBox(height: 12), if (estado.alarmas.isEmpty) const _EmptyAlarmas() else for (final alarma in estado.alarmas) ...[ _TarjetaAlarma(alarma: alarma), // 7.5 (Tier 4 visual fidelity): the prototype's gap // between stacked alarm cards is 10px (t4 line 334), // not 12. Keyed per alarm id so the guard test can // target the exact gap between two known cards. SizedBox( height: 10, key: ValueKey('alarm-card-gap-${alarma.id}'), ), ], _PanelVacaciones(estado: estado), const SizedBox(height: 12), _AccesoDiagnostico(estado: estado), ], ), ), ], ), ); } Future _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 tokens = context.pluriTokens; final proxima = estado.proximaAlarma; final activasSinProxima = estado.alarmas .where((a) => a.activa && a.proximaProgramable == null) .length; final proximaProgramable = proxima?.proximaProgramable; // Audit 7.2 (t4:326-330): warmCoral-tinted card, `alarm_on` icon at // 26px, and the "Saltar" chip BESIDE the text on the same row -- was // an opaque default card with a 72px PNG and the skip action stacked // BELOW the text as an OutlinedButton. return DecoratedBox( key: const ValueKey('next-alarm-banner'), decoration: BoxDecoration( color: tokens.warmCoral.withValues(alpha: 0.13), borderRadius: BorderRadius.circular(20), border: Border.all(color: tokens.warmCoral.withValues(alpha: 0.34)), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 14), child: Row( crossAxisAlignment: CrossAxisAlignment.center, children: [ Icon(Icons.alarm_on, size: 26, color: tokens.warmCoral), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( proxima == null ? activasSinProxima > 0 ? l10n.activeAlarmsWithoutNextTitle : l10n.noActiveAlarms : l10n.nextAlarmTitle, style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.w900, ), ), const SizedBox(height: 4), Text( proxima == null ? activasSinProxima > 0 ? l10n.activeAlarmsWithoutNextSubtitle( activasSinProxima, ) : l10n.createAlarmHint : '${_nombreVisibleAlarma(l10n, proxima)} · ${_fechaHora(l10n, proximaProgramable!)}', ), ], ), ), if (proxima != null) ...[ const SizedBox(width: 8), _ChipSaltar(onTap: () => _saltarDesdeHero(context, proxima)), ], ], ), ), ); } } /// Audit 7.2 (t4:329): `padding:8px 12px;radius:10;rgba(255,255,255,.08)` -- /// plain text, no icon, unlike the previous `OutlinedButton.icon`. class _ChipSaltar extends StatelessWidget { const _ChipSaltar({required this.onTap}); final VoidCallback onTap; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return Material( color: Colors.white.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(10), child: InkWell( key: const ValueKey('hero-skip-next'), borderRadius: BorderRadius.circular(10), onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), child: Text( l10n.alarmHeroSkipAction, style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w800), ), ), ), ); } } /// Hero banner inline skip (native-alarms delta, WU8): skips the featured /// (soonest-firing) alarm via the SAME `saltarProxima` path the old /// always-visible per-card skip button used to call — only the trigger /// location moved. Future _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); // Item 5: surfaces the genuinely useful fields that already exist on // the model, WITHOUT turning the row into clutter -- each is shown // only when it is a meaningful deviation from the common case. // Mirrors EXACTLY the pause predicate `impactoDeRango`/ // `ServicioProgramacionAlarmas` already use // (`!sonarEnVacaciones` while `activa`), gated by whether a vacation // range is CURRENTLY active -- an alarm configured to pause but with // no active range right now is not actually paused by anything yet. final pausadaPorVacaciones = alarma.activa && !alarma.sonarEnVacaciones && estado.rangoVacacionesActivo() != null; final detalles = [ if (alarma.fadeInSegundos > 0) l10n.alarmFadeInLabel(alarma.fadeInSegundos), if ((alarma.volumen * 100).round() != 85) '${(alarma.volumen * 100).round()}%', if (pausadaPorVacaciones) l10n.alarmCardVacationPausedBadge, ]; // fix/alarmas-fallos-silenciosos: `ultimaExcepcionPara` existed but was // never read from any screen, so a failed native scheduling attempt (main // alarm, pre-notice, foreground service, or a post-boot reschedule) // rendered exactly like a healthy alarm -- switched on, no visible sign // anything was wrong. `_esValida` only ever treats `tipoSaltoSiguiente` // as a real skip, so any OTHER tipo found here is a reliability failure, // never a deliberate user action. final ultimaExcepcion = estado.ultimaExcepcionPara(alarma.id); final fallo = ultimaExcepcion != null && ExcepcionAlarma.tiposFallo.contains(ultimaExcepcion.tipo) ? ultimaExcepcion : null; return Dismissible( key: ValueKey('tarjeta-alarma-${alarma.id}'), direction: DismissDirection.horizontal, background: const _FondoSwipeEliminarAlarma( alignment: Alignment.centerLeft, ), secondaryBackground: const _FondoSwipeEliminarAlarma( alignment: Alignment.centerRight, ), confirmDismiss: (_) => _confirmarEliminarAlarma(context, l10n), onDismissed: (_) => estado.eliminarAlarma(alarma.id), child: PluriGlassSurface( glowColor: tokens.electricMagenta.withValues(alpha: 0.22), padding: EdgeInsets.zero, child: Material( type: MaterialType.transparency, child: InkWell( borderRadius: BorderRadius.circular(tokens.radiusMd), onTap: () => _abrirEditor(context, alarma: alarma), child: Padding( padding: const EdgeInsets.all(16), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Audit 7.4 (t4:339): the recurrence label sits on // the SAME baseline as the giant time -- it used to // be missing entirely. Row( crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ Text( _hora(alarma), style: Theme.of( context, ).textTheme.displaySmall?.copyWith( fontWeight: FontWeight.w900, letterSpacing: -1.5, ), ), const SizedBox(width: 8), // Item 5: real day list can run longer than the // old generic "Días" label -- Flexible+ellipsis // keeps a long selection from overflowing the // Row instead of clipping visibly. Flexible( child: Text( _recurrenciaCorta(l10n, alarma), overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w700, color: Theme.of(context).colorScheme.onSurface .withValues(alpha: 0.5), ), ), ), ], ), const SizedBox(height: 7), // Audit 7.4 (t4:341): a small station-art slot // inline with the name -- was the name alone. // `Emisora.favicon` is a network URL (the same // hazard documented for the ringing screen's audit // 9.2 -- `Image.network` here would hang widget // tests without a mocked HttpClient), so this is a // themed fallback icon, not the real per-station // artwork, mirroring the recordings-row precedent // (`pantalla_grabaciones.dart`'s `_FilaGrabacion`). Row( children: [ if (alarma.emisora != null) ...[ DecoratedBox( key: const ValueKey('tarjeta-alarma-arte'), decoration: BoxDecoration( color: tokens.listSurface, borderRadius: BorderRadius.circular(6), ), child: SizedBox( width: 22, height: 22, child: Icon( Icons.radio_rounded, size: 14, color: Theme.of(context) .colorScheme .onSurface .withValues(alpha: 0.75), ), ), ), const SizedBox(width: 7), ], Flexible( child: Text( estacion, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 12.5, fontWeight: FontWeight.w600, color: Theme.of(context).colorScheme.onSurface .withValues(alpha: 0.75), ), ), ), ], ), // Item 5: fade/volume/vacation-pause state, only // when each is a genuinely useful deviation from // the common case (see `detalles` above) -- a // single compact line, not a badge per field. if (detalles.isNotEmpty) ...[ const SizedBox(height: 3), Text( detalles.join(' · '), key: ValueKey( 'tarjeta-alarma-detalles-${alarma.id}', ), overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: Theme.of( context, ).colorScheme.onSurface.withValues(alpha: 0.55), ), ), ], if (fallo != null) ...[ const SizedBox(height: 6), _AvisoFalloProgramacion( alarmaId: alarma.id, esSoloPreaviso: fallo.tipo == ExcepcionAlarma.tipoFalloPreaviso, ), ], ], ), ), 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; } } /// Per-alarm scheduling-failure notice (fix/alarmas-fallos-silenciosos): /// renders INSIDE the card's own content, in its own small tap target -- /// the surrounding card `InkWell` (tap = edit) and `Dismissible` (swipe = /// delete) are untouched; this inner `InkWell` only claims its own region /// and pushes the diagnostics screen instead of opening the editor. class _AvisoFalloProgramacion extends StatelessWidget { const _AvisoFalloProgramacion({ required this.alarmaId, required this.esSoloPreaviso, }); final String alarmaId; /// True when only the pre-notice reminder failed (the alarm itself is /// still scheduled) -- the user's reported symptom explicitly called out /// a missing pre-notice as distinct from the alarm never ringing at all, /// so the message must not conflate the two. final bool esSoloPreaviso; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final color = Theme.of(context).colorScheme.error; return Material( type: MaterialType.transparency, child: InkWell( key: ValueKey('tarjeta-alarma-fallo-$alarmaId'), borderRadius: BorderRadius.circular(8), onTap: () => _abrirDiagnostico(context), child: Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(Icons.warning_amber_rounded, size: 15, color: color), const SizedBox(width: 6), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisSize: MainAxisSize.min, children: [ Text( esSoloPreaviso ? l10n.alarmCardPreNoticeFailedMessage : l10n.alarmCardSchedulingFailedMessage, style: TextStyle( fontSize: 11, fontWeight: FontWeight.w600, color: color, ), ), const SizedBox(height: 2), Text( l10n.androidReliabilityReview, style: TextStyle( fontSize: 11, fontWeight: FontWeight.w800, color: color, decoration: TextDecoration.underline, ), ), ], ), ), ], ), ), ), ); } void _abrirDiagnostico(BuildContext context) { PluriPushScaffold.push(context, (_) => const PantallaDiagnosticoAlarmas()); } } /// 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); // Audit 8.1 (t4 lines 372-374): opaque, bottom-anchored, full-bleed // sheet with rounded TOP corners only and a grab handle — was a // floating card (glass, 12px margin on all 4 sides, uniform radius). return DecoratedBox( key: const ValueKey('alarm-editor-sheet-surface'), decoration: const BoxDecoration( color: Color(0xFF0D1B24), borderRadius: BorderRadius.vertical(top: Radius.circular(30)), border: Border(top: BorderSide(color: Color(0x1CFFFFFF))), ), child: Padding( padding: EdgeInsets.fromLTRB(20, 14, 20, bottom + 24), child: Material( type: MaterialType.transparency, child: SingleChildScrollView( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Center( child: Container( key: const ValueKey('alarm-editor-grab-handle'), width: 44, height: 4, decoration: BoxDecoration( color: Colors.white.withValues(alpha: 0.22), borderRadius: BorderRadius.circular(2), ), ), ), const SizedBox(height: 14), Row( children: [ _AssetIcon( 'assets/icons/alarmas/alarm_music.png', size: 58, semanticLabel: l10n.alarmIconLabel, ), const SizedBox(width: 12), Expanded( child: Text( widget.alarma == null ? l10n.newAlarmTitle : l10n.editAlarmTitle, style: Theme.of(context).textTheme.titleLarge?.copyWith( fontWeight: FontWeight.w900, ), ), ), IconButton( icon: const Icon(Icons.close_rounded), onPressed: () => Navigator.pop(context), ), ], ), const SizedBox(height: 14), TextField( controller: _nombreController, decoration: InputDecoration(labelText: l10n.nameLabel), ), const SizedBox(height: 16), // WU10: the native showTimePicker dialog is replaced by a // giant inline HH:MM editor (drag/tap to adjust); see // `EditorHoraInline`, standalone-tested on its own. // // Audit 8.3 (t4 line 378): framed in its own card — radius // 22, `listSurface` (#102532), a thin border — instead of // sitting bare on the sheet background. `EditorHoraInline` // itself is unchanged (no other consumer to keep in sync). DecoratedBox( key: const ValueKey('alarm-editor-hour-card'), decoration: BoxDecoration( color: context.pluriTokens.listSurface, borderRadius: BorderRadius.circular(22), border: Border.all( color: Colors.white.withValues(alpha: 0.08), ), ), child: Padding( padding: const EdgeInsets.symmetric(vertical: 18), child: Center( child: EditorHoraInline( value: _hora, onChanged: (nuevo) => setState(() => _hora = nuevo), ), ), ), ), const SizedBox(height: 16), SegmentedButton( segments: [ ButtonSegment( value: TipoProgramacionAlarma.unica, label: Text(l10n.oneTimeOption), ), ButtonSegment( value: TipoProgramacionAlarma.diaria, label: Text(l10n.dailyOption), ), ButtonSegment( value: TipoProgramacionAlarma.diasSemana, label: Text(l10n.weekdaysOption), ), ], selected: {_tipo}, onSelectionChanged: (value) => setState(() => _tipo = value.first), ), const SizedBox(height: 10), // Audit 8.5 (t4 line 381): the "REPETIR" eyebrow above the // weekday circles -- never rendered anywhere before. Padding( padding: const EdgeInsets.fromLTRB(4, 0, 4, 8), child: Text( l10n.alarmRepeatSectionLabel, style: context.pluriType.eyebrowLabel, ), ), // WU10: weekday circles are now ALWAYS visible (previously // only inserted into the tree in diasSemana mode) — matching // the mockup, which shows them unconditionally under the // giant time. They stay disabled (habilitado: false, the // same "greyed out, non-interactive" state the previous // FilterChip used) outside diasSemana mode rather than // being wired to silently mutate `_diasSemana` while a // different `_tipo` is saved — no scheduling-data-model // change, presentation only. // // Audit 8.4 (t4 lines 383-390): 7 circles (flex:1 each, // aspect-ratio:1, gap 7) — was a `Wrap` of `FilterChip`s. Row( key: const ValueKey('alarm-weekday-circles'), children: [ for ( var i = DateTime.monday; i <= DateTime.sunday; i++ ) ...[ if (i > DateTime.monday) const SizedBox(width: 7), Expanded( child: _CirculoDiaSemana( label: _weekdayShort(l10n, i), seleccionado: _diasSemana.contains(i), habilitado: _tipo == TipoProgramacionAlarma.diasSemana, onTap: () => setState(() { _diasSemana.contains(i) ? _diasSemana.remove(i) : _diasSemana.add(i); }), ), ), ], ], ), const SizedBox(height: 12), _vistaProximaEjecucion(l10n), const SizedBox(height: 14), // Audit 8.6 (t4 lines 392-401): the station picker, volume, // fade-in and vacation toggle now share ONE bordered card // with sangred divider lines between rows -- matching the // prototype's own single grouped block -- instead of // sitting bare on the sheet, each with its own gaps. The // extra fields the prototype does NOT show (name, type // selector, snooze selector, "use current station", // Advanced) stay OUTSIDE this card, exactly where they // were (documented as deliberate in audit 8.8 / WU10). DecoratedBox( key: const ValueKey('alarm-editor-grouped-card'), decoration: BoxDecoration( color: context.pluriTokens.listSurface, borderRadius: BorderRadius.circular(18), border: Border.all( color: Colors.white.withValues(alpha: 0.08), ), ), // A transparent Material sits directly inside the coloured // box, closer to the fade-in ListTile and the vacation // SwitchListTile than the DecoratedBox's own opaque fill // -- both paint their ink/background on the NEAREST // Material ancestor, and without this the box's colour // would hide those effects (Flutter's own debug-mode // check for exactly this). child: Material( type: MaterialType.transparency, child: Column( children: [ // S2-R9: searchable bottom-sheet picker instead of a // dropdown, for the primary station. The backup // (fallback) picker moves into the Advanced section // below (WU10) — the primary choice stays a // top-level field, only its secondary/backup sibling // is now one tap further away. _CampoSelectorEmisora( key: const ValueKey('alarm-station-field'), label: l10n.favoriteStationLabel, icon: Icons.radio_rounded, value: _emisora == null ? l10n.noStationUseInternalSound : localizedStationName( l10n, _emisora!.nombre, ), onTap: () => _elegirEmisora( favoritas, seleccionar: (emisora) => setState(() => _emisora = emisora), ), ), const Divider(height: 1, indent: 66), // Audit 8.7 (t4 line 396): a compact 112px track with // a trailing percentage label -- was a bare // full-width Slider with no visible value. _FilaVolumen( volumen: _volumen, onChanged: (value) => setState(() => _volumen = value), ), const Divider(height: 1, indent: 46), Padding( padding: const EdgeInsets.fromLTRB(14, 4, 14, 4), child: ListTile( contentPadding: EdgeInsets.zero, title: Text( l10n.alarmFadeInTitle, style: context.pluriType.cardTitle, ), subtitle: Text( _fadeInSegundos == 0 ? l10n.alarmFadeInOff : l10n.alarmFadeInSummary(_fadeInSegundos), ), ), ), Padding( padding: const EdgeInsets.symmetric(horizontal: 10), child: Slider( value: _fadeInSegundos.toDouble(), min: 0, max: 60, divisions: 60, label: '${_fadeInSegundos}s', onChanged: (value) => setState( () => _fadeInSegundos = value.round(), ), ), ), const Divider(height: 1, indent: 46), SwitchListTile.adaptive( value: _sonarEnVacaciones, onChanged: (value) => setState(() => _sonarEnVacaciones = value), secondary: _AssetIcon( 'assets/icons/alarmas/vacation_wave.png', size: 42, semanticLabel: l10n.vacationIconLabel, ), title: Text(l10n.playDuringVacations), subtitle: Text(l10n.playDuringVacationsHint), ), ], ), ), ), if (favoritas.isEmpty) ...[ const SizedBox(height: 6), Text(l10n.saveFavoritesAlarmHint), ], if (radio.emisoraActual != null) ...[ const SizedBox(height: 8), Align( alignment: Alignment.centerLeft, child: FilledButton.tonalIcon( onPressed: () => setState(() => _emisora = radio.emisoraActual), icon: const Icon(Icons.add_task_rounded), label: Text(l10n.useCurrentStationAction), ), ), ], const SizedBox(height: 12), ListTile( contentPadding: EdgeInsets.zero, title: Text(l10n.alarmSnoozeDurationTitle), subtitle: Text(l10n.alarmSnoozeOptionLabel(_snoozeMinutos)), ), SegmentedButton( segments: [ for (final minutos in _opcionesSnooze()) ButtonSegment( value: minutos, label: Text(l10n.alarmSnoozeOptionLabel(minutos)), ), ], selected: {_snoozeMinutos}, onSelectionChanged: (value) => setState(() => _snoozeMinutos = value.first), ), const SizedBox(height: 8), // WU10 (native-alarms delta — Alarm Editor Preserves Date, // Fallback Station, and Sound Fields): the mockup's editor // shows only the giant time + weekday circles, but the // one-time date field, the fallback-station picker, and the // sound dropdown are NOT dropped — they move here, one tap // away, instead of being always inline. ExpansionTile( key: const ValueKey('alarm-advanced-section'), tilePadding: EdgeInsets.zero, title: Text( l10n.alarmAdvancedSectionTitle, style: context.pluriType.cardTitle, ), children: [ _PickerButton( icon: Icons.event_rounded, label: l10n.dateField, value: _fechaCorta(l10n, _fecha), onTap: _tipo == TipoProgramacionAlarma.unica ? _elegirFecha : null, ), const SizedBox(height: 8), _CampoSelectorEmisora( key: const ValueKey('alarm-fallback-station-field'), label: l10n.alarmFallbackStationLabel, icon: Icons.settings_backup_restore_rounded, value: _emisoraFallback == null ? l10n.noStationUseInternalSound : localizedStationName( l10n, _emisoraFallback!.nombre, ), onTap: () => _elegirEmisora( favoritas, seleccionar: (emisora) => setState(() => _emisoraFallback = emisora), ), ), const SizedBox(height: 8), DropdownButtonFormField( 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 _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 _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; /// Audit 8.6 (t4:394): a flat nav row -- icon, bold label, muted value, /// chevron -- matching the same convention `FilaAjuste` uses everywhere /// else in Settings, so this fits cleanly inside the grouped card below /// instead of drawing its own outlined `InputDecoration` chrome. @override Widget build(BuildContext context) { final type = context.pluriType; return Material( type: MaterialType.transparency, child: InkWell( onTap: onTap, child: Padding( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 11), child: Row( children: [ Icon(icon, size: 20), const SizedBox(width: 12), Expanded( child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: type.cardTitle), Text( value, overflow: TextOverflow.ellipsis, style: type.bodyStrong.copyWith( color: Theme.of( context, ).colorScheme.onSurface.withValues(alpha: 0.55), ), ), ], ), ), Icon( Icons.chevron_right_rounded, size: 19, color: Theme.of( context, ).colorScheme.onSurface.withValues(alpha: 0.4), ), ], ), ), ), ); } } /// Audit 8.7 (t4 line 396): a compact volume row -- icon, label, a 112px /// track, and a trailing "80%" -- was a bare full-width `Slider` with no /// visible current value. Constrains a real, still-draggable `Slider` /// (not a static bar) to the prototype's 112px track width via a /// `SliderTheme` + fixed-width `SizedBox`, preserving drag interactivity. class _FilaVolumen extends StatelessWidget { const _FilaVolumen({required this.volumen, required this.onChanged}); final double volumen; final ValueChanged onChanged; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final onSurface = Theme.of(context).colorScheme.onSurface; return Padding( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4), child: Row( children: [ Icon( Icons.volume_up_rounded, size: 20, color: onSurface.withValues(alpha: 0.7), ), const SizedBox(width: 12), Expanded( child: Text( l10n.alarmVolumeLabel, style: context.pluriType.cardTitle, ), ), SizedBox( width: 112, height: 24, child: SliderTheme( data: SliderTheme.of(context).copyWith( trackHeight: 4, activeTrackColor: PluriWaveTokens.brand, inactiveTrackColor: Colors.white.withValues(alpha: 0.14), thumbShape: const RoundSliderThumbShape(enabledThumbRadius: 7), overlayShape: const RoundSliderOverlayShape(overlayRadius: 14), ), child: Slider( value: volumen, // S2-R11: floor lowered from 0.25 to 0.0. min: 0, max: 1, divisions: 20, label: '${(volumen * 100).round()}%', onChanged: onChanged, ), ), ), SizedBox( width: 34, child: Text( '${(volumen * 100).round()}%', textAlign: TextAlign.right, style: TextStyle( fontSize: 12, fontWeight: FontWeight.w800, color: onSurface.withValues(alpha: 0.7), ), ), ), ], ), ); } } /// Searchable station picker (S2-R9): bottom sheet with a [SearchBar] over /// the user's favorites, matching the main station-picker interaction. class _SelectorEmisoraSheet extends StatefulWidget { const _SelectorEmisoraSheet({required this.emisoras}); final List 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), ), ), ], ), ), ], ), ), ), ), ); } } /// Entry point into the full Android alarm-reliability diagnostics screen /// (fix/alarmas-fiabilidad). Was a one-line `TextButton.icon` that only ever /// surfaced 3 of the 6 fields `DiagnosticoAlarmasAndroid` collects (exact /// alarms, notifications, full-screen intent) and cycled all three /// permission requests on a single tap; the two most diagnostic fields -- /// battery-optimization exemption and the native pending-alarm count, which /// tells the user whether the alarm ever reached the OS at all -- were /// gathered and never shown. Now a tap target row (mirrors /// `_PanelVacaciones`'s shape) pushing `PantallaDiagnosticoAlarmas`, which /// shows every signal individually with its own fix action. class _AccesoDiagnostico extends StatelessWidget { const _AccesoDiagnostico({required this.estado}); final EstadoAlarmas estado; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final tokens = context.pluriTokens; return PluriGlassSurface( padding: EdgeInsets.zero, child: Material( type: MaterialType.transparency, child: InkWell( key: const ValueKey('diagnostico-alarmas-resumen'), borderRadius: BorderRadius.circular(tokens.radiusMd), onTap: () => _abrirDiagnostico(context), child: Padding( padding: const EdgeInsets.all(16), child: Row( children: [ const _AssetIcon( 'assets/icons/alarmas/android_reliability.png', size: 28, ), const SizedBox(width: 10), Expanded(child: Text(l10n.androidReliabilityReview)), const Icon(Icons.chevron_right_rounded), ], ), ), ), ), ); } void _abrirDiagnostico(BuildContext context) { PluriPushScaffold.push(context, (_) => const PantallaDiagnosticoAlarmas()); } } /// Vacation summary row (alarm-vacation-ranges delta, WU8): replaces the old /// always-inline range list with a tap target showing range count + next- /// range countdown, pushing the Vacaciones manager screen. WU9 owns the /// destination screen's real content (`EstadoAlarmas` query additions per /// design ADR-6); this row's tap target is a temporary placeholder until then /// (see `_PantallaVacacionesTemporal` below). class _PanelVacaciones extends StatelessWidget { const _PanelVacaciones({required this.estado}); final EstadoAlarmas estado; @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); final tokens = context.pluriTokens; final resumen = _resumenVacaciones(l10n, estado.vacaciones); // Audit 7.3 (t4:332): a trailing date-range pill ("4-18 AGO") for the // active-or-next range -- never rendered anywhere before. final proximas = estado.vacacionesProximas(); final rangoRelevante = estado.rangoVacacionesActivo() ?? (proximas.isEmpty ? null : proximas.first); return PluriGlassSurface( glowColor: PluriWaveTokens.skyBlue.withValues(alpha: 0.22), padding: EdgeInsets.zero, child: Material( type: MaterialType.transparency, child: InkWell( key: const ValueKey('vacaciones-resumen'), borderRadius: BorderRadius.circular(tokens.radiusMd), onTap: () => _abrirVacaciones(context), child: Padding( padding: const EdgeInsets.all(16), child: Row( children: [ _AssetIcon( 'assets/icons/alarmas/vacation_wave.png', size: 48, semanticLabel: l10n.vacationIconLabel, ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( l10n.vacationRangesTitle, style: Theme.of(context).textTheme.titleMedium ?.copyWith(fontWeight: FontWeight.w900), ), const SizedBox(height: 4), Text(resumen), ], ), ), if (rangoRelevante != null) ...[ const SizedBox(width: 8), _PildoraFechasVacaciones(rango: rangoRelevante), ], const Icon(Icons.chevron_right_rounded), ], ), ), ), ), ); } void _abrirVacaciones(BuildContext context) { PluriPushScaffold.push(context, (_) => const PantallaVacaciones()); } /// Range count + next-range countdown, computed directly over the existing /// `estado.vacaciones` (no new `EstadoAlarmas` query method — those are /// design ADR-6's pure additions, owned by WU9). String _resumenVacaciones( AppLocalizations l10n, List vacaciones, ) { if (vacaciones.isEmpty) return l10n.noVacationRangesLoaded; final ahora = DateTime.now(); final hoy = DateTime(ahora.year, ahora.month, ahora.day); final conteo = l10n.vacationRangesCount(vacaciones.length); RangoVacaciones? activo; for (final rango in vacaciones) { if (rango.contiene(ahora)) { activo = rango; break; } } if (activo != null) { final dias = activo.finDia.difference(hoy).inDays; return '$conteo · ${l10n.vacationSummaryActiveCountdown(dias)}'; } final futuros = vacaciones.where((rango) => rango.inicioDia.isAfter(hoy)).toList() ..sort((a, b) => a.inicioDia.compareTo(b.inicioDia)); if (futuros.isNotEmpty) { final dias = futuros.first.inicioDia.difference(hoy).inDays; return '$conteo · ${l10n.vacationSummaryUpcomingCountdown(dias)}'; } return conteo; } } /// Audit 7.3 (t4:332): "4-18 AGO" -- `radius:999`, `liveGreen@.16` fill, /// `liveGreen@.38` border, 10.5px/w800 in `liveGreen` (matches the /// prototype's own `rgba(126,228,194,...)` teal, not the alarm banner's /// warmCoral). class _PildoraFechasVacaciones extends StatelessWidget { const _PildoraFechasVacaciones({required this.rango}); final RangoVacaciones rango; @override Widget build(BuildContext context) { final tokens = context.pluriTokens; final locale = AppLocalizations.of(context).localeName; return DecoratedBox( decoration: BoxDecoration( color: tokens.liveGreen.withValues(alpha: 0.16), borderRadius: BorderRadius.circular(999), border: Border.all(color: tokens.liveGreen.withValues(alpha: 0.38)), ), child: Padding( padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), child: Text( rangoFechasCorto(locale, rango.inicioDia, rango.finDia), style: TextStyle( fontSize: 10.5, fontWeight: FontWeight.w800, color: tokens.liveGreen, ), ), ), ); } } /// Audit 8.4 (t4 lines 383-390): one circular weekday button in the alarm /// editor's REPETIR row — was a `FilterChip`. `aspect-ratio:1` in the /// prototype is achieved here by the caller wrapping each instance in an /// `Expanded` inside an `AspectRatio`-free `Row` — this widget's own /// `AspectRatio(1)` does the squaring regardless of the column width the /// `Row` assigns it. class _CirculoDiaSemana extends StatelessWidget { const _CirculoDiaSemana({ required this.label, required this.seleccionado, required this.habilitado, required this.onTap, }); final String label; final bool seleccionado; final bool habilitado; final VoidCallback onTap; /// t4 lines 384-388: the selected circle's TEXT is this specific dark /// literal — this theme's `colorScheme.onPrimary` is `Colors.white` /// (`pluriwave_theme.dart:23`), which would fail contrast against the /// bright cyan fill. No existing token matches this exact value. static const _textoSobreBrand = Color(0xFF062126); @override Widget build(BuildContext context) { final tokens = context.pluriTokens; final colorFondo = seleccionado ? PluriWaveTokens.brand : tokens.listSurface; final colorTexto = seleccionado ? _textoSobreBrand : Theme.of(context).colorScheme.onSurface.withValues(alpha: 0.6); return AspectRatio( aspectRatio: 1, child: Material( color: colorFondo, shape: CircleBorder( side: seleccionado ? BorderSide.none : BorderSide(color: Colors.white.withValues(alpha: 0.1)), ), child: InkWell( customBorder: const CircleBorder(), onTap: habilitado ? onTap : null, child: Center( child: Text( label, style: TextStyle( fontSize: 13, fontWeight: seleccionado ? FontWeight.w800 : FontWeight.w700, color: habilitado ? colorTexto : colorTexto.withValues(alpha: 0.4), ), ), ), ), ), ); } } class _AssetIcon extends StatelessWidget { const _AssetIcon(this.asset, {this.size = 44, this.semanticLabel}); final String asset; final double size; /// S5-R2: meaningful images carry a label; without one the image is /// treated as decorative and excluded from the semantics tree. final String? semanticLabel; @override Widget build(BuildContext context) { return Image.asset( asset, width: size, height: size, fit: BoxFit.contain, semanticLabel: semanticLabel, excludeFromSemantics: semanticLabel == null, errorBuilder: (_, __, ___) => Icon(Icons.music_note_rounded, size: size * 0.65), ); } } class _PickerButton extends StatelessWidget { const _PickerButton({ required this.icon, required this.label, required this.value, required this.onTap, }); final IconData icon; final String label; final String value; final VoidCallback? onTap; @override Widget build(BuildContext context) { return OutlinedButton.icon( onPressed: onTap, icon: Icon(icon), label: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: Theme.of(context).textTheme.labelSmall), Text(value), ], ), ); } } class _NoticeLine extends StatelessWidget { const _NoticeLine({super.key, required this.icon, required this.text}); final IconData icon; final String text; @override Widget build(BuildContext context) { return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(icon, size: 18), const SizedBox(width: 8), Expanded(child: Text(text)), ], ); } } class _EmptyAlarmas extends StatelessWidget { const _EmptyAlarmas(); @override Widget build(BuildContext context) { final l10n = AppLocalizations.of(context); return PluriGlassSurface( child: Column( children: [ _AssetIcon( 'assets/icons/alarmas/alarm_music.png', size: 92, semanticLabel: l10n.alarmIconLabel, ), const SizedBox(height: 12), Text(l10n.noAlarmsYetTitle), const SizedBox(height: 4), Text(l10n.noAlarmsYetSubtitle), ], ), ); } } String _nombreVisibleAlarma(AppLocalizations l10n, AlarmaMusical alarma) { return localizedAlarmName(l10n, alarma.nombre); } String _hora(AlarmaMusical alarma) => '${alarma.hora.toString().padLeft(2, '0')}:${alarma.minuto.toString().padLeft(2, '0')}'; String _fechaHora(AppLocalizations l10n, DateTime fecha) => l10n.dateTimeSentence(fecha); String _weekdayShort(AppLocalizations l10n, int day) => switch (day) { DateTime.monday => l10n.weekdayShortMonday, DateTime.tuesday => l10n.weekdayShortTuesday, DateTime.wednesday => l10n.weekdayShortWednesday, DateTime.thursday => l10n.weekdayShortThursday, DateTime.friday => l10n.weekdayShortFriday, DateTime.saturday => l10n.weekdayShortSaturday, DateTime.sunday => l10n.weekdayShortSunday, _ => '?', }; // S5-R4: short dates follow the active locale (en-US = M/D/Y, ja = Y/M/D). String _fechaCorta(AppLocalizations l10n, DateTime fecha) => fechaCortaLocalizada(l10n.localeName, fecha); /// Audit 7.4 (t4:339) / item 5: a compact recurrence label next to the /// alarm card's giant time. `diaria`/`unica` still show the SAME generic /// labels the editor's own `TipoProgramacionAlarma` `SegmentedButton` /// already uses (`dailyOption`/`oneTimeOption`) -- both are already fully /// specific (there is nothing more concrete to say than "every day"/"just /// once"). `diasSemana` now renders the alarm's ACTUAL configured days /// (e.g. "Lun, Mié, Vie") instead of the generic `weekdaysOption` ("Días"), /// reusing [_weekdayShort] (the SAME per-day abbreviation the editor's own /// day-picker circles already use) -- no new ARB keys, no second /// formatting scheme, and the resulting Text is wrapped in a /// `Flexible`+ellipsis at the call site so a long selection never /// overflows the row. String _recurrenciaCorta(AppLocalizations l10n, AlarmaMusical alarma) { return switch (alarma.tipoProgramacion) { TipoProgramacionAlarma.diaria => l10n.dailyOption, TipoProgramacionAlarma.diasSemana => _diasSemanaCorto( l10n, alarma.diasSemana, ), TipoProgramacionAlarma.unica => l10n.oneTimeOption, }; } /// The real, ordered day abbreviations for a `diasSemana` alarm (item 5), /// e.g. "Lun, Mié, Vie". [diasSemana] is re-sorted defensively (the editor /// always persists it sorted, but this does not rely on that). Falls back /// to the generic [AppLocalizations.weekdaysOption] label when /// [diasSemana] is empty -- the editor already blocks saving an empty /// selection in this mode, but a corrupt/legacy persisted record could /// still reach here, and showing nothing would be worse than the old /// generic label. String _diasSemanaCorto(AppLocalizations l10n, List diasSemana) { if (diasSemana.isEmpty) return l10n.weekdaysOption; final ordenados = [...diasSemana]..sort(); return ordenados.map((dia) => _weekdayShort(l10n, dia)).join(', '); }