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
120 lines
4.5 KiB
Dart
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))),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|