etm-powersync-app/lib/widgets/battery_reserve_card.dart
Patrick Schurig 2701a9c0b3 feat(config): l'arbitrage descend en bas, et les blocs se replient
Trois changements demandés après le passage sur téléphone.

1. L'ARBITRAGE EN DIRECT PASSE TOUT EN BAS. Il était en tête avec cet argument : « Alloué
   3 000 W ne veut rien dire tant qu'on ne sait pas s'il y avait 3 100 ou 12 000 W à
   distribuer ». L'argument reste juste pour LIRE une allocation — mais cet écran sert à
   CONFIGURER, et faire descendre l'installateur sous un pavé de télémétrie à chaque
   ouverture met la lecture devant le geste.

   La note de rangs ex æquo, elle, RESTE EN TÊTE : elle prévient que l'ordre affiché juste
   en dessous n'en est pas un, et la lire après les cartes arriverait trop tard.

2. LES DEUX FLÈCHES DE DOMAINE DEVIENNENT UN CHEVRON DE REPLI. Elles faisaient doublon
   avec la poignée de glissement, désormais vérifiée au doigt sur appareil (48 px,
   réordonnancement mesuré, 0 px de défilement parasite). Deux chemins pour le même geste,
   dont l'un sans repli, valaient moins qu'un chemin sûr plus une commande qui manquait.

   Replié, le bloc dit COMBIEN de charges il contient : sinon replier reviendrait à faire
   disparaître des charges, et une charge qu'on ne voit plus se lit comme une charge
   perdue. Le repli est clé par CODE de domaine, pas par index — un domaine déplacé emporte
   son repli au lieu de replier son voisin.

3. LA CARTE RÉSERVE BATTERIE SE REPLIE AUSSI, avec une réserve qui n'est pas cosmétique :
   repliée, elle garde le pourcentage ET l'alerte de seuil inatteignable. Replier abrège,
   ça ne fait pas taire — c'est le réglage qui peut geler toute l'installation sans erreur
   ni panne, et le cacher ramènerait le paramètre d'usine invisible que cette carte existe
   pour supprimer. Jamais repliée d'office.

Aucun repli n'est persisté, délibérément : c'est une commodité de lecture, pas une
configuration. La rendre durable ferait retrouver un écran à moitié caché des semaines plus
tard, sans savoir pourquoi.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
2026-08-27 19:43:46 +02:00

232 lines
9.3 KiB
Dart

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<BatteryReserveCard> createState() => _BatteryReserveCardState();
}
class _BatteryReserveCardState extends State<BatteryReserveCard> {
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<void> _lire() async {
final v = await context.read<NymeaService>().getBatteryLevelConsideration();
if (!mounted) return;
setState(() {
_valeur = v;
_brouillon = v;
_charge = false;
});
}
Future<void> _ecrire() async {
final v = _brouillon;
if (v == null) return;
final err = await context.read<NymeaService>().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<NymeaService>();
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),
),
],
],
),
);
}
}