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}}",
|
||||
"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",
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
+122
-102
@@ -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<TipoProgramacionAlarma>(
|
||||
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<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,
|
||||
// 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<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),
|
||||
FilledButton.icon(
|
||||
onPressed: _guardar,
|
||||
@@ -727,11 +754,6 @@ class _EditorAlarmaSheetState extends State<_EditorAlarmaSheet> {
|
||||
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 {
|
||||
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),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user