Files
DriverVault/Phone App/lib/widgets/time_field.dart
T
tajniak81andClaude Opus 5 181f55a849 The phone catches up with the month the web had
Twenty-eight commits landed on the web app and the API since the phone was
last touched, and the phone's own README opens by claiming full feature
parity. It was not a small drift: a whole tab, two whole cards, and the two
settings that decide how a time is read.

The scheduler arrives as the third charging tab. One list of tasks covering
every charger the account owns, where the charger's own cloud schedule is one
window inside one box. A task is a flow — start at 23:00, cap to 10 A at
01:00, stop at 06:30 — on the days and the chargers it names, and naming no
charger means all of them, including the ones imported later. The clock is the
server's, so the tab only writes tasks and reads back how each one last went,
and any step can be fired now to find out whether it will reach the charger
before the night it matters.

The RFID card comes with it: the list the account holds, a card added by its
number or by holding it against the charger's own reader, and the charger's
own list read back from the device. Both halves are written by every add and
remove and they can still come apart, so when they disagree the card says
which list each card is missing from — nothing else on the page would.

The charger settings card the phone never had at all goes in whole rather than
only its new half. Over Modbus that is the four writable registers; over the
cloud it is the charger's whole settings group in sections, drawn from the same
block table the web reads, one write per section because the charger takes a
command whole and a schedule carrying only its switch is a schedule whose times
have just been set to midnight.

The clock and the week become settings. format.dart grows formatTime, the
weekday order and the short names, with "auto" asking intl's own hour pattern
and FIRSTDAYOFWEEK rather than a table here; Settings › Appearance asks both
questions beneath the date. Flutter's own picker renders on the device locale,
which nothing in this app steers, so TimeField types four digits on whichever
clock is in force and keeps the meridiem as its own control — a box reading
13:45 beside a dial saying 01:45 PM is the disagreement the setting exists to
end.

The smaller ones travel too. The control card says which charger its buttons
drive, picture and name, because it follows a serial and not the highlighted
row; its two tiles take the names of the readings they actually hold; and the
limit slider leaves it wherever a settings card now owns that value. The list's
reachability re-asks every thirty seconds while the tab is in front, merged
rather than replaced — "we could not ask" is not an answer, and it certainly is
not "unknown". A settings frame that answers half a minute late is chased at
widening gaps and then given up on. The information card names the fields the
service sent under its own names and groups list records under their own, so
list[0].* stops being read as one alphabetical run. An inherited integration
field shows what it inherited rather than an example. The sign-in fields say
nothing until you type.

One gap stays open, and deliberately. The task form sends the phone's zone only
when Dart reports an IANA name; Android usually answers with an abbreviation
like CEST, which is not a zone, so it sends nothing and the server falls back to
its own clock. A name the server would misread is worse than no name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-04 15:11:19 +02:00

194 lines
6.3 KiB
Dart

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<String> onChanged;
final bool enabled;
final String? label;
const TimeField({
super.key,
required this.value,
required this.onChanged,
this.enabled = true,
this.label,
});
@override
State<TimeField> createState() => _TimeFieldState();
}
class _TimeFieldState extends State<TimeField> {
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<bool>(
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),
);
}
}