fix(ev): la tuile des favoris devient muette — elle lit la borne, ou le dit
Elle reste : la retirer aurait laissé un trou dans une liste que le client a composée lui-même. Elle affiche maintenant ce que la box publie, et là où il n'y a rien à lire, elle le dit. Ce qui est lu, et sa source · branché / en charge ← états pluggedIn, charging du Thing · puissance ← état currentPower du Thing · mode ← GetChargingInfos + notification ChargingInfoChanged Absent = « non publié », jamais zéro. Un mode que l'app ne connaît pas se lit « mode non reconnu (X) » plutôt que d'être deviné, et aucun bouton n'est allumé tant que la box n'a pas dit ce qu'elle porte. L'écriture restitue le refus, sinon elle n'existe pas. Les pastilles de 7 à 10 px qui avalaient les codes d'erreur de setChargingInfo passent à 44 px, montrent l'attente, et affichent le refus tel que la box le nomme. Et l'app ne tient plus d'état de mode local : après une écriture acceptée, elle RELIT GetChargingInfos. Elle se souvenait de ce qu'elle avait demandé, ce qui n'est pas ce que la borne porte. La fiction perd sa porte d'entrée. chargingMode, chargingPower (3,6 kW) et solarSourcePercent (82 %) sont SUPPRIMÉS de EnergyData : trois valeurs par défaut que _updateEnergyData ne touchait jamais. Ce que la box publie sur une borne vit désormais dans EvChargerLive, champ par champ, nullable. ⚠️ Et le Sankey du tableau de bord en tirait un flux « Voiture » permanent à 3,6 kW, voiture débranchée. Il est branché sur la mesure de la borne — « non lu » quand il n'y en a pas. Mais la même vue invente encore PAC = 38 % de la maison, Eau chaude = 18 %, Autres = 22 %, dont un seul marqué « estimé » : entrée 🔴 au TODO, avec la source réelle (GetLoadTelemetry publie measuredW par charge : 1500 W et 800 W ce midi). Brancher le Sankey dessus est une refonte de l'écran d'accueil, pas un correctif — à décider. Deux attentes tombent, une reste (relevé Integrations.GetThings sur .75, cet après-midi) · currentL1/L2/L3 est DÉPLOYÉ (5,86 / 5,94 / 6,02 A) — powerL1/L2/L3 a disparu · phaseCount EXISTE sur la Trydan (3) — la note qui le disait absent était en retard · sessionEnergy existe (4,229 kWh) MAIS vaut exactement chargeEnergy : c'est ce qu'on attend d'une session sans interruption, donc ça ne prouve rien. LM-1009 §C1 reste entière tant qu'une remise à zéro de chargeEnergy n'a pas été vue avec un sessionEnergy qui continue. Le brief moteur dit désormais du masquage WRITE_FAILED que ce n'est pas « ne se produit pas » mais « ne s'est pas encore produit » : il attend une charge variable qui perd son Thing, et le banc n'a pas eu ce cas. 154 tests (8 nouveaux sur la tuile, dont les cibles tactiles et la restitution du refus), flutter analyze à 27 remarques, aucune nouvelle. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
This commit is contained in:
parent
33d89a8c26
commit
95769938fc
46
TODO.md
46
TODO.md
@ -35,16 +35,16 @@
|
||||
prêtes, les motifs n'ont **jamais été vus sur machine**. Signaler la première occurrence
|
||||
réelle. Ne rien bâtir autour d'ici là.
|
||||
|
||||
- [ ] ⏳ **Deux grandeurs de la V2C attendent le plugin — ne rien câbler d'ici là.**
|
||||
Mesuré sur `.75` le 2026-08-28, sonde `tools/rpc/evcharger_watch.dart`.
|
||||
- `chargeEnergy` est **exact pendant la charge** et **remis à zéro à chaque
|
||||
interruption** — donc à chaque nuage sur du pilotage par surplus. Le plugin V2C
|
||||
publiera un `sessionEnergy` construit ; attendre celui-là, et **n'accumuler rien côté
|
||||
app**. D'ici là, la condition C1 de LM-1009 s'applique en vrai sur cette borne :
|
||||
« pas mesurable » et « rien livré » sont deux états différents, et zéro dit le second.
|
||||
- `powerL1/L2/L3` sont des **ampères mal nommés** ; le renommage `currentL1/L2/L3`
|
||||
(unité Ampere) est en cours côté plugin. Ne pas les afficher avant son déploiement, et
|
||||
**n'écrire aucune conversion côté app** — elle survivrait au correctif.
|
||||
- [ ] ⏳ **`sessionEnergy` existe maintenant — mais rien ne prouve encore qu'il survive à
|
||||
une interruption.** Relevé du 2026-08-28 après-midi sur `.75` (`Integrations.GetThings`) :
|
||||
la V2C publie `sessionEnergy` **et** `chargeEnergy`, tous deux à **4,229 kWh** — égaux,
|
||||
ce qui est exactement ce qu'on attend d'une session sans interruption. Le test qui
|
||||
tranche est une **remise à zéro observée de `chargeEnergy` avec un `sessionEnergy` qui
|
||||
continue**. Tant qu'elle n'est pas vue, LM-1009 §C1 reste entière : **pas d'avancement
|
||||
affiché**. À vérifier sur la campagne de mesures en cours.
|
||||
- Deux attentes **levées** au même relevé : le renommage `currentL1/L2/L3` (unité
|
||||
Ampère) **est déployé** — plus de `powerL1/L2/L3` — et `phaseCount` **existe** sur la
|
||||
Trydan (valeur 3), contrairement à ce que `docs/PREPA_BANC_20260828.md` avait conclu.
|
||||
|
||||
- [x] ~~**`EVChargingCard` n'est montée par AUCUN écran**~~ — **tranché le 2026-08-28 :
|
||||
elle part.** La monter aurait publié de la fiction. Trois des grandeurs qu'elle affiche
|
||||
@ -54,13 +54,25 @@
|
||||
« Paramètres de charge » proposait en plus un **courant minimum**, qui n'existe dans aucun
|
||||
contrat, et son bouton « Enregistrer » ne faisait que `Navigator.pop` : rien n'était écrit.
|
||||
|
||||
- [ ] 🔴 **La borne EV n'a plus AUCUNE surface honnête dans l'app — et il en reste une
|
||||
malhonnête.** `_EVWidget` (`screens/favorites_screen.dart:419`) est **vivante**, affiche
|
||||
les mêmes valeurs par défaut comme si elles étaient mesurées (« 3600 W », « 82 % solaire »,
|
||||
« Mode PV ») et **écrit** le mode de recharge sur des pastilles de 7 à 10 px, sans
|
||||
restituer un refus. Deux gestes, dans cet ordre : rendre ce widget muet sur ce qu'il ne
|
||||
lit pas, puis bâtir la vraie carte.
|
||||
Ce qu'une carte de recharge peut dire aujourd'hui, et rien de plus :
|
||||
- [x] ~~**La tuile EV des favoris affiche des valeurs par défaut comme des mesures**~~ —
|
||||
**fait le 2026-08-28 : rendue muette, pas retirée.** La retirer aurait laissé un trou
|
||||
dans une liste que le client a composée lui-même. `EvFavoriteTile`
|
||||
(`widgets/ev_favorite_tile.dart`) lit la borne — `pluggedIn`, `charging`, `currentPower`
|
||||
du Thing, mode par `GetChargingInfos` + `ChargingInfoChanged` — et **dit** ce qui n'est
|
||||
pas publié plutôt que d'afficher zéro. L'écriture du mode **restitue le refus** et les
|
||||
cibles tactiles passent de 7–10 px à 44 px. Les trois champs fictifs de `EnergyData`
|
||||
(`chargingMode`, `chargingPower`, `solarSourcePercent`) sont **supprimés du modèle** :
|
||||
la fiction n'a plus de porte d'entrée.
|
||||
|
||||
- [ ] 🔴 **La vue « consommation » du Sankey est INVENTÉE — tableau de bord, écran
|
||||
d'accueil.** `features/dashboard/widgets/energy_flow_card.dart` : au drill-down
|
||||
consommation, *PAC = 38 % de la maison*, *Eau chaude = 18 %*, *Autres = 22 %* — trois
|
||||
ratios en dur, dont un seul (`Autres`) porte le marqueur `estimated`. La voiture, elle,
|
||||
a été branchée sur la mesure réelle de la borne le 2026-08-28 (`currentPower`, « non
|
||||
lu » sans borne). Le reste attend une décision : la source existe — `GetLoadTelemetry`
|
||||
publie `measuredW` **par charge** (relevé du jour : chauffe-eau 1500 W, PAC 800 W) — mais
|
||||
brancher le Sankey dessus est une refonte de l'écran d'accueil, pas un correctif.
|
||||
Ce qu'une carte de recharge dédiée pourrait dire, si elle revient un jour :
|
||||
- le **mode courant**, lu par `NymeaEnergy.GetChargingInfos` et tenu à jour par
|
||||
`ChargingInfoChanged` — l'écriture existe déjà et est vérifiée
|
||||
(`nymea_service.setChargingInfo`, lecture → patch → réécriture complète) ;
|
||||
|
||||
@ -24,7 +24,10 @@ reviendrait au cycle suivant. Rendu figé par test sur la trame ci-dessus.
|
||||
ne puisse s'exprimer, donc une charge variable dont le Thing a disparu sort en
|
||||
`WRITE_FAILED` après épuisement de l'échelle d'écriture. L'adaptateur `evcharger` n'a pas ce
|
||||
défaut, ce qui explique que le code sorte proprement ici et pas là. Il n'est donc **pas
|
||||
corrigé, seulement contourné par le hasard du chemin de code**.
|
||||
corrigé, seulement contourné par le hasard du chemin de code** — et la nuance compte : ce
|
||||
n'est pas « ne se produit pas », c'est « **ne s'est pas encore produit** ». Il attend une
|
||||
installation où une charge variable — relais, SG-Ready — perd son Thing. Le banc n'a pas
|
||||
encore eu ce cas ; une installation client l'aura.
|
||||
|
||||
**Pourquoi ça vaut un correctif et pas un renvoi à la doc.** Les deux codes envoient le
|
||||
client faire deux gestes différents : `WRITE_FAILED` fait chercher un défaut de liaison —
|
||||
|
||||
@ -8,7 +8,16 @@ enum _DrillView { overview, cons, prod }
|
||||
|
||||
class EnergyFlowCard extends StatefulWidget {
|
||||
final EnergyData data;
|
||||
const EnergyFlowCard({super.key, required this.data});
|
||||
|
||||
/// Puissance **mesurée** de la borne de recharge (`currentPower` du Thing), ou `null`
|
||||
/// quand aucune borne n'est déclarée — ou qu'elle ne publie pas cet état.
|
||||
///
|
||||
/// Elle valait 3,6 kW en dur jusqu'au 2026-08-28 : `EnergyData.chargingPower` était une
|
||||
/// valeur par défaut que rien ne remplaçait, et le Sankey dessinait donc en permanence
|
||||
/// un flux vers une voiture qui n'était pas branchée.
|
||||
final double? evChargerPowerW;
|
||||
const EnergyFlowCard(
|
||||
{super.key, required this.data, this.evChargerPowerW});
|
||||
|
||||
@override
|
||||
State<EnergyFlowCard> createState() => _EnergyFlowCardState();
|
||||
@ -115,7 +124,8 @@ class _EnergyFlowCardState extends State<EnergyFlowCard>
|
||||
child: LayoutBuilder(
|
||||
builder: (context, constraints) {
|
||||
final size = constraints.maxWidth;
|
||||
final segments = _buildSegments(widget.data, _view);
|
||||
final segments =
|
||||
_buildSegments(widget.data, _view, widget.evChargerPowerW);
|
||||
return AnimatedBuilder(
|
||||
animation: _particleCtrl,
|
||||
builder: (context, _) {
|
||||
@ -143,6 +153,7 @@ class _EnergyFlowCardState extends State<EnergyFlowCard>
|
||||
key: ValueKey(_view),
|
||||
view: _view,
|
||||
data: widget.data,
|
||||
evChargerPowerW: widget.evChargerPowerW,
|
||||
pulseAnim: disableAnim ? null : _pulseAnim,
|
||||
onTapSolar: () => _switchView(_DrillView.prod),
|
||||
onTapMaison: () => _switchView(_DrillView.cons),
|
||||
@ -192,7 +203,8 @@ class _EnergyFlowCardState extends State<EnergyFlowCard>
|
||||
static const double _nE = 0.22;
|
||||
static const double _hE = 0.39;
|
||||
|
||||
static List<FlowSegment> _buildSegments(EnergyData d, _DrillView view) {
|
||||
static List<FlowSegment> _buildSegments(
|
||||
EnergyData d, _DrillView view, double? evW) {
|
||||
const nE = _nE;
|
||||
const hE = _hE;
|
||||
|
||||
@ -216,7 +228,7 @@ class _EnergyFlowCardState extends State<EnergyFlowCard>
|
||||
return [
|
||||
FlowSegment(from: Offset(0.5, hE), to: Offset(0.5, nE), color: EtmTokens.heat, powerW: total * 0.38),
|
||||
FlowSegment(from: Offset(1-hE, 0.5), to: Offset(1-nE, 0.5), color: EtmTokens.water, powerW: total * 0.18),
|
||||
FlowSegment(from: Offset(0.5, 1-hE), to: Offset(0.5, 1-nE), color: EtmTokens.eco, powerW: d.chargingPower * 1000),
|
||||
FlowSegment(from: Offset(0.5, 1-hE), to: Offset(0.5, 1-nE), color: EtmTokens.eco, powerW: evW ?? 0),
|
||||
FlowSegment(from: Offset(hE, 0.5), to: Offset(nE, 0.5), color: EtmTokens.gridColor, powerW: total * 0.22, dashed: true),
|
||||
];
|
||||
|
||||
@ -235,6 +247,9 @@ class _EnergyFlowCardState extends State<EnergyFlowCard>
|
||||
class _NodeLayer extends StatefulWidget {
|
||||
final _DrillView view;
|
||||
final EnergyData data;
|
||||
|
||||
/// Puissance mesurée de la borne — `null` = non lue. Voir [EnergyFlowCard].
|
||||
final double? evChargerPowerW;
|
||||
final Animation<double>? pulseAnim;
|
||||
final VoidCallback onTapSolar;
|
||||
final VoidCallback onTapMaison;
|
||||
@ -243,6 +258,7 @@ class _NodeLayer extends StatefulWidget {
|
||||
super.key,
|
||||
required this.view,
|
||||
required this.data,
|
||||
required this.evChargerPowerW,
|
||||
required this.pulseAnim,
|
||||
required this.onTapSolar,
|
||||
required this.onTapMaison,
|
||||
@ -370,7 +386,12 @@ class _NodeLayerState extends State<_NodeLayer>
|
||||
label: 'Eau chaude', value: '${(total * 0.18 / 1000).toStringAsFixed(1)} kW'))),
|
||||
_at(Offset(0.5, 0.88), ScaleTransition(scale: _stagger(3),
|
||||
child: _sat(context, icon: Icons.ev_station_rounded, color: eco(context),
|
||||
label: 'Voiture', value: '${d.chargingPower.toStringAsFixed(1)} kW'))),
|
||||
label: 'Voiture',
|
||||
// « non lu » plutôt qu'un zéro : sans borne déclarée, l'app ne sait pas
|
||||
// qu'il ne se charge rien — elle sait qu'elle ne mesure rien.
|
||||
value: widget.evChargerPowerW == null
|
||||
? 'non lu'
|
||||
: '${(widget.evChargerPowerW! / 1000).toStringAsFixed(1)} kW'))),
|
||||
_at(Offset(0.12, 0.5), ScaleTransition(scale: _stagger(4),
|
||||
child: _sat(context, icon: Icons.devices_other_rounded, color: EtmTokens.gridColor,
|
||||
label: 'Autres', value: '${(total * 0.22 / 1000).toStringAsFixed(1)} kW',
|
||||
|
||||
@ -28,10 +28,17 @@ class EnergyData {
|
||||
final double autonomyRate; // %
|
||||
final double dayGains; // €
|
||||
|
||||
// Borne de recharge
|
||||
final ChargingMode chargingMode;
|
||||
final double chargingPower; // kW
|
||||
final double solarSourcePercent; // %
|
||||
// ── Borne de recharge : PLUS RIEN ICI ──────────────────────────────────────
|
||||
//
|
||||
// `chargingMode`, `chargingPower` (3,6 kW) et `solarSourcePercent` (82 %) ont vécu ici
|
||||
// avec des valeurs par défaut que rien ne remplaçait : `_updateEnergyData` ne les
|
||||
// touchait pas, et le mode n'avançait qu'en optimiste après une commande envoyée depuis
|
||||
// l'app. Trois écrans les affichaient comme des mesures — la tuile des favoris, le
|
||||
// Sankey du tableau de bord, et la carte véhicule (supprimée le 2026-08-28).
|
||||
//
|
||||
// Ce que la box publie vraiment sur une borne vit désormais dans `EvChargerLive`
|
||||
// (`models/ev_charger_live.dart`), champ par champ, nullable : `null` veut dire « non
|
||||
// publié », jamais zéro.
|
||||
final bool gridLimitOk;
|
||||
|
||||
const EnergyData({
|
||||
@ -50,9 +57,6 @@ class EnergyData {
|
||||
this.selfConsumptionRate = 0,
|
||||
this.autonomyRate = 0,
|
||||
this.dayGains = 0,
|
||||
this.chargingMode = ChargingMode.pv,
|
||||
this.chargingPower = 3.6,
|
||||
this.solarSourcePercent = 82,
|
||||
this.gridLimitOk = true,
|
||||
});
|
||||
|
||||
@ -72,9 +76,6 @@ class EnergyData {
|
||||
double? selfConsumptionRate,
|
||||
double? autonomyRate,
|
||||
double? dayGains,
|
||||
ChargingMode? chargingMode,
|
||||
double? chargingPower,
|
||||
double? solarSourcePercent,
|
||||
bool? gridLimitOk,
|
||||
}) {
|
||||
return EnergyData(
|
||||
@ -93,9 +94,6 @@ class EnergyData {
|
||||
selfConsumptionRate: selfConsumptionRate ?? this.selfConsumptionRate,
|
||||
autonomyRate: autonomyRate ?? this.autonomyRate,
|
||||
dayGains: dayGains ?? this.dayGains,
|
||||
chargingMode: chargingMode ?? this.chargingMode,
|
||||
chargingPower: chargingPower ?? this.chargingPower,
|
||||
solarSourcePercent: solarSourcePercent ?? this.solarSourcePercent,
|
||||
gridLimitOk: gridLimitOk ?? this.gridLimitOk,
|
||||
);
|
||||
}
|
||||
|
||||
76
lib/models/ev_charger_live.dart
Normal file
76
lib/models/ev_charger_live.dart
Normal file
@ -0,0 +1,76 @@
|
||||
/// Ce qu'une borne de recharge **publie réellement**, et rien d'autre.
|
||||
///
|
||||
/// ## Pourquoi ce type existe
|
||||
///
|
||||
/// L'app portait trois grandeurs de recharge qui n'étaient jamais lues sur la box :
|
||||
/// `EnergyData.chargingMode` (défaut `pv`), `chargingPower` (défaut 3,6 kW) et
|
||||
/// `solarSourcePercent` (défaut 82 %). Elles étaient affichées comme des mesures — « 3600
|
||||
/// W », « 82 % solaire » — sur un écran de favoris que le client peut ouvrir. C'est la même
|
||||
/// famille de défaut que le SOC à 62 % en dur et les quatre pièces de climatisation
|
||||
/// fictives : une valeur par défaut présentée comme une lecture.
|
||||
///
|
||||
/// Ce type ne porte donc **que des champs nullables**, et `null` veut dire *la box ne le
|
||||
/// publie pas* — jamais zéro. L'écran doit le dire, pas le combler.
|
||||
///
|
||||
/// ## Ce qui est lisible aujourd'hui, mesuré sur `.75` le 2026-08-28
|
||||
///
|
||||
/// Les deux bornes du banc (Simulated wallbox, V2C Trydan) publient `pluggedIn`,
|
||||
/// `charging`, `currentPower` et `maxChargingCurrent`. Le mode, lui, ne vient pas du Thing
|
||||
/// mais de `NymeaEnergy.GetChargingInfos` — les deux bornes y étaient en
|
||||
/// `ChargingModeNormal`, `ChargingStateIdle`.
|
||||
///
|
||||
/// **`sessionEnergy` existe** sur les deux (4,229 kWh sur la V2C), et le renommage
|
||||
/// `currentL1/L2/L3` **est déployé** — plus de `powerL1/L2/L3`. Ces deux champs ne sont pas
|
||||
/// portés ici pour autant : voir la note de `LoadMechanism`
|
||||
/// (`models/load_telemetry.dart`), l'égalité `sessionEnergy == chargeEnergy` relevée ce
|
||||
/// jour ne prouve pas encore que le premier survit à une interruption de charge, et c'est
|
||||
/// exactement ce que LM-1009 §C1 demande de savoir avant d'afficher un avancement.
|
||||
library;
|
||||
|
||||
import 'energy_data.dart' show ChargingMode;
|
||||
|
||||
class EvChargerLive {
|
||||
/// `thingId` de la borne — l'identifiant que `SetChargingInfo` attend.
|
||||
final String thingId;
|
||||
final String name;
|
||||
|
||||
/// États du Thing. `null` = non publié par cette borne.
|
||||
final bool? pluggedIn;
|
||||
final bool? charging;
|
||||
final double? currentPowerW;
|
||||
|
||||
/// Mode **tel que la box le porte** (`ChargingModeEco`, `ChargingModeNormal`, …), lu par
|
||||
/// `GetChargingInfos`. `null` = pas encore lu, ou aucune entrée pour cette borne — et
|
||||
/// dans ce cas l'écran n'a **aucun** mode à présenter comme courant.
|
||||
final String? apiMode;
|
||||
|
||||
const EvChargerLive({
|
||||
required this.thingId,
|
||||
required this.name,
|
||||
this.pluggedIn,
|
||||
this.charging,
|
||||
this.currentPowerW,
|
||||
this.apiMode,
|
||||
});
|
||||
|
||||
/// Traduction du mode de la box vers les trois boutons de l'IU.
|
||||
///
|
||||
/// Les cinq valeurs de l'énumération se replient sur trois : les variantes « avec
|
||||
/// échéance » gardent le bouton de base, l'échéance étant une option à côté. `null` pour
|
||||
/// une valeur inconnue — une borne peut porter un mode qu'aucune version d'app ne
|
||||
/// connaît, et deviner serait pire que dire « mode non reconnu ».
|
||||
ChargingMode? get uiMode => switch (apiMode) {
|
||||
'ChargingModeNormal' => ChargingMode.boost,
|
||||
'ChargingModeEco' || 'ChargingModeEcoWithTargetTime' => ChargingMode.pv,
|
||||
'ChargingModeEcoWithMinCurrent' ||
|
||||
'ChargingModeEcoMinWithTargetTime' =>
|
||||
ChargingMode.minPv,
|
||||
_ => null,
|
||||
};
|
||||
|
||||
/// Vrai quand la borne porte une échéance dans son mode — l'information est dans le nom
|
||||
/// de l'énumération, pas dans un champ à part.
|
||||
bool get hasDeadlineMode =>
|
||||
apiMode == 'ChargingModeEcoWithTargetTime' ||
|
||||
apiMode == 'ChargingModeEcoMinWithTargetTime';
|
||||
}
|
||||
@ -181,9 +181,20 @@ class LoadMechanism {
|
||||
/// publiera un `sessionEnergy` construit ; attendre celui-là**, et n'accumuler rien
|
||||
/// côté app en attendant.
|
||||
///
|
||||
/// Même prudence pour `powerL1/L2/L3` de cette borne : ce sont des **ampères mal nommés**
|
||||
/// (renommage `currentL1/L2/L3` en cours côté plugin). Ne pas les afficher, et surtout
|
||||
/// n'écrire aucune conversion ici — elle survivrait au correctif et le contredirait.
|
||||
/// **Relevé de suivi, 2026-08-28 après-midi** — deux des trois attentes sont levées, la
|
||||
/// troisième ne l'est pas :
|
||||
///
|
||||
/// - le renommage **est déployé** : la V2C publie `currentL1/L2/L3` (5,86 / 5,94 /
|
||||
/// 6,02 A), et `powerL1/L2/L3` a disparu. Il n'y a donc plus de conversion à ne pas
|
||||
/// écrire — mais toujours aucune raison d'afficher des ampères par phase avant qu'un
|
||||
/// écran ne les demande ;
|
||||
/// - `phaseCount` **existe** sur la Trydan (valeur 3) : la note qui le disait absent de
|
||||
/// la carte Modbus était en retard sur la machine ;
|
||||
/// - `sessionEnergy` **existe aussi** (4,229 kWh) — mais il valait **exactement**
|
||||
/// `chargeEnergy` au moment du relevé. L'égalité ne prouve rien : c'est justement ce
|
||||
/// qu'on attend d'une session sans interruption. **Tant qu'une remise à zéro de
|
||||
/// `chargeEnergy` n'a pas été observée avec un `sessionEnergy` qui, lui, continue,
|
||||
/// la condition C1 de LM-1009 reste entière** : ne pas afficher d'avancement.
|
||||
|
||||
// ── evcharger (3g) — l'ÉTAT RÉEL de la borne ────────────────────────────────
|
||||
//
|
||||
|
||||
@ -79,7 +79,12 @@ class _DashboardScreenState extends State<DashboardScreen> {
|
||||
sliver: SliverList(
|
||||
delegate: SliverChildListDelegate([
|
||||
const SizedBox(height: 14),
|
||||
EnergyFlowCard(data: data),
|
||||
// La voiture du Sankey vient de la BORNE, pas d'un défaut de
|
||||
// modèle : `null` quand aucune borne n'est déclarée.
|
||||
EnergyFlowCard(
|
||||
data: data,
|
||||
evChargerPowerW:
|
||||
service.evCharger?.currentPowerW),
|
||||
const SizedBox(height: 14),
|
||||
KpiRow(data: data, balance: _bilan),
|
||||
const SizedBox(height: 14),
|
||||
|
||||
@ -3,6 +3,7 @@ import 'package:provider/provider.dart';
|
||||
import '../models/energy_data.dart';
|
||||
import '../models/nymea_models.dart';
|
||||
import '../services/nymea_service.dart';
|
||||
import '../widgets/ev_favorite_tile.dart';
|
||||
import '../theme/etm_tokens.dart';
|
||||
import '../main.dart' show DrawerMenuButton;
|
||||
|
||||
@ -207,7 +208,12 @@ class _FavoriteCard extends StatelessWidget {
|
||||
sub: d.gridPower > 0 ? 'Achat réseau' : 'Vente réseau',
|
||||
),
|
||||
FavoriteType.rates => _RatesWidget(data: d),
|
||||
FavoriteType.evCharger => _EVWidget(data: d, service: service),
|
||||
// La tuile ne reçoit plus `EnergyData` : ses trois grandeurs de recharge
|
||||
// n'étaient jamais lues sur la box. Elle lit la borne, ou dit qu'il n'y a
|
||||
// rien à lire.
|
||||
FavoriteType.evCharger => EvFavoriteTile(
|
||||
live: service.evCharger,
|
||||
onSetMode: (m) => service.setChargingInfo(mode: m)),
|
||||
FavoriteType.thingState => _ThingStateWidget(fav: fav, service: service),
|
||||
FavoriteType.chart => _MiniChart(history: service.historyPoints),
|
||||
};
|
||||
@ -416,75 +422,6 @@ class _RateTile extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
class _EVWidget extends StatelessWidget {
|
||||
final EnergyData data;
|
||||
final NymeaService service;
|
||||
const _EVWidget({required this.data, required this.service});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final (color, label) = switch (data.chargingMode) {
|
||||
ChargingMode.pv => (EtmTokens.amber, 'PV'),
|
||||
ChargingMode.minPv => (EtmTokens.blue, 'Min+PV'),
|
||||
ChargingMode.boost => (EtmTokens.green, 'Boost'),
|
||||
};
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 52, height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(Icons.ev_station_rounded, color: color, size: 28),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('${(data.chargingPower * 1000).toStringAsFixed(0)} W',
|
||||
style: EtmTokens.mono(
|
||||
size: 26, weight: FontWeight.w700, color: color)),
|
||||
Text('Mode $label',
|
||||
style: EtmTokens.sans(size: 12, color: color)),
|
||||
Text(
|
||||
'${data.solarSourcePercent.toStringAsFixed(0)}% solaire',
|
||||
style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))),
|
||||
],
|
||||
),
|
||||
),
|
||||
// Sélecteur de mode rapide
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: ChargingMode.values.map((m) {
|
||||
final mc = switch (m) {
|
||||
ChargingMode.pv => EtmTokens.amber,
|
||||
ChargingMode.minPv => EtmTokens.blue,
|
||||
ChargingMode.boost => EtmTokens.green,
|
||||
};
|
||||
final sel = data.chargingMode == m;
|
||||
return GestureDetector(
|
||||
onTap: () => service.setChargingInfo(mode: m),
|
||||
child: AnimatedContainer(
|
||||
duration: const Duration(milliseconds: 150),
|
||||
width: sel ? 10 : 7,
|
||||
height: sel ? 10 : 7,
|
||||
margin: const EdgeInsets.symmetric(vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: sel ? mc : mc.withValues(alpha: 0.3),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
),
|
||||
);
|
||||
}).toList(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThingStateWidget extends StatelessWidget {
|
||||
final FavoriteWidget fav;
|
||||
final NymeaService service;
|
||||
|
||||
@ -7,7 +7,8 @@ import 'package:flutter/foundation.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
import 'package:web_socket_channel/web_socket_channel.dart';
|
||||
import '../models/daily_balance.dart';
|
||||
import '../models/energy_data.dart'; // EnergyData, HistoryPoint, PowerBalanceEntry
|
||||
import '../models/energy_data.dart';
|
||||
import '../models/ev_charger_live.dart'; // EnergyData, HistoryPoint, PowerBalanceEntry
|
||||
import '../models/modbus_models.dart'; // ModbusRtuMaster, SerialPort
|
||||
import '../models/nymea_models.dart';
|
||||
import 'energy_ratios.dart'; // seam interim ratios (§2.1)
|
||||
@ -671,6 +672,24 @@ class NymeaService extends ChangeNotifier {
|
||||
// seraient le même silence.
|
||||
_onLoadTelemetryChanged(params);
|
||||
break;
|
||||
case 'NymeaEnergy.ChargingInfoAdded':
|
||||
case 'NymeaEnergy.ChargingInfoChanged':
|
||||
// La box confirme le mode qu'elle porte VRAIMENT. C'est la seule source du mode
|
||||
// affiché : l'app ne le déduit plus de la commande qu'elle vient d'envoyer.
|
||||
final info = params['chargingInfo'];
|
||||
if (info is Map) {
|
||||
final m = Map<String, dynamic>.from(info);
|
||||
final id = m['evChargerId'] as String?;
|
||||
if (id != null) {
|
||||
_chargingInfos[id] = m;
|
||||
notifyListeners();
|
||||
}
|
||||
}
|
||||
break;
|
||||
case 'NymeaEnergy.ChargingInfoRemoved':
|
||||
final id = params['evChargerId'] as String?;
|
||||
if (id != null && _chargingInfos.remove(id) != null) notifyListeners();
|
||||
break;
|
||||
case 'NymeaEnergy.LoadConfigChanged':
|
||||
// Émise après TOUTE persistance réussie (SetLoadConfig ou relecture du
|
||||
// fichier édité hors app). Porte la liste complète à jour : c'est LA
|
||||
@ -808,6 +827,9 @@ class NymeaService extends ChangeNotifier {
|
||||
_loadEnergyStatus(),
|
||||
_loadEnergyHistory(),
|
||||
]);
|
||||
// Après les Things : la borne se trouve par son interface, et `GetChargingInfos` ne
|
||||
// dit rien tant qu'on ne sait pas à quel Thing rattacher chaque entrée.
|
||||
await refreshChargingInfos();
|
||||
}
|
||||
|
||||
void _startHeartbeat() {
|
||||
@ -1300,15 +1322,107 @@ class NymeaService extends ChangeNotifier {
|
||||
}
|
||||
}
|
||||
|
||||
// L'état local ne suit que si la box a accepté — sinon l'UI montrerait un mode
|
||||
// que la borne n'a jamais reçu.
|
||||
if (error == null) {
|
||||
_energyData = _energyData.copyWith(chargingMode: mode);
|
||||
notifyListeners();
|
||||
}
|
||||
// ⚠️ L'app ne tient PLUS d'état de mode local, même après un succès.
|
||||
//
|
||||
// Elle le faisait, et c'était la racine de la fiction : `EnergyData.chargingMode`
|
||||
// partait de `pv` par défaut, n'était jamais relu sur la box, et n'avançait qu'ici —
|
||||
// donc l'écran affichait « Mode PV » sur une borne que personne n'avait réglée, et
|
||||
// « Boost » après une commande que la borne pouvait avoir appliquée autrement.
|
||||
//
|
||||
// Le mode affiché vient désormais de `GetChargingInfos` et de la notification
|
||||
// `ChargingInfoChanged`. On relit après une écriture acceptée : c'est la box qui dit
|
||||
// ce qu'elle porte, pas l'app qui se souvient de ce qu'elle a demandé.
|
||||
if (error == null) await refreshChargingInfos();
|
||||
return error;
|
||||
}
|
||||
|
||||
// ── Ce que la borne publie, et rien de plus ─────────────────────────────────
|
||||
|
||||
/// `evChargerId` → charge utile `ChargingInfo` telle que la box la porte.
|
||||
///
|
||||
/// Alimenté par [refreshChargingInfos] et par les notifications
|
||||
/// `ChargingInfoAdded/Changed/Removed`. Vide tant que rien n'a été lu — et « vide » doit
|
||||
/// se lire « pas encore lu », jamais « aucun mode ».
|
||||
final Map<String, Map<String, dynamic>> _chargingInfos = {};
|
||||
|
||||
/// Relit `NymeaEnergy.GetChargingInfos` pour TOUTES les bornes.
|
||||
Future<void> refreshChargingInfos() async {
|
||||
if (_isSimulation || !_connected) return;
|
||||
try {
|
||||
final r = await _sendRequest('NymeaEnergy.GetChargingInfos', {});
|
||||
final list = r['params']?['chargingInfos'];
|
||||
if (list is! List) return;
|
||||
_chargingInfos.clear();
|
||||
for (final e in list) {
|
||||
if (e is Map) {
|
||||
final m = Map<String, dynamic>.from(e);
|
||||
final id = m['evChargerId'] as String?;
|
||||
if (id != null) _chargingInfos[id] = m;
|
||||
}
|
||||
}
|
||||
_log('[Recharge] GetChargingInfos → ${_chargingInfos.length} borne(s)',
|
||||
force: true);
|
||||
notifyListeners();
|
||||
} catch (e) {
|
||||
_log('[Recharge] GetChargingInfos: $e', force: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// Valeur d'un état du Thing **par son nom** (`pluggedIn`, `currentPower`, …).
|
||||
///
|
||||
/// Les états voyagent par `stateTypeId` (un UUID) ; le nom vit dans la `ThingClass`. Sans
|
||||
/// la classe, on ne peut rien nommer — et `null` est alors la seule réponse honnête.
|
||||
Object? _stateByName(NymeaThing thing, String name) {
|
||||
NymeaThingClass? cls;
|
||||
for (final c in _thingClasses) {
|
||||
if (c.id == thing.thingClassId) {
|
||||
cls = c;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (cls == null) return null;
|
||||
String? id;
|
||||
for (final st in cls.stateTypes) {
|
||||
if (st.name == name) {
|
||||
id = st.id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (id == null) return null;
|
||||
for (final s in thing.states) {
|
||||
if (s.stateTypeId == id) return s.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/// La première borne de recharge configurée, **telle que la box la publie**.
|
||||
///
|
||||
/// `null` = aucune borne sur cette installation. Chaque champ de [EvChargerLive] est
|
||||
/// nullable pour la même raison : une borne qui ne publie pas `currentPower` ne consomme
|
||||
/// pas 0 W, elle ne le dit pas. Cf. LM-1009 §C1.
|
||||
EvChargerLive? get evCharger {
|
||||
final id = _findEvChargerId();
|
||||
if (id == null) return null;
|
||||
NymeaThing? thing;
|
||||
for (final t in _things) {
|
||||
if (t.id == id) {
|
||||
thing = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (thing == null) return null;
|
||||
final info = _chargingInfos[id];
|
||||
final power = _stateByName(thing, 'currentPower');
|
||||
return EvChargerLive(
|
||||
thingId: id,
|
||||
name: thing.name,
|
||||
pluggedIn: _stateByName(thing, 'pluggedIn') as bool?,
|
||||
charging: _stateByName(thing, 'charging') as bool?,
|
||||
currentPowerW: power is num ? power.toDouble() : null,
|
||||
apiMode: info?['chargingMode'] as String?,
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// CONFIG ÉNERGIE — rôles & charges pilotées (écran Rôles & appareils)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
243
lib/widgets/ev_favorite_tile.dart
Normal file
243
lib/widgets/ev_favorite_tile.dart
Normal file
@ -0,0 +1,243 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/energy_data.dart' show ChargingMode;
|
||||
import '../models/ev_charger_live.dart';
|
||||
import '../theme/etm_tokens.dart';
|
||||
|
||||
/// La tuile « Borne EV » des favoris — **rendue muette le 2026-08-28**.
|
||||
///
|
||||
/// ## Ce qu'elle affichait, et pourquoi c'était grave
|
||||
///
|
||||
/// Elle montrait « 3600 W », « 82 % solaire » et « Mode PV » : trois valeurs par défaut de
|
||||
/// `EnergyData` que l'app **n'a jamais lues sur la box**. Le mode n'était relu nulle part —
|
||||
/// il partait de `pv` et n'avançait qu'après une commande envoyée depuis l'app. Un client
|
||||
/// ouvrant ce favori voyait donc une borne en train de charger à 3,6 kW alors que rien
|
||||
/// n'était branché.
|
||||
///
|
||||
/// La tuile **reste** — la retirer laisserait un trou dans une liste que le client a
|
||||
/// composée lui-même, sans explication. Elle dit maintenant ce que la box publie, et là où
|
||||
/// il n'y a rien à lire, elle le dit.
|
||||
///
|
||||
/// ## Les trois grandeurs qui restent, et leur source
|
||||
///
|
||||
/// | Affiché | Source | Absent = |
|
||||
/// |---|---|---|
|
||||
/// | branché / en charge | états `pluggedIn`, `charging` du Thing | « état non publié » |
|
||||
/// | puissance | état `currentPower` du Thing | « puissance non publiée » |
|
||||
/// | mode | `NymeaEnergy.GetChargingInfos` + `ChargingInfoChanged` | « mode non lu » |
|
||||
///
|
||||
/// Pas de part solaire : la box ne dit pas d'où vient le courant d'une borne. Pas
|
||||
/// d'avancement de charge : LM-1009 §C1 — `chargeEnergy` est remis à zéro à chaque
|
||||
/// interruption, et l'égalité `sessionEnergy == chargeEnergy` relevée le 2026-08-28 ne
|
||||
/// prouve pas encore que le second y survive.
|
||||
///
|
||||
/// ## L'écriture restitue le refus, ou n'existe pas
|
||||
///
|
||||
/// Les trois modes s'écrivaient sur des pastilles de 7 à 10 px qui **avalaient les
|
||||
/// refus** : `setChargingInfo` rend un code d'erreur, personne ne le lisait. Une commande
|
||||
/// muette est pire qu'une lecture seule — l'utilisateur croit avoir réglé la borne. Les
|
||||
/// boutons font désormais 44 px, montrent l'attente, et affichent le refus tel que la box
|
||||
/// le nomme.
|
||||
class EvFavoriteTile extends StatefulWidget {
|
||||
/// `null` = aucune borne déclarée sur l'installation.
|
||||
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;
|
||||
|
||||
const EvFavoriteTile({super.key, required this.live, required this.onSetMode});
|
||||
|
||||
@override
|
||||
State<EvFavoriteTile> createState() => _EvFavoriteTileState();
|
||||
}
|
||||
|
||||
class _EvFavoriteTileState extends State<EvFavoriteTile> {
|
||||
/// 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 live = widget.live;
|
||||
if (live == null) {
|
||||
return _Muet(
|
||||
icon: Icons.ev_station_outlined,
|
||||
titre: 'Borne de recharge',
|
||||
detail: 'Aucune borne déclarée sur cette installation.',
|
||||
);
|
||||
}
|
||||
|
||||
final couleur = live.charging == true ? EtmTokens.green : EtmTokens.blue;
|
||||
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: couleur.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(Icons.ev_station_rounded, color: couleur, size: 28),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// La puissance MESURÉE, ou le fait qu'elle ne soit pas publiée. Jamais 0 W
|
||||
// par défaut : « 0 W » et « pas de mesure » sont deux choses différentes.
|
||||
if (live.currentPowerW != null)
|
||||
Text('${live.currentPowerW!.round()} W',
|
||||
style: EtmTokens.mono(
|
||||
size: 26, weight: FontWeight.w700, color: couleur))
|
||||
else
|
||||
Text('Puissance non publiée',
|
||||
style: EtmTokens.sans(
|
||||
size: 13, color: EtmTokens.mutedOf(context))),
|
||||
Text(live.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: EtmTokens.sans(size: 12, color: couleur)),
|
||||
Text(_etat(live),
|
||||
style: EtmTokens.sans(
|
||||
size: 11, color: EtmTokens.mutedOf(context))),
|
||||
Text(_mode(live),
|
||||
style: EtmTokens.sans(
|
||||
size: 11, color: EtmTokens.mutedOf(context))),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
for (final m in ChargingMode.values) _bouton(live, m),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
/// Branché ? En charge ? — et « non publié » quand la borne ne le dit pas.
|
||||
String _etat(EvChargerLive live) {
|
||||
if (live.pluggedIn == null && live.charging == null) {
|
||||
return 'Branchement non publié par cette borne';
|
||||
}
|
||||
if (live.charging == true) return 'En charge';
|
||||
if (live.pluggedIn == true) return 'Véhicule branché, à l\'arrêt';
|
||||
if (live.pluggedIn == false) return 'Aucun véhicule branché';
|
||||
return 'À l\'arrêt';
|
||||
}
|
||||
|
||||
/// Le mode **que la box porte**, jamais celui que l'app a demandé.
|
||||
String _mode(EvChargerLive live) {
|
||||
if (live.apiMode == null) return 'Mode non lu';
|
||||
final ui = live.uiMode;
|
||||
if (ui == null) return 'Mode non reconnu (${live.apiMode})';
|
||||
final base = switch (ui) {
|
||||
ChargingMode.pv => 'PV',
|
||||
ChargingMode.minPv => 'Min+PV',
|
||||
ChargingMode.boost => 'Boost',
|
||||
};
|
||||
return live.hasDeadlineMode ? 'Mode $base + échéance' : 'Mode $base';
|
||||
}
|
||||
|
||||
/// 44 px de cible tactile — les pastilles précédentes faisaient 7 à 10 px, et le premier
|
||||
/// passage sur appareil avait déjà trouvé deux cibles hors norme sur d'autres écrans.
|
||||
Widget _bouton(EvChargerLive live, ChargingMode m) {
|
||||
final (label, couleur) = switch (m) {
|
||||
ChargingMode.pv => ('PV', EtmTokens.amber),
|
||||
ChargingMode.minPv => ('Min+PV', EtmTokens.blue),
|
||||
ChargingMode.boost => ('Boost', EtmTokens.green),
|
||||
};
|
||||
// Sélection = ce que la BOX porte. Un mode non lu ne sélectionne rien : mieux vaut
|
||||
// trois boutons éteints qu'un bouton allumé au hasard.
|
||||
final selected = live.uiMode == m;
|
||||
final busy = _pending != null;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||
child: SizedBox(
|
||||
height: 44,
|
||||
width: 76,
|
||||
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: 11,
|
||||
weight: selected ? FontWeight.w700 : FontWeight.w400,
|
||||
color: selected ? couleur : EtmTokens.mutedOf(context))),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Tuile sans donnée : elle nomme ce qu'elle attend, plutôt que d'afficher un zéro.
|
||||
class _Muet extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String titre;
|
||||
final String detail;
|
||||
|
||||
const _Muet({required this.icon, required this.titre, required this.detail});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 52,
|
||||
height: 52,
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.mutedOf(context).withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
),
|
||||
child: Icon(icon, color: EtmTokens.mutedOf(context), size: 26),
|
||||
),
|
||||
const SizedBox(width: 14),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(titre,
|
||||
style: EtmTokens.sans(size: 14, weight: FontWeight.w600)),
|
||||
const SizedBox(height: 2),
|
||||
Text(detail,
|
||||
style: EtmTokens.sans(
|
||||
size: 11.5, color: EtmTokens.mutedOf(context))),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
160
test/ev_favorite_tile_test.dart
Normal file
160
test/ev_favorite_tile_test.dart
Normal file
@ -0,0 +1,160 @@
|
||||
/// La tuile « Borne EV » **rendue muette** — éprouvée sur ce qu'elle affiche.
|
||||
///
|
||||
/// Le défaut qu'elle corrigeait ne se voyait pas en test de fonction : les trois grandeurs
|
||||
/// affichées (« 3600 W », « 82 % solaire », « Mode PV ») étaient des valeurs par défaut de
|
||||
/// `EnergyData`, justes au sens du type et fausses à l'écran. Ces tests montent donc le
|
||||
/// widget et lisent le texte, comme ceux des notes de configuration.
|
||||
library;
|
||||
|
||||
import 'package:etm_powersync_app/models/energy_data.dart' show ChargingMode;
|
||||
import 'package:etm_powersync_app/models/ev_charger_live.dart';
|
||||
import 'package:etm_powersync_app/widgets/ev_favorite_tile.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
Widget _harness({
|
||||
required EvChargerLive? live,
|
||||
Future<String?> Function(ChargingMode)? onSetMode,
|
||||
}) =>
|
||||
MaterialApp(
|
||||
home: Scaffold(
|
||||
body: EvFavoriteTile(
|
||||
live: live,
|
||||
onSetMode: onSetMode ?? (_) async => null,
|
||||
),
|
||||
),
|
||||
);
|
||||
|
||||
/// La V2C du banc, telle que `.75` la publiait le 2026-08-28 : états `pluggedIn`,
|
||||
/// `charging`, `currentPower` sur le Thing, mode par `GetChargingInfos`.
|
||||
EvChargerLive _v2c({
|
||||
bool? pluggedIn = false,
|
||||
bool? charging = false,
|
||||
double? powerW = 0,
|
||||
String? apiMode = 'ChargingModeNormal',
|
||||
}) =>
|
||||
EvChargerLive(
|
||||
thingId: '4d1adafd-cafc-4813-93b1-ca8933ce71ab',
|
||||
name: 'V2C Trydan',
|
||||
pluggedIn: pluggedIn,
|
||||
charging: charging,
|
||||
currentPowerW: powerW,
|
||||
apiMode: apiMode,
|
||||
);
|
||||
|
||||
void main() {
|
||||
group('la tuile ne montre que ce que la box publie', () {
|
||||
testWidgets('sans borne, elle le dit — elle ne disparaît pas',
|
||||
(tester) async {
|
||||
// Retirer la tuile laisserait un trou dans une liste que le client a composée.
|
||||
await tester.pumpWidget(_harness(live: null));
|
||||
expect(find.textContaining('Aucune borne déclarée'), findsOneWidget);
|
||||
expect(find.textContaining('W'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('une borne qui ne publie rien n\'affiche AUCUN zéro',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(_harness(
|
||||
live: _v2c(
|
||||
pluggedIn: null, charging: null, powerW: null, apiMode: null)));
|
||||
|
||||
// Les trois absences se disent, chacune à sa place.
|
||||
expect(find.text('Puissance non publiée'), findsOneWidget);
|
||||
expect(find.textContaining('Branchement non publié'), findsOneWidget);
|
||||
expect(find.text('Mode non lu'), findsOneWidget);
|
||||
// Et surtout : pas de « 0 W », qui se lirait « la borne ne consomme rien ».
|
||||
expect(find.textContaining('0 W'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('elle affiche la puissance MESURÉE et le mode DE LA BOX',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(_harness(
|
||||
live: _v2c(
|
||||
pluggedIn: true,
|
||||
charging: true,
|
||||
powerW: 3907,
|
||||
apiMode: 'ChargingModeEco')));
|
||||
|
||||
expect(find.text('3907 W'), findsOneWidget);
|
||||
expect(find.text('En charge'), findsOneWidget);
|
||||
expect(find.text('Mode PV'), findsOneWidget);
|
||||
expect(find.text('V2C Trydan'), findsOneWidget);
|
||||
// Aucune part solaire : la box ne dit pas d'où vient le courant d'une borne.
|
||||
expect(find.textContaining('solaire'), findsNothing);
|
||||
});
|
||||
|
||||
testWidgets('un mode à échéance se lit comme tel, un mode inconnu aussi',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
_harness(live: _v2c(apiMode: 'ChargingModeEcoMinWithTargetTime')));
|
||||
expect(find.text('Mode Min+PV + échéance'), findsOneWidget);
|
||||
|
||||
// Une box peut porter un mode qu'aucune version d'app ne connaît. Deviner serait
|
||||
// pire que le nommer.
|
||||
await tester.pumpWidget(_harness(live: _v2c(apiMode: 'ChargingModeFutur')));
|
||||
await tester.pump();
|
||||
expect(find.textContaining('Mode non reconnu'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('branché mais à l\'arrêt n\'est pas « en charge »',
|
||||
(tester) async {
|
||||
await tester.pumpWidget(
|
||||
_harness(live: _v2c(pluggedIn: true, charging: false, powerW: 0)));
|
||||
expect(find.textContaining('branché, à l\'arrêt'), findsOneWidget);
|
||||
// Ici le zéro est une MESURE, pas une absence : la borne publie `currentPower`.
|
||||
expect(find.text('0 W'), findsOneWidget);
|
||||
});
|
||||
});
|
||||
|
||||
group('l\'écriture du mode restitue le refus', () {
|
||||
testWidgets('un refus de la box est affiché, avec son code', (tester) async {
|
||||
await tester.pumpWidget(_harness(
|
||||
live: _v2c(),
|
||||
onSetMode: (_) async => 'EnergyErrorInvalidParameter',
|
||||
));
|
||||
|
||||
await tester.tap(find.text('PV'));
|
||||
await tester.pump(); // écriture en cours
|
||||
await tester.pump(); // réponse
|
||||
await tester.pump(); // SnackBar
|
||||
|
||||
expect(find.textContaining('EnergyErrorInvalidParameter'), findsOneWidget);
|
||||
expect(find.textContaining('a refusé'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('un succès n\'affiche rien — c\'est la box qui dira le mode',
|
||||
(tester) async {
|
||||
var appels = 0;
|
||||
await tester.pumpWidget(_harness(
|
||||
live: _v2c(),
|
||||
onSetMode: (m) async {
|
||||
appels++;
|
||||
return null;
|
||||
},
|
||||
));
|
||||
|
||||
await tester.tap(find.text('Boost'));
|
||||
await tester.pumpAndSettle();
|
||||
|
||||
expect(appels, 1);
|
||||
expect(find.textContaining('a refusé'), findsNothing);
|
||||
// La sélection n'a pas bougé toute seule : la tuile affiche le mode que la box
|
||||
// portait à l'ouverture, et attend `ChargingInfoChanged` pour changer d'avis.
|
||||
expect(find.text('Mode Boost'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets('les cibles tactiles font au moins 44 px', (tester) async {
|
||||
// Les pastilles précédentes faisaient 7 à 10 px, et écrivaient pour de vrai.
|
||||
await tester.pumpWidget(_harness(live: _v2c()));
|
||||
for (final label in ['PV', 'Min+PV', 'Boost']) {
|
||||
final taille = tester.getSize(find.ancestor(
|
||||
of: find.text(label),
|
||||
matching: find.byType(OutlinedButton),
|
||||
));
|
||||
expect(taille.height, greaterThanOrEqualTo(44),
|
||||
reason: '$label : cible tactile trop petite');
|
||||
expect(taille.width, greaterThanOrEqualTo(44));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user