etm-powersync-app/lib/widgets/pv_forecast_card.dart
Patrick Schurig ae454b703e feat(prévision): consommation de base prévue, bande apprise en Wh (p10Wh/p90Wh)
Consigne app §H.4 et §G (2026-10-16), contre INTERFACE.md local de la
branche moteur — etm70 pas encore sur le banc.

- baseload : lu sur la grille start + i·resolutionS, W moyens → Wh par
  créneau. Sous model « none » (forme réelle du banc etm69 : p50W tout
  null) rien n'est tracé ni légendé — une absence, jamais 0 ; une case
  null reste absente. Carte « Journée » : heures à venir, colonne
  consommation en contour bleu « Consommation de base prévue ». Premier
  profil attendu vers le 25 octobre.
- Bande apprise : forme fixée par la consigne G, p10Wh / p90Wh en Wh par
  point de forecastPoints (l'ancienne lecture p10W/p90W est retirée et
  testée comme telle). Libellé client « Fourchette apprise (8 cas sur
  10) ». Toujours inactive : aucune box ne la publie.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:31:11 +02:00

552 lines
20 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:math';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import '../models/pv_forecast.dart';
import '../theme/etm_tokens.dart';
/// Rendu pur de la prévision — tout ce qu'il affiche lui est donné.
class PvForecastView extends StatelessWidget {
final bool chargement;
final PvForecastSnapshot? snapshot;
/// Non nul = la prévision n'a pas été LUE. Ce n'est pas « rien de publié ».
final String? erreur;
final PvForecastSite? site;
/// Le graphe de la courbe — fourni par l'écran, qui seul connaît son axe du temps.
/// L'écran Énergie y met sa fenêtre glissante, l'accueil ses barres de la journée.
final Widget Function(BuildContext context, PvForecastSnapshot snap) graphe;
final double hauteurGraphe;
/// Vrai si le graphe superpose une production mesurée — pour la légende seulement.
final bool avecMesure;
final String titre;
/// Posé sous la légende (la plage visible de l'écran Énergie, par exemple).
final Widget? sousLegende;
/// La bande tracée par le graphe, s'il y en a une — pour la légende et les notes.
final BandePrevision? bande;
final String Function(String thingId) nomDuThing;
final String? refusDesignation;
final Future<void> Function(String siteId)? onDesigner;
final Future<void> Function()? onRelire;
const PvForecastView({
super.key,
required this.chargement,
required this.snapshot,
this.erreur,
this.site,
required this.graphe,
this.hauteurGraphe = 200,
this.avecMesure = false,
this.titre = 'Prévision PV',
this.sousLegende,
this.bande,
required this.nomDuThing,
this.refusDesignation,
this.onDesigner,
this.onRelire,
});
@override
Widget build(BuildContext context) {
final muted = EtmTokens.mutedOf(context);
final snap = snapshot;
final pas = snap?.pas;
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusLg),
boxShadow: EtmTokens.cardShadow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(titre,
style: EtmTokens.sans(size: 16, weight: FontWeight.w600, color: EtmTokens.inkOf(context))),
if (snap?.etat == PvForecastEtat.courbe && pas != null)
Text('par créneau de ${_duree(pas)}',
style: EtmTokens.sans(size: 12, color: muted)),
],
),
const SizedBox(height: 12),
..._corps(context),
],
),
);
}
List<Widget> _corps(BuildContext context) {
if (chargement) {
return const [
SizedBox(
height: 60,
child: Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2)))),
];
}
final snap = snapshot;
if (snap == null) {
if (erreur == 'NoSuchMethod') {
return [
_Encadre(
icone: Icons.cloud_off_outlined,
titre: 'Cette box ne publie pas de prévision PV',
texte: 'Le moteur installé ne connaît pas encore '
'« NymeaEnergy.GetForecastSnapshot ». La carte s\'activera après '
'mise à jour.'),
];
}
if (erreur == 'Simulation') {
return [
_Encadre(
icone: Icons.science_outlined,
titre: 'Pas de prévision en simulation',
texte: 'La prévision vient de la box ; aucune n\'est inventée ici.'),
];
}
return [
_Encadre(
icone: Icons.sync_problem_rounded,
titre: 'Prévision non lue',
texte: 'La box n\'a pas répondu (${erreur ?? 'raison inconnue'}). '
'Ce n\'est pas une absence de prévision : elle n\'a pas pu être lue.',
action: onRelire == null
? null
: TextButton(onPressed: onRelire, child: const Text('Relire'))),
];
}
switch (snap.etat) {
case PvForecastEtat.siteManquant:
return _siteManquant(context);
case PvForecastEtat.chargeRefusee:
final cause = snap.faultParams['cause'];
return [
_Encadre(
icone: Icons.report_gmailerrorred_rounded,
titre: 'Prévision refusée par la box',
texte: 'Le producteur météo publie une prévision que le moteur ne sait '
'pas lire — c\'est lui qu\'il faut regarder, pas ce réglage.'
'${cause != null ? '\nCause : $cause' : ''}',
code: 'FORECAST_PAYLOAD_REFUSED'),
];
case PvForecastEtat.faultInconnu:
return [
_Encadre(
icone: Icons.help_outline,
titre: 'Pas de courbe : défaut publié par la box',
texte: 'Ce défaut est inconnu de cette version de l\'app.'
'${snap.faultParams.isNotEmpty ? '\n${snap.faultParams}' : ''}',
code: snap.fault),
];
case PvForecastEtat.rienPublie:
return [
_Encadre(
icone: Icons.cloud_queue_rounded,
titre: 'Aucune prévision publiée',
texte: 'Pas de plugin météo sur cette box, ou ses données ne sont pas '
'encore arrivées. La box ne distingue pas les deux cas.'),
];
case PvForecastEtat.courbe:
return _courbe(context, snap);
}
}
List<Widget> _siteManquant(BuildContext context) {
final candidats = site?.candidats ?? const <String>[];
return [
_Encadre(
icone: Icons.place_outlined,
titre: 'Aucun site de prévision désigné',
texte: candidats.isEmpty
? 'Des champs PV publient une prévision, mais aucun site n\'est '
'désigné et la liste des sites possibles n\'a pas pu être lue.'
: 'Des champs PV publient une prévision. Choisissez le site dont la '
'production doit être prévue — tous ses champs seront additionnés.',
code: 'MISSING_FORECAST_SITE'),
for (final c in candidats)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Row(children: [
const Icon(Icons.solar_power_outlined, size: 18),
const SizedBox(width: 8),
Expanded(
child: Text(nomDuThing(c),
style: EtmTokens.sans(size: 13, weight: FontWeight.w500, color: EtmTokens.inkOf(context)))),
TextButton(
onPressed: onDesigner == null ? null : () => _confirmer(context, c),
child: const Text('Désigner'),
),
]),
),
if (refusDesignation != null)
Padding(
padding: const EdgeInsets.only(top: 8),
child: Text('La box a refusé la désignation : $refusDesignation',
style: EtmTokens.sans(size: 12, color: EtmTokens.danger)),
),
];
}
Future<void> _confirmer(BuildContext context, String siteId) async {
final ok = await showDialog<bool>(
context: context,
builder: (ctx) => AlertDialog(
title: const Text('Désigner ce site ?'),
content: Text('La prévision de « ${nomDuThing(siteId)} » sera gardée et '
'utilisée par le moteur. Le choix est enregistré sur la box.'),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx, false),
child: const Text('Annuler')),
FilledButton(
onPressed: () => Navigator.pop(ctx, true),
child: const Text('Désigner')),
],
),
);
if (ok == true) await onDesigner?.call(siteId);
}
List<Widget> _courbe(BuildContext context, PvForecastSnapshot snap) {
// Aucun texte sous le graphe (Patrick, 2026-10-15) : ce qu'il faut savoir tient dans
// la légende — « plafond optimiste », « non datée ».
return [
Wrap(spacing: 14, runSpacing: 4, children: [
if (avecMesure)
_Legende(EtmTokens.selfUseOf(context), 'Production mesurée', plein: true),
_Legende(EtmTokens.solarOf(context), 'Prévision (plafond optimiste)',
pointille: true),
if (bande != null)
_Legende(
EtmTokens.solarOf(context).withValues(alpha: 0.30),
plein: true,
bande!.source == SourceBande.p10p90
// Libellé client fixé par la consigne G ; « p10–p90 » réservé à
// l'installateur.
? 'Fourchette apprise (8 cas sur 10)'
: 'Fourchette météo (dispersion des modèles)'
'${bande!.attenuee ? ' · non datée' : ''}'),
]),
if (sousLegende != null) ...[const SizedBox(height: 6), sousLegende!],
const SizedBox(height: 10),
SizedBox(height: hauteurGraphe, child: graphe(context, snap)),
];
}
static String _duree(Duration d) => d.inMinutes % 60 == 0
? '${d.inHours} h'
: '${d.inMinutes} min';
}
class _Encadre extends StatelessWidget {
final IconData icone;
final String titre;
final String texte;
final String? code;
final Widget? action;
const _Encadre({
required this.icone,
required this.titre,
required this.texte,
this.code,
this.action,
});
@override
Widget build(BuildContext context) {
final muted = EtmTokens.mutedOf(context);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: EtmTokens.bgOf(context),
borderRadius: BorderRadius.circular(12),
),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Icon(icone, color: EtmTokens.faintOf(context), size: 26),
const SizedBox(width: 12),
Expanded(
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(titre,
style: EtmTokens.sans(size: 13, weight: FontWeight.w600, color: muted)),
const SizedBox(height: 4),
Text(texte,
style: EtmTokens.sans(size: 11.5, color: muted).copyWith(height: 1.35)),
// Le code brut rend le rapport de terrain actionnable.
if (code != null) ...[
const SizedBox(height: 4),
Text(code!, style: EtmTokens.mono(size: 10.5, color: EtmTokens.faintOf(context))),
],
?action,
]),
),
]),
);
}
}
class _Legende extends StatelessWidget {
final Color couleur;
final String texte;
/// `pointille` : un trait (la prévision) ; `plein` : un pavé (une surface mesurée).
final bool pointille;
final bool plein;
const _Legende(this.couleur, this.texte, {this.pointille = false, this.plein = false});
@override
Widget build(BuildContext context) => Row(mainAxisSize: MainAxisSize.min, children: [
if (pointille)
Row(mainAxisSize: MainAxisSize.min, children: [
Container(width: 5, height: 2, color: couleur),
const SizedBox(width: 2),
Container(width: 5, height: 2, color: couleur),
])
else
Container(
width: 14,
height: plein ? 10 : 3,
decoration: BoxDecoration(
color: couleur, borderRadius: BorderRadius.circular(2))),
const SizedBox(width: 5),
Text(texte, style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))),
]);
}
/// **La courbe de prévision sur un axe du temps fourni par l'écran.**
///
/// L'axe n'est pas calculé ici : l'écran Énergie numérote ses échantillons et fait glisser
/// une fenêtre sur ces numéros. Pour que la prévision tombe sous les mêmes heures que les
/// graphes du dessus, elle doit utiliser **leur** numérotation — d'où [x], [minX], [maxX]
/// et [etiquette], tous donnés par l'appelant.
class PvForecastLineChart extends StatelessWidget {
final PvForecastSnapshot snapshot;
/// Production mesurée, déjà placée sur l'axe : `(x, W)`, signe déjà retourné.
final List<({double x, double w})> mesure;
/// Instant → abscisse, dans la numérotation de l'écran.
final double Function(DateTime t) x;
final double minX;
final double maxX;
/// Abscisse → étiquette d'axe (`null` = pas d'étiquette ici).
final String? Function(double x) etiquette;
final double xInterval;
/// Abscisse de « maintenant », pour le trait vertical. `null` = hors cadre.
final double? xMaintenant;
/// Écart d'abscisse au-delà duquel deux mesures consécutives ne se relient pas.
final double trouMesure;
/// Bande autour de la prévision (fourchette météo ou p10–p90), ou `null`.
final BandePrevision? bande;
const PvForecastLineChart({
super.key,
required this.snapshot,
this.mesure = const [],
required this.x,
required this.minX,
required this.maxX,
required this.etiquette,
required this.xInterval,
this.xMaintenant,
this.trouMesure = 2.1,
this.bande,
});
@override
Widget build(BuildContext context) {
final pas = snapshot.pas;
// L'énergie d'un créneau devient une puissance MOYENNE sur ce créneau : seule unité
// commune avec la production mesurée. Simple changement d'unité, pas un calcul de
// prévision. Sans pas connu (un point seul), on ne sait pas sur quoi diviser.
final prev = <FlSpot>[];
if (pas != null) {
final h = pas.inSeconds / 3600.0;
for (final seg in snapshot.segments) {
if (prev.isNotEmpty) prev.add(FlSpot.nullSpot); // un trou, jamais un zéro
for (final p in seg) {
final kw = p.wh / h / 1000;
prev
..add(FlSpot(x(p.debutUtc), kw))
..add(FlSpot(x(p.debutUtc.add(pas)), kw));
}
}
}
// La bande, segment par segment : un créneau sans bande coupe le segment. Chaque
// segment est une paire de lignes invisibles (haut, bas) remplie entre elles — sans
// point nul, qu'un remplissage entre deux lignes enjamberait.
final bandes = <(List<FlSpot>, List<FlSpot>)>[];
final b = bande;
if (b != null && pas != null) {
final h = pas.inSeconds / 3600.0;
DateTime? precedent;
for (final p in snapshot.points) {
final v = b.parCreneau[p.debutUtc];
if (v == null) {
precedent = null;
continue;
}
if (precedent == null || p.debutUtc.difference(precedent) != pas) {
bandes.add((<FlSpot>[], <FlSpot>[]));
}
final x0 = x(p.debutUtc), x1 = x(p.debutUtc.add(pas));
bandes.last.$1
..add(FlSpot(x0, v.hautWh / h / 1000))
..add(FlSpot(x1, v.hautWh / h / 1000));
bandes.last.$2
..add(FlSpot(x0, v.basWh / h / 1000))
..add(FlSpot(x1, v.basWh / h / 1000));
precedent = p.debutUtc;
}
}
final mes = <FlSpot>[];
double? avant;
for (final m in mesure) {
// Un trou de mesure reste un trou.
if (avant != null && m.x - avant > trouMesure) mes.add(FlSpot.nullSpot);
mes.add(FlSpot(m.x, m.w / 1000));
avant = m.x;
}
// L'échelle suit ce qui est VISIBLE, comme les graphes du dessus.
final valeurs = [
for (final s in [
...prev,
...mes,
for (final (haut, _) in bandes) ...haut,
])
if (s != FlSpot.nullSpot && s.x >= minX - 1 && s.x <= maxX + 1) s.y
];
final yMax = max(0.5, (valeurs.isEmpty ? 0.0 : valeurs.reduce(max)) * 1.15);
final yMin = min(0.0, valeurs.isEmpty ? 0.0 : valeurs.reduce(min));
final label = EtmTokens.sans(size: 9, color: EtmTokens.mutedOf(context));
final solaire = EtmTokens.solarOf(context);
// Le solaire est JAUNE (Patrick, 2026-10-15) : la mesure en doré plein, la prévision
// en jaune translucide derrière.
final mesure_ = EtmTokens.selfUseOf(context);
final xNow = xMaintenant;
// Atténuée tant que la valeur n'est pas datée : visible, mais en retrait.
final alphaBande = (b?.attenuee ?? false) ? 0.10 : 0.20;
LineChartBarData invisible(List<FlSpot> s) => LineChartBarData(
spots: s,
isCurved: false,
color: Colors.transparent,
barWidth: 0,
dotData: const FlDotData(show: false));
return LineChart(
LineChartData(
clipData: const FlClipData.all(),
minX: minX,
maxX: maxX,
minY: yMin,
maxY: yMax,
borderData: FlBorderData(show: false),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: yMax / 4,
getDrawingHorizontalLine: (_) =>
FlLine(color: EtmTokens.lineOf(context), strokeWidth: 1),
),
extraLinesData: ExtraLinesData(verticalLines: [
if (xNow != null && xNow > minX && xNow < maxX)
VerticalLine(
x: xNow,
color: EtmTokens.mutedOf(context),
strokeWidth: 1,
dashArray: const [3, 3]),
]),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
// Même réserve que les graphes du dessus : sinon le zéro de l'axe X ne
// tombe pas sous le leur, et les heures se décalent d'un graphe à l'autre.
reservedSize: 46,
interval: yMax / 4,
getTitlesWidget: (v, meta) => v == meta.max
? const SizedBox.shrink()
: SideTitleWidget(
meta: meta,
child: Text('${v.toStringAsFixed(1)}kW', style: label)),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 22,
interval: xInterval,
getTitlesWidget: (v, meta) {
if (v <= meta.min || v >= meta.max) return const SizedBox.shrink();
final t = etiquette(v);
return t == null
? const SizedBox.shrink()
: SideTitleWidget(meta: meta, child: Text(t, style: label));
},
),
),
),
lineTouchData: const LineTouchData(enabled: false),
betweenBarsData: [
for (var i = 0; i < bandes.length; i++)
BetweenBarsData(
fromIndex: 2 * i,
toIndex: 2 * i + 1,
color: solaire.withValues(alpha: alphaBande)),
],
lineBarsData: [
for (final (haut, bas) in bandes) ...[invisible(haut), invisible(bas)],
// Plein = réel, contour = prévu (Patrick, 2026-10-15 : deux aplats de jaune ne se
// distinguaient pas). Le mesuré est une SURFACE dorée, la prévision un TRAIT
// pointillé sans remplissage, tracé par-dessus pour rester visible sur la surface.
if (mes.isNotEmpty)
LineChartBarData(
spots: mes,
isCurved: false,
color: mesure_,
barWidth: 1.4,
dotData: const FlDotData(show: false),
belowBarData:
BarAreaData(show: true, color: mesure_.withValues(alpha: 0.55)),
),
LineChartBarData(
spots: prev,
isCurved: false,
color: solaire,
barWidth: 1.8,
dashArray: const [5, 3],
dotData: FlDotData(show: pas == null),
),
],
),
);
}
}