Patrick Schurig 92495c0e87 fix(dashboard): « sans production » n'est pas « pas mesurable »
Signalé sur appareil de nuit : tuile Autoconsommation « — · pas mesurable »,
sur une installation dont les quatre compteurs marchent.

D'où vient le nombre : la tuile lit `DailyBalance`, pas `GetEnergyRatios`.
`selfConsumptionRate` y rend `null` dès que `productionKwh <= 0` — production
nulle depuis minuit, donc 0/0.

Le « — » est JUSTE et ne bouge pas. Un « 0 % » affirmerait qu'aucune production
n'a été autoconsommée : une phrase sur une production qui n'existe pas, soit un
zéro forgé de plus. Ce qui était faux, c'est le motif écrit dessous — il
accusait la mesure là où il n'y avait simplement rien à mesurer.

Les deux causes d'un taux absent sont désormais séparées :
- pas de bilan (échantillons, cumuls, compteur en recul) → « pas mesurable » ;
- dénominateur nul → « sans production » / « sans consommation ».

C'est la distinction habituelle du dépôt prise par l'autre bout : d'ordinaire on
empêche une absence de se dire zéro ; ici on l'empêche d'accuser la mesure.

Mesuré sur .75 au même instant : production 0 W, `selfConsumptionRate` absent de
GetEnergyRatios sur trois relevés, `autonomyRate: 0` présent — la box fait la
même distinction, sur la même arithmétique.

Le premier jet du test asserait `find.text('0 %'), findsNothing` : il mordait
sur le 0 % de l'Autonomie, qui est une mesure. Corrigé en le voyant échouer, et
la raison est dans le test.

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

487 lines
19 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import '../../../models/daily_balance.dart';
import '../../../models/energy_ratios_snapshot.dart';
import '../../../models/energy_data.dart';
import '../../../theme/etm_tokens.dart';
import '../painters/ring_painter.dart';
/// Les quatre tuiles sous le flux énergétique — **le bilan du JOUR**.
///
/// ## Ce qu'elles montraient, et pourquoi c'était incohérent
///
/// Aucune n'était journalière, et elles mélangeaient deux natures de grandeur sans le
/// dire : *Autoconsommation* et *Autonomie* étaient des **pourcentages instantanés**,
/// *Vers réseau* et *Depuis réseau* des **kWh cumulés depuis l'origine** de l'installation.
/// Les deux dernières ont même porté le mot « aujourd'hui », qui était faux —
/// `totalReturn` n'est pas remis à zéro chaque nuit.
///
/// L'instantané n'est pas perdu : c'est ce que montre le **flux énergétique juste
/// au-dessus**. Les tuiles disent la journée, la carte dit l'instant, et chacune ne dit
/// qu'une chose.
///
/// ## Ce qu'elles font des trous
///
/// [balance] à `null` = journée **non mesurable** : moins de deux échantillons, compteurs
/// corrompus, ou compteur en recul. Les tuiles affichent alors « — » et le disent. Elles
/// n'affichent **jamais zéro** : « rien produit » et « on ne sait pas » sont deux états
/// différents, et à 3 h du matin le premier est vrai sans que le second le soit.
/// **La ligne ne s'affiche que si elle DIT quelque chose.**
///
/// Première version : elle sortait dès que l'origine différait du début de journée. Vu sur
/// appareil le 2026-09-05, ça donnait ceci — tuiles **52 % / 54 %**, et juste dessous
/// « Instantané depuis 09:23 — autoconsommation **52 %**, autonomie **54 %** ». Les mêmes
/// deux nombres, écrits deux fois.
///
/// Les valeurs coïncidaient pour de bon : bilan sur 00:00→16:45 = 51,8 / 54,1, box depuis
/// 09:23 = 51,9 / 53,7. Rien n'était faux — et la ligne était quand même illisible, parce
/// qu'**un lecteur compare des nombres, pas des origines**. Devant deux fois « 52 % », il
/// doit raisonner pour découvrir que les fenêtres diffèrent : exactement ce que la ligne
/// existait pour lui épargner.
///
/// Elle sort donc quand l'origine diffère **et** que l'écart se voit. Sous le seuil, les
/// tuiles suffisent — et une ligne permanente cesse d'être lue, comme un avertissement
/// permanent.
///
/// Sans bilan, elle sort quand même : c'est alors le seul chiffre disponible.
bool _ligneUtile(EnergyRatiosSnapshot? r, DailyBalance? b) {
if (r == null || !r.baselineRepriseEnJournee) return false;
if (b == null) return true;
double ecart(double? x, double? y) =>
(x == null || y == null) ? double.infinity : (x - y).abs();
const seuil = 2.0; // points de pourcentage — sous ça, l'arrondi affiché est identique
return ecart(r.selfConsumptionRate, b.selfConsumptionRate) >= seuil ||
ecart(r.autonomyRate, b.autonomyRate) >= seuil;
}
/// La phrase des ratios de la box — **avec leur origine, puisqu'elle diffère**.
///
/// Appelée seulement quand la baseline a été reposée après le début de journée : sans ça,
/// il n'y a rien à dire de plus que ce que les tuiles disent déjà.
String _instantane(EnergyRatiosSnapshot r) {
String pc(double? v) => v == null ? '—' : '${v.round()} %';
final d = r.measuredSince!;
final h = '${d.hour.toString().padLeft(2, '0')}:'
'${d.minute.toString().padLeft(2, '0')}';
return 'Instantané depuis $h — autoconsommation ${pc(r.selfConsumptionRate)}, '
'autonomie ${pc(r.autonomyRate)}';
}
/// **Pourquoi la tuile n'a pas de nombre** — et les deux raisons ne se ressemblent pas.
///
/// Un taux absent a deux causes qui ne disent pas du tout la même chose :
///
/// - **la box n'a pas de bilan** — moins de deux échantillons, cumuls absents, compteur en
/// recul. Là, on ne sait pas : « pas mesurable » ;
/// - **le dénominateur est nul** — aucune production depuis minuit, donc aucune fraction de
/// production à rapporter. Là, on sait parfaitement, et il n'y a **rien à mesurer**.
///
/// Signalé sur appareil le 2026-09-07, de nuit : la tuile Autoconsommation affichait
/// « — · pas mesurable » sur une installation dont tous les compteurs marchaient. Le « — »
/// était juste — un `0 %` aurait affirmé qu'aucune production n'a été autoconsommée, une
/// phrase sur une production qui n'existe pas — mais **le motif était faux**, et c'est lui
/// qui se lit comme une panne.
///
/// C'est la distinction habituelle de ce dépôt prise par l'autre bout : d'ordinaire on
/// empêche une absence de se dire zéro ; ici on empêche une absence d'accuser la mesure.
String? _motifAbsence(DailyBalance? b, {required bool production}) {
if (b == null) return 'pas mesurable';
final denominateur = production ? b.productionKwh : b.consumptionKwh;
if (denominateur <= 0) return production ? 'sans production' : 'sans consommation';
return 'pas mesurable';
}
class KpiRow extends StatelessWidget {
final EnergyData data;
/// Bilan du jour, ou `null` tant qu'il n'est pas lu — ou s'il n'est pas mesurable.
final DailyBalance? balance;
/// **Les ratios de la BOX, qui ne sont pas ceux des tuiles.**
///
/// Deux grandeurs voisines et deux phrases différentes, comme `purchasedWh` et
/// `deliveredWh` : les tuiles disent la **journée**, calculée par différence de compteurs
/// sur la fenêtre `periodStartedAt → maintenant` ; ceux-ci partent de la **baseline**, qui
/// se repose à chaque redémarrage de `nymead`.
///
/// Mesuré sur `.75` le 2026-09-05 à 09:26, après un redémarrage à 09:23 : la box publiait
/// **100 % d'autoconsommation** — vrai sur trois minutes, absurde intitulé « aujourd'hui ».
/// D'où la règle : on les garde comme instantanés, et on **dit leur origine** quand elle
/// diffère du début de journée. Quand les deux coïncident — le cas normal — on n'ajoute
/// rien : une phrase permanente cesse d'être lue, exactement comme un avertissement
/// permanent.
final EnergyRatiosSnapshot? ratios;
const KpiRow(
{super.key, required this.data, this.balance, this.ratios});
@override
Widget build(BuildContext context) {
final b = balance;
// Identité fausse = compteur cassé ou remis à zéro dans la fenêtre. On garde les
// chiffres — ils restent la seule chose que la box ait dite — mais la carte le
// signale, plutôt que de présenter comme une mesure ce qui ne se recoupe pas.
final suspect = b != null && !b.identityHolds;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Le mot « aujourd'hui » est dit UNE fois, au-dessus des quatre tuiles.
//
// Répété dans chaque libellé, il les faisait toutes déborder — constaté à la
// capture d'appareil : « Autoconsomm… », « Vers réseau · aujou… ». Un suffixe qui
// mange le nom de la grandeur coûte plus qu'il n'apporte, alors qu'un en-tête
// porte la même information pour les quatre.
Padding(
padding: const EdgeInsets.only(left: 2, bottom: 8),
child: Row(children: [
Icon(Icons.today_rounded, size: 14, color: EtmTokens.mutedOf(context)),
const SizedBox(width: 6),
Text('Aujourd\'hui',
style: EtmTokens.sans(
size: 12,
weight: FontWeight.w600,
color: EtmTokens.mutedOf(context))),
if (b != null && b.startsLate) ...[
const SizedBox(width: 6),
Text('· depuis ${_hhmm(b.from)}',
style: EtmTokens.sans(
size: 11, color: EtmTokens.mutedOf(context))),
],
]),
),
Row(
children: [
Expanded(
child: _RingTile(
label: 'Autoconsommation',
value: b?.selfConsumptionRate,
motifAbsence: _motifAbsence(b, production: true),
color: EtmTokens.solarOf(context),
),
),
const SizedBox(width: 10),
Expanded(
child: _RingTile(
label: 'Autonomie',
value: b?.autonomyRate,
motifAbsence: _motifAbsence(b, production: false),
color: EtmTokens.ecoOf(context),
),
),
],
),
if (_ligneUtile(ratios, b)) ...[
const SizedBox(height: 8),
Padding(
padding: const EdgeInsets.only(left: 2),
child: Row(children: [
Icon(Icons.bolt_rounded,
size: 13, color: EtmTokens.mutedOf(context)),
const SizedBox(width: 5),
Expanded(
child: Text(
_instantane(ratios!),
style: EtmTokens.sans(
size: 11, color: EtmTokens.mutedOf(context)),
),
),
]),
),
],
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: _ValueTile(
label: 'Vers réseau',
value: b?.exportKwh,
icon: Icons.north_west_rounded,
color: EtmTokens.ecoOf(context),
),
),
const SizedBox(width: 10),
Expanded(
child: _ValueTile(
label: 'Depuis réseau',
value: b?.importKwh,
icon: Icons.south_east_rounded,
color: EtmTokens.importColor,
),
),
],
),
if (b != null && b.startsLate) ...[
const SizedBox(height: 8),
_Mention(
icon: Icons.schedule_rounded,
texte: 'Journée partielle : la box ne publie rien avant '
'${_hhmm(b.from)}. Le bilan part de cette heure, pas de minuit.',
),
],
if (suspect) ...[
const SizedBox(height: 8),
_Mention(
icon: Icons.rule_rounded,
alerte: true,
texte: 'Les compteurs ne se recoupent pas : production + soutiré devrait '
'égaler consommation + injecté. Un compteur a probablement été remis à '
'zéro dans la journée.',
),
],
],
);
}
static String _hhmm(DateTime t) =>
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
}
// ── Ring tile — horizontal (ring left, text right) ────────────────────────────
class _RingTile extends StatefulWidget {
final String label;
/// `null` = **pas de nombre**, et surtout pas 0 %. Un anneau vide et un anneau à zéro se
/// ressemblent trop pour qu'on laisse le second dire le premier.
final double? value;
/// Ce qui s'écrit à la place, quand [value] est `null` — voir [_motifAbsence].
final String? motifAbsence;
final Color color;
const _RingTile(
{required this.label,
required this.value,
required this.color,
this.motifAbsence});
@override
State<_RingTile> createState() => _RingTileState();
}
class _RingTileState extends State<_RingTile>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
late Animation<double> _ring;
late Animation<int> _count;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 1100));
_armer();
_ctrl.forward();
}
void _armer() {
final v = widget.value ?? 0;
_ring = Tween<double>(begin: 0, end: v / 100)
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutCubic));
_count = IntTween(begin: 0, end: v.round())
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutCubic));
}
// Le bilan arrive APRÈS la première image — il faut un aller-retour RPC. Sans ce
// rejeu, l'anneau resterait figé sur la valeur qu'il avait au montage, c'est-à-dire
// aucune, et le tableau de bord paraîtrait vide sur une installation qui produit.
@override
void didUpdateWidget(covariant _RingTile old) {
super.didUpdateWidget(old);
if (old.value != widget.value) {
_armer();
_ctrl
..reset()
..forward();
}
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
boxShadow: EtmTokens.cardShadowOf(context),
border: Border.all(color: EtmTokens.lineOf(context), width: 1),
),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
child: Row(
children: [
// Ring — 50×50, strokeWidth 5 (matches HTML .ring SVG)
SizedBox(
width: 50, height: 50,
child: AnimatedBuilder(
animation: _ring,
builder: (context, _) => CustomPaint(
size: const Size(50, 50),
painter: RingPainter(
value: _ring.value,
color: widget.color,
strokeWidth: 5,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.value == null)
Text('—',
style: EtmTokens.mono(
size: 18,
weight: FontWeight.w600,
color: EtmTokens.mutedOf(context),
))
else
AnimatedBuilder(
animation: _count,
builder: (context, _) => Text(
'${_count.value} %',
style: EtmTokens.mono(
size: 18,
weight: FontWeight.w600,
color: EtmTokens.inkOf(context),
),
),
),
const SizedBox(height: 3),
// ⚠️ **La coupure se fait AU MILIEU du mot quand le texte est agrandi.**
// Vu sur appareil le 2026-09-05, réglage de police système au-dessus de
// 100 % : « Autoconsommat / ion ». `maxLines: 2` ne protège de rien —
// Flutter coupe un mot long sans espace plutôt que de déborder, et
// l'ellipse ne s'affiche même pas. Le défaut n'est donc pas la longueur du
// libellé mais la mise à l'échelle, et il reviendra sur n'importe quel mot
// à la première langue plus verbeuse.
//
// `scaleDown` garde le mot ENTIER en réduisant ce qu'il faut, et ne touche
// à rien quand la place suffit — donc aucun effet à échelle normale.
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
widget.value == null
? '${widget.label} · ${widget.motifAbsence ?? 'pas mesurable'}'
: widget.label,
style: EtmTokens.sans(
size: 11,
color: EtmTokens.mutedOf(context),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
],
),
),
],
),
);
}
}
// ── Value tile — horizontal (icon left, text right) ───────────────────────────
class _ValueTile extends StatelessWidget {
final String label;
/// Valeur en kWh, ou `null` si la box en publie une **inexploitable**.
///
/// Le cas n'est pas théorique : `.75` publie 9,1e+33 kWh exportés. Imprimer ce nombre
/// le présente comme une mesure ; l'effacer laisse croire à zéro. On affiche donc « — »
/// et on dit d'où vient le trou, ce qui est la seule lecture honnête des trois.
final double? value;
final IconData icon;
final Color color;
const _ValueTile({
required this.label,
required this.value,
required this.icon,
required this.color,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
boxShadow: EtmTokens.cardShadowOf(context),
border: Border.all(color: EtmTokens.lineOf(context), width: 1),
),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
child: Row(
children: [
Container(
width: 30, height: 30,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(9),
),
child: Icon(icon, color: color, size: 16),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
value == null ? '—' : '${value!.toStringAsFixed(1)} kWh',
style: EtmTokens.mono(
size: 16,
weight: FontWeight.w600,
color: value == null
? EtmTokens.mutedOf(context)
: EtmTokens.inkOf(context),
),
),
const SizedBox(height: 3),
Text(
value == null ? '$label · pas mesurable' : label,
style: EtmTokens.sans(
size: 10.5,
color: EtmTokens.mutedOf(context),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
);
}
}
/// Ligne d'explication sous les tuiles. Elle n'apparaît que quand il y a quelque chose à
/// dire — une journée partielle, ou des compteurs qui ne se recoupent pas.
class _Mention extends StatelessWidget {
final IconData icon;
final String texte;
final bool alerte;
const _Mention({required this.icon, required this.texte, this.alerte = false});
@override
Widget build(BuildContext context) {
final couleur =
alerte ? EtmTokens.importColor : EtmTokens.mutedOf(context);
return Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Icon(icon, size: 14, color: couleur),
const SizedBox(width: 7),
Expanded(
child: Text(texte,
style: EtmTokens.sans(size: 10.5, color: couleur)),
),
]);
}
}