Files
DriverVault/Phone App/lib/screens/charging_task_sheet.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

411 lines
16 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import "package:flutter/material.dart";
import "../format.dart";
import "../i18n.dart";
import "../main.dart";
import "../models.dart";
import "../theme.dart";
import "../widgets/time_field.dart";
/// One line of the home-charger scheduler, being written or edited.
///
/// The charger's own cloud schedule asks four questions and asks them inside one
/// charger: on/off, mode, from, to. This asks five, and the fifth is the one that
/// makes it a scheduler rather than a second copy of that: *which* chargers. A
/// task can name one, several, or none at all — and none means every charger on
/// the account, including ones imported after the task was written, because "all
/// of them" is a standing wish rather than the list that happened to exist that
/// day.
///
/// A task holds a flow rather than a single command: start at 23:00, ease down
/// to 10 A at 01:00, stop at 06:30. That is one intention, so it is one named
/// thing with one switch — splitting a charging window across two tasks meant
/// naming it twice and remembering to switch off both ends.
///
/// Pops the saved [ChargingTask].
Future<ChargingTask?> showChargingTaskSheet(
BuildContext context, {
ChargingTask? task,
required List<HomeCharger> chargers,
}) {
return showModalBottomSheet<ChargingTask>(
context: context,
isScrollControlled: true,
builder: (_) => _ChargingTaskSheet(task: task, chargers: chargers),
);
}
/// What the charger can actually be asked to do. Boost and the current limit
/// only reach it over the Anker cloud connection; start and stop reach it over
/// all three transports. The control mode is a Settings choice, not this form's
/// business, so all four are offered and the one that cannot be sent says so
/// when it fires — same as the buttons on the page behind this.
const List<String> _kActions = ["start", "stop", "limit", "boost"];
/// One row of the flow while it is being edited. Mutable, because the form edits
/// the rows in place; [ChargingStep] is what gets sent.
class _StepDraft {
String action;
String time;
double amps;
_StepDraft(this.action, this.time, this.amps);
}
class _ChargingTaskSheet extends StatefulWidget {
final ChargingTask? task;
final List<HomeCharger> chargers;
const _ChargingTaskSheet({this.task, required this.chargers});
@override
State<_ChargingTaskSheet> createState() => _ChargingTaskSheetState();
}
class _ChargingTaskSheetState extends State<_ChargingTaskSheet> {
late final TextEditingController _name =
TextEditingController(text: widget.task?.name ?? "");
/// The flow, as rows the form edits in place. A new task opens with the one
/// step most schedules start from, so the common case is a name and a time
/// rather than a decision about how many rows to add.
late final List<_StepDraft> _steps = (widget.task?.steps ?? const []).isEmpty
? [_StepDraft("start", "23:00", 16)]
: widget.task!.steps
.map((s) => _StepDraft(s.action, s.time, s.amps > 0 ? s.amps : 16))
.toList();
/// The chargers this task acts on. Empty is meaningful — it means all of them
/// — so the picker has a switch of its own rather than leaving an empty list
/// looking like an unfinished form.
late bool _allChargers = widget.task == null || widget.task!.chargers.isEmpty;
late final Set<String> _picked = {...?widget.task?.chargers};
late bool _everyDay = widget.task == null || widget.task!.days.isEmpty;
late final Set<int> _days = {...?widget.task?.days};
bool _saving = false;
String? _error;
bool get _editing => (widget.task?.id ?? "").isNotEmpty;
@override
void dispose() {
_name.dispose();
super.dispose();
}
/// A flow of one is a flow, so the last row cannot be removed — an empty task
/// would have nothing to fire and the server refuses it anyway.
void _addStep() => setState(() => _steps.add(_StepDraft("stop", "06:30", 16)));
void _removeStep(int i) {
if (_steps.length <= 1) return;
setState(() => _steps.removeAt(i));
}
/// Unticking every day (or every charger) by hand is the same wish as the
/// "all" switch, so it lands there rather than leaving a task that acts on
/// nothing.
void _toggleDay(int day) {
setState(() {
_everyDay = false;
_days.contains(day) ? _days.remove(day) : _days.add(day);
if (_days.isEmpty) _everyDay = true;
});
}
void _toggleCharger(String id) {
setState(() {
_allChargers = false;
_picked.contains(id) ? _picked.remove(id) : _picked.add(id);
if (_picked.isEmpty) _allChargers = true;
});
}
/// A time still being typed is not a time — [TimeField] says so with an empty
/// value — and one unfinished row is enough to make the whole flow unsaveable,
/// because the server would otherwise refuse it with a step number the form
/// does not show.
bool get _canSave =>
_name.text.trim().isNotEmpty && _steps.every((s) => s.time.isNotEmpty) && !_saving;
Future<void> _submit() async {
if (!_canSave) return;
setState(() {
_saving = true;
_error = null;
});
final body = <String, dynamic>{
"name": _name.text.trim(),
// The amps ride along on every step so switching one to "limit" and back
// does not lose the number that was typed; the server keeps them for the
// same reason and ignores them on the actions that have no ceiling.
"steps": [
for (final s in _steps)
{
"action": s.action,
"time": s.time,
"amps": s.action == "limit" ? s.amps : 0,
},
],
"chargers": _allChargers ? <String>[] : _picked.toList(),
"days": _everyDay ? <int>[] : _days.toList(),
// The time is a wall clock, and the server's is not the one it was set by.
// Sending the zone this phone is in is what keeps 23:00 at 23:00 for a
// server sitting in another country.
"zone": _deviceZone(),
};
try {
final saved = _editing
? await apiClient.updateChargingTask(widget.task!.id, body)
: await apiClient.createChargingTask(body);
if (mounted) Navigator.pop(context, saved);
} catch (e) {
if (mounted) setState(() => _error = "$e");
} finally {
if (mounted) setState(() => _saving = false);
}
}
/// The phone's own zone name. Dart has no IANA name to hand — only an offset
/// and the platform's abbreviation — so the server is sent what it can read
/// and falls back to its own clock when it cannot: an offset is not a zone,
/// and a name that is not IANA would be worse than saying nothing.
String _deviceZone() {
final name = DateTime.now().timeZoneName;
return name.contains("/") ? name : "";
}
String _chargerSubtitle(HomeCharger c) =>
[c.serial, c.model].where((v) => v.isNotEmpty).join(" · ");
@override
Widget build(BuildContext context) {
final muted = DriverVault.muted(context);
final sunken = DriverVault.isDark(context) ? DriverVault.darkSunken : DriverVault.ink50;
return Padding(
padding: EdgeInsets.only(
left: 16,
right: 16,
top: 16,
bottom: DriverVault.sheetBottomInset(context),
),
child: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
t(_editing ? "forms.chargingTask.editTitle" : "forms.chargingTask.title"),
style: const TextStyle(fontSize: 18, fontWeight: FontWeight.w600),
),
const SizedBox(height: 12),
if (_error != null)
Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Text(_error!, style: const TextStyle(color: DriverVault.danger)),
),
Text(t("forms.chargingTask.name"),
style: const TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 6),
TextField(
controller: _name,
decoration: InputDecoration(
border: const OutlineInputBorder(),
isDense: true,
hintText: t("forms.chargingTask.namePlaceholder"),
),
onChanged: (_) => setState(() {}),
),
// The flow. One row per step, each an action and the time it fires —
// read down, they are the night: start at 23:00, ease off at 01:00,
// stop at 06:30.
const SizedBox(height: 16),
Text(t("forms.chargingTask.flow"),
style: const TextStyle(fontWeight: FontWeight.w500)),
const SizedBox(height: 6),
for (var i = 0; i < _steps.length; i++) _stepRow(context, i, sunken, muted),
SizedBox(
width: double.infinity,
child: OutlinedButton(
onPressed: _addStep,
child: Text(t("forms.chargingTask.addStep")),
),
),
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(t("forms.chargingTask.flowHint"),
style: TextStyle(fontSize: 12, color: muted)),
),
// Which chargers. The point of one scheduler for all of them.
const SizedBox(height: 16),
Text(t("forms.chargingTask.chargers"),
style: const TextStyle(fontWeight: FontWeight.w500)),
CheckboxListTile(
value: _allChargers,
dense: true,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: Text(t("forms.chargingTask.allChargers"),
style: const TextStyle(fontSize: 14)),
subtitle: _allChargers
? Text(t("forms.chargingTask.allChargersHint"),
style: TextStyle(fontSize: 12, color: muted))
: null,
onChanged: (on) => setState(() {
_allChargers = on ?? true;
if (_allChargers) _picked.clear();
}),
),
if (widget.chargers.isEmpty)
Text(t("forms.chargingTask.noChargers"),
style: TextStyle(fontSize: 12, color: muted)),
for (final c in widget.chargers)
CheckboxListTile(
value: !_allChargers && _picked.contains(c.id),
dense: true,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: Text(c.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14)),
subtitle: _chargerSubtitle(c).isEmpty
? null
: Text(_chargerSubtitle(c),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: DriverVault.mono(context, size: 11, color: muted)),
onChanged: (_) => _toggleCharger(c.id),
),
// Which days.
const SizedBox(height: 8),
Text(t("forms.chargingTask.days"),
style: const TextStyle(fontWeight: FontWeight.w500)),
CheckboxListTile(
value: _everyDay,
dense: true,
contentPadding: EdgeInsets.zero,
controlAffinity: ListTileControlAffinity.leading,
title: Text(t("forms.chargingTask.everyDay"),
style: const TextStyle(fontSize: 14)),
onChanged: (on) => setState(() {
_everyDay = on ?? true;
if (_everyDay) _days.clear();
}),
),
// The row starts on whichever day this account reads a week as
// starting on — Settings Appearance First day of the week,
// following the region unless it was answered outright. format.dart
// owns the rule for every weekday row in the app.
Wrap(
spacing: 6,
runSpacing: 6,
children: [
for (final d in weekdaysInOrder())
ChoiceChip(
label: Text(weekdayShortName(d), style: const TextStyle(fontSize: 12)),
selected: !_everyDay && _days.contains(d),
onSelected: (_) => _toggleDay(d),
),
],
),
const SizedBox(height: 16),
Row(children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.pop(context),
child: Text(t("common.cancel")),
),
),
const SizedBox(width: 8),
Expanded(
child: FilledButton(
onPressed: _canSave ? _submit : null,
child: Text(_saving ? t("common.saving") : t("common.save")),
),
),
]),
],
),
),
);
}
Widget _stepRow(BuildContext context, int i, Color sunken, Color muted) {
final s = _steps[i];
return Container(
margin: const EdgeInsets.only(bottom: 8),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: sunken,
borderRadius: BorderRadius.circular(DriverVault.radiusControl),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Expanded(
child: DropdownButtonFormField<String>(
initialValue: s.action,
isExpanded: true,
decoration:
const InputDecoration(border: OutlineInputBorder(), isDense: true),
items: [
for (final a in _kActions)
DropdownMenuItem(
value: a,
child: Text(t("charging.scheduler.actions.$a"),
overflow: TextOverflow.ellipsis),
),
],
onChanged: (v) => setState(() => s.action = v ?? s.action),
),
),
const SizedBox(width: 8),
TimeField(
value: s.time,
onChanged: (v) => setState(() => s.time = v),
),
// The last step cannot go: a task with no steps has nothing to
// fire, so the control is absent rather than there and refusing.
if (_steps.length > 1)
IconButton(
icon: const Icon(Icons.close, size: 18),
color: muted,
tooltip: t("forms.chargingTask.removeStep"),
onPressed: () => _removeStep(i),
),
],
),
// The ceiling, under the one action that takes one.
if (s.action == "limit") ...[
const SizedBox(height: 8),
Row(children: [
Text(t("forms.chargingTask.amps"),
style: TextStyle(fontSize: 13, color: muted)),
const Spacer(),
Text("${s.amps.round()} A",
style: DriverVault.mono(context, size: 13, weight: FontWeight.w600)),
]),
Slider(
value: s.amps.clamp(6, 32),
min: 6,
max: 32,
divisions: 26,
label: "${s.amps.round()} A",
onChanged: (v) => setState(() => s.amps = v),
),
Text(t("forms.chargingTask.ampsHint"),
style: TextStyle(fontSize: 11, color: muted)),
],
],
),
);
}
}