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
487 lines
19 KiB
Dart
487 lines
19 KiB
Dart
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)),
|
||
),
|
||
]);
|
||
}
|
||
}
|