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>
616 lines
24 KiB
Dart
616 lines
24 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;
|
||
String? _luePour;
|
||
bool _lue = false;
|
||
|
||
/// Le jour affiché (minuit local). Aujourd'hui par défaut.
|
||
DateTime _jour = _minuit(DateTime.now());
|
||
|
||
/// Prévision ENREGISTRÉE pour un jour passé (`GetPvForecast`, connue au début du jour).
|
||
/// `null` pour aujourd'hui et demain : c'est alors la prévision en vigueur du provider.
|
||
PvForecastSnapshot? _historique;
|
||
|
||
/// Numéro de la dernière lecture lancée : une réponse plus ancienne est jetée — sinon un
|
||
/// tap rapide sur ‹ ‹ ‹ afficherait le jour de la première réponse arrivée.
|
||
int _lecture = 0;
|
||
|
||
static DateTime _minuit(DateTime t) => DateTime(t.year, t.month, t.day);
|
||
static DateTime _decale(DateTime j, int jours) => DateTime(j.year, j.month, j.day + jours);
|
||
|
||
DateTime get _aujourdhui => _minuit(DateTime.now());
|
||
|
||
/// La prévision s'arrête à la fin de demain ; l'historique horaire remonte à un an.
|
||
DateTime get _dernier => _decale(_aujourdhui, 1);
|
||
DateTime get _premier => _decale(_aujourdhui, -365);
|
||
|
||
void _aller(DateTime jour) {
|
||
final j = _minuit(jour);
|
||
if (j.isBefore(_premier) || j.isAfter(_dernier) || j == _jour) return;
|
||
setState(() {
|
||
_jour = j;
|
||
_lue = false;
|
||
});
|
||
_lire();
|
||
}
|
||
|
||
@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();
|
||
// Sur le compteur de lectures, pas sur `capturedAt` : une box sans prévision lisible
|
||
// ne doit pas laisser la carte sans mesures (trouvé par le test de navigation).
|
||
final lectures = context.watch<PvForecastProvider>().lectures;
|
||
final n = context.watch<LoadConfigProvider>().entries.length;
|
||
final cle = '$lectures/$n';
|
||
if (lectures > 0 && cle != _luePour) {
|
||
_luePour = cle;
|
||
// Un jour passé ne change plus : seule la journée en cours se relit au rythme du
|
||
// provider (et toute journée pas encore lue).
|
||
if (_jour == _aujourdhui || !_lue) _lire();
|
||
}
|
||
}
|
||
|
||
Future<void> _lire() async {
|
||
final service = context.read<NymeaService>();
|
||
final cfg = context.read<LoadConfigProvider>();
|
||
final now = DateTime.now();
|
||
final lecture = ++_lecture;
|
||
final debut = _jour;
|
||
final fin = _decale(_jour, 1);
|
||
final jusqua = fin.isBefore(now) ? fin : now;
|
||
// 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)
|
||
];
|
||
// Demain : rien à mesurer. Un jour passé : la prévision telle qu'elle était connue à son
|
||
// début — sans `knownAt`, la table rendrait la dernière révision, faite après coup.
|
||
final futur = !debut.isBefore(now);
|
||
final passe = !fin.isAfter(_aujourdhui);
|
||
final r = await Future.wait<dynamic>([
|
||
futur
|
||
? Future.value(const <PowerBalanceEntry>[])
|
||
: service.fetchPowerBalanceLogs(
|
||
from: debut, to: jusqua, sampleRate: 'SampleRate1Hour'),
|
||
futur
|
||
? Future.value(ConsumerHistory.vide)
|
||
: service.fetchThingPowerLogs(
|
||
appareils: appareils, from: debut, to: jusqua, sampleRate: 'SampleRate1Hour'),
|
||
passe
|
||
? service.fetchPvForecast(de: debut, a: fin, connueA: debut)
|
||
: Future<PvForecastSnapshot?>.value(null),
|
||
]);
|
||
if (!mounted || lecture != _lecture) return;
|
||
setState(() {
|
||
_bilan = r[0] as List<PowerBalanceEntry>;
|
||
_conso = r[1] as ConsumerHistory;
|
||
_historique = r[2] as PvForecastSnapshot?;
|
||
_lue = true;
|
||
});
|
||
}
|
||
|
||
static const _jours = ['lun.', 'mar.', 'mer.', 'jeu.', 'ven.', 'sam.', 'dim.'];
|
||
static const _mois = [
|
||
'janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin',
|
||
'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.'
|
||
];
|
||
|
||
String _libelle(DateTime j) =>
|
||
'${_jours[j.weekday - 1]} ${j.day} ${_mois[j.month - 1]}';
|
||
|
||
|
||
Future<void> _calendrier() async {
|
||
final choisi = await showDatePicker(
|
||
context: context,
|
||
initialDate: _jour,
|
||
firstDate: _premier,
|
||
lastDate: _dernier,
|
||
);
|
||
if (choisi != null) _aller(choisi);
|
||
}
|
||
|
||
/// **Le choix du jour** : un menu Hier / Aujourd'hui / Demain et un calendrier à côté
|
||
/// (Patrick, 2026-10-16 : « fonctionnel mais visuellement trop »). Un jour choisi au
|
||
/// calendrier s'ajoute au menu, sous sa date, pour que la sélection reste visible.
|
||
Widget _navigation(BuildContext context) {
|
||
final muted = EtmTokens.mutedOf(context);
|
||
final ecart = _jour.difference(_aujourdhui).inHours.round() ~/ 24;
|
||
DropdownMenuItem<int> item(int v, String t) => DropdownMenuItem(
|
||
value: v, child: Text(t, style: EtmTokens.sans(size: 12.5, color: EtmTokens.inkOf(context))));
|
||
return Row(mainAxisSize: MainAxisSize.min, children: [
|
||
DropdownButton<int>(
|
||
value: ecart,
|
||
isDense: true,
|
||
underline: const SizedBox.shrink(),
|
||
borderRadius: BorderRadius.circular(10),
|
||
icon: Icon(Icons.expand_more_rounded, size: 18, color: muted),
|
||
items: [
|
||
item(-1, 'Hier'),
|
||
item(0, 'Aujourd\'hui'),
|
||
item(1, 'Demain'),
|
||
if (ecart < -1 || ecart > 1) item(ecart, _libelle(_jour)),
|
||
],
|
||
onChanged: (v) {
|
||
if (v != null) _aller(_decale(_aujourdhui, v));
|
||
},
|
||
),
|
||
IconButton(
|
||
visualDensity: VisualDensity.compact,
|
||
tooltip: 'Choisir une date',
|
||
icon: Icon(Icons.calendar_today_rounded, size: 16, color: muted),
|
||
onPressed: _calendrier,
|
||
),
|
||
]);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final pv = context.watch<PvForecastProvider>();
|
||
final service = context.watch<NymeaService>();
|
||
final passe = !_decale(_jour, 1).isAfter(_aujourdhui);
|
||
final source = passe ? _historique : pv.snapshot;
|
||
final snap = source?.etat == PvForecastEtat.courbe ? source : null;
|
||
final heures = construireJournee(
|
||
jourLocal: _jour,
|
||
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,
|
||
// La météo de MAINTENANT n'a de sens que sur aujourd'hui.
|
||
meteo: etats == null || _jour != _aujourdhui
|
||
? null
|
||
: MeteoActuelle.depuisEtats(etats),
|
||
meteoAttendue: _jour == _aujourdhui,
|
||
navigation: _navigation(context),
|
||
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;
|
||
|
||
/// Faux sur un autre jour qu'aujourd'hui : la ligne « météo de maintenant » disparaît au
|
||
/// lieu de dire « non publiée » — elle n'est pas absente, elle est hors sujet.
|
||
final bool meteoAttendue;
|
||
|
||
/// Le choix du jour (menu + calendrier), posé à droite du titre.
|
||
final Widget? navigation;
|
||
|
||
const JourneeView({
|
||
super.key,
|
||
required this.heures,
|
||
this.series = const [],
|
||
this.meteo,
|
||
this.meteoHoraire,
|
||
this.chargement = false,
|
||
this.meteoAttendue = true,
|
||
this.navigation,
|
||
});
|
||
|
||
/// 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(children: [
|
||
Expanded(
|
||
child: Text('Journée',
|
||
style: EtmTokens.sans(size: 16, weight: FontWeight.w600, color: EtmTokens.inkOf(context)))),
|
||
?navigation,
|
||
]),
|
||
if (meteoAttendue) ...[const SizedBox(height: 8), _meteo(context)],
|
||
const SizedBox(height: 10),
|
||
// La rangée horaire ne s'affiche que si la série couvre ce jour (aujourd'hui, demain) :
|
||
// la box ne garde pas la météo des jours passés.
|
||
if (meteoHoraire != null &&
|
||
heures.any((h) => meteoHoraire!.containsKey(h.debut.toUtc()))) ...[
|
||
_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),
|
||
// Seulement quand la box publie un profil (`model != "none"`) : une légende ne
|
||
// promet pas une série qui n'existe pas.
|
||
if (heures.any((h) => h.baseWh != null))
|
||
_Puce(EtmTokens.brand, 'Consommation de base prévue', 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, color: EtmTokens.inkOf(context))),
|
||
]),
|
||
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,
|
||
h.baseWh ?? 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é. Heure à
|
||
/// venir : la consommation de base PRÉVUE en contour, si la box publie un profil.
|
||
BarChartRodData _conso(BuildContext context, HeureJournee h, double w) {
|
||
if (!h.mesuree) {
|
||
final base = h.baseWh;
|
||
if (base == null || base <= 0) {
|
||
return BarChartRodData(toY: 0, width: w, color: Colors.transparent);
|
||
}
|
||
return BarChartRodData(
|
||
toY: base / 1000,
|
||
width: w,
|
||
color: EtmTokens.brand.withValues(alpha: 0.25),
|
||
borderRadius: BorderRadius.zero,
|
||
borderSide: const BorderSide(color: EtmTokens.brand, width: 1),
|
||
);
|
||
}
|
||
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))),
|
||
]);
|
||
}
|