import 'package:flutter/material.dart'; import '../../../models/energy_data.dart'; import '../../../theme/etm_tokens.dart'; import '../painters/ring_painter.dart'; class KpiRow extends StatelessWidget { final EnergyData data; const KpiRow({super.key, required this.data}); @override Widget build(BuildContext context) { final autoconso = data.selfConsumptionRate.clamp(0.0, 100.0); final autonomy = data.autonomyRate.clamp(0.0, 100.0); // Deux compteurs DISTINCTS de la box, et non un seul et son opposé. // // Ces deux tuiles lisaient toutes les deux `dayGridInjectionWh` — l'export — la // seconde en prenant son opposé. Or l'import a son propre compteur // (`totalAcquisition`) : dès que l'export était positif, « Depuis réseau » affichait // 0,0 kWh en permanence, quelle que soit l'électricité réellement achetée. // // `null` = la box publie une valeur inexploitable ; la tuile le dit au lieu // d'imprimer un nombre absurde (constaté sur `.75` : 9,1e+33 kWh exportés, alors que // `totalProduction` restait sain — les compteurs cumulés de l'experience-plugin // énergie sont corrompus, pas ceux du Thing compteur). final toGrid = data.gridExportKwhOrNull; final fromGrid = data.gridImportKwhOrNull; return Column( children: [ Row( children: [ Expanded( child: _RingTile( label: 'Autoconsommation', value: autoconso, color: EtmTokens.solarOf(context), ), ), const SizedBox(width: 10), Expanded( child: _RingTile( label: 'Autonomie', value: autonomy, color: EtmTokens.ecoOf(context), ), ), ], ), const SizedBox(height: 10), Row( children: [ Expanded( child: _ValueTile( // « aujourd'hui » était faux : `totalReturn` est cumulé depuis // l'origine et n'est pas remis à zéro chaque nuit. label: 'Vers réseau · cumulé', value: toGrid, icon: Icons.north_west_rounded, color: EtmTokens.ecoOf(context), ), ), const SizedBox(width: 10), Expanded( child: _ValueTile( label: 'Depuis réseau · cumulé', value: fromGrid, icon: Icons.south_east_rounded, color: EtmTokens.importColor, ), ), ], ), ], ); } } // ── Ring tile — horizontal (ring left, text right) ──────────────────────────── class _RingTile extends StatefulWidget { final String label; 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)); _ring = Tween(begin: 0, end: widget.value / 100) .animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutCubic)); _count = IntTween(begin: 0, end: widget.value.round()) .animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutCubic)); _ctrl.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: [ 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.label, style: EtmTokens.sans( size: 11, color: EtmTokens.mutedOf(context), ), ), ], ), ), ], ), ); } } // ── 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 · compteur box invalide' : label, style: EtmTokens.sans( size: 10.5, color: EtmTokens.mutedOf(context), ), overflow: TextOverflow.ellipsis, ), ], ), ), ], ), ); } }