etm-powersync-app/lib/widgets/energy_flow_widget.dart
Patrick Schurig ETM-Schurig d0a475a5d9 feat: refonte UI complète — design system EtmTokens + 4 écrans
Design system
- lib/theme/etm_tokens.dart : source de vérité couleurs + typo (IBM Plex Sans/Mono)
- lib/models/nymea_user.dart : modèle utilisateur nymea avec permissions EtmRole
- app_theme.dart : ThemeData migré vers IBM Plex Sans + couleurs EtmTokens

Navigation & drawer
- DrawerMenuButton : logo vert gradient + ombre
- Bottom nav : EtmTokens.green actif, EtmTokens.muted inactif
- DrawerPanel 320 px, restyled navy + gradient header + badges rôle

Dashboard (01_dashboard.html)
- Hero système : status pill + 3 métriques mono + illustration maison CustomPainter
- EnergyFlowWidget : 4 nœuds animés CustomPainter (flèches directionnelles)
  · gridPower > 0 = soutirage → flèche Grid→Home (amber)
  · gridPower < 0 = injection → flèche Home→Grid (bleu)
- EVChargingCard restyled : badge En charge + puissance mono 38px + 3 modes + SOC bar
- KPI 2×2 : spark bars, trend line, progress bar
- Consommateurs principaux + Décisions d'Héos (chips motifs)
- Prévisions placeholder explicite

Énergie
- KPI 2×2 avec icônes + fond soft + IBM Plex Mono
- Sélecteur période vert pill
- LineChart double axe : kW (gauche) / SOC % (droite, normalisé)
- BarChart bilan énergétique Wh (amber/bleu)
- Section Météo & prévision placeholder

Things
- Grille 2 col à hauteur intrinsèque (pas de childAspectRatio)
- Bandeau statut global (simulation / connecté / hors-ligne)
- _CategoryCard : header icon+label+count, séparateur coloré, liste tous items
- thing_category.dart : couleurs migrées vers EtmTokens

A/C — Climatisation / Chauffage
- Thermostats pièces EN HAUT : actives expandées, éteintes compactes
- Températures actuelle → cible ± avec EtmTokens.mono
- Sélecteur mode 4 boutons (Chauf/Clim/Auto/Vent)
- Chip "Chauffe au solaire en ce moment" (Héos)
- Sources pilotées par Héos EN BAS :
  · PAC SG-Ready : 4 états (Bloqué grisé / Normal / Recommandé / Forcé) + toggle Auto
  · Chauffe-eau : Surplus/Éco/Boost + temp eau 52°→60°C
  · Climatiseur : pré-refroidissement anticipé + info solaire

Packages ajoutés : google_fonts, flutter_staggered_grid_view, flutter_secure_storage
Asset : assets/house.svg (illustration maison CustomPainter)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 21:51:51 +02:00

439 lines
14 KiB
Dart
Raw Permalink 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.card,
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.muted)),
const SizedBox(width: 4),
const Icon(Icons.wb_cloudy_outlined, color: EtmTokens.faint, 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,
),
),
),
_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(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;
const _FlowPainter({required this.nodes, required this.data, required this.t});
@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 : EtmTokens.line,
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: EtmTokens.line, 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: EtmTokens.line, 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({required Offset center, required double r, required EnergyData data}) {
return _Node(
center: center,
r: r,
icon: Icons.electrical_services_rounded,
iconColor: EtmTokens.faint,
label: 'Réseau',
value: '${data.gridPower.abs().toStringAsFixed(0)} W',
valueColor: EtmTokens.muted,
);
}
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.line,
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.muted)),
],
),
),
const SizedBox(height: 4),
Text(
label,
style: EtmTokens.sans(size: 11, color: EtmTokens.muted),
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.muted)),
],
),
),
const Icon(Icons.chevron_right, color: EtmTokens.faint, size: 20),
],
),
);
}
}