etm-powersync-app/lib/features/dashboard/widgets/heos_decision_card.dart
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

281 lines
8.8 KiB
Dart

import 'package:flutter/material.dart';
import '../../../theme/etm_tokens.dart';
import '../models/dashboard_models.dart';
class HeosDecisionCard extends StatefulWidget {
const HeosDecisionCard({super.key});
@override
State<HeosDecisionCard> createState() => _HeosDecisionCardState();
}
class _HeosDecisionCardState extends State<HeosDecisionCard>
with SingleTickerProviderStateMixin {
late final AnimationController _pulseCtrl;
late final Animation<double> _pulseAnim;
final _decision = HeosDecision.demo();
@override
void initState() {
super.initState();
_pulseCtrl = AnimationController(
vsync: this,
duration: const Duration(seconds: 2),
)..repeat(reverse: true);
_pulseAnim = Tween<double>(begin: 0.25, end: 0.7)
.animate(CurvedAnimation(parent: _pulseCtrl, curve: Curves.easeInOut));
}
@override
void dispose() {
_pulseCtrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final eco = EtmTokens.ecoOf(context);
final ink = EtmTokens.inkOf(context);
return Container(
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
boxShadow: EtmTokens.cardShadowOf(context),
border: Border.all(color: eco.withValues(alpha: 0.25), width: 1),
),
padding: const EdgeInsets.all(18),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── En-tête
Row(
children: [
Container(
width: 34, height: 34,
decoration: BoxDecoration(
color: eco.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
),
child: Icon(Icons.auto_awesome_rounded, color: eco, size: 18),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Héos pilote en ce moment',
style: EtmTokens.sans(
size: 14,
weight: FontWeight.w600,
color: ink,
)),
Text(
'optimise sur 24 h · maj ${_fmtTime(_decision.updatedAt)}',
style: EtmTokens.mono(
size: 10,
color: EtmTokens.mutedOf(context),
weight: FontWeight.w400,
),
),
],
),
),
AnimatedBuilder(
animation: _pulseAnim,
builder: (context, _) => Container(
width: 10, height: 10,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: eco.withValues(alpha: _pulseAnim.value),
boxShadow: [
BoxShadow(
color: eco.withValues(alpha: _pulseAnim.value * 0.6),
blurRadius: 6,
spreadRadius: 2,
),
],
),
),
),
],
),
const SizedBox(height: 14),
// ── Résumé avec highlight
_HighlightedSummary(
text: _decision.summary,
highlight: _decision.highlight,
highlightColor: eco,
textColor: EtmTokens.mutedOf(context),
),
const SizedBox(height: 14),
// ── Hairline
Divider(height: 1, color: EtmTokens.lineOf(context)),
// ── Actions
...List.generate(_decision.actions.length, (i) {
final action = _decision.actions[i];
final isLast = i == _decision.actions.length - 1;
return Column(
children: [
_ActionRow(action: action, eco: eco, context: context),
if (!isLast)
Divider(height: 1, color: EtmTokens.lineOf(context)),
],
);
}),
const SizedBox(height: 12),
// ── Pied
GestureDetector(
onTap: () {},
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Text(
'Voir le plan de la journée',
style: EtmTokens.sans(
size: 13,
weight: FontWeight.w600,
color: EtmTokens.brand),
),
const SizedBox(width: 4),
Icon(Icons.arrow_forward_ios_rounded,
size: 12, color: EtmTokens.brand),
],
),
),
],
),
);
}
String _fmtTime(DateTime dt) =>
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
}
// ── Highlighted summary ───────────────────────────────────────────────────────
class _HighlightedSummary extends StatelessWidget {
final String text;
final String highlight;
final Color highlightColor;
final Color textColor;
const _HighlightedSummary({
required this.text,
required this.highlight,
required this.highlightColor,
required this.textColor,
});
@override
Widget build(BuildContext context) {
final idx = text.indexOf(highlight);
if (idx < 0) {
return Text(text,
style: EtmTokens.sans(size: 13, color: textColor, height: 1.5));
}
return Text.rich(
TextSpan(children: [
TextSpan(
text: text.substring(0, idx),
style: EtmTokens.sans(size: 13, color: textColor, height: 1.5),
),
WidgetSpan(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 3, vertical: 1),
decoration: BoxDecoration(
color: highlightColor.withValues(alpha: 0.15),
borderRadius: BorderRadius.circular(4),
),
child: Text(
highlight,
style: EtmTokens.sans(
size: 13,
color: highlightColor,
weight: FontWeight.w600,
height: 1.5,
),
),
),
),
TextSpan(
text: text.substring(idx + highlight.length),
style: EtmTokens.sans(size: 13, color: textColor, height: 1.5),
),
]),
);
}
}
// ── Action row ────────────────────────────────────────────────────────────────
class _ActionRow extends StatelessWidget {
final HeosAction action;
final Color eco;
final BuildContext context;
const _ActionRow({
required this.action,
required this.eco,
required this.context,
});
@override
Widget build(BuildContext _) {
final icon = _iconFor(action.iconKey);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 32, height: 32,
decoration: BoxDecoration(
color: eco.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
),
child: Icon(icon, color: eco, size: 17),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(action.title,
style: EtmTokens.sans(
size: 13,
weight: FontWeight.w600,
color: EtmTokens.inkOf(context))),
const SizedBox(height: 2),
Text(action.why,
style: EtmTokens.sans(
size: 11, color: EtmTokens.mutedOf(context))),
],
),
),
const SizedBox(width: 8),
Text(
'${action.powerKw.toStringAsFixed(1)} kW',
style: EtmTokens.mono(size: 13, color: eco),
),
],
),
);
}
IconData _iconFor(String key) => switch (key) {
'water' => Icons.water_drop_outlined,
'ev' => Icons.ev_station_rounded,
'battery' => Icons.battery_charging_full_rounded,
'heat' => Icons.heat_pump_outlined,
_ => Icons.bolt_rounded,
};
}