diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 0f5bd8b..cbb3835 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -720,6 +720,10 @@ "stationCount": "{count, plural, =1{1 station} other{{count} stations}}", "alarmIconLabel": "Musical alarm", "vacationIconLabel": "Vacation mode", + "alarmAdvancedSectionTitle": "Advanced", + "alarmInlineHourLabel": "Hour", + "alarmInlineMinuteLabel": "Minute", + "alarmVolumeRisingStatus": "Turning up the volume", "streamUrlHint": "https://stream.example.com:8000/radio", "advancedEqSectionTitle": "Advanced Equalization Options", "advancedEqEnableToggle": "Enable per-device EQ", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 6421abb..70a30e2 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -672,6 +672,10 @@ "stationCount": "{count, plural, =1{1 emisora} other{{count} emisoras}}", "alarmIconLabel": "Alarma musical", "vacationIconLabel": "Modo vacaciones", + "alarmAdvancedSectionTitle": "Avanzado", + "alarmInlineHourLabel": "Hora", + "alarmInlineMinuteLabel": "Minuto", + "alarmVolumeRisingStatus": "Subiendo volumen", "streamUrlHint": "https://stream.example.com:8000/radio", "@stationCount": { "placeholders": { diff --git a/lib/l10n/gen/app_localizations.dart b/lib/l10n/gen/app_localizations.dart index 477d2d5..303407b 100644 --- a/lib/l10n/gen/app_localizations.dart +++ b/lib/l10n/gen/app_localizations.dart @@ -2564,6 +2564,30 @@ abstract class AppLocalizations { /// **'Modo vacaciones'** String get vacationIconLabel; + /// No description provided for @alarmAdvancedSectionTitle. + /// + /// In es, this message translates to: + /// **'Avanzado'** + String get alarmAdvancedSectionTitle; + + /// No description provided for @alarmInlineHourLabel. + /// + /// In es, this message translates to: + /// **'Hora'** + String get alarmInlineHourLabel; + + /// No description provided for @alarmInlineMinuteLabel. + /// + /// In es, this message translates to: + /// **'Minuto'** + String get alarmInlineMinuteLabel; + + /// No description provided for @alarmVolumeRisingStatus. + /// + /// In es, this message translates to: + /// **'Subiendo volumen'** + String get alarmVolumeRisingStatus; + /// No description provided for @streamUrlHint. /// /// In es, this message translates to: diff --git a/lib/l10n/gen/app_localizations_ar.dart b/lib/l10n/gen/app_localizations_ar.dart index 786ab08..c431288 100644 --- a/lib/l10n/gen/app_localizations_ar.dart +++ b/lib/l10n/gen/app_localizations_ar.dart @@ -1410,6 +1410,18 @@ class AppLocalizationsAr extends AppLocalizations { @override String get vacationIconLabel => 'وضع الإجازة'; + @override + String get alarmAdvancedSectionTitle => 'Avanzado'; + + @override + String get alarmInlineHourLabel => 'Hora'; + + @override + String get alarmInlineMinuteLabel => 'Minuto'; + + @override + String get alarmVolumeRisingStatus => 'Subiendo volumen'; + @override String get streamUrlHint => 'https://stream.example.com:8000/radio'; diff --git a/lib/l10n/gen/app_localizations_bn.dart b/lib/l10n/gen/app_localizations_bn.dart index 73df8f2..8f9dc69 100644 --- a/lib/l10n/gen/app_localizations_bn.dart +++ b/lib/l10n/gen/app_localizations_bn.dart @@ -1417,6 +1417,18 @@ class AppLocalizationsBn extends AppLocalizations { @override String get vacationIconLabel => 'ছুটির মোড'; + @override + String get alarmAdvancedSectionTitle => 'Avanzado'; + + @override + String get alarmInlineHourLabel => 'Hora'; + + @override + String get alarmInlineMinuteLabel => 'Minuto'; + + @override + String get alarmVolumeRisingStatus => 'Subiendo volumen'; + @override String get streamUrlHint => 'https://stream.example.com:8000/radio'; diff --git a/lib/l10n/gen/app_localizations_de.dart b/lib/l10n/gen/app_localizations_de.dart index fcbb7f6..0bbb3a6 100644 --- a/lib/l10n/gen/app_localizations_de.dart +++ b/lib/l10n/gen/app_localizations_de.dart @@ -1427,6 +1427,18 @@ class AppLocalizationsDe extends AppLocalizations { @override String get vacationIconLabel => 'Urlaubsmodus'; + @override + String get alarmAdvancedSectionTitle => 'Avanzado'; + + @override + String get alarmInlineHourLabel => 'Hora'; + + @override + String get alarmInlineMinuteLabel => 'Minuto'; + + @override + String get alarmVolumeRisingStatus => 'Subiendo volumen'; + @override String get streamUrlHint => 'https://stream.example.com:8000/radio'; diff --git a/lib/l10n/gen/app_localizations_en.dart b/lib/l10n/gen/app_localizations_en.dart index 39de3bb..533fe09 100644 --- a/lib/l10n/gen/app_localizations_en.dart +++ b/lib/l10n/gen/app_localizations_en.dart @@ -1411,6 +1411,18 @@ class AppLocalizationsEn extends AppLocalizations { @override String get vacationIconLabel => 'Vacation mode'; + @override + String get alarmAdvancedSectionTitle => 'Advanced'; + + @override + String get alarmInlineHourLabel => 'Hour'; + + @override + String get alarmInlineMinuteLabel => 'Minute'; + + @override + String get alarmVolumeRisingStatus => 'Turning up the volume'; + @override String get streamUrlHint => 'https://stream.example.com:8000/radio'; diff --git a/lib/l10n/gen/app_localizations_es.dart b/lib/l10n/gen/app_localizations_es.dart index 0a25f82..35133e8 100644 --- a/lib/l10n/gen/app_localizations_es.dart +++ b/lib/l10n/gen/app_localizations_es.dart @@ -1422,6 +1422,18 @@ class AppLocalizationsEs extends AppLocalizations { @override String get vacationIconLabel => 'Modo vacaciones'; + @override + String get alarmAdvancedSectionTitle => 'Avanzado'; + + @override + String get alarmInlineHourLabel => 'Hora'; + + @override + String get alarmInlineMinuteLabel => 'Minuto'; + + @override + String get alarmVolumeRisingStatus => 'Subiendo volumen'; + @override String get streamUrlHint => 'https://stream.example.com:8000/radio'; diff --git a/lib/l10n/gen/app_localizations_fr.dart b/lib/l10n/gen/app_localizations_fr.dart index df503ac..f397658 100644 --- a/lib/l10n/gen/app_localizations_fr.dart +++ b/lib/l10n/gen/app_localizations_fr.dart @@ -1432,6 +1432,18 @@ class AppLocalizationsFr extends AppLocalizations { @override String get vacationIconLabel => 'Mode vacances'; + @override + String get alarmAdvancedSectionTitle => 'Avanzado'; + + @override + String get alarmInlineHourLabel => 'Hora'; + + @override + String get alarmInlineMinuteLabel => 'Minuto'; + + @override + String get alarmVolumeRisingStatus => 'Subiendo volumen'; + @override String get streamUrlHint => 'https://stream.example.com:8000/radio'; diff --git a/lib/l10n/gen/app_localizations_hi.dart b/lib/l10n/gen/app_localizations_hi.dart index f6469b4..c94e04b 100644 --- a/lib/l10n/gen/app_localizations_hi.dart +++ b/lib/l10n/gen/app_localizations_hi.dart @@ -1416,6 +1416,18 @@ class AppLocalizationsHi extends AppLocalizations { @override String get vacationIconLabel => 'अवकाश मोड'; + @override + String get alarmAdvancedSectionTitle => 'Avanzado'; + + @override + String get alarmInlineHourLabel => 'Hora'; + + @override + String get alarmInlineMinuteLabel => 'Minuto'; + + @override + String get alarmVolumeRisingStatus => 'Subiendo volumen'; + @override String get streamUrlHint => 'https://stream.example.com:8000/radio'; diff --git a/lib/l10n/gen/app_localizations_id.dart b/lib/l10n/gen/app_localizations_id.dart index 0592a0b..9f50a4c 100644 --- a/lib/l10n/gen/app_localizations_id.dart +++ b/lib/l10n/gen/app_localizations_id.dart @@ -1421,6 +1421,18 @@ class AppLocalizationsId extends AppLocalizations { @override String get vacationIconLabel => 'Mode liburan'; + @override + String get alarmAdvancedSectionTitle => 'Avanzado'; + + @override + String get alarmInlineHourLabel => 'Hora'; + + @override + String get alarmInlineMinuteLabel => 'Minuto'; + + @override + String get alarmVolumeRisingStatus => 'Subiendo volumen'; + @override String get streamUrlHint => 'https://stream.example.com:8000/radio'; diff --git a/lib/l10n/gen/app_localizations_it.dart b/lib/l10n/gen/app_localizations_it.dart index ca05fa1..16d05c3 100644 --- a/lib/l10n/gen/app_localizations_it.dart +++ b/lib/l10n/gen/app_localizations_it.dart @@ -1427,6 +1427,18 @@ class AppLocalizationsIt extends AppLocalizations { @override String get vacationIconLabel => 'Modalità vacanza'; + @override + String get alarmAdvancedSectionTitle => 'Avanzado'; + + @override + String get alarmInlineHourLabel => 'Hora'; + + @override + String get alarmInlineMinuteLabel => 'Minuto'; + + @override + String get alarmVolumeRisingStatus => 'Subiendo volumen'; + @override String get streamUrlHint => 'https://stream.example.com:8000/radio'; diff --git a/lib/l10n/gen/app_localizations_ja.dart b/lib/l10n/gen/app_localizations_ja.dart index fe34c22..1ecd392 100644 --- a/lib/l10n/gen/app_localizations_ja.dart +++ b/lib/l10n/gen/app_localizations_ja.dart @@ -1376,6 +1376,18 @@ class AppLocalizationsJa extends AppLocalizations { @override String get vacationIconLabel => '休暇モード'; + @override + String get alarmAdvancedSectionTitle => 'Avanzado'; + + @override + String get alarmInlineHourLabel => 'Hora'; + + @override + String get alarmInlineMinuteLabel => 'Minuto'; + + @override + String get alarmVolumeRisingStatus => 'Subiendo volumen'; + @override String get streamUrlHint => 'https://stream.example.com:8000/radio'; diff --git a/lib/l10n/gen/app_localizations_pt.dart b/lib/l10n/gen/app_localizations_pt.dart index 2f6416a..844e8b8 100644 --- a/lib/l10n/gen/app_localizations_pt.dart +++ b/lib/l10n/gen/app_localizations_pt.dart @@ -1419,6 +1419,18 @@ class AppLocalizationsPt extends AppLocalizations { @override String get vacationIconLabel => 'Modo férias'; + @override + String get alarmAdvancedSectionTitle => 'Avanzado'; + + @override + String get alarmInlineHourLabel => 'Hora'; + + @override + String get alarmInlineMinuteLabel => 'Minuto'; + + @override + String get alarmVolumeRisingStatus => 'Subiendo volumen'; + @override String get streamUrlHint => 'https://stream.example.com:8000/radio'; diff --git a/lib/l10n/gen/app_localizations_ru.dart b/lib/l10n/gen/app_localizations_ru.dart index 59f0563..1bb6707 100644 --- a/lib/l10n/gen/app_localizations_ru.dart +++ b/lib/l10n/gen/app_localizations_ru.dart @@ -1423,6 +1423,18 @@ class AppLocalizationsRu extends AppLocalizations { @override String get vacationIconLabel => 'Режим отпуска'; + @override + String get alarmAdvancedSectionTitle => 'Avanzado'; + + @override + String get alarmInlineHourLabel => 'Hora'; + + @override + String get alarmInlineMinuteLabel => 'Minuto'; + + @override + String get alarmVolumeRisingStatus => 'Subiendo volumen'; + @override String get streamUrlHint => 'https://stream.example.com:8000/radio'; diff --git a/lib/l10n/gen/app_localizations_zh.dart b/lib/l10n/gen/app_localizations_zh.dart index d4ed20b..846839d 100644 --- a/lib/l10n/gen/app_localizations_zh.dart +++ b/lib/l10n/gen/app_localizations_zh.dart @@ -1371,6 +1371,18 @@ class AppLocalizationsZh extends AppLocalizations { @override String get vacationIconLabel => '假期模式'; + @override + String get alarmAdvancedSectionTitle => 'Avanzado'; + + @override + String get alarmInlineHourLabel => 'Hora'; + + @override + String get alarmInlineMinuteLabel => 'Minuto'; + + @override + String get alarmVolumeRisingStatus => 'Subiendo volumen'; + @override String get streamUrlHint => 'https://stream.example.com:8000/radio'; diff --git a/lib/pantallas/pantalla_alarmas.dart b/lib/pantallas/pantalla_alarmas.dart index 3e04fc1..075aaeb 100644 --- a/lib/pantallas/pantalla_alarmas.dart +++ b/lib/pantallas/pantalla_alarmas.dart @@ -12,6 +12,7 @@ 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_icon.dart'; import '../widgets/pluri_layout.dart'; @@ -448,32 +449,17 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { controller: _nombreController, decoration: InputDecoration(labelText: l10n.nameLabel), ), - const SizedBox(height: 12), - Row( - children: [ - Expanded( - child: _PickerButton( - icon: Icons.schedule_rounded, - label: l10n.timeField, - value: _hora.format(context), - onTap: _elegirHora, - ), - ), - const SizedBox(width: 10), - Expanded( - child: _PickerButton( - icon: Icons.event_rounded, - label: l10n.dateField, - value: _fechaCorta(l10n, _fecha), - onTap: - _tipo == TipoProgramacionAlarma.unica - ? _elegirFecha - : null, - ), - ), - ], + const SizedBox(height: 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. + Center( + child: EditorHoraInline( + value: _hora, + onChanged: (nuevo) => setState(() => _hora = nuevo), + ), ), - const SizedBox(height: 12), + const SizedBox(height: 16), SegmentedButton( segments: [ ButtonSegment( @@ -493,25 +479,33 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { onSelectionChanged: (value) => setState(() => _tipo = value.first), ), - if (_tipo == TipoProgramacionAlarma.diasSemana) ...[ - const SizedBox(height: 10), - Wrap( - spacing: 6, - children: [ - for (var i = DateTime.monday; i <= DateTime.sunday; i++) - FilterChip( - label: Text(_weekdayShort(l10n, i)), - selected: _diasSemana.contains(i), - onSelected: - (selected) => setState(() { - selected - ? _diasSemana.add(i) - : _diasSemana.remove(i); - }), - ), - ], - ), - ], + const SizedBox(height: 10), + // 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 (onSelected: null, the + // standard Material "greyed out" FilterChip state) outside + // diasSemana mode rather than being wired to silently mutate + // `_diasSemana` while a different `_tipo` is saved — no + // scheduling-data-model change, presentation only. + Wrap( + spacing: 6, + children: [ + for (var i = DateTime.monday; i <= DateTime.sunday; i++) + FilterChip( + label: Text(_weekdayShort(l10n, i)), + selected: _diasSemana.contains(i), + onSelected: + _tipo == TipoProgramacionAlarma.diasSemana + ? (selected) => setState(() { + selected + ? _diasSemana.add(i) + : _diasSemana.remove(i); + }) + : null, + ), + ], + ), const SizedBox(height: 12), _vistaProximaEjecucion(l10n), const SizedBox(height: 14), @@ -531,7 +525,10 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { const SizedBox(height: 8), ListTile( contentPadding: EdgeInsets.zero, - title: Text(l10n.alarmFadeInTitle), + title: Text( + l10n.alarmFadeInTitle, + style: context.pluriType.cardTitle, + ), subtitle: Text( _fadeInSegundos == 0 ? l10n.alarmFadeInOff @@ -566,33 +563,11 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { (value) => setState(() => _snoozeMinutos = value.first), ), const SizedBox(height: 8), - DropdownButtonFormField( - initialValue: _sonidoInterno, - decoration: InputDecoration( - labelText: l10n.internalSafeSoundLabel, - ), - items: [ - DropdownMenuItem( - value: SonidoInternoAlarma.amanecer, - child: Text(l10n.soundWarmSunrise), - ), - DropdownMenuItem( - value: SonidoInternoAlarma.campanaSuave, - child: Text(l10n.soundSoftBell), - ), - DropdownMenuItem( - value: SonidoInternoAlarma.pulsoDigital, - child: Text(l10n.soundDigitalPulse), - ), - ], - onChanged: - (value) => setState( - () => _sonidoInterno = value ?? _sonidoInterno, - ), - ), - const SizedBox(height: 8), // S2-R9: searchable bottom-sheet picker instead of a dropdown, - // for both the primary and the backup (fallback) station. + // 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, @@ -608,26 +583,6 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { (emisora) => setState(() => _emisora = emisora), ), ), - const SizedBox(height: 8), - _CampoSelectorEmisora( - key: const ValueKey('alarm-fallback-station-field'), - label: l10n.alarmFallbackStationLabel, - icon: Icons.settings_backup_restore_rounded, - value: - _emisoraFallback == null - ? l10n.noStationUseInternalSound - : localizedStationName( - l10n, - _emisoraFallback!.nombre, - ), - onTap: - () => _elegirEmisora( - favoritas, - seleccionar: - (emisora) => - setState(() => _emisoraFallback = emisora), - ), - ), if (favoritas.isEmpty) ...[ const SizedBox(height: 6), Text(l10n.saveFavoritesAlarmHint), @@ -658,6 +613,78 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { title: Text(l10n.playDuringVacations), subtitle: Text(l10n.playDuringVacationsHint), ), + 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, @@ -727,11 +754,6 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> { seleccionar(resultado.emisora); } - Future _elegirHora() async { - final nueva = await showTimePicker(context: context, initialTime: _hora); - if (nueva != null) setState(() => _hora = nueva); - } - Future _elegirFecha() async { final ahora = DateTime.now(); final nueva = await showDatePicker( @@ -1148,12 +1170,10 @@ class _SectionLabel extends StatelessWidget { children: [ _AssetIcon(icon, size: 34), const SizedBox(width: 8), - Text( - text, - style: Theme.of( - context, - ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800), - ), + // WU10: swapped the raw TextTheme lookup for the named type-scale + // token (cosmetic only — same weight class, now shared with every + // other card/section title in the redesign). + Text(text, style: context.pluriType.cardTitle), ], ); } diff --git a/lib/widgets/editor_hora_inline.dart b/lib/widgets/editor_hora_inline.dart new file mode 100644 index 0000000..49638c4 --- /dev/null +++ b/lib/widgets/editor_hora_inline.dart @@ -0,0 +1,208 @@ +import 'package:flutter/material.dart'; + +import '../l10n/gen/app_localizations.dart'; +import '../tema/pluriwave_theme.dart'; + +/// Giant inline HH:MM editor (WU10, `native-alarms` delta — Alarm Editor +/// Preserves Date, Fallback Station, and Sound Fields): replaces the native +/// `showTimePicker` dialog inside `_EditorAlarmaSheet`. Standalone and +/// independent of the sheet — it only exposes `value`/`onChanged`, so it can +/// be unit-tested (and reused) with no alarm/editor state at all. +/// +/// Each segment (hour, minute) supports two independent adjustment paths: +/// - Tap: increments that segment by one step, wrapping (`23:59` + 1 minute +/// wraps to `00:00`, matching a real clock's minute rollover). +/// - Vertical drag: continuous bidirectional adjustment — up increases, down +/// decreases — for users who want to scrub several steps at once. +/// +/// Screen readers get BOTH directions regardless of the touch affordance: +/// each segment exposes `Semantics.onIncrease`/`onDecrease` (the same +/// adjustable-control pattern `Slider` uses internally), so a drag gesture is +/// never required for accessible use. +class EditorHoraInline extends StatefulWidget { + const EditorHoraInline({ + super.key, + required this.value, + required this.onChanged, + }); + + final TimeOfDay value; + final ValueChanged onChanged; + + @override + State createState() => _EditorHoraInlineState(); +} + +class _EditorHoraInlineState extends State { + /// Logical pixels of accumulated vertical drag per one-unit step. Chosen + /// for a comfortable scrub distance — not derived from any measured + /// constant, this widget has no other consumer to stay in sync with. + static const double _pixelesPorPaso = 24; + + double _arrastreHora = 0; + double _arrastreMinuto = 0; + + /// Pure preview: the hour after applying [delta], wrapping 23→0 / 0→23. + /// Shared by the actual mutation and by the `Semantics` + /// increasedValue/decreasedValue text (Flutter requires both whenever + /// `onIncrease`/`onDecrease` are set). + int _horaConDelta(int delta) { + final horas = (widget.value.hour + delta) % 24; + return horas < 0 ? horas + 24 : horas; + } + + /// Pure preview: the minute after applying [delta] to the whole HH:MM, + /// wrapping at the day boundary (`23:59` + 1 minute → `00:00`). + TimeOfDay _horaCompletaConDeltaMinuto(int delta) { + final totalMinutos = widget.value.hour * 60 + widget.value.minute + delta; + final normalizado = totalMinutos % (24 * 60); + final positivo = normalizado < 0 ? normalizado + 24 * 60 : normalizado; + return TimeOfDay(hour: positivo ~/ 60, minute: positivo % 60); + } + + void _ajustarHora(int delta) { + if (delta == 0) return; + widget.onChanged( + TimeOfDay(hour: _horaConDelta(delta), minute: widget.value.minute), + ); + } + + void _ajustarMinuto(int delta) { + if (delta == 0) return; + widget.onChanged(_horaCompletaConDeltaMinuto(delta)); + } + + void _onArrastreHora(DragUpdateDetails details) { + // Screen-space dy grows downward, so an upward drag (negative dy) must + // increase the value: subtract, don't add. + _arrastreHora -= details.delta.dy; + while (_arrastreHora >= _pixelesPorPaso) { + _arrastreHora -= _pixelesPorPaso; + _ajustarHora(1); + } + while (_arrastreHora <= -_pixelesPorPaso) { + _arrastreHora += _pixelesPorPaso; + _ajustarHora(-1); + } + } + + void _onArrastreMinuto(DragUpdateDetails details) { + _arrastreMinuto -= details.delta.dy; + while (_arrastreMinuto >= _pixelesPorPaso) { + _arrastreMinuto -= _pixelesPorPaso; + _ajustarMinuto(1); + } + while (_arrastreMinuto <= -_pixelesPorPaso) { + _arrastreMinuto += _pixelesPorPaso; + _ajustarMinuto(-1); + } + } + + @override + Widget build(BuildContext context) { + final l10n = AppLocalizations.of(context); + final type = context.pluriType; + final horaTexto = widget.value.hour.toString().padLeft(2, '0'); + final minutoTexto = widget.value.minute.toString().padLeft(2, '0'); + + return FittedBox( + fit: BoxFit.scaleDown, + child: Row( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + _Segmento( + key: const ValueKey('editor-hora-inline-hora'), + texto: horaTexto, + semanticLabel: l10n.alarmInlineHourLabel, + incrementado: _horaConDelta(1).toString().padLeft(2, '0'), + decrementado: _horaConDelta(-1).toString().padLeft(2, '0'), + style: type.heroTime, + onTap: () => _ajustarHora(1), + onIncrease: () => _ajustarHora(1), + onDecrease: () => _ajustarHora(-1), + onDragUpdate: _onArrastreHora, + ), + Text(':', style: type.heroTime), + _Segmento( + key: const ValueKey('editor-hora-inline-minuto'), + texto: minutoTexto, + semanticLabel: l10n.alarmInlineMinuteLabel, + incrementado: _horaCompletaConDeltaMinuto( + 1, + ).minute.toString().padLeft(2, '0'), + decrementado: _horaCompletaConDeltaMinuto( + -1, + ).minute.toString().padLeft(2, '0'), + style: type.heroTime, + onTap: () => _ajustarMinuto(1), + onIncrease: () => _ajustarMinuto(1), + onDecrease: () => _ajustarMinuto(-1), + onDragUpdate: _onArrastreMinuto, + ), + ], + ), + ); + } +} + +class _Segmento extends StatelessWidget { + const _Segmento({ + super.key, + required this.texto, + required this.semanticLabel, + required this.incrementado, + required this.decrementado, + required this.style, + required this.onTap, + required this.onIncrease, + required this.onDecrease, + required this.onDragUpdate, + }); + + final String texto; + final String semanticLabel; + + /// Text `Semantics.value` becomes after `onIncrease`/`onDecrease` fires. + /// Flutter requires both whenever a node exposes increase/decrease + /// actions alongside a `value` (see `SemanticsNode.updateWith`'s + /// `(value == '') == (increasedValue == '')` assertion). + final String incrementado; + final String decrementado; + final TextStyle? style; + final VoidCallback onTap; + final VoidCallback onIncrease; + final VoidCallback onDecrease; + final GestureDragUpdateCallback onDragUpdate; + + @override + Widget build(BuildContext context) { + // `excludeSemantics: true` + an explicit `onTap` here: without it, the + // GestureDetector's OWN semantics contribution merges upward and (a) + // duplicates the digits into `label` (via its child Text's implicit + // semantics) and (b) auto-exposes scrollUp/scrollDown (Flutter's default + // accessibility mapping for a registered vertical-drag recognizer) — + // neither of which this widget wants. Declaring every action explicitly + // on this one node keeps the exposed contract exactly label/value/ + // increasedValue/decreasedValue/tap/increase/decrease, nothing more. + return Semantics( + label: semanticLabel, + value: texto, + increasedValue: incrementado, + decreasedValue: decrementado, + onTap: onTap, + onIncrease: onIncrease, + onDecrease: onDecrease, + excludeSemantics: true, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap, + onVerticalDragUpdate: onDragUpdate, + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 6), + child: Text(texto, style: style), + ), + ), + ); + } +} diff --git a/openspec/changes/rediseno-funcional/tasks.md b/openspec/changes/rediseno-funcional/tasks.md index b2e6ba5..4e763a4 100644 --- a/openspec/changes/rediseno-funcional/tasks.md +++ b/openspec/changes/rediseno-funcional/tasks.md @@ -612,22 +612,60 @@ splitting further would leave either dead query methods with no UI consumer or a **New tests**: `test/widgets/editor_hora_inline_test.dart` (standalone) **Modified tests**: `pantalla_alarmas_fecha_test.dart` -- [ ] 10.1 RED — `editor_hora_inline_test.dart`, **standalone, independent of the sheet**: drag/tap adjusts hour and - minute, wraps at 23:59→00:00, exposes correct a11y labels. -- [ ] 10.2 GREEN — implement `lib/widgets/editor_hora_inline.dart` (giant inline HH:MM display, drag/tap-to-adjust). -- [ ] 10.3 RED — update `pantalla_alarmas_fecha_test.dart`: one-time date alarm still creatable via the (now +- [x] 10.1 RED — `editor_hora_inline_test.dart`, **standalone, independent of the sheet**: drag/tap adjusts hour and + minute, wraps at 23:59→00:00, exposes correct a11y labels. **Correction found at apply time**: Flutter + requires a `Semantics` node exposing `onIncrease`/`onDecrease` to ALSO carry `increasedValue`/`decreasedValue` + whenever `value` is set (`SemanticsNode.updateWith`'s `(value == '') == (increasedValue == '')` assertion) — + added both, computed from the same pure delta-preview helpers the mutation uses. Also had to set + `excludeSemantics: true` on each segment's `Semantics` node: without it, the inner `GestureDetector`'s own + semantics contribution merged upward and (a) duplicated the digits into `label` via the child `Text`'s + implicit semantics and (b) auto-exposed `scrollUp`/`scrollDown` (Flutter's default accessibility mapping for + a registered vertical-drag recognizer) — neither wanted. Every action (`tap`/`increase`/`decrease`) is now + declared explicitly on the one Semantics node instead. +- [x] 10.2 GREEN — implement `lib/widgets/editor_hora_inline.dart` (giant inline HH:MM display, drag/tap-to-adjust). + Tap increments by one step (wrapping); vertical drag adjusts continuously (24px/step, chosen for a + comfortable scrub distance — not a reused measured constant, this widget has no other consumer). +- [x] 10.3 RED — update `pantalla_alarmas_fecha_test.dart`: one-time date alarm still creatable via the (now collapsed) Advanced section; fallback-station picker and sound dropdown still settable and persisted. -- [ ] 10.4 GREEN — replace the native `showTimePicker` dialog with `EditorHoraInline`; keep weekday circles always + **Correction found at apply time**: this file previously held ONLY the 2 pure `fechaCortaLocalizada` format + tests, no widget coverage at all — extended it in place (kept both untouched) rather than creating a second + file, since the task's own instruction was to "update" this file. Also required a one-line surgical fix to + the PRE-EXISTING fallback-station test in `pantalla_alarmas_editor_test.dart` (not itself listed as a + "Modified test" for this WU): a collapsed `ExpansionTile` does not build its children, so + `find.byKey('alarm-fallback-station-field')` found nothing until the "Advanced" header is tapped first — + added that one tap, no scenario/assertion changed. +- [x] 10.4 GREEN — replace the native `showTimePicker` dialog with `EditorHoraInline`; keep weekday circles always visible; move the date field, fallback-station picker, and sound dropdown into a collapsed "Advanced" - section rather than dropping them. -- [ ] 10.5 GREEN — restyle the volume/fade-in sliders (cosmetic only, no behaviour change). -- [ ] 10.6 REFACTOR — confirm the dismiss-guard test is untouched by this WU (editor sheet only, not the ringing - screen). -- [ ] 10.7 Verify — standalone widget test green independent of the sheet; date/fallback/sound round-trip test - green. + section rather than dropping them. **Design decision, not specified by any ADR (WU10 has none)**: the + one-time/daily/weekly `SegmentedButton` stays in the main flow (not Advanced) since it gates which weekday + circles are enabled; weekday circles now render unconditionally with `onSelected: null` (Material's standard + disabled-chip state) when `_tipo != diasSemana`, instead of being removed from the tree — no change to + `_tipo`/`diasSemana`/`fechaUnica` persistence logic, confirmed the scheduler treats `diaria` and + `diasSemana` as genuinely different code paths (`_buscarDiaria` vs `_buscarPorDiasSemana` in + `servicio_programacion_alarmas.dart`), so the two were never merged. +- [x] 10.5 GREEN — restyle the volume/fade-in sliders (cosmetic only, no behaviour change): swapped the raw + `Theme.of(context).textTheme` lookups on `_SectionLabel` ("Sonido y volumen") and the fade-in `ListTile` + title for `context.pluriType.cardTitle` — same weight class, now sharing the named type-scale token with + every other card/section title in the redesign. `Slider` widget types and their `min`/`max`/`value` wiring + are byte-identical, so `'el slider de volumen permite bajar hasta 0.0 (S2-R11)'` passes unmodified. +- [x] 10.6 REFACTOR — confirmed the dismiss-guard test is untouched by this WU (editor sheet only, not the ringing + screen): `git diff` empty for both `pantalla_alarma_sonando.dart` and + `pantalla_alarma_sonando_dismiss_guard_test.dart`; also removed the now-dead `_elegirHora()` method (its only + caller, the old time-picker `_PickerButton`, no longer exists). +- [x] 10.7 Verify — standalone widget test green (7/7); date/fallback/sound round-trip tests green (4/4 new in + `pantalla_alarmas_fecha_test.dart`); full WU10-scoped run (`editor_hora_inline_test.dart` + + `pantalla_alarmas_fecha_test.dart` + `pantalla_alarmas_editor_test.dart` + the two `estado_alarmas*` guard + files + all 3 `pantalla_alarma_sonando*` files + `pantalla_vacaciones_test.dart`): 82/82 green. `flutter + analyze`: 1 issue, identical to baseline. -**`size:exception` recommended.** ~500-650 lines — the largest genuinely-new widget in the plan; already isolated -as its own unit per the proposal ("never bundled"). See forecast table footnote ‡. +**`size:exception` recorded.** Realized: **993 changed lines** (891+/102-) across 21 files — over the 500-650 +forecast, same "a strict-TDD commit carries its test files, and any ARB touch drags 13 generated files with it" +pattern as every prior WU (Engram `reference/estimating-strict-tdd-diffs`, id 2514). Breakdown: `lib/` production +code alone is 429 lines (`pantalla_alarmas.dart` 123+/102-, new `editor_hora_inline.dart` 204+/0-) — within the +forecast band on its own; the 3 new/modified test files add 376 lines and the 4 new ARB keys drag in 13 regenerated +`lib/l10n/gen/*.dart` files plus the 2 ARB sources for 188 more. Justification unchanged from the forecast table's +own footnote ‡: "the proposal already isolates WU10 as its own PR, never bundled — splitting further would leave an +unintegrated commit (a widget with no consumer, or a sheet rewrite with no new editor)." ## WU11 — Alarm ringing restyle diff --git a/test/pantallas/pantalla_alarmas_editor_test.dart b/test/pantallas/pantalla_alarmas_editor_test.dart index 8b7f977..57955fd 100644 --- a/test/pantallas/pantalla_alarmas_editor_test.dart +++ b/test/pantallas/pantalla_alarmas_editor_test.dart @@ -242,6 +242,15 @@ void main() { (tester) async { await _abrirEditor(tester); + // WU10 correction: the fallback-station field moved into the + // collapsed "Advanced" section (native-alarms delta — Alarm Editor + // Preserves Date, Fallback Station, and Sound Fields). It must be + // expanded first — a collapsed `ExpansionTile` does not build its + // children, so `find.byKey` would otherwise find nothing. + await tester.ensureVisible(find.text(l10n.alarmAdvancedSectionTitle)); + await tester.tap(find.text(l10n.alarmAdvancedSectionTitle)); + await tester.pumpAndSettle(); + await tester.ensureVisible( find.byKey(const ValueKey('alarm-fallback-station-field')), ); diff --git a/test/pantallas/pantalla_alarmas_fecha_test.dart b/test/pantallas/pantalla_alarmas_fecha_test.dart index 906dc4c..2266ec9 100644 --- a/test/pantallas/pantalla_alarmas_fecha_test.dart +++ b/test/pantallas/pantalla_alarmas_fecha_test.dart @@ -1,7 +1,19 @@ +import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:intl/date_symbol_data_local.dart'; import 'package:intl/intl.dart'; +import 'package:pluriwave/estado/estado_alarmas.dart'; +import 'package:pluriwave/estado/estado_radio.dart'; import 'package:pluriwave/l10n/formato_fechas.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'; /// S5-R4: short dates must follow the active locale, not a hardcoded /// DD/MM/YYYY pattern. @@ -24,4 +36,181 @@ void main() { DateFormat.yMd('es').format(fecha), ); }); + + group('WU10 — la seccion Avanzada del editor conserva fecha, respaldo y ' + 'sonido', () { + final l10n = lookupAppLocalizations(const Locale('es')); + + setUp(() { + SharedPreferences.setMockInitialValues({}); + }); + + Future abrirEditorNuevo(WidgetTester tester) async { + tester.view.physicalSize = const Size(1440, 3200); + tester.view.devicePixelRatio = 1.0; + addTearDown(tester.view.resetPhysicalSize); + addTearDown(tester.view.resetDevicePixelRatio); + + final favoritos = FakeServicioFavoritos(); + await favoritos.agregar(emisoraDemo(uuid: 'gamma', nombre: 'Gamma FM')); + final radio = EstadoRadio( + audio: FakeServicioAudio(), + favoritos: favoritos, + radio: FakeServicioRadio(), + servicioEcualizador: FakeServicioEcualizador(), + servicioGrabacion: FakeServicioGrabacionRadioInactiva(), + iniciarAutomaticamente: false, + ); + addTearDown(radio.dispose); + await radio.cargarFavoritos(); + + final android = FakePuertoAlarmasAndroid(); + final estadoAlarmas = EstadoAlarmas( + servicio: ServicioAlarmas(reloj: DateTime.now), + android: android, + iniciarAutomaticamente: false, + ); + addTearDown(estadoAlarmas.dispose); + addTearDown(android.dispose); + + await tester.pumpWidget( + MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: radio), + ChangeNotifierProvider.value(value: estadoAlarmas), + ], + child: MaterialApp( + locale: const Locale('es'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: const Scaffold(body: PantallaAlarmas()), + ), + ), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text(l10n.createAlarmAction)); + await tester.pumpAndSettle(); + + return estadoAlarmas; + } + + Future expandirAvanzado(WidgetTester tester) async { + await tester.ensureVisible(find.text(l10n.alarmAdvancedSectionTitle)); + await tester.tap(find.text(l10n.alarmAdvancedSectionTitle)); + await tester.pumpAndSettle(); + } + + testWidgets( + 'los circulos de dia de la semana son siempre visibles, incluso ' + 'cuando el tipo es Una vez (por defecto en una alarma nueva)', + (tester) async { + await abrirEditorNuevo(tester); + + expect(find.text(l10n.weekdayShortMonday), findsOneWidget); + expect(find.text(l10n.weekdayShortSunday), findsOneWidget); + }, + ); + + testWidgets( + 'permite crear una alarma de fecha unica desde la seccion Avanzada ' + '(colapsada por defecto)', + (tester) async { + final estadoAlarmas = await abrirEditorNuevo(tester); + + // A brand-new alarm already defaults to "one time" — the date field + // is reachable as soon as Advanced is expanded, no mode switch + // needed first. + await expandirAvanzado(tester); + + await tester.ensureVisible(find.text(l10n.dateField)); + await tester.tap(find.text(l10n.dateField)); + await tester.pumpAndSettle(); + + final okLabel = + MaterialLocalizations.of( + tester.element(find.text(l10n.dateField)), + ).okButtonLabel; + await tester.tap(find.text(okLabel)); + await tester.pumpAndSettle(); + + await tester.ensureVisible(find.text(l10n.saveAlarmAction)); + await tester.tap(find.text(l10n.saveAlarmAction)); + await tester.pumpAndSettle(); + + final alarma = estadoAlarmas.alarmas.single; + expect(alarma.tipoProgramacion, TipoProgramacionAlarma.unica); + expect(alarma.fechaUnica, isNotNull); + expect(alarma.diasSemana, isEmpty); + }, + ); + + testWidgets( + 'el selector de emisora de respaldo sigue alcanzable desde Avanzado ' + 'y persiste (S2-R9)', + (tester) async { + final estadoAlarmas = await abrirEditorNuevo(tester); + + await expandirAvanzado(tester); + + await tester.ensureVisible( + find.byKey(const ValueKey('alarm-fallback-station-field')), + ); + await tester.tap( + find.byKey(const ValueKey('alarm-fallback-station-field')), + ); + await tester.pumpAndSettle(); + + // Scoped to the just-opened picker sheet's list: the primary + // station field auto-selects the sole registered favorite too + // (`EstadoRadio.emisoraPreferida` falls back to the first + // favorite), so an unscoped `find.text('Gamma FM')` would match + // twice — once there, once in this sheet. + final lista = find.byType(ListView).last; + expect( + find.descendant(of: lista, matching: find.text('Gamma FM')), + findsOneWidget, + ); + await tester.tap( + find.descendant(of: lista, matching: find.text('Gamma FM')), + ); + await tester.pumpAndSettle(); + + await tester.ensureVisible(find.text(l10n.saveAlarmAction)); + await tester.tap(find.text(l10n.saveAlarmAction)); + await tester.pumpAndSettle(); + + final alarma = estadoAlarmas.alarmas.single; + expect(alarma.emisoraFallback?.nombre, 'Gamma FM'); + }, + ); + + testWidgets( + 'el dropdown de sonido interno sigue alcanzable desde Avanzado y ' + 'persiste', + (tester) async { + final estadoAlarmas = await abrirEditorNuevo(tester); + + await expandirAvanzado(tester); + + await tester.ensureVisible( + find.byType(DropdownButtonFormField), + ); + await tester.tap( + find.byType(DropdownButtonFormField), + ); + await tester.pumpAndSettle(); + + await tester.tap(find.text(l10n.soundSoftBell).last); + await tester.pumpAndSettle(); + + await tester.ensureVisible(find.text(l10n.saveAlarmAction)); + await tester.tap(find.text(l10n.saveAlarmAction)); + await tester.pumpAndSettle(); + + final alarma = estadoAlarmas.alarmas.single; + expect(alarma.sonidoInterno, SonidoInternoAlarma.campanaSuave); + }, + ); + }); } diff --git a/test/widgets/editor_hora_inline_test.dart b/test/widgets/editor_hora_inline_test.dart new file mode 100644 index 0000000..7dbc239 --- /dev/null +++ b/test/widgets/editor_hora_inline_test.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:pluriwave/l10n/gen/app_localizations.dart'; +import 'package:pluriwave/widgets/editor_hora_inline.dart'; + +/// WU10: standalone tests for the inline HH:MM editor, independent of +/// `_EditorAlarmaSheet` (the sheet only wires `value`/`onChanged`). +Future _montar( + WidgetTester tester, { + required TimeOfDay inicial, + ValueChanged? onChanged, +}) async { + var valor = inicial; + await tester.pumpWidget( + MaterialApp( + locale: const Locale('es'), + localizationsDelegates: AppLocalizations.localizationsDelegates, + supportedLocales: AppLocalizations.supportedLocales, + home: Scaffold( + body: StatefulBuilder( + builder: (context, setState) { + return EditorHoraInline( + value: valor, + onChanged: (nuevo) { + setState(() => valor = nuevo); + onChanged?.call(nuevo); + }, + ); + }, + ), + ), + ), + ); +} + +const _keyHora = ValueKey('editor-hora-inline-hora'); +const _keyMinuto = ValueKey('editor-hora-inline-minuto'); + +void main() { + testWidgets('muestra la hora inicial formateada HH:MM', (tester) async { + await _montar(tester, inicial: const TimeOfDay(hour: 7, minute: 5)); + + expect(find.text('07'), findsOneWidget); + expect(find.text('05'), findsOneWidget); + expect(find.text(':'), findsOneWidget); + }); + + testWidgets('tocar el segmento de hora incrementa solo la hora', ( + tester, + ) async { + TimeOfDay? recibido; + await _montar( + tester, + inicial: const TimeOfDay(hour: 10, minute: 30), + onChanged: (nuevo) => recibido = nuevo, + ); + + await tester.tap(find.byKey(_keyHora)); + await tester.pump(); + + expect(recibido, const TimeOfDay(hour: 11, minute: 30)); + expect(find.text('11'), findsOneWidget); + expect(find.text('30'), findsOneWidget); + }); + + testWidgets('tocar el segmento de minuto incrementa solo el minuto', ( + tester, + ) async { + TimeOfDay? recibido; + await _montar( + tester, + inicial: const TimeOfDay(hour: 10, minute: 30), + onChanged: (nuevo) => recibido = nuevo, + ); + + await tester.tap(find.byKey(_keyMinuto)); + await tester.pump(); + + expect(recibido, const TimeOfDay(hour: 10, minute: 31)); + }); + + testWidgets( + 'incrementar el minuto en 23:59 envuelve a 00:00 (hora y minuto)', + (tester) async { + TimeOfDay? recibido; + await _montar( + tester, + inicial: const TimeOfDay(hour: 23, minute: 59), + onChanged: (nuevo) => recibido = nuevo, + ); + + await tester.tap(find.byKey(_keyMinuto)); + await tester.pump(); + + expect(recibido, const TimeOfDay(hour: 0, minute: 0)); + }, + ); + + testWidgets('incrementar la hora en 23 envuelve a 0 sin tocar el minuto', ( + tester, + ) async { + TimeOfDay? recibido; + await _montar( + tester, + inicial: const TimeOfDay(hour: 23, minute: 45), + onChanged: (nuevo) => recibido = nuevo, + ); + + await tester.tap(find.byKey(_keyHora)); + await tester.pump(); + + expect(recibido, const TimeOfDay(hour: 0, minute: 45)); + }); + + testWidgets( + 'arrastrar hacia arriba en el minuto lo incrementa; hacia abajo lo ' + 'decrementa', + (tester) async { + TimeOfDay? recibido; + await _montar( + tester, + inicial: const TimeOfDay(hour: 10, minute: 30), + onChanged: (nuevo) => recibido = nuevo, + ); + + await tester.drag(find.byKey(_keyMinuto), const Offset(0, -96)); + await tester.pump(); + expect(recibido, isNotNull); + expect(recibido!.hour, 10); + expect(recibido!.minute, greaterThan(30)); + final minutoTrasSubir = recibido!.minute; + + await tester.drag(find.byKey(_keyMinuto), Offset(0, 96)); + await tester.pump(); + expect(recibido!.minute, lessThan(minutoTrasSubir)); + }, + ); + + testWidgets('expone acciones de accesibilidad de incrementar/decrementar con ' + 'etiqueta y valor', (tester) async { + final semantics = tester.ensureSemantics(); + final l10n = await AppLocalizations.delegate.load(const Locale('es')); + await _montar(tester, inicial: const TimeOfDay(hour: 6, minute: 8)); + + expect( + tester.getSemantics(find.byKey(_keyHora)), + matchesSemantics( + label: l10n.alarmInlineHourLabel, + value: '06', + hasIncreaseAction: true, + hasDecreaseAction: true, + hasTapAction: true, + ), + ); + expect( + tester.getSemantics(find.byKey(_keyMinuto)), + matchesSemantics( + label: l10n.alarmInlineMinuteLabel, + value: '08', + hasIncreaseAction: true, + hasDecreaseAction: true, + hasTapAction: true, + ), + ); + + semantics.dispose(); + }); +}