etm-powersync-app/lib/widgets/ev_mode_buttons.dart
Patrick Schurig 5a51582729 fix(ev): « Boost » devient « Manuel » — le nom promettait le contraire du comportement
Décision de Patrick : trois modes suffisent — Manuel, PV, Min+PV. Le boost n'est
pas un quatrième mode, c'est la dérogation (+etm61).

LE FAIT. Le bouton écrivait `ChargingModeNormal`, qui sort la borne de
l'arbitrage et lui restitue ses valeurs manuelles stockées —
manualChargingEnabled, manualMaxChargingCurrent, manualDesiredPhaseCount. Sur
.75 elles valaient false / 6 A / 3 phases : mesuré le 2026-09-08 à 11:09:51,
l'appui a ARRÊTÉ la charge et armé un verrou de 300 s. Pas une régression —
c'est ce que Normal a toujours fait. C'est le nom qui mentait.

Renommé jusque dans le code (`ChargingMode.boost` -> `ChargingMode.manuel`), pas
seulement sur l'étiquette : un identifiant faux reste un piège pour le prochain
lecteur, et c'est précisément ce qui a duré des mois ici. L'ordre met Manuel en
tête, parce que ChargingModeNormal est aussi le mode d'office d'une borne
détectée.

ET LE RENOMMAGE NE SUFFISAIT PAS. « Manuel » dit QUI commande, pas ce que la
borne va faire : tant que l'app n'offre pas de régler ces valeurs stockées, le
mode veut dire « ce qui traînait la dernière fois ». Passer en manuel demande
donc confirmation, et la confirmation dit la conséquence — la charge peut
s'arrêter — AVANT, pas après.

⚠️ La confirmation ne se déclenche pas sur un ré-appui du mode déjà en place :
une confirmation qu'on apprend à cliquer sans lire ne protège plus de rien. Un
test le fige.

Vérifié sur .75 (1.15.2+etm61) : SetLoadDerogation et ClearLoadDerogation sont
dans Introspect, avec derogationEnded / durationS / restoredMode. Le contrat de
la dérogation est écrit dans CLAUDE.md — durée obligatoire bornée [60, 21600],
état lu dans decision.code == "DEROGATION_ACTIVE" et jamais dans le mode, fin au
niveau de la trame avec six causes. Aucun bouton de boost n'est écrit : c'est le
lot suivant.

Et une sonde pour le moteur, faite au passage : la V2C est bien restée en
ChargingModeNormal et elle est ABSENTE de loads[] — hors arbitrage. Leur
hypothèse est confirmée sans redémarrage.

339 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 12:19:39 +02:00

160 lines
6.3 KiB
Dart

import 'package:flutter/material.dart';
import '../models/energy_data.dart' show ChargingMode;
import '../models/ev_charger_live.dart';
import '../theme/etm_tokens.dart';
/// Les trois modes de recharge — **le seul endroit de l'app qui écrit `SetChargingInfo`**.
///
/// ## Un seul lieu d'écriture, délibérément
///
/// Ces boutons ont vécu aux favoris, sur la tuile de la borne. Ils sont ici depuis le
/// 2026-08-28 : « Configurer » est l'écran de **mise en service**, celui où un installateur
/// découvre qu'une borne neuve est hors arbitrage — et deux écrans qui écrivent la même
/// chose sont deux comportements qui divergent. La tuile des favoris garde la LECTURE et
/// la note « hors arbitrage » : le client doit voir que sa borne n'est pas pilotée, même
/// là où il ne peut pas la régler.
///
/// ## Ce qui est affiché comme sélectionné
///
/// Le mode que la **box** porte, jamais celui que l'app vient de demander. Un mode non lu
/// n'allume rien : trois boutons éteints valent mieux qu'un allumé au hasard. Après une
/// écriture acceptée, `NymeaService` relit `GetChargingInfos` — la sélection ne bouge que
/// quand la box l'a dit.
///
/// ## Un refus se voit
///
/// `setChargingInfo` rend un code d'erreur ; il était avalé. Une commande muette est pire
/// qu'une lecture seule — l'utilisateur croit avoir réglé la borne.
class EvModeButtons extends StatefulWidget {
final EvChargerLive live;
/// Écrit le mode et rend `null` si la box a accepté, sinon le code de refus.
final Future<String?> Function(ChargingMode) onSetMode;
/// Disposition : en colonne sur une tuile étroite, en ligne sur un écran.
final Axis axis;
const EvModeButtons({
super.key,
required this.live,
required this.onSetMode,
this.axis = Axis.horizontal,
});
@override
State<EvModeButtons> createState() => _EvModeButtonsState();
}
class _EvModeButtonsState extends State<EvModeButtons> {
/// Mode en cours d'écriture — jamais affiché comme sélectionné : tant que la box n'a pas
/// répondu, l'app ne sait pas ce que porte la borne.
ChargingMode? _pending;
/// **Passer en Manuel se confirme.** Ce n'est pas de la prudence décorative.
///
/// `ChargingModeNormal` sort la borne de l'arbitrage et lui **restitue ses valeurs
/// manuelles stockées** — `manualChargingEnabled`, `manualMaxChargingCurrent`,
/// `manualDesiredPhaseCount`. Elles peuvent dire n'importe quoi, y compris « charge
/// désactivée » : sur `.75` elles valaient `false / 6 A / 3 phases`, et le bouton — qui
/// s'appelait alors « Boost » — a **arrêté la charge** le 2026-09-08 à 11:09:51, avec un
/// verrou de 300 s derrière.
///
/// Le renommage seul ne suffisait pas : « Manuel » dit bien QUI commande, mais rien de
/// ce que la borne va faire. Tant que l'app n'offre pas de régler ces valeurs, Manuel
/// veut dire « ce qui traînait la dernière fois », et l'utilisateur a le droit de le
/// savoir avant, pas de le découvrir après.
Future<bool> _confirmerManuel() async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Rendre la main à la borne ?'),
content: const Text(
'En mode manuel, le HEMS ne pilote plus cette borne : elle reprend ses propres '
'réglages enregistrés (charge autorisée ou non, courant, nombre de phases).\n\n'
'Selon ce qu\'ils contiennent, la charge peut s\'arrêter.',
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler')),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Passer en manuel')),
],
),
);
return ok == true;
}
Future<void> _set(ChargingMode m) async {
if (m == ChargingMode.manuel && widget.live.uiMode != ChargingMode.manuel) {
if (!await _confirmerManuel()) return;
if (!mounted) return;
}
setState(() => _pending = m);
final err = await widget.onSetMode(m);
if (!mounted) return;
setState(() => _pending = null);
if (err == null) return;
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Text('La box a refusé le mode de recharge ($err). '
'Détail dans son journal.'),
));
}
@override
Widget build(BuildContext context) {
final boutons = [for (final m in ChargingMode.values) _bouton(m)];
return widget.axis == Axis.horizontal
? Row(children: [
for (final b in boutons) ...[Expanded(child: b), const SizedBox(width: 6)]
]..removeLast())
: Column(mainAxisSize: MainAxisSize.min, children: boutons);
}
/// 44 px de cible tactile — les pastilles d'origine faisaient 7 à 10 px, et écrivaient
/// pour de vrai.
Widget _bouton(ChargingMode m) {
final (label, couleur) = switch (m) {
ChargingMode.manuel => ('Manuel', EtmTokens.mutedOf(context)),
ChargingMode.pv => ('PV', EtmTokens.amber),
ChargingMode.minPv => ('Min+PV', EtmTokens.blue),
};
final selected = widget.live.uiMode == m;
final busy = _pending != null;
return Padding(
padding: const EdgeInsets.symmetric(vertical: 2),
child: SizedBox(
height: 44,
width: widget.axis == Axis.vertical ? 76 : null,
child: OutlinedButton(
onPressed: busy ? null : () => _set(m),
style: OutlinedButton.styleFrom(
padding: EdgeInsets.zero,
backgroundColor:
selected ? couleur.withValues(alpha: 0.14) : Colors.transparent,
side: BorderSide(
color: selected
? couleur
: EtmTokens.lineOf(context).withValues(alpha: 0.6)),
shape:
RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
),
child: _pending == m
? const SizedBox(
width: 14,
height: 14,
child: CircularProgressIndicator(strokeWidth: 2))
: Text(label,
style: EtmTokens.sans(
size: 12,
weight: selected ? FontWeight.w700 : FontWeight.w400,
color: selected ? couleur : EtmTokens.mutedOf(context))),
),
),
);
}
}