Demande de Patrick : élargir le glissement. 72 h chargées au quart d'heure, la fenêtre en montre 12 — on remonte donc sur trois jours sans changer d'onglet. TROIS CHOSES QUE CE CHANGEMENT RENDAIT NÉCESSAIRES. La taille de fenêtre était « la moitié du jeu de données ». Avec 24 h ça donnait 12 h ; avec 72 h ça en aurait donné 36, et la fenêtre aurait grandi avec les données au lieu de rester une fenêtre. Elle est fixée en DURÉE : la densité de lecture ne dépend plus de la profondeur chargée. La fenêtre s'ouvre sur le plus RÉCENT. Calée au début, elle aurait montré avant-hier soir à l'ouverture — la seule vue dont personne n'a besoin en premier. Et la plage visible porte sa DATE : « 8/9 21:00 → 9/9 09:00 ». Sur 24 h, « 09:00 » désignait sans ambiguïté ce matin ; sur trois jours, c'est l'un de trois matins, et l'axe n'affiche que des heures. Sans cette ligne, l'écran répondait à côté de la question « c'était quand ? ». Et les étiquettes des deux BORDS de l'axe sont retirées : fl_chart en pose une à `min` et à `max` en plus de celles de l'intervalle, et elles se superposaient à leur voisine — « 20:4500 » à gauche, « 08:1509:00 » à droite. J'avais corrigé le bord gauche sans penser que le droit avait le même défaut : la recherche auto-confirmante, appliquée à un axe. On ne perd rien, la ligne de plage donne les deux bornes exactes avec leur jour. Vérifié à l'écran : fenêtre 21:15 → 09:00 à l'ouverture, ligne de plage et indice de geste présents. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
1886 lines
78 KiB
Dart
1886 lines
78 KiB
Dart
import 'dart:async';
|
||
import 'dart:math' show max;
|
||
import 'package:fl_chart/fl_chart.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:provider/provider.dart';
|
||
import '../models/consumer_series.dart';
|
||
import '../models/daily_balance.dart';
|
||
import '../models/energy_data.dart';
|
||
import '../models/nymea_models.dart';
|
||
import '../providers/load_config_provider.dart';
|
||
import '../services/energy_ratios.dart';
|
||
import '../services/nymea_service.dart';
|
||
import '../theme/etm_tokens.dart';
|
||
import '../main.dart' show DrawerMenuButton;
|
||
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
// EnergyScreen — historique énergétique
|
||
//
|
||
// 4 KPIs · sélecteur période · line chart double axe (kW / SOC%) · bar chart
|
||
// ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
/// Une ligne d'explication sous un graphe — elle ne sort que quand elle DIT quelque chose.
|
||
class _NoteEcran extends StatelessWidget {
|
||
final IconData icon;
|
||
final String texte;
|
||
const _NoteEcran({required this.icon, required this.texte});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final c = EtmTokens.mutedOf(context);
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(4, 6, 4, 0),
|
||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Icon(icon, size: 14, color: c),
|
||
const SizedBox(width: 7),
|
||
Expanded(
|
||
child: Text(texte,
|
||
style: EtmTokens.sans(size: 10.5, color: c, height: 1.35))),
|
||
]),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Comment une vue se dessine — et ce n'est pas un détail de style.
|
||
///
|
||
/// `courbes` porte des **puissances** (kW) échantillonnées ; `barres` porte des **énergies**
|
||
/// (kWh) accumulées sur la période. Une semaine de puissances instantanées ne veut rien
|
||
/// dire : au pas du jour, ce qu'on peut lire est ce qui a été produit et consommé, pas ce
|
||
/// qui passait à un instant.
|
||
enum _Rendu { courbes, barres }
|
||
|
||
class _Tab {
|
||
final String label;
|
||
final Duration range;
|
||
final String sampleRate;
|
||
final bool showTime;
|
||
final _Rendu rendu;
|
||
|
||
/// Vrai si la plage se cale sur **une journée civile** (00:00 → 24:00) plutôt que sur
|
||
/// les N dernières heures. C'est ce que « Jour » promet, et ce qu'il ne faisait pas.
|
||
final bool journeeCivile;
|
||
|
||
const _Tab(this.label, this.range, this.sampleRate,
|
||
{required this.showTime,
|
||
this.rendu = _Rendu.courbes,
|
||
this.journeeCivile = false});
|
||
}
|
||
|
||
class EnergyScreen extends StatefulWidget {
|
||
const EnergyScreen({super.key});
|
||
|
||
@override
|
||
State<EnergyScreen> createState() => _EnergyScreenState();
|
||
}
|
||
|
||
class _EnergyScreenState extends State<EnergyScreen> {
|
||
/// 🔴 **Chaque onglet portait le nom d'un cran EN DESSOUS de sa plage** — « Jour »
|
||
/// montrait 7 jours, « Semaine » 28, « Mois » 365. Quatre étiquettes, quatre mensonges,
|
||
/// et personne ne pouvait obtenir la journée que le premier promettait. Corrigé le
|
||
/// 2026-09-09 ; l'onglet « Année » manquait, puisque c'est « Mois » qui la montrait.
|
||
static const _tabs = [
|
||
// 72 h au quart d'heure : la fenêtre n'en montre que 12, mais on peut remonter sur
|
||
// trois jours sans changer d'onglet (demande de Patrick, 2026-09-09).
|
||
_Tab('Heures', Duration(hours: 72), 'SampleRate15Mins', showTime: true),
|
||
_Tab('Jour', Duration(days: 1), 'SampleRate1Hour',
|
||
showTime: true, rendu: _Rendu.barres, journeeCivile: true),
|
||
_Tab('Semaine', Duration(days: 7), 'SampleRate1Day',
|
||
showTime: false, rendu: _Rendu.barres),
|
||
_Tab('Mois', Duration(days: 30), 'SampleRate1Day',
|
||
showTime: false, rendu: _Rendu.barres),
|
||
_Tab('Année', Duration(days: 365), 'SampleRate1Month',
|
||
showTime: false, rendu: _Rendu.barres),
|
||
];
|
||
|
||
int _tabIdx = 0;
|
||
List<PowerBalanceEntry> _data = [];
|
||
List<HistoryEntry> _socData = [];
|
||
ConsumerHistory _conso = ConsumerHistory.vide;
|
||
|
||
/// Le bilan du JOUR — indépendant de l'onglet de période choisi juste en dessous.
|
||
/// C'est délibéré : les tuiles disent la journée, les graphes disent la fenêtre.
|
||
DailyBalance? _bilan;
|
||
|
||
/// **Le premier point visible** de la fenêtre glissante, en index d'échantillon.
|
||
///
|
||
/// La vue « Heures » porte 96 points (24 h au quart d'heure) : tout montrer d'un coup
|
||
/// écrase le détail, et il n'y a alors rien à faire glisser. On en montre donc la moitié
|
||
/// et on se déplace dedans.
|
||
///
|
||
/// ⚠️ **On borne minX/maxX, on ne DÉCOUPE pas les données.** Découper renumérotrait les
|
||
/// index, et l'axe du bas — qui lit `_data[x.round()]` — désignerait alors les mauvaises
|
||
/// heures. Les séries restent entières, la fenêtre ne fait que cadrer.
|
||
double _fenetreDebut = 0;
|
||
|
||
/// **Douze heures visibles à la fois**, quelle que soit la profondeur des données.
|
||
///
|
||
/// C'était « la moitié du jeu » : avec 24 h de données, ça donnait 12 h — mais en passant
|
||
/// à 72 h ça en aurait donné 36, et la fenêtre aurait grandi avec les données au lieu de
|
||
/// rester une fenêtre. Une durée fixe garde la même densité de lecture quel que soit
|
||
/// l'historique chargé.
|
||
static const Duration _fenetreDuree = Duration(hours: 12);
|
||
|
||
int? get _fenetreTaille {
|
||
final tab = _tabs[_tabIdx];
|
||
if (tab.rendu != _Rendu.courbes) return null; // seules les courbes glissent
|
||
final pas = _pasEchantillon(tab).inSeconds;
|
||
if (pas <= 0) return null;
|
||
final points = _fenetreDuree.inSeconds ~/ pas;
|
||
return _data.length > points + 1 ? points : null;
|
||
}
|
||
|
||
bool get _glissable => _fenetreTaille != null;
|
||
|
||
/// Dernier point visible. Sans fenêtre, c'est tout le jeu de données.
|
||
double get _fenetreFin {
|
||
final taille = _fenetreTaille;
|
||
final dernier = (_data.length - 1).toDouble();
|
||
if (taille == null) return dernier;
|
||
return (_fenetreDebut + taille).clamp(0, dernier).toDouble();
|
||
}
|
||
|
||
/// Déplace la fenêtre, **bornée aux données** : on ne glisse pas dans le vide.
|
||
///
|
||
/// Le vide serait pire qu'une butée — il ferait croire qu'il n'y a rien à cet endroit,
|
||
/// alors qu'il n'y a simplement pas d'endroit.
|
||
void _glisser(double deltaPoints) {
|
||
final taille = _fenetreTaille;
|
||
if (taille == null) return;
|
||
final maxDebut = (_data.length - 1 - taille).toDouble();
|
||
setState(() =>
|
||
_fenetreDebut = (_fenetreDebut + deltaPoints).clamp(0, max(0, maxDebut)));
|
||
}
|
||
|
||
/// Vrai quand la box a livré ses Things ET qu'aucun ne porte de batterie.
|
||
///
|
||
/// Les deux moitiés comptent : tant que les Things ne sont pas chargés, « pas de
|
||
/// batterie » et « on ne sait pas encore » sont indiscernables, et annoncer l'absence
|
||
/// trop tôt serait une absence côté APP présentée comme une absence côté BOX.
|
||
bool get _sansBatterie =>
|
||
(_nymeaService?.thingsLoaded ?? false) &&
|
||
_nymeaService?.batterySOCSource == null;
|
||
|
||
bool get _aUneBatterie => _nymeaService?.batterySOCSource != null;
|
||
bool _loading = true;
|
||
bool _noData = false;
|
||
DateTime? _selectedDate;
|
||
Timer? _refreshTimer;
|
||
|
||
NymeaService? _nymeaService;
|
||
bool _initialSocFetched = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
_nymeaService = context.read<NymeaService>();
|
||
// 🔴 **Plus d'auto-simulation ici.** L'écran faisait
|
||
// `if (!connected) startSimulation()` : monté avant que la WebSocket soit établie —
|
||
// le cas NORMAL, puisqu'on y arrive en tapant l'installation — il basculait tout le
|
||
// service en démo, et n'en ressortait jamais.
|
||
//
|
||
// Vu à l'écran le 2026-09-08, box parfaitement joignable : une sinusoïde orange et
|
||
// une courbe de SOC à 75 % sur une installation **qui n'a pas de batterie**. Rien ne
|
||
// distinguait ces données inventées des vraies — ni couleur, ni mention, ni forme.
|
||
// C'est la fabrication sans même l'étiquette qui la blanchit.
|
||
//
|
||
// Le tableau de bord avait retiré la même chose pour la même raison : la démo est un
|
||
// CHOIX, pris depuis l'écran Installations, jamais un repli silencieux.
|
||
_nymeaService!.addListener(_onServiceChanged);
|
||
_fetch();
|
||
});
|
||
_refreshTimer = Timer.periodic(const Duration(minutes: 5), (_) {
|
||
if (mounted) _fetch();
|
||
});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_refreshTimer?.cancel();
|
||
_nymeaService?.removeListener(_onServiceChanged);
|
||
super.dispose();
|
||
}
|
||
|
||
/// Rattrape ce qui n'était pas encore disponible au montage.
|
||
///
|
||
/// Deux cas, et le premier est la raison d'être de ce listener depuis qu'on ne simule
|
||
/// plus : la **connexion** peut arriver après l'écran. Sans ce rattrapage, l'écran
|
||
/// resterait vide jusqu'au tick de cinq minutes.
|
||
void _onServiceChanged() {
|
||
if (!mounted || _loading) return;
|
||
final svc = _nymeaService;
|
||
if (svc == null) return;
|
||
|
||
if (svc.connected && _data.isEmpty) {
|
||
_fetch();
|
||
return;
|
||
}
|
||
if (!_initialSocFetched && svc.batterySOCSource != null && _socData.isEmpty) {
|
||
_initialSocFetched = true;
|
||
_fetchSocOnly();
|
||
}
|
||
}
|
||
|
||
Future<void> _fetchSocOnly() async {
|
||
if (!mounted) return;
|
||
final tab = _tabs[_tabIdx];
|
||
final to = _anchorDate();
|
||
final service = context.read<NymeaService>();
|
||
final socSource = service.batterySOCSource;
|
||
if (socSource == null) return;
|
||
final soc = await service.fetchHistory(
|
||
thingId: socSource['thingId']!,
|
||
stateTypeName: socSource['stateName']!,
|
||
from: to.subtract(tab.range),
|
||
to: to,
|
||
sampleRate: tab.sampleRate,
|
||
);
|
||
if (mounted && soc.isNotEmpty) setState(() => _socData = soc);
|
||
}
|
||
|
||
Future<void> _fetch() async {
|
||
if (!mounted) return;
|
||
setState(() { _loading = true; _noData = false; });
|
||
final tab = _tabs[_tabIdx];
|
||
final (from, to) = _fenetreRequete(tab);
|
||
final service = context.read<NymeaService>();
|
||
final socSource = service.batterySOCSource;
|
||
|
||
// **La liste des consommateurs vient de `GetLoadConfig`, pas d'une devinette.**
|
||
//
|
||
// Chaque charge déclarée porte son `meterThingId` — vérifié renseigné sur les quatre
|
||
// charges de `.75` le 2026-09-08 — plus son `label` et son `domain`. On demande donc
|
||
// l'historique DES COMPTEURS DES CHARGES DÉCLARÉES : rien n'est deviné à partir d'un
|
||
// nom d'appareil, et une charge sans compteur déclaré n'entre simplement pas.
|
||
final cfg = context.read<LoadConfigProvider>();
|
||
if (cfg.entries.isEmpty) {
|
||
cfg
|
||
..attach(service)
|
||
..load();
|
||
}
|
||
final appareils = [
|
||
for (final e in cfg.entries)
|
||
if (e.meterThingId.isNotEmpty)
|
||
(id: e.meterThingId, label: e.label, domaine: e.domain)
|
||
];
|
||
|
||
final results = await Future.wait<dynamic>([
|
||
service.fetchPowerBalanceLogs(from: from, to: to, sampleRate: tab.sampleRate),
|
||
socSource != null
|
||
? service.fetchHistory(
|
||
thingId: socSource['thingId']!, stateTypeName: socSource['stateName']!,
|
||
from: from, to: to, sampleRate: tab.sampleRate)
|
||
: Future<List<HistoryEntry>>.value([]),
|
||
service.fetchThingPowerLogs(
|
||
appareils: appareils, from: from, to: to, sampleRate: tab.sampleRate),
|
||
service.fetchDailyBalance(),
|
||
]);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_data = results[0] as List<PowerBalanceEntry>;
|
||
_socData = results[1] as List<HistoryEntry>;
|
||
_conso = results[2] as ConsumerHistory;
|
||
_bilan = results[3] as DailyBalance?;
|
||
// **La fenêtre s'ouvre sur le plus RÉCENT.** Avec 72 h chargées, la caler au début
|
||
// montrerait avant-hier soir à l'ouverture de l'écran — la seule vue dont personne
|
||
// n'a besoin en premier. Recalculée à chaque lecture : garder l'ancienne cadrerait
|
||
// une plage d'index qui ne désigne plus les mêmes heures.
|
||
final taille = _fenetreTaille;
|
||
_fenetreDebut =
|
||
taille == null ? 0 : max(0, _data.length - 1 - taille).toDouble();
|
||
_loading = false;
|
||
_noData = _data.isEmpty;
|
||
});
|
||
}
|
||
|
||
/// La fenêtre demandée à la box, **et pourquoi elle déborde d'un échantillon**.
|
||
///
|
||
/// Les barres portent des ÉNERGIES, obtenues par différence de compteurs cumulés entre
|
||
/// deux échantillons. Il faut donc un point AVANT le début de la période, sans quoi la
|
||
/// première barre n'a pas de prédécesseur — et une barre sans prédécesseur ne vaut pas
|
||
/// zéro, elle n'existe pas. Demander une heure de plus coûte un point et évite d'inventer
|
||
/// la première.
|
||
///
|
||
/// Pour « Jour », la fenêtre se cale sur la **journée civile** 00:00 → 24:00 : c'est ce
|
||
/// que l'onglet promet. Les autres restent glissantes à partir de maintenant.
|
||
(DateTime, DateTime) _fenetreRequete(_Tab tab) {
|
||
final ancre = _anchorDate();
|
||
// 🔴 **Toutes les vues en barres se calent sur minuit**, pas seulement « Jour ».
|
||
//
|
||
// Sans ça, une plage de 7 jours partant de 08:12 coupe deux journées en deux : le
|
||
// premier et le dernier échantillon tombent le même jour civil, l'axe affiche « 6/9 »
|
||
// deux fois, et on obtient huit barres pour une semaine. Vu à l'écran le 2026-09-09.
|
||
// Une barre journalière doit couvrir une journée entière ou ne pas exister.
|
||
final fin = (tab.journeeCivile || tab.rendu == _Rendu.barres)
|
||
? DateTime(ancre.year, ancre.month, ancre.day).add(const Duration(days: 1))
|
||
: ancre;
|
||
final debut = fin.subtract(tab.range);
|
||
if (tab.rendu != _Rendu.barres) return (debut, fin);
|
||
return (debut.subtract(_pasEchantillon(tab)), fin);
|
||
}
|
||
|
||
static Duration _pasEchantillon(_Tab tab) => switch (tab.sampleRate) {
|
||
'SampleRate15Mins' => const Duration(minutes: 15),
|
||
'SampleRate1Hour' => const Duration(hours: 1),
|
||
'SampleRate1Day' => const Duration(days: 1),
|
||
'SampleRate1Week' => const Duration(days: 7),
|
||
'SampleRate1Month' => const Duration(days: 31),
|
||
_ => const Duration(hours: 1),
|
||
};
|
||
|
||
DateTime _anchorDate() => _selectedDate != null
|
||
? DateTime(_selectedDate!.year, _selectedDate!.month, _selectedDate!.day, 23, 59, 59)
|
||
: DateTime.now();
|
||
|
||
Future<void> _pickDate() async {
|
||
final picked = await showDatePicker(
|
||
context: context,
|
||
initialDate: _selectedDate ?? DateTime.now(),
|
||
firstDate: DateTime(2020),
|
||
lastDate: DateTime.now(),
|
||
builder: (ctx, child) => Theme(
|
||
data: Theme.of(ctx).copyWith(
|
||
colorScheme: const ColorScheme.light(
|
||
primary: EtmTokens.green,
|
||
onPrimary: Colors.white,
|
||
),
|
||
),
|
||
child: child!,
|
||
),
|
||
);
|
||
if (picked != null && mounted) {
|
||
setState(() { _selectedDate = picked; _initialSocFetched = false; });
|
||
_fetch();
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Consumer<NymeaService>(
|
||
builder: (context, service, _) {
|
||
return Scaffold(
|
||
backgroundColor: EtmTokens.bgOf(context),
|
||
body: CustomScrollView(
|
||
slivers: [
|
||
// AppBar
|
||
SliverAppBar(
|
||
floating: true,
|
||
backgroundColor: EtmTokens.bgOf(context),
|
||
elevation: 0,
|
||
leading: const DrawerMenuButton(),
|
||
leadingWidth: 64,
|
||
title: Text('Énergie',
|
||
style: EtmTokens.sans(size: 20, weight: FontWeight.w600)),
|
||
actions: [
|
||
IconButton(
|
||
icon: Icon(
|
||
Icons.calendar_today_rounded,
|
||
color: _selectedDate != null ? EtmTokens.blue : EtmTokens.mutedOf(context),
|
||
size: 20,
|
||
),
|
||
onPressed: _pickDate,
|
||
),
|
||
Padding(
|
||
padding: const EdgeInsets.only(right: 8),
|
||
child: IconButton(
|
||
icon: Icon(Icons.refresh_rounded,
|
||
color: EtmTokens.mutedOf(context), size: 20),
|
||
onPressed: _fetch,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
|
||
SliverPadding(
|
||
padding: const EdgeInsets.fromLTRB(18, 4, 18, 32),
|
||
sliver: SliverList(
|
||
delegate: SliverChildListDelegate([
|
||
// KPI 2x2
|
||
_KpiSection(bilan: _bilan),
|
||
const SizedBox(height: 16),
|
||
|
||
// Sélecteur période
|
||
_PeriodSelector(
|
||
tabs: _tabs,
|
||
selectedIndex: _tabIdx,
|
||
onSelect: (i) {
|
||
if (_tabIdx != i) {
|
||
setState(() { _tabIdx = i; _initialSocFetched = false; });
|
||
_fetch();
|
||
}
|
||
},
|
||
),
|
||
|
||
// Chip date sélectionnée
|
||
if (_selectedDate != null) ...[
|
||
const SizedBox(height: 10),
|
||
_DateChip(
|
||
date: _selectedDate!,
|
||
onClear: () {
|
||
setState(() => _selectedDate = null);
|
||
_fetch();
|
||
},
|
||
),
|
||
],
|
||
const SizedBox(height: 16),
|
||
|
||
// ── Vues d'ÉNERGIE : une seule carte, en barres ──────────────
|
||
//
|
||
// Au pas du jour, une puissance instantanée ne veut rien dire : ce
|
||
// qu'on peut lire est ce qui a été produit et consommé. Les deux
|
||
// graphes de puissance n'ont donc pas de version « semaine » — ils
|
||
// sont remplacés, pas empilés avec.
|
||
if (_tabs[_tabIdx].rendu == _Rendu.barres) ...[
|
||
_EtmChartCard(
|
||
title: 'Bilan énergétique',
|
||
subtitle: 'kWh par période — le bloc orange est partagé : '
|
||
'ce qui a été produit ET gardé',
|
||
legend: [
|
||
_LegendDot(EtmTokens.selfUse, 'Autoconsommé'),
|
||
_LegendDot(EtmTokens.amber, 'Exporté'),
|
||
_LegendDot(EtmTokens.blue, 'Soutiré'),
|
||
],
|
||
height: 240,
|
||
loading: _loading,
|
||
noData: _noData,
|
||
child: _buildEnergyBars(),
|
||
),
|
||
const SizedBox(height: 14),
|
||
_EtmChartCard(
|
||
title: 'Où est passée la consommation',
|
||
subtitle: 'kWh par période, par charge déclarée',
|
||
legend: [
|
||
for (final s in _conso.series)
|
||
_LegendDot(
|
||
_couleursSeries(context)[s.thingId] ??
|
||
EtmTokens.mutedOf(context),
|
||
s.label),
|
||
const _LegendDot(EtmTokens.brand, 'Reste du foyer'),
|
||
],
|
||
height: 240,
|
||
loading: _loading,
|
||
noData: _noData,
|
||
child: _buildConsumerEnergyBars(),
|
||
),
|
||
_notesConsommateurs(),
|
||
] else ...[
|
||
// Graphe Puissances (· SOC % quand il y a une batterie)
|
||
//
|
||
// ⚠️ **La légende ne promet pas une courbe qui n'existe pas.**
|
||
// « SOC % » y figurait en dur : sur une installation sans batterie,
|
||
// l'écran annonçait donc une série qu'aucune donnée ne pouvait
|
||
// remplir. Vu à l'écran le 2026-09-08 — c'est le même défaut que
|
||
// partout ailleurs (montrer ce qu'on n'a pas), pris par la légende
|
||
// au lieu du tracé.
|
||
_EtmChartCard(
|
||
title: _aUneBatterie
|
||
? 'Puissances · SOC %'
|
||
: 'Puissances',
|
||
subtitle: _aUneBatterie
|
||
? 'kW — le sommet de la pile est la production · % batterie (droite)'
|
||
: 'kW — le sommet de la pile est la production',
|
||
legend: [
|
||
_LegendDot(EtmTokens.selfUse, 'Autoconsommé'),
|
||
_LegendDot(EtmTokens.amber, 'Exporté'),
|
||
_LegendDot(EtmTokens.blue, 'Consommation'),
|
||
if (_aUneBatterie)
|
||
_LegendDot(EtmTokens.ev, 'SOC %', dashed: true),
|
||
],
|
||
height: 220,
|
||
loading: _loading,
|
||
noData: _noData,
|
||
child: _glissant(_buildLineChart()),
|
||
),
|
||
// La note d'absence de batterie va SOUS ce graphe-ci : c'est ici que
|
||
// le SOC aurait été tracé, donc c'est ici qu'il manque.
|
||
if (_sansBatterie)
|
||
const _NoteEcran(
|
||
icon: Icons.battery_unknown_rounded,
|
||
texte: 'Aucune batterie sur cette installation : ni charge, ni '
|
||
'décharge, ni état de charge. Rien n\'est masqué — il n\'y a '
|
||
'rien à tracer.',
|
||
),
|
||
const SizedBox(height: 14),
|
||
|
||
// Graphe : qui consomme
|
||
_EtmChartCard(
|
||
title: 'Qui consomme',
|
||
subtitle: 'Puissance empilée, par charge déclarée',
|
||
legend: [
|
||
const _LegendDot(EtmTokens.brand, 'Reste du foyer'),
|
||
for (final s in _conso.series)
|
||
_LegendDot(
|
||
_couleursSeries(context)[s.thingId] ??
|
||
EtmTokens.mutedOf(context),
|
||
s.label),
|
||
],
|
||
height: 220,
|
||
loading: _loading,
|
||
noData: _noData,
|
||
child: _glissant(_buildConsumerChart()),
|
||
),
|
||
// L'indice ne sort que si le geste existe : une aide permanente
|
||
// cesse d'être lue, et celle-ci serait fausse sur les autres vues.
|
||
if (_glissable && _plageVisible() != null)
|
||
_NoteEcran(
|
||
icon: Icons.date_range_rounded,
|
||
texte: _plageVisible()!,
|
||
),
|
||
if (_glissable)
|
||
const _NoteEcran(
|
||
icon: Icons.swipe_rounded,
|
||
texte: 'Glissez horizontalement pour remonter sur trois jours. '
|
||
'Les deux graphes gardent le même cadrage.',
|
||
),
|
||
],
|
||
// Les notes parlent des séries par consommateur : elles n'ont aucun
|
||
// sens sous un graphe d'énergie qui n'en montre aucune. Vu à l'écran :
|
||
// « V2C Trydan n'est mesurée que depuis 00:00 » sous les barres de la
|
||
// semaine, où aucune V2C n'est tracée.
|
||
_notesConsommateurs(),
|
||
const SizedBox(height: 14),
|
||
|
||
// Météo & prévision — placeholder
|
||
_ForecastPlaceholder(),
|
||
]),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
);
|
||
}
|
||
|
||
// ── ① Line chart : puissances + SOC ────────────────────────────────────────
|
||
|
||
/// **Ce qu'on regarde, avec sa date** — `null` hors vue glissable.
|
||
///
|
||
/// Sur 24 h, « 09:00 » désignait sans ambiguïté ce matin. Sur trois jours, la même
|
||
/// étiquette peut être l'un de trois matins, et l'axe seul ne le dit pas : il n'affiche
|
||
/// que des heures. La plage visible porte donc le jour, faute de quoi l'écran répond
|
||
/// exactement à côté de la question « c'était quand ? ».
|
||
String? _plageVisible() {
|
||
if (!_glissable || _data.isEmpty) return null;
|
||
final a = _data[_fenetreDebut.round().clamp(0, _data.length - 1)].timestamp;
|
||
final b = _data[_fenetreFin.round().clamp(0, _data.length - 1)].timestamp;
|
||
String jour(DateTime t) => '${t.day}/${t.month}';
|
||
String heure(DateTime t) =>
|
||
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
|
||
return jour(a) == jour(b)
|
||
? '${jour(a)} · ${heure(a)} → ${heure(b)}'
|
||
: '${jour(a)} ${heure(a)} → ${jour(b)} ${heure(b)}';
|
||
}
|
||
|
||
/// Rend un graphe **glissable à l'horizontale** quand la vue s'y prête.
|
||
///
|
||
/// Le geste convertit des pixels en points d'échantillon : glisser d'une largeur d'écran
|
||
/// déplace d'une fenêtre entière, ce qui rend le mouvement indépendant de la densité des
|
||
/// données. Hors vue « Heures », le graphe est rendu tel quel — envelopper un widget
|
||
/// non glissable dans un détecteur de geste volerait le défilement vertical de la page
|
||
/// pour rien.
|
||
Widget _glissant(Widget graphe) {
|
||
if (!_glissable) return graphe;
|
||
final taille = _fenetreTaille!;
|
||
return LayoutBuilder(
|
||
builder: (context, c) => GestureDetector(
|
||
behavior: HitTestBehavior.opaque,
|
||
onHorizontalDragUpdate: (d) {
|
||
final largeur = c.maxWidth <= 0 ? 1.0 : c.maxWidth;
|
||
_glisser(-d.delta.dx / largeur * taille);
|
||
},
|
||
child: graphe,
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildLineChart({bool axeGauche = true}) {
|
||
if (_loading || _noData || _data.isEmpty) return const SizedBox.shrink();
|
||
|
||
final prodSpots = <FlSpot>[];
|
||
final consoSpots = <FlSpot>[];
|
||
final autoSpots = <FlSpot>[];
|
||
final socSpots = <FlSpot>[];
|
||
|
||
for (int i = 0; i < _data.length; i++) {
|
||
final d = _data[i];
|
||
final x = i.toDouble();
|
||
// 🔴 **La MAGNITUDE, et c'est ici que le signe se corrige — pas au transport.**
|
||
//
|
||
// `.75` publie `production` NÉGATIF : −103 W ce matin. Le champ reste brut dans
|
||
// `PowerBalanceEntry` (§7.1 du contrat), donc c'est au rendu de prendre la valeur
|
||
// absolue. Sans ça, deux courbes disparaissaient pour la même raison — signalé par
|
||
// Patrick le 2026-09-08 : la production tracée à −0,1 kW sous une échelle qui monte
|
||
// à 3,1, et l'autoconsommation clouée à 0 parce que `production − export` est
|
||
// toujours négatif.
|
||
//
|
||
// `.abs()` est licite **sur une production** : une installation ne produit pas une
|
||
// puissance négative, donc le signe ne porte aucune information à préserver. Ce
|
||
// n'est pas vrai du réseau, où le signe DIT le sens — d'où `gridImportW` /
|
||
// `gridExportW` plutôt qu'un `.abs()` là-bas.
|
||
final prodW = d.productionW.abs();
|
||
// INTERIM (§2.2) — bande autoconso via la formule canonique du seam.
|
||
final selfW = EnergyRatiosInterim.selfConsumptionPower(prodW, d.acquisitionW);
|
||
// `prodSpots` porte désormais le SOMMET DE LA PILE, c'est-à-dire la production :
|
||
// autoconsommé + exporté. L'égalité est exacte par construction — l'exporté est
|
||
// défini comme `production − autoconsommé` — donc la pile ne peut pas mentir sur
|
||
// son total.
|
||
prodSpots .add(FlSpot(x, prodW / 1000));
|
||
autoSpots .add(FlSpot(x, selfW / 1000));
|
||
consoSpots.add(FlSpot(x, d.consumptionW.abs() / 1000));
|
||
}
|
||
|
||
// Plage Y gauche en kW
|
||
final allKw = _data
|
||
.expand((d) => [d.productionW.abs() / 1000, d.consumptionW.abs() / 1000])
|
||
.toList();
|
||
final maxKw = allKw.isEmpty ? 2.5 : allKw.reduce(max);
|
||
final yMax = max(maxKw * 1.15, 0.5); // min 0.5 kW pour éviter y écrasé
|
||
|
||
// SOC normalisé sur l'échelle kW (100% → yMax)
|
||
final hasSoc = _socData.length > 1;
|
||
if (hasSoc) {
|
||
final n = _socData.length;
|
||
final xMax = (_data.length - 1).toDouble();
|
||
for (int i = 0; i < n; i++) {
|
||
socSpots.add(FlSpot(
|
||
xMax * i / (n - 1),
|
||
_socData[i].value * yMax / 100.0,
|
||
));
|
||
}
|
||
}
|
||
|
||
final xInterval = _xInterval();
|
||
final labelStyle = EtmTokens.sans(size: 9, color: EtmTokens.mutedOf(context));
|
||
final yInterval = yMax / 4; // 5 graduations : 0, 25%, 50%, 75%, 100% de yMax
|
||
|
||
return LineChart(
|
||
LineChartData(
|
||
clipData: const FlClipData.all(),
|
||
minX: _fenetreDebut,
|
||
maxX: _fenetreFin,
|
||
minY: 0,
|
||
maxY: yMax,
|
||
gridData: FlGridData(
|
||
show: true,
|
||
drawVerticalLine: false,
|
||
horizontalInterval: yInterval,
|
||
getDrawingHorizontalLine: (_) =>
|
||
FlLine(color: EtmTokens.lineOf(context), strokeWidth: 1),
|
||
),
|
||
borderData: FlBorderData(show: false),
|
||
titlesData: FlTitlesData(
|
||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||
|
||
// Axe gauche : kW
|
||
leftTitles: AxisTitles(
|
||
sideTitles: SideTitles(
|
||
showTitles: axeGauche,
|
||
reservedSize: axeGauche ? 46 : 0,
|
||
interval: yInterval,
|
||
getTitlesWidget: (v, meta) {
|
||
if (v == meta.max) return const SizedBox.shrink(); // évite label dupliqué au sommet
|
||
return SideTitleWidget(
|
||
meta: meta,
|
||
child: Text(
|
||
'${v.toStringAsFixed(1)}kW',
|
||
style: labelStyle,
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
|
||
// Axe droit : SOC %
|
||
rightTitles: hasSoc
|
||
? AxisTitles(
|
||
sideTitles: SideTitles(
|
||
showTitles: true,
|
||
reservedSize: 40,
|
||
interval: yInterval,
|
||
getTitlesWidget: (v, meta) {
|
||
if (v == meta.max) return const SizedBox.shrink();
|
||
final pct = (v / yMax * 100).round();
|
||
if (pct < 0 || pct > 100) return const SizedBox.shrink();
|
||
return SideTitleWidget(
|
||
meta: meta,
|
||
child: Text(
|
||
'$pct%',
|
||
style: EtmTokens.sans(size: 9, color: EtmTokens.ev),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
)
|
||
: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||
|
||
// Axe bas : temps
|
||
bottomTitles: AxisTitles(
|
||
sideTitles: SideTitles(
|
||
showTitles: true,
|
||
reservedSize: 22,
|
||
interval: xInterval,
|
||
getTitlesWidget: (v, meta) {
|
||
// ⚠️ **Aucune étiquette sur les deux BORDS.** fl_chart en pose une à `min`
|
||
// et à `max` en plus de celles de l'intervalle : quand les bornes ne
|
||
// tombent pas sur un multiple, elles se superposent à leur voisine —
|
||
// « 20:4500 » à gauche, « 08:1509:00 » à droite, vus à l'écran le
|
||
// 2026-09-09. Deux heures imprimées l'une sur l'autre ne se lisent ni
|
||
// l'une ni l'autre.
|
||
//
|
||
// On ne perd rien : la ligne de plage sous le graphe donne les deux bornes
|
||
// exactes, avec leur jour — ce que l'axe ne pouvait pas dire de toute
|
||
// façon sur trois jours.
|
||
if (v <= meta.min || v >= meta.max) return const SizedBox.shrink();
|
||
final idx = v.round();
|
||
if (idx < 0 || idx >= _data.length) return const SizedBox.shrink();
|
||
return SideTitleWidget(
|
||
meta: meta,
|
||
child: Text(_fmtTime(_data[idx].timestamp), style: labelStyle),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
// ⚠️ **On EMPILE, on ne superpose pas.** Validé par Patrick le 2026-09-08.
|
||
//
|
||
// Superposées, autoconsommation et production sont identiques au watt près dès
|
||
// que rien n'est exporté — et deux traits pleins superposés n'en laissent voir
|
||
// qu'un, quelles que soient leurs couleurs. Le problème est géométrique, pas
|
||
// chromatique : changer de teinte ne l'aurait pas réglé, et le pointillé essayé
|
||
// avant n'était qu'un contournement.
|
||
//
|
||
// Empilées, il n'y a plus de recouvrement possible :
|
||
//
|
||
// jaune PV → réseau (exporté) ┐
|
||
// orange PV → maison (autoconsommé) ┘ sommet = production totale
|
||
//
|
||
// La production cesse d'être une courbe : elle EST le haut de la pile. Et quand
|
||
// rien n'est exporté, la bande jaune a une hauteur nulle — ce qui est vrai, et se
|
||
// lit d'un coup d'œil : « tout ce qui est produit est consommé sur place ».
|
||
// L'export devient une SURFACE au lieu d'un écart à mesurer à l'œil entre deux
|
||
// traits.
|
||
//
|
||
// Tracé du haut vers le bas : chaque aire est remplie jusqu'à zéro et la suivante
|
||
// repeint par-dessus. Même procédé que l'empilement par consommateur.
|
||
lineBarsData: [
|
||
_aire(prodSpots, EtmTokens.amber), // sommet = production
|
||
_aire(autoSpots, EtmTokens.selfUse), // repeint la part autoconsommée
|
||
_line(consoSpots, EtmTokens.blue),
|
||
// Le SOC est en indigo, pas en vert pointillé : le vert pointillé a servi à
|
||
// l'autoconsommation le temps d'un essai, et sur une installation AVEC batterie
|
||
// les deux auraient été indiscernables. Aucun banc sans batterie ne pouvait le
|
||
// montrer.
|
||
if (hasSoc) _line(socSpots, EtmTokens.ev, dashed: true, width: 1.5),
|
||
],
|
||
lineTouchData: LineTouchData(
|
||
touchTooltipData: LineTouchTooltipData(
|
||
getTooltipColor: (_) => EtmTokens.navy.withValues(alpha: 0.85),
|
||
getTooltipItems: (spots) => spots.map((s) {
|
||
final isSoc = hasSoc && s.barIndex == 3;
|
||
final label = isSoc
|
||
? '${(s.y / yMax * 100).toStringAsFixed(0)} %'
|
||
: '${s.y.toStringAsFixed(2)} kW';
|
||
return LineTooltipItem(
|
||
label,
|
||
EtmTokens.mono(size: 11, color: s.bar.color ?? Colors.white),
|
||
);
|
||
}).toList(),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Une bande PLEINE de l'empilement — remplie jusqu'à zéro, sans courbure.
|
||
///
|
||
/// Pas de `isCurved` ici : une spline peut passer sous zéro entre deux points, et une
|
||
/// bande d'énergie qui plonge sous l'axe affiche une grandeur qui n'existe pas.
|
||
LineChartBarData _aire(List<FlSpot> spots, Color color) => LineChartBarData(
|
||
spots: spots,
|
||
isCurved: false,
|
||
color: color,
|
||
barWidth: 1.2,
|
||
dotData: const FlDotData(show: false),
|
||
belowBarData:
|
||
BarAreaData(show: true, color: color.withValues(alpha: 0.62)),
|
||
);
|
||
|
||
LineChartBarData _line(List<FlSpot> spots, Color color,
|
||
{bool dashed = false, double width = 2.0, bool fill = false}) =>
|
||
LineChartBarData(
|
||
spots: spots,
|
||
isCurved: true,
|
||
curveSmoothness: 0.25,
|
||
color: color,
|
||
barWidth: width,
|
||
dotData: const FlDotData(show: false),
|
||
dashArray: dashed ? [4, 5] : null,
|
||
// Aucun remplissage par défaut depuis l'empilement : un voile, même à 7 %,
|
||
// recouvre les bandes et fausse leurs teintes. La consommation est une LIGNE
|
||
// au-dessus de la pile, pas une surface de plus.
|
||
belowBarData: BarAreaData(
|
||
show: fill && !dashed,
|
||
color: color.withValues(alpha: 0.07),
|
||
),
|
||
);
|
||
|
||
/// **Les périodes d'une vue en barres, regroupées par période CIVILE.**
|
||
///
|
||
/// 🔴 Ne pas se contenter des paires d'échantillons consécutifs. La box a changé de fuseau
|
||
/// le 2026-09-06 (`Europe/London` → `Europe/Paris`) et sa frontière de journée s'est
|
||
/// déplacée d'une heure : l'historique porte **deux échantillons pour le 06/09**, à 00:00
|
||
/// et à 01:00. Sur le graphe, ça donnait huit barres pour sept jours, deux étiquettes
|
||
/// « 6/9 », et une « journée » d'une heure à côté de journées entières.
|
||
///
|
||
/// La couture est **permanente** — elle est dans les données passées, pas dans une
|
||
/// requête. Tout écran qui compare des périodes doit donc grouper par période civile,
|
||
/// jamais par indices consécutifs.
|
||
///
|
||
/// On garde le **premier** échantillon de chaque période, et l'énergie d'une période est
|
||
/// la différence avec le premier de la suivante. Aucune énergie n'est perdue : la sliver
|
||
/// d'une heure est absorbée dans la journée à laquelle elle appartient.
|
||
List<({PowerBalanceEntry a, PowerBalanceEntry b})> _periodes() {
|
||
if (_data.length < 2) return const [];
|
||
final parPeriode = <String, PowerBalanceEntry>{};
|
||
for (final e in _data) {
|
||
final k = _clePeriode(e.timestamp);
|
||
// `putIfAbsent` : on garde le PREMIER de la période, pas le dernier.
|
||
parPeriode.putIfAbsent(k, () => e);
|
||
}
|
||
final cles = parPeriode.keys.toList()..sort();
|
||
return [
|
||
for (int i = 1; i < cles.length; i++)
|
||
(a: parPeriode[cles[i - 1]]!, b: parPeriode[cles[i]]!)
|
||
];
|
||
}
|
||
|
||
/// La clé de regroupement, selon la granularité de l'onglet. Triable en ordre
|
||
/// lexicographique — d'où le zéro devant.
|
||
String _clePeriode(DateTime t) {
|
||
final tab = _tabs[_tabIdx];
|
||
String d2(int v) => v.toString().padLeft(2, '0');
|
||
return switch (tab.sampleRate) {
|
||
'SampleRate1Hour' => '${t.year}-${d2(t.month)}-${d2(t.day)}T${d2(t.hour)}',
|
||
'SampleRate1Month' => '${t.year}-${d2(t.month)}',
|
||
_ => '${t.year}-${d2(t.month)}-${d2(t.day)}',
|
||
};
|
||
}
|
||
|
||
// ── ③ Barres d'ÉNERGIE — vues Jour / Semaine / Mois / Année ────────────────
|
||
|
||
/// Une période, deux barres empilées, et le bloc qu'elles partagent.
|
||
///
|
||
/// ## Pourquoi pas la production sous l'axe
|
||
///
|
||
/// Un diagramme divergent — consommation au-dessus, production en dessous — est une
|
||
/// convention défendable, et je l'ai écartée pour une raison propre à ce dépôt : la box
|
||
/// publie déjà `production` avec un signe négatif, et ce défaut nous a coûté deux écrans
|
||
/// et trois jours. Dessiner la production **sous l'axe** rendrait une capture d'écran
|
||
/// correcte indiscernable de ce bug. Une production n'est pas une quantité négative ;
|
||
/// l'afficher comme telle entretient exactement la confusion qu'on vient de payer.
|
||
///
|
||
/// ## Ce que les deux barres disent, et ce que le bloc commun ajoute
|
||
///
|
||
/// ```
|
||
/// Production = autoconsommé + exporté
|
||
/// Consommation = autoconsommé + soutiré
|
||
/// ```
|
||
///
|
||
/// Le segment **autoconsommé** est le même des deux côtés, à la même couleur : c'est ce
|
||
/// qui a été produit ET gardé. Deux barres simples (production, consommation) diraient
|
||
/// laquelle est la plus haute ; celles-ci disent en plus **où est passée la différence**,
|
||
/// et c'est la question qu'on se pose devant un bilan.
|
||
///
|
||
/// ## L'énergie vient des CUMULS, jamais des puissances
|
||
///
|
||
/// Chaque barre est une différence de compteurs entre deux échantillons. Une période dont
|
||
/// l'une des deux bornes ne publie pas ses cumuls n'a **pas de barre** — pas une barre à
|
||
/// zéro : « la box ne l'a pas publié » et « rien n'a été consommé » sont deux états, et
|
||
/// une nuit à 0 kWh serait indiscernable d'un trou.
|
||
Widget _buildEnergyBars() {
|
||
if (_loading || _noData || _data.length < 2) return const SizedBox.shrink();
|
||
|
||
final groupes = <BarChartGroupData>[];
|
||
var maxKwh = 0.5;
|
||
final orange = EtmTokens.selfUse;
|
||
final jaune = EtmTokens.amber;
|
||
// Le soutiré passe du rouge au BLEU (demande de Patrick, 2026-09-09) : avec l'orange
|
||
// partagé sur les deux barres, le rouge en faisait une image trop chaude, et le bleu
|
||
// est déjà la couleur de la maison partout ailleurs dans l'app.
|
||
final bleu = EtmTokens.blue;
|
||
|
||
final periodes = _periodes();
|
||
for (int i = 0; i < periodes.length; i++) {
|
||
final a = periodes[i].a, b = periodes[i].b;
|
||
if (!a.totalsPresent || !b.totalsPresent) continue;
|
||
double delta(double x, double y) => max(0.0, y - x);
|
||
final prod = delta(a.totalProductionKwh, b.totalProductionKwh);
|
||
final conso = delta(a.totalConsumptionKwh, b.totalConsumptionKwh);
|
||
final exp = delta(a.totalReturnKwh, b.totalReturnKwh);
|
||
final imp = delta(a.totalAcquisitionKwh, b.totalAcquisitionKwh);
|
||
// Autoconsommé = ce qui a été produit et n'est pas parti. Borné à la production :
|
||
// un export supérieur à la production est une incohérence de compteurs, pas une
|
||
// autoconsommation négative.
|
||
final self = (prod - exp).clamp(0.0, prod);
|
||
|
||
maxKwh = [maxKwh, prod, conso].reduce(max);
|
||
groupes.add(BarChartGroupData(x: i, barsSpace: 2, barRods: [
|
||
BarChartRodData(
|
||
toY: prod,
|
||
width: _largeurBarre(),
|
||
borderRadius: const BorderRadius.vertical(top: Radius.circular(2)),
|
||
rodStackItems: [
|
||
BarChartRodStackItem(0, self, orange),
|
||
BarChartRodStackItem(self, prod, jaune),
|
||
],
|
||
color: jaune,
|
||
),
|
||
BarChartRodData(
|
||
toY: conso,
|
||
width: _largeurBarre(),
|
||
borderRadius: const BorderRadius.vertical(top: Radius.circular(2)),
|
||
rodStackItems: [
|
||
BarChartRodStackItem(0, self.clamp(0.0, conso), orange),
|
||
BarChartRodStackItem(self.clamp(0.0, conso), conso, bleu),
|
||
],
|
||
color: bleu,
|
||
),
|
||
]));
|
||
}
|
||
if (groupes.isEmpty) return const SizedBox.shrink();
|
||
|
||
final labelStyle = EtmTokens.sans(size: 9, color: EtmTokens.mutedOf(context));
|
||
return BarChart(
|
||
BarChartData(
|
||
// ⚠️ `barGroups` était OUBLIÉ : les groupes étaient calculés, `maxY` en découlait —
|
||
// donc l'axe montait bien à la bonne hauteur — et aucune barre n'était dessinée.
|
||
// Un graphe vide sous un axe juste, ce qui rend le défaut difficile à lire : tout
|
||
// ce qui entoure les données avait l'air correct.
|
||
barGroups: groupes,
|
||
maxY: maxKwh * 1.15,
|
||
alignment: BarChartAlignment.spaceAround,
|
||
gridData: FlGridData(
|
||
show: true,
|
||
drawVerticalLine: false,
|
||
getDrawingHorizontalLine: (_) =>
|
||
FlLine(color: EtmTokens.lineOf(context), strokeWidth: 1),
|
||
),
|
||
borderData: FlBorderData(show: false),
|
||
barTouchData: BarTouchData(
|
||
touchTooltipData: BarTouchTooltipData(
|
||
getTooltipColor: (_) => EtmTokens.navy.withValues(alpha: 0.9),
|
||
getTooltipItem: (g, _, rod, __) => BarTooltipItem(
|
||
'${rod.toY.toStringAsFixed(2)} kWh',
|
||
EtmTokens.mono(size: 11, color: Colors.white),
|
||
),
|
||
),
|
||
),
|
||
titlesData: FlTitlesData(
|
||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||
rightTitles:
|
||
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||
leftTitles: AxisTitles(
|
||
sideTitles: SideTitles(
|
||
showTitles: true,
|
||
// 54 px et pas de décimale au-dessus de 10 : « 80.0kWh » ne tenait pas dans
|
||
// 46 px et se coupait en « 80.0kW / h ». Une unité coupée en deux lignes se
|
||
// lit comme une autre unité.
|
||
reservedSize: 54,
|
||
getTitlesWidget: (v, meta) => v == meta.max
|
||
? const SizedBox.shrink()
|
||
: SideTitleWidget(
|
||
meta: meta,
|
||
child: Text(
|
||
'${v >= 10 ? v.toStringAsFixed(0) : v.toStringAsFixed(1)} kWh',
|
||
style: labelStyle)),
|
||
),
|
||
),
|
||
bottomTitles: AxisTitles(
|
||
sideTitles: SideTitles(
|
||
showTitles: true,
|
||
reservedSize: 22,
|
||
interval: _xIntervalPeriodes(),
|
||
getTitlesWidget: (v, meta) {
|
||
final p = _periodes();
|
||
final idx = v.round();
|
||
if (idx < 0 || idx >= p.length) return const SizedBox.shrink();
|
||
// Étiquetée par le DÉBUT de la période : « 6/9 » désigne la journée du 6,
|
||
// pas l'instant où on a arrêté de la compter.
|
||
return SideTitleWidget(
|
||
meta: meta,
|
||
child: Text(_fmtTime(p[idx].a.timestamp), style: labelStyle));
|
||
},
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Des barres lisibles quel qu'en soit le nombre : 24 heures, 30 jours ou 12 mois ne
|
||
/// tiennent pas à la même largeur, et une barre de 1 px n'est plus une barre.
|
||
/// Espacement des étiquettes, compté en PÉRIODES et non en échantillons.
|
||
double _xIntervalPeriodes() {
|
||
final n = _periodes().length;
|
||
return n > 8 ? (n / 6).ceilToDouble() : 1.0;
|
||
}
|
||
|
||
double _largeurBarre() {
|
||
final n = _periodes().length;
|
||
if (n <= 14) return 9;
|
||
if (n <= 32) return 5;
|
||
return 3;
|
||
}
|
||
|
||
// ── ④ Répartition de la CONSOMMATION, en énergie ───────────────────────────
|
||
|
||
/// Où est passée la consommation de chaque période — une barre empilée par période.
|
||
///
|
||
/// Le pendant en énergie du graphe par consommateur : là-bas des puissances instantanées,
|
||
/// ici des **kWh accumulés**, obtenus par différence du compteur cumulé de chaque
|
||
/// appareil (`totalConsumption`), jamais par intégration d'une puissance.
|
||
///
|
||
/// Le **reste du foyer** ferme la barre à la consommation totale de la période. Il est
|
||
/// licite pour la même raison que dans la vue Heures : chaque part est un compteur dédié,
|
||
/// le total est le compteur du foyer, la soustraction ne porte que du mesuré. Et un reste
|
||
/// **négatif** n'est pas raboté — il prouverait qu'un sous-compteur n'est pas dans le
|
||
/// périmètre du total.
|
||
///
|
||
/// ⚠️ Une charge dont le compteur n'a pas bougé sur la période donne **0 kWh, qui est une
|
||
/// mesure**. Une charge dont le cumul manque à l'une des deux bornes n'a **rien** — pas un
|
||
/// zéro. Les deux se ressemblent à l'écran et ne veulent pas dire la même chose.
|
||
Widget _buildConsumerEnergyBars() {
|
||
if (_loading || _noData || _data.length < 2 || _conso.estVide) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
final couleurs = _couleursSeries(context);
|
||
final groupes = <BarChartGroupData>[];
|
||
var maxKwh = 0.5;
|
||
|
||
final periodes = _periodes();
|
||
for (int i = 0; i < periodes.length; i++) {
|
||
final a = periodes[i].a, b = periodes[i].b;
|
||
if (!a.totalsPresent || !b.totalsPresent) continue;
|
||
final total =
|
||
max(0.0, b.totalConsumptionKwh - a.totalConsumptionKwh);
|
||
|
||
final parts = <(Color, double)>[];
|
||
var somme = 0.0;
|
||
for (final serie in _conso.series) {
|
||
final e = serie.energieEntre(a.timestamp, b.timestamp);
|
||
if (e == null) continue; // pas de cumul : pas de part, pas un zéro
|
||
somme += e;
|
||
parts.add((couleurs[serie.thingId] ?? EtmTokens.mutedOf(context), e));
|
||
}
|
||
final reste = total - somme;
|
||
|
||
final piles = <BarChartRodStackItem>[];
|
||
var bas = 0.0;
|
||
for (final (c, v) in parts) {
|
||
piles.add(BarChartRodStackItem(bas, bas + v, c));
|
||
bas += v;
|
||
}
|
||
piles.add(BarChartRodStackItem(bas, bas + reste, EtmTokens.brand));
|
||
|
||
maxKwh = max(maxKwh, max(total, bas + reste));
|
||
groupes.add(BarChartGroupData(x: i, barRods: [
|
||
BarChartRodData(
|
||
toY: bas + reste,
|
||
width: _largeurBarre() * 1.6,
|
||
borderRadius: const BorderRadius.vertical(top: Radius.circular(2)),
|
||
rodStackItems: piles,
|
||
color: EtmTokens.brand,
|
||
),
|
||
]));
|
||
}
|
||
if (groupes.isEmpty) return const SizedBox.shrink();
|
||
|
||
final labelStyle = EtmTokens.sans(size: 9, color: EtmTokens.mutedOf(context));
|
||
return BarChart(
|
||
BarChartData(
|
||
barGroups: groupes,
|
||
maxY: maxKwh * 1.15,
|
||
alignment: BarChartAlignment.spaceAround,
|
||
gridData: FlGridData(
|
||
show: true,
|
||
drawVerticalLine: false,
|
||
getDrawingHorizontalLine: (_) =>
|
||
FlLine(color: EtmTokens.lineOf(context), strokeWidth: 1),
|
||
),
|
||
borderData: FlBorderData(show: false),
|
||
barTouchData: BarTouchData(
|
||
touchTooltipData: BarTouchTooltipData(
|
||
getTooltipColor: (_) => EtmTokens.navy.withValues(alpha: 0.9),
|
||
getTooltipItem: (g, _, rod, __) => BarTooltipItem(
|
||
'${rod.toY.toStringAsFixed(2)} kWh',
|
||
EtmTokens.mono(size: 11, color: Colors.white),
|
||
),
|
||
),
|
||
),
|
||
titlesData: FlTitlesData(
|
||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||
rightTitles:
|
||
const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||
leftTitles: AxisTitles(
|
||
sideTitles: SideTitles(
|
||
showTitles: true,
|
||
reservedSize: 54,
|
||
getTitlesWidget: (v, meta) => v == meta.max
|
||
? const SizedBox.shrink()
|
||
: SideTitleWidget(
|
||
meta: meta,
|
||
child: Text(
|
||
'${v >= 10 ? v.toStringAsFixed(0) : v.toStringAsFixed(1)} kWh',
|
||
style: labelStyle)),
|
||
),
|
||
),
|
||
bottomTitles: AxisTitles(
|
||
sideTitles: SideTitles(
|
||
showTitles: true,
|
||
reservedSize: 22,
|
||
interval: _xIntervalPeriodes(),
|
||
getTitlesWidget: (v, meta) {
|
||
final p = _periodes();
|
||
final idx = v.round();
|
||
if (idx < 0 || idx >= p.length) return const SizedBox.shrink();
|
||
// Étiquetée par le DÉBUT de la période : « 6/9 » désigne la journée du 6,
|
||
// pas l'instant où on a arrêté de la compter.
|
||
return SideTitleWidget(
|
||
meta: meta,
|
||
child: Text(_fmtTime(p[idx].a.timestamp), style: labelStyle));
|
||
},
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
// ── ② Empilement par CONSOMMATEUR ──────────────────────────────────────────
|
||
|
||
/// Qui consomme, dans le temps — une aire par charge déclarée, plus le reste du foyer.
|
||
///
|
||
/// ## Le RESTE est licite ici, et il ne l'était pas sur le Sankey
|
||
///
|
||
/// `CLAUDE.md` interdit « pas de RESTE sans mesure exhaustive » — la règle est née d'un
|
||
/// Sankey qui calculait « Autres = 22 % » à partir de parts **inventées**. Ici chaque
|
||
/// part est un **compteur dédié** et le total est le compteur du foyer : la soustraction
|
||
/// ne porte que des grandeurs mesurées.
|
||
///
|
||
/// 🔴 **Et elle porte son propre contrôle** : si un sous-compteur n'est pas réellement à
|
||
/// l'intérieur du total du foyer — une borne sur une alimentation séparée, par exemple —
|
||
/// le reste devient **négatif**. On ne le rabote pas à zéro : un reste négatif est la
|
||
/// preuve d'un périmètre faux, et l'écraser effacerait la seule chose qui le dise.
|
||
/// « Qu'est-ce que ce contrôle aurait attrapé s'il avait échoué ? » — exactement ça.
|
||
/// La couleur de chaque série, **rang dans son domaine compris**.
|
||
///
|
||
/// Calculée à un seul endroit pour que la légende et le tracé ne puissent pas diverger :
|
||
/// deux sources de couleur, c'est une légende qui finit par désigner la mauvaise bande.
|
||
Map<String, Color> _couleursSeries(BuildContext context) {
|
||
final rangs = <String, int>{};
|
||
final out = <String, Color>{};
|
||
for (final s in _conso.series) {
|
||
final base = EtmTokens.domaineOf(context, s.domaine);
|
||
if (base == null) {
|
||
out[s.thingId] = EtmTokens.mutedOf(context);
|
||
continue;
|
||
}
|
||
final cle = s.domaine ?? '';
|
||
final rang = rangs[cle] ?? 0;
|
||
rangs[cle] = rang + 1;
|
||
out[s.thingId] = EtmTokens.nuance(base, rang);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
Widget _buildConsumerChart({bool axeGauche = true}) {
|
||
if (_loading || _noData || _data.isEmpty) return const SizedBox.shrink();
|
||
if (_conso.estVide) return const SizedBox.shrink();
|
||
|
||
final series = _conso.series;
|
||
final n = _data.length;
|
||
|
||
// ⚠️ **Alignement par INSTANT, pas par index** — et une série peut légitimement
|
||
// commencer au milieu du graphe.
|
||
//
|
||
// La V2C Trydan a reçu son comptage le 2026-09-08 à 10:57 (`powersync-plugin-v2c
|
||
// +etm14`) : sur une fenêtre de 24 h elle rend **1 point contre 45** aux autres. Le
|
||
// filtre précédent (« même longueur, sinon on ignore ») l'aurait écartée du tracé
|
||
// **en la laissant dans la légende** — une légende qui promet une bande qu'on ne
|
||
// dessine pas, le défaut même qu'on venait de corriger sur l'autre graphe.
|
||
//
|
||
// Une série ne contribue donc que **là où elle a des points**. Avant son premier
|
||
// point, elle n'apporte rien à la pile — et l'énergie qu'elle a consommée est
|
||
// comptée dans le reste du foyer, ce qui est **arithmétiquement juste** : le total
|
||
// reste le compteur du foyer, seule la RÉPARTITION est incomplète. C'est ce que dit
|
||
// la note, et c'est une phrase différente de « elle a consommé zéro ».
|
||
final index = {
|
||
for (final s in series)
|
||
s.thingId: {
|
||
for (final p in s.points) p.t.millisecondsSinceEpoch: p.powerW
|
||
}
|
||
};
|
||
double? valeurAt(ConsumerSeries s, int i) =>
|
||
index[s.thingId]?[_data[i].timestamp.millisecondsSinceEpoch];
|
||
|
||
// Cumuls empilés, base = reste du foyer.
|
||
final restes = <double>[];
|
||
for (int i = 0; i < n; i++) {
|
||
final somme =
|
||
series.fold<double>(0, (a, s) => a + (valeurAt(s, i) ?? 0));
|
||
restes.add(_data[i].consumptionW.abs() - somme);
|
||
}
|
||
final resteNegatif = restes.any((v) => v < -1);
|
||
final alignees = series;
|
||
|
||
final couleurs = _couleursSeries(context);
|
||
final bandes = <({String label, Color couleur, List<double> valeurs})>[
|
||
(
|
||
label: 'Reste du foyer',
|
||
couleur: EtmTokens.brand,
|
||
valeurs: restes
|
||
),
|
||
for (final s in alignees)
|
||
(
|
||
label: s.label,
|
||
couleur: couleurs[s.thingId] ?? EtmTokens.mutedOf(context),
|
||
valeurs: [for (int i = 0; i < n; i++) valeurAt(s, i) ?? 0.0]
|
||
),
|
||
];
|
||
|
||
// Cumul croissant, puis tracé du HAUT vers le BAS : chaque aire remplie jusqu'à zéro,
|
||
// la suivante repeint par-dessus. C'est ce qui donne l'empilement sans `betweenBars`.
|
||
final cumuls = <List<double>>[];
|
||
for (int b = 0; b < bandes.length; b++) {
|
||
final prec = b == 0 ? List<double>.filled(n, 0.0) : cumuls[b - 1];
|
||
cumuls.add([for (int i = 0; i < n; i++) prec[i] + bandes[b].valeurs[i]]);
|
||
}
|
||
|
||
final maxKw = cumuls.isEmpty
|
||
? 1.0
|
||
: cumuls.last.map((v) => v / 1000).fold<double>(0.5, max);
|
||
final yMax = max(maxKw * 1.15, 0.5);
|
||
final labelStyle = EtmTokens.sans(size: 9, color: EtmTokens.mutedOf(context));
|
||
|
||
return Stack(children: [
|
||
LineChart(
|
||
LineChartData(
|
||
clipData: const FlClipData.all(),
|
||
minX: _fenetreDebut,
|
||
maxX: _fenetreFin,
|
||
minY: resteNegatif ? -yMax * 0.25 : 0,
|
||
maxY: yMax,
|
||
gridData: FlGridData(
|
||
show: true,
|
||
drawVerticalLine: false,
|
||
horizontalInterval: yMax / 4,
|
||
getDrawingHorizontalLine: (_) =>
|
||
FlLine(color: EtmTokens.lineOf(context), strokeWidth: 1),
|
||
),
|
||
borderData: FlBorderData(show: false),
|
||
titlesData: FlTitlesData(
|
||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||
leftTitles: AxisTitles(
|
||
sideTitles: SideTitles(
|
||
showTitles: axeGauche,
|
||
reservedSize: axeGauche ? 46 : 0,
|
||
interval: yMax / 4,
|
||
getTitlesWidget: (v, meta) => v == meta.max
|
||
? const SizedBox.shrink()
|
||
: SideTitleWidget(
|
||
meta: meta,
|
||
child: Text('${v.toStringAsFixed(1)}kW', style: labelStyle)),
|
||
),
|
||
),
|
||
bottomTitles: AxisTitles(
|
||
sideTitles: SideTitles(
|
||
showTitles: true,
|
||
reservedSize: 22,
|
||
interval: _xInterval(),
|
||
getTitlesWidget: (v, meta) {
|
||
final idx = v.round();
|
||
if (idx < 0 || idx >= _data.length) return const SizedBox.shrink();
|
||
return SideTitleWidget(
|
||
meta: meta,
|
||
child:
|
||
Text(_fmtTime(_data[idx].timestamp), style: labelStyle));
|
||
},
|
||
),
|
||
),
|
||
),
|
||
lineBarsData: [
|
||
for (int b = bandes.length - 1; b >= 0; b--)
|
||
LineChartBarData(
|
||
spots: [
|
||
for (int i = 0; i < n; i++)
|
||
FlSpot(i.toDouble(), cumuls[b][i] / 1000)
|
||
],
|
||
isCurved: false,
|
||
color: bandes[b].couleur,
|
||
barWidth: 1.2,
|
||
dotData: const FlDotData(show: false),
|
||
belowBarData: BarAreaData(
|
||
show: true,
|
||
color: bandes[b].couleur.withValues(alpha: 0.55)),
|
||
),
|
||
],
|
||
lineTouchData: const LineTouchData(enabled: false),
|
||
),
|
||
),
|
||
]);
|
||
}
|
||
|
||
/// Ce que le graphe des consommateurs **ne peut pas** montrer, nommé sous lui.
|
||
///
|
||
/// Une charge absente de l'historique ne se dessine pas à zéro : une ligne plate
|
||
/// affirmerait « elle n'a rien tiré », quand la vérité est « elle n'a pas de passé ».
|
||
/// Vu sur `.75` : la V2C Trydan ne déclare pas d'interface `smartmeter`, donc elle
|
||
/// n'entre pas dans `GetThingPowerLogs` — alors que sa puissance instantanée est lisible.
|
||
Widget _notesConsommateurs() {
|
||
final manquants = _conso.sansHistorique;
|
||
|
||
// **Une série qui COMMENCE au milieu du graphe.**
|
||
//
|
||
// Cas rencontré le 2026-09-08 : la V2C a reçu son comptage à 10:57, donc sur 24 h
|
||
// elle rend un point sur quarante-cinq. Le total de la pile reste juste — le foyer
|
||
// est mesuré de bout en bout — mais la RÉPARTITION ne vaut qu'à partir de là. Le
|
||
// taire ferait lire « cette charge n'a rien tiré avant », qui est le mensonge d'hier
|
||
// sous une autre forme : ce n'est plus la présence de la série qui est en jeu, c'est
|
||
// son début.
|
||
final debut = _data.isEmpty ? null : _data.first.timestamp;
|
||
final tardives = <(String, DateTime)>[
|
||
for (final s in _conso.series)
|
||
if (s.points.isNotEmpty &&
|
||
debut != null &&
|
||
s.points.first.t.isAfter(debut.add(const Duration(minutes: 2))))
|
||
(s.label, s.points.first.t)
|
||
];
|
||
|
||
if (manquants.isEmpty && tardives.isEmpty && !_conso.signeIndetermine) {
|
||
return const SizedBox.shrink();
|
||
}
|
||
return Padding(
|
||
padding: const EdgeInsets.only(top: 8),
|
||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
if (manquants.isNotEmpty)
|
||
_NoteEcran(
|
||
icon: Icons.timeline_rounded,
|
||
texte: manquants.length == 1
|
||
? '${manquants.first} : pas d\'historique de puissance — cet appareil ne '
|
||
'publie pas de comptage. Sa puissance instantanée reste lisible.'
|
||
: '${manquants.join(', ')} : pas d\'historique de puissance.',
|
||
),
|
||
for (final (label, t) in tardives)
|
||
_NoteEcran(
|
||
icon: Icons.start_rounded,
|
||
texte: '$label n\'est mesurée que depuis '
|
||
'${t.hour.toString().padLeft(2, '0')}:'
|
||
'${t.minute.toString().padLeft(2, '0')} : avant, sa consommation est '
|
||
'comptée dans le reste du foyer. Le total est juste, la répartition ne '
|
||
'l\'est qu\'à partir de cette heure.',
|
||
),
|
||
if (_conso.signeIndetermine)
|
||
_NoteEcran(
|
||
icon: Icons.help_outline_rounded,
|
||
texte: 'Tous les sous-compteurs lisent 0 W sur la période. C\'est une mesure, '
|
||
'pas un trou — mais tant qu\'aucune valeur non nulle n\'est vue, la '
|
||
'convention de signe de ces compteurs reste invérifiée.',
|
||
),
|
||
]),
|
||
);
|
||
}
|
||
|
||
// ── Helpers ───────────────────────────────────────────────────────────────
|
||
|
||
/// L'espacement des étiquettes de l'axe du bas, calculé sur ce qui est **visible**.
|
||
///
|
||
/// Le calculer sur le jeu entier donnerait six étiquettes réparties sur 24 h alors que la
|
||
/// fenêtre n'en montre que douze : on en verrait trois, mal placées. L'axe doit suivre le
|
||
/// cadrage, pas les données.
|
||
double _xInterval() {
|
||
final visibles = (_fenetreFin - _fenetreDebut).round();
|
||
final n = visibles > 0 ? visibles : _data.length;
|
||
return n > 8 ? (n / 6).ceilToDouble() : 1.0;
|
||
}
|
||
|
||
/// Les valeurs sont **déjà en kWh** : on descend vers le Wh pour les petites, on ne
|
||
/// remonte pas. L'ancienne version divisait par mille des kWh — d'où « 78 Wh » pour une
|
||
/// journée entière.
|
||
|
||
String _fmtTime(DateTime t) => _tabs[_tabIdx].showTime
|
||
? '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'
|
||
: '${t.day}/${t.month}';
|
||
}
|
||
|
||
// ─────────────────────────── KPI section ───────────────────────────────────────
|
||
|
||
/// **Les six tuiles du haut — la JOURNÉE, et rien d'autre.**
|
||
///
|
||
/// Elles lisaient `service.energyData` : `dayProductionWh` y porte en réalité le compteur
|
||
/// **cumulé depuis l'origine** de l'installation. Vu à l'écran le 2026-09-08 :
|
||
/// « Production 62946.74… » au-dessus d'un graphe qui montre 24 heures. Et « Gains
|
||
/// 8789.84 € » n'avait aucune source du tout — un prix du kWh que personne n'a saisi.
|
||
///
|
||
/// Elles lisent maintenant [DailyBalance], le même objet que le tableau de bord : une
|
||
/// différence de compteurs sur la fenêtre `periodStartedAt → maintenant`, publiée par la
|
||
/// box et non dérivée d'un fuseau.
|
||
///
|
||
/// ## Chaque taux porte son dénominateur
|
||
///
|
||
/// Décidé après le 2026-09-08 : « 70 % » se lisait comme une bonne matinée alors que
|
||
/// l'installation avait produit **79 Wh** et acheté 6,4 kWh. Le nombre était exact ; la
|
||
/// question à laquelle il répond n'intéressait personne.
|
||
///
|
||
/// Un seuil aurait demandé une constante inventée — et celle que j'avais proposée
|
||
/// (0,05 kWh) aurait laissé passer ces 79 Wh. « 70 % sur 79 Wh produits » se lit juste
|
||
/// toute l'année ; « 70 % » ne se lit juste que les bons jours. **Un ratio sans son
|
||
/// dénominateur est une part sans son tout.**
|
||
class _KpiSection extends StatelessWidget {
|
||
/// `null` = pas encore lu, ou journée non mesurable. Les deux se disent « — ».
|
||
final DailyBalance? bilan;
|
||
const _KpiSection({required this.bilan});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final b = bilan;
|
||
String kwh(double? v) => v == null ? '—' : '${v.toStringAsFixed(2)} kWh';
|
||
String pct(double? v) => v == null ? '—' : '${v.round()} %';
|
||
|
||
// « sur X produits » n'a de sens que s'il y a un X. Sans dénominateur, la tuile dit
|
||
// pourquoi elle n'a pas de taux — pas un zéro, pas un tiret muet.
|
||
final surProduction = b == null
|
||
? null
|
||
: b.productionKwh > 0
|
||
? 'sur ${_court(b.productionKwh)} produits'
|
||
: 'sans production';
|
||
final surConsommation = b == null
|
||
? null
|
||
: b.consumptionKwh > 0
|
||
? 'sur ${_court(b.consumptionKwh)} consommés'
|
||
: 'sans consommation';
|
||
|
||
return Column(children: [
|
||
Row(children: [
|
||
_KpiTile(
|
||
icon: Icons.wb_sunny_rounded,
|
||
color: EtmTokens.amber,
|
||
bgColor: EtmTokens.amberSoft,
|
||
label: 'Production',
|
||
value: kwh(b?.productionKwh),
|
||
),
|
||
const SizedBox(width: 14),
|
||
_KpiTile(
|
||
icon: Icons.home_rounded,
|
||
color: EtmTokens.blue,
|
||
bgColor: EtmTokens.blueSoft,
|
||
label: 'Consommation',
|
||
value: kwh(b?.consumptionKwh),
|
||
),
|
||
]),
|
||
const SizedBox(height: 14),
|
||
Row(children: [
|
||
_KpiTile(
|
||
icon: Icons.north_west_rounded,
|
||
color: EtmTokens.green,
|
||
bgColor: EtmTokens.greenSoft,
|
||
label: 'Vers réseau',
|
||
value: kwh(b?.exportKwh),
|
||
),
|
||
const SizedBox(width: 14),
|
||
_KpiTile(
|
||
icon: Icons.south_east_rounded,
|
||
color: EtmTokens.importColor,
|
||
bgColor: EtmTokens.amberSoft,
|
||
label: 'Depuis réseau',
|
||
value: kwh(b?.importKwh),
|
||
),
|
||
]),
|
||
const SizedBox(height: 14),
|
||
Row(children: [
|
||
_KpiTile(
|
||
icon: Icons.recycling_rounded,
|
||
color: EtmTokens.selfUse,
|
||
bgColor: EtmTokens.amberSoft,
|
||
label: 'Autoconsommation',
|
||
value: pct(b?.selfConsumptionRate),
|
||
detail: surProduction,
|
||
),
|
||
const SizedBox(width: 14),
|
||
_KpiTile(
|
||
icon: Icons.bolt_rounded,
|
||
color: EtmTokens.green,
|
||
bgColor: EtmTokens.greenSoft,
|
||
label: 'Autonomie',
|
||
value: pct(b?.autonomyRate),
|
||
detail: surConsommation,
|
||
),
|
||
]),
|
||
// L'identité des compteurs est le seul contrôle disponible sur cette fenêtre.
|
||
// Fausse, elle désigne un compteur cassé ou remis à zéro — et le bilan ne doit
|
||
// alors pas être présenté comme une mesure.
|
||
if (b != null && !b.identityHolds)
|
||
const _NoteEcran(
|
||
icon: Icons.rule_rounded,
|
||
texte: 'Les compteurs ne se recoupent pas sur la journée : production + soutiré '
|
||
'devrait égaler consommation + injecté. Un compteur a probablement été remis '
|
||
'à zéro.',
|
||
),
|
||
if (b != null && b.startsLate)
|
||
_NoteEcran(
|
||
icon: Icons.schedule_rounded,
|
||
texte: 'Journée partielle : la box ne publie rien avant '
|
||
'${b.from.hour.toString().padLeft(2, '0')}:'
|
||
'${b.from.minute.toString().padLeft(2, '0')}. Le bilan part de cette heure, '
|
||
'pas de minuit.',
|
||
),
|
||
]);
|
||
}
|
||
|
||
/// Une énergie courte : les watt-heures sous le kilowattheure, parce que « 0,08 kWh »
|
||
/// se lit comme un arrondi et « 79 Wh » comme une mesure.
|
||
static String _court(double kwh) =>
|
||
kwh < 1 ? '${(kwh * 1000).round()} Wh' : '${kwh.toStringAsFixed(1)} kWh';
|
||
}
|
||
|
||
class _KpiTile extends StatelessWidget {
|
||
final IconData icon;
|
||
final Color color;
|
||
final Color bgColor;
|
||
final String label;
|
||
final String value;
|
||
|
||
/// Ce dont la valeur est la fraction — `null` quand elle n'est fraction de rien.
|
||
final String? detail;
|
||
|
||
const _KpiTile({
|
||
required this.icon,
|
||
required this.color,
|
||
required this.bgColor,
|
||
this.detail,
|
||
required this.label,
|
||
required this.value,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Expanded(
|
||
child: Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: EtmTokens.surfaceOf(context),
|
||
borderRadius: BorderRadius.circular(EtmTokens.radius),
|
||
boxShadow: EtmTokens.cardShadow,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 36, height: 36,
|
||
decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(10)),
|
||
child: Icon(icon, color: color, size: 20),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// `scaleDown` dans une largeur BORNÉE (on est dans un `Expanded`) :
|
||
// il réduit seulement quand il le faut. Sans lui, « Autoconsommation »
|
||
// se coupait au milieu du mot — vu à l'écran le 2026-09-08.
|
||
FittedBox(
|
||
fit: BoxFit.scaleDown,
|
||
alignment: Alignment.centerLeft,
|
||
child: Text(label,
|
||
maxLines: 1,
|
||
style: EtmTokens.sans(
|
||
size: 11, color: EtmTokens.mutedOf(context))),
|
||
),
|
||
const SizedBox(height: 2),
|
||
Text(value,
|
||
style: EtmTokens.mono(size: 15, weight: FontWeight.w700, color: color),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis),
|
||
if (detail != null) ...[
|
||
const SizedBox(height: 2),
|
||
FittedBox(
|
||
fit: BoxFit.scaleDown,
|
||
alignment: Alignment.centerLeft,
|
||
child: Text(detail!,
|
||
maxLines: 1,
|
||
style: EtmTokens.sans(
|
||
size: 10, color: EtmTokens.mutedOf(context))),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────── Sélecteur période ─────────────────────────────────
|
||
|
||
class _PeriodSelector extends StatelessWidget {
|
||
final List<_Tab> tabs;
|
||
final int selectedIndex;
|
||
final ValueChanged<int> onSelect;
|
||
|
||
const _PeriodSelector({
|
||
required this.tabs,
|
||
required this.selectedIndex,
|
||
required this.onSelect,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
padding: const EdgeInsets.all(4),
|
||
decoration: BoxDecoration(
|
||
color: EtmTokens.surfaceOf(context),
|
||
borderRadius: BorderRadius.circular(EtmTokens.radius),
|
||
boxShadow: EtmTokens.cardShadow,
|
||
),
|
||
child: Row(
|
||
children: tabs.asMap().entries.map((e) {
|
||
final selected = selectedIndex == e.key;
|
||
return Expanded(
|
||
child: GestureDetector(
|
||
onTap: () => onSelect(e.key),
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 200),
|
||
padding: const EdgeInsets.symmetric(vertical: 9),
|
||
decoration: BoxDecoration(
|
||
color: selected ? EtmTokens.green : Colors.transparent,
|
||
borderRadius: BorderRadius.circular(EtmTokens.radius - 4),
|
||
),
|
||
child: Text(
|
||
e.value.label,
|
||
textAlign: TextAlign.center,
|
||
style: EtmTokens.sans(
|
||
size: 13,
|
||
weight: selected ? FontWeight.w600 : FontWeight.w400,
|
||
color: selected ? Colors.white : EtmTokens.mutedOf(context),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}).toList(),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────── Chip date ─────────────────────────────────────────
|
||
|
||
class _DateChip extends StatelessWidget {
|
||
final DateTime date;
|
||
final VoidCallback onClear;
|
||
const _DateChip({required this.date, required this.onClear});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: GestureDetector(
|
||
onTap: onClear,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
|
||
decoration: BoxDecoration(
|
||
color: EtmTokens.blueSoft,
|
||
borderRadius: BorderRadius.circular(99),
|
||
border: Border.all(color: EtmTokens.blue.withValues(alpha: 0.4)),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
const Icon(Icons.calendar_today_rounded, size: 12, color: EtmTokens.blue),
|
||
const SizedBox(width: 6),
|
||
Text(
|
||
'${date.day.toString().padLeft(2, '0')}/'
|
||
'${date.month.toString().padLeft(2, '0')}/'
|
||
'${date.year}',
|
||
style: EtmTokens.sans(size: 12, weight: FontWeight.w600, color: EtmTokens.blue),
|
||
),
|
||
const SizedBox(width: 6),
|
||
const Icon(Icons.close_rounded, size: 12, color: EtmTokens.blue),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────── Carte graphe ──────────────────────────────────────
|
||
|
||
class _EtmChartCard extends StatelessWidget {
|
||
final String title;
|
||
final String subtitle;
|
||
final List<Widget> legend;
|
||
final double height;
|
||
final bool loading;
|
||
final bool noData;
|
||
final Widget child;
|
||
|
||
const _EtmChartCard({
|
||
required this.title,
|
||
required this.subtitle,
|
||
required this.legend,
|
||
required this.height,
|
||
required this.loading,
|
||
required this.noData,
|
||
required this.child,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
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: [
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text(title,
|
||
style: EtmTokens.sans(size: 16, weight: FontWeight.w600)),
|
||
Text(subtitle,
|
||
style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 10),
|
||
Wrap(spacing: 14, runSpacing: 4, children: legend),
|
||
const SizedBox(height: 14),
|
||
SizedBox(
|
||
height: height,
|
||
child: loading
|
||
? Center(child: CircularProgressIndicator(
|
||
strokeWidth: 2, color: EtmTokens.green))
|
||
: noData
|
||
? Center(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.bar_chart_rounded,
|
||
color: EtmTokens.faintOf(context), size: 32),
|
||
const SizedBox(height: 8),
|
||
Text('Aucune donnée',
|
||
style: EtmTokens.sans(size: 13, color: EtmTokens.mutedOf(context))),
|
||
],
|
||
),
|
||
)
|
||
: child,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────── Légende ───────────────────────────────────────────
|
||
|
||
class _LegendDot extends StatelessWidget {
|
||
final Color color;
|
||
final String label;
|
||
final bool dashed;
|
||
|
||
const _LegendDot(this.color, this.label, {this.dashed = false});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
dashed
|
||
? Row(mainAxisSize: MainAxisSize.min, children: [
|
||
Container(width: 5, height: 2, color: color),
|
||
const SizedBox(width: 2),
|
||
Container(width: 5, height: 2, color: color),
|
||
])
|
||
: Container(
|
||
width: 14, height: 3,
|
||
decoration: BoxDecoration(
|
||
color: color, borderRadius: BorderRadius.circular(2))),
|
||
const SizedBox(width: 5),
|
||
Text(label, style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────── Météo placeholder ─────────────────────────────────
|
||
|
||
class _ForecastPlaceholder extends StatelessWidget {
|
||
const _ForecastPlaceholder();
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
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('Météo & prévision',
|
||
style: EtmTokens.sans(size: 16, weight: FontWeight.w600)),
|
||
Text('aujourd\'hui',
|
||
style: EtmTokens.sans(size: 12, color: EtmTokens.mutedOf(context))),
|
||
],
|
||
),
|
||
const SizedBox(height: 14),
|
||
Container(
|
||
padding: const EdgeInsets.all(16),
|
||
decoration: BoxDecoration(
|
||
color: EtmTokens.bgOf(context),
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Icon(Icons.cloud_outlined, color: EtmTokens.faintOf(context), size: 30),
|
||
const SizedBox(width: 14),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text('Prévisions non disponibles',
|
||
style: EtmTokens.sans(size: 13, weight: FontWeight.w500,
|
||
color: EtmTokens.mutedOf(context))),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
'Configurez un provider dans Tarifs & Héos '
|
||
'pour activer les prévisions PV et tarifaires.',
|
||
style: EtmTokens.sans(size: 11, color: EtmTokens.faintOf(context))),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|