1) GLISSEMENT — diagnostic d'abord, et il a donné DEUX réponses. Le geste n'est PAS avalé par le ListView parent : les deux montages du test (section seule / section imbriquée) glissent identiquement. La cause du symptôme était bien que .75 n'a qu'une charge par domaine — rien à réordonner. Mais l'enquête a trouvé un vrai défaut au passage : le centre de la poignée tombait dans le vide de 3 px séparant l'icône de la pastille de rang, et ReorderableDragStartListener défère le test de collision à ses enfants. Une bande morte invisible au milieu d'une poignée qui paraît d'un seul tenant. Corrigé par un Container transparent. Tentative faite et ANNULÉE : rendre les domaines glissables aussi. Deux ReorderableListView imbriqués se disputent le geste et l'extérieur gagne — le glissement des cartes cassait. Les domaines gardent leurs flèches, et une charge seule dans son domaine n'offre plus de prise qui mènerait nulle part (avec l'infobulle qui renvoie au bon geste). Vérifié SUR L'APPAREIL, contre .75, avec les deux charges mises dans le même domaine : [chauffe-eau, pac-terrain] → [pac-terrain, chauffe-eau], brouillon abandonné sans rien écrire. Classement ecs/hvac rétabli ensuite. 2) SÉLECTEUR DE MÉCANISME — actif, et c'était le morceau délicat. LoadConfig est une union discriminée : écrire `adapter` sans nettoyer le reste produit une configuration que la box refuse EN BLOC, et le refus emporte toutes les charges de l'appel. D'où basculerMecanisme(), qui retire les clés du mécanisme quitté et pose celles du nouveau, en récupérant ce qui peut l'être — les deux premiers contacts deviennent SG1/SG2 dans l'encodage standard, les relais gardent leur puissance. Ce qui n'est PAS transposé : l'estimation d'un état SG-Ready n'est pas la puissance d'une résistance. La charge produite est alors incomplète, et c'est voulu — le formulaire réclame plutôt que d'inventer une valeur de plaque. La bascule franchit sameHardware() : confirmation préalable qui annonce la reconstruction et la durée de réarmement (900 s par défaut vers SG-Ready — la valeur d'une PAC réelle, pas celle du banc). Le mode fixed d'etmvariableload reste LISIBLE sans être proposé : bloc « Configuration héritée » en lecture seule, et conversion seulement sur geste explicite, avec le plafond que la déclaration en paliers ne porte pas. Ouvrir l'écran ne convertit personne. 3) CHIP DE DOMAINE — il ne rendait qu'une icône muette : le domaine d'une charge était illisible sur sa carte, alors qu'il décide de son groupe et de son rang. Il porte maintenant son libellé, et sa couleur suit le thème. 4) THÈME — les 11 derniers sites (pas 9) basculés. Trois cas : contexte disponible (main, ac_screen) ; peintre sans contexte, à qui la couleur est passée résolue (energy_flow_widget) ; tables statiques, où la teinte devient nullable et se résout au rendu (categoryInfoMap, favoris). Plus AUCUN jeton hors thème dans lib/. 5) ZONES — non entreprises, comme convenu. Tests 93/93, analyze 0 erreur. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
451 lines
15 KiB
Dart
451 lines
15 KiB
Dart
import 'dart:math' as math;
|
||
import 'package:flutter/material.dart';
|
||
import '../models/energy_data.dart';
|
||
import '../theme/etm_tokens.dart';
|
||
|
||
/// Diagramme de flux d'énergie animé — réutilisable Dashboard + Énergie.
|
||
///
|
||
/// Layout 4 nœuds : Solaire (haut centre), Maison (centre), Batterie (bas gauche),
|
||
/// Réseau (bas droite). Les lignes de flux sont animées via CustomPainter.
|
||
class EnergyFlowWidget extends StatefulWidget {
|
||
final EnergyData data;
|
||
const EnergyFlowWidget({super.key, required this.data});
|
||
|
||
@override
|
||
State<EnergyFlowWidget> createState() => _EnergyFlowWidgetState();
|
||
}
|
||
|
||
class _EnergyFlowWidgetState extends State<EnergyFlowWidget>
|
||
with SingleTickerProviderStateMixin {
|
||
late AnimationController _ctrl;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_ctrl = AnimationController(
|
||
vsync: this,
|
||
duration: const Duration(milliseconds: 1400),
|
||
)..repeat();
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_ctrl.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final data = widget.data;
|
||
return Container(
|
||
decoration: BoxDecoration(
|
||
color: EtmTokens.surfaceOf(context),
|
||
borderRadius: BorderRadius.circular(EtmTokens.radiusLg),
|
||
boxShadow: EtmTokens.cardShadow,
|
||
),
|
||
padding: const EdgeInsets.all(20),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// Header
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text('Flux d\'énergie en temps réel',
|
||
style: EtmTokens.sans(size: 17, weight: FontWeight.w600)),
|
||
Row(
|
||
children: [
|
||
Text('${data.temperature.toStringAsFixed(0)}°C',
|
||
style: EtmTokens.sans(size: 14, color: EtmTokens.mutedOf(context))),
|
||
const SizedBox(width: 4),
|
||
Icon(Icons.wb_cloudy_outlined, color: EtmTokens.faintOf(context), size: 20),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 16),
|
||
|
||
// Flow diagram
|
||
SizedBox(
|
||
height: 240,
|
||
child: LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final w = constraints.maxWidth;
|
||
final h = constraints.maxHeight;
|
||
final nodeR = w * 0.13; // rayon nœud ≈52px sur 400px
|
||
final nodes = _NodePositions(w: w, h: h, r: nodeR);
|
||
return AnimatedBuilder(
|
||
animation: _ctrl,
|
||
builder: (_, __) => Stack(
|
||
clipBehavior: Clip.none,
|
||
children: [
|
||
Positioned.fill(
|
||
child: CustomPaint(
|
||
painter: _FlowPainter(
|
||
nodes: nodes,
|
||
data: data,
|
||
t: _ctrl.value,
|
||
inactif: EtmTokens.lineOf(context),
|
||
),
|
||
),
|
||
),
|
||
_sunNode(center: nodes.sun, r: nodeR, data: data),
|
||
_homeNode(center: nodes.home, r: nodeR * 1.08, data: data),
|
||
_battNode(center: nodes.batt, r: nodeR, data: data),
|
||
_gridNode(context, center: nodes.grid, r: nodeR, data: data),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
|
||
// Footer mode optimal
|
||
const SizedBox(height: 6),
|
||
_OptimalBanner(),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────── Positions ─────────────────────────────────────────
|
||
|
||
class _NodePositions {
|
||
final double w, h, r;
|
||
late final Offset sun, home, batt, grid;
|
||
|
||
_NodePositions({required this.w, required this.h, required this.r}) {
|
||
sun = Offset(w * 0.5, h * 0.19);
|
||
home = Offset(w * 0.5, h * 0.73);
|
||
batt = Offset(w * 0.14, h * 0.73);
|
||
grid = Offset(w * 0.86, h * 0.73);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────── Painter ───────────────────────────────────────────
|
||
|
||
class _FlowPainter extends CustomPainter {
|
||
final _NodePositions nodes;
|
||
final EnergyData data;
|
||
final double t;
|
||
|
||
/// Gris des liens INACTIFS. Un peintre n'a pas de `BuildContext` : la couleur qui dépend
|
||
/// du thème doit donc lui être **passée**, résolue par le widget qui le construit.
|
||
/// Sans ça, les liens éteints restaient en gris clair sur fond sombre.
|
||
final Color inactif;
|
||
|
||
const _FlowPainter({
|
||
required this.nodes,
|
||
required this.data,
|
||
required this.t,
|
||
required this.inactif,
|
||
});
|
||
|
||
@override
|
||
void paint(Canvas canvas, Size size) {
|
||
final r = nodes.r;
|
||
|
||
// Sun → Home
|
||
_line(
|
||
canvas,
|
||
from: Offset(nodes.sun.dx, nodes.sun.dy + r),
|
||
to: Offset(nodes.home.dx, nodes.home.dy - r * 1.08),
|
||
color: data.pvPower > 0 ? EtmTokens.amber : inactif,
|
||
animated: data.pvPower > 0,
|
||
arrowDown: true,
|
||
);
|
||
|
||
// Home ↔ Battery
|
||
final battFlow = data.batteryPower;
|
||
if (battFlow > 0) {
|
||
// Charging: Home → Battery
|
||
_line(canvas,
|
||
from: Offset(nodes.home.dx - r * 1.08, nodes.home.dy),
|
||
to: Offset(nodes.batt.dx + r, nodes.batt.dy),
|
||
color: EtmTokens.green, animated: true, arrowLeft: true);
|
||
} else if (battFlow < 0) {
|
||
// Discharging: Battery → Home
|
||
_line(canvas,
|
||
from: Offset(nodes.batt.dx + r, nodes.batt.dy),
|
||
to: Offset(nodes.home.dx - r * 1.08, nodes.home.dy),
|
||
color: EtmTokens.amber, animated: true, arrowLeft: false);
|
||
} else {
|
||
_line(canvas,
|
||
from: Offset(nodes.home.dx - r * 1.08, nodes.home.dy),
|
||
to: Offset(nodes.batt.dx + r, nodes.batt.dy),
|
||
color: inactif, animated: false);
|
||
}
|
||
|
||
// Réseau ↔ Maison (gridPower > 0 = soutirage = Grid → Home)
|
||
final gridFlow = data.gridPower;
|
||
final homeRight = Offset(nodes.home.dx + r * 1.08, nodes.home.dy);
|
||
final gridLeft = Offset(nodes.grid.dx - r, nodes.grid.dy);
|
||
if (gridFlow > 20) {
|
||
// Soutirage : Grid → Home (amber)
|
||
_line(canvas, from: gridLeft, to: homeRight,
|
||
color: EtmTokens.amber, animated: true);
|
||
} else if (gridFlow < -20) {
|
||
// Injection : Home → Grid (blue)
|
||
_line(canvas, from: homeRight, to: gridLeft,
|
||
color: EtmTokens.blue, animated: true);
|
||
} else {
|
||
_line(canvas, from: homeRight, to: gridLeft,
|
||
color: inactif, animated: false);
|
||
}
|
||
}
|
||
|
||
void _line(
|
||
Canvas canvas, {
|
||
required Offset from,
|
||
required Offset to,
|
||
required Color color,
|
||
required bool animated,
|
||
bool arrowDown = false,
|
||
bool arrowLeft = false,
|
||
}) {
|
||
final dx = to.dx - from.dx;
|
||
final dy = to.dy - from.dy;
|
||
final len = math.sqrt(dx * dx + dy * dy);
|
||
if (len < 1) return;
|
||
|
||
final paint = Paint()
|
||
..color = color
|
||
..strokeWidth = 2.5
|
||
..style = PaintingStyle.stroke
|
||
..strokeCap = StrokeCap.round;
|
||
|
||
const dash = 4.0;
|
||
const gap = 7.0;
|
||
const period = dash + gap;
|
||
|
||
final offset = animated ? (t * period) % period : 0.0;
|
||
double dist = -offset;
|
||
|
||
while (dist < len) {
|
||
final d0 = dist.clamp(0.0, len);
|
||
final d1 = (dist + dash).clamp(0.0, len);
|
||
if (d1 > d0) {
|
||
canvas.drawLine(
|
||
Offset(from.dx + dx / len * d0, from.dy + dy / len * d0),
|
||
Offset(from.dx + dx / len * d1, from.dy + dy / len * d1),
|
||
paint,
|
||
);
|
||
}
|
||
dist += period;
|
||
}
|
||
|
||
// Arrow head at destination
|
||
if (animated) {
|
||
_arrow(canvas, from, to, color);
|
||
}
|
||
}
|
||
|
||
void _arrow(Canvas canvas, Offset from, Offset to, Color color) {
|
||
final dx = to.dx - from.dx;
|
||
final dy = to.dy - from.dy;
|
||
final len = math.sqrt(dx * dx + dy * dy);
|
||
if (len < 1) return;
|
||
final nx = dx / len;
|
||
final ny = dy / len;
|
||
const size = 7.0;
|
||
final tip = to;
|
||
final p1 = Offset(tip.dx - nx * size + ny * size * 0.5,
|
||
tip.dy - ny * size - nx * size * 0.5);
|
||
final p2 = Offset(tip.dx - nx * size - ny * size * 0.5,
|
||
tip.dy - ny * size + nx * size * 0.5);
|
||
final path = Path()..moveTo(tip.dx, tip.dy)..lineTo(p1.dx, p1.dy)..lineTo(p2.dx, p2.dy)..close();
|
||
canvas.drawPath(path, Paint()..color = color..style = PaintingStyle.fill);
|
||
}
|
||
|
||
@override
|
||
bool shouldRepaint(_FlowPainter old) =>
|
||
old.t != t || old.data.pvPower != data.pvPower ||
|
||
old.data.batteryPower != data.batteryPower ||
|
||
old.data.gridPower != data.gridPower;
|
||
}
|
||
|
||
// ─────────────────────────── Nœuds ─────────────────────────────────────────────
|
||
|
||
Widget _sunNode({required Offset center, required double r, required EnergyData data}) {
|
||
final kw = (data.pvPower / 1000);
|
||
return _Node(
|
||
center: center,
|
||
r: r,
|
||
borderColor: const Color(0xFFFDE9A8),
|
||
icon: Icons.wb_sunny_rounded,
|
||
iconColor: EtmTokens.amber,
|
||
label: 'Solaire',
|
||
value: '${kw.toStringAsFixed(2)} kW',
|
||
valueColor: EtmTokens.amber,
|
||
);
|
||
}
|
||
|
||
Widget _homeNode({required Offset center, required double r, required EnergyData data}) {
|
||
return _Node(
|
||
center: center,
|
||
r: r,
|
||
borderColor: const Color(0xFFBFE2F3),
|
||
ringColor: const Color(0xFFEAF6FC),
|
||
icon: Icons.home_rounded,
|
||
iconColor: EtmTokens.blue,
|
||
label: 'Maison',
|
||
value: '${data.homePower.toStringAsFixed(0)} W',
|
||
valueColor: EtmTokens.blue,
|
||
);
|
||
}
|
||
|
||
Widget _battNode({required Offset center, required double r, required EnergyData data}) {
|
||
final sign = data.batteryPower > 0 ? '+ ' : data.batteryPower < 0 ? '− ' : '';
|
||
return _Node(
|
||
center: center,
|
||
r: r,
|
||
borderColor: const Color(0xFFC4EED7),
|
||
icon: Icons.battery_charging_full_rounded,
|
||
iconColor: EtmTokens.green,
|
||
label: 'Batterie',
|
||
value: '${data.batterySOC.toStringAsFixed(0)}%',
|
||
valueColor: EtmTokens.green,
|
||
sub: '$sign${data.batteryPower.abs().toStringAsFixed(0)} W',
|
||
subColor: EtmTokens.green,
|
||
);
|
||
}
|
||
|
||
Widget _gridNode(BuildContext context,
|
||
{required Offset center, required double r, required EnergyData data}) {
|
||
return _Node(
|
||
center: center,
|
||
r: r,
|
||
icon: Icons.electrical_services_rounded,
|
||
iconColor: EtmTokens.faintOf(context),
|
||
label: 'Réseau',
|
||
value: '${data.gridPower.abs().toStringAsFixed(0)} W',
|
||
valueColor: EtmTokens.mutedOf(context),
|
||
);
|
||
}
|
||
|
||
class _Node extends StatelessWidget {
|
||
final Offset center;
|
||
final double r;
|
||
final Color? borderColor;
|
||
final Color? ringColor;
|
||
final IconData icon;
|
||
final Color iconColor;
|
||
final String label;
|
||
final String value;
|
||
final Color valueColor;
|
||
final String? sub;
|
||
final Color? subColor;
|
||
|
||
const _Node({
|
||
required this.center,
|
||
required this.r,
|
||
this.borderColor,
|
||
this.ringColor,
|
||
required this.icon,
|
||
required this.iconColor,
|
||
required this.label,
|
||
required this.value,
|
||
required this.valueColor,
|
||
this.sub,
|
||
this.subColor,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final diameter = r * 2;
|
||
return Positioned(
|
||
left: center.dx - r,
|
||
top: center.dy - r,
|
||
width: diameter,
|
||
height: diameter + 32, // extra for label below
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Container(
|
||
width: diameter,
|
||
height: diameter,
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
shape: BoxShape.circle,
|
||
border: Border.all(
|
||
color: borderColor ?? EtmTokens.lineOf(context),
|
||
width: 1.5,
|
||
),
|
||
boxShadow: ringColor != null
|
||
? [
|
||
BoxShadow(color: ringColor!, blurRadius: 0, spreadRadius: 5),
|
||
BoxShadow(
|
||
color: EtmTokens.navy.withValues(alpha: 0.08),
|
||
blurRadius: 16,
|
||
offset: const Offset(0, 4)),
|
||
]
|
||
: [
|
||
BoxShadow(
|
||
color: EtmTokens.navy.withValues(alpha: 0.08),
|
||
blurRadius: 16,
|
||
offset: const Offset(0, 4)),
|
||
],
|
||
),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(icon, color: iconColor, size: r * 0.56),
|
||
const SizedBox(height: 2),
|
||
Text(value, style: EtmTokens.mono(size: r * 0.32, color: valueColor)),
|
||
if (sub != null)
|
||
Text(sub!, style: EtmTokens.mono(size: r * 0.26, color: subColor ?? EtmTokens.mutedOf(context))),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
Text(
|
||
label,
|
||
style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context)),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────── Bannière ──────────────────────────────────────────
|
||
|
||
class _OptimalBanner extends StatelessWidget {
|
||
const _OptimalBanner();
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
||
decoration: BoxDecoration(
|
||
color: EtmTokens.greenSoft,
|
||
borderRadius: BorderRadius.circular(14),
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Container(
|
||
width: 32, height: 32,
|
||
decoration: const BoxDecoration(color: EtmTokens.green, shape: BoxShape.circle),
|
||
child: const Icon(Icons.check, color: Colors.white, size: 18),
|
||
),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text('Mode optimal',
|
||
style: EtmTokens.sans(size: 14, weight: FontWeight.w600, color: EtmTokens.greenDark)),
|
||
Text('Héos optimise votre consommation',
|
||
style: EtmTokens.sans(size: 12, color: EtmTokens.mutedOf(context))),
|
||
],
|
||
),
|
||
),
|
||
Icon(Icons.chevron_right, color: EtmTokens.faintOf(context), size: 20),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|