import "package:flutter/material.dart"; import "package:flutter/services.dart"; import "../format.dart"; /// A time box that reads on the clock the user chose. /// /// The same problem the web app's `components/TimeField.vue` solves, and the /// same shape of answer. Flutter's own `showTimePicker` renders on the *device's* /// locale, which nothing in this app steers: a Settings → Time format of 24-hour /// still met the account with an AM/PM dial, disagreeing with the 00:00 the card /// beside it printed. So the typing is ours — four digits, masked into the clock /// in force, with the meridiem as its own control rather than something to be /// spelled. /// /// The value in and out is always 24-hour "HH:MM", which is what the charger's /// schedule commands take and what every caller already had. A half-typed time /// emits "" — a half-typed time is not a time, and emitting the part of it that /// parses would set the charger's schedule to whatever was passed through on the /// way to the value somebody meant. class TimeField extends StatefulWidget { final String value; final ValueChanged onChanged; final bool enabled; final String? label; const TimeField({ super.key, required this.value, required this.onChanged, this.enabled = true, this.label, }); @override State createState() => _TimeFieldState(); } class _TimeFieldState extends State { final _controller = TextEditingController(); bool _pm = false; bool _twelve = false; @override void initState() { super.initState(); _twelve = clockIsTwelveHour(); _controller.text = _toText(widget.value); _pm = _toPm(widget.value); } @override void didUpdateWidget(TimeField old) { super.didUpdateWidget(old); // Switching the setting elsewhere re-lays out what is already in the box, // rather than leaving one field on the old clock. final twelve = clockIsTwelveHour(); if (twelve != _twelve) { _twelve = twelve; _controller.text = _toText(widget.value); _pm = _toPm(widget.value); return; } // Only re-render the box when the value it is showing is genuinely a // different time. Half-typed input emits "" — there is no time yet — and // reacting to that would wipe the very digits being typed. if (_toValue(_controller.text, _pm) == widget.value) return; _controller.text = _toText(widget.value); _pm = _toPm(widget.value); } @override void dispose() { _controller.dispose(); super.dispose(); } static String _pad(int n) => n.toString().padLeft(2, "0"); /// "HH:MM" → its two numbers, or null for anything that is not a time of day. static (int, int)? _parse(String value) { final m = RegExp(r"^(\d{1,2}):(\d{2})$").firstMatch(value.trim()); if (m == null) return null; final h = int.parse(m.group(1)!); final min = int.parse(m.group(2)!); return h > 23 || min > 59 ? null : (h, min); } /// The digits the box shows: the hour as this clock writes it, and the minute. String _toText(String value) { final p = _parse(value); if (p == null) return ""; final h = _twelve ? (p.$1 % 12 == 0 ? 12 : p.$1 % 12) : p.$1; return "${_pad(h)}:${_pad(p.$2)}"; } /// Whether the value sits in the afternoon. Only consulted on a 12-hour clock, /// where the box cannot say it and the toggle has to. bool _toPm(String value) { final p = _parse(value); return p != null && p.$1 >= 12; } /// What the box and the toggle hold → "HH:MM", or "" while it is still half /// typed. String _toValue(String text, bool pm) { final digits = text.replaceAll(RegExp(r"\D"), ""); if (digits.length != 4) return ""; var h = int.parse(digits.substring(0, 2)); final min = int.parse(digits.substring(2)); if (min > 59) return ""; if (_twelve) { if (h < 1 || h > 12) return ""; h = (h % 12) + (pm ? 12 : 0); } else if (h > 23) { return ""; } return "${_pad(h)}:${_pad(min)}"; } void _onChanged(String raw) { // The formatter below has already regrouped the digits; this only reports // what they now mean. widget.onChanged(_toValue(raw, _pm)); } void _setPm(bool pm) { setState(() => _pm = pm); widget.onChanged(_toValue(_controller.text, pm)); } @override Widget build(BuildContext context) { return Row( mainAxisSize: MainAxisSize.min, children: [ SizedBox( width: 76, child: TextField( controller: _controller, enabled: widget.enabled, keyboardType: TextInputType.number, textAlign: TextAlign.center, inputFormatters: [_ClockMask()], decoration: InputDecoration( border: const OutlineInputBorder(), isDense: true, counterText: "", hintText: "--:--", labelText: widget.label, ), onChanged: _onChanged, ), ), if (_twelve) ...[ const SizedBox(width: 6), // A toggle rather than a dropdown: two values, and the one not chosen // is the only other answer there is. SegmentedButton( style: const ButtonStyle( visualDensity: VisualDensity(horizontal: -3, vertical: -3), tapTargetSize: MaterialTapTargetSize.shrinkWrap, ), showSelectedIcon: false, segments: const [ ButtonSegment(value: false, label: Text("am")), ButtonSegment(value: true, label: Text("pm")), ], selected: {_pm}, onSelectionChanged: widget.enabled ? (s) => _setPm(s.first) : null, ), ], ], ); } } /// Digits regrouped as hh:mm as they are typed. No trailing colon: it appears /// with the next digit, and adding it early only gives backspace something to /// fight with. class _ClockMask extends TextInputFormatter { @override TextEditingValue formatEditUpdate(TextEditingValue _, TextEditingValue next) { var digits = next.text.replaceAll(RegExp(r"\D"), ""); if (digits.length > 4) digits = digits.substring(0, 4); final text = digits.length > 2 ? "${digits.substring(0, 2)}:${digits.substring(2)}" : digits; return TextEditingValue( text: text, selection: TextSelection.collapsed(offset: text.length), ); } }