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), ), ), ); } }