Les tuiles disent la JOURNÉE, par différence de compteurs sur la fenêtre publiée. La nouvelle ligne dit ce que la BOX publie, qui part de la baseline — et elle ne s'affiche QUE lorsque cette origine diffère du début de journée. Le cas est arrivé de lui-même : `nymead` redémarré à 09:23, sonde à 09:26, et la box publiait 100 % d'autoconsommation. Vrai sur trois minutes ; absurde intitulé « aujourd'hui ». La ligne le rend lisible — « Instantané depuis 09:23 » — au lieu de le laisser passer pour la journée. Vérifié avec le MODÈLE de l'app et non à l'œil sur le JSON : `ratios_check.dart` importe `EnergyRatiosSnapshot` (idiome de `measurement_check.dart`) et rend `baselineRepriseEnJournee: true`, ratios couvrant 3 minutes. Le banc est passé en Europe/Paris entre-temps, et `periodStartedAt` suit sans que le code bouge — c'est l'intérêt d'une frontière publiée. Quand les deux coïncident, rien n'est ajouté : une phrase permanente cesse d'être lue, comme un avertissement permanent. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
414 lines
15 KiB
Dart
414 lines
15 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 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 (ratios?.baselineRepriseEnJournee ?? false) ...[
|
||
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),
|
||
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)),
|
||
),
|
||
]);
|
||
}
|
||
}
|