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

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

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

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

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

225 lines
6.7 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import '../../../models/energy_data.dart';
import '../../../theme/etm_tokens.dart';
import '../painters/ring_painter.dart';
class KpiRow extends StatelessWidget {
final EnergyData data;
const KpiRow({super.key, required this.data});
@override
Widget build(BuildContext context) {
final autoconso = data.selfConsumptionRate.clamp(0.0, 100.0);
final autonomy = data.autonomyRate.clamp(0.0, 100.0);
final toGrid = data.dayGridInjectionWh.clamp(0.0, double.infinity) / 1000;
final fromGrid = (-data.dayGridInjectionWh).clamp(0.0, double.infinity) / 1000;
return Column(
children: [
Row(
children: [
Expanded(
child: _RingTile(
label: 'Autoconsommation',
value: autoconso,
color: EtmTokens.solarOf(context),
),
),
const SizedBox(width: 10),
Expanded(
child: _RingTile(
label: 'Autonomie',
value: autonomy,
color: EtmTokens.ecoOf(context),
),
),
],
),
const SizedBox(height: 10),
Row(
children: [
Expanded(
child: _ValueTile(
label: 'Vers réseau · aujourd\'hui',
value: toGrid,
icon: Icons.north_west_rounded,
color: EtmTokens.ecoOf(context),
),
),
const SizedBox(width: 10),
Expanded(
child: _ValueTile(
label: 'Depuis réseau · aujourd\'hui',
value: fromGrid,
icon: Icons.south_east_rounded,
color: EtmTokens.importColor,
),
),
],
),
],
);
}
}
// ── Ring tile — horizontal (ring left, text right) ────────────────────────────
class _RingTile extends StatefulWidget {
final String label;
final double value;
final Color color;
const _RingTile({required this.label, required this.value, required this.color});
@override
State<_RingTile> createState() => _RingTileState();
}
class _RingTileState extends State<_RingTile>
with SingleTickerProviderStateMixin {
late final AnimationController _ctrl;
late Animation<double> _ring;
late Animation<int> _count;
@override
void initState() {
super.initState();
_ctrl = AnimationController(vsync: this, duration: const Duration(milliseconds: 1100));
_ring = Tween<double>(begin: 0, end: widget.value / 100)
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutCubic));
_count = IntTween(begin: 0, end: widget.value.round())
.animate(CurvedAnimation(parent: _ctrl, curve: Curves.easeOutCubic));
_ctrl.forward();
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
boxShadow: EtmTokens.cardShadowOf(context),
border: Border.all(color: EtmTokens.lineOf(context), width: 1),
),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
child: Row(
children: [
// Ring — 50×50, strokeWidth 5 (matches HTML .ring SVG)
SizedBox(
width: 50, height: 50,
child: AnimatedBuilder(
animation: _ring,
builder: (context, _) => CustomPaint(
size: const Size(50, 50),
painter: RingPainter(
value: _ring.value,
color: widget.color,
strokeWidth: 5,
),
),
),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
AnimatedBuilder(
animation: _count,
builder: (context, _) => Text(
'${_count.value} %',
style: EtmTokens.mono(
size: 18,
weight: FontWeight.w600,
color: EtmTokens.inkOf(context),
),
),
),
const SizedBox(height: 3),
Text(
widget.label,
style: EtmTokens.sans(
size: 11,
color: EtmTokens.mutedOf(context),
),
),
],
),
),
],
),
);
}
}
// ── Value tile — horizontal (icon left, text right) ───────────────────────────
class _ValueTile extends StatelessWidget {
final String label;
final double value;
final IconData icon;
final Color color;
const _ValueTile({
required this.label,
required this.value,
required this.icon,
required this.color,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
boxShadow: EtmTokens.cardShadowOf(context),
border: Border.all(color: EtmTokens.lineOf(context), width: 1),
),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 13),
child: Row(
children: [
Container(
width: 30, height: 30,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(9),
),
child: Icon(icon, color: color, size: 16),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'${value.toStringAsFixed(1)} kWh',
style: EtmTokens.mono(
size: 16,
weight: FontWeight.w600,
color: EtmTokens.inkOf(context),
),
),
const SizedBox(height: 3),
Text(
label,
style: EtmTokens.sans(
size: 10.5,
color: EtmTokens.mutedOf(context),
),
overflow: TextOverflow.ellipsis,
),
],
),
),
],
),
);
}
}