fix(alarmas): show which days a weekday alarm actually fires on

The alarms list showed a generic "Días" label for a diasSemana alarm
instead of its actual configured days. Render the real recurrence (e.g.
"Lun, Mié, Vie") by reusing the SAME per-day abbreviation the editor's own
day-picker circles already use -- no new formatting scheme, no new ARB
keys for the days themselves.

Also surface fade/volume/vacation-pause state on the card, each only when
it is a genuinely useful deviation from the common case: a fade badge when
fadeInSegundos > 0 (reusing the existing alarmFadeInLabel key), a volume
percentage when it differs from the 85% default, and a vacation-paused
badge when the alarm is both configured to pause and a vacation range is
currently active (mirrors the exact predicate ServicioProgramacionAlarmas
already uses). One compact line, not a badge per field.

Fixes a text-collision regression in pantalla_alarmas_editor_test.dart:
opening the editor for an alarm whose own day now renders on its card
(e.g. "Lun") made a bare find.text(weekday) ambiguous against the editor's
day-picker circle with the same label -- scoped that finder to the
BottomSheet subtree.
This commit is contained in:
2026-07-31 01:05:46 +02:00
parent 491585ad12
commit 4168dc5019
30 changed files with 504 additions and 30 deletions
+2 -1
View File
@@ -840,5 +840,6 @@
"type": "String"
}
}
}
},
"alarmCardVacationPausedBadge": "متوقفة مؤقتًا بسبب الإجازة"
}
+2 -1
View File
@@ -840,5 +840,6 @@
"type": "String"
}
}
}
},
"alarmCardVacationPausedBadge": "ছুটির কারণে বিরত"
}
+2 -1
View File
@@ -840,5 +840,6 @@
"type": "String"
}
}
}
},
"alarmCardVacationPausedBadge": "Wegen Urlaub pausiert"
}
+2 -1
View File
@@ -840,5 +840,6 @@
"type": "String"
}
}
}
},
"alarmCardVacationPausedBadge": "Paused for vacation"
}
+2 -1
View File
@@ -799,5 +799,6 @@
"type": "String"
}
}
}
},
"alarmCardVacationPausedBadge": "Pausada por vacaciones"
}
+2 -1
View File
@@ -840,5 +840,6 @@
"type": "String"
}
}
}
},
"alarmCardVacationPausedBadge": "En pause pour les vacances"
}
+2 -1
View File
@@ -840,5 +840,6 @@
"type": "String"
}
}
}
},
"alarmCardVacationPausedBadge": "छुट्टी के कारण रोका गया"
}
+2 -1
View File
@@ -840,5 +840,6 @@
"type": "String"
}
}
}
},
"alarmCardVacationPausedBadge": "Dijeda karena liburan"
}
+2 -1
View File
@@ -840,5 +840,6 @@
"type": "String"
}
}
}
},
"alarmCardVacationPausedBadge": "In pausa per le vacanze"
}
+2 -1
View File
@@ -840,5 +840,6 @@
"type": "String"
}
}
}
},
"alarmCardVacationPausedBadge": "休暇のため一時停止中"
}
+2 -1
View File
@@ -840,5 +840,6 @@
"type": "String"
}
}
}
},
"alarmCardVacationPausedBadge": "Pausada por férias"
}
+2 -1
View File
@@ -840,5 +840,6 @@
"type": "String"
}
}
}
},
"alarmCardVacationPausedBadge": "Приостановлено на время отпуска"
}
+2 -1
View File
@@ -840,5 +840,6 @@
"type": "String"
}
}
}
},
"alarmCardVacationPausedBadge": "因假期已暂停"
}
+6
View File
@@ -3067,6 +3067,12 @@ abstract class AppLocalizations {
/// In es, this message translates to:
/// **'Preset: {preset}'**
String eqCustomActionPresetLabel(String preset);
/// No description provided for @alarmCardVacationPausedBadge.
///
/// In es, this message translates to:
/// **'Pausada por vacaciones'**
String get alarmCardVacationPausedBadge;
}
class _AppLocalizationsDelegate
+3
View File
@@ -1687,4 +1687,7 @@ class AppLocalizationsAr extends AppLocalizations {
String eqCustomActionPresetLabel(String preset) {
return 'الإعداد المسبق: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'متوقفة مؤقتًا بسبب الإجازة';
}
+3
View File
@@ -1696,4 +1696,7 @@ class AppLocalizationsBn extends AppLocalizations {
String eqCustomActionPresetLabel(String preset) {
return 'প্রিসেট: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'ছুটির কারণে বিরত';
}
+3
View File
@@ -1709,4 +1709,7 @@ class AppLocalizationsDe extends AppLocalizations {
String eqCustomActionPresetLabel(String preset) {
return 'Voreinstellung: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'Wegen Urlaub pausiert';
}
+3
View File
@@ -1689,4 +1689,7 @@ class AppLocalizationsEn extends AppLocalizations {
String eqCustomActionPresetLabel(String preset) {
return 'Preset: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'Paused for vacation';
}
+3
View File
@@ -1703,4 +1703,7 @@ class AppLocalizationsEs extends AppLocalizations {
String eqCustomActionPresetLabel(String preset) {
return 'Preset: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'Pausada por vacaciones';
}
+3
View File
@@ -1712,4 +1712,7 @@ class AppLocalizationsFr extends AppLocalizations {
String eqCustomActionPresetLabel(String preset) {
return 'Préréglage : $preset';
}
@override
String get alarmCardVacationPausedBadge => 'En pause pour les vacances';
}
+3
View File
@@ -1691,4 +1691,7 @@ class AppLocalizationsHi extends AppLocalizations {
String eqCustomActionPresetLabel(String preset) {
return 'प्रीसेट: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'छुट्टी के कारण रोका गया';
}
+3
View File
@@ -1699,4 +1699,7 @@ class AppLocalizationsId extends AppLocalizations {
String eqCustomActionPresetLabel(String preset) {
return 'Prasetel: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'Dijeda karena liburan';
}
+3
View File
@@ -1711,4 +1711,7 @@ class AppLocalizationsIt extends AppLocalizations {
String eqCustomActionPresetLabel(String preset) {
return 'Preset attivo: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'In pausa per le vacanze';
}
+3
View File
@@ -1642,4 +1642,7 @@ class AppLocalizationsJa extends AppLocalizations {
String eqCustomActionPresetLabel(String preset) {
return 'プリセット: $preset';
}
@override
String get alarmCardVacationPausedBadge => '休暇のため一時停止中';
}
+3
View File
@@ -1699,4 +1699,7 @@ class AppLocalizationsPt extends AppLocalizations {
String eqCustomActionPresetLabel(String preset) {
return 'Predefinição: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'Pausada por férias';
}
+3
View File
@@ -1703,4 +1703,7 @@ class AppLocalizationsRu extends AppLocalizations {
String eqCustomActionPresetLabel(String preset) {
return 'Пресет: $preset';
}
@override
String get alarmCardVacationPausedBadge => 'Приостановлено на время отпуска';
}
+3
View File
@@ -1634,4 +1634,7 @@ class AppLocalizationsZh extends AppLocalizations {
String eqCustomActionPresetLabel(String preset) {
return '预设:$preset';
}
@override
String get alarmCardVacationPausedBadge => '因假期已暂停';
}
+80 -11
View File
@@ -265,6 +265,26 @@ class _TarjetaAlarma extends StatelessWidget {
? 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 = <String>[
if (alarma.fadeInSegundos > 0)
l10n.alarmFadeInLabel(alarma.fadeInSegundos),
if ((alarma.volumen * 100).round() != 85)
'${(alarma.volumen * 100).round()}%',
if (pausadaPorVacaciones) l10n.alarmCardVacationPausedBadge,
];
return Dismissible(
key: ValueKey('tarjeta-alarma-${alarma.id}'),
direction: DismissDirection.horizontal,
@@ -309,14 +329,21 @@ class _TarjetaAlarma extends StatelessWidget {
),
),
const SizedBox(width: 8),
Text(
// 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),
color: Theme.of(context).colorScheme
.onSurface
.withValues(alpha: 0.5),
),
),
),
],
@@ -369,6 +396,25 @@ class _TarjetaAlarma extends StatelessWidget {
),
],
),
// 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),
),
),
],
],
),
),
@@ -1668,16 +1714,39 @@ String _weekdayShort(AppLocalizations l10n, int day) => switch (day) {
String _fechaCorta(AppLocalizations l10n, DateTime fecha) =>
fechaCortaLocalizada(l10n.localeName, fecha);
/// Audit 7.4 (t4:339): a compact recurrence label next to the alarm card's
/// giant time. Reuses the SAME generic labels the editor's own
/// `TipoProgramacionAlarma` `SegmentedButton` already shows (`oneTimeOption`
/// / `dailyOption` / `weekdaysOption`) rather than inventing a new, more
/// specific ARB string -- honest given the space (12px, next to a 34px
/// time) genuinely only fits a short word, not a full weekday list.
/// 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 => l10n.weekdaysOption,
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<int> diasSemana) {
if (diasSemana.isEmpty) return l10n.weekdaysOption;
final ordenados = [...diasSemana]..sort();
return ordenados.map((dia) => _weekdayShort(l10n, dia)).join(', ');
}
@@ -190,9 +190,26 @@ void main() {
expect(antes, isNot(l10n.alarmNoNextExecution));
// Lunes -> Martes: la fecha calculada SIEMPRE cambia, sea cual sea hoy.
await tester.tap(find.text(l10n.weekdayShortTuesday));
//
// Item 5: the alarm CARD underneath now also renders the real day
// abbreviation ("Lun") for a diasSemana alarm, so a bare
// `find.text(...)` for a weekday letter is ambiguous while the
// editor sheet is open on top of the list — scope to the sheet's own
// BottomSheet subtree to target the day-picker circle specifically.
final hojaEditor = find.byType(BottomSheet);
await tester.tap(
find.descendant(
of: hojaEditor,
matching: find.text(l10n.weekdayShortTuesday),
),
);
await tester.pumpAndSettle();
await tester.tap(find.text(l10n.weekdayShortMonday));
await tester.tap(
find.descendant(
of: hojaEditor,
matching: find.text(l10n.weekdayShortMonday),
),
);
await tester.pumpAndSettle();
final despues = _textoPreview(tester);
@@ -0,0 +1,330 @@
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:pluriwave/estado/estado_alarmas.dart';
import 'package:pluriwave/estado/estado_radio.dart';
import 'package:pluriwave/l10n/gen/app_localizations.dart';
import 'package:pluriwave/modelos/alarma_musical.dart';
import 'package:pluriwave/pantallas/pantalla_alarmas.dart';
import 'package:pluriwave/servicios/servicio_alarmas.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../helpers/fakes.dart';
import '../helpers/fakes_alarmas.dart';
/// Item 5: the alarm list must show which days a `diasSemana` alarm
/// actually fires on (e.g. "Lun, Mié, Vie"), not the generic "Días" label,
/// plus surface fade/volume/vacation-pause state when they are genuinely
/// informative -- without cluttering the row.
void main() {
setUp(() {
SharedPreferences.setMockInitialValues({});
});
Future<(EstadoRadio, EstadoAlarmas)> montar(
WidgetTester tester, {
required AlarmaMusical alarma,
List<RangoVacaciones> vacaciones = const [],
}) async {
tester.view.physicalSize = const Size(1440, 3200);
tester.view.devicePixelRatio = 1.0;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
final radio = EstadoRadio(
audio: FakeServicioAudio(),
favoritos: FakeServicioFavoritos(),
radio: FakeServicioRadio(),
servicioEcualizador: FakeServicioEcualizador(),
servicioGrabacion: FakeServicioGrabacionRadioInactiva(),
iniciarAutomaticamente: false,
);
addTearDown(radio.dispose);
final android = FakePuertoAlarmasAndroid();
final estadoAlarmas = EstadoAlarmas(
servicio: ServicioAlarmas(reloj: () => DateTime(2026, 6, 11, 6, 0)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estadoAlarmas.dispose);
addTearDown(android.dispose);
await estadoAlarmas.guardarAlarma(alarma);
if (vacaciones.isNotEmpty) {
await estadoAlarmas.guardarVacaciones(vacaciones);
}
await tester.pumpWidget(
MultiProvider(
providers: [
ChangeNotifierProvider<EstadoRadio>.value(value: radio),
ChangeNotifierProvider<EstadoAlarmas>.value(value: estadoAlarmas),
],
child: MaterialApp(
locale: const Locale('es'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const Scaffold(body: PantallaAlarmas()),
),
),
);
await tester.pump();
await tester.pump(const Duration(milliseconds: 100));
return (radio, estadoAlarmas);
}
testWidgets(
'diasSemana alarm shows the ACTUAL configured days (Lun, Mié, Vie), '
'not the generic "Días" label',
(tester) async {
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-dias',
nombre: 'Entre semana',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diasSemana,
diasSemana: [DateTime.monday, DateTime.wednesday, DateTime.friday],
),
);
expect(find.text('Lun, Mié, Vie'), findsOneWidget);
expect(find.text('Días'), findsNothing);
},
);
testWidgets('daily alarm still shows "Diaria" (unaffected)', (
tester,
) async {
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-diaria',
nombre: 'Todos los días',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
expect(find.text('Diaria'), findsOneWidget);
});
testWidgets('one-time alarm still shows "Una vez" (unaffected)', (
tester,
) async {
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-unica',
nombre: 'Una sola vez',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.unica,
diasSemana: [],
fechaUnica: null,
),
);
expect(find.text('Una vez'), findsOneWidget);
});
testWidgets(
'a diasSemana alarm with an (invalid/legacy) empty diasSemana falls '
'back to the generic label instead of showing nothing',
(tester) async {
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-dias-vacio',
nombre: 'Corrupta',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diasSemana,
diasSemana: [],
),
);
expect(find.text('Días'), findsOneWidget);
},
);
testWidgets('a configured fade-in shows a compact "Fade-in Ns" detail', (
tester,
) async {
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-fade',
nombre: 'Con fade',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
fadeInSegundos: 8,
),
);
expect(find.textContaining('Fade-in 8s'), findsOneWidget);
});
testWidgets('no fade-in (0s, the default) shows no fade detail', (
tester,
) async {
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-sin-fade',
nombre: 'Sin fade',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
fadeInSegundos: 0,
),
);
expect(find.textContaining('Fade-in'), findsNothing);
});
testWidgets(
'a non-default volume shows a compact percentage detail',
(tester) async {
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-vol',
nombre: 'Volumen bajo',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
volumen: 0.5,
),
);
expect(find.textContaining('50%'), findsOneWidget);
},
);
testWidgets('the default volume (85%) shows no volume detail', (
tester,
) async {
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-vol-default',
nombre: 'Volumen default',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
expect(find.textContaining('85%'), findsNothing);
});
testWidgets(
'an alarm paused by a CURRENTLY active vacation range shows a '
'vacation-paused detail',
(tester) async {
final l10n = lookupAppLocalizations(const Locale('es'));
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-vacaciones',
nombre: 'Pausada',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
sonarEnVacaciones: false,
),
vacaciones: [
// Wide, real-wall-clock-safe range (rangoVacacionesActivo()
// defaults to the REAL DateTime.now(), not this file's injected
// `reloj`) -- deliberately spans many years so the test stays
// valid regardless of exactly when it runs.
RangoVacaciones(
id: 'v1',
nombre: 'Verano',
inicio: DateTime(2020, 1, 1),
fin: DateTime(2030, 12, 31),
),
],
);
expect(
find.textContaining(l10n.alarmCardVacationPausedBadge),
findsOneWidget,
);
},
);
testWidgets(
'an alarm that DOES sound during vacations shows NO vacation-paused '
'detail even with an active range',
(tester) async {
final l10n = lookupAppLocalizations(const Locale('es'));
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-suena-vacaciones',
nombre: 'Suena igual',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
sonarEnVacaciones: true,
),
vacaciones: [
// Wide, real-wall-clock-safe range (rangoVacacionesActivo()
// defaults to the REAL DateTime.now(), not this file's injected
// `reloj`) -- deliberately spans many years so the test stays
// valid regardless of exactly when it runs.
RangoVacaciones(
id: 'v1',
nombre: 'Verano',
inicio: DateTime(2020, 1, 1),
fin: DateTime(2030, 12, 31),
),
],
);
expect(
find.textContaining(l10n.alarmCardVacationPausedBadge),
findsNothing,
);
},
);
testWidgets(
'sonarEnVacaciones:false with NO currently-active vacation range shows '
'no vacation-paused detail (nothing to be paused BY right now)',
(tester) async {
final l10n = lookupAppLocalizations(const Locale('es'));
await montar(
tester,
alarma: const AlarmaMusical(
id: 'a-sin-rango-activo',
nombre: 'Sin vacaciones activas',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
sonarEnVacaciones: false,
),
);
expect(
find.textContaining(l10n.alarmCardVacationPausedBadge),
findsNothing,
);
},
);
}