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 _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), 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)), ), ]); } }