import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../services/nymea_service.dart'; import '../theme/etm_tokens.dart'; /// **Réserve batterie** — le seuil sous lequel plus rien n'est piloté. /// /// ## Pourquoi ce réglage sort de l'usine /// /// `batteryLevelConsideration` ne gouverne aujourd'hui que la recharge du véhicule. Une /// fois la règle montée dans le budget, elle gouvernera **toute l'installation** : sous le /// seuil, le budget de surplus est **annulé** — pas réduit — donc ni ECS, ni PAC, ni /// bornes. /// /// Le défaut d'usine **était** à 0,9. Sur une installation dont la batterie plafonne à /// 50 %, cela gèle tout. Et le symptôme, chez un client, est le pire qui soit : un HEMS /// qui ne pilote plus rien **par grand soleil, sans erreur ni panne**. Rien à lire dans /// un journal, rien à diagnostiquer — juste une installation muette. /// /// Il est passé à **0,2** côté plugin (`smartchargingmanager.cpp`, défaut de lecture des /// réglages). **Cela ne rend pas cette carte inutile, pour deux raisons :** le nouveau /// défaut ne s'applique qu'à une installation qui n'a jamais écrit le réglage — une box /// déjà déployée garde le 0,9 qu'elle a persisté — et le seuil reste réglable jusqu'à 1,0, /// donc reproductible à la main. /// /// Un seuil qui peut produire ça n'est pas un paramètre d'usine. C'est un réglage /// d'installation, et il doit se voir avant de mordre, pas après. /// /// ## Ce que la carte ne fait pas /// /// Elle n'invente pas de valeur par défaut et n'en propose pas : la box a la sienne, la /// carte la lit. Si la box ne l'expose pas, la carte se tait — un réglage absent affiché /// comme réglable serait pire que rien. class BatteryReserveCard extends StatefulWidget { const BatteryReserveCard({super.key}); @override State createState() => _BatteryReserveCardState(); } class _BatteryReserveCardState extends State { double? _valeur; double? _brouillon; bool _charge = true; String? _erreur; /// Carte repliée — l'en-tête et sa valeur restent, le réglage se retire. /// /// **Jamais repliée d'office**, et jamais retenue d'une session à l'autre : c'est le /// réglage qui peut geler toute l'installation sans erreur ni panne, et le cacher par /// défaut ramènerait le paramètre d'usine invisible que cette carte existe pour /// supprimer. Se replier reste un geste de l'utilisateur, pour la session en cours. bool _replie = false; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) => _lire()); } Future _lire() async { final v = await context.read().getBatteryLevelConsideration(); if (!mounted) return; setState(() { _valeur = v; _brouillon = v; _charge = false; }); } Future _ecrire() async { final v = _brouillon; if (v == null) return; final err = await context.read().setBatteryLevelConsideration(v); if (!mounted) return; setState(() { _erreur = err; if (err == null) _valeur = v; }); } /// Vrai si le seuil est **au-dessus** de ce que la batterie atteint réellement — le cas /// qui gèle tout sans rien dire. bool _seuilInatteignable(double soc) => _valeur != null && soc > 0 && _valeur! * 100 > soc; @override Widget build(BuildContext context) { final svc = context.watch(); if (_charge) return const SizedBox.shrink(); // Box qui n'expose pas le réglage : se taire plutôt que de montrer un curseur mort. if (_valeur == null) return const SizedBox.shrink(); final muted = EtmTokens.mutedOf(context); final soc = svc.energyData.batterySOC; final alerte = _seuilInatteignable(soc); final modifie = _brouillon != null && _brouillon != _valeur; return Container( width: double.infinity, margin: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: EtmTokens.surfaceOf(context), borderRadius: BorderRadius.circular(EtmTokens.radiusCard), border: Border.all( color: alerte ? EtmTokens.orange.withValues(alpha: 0.6) : EtmTokens.lineOf(context)), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ Icon(Icons.battery_saver_rounded, size: 18, color: alerte ? EtmTokens.orange : muted), const SizedBox(width: 8), Expanded( child: Text('Réserve batterie', style: TextStyle( fontSize: 14, fontWeight: FontWeight.w700, color: EtmTokens.inkOf(context))), ), Text('${((_brouillon ?? _valeur)! * 100).round()} %', style: TextStyle( fontSize: 15, fontWeight: FontWeight.w700, fontFamily: 'monospace', color: EtmTokens.inkOf(context))), IconButton( key: const ValueKey('replier-reserve-batterie'), visualDensity: VisualDensity.compact, icon: Icon( _replie ? Icons.keyboard_arrow_down_rounded : Icons.keyboard_arrow_up_rounded, size: 22), tooltip: _replie ? 'Déplier la réserve' : 'Replier la réserve', onPressed: () => setState(() => _replie = !_replie), ), ]), // Repliée, la carte garde le POURCENTAGE et, s'il y a lieu, l'alerte. Un seuil // qui gèle l'installation ne doit pas pouvoir être caché : replier abrège, ça // ne fait pas taire. if (_replie && alerte) ...[ const SizedBox(height: 4), Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ const Icon(Icons.warning_amber_rounded, size: 16, color: EtmTokens.orange), const SizedBox(width: 7), Expanded( child: Text( 'Seuil au-dessus de la charge actuelle (${soc.round()} %) — rien ne ' 'sera piloté.', style: TextStyle( fontSize: 11.5, height: 1.35, color: EtmTokens.inkOf(context)), ), ), ]), ], if (!_replie) ...[ const SizedBox(height: 2), Text( 'Sous ce niveau de charge, le budget de surplus est ANNULÉ — pas réduit. ' 'Aucune charge n\'est pilotée : ni eau chaude, ni pompe à chaleur, ni borne.', style: TextStyle(fontSize: 11.5, height: 1.35, color: muted), ), Slider( value: (_brouillon ?? _valeur)!.clamp(0.0, 1.0), divisions: 20, label: '${((_brouillon ?? _valeur)! * 100).round()} %', onChanged: (v) => setState(() => _brouillon = v), ), if (alerte) Padding( padding: const EdgeInsets.only(bottom: 8), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Icon(Icons.warning_amber_rounded, size: 16, color: EtmTokens.orange), const SizedBox(width: 7), Expanded( child: Text( 'Le seuil est au-dessus de la charge actuelle ' '(${soc.round()} %) : rien ne sera piloté tant que la batterie ne ' 'l\'aura pas dépassé — par grand soleil comme la nuit, et sans ' 'qu\'aucune erreur ne le signale.', style: TextStyle( fontSize: 11.5, height: 1.35, color: EtmTokens.inkOf(context)), ), ), ], ), ), if (_erreur != null) Padding( padding: const EdgeInsets.only(bottom: 6), child: Text('La box a refusé le réglage ($_erreur).', style: const TextStyle(fontSize: 11.5, color: EtmTokens.danger)), ), if (modifie) Row(children: [ TextButton( onPressed: () => setState(() => _brouillon = _valeur), child: const Text('Annuler'), ), const Spacer(), FilledButton(onPressed: _ecrire, child: const Text('Enregistrer')), ]), const SizedBox(height: 2), Text( // La portée VA changer : le dire évite qu'un installateur règle aujourd'hui un // seuil « pour la voiture » qui coupera tout demain. 'Aujourd\'hui ce seuil ne gouverne que la recharge du véhicule. Il gouvernera ' 'toute l\'installation quand l\'arbitrage prendra la recharge en charge — le ' 'régler « pour la voiture » couperait alors le reste.', style: TextStyle(fontSize: 10.5, height: 1.35, color: muted), ), ], ], ), ); } }