feat(alarmas): simplify alarm cards and add vacation summary row

Restyle the Alarmas root per the functional redesign: alarm cards drop
the always-visible edit/skip/delete button row for a minimal giant
time + station + switch layout. Tap opens the editor, swipe deletes
(with an AlertDialog confirmation), and the hero banner gains an
inline "Saltar" pill for the featured (soonest-firing) alarm's skip
action. No capability from the old button row is lost, only the
trigger location moved; estado_alarmas.dart and its scheduling/
snooze/dismiss-guard tests are untouched.

The vacation inline panel becomes a tappable summary row (range count
+ next-range countdown, computed over the existing estado.vacaciones)
that pushes a Vacaciones manager screen. That destination is a
placeholder for now (_PantallaVacacionesTemporal, holding the old
panel's body verbatim so add/delete-range capability is preserved) --
WU9 replaces it with the real PantallaVacaciones per design ADR-6.

New ARB keys (en/es only; other 11 locales are WU18's job):
alarmHeroSkipAction, alarmDeleteConfirmTitle/Message,
vacationRangesCount, vacationSummary{Active,Upcoming}Countdown.
This commit is contained in:
2026-07-29 10:20:25 +02:00
parent 6653e044c7
commit 9a2eb57a0e
19 changed files with 876 additions and 237 deletions
+27
View File
@@ -422,6 +422,9 @@
"editAction": "Edit",
"skipNextAction": "Skip next",
"deleteTooltip": "Delete",
"alarmHeroSkipAction": "Skip",
"alarmDeleteConfirmTitle": "Delete alarm?",
"alarmDeleteConfirmMessage": "This can't be undone.",
"alarmSkippedNoNextSnackbar": "Alarm skipped. There is no next occurrence left.",
"alarmSkippedReturnsSnackbar": "Alarm skipped. It will return on {date}.",
"@alarmSkippedReturnsSnackbar": {
@@ -508,6 +511,30 @@
"vacationRangesHint": "If an alarm is set to \"Paused during vacations\", it automatically skips these ranges.",
"noVacationRangesLoaded": "No ranges loaded.",
"deleteRangeTooltip": "Delete range",
"vacationRangesCount": "{count} ranges",
"@vacationRangesCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"vacationSummaryActiveCountdown": "Active now · {days} days left",
"@vacationSummaryActiveCountdown": {
"placeholders": {
"days": {
"type": "int"
}
}
},
"vacationSummaryUpcomingCountdown": "Next range in {days} days",
"@vacationSummaryUpcomingCountdown": {
"placeholders": {
"days": {
"type": "int"
}
}
},
"vacationsDefaultName": "Vacation",
"newVacationRangeTitle": "New vacation range",
"startField": "Start",
+27
View File
@@ -422,6 +422,9 @@
"editAction": "Editar",
"skipNextAction": "Omitir siguiente",
"deleteTooltip": "Eliminar",
"alarmHeroSkipAction": "Saltar",
"alarmDeleteConfirmTitle": "¿Eliminar alarma?",
"alarmDeleteConfirmMessage": "Esta acción no se puede deshacer.",
"alarmSkippedNoNextSnackbar": "Alarma omitida. No queda próxima ejecución.",
"alarmSkippedReturnsSnackbar": "Alarma omitida. Volverá el {date}.",
"@alarmSkippedReturnsSnackbar": {
@@ -508,6 +511,30 @@
"vacationRangesHint": "Si una alarma tiene \"Pausa en vacaciones\", se salta automáticamente estos rangos.",
"noVacationRangesLoaded": "Sin rangos cargados.",
"deleteRangeTooltip": "Eliminar rango",
"vacationRangesCount": "{count} rangos",
"@vacationRangesCount": {
"placeholders": {
"count": {
"type": "int"
}
}
},
"vacationSummaryActiveCountdown": "En curso · quedan {days} días",
"@vacationSummaryActiveCountdown": {
"placeholders": {
"days": {
"type": "int"
}
}
},
"vacationSummaryUpcomingCountdown": "Próximo rango en {days} días",
"@vacationSummaryUpcomingCountdown": {
"placeholders": {
"days": {
"type": "int"
}
}
},
"vacationsDefaultName": "Vacaciones",
"newVacationRangeTitle": "Nuevo rango de vacaciones",
"startField": "Inicio",
+36
View File
@@ -1570,6 +1570,24 @@ abstract class AppLocalizations {
/// **'Eliminar'**
String get deleteTooltip;
/// No description provided for @alarmHeroSkipAction.
///
/// In es, this message translates to:
/// **'Saltar'**
String get alarmHeroSkipAction;
/// No description provided for @alarmDeleteConfirmTitle.
///
/// In es, this message translates to:
/// **'¿Eliminar alarma?'**
String get alarmDeleteConfirmTitle;
/// No description provided for @alarmDeleteConfirmMessage.
///
/// In es, this message translates to:
/// **'Esta acción no se puede deshacer.'**
String get alarmDeleteConfirmMessage;
/// No description provided for @alarmSkippedNoNextSnackbar.
///
/// In es, this message translates to:
@@ -1838,6 +1856,24 @@ abstract class AppLocalizations {
/// **'Eliminar rango'**
String get deleteRangeTooltip;
/// No description provided for @vacationRangesCount.
///
/// In es, this message translates to:
/// **'{count} rangos'**
String vacationRangesCount(int count);
/// No description provided for @vacationSummaryActiveCountdown.
///
/// In es, this message translates to:
/// **'En curso · quedan {days} días'**
String vacationSummaryActiveCountdown(int days);
/// No description provided for @vacationSummaryUpcomingCountdown.
///
/// In es, this message translates to:
/// **'Próximo rango en {days} días'**
String vacationSummaryUpcomingCountdown(int days);
/// No description provided for @vacationsDefaultName.
///
/// In es, this message translates to:
+24
View File
@@ -840,6 +840,15 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get deleteTooltip => 'حذف';
@override
String get alarmHeroSkipAction => 'Saltar';
@override
String get alarmDeleteConfirmTitle => '¿Eliminar alarma?';
@override
String get alarmDeleteConfirmMessage => 'Esta acción no se puede deshacer.';
@override
String get alarmSkippedNoNextSnackbar =>
'تم تخطي المنبه. لا يوجد تشغيل قادم.';
@@ -994,6 +1003,21 @@ class AppLocalizationsAr extends AppLocalizations {
@override
String get deleteRangeTooltip => 'حذف النطاق';
@override
String vacationRangesCount(int count) {
return '$count rangos';
}
@override
String vacationSummaryActiveCountdown(int days) {
return 'En curso · quedan $days días';
}
@override
String vacationSummaryUpcomingCountdown(int days) {
return 'Próximo rango en $days días';
}
@override
String get vacationsDefaultName => 'إجازات';
+24
View File
@@ -845,6 +845,15 @@ class AppLocalizationsBn extends AppLocalizations {
@override
String get deleteTooltip => 'মুছুন';
@override
String get alarmHeroSkipAction => 'Saltar';
@override
String get alarmDeleteConfirmTitle => '¿Eliminar alarma?';
@override
String get alarmDeleteConfirmMessage => 'Esta acción no se puede deshacer.';
@override
String get alarmSkippedNoNextSnackbar =>
'অ্যালার্ম এড়ানো হয়েছে। আর কোনো পরবর্তী চালনা নেই।';
@@ -1001,6 +1010,21 @@ class AppLocalizationsBn extends AppLocalizations {
@override
String get deleteRangeTooltip => 'পরিসর মুছুন';
@override
String vacationRangesCount(int count) {
return '$count rangos';
}
@override
String vacationSummaryActiveCountdown(int days) {
return 'En curso · quedan $days días';
}
@override
String vacationSummaryUpcomingCountdown(int days) {
return 'Próximo rango en $days días';
}
@override
String get vacationsDefaultName => 'ছুটি';
+24
View File
@@ -848,6 +848,15 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get deleteTooltip => 'Löschen';
@override
String get alarmHeroSkipAction => 'Saltar';
@override
String get alarmDeleteConfirmTitle => '¿Eliminar alarma?';
@override
String get alarmDeleteConfirmMessage => 'Esta acción no se puede deshacer.';
@override
String get alarmSkippedNoNextSnackbar =>
'Alarm ausgelassen. Es bleibt keine nächste Ausführung.';
@@ -1002,6 +1011,21 @@ class AppLocalizationsDe extends AppLocalizations {
@override
String get deleteRangeTooltip => 'Zeitraum löschen';
@override
String vacationRangesCount(int count) {
return '$count rangos';
}
@override
String vacationSummaryActiveCountdown(int days) {
return 'En curso · quedan $days días';
}
@override
String vacationSummaryUpcomingCountdown(int days) {
return 'Próximo rango en $days días';
}
@override
String get vacationsDefaultName => 'Ferien';
+24
View File
@@ -842,6 +842,15 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get deleteTooltip => 'Delete';
@override
String get alarmHeroSkipAction => 'Skip';
@override
String get alarmDeleteConfirmTitle => 'Delete alarm?';
@override
String get alarmDeleteConfirmMessage => 'This can\'t be undone.';
@override
String get alarmSkippedNoNextSnackbar =>
'Alarm skipped. There is no next occurrence left.';
@@ -996,6 +1005,21 @@ class AppLocalizationsEn extends AppLocalizations {
@override
String get deleteRangeTooltip => 'Delete range';
@override
String vacationRangesCount(int count) {
return '$count ranges';
}
@override
String vacationSummaryActiveCountdown(int days) {
return 'Active now · $days days left';
}
@override
String vacationSummaryUpcomingCountdown(int days) {
return 'Next range in $days days';
}
@override
String get vacationsDefaultName => 'Vacation';
+24
View File
@@ -846,6 +846,15 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get deleteTooltip => 'Eliminar';
@override
String get alarmHeroSkipAction => 'Saltar';
@override
String get alarmDeleteConfirmTitle => '¿Eliminar alarma?';
@override
String get alarmDeleteConfirmMessage => 'Esta acción no se puede deshacer.';
@override
String get alarmSkippedNoNextSnackbar =>
'Alarma omitida. No queda próxima ejecución.';
@@ -1000,6 +1009,21 @@ class AppLocalizationsEs extends AppLocalizations {
@override
String get deleteRangeTooltip => 'Eliminar rango';
@override
String vacationRangesCount(int count) {
return '$count rangos';
}
@override
String vacationSummaryActiveCountdown(int days) {
return 'En curso · quedan $days días';
}
@override
String vacationSummaryUpcomingCountdown(int days) {
return 'Próximo rango en $days días';
}
@override
String get vacationsDefaultName => 'Vacaciones';
+24
View File
@@ -850,6 +850,15 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get deleteTooltip => 'Supprimer';
@override
String get alarmHeroSkipAction => 'Saltar';
@override
String get alarmDeleteConfirmTitle => '¿Eliminar alarma?';
@override
String get alarmDeleteConfirmMessage => 'Esta acción no se puede deshacer.';
@override
String get alarmSkippedNoNextSnackbar =>
'Alarme ignorée. Il ne reste aucune prochaine exécution.';
@@ -1006,6 +1015,21 @@ class AppLocalizationsFr extends AppLocalizations {
@override
String get deleteRangeTooltip => 'Supprimer la période';
@override
String vacationRangesCount(int count) {
return '$count rangos';
}
@override
String vacationSummaryActiveCountdown(int days) {
return 'En curso · quedan $days días';
}
@override
String vacationSummaryUpcomingCountdown(int days) {
return 'Próximo rango en $days días';
}
@override
String get vacationsDefaultName => 'Vacances';
+24
View File
@@ -842,6 +842,15 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get deleteTooltip => 'हटाएँ';
@override
String get alarmHeroSkipAction => 'Saltar';
@override
String get alarmDeleteConfirmTitle => '¿Eliminar alarma?';
@override
String get alarmDeleteConfirmMessage => 'Esta acción no se puede deshacer.';
@override
String get alarmSkippedNoNextSnackbar =>
'अलार्म छोड़ा गया। कोई अगली चाल बाकी नहीं।';
@@ -997,6 +1006,21 @@ class AppLocalizationsHi extends AppLocalizations {
@override
String get deleteRangeTooltip => 'अवधि हटाएँ';
@override
String vacationRangesCount(int count) {
return '$count rangos';
}
@override
String vacationSummaryActiveCountdown(int days) {
return 'En curso · quedan $days días';
}
@override
String vacationSummaryUpcomingCountdown(int days) {
return 'Próximo rango en $days días';
}
@override
String get vacationsDefaultName => 'छुट्टियाँ';
+24
View File
@@ -845,6 +845,15 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get deleteTooltip => 'Hapus';
@override
String get alarmHeroSkipAction => 'Saltar';
@override
String get alarmDeleteConfirmTitle => '¿Eliminar alarma?';
@override
String get alarmDeleteConfirmMessage => 'Esta acción no se puede deshacer.';
@override
String get alarmSkippedNoNextSnackbar =>
'Alarm dilewati. Tidak ada eksekusi berikutnya.';
@@ -1001,6 +1010,21 @@ class AppLocalizationsId extends AppLocalizations {
@override
String get deleteRangeTooltip => 'Hapus rentang';
@override
String vacationRangesCount(int count) {
return '$count rangos';
}
@override
String vacationSummaryActiveCountdown(int days) {
return 'En curso · quedan $days días';
}
@override
String vacationSummaryUpcomingCountdown(int days) {
return 'Próximo rango en $days días';
}
@override
String get vacationsDefaultName => 'Liburan';
+24
View File
@@ -846,6 +846,15 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get deleteTooltip => 'Elimina';
@override
String get alarmHeroSkipAction => 'Saltar';
@override
String get alarmDeleteConfirmTitle => '¿Eliminar alarma?';
@override
String get alarmDeleteConfirmMessage => 'Esta acción no se puede deshacer.';
@override
String get alarmSkippedNoNextSnackbar =>
'Sveglia saltata. Non resta alcuna prossima esecuzione.';
@@ -1002,6 +1011,21 @@ class AppLocalizationsIt extends AppLocalizations {
@override
String get deleteRangeTooltip => 'Elimina periodo';
@override
String vacationRangesCount(int count) {
return '$count rangos';
}
@override
String vacationSummaryActiveCountdown(int days) {
return 'En curso · quedan $days días';
}
@override
String vacationSummaryUpcomingCountdown(int days) {
return 'Próximo rango en $days días';
}
@override
String get vacationsDefaultName => 'Vacanze';
+24
View File
@@ -820,6 +820,15 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get deleteTooltip => '削除';
@override
String get alarmHeroSkipAction => 'Saltar';
@override
String get alarmDeleteConfirmTitle => '¿Eliminar alarma?';
@override
String get alarmDeleteConfirmMessage => 'Esta acción no se puede deshacer.';
@override
String get alarmSkippedNoNextSnackbar => 'アラームをスキップしました。次回実行は残っていません。';
@@ -970,6 +979,21 @@ class AppLocalizationsJa extends AppLocalizations {
@override
String get deleteRangeTooltip => '期間を削除';
@override
String vacationRangesCount(int count) {
return '$count rangos';
}
@override
String vacationSummaryActiveCountdown(int days) {
return 'En curso · quedan $days días';
}
@override
String vacationSummaryUpcomingCountdown(int days) {
return 'Próximo rango en $days días';
}
@override
String get vacationsDefaultName => '休暇';
+24
View File
@@ -845,6 +845,15 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get deleteTooltip => 'Excluir';
@override
String get alarmHeroSkipAction => 'Saltar';
@override
String get alarmDeleteConfirmTitle => '¿Eliminar alarma?';
@override
String get alarmDeleteConfirmMessage => 'Esta acción no se puede deshacer.';
@override
String get alarmSkippedNoNextSnackbar =>
'Alarme ignorado. Não resta próxima execução.';
@@ -999,6 +1008,21 @@ class AppLocalizationsPt extends AppLocalizations {
@override
String get deleteRangeTooltip => 'Excluir período';
@override
String vacationRangesCount(int count) {
return '$count rangos';
}
@override
String vacationSummaryActiveCountdown(int days) {
return 'En curso · quedan $days días';
}
@override
String vacationSummaryUpcomingCountdown(int days) {
return 'Próximo rango en $days días';
}
@override
String get vacationsDefaultName => 'Férias';
+24
View File
@@ -846,6 +846,15 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get deleteTooltip => 'Удалить';
@override
String get alarmHeroSkipAction => 'Saltar';
@override
String get alarmDeleteConfirmTitle => '¿Eliminar alarma?';
@override
String get alarmDeleteConfirmMessage => 'Esta acción no se puede deshacer.';
@override
String get alarmSkippedNoNextSnackbar =>
'Будильник пропущен. Следующего запуска нет.';
@@ -1001,6 +1010,21 @@ class AppLocalizationsRu extends AppLocalizations {
@override
String get deleteRangeTooltip => 'Удалить период';
@override
String vacationRangesCount(int count) {
return '$count rangos';
}
@override
String vacationSummaryActiveCountdown(int days) {
return 'En curso · quedan $days días';
}
@override
String vacationSummaryUpcomingCountdown(int days) {
return 'Próximo rango en $days días';
}
@override
String get vacationsDefaultName => 'Отпуск';
+24
View File
@@ -816,6 +816,15 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get deleteTooltip => '删除';
@override
String get alarmHeroSkipAction => 'Saltar';
@override
String get alarmDeleteConfirmTitle => '¿Eliminar alarma?';
@override
String get alarmDeleteConfirmMessage => 'Esta acción no se puede deshacer.';
@override
String get alarmSkippedNoNextSnackbar => '已跳过闹钟。没有剩余的下次执行。';
@@ -966,6 +975,21 @@ class AppLocalizationsZh extends AppLocalizations {
@override
String get deleteRangeTooltip => '删除范围';
@override
String vacationRangesCount(int count) {
return '$count rangos';
}
@override
String vacationSummaryActiveCountdown(int days) {
return 'En curso · quedan $days días';
}
@override
String vacationSummaryUpcomingCountdown(int days) {
return 'Próximo rango en $days días';
}
@override
String get vacationsDefaultName => '假期';
+262 -223
View File
@@ -16,6 +16,7 @@ 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});
@@ -128,6 +129,18 @@ class _PanelProximaAlarma extends StatelessWidget {
: 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),
),
),
],
],
),
),
@@ -137,6 +150,40 @@ class _PanelProximaAlarma extends StatelessWidget {
}
}
/// Hero banner inline skip (native-alarms delta, WU8): skips the featured
/// (soonest-firing) alarm via the SAME `saltarProxima` path the old
/// always-visible per-card skip button used to call — only the trigger
/// location moved.
Future<void> _saltarDesdeHero(
BuildContext context,
AlarmaMusical proxima,
) async {
final l10n = AppLocalizations.of(context);
final estado = context.read<EstadoAlarmas>();
await estado.saltarProxima(proxima.id);
if (!context.mounted) return;
final actualizada = context.read<EstadoAlarmas>().alarmas.firstWhere(
(item) => item.id == proxima.id,
orElse: () => proxima,
);
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text(
actualizada.proximaProgramable == null
? l10n.alarmSkippedNoNextSnackbar
: l10n.alarmSkippedReturnsSnackbar(
_fechaHora(l10n, actualizada.proximaProgramable!),
),
),
),
);
}
/// Simplified alarm card (native-alarms delta, WU8): giant time + station +
/// switch, no always-visible action row. Edit/skip/delete are NEVER lost —
/// they move behind gestures: tap opens the editor, swipe deletes (with
/// confirmation), skip lives on the hero banner (`_PanelProximaAlarma`)
/// instead of a per-card button.
class _TarjetaAlarma extends StatelessWidget {
const _TarjetaAlarma({required this.alarma});
@@ -146,187 +193,67 @@ class _TarjetaAlarma extends StatelessWidget {
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final estado = context.watch<EstadoAlarmas>();
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,
),
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),
],
),
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),
),
const SizedBox(width: 8),
Switch.adaptive(
value: alarma.activa,
onChanged: (value) => estado.cambiarActiva(alarma, value),
),
],
),
),
],
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<EstadoAlarmas>().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<RangoVacaciones> 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<void>(
context: context,
@@ -336,6 +263,56 @@ class _TarjetaAlarma extends StatelessWidget {
builder: (_) => _EditorAlarmaSheet(alarma: alarma),
);
}
Future<bool> _confirmarEliminarAlarma(
BuildContext context,
AppLocalizations l10n,
) async {
final confirmado = await showDialog<bool>(
context: context,
builder:
(ctx) => AlertDialog(
title: Text(l10n.alarmDeleteConfirmTitle),
content: Text(l10n.alarmDeleteConfirmMessage),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: Text(l10n.cancelAction),
),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: Text(l10n.deleteAction),
),
],
),
);
return confirmado ?? false;
}
}
/// Swipe-to-delete reveal, shown on both sides so either swipe direction
/// works regardless of locale text direction.
class _FondoSwipeEliminarAlarma extends StatelessWidget {
const _FondoSwipeEliminarAlarma({required this.alignment});
final Alignment alignment;
@override
Widget build(BuildContext context) {
final tokens = context.pluriTokens;
return Container(
alignment: alignment,
padding: const EdgeInsets.symmetric(horizontal: 24),
decoration: BoxDecoration(
color: Theme.of(context).colorScheme.error,
borderRadius: BorderRadius.circular(tokens.radiusMd),
),
child: Icon(
Icons.delete_outline_rounded,
color: Theme.of(context).colorScheme.onError,
),
);
}
}
class _EditorAlarmaSheet extends StatefulWidget {
@@ -1009,6 +986,12 @@ class _AccesoDiagnostico extends StatelessWidget {
}
}
/// 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});
@@ -1017,38 +1000,118 @@ class _PanelVacaciones extends StatelessWidget {
@override
Widget build(BuildContext context) {
final l10n = AppLocalizations.of(context);
final vacaciones = [...estado.vacaciones]
..sort((a, b) => a.inicioDia.compareTo(b.inicioDia));
final tokens = context.pluriTokens;
final resumen = _resumenVacaciones(l10n, estado.vacaciones);
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,
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),
],
),
),
),
FilledButton.tonalIcon(
onPressed: () => _abrirAlta(context),
icon: const Icon(Icons.add_rounded),
label: Text(l10n.addAction),
),
],
const Icon(Icons.chevron_right_rounded),
],
),
),
const SizedBox(height: 8),
),
),
);
}
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<RangoVacaciones> vacaciones,
) {
if (vacaciones.isEmpty) return l10n.noVacationRangesLoaded;
final ahora = DateTime.now();
final hoy = DateTime(ahora.year, ahora.month, ahora.day);
final conteo = l10n.vacationRangesCount(vacaciones.length);
RangoVacaciones? activo;
for (final rango in vacaciones) {
if (rango.contiene(ahora)) {
activo = rango;
break;
}
}
if (activo != null) {
final dias = activo.finDia.difference(hoy).inDays;
return '$conteo · ${l10n.vacationSummaryActiveCountdown(dias)}';
}
final futuros =
vacaciones.where((rango) => rango.inicioDia.isAfter(hoy)).toList()
..sort((a, b) => a.inicioDia.compareTo(b.inicioDia));
if (futuros.isNotEmpty) {
final dias = futuros.first.inicioDia.difference(hoy).inDays;
return '$conteo · ${l10n.vacationSummaryUpcomingCountdown(dias)}';
}
return conteo;
}
}
/// 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<EstadoAlarmas>();
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
@@ -1285,18 +1348,6 @@ class _SectionLabel extends StatelessWidget {
}
}
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});
@@ -1351,18 +1402,6 @@ String _nombreVisibleVacaciones(AppLocalizations l10n, RangoVacaciones rango) {
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);