Patrick Schurig c638ec6c52 feat: connexions multi-HEMS + rôles & appareils (beta add/config)
Lot Connexions multi-HEMS :
- modèle Installation + InstallationStore (persistance par UUID ;
  métadonnées SharedPreferences, token via flutter_secure_storage)
- ConnectionManager au-dessus de nymea_service : mono-connexion, switchTo,
  connectNew, enterDemo (démo = active factice), contrôle d'identité DHCP
- nymea_service : connect() réveillé (ws://4444, token par UUID), capture
  Hello (uuid/name/initialSetupRequired), authenticate/createUser, resetState
- écran Installations (3 états) + déverrouillage installateur depuis la racine
- écran Connexion : auth 2 branches (JSONRPC.Authenticate / CreateUser),
  détection auto via initialSetupRequired, segmenteur de repli
- gate de routage (redirect + refreshListenable merge[cm,svc]) ;
  hasActiveConnection = isSimulation || (connected && authenticated)
- en-tête drawer cliquable → Installations
- invalidation des caches au switch (service.resetState + clearForSwitch
  rôles/scheduler/tariff), sans persist()

Lot Rôles & appareils :
- EnergySetupProvider refondu en tri-état (present/absent/non-configuré)
- écran roles_devices_screen (3 zones, drag-priorité, inférence solaire/batterie)
- LoadDescriptor (etmvariableload, rév.2 : PAC exclue → SG-Ready) ;
  Energy.SetRootMeter réel, Get/SetLoadConfig en stub loggé

Correctifs :
- bug signe énergie : production +, consommation NÉGATIVE sur nymea 1.15.2
  → consumptionW/home en .abs() (autoconso n'est plus clouée à 0)
- UUID Hello brace-wrapped normalisé

Tests : flutter analyze 0 erreur ; gate + zéro-fuite au switch (5/5 verts).
Validé en vrai : login auth .120, .75 ouvert, switch, déverrouillage installateur.
Note : embarque aussi le WIP dashboard/thème déjà présent dans l'arbre.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 08:39:08 +02:00

297 lines
8.9 KiB
Dart

import 'dart:math' as math;
import 'package:flutter/material.dart';
import '../../../theme/etm_tokens.dart';
import '../models/dashboard_models.dart';
class DayPlanPainter extends CustomPainter {
final DayPlan plan;
final Color surfaceColor;
final Color lineColor;
final Color inkColor;
final Color mutedColor;
final bool isDark;
const DayPlanPainter({
required this.plan,
required this.surfaceColor,
required this.lineColor,
required this.inkColor,
required this.mutedColor,
required this.isDark,
});
static const double _weatherH = 44.0;
static const double _xAxisH = 22.0;
static const double _leftPad = 28.0;
static const double _maxPosKwh = 4.0;
static const double _maxNegKwh = 1.8;
@override
void paint(Canvas canvas, Size size) {
final chartH = size.height - _weatherH - _xAxisH;
final chartW = size.width - _leftPad;
final zeroY = _weatherH + chartH * (_maxPosKwh / (_maxPosKwh + _maxNegKwh));
final posScale = chartH * (_maxPosKwh / (_maxPosKwh + _maxNegKwh)) / _maxPosKwh;
final negScale = chartH * (_maxNegKwh / (_maxPosKwh + _maxNegKwh)) / _maxNegKwh;
final nowX = _leftPad + plan.nowHour / 24 * chartW;
// ── Future background
if (nowX < size.width) {
canvas.drawRect(
Rect.fromLTRB(nowX, _weatherH, size.width, _weatherH + chartH),
Paint()
..color = (isDark
? const Color(0xFF06141D)
: const Color(0xFFEEF2F5))
.withValues(alpha: 0.55),
);
_drawHatch(canvas, Rect.fromLTRB(nowX, _weatherH, size.width, _weatherH + chartH));
}
// ── Bars (24 hours)
final barW = chartW / 24;
for (final h in plan.hours) {
final x = _leftPad + h.hour * barW;
_drawStackedBars(canvas, x, barW, zeroY, posScale, negScale, h);
}
// ── Y axis labels
_drawYLabels(canvas, zeroY, posScale, size);
// ── Zero line
canvas.drawLine(
Offset(_leftPad, zeroY),
Offset(size.width, zeroY),
Paint()
..color = lineColor
..strokeWidth = 1.0,
);
// ── X axis labels (0h 6h 12h 18h 24h)
for (final h in [0, 6, 12, 18, 24]) {
final x = _leftPad + h / 24 * chartW;
_paintText(
canvas,
h == 24 ? '24h' : '${h}h',
Offset(x, _weatherH + chartH + 4),
mutedColor,
9,
center: true,
);
}
// ── "Now" line + label
if (nowX > _leftPad && nowX < size.width) {
canvas.drawLine(
Offset(nowX, _weatherH),
Offset(nowX, _weatherH + chartH),
Paint()
..color = inkColor.withValues(alpha: 0.6)
..strokeWidth = 1.2
..style = PaintingStyle.stroke,
);
final now = DateTime.now();
final label =
'${now.hour.toString().padLeft(2, '0')}:${now.minute.toString().padLeft(2, '0')}';
_drawNowBadge(canvas, nowX, _weatherH, label);
}
// ── Weather band
_drawWeatherBand(canvas, chartW, plan.weather);
}
void _drawStackedBars(Canvas canvas, double x, double barW, double zeroY,
double posScale, double negScale, HourlyFlow h) {
const gap = 1.5;
final bw = barW - gap;
// Positive stack (upward from zeroY)
double topY = zeroY;
void stackPos(double kWh, Color color) {
if (kWh < 0.01) return;
final h2 = kWh * posScale;
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(x + gap / 2, topY - h2, bw, h2),
const Radius.circular(1.5),
),
Paint()..color = color,
);
topY -= h2;
}
stackPos(h.solToHouse, EtmTokens.solToHouse);
stackPos(h.solToEcs, EtmTokens.solToEcs);
stackPos(h.solToVe, EtmTokens.solToVe);
stackPos(h.solToBatt, EtmTokens.solToBatt);
stackPos(h.solToGrid, EtmTokens.solToGrid);
// Negative stack (downward from zeroY)
double botY = zeroY;
void stackNeg(double kWh, Color color) {
if (kWh < 0.01) return;
final h2 = kWh * negScale;
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(x + gap / 2, botY, bw, h2),
const Radius.circular(1.5),
),
Paint()..color = color.withValues(alpha: 0.7),
);
botY += h2;
}
stackNeg(h.battToHouse, EtmTokens.battToHouse);
stackNeg(h.gridToHouse, EtmTokens.gridToHouse);
}
void _drawHatch(Canvas canvas, Rect rect) {
canvas.save();
canvas.clipRect(rect);
final hatchPaint = Paint()
..color = surfaceColor.withValues(alpha: 0.5)
..strokeWidth = 1.0
..style = PaintingStyle.stroke;
const step = 8.0;
for (double d = -rect.height; d < rect.width + rect.height; d += step) {
canvas.drawLine(
Offset(rect.left + d, rect.top),
Offset(rect.left + d + rect.height, rect.bottom),
hatchPaint,
);
}
canvas.restore();
}
void _drawYLabels(Canvas canvas, double zeroY, double posScale, Size size) {
for (final kWh in [2.0, 4.0]) {
final y = zeroY - kWh * posScale;
_paintText(canvas, '${kWh.toInt()}', Offset(0, y - 5), mutedColor, 8);
canvas.drawLine(
Offset(_leftPad, y),
Offset(size.width, y),
Paint()
..color = lineColor.withValues(alpha: 0.6)
..strokeWidth = 0.5,
);
}
}
void _drawNowBadge(Canvas canvas, double x, double top, String label) {
final tp = TextPainter(
text: TextSpan(
text: label,
style: TextStyle(
fontFamily: 'IBMPlexMono',
fontSize: 9,
fontWeight: FontWeight.w600,
color: surfaceColor,
),
),
textDirection: TextDirection.ltr,
)..layout();
const pad = 4.0;
final rect = Rect.fromCenter(
center: Offset(x, top - tp.height / 2 - pad - 2),
width: tp.width + pad * 2,
height: tp.height + pad,
);
canvas.drawRRect(
RRect.fromRectAndRadius(rect, const Radius.circular(4)),
Paint()..color = inkColor.withValues(alpha: 0.75),
);
tp.paint(canvas, Offset(rect.left + pad, rect.top + pad / 2));
}
void _drawWeatherBand(
Canvas canvas, double chartW, List<WeatherPoint> points) {
for (final p in points) {
final x = _leftPad + p.hour / 24 * chartW;
final iconColor = p.kind == WeatherKind.sun || p.kind == WeatherKind.partlyCloudy
? EtmTokens.solar
: mutedColor;
_drawWeatherIcon(canvas, Offset(x, 12), p.kind, iconColor);
_paintText(
canvas,
'${p.tempC}°',
Offset(x, 26),
mutedColor,
9,
center: true,
);
}
}
void _drawWeatherIcon(
Canvas canvas, Offset center, WeatherKind kind, Color color) {
final paint = Paint()..color = color..style = PaintingStyle.fill;
switch (kind) {
case WeatherKind.sun:
_drawSun(canvas, center, color);
case WeatherKind.partlyCloudy:
_drawSun(canvas, center + const Offset(-3, -3), color, r: 5);
_drawCloud(canvas, center + const Offset(2, 2), color.withValues(alpha: 0.9));
case WeatherKind.cloudy:
_drawCloud(canvas, center, mutedColor);
case WeatherKind.moon:
_drawMoon(canvas, center, color, paint);
}
}
void _drawSun(Canvas canvas, Offset c, Color color, {double r = 6}) {
final p = Paint()..color = color..style = PaintingStyle.fill;
canvas.drawCircle(c, r, p);
final rp = Paint()
..color = color
..strokeWidth = 1.2
..style = PaintingStyle.stroke;
for (int i = 0; i < 8; i++) {
final a = i * math.pi / 4;
canvas.drawLine(
c + Offset(math.cos(a), math.sin(a)) * (r + 2),
c + Offset(math.cos(a), math.sin(a)) * (r + 5),
rp,
);
}
}
void _drawCloud(Canvas canvas, Offset c, Color color) {
final p = Paint()..color = color..style = PaintingStyle.fill;
canvas.drawCircle(c + const Offset(-4, 1), 4, p);
canvas.drawCircle(c + const Offset(1, -1), 5.5, p);
canvas.drawCircle(c + const Offset(6, 2), 3.5, p);
canvas.drawRect(
Rect.fromLTWH(c.dx - 7, c.dy + 1, 16, 4),
p,
);
}
void _drawMoon(Canvas canvas, Offset c, Color color, Paint paint) {
final path = Path()..addOval(Rect.fromCircle(center: c, radius: 7));
final cut = Path()..addOval(Rect.fromCircle(center: c + const Offset(4, -3), radius: 6));
final crescent = Path.combine(PathOperation.difference, path, cut);
canvas.drawPath(crescent, paint);
}
void _paintText(Canvas canvas, String text, Offset pos, Color color,
double size, {bool center = false}) {
final tp = TextPainter(
text: TextSpan(
text: text,
style: TextStyle(fontSize: size, color: color, fontWeight: FontWeight.w500),
),
textDirection: TextDirection.ltr,
)..layout();
tp.paint(
canvas,
center ? Offset(pos.dx - tp.width / 2, pos.dy) : pos,
);
}
@override
bool shouldRepaint(DayPlanPainter old) =>
old.plan != plan || old.isDark != isDark;
}