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>
281 lines
8.8 KiB
Dart
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,
|
|
};
|
|
}
|