etm-powersync-app/lib/screens/energy_screen.dart
Patrick Schurig 6d0ba26e07 feat(énergie): le graphe de flux EMPILE au lieu de superposer
Validé par Patrick. Sa proposition — production en jaune, autoconso en orange,
et des traits pleins — partait d'un bon constat, mais la couleur ne pouvait pas
régler le problème : superposées, autoconsommation et production sont égales 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 teintes. Le défaut est
géométrique, pas chromatique — et le pointillé posé avant n'était qu'un
contournement.

Empilées, le recouvrement devient impossible :

    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. Quand rien
n'est exporté la bande jaune a une hauteur nulle — vrai, et lisible d'un coup
d'œil. L'export devient une SURFACE au lieu d'un écart à mesurer à l'œil entre
deux traits.

Vérifié à l'écran : l'orange porte le fond de la journée, le jaune ne sort que
sur l'épisode d'export de fin d'après-midi.

Deux détails qui vont avec :
- `_aire` ne courbe pas (`isCurved: false`) — une spline peut passer sous zéro
  entre deux points, et une bande d'énergie sous l'axe affiche une grandeur qui
  n'existe pas ;
- les lignes n'ont plus de remplissage par défaut : un voile à 7 % recouvrait
  les bandes et faussait leurs teintes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 11:00:38 +02:00

1119 lines
43 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart: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/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))),
]),
);
}
}
class _Tab {
final String label;
final Duration range;
final String sampleRate;
final bool showTime;
const _Tab(this.label, this.range, this.sampleRate, {required this.showTime});
}
class EnergyScreen extends StatefulWidget {
const EnergyScreen({super.key});
@override
State<EnergyScreen> createState() => _EnergyScreenState();
}
class _EnergyScreenState extends State<EnergyScreen> {
static const _tabs = [
_Tab('Heures', Duration(hours: 24), 'SampleRate15Mins', showTime: true),
_Tab('Jour', Duration(days: 7), 'SampleRate1Hour', showTime: false),
_Tab('Semaine', Duration(days: 28), 'SampleRate1Day', showTime: false),
_Tab('Mois', Duration(days: 365), 'SampleRate1Week', showTime: false),
];
int _tabIdx = 0;
List<PowerBalanceEntry> _data = [];
List<HistoryEntry> _socData = [];
ConsumerHistory _conso = ConsumerHistory.vide;
/// 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>();
if (!_nymeaService!.connected) _nymeaService!.startSimulation();
_nymeaService!.addListener(_onServiceChangedForSoc);
_fetch();
});
_refreshTimer = Timer.periodic(const Duration(minutes: 5), (_) {
if (mounted) _fetch();
});
}
@override
void dispose() {
_refreshTimer?.cancel();
_nymeaService?.removeListener(_onServiceChangedForSoc);
super.dispose();
}
void _onServiceChangedForSoc() {
if (!mounted || _initialSocFetched || _loading) return;
if (_nymeaService?.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 to = _anchorDate();
final from = to.subtract(tab.range);
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),
]);
if (!mounted) return;
setState(() {
_data = results[0] as List<PowerBalanceEntry>;
_socData = results[1] as List<HistoryEntry>;
_conso = results[2] as ConsumerHistory;
_loading = false;
_noData = _data.isEmpty;
});
}
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(service: service),
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),
// 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: _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(
EtmTokens.domaineOf(context, s.domaine) ??
EtmTokens.mutedOf(context),
s.label),
],
height: 220,
loading: _loading,
noData: _noData,
child: _buildConsumerChart(),
),
_notesConsommateurs(),
const SizedBox(height: 14),
// Météo & prévision — placeholder
_ForecastPlaceholder(),
]),
),
),
],
),
);
},
);
}
// ── ① Line chart : puissances + SOC ────────────────────────────────────────
Widget _buildLineChart() {
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(),
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: true,
reservedSize: 46,
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) {
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),
),
);
// ── ② 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.
Widget _buildConsumerChart() {
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 INDEX, et seulement si les longueurs coïncident : les compteurs
// n'échantillonnent pas tous au même instant, et interpoler inventerait des points.
double valeurAt(ConsumerSeries s, int i) =>
s.points.length == n ? s.points[i].powerW : 0.0;
final alignees = series.where((s) => s.points.length == n).toList();
// Cumuls empilés, base = reste du foyer.
final restes = <double>[];
for (int i = 0; i < n; i++) {
final somme = alignees.fold<double>(0, (a, s) => a + valeurAt(s, i));
restes.add(_data[i].consumptionW.abs() - somme);
}
final resteNegatif = restes.any((v) => v < -1);
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: EtmTokens.domaineOf(context, s.domaine) ??
EtmTokens.mutedOf(context),
valeurs: [for (int i = 0; i < n; i++) valeurAt(s, i)]
),
];
// 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(),
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: true,
reservedSize: 46,
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;
if (manquants.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.',
),
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 ───────────────────────────────────────────────────────────────
double _xInterval() =>
_data.length > 8 ? (_data.length / 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 ───────────────────────────────────────
class _KpiSection extends StatelessWidget {
final NymeaService service;
const _KpiSection({required this.service});
@override
Widget build(BuildContext context) {
final d = service.energyData;
return Column(
children: [
Row(
children: [
_KpiTile(
icon: Icons.wb_sunny_rounded,
color: EtmTokens.amber,
bgColor: EtmTokens.amberSoft,
label: 'Production',
value: _kwh(d.dayProductionWh),
),
const SizedBox(width: 14),
_KpiTile(
icon: Icons.home_rounded,
color: EtmTokens.blue,
bgColor: EtmTokens.blueSoft,
label: 'Consommation',
value: _kwh(d.daySelfConsumptionWh),
),
],
),
const SizedBox(height: 14),
Row(
children: [
_KpiTile(
icon: Icons.recycling_rounded,
color: EtmTokens.green,
bgColor: EtmTokens.greenSoft,
label: 'Autoconsommation',
value: '${d.selfConsumptionRate.toStringAsFixed(0)}%',
),
const SizedBox(width: 14),
_KpiTile(
icon: Icons.euro_rounded,
color: EtmTokens.greenDark,
bgColor: EtmTokens.greenSoft,
label: 'Gains',
value: '${d.dayGains.toStringAsFixed(2)} €',
),
],
),
],
);
}
static String _kwh(double wh) => '${(wh / 1000).toStringAsFixed(2)} kWh';
}
class _KpiTile extends StatelessWidget {
final IconData icon;
final Color color;
final Color bgColor;
final String label;
final String value;
const _KpiTile({
required this.icon,
required this.color,
required this.bgColor,
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: [
Text(label, 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),
],
),
),
],
),
),
);
}
}
// ─────────────────────────── 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))),
],
),
),
],
),
),
],
),
);
}
}