Patrick Schurig 28c4196b9b feat(accueil): carte « Journée » — consommation par consommateur et solaire
Variante E de la maquette, retenue par Patrick le 2026-10-15 (« deux
colonnes à la Victron ») ; remplace la carte « Production du jour ».

- Par heure, deux colonnes : à gauche la consommation, Maison (bleu) puis
  chaque consommateur déclaré avec compteur (couleur de son domaine, même
  règle que l'écran Énergie) ; à droite le solaire, autoconsommé (doré)
  puis injecté (pâle), la prévision en fond pour comparer. Heures à venir :
  la prévision seule, en contour — aucun plan n'est publié par la box.
- Échantillon horaire horodaté T = heure qui commence à T − 1 h, pour le
  bilan comme pour les Things.
- Météo de maintenant lue sur le Thing site openmeteo, et seulement si
  updateTime a été écrit : les défauts du plugin (clear-day, 0 °C)
  afficheraient sinon un soleil à 0 °C. Les icônes horaires attendent
  openmeteo etm16 (consignes/CONSIGNE-openmeteo-meteo-horaire.md).
- Charges sans compteur déclaré nommées sous la carte (« comptée dans
  Maison »), jamais devinées.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:53:24 +02:00

505 lines
19 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 'package:provider/provider.dart';
import '../../../models/consumer_series.dart';
import '../../../models/energy_data.dart';
import '../../../models/journee.dart';
import '../../../models/pv_forecast.dart';
import '../../../providers/load_config_provider.dart';
import '../../../providers/pv_forecast_provider.dart';
import '../../../services/nymea_service.dart';
import '../../../theme/etm_tokens.dart';
/// **Carte « Journée »** de l'accueil — variante E de la maquette, retenue par Patrick le
/// 2026-10-15 : par heure, deux colonnes « à la Victron ».
///
/// - à gauche, la **consommation**, décomposée par consommateur déclaré (borne, PAC, ECS…)
/// au-dessus de la « Maison », c'est-à-dire ce que les compteurs déclarés n'expliquent pas ;
/// - à droite, le **solaire** : autoconsommé (jaune doré) puis injecté (jaune pâle) pour les
/// heures écoulées, la prévision en contour pour les heures à venir.
///
/// Code couleur de Patrick : jaune = solaire, bleu = maison, vert = batterie.
///
/// Les icônes météo HEURE PAR HEURE n'existent pas encore côté box : demandées à openmeteo
/// (`consignes/CONSIGNE-openmeteo-meteo-horaire.md`, etm16). D'ici là, seule la météo de
/// maintenant est affichée — rien n'est deviné pour les autres heures.
class JourneeCard extends StatefulWidget {
const JourneeCard({super.key});
@override
State<JourneeCard> createState() => _JourneeCardState();
}
class _JourneeCardState extends State<JourneeCard> {
List<PowerBalanceEntry> _bilan = const [];
ConsumerHistory _conso = ConsumerHistory.vide;
List<String> _sansCompteur = const [];
DateTime? _luePour;
bool _lue = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!mounted) return;
context.read<PvForecastProvider>().assurer();
final cfg = context.read<LoadConfigProvider>();
if (cfg.entries.isEmpty) {
cfg
..attach(context.read<NymeaService>())
..load();
}
});
}
/// La mesure suit la prévision : relue à chaque lecture du provider (12 min), sans second
/// minuteur — et quand la liste des charges arrive.
@override
void didChangeDependencies() {
super.didChangeDependencies();
final lue = context.watch<PvForecastProvider>().snapshot?.capturedAt;
final n = context.watch<LoadConfigProvider>().entries.length;
final cle = lue?.add(Duration(microseconds: n));
if (cle != null && cle != _luePour) {
_luePour = cle;
_lire();
}
}
Future<void> _lire() async {
final service = context.read<NymeaService>();
final cfg = context.read<LoadConfigProvider>();
final now = DateTime.now();
final debut = DateTime(now.year, now.month, now.day);
// Seules les charges qui déclarent un compteur ont une colonne : rien n'est deviné à
// partir d'un nom d'appareil. Les autres sont nommées sous la carte.
final appareils = [
for (final e in cfg.entries)
if (e.meterThingId.isNotEmpty)
(id: e.meterThingId, label: e.label, domaine: e.domain)
];
final sansCompteur = [
for (final e in cfg.entries)
if (e.meterThingId.isEmpty && e.adapter != 'evcharger') e.label
];
final r = await Future.wait<dynamic>([
service.fetchPowerBalanceLogs(
from: debut, to: now, sampleRate: 'SampleRate1Hour'),
service.fetchThingPowerLogs(
appareils: appareils, from: debut, to: now, sampleRate: 'SampleRate1Hour'),
]);
if (!mounted) return;
setState(() {
_bilan = r[0] as List<PowerBalanceEntry>;
_conso = r[1] as ConsumerHistory;
_sansCompteur = sansCompteur;
_lue = true;
});
}
@override
Widget build(BuildContext context) {
final pv = context.watch<PvForecastProvider>();
final service = context.watch<NymeaService>();
final snap = pv.snapshot?.etat == PvForecastEtat.courbe ? pv.snapshot : null;
final plans = <String>[for (final f in snap?.perField ?? const []) f.id];
final fourchette = context.select<NymeaService, FourchetteMeteo?>((s) =>
plans.isEmpty ? null : FourchetteMeteo.depuisPlans(s.plansMeteo(plans)));
final maintenant = DateTime.now();
final heures = construireJournee(
jourLocal: maintenant,
snapshot: snap,
bilanHoraire: _bilan,
consoHoraire: _conso);
final etats = service.etatsParNom('openMeteoSite');
return JourneeView(
heures: heures,
series: _conso.series,
meteo: etats == null ? null : MeteoActuelle.depuisEtats(etats),
bande: snap == null
? null
: bandePrevision(snap, fourchette, maintenant: maintenant),
etatPrevision: pv.chargement && pv.snapshot == null
? null
: pv.snapshot?.etat ?? (pv.erreur != null ? null : PvForecastEtat.rienPublie),
previsionNonLue: pv.erreur != null && pv.snapshot == null,
sansCompteur: _sansCompteur,
sansHistorique: _conso.sansHistorique,
chargement: !_lue,
);
}
}
/// Rendu pur de la carte — tout ce qu'il affiche lui est donné.
class JourneeView extends StatelessWidget {
final List<HeureJournee> heures;
final List<ConsumerSeries> series;
final MeteoActuelle? meteo;
final BandePrevision? bande;
final PvForecastEtat? etatPrevision;
final bool previsionNonLue;
final List<String> sansCompteur;
final List<String> sansHistorique;
final bool chargement;
const JourneeView({
super.key,
required this.heures,
this.series = const [],
this.meteo,
this.bande,
this.etatPrevision,
this.previsionNonLue = false,
this.sansCompteur = const [],
this.sansHistorique = const [],
this.chargement = false,
});
/// Couleur d'un consommateur : celle de son domaine, nuancée s'il y en a plusieurs —
/// la même règle que le graphe « Qui consomme » de l'écran Énergie.
static Map<String, Color> couleurs(BuildContext c, List<ConsumerSeries> series) {
final rangs = <String, int>{};
final out = <String, Color>{};
for (final s in series) {
final base = EtmTokens.domaineOf(c, s.domaine);
if (base == null) {
out[s.thingId] = EtmTokens.mutedOf(c);
continue;
}
final cle = s.domaine ?? '';
final rang = rangs[cle] ?? 0;
rangs[cle] = rang + 1;
out[s.thingId] = EtmTokens.nuance(base, rang);
}
return out;
}
@override
Widget build(BuildContext context) {
final muted = EtmTokens.mutedOf(context);
final teintes = couleurs(context, series);
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('Journée', style: EtmTokens.sans(size: 16, weight: FontWeight.w600)),
Text('aujourd\'hui', style: EtmTokens.sans(size: 12, color: muted)),
]),
const SizedBox(height: 8),
_meteo(context),
const SizedBox(height: 10),
SizedBox(
height: 190,
child: chargement
? const Center(
child: SizedBox(
width: 22,
height: 22,
child: CircularProgressIndicator(strokeWidth: 2)))
: JourneeChart(heures: heures, series: series, teintes: teintes),
),
const SizedBox(height: 10),
Wrap(spacing: 12, runSpacing: 4, children: [
_Puce(EtmTokens.brand, 'Maison'),
for (final s in series) _Puce(teintes[s.thingId] ?? muted, s.label),
_Puce(EtmTokens.selfUse, 'Solaire autoconsommé'),
_Puce(EtmTokens.exportSolaire, 'Solaire injecté'),
_Puce(EtmTokens.solar, 'Prévision', contour: true),
]),
const SizedBox(height: 10),
_totaux(context),
..._notes(context),
]),
);
}
Widget _meteo(BuildContext context) {
final muted = EtmTokens.mutedOf(context);
final m = meteo;
final style = EtmTokens.sans(size: 12, color: muted);
if (m == null) {
return Text('Météo non publiée par la box', style: style);
}
String deg(double v) => '${v.toStringAsFixed(1).replaceAll('.', ',')} °C';
String hm(DateTime t) =>
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
return Wrap(spacing: 14, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [
Row(mainAxisSize: MainAxisSize.min, children: [
Icon(iconeMeteo(m.condition), size: 18, color: EtmTokens.inkOf(context)),
const SizedBox(width: 6),
Text('${m.libelle}${m.temperature != null ? ' ${deg(m.temperature!)}' : ''}',
style: EtmTokens.sans(size: 12.5, weight: FontWeight.w500)),
]),
if (m.min != null && m.max != null)
Text('min ${deg(m.min!)} · max ${deg(m.max!)}', style: style),
if (m.lever != null && m.coucher != null)
Row(mainAxisSize: MainAxisSize.min, children: [
Icon(Icons.wb_twilight_rounded, size: 15, color: EtmTokens.solarOf(context)),
const SizedBox(width: 4),
Text('${hm(m.lever!)} – ${hm(m.coucher!)}', style: style),
]),
]);
}
/// Icône de l'état du ciel publié par le plugin (`weatherCondition`).
static IconData iconeMeteo(String c) => switch (c) {
'clear-day' => Icons.wb_sunny_rounded,
'clear-night' => Icons.nightlight_round,
'few-clouds-day' => Icons.wb_cloudy_outlined,
'few-clouds-night' => Icons.nights_stay_outlined,
'clouds' || 'overcast' => Icons.cloud_rounded,
'light-rain' || 'shower-rain' => Icons.grain_rounded,
'thunderstorm' => Icons.thunderstorm_rounded,
'snow' => Icons.ac_unit_rounded,
'fog' => Icons.foggy,
_ => Icons.help_outline_rounded,
};
Widget _totaux(BuildContext context) {
String kwh(double wh) => wh.abs() < 1000
? '${(wh / 1000).toStringAsFixed(2).replaceAll('.', ',')} kWh'
: '${(wh / 1000).toStringAsFixed(1).replaceAll('.', ',')} kWh';
final mes = heures.where((h) => h.mesuree).toList();
final lignes = <String>[];
if (mes.isNotEmpty) {
final prod = mes.fold(0.0, (a, h) => a + max(0.0, h.prodWh ?? 0));
final cons = mes.fold(0.0, (a, h) => a + (h.consWh ?? 0));
final imp = mes.fold(0.0, (a, h) => a + (h.importWh ?? 0));
lignes.add('Solaire ${kwh(prod)} · consommé ${kwh(cons)} dont réseau ${kwh(imp)}');
}
// Prévu sur la journée : seulement si CHAQUE heure en a une ; la fourchette, seulement
// si chaque créneau en a une aussi.
if (heures.isNotEmpty && heures.every((h) => h.prevuWh != null)) {
var l = 'Prévu aujourd\'hui ${kwh(heures.fold(0.0, (a, h) => a + h.prevuWh!))}';
final b = bande;
if (b != null) {
final debut = heures.first.debut.toUtc();
final fin = heures.last.debut.toUtc().add(const Duration(hours: 1));
final creneaux = b.parCreneau.keys
.where((t) => !t.isBefore(debut) && t.isBefore(fin))
.toList();
final attendus = fin.difference(debut).inHours;
if (creneaux.length >= attendus) {
final bas = creneaux.fold(0.0, (a, t) => a + b.parCreneau[t]!.basWh);
final haut = creneaux.fold(0.0, (a, t) => a + b.parCreneau[t]!.hautWh);
l += b.source == SourceBande.p10p90
? ' (p10–p90 ${kwh(bas)} – ${kwh(haut)})'
: ' (fourchette météo ${kwh(bas)} – ${kwh(haut)}'
'${b.attenuee ? ', non datée' : ''})';
}
}
lignes.add(l);
}
if (lignes.isEmpty) return const SizedBox.shrink();
return Text(lignes.join('\n'),
style: EtmTokens.mono(size: 11, color: EtmTokens.mutedOf(context)));
}
List<Widget> _notes(BuildContext context) {
final notes = <String>[
if (previsionNonLue)
'Prévision non lue : la box n\'a pas répondu.'
else
switch (etatPrevision) {
PvForecastEtat.siteManquant =>
'Pas de prévision : aucun site désigné — à choisir dans l\'écran Énergie.',
PvForecastEtat.chargeRefusee =>
'Pas de prévision : le producteur météo publie une courbe illisible.',
PvForecastEtat.faultInconnu => 'Pas de prévision : défaut publié par la box.',
PvForecastEtat.rienPublie => 'Aucune prévision publiée par la box.',
_ => '',
},
for (final l in sansCompteur)
'$l : aucun compteur déclaré, comptée dans « Maison ».',
for (final l in sansHistorique) '$l : pas d\'historique de puissance.',
if (heures.any((h) => (h.resteWh ?? 0) < -1))
'Les compteurs déclarés dépassent la consommation totale sur certaines heures.',
].where((n) => n.isNotEmpty).toList();
return [
for (final n in notes)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(n,
style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))),
),
];
}
}
/// Le graphe : deux colonnes par heure.
class JourneeChart extends StatelessWidget {
final List<HeureJournee> heures;
final List<ConsumerSeries> series;
final Map<String, Color> teintes;
const JourneeChart(
{super.key, required this.heures, required this.series, required this.teintes});
@override
Widget build(BuildContext context) {
final solaire = EtmTokens.solarOf(context);
final label = EtmTokens.sans(size: 9, color: EtmTokens.mutedOf(context));
final haut = heures.fold<double>(
0,
(a, h) => [
a,
h.consWh ?? 0,
max(0.0, h.prodWh ?? 0),
h.prevuWh ?? 0,
].reduce(max));
final yMax = max(0.5, haut / 1000 * 1.15);
return LayoutBuilder(builder: (context, c) {
// Deux colonnes par heure, et un vide entre les heures pour qu'on lise les paires.
final groupe = max(4.0, (c.maxWidth - 40) / max(1, heures.length));
final largeur = max(1.5, groupe * 0.36);
return BarChart(
BarChartData(
maxY: yMax,
minY: 0,
alignment: BarChartAlignment.spaceAround,
borderData: FlBorderData(show: false),
barTouchData: BarTouchData(enabled: false),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: yMax / 4,
getDrawingHorizontalLine: (_) =>
FlLine(color: EtmTokens.lineOf(context), strokeWidth: 1),
),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 36,
interval: yMax / 4,
getTitlesWidget: (v, meta) => v == meta.max
? const SizedBox.shrink()
: SideTitleWidget(
meta: meta,
child: Text(v.toStringAsFixed(1), style: label)),
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 20,
getTitlesWidget: (v, meta) {
final i = v.toInt();
if (i < 0 || i >= heures.length) return const SizedBox.shrink();
final t = heures[i].debut;
if (t.hour % 6 != 0) return const SizedBox.shrink();
return SideTitleWidget(
meta: meta, child: Text('${t.hour}h', style: label));
},
),
),
),
barGroups: [
for (var i = 0; i < heures.length; i++)
BarChartGroupData(x: i, barsSpace: 1, barRods: [
_conso(context, heures[i], largeur),
_solaire(heures[i], largeur, solaire),
]),
],
),
);
});
}
/// Colonne de gauche : la Maison en bas, puis chaque consommateur déclaré.
BarChartRodData _conso(BuildContext context, HeureJournee h, double w) {
if (!h.mesuree) {
return BarChartRodData(toY: 0, width: w, color: Colors.transparent);
}
final parts = <(double, Color)>[
(max(0.0, h.resteWh ?? 0), EtmTokens.brand),
for (final s in series)
if ((h.parConsommateur[s.thingId] ?? 0) > 0)
(h.parConsommateur[s.thingId]!, teintes[s.thingId] ?? EtmTokens.mutedOf(context)),
];
final items = <BarChartRodStackItem>[];
var acc = 0.0;
for (final (wh, couleur) in parts) {
if (wh <= 0) continue;
items.add(BarChartRodStackItem(acc / 1000, (acc + wh) / 1000, couleur));
acc += wh;
}
return BarChartRodData(
toY: acc / 1000,
width: w,
color: Colors.transparent,
rodStackItems: items,
borderRadius: BorderRadius.zero,
);
}
/// Colonne de droite : le solaire mesuré (doré + pâle), ou la prévision en contour.
BarChartRodData _solaire(HeureJournee h, double w, Color solaire) {
final prevu = h.prevuWh;
if (h.mesuree) {
final auto = h.autoWh ?? 0;
final exp = h.exportWh ?? 0;
return BarChartRodData(
toY: (auto + exp) / 1000,
width: w,
color: Colors.transparent,
borderRadius: BorderRadius.zero,
rodStackItems: [
if (auto > 0) BarChartRodStackItem(0, auto / 1000, EtmTokens.selfUse),
if (exp > 0)
BarChartRodStackItem(auto / 1000, (auto + exp) / 1000, EtmTokens.exportSolaire),
],
// Le niveau qui était prévu, en fond pâle : comparer sans cacher la mesure.
backDrawRodData: BackgroundBarChartRodData(
show: prevu != null && prevu > 0,
toY: (prevu ?? 0) / 1000,
color: solaire.withValues(alpha: 0.18),
),
);
}
if (prevu == null || prevu <= 0) {
return BarChartRodData(toY: 0, width: w, color: Colors.transparent);
}
return BarChartRodData(
toY: prevu / 1000,
width: w,
color: solaire.withValues(alpha: 0.30),
borderRadius: BorderRadius.zero,
borderSide: BorderSide(color: solaire, width: 1),
);
}
}
class _Puce extends StatelessWidget {
final Color couleur;
final String texte;
final bool contour;
const _Puce(this.couleur, this.texte, {this.contour = false});
@override
Widget build(BuildContext context) => Row(mainAxisSize: MainAxisSize.min, children: [
Container(
width: 10,
height: 10,
decoration: BoxDecoration(
color: contour ? couleur.withValues(alpha: 0.30) : couleur,
border: contour ? Border.all(color: couleur) : null,
borderRadius: BorderRadius.circular(2),
),
),
const SizedBox(width: 5),
Text(texte, style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))),
]);
}