La V2C et la borne simulée, toutes deux en `ev`, recevaient exactement le même violet : indiscernables dans l'empilement, et la légende répétait la même pastille. Vu à l'écran dès qu'une seconde borne est apparue. `EtmTokens.nuance` décline sur le RANG dans le domaine, donc une troisième borne prend sa teinte sans qu'on déclare quoi que ce soit. Le décalage porte sur la LUMINOSITÉ, pas sur la teinte : glisser la teinte finirait par heurter le domaine voisin, l'éclaircir garde la famille lisible. ⚠️ La série est bornée à quatre, et c'est explicite plutôt que subi : au-delà, l'écart ne se voit plus, et prétendre distinguer huit nuances d'un même violet serait une promesse que l'œil ne tient pas. Le rang 3 et le rang 9 rendent la même couleur — le signe qu'il faut alors autre chose que la couleur pour identifier, et c'est un choix d'écran, pas un réglage de palette. Un test le fige. La couleur est calculée à UN endroit, partagé par le tracé et la légende : deux sources de couleur, c'est une légende qui finit par désigner la mauvaise bande. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
1314 lines
52 KiB
Dart
1314 lines
52 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))),
|
||
]),
|
||
);
|
||
}
|
||
}
|
||
|
||
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;
|
||
|
||
/// 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;
|
||
|
||
/// 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 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),
|
||
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?;
|
||
_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(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),
|
||
|
||
// 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(
|
||
_couleursSeries(context)[s.thingId] ??
|
||
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.
|
||
/// 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() {
|
||
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(),
|
||
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;
|
||
|
||
// **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 ───────────────────────────────────────────────────────────────
|
||
|
||
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 ───────────────────────────────────────
|
||
|
||
/// **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))),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|