feat(alarmas): rewrite alarm editor with inline time widget
Replace the native showTimePicker dialog in the alarm editor sheet with a giant inline HH:MM editor (drag/tap to adjust, wraps at 23:59-00:00). Weekday circles now render unconditionally (disabled outside weekly mode) instead of being gated behind an `if`. The date field, fallback-station picker, and sound dropdown are not dropped: per resolution 3 they move into a collapsed "Advanced" section so the mockup's weekday-circles-only layout does not lose capability. Volume/fade-in sliders get a cosmetic type-scale restyle only. size:exception: 993 changed lines (891+/102-) against the 500-650 forecast - lib/ production code alone is 429 lines, within band; new test files and 13 regenerated l10n/gen files account for the rest, the same pattern every prior work unit in this branch has hit.
This commit is contained in:
@@ -720,6 +720,10 @@
|
|||||||
"stationCount": "{count, plural, =1{1 station} other{{count} stations}}",
|
"stationCount": "{count, plural, =1{1 station} other{{count} stations}}",
|
||||||
"alarmIconLabel": "Musical alarm",
|
"alarmIconLabel": "Musical alarm",
|
||||||
"vacationIconLabel": "Vacation mode",
|
"vacationIconLabel": "Vacation mode",
|
||||||
|
"alarmAdvancedSectionTitle": "Advanced",
|
||||||
|
"alarmInlineHourLabel": "Hour",
|
||||||
|
"alarmInlineMinuteLabel": "Minute",
|
||||||
|
"alarmVolumeRisingStatus": "Turning up the volume",
|
||||||
"streamUrlHint": "https://stream.example.com:8000/radio",
|
"streamUrlHint": "https://stream.example.com:8000/radio",
|
||||||
"advancedEqSectionTitle": "Advanced Equalization Options",
|
"advancedEqSectionTitle": "Advanced Equalization Options",
|
||||||
"advancedEqEnableToggle": "Enable per-device EQ",
|
"advancedEqEnableToggle": "Enable per-device EQ",
|
||||||
|
|||||||
@@ -672,6 +672,10 @@
|
|||||||
"stationCount": "{count, plural, =1{1 emisora} other{{count} emisoras}}",
|
"stationCount": "{count, plural, =1{1 emisora} other{{count} emisoras}}",
|
||||||
"alarmIconLabel": "Alarma musical",
|
"alarmIconLabel": "Alarma musical",
|
||||||
"vacationIconLabel": "Modo vacaciones",
|
"vacationIconLabel": "Modo vacaciones",
|
||||||
|
"alarmAdvancedSectionTitle": "Avanzado",
|
||||||
|
"alarmInlineHourLabel": "Hora",
|
||||||
|
"alarmInlineMinuteLabel": "Minuto",
|
||||||
|
"alarmVolumeRisingStatus": "Subiendo volumen",
|
||||||
"streamUrlHint": "https://stream.example.com:8000/radio",
|
"streamUrlHint": "https://stream.example.com:8000/radio",
|
||||||
"@stationCount": {
|
"@stationCount": {
|
||||||
"placeholders": {
|
"placeholders": {
|
||||||
|
|||||||
@@ -2564,6 +2564,30 @@ abstract class AppLocalizations {
|
|||||||
/// **'Modo vacaciones'**
|
/// **'Modo vacaciones'**
|
||||||
String get vacationIconLabel;
|
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.
|
/// No description provided for @streamUrlHint.
|
||||||
///
|
///
|
||||||
/// In es, this message translates to:
|
/// In es, this message translates to:
|
||||||
|
|||||||
@@ -1410,6 +1410,18 @@ class AppLocalizationsAr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get vacationIconLabel => 'وضع الإجازة';
|
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
|
@override
|
||||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||||
|
|
||||||
|
|||||||
@@ -1417,6 +1417,18 @@ class AppLocalizationsBn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get vacationIconLabel => 'ছুটির মোড';
|
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
|
@override
|
||||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||||
|
|
||||||
|
|||||||
@@ -1427,6 +1427,18 @@ class AppLocalizationsDe extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get vacationIconLabel => 'Urlaubsmodus';
|
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
|
@override
|
||||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||||
|
|
||||||
|
|||||||
@@ -1411,6 +1411,18 @@ class AppLocalizationsEn extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get vacationIconLabel => 'Vacation mode';
|
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
|
@override
|
||||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||||
|
|
||||||
|
|||||||
@@ -1422,6 +1422,18 @@ class AppLocalizationsEs extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get vacationIconLabel => 'Modo vacaciones';
|
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
|
@override
|
||||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||||
|
|
||||||
|
|||||||
@@ -1432,6 +1432,18 @@ class AppLocalizationsFr extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get vacationIconLabel => 'Mode vacances';
|
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
|
@override
|
||||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||||
|
|
||||||
|
|||||||
@@ -1416,6 +1416,18 @@ class AppLocalizationsHi extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get vacationIconLabel => 'अवकाश मोड';
|
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
|
@override
|
||||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||||
|
|
||||||
|
|||||||
@@ -1421,6 +1421,18 @@ class AppLocalizationsId extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get vacationIconLabel => 'Mode liburan';
|
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
|
@override
|
||||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||||
|
|
||||||
|
|||||||
@@ -1427,6 +1427,18 @@ class AppLocalizationsIt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get vacationIconLabel => 'Modalità vacanza';
|
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
|
@override
|
||||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||||
|
|
||||||
|
|||||||
@@ -1376,6 +1376,18 @@ class AppLocalizationsJa extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get vacationIconLabel => '休暇モード';
|
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
|
@override
|
||||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||||
|
|
||||||
|
|||||||
@@ -1419,6 +1419,18 @@ class AppLocalizationsPt extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get vacationIconLabel => 'Modo férias';
|
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
|
@override
|
||||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||||
|
|
||||||
|
|||||||
@@ -1423,6 +1423,18 @@ class AppLocalizationsRu extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get vacationIconLabel => 'Режим отпуска';
|
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
|
@override
|
||||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||||
|
|
||||||
|
|||||||
@@ -1371,6 +1371,18 @@ class AppLocalizationsZh extends AppLocalizations {
|
|||||||
@override
|
@override
|
||||||
String get vacationIconLabel => '假期模式';
|
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
|
@override
|
||||||
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
String get streamUrlHint => 'https://stream.example.com:8000/radio';
|
||||||
|
|
||||||
|
|||||||
+122
-102
@@ -12,6 +12,7 @@ import '../modelos/emisora.dart';
|
|||||||
import '../servicios/servicio_programacion_alarmas.dart';
|
import '../servicios/servicio_programacion_alarmas.dart';
|
||||||
import '../tema/pluriwave_theme.dart';
|
import '../tema/pluriwave_theme.dart';
|
||||||
import '../tema/pluriwave_tokens.dart';
|
import '../tema/pluriwave_tokens.dart';
|
||||||
|
import '../widgets/editor_hora_inline.dart';
|
||||||
import '../widgets/pluri_glass_surface.dart';
|
import '../widgets/pluri_glass_surface.dart';
|
||||||
import '../widgets/pluri_icon.dart';
|
import '../widgets/pluri_icon.dart';
|
||||||
import '../widgets/pluri_layout.dart';
|
import '../widgets/pluri_layout.dart';
|
||||||
@@ -448,32 +449,17 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
|||||||
controller: _nombreController,
|
controller: _nombreController,
|
||||||
decoration: InputDecoration(labelText: l10n.nameLabel),
|
decoration: InputDecoration(labelText: l10n.nameLabel),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 16),
|
||||||
Row(
|
// WU10: the native showTimePicker dialog is replaced by a
|
||||||
children: [
|
// giant inline HH:MM editor (drag/tap to adjust); see
|
||||||
Expanded(
|
// `EditorHoraInline`, standalone-tested on its own.
|
||||||
child: _PickerButton(
|
Center(
|
||||||
icon: Icons.schedule_rounded,
|
child: EditorHoraInline(
|
||||||
label: l10n.timeField,
|
value: _hora,
|
||||||
value: _hora.format(context),
|
onChanged: (nuevo) => setState(() => _hora = nuevo),
|
||||||
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: 12),
|
const SizedBox(height: 16),
|
||||||
SegmentedButton<TipoProgramacionAlarma>(
|
SegmentedButton<TipoProgramacionAlarma>(
|
||||||
segments: [
|
segments: [
|
||||||
ButtonSegment(
|
ButtonSegment(
|
||||||
@@ -493,25 +479,33 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
|||||||
onSelectionChanged:
|
onSelectionChanged:
|
||||||
(value) => setState(() => _tipo = value.first),
|
(value) => setState(() => _tipo = value.first),
|
||||||
),
|
),
|
||||||
if (_tipo == TipoProgramacionAlarma.diasSemana) ...[
|
const SizedBox(height: 10),
|
||||||
const SizedBox(height: 10),
|
// WU10: weekday circles are now ALWAYS visible (previously
|
||||||
Wrap(
|
// only inserted into the tree in diasSemana mode) — matching
|
||||||
spacing: 6,
|
// the mockup, which shows them unconditionally under the
|
||||||
children: [
|
// giant time. They stay disabled (onSelected: null, the
|
||||||
for (var i = DateTime.monday; i <= DateTime.sunday; i++)
|
// standard Material "greyed out" FilterChip state) outside
|
||||||
FilterChip(
|
// diasSemana mode rather than being wired to silently mutate
|
||||||
label: Text(_weekdayShort(l10n, i)),
|
// `_diasSemana` while a different `_tipo` is saved — no
|
||||||
selected: _diasSemana.contains(i),
|
// scheduling-data-model change, presentation only.
|
||||||
onSelected:
|
Wrap(
|
||||||
(selected) => setState(() {
|
spacing: 6,
|
||||||
selected
|
children: [
|
||||||
? _diasSemana.add(i)
|
for (var i = DateTime.monday; i <= DateTime.sunday; i++)
|
||||||
: _diasSemana.remove(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),
|
const SizedBox(height: 12),
|
||||||
_vistaProximaEjecucion(l10n),
|
_vistaProximaEjecucion(l10n),
|
||||||
const SizedBox(height: 14),
|
const SizedBox(height: 14),
|
||||||
@@ -531,7 +525,10 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
|||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
ListTile(
|
ListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
title: Text(l10n.alarmFadeInTitle),
|
title: Text(
|
||||||
|
l10n.alarmFadeInTitle,
|
||||||
|
style: context.pluriType.cardTitle,
|
||||||
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
_fadeInSegundos == 0
|
_fadeInSegundos == 0
|
||||||
? l10n.alarmFadeInOff
|
? l10n.alarmFadeInOff
|
||||||
@@ -566,33 +563,11 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
|||||||
(value) => setState(() => _snoozeMinutos = value.first),
|
(value) => setState(() => _snoozeMinutos = value.first),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 8),
|
||||||
DropdownButtonFormField<SonidoInternoAlarma>(
|
|
||||||
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,
|
// 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(
|
_CampoSelectorEmisora(
|
||||||
key: const ValueKey('alarm-station-field'),
|
key: const ValueKey('alarm-station-field'),
|
||||||
label: l10n.favoriteStationLabel,
|
label: l10n.favoriteStationLabel,
|
||||||
@@ -608,26 +583,6 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
|||||||
(emisora) => setState(() => _emisora = emisora),
|
(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) ...[
|
if (favoritas.isEmpty) ...[
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
Text(l10n.saveFavoritesAlarmHint),
|
Text(l10n.saveFavoritesAlarmHint),
|
||||||
@@ -658,6 +613,78 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
|||||||
title: Text(l10n.playDuringVacations),
|
title: Text(l10n.playDuringVacations),
|
||||||
subtitle: Text(l10n.playDuringVacationsHint),
|
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<SonidoInternoAlarma>(
|
||||||
|
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),
|
const SizedBox(height: 16),
|
||||||
FilledButton.icon(
|
FilledButton.icon(
|
||||||
onPressed: _guardar,
|
onPressed: _guardar,
|
||||||
@@ -727,11 +754,6 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
|||||||
seleccionar(resultado.emisora);
|
seleccionar(resultado.emisora);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _elegirHora() async {
|
|
||||||
final nueva = await showTimePicker(context: context, initialTime: _hora);
|
|
||||||
if (nueva != null) setState(() => _hora = nueva);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _elegirFecha() async {
|
Future<void> _elegirFecha() async {
|
||||||
final ahora = DateTime.now();
|
final ahora = DateTime.now();
|
||||||
final nueva = await showDatePicker(
|
final nueva = await showDatePicker(
|
||||||
@@ -1148,12 +1170,10 @@ class _SectionLabel extends StatelessWidget {
|
|||||||
children: [
|
children: [
|
||||||
_AssetIcon(icon, size: 34),
|
_AssetIcon(icon, size: 34),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
Text(
|
// WU10: swapped the raw TextTheme lookup for the named type-scale
|
||||||
text,
|
// token (cosmetic only — same weight class, now shared with every
|
||||||
style: Theme.of(
|
// other card/section title in the redesign).
|
||||||
context,
|
Text(text, style: context.pluriType.cardTitle),
|
||||||
).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.w800),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<TimeOfDay> onChanged;
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<EditorHoraInline> createState() => _EditorHoraInlineState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EditorHoraInlineState extends State<EditorHoraInline> {
|
||||||
|
/// 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),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
**New tests**: `test/widgets/editor_hora_inline_test.dart` (standalone)
|
||||||
**Modified tests**: `pantalla_alarmas_fecha_test.dart`
|
**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
|
- [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.
|
minute, wraps at 23:59→00:00, exposes correct a11y labels. **Correction found at apply time**: Flutter
|
||||||
- [ ] 10.2 GREEN — implement `lib/widgets/editor_hora_inline.dart` (giant inline HH:MM display, drag/tap-to-adjust).
|
requires a `Semantics` node exposing `onIncrease`/`onDecrease` to ALSO carry `increasedValue`/`decreasedValue`
|
||||||
- [ ] 10.3 RED — update `pantalla_alarmas_fecha_test.dart`: one-time date alarm still creatable via the (now
|
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.
|
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"
|
visible; move the date field, fallback-station picker, and sound dropdown into a collapsed "Advanced"
|
||||||
section rather than dropping them.
|
section rather than dropping them. **Design decision, not specified by any ADR (WU10 has none)**: the
|
||||||
- [ ] 10.5 GREEN — restyle the volume/fade-in sliders (cosmetic only, no behaviour change).
|
one-time/daily/weekly `SegmentedButton` stays in the main flow (not Advanced) since it gates which weekday
|
||||||
- [ ] 10.6 REFACTOR — confirm the dismiss-guard test is untouched by this WU (editor sheet only, not the ringing
|
circles are enabled; weekday circles now render unconditionally with `onSelected: null` (Material's standard
|
||||||
screen).
|
disabled-chip state) when `_tipo != diasSemana`, instead of being removed from the tree — no change to
|
||||||
- [ ] 10.7 Verify — standalone widget test green independent of the sheet; date/fallback/sound round-trip test
|
`_tipo`/`diasSemana`/`fechaUnica` persistence logic, confirmed the scheduler treats `diaria` and
|
||||||
green.
|
`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
|
**`size:exception` recorded.** Realized: **993 changed lines** (891+/102-) across 21 files — over the 500-650
|
||||||
as its own unit per the proposal ("never bundled"). See forecast table footnote ‡.
|
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
|
## WU11 — Alarm ringing restyle
|
||||||
|
|
||||||
|
|||||||
@@ -242,6 +242,15 @@ void main() {
|
|||||||
(tester) async {
|
(tester) async {
|
||||||
await _abrirEditor(tester);
|
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(
|
await tester.ensureVisible(
|
||||||
find.byKey(const ValueKey('alarm-fallback-station-field')),
|
find.byKey(const ValueKey('alarm-fallback-station-field')),
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,7 +1,19 @@
|
|||||||
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
import 'package:intl/date_symbol_data_local.dart';
|
import 'package:intl/date_symbol_data_local.dart';
|
||||||
import 'package:intl/intl.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/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
|
/// S5-R4: short dates must follow the active locale, not a hardcoded
|
||||||
/// DD/MM/YYYY pattern.
|
/// DD/MM/YYYY pattern.
|
||||||
@@ -24,4 +36,181 @@ void main() {
|
|||||||
DateFormat.yMd('es').format(fecha),
|
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<EstadoAlarmas> 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<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();
|
||||||
|
|
||||||
|
await tester.tap(find.text(l10n.createAlarmAction));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
return estadoAlarmas;
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> 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<SonidoInternoAlarma>),
|
||||||
|
);
|
||||||
|
await tester.tap(
|
||||||
|
find.byType(DropdownButtonFormField<SonidoInternoAlarma>),
|
||||||
|
);
|
||||||
|
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);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<void> _montar(
|
||||||
|
WidgetTester tester, {
|
||||||
|
required TimeOfDay inicial,
|
||||||
|
ValueChanged<TimeOfDay>? 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();
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user