diff --git a/lib/features/dashboard/models/dashboard_models.dart b/lib/features/dashboard/models/dashboard_models.dart new file mode 100644 index 0000000..408dddc --- /dev/null +++ b/lib/features/dashboard/models/dashboard_models.dart @@ -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 hours; + final List 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 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(), + ); +} diff --git a/lib/features/dashboard/painters/day_plan_painter.dart b/lib/features/dashboard/painters/day_plan_painter.dart new file mode 100644 index 0000000..95ad712 --- /dev/null +++ b/lib/features/dashboard/painters/day_plan_painter.dart @@ -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 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; +} diff --git a/lib/features/dashboard/painters/flow_painter.dart b/lib/features/dashboard/painters/flow_painter.dart new file mode 100644 index 0000000..0f5c649 --- /dev/null +++ b/lib/features/dashboard/painters/flow_painter.dart @@ -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 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; +} diff --git a/lib/features/dashboard/painters/ring_painter.dart b/lib/features/dashboard/painters/ring_painter.dart new file mode 100644 index 0000000..12964a3 --- /dev/null +++ b/lib/features/dashboard/painters/ring_painter.dart @@ -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; +} diff --git a/lib/features/dashboard/widgets/day_plan_card.dart b/lib/features/dashboard/widgets/day_plan_card.dart new file mode 100644 index 0000000..f02d04b --- /dev/null +++ b/lib/features/dashboard/widgets/day_plan_card.dart @@ -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(), + ); + } +} diff --git a/lib/features/dashboard/widgets/heos_decision_card.dart b/lib/features/dashboard/widgets/heos_decision_card.dart new file mode 100644 index 0000000..e4fecd0 --- /dev/null +++ b/lib/features/dashboard/widgets/heos_decision_card.dart @@ -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 createState() => _HeosDecisionCardState(); +} + +class _HeosDecisionCardState extends State + with SingleTickerProviderStateMixin { + late final AnimationController _pulseCtrl; + late final Animation _pulseAnim; + final _decision = HeosDecision.demo(); + + @override + void initState() { + super.initState(); + _pulseCtrl = AnimationController( + vsync: this, + duration: const Duration(seconds: 2), + )..repeat(reverse: true); + _pulseAnim = Tween(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, + }; +} diff --git a/lib/features/dashboard/widgets/kpi_row.dart b/lib/features/dashboard/widgets/kpi_row.dart new file mode 100644 index 0000000..e776684 --- /dev/null +++ b/lib/features/dashboard/widgets/kpi_row.dart @@ -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 _ring; + late Animation _count; + + @override + void initState() { + super.initState(); + _ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 1100)); + _ring = Tween(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, + ), + ], + ), + ), + ], + ), + ); + } +} diff --git a/lib/main.dart b/lib/main.dart index cc051bb..97332eb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -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(); 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, ), ), diff --git a/lib/models/installation.dart b/lib/models/installation.dart new file mode 100644 index 0000000..0961269 --- /dev/null +++ b/lib/models/installation.dart @@ -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 toJson() => { + 'uuid': uuid, + 'name': name, + 'host': host, + 'port': port, + if (lastSeen != null) 'lastSeen': lastSeen!.toIso8601String(), + }; + + factory Installation.fromJson(Map 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, + ); +} diff --git a/lib/providers/energy_setup_provider.dart b/lib/providers/energy_setup_provider.dart index 8489bb8..68f5e94 100644 --- a/lib/providers/energy_setup_provider.dart +++ b/lib/providers/energy_setup_provider.dart @@ -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 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 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 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? 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 _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 _canonicalLoadOrder = [ + EmsRole.evCharger, + EmsRole.dhw, + EmsRole.heatPump, + EmsRole.battery, + ]; + + final Map _states = { + for (final r in EmsRole.values) r: const RoleUnconfigured(), }; - ConnectionTestResult _testResult = - const ConnectionTestResult(ConnectionTestStatus.idle); + final Map _loadConfigs = {}; + final Map _enabled = {}; - Map get assignments => _assignments; - ConnectionTestResult get testResult => _testResult; + /// Charges pilotables présentes, dans l'ordre de priorité (index 0 = rang 1). + final List _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 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 get meterRoles => + EmsRole.values.where((r) => r.isMeter && isPresent(r)).toList(); + + /// Charges pilotables présentes, **dans l'ordre de priorité**. + List 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 get toConfigureRoles => + EmsRole.values.where((r) => r.isPicked && isUnconfigured(r)).toList(); + + /// Rôles actés « Pas d'appareil ». + List 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? buildLoadDescriptor(EmsRole role) { + if (!isVariableLoad(role)) return null; + final thingId = thingIdOf(role); + if (thingId == null) return null; + + final d = { + '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> buildLoadDescriptors() => controllableRoles + .map(buildLoadDescriptor) + .whereType>() + .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 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 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 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); } } diff --git a/lib/providers/scheduler_provider.dart b/lib/providers/scheduler_provider.dart index d170823..37844bf 100644 --- a/lib/providers/scheduler_provider.dart +++ b/lib/providers/scheduler_provider.dart @@ -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 load(NymeaService service) async { _loading = true; diff --git a/lib/providers/tariff_provider.dart b/lib/providers/tariff_provider.dart index 67038a0..e80b3ef 100644 --- a/lib/providers/tariff_provider.dart +++ b/lib/providers/tariff_provider.dart @@ -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; diff --git a/lib/screens/dashboard_screen.dart b/lib/screens/dashboard_screen.dart index 04ec9ec..ebb2417 100644 --- a/lib/screens/dashboard_screen.dart +++ b/lib/screens/dashboard_screen.dart @@ -1,11 +1,13 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../main.dart' show DrawerMenuButton; -import '../models/energy_data.dart'; +import '../providers/app_settings_provider.dart'; import '../services/nymea_service.dart'; import '../theme/etm_tokens.dart'; -import '../widgets/energy_flow_widget.dart'; -import '../widgets/ev_charging_card.dart'; +import '../features/dashboard/widgets/energy_flow_card.dart'; +import '../features/dashboard/widgets/kpi_row.dart'; +import '../features/dashboard/widgets/heos_decision_card.dart'; +import '../features/dashboard/widgets/day_plan_card.dart'; class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); @@ -15,14 +17,9 @@ class DashboardScreen extends StatefulWidget { } class _DashboardScreenState extends State { - @override - void initState() { - super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) { - final service = context.read(); - if (!service.connected) service.startSimulation(); - }); - } + // Plus d'auto-simulation ici : le gate de routage (Étape 6) garantit qu'on + // n'atteint le dashboard qu'avec une session active (box réelle ou démo). + // La démo est un choix explicite depuis l'écran Installations. @override Widget build(BuildContext context) { @@ -30,31 +27,26 @@ class _DashboardScreenState extends State { builder: (context, service, _) { final data = service.energyData; return Scaffold( - backgroundColor: EtmTokens.bg, + backgroundColor: EtmTokens.bgOf(context), body: RefreshIndicator( onRefresh: () async => service.startSimulation(), - color: EtmTokens.green, + color: EtmTokens.ecoOf(context), child: CustomScrollView( slivers: [ - _DashAppBar(service: service), + _DashTopBar(service: service), SliverPadding( - padding: const EdgeInsets.fromLTRB(18, 4, 18, 32), + padding: const EdgeInsets.fromLTRB(14, 0, 14, 32), sliver: SliverList( delegate: SliverChildListDelegate([ + const SizedBox(height: 14), + EnergyFlowCard(data: data), + const SizedBox(height: 14), + KpiRow(data: data), + const SizedBox(height: 14), + const HeosDecisionCard(), + const SizedBox(height: 14), + const DayPlanCard(), const SizedBox(height: 8), - _SystemHeroCard(data: data), - const SizedBox(height: 16), - EnergyFlowWidget(data: data), - const SizedBox(height: 16), - EVChargingCard(data: data, service: service), - const SizedBox(height: 16), - _KpiGrid(data: data), - const SizedBox(height: 16), - _ConsumersCard(data: data), - const SizedBox(height: 16), - _HeosDecisionsCard(), - const SizedBox(height: 16), - _ForecastCard(), ]), ), ), @@ -67,50 +59,46 @@ class _DashboardScreenState extends State { } } -// ─────────────────────────────── AppBar ──────────────────────────────────────── +// ── TopBar ──────────────────────────────────────────────────────────────────── -class _DashAppBar extends StatelessWidget { +class _DashTopBar extends StatelessWidget { final NymeaService service; - const _DashAppBar({required this.service}); + const _DashTopBar({required this.service}); @override Widget build(BuildContext context) { return SliverAppBar( floating: true, - backgroundColor: EtmTokens.bg, + snap: true, + backgroundColor: EtmTokens.bgOf(context), + surfaceTintColor: Colors.transparent, elevation: 0, leading: const DrawerMenuButton(), leadingWidth: 64, - title: Text('ETM PowerSync', - style: EtmTokens.sans(size: 20, weight: FontWeight.w600)), + title: Text( + 'ETM PowerSync', + style: EtmTokens.sans( + size: 18, + weight: FontWeight.w700, + color: EtmTokens.inkOf(context), + ), + ), actions: [ + const _ThemeToggle(), + const SizedBox(width: 4), IconButton( icon: Icon( - service.connected ? Icons.wifi_rounded : Icons.wifi_off_rounded, - color: service.connected ? EtmTokens.green : EtmTokens.danger, + service.connected + ? Icons.wifi_rounded + : Icons.wifi_off_rounded, + color: service.connected + ? EtmTokens.ecoOf(context) + : EtmTokens.danger, + size: 20, ), onPressed: () => _showConnectionDialog(context, service), ), - Padding( - padding: const EdgeInsets.only(right: 16), - child: Stack( - clipBehavior: Clip.none, - children: [ - const Icon(Icons.notifications_outlined, color: EtmTokens.navy, size: 24), - Positioned( - right: -1, top: -1, - child: Container( - width: 8, height: 8, - decoration: BoxDecoration( - color: EtmTokens.green, - shape: BoxShape.circle, - border: Border.all(color: EtmTokens.bg, width: 1.5), - ), - ), - ), - ], - ), - ), + const SizedBox(width: 4), ], ); } @@ -120,19 +108,21 @@ class _DashAppBar extends StatelessWidget { context: context, builder: (_) => AlertDialog( title: Text('Connexion nymea', - style: EtmTokens.sans(size: 18, weight: FontWeight.w600)), + style: EtmTokens.sans(size: 18, weight: FontWeight.w600, + color: EtmTokens.inkOf(context))), content: Text( service.connected ? 'Connecté à ${service.host}' : service.isSimulation ? 'Mode simulation actif' : 'Non connecté', - style: EtmTokens.sans(size: 14, color: EtmTokens.muted), + style: EtmTokens.sans(size: 14, color: EtmTokens.mutedOf(context)), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), - child: Text('OK', style: EtmTokens.sans(color: EtmTokens.green)), + child: Text('OK', + style: EtmTokens.sans(color: EtmTokens.ecoOf(context))), ), ], ), @@ -140,801 +130,66 @@ class _DashAppBar extends StatelessWidget { } } -// ─────────────────────────── Hero système ────────────────────────────────────── +// ── Theme toggle ────────────────────────────────────────────────────────────── -class _SystemHeroCard extends StatelessWidget { - final EnergyData data; - const _SystemHeroCard({required this.data}); - - String get _statusLabel { - if (data.gridPower.abs() < 50) return 'Optimal'; - return data.gridPower > 0 ? 'Soutirage réseau' : 'Injection réseau'; - } - - Color get _statusColor { - if (data.gridPower.abs() < 50) return EtmTokens.green; - return data.gridPower > 0 ? EtmTokens.orange : EtmTokens.blue; - } +class _ThemeToggle extends StatelessWidget { + const _ThemeToggle(); @override Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: EtmTokens.card, - borderRadius: BorderRadius.circular(EtmTokens.radiusLg), - boxShadow: EtmTokens.cardShadow, + final settings = context.watch(); + final isDark = settings.themeMode == ThemeMode.dark; + + return GestureDetector( + onTap: () => settings.setThemeMode( + isDark ? ThemeMode.light : ThemeMode.dark, ), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - // Left — status + metrics - Expanded( - child: Padding( - padding: const EdgeInsets.fromLTRB(22, 22, 12, 22), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - children: [ - _StatusPill(label: _statusLabel, color: _statusColor), - const SizedBox(width: 10), - Text('État du système', - style: EtmTokens.sans( - size: 11, - color: EtmTokens.faint, - letterSpacing: 0.12)), - ], - ), - const SizedBox(height: 12), - Text.rich( - TextSpan( - text: 'Maison ', - style: EtmTokens.sans(size: 17, weight: FontWeight.w500), - children: [ - TextSpan( - text: '${data.selfConsumptionRate.toStringAsFixed(0)}%', - style: EtmTokens.sans(size: 17, weight: FontWeight.w700), - ), - const TextSpan(text: ' autonome'), - ], - ), - ), - const SizedBox(height: 4), - Text( - '${data.gridPower.abs().toStringAsFixed(0)} W soutiré du réseau', - style: EtmTokens.sans(size: 13, color: EtmTokens.muted), - ), - const SizedBox(height: 16), - Row( - children: [ - _Metric(label: 'Autocons.', value: '${data.selfConsumptionRate.toStringAsFixed(0)}%'), - const SizedBox(width: 20), - _Metric(label: 'Soutirage', value: '${data.gridPower.abs().toStringAsFixed(0)} W'), - const SizedBox(width: 20), - _Metric(label: 'Batterie', value: '${data.batterySOC.toStringAsFixed(0)}%'), - ], - ), - ], - ), - ), + child: Container( + width: 50, + height: 28, + margin: const EdgeInsets.symmetric(vertical: 8), + decoration: BoxDecoration( + color: isDark + ? EtmTokens.brand.withValues(alpha: 0.2) + : EtmTokens.bg2Of(context), + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: EtmTokens.lineOf(context), + width: 1, ), - // Right — illustration maison - Container( - width: 120, - decoration: const BoxDecoration( - border: Border(left: BorderSide(color: EtmTokens.line)), - borderRadius: BorderRadius.only( - topRight: Radius.circular(EtmTokens.radiusLg), - bottomRight: Radius.circular(EtmTokens.radiusLg), - ), - ), - child: const _HouseIllustration(), - ), - ], - ), - ); - } -} - -class _StatusPill extends StatelessWidget { - final String label; - final Color color; - const _StatusPill({required this.label, required this.color}); - - @override - Widget build(BuildContext context) { - return Container( - padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5), - decoration: BoxDecoration( - color: color.withValues(alpha: 0.12), - borderRadius: BorderRadius.circular(99), - ), - child: Row( - mainAxisSize: MainAxisSize.min, - children: [ - Container(width: 6, height: 6, - decoration: BoxDecoration(color: color, shape: BoxShape.circle)), - const SizedBox(width: 5), - Text(label, - style: EtmTokens.sans(size: 12, weight: FontWeight.w600, color: color)), - ], - ), - ); - } -} - -class _Metric extends StatelessWidget { - final String label; - final String value; - const _Metric({required this.label, required this.value}); - - @override - Widget build(BuildContext context) { - return Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(label, style: EtmTokens.sans(size: 11, color: EtmTokens.muted)), - Text(value, style: EtmTokens.mono(size: 18, weight: FontWeight.w700)), - ], - ); - } -} - -// Illustration maison simplifiée en CustomPainter -class _HouseIllustration extends StatelessWidget { - const _HouseIllustration(); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.all(10), - child: AspectRatio( - aspectRatio: 270 / 200, - child: CustomPaint(painter: _HousePainter()), - ), - ); - } -} - -class _HousePainter extends CustomPainter { - @override - void paint(Canvas canvas, Size size) { - final sx = size.width / 270; - final sy = size.height / 200; - Offset p(double x, double y) => Offset(x * sx, y * sy); - - final outlinePaint = Paint() - ..color = const Color(0xFF0D2B3B).withValues(alpha: 0.45) - ..strokeWidth = 1.0 - ..style = PaintingStyle.stroke; - - // Ground - canvas.drawOval(Rect.fromCenter(center: p(138, 176), width: 236 * sx, height: 24 * sy), - Paint()..color = const Color(0xFFE9EEF1)); - - // Front wall - canvas.drawRect(Rect.fromLTWH(p(66, 96).dx, p(66, 96).dy, 104 * sx, 72 * sy), - Paint()..color = const Color(0xFFF6F9FB)); - canvas.drawRect(Rect.fromLTWH(p(66, 96).dx, p(66, 96).dy, 104 * sx, 72 * sy), outlinePaint); - - // Side wall - final side = Path() - ..moveTo(p(170, 96).dx, p(170, 96).dy) - ..lineTo(p(200, 84).dx, p(200, 84).dy) - ..lineTo(p(200, 158).dx, p(200, 158).dy) - ..lineTo(p(170, 168).dx, p(170, 168).dy)..close(); - canvas.drawPath(side, Paint()..color = const Color(0xFFDDE6EC)); - canvas.drawPath(side, outlinePaint); - - // Roof front slope - final roofF = Path() - ..moveTo(p(58, 98).dx, p(58, 98).dy) - ..lineTo(p(118, 56).dx, p(118, 56).dy) - ..lineTo(p(170, 56).dx, p(170, 56).dy) - ..lineTo(p(122, 98).dx, p(122, 98).dy)..close(); - canvas.drawPath(roofF, Paint()..color = const Color(0xFF3A4A55)); - canvas.drawPath(roofF, outlinePaint); - - // PV panels - final pv = Path() - ..moveTo(p(70, 92).dx, p(70, 92).dy) - ..lineTo(p(120, 60).dx, p(120, 60).dy) - ..lineTo(p(162, 60).dx, p(162, 60).dy) - ..lineTo(p(116, 92).dx, p(116, 92).dy)..close(); - canvas.drawPath(pv, Paint()..color = const Color(0xFF173A52)); - // Amber sheen - canvas.drawPath(pv, - Paint()..color = const Color(0xFFFEC113).withValues(alpha: 0.10)..style = PaintingStyle.fill); - - // Wallbox (blue) - canvas.drawRRect( - RRect.fromRectAndRadius( - Rect.fromLTWH(p(70, 120).dx, p(70, 120).dy, 13 * sx, 22 * sy), - const Radius.circular(3)), - Paint()..color = const Color(0xFF31A3DD), - ); - canvas.drawRRect( - RRect.fromRectAndRadius( - Rect.fromLTWH(p(70, 120).dx, p(70, 120).dy, 13 * sx, 22 * sy), - const Radius.circular(3)), - outlinePaint, - ); - - // Door - canvas.drawRRect( - RRect.fromRectAndRadius( - Rect.fromLTWH(p(86, 128).dx, p(86, 128).dy, 22 * sx, 40 * sy), - const Radius.circular(2)), - Paint()..color = const Color(0xFFC98A4A), - ); - - // Window - canvas.drawRRect( - RRect.fromRectAndRadius( - Rect.fromLTWH(p(124, 112).dx, p(124, 112).dy, 32 * sx, 22 * sy), - const Radius.circular(2)), - Paint()..color = const Color(0xFFBFE2F3), - ); - canvas.drawRRect( - RRect.fromRectAndRadius( - Rect.fromLTWH(p(124, 112).dx, p(124, 112).dy, 32 * sx, 22 * sy), - const Radius.circular(2)), - outlinePaint, - ); - - // Labels - void drawLabel(String text, Offset pos) { - final tp = TextPainter( - text: TextSpan( - text: text, - style: const TextStyle( - fontSize: 7, - fontWeight: FontWeight.w600, - color: Color(0xFF6B7D88), - letterSpacing: 0.5), ), - textDirection: TextDirection.ltr, - )..layout(); - tp.paint(canvas, pos); - } - - drawLabel('PV', p(150, 32)); - drawLabel('BORNE', p(34, 127)); - } - - @override - bool shouldRepaint(_HousePainter _) => false; -} - -// ─────────────────────────── KPI 2×2 ────────────────────────────────────────── - -class _KpiGrid extends StatelessWidget { - final EnergyData data; - const _KpiGrid({required this.data}); - - @override - Widget build(BuildContext context) { - final prodKwh = (data.dayProductionWh / 1000); - return Column( - children: [ - Row( + child: Stack( children: [ - Expanded(child: _KpiCard( - title: 'Production', - value: prodKwh.toStringAsFixed(1), - unit: ' kWh', - chart: _SparkBars(values: const [18, 12, 15, 22, 40, 55, 70, 85, 100, 78, 60, 42]), - )), - const SizedBox(width: 14), - Expanded(child: _KpiCard( - title: 'Autonomie', - value: data.autonomyRate.toStringAsFixed(0), - unit: '%', - color: EtmTokens.blue, - chart: _ProgressBar(value: data.autonomyRate / 100, color: EtmTokens.blue), - note: 'Objectif : 80%', - )), - ], - ), - const SizedBox(height: 14), - Row( - children: [ - Expanded(child: _KpiCard( - title: 'Économie', - value: data.dayGains.toStringAsFixed(2), - unit: ' €', - color: EtmTokens.green, - chart: _TrendLine(), - note: 'vs maison non pilotée', - )), - const SizedBox(width: 14), - Expanded(child: _KpiCard( - title: 'Soutirage', - value: (data.dayGridInjectionWh.abs() / 1000).toStringAsFixed(1), - unit: ' kWh', - chart: _SparkBars( - values: const [30, 55, 40, 20, 8, 5, 6, 10], - color: EtmTokens.faint), - note: '0.42 €', - )), - ], - ), - ], - ); - } -} - -class _KpiCard extends StatelessWidget { - final String title; - final String value; - final String unit; - final Color color; - final Widget chart; - final String? note; - - const _KpiCard({ - required this.title, - required this.value, - required this.unit, - this.color = EtmTokens.navy, - required this.chart, - this.note, - }); - - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: EtmTokens.card, - borderRadius: BorderRadius.circular(EtmTokens.radius), - boxShadow: EtmTokens.cardShadow, - ), - padding: const EdgeInsets.all(16), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: EtmTokens.sans(size: 13, color: EtmTokens.muted, weight: FontWeight.w500)), - const SizedBox(height: 6), - Text.rich( - TextSpan( - text: value, - style: EtmTokens.mono(size: 26, weight: FontWeight.w700, color: color), - children: [ - TextSpan( - text: unit, - style: EtmTokens.sans(size: 13, color: EtmTokens.muted, weight: FontWeight.w600), - ), - ], - ), - ), - const SizedBox(height: 8), - chart, - if (note != null) ...[ - const SizedBox(height: 6), - Text(note!, style: EtmTokens.sans(size: 11, color: EtmTokens.muted)), - ], - ], - ), - ); - } -} - -class _SparkBars extends StatelessWidget { - final List values; - final Color color; - const _SparkBars({required this.values, this.color = EtmTokens.amber}); - - @override - Widget build(BuildContext context) { - final max = values.reduce((a, b) => a > b ? a : b); - return SizedBox( - height: 34, - child: Row( - crossAxisAlignment: CrossAxisAlignment.end, - children: values.map((v) => Expanded( - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 1), - child: FractionallySizedBox( - heightFactor: v / max, - alignment: Alignment.bottomCenter, - child: DecoratedBox( + AnimatedPositioned( + duration: const Duration(milliseconds: 280), + curve: Curves.easeOutBack, + left: isDark ? 24 : 2, + top: 2, + child: Container( + width: 22, + height: 22, decoration: BoxDecoration( - color: color.withValues(alpha: 0.85), - borderRadius: BorderRadius.circular(2), + color: isDark ? EtmTokens.brand : Colors.white, + shape: BoxShape.circle, + boxShadow: [ + BoxShadow( + color: Colors.black.withValues(alpha: 0.12), + blurRadius: 4, + ), + ], + ), + child: Icon( + isDark + ? Icons.dark_mode_rounded + : Icons.light_mode_rounded, + size: 13, + color: isDark ? Colors.white : EtmTokens.amber, ), ), ), - ), - )).toList(), - ), - ); - } -} - -class _ProgressBar extends StatelessWidget { - final double value; - final Color color; - const _ProgressBar({required this.value, required this.color}); - - @override - Widget build(BuildContext context) { - return ClipRRect( - borderRadius: BorderRadius.circular(99), - child: SizedBox( - height: 7, - child: LinearProgressIndicator( - value: value, - backgroundColor: EtmTokens.line, - valueColor: AlwaysStoppedAnimation(color), + ], ), ), ); } } - -class _TrendLine extends StatelessWidget { - const _TrendLine(); - - @override - Widget build(BuildContext context) { - return SizedBox( - height: 38, - child: CustomPaint(painter: _TrendPainter()), - ); - } -} - -class _TrendPainter extends CustomPainter { - @override - void paint(Canvas canvas, Size size) { - final points = [34.0, 30.0, 31.0, 24.0, 22.0, 14.0, 11.0, 3.0]; - final w = size.width; - final h = size.height; - final dx = w / (points.length - 1); - final max = points.reduce((a, b) => a > b ? a : b); - - final pts = List.generate(points.length, - (i) => Offset(i * dx, h - (points[i] / max) * (h - 4))); - - // Fill - final fill = Path()..moveTo(pts.first.dx, h); - for (final p in pts) { - fill.lineTo(p.dx, p.dy); - } - fill.lineTo(pts.last.dx, h); - fill.close(); - canvas.drawPath( - fill, - Paint() - ..shader = LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - EtmTokens.green.withValues(alpha: 0.25), - EtmTokens.green.withValues(alpha: 0), - ], - ).createShader(Rect.fromLTWH(0, 0, w, h))); - - // Line - final linePaint = Paint() - ..color = EtmTokens.green - ..strokeWidth = 2.2 - ..style = PaintingStyle.stroke - ..strokeCap = StrokeCap.round; - final path = Path()..moveTo(pts.first.dx, pts.first.dy); - for (final p in pts.skip(1)) { - path.lineTo(p.dx, p.dy); - } - canvas.drawPath(path, linePaint); - } - - @override - bool shouldRepaint(_TrendPainter _) => false; -} - -// ─────────────────────────── Consommateurs ───────────────────────────────────── - -class _ConsumersCard extends StatelessWidget { - final EnergyData data; - const _ConsumersCard({required this.data}); - - @override - Widget build(BuildContext context) { - final total = data.homePower.clamp(1.0, double.infinity); - final consumers = [ - _Consumer('PAC', Icons.heat_pump_outlined, EtmTokens.blue, - EtmTokens.blueSoft, total * 0.40), - _Consumer('EVSE', Icons.ev_station_rounded, EtmTokens.green, - EtmTokens.greenSoft, data.chargingPower * 1000), - _Consumer('Chauffe-eau', Icons.water_drop_outlined, EtmTokens.amber, - EtmTokens.amberSoft, total * 0.18), - ]; - - return _Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _CardHeader('Consommateurs principaux', link: 'Voir tout'), - const SizedBox(height: 4), - ...consumers.map((c) => _ConsumerRow(c: c, total: total)), - ], - ), - ); - } -} - -class _Consumer { - final String name; - final IconData icon; - final Color color; - final Color bgColor; - final double watts; - const _Consumer(this.name, this.icon, this.color, this.bgColor, this.watts); -} - -class _ConsumerRow extends StatelessWidget { - final _Consumer c; - final double total; - const _ConsumerRow({required this.c, required this.total}); - - @override - Widget build(BuildContext context) { - final pct = (c.watts / total).clamp(0.0, 1.0); - return Padding( - padding: const EdgeInsets.symmetric(vertical: 10), - child: Row( - children: [ - Container( - width: 34, height: 34, - decoration: BoxDecoration(color: c.bgColor, borderRadius: BorderRadius.circular(10)), - child: Icon(c.icon, color: c.color, size: 18), - ), - const SizedBox(width: 12), - Expanded(child: Text(c.name, style: EtmTokens.sans(size: 14, weight: FontWeight.w500))), - Text('${c.watts.toStringAsFixed(0)} W', - style: EtmTokens.mono(size: 13, color: EtmTokens.muted)), - const SizedBox(width: 12), - SizedBox( - width: 70, - child: ClipRRect( - borderRadius: BorderRadius.circular(99), - child: SizedBox( - height: 6, - child: LinearProgressIndicator( - value: pct, - backgroundColor: EtmTokens.line, - valueColor: AlwaysStoppedAnimation(c.color), - ), - ), - ), - ), - const SizedBox(width: 8), - SizedBox( - width: 36, - child: Text('${(pct * 100).toStringAsFixed(0)}%', - style: EtmTokens.mono(size: 13, weight: FontWeight.w700, color: c.color), - textAlign: TextAlign.right), - ), - ], - ), - ); - } -} - -// ─────────────────────────── Décisions Héos ──────────────────────────────────── - -class _HeosDecisionsCard extends StatelessWidget { - const _HeosDecisionsCard(); - - @override - Widget build(BuildContext context) { - return _Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - _CardHeader('Décisions d\'Héos', link: 'Voir tout'), - const SizedBox(height: 4), - _DecisionRow( - icon: Icons.ev_station_rounded, - bgColor: EtmTokens.greenSoft, - iconColor: EtmTokens.green, - title: 'Charge voiture décalée', - time: '02h – 06h', - tags: [_Tag.tarif, _Tag.pv], - ), - _DecisionRow( - icon: Icons.heat_pump_outlined, - bgColor: EtmTokens.blueSoft, - iconColor: EtmTokens.blue, - title: 'PAC optimisée', - time: '06h30 – 08h30', - tags: [_Tag.heuresCreuses, _Tag.confort], - ), - _DecisionRow( - icon: Icons.battery_charging_full_rounded, - bgColor: EtmTokens.greenSoft, - iconColor: EtmTokens.green, - title: 'Charge batterie planifiée', - time: '12h – 15h', - tags: [_Tag.pv, _Tag.pic], - ), - Padding( - padding: const EdgeInsets.only(top: 12), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('Impact consolidé aujourd\'hui', - style: EtmTokens.sans(size: 12, color: EtmTokens.muted)), - Text.rich(TextSpan( - text: '4,60 € ', - style: EtmTokens.mono(size: 13, weight: FontWeight.w700, color: EtmTokens.greenDark), - children: [ - TextSpan(text: 'économisés', - style: EtmTokens.sans(size: 12, color: EtmTokens.muted)), - ], - )), - ], - ), - ), - ], - ), - ); - } -} - -enum _Tag { tarif, pv, heuresCreuses, confort, pic } - -class _DecisionRow extends StatelessWidget { - final IconData icon; - final Color bgColor; - final Color iconColor; - final String title; - final String time; - final List<_Tag> tags; - - const _DecisionRow({ - required this.icon, - required this.bgColor, - required this.iconColor, - required this.title, - required this.time, - required this.tags, - }); - - @override - Widget build(BuildContext context) { - return Padding( - padding: const EdgeInsets.symmetric(vertical: 10), - child: Row( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Container( - width: 34, height: 34, - decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(10)), - child: Icon(icon, color: iconColor, size: 18), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text(title, style: EtmTokens.sans(size: 14, weight: FontWeight.w500)), - Text(time, style: EtmTokens.mono(size: 12, color: EtmTokens.muted)), - const SizedBox(height: 6), - Wrap( - spacing: 5, runSpacing: 5, - children: tags.map(_buildTag).toList(), - ), - ], - ), - ), - ], - ), - ); - } - - Widget _buildTag(_Tag tag) { - final (label, bg, fg) = switch (tag) { - _Tag.tarif => ('Tarif bas', EtmTokens.amberSoft, const Color(0xFF9A7510)), - _Tag.pv => ('Surplus PV', EtmTokens.greenSoft, EtmTokens.greenDark), - _Tag.heuresCreuses=> ('Heures creuses',EtmTokens.amberSoft, const Color(0xFF9A7510)), - _Tag.confort => ('Confort', const Color(0xFFEEF2F5), EtmTokens.muted), - _Tag.pic => ('Couvre le pic', EtmTokens.blueSoft, const Color(0xFF1F6F97)), - }; - return Container( - padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), - decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(8)), - child: Text(label, style: EtmTokens.sans(size: 11, weight: FontWeight.w500, color: fg)), - ); - } -} - -// ─────────────────────────── Prévisions ──────────────────────────────────────── - -class _ForecastCard extends StatelessWidget { - const _ForecastCard(); - - @override - Widget build(BuildContext context) { - return _Card( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text('Prévisions & recommandations', - style: EtmTokens.sans(size: 17, weight: FontWeight.w600)), - Text('aujourd\'hui', style: EtmTokens.sans(size: 13, color: EtmTokens.muted)), - ], - ), - const SizedBox(height: 16), - Container( - padding: const EdgeInsets.all(18), - decoration: BoxDecoration( - color: EtmTokens.bg, - borderRadius: BorderRadius.circular(14), - ), - child: Row( - children: [ - Icon(Icons.cloud_outlined, color: EtmTokens.faint, size: 32), - const SizedBox(width: 16), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text('Prévisions non disponibles', - style: EtmTokens.sans(size: 14, weight: FontWeight.w500, color: EtmTokens.muted)), - const SizedBox(height: 4), - Text('Configurez un provider dans Tarifs & Héos pour activer les prévisions PV et tarifaires.', - style: EtmTokens.sans(size: 12, color: EtmTokens.faint)), - ], - ), - ), - ], - ), - ), - ], - ), - ); - } -} - -// ─────────────────────────── Widgets partagés ────────────────────────────────── - -class _Card extends StatelessWidget { - final Widget child; - final EdgeInsets? padding; - const _Card({required this.child, this.padding}); - - @override - Widget build(BuildContext context) { - return Container( - decoration: BoxDecoration( - color: EtmTokens.card, - borderRadius: BorderRadius.circular(EtmTokens.radiusLg), - boxShadow: EtmTokens.cardShadow, - ), - padding: padding ?? const EdgeInsets.all(20), - child: child, - ); - } -} - -class _CardHeader extends StatelessWidget { - final String title; - final String? link; - const _CardHeader(this.title, {this.link}); - - @override - Widget build(BuildContext context) { - return Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text(title, style: EtmTokens.sans(size: 17, weight: FontWeight.w600)), - if (link != null) - Text(link!, style: EtmTokens.sans(size: 13, weight: FontWeight.w600, color: EtmTokens.blue)), - ], - ); - } -} diff --git a/lib/screens/drawer/main_drawer.dart b/lib/screens/drawer/main_drawer.dart index 7bd3972..bad2ea2 100644 --- a/lib/screens/drawer/main_drawer.dart +++ b/lib/screens/drawer/main_drawer.dart @@ -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(); final installer = context.watch(); + final manager = context.watch(); + + // 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().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), diff --git a/lib/screens/energy/energy_setup_screen.dart b/lib/screens/energy/energy_setup_screen.dart deleted file mode 100644 index 1eecf05..0000000 --- a/lib/screens/energy/energy_setup_screen.dart +++ /dev/null @@ -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(); - - 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(); - final service = context.read(); - - 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 _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), - ); - } -} diff --git a/lib/screens/energy/role_config_flow.dart b/lib/screens/energy/role_config_flow.dart deleted file mode 100644 index 73961e3..0000000 --- a/lib/screens/energy/role_config_flow.dart +++ /dev/null @@ -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 createState() => _RoleConfigFlowState(); -} - -class _RoleConfigFlowState extends State { - int _step = 0; - NymeaThing? _selectedThing; - String _searchQuery = ''; - final Map _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 _runTest() async { - final setup = context.read(); - final service = context.read(); - // Assigne temporairement pour le test - setup.assign(widget.role, _selectedThing!, _params); - await setup.testConnection(widget.role, service); - if (mounted) setState(() {}); - } - - void _save() { - context - .read() - .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().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 onSearch; - final ValueChanged 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(); - final service = context.read(); - 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 params; - final List 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 _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 _relayParams() => [ - _NumField('Puissance nominale (W)', 'powerW', params, onChange, - min: 100, max: 20000), - _PriorityDropdown(params, onChange), - ]; - - List _heatPumpParams(List things) => [ - _NumField('Puissance normale (W)', 'powerW', params, onChange, - min: 500, max: 20000), - _PriorityDropdown(params, onChange), - ]; - - List _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 _solarMeterParams() => [ - _NumField('Puissance crête onduleur (W)', 'powerW', params, onChange, - min: 500, max: 100000), - ]; - - List _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 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 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( - 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().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().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 →'), - ), - ], - ), - ); - } -} diff --git a/lib/screens/energy/roles_devices_screen.dart b/lib/screens/energy/roles_devices_screen.dart new file mode 100644 index 0000000..f172fb3 --- /dev/null +++ b/lib/screens/energy/roles_devices_screen.dart @@ -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 createState() => _RolesDevicesScreenState(); +} + +class _RolesDevicesScreenState extends State { + NymeaService? _svc; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + if (!mounted) return; + final svc = context.read(); + _svc = svc; + context.read().attach(svc); + svc.addListener(_sync); // re-inférer quand les things se chargent + _sync(); + }); + } + + void _sync() { + if (!mounted) return; + context.read().syncFromThings(context.read()); + } + + @override + void dispose() { + _svc?.removeListener(_sync); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + final setup = context.watch(); + final svc = context.watch(); + + 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 = [ + 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( + 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 ''; + } +} diff --git a/lib/screens/installations/connection_screen.dart b/lib/screens/installations/connection_screen.dart new file mode 100644 index 0000000..9bcac0a --- /dev/null +++ b/lib/screens/installations/connection_screen.dart @@ -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 createState() => _ConnectionScreenState(); +} + +class _ConnectionScreenState extends State { + 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(); + _branch = svc.initialSetupRequired ? _AuthBranch.create : _AuthBranch.login; + } + + @override + void dispose() { + _user.dispose(); + _email.dispose(); + _pass.dispose(); + _confirm.dispose(); + super.dispose(); + } + + Future _submit() async { + final manager = context.read(); + 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(); + final installer = context.watch().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.'), + ], + ), + ), + ); + } +} diff --git a/lib/screens/installations/installations_screen.dart b/lib/screens/installations/installations_screen.dart new file mode 100644 index 0000000..15234a2 --- /dev/null +++ b/lib/screens/installations/installations_screen.dart @@ -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(); + final service = context.watch(); + final installer = context.watch().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(); + if (installer) { + p.lock(); + } else { + showDialog( + 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 _connectTo(BuildContext context, Installation inst) async { + final manager = context.read(); + final service = context.read(); + final ok = await manager.switchTo(inst); + if (!context.mounted) return; + _afterConnect(context, ok, service); + } + + Future _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(); + final service = context.read(); + 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? 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))), + ), + ], + ); +} diff --git a/lib/services/connection_manager.dart b/lib/services/connection_manager.dart new file mode 100644 index 0000000..8db85f6 --- /dev/null +++ b/lib/services/connection_manager.dart @@ -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 _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 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 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 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 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 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 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 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 _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)}…'; +} diff --git a/lib/services/installation_store.dart b/lib/services/installation_store.dart new file mode 100644 index 0000000..e31ccd6 --- /dev/null +++ b/lib/services/installation_store.dart @@ -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_`. +/// +/// 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 _byUuid = {}; + + /// Installations connues, les plus récemment vues d'abord. + List 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 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.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 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 remove(String uuid) async { + _byUuid.remove(uuid); + await _persistMeta(); + await _deleteToken(uuid); + } + + /// Token stocké pour cet UUID (lecture directe secure storage, tolérante). + Future tokenFor(String uuid) => _readToken(uuid); + + // ── Accès secure storage tolérants aux pannes (async + faillible) ────────── + + Future _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 _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 _deleteToken(String uuid) async { + try { + await _secure.delete(key: _tokenPrefix + uuid); + } catch (_) {/* idempotent */} + } + + Future _persistMeta() async { + final prefs = await SharedPreferences.getInstance(); + final list = _byUuid.values.map((i) => i.toJson()).toList(); + await prefs.setString(_listKey, jsonEncode(list)); + } +} diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index 5eb4150..6cb9085 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -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>> _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 get historyPoints => _historyPoints; List get things => _things; @@ -167,9 +182,10 @@ class NymeaService extends ChangeNotifier { Future 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 _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 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 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 _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 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 setLoadConfig(List> 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>> 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 // ═══════════════════════════════════════════════════════════════════════════ diff --git a/lib/shared/widgets/hems_icon.dart b/lib/shared/widgets/hems_icon.dart new file mode 100644 index 0000000..90c5645 --- /dev/null +++ b/lib/shared/widgets/hems_icon.dart @@ -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(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; +} diff --git a/lib/theme/app_theme.dart b/lib/theme/app_theme.dart index c2c5b71..bed400b 100644 --- a/lib/theme/app_theme.dart +++ b/lib/theme/app_theme.dart @@ -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, + ); } diff --git a/lib/theme/etm_tokens.dart b/lib/theme/etm_tokens.dart index d8209fe..373b28e 100644 --- a/lib/theme/etm_tokens.dart +++ b/lib/theme/etm_tokens.dart @@ -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 get cardShadow => [ BoxShadow( color: navy.withValues(alpha: 0.04), @@ -82,4 +133,8 @@ class EtmTokens { offset: const Offset(0, 10), ), ]; + + static List cardShadowOf(BuildContext c) => _isDark(c) + ? [] + : cardShadow; } diff --git a/lib/widgets/role_card.dart b/lib/widgets/role_card.dart deleted file mode 100644 index fa172ef..0000000 --- a/lib/widgets/role_card.dart +++ /dev/null @@ -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)), - ), - ), - ], - ), - ], - ], - ), - ), - ); - } -} diff --git a/test/cache_invalidation_test.dart b/test/cache_invalidation_test.dart new file mode 100644 index 0000000..b7a4a2c --- /dev/null +++ b/test/cache_invalidation_test.dart @@ -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 + }); +} diff --git a/test/widget_test.dart b/test/widget_test.dart index 3b87a07..eb49de9 100644 --- a/test/widget_test.dart +++ b/test/widget_test.dart @@ -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); }); }