etm-powersync-app/lib/widgets/ev_favorite_tile.dart
Patrick Schurig 95769938fc 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
2026-08-28 12:49:28 +02:00

244 lines
9.1 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';
/// 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))),
],
),
),
],
);
}
}