« SOC % » figurait en dur dans la légende du premier graphe. Sur une installation sans batterie — celle du banc — l'écran annonçait donc une série qu'aucune donnée ne pouvait remplir. Même défaut que partout ailleurs (montrer ce qu'on n'a pas), pris par la légende au lieu du tracé. Le titre, le sous-titre et l'entrée de légende suivent maintenant la présence d'une batterie, et la note d'absence passe SOUS ce graphe-ci : c'est là que le SOC aurait été tracé, donc c'est là qu'il manque. `_sansBatterie` exige que les Things soient chargés : tant qu'ils ne le sont pas, « 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. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
1090 lines
41 KiB
Dart
1090 lines
41 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/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 (gauche) · % batterie (droite)'
|
||
: 'kW',
|
||
legend: [
|
||
_LegendDot(EtmTokens.amber, 'Production'),
|
||
_LegendDot(EtmTokens.blue, 'Consommation'),
|
||
_LegendDot(EtmTokens.green, 'Autoconso', dashed: true),
|
||
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();
|
||
prodSpots .add(FlSpot(x, prodW / 1000));
|
||
consoSpots.add(FlSpot(x, d.consumptionW.abs() / 1000));
|
||
// INTERIM (§2.2) — bande autoconso via la formule canonique du seam.
|
||
autoSpots .add(FlSpot(
|
||
x, EnergyRatiosInterim.selfConsumptionPower(prodW, d.acquisitionW) / 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),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
),
|
||
// ⚠️ **L'autoconsommation est en POINTILLÉ, et ce n'est pas décoratif.**
|
||
//
|
||
// Tant que rien n'est exporté, autoconsommation == production **exactement** :
|
||
// tracée pleine et par-dessus, elle recouvrait la production au pixel près. Vu à
|
||
// l'écran le 2026-09-08, juste après avoir corrigé le signe — la courbe ambre
|
||
// n'apparaissait qu'aux rares moments d'export, c'est-à-dire là où les deux
|
||
// diffèrent. Le signe était réparé et la production toujours invisible, pour une
|
||
// deuxième raison qui n'avait rien à voir avec la première.
|
||
//
|
||
// En pointillé elle se lit COMME une superposition : sur la bande ambre, des
|
||
// tirets verts disent « tout ce qui est produit est consommé sur place », et leur
|
||
// décrochage dit l'export. Deux courbes qui coïncident doivent pouvoir se voir
|
||
// coïncider — sinon l'une des deux ment par absence.
|
||
lineBarsData: [
|
||
_line(prodSpots, EtmTokens.amber),
|
||
_line(consoSpots, EtmTokens.blue),
|
||
_line(autoSpots, EtmTokens.green, dashed: true, width: 1.8),
|
||
// Le SOC passe en indigo : il était vert pointillé, comme l'autoconsommation
|
||
// ci-dessus. Sur une installation AVEC batterie les deux auraient été
|
||
// indiscernables — collision créée à l'instant où l'autoconso est passée en
|
||
// pointillé, et qu'aucun banc sans batterie n'aurait montrée.
|
||
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(),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
LineChartBarData _line(List<FlSpot> spots, Color color,
|
||
{bool dashed = false, double width = 2.0}) =>
|
||
LineChartBarData(
|
||
spots: spots,
|
||
isCurved: true,
|
||
curveSmoothness: 0.25,
|
||
color: color,
|
||
barWidth: width,
|
||
dotData: const FlDotData(show: false),
|
||
dashArray: dashed ? [4, 5] : null,
|
||
belowBarData: BarAreaData(
|
||
show: !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))),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|