Patrick Schurig 335e6a1290 feat(accueil): météo heure par heure sur la carte « Journée » (openmeteo etm16)
Consigne app §E, vérifiée sur le banc le 2026-10-15 :
- lecture de forecastHourlyWeather (v 1) sur le Thing SITE, parent des
  plans de perField (parentId désormais lu sur les Things) ; v inconnu =
  rien d'affiché, jamais forecastCurveTransport ;
- case i = valeur au DÉBUT du créneau t0 + i·step : posée sur l'heure qui
  commence là, comme la prévision PV ;
- icône (code WMO → icône, variante de nuit par isDay) et température une
  heure sur trois, alignées sur les colonnes du graphe ;
- une case null n'a ni icône ni température : WMO 0 = ciel dégagé, et un
  null lu comme 0 afficherait un soleil. Test à la lecture ajouté après
  qu'une mutation null → 0 n'eut fait échouer aucun test (la fixture du
  banc n'a pas de null).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 08:32:28 +02:00

478 lines
18 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;
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 comptent dans « Maison ».
final appareils = [
for (final e in cfg.entries)
if (e.meterThingId.isNotEmpty)
(id: e.meterThingId, label: e.label, domaine: e.domain)
];
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;
_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 maintenant = DateTime.now();
final heures = construireJournee(
jourLocal: maintenant,
snapshot: snap,
bilanHoraire: _bilan,
consoHoraire: _conso);
// Le site est le PARENT des plans sommés par la prévision ; à défaut, le premier site.
final plan = pv.snapshot?.perField.firstOrNull?.id;
final site = plan == null ? null : service.parentDe(plan);
final etats = service.etatsParNom('openMeteoSite',
preferer: site == null || site.isEmpty ? null : site);
return JourneeView(
heures: heures,
series: _conso.series,
meteo: etats == null ? null : MeteoActuelle.depuisEtats(etats),
meteoHoraire: meteoHoraireDepuis(etats?['forecastHourlyWeather']),
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;
/// Météo par début d'heure (UTC), ou `null` si la box ne la publie pas (openmeteo < etm16).
final Map<DateTime, MeteoHeure>? meteoHoraire;
final bool chargement;
const JourneeView({
super.key,
required this.heures,
this.series = const [],
this.meteo,
this.meteoHoraire,
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),
if (meteoHoraire != null) ...[
_rangeeMeteo(context),
const SizedBox(height: 2),
],
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),
]),
// Aucun texte sous le graphe (Patrick, 2026-10-15).
]),
);
}
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 et température, une heure sur trois**, alignées sur les colonnes du graphe.
///
/// Le graphe réserve 36 px à gauche pour son axe et répartit les heures à parts égales
/// (`spaceAround`) : la rangée reprend exactement ce découpage. Une heure sur trois, parce
/// que 24 icônes ne tiennent pas lisiblement sur la largeur d'un téléphone.
Widget _rangeeMeteo(BuildContext context) {
final muted = EtmTokens.mutedOf(context);
return Padding(
padding: const EdgeInsets.only(left: 36),
child: Row(children: [
for (final h in heures)
Expanded(
child: h.debut.hour % 3 != 0
? const SizedBox(height: 30)
: Builder(builder: (context) {
final m = meteoHoraire![h.debut.toUtc()];
final icone = m?.code == null ? null : iconeWmo(m!.code!, m.jour);
return SizedBox(
height: 30,
child: OverflowBox(
maxWidth: 40,
child: Column(mainAxisSize: MainAxisSize.min, children: [
if (icone != null)
Icon(icone,
size: 14,
color: m!.code == 0 && m.jour != false
? EtmTokens.solarOf(context)
: muted)
else
const SizedBox(height: 14),
Text(
m?.tempC == null ? '' : '${m!.tempC!.round()}°',
style: EtmTokens.mono(size: 9, color: muted)),
]),
),
);
}),
),
]),
);
}
/// **Code WMO → icône** (table de Patrick du 2026-10-15, inspirée de Weather Icons).
/// `jour == false` donne la variante de nuit. Un code inconnu n'a pas d'icône.
static IconData? iconeWmo(int code, bool? jour) {
final nuit = jour == false;
return switch (code) {
0 => nuit ? Icons.nightlight_round : Icons.wb_sunny_rounded,
1 || 2 => nuit ? Icons.nights_stay_rounded : Icons.wb_cloudy_rounded,
3 => Icons.cloud_rounded,
45 || 48 => Icons.foggy,
51 || 53 || 55 => Icons.grain_rounded,
56 || 57 || 66 || 67 => Icons.cloudy_snowing,
61 || 63 || 65 || 80 || 81 || 82 => Icons.water_drop_rounded,
71 || 73 || 75 || 77 || 85 || 86 => Icons.ac_unit_rounded,
95 || 96 || 99 => Icons.thunderstorm_rounded,
_ => null,
};
}
/// 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,
};
}
/// 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))),
]);
}