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 _ring; late Animation _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(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)), ), ]); } }