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>
439 lines
14 KiB
Dart
439 lines
14 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.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),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|