etm-powersync-app/lib/widgets/ev_mode_buttons.dart
Patrick Schurig 51a1ab929c feat(config): retirer une charge orpheline, et régler le mode de recharge à un seul endroit
Deux manques trouvés en usage réel sur téléphone, contre .75.

── 1. Retirer une charge dont l'appareil n'existe plus ────────────────────────

Le plugin crée d'office une entrée par borne détectée mais ne la retire pas quand le Thing
s'en va : la Terra AC supprimée de nymea tenait encore le rang 4 deux jours plus tard, et
l'app savait le dire sans savoir y remédier.

Le bouton n'apparaît que sur THING_MISSING. Sur une charge vivante le geste n'aurait pas de
sens — le plugin recrée l'entrée au cycle suivant, et un bouton qui se défait tout seul
ferait douter de la box.

Le chemin est celui qui était demandé, et pour la raison qui était donnée :
  · LIRE (GetLoadConfig, frais — pas l'état en mémoire, la box a pu changer d'avis)
  · PATCHER (payloadWithoutLoad : les autres charges recopiées VERBATIM, champ par champ)
  · RÉÉCRIRE (SetLoadConfig, qui remplace l'ensemble)

Jamais une reconstruction depuis le modèle typé : un champ qu'une box plus récente porterait
et que l'app ne connaît pas serait perdu pour TOUTES les charges d'un coup, en silence. Les
rangs ne sont pas renumérotés — le trou laissé (1,2,3,5) ne change l'ordre de personne,
alors qu'une renumérotation réécrirait le rang de charges que personne n'a touchées.

Rien à moitié écrit : un refus laisse la configuration locale intacte (aucune adoption avant
confirmation) et la box refuse l'ensemble ou l'accepte en entier. Le geste est refusé tant
qu'un brouillon est en cours — sinon la suppression emporterait des modifications non
enregistrées. La confirmation annonce ce que l'utilisateur ne peut pas deviner : retirer une
charge réécrit toutes les autres.

── 2. Le mode de recharge, un seul lieu d'écriture ───────────────────────────

Les trois boutons quittent la tuile des favoris pour l'écran « Configurer » de la borne :
c'est l'écran de mise en service, celui où on découvre qu'une borne neuve est en
ChargingModeNormal. Deux endroits qui écrivent SetChargingInfo seraient deux comportements
qui divergent.

La tuile des favoris garde la LECTURE et l'avertissement « hors arbitrage » : c'est le
client qui ouvre ses favoris, et il doit voir que sa borne n'est pas pilotée même s'il doit
aller ailleurs pour le corriger.

⚠️ Bug latent trouvé en déplaçant : setChargingInfo écrivait toujours sur la PREMIÈRE borne
configurée (_findEvChargerId). Sur le banc qui en porte trois, régler la V2C depuis son
propre écran aurait réglé la wallbox simulée. La borne est maintenant visée explicitement,
et evChargerFor() réconcilie les deux graphies d'identifiant (§9 du contrat de données).

181 tests (+12), flutter analyze inchangé à 27 remarques.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
2026-08-28 14:36:50 +02:00

120 lines
4.5 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;
Future<void> _set(ChargingMode m) async {
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.pv => ('PV', EtmTokens.amber),
ChargingMode.minPv => ('Min+PV', EtmTokens.blue),
ChargingMode.boost => ('Boost', EtmTokens.green),
};
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))),
),
),
);
}
}