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>
This commit is contained in:
parent
706c390f1f
commit
c638ec6c52
157
lib/features/dashboard/models/dashboard_models.dart
Normal file
157
lib/features/dashboard/models/dashboard_models.dart
Normal file
@ -0,0 +1,157 @@
|
||||
import 'dart:math' as math;
|
||||
|
||||
enum WeatherKind { sun, partlyCloudy, cloudy, moon }
|
||||
|
||||
class HourlyFlow {
|
||||
final int hour;
|
||||
final double solToHouse;
|
||||
final double solToEcs;
|
||||
final double solToVe;
|
||||
final double solToBatt;
|
||||
final double solToGrid;
|
||||
final double battToHouse;
|
||||
final double gridToHouse;
|
||||
|
||||
const HourlyFlow({
|
||||
required this.hour,
|
||||
this.solToHouse = 0,
|
||||
this.solToEcs = 0,
|
||||
this.solToVe = 0,
|
||||
this.solToBatt = 0,
|
||||
this.solToGrid = 0,
|
||||
this.battToHouse = 0,
|
||||
this.gridToHouse = 0,
|
||||
});
|
||||
|
||||
double get totalPositive =>
|
||||
solToHouse + solToEcs + solToVe + solToBatt + solToGrid;
|
||||
double get totalNegative => battToHouse + gridToHouse;
|
||||
}
|
||||
|
||||
class WeatherPoint {
|
||||
final int hour;
|
||||
final WeatherKind kind;
|
||||
final int tempC;
|
||||
|
||||
const WeatherPoint({
|
||||
required this.hour,
|
||||
required this.kind,
|
||||
required this.tempC,
|
||||
});
|
||||
}
|
||||
|
||||
class DayPlan {
|
||||
final List<HourlyFlow> hours;
|
||||
final List<WeatherPoint> weather;
|
||||
final double nowHour;
|
||||
|
||||
const DayPlan({
|
||||
required this.hours,
|
||||
required this.weather,
|
||||
required this.nowHour,
|
||||
});
|
||||
|
||||
static DayPlan demo() {
|
||||
final now = DateTime.now();
|
||||
final nowHour = now.hour + now.minute / 60.0;
|
||||
|
||||
final hours = List.generate(24, (h) {
|
||||
final solar = h >= 6 && h <= 20
|
||||
? 3.8 * math.exp(-math.pow(h - 13, 2) / 18.0)
|
||||
: 0.0;
|
||||
final baseHouse = 1.2;
|
||||
final peakHouse = (h >= 7 && h <= 9) || (h >= 18 && h <= 21) ? 0.7 : 0.0;
|
||||
final house = baseHouse + peakHouse;
|
||||
|
||||
final excess = math.max(0.0, solar - house);
|
||||
final deficit = math.max(0.0, house - solar);
|
||||
|
||||
final solToHouse = math.min(solar, house);
|
||||
final solToEcs = excess > 0.5 ? math.min(excess * 0.35, 0.7) : 0.0;
|
||||
final solToVe = excess > 1.2 ? math.min(excess * 0.3, 1.1) : 0.0;
|
||||
final solToBatt = excess > 0.3 ? math.min(excess * 0.15, 0.4) : 0.0;
|
||||
final solToGrid = math.max(0.0, excess - solToEcs - solToVe - solToBatt);
|
||||
|
||||
final battToHouse = h >= 17 ? math.min(deficit * 0.55, 0.8) : 0.0;
|
||||
final gridToHouse = math.max(0.0, deficit - battToHouse);
|
||||
|
||||
return HourlyFlow(
|
||||
hour: h,
|
||||
solToHouse: solToHouse,
|
||||
solToEcs: solToEcs,
|
||||
solToVe: solToVe,
|
||||
solToBatt: solToBatt,
|
||||
solToGrid: solToGrid,
|
||||
battToHouse: battToHouse,
|
||||
gridToHouse: gridToHouse,
|
||||
);
|
||||
});
|
||||
|
||||
final weather = [
|
||||
const WeatherPoint(hour: 0, kind: WeatherKind.moon, tempC: 17),
|
||||
const WeatherPoint(hour: 4, kind: WeatherKind.moon, tempC: 15),
|
||||
const WeatherPoint(hour: 8, kind: WeatherKind.partlyCloudy, tempC: 19),
|
||||
const WeatherPoint(hour: 12, kind: WeatherKind.sun, tempC: 25),
|
||||
const WeatherPoint(hour: 16, kind: WeatherKind.sun, tempC: 27),
|
||||
const WeatherPoint(hour: 20, kind: WeatherKind.partlyCloudy, tempC: 22),
|
||||
];
|
||||
|
||||
return DayPlan(hours: hours, weather: weather, nowHour: nowHour);
|
||||
}
|
||||
}
|
||||
|
||||
class HeosAction {
|
||||
final String iconKey;
|
||||
final String title;
|
||||
final String why;
|
||||
final double powerKw;
|
||||
|
||||
const HeosAction({
|
||||
required this.iconKey,
|
||||
required this.title,
|
||||
required this.why,
|
||||
required this.powerKw,
|
||||
});
|
||||
}
|
||||
|
||||
class HeosDecision {
|
||||
final String summary;
|
||||
final String highlight;
|
||||
final List<HeosAction> actions;
|
||||
final DateTime updatedAt;
|
||||
|
||||
const HeosDecision({
|
||||
required this.summary,
|
||||
required this.highlight,
|
||||
required this.actions,
|
||||
required this.updatedAt,
|
||||
});
|
||||
|
||||
static HeosDecision demo() => HeosDecision(
|
||||
summary:
|
||||
'Le surplus solaire part dans l\'eau chaude et la voiture '
|
||||
'plutôt que d\'être revendu à bas prix.',
|
||||
highlight: 'plutôt que d\'être revendu à bas prix',
|
||||
actions: [
|
||||
const HeosAction(
|
||||
iconKey: 'water',
|
||||
title: 'Chauffe-eau',
|
||||
why: 'Surplus PV disponible · coût réseau ce soir 0,22 €/kWh',
|
||||
powerKw: 2.0,
|
||||
),
|
||||
const HeosAction(
|
||||
iconKey: 'ev',
|
||||
title: 'Voiture — mode Éco',
|
||||
why: 'Charge à partir du surplus · objectif 80 % pour 18h',
|
||||
powerKw: 3.6,
|
||||
),
|
||||
const HeosAction(
|
||||
iconKey: 'battery',
|
||||
title: 'Batterie en charge',
|
||||
why: 'Stockage pour couvrir le pic 18h–21h',
|
||||
powerKw: 0.8,
|
||||
),
|
||||
],
|
||||
updatedAt: DateTime.now(),
|
||||
);
|
||||
}
|
||||
296
lib/features/dashboard/painters/day_plan_painter.dart
Normal file
296
lib/features/dashboard/painters/day_plan_painter.dart
Normal file
@ -0,0 +1,296 @@
|
||||
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;
|
||||
}
|
||||
84
lib/features/dashboard/painters/flow_painter.dart
Normal file
84
lib/features/dashboard/painters/flow_painter.dart
Normal file
@ -0,0 +1,84 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class FlowSegment {
|
||||
final Offset from;
|
||||
final Offset to;
|
||||
final Color color;
|
||||
final double powerW;
|
||||
final bool dashed;
|
||||
|
||||
const FlowSegment({
|
||||
required this.from,
|
||||
required this.to,
|
||||
required this.color,
|
||||
required this.powerW,
|
||||
this.dashed = false,
|
||||
});
|
||||
}
|
||||
|
||||
class FlowPainter extends CustomPainter {
|
||||
final List<FlowSegment> segments;
|
||||
final double animValue;
|
||||
final Color lineColor;
|
||||
|
||||
const FlowPainter({
|
||||
required this.segments,
|
||||
required this.animValue,
|
||||
required this.lineColor,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final linePaint = Paint()
|
||||
..color = lineColor
|
||||
..strokeWidth = 1.5
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeCap = StrokeCap.round;
|
||||
|
||||
for (final seg in segments) {
|
||||
final src = Offset(seg.from.dx * size.width, seg.from.dy * size.height);
|
||||
final dst = Offset(seg.to.dx * size.width, seg.to.dy * size.height);
|
||||
|
||||
if (seg.dashed) {
|
||||
_drawDashed(canvas, src, dst, linePaint);
|
||||
} else {
|
||||
canvas.drawLine(src, dst, linePaint);
|
||||
}
|
||||
|
||||
if (seg.powerW > 30) {
|
||||
final n = (seg.powerW / 600).clamp(1, 5).round();
|
||||
final r = seg.powerW > 1500 ? 3.5 : 2.5;
|
||||
final particlePaint = Paint()
|
||||
..color = seg.color
|
||||
..style = PaintingStyle.fill;
|
||||
|
||||
for (int i = 0; i < n; i++) {
|
||||
final t = (animValue + i / n) % 1.0;
|
||||
final pos = Offset.lerp(src, dst, t)!;
|
||||
canvas.drawCircle(pos, r, particlePaint);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void _drawDashed(Canvas canvas, Offset src, Offset dst, Paint paint) {
|
||||
final dir = dst - src;
|
||||
final length = dir.distance;
|
||||
if (length == 0) return;
|
||||
final unit = dir / length;
|
||||
const dashLen = 5.0;
|
||||
const gapLen = 4.0;
|
||||
double pos = 0;
|
||||
while (pos < length) {
|
||||
final end = (pos + dashLen).clamp(0.0, length);
|
||||
canvas.drawLine(src + unit * pos, src + unit * end, paint);
|
||||
pos += dashLen + gapLen;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(FlowPainter old) =>
|
||||
old.animValue != animValue ||
|
||||
old.lineColor != lineColor ||
|
||||
old.segments.length != segments.length;
|
||||
}
|
||||
47
lib/features/dashboard/painters/ring_painter.dart
Normal file
47
lib/features/dashboard/painters/ring_painter.dart
Normal file
@ -0,0 +1,47 @@
|
||||
import 'dart:math' as math;
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
class RingPainter extends CustomPainter {
|
||||
final double value;
|
||||
final Color color;
|
||||
final double strokeWidth;
|
||||
|
||||
const RingPainter({
|
||||
required this.value,
|
||||
required this.color,
|
||||
this.strokeWidth = 9.0,
|
||||
});
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final center = Offset(size.width / 2, size.height / 2);
|
||||
final radius = (math.min(size.width, size.height) - strokeWidth) / 2;
|
||||
|
||||
canvas.drawCircle(
|
||||
center,
|
||||
radius,
|
||||
Paint()
|
||||
..color = color.withValues(alpha: 0.12)
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = strokeWidth,
|
||||
);
|
||||
|
||||
if (value > 0.005) {
|
||||
canvas.drawArc(
|
||||
Rect.fromCircle(center: center, radius: radius),
|
||||
-math.pi / 2,
|
||||
2 * math.pi * value.clamp(0.0, 1.0),
|
||||
false,
|
||||
Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.stroke
|
||||
..strokeWidth = strokeWidth
|
||||
..strokeCap = StrokeCap.round,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(RingPainter old) =>
|
||||
old.value != value || old.color != color || old.strokeWidth != strokeWidth;
|
||||
}
|
||||
97
lib/features/dashboard/widgets/day_plan_card.dart
Normal file
97
lib/features/dashboard/widgets/day_plan_card.dart
Normal file
@ -0,0 +1,97 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../theme/etm_tokens.dart';
|
||||
import '../models/dashboard_models.dart';
|
||||
import '../painters/day_plan_painter.dart';
|
||||
|
||||
class DayPlanCard extends StatelessWidget {
|
||||
const DayPlanCard({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final plan = DayPlan.demo();
|
||||
final isDark = Theme.of(context).brightness == Brightness.dark;
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.surfaceOf(context),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
|
||||
boxShadow: EtmTokens.cardShadowOf(context),
|
||||
),
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text('Plan de la journée',
|
||||
style: EtmTokens.sans(
|
||||
size: 15,
|
||||
weight: FontWeight.w600,
|
||||
color: EtmTokens.inkOf(context))),
|
||||
Text("aujourd'hui",
|
||||
style: EtmTokens.sans(
|
||||
size: 12, color: EtmTokens.mutedOf(context))),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
SizedBox(
|
||||
height: 180,
|
||||
child: CustomPaint(
|
||||
painter: DayPlanPainter(
|
||||
plan: plan,
|
||||
surfaceColor: EtmTokens.surfaceOf(context),
|
||||
lineColor: EtmTokens.lineOf(context),
|
||||
inkColor: EtmTokens.inkOf(context),
|
||||
mutedColor: EtmTokens.mutedOf(context),
|
||||
isDark: isDark,
|
||||
),
|
||||
size: const Size(double.infinity, 180),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
_Legend(context: context),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Legend extends StatelessWidget {
|
||||
final BuildContext context;
|
||||
const _Legend({required this.context});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext _) {
|
||||
final items = [
|
||||
('Maison', EtmTokens.solToHouse),
|
||||
('ECS', EtmTokens.solToEcs),
|
||||
('VE', EtmTokens.solToVe),
|
||||
('Batterie', EtmTokens.solToBatt),
|
||||
('Réseau', EtmTokens.solToGrid),
|
||||
('Batt.→maison', EtmTokens.battToHouse),
|
||||
];
|
||||
return Wrap(
|
||||
spacing: 12,
|
||||
runSpacing: 6,
|
||||
children: items.map((e) {
|
||||
return Row(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Container(
|
||||
width: 10, height: 10,
|
||||
decoration: BoxDecoration(
|
||||
color: e.$2,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Text(e.$1,
|
||||
style: EtmTokens.sans(
|
||||
size: 10, color: EtmTokens.mutedOf(context))),
|
||||
],
|
||||
);
|
||||
}).toList(),
|
||||
);
|
||||
}
|
||||
}
|
||||
280
lib/features/dashboard/widgets/heos_decision_card.dart
Normal file
280
lib/features/dashboard/widgets/heos_decision_card.dart
Normal file
@ -0,0 +1,280 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../theme/etm_tokens.dart';
|
||||
import '../models/dashboard_models.dart';
|
||||
|
||||
class HeosDecisionCard extends StatefulWidget {
|
||||
const HeosDecisionCard({super.key});
|
||||
|
||||
@override
|
||||
State<HeosDecisionCard> createState() => _HeosDecisionCardState();
|
||||
}
|
||||
|
||||
class _HeosDecisionCardState extends State<HeosDecisionCard>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _pulseCtrl;
|
||||
late final Animation<double> _pulseAnim;
|
||||
final _decision = HeosDecision.demo();
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_pulseCtrl = AnimationController(
|
||||
vsync: this,
|
||||
duration: const Duration(seconds: 2),
|
||||
)..repeat(reverse: true);
|
||||
_pulseAnim = Tween<double>(begin: 0.25, end: 0.7)
|
||||
.animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_pulseCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final eco = EtmTokens.ecoOf(context);
|
||||
final ink = EtmTokens.inkOf(context);
|
||||
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.surfaceOf(context),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
|
||||
boxShadow: EtmTokens.cardShadowOf(context),
|
||||
border: Border.all(color: eco.withValues(alpha: 0.25), width: 1),
|
||||
),
|
||||
padding: const EdgeInsets.all(18),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── En-tête
|
||||
Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 34, height: 34,
|
||||
decoration: BoxDecoration(
|
||||
color: eco.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
|
||||
),
|
||||
child: Icon(Icons.auto_awesome_rounded, color: eco, size: 18),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Héos pilote en ce moment',
|
||||
style: EtmTokens.sans(
|
||||
size: 14,
|
||||
weight: FontWeight.w600,
|
||||
color: ink,
|
||||
)),
|
||||
Text(
|
||||
'optimise sur 24 h · maj ${_fmtTime(_decision.updatedAt)}',
|
||||
style: EtmTokens.mono(
|
||||
size: 10,
|
||||
color: EtmTokens.mutedOf(context),
|
||||
weight: FontWeight.w400,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
AnimatedBuilder(
|
||||
animation: _pulseAnim,
|
||||
builder: (context, _) => Container(
|
||||
width: 10, height: 10,
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: eco.withValues(alpha: _pulseAnim.value),
|
||||
boxShadow: [
|
||||
BoxShadow(
|
||||
color: eco.withValues(alpha: _pulseAnim.value * 0.6),
|
||||
blurRadius: 6,
|
||||
spreadRadius: 2,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// ── Résumé avec highlight
|
||||
_HighlightedSummary(
|
||||
text: _decision.summary,
|
||||
highlight: _decision.highlight,
|
||||
highlightColor: eco,
|
||||
textColor: EtmTokens.mutedOf(context),
|
||||
),
|
||||
|
||||
const SizedBox(height: 14),
|
||||
|
||||
// ── Hairline
|
||||
Divider(height: 1, color: EtmTokens.lineOf(context)),
|
||||
|
||||
// ── Actions
|
||||
...List.generate(_decision.actions.length, (i) {
|
||||
final action = _decision.actions[i];
|
||||
final isLast = i == _decision.actions.length - 1;
|
||||
return Column(
|
||||
children: [
|
||||
_ActionRow(action: action, eco: eco, context: context),
|
||||
if (!isLast)
|
||||
Divider(height: 1, color: EtmTokens.lineOf(context)),
|
||||
],
|
||||
);
|
||||
}),
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ── Pied
|
||||
GestureDetector(
|
||||
onTap: () {},
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Text(
|
||||
'Voir le plan de la journée',
|
||||
style: EtmTokens.sans(
|
||||
size: 13,
|
||||
weight: FontWeight.w600,
|
||||
color: EtmTokens.brand),
|
||||
),
|
||||
const SizedBox(width: 4),
|
||||
Icon(Icons.arrow_forward_ios_rounded,
|
||||
size: 12, color: EtmTokens.brand),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
String _fmtTime(DateTime dt) =>
|
||||
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||||
}
|
||||
|
||||
// ── Highlighted summary ───────────────────────────────────────────────────────
|
||||
|
||||
class _HighlightedSummary extends StatelessWidget {
|
||||
final String text;
|
||||
final String highlight;
|
||||
final Color highlightColor;
|
||||
final Color textColor;
|
||||
|
||||
const _HighlightedSummary({
|
||||
required this.text,
|
||||
required this.highlight,
|
||||
required this.highlightColor,
|
||||
required this.textColor,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final idx = text.indexOf(highlight);
|
||||
if (idx < 0) {
|
||||
return Text(text,
|
||||
style: EtmTokens.sans(size: 13, color: textColor, height: 1.5));
|
||||
}
|
||||
return Text.rich(
|
||||
TextSpan(children: [
|
||||
TextSpan(
|
||||
text: text.substring(0, idx),
|
||||
style: EtmTokens.sans(size: 13, color: textColor, height: 1.5),
|
||||
),
|
||||
WidgetSpan(
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
|
||||
decoration: BoxDecoration(
|
||||
color: highlightColor.withValues(alpha: 0.15),
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
child: Text(
|
||||
highlight,
|
||||
style: EtmTokens.sans(
|
||||
size: 13,
|
||||
color: highlightColor,
|
||||
weight: FontWeight.w600,
|
||||
height: 1.5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
TextSpan(
|
||||
text: text.substring(idx + highlight.length),
|
||||
style: EtmTokens.sans(size: 13, color: textColor, height: 1.5),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Action row ────────────────────────────────────────────────────────────────
|
||||
|
||||
class _ActionRow extends StatelessWidget {
|
||||
final HeosAction action;
|
||||
final Color eco;
|
||||
final BuildContext context;
|
||||
|
||||
const _ActionRow({
|
||||
required this.action,
|
||||
required this.eco,
|
||||
required this.context,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext _) {
|
||||
final icon = _iconFor(action.iconKey);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 12),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Container(
|
||||
width: 32, height: 32,
|
||||
decoration: BoxDecoration(
|
||||
color: eco.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
|
||||
),
|
||||
child: Icon(icon, color: eco, size: 17),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(action.title,
|
||||
style: EtmTokens.sans(
|
||||
size: 13,
|
||||
weight: FontWeight.w600,
|
||||
color: EtmTokens.inkOf(context))),
|
||||
const SizedBox(height: 2),
|
||||
Text(action.why,
|
||||
style: EtmTokens.sans(
|
||||
size: 11, color: EtmTokens.mutedOf(context))),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
Text(
|
||||
'${action.powerKw.toStringAsFixed(1)} kW',
|
||||
style: EtmTokens.mono(size: 13, color: eco),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
IconData _iconFor(String key) => switch (key) {
|
||||
'water' => Icons.water_drop_outlined,
|
||||
'ev' => Icons.ev_station_rounded,
|
||||
'battery' => Icons.battery_charging_full_rounded,
|
||||
'heat' => Icons.heat_pump_outlined,
|
||||
_ => Icons.bolt_rounded,
|
||||
};
|
||||
}
|
||||
224
lib/features/dashboard/widgets/kpi_row.dart
Normal file
224
lib/features/dashboard/widgets/kpi_row.dart
Normal file
@ -0,0 +1,224 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../../../models/energy_data.dart';
|
||||
import '../../../theme/etm_tokens.dart';
|
||||
import '../painters/ring_painter.dart';
|
||||
|
||||
class KpiRow extends StatelessWidget {
|
||||
final EnergyData data;
|
||||
const KpiRow({super.key, required this.data});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final autoconso = data.selfConsumptionRate.clamp(0.0, 100.0);
|
||||
final autonomy = data.autonomyRate.clamp(0.0, 100.0);
|
||||
final toGrid = data.dayGridInjectionWh.clamp(0.0, double.infinity) / 1000;
|
||||
final fromGrid = (-data.dayGridInjectionWh).clamp(0.0, double.infinity) / 1000;
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _RingTile(
|
||||
label: 'Autoconsommation',
|
||||
value: autoconso,
|
||||
color: EtmTokens.solarOf(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _RingTile(
|
||||
label: 'Autonomie',
|
||||
value: autonomy,
|
||||
color: EtmTokens.ecoOf(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _ValueTile(
|
||||
label: 'Vers réseau · aujourd\'hui',
|
||||
value: toGrid,
|
||||
icon: Icons.north_west_rounded,
|
||||
color: EtmTokens.ecoOf(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _ValueTile(
|
||||
label: 'Depuis réseau · aujourd\'hui',
|
||||
value: fromGrid,
|
||||
icon: Icons.south_east_rounded,
|
||||
color: EtmTokens.importColor,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Ring tile — horizontal (ring left, text right) ────────────────────────────
|
||||
|
||||
class _RingTile extends StatefulWidget {
|
||||
final String label;
|
||||
final double value;
|
||||
final Color color;
|
||||
const _RingTile({required this.label, required this.value, required this.color});
|
||||
|
||||
@override
|
||||
State<_RingTile> createState() => _RingTileState();
|
||||
}
|
||||
|
||||
class _RingTileState extends State<_RingTile>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _ctrl;
|
||||
late Animation<double> _ring;
|
||||
late Animation<int> _count;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 1100));
|
||||
_ring = Tween<double>(begin: 0, end: widget.value / 100)
|
||||
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutCubic));
|
||||
_count = IntTween(begin: 0, end: widget.value.round())
|
||||
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutCubic));
|
||||
_ctrl.forward();
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_ctrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.surfaceOf(context),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
|
||||
boxShadow: EtmTokens.cardShadowOf(context),
|
||||
border: Border.all(color: EtmTokens.lineOf(context), width: 1),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
|
||||
child: Row(
|
||||
children: [
|
||||
// Ring — 50×50, strokeWidth 5 (matches HTML .ring SVG)
|
||||
SizedBox(
|
||||
width: 50, height: 50,
|
||||
child: AnimatedBuilder(
|
||||
animation: _ring,
|
||||
builder: (context, _) => CustomPaint(
|
||||
size: const Size(50, 50),
|
||||
painter: RingPainter(
|
||||
value: _ring.value,
|
||||
color: widget.color,
|
||||
strokeWidth: 5,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
AnimatedBuilder(
|
||||
animation: _count,
|
||||
builder: (context, _) => Text(
|
||||
'${_count.value} %',
|
||||
style: EtmTokens.mono(
|
||||
size: 18,
|
||||
weight: FontWeight.w600,
|
||||
color: EtmTokens.inkOf(context),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
widget.label,
|
||||
style: EtmTokens.sans(
|
||||
size: 11,
|
||||
color: EtmTokens.mutedOf(context),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Value tile — horizontal (icon left, text right) ───────────────────────────
|
||||
|
||||
class _ValueTile extends StatelessWidget {
|
||||
final String label;
|
||||
final double value;
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
|
||||
const _ValueTile({
|
||||
required this.label,
|
||||
required this.value,
|
||||
required this.icon,
|
||||
required this.color,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.surfaceOf(context),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
|
||||
boxShadow: EtmTokens.cardShadowOf(context),
|
||||
border: Border.all(color: EtmTokens.lineOf(context), width: 1),
|
||||
),
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 30, height: 30,
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(9),
|
||||
),
|
||||
child: Icon(icon, color: color, size: 16),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${value.toStringAsFixed(1)} kWh',
|
||||
style: EtmTokens.mono(
|
||||
size: 16,
|
||||
weight: FontWeight.w600,
|
||||
color: EtmTokens.inkOf(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text(
|
||||
label,
|
||||
style: EtmTokens.sans(
|
||||
size: 10.5,
|
||||
color: EtmTokens.mutedOf(context),
|
||||
),
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -13,26 +13,48 @@ import 'providers/tariff_provider.dart';
|
||||
import 'screens/ac_screen.dart';
|
||||
import 'screens/dashboard_screen.dart';
|
||||
import 'screens/drawer/main_drawer.dart';
|
||||
import 'screens/energy/energy_setup_screen.dart';
|
||||
import 'screens/energy/roles_devices_screen.dart';
|
||||
import 'screens/energy/scheduler_screen.dart';
|
||||
import 'screens/energy/tariff_screen.dart';
|
||||
import 'screens/energy/timeline_screen.dart';
|
||||
import 'screens/energy_screen.dart';
|
||||
import 'screens/favorites_screen.dart';
|
||||
import 'screens/installations/connection_screen.dart';
|
||||
import 'screens/installations/installations_screen.dart';
|
||||
import 'screens/settings/app_settings_screen.dart';
|
||||
import 'screens/settings/appearance_screen.dart';
|
||||
import 'screens/settings/screens_settings_screen.dart';
|
||||
import 'screens/things_screen.dart';
|
||||
import 'services/connection_manager.dart';
|
||||
import 'services/installation_store.dart';
|
||||
import 'services/nymea_service.dart';
|
||||
import 'theme/app_theme.dart';
|
||||
import 'theme/etm_tokens.dart';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Router GoRouter
|
||||
// Router GoRouter — gate de connexion (Étape 6)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
final _router = GoRouter(
|
||||
/// Construit le routeur avec le **gate de connexion**.
|
||||
///
|
||||
/// Condition = « pas de connexion active » ([ConnectionManager.hasActiveConnection],
|
||||
/// qui inclut la démo). `refreshListenable` écoute le manager **et** le service :
|
||||
/// une déconnexion subie (socket qui tombe → notif du service, pas du manager)
|
||||
/// doit ré-évaluer le gate et renvoyer vers Installations.
|
||||
GoRouter buildAppRouter(ConnectionManager cm, NymeaService svc) => GoRouter(
|
||||
initialLocation: '/',
|
||||
refreshListenable: Listenable.merge([cm, svc]),
|
||||
redirect: (context, state) {
|
||||
final loc = state.matchedLocation;
|
||||
final onInstall =
|
||||
loc == '/installations' || loc.startsWith('/installations/');
|
||||
if (!cm.hasActiveConnection) {
|
||||
// Pas de session (ni box réelle, ni démo) → Installations en racine.
|
||||
return onInstall ? null : '/installations';
|
||||
}
|
||||
// Actif (ou démo) → on laisse tout ; Installations reste accessible (drawer).
|
||||
return null;
|
||||
},
|
||||
routes: [
|
||||
// ── Shell principal (bottom nav + drawer overlay) ─────────────────────
|
||||
ShellRoute(
|
||||
@ -53,10 +75,20 @@ final _router = GoRouter(
|
||||
],
|
||||
),
|
||||
|
||||
// ── Installations (multi-HEMS) — plein écran ──────────────────────────
|
||||
GoRoute(
|
||||
path: '/installations',
|
||||
builder: (c, s) => const InstallationsScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/installations/connect',
|
||||
builder: (c, s) => const ConnectionScreen(),
|
||||
),
|
||||
|
||||
// ── Écrans énergie (push par-dessus le shell) ─────────────────────────
|
||||
GoRoute(
|
||||
path: '/energy/setup',
|
||||
builder: (c, s) => const EnergySetupScreen(),
|
||||
builder: (c, s) => const RolesDevicesScreen(),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/energy/scheduler',
|
||||
@ -122,23 +154,38 @@ void main() async {
|
||||
// Initialisation des providers qui ont besoin d'I/O asynchrone
|
||||
final appSettings = AppSettingsProvider();
|
||||
final installerMode = InstallerModeProvider();
|
||||
// NymeaService partagé avec le ConnectionManager (même instance).
|
||||
final nymeaService = NymeaService();
|
||||
final connectionManager =
|
||||
ConnectionManager(nymeaService, InstallationStore());
|
||||
// Providers à état par-box : instanciés eager pour les vider au switch.
|
||||
final energySetup = EnergySetupProvider();
|
||||
final scheduler = SchedulerProvider();
|
||||
final tariff = TariffProvider();
|
||||
connectionManager.registerCacheClear(energySetup.clearForSwitch);
|
||||
connectionManager.registerCacheClear(scheduler.clearForSwitch);
|
||||
connectionManager.registerCacheClear(tariff.clearForSwitch);
|
||||
await Future.wait([
|
||||
appSettings.load(),
|
||||
installerMode.init(),
|
||||
connectionManager.load(),
|
||||
]);
|
||||
|
||||
runApp(
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider(create: (_) => NymeaService()),
|
||||
ChangeNotifierProvider.value(value: nymeaService),
|
||||
ChangeNotifierProvider.value(value: connectionManager),
|
||||
ChangeNotifierProvider(create: (_) => NavigationProvider()),
|
||||
ChangeNotifierProvider.value(value: installerMode),
|
||||
ChangeNotifierProvider.value(value: appSettings),
|
||||
ChangeNotifierProvider(create: (_) => EnergySetupProvider()),
|
||||
ChangeNotifierProvider(create: (_) => SchedulerProvider()),
|
||||
ChangeNotifierProvider(create: (_) => TariffProvider()),
|
||||
ChangeNotifierProvider.value(value: energySetup),
|
||||
ChangeNotifierProvider.value(value: scheduler),
|
||||
ChangeNotifierProvider.value(value: tariff),
|
||||
],
|
||||
child: const NymeaEnergyApp(),
|
||||
child: NymeaEnergyApp(
|
||||
router: buildAppRouter(connectionManager, nymeaService),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
@ -148,15 +195,20 @@ void main() async {
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class NymeaEnergyApp extends StatelessWidget {
|
||||
const NymeaEnergyApp({super.key});
|
||||
const NymeaEnergyApp({super.key, required this.router});
|
||||
|
||||
final GoRouter router;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final settings = context.watch<AppSettingsProvider>();
|
||||
return MaterialApp.router(
|
||||
title: 'ETM PowerSync',
|
||||
debugShowCheckedModeBanner: false,
|
||||
theme: AppTheme.theme,
|
||||
routerConfig: _router,
|
||||
darkTheme: AppTheme.darkTheme,
|
||||
themeMode: settings.themeMode,
|
||||
routerConfig: router,
|
||||
builder: (context, child) {
|
||||
// Sur mobile natif, on rend tel quel
|
||||
if (!kIsWeb) return child!;
|
||||
@ -386,13 +438,13 @@ class _BottomNav extends StatelessWidget {
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: selected
|
||||
? EtmTokens.green.withValues(alpha: 0.12)
|
||||
? EtmTokens.brand.withValues(alpha: 0.12)
|
||||
: Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
),
|
||||
child: Icon(
|
||||
item.icon,
|
||||
color: selected ? EtmTokens.green : EtmTokens.muted,
|
||||
color: selected ? EtmTokens.brand : EtmTokens.muted,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
@ -401,7 +453,7 @@ class _BottomNav extends StatelessWidget {
|
||||
item.label,
|
||||
style: EtmTokens.sans(
|
||||
size: 10,
|
||||
color: selected ? EtmTokens.green : EtmTokens.muted,
|
||||
color: selected ? EtmTokens.brand : EtmTokens.muted,
|
||||
weight: selected ? FontWeight.w600 : FontWeight.w400,
|
||||
),
|
||||
),
|
||||
|
||||
72
lib/models/installation.dart
Normal file
72
lib/models/installation.dart
Normal file
@ -0,0 +1,72 @@
|
||||
/// Une installation HEMS enregistrée.
|
||||
///
|
||||
/// **Identité stable = [uuid]** (Server UUID renvoyé par `JSONRPC.Hello`), jamais
|
||||
/// l'IP : en DHCP, [host] change. Le [token] est un **credential** persisté à
|
||||
/// part (Keychain/Keystore via `flutter_secure_storage`) — il ne figure donc
|
||||
/// **pas** dans [toJson] (métadonnées SharedPreferences uniquement).
|
||||
class Installation {
|
||||
/// Server UUID (clé d'identité, Hello `uuid`).
|
||||
final String uuid;
|
||||
|
||||
/// Nom de la box (Hello `name`).
|
||||
final String name;
|
||||
|
||||
/// Hôte courant (IP/hostname) — peut changer en DHCP, jamais une identité.
|
||||
final String host;
|
||||
|
||||
/// Port JSON-RPC. Défaut **4444** (ws clair, LAN).
|
||||
final int port;
|
||||
|
||||
/// Dernière fois où la box a répondu (connexion ou découverte).
|
||||
final DateTime? lastSeen;
|
||||
|
||||
/// Token d'authentification — **transitoire en mémoire**, persisté séparément
|
||||
/// par UUID dans le secure storage. `null` tant que la box n'est pas appairée.
|
||||
final String? token;
|
||||
|
||||
const Installation({
|
||||
required this.uuid,
|
||||
required this.name,
|
||||
required this.host,
|
||||
this.port = 4444,
|
||||
this.lastSeen,
|
||||
this.token,
|
||||
});
|
||||
|
||||
bool get hasToken => token != null && token!.isNotEmpty;
|
||||
|
||||
Installation copyWith({
|
||||
String? name,
|
||||
String? host,
|
||||
int? port,
|
||||
DateTime? lastSeen,
|
||||
String? token,
|
||||
}) =>
|
||||
Installation(
|
||||
uuid: uuid,
|
||||
name: name ?? this.name,
|
||||
host: host ?? this.host,
|
||||
port: port ?? this.port,
|
||||
lastSeen: lastSeen ?? this.lastSeen,
|
||||
token: token ?? this.token,
|
||||
);
|
||||
|
||||
/// Métadonnées persistées (SharedPreferences). **Sans token** (secure storage).
|
||||
Map<String, dynamic> toJson() => {
|
||||
'uuid': uuid,
|
||||
'name': name,
|
||||
'host': host,
|
||||
'port': port,
|
||||
if (lastSeen != null) 'lastSeen': lastSeen!.toIso8601String(),
|
||||
};
|
||||
|
||||
factory Installation.fromJson(Map<String, dynamic> j) => Installation(
|
||||
uuid: j['uuid'] as String,
|
||||
name: (j['name'] as String?) ?? 'nymea',
|
||||
host: j['host'] as String,
|
||||
port: (j['port'] as num?)?.toInt() ?? 4444,
|
||||
lastSeen: j['lastSeen'] != null
|
||||
? DateTime.tryParse(j['lastSeen'] as String)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
@ -2,19 +2,28 @@ import 'package:flutter/foundation.dart';
|
||||
import '../services/nymea_service.dart';
|
||||
import '../models/nymea_models.dart';
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Rôles EMS
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Rôles de l'EMS (Energy Management System).
|
||||
///
|
||||
/// Six rôles répartis en trois destinations (contrat) :
|
||||
/// • Compteurs → [gridMeter] (rootMeter), [solarMeter] (détecté)
|
||||
/// • Charges pilotables → [evCharger], [dhw], [heatPump], [battery]
|
||||
/// • Absent → n'importe quel rôle acté « Pas d'appareil »
|
||||
enum EmsRole {
|
||||
evCharger,
|
||||
dhw, // Chauffe-eau
|
||||
gridMeter, // Compteur réseau → Energy.SetRootMeter
|
||||
solarMeter, // Compteur solaire → inféré par interface
|
||||
battery, // Batterie → inférée ; charge pilotable (priorité + needs)
|
||||
evCharger, // Borne de recharge
|
||||
dhw, // Chauffe-eau (ECS)
|
||||
heatPump, // Pompe à chaleur / SG-Ready
|
||||
battery,
|
||||
solarMeter, // Compteur solaire
|
||||
gridMeter, // Compteur réseau
|
||||
}
|
||||
|
||||
extension EmsRoleExt on EmsRole {
|
||||
String get label => switch (this) {
|
||||
EmsRole.evCharger => 'EV Charger',
|
||||
EmsRole.evCharger => 'Borne de recharge',
|
||||
EmsRole.dhw => 'Chauffe-eau',
|
||||
EmsRole.heatPump => 'Pompe à chaleur',
|
||||
EmsRole.battery => 'Batterie',
|
||||
@ -41,7 +50,7 @@ extension EmsRoleExt on EmsRole {
|
||||
EmsRole.gridMeter => ['energymeter', 'smartmeter', 'meter'],
|
||||
};
|
||||
|
||||
/// Label d'interface affiché dans la liste de sélection (Step 1).
|
||||
/// Label d'interface affiché dans la liste de sélection des things.
|
||||
String interfaceLabelFor(String iface) => switch (iface.toLowerCase()) {
|
||||
'sgready' => 'SG-Ready',
|
||||
'sgrelay' => 'SG-Ready',
|
||||
@ -59,113 +68,420 @@ extension EmsRoleExt on EmsRole {
|
||||
};
|
||||
}
|
||||
|
||||
/// Assignation d'un thing à un rôle EMS.
|
||||
class RoleAssignment {
|
||||
final EmsRole role;
|
||||
final NymeaThing thing;
|
||||
final bool enabled;
|
||||
final Map<String, dynamic> params; // puissance, priorité, etc.
|
||||
/// Classification d'un rôle — détermine sa zone et son comportement (contrat).
|
||||
extension EmsRoleClassify on EmsRole {
|
||||
/// Rôle inféré par interface (solaire, batterie). Apparaît **direct** en zone
|
||||
/// définitive avec un badge « détecté » — jamais en « À configurer », jamais
|
||||
/// bloquant pour la complétude.
|
||||
bool get isInferred => this == EmsRole.solarMeter || this == EmsRole.battery;
|
||||
|
||||
const RoleAssignment({
|
||||
required this.role,
|
||||
required this.thing,
|
||||
this.enabled = true,
|
||||
this.params = const {},
|
||||
/// Rôle « pické » par l'installateur (réseau, EV, ECS, PAC). Compté quand
|
||||
/// assigné **ou** acté « Pas d'appareil ».
|
||||
bool get isPicked => !isInferred;
|
||||
|
||||
/// Compteur → zone Compteurs, carte non draggable.
|
||||
bool get isMeter => this == EmsRole.gridMeter || this == EmsRole.solarMeter;
|
||||
|
||||
/// Charge pilotable → zone Charges pilotables, priorisable par drag.
|
||||
bool get isControllableLoad =>
|
||||
this == EmsRole.battery ||
|
||||
this == EmsRole.evCharger ||
|
||||
this == EmsRole.dhw ||
|
||||
this == EmsRole.heatPump;
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// État tri-état d'un rôle (contrat : present / absent / non-configuré)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// État d'un rôle. **Jamais `thingId:null`** : l'absence d'appareil est un état
|
||||
/// explicite ([RoleAbsent]), distinct du « pas encore configuré »
|
||||
/// ([RoleUnconfigured]).
|
||||
sealed class RoleState {
|
||||
const RoleState();
|
||||
}
|
||||
|
||||
/// Rôle pas encore configuré (clé absente, au sens du contrat).
|
||||
class RoleUnconfigured extends RoleState {
|
||||
const RoleUnconfigured();
|
||||
}
|
||||
|
||||
/// Rôle assigné à un thing concret.
|
||||
class RolePresent extends RoleState {
|
||||
final String thingId;
|
||||
|
||||
/// `true` si le rôle a été **inféré** par interface (solaire / batterie).
|
||||
final bool detected;
|
||||
|
||||
const RolePresent(this.thingId, {this.detected = false});
|
||||
}
|
||||
|
||||
/// Rôle acté « Pas d'appareil » (`{status: "absent"}`).
|
||||
class RoleAbsent extends RoleState {
|
||||
const RoleAbsent();
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// Déclaration de charge pilotable (LoadDescriptor §3 — édité écran 2)
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
enum LoadMode { fixed, dynamic }
|
||||
|
||||
/// Besoins d'une charge pilotable (`needs` du LoadDescriptor).
|
||||
class LoadNeeds {
|
||||
final String? dailyDeadline; // ex. "06:00"
|
||||
final int? minEnergyWhPerDay; // ex. 4000
|
||||
|
||||
const LoadNeeds({this.dailyDeadline, this.minEnergyWhPerDay});
|
||||
|
||||
Map<String, dynamic> toJson() => {
|
||||
if (dailyDeadline != null) 'dailyDeadline': dailyDeadline,
|
||||
if (minEnergyWhPerDay != null) 'minEnergyWhPerDay': minEnergyWhPerDay,
|
||||
};
|
||||
}
|
||||
|
||||
/// Déclaration d'une charge pilotable, éditée dans l'**écran 2**.
|
||||
///
|
||||
/// Sur la carte de liste (écran 1), seul [summary] est montré — **pas d'éditeur
|
||||
/// de paliers** (décision contrat : les paliers sont une déclaration, pas une
|
||||
/// propriété matérielle).
|
||||
class LoadConfig {
|
||||
final LoadMode mode;
|
||||
|
||||
/// Paliers atteignables en W — REQUIS si [LoadMode.fixed], trié, contient `0`.
|
||||
final List<int> powerLevels;
|
||||
|
||||
/// Plafond physique en W — REQUIS si [LoadMode.dynamic] ; plafond aussi en fixed.
|
||||
final int? maxPowerW;
|
||||
|
||||
final LoadNeeds? needs;
|
||||
|
||||
const LoadConfig({
|
||||
required this.mode,
|
||||
this.powerLevels = const [],
|
||||
this.maxPowerW,
|
||||
this.needs,
|
||||
});
|
||||
|
||||
RoleAssignment copyWith({
|
||||
bool? enabled,
|
||||
Map<String, dynamic>? params,
|
||||
}) => RoleAssignment(
|
||||
role: role,
|
||||
thing: thing,
|
||||
enabled: enabled ?? this.enabled,
|
||||
params: params ?? this.params,
|
||||
);
|
||||
/// Plafond effectif : `maxPowerW` sinon dernier palier déclaré.
|
||||
int? get effectiveMaxW =>
|
||||
maxPowerW ?? (powerLevels.isNotEmpty ? powerLevels.last : null);
|
||||
|
||||
/// Résumé affiché sur la carte de liste, ex. « 5 paliers · max 2400 W ».
|
||||
String get summary {
|
||||
if (mode == LoadMode.dynamic) {
|
||||
return maxPowerW != null ? 'Dynamique · max $maxPowerW W' : 'Dynamique';
|
||||
}
|
||||
final n = powerLevels.length;
|
||||
final maxW = effectiveMaxW;
|
||||
return maxW != null ? '$n paliers · max $maxW W' : '$n paliers';
|
||||
}
|
||||
}
|
||||
|
||||
/// Résultat d'un test de connexion de rôle.
|
||||
enum ConnectionTestStatus { idle, testing, success, failure }
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// État global de l'écran Rôles & appareils
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
class ConnectionTestResult {
|
||||
final ConnectionTestStatus status;
|
||||
final String? message;
|
||||
const ConnectionTestResult(this.status, [this.message]);
|
||||
}
|
||||
/// Trois états d'écran (contrat) : vierge / en cours / complet.
|
||||
enum SetupScreenState { blank, inProgress, complete }
|
||||
|
||||
/// Gère les assignations de rôles EMS et les flux de configuration.
|
||||
/// Source de vérité **unique** des rôles EMS et de leur configuration.
|
||||
///
|
||||
/// Modèle tri-état conforme à `INTERFACE_etmvariableload.md`. La persistance
|
||||
/// réelle (`Energy.SetRootMeter`, `NymeaEnergy.Set/GetLoadConfig`) est branchée
|
||||
/// à l'**Étape 2** ; ce provider ne gère que l'état en mémoire et son routage.
|
||||
class EnergySetupProvider extends ChangeNotifier {
|
||||
final Map<EmsRole, RoleAssignment?> _assignments = {
|
||||
for (final r in EmsRole.values) r: null,
|
||||
/// Ordre canonique d'insertion des charges pilotables (contrat :
|
||||
/// EV → ECS → PAC → Batterie). L'installateur réordonne ensuite par drag.
|
||||
static const List<EmsRole> _canonicalLoadOrder = [
|
||||
EmsRole.evCharger,
|
||||
EmsRole.dhw,
|
||||
EmsRole.heatPump,
|
||||
EmsRole.battery,
|
||||
];
|
||||
|
||||
final Map<EmsRole, RoleState> _states = {
|
||||
for (final r in EmsRole.values) r: const RoleUnconfigured(),
|
||||
};
|
||||
|
||||
ConnectionTestResult _testResult =
|
||||
const ConnectionTestResult(ConnectionTestStatus.idle);
|
||||
final Map<EmsRole, LoadConfig> _loadConfigs = {};
|
||||
final Map<EmsRole, bool> _enabled = {};
|
||||
|
||||
Map<EmsRole, RoleAssignment?> get assignments => _assignments;
|
||||
ConnectionTestResult get testResult => _testResult;
|
||||
/// Charges pilotables présentes, dans l'ordre de priorité (index 0 = rang 1).
|
||||
final List<EmsRole> _priorityOrder = [];
|
||||
|
||||
RoleAssignment? assignmentFor(EmsRole role) => _assignments[role];
|
||||
bool isConfigured(EmsRole role) => _assignments[role] != null;
|
||||
/// Service de transport (Étape 2) — attaché par l'écran une fois connecté.
|
||||
NymeaService? _service;
|
||||
|
||||
// ── Assignation locale ───────────────────────────────────────────────────────
|
||||
void assign(EmsRole role, NymeaThing thing, Map<String, dynamic> params) {
|
||||
_assignments[role] = RoleAssignment(role: role, thing: thing, params: params);
|
||||
notifyListeners();
|
||||
/// Dernier rootMeter poussé — évite de renvoyer `SetRootMeter` (RPC admin)
|
||||
/// à chaque mutation.
|
||||
String? _lastRootMeterId;
|
||||
|
||||
// ── Lecture d'état ─────────────────────────────────────────────────────────
|
||||
|
||||
RoleState stateOf(EmsRole role) => _states[role]!;
|
||||
LoadConfig? loadConfigOf(EmsRole role) => _loadConfigs[role];
|
||||
bool isEnabled(EmsRole role) => _enabled[role] ?? true;
|
||||
|
||||
bool isPresent(EmsRole role) => _states[role] is RolePresent;
|
||||
bool isAbsent(EmsRole role) => _states[role] is RoleAbsent;
|
||||
bool isUnconfigured(EmsRole role) => _states[role] is RoleUnconfigured;
|
||||
|
||||
/// `true` si le rôle a été inféré par interface.
|
||||
bool isDetected(EmsRole role) {
|
||||
final s = _states[role];
|
||||
return s is RolePresent && s.detected;
|
||||
}
|
||||
|
||||
void unassign(EmsRole role) {
|
||||
_assignments[role] = null;
|
||||
notifyListeners();
|
||||
/// Thing assigné au rôle, ou `null` si non présent.
|
||||
String? thingIdOf(EmsRole role) {
|
||||
final s = _states[role];
|
||||
return s is RolePresent ? s.thingId : null;
|
||||
}
|
||||
|
||||
/// Rang de priorité (1 = servi en premier), ou `null` si non priorisé.
|
||||
int? priorityOf(EmsRole role) {
|
||||
final i = _priorityOrder.indexOf(role);
|
||||
return i < 0 ? null : i + 1;
|
||||
}
|
||||
|
||||
// ── Zones d'affichage ──────────────────────────────────────────────────────
|
||||
|
||||
/// Compteurs présents (réseau, solaire détecté). Non draggables.
|
||||
List<EmsRole> get meterRoles =>
|
||||
EmsRole.values.where((r) => r.isMeter && isPresent(r)).toList();
|
||||
|
||||
/// Charges pilotables présentes, **dans l'ordre de priorité**.
|
||||
List<EmsRole> get controllableRoles => List.unmodifiable(_priorityOrder);
|
||||
|
||||
/// Rôles pickés encore non configurés → zone « À configurer ».
|
||||
/// Les rôles inférés n'y figurent jamais.
|
||||
List<EmsRole> get toConfigureRoles =>
|
||||
EmsRole.values.where((r) => r.isPicked && isUnconfigured(r)).toList();
|
||||
|
||||
/// Rôles actés « Pas d'appareil ».
|
||||
List<EmsRole> get absentRoles =>
|
||||
EmsRole.values.where((r) => isAbsent(r)).toList();
|
||||
|
||||
// ── Compteur X/6 & complétude ──────────────────────────────────────────────
|
||||
|
||||
int get totalRoles => EmsRole.values.length;
|
||||
|
||||
/// Rôles configurés : inférés comptés d'office ; pickés comptés quand assignés
|
||||
/// **ou** actés absents.
|
||||
int get configuredCount {
|
||||
var n = 0;
|
||||
for (final r in EmsRole.values) {
|
||||
if (r.isInferred) {
|
||||
n++; // configurés d'office
|
||||
} else if (_states[r] is RolePresent || _states[r] is RoleAbsent) {
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n;
|
||||
}
|
||||
|
||||
/// « Complet » = aucun rôle pické en non-configuré (les inférés ne bloquent
|
||||
/// jamais).
|
||||
bool get isComplete => EmsRole.values
|
||||
.where((r) => r.isPicked)
|
||||
.every((r) => _states[r] is! RoleUnconfigured);
|
||||
|
||||
SetupScreenState get screenState {
|
||||
final anyPresent = _states.values.any((s) => s is RolePresent);
|
||||
if (!anyPresent) return SetupScreenState.blank;
|
||||
return isComplete ? SetupScreenState.complete : SetupScreenState.inProgress;
|
||||
}
|
||||
|
||||
// ── Mutations ──────────────────────────────────────────────────────────────
|
||||
|
||||
/// Assigne un thing à un rôle. Une charge pilotable rejoint la liste de
|
||||
/// priorité à sa place canonique.
|
||||
void assignThing(EmsRole role, String thingId) {
|
||||
_states[role] = RolePresent(thingId);
|
||||
if (role.isControllableLoad) _ensureInOrder(role);
|
||||
_commit();
|
||||
}
|
||||
|
||||
/// Acte « Pas d'appareil » pour un rôle.
|
||||
void markAbsent(EmsRole role) {
|
||||
_states[role] = const RoleAbsent();
|
||||
_priorityOrder.remove(role);
|
||||
_commit();
|
||||
}
|
||||
|
||||
/// Remet un rôle en « À configurer ».
|
||||
void unconfigure(EmsRole role) {
|
||||
_states[role] = const RoleUnconfigured();
|
||||
_priorityOrder.remove(role);
|
||||
_commit();
|
||||
}
|
||||
|
||||
/// Active / exclut une charge de l'arbitrage (`enabled` du LoadDescriptor).
|
||||
void setEnabled(EmsRole role, bool enabled) {
|
||||
final current = _assignments[role];
|
||||
if (current != null) {
|
||||
_assignments[role] = current.copyWith(enabled: enabled);
|
||||
notifyListeners();
|
||||
_enabled[role] = enabled;
|
||||
_commit();
|
||||
}
|
||||
|
||||
/// Enregistre la déclaration éditée dans l'écran 2.
|
||||
void setLoadConfig(EmsRole role, LoadConfig config) {
|
||||
_loadConfigs[role] = config;
|
||||
_commit();
|
||||
}
|
||||
|
||||
/// Réordonne les charges pilotables → réécrit la priorité (rang 1 = premier
|
||||
/// servi). Sémantique `ReorderableListView` (newIndex décalé vers le haut).
|
||||
void reorderControllableLoads(int oldIndex, int newIndex) {
|
||||
if (oldIndex < 0 || oldIndex >= _priorityOrder.length) return;
|
||||
if (newIndex > oldIndex) newIndex--;
|
||||
final role = _priorityOrder.removeAt(oldIndex);
|
||||
_priorityOrder.insert(newIndex.clamp(0, _priorityOrder.length), role);
|
||||
_commit();
|
||||
}
|
||||
|
||||
// ── Persistance côté plugin (Étape 2) ──────────────────────────────────────
|
||||
|
||||
/// Attache le service de transport. Appelé par l'écran une fois connecté.
|
||||
/// Tente une lecture initiale (stub vide — ne bloque pas le démarrage).
|
||||
void attach(NymeaService service) {
|
||||
if (identical(_service, service)) return;
|
||||
_service = service;
|
||||
service.getLoadConfig();
|
||||
}
|
||||
|
||||
/// `etmvariableload` = charge à **puissance pilotable** (rév. 2). La **PAC**
|
||||
/// en est exclue (modèle à états SG-Ready, §8) ; la **batterie** aussi (kind
|
||||
/// `Constraint`, déféré 3f). Seuls EV & ECS émettent un `LoadDescriptor`.
|
||||
bool isVariableLoad(EmsRole role) =>
|
||||
role == EmsRole.evCharger || role == EmsRole.dhw;
|
||||
|
||||
/// Construit le `LoadDescriptor` (§4) d'une charge etmvariableload présente,
|
||||
/// ou `null` sinon. Les champs déclarés (`mode`/`powerLevels`/`maxPowerW`/
|
||||
/// `needs`) ne sont posés que si la déclaration (écran 2) existe — sinon seul
|
||||
/// l'ossature priorité/activation part (déclaration encore à faire).
|
||||
Map<String, dynamic>? buildLoadDescriptor(EmsRole role) {
|
||||
if (!isVariableLoad(role)) return null;
|
||||
final thingId = thingIdOf(role);
|
||||
if (thingId == null) return null;
|
||||
|
||||
final d = <String, dynamic>{
|
||||
'id': thingId,
|
||||
'label': role.label,
|
||||
'adapter': 'etmvariableload',
|
||||
'priority': priorityOf(role) ?? 0,
|
||||
'enabled': isEnabled(role),
|
||||
};
|
||||
|
||||
final cfg = _loadConfigs[role];
|
||||
if (cfg != null) {
|
||||
d['mode'] = cfg.mode == LoadMode.fixed ? 'fixed' : 'dynamic';
|
||||
if (cfg.mode == LoadMode.fixed) d['powerLevels'] = cfg.powerLevels;
|
||||
if (cfg.maxPowerW != null) d['maxPowerW'] = cfg.maxPowerW;
|
||||
if (cfg.needs != null) d['needs'] = cfg.needs!.toJson();
|
||||
}
|
||||
return d;
|
||||
}
|
||||
|
||||
/// Tous les `LoadDescriptor` à émettre, dans l'ordre de priorité.
|
||||
List<Map<String, dynamic>> buildLoadDescriptors() => controllableRoles
|
||||
.map(buildLoadDescriptor)
|
||||
.whereType<Map<String, dynamic>>()
|
||||
.toList();
|
||||
|
||||
/// Pousse l'état courant vers le plugin :
|
||||
/// • gridMeter → `Energy.SetRootMeter` (**réel**, seulement si changé) ;
|
||||
/// • EV / ECS → `SetLoadConfig` (**stub** : logge le JSON) ;
|
||||
/// • PAC / batterie → chemins distincts (rév. 2), tracés explicitement.
|
||||
void persist() {
|
||||
final service = _service;
|
||||
if (service == null) return;
|
||||
|
||||
final gridId = thingIdOf(EmsRole.gridMeter);
|
||||
if (gridId != null && gridId != _lastRootMeterId) {
|
||||
_lastRootMeterId = gridId;
|
||||
service.setRootMeter(gridId);
|
||||
}
|
||||
|
||||
service.setLoadConfig(buildLoadDescriptors());
|
||||
|
||||
for (final role in controllableRoles) {
|
||||
if (role == EmsRole.heatPump) {
|
||||
service.logInfo('[LoadConfig STUB] PAC "${role.label}" → action état '
|
||||
'SG-Ready (hors LoadDescriptor, chemin §8)');
|
||||
} else if (role == EmsRole.battery) {
|
||||
service.logInfo('[LoadConfig STUB] Batterie "${role.label}" → kind '
|
||||
'Constraint (déféré 3f), non émis');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Filtrage des things compatibles avec un rôle ─────────────────────────────
|
||||
List<NymeaThing> compatibleThings(
|
||||
EmsRole role, NymeaService service) {
|
||||
/// Notifie l'UI **et** pousse vers le plugin.
|
||||
void _commit() {
|
||||
notifyListeners();
|
||||
persist();
|
||||
}
|
||||
|
||||
/// Réinitialise l'état au **switch d'installation**. Reset mémoire **sans
|
||||
/// `persist()`** : on ne pousse jamais une config vide vers la nouvelle box
|
||||
/// (sinon on écraserait sa vraie config au connect). Garde la ref `_service`.
|
||||
void clearForSwitch() {
|
||||
for (final r in EmsRole.values) {
|
||||
_states[r] = const RoleUnconfigured();
|
||||
}
|
||||
_loadConfigs.clear();
|
||||
_enabled.clear();
|
||||
_priorityOrder.clear();
|
||||
_lastRootMeterId = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ── Inférence solaire / batterie ───────────────────────────────────────────
|
||||
|
||||
/// Infère les rôles solaire & batterie depuis les interfaces des things : ils
|
||||
/// passent **direct** en zone définitive (badge « détecté »). N'écrase jamais
|
||||
/// un choix explicite (assignation manuelle ou « Pas d'appareil »).
|
||||
void syncFromThings(NymeaService service) {
|
||||
var changed = false;
|
||||
for (final role in const [EmsRole.solarMeter, EmsRole.battery]) {
|
||||
if (_states[role] is! RoleUnconfigured) continue; // respecte l'explicite
|
||||
final matches = compatibleThings(role, service);
|
||||
if (matches.isNotEmpty) {
|
||||
_states[role] = RolePresent(matches.first.id, detected: true);
|
||||
if (role.isControllableLoad) _ensureInOrder(role);
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
if (changed) notifyListeners();
|
||||
}
|
||||
|
||||
// ── Filtrage des things compatibles avec un rôle ───────────────────────────
|
||||
|
||||
List<NymeaThing> compatibleThings(EmsRole role, NymeaService service) {
|
||||
return service.things.where((thing) {
|
||||
try {
|
||||
final cls = service.thingClasses
|
||||
.firstWhere((c) => c.id == thing.thingClassId);
|
||||
return cls.interfaces.any(
|
||||
(i) => role.compatibleInterfaces
|
||||
.contains(i.toLowerCase()));
|
||||
final cls =
|
||||
service.thingClasses.firstWhere((c) => c.id == thing.thingClassId);
|
||||
return cls.interfaces
|
||||
.any((i) => role.compatibleInterfaces.contains(i.toLowerCase()));
|
||||
} catch (_) {
|
||||
return false;
|
||||
}
|
||||
}).toList();
|
||||
}
|
||||
|
||||
// ── Test de connexion (simulé — à brancher sur RPC réel) ────────────────────
|
||||
Future<void> testConnection(EmsRole role, NymeaService service) async {
|
||||
_testResult = const ConnectionTestResult(ConnectionTestStatus.testing);
|
||||
notifyListeners();
|
||||
// ── Interne ────────────────────────────────────────────────────────────────
|
||||
|
||||
await Future.delayed(const Duration(seconds: 2));
|
||||
|
||||
final assignment = _assignments[role];
|
||||
if (assignment == null) {
|
||||
_testResult = const ConnectionTestResult(
|
||||
ConnectionTestStatus.failure,
|
||||
'Aucun appareil configuré pour ce rôle.');
|
||||
} else {
|
||||
// En mode simulation, retourne toujours succès
|
||||
_testResult = ConnectionTestResult(
|
||||
ConnectionTestStatus.success,
|
||||
'${assignment.thing.name} a répondu correctement.');
|
||||
/// Insère une charge pilotable à sa place canonique si absente de l'ordre.
|
||||
void _ensureInOrder(EmsRole role) {
|
||||
if (_priorityOrder.contains(role)) return;
|
||||
final canonIdx = _canonicalLoadOrder.indexOf(role);
|
||||
var insertAt = _priorityOrder.length;
|
||||
for (var i = 0; i < _priorityOrder.length; i++) {
|
||||
if (_canonicalLoadOrder.indexOf(_priorityOrder[i]) > canonIdx) {
|
||||
insertAt = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
void resetTest() {
|
||||
_testResult = const ConnectionTestResult(ConnectionTestStatus.idle);
|
||||
notifyListeners();
|
||||
_priorityOrder.insert(insertAt, role);
|
||||
}
|
||||
}
|
||||
|
||||
@ -71,6 +71,16 @@ class SchedulerProvider extends ChangeNotifier {
|
||||
SchedulerState get state => _state;
|
||||
bool get loading => _loading;
|
||||
|
||||
/// Réinitialise au switch d'installation (reset mémoire aux défauts ; la
|
||||
/// config de la nouvelle box sera rechargée par `load()`).
|
||||
void clearForSwitch() {
|
||||
_strategy = SchedulerStrategy.rulesBased;
|
||||
_config = const SchedulerConfig();
|
||||
_state = const SchedulerState();
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ── Chargement depuis nymea (stub — à brancher sur RPC réel) ────────────────
|
||||
Future<void> load(NymeaService service) async {
|
||||
_loading = true;
|
||||
|
||||
@ -98,6 +98,21 @@ class TariffProvider extends ChangeNotifier {
|
||||
);
|
||||
bool _loading = false;
|
||||
|
||||
/// Réinitialise au switch d'installation (reset mémoire aux défauts ; la
|
||||
/// config tarifaire de la nouvelle box sera rechargée par `load()`).
|
||||
void clearForSwitch() {
|
||||
_activeProvider = TariffProviderType.manual;
|
||||
_mode = TariffMode.fixed;
|
||||
_manualPrice = 0.25;
|
||||
_hchp = const HcHpConfig();
|
||||
_prices = const CurrentPrices(
|
||||
now: 0.092, inOneHour: 0.085,
|
||||
min24h: 0.048, max24h: 0.182,
|
||||
);
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
TariffProviderType get activeProvider => _activeProvider;
|
||||
TariffMode get mode => _mode;
|
||||
HcHpConfig get hchp => _hchp;
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -4,6 +4,7 @@ import 'package:provider/provider.dart';
|
||||
import 'package:url_launcher/url_launcher.dart';
|
||||
import '../../providers/installer_mode_provider.dart';
|
||||
import '../../providers/navigation_provider.dart';
|
||||
import '../../services/connection_manager.dart';
|
||||
import '../../services/nymea_service.dart';
|
||||
import '../../theme/etm_tokens.dart';
|
||||
import 'installer_pin_dialog.dart';
|
||||
@ -62,6 +63,15 @@ class _DrawerHeader extends StatelessWidget {
|
||||
Widget build(BuildContext context) {
|
||||
final service = context.watch<NymeaService>();
|
||||
final installer = context.watch<InstallerModeProvider>();
|
||||
final manager = context.watch<ConnectionManager>();
|
||||
|
||||
// Nom de l'installation active : box réelle > identité Hello > démo.
|
||||
final boxName = service.isSimulation
|
||||
? 'Mode démo'
|
||||
: (manager.active?.name ??
|
||||
(service.serverName.isNotEmpty
|
||||
? service.serverName
|
||||
: 'Non connecté'));
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.fromLTRB(20, 18, 16, 18),
|
||||
@ -119,10 +129,30 @@ class _DrawerHeader extends StatelessWidget {
|
||||
|
||||
const SizedBox(height: 12),
|
||||
|
||||
// ── Nom du site ──────────────────────────────────────────────────
|
||||
Text(
|
||||
service.isSimulation ? 'Site démo' : 'Mon installation',
|
||||
style: EtmTokens.sans(size: 15, weight: FontWeight.w600, color: Colors.white),
|
||||
// ── Nom de la box active (cliquable → Installations) ─────────────
|
||||
// L'en-tête EST l'accès au gestionnaire multi-HEMS (pas d'entrée de
|
||||
// menu dédiée) : tap → bascule d'installation.
|
||||
GestureDetector(
|
||||
onTap: () {
|
||||
context.read<NavigationProvider>().closeDrawer();
|
||||
context.go('/installations');
|
||||
},
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
boxName,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: EtmTokens.sans(
|
||||
size: 15, weight: FontWeight.w600, color: Colors.white),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
const Icon(Icons.unfold_more_rounded,
|
||||
size: 16, color: Color(0xFFA9C4D3)),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: 6),
|
||||
|
||||
@ -1,172 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../providers/energy_setup_provider.dart';
|
||||
import '../../providers/installer_mode_provider.dart';
|
||||
import '../../services/nymea_service.dart';
|
||||
import '../../theme/etm_theme.dart';
|
||||
import '../../widgets/role_card.dart';
|
||||
import 'role_config_flow.dart';
|
||||
|
||||
/// Écran "Rôles & appareils" — configuration des rôles EMS.
|
||||
/// Accessible uniquement en mode installateur.
|
||||
class EnergySetupScreen extends StatelessWidget {
|
||||
const EnergySetupScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final installer = context.watch<InstallerModeProvider>();
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFFF0F2F5),
|
||||
appBar: AppBar(
|
||||
title: const Text('Rôles & appareils'),
|
||||
backgroundColor: Colors.white,
|
||||
foregroundColor: const Color(0xFF1A1A2E),
|
||||
elevation: 0,
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.help_outline_rounded),
|
||||
tooltip: 'Aide',
|
||||
onPressed: () => _showHelp(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: installer.isUnlocked
|
||||
? _SetupBody()
|
||||
: _LockedOverlay(),
|
||||
);
|
||||
}
|
||||
|
||||
void _showHelp(BuildContext context) {
|
||||
showModalBottomSheet(
|
||||
context: context,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (_) => Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: const [
|
||||
Text('Rôles & appareils',
|
||||
style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold)),
|
||||
SizedBox(height: 12),
|
||||
Text(
|
||||
'Assignez chaque appareil physique à son rôle dans '
|
||||
'le système de gestion d\'énergie.\n\n'
|
||||
'Le gestionnaire d\'énergie utilise ces assignations pour '
|
||||
'optimiser la consommation et maximiser l\'autoconsommation.',
|
||||
style: TextStyle(fontSize: 14),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LockedOverlay extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.lock_rounded,
|
||||
size: 48, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 16),
|
||||
const Text(
|
||||
'Mode installateur requis',
|
||||
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
const Text(
|
||||
'Activez le mode installateur depuis le menu\npour accéder à cet écran.',
|
||||
textAlign: TextAlign.center,
|
||||
style: TextStyle(color: Color(0xFF6B7280)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _SetupBody extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final setup = context.watch<EnergySetupProvider>();
|
||||
final service = context.read<NymeaService>();
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.all(16),
|
||||
children: [
|
||||
// Bannière d'info
|
||||
Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
margin: const EdgeInsets.only(bottom: 16),
|
||||
decoration: BoxDecoration(
|
||||
color: ETMTheme.accentColor.withValues(alpha: 0.1),
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: ETMTheme.accentColor.withValues(alpha: 0.3)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.info_outline_rounded,
|
||||
color: ETMTheme.accentColor, size: 18),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'${EmsRole.values.where((r) => setup.isConfigured(r)).length} / '
|
||||
'${EmsRole.values.length} rôles configurés',
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: ETMTheme.accentColor,
|
||||
fontWeight: FontWeight.w500,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
...EmsRole.values.map((role) => RoleCard(
|
||||
role: role,
|
||||
assignment: setup.assignmentFor(role),
|
||||
isReachable: true,
|
||||
onConfigure: () => _openConfigFlow(context, role, null),
|
||||
onToggle: setup.isConfigured(role)
|
||||
? () => setup.setEnabled(
|
||||
role,
|
||||
!(setup.assignmentFor(role)?.enabled ?? false),
|
||||
)
|
||||
: null,
|
||||
onEdit: setup.isConfigured(role)
|
||||
? () => _openConfigFlow(
|
||||
context,
|
||||
role,
|
||||
setup.assignmentFor(role),
|
||||
)
|
||||
: null,
|
||||
onTest: setup.isConfigured(role)
|
||||
? () => setup.testConnection(role, service)
|
||||
: null,
|
||||
)),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _openConfigFlow(
|
||||
BuildContext context, EmsRole role, RoleAssignment? existing) async {
|
||||
await showModalBottomSheet(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
useSafeArea: true,
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (_) => RoleConfigFlow(role: role, existing: existing),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -1,688 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import '../../models/nymea_models.dart';
|
||||
import '../../providers/energy_setup_provider.dart';
|
||||
import '../../services/nymea_service.dart';
|
||||
import '../../theme/app_theme.dart';
|
||||
import '../../theme/etm_theme.dart';
|
||||
|
||||
/// Bottom sheet en 3 étapes pour configurer un rôle EMS.
|
||||
///
|
||||
/// Étape 1 : Choisir un thing compatible
|
||||
/// Étape 2 : Paramètres spécifiques au rôle
|
||||
/// Étape 3 : Test de connexion
|
||||
class RoleConfigFlow extends StatefulWidget {
|
||||
final EmsRole role;
|
||||
final RoleAssignment? existing;
|
||||
|
||||
const RoleConfigFlow({
|
||||
super.key,
|
||||
required this.role,
|
||||
this.existing,
|
||||
});
|
||||
|
||||
@override
|
||||
State<RoleConfigFlow> createState() => _RoleConfigFlowState();
|
||||
}
|
||||
|
||||
class _RoleConfigFlowState extends State<RoleConfigFlow> {
|
||||
int _step = 0;
|
||||
NymeaThing? _selectedThing;
|
||||
String _searchQuery = '';
|
||||
final Map<String, dynamic> _params = {};
|
||||
|
||||
// Paramètres par défaut selon le rôle
|
||||
void _initParams() {
|
||||
switch (widget.role) {
|
||||
case EmsRole.evCharger:
|
||||
_params['phases'] = 1;
|
||||
_params['minA'] = 6;
|
||||
_params['maxA'] = 32;
|
||||
_params['priority'] = 'Normal';
|
||||
case EmsRole.dhw:
|
||||
_params['powerW'] = 2000;
|
||||
_params['priority'] = 'Normal';
|
||||
case EmsRole.heatPump:
|
||||
_params['powerW'] = 5000;
|
||||
_params['priority'] = 'High';
|
||||
case EmsRole.battery:
|
||||
_params['capacityWh'] = 10000;
|
||||
_params['maxChargeW'] = 3000;
|
||||
_params['maxDischargeW'] = 3000;
|
||||
case EmsRole.solarMeter:
|
||||
_params['powerW'] = 6000;
|
||||
case EmsRole.gridMeter:
|
||||
_params['breakerKVA'] = 12;
|
||||
}
|
||||
// Pré-remplir avec les valeurs existantes
|
||||
if (widget.existing != null) {
|
||||
_params.addAll(widget.existing!.params);
|
||||
_selectedThing = widget.existing!.thing;
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_initParams();
|
||||
if (widget.existing != null) {
|
||||
_step = 1; // Va directement à l'étape paramètres si édition
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return DraggableScrollableSheet(
|
||||
initialChildSize: 0.85,
|
||||
maxChildSize: 0.95,
|
||||
minChildSize: 0.5,
|
||||
expand: false,
|
||||
builder: (context, sc) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.only(
|
||||
bottom: MediaQuery.of(context).viewInsets.bottom),
|
||||
child: Column(
|
||||
children: [
|
||||
// ── Drag handle ───────────────────────────────────────────────
|
||||
const SizedBox(height: 12),
|
||||
Center(
|
||||
child: Container(
|
||||
width: 36, height: 4,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(2),
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Titre + stepper ───────────────────────────────────────────
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Row(
|
||||
children: [
|
||||
Text(
|
||||
'${widget.role.icon} ${widget.role.label}',
|
||||
style: const TextStyle(
|
||||
fontSize: 17,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const Spacer(),
|
||||
_StepDots(current: _step, total: 3),
|
||||
],
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: Text(
|
||||
_stepTitle(),
|
||||
style: const TextStyle(
|
||||
fontSize: 13,
|
||||
color: AppTheme.textLight,
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
const Divider(height: 20),
|
||||
|
||||
// ── Contenu de l'étape ────────────────────────────────────────
|
||||
Expanded(
|
||||
child: SingleChildScrollView(
|
||||
controller: sc,
|
||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||
child: _stepContent(context),
|
||||
),
|
||||
),
|
||||
|
||||
// ── Boutons de navigation ─────────────────────────────────────
|
||||
_StepButtons(
|
||||
step: _step,
|
||||
canNext: _canProceed(),
|
||||
onBack: _step > 0 ? () => setState(() => _step--) : null,
|
||||
onNext: _canProceed() ? _next : null,
|
||||
onCancel: () => Navigator.pop(context),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
String _stepTitle() => switch (_step) {
|
||||
0 => 'Choisissez un appareil compatible',
|
||||
1 => 'Paramètres de configuration',
|
||||
_ => 'Test de connexion',
|
||||
};
|
||||
|
||||
bool _canProceed() => switch (_step) {
|
||||
0 => _selectedThing != null,
|
||||
1 => true,
|
||||
_ => true,
|
||||
};
|
||||
|
||||
void _next() {
|
||||
if (_step < 2) {
|
||||
setState(() => _step++);
|
||||
if (_step == 2) _runTest();
|
||||
} else {
|
||||
_save();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _runTest() async {
|
||||
final setup = context.read<EnergySetupProvider>();
|
||||
final service = context.read<NymeaService>();
|
||||
// Assigne temporairement pour le test
|
||||
setup.assign(widget.role, _selectedThing!, _params);
|
||||
await setup.testConnection(widget.role, service);
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
void _save() {
|
||||
context
|
||||
.read<EnergySetupProvider>()
|
||||
.assign(widget.role, _selectedThing!, _params);
|
||||
Navigator.pop(context);
|
||||
}
|
||||
|
||||
// ── Contenu des étapes ────────────────────────────────────────────────────
|
||||
Widget _stepContent(BuildContext context) {
|
||||
return switch (_step) {
|
||||
0 => _Step1ThingList(
|
||||
role: widget.role,
|
||||
selected: _selectedThing,
|
||||
searchQuery: _searchQuery,
|
||||
onSearch: (q) => setState(() => _searchQuery = q),
|
||||
onSelect: (t) => setState(() => _selectedThing = t),
|
||||
),
|
||||
1 => _Step2Params(
|
||||
role: widget.role,
|
||||
params: _params,
|
||||
things: context.read<NymeaService>().things,
|
||||
onChange: (k, v) => setState(() => _params[k] = v),
|
||||
),
|
||||
_ => _Step3Test(
|
||||
role: widget.role,
|
||||
onRetry: _runTest,
|
||||
onSkip: _save,
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Étape 1 — Choix du thing ──────────────────────────────────────────────────
|
||||
class _Step1ThingList extends StatelessWidget {
|
||||
final EmsRole role;
|
||||
final NymeaThing? selected;
|
||||
final String searchQuery;
|
||||
final ValueChanged<String> onSearch;
|
||||
final ValueChanged<NymeaThing> onSelect;
|
||||
|
||||
const _Step1ThingList({
|
||||
required this.role,
|
||||
required this.selected,
|
||||
required this.searchQuery,
|
||||
required this.onSearch,
|
||||
required this.onSelect,
|
||||
});
|
||||
|
||||
/// Retourne le label d'interface le plus pertinent d'un thing pour ce rôle.
|
||||
String _ifaceLabel(NymeaThing thing, NymeaService service) {
|
||||
try {
|
||||
final cls = service.thingClasses
|
||||
.firstWhere((c) => c.id == thing.thingClassId);
|
||||
for (final iface in role.compatibleInterfaces) {
|
||||
if (cls.interfaces.any((i) => i.toLowerCase() == iface)) {
|
||||
return role.interfaceLabelFor(iface);
|
||||
}
|
||||
}
|
||||
return cls.interfaces.firstOrNull ?? '';
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final setup = context.read<EnergySetupProvider>();
|
||||
final service = context.read<NymeaService>();
|
||||
final things = setup.compatibleThings(role, service)
|
||||
.where((t) => searchQuery.isEmpty ||
|
||||
t.name.toLowerCase().contains(searchQuery.toLowerCase()))
|
||||
.toList();
|
||||
|
||||
return Column(
|
||||
children: [
|
||||
// Barre de recherche
|
||||
TextField(
|
||||
decoration: InputDecoration(
|
||||
hintText: 'Rechercher un appareil...',
|
||||
prefixIcon: const Icon(Icons.search_rounded, size: 20),
|
||||
filled: true,
|
||||
fillColor: Colors.grey.shade100,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
contentPadding: const EdgeInsets.symmetric(vertical: 10),
|
||||
),
|
||||
onChanged: onSearch,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
if (things.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.device_unknown_rounded,
|
||||
size: 48, color: Colors.grey.shade400),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
searchQuery.isEmpty
|
||||
? 'Aucun appareil compatible pour ce rôle'
|
||||
: 'Aucun résultat pour "$searchQuery"',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppTheme.textLight),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
else
|
||||
...things.map((t) {
|
||||
final isSelected = selected?.id == t.id;
|
||||
return _ThingTile(
|
||||
thing: t,
|
||||
ifaceLabel: _ifaceLabel(t, service),
|
||||
isSelected: isSelected,
|
||||
onTap: () => onSelect(t),
|
||||
);
|
||||
}),
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _ThingTile extends StatelessWidget {
|
||||
final NymeaThing thing;
|
||||
final String ifaceLabel;
|
||||
final bool isSelected;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _ThingTile({
|
||||
required this.thing,
|
||||
required this.ifaceLabel,
|
||||
required this.isSelected,
|
||||
required this.onTap,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
child: Container(
|
||||
margin: const EdgeInsets.only(bottom: 8),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: isSelected
|
||||
? ETMTheme.accentColor.withValues(alpha: 0.08)
|
||||
: Colors.grey.shade50,
|
||||
borderRadius: BorderRadius.circular(12),
|
||||
border: Border.all(
|
||||
color: isSelected
|
||||
? ETMTheme.accentColor
|
||||
: Colors.grey.shade200,
|
||||
),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40, height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: Colors.grey.shade100,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
),
|
||||
child: const Icon(Icons.devices_rounded,
|
||||
color: AppTheme.textLight),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(thing.name,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.w600, fontSize: 13)),
|
||||
Text(
|
||||
ifaceLabel.isNotEmpty ? ifaceLabel : thing.thingClassId,
|
||||
style: const TextStyle(
|
||||
fontSize: 11, color: AppTheme.textLight),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (isSelected)
|
||||
const Icon(Icons.check_circle_rounded,
|
||||
color: ETMTheme.accentColor, size: 20),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Étape 2 — Paramètres ──────────────────────────────────────────────────────
|
||||
class _Step2Params extends StatelessWidget {
|
||||
final EmsRole role;
|
||||
final Map<String, dynamic> params;
|
||||
final List<NymeaThing> things;
|
||||
final void Function(String, dynamic) onChange;
|
||||
|
||||
const _Step2Params({
|
||||
required this.role,
|
||||
required this.params,
|
||||
required this.things,
|
||||
required this.onChange,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
children: [
|
||||
...switch (role) {
|
||||
EmsRole.evCharger => _evChargerParams(),
|
||||
EmsRole.dhw => _relayParams(),
|
||||
EmsRole.heatPump => _heatPumpParams(things),
|
||||
EmsRole.battery => _batteryParams(),
|
||||
EmsRole.solarMeter => _solarMeterParams(),
|
||||
EmsRole.gridMeter => _gridMeterParams(),
|
||||
},
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
List<Widget> _evChargerParams() => [
|
||||
_NumField('Courant min (A)', 'minA', params, onChange, min: 6, max: 16),
|
||||
_NumField('Courant max (A)', 'maxA', params, onChange, min: 6, max: 32),
|
||||
_PriorityDropdown(params, onChange),
|
||||
];
|
||||
|
||||
List<Widget> _relayParams() => [
|
||||
_NumField('Puissance nominale (W)', 'powerW', params, onChange,
|
||||
min: 100, max: 20000),
|
||||
_PriorityDropdown(params, onChange),
|
||||
];
|
||||
|
||||
List<Widget> _heatPumpParams(List<NymeaThing> things) => [
|
||||
_NumField('Puissance normale (W)', 'powerW', params, onChange,
|
||||
min: 500, max: 20000),
|
||||
_PriorityDropdown(params, onChange),
|
||||
];
|
||||
|
||||
List<Widget> _batteryParams() => [
|
||||
_NumField('Capacité (Wh)', 'capacityWh', params, onChange,
|
||||
min: 1000, max: 100000),
|
||||
_NumField('Puissance max charge (W)', 'maxChargeW', params, onChange,
|
||||
min: 100, max: 20000),
|
||||
_NumField('Puissance max décharge (W)', 'maxDischargeW', params, onChange,
|
||||
min: 100, max: 20000),
|
||||
];
|
||||
|
||||
List<Widget> _solarMeterParams() => [
|
||||
_NumField('Puissance crête onduleur (W)', 'powerW', params, onChange,
|
||||
min: 500, max: 100000),
|
||||
];
|
||||
|
||||
List<Widget> _gridMeterParams() => [
|
||||
_NumField('Puissance de coupure (kVA)', 'breakerKVA', params, onChange,
|
||||
min: 3, max: 630),
|
||||
];
|
||||
}
|
||||
|
||||
class _NumField extends StatelessWidget {
|
||||
final String label;
|
||||
final String paramKey;
|
||||
final Map<String, dynamic> params;
|
||||
final void Function(String, dynamic) onChange;
|
||||
final double min;
|
||||
final double max;
|
||||
|
||||
const _NumField(this.label, this.paramKey, this.params, this.onChange,
|
||||
{required this.min, required this.max});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: TextFormField(
|
||||
initialValue: params[paramKey]?.toString() ?? '',
|
||||
decoration: InputDecoration(
|
||||
labelText: label,
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 14, vertical: 12),
|
||||
),
|
||||
keyboardType: const TextInputType.numberWithOptions(decimal: true),
|
||||
onChanged: (v) {
|
||||
final n = num.tryParse(v);
|
||||
if (n != null) onChange(paramKey, n);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PriorityDropdown extends StatelessWidget {
|
||||
final Map<String, dynamic> params;
|
||||
final void Function(String, dynamic) onChange;
|
||||
|
||||
const _PriorityDropdown(this.params, this.onChange);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final priorities = ['Critical', 'High', 'Normal', 'Low'];
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 16),
|
||||
child: DropdownButtonFormField<String>(
|
||||
value: params['priority'] as String? ?? 'Normal',
|
||||
decoration: InputDecoration(
|
||||
labelText: 'Priorité',
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
contentPadding: const EdgeInsets.symmetric(
|
||||
horizontal: 14, vertical: 12),
|
||||
),
|
||||
items: priorities
|
||||
.map((p) => DropdownMenuItem(value: p, child: Text(p)))
|
||||
.toList(),
|
||||
onChanged: (v) { if (v != null) onChange('priority', v); },
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Étape 3 — Test de connexion ───────────────────────────────────────────────
|
||||
class _Step3Test extends StatelessWidget {
|
||||
final EmsRole role;
|
||||
final VoidCallback onRetry;
|
||||
final VoidCallback onSkip;
|
||||
|
||||
const _Step3Test({
|
||||
required this.role,
|
||||
required this.onRetry,
|
||||
required this.onSkip,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final result = context.watch<EnergySetupProvider>().testResult;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 32),
|
||||
child: switch (result.status) {
|
||||
ConnectionTestStatus.idle || ConnectionTestStatus.testing => Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 20),
|
||||
const Text(
|
||||
'Test de connexion en cours...',
|
||||
style: TextStyle(fontSize: 15),
|
||||
),
|
||||
],
|
||||
),
|
||||
ConnectionTestStatus.success => Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 60, height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.primaryGreen.withValues(alpha: 0.12),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.check_rounded,
|
||||
color: AppTheme.primaryGreen, size: 32),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Connexion réussie',
|
||||
style: TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
result.message ?? '',
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppTheme.textLight),
|
||||
),
|
||||
],
|
||||
),
|
||||
ConnectionTestStatus.failure => Column(
|
||||
children: [
|
||||
Container(
|
||||
width: 60, height: 60,
|
||||
decoration: BoxDecoration(
|
||||
color: AppTheme.boostRed.withValues(alpha: 0.12),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: const Icon(Icons.close_rounded,
|
||||
color: AppTheme.boostRed, size: 32),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
const Text('Échec de la connexion',
|
||||
style: TextStyle(
|
||||
fontSize: 16, fontWeight: FontWeight.bold)),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
result.message ??
|
||||
"L'appareil n'a pas répondu dans les 5 secondes",
|
||||
textAlign: TextAlign.center,
|
||||
style: const TextStyle(color: AppTheme.textLight),
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
ElevatedButton.icon(
|
||||
icon: const Icon(Icons.refresh_rounded, size: 18),
|
||||
label: const Text('Réessayer'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: ETMTheme.accentColor,
|
||||
foregroundColor: Colors.white,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
),
|
||||
onPressed: onRetry,
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
TextButton(
|
||||
onPressed: onSkip,
|
||||
child: const Text('Configurer quand même'),
|
||||
),
|
||||
],
|
||||
),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Composants partagés ───────────────────────────────────────────────────────
|
||||
class _StepDots extends StatelessWidget {
|
||||
final int current;
|
||||
final int total;
|
||||
const _StepDots({required this.current, required this.total});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
children: List.generate(total, (i) {
|
||||
final active = i == current;
|
||||
return Container(
|
||||
width: active ? 16 : 8,
|
||||
height: 8,
|
||||
margin: const EdgeInsets.only(left: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: active ? ETMTheme.accentColor : Colors.grey.shade300,
|
||||
borderRadius: BorderRadius.circular(4),
|
||||
),
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _StepButtons extends StatelessWidget {
|
||||
final int step;
|
||||
final bool canNext;
|
||||
final VoidCallback? onBack;
|
||||
final VoidCallback? onNext;
|
||||
final VoidCallback onCancel;
|
||||
|
||||
const _StepButtons({
|
||||
required this.step,
|
||||
required this.canNext,
|
||||
required this.onBack,
|
||||
required this.onNext,
|
||||
required this.onCancel,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final result = context.watch<EnergySetupProvider>().testResult;
|
||||
final isTesting =
|
||||
result.status == ConnectionTestStatus.testing;
|
||||
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(20, 8, 20, 20),
|
||||
child: Row(
|
||||
children: [
|
||||
if (onBack != null)
|
||||
TextButton(
|
||||
onPressed: onBack,
|
||||
child: const Text('← Retour'),
|
||||
)
|
||||
else
|
||||
TextButton(
|
||||
onPressed: onCancel,
|
||||
child: const Text('Annuler'),
|
||||
),
|
||||
const Spacer(),
|
||||
if (step < 2 || result.status == ConnectionTestStatus.success)
|
||||
ElevatedButton(
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: ETMTheme.accentColor,
|
||||
foregroundColor: Colors.white,
|
||||
disabledBackgroundColor: Colors.grey.shade200,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(12)),
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 24, vertical: 12),
|
||||
),
|
||||
onPressed: isTesting ? null : onNext,
|
||||
child: Text(step == 2 ? 'Terminer' : 'Suivant →'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
806
lib/screens/energy/roles_devices_screen.dart
Normal file
806
lib/screens/energy/roles_devices_screen.dart
Normal file
@ -0,0 +1,806 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../models/nymea_models.dart';
|
||||
import '../../providers/energy_setup_provider.dart';
|
||||
import '../../services/nymea_service.dart';
|
||||
import '../../theme/etm_tokens.dart';
|
||||
|
||||
/// Écran 1 — **Rôles & appareils**.
|
||||
///
|
||||
/// Trois zones (contrat) : Compteurs (mesure, hors priorité) · Charges
|
||||
/// pilotables (réordonnables → écrit `priority`) · À configurer (tri-état).
|
||||
/// L'édition des paliers ECS vit dans l'écran 2 (Configurer) : ici, seul un
|
||||
/// **résumé read-only** (`LoadConfig.summary`) est affiché.
|
||||
class RolesDevicesScreen extends StatefulWidget {
|
||||
const RolesDevicesScreen({super.key});
|
||||
|
||||
@override
|
||||
State<RolesDevicesScreen> createState() => _RolesDevicesScreenState();
|
||||
}
|
||||
|
||||
class _RolesDevicesScreenState extends State<RolesDevicesScreen> {
|
||||
NymeaService? _svc;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
if (!mounted) return;
|
||||
final svc = context.read<NymeaService>();
|
||||
_svc = svc;
|
||||
context.read<EnergySetupProvider>().attach(svc);
|
||||
svc.addListener(_sync); // re-inférer quand les things se chargent
|
||||
_sync();
|
||||
});
|
||||
}
|
||||
|
||||
void _sync() {
|
||||
if (!mounted) return;
|
||||
context.read<EnergySetupProvider>().syncFromThings(context.read<NymeaService>());
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_svc?.removeListener(_sync);
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final setup = context.watch<EnergySetupProvider>();
|
||||
final svc = context.watch<NymeaService>();
|
||||
|
||||
final meters = setup.meterRoles;
|
||||
final loads = setup.controllableRoles;
|
||||
final todo = setup.toConfigureRoles;
|
||||
final absent = setup.absentRoles;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: EtmTokens.bgOf(context),
|
||||
appBar: AppBar(
|
||||
title: const Text('Rôles & appareils'),
|
||||
backgroundColor: EtmTokens.surfaceOf(context),
|
||||
foregroundColor: EtmTokens.inkOf(context),
|
||||
elevation: 0,
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
|
||||
children: [
|
||||
_CounterHeader(setup),
|
||||
|
||||
if (setup.screenState == SetupScreenState.blank) ...[
|
||||
const SizedBox(height: 16),
|
||||
const _BlankInvite(),
|
||||
],
|
||||
|
||||
// ── Compteurs ────────────────────────────────────────────────────
|
||||
if (meters.isNotEmpty) ...[
|
||||
const SizedBox(height: 20),
|
||||
const _SectionHeader('Compteurs', subtitle: 'Mesure — hors priorité'),
|
||||
const SizedBox(height: 10),
|
||||
for (final role in meters) ...[
|
||||
_MeterCard(role: role, setup: setup, service: svc),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
],
|
||||
|
||||
// ── Charges pilotables (drag = priorité) ─────────────────────────
|
||||
if (loads.isNotEmpty) ...[
|
||||
const SizedBox(height: 10),
|
||||
const _SectionHeader('Charges pilotables',
|
||||
subtitle: 'Glissez pour l\'ordre de service du surplus'),
|
||||
const SizedBox(height: 10),
|
||||
ReorderableListView.builder(
|
||||
shrinkWrap: true,
|
||||
primary: false,
|
||||
physics: const NeverScrollableScrollPhysics(),
|
||||
buildDefaultDragHandles: false,
|
||||
itemCount: loads.length,
|
||||
onReorder: setup.reorderControllableLoads,
|
||||
itemBuilder: (context, i) {
|
||||
final role = loads[i];
|
||||
return Padding(
|
||||
key: ValueKey(role),
|
||||
padding: const EdgeInsets.only(bottom: 10),
|
||||
child: _LoadCard(
|
||||
role: role,
|
||||
index: i,
|
||||
setup: setup,
|
||||
service: svc,
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
const _Legend(),
|
||||
],
|
||||
|
||||
// ── À configurer ─────────────────────────────────────────────────
|
||||
if (todo.isNotEmpty) ...[
|
||||
const SizedBox(height: 18),
|
||||
const _SectionHeader('À configurer'),
|
||||
const SizedBox(height: 10),
|
||||
for (final role in todo) ...[
|
||||
_TodoCard(role: role, setup: setup, service: svc),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
],
|
||||
|
||||
// ── Sans appareil (actés absents) ────────────────────────────────
|
||||
if (absent.isNotEmpty) ...[
|
||||
const SizedBox(height: 18),
|
||||
const _SectionHeader('Sans appareil'),
|
||||
const SizedBox(height: 10),
|
||||
for (final role in absent) ...[
|
||||
_AbsentCard(role: role, setup: setup),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// En-tête compteur X/6 + état d'écran
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class _CounterHeader extends StatelessWidget {
|
||||
final EnergySetupProvider setup;
|
||||
const _CounterHeader(this.setup);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final detected = EmsRole.values.where(setup.isDetected).length;
|
||||
final remaining = setup.toConfigureRoles.length;
|
||||
final complete = setup.screenState == SetupScreenState.complete;
|
||||
|
||||
final parts = <String>[
|
||||
if (detected > 0) '$detected détecté${detected > 1 ? 's' : ''} automatiquement',
|
||||
if (remaining > 0)
|
||||
'$remaining reste${remaining > 1 ? 'nt' : ''} à configurer'
|
||||
else
|
||||
'tout est assigné',
|
||||
];
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.surfaceOf(context),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
|
||||
boxShadow: EtmTokens.cardShadowOf(context),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 40,
|
||||
height: 40,
|
||||
decoration: BoxDecoration(
|
||||
color: (complete ? EtmTokens.eco : EtmTokens.brand)
|
||||
.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
|
||||
),
|
||||
child: Icon(
|
||||
complete ? Icons.check_rounded : Icons.tune_rounded,
|
||||
color: complete ? EtmTokens.eco : EtmTokens.brand,
|
||||
size: 22,
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(
|
||||
'${setup.configuredCount} / ${setup.totalRoles} rôles configurés',
|
||||
style: EtmTokens.sans(
|
||||
size: 15,
|
||||
weight: FontWeight.w700,
|
||||
color: EtmTokens.inkOf(context),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
parts.join(' · '),
|
||||
style: EtmTokens.sans(
|
||||
size: 12, color: EtmTokens.mutedOf(context)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _BlankInvite extends StatelessWidget {
|
||||
const _BlankInvite();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(18),
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.brand.withValues(alpha: 0.07),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
|
||||
border: Border.all(color: EtmTokens.brand.withValues(alpha: 0.18)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
const Icon(Icons.bolt_rounded, color: EtmTokens.brand),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Commencez par assigner vos compteurs et charges pilotables. '
|
||||
'Le solaire et la batterie sont détectés automatiquement.',
|
||||
style: EtmTokens.sans(
|
||||
size: 13, color: EtmTokens.inkOf(context), height: 1.35),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Cartes
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class _MeterCard extends StatelessWidget {
|
||||
final EmsRole role;
|
||||
final EnergySetupProvider setup;
|
||||
final NymeaService service;
|
||||
const _MeterCard(
|
||||
{required this.role, required this.setup, required this.service});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final thing = _thingById(service, setup.thingIdOf(role));
|
||||
final sub = thing == null
|
||||
? '—'
|
||||
: [thing.name, _thingClassName(service, thing)]
|
||||
.where((s) => s.isNotEmpty)
|
||||
.join(' · ');
|
||||
|
||||
return _CardShell(
|
||||
child: Row(
|
||||
children: [
|
||||
_Emoji(role.icon),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(role.label,
|
||||
style: EtmTokens.sans(
|
||||
size: 14.5,
|
||||
weight: FontWeight.w700,
|
||||
color: EtmTokens.inkOf(context))),
|
||||
const SizedBox(height: 2),
|
||||
Text(sub,
|
||||
style: EtmTokens.sans(
|
||||
size: 12, color: EtmTokens.mutedOf(context))),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (role == EmsRole.gridMeter)
|
||||
_Badge('rootMeter', EtmTokens.brand)
|
||||
else if (setup.isDetected(role))
|
||||
_Badge('auto · lecture seule', EtmTokens.eco),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LoadCard extends StatelessWidget {
|
||||
final EmsRole role;
|
||||
final int index;
|
||||
final EnergySetupProvider setup;
|
||||
final NymeaService service;
|
||||
const _LoadCard({
|
||||
required this.role,
|
||||
required this.index,
|
||||
required this.setup,
|
||||
required this.service,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final thing = _thingById(service, setup.thingIdOf(role));
|
||||
final detected = setup.isDetected(role);
|
||||
final sub = [
|
||||
if (thing != null) thing.name,
|
||||
if (thing != null) _thingClassName(service, thing),
|
||||
if (detected) 'détecté',
|
||||
].where((s) => s.isNotEmpty).join(' · ');
|
||||
|
||||
// Résumé READ-ONLY dérivé de la déclaration — l'édition des paliers est
|
||||
// dans l'écran 2 (Configurer), jamais ici.
|
||||
final summary = setup.loadConfigOf(role)?.summary;
|
||||
final enabled = setup.isEnabled(role);
|
||||
|
||||
return _CardShell(
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Poignée de drag + rang de priorité
|
||||
ReorderableDragStartListener(
|
||||
index: index,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(right: 12, top: 2),
|
||||
child: Column(
|
||||
children: [
|
||||
Icon(Icons.drag_indicator_rounded,
|
||||
size: 18, color: EtmTokens.faintOf(context)),
|
||||
const SizedBox(height: 6),
|
||||
Container(
|
||||
width: 22,
|
||||
height: 22,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.eco.withValues(alpha: 0.14),
|
||||
shape: BoxShape.circle,
|
||||
),
|
||||
child: Text('${setup.priorityOf(role) ?? index + 1}',
|
||||
style: EtmTokens.mono(
|
||||
size: 12,
|
||||
weight: FontWeight.w700,
|
||||
color: EtmTokens.eco)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_Emoji(role.icon),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(role.label,
|
||||
style: EtmTokens.sans(
|
||||
size: 14.5,
|
||||
weight: FontWeight.w700,
|
||||
color: EtmTokens.inkOf(context))),
|
||||
if (sub.isNotEmpty) ...[
|
||||
const SizedBox(height: 2),
|
||||
Text(sub,
|
||||
style: EtmTokens.sans(
|
||||
size: 12,
|
||||
color: EtmTokens.mutedOf(context))),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
Switch(
|
||||
value: enabled,
|
||||
activeThumbColor: EtmTokens.eco,
|
||||
onChanged: (v) => setup.setEnabled(role, v),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
spacing: 6,
|
||||
runSpacing: 6,
|
||||
children: [
|
||||
if (summary != null)
|
||||
_Chip(summary)
|
||||
else
|
||||
_Chip('À déclarer dans Configurer', muted: true),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _TodoCard extends StatelessWidget {
|
||||
final EmsRole role;
|
||||
final EnergySetupProvider setup;
|
||||
final NymeaService service;
|
||||
const _TodoCard(
|
||||
{required this.role, required this.setup, required this.service});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _CardShell(
|
||||
dashed: true,
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
_Emoji(role.icon, faded: true),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(role.label,
|
||||
style: EtmTokens.sans(
|
||||
size: 14.5,
|
||||
weight: FontWeight.w700,
|
||||
color: EtmTokens.inkOf(context))),
|
||||
const SizedBox(height: 2),
|
||||
Text('Aucun appareil configuré',
|
||||
style: EtmTokens.sans(
|
||||
size: 12, color: EtmTokens.mutedOf(context))),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Row(
|
||||
children: [
|
||||
Expanded(
|
||||
child: _Btn(
|
||||
label: '+ Configurer',
|
||||
primary: true,
|
||||
onTap: () => _showThingPicker(context, role, setup, service),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: _Btn(
|
||||
label: 'Pas d\'appareil',
|
||||
onTap: () => setup.markAbsent(role),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AbsentCard extends StatelessWidget {
|
||||
final EmsRole role;
|
||||
final EnergySetupProvider setup;
|
||||
const _AbsentCard({required this.role, required this.setup});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _CardShell(
|
||||
child: Row(
|
||||
children: [
|
||||
_Emoji(role.icon, faded: true),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(role.label,
|
||||
style: EtmTokens.sans(
|
||||
size: 14.5,
|
||||
weight: FontWeight.w700,
|
||||
color: EtmTokens.mutedOf(context))),
|
||||
const SizedBox(height: 2),
|
||||
Text('Pas d\'appareil',
|
||||
style: EtmTokens.sans(
|
||||
size: 12, color: EtmTokens.faintOf(context))),
|
||||
],
|
||||
),
|
||||
),
|
||||
_Btn(
|
||||
label: 'Reconfigurer',
|
||||
onTap: () => setup.unconfigure(role),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Sélecteur d'appareil (assign)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
void _showThingPicker(BuildContext context, EmsRole role,
|
||||
EnergySetupProvider setup, NymeaService service) {
|
||||
final matches = setup.compatibleThings(role, service);
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: EtmTokens.surfaceOf(context),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(22)),
|
||||
),
|
||||
builder: (sheetCtx) {
|
||||
return SafeArea(
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Assigner — ${role.label}',
|
||||
style: EtmTokens.sans(
|
||||
size: 16,
|
||||
weight: FontWeight.w700,
|
||||
color: EtmTokens.inkOf(sheetCtx))),
|
||||
const SizedBox(height: 4),
|
||||
Text('Appareils compatibles détectés',
|
||||
style: EtmTokens.sans(
|
||||
size: 12, color: EtmTokens.mutedOf(sheetCtx))),
|
||||
const SizedBox(height: 12),
|
||||
if (matches.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 18),
|
||||
child: Text('Aucun appareil compatible.',
|
||||
style: EtmTokens.sans(
|
||||
size: 13, color: EtmTokens.mutedOf(sheetCtx))),
|
||||
)
|
||||
else
|
||||
...matches.map((t) => ListTile(
|
||||
contentPadding: EdgeInsets.zero,
|
||||
leading: _Emoji(role.icon),
|
||||
title: Text(t.name,
|
||||
style: EtmTokens.sans(
|
||||
size: 14,
|
||||
weight: FontWeight.w600,
|
||||
color: EtmTokens.inkOf(sheetCtx))),
|
||||
subtitle: Text(_thingClassName(service, t),
|
||||
style: EtmTokens.sans(
|
||||
size: 12, color: EtmTokens.mutedOf(sheetCtx))),
|
||||
onTap: () {
|
||||
setup.assignThing(role, t.id);
|
||||
Navigator.of(sheetCtx).pop();
|
||||
},
|
||||
)),
|
||||
const SizedBox(height: 8),
|
||||
Align(
|
||||
alignment: Alignment.centerRight,
|
||||
child: _Btn(
|
||||
label: 'Pas d\'appareil',
|
||||
onTap: () {
|
||||
setup.markAbsent(role);
|
||||
Navigator.of(sheetCtx).pop();
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Petits composants
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
final String? subtitle;
|
||||
const _SectionHeader(this.title, {this.subtitle});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.baseline,
|
||||
textBaseline: TextBaseline.alphabetic,
|
||||
children: [
|
||||
Text(title,
|
||||
style: EtmTokens.sans(
|
||||
size: 13.5,
|
||||
weight: FontWeight.w700,
|
||||
color: EtmTokens.inkOf(context))),
|
||||
if (subtitle != null) ...[
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(subtitle!,
|
||||
style: EtmTokens.sans(
|
||||
size: 11, color: EtmTokens.faintOf(context))),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Legend extends StatelessWidget {
|
||||
const _Legend();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
margin: const EdgeInsets.only(top: 2),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.solar.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
|
||||
),
|
||||
child: Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const Icon(Icons.info_outline_rounded,
|
||||
size: 16, color: EtmTokens.heat),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: RichText(
|
||||
text: TextSpan(
|
||||
style: EtmTokens.sans(
|
||||
size: 11.5, color: EtmTokens.mutedOf(context), height: 1.35),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: 'Ordre indicatif. ',
|
||||
style: EtmTokens.sans(
|
||||
size: 11.5,
|
||||
weight: FontWeight.w700,
|
||||
color: EtmTokens.inkOf(context))),
|
||||
const TextSpan(
|
||||
text:
|
||||
'Il fixe le service par défaut du surplus. Le pilote '
|
||||
'peut le bousculer les jours Tempo rouge ou à l\'approche '
|
||||
'd\'une échéance de recharge.'),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _CardShell extends StatelessWidget {
|
||||
final Widget child;
|
||||
final bool dashed;
|
||||
const _CardShell({required this.child, this.dashed = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: dashed
|
||||
? EtmTokens.surfaceOf(context).withValues(alpha: 0.6)
|
||||
: EtmTokens.surfaceOf(context),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
|
||||
boxShadow: dashed ? null : EtmTokens.cardShadowOf(context),
|
||||
border: dashed
|
||||
? Border.all(color: EtmTokens.lineOf(context))
|
||||
: null,
|
||||
),
|
||||
child: child,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Emoji extends StatelessWidget {
|
||||
final String emoji;
|
||||
final bool faded;
|
||||
const _Emoji(this.emoji, {this.faded = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Opacity(
|
||||
opacity: faded ? 0.55 : 1,
|
||||
child: Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.bg2Of(context),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
|
||||
),
|
||||
child: Text(emoji, style: const TextStyle(fontSize: 18)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Badge extends StatelessWidget {
|
||||
final String text;
|
||||
final Color color;
|
||||
const _Badge(this.text, this.color);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusPill),
|
||||
),
|
||||
child: Text(text,
|
||||
style: EtmTokens.sans(
|
||||
size: 11, weight: FontWeight.w600, color: color)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Chip extends StatelessWidget {
|
||||
final String text;
|
||||
final bool muted;
|
||||
const _Chip(this.text, {this.muted = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5),
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.bg2Of(context),
|
||||
borderRadius: BorderRadius.circular(8),
|
||||
),
|
||||
child: Text(text,
|
||||
style: EtmTokens.sans(
|
||||
size: 11.5,
|
||||
weight: muted ? FontWeight.w400 : FontWeight.w600,
|
||||
color: muted
|
||||
? EtmTokens.faintOf(context)
|
||||
: EtmTokens.mutedOf(context))),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Btn extends StatelessWidget {
|
||||
final String label;
|
||||
final bool primary;
|
||||
final VoidCallback onTap;
|
||||
const _Btn({required this.label, required this.onTap, this.primary = false});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: primary ? EtmTokens.brand : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
|
||||
border: primary
|
||||
? null
|
||||
: Border.all(color: EtmTokens.lineStrongOf(context)),
|
||||
),
|
||||
child: Text(label,
|
||||
style: EtmTokens.sans(
|
||||
size: 13,
|
||||
weight: FontWeight.w600,
|
||||
color: primary ? Colors.white : EtmTokens.mutedOf(context))),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Résolution d'affichage des things
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
NymeaThing? _thingById(NymeaService service, String? id) {
|
||||
if (id == null) return null;
|
||||
for (final t in service.things) {
|
||||
if (t.id == id) return t;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String _thingClassName(NymeaService service, NymeaThing thing) {
|
||||
try {
|
||||
return service.thingClasses
|
||||
.firstWhere((c) => c.id == thing.thingClassId)
|
||||
.displayName;
|
||||
} catch (_) {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
416
lib/screens/installations/connection_screen.dart
Normal file
416
lib/screens/installations/connection_screen.dart
Normal file
@ -0,0 +1,416 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../providers/installer_mode_provider.dart';
|
||||
import '../../services/connection_manager.dart';
|
||||
import '../../services/nymea_service.dart';
|
||||
import '../../theme/etm_tokens.dart';
|
||||
|
||||
enum _AuthBranch { login, create }
|
||||
|
||||
/// Écran **Connexion** (`/installations/connect`) — auth à deux branches.
|
||||
///
|
||||
/// La branche est **détectée au handshake** via `initialSetupRequired` (box
|
||||
/// neuve → créer l'admin ; sinon → login). Le segmenteur n'est qu'un **repli
|
||||
/// manuel**. Le token obtenu est persisté **par UUID** (secure storage) par le
|
||||
/// [ConnectionManager]. Créer l'admin = **mode installateur**.
|
||||
class ConnectionScreen extends StatefulWidget {
|
||||
const ConnectionScreen({super.key});
|
||||
|
||||
@override
|
||||
State<ConnectionScreen> createState() => _ConnectionScreenState();
|
||||
}
|
||||
|
||||
class _ConnectionScreenState extends State<ConnectionScreen> {
|
||||
late _AuthBranch _branch;
|
||||
bool _busy = false;
|
||||
|
||||
final _user = TextEditingController();
|
||||
final _email = TextEditingController();
|
||||
final _pass = TextEditingController();
|
||||
final _confirm = TextEditingController();
|
||||
String? _formError;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
// Détection auto : box neuve → créer ; sinon → login.
|
||||
final svc = context.read<NymeaService>();
|
||||
_branch = svc.initialSetupRequired ? _AuthBranch.create : _AuthBranch.login;
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_user.dispose();
|
||||
_email.dispose();
|
||||
_pass.dispose();
|
||||
_confirm.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
final manager = context.read<ConnectionManager>();
|
||||
final user = _user.text.trim();
|
||||
final pass = _pass.text;
|
||||
if (user.isEmpty || pass.isEmpty) {
|
||||
setState(() => _formError = 'Utilisateur et mot de passe requis.');
|
||||
return;
|
||||
}
|
||||
if (_branch == _AuthBranch.create && pass != _confirm.text) {
|
||||
setState(() => _formError = 'Les mots de passe ne correspondent pas.');
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_formError = null;
|
||||
_busy = true;
|
||||
});
|
||||
|
||||
final ok = _branch == _AuthBranch.create
|
||||
? await manager.createAdmin(user, pass, email: _email.text.trim())
|
||||
: await manager.authenticate(user, pass);
|
||||
|
||||
if (!mounted) return;
|
||||
if (ok) {
|
||||
context.go('/'); // authentifié → le gate autorise le dashboard
|
||||
} else {
|
||||
setState(() {
|
||||
_busy = false;
|
||||
_formError = manager.error ?? 'Échec de l\'authentification.';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final svc = context.watch<NymeaService>();
|
||||
final installer = context.watch<InstallerModeProvider>().isUnlocked;
|
||||
final createLocked = _branch == _AuthBranch.create && !installer;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: EtmTokens.bgOf(context),
|
||||
appBar: AppBar(
|
||||
title: const Text('Connexion'),
|
||||
backgroundColor: EtmTokens.surfaceOf(context),
|
||||
foregroundColor: EtmTokens.inkOf(context),
|
||||
elevation: 0,
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
|
||||
children: [
|
||||
_IdentityHeader(
|
||||
name: svc.serverName.isNotEmpty ? svc.serverName : 'nymea',
|
||||
host: svc.host,
|
||||
port: svc.port,
|
||||
uuid: svc.serverUuid,
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
_Segment(
|
||||
branch: _branch,
|
||||
onChanged: (b) => setState(() {
|
||||
_branch = b;
|
||||
_formError = null;
|
||||
}),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
Text(
|
||||
_branch == _AuthBranch.create
|
||||
? 'Box neuve : aucun administrateur n\'existe encore. Créez le '
|
||||
'compte qui pilotera cette installation.'
|
||||
: 'Box déjà initialisée : connectez-vous avec un compte existant.',
|
||||
style: EtmTokens.sans(
|
||||
size: 12, color: EtmTokens.mutedOf(context), height: 1.35),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
|
||||
if (createLocked)
|
||||
_LockedCreate()
|
||||
else ...[
|
||||
_LabeledField(
|
||||
controller: _user,
|
||||
label: 'Utilisateur (e-mail)',
|
||||
hint: 'installateur@etm-schurig.eu',
|
||||
icon: Icons.alternate_email_rounded),
|
||||
if (_branch == _AuthBranch.create)
|
||||
_LabeledField(
|
||||
controller: _email,
|
||||
label: 'E-mail (optionnel)',
|
||||
hint: 'contact@…',
|
||||
icon: Icons.mail_outline_rounded),
|
||||
_LabeledField(
|
||||
controller: _pass,
|
||||
label: 'Mot de passe',
|
||||
hint: '••••••••',
|
||||
icon: Icons.lock_outline_rounded,
|
||||
obscure: true),
|
||||
if (_branch == _AuthBranch.create)
|
||||
_LabeledField(
|
||||
controller: _confirm,
|
||||
label: 'Confirmer le mot de passe',
|
||||
hint: '••••••••',
|
||||
icon: Icons.lock_outline_rounded,
|
||||
obscure: true),
|
||||
if (_formError != null) ...[
|
||||
const SizedBox(height: 8),
|
||||
Text(_formError!,
|
||||
style: EtmTokens.sans(size: 12.5, color: EtmTokens.danger)),
|
||||
],
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: EtmTokens.brand,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
|
||||
),
|
||||
),
|
||||
onPressed: _busy ? null : _submit,
|
||||
child: _busy
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(
|
||||
strokeWidth: 2, color: Colors.white))
|
||||
: Text(
|
||||
_branch == _AuthBranch.create
|
||||
? 'Créer le compte administrateur'
|
||||
: 'Se connecter',
|
||||
style: EtmTokens.sans(
|
||||
size: 14,
|
||||
weight: FontWeight.w700,
|
||||
color: Colors.white)),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
const SizedBox(height: 18),
|
||||
_DetectNote(),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class _IdentityHeader extends StatelessWidget {
|
||||
final String name;
|
||||
final String host;
|
||||
final int port;
|
||||
final String uuid;
|
||||
const _IdentityHeader(
|
||||
{required this.name,
|
||||
required this.host,
|
||||
required this.port,
|
||||
required this.uuid});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.surfaceOf(context),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
|
||||
boxShadow: EtmTokens.cardShadowOf(context),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 38,
|
||||
height: 38,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.brand.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
|
||||
),
|
||||
child: const Icon(Icons.power_rounded,
|
||||
color: EtmTokens.brand, size: 20),
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(name,
|
||||
style: EtmTokens.sans(
|
||||
size: 14.5,
|
||||
weight: FontWeight.w700,
|
||||
color: EtmTokens.inkOf(context))),
|
||||
const SizedBox(height: 2),
|
||||
Text('$host : $port',
|
||||
style: EtmTokens.mono(
|
||||
size: 11.5, color: EtmTokens.mutedOf(context))),
|
||||
if (uuid.isNotEmpty) ...[
|
||||
const SizedBox(height: 1),
|
||||
Text(uuid,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: EtmTokens.mono(
|
||||
size: 10.5, color: EtmTokens.faintOf(context))),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Segment extends StatelessWidget {
|
||||
final _AuthBranch branch;
|
||||
final ValueChanged<_AuthBranch> onChanged;
|
||||
const _Segment({required this.branch, required this.onChanged});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
Widget seg(String label, _AuthBranch b) {
|
||||
final on = branch == b;
|
||||
return Expanded(
|
||||
child: GestureDetector(
|
||||
onTap: () => onChanged(b),
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(vertical: 10),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: on ? EtmTokens.brand : Colors.transparent,
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl - 3),
|
||||
),
|
||||
child: Text(label,
|
||||
style: EtmTokens.sans(
|
||||
size: 13,
|
||||
weight: FontWeight.w600,
|
||||
color: on ? Colors.white : EtmTokens.mutedOf(context))),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(4),
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.bg2Of(context),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
seg('Nouvelle', _AuthBranch.create),
|
||||
seg('Existante', _AuthBranch.login),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LabeledField extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final String label;
|
||||
final String hint;
|
||||
final IconData icon;
|
||||
final bool obscure;
|
||||
const _LabeledField({
|
||||
required this.controller,
|
||||
required this.label,
|
||||
required this.hint,
|
||||
required this.icon,
|
||||
this.obscure = false,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(bottom: 12),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label,
|
||||
style: EtmTokens.sans(
|
||||
size: 12,
|
||||
weight: FontWeight.w600,
|
||||
color: EtmTokens.mutedOf(context))),
|
||||
const SizedBox(height: 6),
|
||||
TextField(
|
||||
controller: controller,
|
||||
obscureText: obscure,
|
||||
style: EtmTokens.sans(size: 14, color: EtmTokens.inkOf(context)),
|
||||
decoration: InputDecoration(
|
||||
prefixIcon: Icon(icon, size: 18, color: EtmTokens.faintOf(context)),
|
||||
hintText: hint,
|
||||
isDense: true,
|
||||
filled: true,
|
||||
fillColor: EtmTokens.bg2Of(context),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _LockedCreate extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(16),
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.surfaceOf(context),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
|
||||
border: Border.all(color: EtmTokens.lineOf(context)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.lock_outline_rounded,
|
||||
size: 20, color: EtmTokens.faintOf(context)),
|
||||
const SizedBox(width: 12),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Création de l\'administrateur réservée au mode installateur.',
|
||||
style: EtmTokens.sans(
|
||||
size: 13, color: EtmTokens.mutedOf(context), height: 1.35),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DetectNote extends StatelessWidget {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Container(
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.brand.withValues(alpha: 0.07),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
|
||||
),
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
style: EtmTokens.sans(
|
||||
size: 11.5, color: EtmTokens.mutedOf(context), height: 1.4),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: 'Détection auto. ',
|
||||
style: EtmTokens.sans(
|
||||
size: 11.5,
|
||||
weight: FontWeight.w700,
|
||||
color: EtmTokens.inkOf(context))),
|
||||
const TextSpan(
|
||||
text:
|
||||
'La branche est choisie d\'après la box au handshake ; le '
|
||||
'segmenteur n\'est qu\'un repli manuel. Le token est stocké '
|
||||
'sous l\'UUID de cette installation.'),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
574
lib/screens/installations/installations_screen.dart
Normal file
574
lib/screens/installations/installations_screen.dart
Normal file
@ -0,0 +1,574 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../../models/installation.dart';
|
||||
import '../../providers/installer_mode_provider.dart';
|
||||
import '../drawer/installer_pin_dialog.dart';
|
||||
import '../../services/connection_manager.dart';
|
||||
import '../../services/nymea_service.dart';
|
||||
import '../../theme/etm_tokens.dart';
|
||||
|
||||
/// Écran **Installations** — gestionnaire multi-HEMS.
|
||||
///
|
||||
/// Trois états (mêmes composants) : vierge (aucune installation) / liste sans
|
||||
/// active / liste avec active. Ajout manuel + suppression = **installateur** ;
|
||||
/// basculer = tout le monde. La découverte mDNS (« Détectées ») viendra à
|
||||
/// l'Étape 3 — la place est réservée mais non remplie.
|
||||
class InstallationsScreen extends StatelessWidget {
|
||||
const InstallationsScreen({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final manager = context.watch<ConnectionManager>();
|
||||
final service = context.watch<NymeaService>();
|
||||
final installer = context.watch<InstallerModeProvider>().isUnlocked;
|
||||
|
||||
final installs = manager.installations;
|
||||
final blank = installs.isEmpty;
|
||||
|
||||
return Scaffold(
|
||||
backgroundColor: EtmTokens.bgOf(context),
|
||||
appBar: AppBar(
|
||||
title: const Text('Installations'),
|
||||
backgroundColor: EtmTokens.surfaceOf(context),
|
||||
foregroundColor: EtmTokens.inkOf(context),
|
||||
elevation: 0,
|
||||
actions: [
|
||||
// Accès installateur depuis la racine : sans ça, impossible de
|
||||
// déverrouiller avant toute connexion (le drawer est hors d'atteinte).
|
||||
IconButton(
|
||||
tooltip: installer
|
||||
? 'Mode installateur actif — toucher pour verrouiller'
|
||||
: 'Déverrouiller le mode installateur',
|
||||
icon: Icon(
|
||||
installer ? Icons.lock_open_rounded : Icons.lock_outline_rounded,
|
||||
color: installer ? EtmTokens.eco : EtmTokens.inkOf(context),
|
||||
),
|
||||
onPressed: () {
|
||||
final p = context.read<InstallerModeProvider>();
|
||||
if (installer) {
|
||||
p.lock();
|
||||
} else {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
builder: (_) => const InstallerPinDialog(),
|
||||
);
|
||||
}
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
body: ListView(
|
||||
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
|
||||
children: [
|
||||
if (manager.isTransitioning)
|
||||
_Banner(
|
||||
icon: Icons.sync_rounded,
|
||||
color: EtmTokens.brand,
|
||||
text: 'Connexion à ${manager.connectingToName}…',
|
||||
),
|
||||
if (!manager.isTransitioning && manager.error != null)
|
||||
_Banner(
|
||||
icon: Icons.error_outline_rounded,
|
||||
color: EtmTokens.danger,
|
||||
text: manager.error!,
|
||||
),
|
||||
|
||||
// ── Détectées sur le réseau (mDNS) — Étape 3 ──────────────────────
|
||||
_buildDiscoverySection(context),
|
||||
|
||||
// ── Mes installations ─────────────────────────────────────────────
|
||||
if (!blank) ...[
|
||||
const _SectionHeader('Mes installations'),
|
||||
const SizedBox(height: 10),
|
||||
for (final inst in installs) ...[
|
||||
_InstallationCard(
|
||||
inst: inst,
|
||||
active: _isActive(service, manager, inst),
|
||||
showDelete: installer,
|
||||
onTap: () => _connectTo(context, inst),
|
||||
onDelete: () => manager.removeInstallation(inst.uuid),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
],
|
||||
] else
|
||||
const _BlankInvite(),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// ── Ajout manuel (installateur) ───────────────────────────────────
|
||||
if (installer)
|
||||
_AddManualButton(onTap: () => _showAddManual(context))
|
||||
else
|
||||
_LockedHint(
|
||||
'Ajout d\'installation réservé au mode installateur',
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
|
||||
// ── Mode démo ─────────────────────────────────────────────────────
|
||||
Center(
|
||||
child: GestureDetector(
|
||||
onTap: () {
|
||||
manager.enterDemo();
|
||||
context.go('/');
|
||||
},
|
||||
child: Text.rich(
|
||||
TextSpan(
|
||||
text: 'ou continuer en ',
|
||||
style: EtmTokens.sans(
|
||||
size: 13, color: EtmTokens.mutedOf(context)),
|
||||
children: [
|
||||
TextSpan(
|
||||
text: 'mode démo',
|
||||
style: EtmTokens.sans(
|
||||
size: 13,
|
||||
weight: FontWeight.w700,
|
||||
color: EtmTokens.brand),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
bool _isActive(
|
||||
NymeaService service, ConnectionManager manager, Installation inst) =>
|
||||
service.connected && manager.active?.uuid == inst.uuid;
|
||||
|
||||
/// Place réservée pour la découverte mDNS (Étape 3). Rien à afficher tant que
|
||||
/// le scan n'existe pas (et jamais sur web : pas de multicast navigateur).
|
||||
Widget _buildDiscoverySection(BuildContext context) =>
|
||||
const SizedBox.shrink();
|
||||
|
||||
// ── Connexion / bascule ────────────────────────────────────────────────────
|
||||
|
||||
Future<void> _connectTo(BuildContext context, Installation inst) async {
|
||||
final manager = context.read<ConnectionManager>();
|
||||
final service = context.read<NymeaService>();
|
||||
final ok = await manager.switchTo(inst);
|
||||
if (!context.mounted) return;
|
||||
_afterConnect(context, ok, service);
|
||||
}
|
||||
|
||||
Future<void> _showAddManual(BuildContext context) async {
|
||||
final result = await showModalBottomSheet<({String host, int port})>(
|
||||
context: context,
|
||||
isScrollControlled: true,
|
||||
backgroundColor: EtmTokens.surfaceOf(context),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(22)),
|
||||
),
|
||||
builder: (_) => const _AddManualSheet(),
|
||||
);
|
||||
if (result == null || !context.mounted) return;
|
||||
|
||||
final manager = context.read<ConnectionManager>();
|
||||
final service = context.read<NymeaService>();
|
||||
final ok = await manager.connectNew(host: result.host, port: result.port);
|
||||
if (!context.mounted) return;
|
||||
_afterConnect(context, ok, service);
|
||||
}
|
||||
|
||||
/// Suite d'une connexion : authentifié → dashboard ; transport up mais auth
|
||||
/// requise → écran Connexion ; échec → bannière d'erreur (portée par le
|
||||
/// manager).
|
||||
void _afterConnect(BuildContext context, bool ok, NymeaService service) {
|
||||
if (ok && service.authenticated) {
|
||||
context.go('/');
|
||||
} else if (ok) {
|
||||
// Transport up mais auth requise → écran Connexion (login / créer admin).
|
||||
context.go('/installations/connect');
|
||||
}
|
||||
// échec : manager.error est affiché par la bannière au prochain build.
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Carte installation
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class _InstallationCard extends StatelessWidget {
|
||||
final Installation inst;
|
||||
final bool active;
|
||||
final bool showDelete;
|
||||
final VoidCallback onTap;
|
||||
final VoidCallback onDelete;
|
||||
|
||||
const _InstallationCard({
|
||||
required this.inst,
|
||||
required this.active,
|
||||
required this.showDelete,
|
||||
required this.onTap,
|
||||
required this.onDelete,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return GestureDetector(
|
||||
onTap: active ? null : onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.surfaceOf(context),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
|
||||
boxShadow: EtmTokens.cardShadowOf(context),
|
||||
border: active
|
||||
? Border.all(color: EtmTokens.eco.withValues(alpha: 0.55), width: 1.5)
|
||||
: null,
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Container(
|
||||
width: 10,
|
||||
height: 10,
|
||||
margin: const EdgeInsets.only(top: 4, right: 12),
|
||||
decoration: BoxDecoration(
|
||||
shape: BoxShape.circle,
|
||||
color: active ? EtmTokens.eco : EtmTokens.faintOf(context),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Flexible(
|
||||
child: Text(
|
||||
inst.name,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: EtmTokens.sans(
|
||||
size: 14.5,
|
||||
weight: FontWeight.w700,
|
||||
color: EtmTokens.inkOf(context)),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 8),
|
||||
if (active) _StateChip('actif', EtmTokens.eco),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 3),
|
||||
Text('nymea://${inst.host} : ${inst.port}',
|
||||
style: EtmTokens.mono(
|
||||
size: 11.5, color: EtmTokens.mutedOf(context))),
|
||||
const SizedBox(height: 2),
|
||||
Text(inst.uuid,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: EtmTokens.mono(
|
||||
size: 10.5, color: EtmTokens.faintOf(context))),
|
||||
],
|
||||
),
|
||||
),
|
||||
if (showDelete)
|
||||
IconButton(
|
||||
icon: Icon(Icons.delete_outline_rounded,
|
||||
size: 20, color: EtmTokens.faintOf(context)),
|
||||
onPressed: onDelete,
|
||||
)
|
||||
else if (!active)
|
||||
Icon(Icons.chevron_right_rounded,
|
||||
color: EtmTokens.faintOf(context)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Ajout manuel (bottom sheet)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class _AddManualSheet extends StatefulWidget {
|
||||
const _AddManualSheet();
|
||||
|
||||
@override
|
||||
State<_AddManualSheet> createState() => _AddManualSheetState();
|
||||
}
|
||||
|
||||
class _AddManualSheetState extends State<_AddManualSheet> {
|
||||
final _host = TextEditingController();
|
||||
final _port = TextEditingController(text: '4444');
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_host.dispose();
|
||||
_port.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
final host = _host.text.trim();
|
||||
if (host.isEmpty) return;
|
||||
final port = int.tryParse(_port.text.trim()) ?? 4444;
|
||||
Navigator.of(context).pop((host: host, port: port));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: EdgeInsets.fromLTRB(
|
||||
16, 16, 16, 16 + MediaQuery.viewInsetsOf(context).bottom),
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Ajouter manuellement',
|
||||
style: EtmTokens.sans(
|
||||
size: 16,
|
||||
weight: FontWeight.w700,
|
||||
color: EtmTokens.inkOf(context))),
|
||||
const SizedBox(height: 4),
|
||||
Text('Adresse IP / hôte de la box (ws clair, port 4444)',
|
||||
style:
|
||||
EtmTokens.sans(size: 12, color: EtmTokens.mutedOf(context))),
|
||||
const SizedBox(height: 14),
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Expanded(
|
||||
flex: 3,
|
||||
child: _Field(
|
||||
controller: _host,
|
||||
label: 'Hôte',
|
||||
hint: '192.168.1.75',
|
||||
keyboardType: TextInputType.url,
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
flex: 1,
|
||||
child: _Field(
|
||||
controller: _port,
|
||||
label: 'Port',
|
||||
hint: '4444',
|
||||
keyboardType: TextInputType.number,
|
||||
onSubmitted: (_) => _submit(),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: FilledButton(
|
||||
style: FilledButton.styleFrom(
|
||||
backgroundColor: EtmTokens.brand,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
|
||||
),
|
||||
),
|
||||
onPressed: _submit,
|
||||
child: Text('Se connecter',
|
||||
style: EtmTokens.sans(
|
||||
size: 14,
|
||||
weight: FontWeight.w700,
|
||||
color: Colors.white)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _Field extends StatelessWidget {
|
||||
final TextEditingController controller;
|
||||
final String label;
|
||||
final String hint;
|
||||
final TextInputType keyboardType;
|
||||
final ValueChanged<String>? onSubmitted;
|
||||
|
||||
const _Field({
|
||||
required this.controller,
|
||||
required this.label,
|
||||
required this.hint,
|
||||
required this.keyboardType,
|
||||
this.onSubmitted,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(label,
|
||||
style: EtmTokens.sans(
|
||||
size: 12,
|
||||
weight: FontWeight.w600,
|
||||
color: EtmTokens.mutedOf(context))),
|
||||
const SizedBox(height: 6),
|
||||
TextField(
|
||||
controller: controller,
|
||||
keyboardType: keyboardType,
|
||||
autofocus: label == 'Hôte',
|
||||
style: EtmTokens.mono(size: 14, color: EtmTokens.inkOf(context)),
|
||||
decoration: InputDecoration(
|
||||
hintText: hint,
|
||||
isDense: true,
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 12, vertical: 12),
|
||||
filled: true,
|
||||
fillColor: EtmTokens.bg2Of(context),
|
||||
border: OutlineInputBorder(
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
|
||||
borderSide: BorderSide.none,
|
||||
),
|
||||
),
|
||||
onSubmitted: onSubmitted,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// Petits composants
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class _SectionHeader extends StatelessWidget {
|
||||
final String title;
|
||||
const _SectionHeader(this.title);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Text(title,
|
||||
style: EtmTokens.sans(
|
||||
size: 13.5,
|
||||
weight: FontWeight.w700,
|
||||
color: EtmTokens.inkOf(context)));
|
||||
}
|
||||
|
||||
class _StateChip extends StatelessWidget {
|
||||
final String text;
|
||||
final Color color;
|
||||
const _StateChip(this.text, this.color);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.14),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusPill),
|
||||
),
|
||||
child: Text(text,
|
||||
style: EtmTokens.sans(
|
||||
size: 10.5, weight: FontWeight.w700, color: color)),
|
||||
);
|
||||
}
|
||||
|
||||
class _Banner extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final Color color;
|
||||
final String text;
|
||||
const _Banner({required this.icon, required this.color, required this.text});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
padding: const EdgeInsets.all(12),
|
||||
decoration: BoxDecoration(
|
||||
color: color.withValues(alpha: 0.10),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
|
||||
border: Border.all(color: color.withValues(alpha: 0.25)),
|
||||
),
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(icon, size: 18, color: color),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(text,
|
||||
style: EtmTokens.sans(
|
||||
size: 12.5, color: EtmTokens.inkOf(context))),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _BlankInvite extends StatelessWidget {
|
||||
const _BlankInvite();
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Container(
|
||||
padding: const EdgeInsets.all(20),
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.brand.withValues(alpha: 0.07),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
|
||||
border: Border.all(color: EtmTokens.brand.withValues(alpha: 0.18)),
|
||||
),
|
||||
child: Column(
|
||||
children: [
|
||||
const Icon(Icons.hub_outlined, color: EtmTokens.brand, size: 32),
|
||||
const SizedBox(height: 12),
|
||||
Text('Aucune installation enregistrée',
|
||||
style: EtmTokens.sans(
|
||||
size: 15,
|
||||
weight: FontWeight.w700,
|
||||
color: EtmTokens.inkOf(context))),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Ajoutez une box par son adresse (ajout manuel) pour vous y '
|
||||
'connecter. La découverte réseau arrivera plus tard.',
|
||||
textAlign: TextAlign.center,
|
||||
style: EtmTokens.sans(
|
||||
size: 12.5,
|
||||
color: EtmTokens.mutedOf(context),
|
||||
height: 1.35),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _AddManualButton extends StatelessWidget {
|
||||
final VoidCallback onTap;
|
||||
const _AddManualButton({required this.onTap});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => GestureDetector(
|
||||
onTap: onTap,
|
||||
behavior: HitTestBehavior.opaque,
|
||||
child: Container(
|
||||
width: double.infinity,
|
||||
padding: const EdgeInsets.symmetric(vertical: 14),
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
|
||||
border: Border.all(color: EtmTokens.lineStrongOf(context)),
|
||||
),
|
||||
child: Text('+ Ajouter manuellement',
|
||||
style: EtmTokens.sans(
|
||||
size: 14,
|
||||
weight: FontWeight.w700,
|
||||
color: EtmTokens.inkOf(context))),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
class _LockedHint extends StatelessWidget {
|
||||
final String text;
|
||||
const _LockedHint(this.text);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) => Row(
|
||||
children: [
|
||||
Icon(Icons.lock_outline_rounded,
|
||||
size: 15, color: EtmTokens.faintOf(context)),
|
||||
const SizedBox(width: 8),
|
||||
Expanded(
|
||||
child: Text(text,
|
||||
style: EtmTokens.sans(
|
||||
size: 12, color: EtmTokens.faintOf(context))),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
230
lib/services/connection_manager.dart
Normal file
230
lib/services/connection_manager.dart
Normal file
@ -0,0 +1,230 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/installation.dart';
|
||||
import 'installation_store.dart';
|
||||
import 'nymea_service.dart';
|
||||
|
||||
/// Gère la **liste** d'installations et la **connexion active unique** au-dessus
|
||||
/// de [NymeaService] (qui reste mono-connexion). Aucune socket parallèle : un
|
||||
/// switch = déconnecter l'active proprement, puis connecter la cible.
|
||||
///
|
||||
/// La **démo** (`startSimulation`) compte comme une connexion active factice :
|
||||
/// elle ne doit pas être renvoyée vers Installations par le gate (Étape 6).
|
||||
class ConnectionManager extends ChangeNotifier {
|
||||
ConnectionManager(this.service, this.store);
|
||||
|
||||
final NymeaService service;
|
||||
final InstallationStore store;
|
||||
|
||||
Installation? _active;
|
||||
String? _connectingToName;
|
||||
String? _error;
|
||||
|
||||
/// Caches à vider au switch (providers rôles/scheduler/tariff). Enregistrés
|
||||
/// depuis `main()`. Découplage par callbacks — pas d'import croisé.
|
||||
final List<void Function()> _cacheClearers = [];
|
||||
|
||||
/// Enregistre un cache à vider lors d'un changement d'installation.
|
||||
void registerCacheClear(void Function() clear) => _cacheClearers.add(clear);
|
||||
|
||||
Installation? get active => _active;
|
||||
String? get connectingToName => _connectingToName;
|
||||
bool get isTransitioning => _connectingToName != null;
|
||||
String? get error => _error;
|
||||
|
||||
List<Installation> get installations => store.installations;
|
||||
|
||||
/// Vrai dès qu'une session **utilisable** existe — démo, ou box réelle
|
||||
/// **connectée ET authentifiée**. Le transport seul (connecté mais auth en
|
||||
/// attente) ne compte pas : sinon le gate laisserait passer un dashboard vide.
|
||||
bool get hasActiveConnection =>
|
||||
service.isSimulation || (service.connected && service.authenticated);
|
||||
|
||||
/// Transport up mais authentification requise → router vers l'écran Connexion.
|
||||
bool get needsAuth =>
|
||||
service.connected &&
|
||||
service.authenticationRequired &&
|
||||
!service.authenticated;
|
||||
|
||||
/// Charge la liste persistée (à appeler au démarrage, Étape 6).
|
||||
Future<void> load() async {
|
||||
await store.load();
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Bascule vers une installation connue : déconnecte l'active, connecte la
|
||||
/// cible avec son token (par UUID), contrôle l'identité, persiste.
|
||||
Future<bool> switchTo(Installation target) async {
|
||||
_error = null;
|
||||
_connectingToName = target.name;
|
||||
notifyListeners();
|
||||
|
||||
// 1. Fermer proprement l'active (WS, listeners, requêtes en attente).
|
||||
service.disconnect();
|
||||
|
||||
// 2. Invalider les caches AVANT de connecter la cible — aucune donnée de
|
||||
// l'ancienne box ne doit subsister (service + providers).
|
||||
_clearCaches();
|
||||
|
||||
// 3. Token par UUID (secure storage, tolérant aux pannes).
|
||||
final token = await store.tokenFor(target.uuid);
|
||||
|
||||
// 4. Connexion ws://host:port (pas de TLS).
|
||||
final ok = await service.connect(
|
||||
target.host,
|
||||
target.port,
|
||||
protocol: NymeaProtocol.webSocket,
|
||||
token: token,
|
||||
);
|
||||
if (!ok) {
|
||||
_connectingToName = null;
|
||||
_error = service.connectionError ?? 'Connexion échouée';
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
|
||||
// 5. Contrôle d'identité : en DHCP, l'IP attendue peut héberger une AUTRE
|
||||
// box. On enregistre alors sous le vrai UUID, sans écraser la cible.
|
||||
final realUuid = service.serverUuid;
|
||||
if (realUuid.isNotEmpty && realUuid != target.uuid) {
|
||||
_active = await _persistFromService(host: target.host, port: target.port);
|
||||
_error = 'Identité différente à ${target.host} : '
|
||||
'${_shortUuid(realUuid)} ≠ ${_shortUuid(target.uuid)}';
|
||||
_connectingToName = null;
|
||||
notifyListeners();
|
||||
return true; // transport up, mais sur une autre installation
|
||||
}
|
||||
|
||||
// 6. Persister (host courant + lastSeen + token) sous l'UUID.
|
||||
_active = await _persistFromService(
|
||||
host: target.host,
|
||||
port: target.port,
|
||||
fallbackName: target.name,
|
||||
);
|
||||
_connectingToName = null;
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Connecte une box **nouvelle** (ajout manuel ou découverte) : sans token.
|
||||
/// Si la box est déjà authentifiée (ouverte), on persiste ; sinon l'UI route
|
||||
/// vers l'écran Connexion (Étape 5) et l'enregistrement suit l'auth réussie.
|
||||
Future<bool> connectNew({required String host, int port = 4444}) async {
|
||||
_error = null;
|
||||
_connectingToName = host;
|
||||
notifyListeners();
|
||||
|
||||
service.disconnect();
|
||||
_clearCaches();
|
||||
final ok = await service.connect(host, port,
|
||||
protocol: NymeaProtocol.webSocket);
|
||||
if (!ok) {
|
||||
_connectingToName = null;
|
||||
_error = service.connectionError ?? 'Connexion échouée';
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
|
||||
if (service.authenticated && service.serverUuid.isNotEmpty) {
|
||||
_active = await _persistFromService(host: host, port: port);
|
||||
}
|
||||
_connectingToName = null;
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Login sur la box déjà connectée (transport up, auth requise). Sur succès,
|
||||
/// persiste l'installation **par UUID** (token dans le secure storage).
|
||||
Future<bool> authenticate(String username, String password) async {
|
||||
_error = null;
|
||||
final ok = await service.authenticate(username, password);
|
||||
if (ok) {
|
||||
_active =
|
||||
await _persistFromService(host: service.host, port: service.port);
|
||||
} else {
|
||||
_error = service.connectionError ?? 'Identifiants refusés';
|
||||
}
|
||||
notifyListeners();
|
||||
return ok;
|
||||
}
|
||||
|
||||
/// Crée l'admin d'une box neuve puis login (enchaîné côté service). Sur
|
||||
/// succès, persiste par UUID. **Non vérifié en live** (pas de box vierge).
|
||||
Future<bool> createAdmin(String username, String password,
|
||||
{String? email}) async {
|
||||
_error = null;
|
||||
final ok = await service.createUser(username, password, email: email);
|
||||
if (ok) {
|
||||
_active =
|
||||
await _persistFromService(host: service.host, port: service.port);
|
||||
} else {
|
||||
_error = service.connectionError ?? 'Création du compte refusée';
|
||||
}
|
||||
notifyListeners();
|
||||
return ok;
|
||||
}
|
||||
|
||||
/// Entre en mode démo (connexion active factice).
|
||||
void enterDemo() {
|
||||
// Vider d'abord : real→démo ne doit pas garder la config de la box réelle.
|
||||
_clearCaches();
|
||||
service.startSimulation();
|
||||
_active = null;
|
||||
_connectingToName = null;
|
||||
_error = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Coupe la session active sans détruire l'enregistrement.
|
||||
void disconnectActive() {
|
||||
service.disconnect();
|
||||
_active = null;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Supprime une installation (action installateur) + son token.
|
||||
Future<void> removeInstallation(String uuid) async {
|
||||
await store.remove(uuid);
|
||||
if (_active?.uuid == uuid) {
|
||||
service.disconnect();
|
||||
_active = null;
|
||||
}
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ── Interne ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Vide service + providers enregistrés — aucune donnée de la box précédente
|
||||
/// ne subsiste.
|
||||
void _clearCaches() {
|
||||
service.resetState();
|
||||
for (final clear in _cacheClearers) {
|
||||
clear();
|
||||
}
|
||||
}
|
||||
|
||||
/// Construit + persiste l'installation à partir de l'identité Hello courante.
|
||||
/// Si l'UUID est vide (cas anormal), on ne persiste pas mais on renvoie quand
|
||||
/// même un enregistrement transitoire pour [active].
|
||||
Future<Installation> _persistFromService({
|
||||
required String host,
|
||||
required int port,
|
||||
String? fallbackName,
|
||||
}) async {
|
||||
final uuid = service.serverUuid;
|
||||
final inst = Installation(
|
||||
uuid: uuid,
|
||||
name: service.serverName.isNotEmpty
|
||||
? service.serverName
|
||||
: (fallbackName ?? 'nymea'),
|
||||
host: host,
|
||||
port: port,
|
||||
lastSeen: DateTime.now(),
|
||||
token: service.token.isNotEmpty ? service.token : null,
|
||||
);
|
||||
if (uuid.isNotEmpty) await store.upsert(inst);
|
||||
return inst;
|
||||
}
|
||||
|
||||
String _shortUuid(String u) => u.length <= 8 ? u : '${u.substring(0, 8)}…';
|
||||
}
|
||||
111
lib/services/installation_store.dart
Normal file
111
lib/services/installation_store.dart
Normal file
@ -0,0 +1,111 @@
|
||||
import 'dart:convert';
|
||||
|
||||
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import '../models/installation.dart';
|
||||
|
||||
/// Persistance des installations enregistrées, **indexée par Server UUID**.
|
||||
///
|
||||
/// Séparation des supports (décision verrouillée) :
|
||||
/// • **métadonnées** (`uuid/name/host/port/lastSeen`) → SharedPreferences ;
|
||||
/// • **token** (credential) → `flutter_secure_storage` (Keychain/Keystore),
|
||||
/// clé `etm_token_<uuid>`.
|
||||
///
|
||||
/// Aucune connexion ici : c'est le magasin que pilotera le `ConnectionManager`
|
||||
/// (Étape 2). Le piège mono→multi (réutiliser le token d'une autre box) est
|
||||
/// évité par construction : le token est lu/écrit **par UUID**.
|
||||
class InstallationStore {
|
||||
static const _listKey = 'etm_installations_v1';
|
||||
static const _tokenPrefix = 'etm_token_';
|
||||
|
||||
final FlutterSecureStorage _secure;
|
||||
|
||||
InstallationStore({FlutterSecureStorage? secureStorage})
|
||||
: _secure = secureStorage ?? const FlutterSecureStorage();
|
||||
|
||||
final Map<String, Installation> _byUuid = {};
|
||||
|
||||
/// Installations connues, les plus récemment vues d'abord.
|
||||
List<Installation> get installations {
|
||||
final list = _byUuid.values.toList();
|
||||
list.sort((a, b) {
|
||||
final ad = a.lastSeen, bd = b.lastSeen;
|
||||
if (ad == null && bd == null) return a.name.compareTo(b.name);
|
||||
if (ad == null) return 1;
|
||||
if (bd == null) return -1;
|
||||
return bd.compareTo(ad);
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
Installation? byUuid(String uuid) => _byUuid[uuid];
|
||||
|
||||
/// Charge les métadonnées (prefs) puis hydrate chaque token (secure storage).
|
||||
Future<void> load() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_byUuid.clear();
|
||||
final raw = prefs.getString(_listKey);
|
||||
if (raw == null) return;
|
||||
|
||||
final decoded = jsonDecode(raw);
|
||||
if (decoded is! List) return;
|
||||
for (final entry in decoded) {
|
||||
if (entry is! Map) continue;
|
||||
final meta = Installation.fromJson(Map<String, dynamic>.from(entry));
|
||||
// Le token est un bonus : son indispo (web, plateforme sans secure
|
||||
// storage) ne doit jamais empêcher le chargement des métadonnées.
|
||||
final token = await _readToken(meta.uuid);
|
||||
_byUuid[meta.uuid] = token == null ? meta : meta.copyWith(token: token);
|
||||
}
|
||||
}
|
||||
|
||||
/// Crée ou met à jour une installation (métadonnées + token si présent).
|
||||
Future<void> upsert(Installation inst) async {
|
||||
_byUuid[inst.uuid] = inst;
|
||||
await _persistMeta();
|
||||
if (inst.hasToken) {
|
||||
await _writeToken(inst.uuid, inst.token!);
|
||||
}
|
||||
}
|
||||
|
||||
/// Retire une installation et **efface son token** du secure storage.
|
||||
Future<void> remove(String uuid) async {
|
||||
_byUuid.remove(uuid);
|
||||
await _persistMeta();
|
||||
await _deleteToken(uuid);
|
||||
}
|
||||
|
||||
/// Token stocké pour cet UUID (lecture directe secure storage, tolérante).
|
||||
Future<String?> tokenFor(String uuid) => _readToken(uuid);
|
||||
|
||||
// ── Accès secure storage tolérants aux pannes (async + faillible) ──────────
|
||||
|
||||
Future<String?> _readToken(String uuid) async {
|
||||
try {
|
||||
return await _secure.read(key: _tokenPrefix + uuid);
|
||||
} catch (_) {
|
||||
return null; // dégradation propre : pas de token, pas de crash
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _writeToken(String uuid, String token) async {
|
||||
try {
|
||||
await _secure.write(key: _tokenPrefix + uuid, value: token);
|
||||
} catch (_) {
|
||||
// best-effort : la session reste valide en mémoire pour cette exécution
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _deleteToken(String uuid) async {
|
||||
try {
|
||||
await _secure.delete(key: _tokenPrefix + uuid);
|
||||
} catch (_) {/* idempotent */}
|
||||
}
|
||||
|
||||
Future<void> _persistMeta() async {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
final list = _byUuid.values.map((i) => i.toJson()).toList();
|
||||
await prefs.setString(_listKey, jsonEncode(list));
|
||||
}
|
||||
}
|
||||
@ -24,7 +24,7 @@ class NymeaService extends ChangeNotifier {
|
||||
Socket? _socket; // TCP raw
|
||||
WebSocketChannel? _wsChannel; // WebSocket
|
||||
StreamSubscription? _wsSub;
|
||||
NymeaProtocol _protocol = NymeaProtocol.tcpRaw;
|
||||
NymeaProtocol _protocol = NymeaProtocol.webSocket;
|
||||
|
||||
int _requestId = 1;
|
||||
final Map<int, Completer<Map<String, dynamic>>> _pendingRequests = {};
|
||||
@ -46,6 +46,13 @@ class NymeaService extends ChangeNotifier {
|
||||
String _username = '';
|
||||
String _password = '';
|
||||
|
||||
// ── Identité & état d'auth issus du Hello (JSONRPC.Hello) ────────────────────
|
||||
String _serverUuid = ''; // identité stable de la box (Hello `uuid`)
|
||||
String _serverName = ''; // nom de la box (Hello `name`)
|
||||
bool _initialSetupRequired = false; // box neuve → branche « créer admin »
|
||||
bool _authenticationRequired = false;
|
||||
bool _authenticated = false;
|
||||
|
||||
// ── Debug verbeux ─────────────────────────────────────────────────────────
|
||||
bool _verboseLog = false; // activé depuis l'UI ou à la connexion
|
||||
bool get verboseLog => _verboseLog;
|
||||
@ -117,6 +124,14 @@ class NymeaService extends ChangeNotifier {
|
||||
String? get nymeaVersion => _nymeaVersion;
|
||||
String get username => _username;
|
||||
String get password => _password;
|
||||
|
||||
// ── Identité & auth (Hello) — consommés par le ConnectionManager ────────────
|
||||
String get token => _token;
|
||||
String get serverUuid => _serverUuid;
|
||||
String get serverName => _serverName;
|
||||
bool get initialSetupRequired => _initialSetupRequired;
|
||||
bool get authenticationRequired => _authenticationRequired;
|
||||
bool get authenticated => _authenticated;
|
||||
EnergyData get energyData => _energyData;
|
||||
List<HistoryPoint> get historyPoints => _historyPoints;
|
||||
List<NymeaThing> get things => _things;
|
||||
@ -167,9 +182,10 @@ class NymeaService extends ChangeNotifier {
|
||||
Future<bool> connect(
|
||||
String host,
|
||||
int port, {
|
||||
NymeaProtocol protocol = NymeaProtocol.tcpRaw,
|
||||
NymeaProtocol protocol = NymeaProtocol.webSocket,
|
||||
String username = '',
|
||||
String password = '',
|
||||
String? token,
|
||||
}) async {
|
||||
if (_connecting) return false;
|
||||
_host = host;
|
||||
@ -182,11 +198,12 @@ class NymeaService extends ChangeNotifier {
|
||||
_isSimulation = false;
|
||||
_simulationTimer?.cancel();
|
||||
_buffer.clear();
|
||||
// Charger le token sauvegardé pour cet hôte
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
_token = prefs.getString('nymea_token_$host') ?? '';
|
||||
// Token fourni par le ConnectionManager (persistance **par UUID**, secure
|
||||
// storage). Posé AVANT le dispatch → sur WS, Hello part avec le token et la
|
||||
// box renvoie `authenticated:true` s'il est valide (reconnexion silencieuse).
|
||||
_token = token ?? '';
|
||||
if (_token.isNotEmpty) {
|
||||
_log('🔑 Token restauré depuis SharedPreferences', force: true);
|
||||
_log('🔑 Token préchargé (par UUID) fourni au connect', force: true);
|
||||
}
|
||||
notifyListeners();
|
||||
|
||||
@ -322,38 +339,111 @@ class NymeaService extends ChangeNotifier {
|
||||
?? p['serverVersion'] as String?
|
||||
?? 'nymea';
|
||||
|
||||
_log('✅ Connected to nymea $_nymeaVersion'
|
||||
' via ${_protocol == NymeaProtocol.tcpRaw ? "TCP" : "WebSocket"}');
|
||||
// ── Identité & état d'auth — capturés AVANT toute décision d'auth.
|
||||
// `initialSetupRequired` tranche créer-admin vs login (consommé Étape 5).
|
||||
// nymea renvoie l'UUID entre accolades ({3943…}) ; on normalise une fois
|
||||
// ici → clé de stockage / comparaison / affichage cohérentes en aval.
|
||||
_serverUuid = ((p['uuid'] as String?) ?? '').replaceAll(RegExp(r'[{}]'), '');
|
||||
_serverName = (p['name'] as String?) ?? 'nymea';
|
||||
_authenticationRequired = p['authenticationRequired'] == true;
|
||||
_initialSetupRequired =
|
||||
_authenticationRequired && p['initialSetupRequired'] == true;
|
||||
_authenticated = !_authenticationRequired || p['authenticated'] == true;
|
||||
|
||||
// Flux auth : si requis ET pas encore authentifié avec token valide
|
||||
if (p['authenticationRequired'] == true && p['authenticated'] != true) {
|
||||
if (_token.isNotEmpty) {
|
||||
_log('🔑 Token existant présent, tentative d\'utilisation...', force: true);
|
||||
// Le token sera inclus dans les requêtes — on verra si ça marche
|
||||
} else if (_username.isNotEmpty) {
|
||||
try {
|
||||
await _doAuthenticate();
|
||||
} catch (e) {
|
||||
_connectionError = e.toString();
|
||||
_connected = false;
|
||||
_connecting = false;
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
_log('✅ nymea $_nymeaVersion "$_serverName" ($_serverUuid) via '
|
||||
'${_protocol == NymeaProtocol.tcpRaw ? "TCP" : "WebSocket"} · '
|
||||
'authReq=$_authenticationRequired setup=$_initialSetupRequired '
|
||||
'auth=$_authenticated', force: true);
|
||||
|
||||
// Branche compat : credentials passés directement à connect() → login.
|
||||
// Le chemin nominal multi-box passe par token (déjà validé au Hello).
|
||||
if (_authenticationRequired && !_authenticated && _username.isNotEmpty) {
|
||||
try {
|
||||
await _doAuthenticate();
|
||||
_authenticated = _token.isNotEmpty;
|
||||
} catch (e) {
|
||||
_connectionError = e.toString();
|
||||
_connected = false;
|
||||
_connecting = false;
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
await _setNotificationsEnabled();
|
||||
|
||||
_connected = true;
|
||||
_connecting = false;
|
||||
notifyListeners();
|
||||
|
||||
// Ne charger que si authentifié — sinon transport up, mais on laisse le
|
||||
// ConnectionManager router vers l'écran Connexion (auth tardive).
|
||||
if (_authenticated) {
|
||||
await _postAuthLoad();
|
||||
} else {
|
||||
_log('🔒 Auth requise — chargement différé (écran Connexion)',
|
||||
force: true);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Chargement post-authentification — partagé entre la connexion directe
|
||||
/// (`_finishConnect`, box déjà authentifiée) et l'auth **tardive**
|
||||
/// (`authenticate`/`createUser` depuis l'écran Connexion).
|
||||
Future<void> _postAuthLoad() async {
|
||||
await _setNotificationsEnabled();
|
||||
await _initialLoad();
|
||||
_startHeartbeat();
|
||||
}
|
||||
|
||||
/// Login sur une box déjà initialisée (`JSONRPC.Authenticate`). À appeler
|
||||
/// quand le transport est up mais l'auth requise. Sur succès : token posé,
|
||||
/// `_authenticated=true`, chargement déclenché.
|
||||
Future<bool> authenticate(String username, String password) async {
|
||||
_username = username;
|
||||
_password = password;
|
||||
try {
|
||||
await _doAuthenticate(); // pose _token ou throw
|
||||
} catch (e) {
|
||||
_connectionError = e.toString();
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
_authenticated = true;
|
||||
_connectionError = null;
|
||||
notifyListeners();
|
||||
await _postAuthLoad();
|
||||
notifyListeners();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Crée l'administrateur d'une box **neuve** (`JSONRPC.CreateUser`, autorisé
|
||||
/// uniquement quand `initialSetupRequired`), **puis enchaîne `Authenticate`**
|
||||
/// pour obtenir le token (CreateUser n'en renvoie pas). Non vérifié en live
|
||||
/// faute de box vierge sous la main.
|
||||
Future<bool> createUser(String username, String password,
|
||||
{String? email}) async {
|
||||
_log('🔐 JSONRPC.CreateUser → user=$username', force: true);
|
||||
try {
|
||||
final r = await _sendRequest('JSONRPC.CreateUser', {
|
||||
'username': username,
|
||||
'password': password,
|
||||
if (email != null && email.isNotEmpty) 'email': email,
|
||||
});
|
||||
final err = r['params']?['error'] as String?;
|
||||
if (err != null && err != 'UserErrorNoError') {
|
||||
_connectionError = 'CreateUser: $err';
|
||||
_log('❌ $_connectionError', force: true);
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
} catch (e) {
|
||||
_connectionError = e.toString();
|
||||
notifyListeners();
|
||||
return false;
|
||||
}
|
||||
// Token obtenu via Authenticate (CreateUser n'en renvoie pas).
|
||||
return authenticate(username, password);
|
||||
}
|
||||
|
||||
Future<void> _doAuthenticate() async {
|
||||
_log('🔐 JSONRPC.Authenticate → user=$_username', force: true);
|
||||
final r = await _sendRequest('JSONRPC.Authenticate', {
|
||||
@ -363,9 +453,8 @@ class NymeaService extends ChangeNotifier {
|
||||
});
|
||||
_token = r['params']?['token'] as String? ?? '';
|
||||
if (_token.isNotEmpty) {
|
||||
final prefs = await SharedPreferences.getInstance();
|
||||
await prefs.setString('nymea_token_$_host', _token);
|
||||
_log('✅ Token obtenu et sauvegardé', force: true);
|
||||
// Persistance par UUID assurée par le ConnectionManager (secure storage).
|
||||
_log('✅ Token obtenu', force: true);
|
||||
} else {
|
||||
_log('❌ Authenticate échoué: ${r["params"]}', force: true);
|
||||
throw Exception('Authentication failed: ${r["params"]}');
|
||||
@ -392,6 +481,13 @@ class NymeaService extends ChangeNotifier {
|
||||
_connected = false;
|
||||
_connecting = false;
|
||||
_isSimulation = false;
|
||||
// Réinitialiser identité & auth — ne pas afficher une box déconnectée.
|
||||
_token = '';
|
||||
_serverUuid = '';
|
||||
_serverName = '';
|
||||
_initialSetupRequired = false;
|
||||
_authenticationRequired = false;
|
||||
_authenticated = false;
|
||||
_buffer.clear();
|
||||
for (final c in _pendingRequests.values) {
|
||||
c.completeError(Exception('Disconnected'));
|
||||
@ -400,6 +496,18 @@ class NymeaService extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Vide les caches **par-box** en mémoire — appelé au switch d'installation
|
||||
/// (avant de connecter la cible). Ne touche **pas** aux favoris (préférence
|
||||
/// utilisateur globale, hors box).
|
||||
void resetState() {
|
||||
_energyData = const EnergyData();
|
||||
_historyPoints = [];
|
||||
_things = [];
|
||||
_thingClasses = [];
|
||||
_thingsLoaded = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// JSON-RPC SEND — \n pour TCP, frame WebSocket pour WS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
@ -641,11 +749,11 @@ class NymeaService extends ChangeNotifier {
|
||||
|
||||
double? n(String k) => (p[k] as num?)?.toDouble();
|
||||
|
||||
// Production PV est négative dans nymea (convention producteur = négatif)
|
||||
// Magnitudes pour l'affichage (signes nymea : production +, conso −).
|
||||
final rawProd = n('currentPowerProduction');
|
||||
final pv = rawProd != null ? rawProd.abs() : 0.0;
|
||||
|
||||
final home = n('currentPowerConsumption') ?? 0.0;
|
||||
final home = n('currentPowerConsumption')?.abs() ?? 0.0;
|
||||
final grid = n('currentPowerAcquisition') ?? 0.0; // + import, 0 si export
|
||||
final bat = n('currentPowerStorage') ?? 0.0;
|
||||
|
||||
@ -709,9 +817,11 @@ class NymeaService extends ChangeNotifier {
|
||||
return PowerBalanceEntry(
|
||||
timestamp: DateTime.fromMillisecondsSinceEpoch(
|
||||
(m['timestamp'] as num).toInt() * 1000),
|
||||
// production est négative dans nymea → valeur absolue
|
||||
// Signes nymea (firmware 1.15.2) : production positive, consommation
|
||||
// NÉGATIVE. Le graphe trace des magnitudes → abs sur les deux (sinon
|
||||
// conso<0 cloue l'autoconso à 0 via le clamp et fausse l'axe Y).
|
||||
productionW: d('production').abs(),
|
||||
consumptionW: d('consumption'),
|
||||
consumptionW: d('consumption').abs(),
|
||||
acquisitionW: d('acquisition'),
|
||||
storageW: d('storage'),
|
||||
totalProductionWh: d('totalProduction'),
|
||||
@ -867,6 +977,51 @@ class NymeaService extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// CONFIG ÉNERGIE — rôles & charges pilotées (écran Rôles & appareils)
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
/// gridMeter → **réel** `Energy.SetRootMeter` (natif, testable contre `hems`).
|
||||
Future<void> setRootMeter(String thingId) async {
|
||||
if (_isSimulation || !_connected) {
|
||||
_log('[RootMeter] sim/déconnecté — SetRootMeter ignoré ($thingId)',
|
||||
force: true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
final r = await _sendRequest(
|
||||
'Energy.SetRootMeter', {'rootMeterThingId': thingId});
|
||||
final err = r['params']?['energyError'];
|
||||
_log('[RootMeter] Energy.SetRootMeter($thingId) ← ${err ?? r['status']}',
|
||||
force: true);
|
||||
} catch (e) {
|
||||
_log('[RootMeter] SetRootMeter: $e', force: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// **STUB** — `NymeaEnergy.SetLoadConfig` n'existe pas encore côté plugin
|
||||
/// (l'energymanager l'exposera plus tard). En attendant on logge **le JSON
|
||||
/// exact qui partira**, sans rien casser ni bloquer.
|
||||
Future<void> setLoadConfig(List<Map<String, dynamic>> descriptors) async {
|
||||
_log('[LoadConfig STUB] NymeaEnergy.SetLoadConfig — '
|
||||
'${descriptors.length} charge(s) etmvariableload', force: true);
|
||||
for (final d in descriptors) {
|
||||
final pretty = const JsonEncoder.withIndent(' ').convert(d);
|
||||
_log('[LoadConfig STUB] LoadDescriptor:\n$pretty', force: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// **STUB** — `NymeaEnergy.GetLoadConfig` absent côté plugin. Retourne vide,
|
||||
/// ne bloque pas le démarrage.
|
||||
Future<List<Map<String, dynamic>>> getLoadConfig() async {
|
||||
_log('[LoadConfig STUB] NymeaEnergy.GetLoadConfig → [] '
|
||||
'(méthode plugin absente)', force: true);
|
||||
return const [];
|
||||
}
|
||||
|
||||
/// Trace de routage (charges hors etmvariableload : PAC SG-Ready, batterie).
|
||||
void logInfo(String msg) => _log(msg, force: true);
|
||||
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
// THINGS / INTEGRATIONS
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
63
lib/shared/widgets/hems_icon.dart
Normal file
63
lib/shared/widgets/hems_icon.dart
Normal file
@ -0,0 +1,63 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
/// Icône du cœur HEMS (« Héos ») : étincelle de marque au centre,
|
||||
/// 4 nœuds pilotés alignés sur les pointes.
|
||||
class HemsIcon extends StatelessWidget {
|
||||
final double size;
|
||||
final Color? color;
|
||||
|
||||
const HemsIcon({super.key, this.size = 24, this.color});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final c = color ??
|
||||
IconTheme.of(context).color ??
|
||||
const Color(0xFF28A06A);
|
||||
return SizedBox(
|
||||
width: size,
|
||||
height: size,
|
||||
child: CustomPaint(painter: _HemsPainter(c)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _HemsPainter extends CustomPainter {
|
||||
final Color color;
|
||||
_HemsPainter(this.color);
|
||||
|
||||
static const _nodes = <Offset>[
|
||||
Offset(12, 3.4),
|
||||
Offset(20.6, 12),
|
||||
Offset(12, 20.6),
|
||||
Offset(3.4, 12),
|
||||
];
|
||||
static const _nodeR = 1.7;
|
||||
|
||||
@override
|
||||
void paint(Canvas canvas, Size size) {
|
||||
final s = size.width / 24.0;
|
||||
final paint = Paint()
|
||||
..color = color
|
||||
..style = PaintingStyle.fill
|
||||
..isAntiAlias = true;
|
||||
|
||||
final spark = Path()
|
||||
..moveTo(12 * s, 7.4 * s)
|
||||
..lineTo(13.25 * s, 10.75 * s)
|
||||
..lineTo(16.6 * s, 12 * s)
|
||||
..lineTo(13.25 * s, 13.25 * s)
|
||||
..lineTo(12 * s, 16.6 * s)
|
||||
..lineTo(10.75 * s, 13.25 * s)
|
||||
..lineTo(7.4 * s, 12 * s)
|
||||
..lineTo(10.75 * s, 10.75 * s)
|
||||
..close();
|
||||
canvas.drawPath(spark, paint);
|
||||
|
||||
for (final n in _nodes) {
|
||||
canvas.drawCircle(Offset(n.dx * s, n.dy * s), _nodeR * s, paint);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
bool shouldRepaint(_HemsPainter old) => old.color != color;
|
||||
}
|
||||
@ -2,68 +2,68 @@ import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
class AppTheme {
|
||||
// ── Couleurs historiques (dashboard, widgets existants) ──────────────────────
|
||||
static const Color primaryGreen = Color(0xFF4CAF50);
|
||||
static const Color solarYellow = Color(0xFFF5C842);
|
||||
static const Color gridGray = Color(0xFF5A6473);
|
||||
static const Color homeBlue = Color(0xFF5BA4CF);
|
||||
static const Color batteryGreen = Color(0xFF4CAF50);
|
||||
static const Color accentOrange = Color(0xFFFF7043);
|
||||
static const Color backgroundGray = Color(0xFFF0F2F5);
|
||||
static const Color cardWhite = Color(0xFFFFFFFF);
|
||||
static const Color textDark = Color(0xFF1A1A2E);
|
||||
static const Color textLight = Color(0xFF6B7280);
|
||||
static const Color boostRed = Color(0xFFEF5350);
|
||||
static const Color pvGreen = Color(0xFF43A047);
|
||||
static const Color minPvBlue = Color(0xFF42A5F5);
|
||||
|
||||
// ── Couleurs nymea-style ─────────────────────────────────────────────────────
|
||||
|
||||
/// Couleur d'accent principale nymea (teal/turquoise — #57baae de StyleBase.qml)
|
||||
static const Color accentTeal = Color(0xFF57BAAE);
|
||||
|
||||
/// Fond de tuile nymea — légèrement teinté (tileBackgroundColor dans StyleBase.qml)
|
||||
static const Color tileBackground = Color(0xFFF2F4F7);
|
||||
|
||||
/// Rayon d'arrondi nymea standard (cornerRadius: 10 dans StyleBase.qml)
|
||||
// ── Couleurs historiques (widgets existants) ──────────────────────────────
|
||||
static const Color primaryGreen = Color(0xFF4CAF50);
|
||||
static const Color solarYellow = Color(0xFFF5C842);
|
||||
static const Color gridGray = Color(0xFF5A6473);
|
||||
static const Color homeBlue = Color(0xFF5BA4CF);
|
||||
static const Color batteryGreen = Color(0xFF4CAF50);
|
||||
static const Color accentOrange = Color(0xFFFF7043);
|
||||
static const Color backgroundGray= Color(0xFFF0F2F5);
|
||||
static const Color cardWhite = Color(0xFFFFFFFF);
|
||||
static const Color textDark = Color(0xFF1A1A2E);
|
||||
static const Color textLight = Color(0xFF6B7280);
|
||||
static const Color boostRed = Color(0xFFEF5350);
|
||||
static const Color pvGreen = Color(0xFF43A047);
|
||||
static const Color minPvBlue = Color(0xFF42A5F5);
|
||||
static const Color accentTeal = Color(0xFF57BAAE);
|
||||
static const Color tileBackground= Color(0xFFF2F4F7);
|
||||
static const double cornerRadius = 10.0;
|
||||
|
||||
// ── Couleurs flux énergie (miroir de StyleBase.qml) ──────────────────────────
|
||||
static const Color powerAcquisitionColor = Color(0xFFCD5C5C);
|
||||
static const Color powerReturnColor = Color(0xFFCDCD5C);
|
||||
static const Color powerConsumptionColor = Color(0xFF5C95CD);
|
||||
static const Color powerSelfProductionColor= Color(0xFF5CCD5C);
|
||||
static const Color powerBatteryChargingColor = Color(0xFF955CCD);
|
||||
static const Color powerBatteryDischargingColor= Color(0xFFCD955C);
|
||||
|
||||
/// Soutirer du réseau (grid import) — indianred
|
||||
static const Color powerAcquisitionColor = Color(0xFFCD5C5C);
|
||||
|
||||
/// Injection sur le réseau (grid export) — yellow
|
||||
static const Color powerReturnColor = Color(0xFFCDCD5C);
|
||||
|
||||
/// Consommation maison — blue
|
||||
static const Color powerConsumptionColor = Color(0xFF5C95CD);
|
||||
|
||||
/// PV → maison (autoconsommation) — green
|
||||
static const Color powerSelfProductionColor = Color(0xFF5CCD5C);
|
||||
|
||||
/// Charge batterie — purple
|
||||
static const Color powerBatteryChargingColor = Color(0xFF955CCD);
|
||||
|
||||
/// Décharge batterie — orange
|
||||
static const Color powerBatteryDischargingColor = Color(0xFFCD955C);
|
||||
|
||||
// ── Thème Material3 ───────────────────────────────────────────────────────────
|
||||
// ── Thème clair ───────────────────────────────────────────────────────────
|
||||
static ThemeData get theme => ThemeData(
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF1DB86A),
|
||||
surface: const Color(0xFFF3F6F8),
|
||||
seedColor: const Color(0xFF31A3DD),
|
||||
surface: const Color(0xFFEEF2F5),
|
||||
),
|
||||
scaffoldBackgroundColor: const Color(0xFFF3F6F8),
|
||||
cardTheme: CardThemeData(
|
||||
color: cardWhite,
|
||||
scaffoldBackgroundColor: const Color(0xFFEEF2F5),
|
||||
cardTheme: const CardThemeData(
|
||||
color: Colors.white,
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(22),
|
||||
borderRadius: BorderRadius.all(Radius.circular(20)),
|
||||
),
|
||||
margin: EdgeInsets.zero,
|
||||
),
|
||||
textTheme: GoogleFonts.ibmPlexSansTextTheme(ThemeData.light().textTheme),
|
||||
useMaterial3: true,
|
||||
);
|
||||
|
||||
// ── Thème sombre ──────────────────────────────────────────────────────────
|
||||
static ThemeData get darkTheme => ThemeData(
|
||||
brightness: Brightness.dark,
|
||||
colorScheme: ColorScheme.fromSeed(
|
||||
seedColor: const Color(0xFF31A3DD),
|
||||
brightness: Brightness.dark,
|
||||
surface: const Color(0xFF0D2B3B),
|
||||
),
|
||||
scaffoldBackgroundColor: const Color(0xFF06141D),
|
||||
cardTheme: const CardThemeData(
|
||||
color: Color(0xFF0D2B3B),
|
||||
elevation: 0,
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.all(Radius.circular(20)),
|
||||
),
|
||||
margin: EdgeInsets.zero,
|
||||
),
|
||||
textTheme: GoogleFonts.ibmPlexSansTextTheme(ThemeData.dark().textTheme),
|
||||
useMaterial3: true,
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,39 +1,91 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
/// Tokens de marque ETM PowerSync.
|
||||
/// Source de vérité unique pour les couleurs et la typographie.
|
||||
/// Tokens de marque ETM PowerSync — source de vérité unique.
|
||||
class EtmTokens {
|
||||
EtmTokens._();
|
||||
|
||||
// ---- Couleurs de marque ----
|
||||
static const Color navy = Color(0xFF0D2B3B);
|
||||
static const Color navy2 = Color(0xFF10384C);
|
||||
static const Color blue = Color(0xFF31A3DD);
|
||||
static const Color amber = Color(0xFFFEC113);
|
||||
static const Color green = Color(0xFF1DB86A);
|
||||
static const Color greenDark = Color(0xFF159C58);
|
||||
static const Color orange = Color(0xFFE8923A);
|
||||
static const Color danger = Color(0xFFE8423F);
|
||||
// ── Couleurs de marque (spec Dashboard v4) ────────────────────────────────
|
||||
static const Color brand = Color(0xFF31A3DD);
|
||||
static const Color solar = Color(0xFFFEC113);
|
||||
static const Color solarDark = Color(0xFFFFCB33);
|
||||
static const Color eco = Color(0xFF28A06A);
|
||||
static const Color ecoDark = Color(0xFF33B87A);
|
||||
static const Color heat = Color(0xFFEF8B2A);
|
||||
static const Color water = Color(0xFF31A3DD);
|
||||
static const Color gridColor = Color(0xFF8A98A3);
|
||||
static const Color importColor = Color(0xFFE8806F);
|
||||
|
||||
// ---- Neutres ----
|
||||
static const Color ink = navy;
|
||||
static const Color muted = Color(0xFF6B7D88);
|
||||
static const Color faint = Color(0xFF9AABB4);
|
||||
static const Color line = Color(0xFFE6ECF0);
|
||||
static const Color bg = Color(0xFFF3F6F8);
|
||||
static const Color card = Colors.white;
|
||||
// ── Legacy (compatibilité existante) ─────────────────────────────────────
|
||||
static const Color navy = Color(0xFF0D2B3B);
|
||||
static const Color navy2 = Color(0xFF10384C);
|
||||
static const Color blue = Color(0xFF31A3DD);
|
||||
static const Color amber = Color(0xFFFEC113);
|
||||
static const Color green = Color(0xFF1DB86A);
|
||||
static const Color greenDark= Color(0xFF159C58);
|
||||
static const Color orange = Color(0xFFE8923A);
|
||||
static const Color danger = Color(0xFFE8423F);
|
||||
|
||||
// ---- Soft ----
|
||||
// ── Couleurs de flux ──────────────────────────────────────────────────────
|
||||
static const Color solToHouse = Color(0xFFB8C34D);
|
||||
static const Color solToEcs = Color(0xFF31A3DD);
|
||||
static const Color solToVe = Color(0xFF28A06A);
|
||||
static const Color solToBatt = Color(0xFF7FCF72);
|
||||
static const Color solToGrid = Color(0xFFFEC113);
|
||||
static const Color battToHouse = Color(0xFF2F6FB0);
|
||||
static const Color gridToHouse = Color(0xFFE8806F);
|
||||
|
||||
// ── Neutres light ─────────────────────────────────────────────────────────
|
||||
static const Color ink = navy;
|
||||
static const Color muted= Color(0xFF5D6F7B);
|
||||
static const Color faint= Color(0xFF8B9AA4);
|
||||
static const Color line = Color(0xFFE4EAEE);
|
||||
static const Color bg = Color(0xFFEEF2F5);
|
||||
static const Color bg2 = Color(0xFFE4EAEE);
|
||||
static const Color card = Color(0xFFFFFFFF);
|
||||
|
||||
// ── Neutres dark ──────────────────────────────────────────────────────────
|
||||
static const Color bgDark = Color(0xFF06141D);
|
||||
static const Color bg2Dark = Color(0xFF0A1F2B);
|
||||
static const Color surfaceDark = Color(0xFF0D2B3B);
|
||||
static const Color inkDark = Color(0xFFEAF3F7);
|
||||
static const Color mutedDark = Color(0xFF9BB0BC);
|
||||
static const Color faintDark = Color(0xFF6C8493);
|
||||
|
||||
// ── Soft fills (legacy light) ─────────────────────────────────────────────
|
||||
static const Color greenSoft = Color(0xFFE7F8EF);
|
||||
static const Color blueSoft = Color(0xFFE9F5FB);
|
||||
static const Color amberSoft = Color(0xFFFFF6DD);
|
||||
|
||||
// ---- Rayons ----
|
||||
static const double radius = 16;
|
||||
static const double radiusLg = 22;
|
||||
// ── Rayons ────────────────────────────────────────────────────────────────
|
||||
static const double radius = 16;
|
||||
static const double radiusLg = 22;
|
||||
static const double radiusCard = 20;
|
||||
static const double radiusCtrl = 13;
|
||||
static const double radiusPill = 30;
|
||||
|
||||
// ---- Typographie ----
|
||||
// ── Context-aware ─────────────────────────────────────────────────────────
|
||||
static bool _isDark(BuildContext c) =>
|
||||
Theme.of(c).brightness == Brightness.dark;
|
||||
|
||||
static Color bgOf(BuildContext c) => _isDark(c) ? bgDark : bg;
|
||||
static Color bg2Of(BuildContext c) => _isDark(c) ? bg2Dark : bg2;
|
||||
static Color surfaceOf(BuildContext c) => _isDark(c) ? surfaceDark : card;
|
||||
static Color inkOf(BuildContext c) => _isDark(c) ? inkDark : ink;
|
||||
static Color mutedOf(BuildContext c) => _isDark(c) ? mutedDark : muted;
|
||||
static Color faintOf(BuildContext c) => _isDark(c) ? faintDark : faint;
|
||||
static Color ecoOf(BuildContext c) => _isDark(c) ? ecoDark : eco;
|
||||
static Color solarOf(BuildContext c) => _isDark(c) ? solarDark : solar;
|
||||
|
||||
static Color lineOf(BuildContext c) => _isDark(c)
|
||||
? const Color(0xFFFFFFFF).withValues(alpha: 0.09)
|
||||
: navy.withValues(alpha: 0.10);
|
||||
|
||||
static Color lineStrongOf(BuildContext c) => _isDark(c)
|
||||
? const Color(0xFFFFFFFF).withValues(alpha: 0.16)
|
||||
: navy.withValues(alpha: 0.16);
|
||||
|
||||
// ── Typographie ───────────────────────────────────────────────────────────
|
||||
static TextStyle sans({
|
||||
double size = 14,
|
||||
FontWeight weight = FontWeight.w400,
|
||||
@ -49,7 +101,6 @@ class EtmTokens {
|
||||
letterSpacing: letterSpacing,
|
||||
);
|
||||
|
||||
/// IBM Plex Mono pour TOUS les chiffres (alignement tabulaire).
|
||||
static TextStyle mono({
|
||||
double size = 14,
|
||||
FontWeight weight = FontWeight.w600,
|
||||
@ -69,7 +120,7 @@ class EtmTokens {
|
||||
letterSpacing: 1.2,
|
||||
);
|
||||
|
||||
/// Décoration shadow standard pour les cartes.
|
||||
// ── Ombres ────────────────────────────────────────────────────────────────
|
||||
static List<BoxShadow> get cardShadow => [
|
||||
BoxShadow(
|
||||
color: navy.withValues(alpha: 0.04),
|
||||
@ -82,4 +133,8 @@ class EtmTokens {
|
||||
offset: const Offset(0, 10),
|
||||
),
|
||||
];
|
||||
|
||||
static List<BoxShadow> cardShadowOf(BuildContext c) => _isDark(c)
|
||||
? []
|
||||
: cardShadow;
|
||||
}
|
||||
|
||||
@ -1,179 +0,0 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import '../providers/energy_setup_provider.dart';
|
||||
import '../theme/app_theme.dart';
|
||||
import '../theme/etm_theme.dart';
|
||||
|
||||
/// Carte de statut pour un rôle EMS (EV Charger, Chauffe-eau, Batterie, etc.).
|
||||
class RoleCard extends StatelessWidget {
|
||||
final EmsRole role;
|
||||
final RoleAssignment? assignment;
|
||||
final bool isReachable;
|
||||
final VoidCallback onConfigure;
|
||||
final VoidCallback? onToggle;
|
||||
final VoidCallback? onEdit;
|
||||
final VoidCallback? onTest;
|
||||
|
||||
const RoleCard({
|
||||
super.key,
|
||||
required this.role,
|
||||
required this.assignment,
|
||||
required this.onConfigure,
|
||||
this.isReachable = true,
|
||||
this.onToggle,
|
||||
this.onEdit,
|
||||
this.onTest,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final isConfigured = assignment != null;
|
||||
final isEnabled = assignment?.enabled ?? false;
|
||||
|
||||
return Card(
|
||||
margin: const EdgeInsets.only(bottom: 12),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(14),
|
||||
side: BorderSide(
|
||||
color: isEnabled
|
||||
? AppTheme.primaryGreen.withValues(alpha: 0.3)
|
||||
: Colors.grey.withValues(alpha: 0.15),
|
||||
),
|
||||
),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(14),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// ── En-tête : icône + nom + switch ──────────────────────────────
|
||||
Row(
|
||||
children: [
|
||||
Text(role.icon,
|
||||
style: const TextStyle(fontSize: 22)),
|
||||
const SizedBox(width: 10),
|
||||
Expanded(
|
||||
child: Text(
|
||||
role.label,
|
||||
style: const TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
fontSize: 15,
|
||||
),
|
||||
),
|
||||
),
|
||||
if (isConfigured)
|
||||
Switch.adaptive(
|
||||
value: isEnabled,
|
||||
activeColor: AppTheme.primaryGreen,
|
||||
onChanged: onToggle != null
|
||||
? (_) => onToggle!()
|
||||
: null,
|
||||
),
|
||||
],
|
||||
),
|
||||
|
||||
const SizedBox(height: 8),
|
||||
|
||||
// ── Corps : état de la configuration ─────────────────────────────
|
||||
if (!isConfigured) ...[
|
||||
const Text(
|
||||
'Aucun appareil configuré',
|
||||
style: TextStyle(
|
||||
color: AppTheme.textLight,
|
||||
fontSize: 13,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
SizedBox(
|
||||
width: double.infinity,
|
||||
child: OutlinedButton.icon(
|
||||
icon: const Icon(Icons.add_rounded, size: 16),
|
||||
label: const Text('Configurer'),
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: ETMTheme.accentColor,
|
||||
side: BorderSide(
|
||||
color: ETMTheme.accentColor.withValues(alpha: 0.5)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
),
|
||||
onPressed: onConfigure,
|
||||
),
|
||||
),
|
||||
] else ...[
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
isReachable
|
||||
? Icons.check_circle_rounded
|
||||
: Icons.error_rounded,
|
||||
size: 14,
|
||||
color: isReachable
|
||||
? AppTheme.primaryGreen
|
||||
: AppTheme.boostRed,
|
||||
),
|
||||
const SizedBox(width: 6),
|
||||
Expanded(
|
||||
child: Text(
|
||||
assignment!.thing.name,
|
||||
style: const TextStyle(fontSize: 13),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
if (assignment!.params.containsKey('powerW')) ...[
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'${assignment!.params['powerW']} W · Priorité: ${assignment!.params['priority'] ?? 'Normal'}',
|
||||
style: const TextStyle(
|
||||
fontSize: 12,
|
||||
color: AppTheme.textLight,
|
||||
),
|
||||
),
|
||||
],
|
||||
const SizedBox(height: 10),
|
||||
Row(
|
||||
children: [
|
||||
if (onEdit != null)
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: ETMTheme.accentColor,
|
||||
side: BorderSide(
|
||||
color:
|
||||
ETMTheme.accentColor.withValues(alpha: 0.4)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
),
|
||||
onPressed: onEdit,
|
||||
child: const Text('Modifier',
|
||||
style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
),
|
||||
if (onEdit != null && onTest != null)
|
||||
const SizedBox(width: 8),
|
||||
if (onTest != null)
|
||||
Expanded(
|
||||
child: OutlinedButton(
|
||||
style: OutlinedButton.styleFrom(
|
||||
foregroundColor: AppTheme.primaryGreen,
|
||||
side: BorderSide(
|
||||
color: AppTheme.primaryGreen
|
||||
.withValues(alpha: 0.4)),
|
||||
shape: RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.circular(10)),
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
),
|
||||
onPressed: onTest,
|
||||
child: const Text('Tester',
|
||||
style: TextStyle(fontSize: 13)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
71
test/cache_invalidation_test.dart
Normal file
71
test/cache_invalidation_test.dart
Normal file
@ -0,0 +1,71 @@
|
||||
import 'package:etm_powersync_app/providers/energy_setup_provider.dart';
|
||||
import 'package:etm_powersync_app/providers/scheduler_provider.dart';
|
||||
import 'package:etm_powersync_app/providers/tariff_provider.dart';
|
||||
import 'package:etm_powersync_app/services/connection_manager.dart';
|
||||
import 'package:etm_powersync_app/services/installation_store.dart';
|
||||
import 'package:etm_powersync_app/services/nymea_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
/// Régression « zéro fuite au switch » (Étape 8). Vérifie en machine ce que le
|
||||
/// click-through Android valide à l'œil : aucune donnée d'une box ne survit à
|
||||
/// une bascule.
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
test('NymeaService.resetState vide les caches par-box (things/history/energy)',
|
||||
() {
|
||||
final s = NymeaService();
|
||||
s.startSimulation(); // peuple things + history + energyData
|
||||
expect(s.things, isNotEmpty, reason: 'la démo doit peupler des things');
|
||||
|
||||
s.resetState();
|
||||
|
||||
expect(s.things, isEmpty);
|
||||
expect(s.thingClasses, isEmpty);
|
||||
expect(s.historyPoints, isEmpty);
|
||||
expect(s.thingsLoaded, isFalse);
|
||||
|
||||
s.disconnect(); // stoppe le timer de simulation
|
||||
});
|
||||
|
||||
test('EnergySetupProvider.clearForSwitch réinitialise le tri-état', () {
|
||||
final p = EnergySetupProvider();
|
||||
p.assignThing(EmsRole.evCharger, 'thing-ev-A');
|
||||
p.assignThing(EmsRole.dhw, 'thing-ecs-A');
|
||||
expect(p.isPresent(EmsRole.evCharger), isTrue);
|
||||
expect(p.controllableRoles, isNotEmpty);
|
||||
|
||||
p.clearForSwitch();
|
||||
|
||||
expect(p.isUnconfigured(EmsRole.evCharger), isTrue);
|
||||
expect(p.isUnconfigured(EmsRole.dhw), isTrue);
|
||||
expect(p.controllableRoles, isEmpty);
|
||||
expect(p.priorityOf(EmsRole.evCharger), isNull);
|
||||
});
|
||||
|
||||
test('Scheduler & Tariff clearForSwitch reviennent aux défauts', () {
|
||||
final sc = SchedulerProvider()..clearForSwitch();
|
||||
expect(sc.loading, isFalse);
|
||||
expect(sc.strategy, SchedulerStrategy.rulesBased);
|
||||
|
||||
final t = TariffProvider()..clearForSwitch();
|
||||
expect(t.loading, isFalse);
|
||||
expect(t.mode, TariffMode.fixed);
|
||||
expect(t.manualPrice, 0.25);
|
||||
});
|
||||
|
||||
test('ConnectionManager invoque tous les clearers enregistrés au switch', () {
|
||||
final s = NymeaService();
|
||||
final m = ConnectionManager(s, InstallationStore());
|
||||
var cleared = 0;
|
||||
m.registerCacheClear(() => cleared++);
|
||||
m.registerCacheClear(() => cleared++);
|
||||
|
||||
m.enterDemo(); // _clearCaches() → resetState + clearers, puis startSimulation
|
||||
|
||||
expect(cleared, 2, reason: 'chaque provider enregistré doit être vidé');
|
||||
s.disconnect(); // stoppe le timer de simulation
|
||||
});
|
||||
}
|
||||
@ -1,16 +1,33 @@
|
||||
import 'package:etm_powersync_app/main.dart';
|
||||
import 'package:etm_powersync_app/providers/app_settings_provider.dart';
|
||||
import 'package:etm_powersync_app/providers/installer_mode_provider.dart';
|
||||
import 'package:etm_powersync_app/services/connection_manager.dart';
|
||||
import 'package:etm_powersync_app/services/installation_store.dart';
|
||||
import 'package:etm_powersync_app/services/nymea_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
import 'package:etm_powersync_app/main.dart';
|
||||
import 'package:etm_powersync_app/services/nymea_service.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets('App smoke test', (WidgetTester tester) async {
|
||||
testWidgets('App smoke test — gate vers Installations sans connexion active',
|
||||
(WidgetTester tester) async {
|
||||
final service = NymeaService();
|
||||
final manager = ConnectionManager(service, InstallationStore());
|
||||
|
||||
await tester.pumpWidget(
|
||||
ChangeNotifierProvider(
|
||||
create: (_) => NymeaService(),
|
||||
child: const NymeaEnergyApp(),
|
||||
MultiProvider(
|
||||
providers: [
|
||||
ChangeNotifierProvider.value(value: service),
|
||||
ChangeNotifierProvider.value(value: manager),
|
||||
ChangeNotifierProvider(create: (_) => AppSettingsProvider()),
|
||||
ChangeNotifierProvider(create: (_) => InstallerModeProvider()),
|
||||
],
|
||||
child: NymeaEnergyApp(router: buildAppRouter(manager, service)),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
|
||||
// Sans session active, le gate doit router vers Installations.
|
||||
expect(find.byType(NymeaEnergyApp), findsOneWidget);
|
||||
expect(find.text('Installations'), findsWidgets);
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user