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
@@ -6,6 +6,7 @@ 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:pluriwave/widgets/pluri_push_scaffold.dart';
import 'package:provider/provider.dart';
import 'package:shared_preferences/shared_preferences.dart';
@@ -18,6 +19,80 @@ class _Entorno {
final EstadoAlarmas estadoAlarmas;
}
class _EntornoDosAlarmas {
_EntornoDosAlarmas({required this.estadoAlarmas, required this.android});
final EstadoAlarmas estadoAlarmas;
final FakePuertoAlarmasAndroid android;
}
/// WU8 fixture: two DAILY alarms ('r1' 07:00, 'r2' 08:00) under a FIXED clock
/// (06:00, before both) so 'r1' is deterministically the featured/soonest
/// alarm regardless of the wall-clock time the suite happens to run at.
Future<_EntornoDosAlarmas> _montarDosAlarmas(WidgetTester tester) 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, 1, 6, 0)),
android: android,
iniciarAutomaticamente: false,
);
addTearDown(estadoAlarmas.dispose);
addTearDown(android.dispose);
await estadoAlarmas.guardarAlarma(
const AlarmaMusical(
id: 'r1',
nombre: 'Primera',
hora: 7,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
await estadoAlarmas.guardarAlarma(
const AlarmaMusical(
id: 'r2',
nombre: 'Segunda',
hora: 8,
minuto: 0,
tipoProgramacion: TipoProgramacionAlarma.diaria,
diasSemana: [],
),
);
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.pumpAndSettle();
return _EntornoDosAlarmas(estadoAlarmas: estadoAlarmas, android: android);
}
Future<_Entorno> _abrirEditor(WidgetTester tester) async {
tester.view.physicalSize = const Size(1440, 3200);
tester.view.devicePixelRatio = 1.0;
@@ -73,10 +148,12 @@ Future<_Entorno> _abrirEditor(WidgetTester tester) async {
);
await tester.pumpAndSettle();
final l10n = lookupAppLocalizations(const Locale('es'));
await tester.ensureVisible(find.text(l10n.editAction).first);
// WU8: the always-visible "Edit" button is gone — tapping the simplified
// card itself now opens the editor.
final tarjeta = find.byKey(const ValueKey('tarjeta-alarma-ed1'));
await tester.ensureVisible(tarjeta);
await tester.pumpAndSettle();
await tester.tap(find.text(l10n.editAction).first);
await tester.tap(tarjeta);
await tester.pumpAndSettle();
return _Entorno(estadoAlarmas: estadoAlarmas);
}
@@ -207,4 +284,110 @@ void main() {
final volumen = sliders.firstWhere((slider) => slider.max == 1.0);
expect(volumen.min, 0.0);
});
group('WU8 — tarjeta de alarma simplificada', () {
testWidgets(
'tocar la tarjeta abre el editor precargado con los datos de esa '
'alarma',
(tester) async {
await _montarDosAlarmas(tester);
await tester.tap(find.byKey(const ValueKey('tarjeta-alarma-r2')));
await tester.pumpAndSettle();
expect(find.text(l10n.editAlarmTitle), findsOneWidget);
expect(find.text('Segunda'), findsOneWidget);
},
);
testWidgets('deslizar la tarjeta pide confirmacion; cancelar la conserva y '
'confirmar la elimina', (tester) async {
final entorno = await _montarDosAlarmas(tester);
// Cancelar: la tarjeta se conserva.
await tester.drag(
find.byKey(const ValueKey('tarjeta-alarma-r2')),
const Offset(-600, 0),
);
await tester.pumpAndSettle();
expect(find.text(l10n.alarmDeleteConfirmTitle), findsOneWidget);
await tester.tap(find.text(l10n.cancelAction));
await tester.pumpAndSettle();
expect(entorno.estadoAlarmas.alarmas.length, 2);
expect(find.byKey(const ValueKey('tarjeta-alarma-r2')), findsOneWidget);
// Confirmar: la tarjeta se elimina.
await tester.drag(
find.byKey(const ValueKey('tarjeta-alarma-r2')),
const Offset(-600, 0),
);
await tester.pumpAndSettle();
expect(find.text(l10n.alarmDeleteConfirmTitle), findsOneWidget);
await tester.tap(find.text(l10n.deleteAction));
await tester.pumpAndSettle();
expect(entorno.estadoAlarmas.alarmas.length, 1);
expect(find.byKey(const ValueKey('tarjeta-alarma-r2')), findsNothing);
});
testWidgets(
'el banner destacado permite saltar la alarma mas proxima (accion '
'"Saltar")',
(tester) async {
final entorno = await _montarDosAlarmas(tester);
final proximaAntes = entorno.estadoAlarmas.proximaAlarma;
expect(proximaAntes?.id, 'r1');
await tester.tap(find.byKey(const ValueKey('hero-skip-next')));
await tester.pumpAndSettle();
expect(find.byType(SnackBar), findsOneWidget);
final r1 = entorno.estadoAlarmas.alarmas.firstWhere(
(a) => a.id == 'r1',
);
expect(r1.proximaEjecucion, isNot(proximaAntes!.proximaEjecucion));
},
);
testWidgets('la fila resumen de vacaciones empuja una pantalla al tocarla '
'(destino provisorio; WU9 lo reemplaza)', (tester) async {
await _montarDosAlarmas(tester);
await tester.tap(find.byKey(const ValueKey('vacaciones-resumen')));
await tester.pumpAndSettle();
expect(find.byType(PluriPushScaffold), findsOneWidget);
});
testWidgets(
'guardia de capacidad: editar (tap), saltar (hero) y eliminar (swipe) '
'siguen siendo alcanzables tras la simplificacion de la tarjeta',
(tester) async {
final entorno = await _montarDosAlarmas(tester);
// 1) Saltar, desde el banner destacado.
await tester.tap(find.byKey(const ValueKey('hero-skip-next')));
await tester.pumpAndSettle();
expect(find.byType(SnackBar), findsOneWidget);
// 2) Editar, tocando la tarjeta.
await tester.tap(find.byKey(const ValueKey('tarjeta-alarma-r2')));
await tester.pumpAndSettle();
expect(find.text(l10n.editAlarmTitle), findsOneWidget);
await tester.tap(find.byIcon(Icons.close_rounded));
await tester.pumpAndSettle();
// 3) Eliminar, deslizando y confirmando.
await tester.drag(
find.byKey(const ValueKey('tarjeta-alarma-r2')),
const Offset(-600, 0),
);
await tester.pumpAndSettle();
await tester.tap(find.text(l10n.deleteAction));
await tester.pumpAndSettle();
expect(entorno.estadoAlarmas.alarmas.length, 1);
},
);
});
}