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>
552 lines
20 KiB
Dart
552 lines
20 KiB
Dart
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),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|