etm-powersync-app/lib/widgets/energy_flow_widget.dart
Patrick Schurig fb5b0e7165 feat(accueil): carte « En ce moment » sur la télémétrie, plus de marque ni de démo
Consigne app §G (décision Patrick du 2026-10-16) et motifs etm70 (§H).

G — la carte « Héos pilote en ce moment » affichait des décisions
inventées (HeosDecision.demo : voiture 3,6 kW, batterie en charge sur un
banc sans batterie). Remplacée par EnCeMomentCard : une ligne par charge
de GetLoadTelemetry — libellé de la configuration, decision.code + params
rendus par decisionText, puissance MESURÉE seulement (jamais l'allocation
à sa place). Titre en clé l10n. Rien sur « optimiseur actif » tant que
l'objet de capacités n'existe pas.
Marque retirée des identifiants (HeosDecisionCard, HeosDecision,
HeosAction, _HeosChip, _HeosBadgeSmall, heosActive → pilotageActif…), des
fichiers (heos_decision_card.dart, dashboard_models.dart supprimés), des
textes affichés (hub du flux, écran A/C) et des commentaires :
grep -ri héos/heos vide sur lib/, test/, assets/ et les .arb.

H — contre INTERFACE.md local de la branche moteur, etm70 pas encore sur
le banc :
- motifs éco publiés à la racine (R7, part majoritaire) rendus en clair ;
  ventilation des niveaux affichée (« 83 W éco + 0 W confort », vu sur
  la trame réelle etm69) ;
- ECO_FLOOR_MISSED retenu par charge (publié un seul cycle), y compris
  pour une charge sortie de loads[] ; « manque non chiffrable » quand
  missingWh est absent, jamais 0 Wh ;
- PRICE_SLOT et OPTIMIZER_DECISION en FR / DE / EN (app_en.arb et
  app_de.arb créés pour ces seules clés ; le reste retombe sur le
  français, listé dans l10n_untranslated.json). Code inconnu : repli
  générique avec le code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:26:16 +02:00

451 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

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

import 'dart: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('Pilotage local actif',
style: EtmTokens.sans(size: 12, color: EtmTokens.mutedOf(context))),
],
),
),
Icon(Icons.chevron_right, color: EtmTokens.faintOf(context), size: 20),
],
),
);
}
}