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.
209 lines
7.4 KiB
Dart
209 lines
7.4 KiB
Dart
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),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|