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>
297 lines
8.9 KiB
Dart
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;
|
|
}
|