L'entrée 🔴 du TODO. Aucune des quatre tuiles n'était journalière : deux pourcentages instantanés, deux compteurs cumulés depuis l'origine dont deux portaient le mot « aujourd'hui ». Elles montrent désormais la journée, sous un en-tête qui le dit une fois. MÉTHODE : une DIFFÉRENCE de compteurs entre minuit local et maintenant, jamais une intégration des puissances. La première est une mesure, prise sur les mêmes compteurs que ceux du fournisseur ; la seconde est une estimation qui suppose que chaque échantillon représente son intervalle. Mesuré sur .75 : l'intégration rend 77,70 kWh là où la différence rend 78,31 — 0,8 % d'écart, systématiquement à la baisse. Il n'y a donc PAS de repli silencieux de l'une vers l'autre : quand la mesure n'est pas exploitable, le type le dit. DEUX BUGS D'UNITÉ, trouvés en cherchant à savoir ce que valaient les compteurs. Aucune documentation ne tranchait, donc recoupement sur machine : l'intégration des puissances (en W) rend 77 697 Wh quand la différence de totalProduction rend 78,310. Le rapport est mille — LES COMPTEURS SONT EN kWh, et le modèle Dart les nommait Wh. 1. L'écran Énergie divisait ces kWh par mille pour les afficher : une journée de 78 kWh s'y lisait « 78 Wh », sous un axe intitulé « Bilan énergétique (Wh) ». Invisible sans connaître l'ordre de grandeur attendu. 2. La simulation accumulait des Wh : un écran juste en simulation aurait cassé d'un facteur mille contre une vraie box. LE CONTRÔLE QUI VALIDE LE BILAN : production + soutiré == consommation + injecté. C'est le seul recoupement disponible sur des compteurs qu'on ne peut pas vérifier autrement, et il tient EXACTEMENT sur .75 — 112,244 des deux côtés sur 21,75 h. Faux, il désigne un compteur cassé ou une remise à zéro, et la carte le signale au lieu de présenter comme une mesure ce qui ne se recoupe pas. Les trois pièges du TODO n'ont pas été repayés : bornes à minuit LOCAL via GetPowerBalanceLogs · décodage délégué à fetchPowerBalanceLogs, qui porte déjà les bonnes clés sans préfixe currentPower · plausibilité vérifiée sur les DEUX bornes avant de soustraire, parce que deux valeurs absurdes peuvent avoir une différence d'apparence saine. Plus un quatrième trouvé en chemin : un compteur qui RECULE (redémarrage en milieu de fenêtre) donne un bilan négatif, et un max(0, …) le rendrait faux en silence. Rien ne s'affiche jamais à zéro quand c'est inconnu. Sans production, le taux d'autoconsommation est null et non 0 % — à 3 h du matin, « 0 % » se lirait comme un défaut alors qu'il n'y a rien à autoconsommer. VÉRIFIÉ SUR APPAREIL contre .75, en lecture seule (le banc est en préparation V2C, rien n'a été écrit) : 32 échantillons depuis minuit, prod 28,37 · conso 26,55 · import 11,95 · export 13,77 kWh, identité OK, et les tuiles affichent 51 % / 55 % / 13,8 / 11,9. Le passage sur appareil a trouvé ce qu'aucun des 138 tests unitaires ne pouvait voir : LES QUATRE LIBELLÉS DÉBORDAIENT — « Autoconsomm… », « Vers réseau · aujou… ». Le suffixe « aujourd'hui » répété quatre fois mangeait le nom de la grandeur ; il est dit une fois en en-tête. Le harnais d'appareil échoue désormais sur tout libellé finissant par « … ». Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
366 lines
13 KiB
Dart
366 lines
13 KiB
Dart
import 'package:flutter/material.dart';
|
||
import '../../../models/daily_balance.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.
|
||
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;
|
||
|
||
const KpiRow({super.key, required this.data, this.balance});
|
||
|
||
@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,
|
||
color: EtmTokens.solarOf(context),
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: _RingTile(
|
||
label: 'Autonomie',
|
||
value: b?.autonomyRate,
|
||
color: EtmTokens.ecoOf(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` = **non mesurable**, 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;
|
||
final Color color;
|
||
const _RingTile({required this.label, required this.value, required this.color});
|
||
|
||
@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),
|
||
Text(
|
||
widget.value == null
|
||
? '${widget.label} · pas mesurable'
|
||
: widget.label,
|
||
style: EtmTokens.sans(
|
||
size: 11,
|
||
color: EtmTokens.mutedOf(context),
|
||
),
|
||
// Deux lignes plutôt qu'un mot coupé : « Autoconsomm… » ne se lit pas,
|
||
// et la tuile a la hauteur pour le porter entier.
|
||
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)),
|
||
),
|
||
]);
|
||
}
|
||
}
|