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