Passage sur le Redmi contre `.75`. Aucun des trois n'était visible en test. 1. LA PRODUCTION S'AFFICHAIT NÉGATIVE — « -8.9 kW · Solaire » sous 9 kW de soleil. `currentPowerProduction` est publié négatif par nymea et le service garde les signes bruts par politique ; la carte imprimait la valeur telle quelle. Ce qui rend le défaut net plutôt qu'opinable : la même carte normalisait DÉJÀ le réseau et la batterie par `.abs()` — la branche solaire était la seule oubliée, donc un oubli et non une convention. 2. LE LIBELLÉ SE COUPAIT AU MILIEU DU MOT — « Autoconsommat / ion », alors que le commentaire du code disait vouloir l'éviter. La cause n'est pas la longueur mais l'ÉCHELLE du texte : police système au-dessus de 100 %, et `maxLines: 2` ne protège de rien puisque Flutter coupe un mot long sans espace, sans même afficher l'ellipse. `FittedBox(scaleDown)` garde le mot entier et ne fait rien quand la place suffit. 3. MA LIGNE DES RATIOS RÉPÉTAIT LES TUILES — « Instantané depuis 09:23 — autoconsommation 52 %, autonomie 54 % » sous des tuiles affichant 52 % et 54 %. Les deux chemins étaient corrects et les valeurs coïncidaient vraiment (bilan 51,8/54,1 sur 00:00→16:45, box 51,9/53,7 depuis 09:23). Rien de faux, et illisible quand même : je déclenchais sur l'ORIGINE, alors qu'un lecteur compare des NOMBRES. La ligne sort désormais quand l'origine diffère ET que l'écart dépasse 2 points ; le relevé du jour est devenu un test. Vérifié à l'écran après correction : le libellé tient sur une ligne, la ligne des ratios se tait. Le signe, lui, N'A PAS pu être revu — la production du banc était retombée à 0 W à 17:03, et `abs(0) == 0`. Une correction qu'on ne peut pas voir demande une garde qui se voit : `flux_signe_test.dart` la pose. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
454 lines
17 KiB
Dart
454 lines
17 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)}';
|
||
}
|
||
|
||
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,
|
||
color: EtmTokens.solarOf(context),
|
||
),
|
||
),
|
||
const SizedBox(width: 10),
|
||
Expanded(
|
||
child: _RingTile(
|
||
label: 'Autonomie',
|
||
value: b?.autonomyRate,
|
||
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` = **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),
|
||
// ⚠️ **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} · 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)),
|
||
),
|
||
]);
|
||
}
|
||
}
|