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 showChargingTaskSheet( BuildContext context, { ChargingTask? task, required List chargers, }) { return showModalBottomSheet( 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 _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 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 _picked = {...?widget.task?.chargers}; late bool _everyDay = widget.task == null || widget.task!.days.isEmpty; late final Set _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 _submit() async { if (!_canSave) return; setState(() { _saving = true; _error = null; }); final body = { "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 ? [] : _picked.toList(), "days": _everyDay ? [] : _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( 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)), ], ], ), ); } }