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:
Patrick Schurig 2026-06-28 08:39:08 +02:00
parent 706c390f1f
commit c638ec6c52
28 changed files with 4488 additions and 2094 deletions

View 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(),
);
}

View 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;
}

View 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;
}

View 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;
}

View 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(),
);
}
}

View 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,
};
}

View 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,
),
],
),
),
],
),
);
}
}

View File

@ -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,
),
),

View 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,
);
}

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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

View File

@ -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),

View File

@ -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),
);
}
}

View File

@ -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 →'),
),
],
),
);
}
}

View 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 '';
}
}

View 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.'),
],
),
),
);
}
}

View 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))),
),
],
);
}

View 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)}…';
}

View 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));
}
}

View File

@ -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
// ═══════════════════════════════════════════════════════════════════════════

View 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;
}

View File

@ -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,
);
}

View File

@ -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;
}

View File

@ -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)),
),
),
],
),
],
],
),
),
);
}
}

View 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
});
}

View File

@ -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);
});
}