etm-powersync-app/lib/widgets/controllable_loads_section.dart
Patrick Schurig e0264b5ed6 feat(ev): une borne à pleine puissance n'est pas arbitrée — les deux écrans le nomment
Geste de mise en service, signalé par le moteur : toute borne détectée reçoit d'office
ChargingModeNormal, c'est-à-dire pleine puissance HORS arbitrage. Une borne neuve n'est
donc jamais pilotée tant que personne n'a posé un mode éco, et rien ne le disait —
l'installateur pose une borne, elle charge à fond, et il cherche du côté du HEMS un défaut
qui n'existe pas.

La note apparaît aux deux endroits qui comptent, mot pour mot (kEvUnmanagedNote) :
  · la carte de configuration, celle qu'on ouvre à la mise en service ;
  · la tuile des favoris, où les trois boutons de mode sont juste à côté.

⚠️ Elle nomme l'ÉTAT, pas une négligence. La box publie le même chargingMode pour un boost
délibéré et pour une borne jamais réglée : l'app ne peut pas les distinguer, donc elle dit
« pleine puissance, hors arbitrage » et rappelle que c'est aussi le mode d'office. Même
règle que la note de rangs ex æquo — reproductible n'est pas choisi, et on ne fait pas dire
à un écran ce que la donnée ne porte pas. Un mode NON LU ne déclenche rien non plus : on ne
conclut pas d'une absence.

Le piège qui aurait rendu la note invisible est couvert par un test : GetLoadConfig rend
l'id de charge AVEC accolades ({4d1adafd-…}), GetChargingInfos rend l'evChargerId SANS.
Comparer les deux tels quels ne rend jamais rien — en silence : l'écran aurait conclu
« mode non lu » sur une borne parfaitement lue. chargingModeOf() réconcilie les deux.

Six tests montés (trois par écran), dont les deux contrôles négatifs.

Et la réserve sessionEnergy est documentée à sa juste place : le champ est CONSTRUIT par
le plugin V2C 1.15.2+etm13 — il accumule les incréments de chargeEnergy, détecte les
remises à zéro, ne repart qu'à l'ouverture d'une session — donc l'égalité observée hier
s'explique d'elle-même. La réserve tient jusqu'à la trace que la campagne produira
(chargeEnergy à zéro, sessionEnergy qui continue) : aucun avancement affiché d'ici là.

Noté aussi, pour la suite : sessionEnergy n'est PAS historisé (suggestLogging écrasé par
things.conf sur un Thing déjà appairé ; la V2C n'y a que connected, charging, power). La
valeur courante est juste, il n'y aura pas de courbe — le choix des états enregistrés est
côté app, entrée au TODO.

164 tests, flutter analyze inchangé à 27 remarques.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
2026-08-28 13:08:01 +02:00

996 lines
40 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 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../l10n/l10n_ext.dart';
import '../models/ev_charger_live.dart';
import '../models/load_config_entry.dart';
import '../providers/load_config_provider.dart';
import '../providers/load_telemetry_provider.dart';
import '../services/load_hardware.dart';
import '../services/nymea_service.dart';
import '../services/load_priority.dart';
import '../theme/etm_tokens.dart';
import 'load_cards.dart';
/// Les **charges pilotables** de l'écran « Rôles & appareils », telles que la box les
/// déclare.
///
/// ## Le sens de la fusion
///
/// Cette section remplace l'ancien écran « Ordre de service » — c'est son **chemin de
/// données** qui a migré ici, pas l'inverse. La distinction n'est pas de forme : l'écran
/// des rôles reposait sur une énumération figée de six rôles
/// (`gridMeter`, `solarMeter`, `evCharger`, `dhw`, `heatPump`, `battery`), et une
/// énumération figée ne peut pas décrire une installation.
///
/// **LM-201 le dit sans détour : une charge existe quand il y a un canal de commande
/// distinct pour un service.** Un canal, une charge. Une PAC air/eau qui fait chauffage
/// ET ECS sans ballon séparé est **une** charge — le SG-Ready commande la machine
/// entière et la répartition appartient à sa régulation. Le modèle des rôles, lui, aurait
/// exigé d'en déclarer deux (`dhw` **et** `heatPump`) pour une seule machine, donc deux
/// entrées se disputant un canal unique. C'est exactement ce que `validateSet()` refuse
/// côté box, et ce refus n'est pas une limite subie : c'est le modèle.
///
/// La liste affichée vient donc de `GetLoadConfig`, avec ses identifiants et ses domaines
/// tels quels — y compris ceux qu'aucune version d'app ne connaît encore.
///
/// ## Deux sources qu'il ne faut pas confondre
///
/// - **la configuration** (`GetLoadConfig`) — ce que l'installateur a déclaré, éditable ;
/// - **la télémétrie** (`GetLoadTelemetry`) — ce que l'arbitre a fait au dernier cycle,
/// en lecture seule et jamais reconstruite côté app.
///
/// Depuis le lot plugin B-bis, toute charge publiée en télémétrie a une entrée en
/// configuration. L'inverse reste faux : une charge configurée absente de la télémétrie
/// est **désactivée**, jamais perdue — et « non arbitrée » n'est donc pas une erreur.
class ControllableLoadsSection extends StatefulWidget {
/// Ouvre l'écran de mécanisme d'une charge. Fourni par l'écran hôte, qui connaît la
/// navigation ; les trois contrats ne se ressemblent pas, donc il n'y a pas de
/// formulaire unique à ouvrir.
final void Function(BuildContext, LoadConfigEntry) onConfigure;
const ControllableLoadsSection({super.key, required this.onConfigure});
@override
State<ControllableLoadsSection> createState() =>
_ControllableLoadsSectionState();
}
class _ControllableLoadsSectionState extends State<ControllableLoadsSection> {
/// Domaines repliés, par leur code.
///
/// Clé par **code de domaine** et non par index : un domaine qui se déplace emporte
/// son repli, ce qu'un index ne saurait pas faire — il replierait le voisin.
///
/// **Non 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.
final Set<String> _replies = <String>{};
@override
Widget build(BuildContext context) {
final provider = context.watch<LoadConfigProvider>();
final telemetry = context.watch<LoadTelemetryProvider>();
final groups = provider.groups;
if (provider.loading && groups.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: CircularProgressIndicator()),
);
}
if (groups.isEmpty) {
return _EmptyNote(error: provider.error);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// La note de rang reste EN TÊTE : elle prévient que l'ordre affiché juste en
// dessous n'en est pas un. La lire après les cartes arriverait trop tard.
_NoteOrdreEntrelace(provider: provider),
_NoteRangsExAequo(entries: provider.entries),
// Le niveau glissable est celui des DOMAINES, pas des cartes.
//
// Deux ReorderableListView imbriqués se disputent le geste — l'extérieur gagne et
// l'intérieur devient inerte. Il faut donc choisir, et le choix se décide par ce
// que l'installateur cherche à faire : sur une installation ordinaire, chaque
// domaine porte UNE charge, et l'ordre de service se règle donc entre domaines.
// C'est là que la prise doit être. Réordonner deux charges d'un même domaine reste
// possible, par les flèches de leur carte.
ReorderableListView.builder(
shrinkWrap: true,
primary: false,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
itemCount: groups.length,
onReorder: provider.reorderDomains,
itemBuilder: (context, i) => _DomainBlock(
key: ValueKey('domain-${groups[i].domain}'),
group: groups[i],
index: i,
total: groups.length,
provider: provider,
telemetry: telemetry,
onConfigure: widget.onConfigure,
replie: _replies.contains(groups[i].domain),
onReplier: () => setState(() {
final d = groups[i].domain;
_replies.contains(d) ? _replies.remove(d) : _replies.add(d);
}),
),
),
// Plus de section séparée pour les bornes : depuis `+etm23` elles ONT une entrée
// `GetLoadConfig` (`adapter: "evcharger"`, `domain: "ev"`, un rang), donc elles
// apparaissent dans leur domaine comme n'importe quelle charge. La section qui
// existait pour les rattraper les ferait maintenant apparaître DEUX fois, en
// affirmant qu'elles n'ont « ni rang ni domaine » — ce qui n'est plus vrai.
if (provider.hasPendingChanges) ...[
const SizedBox(height: 4),
ImpactSaveBar(provider: provider),
],
// L'arbitrage en direct 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. Le budget reste là, sous les charges qu'il
// explique, atteignable quand on le cherche.
const SizedBox(height: 4),
TelemetryHeader(telemetry: telemetry),
],
);
}
}
/// **L'ordre en place n'est PAS groupé par domaine — et l'enregistrer le changerait.**
///
/// `GroupedOrder` calcule cette contiguïté depuis le premier jour, et le provider
/// l'expose (`isNotGrouped`). Rien ne l'affichait : le cas ne s'était jamais produit sur
/// le banc, chaque domaine y ayant ses charges d'un seul tenant.
///
/// Il s'est produit le 2026-08-28, à l'arrivée de la V2C. Créée d'office au rang 1 — que
/// le chauffe-eau occupait déjà — elle a donné l'ordre à plat `ecs · ev · heating · ev ·
/// ev` : le domaine `ev` est coupé en deux par le chauffage.
///
/// **Pourquoi il faut le dire, et pas seulement le savoir.** L'écran groupe les charges
/// par domaine pour les afficher. Sur un ordre entrelacé, ce regroupement ne correspond
/// plus à l'ordre réel de la box, et le premier enregistrement réaplatit en groupant —
/// donc **change l'arbitrage** sans que personne ne l'ait demandé. Mesuré sur la
/// configuration du banc : la PAC passerait du rang 2 au rang 5.
///
/// La note ne bloque rien : elle nomme l'écart et annonce ce que l'enregistrement fera.
/// Décider reste à l'installateur — mais en connaissance de cause.
class _NoteOrdreEntrelace extends StatelessWidget {
final LoadConfigProvider provider;
const _NoteOrdreEntrelace({required this.provider});
@override
Widget build(BuildContext context) {
if (!provider.isNotGrouped) return const SizedBox.shrink();
final doms = provider.interleavedDomains
.map((d) => domainLabel(context.l10n, d))
.join(', ');
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(11),
decoration: BoxDecoration(
color: EtmTokens.orange.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
border: Border.all(color: EtmTokens.orange.withValues(alpha: 0.45)),
),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Icon(Icons.shuffle_rounded, size: 15, color: EtmTokens.orange),
const SizedBox(width: 7),
Expanded(
child: Text(
'L\'ordre en place sur la box n\'est pas groupé par domaine : $doms '
'est coupé en deux par une autre famille de charges. L\'écran les regroupe '
'pour les afficher, donc ce que vous voyez n\'est pas l\'ordre réel.\n\n'
'Le premier enregistrement REGROUPERA pour de bon, et changera l\'ordre de '
'service du surplus. Vérifiez les rangs avant d\'enregistrer.',
style: TextStyle(
fontSize: 11.5, height: 1.35, color: EtmTokens.inkOf(context)),
),
),
]),
),
);
}
}
/// **L'ordre affiché n'est pas un choix quand des rangs sont à égalité.**
///
/// À la détection d'une borne, le plugin lui attribue « le plus petit rang existant moins
/// un, borné à 1 ». Dès qu'une charge occupe déjà le rang 1 — le cas de toute installation
/// déjà réglée — l'intention « en tête » dégénère en égalité, et plusieurs charges se
/// retrouvent au même rang.
///
/// Le tri reste **total** : l'identifiant départage, donc l'ordre ne bouge pas d'un cycle
/// à l'autre. Mais reproductible n'est pas choisi, et c'est toute la différence pour qui
/// regarde cette liste à la mise en service. Sans cette note, l'installateur lit un
/// classement, le trouve plausible, et n'y touche pas — validant par inadvertance un
/// ordre que personne n'a posé.
///
/// Elle disparaît d'elle-même dès que les rangs sont distincts, c'est-à-dire dès le
/// premier vrai réglage.
class _NoteRangsExAequo extends StatelessWidget {
final List<LoadConfigEntry> entries;
const _NoteRangsExAequo({required this.entries});
@override
Widget build(BuildContext context) {
final exAequo = tiedByPriority(entries);
if (exAequo.isEmpty) return const SizedBox.shrink();
final noms = exAequo.map((e) => e.label).join(', ');
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(11),
decoration: BoxDecoration(
color: EtmTokens.solar.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Icon(Icons.low_priority_rounded,
size: 15, color: EtmTokens.inkOf(context)),
const SizedBox(width: 7),
Expanded(
child: Text(
'Rangs à égalité : $noms. L\'ordre affiché entre elles est stable, mais '
'ce n\'est pas un réglage — c\'est ce qui reste quand personne n\'a '
'tranché. Les bornes détectées arrivent au rang 1 par défaut, sur lequel '
'une autre charge est souvent déjà posée. Classez-les avant la mise en '
'service.',
style: TextStyle(
fontSize: 11.5, height: 1.35, color: EtmTokens.inkOf(context)),
),
),
]),
),
);
}
}
/// **Une borne en pleine puissance ne passe pas par l'arbitrage — et c'est son défaut.**
///
/// Le moteur crée d'office une entrée de configuration par borne détectée, en
/// `ChargingModeNormal` : pleine puissance, hors arbitrage. Tant que personne n'a posé un
/// mode éco, le HEMS ne pilote donc pas cette borne — et rien ne le disait. C'est le
/// pendant exact de la note de rangs ex æquo : un état par défaut qui a toutes les
/// apparences d'un réglage.
///
/// ⚠️ Elle ne dit pas « personne n'a choisi » : la box publie le même `chargingMode` pour
/// un boost délibéré et pour une borne jamais réglée. Elle nomme l'ÉTAT, et rappelle qu'il
/// est aussi le défaut.
class _NoteBornePleinePuissance extends StatelessWidget {
final LoadConfigEntry entry;
const _NoteBornePleinePuissance({required this.entry});
@override
Widget build(BuildContext context) {
// `GetChargingInfos` porte l'`evChargerId` sans accolades, `GetLoadConfig` l'id de
// charge avec : `chargingModeOf` réconcilie les deux.
final mode = context.watch<NymeaService>().chargingModeOf(entry.id);
if (mode != kChargingModeNormal) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(9),
decoration: BoxDecoration(
color: EtmTokens.solar.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
),
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Icon(Icons.flash_on_rounded, size: 15, color: EtmTokens.solar),
const SizedBox(width: 7),
Expanded(
child: Text(kEvUnmanagedNote,
style: TextStyle(
fontSize: 11.5, height: 1.35, color: EtmTokens.inkOf(context))),
),
]),
),
);
}
}
/// Un domaine et ses charges. Les domaines se déplacent entre eux, les charges à
/// l'intérieur — les deux niveaux que l'ordre de service demande.
class _DomainBlock extends StatelessWidget {
final DomainGroup group;
final int index;
final int total;
final LoadConfigProvider provider;
final LoadTelemetryProvider telemetry;
final void Function(BuildContext, LoadConfigEntry) onConfigure;
/// Bloc replié : l'en-tête reste, les cartes sont retirées de l'arbre.
final bool replie;
final VoidCallback onReplier;
const _DomainBlock({
super.key,
required this.group,
required this.index,
required this.total,
required this.provider,
required this.telemetry,
required this.onConfigure,
required this.replie,
required this.onReplier,
});
@override
Widget build(BuildContext context) {
final t = context.l10n;
final muted = EtmTokens.mutedOf(context);
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
// Cible tactile de 48 px — le minimum Material, et il n'est pas décoratif.
//
// Mesurée à **26 × 27 px** sur le Redmi Note 9S au premier passage du test
// d'appareil. Le `Container` transparent posé lors de l'enquête précédente
// avait supprimé la bande morte entre l'icône et le reste, mais il n'avait
// pas AGRANDI la cible : le geste accrochait quand on visait juste, et ratait
// sinon. Aucun test unitaire ne pouvait le montrer — ils ne rendent pas ces
// widgets à une taille d'écran réelle.
//
// C'est le seul geste de cet écran qui n'a pas de repli : sans lui, l'ordre
// des domaines ne se change pas du tout.
ReorderableDragStartListener(
key: ValueKey('poignee-domaine-${group.domain}'),
index: index,
child: Container(
color: Colors.transparent,
width: 48,
height: 48,
alignment: Alignment.centerLeft,
child: Icon(Icons.drag_indicator_rounded, size: 19, color: muted),
),
),
Expanded(
child: Text(
domainLabel(t, group.domain),
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 13,
letterSpacing: 0.2,
color: group.isUnclassified ? muted : EtmTokens.inkOf(context),
),
),
),
// Quand le bloc est replié, dire ce qu'il contient — sinon replier revient à
// faire disparaître des charges, et une charge qu'on ne voit plus se lit
// comme une charge perdue.
if (replie)
Padding(
padding: const EdgeInsets.only(right: 4),
child: Text(
group.loads.length > 1
? '${group.loads.length} charges'
: '${group.loads.length} charge',
style: TextStyle(fontSize: 11.5, color: muted),
),
),
// Un SEUL chevron, qui replie — à la place des deux flèches monter/descendre.
//
// Elles faisaient doublon avec la poignée de glissement, désormais vérifiée
// au doigt sur appareil (48 px, réordonnancement mesuré). Deux chemins pour
// le même geste, dont l'un n'a pas de repli, valaient moins qu'un chemin sûr
// plus une commande qui manquait.
IconButton(
key: ValueKey('replier-${group.domain}'),
visualDensity: VisualDensity.compact,
icon: Icon(
replie
? Icons.keyboard_arrow_down_rounded
: Icons.keyboard_arrow_up_rounded,
size: 22),
tooltip: replie ? 'Déplier ce domaine' : 'Replier ce domaine',
onPressed: onReplier,
),
]),
if (!replie) const SizedBox(height: 6),
if (!replie)
for (var i = 0; i < group.loads.length; i++)
Padding(
key: ValueKey('load-${group.loads[i].id}'),
padding: const EdgeInsets.only(bottom: 10),
child: LoadConfigCard(
entry: group.loads[i],
indexDansDomaine: i,
totalDansDomaine: group.loads.length,
provider: provider,
telemetry: telemetry,
onConfigure: onConfigure,
),
),
],
),
);
}
}
/// Carte d'une charge : identité, classement, rang, télémétrie, et l'accès au mécanisme.
class LoadConfigCard extends StatelessWidget {
final LoadConfigEntry entry;
/// Position dans son domaine, et taille du domaine. Ensemble, ils disent si un
/// réordonnancement INTERNE a un sens — seul dans son domaine, il n'y a rien à déplacer,
/// et l'ordre se change alors en glissant le domaine.
final int indexDansDomaine;
final int totalDansDomaine;
final LoadConfigProvider provider;
final LoadTelemetryProvider telemetry;
final void Function(BuildContext, LoadConfigEntry) onConfigure;
const LoadConfigCard({
super.key,
required this.entry,
required this.indexDansDomaine,
required this.totalDansDomaine,
required this.provider,
required this.telemetry,
required this.onConfigure,
});
/// Libellé du mécanisme. Code inconnu → repli lisible, jamais une phrase vide : une
/// box plus récente peut publier un mécanisme que cette version d'app ignore.
String _adapterLabel(L10n t) => switch (entry.adapter) {
'relay-router' => t.adapterRelayRouter,
'etmvariableload' => t.adapterEtmVariableLoad,
'sg-ready' => t.adapterSgReady,
'evcharger' => t.adapterEvCharger,
_ => t.adapterUnknown(entry.adapter),
};
/// « Routeur de relais · 3 relais · min ON 60 s » — le mécanisme, son matériel, ses
/// verrous. Chaque morceau n'apparaît que s'il a une valeur à montrer.
String _resume(L10n t) {
final bouts = <String>[_adapterLabel(t)];
if (entry.isRelayRouter) {
bouts.add('${entry.relays.length} relais');
} else if (entry.isSgReady) {
bouts.add('${entry.sgReadyStates.length} états');
if (entry.minStateHoldS > 0) {
bouts.add('maintien d\'état ${entry.minStateHoldS} s');
}
} else if (entry.maxPowerW > 0) {
bouts.add('${entry.maxPowerW} W');
}
if (!entry.isSgReady) {
if (entry.minOnS > 0) bouts.add('min ON ${entry.minOnS} s');
if (entry.minOffS > 0) bouts.add('min OFF ${entry.minOffS} s');
}
return bouts.join(' · ');
}
@override
Widget build(BuildContext context) {
final t = context.l10n;
final muted = EtmTokens.mutedOf(context);
final live = telemetry.entryFor(entry.id);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
boxShadow: EtmTokens.cardShadowOf(context),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Le rang, et — si le domaine porte plusieurs charges — de quoi les
// ordonner entre elles. Le glissement, lui, appartient au domaine.
Padding(
padding: const EdgeInsets.only(top: 1, right: 9),
child: Column(children: [
Container(
width: 20,
height: 20,
alignment: Alignment.center,
decoration: BoxDecoration(
color: EtmTokens.brand.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
),
child: Text('${entry.priority}',
style: const TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.w700,
color: EtmTokens.brand)),
),
// Les deux flèches CÔTE À CÔTE, pas empilées.
//
// Empilées, deux cibles de 44 px font 88 px de haut pour deux icônes de
// 18 : la colonne de rang devient une colonne vide, constaté à la
// capture d'appareil. Côte à côte, la hauteur retombe à 44 et la
// largeur — 88 px sur une carte pleine largeur — ne coûte rien au
// libellé.
//
// La cible tactile, elle, ne bouge pas : c'est la contrainte, pas la
// disposition.
if (totalDansDomaine > 1)
Row(mainAxisSize: MainAxisSize.min, children: [
_FlecheRang(
key: ValueKey('monter-${entry.id}'),
icone: Icons.keyboard_arrow_up_rounded,
actif: indexDansDomaine > 0,
onTap: () => provider.reorderWithinDomain(
entry.domain ?? kUnclassifiedDomain,
indexDansDomaine,
indexDansDomaine - 1),
),
_FlecheRang(
key: ValueKey('descendre-${entry.id}'),
icone: Icons.keyboard_arrow_down_rounded,
actif: indexDansDomaine < totalDansDomaine - 1,
onTap: () => provider.reorderWithinDomain(
entry.domain ?? kUnclassifiedDomain,
indexDansDomaine,
indexDansDomaine + 2),
),
]),
]),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.label,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: entry.enabled
? EtmTokens.inkOf(context)
: muted,
),
),
const SizedBox(height: 2),
// Ce que l'installateur doit reconnaître d'un coup d'œil : le
// mécanisme, ce qu'il commande, et les verrous qui le brident.
// « rang 1 » n'apprend rien ici — la pastille le dit déjà.
Text(_resume(t),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11.5, height: 1.3, color: muted)),
],
),
),
// `enabled` : entrée/sortie de l'arbitrage. Le libellé de l'aide dit ce que
// le drapeau fait VRAIMENT — voir _EnabledSwitch.
_EnabledSwitch(entry: entry, provider: provider),
],
),
const SizedBox(height: 10),
Row(children: [
DomainMenu(
key: ValueKey('chip-domaine-${entry.id}'),
entry: entry,
provider: provider),
const Spacer(),
TextButton.icon(
onPressed: () => onConfigure(context, entry),
icon: const Icon(Icons.tune_rounded, size: 17),
label: const Text('Configurer'),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
foregroundColor: EtmTokens.brand,
),
),
]),
_RenameRow(entry: entry, provider: provider),
const SizedBox(height: 8),
Divider(height: 1, color: EtmTokens.lineOf(context)),
const SizedBox(height: 8),
// ── Une borne à pleine puissance n'est pas arbitrée ────────────────
//
// Elle se lit ici et nulle part ailleurs : la télémétrie n'en dit rien (une
// borne en `ChargingModeNormal` sans véhicule branché est simplement absente de
// `loads[]`), et l'écran de configuration est celui que l'installateur ouvre à
// la mise en service. Sans cette ligne, il pose une borne, elle charge à fond,
// et il cherche du côté du HEMS un défaut qui n'existe pas.
if (entry.isEvCharger) _NoteBornePleinePuissance(entry: entry),
if (live != null)
LoadLiveBlock(
live: live,
stale: telemetry.stale,
telemetry: telemetry,
)
else
// Absente de `loads[]` : désactivée, pas perdue. Le dire explicitement —
// un vide se lirait comme une panne.
Row(children: [
Icon(Icons.pause_circle_outline_rounded, size: 14, color: muted),
const SizedBox(width: 5),
Expanded(
child: Text(
entry.enabled
? 'Non arbitrée à ce cycle — configurée, mais absente de la '
'télémétrie du dernier cycle.'
: 'Désactivée — rôle déclaré, exclue de l\'arbitrage.',
style: TextStyle(fontSize: 11.5, color: muted, height: 1.3),
),
),
]),
],
),
);
}
}
/// Interrupteur d'entrée dans l'arbitrage, avec ce que le geste fait réellement.
class _EnabledSwitch extends StatelessWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
const _EnabledSwitch({required this.entry, required this.provider});
@override
Widget build(BuildContext context) {
return Tooltip(
// Ce texte est le résultat d'une mesure, pas une précaution rédactionnelle :
// sur `.75`, PAC en état 4 (contacts fermés), `enabled: false` a bien OUVERT les
// contacts — par le retrait de l'adaptateur (ECS-413), pas par le drapeau.
message: entry.enabled
? 'Désactiver retire la charge de l\'arbitrage et l\'amène à son état sûr '
'(contacts ouverts) avant de la retirer.\n'
'Ce n\'est pas un sectionnement : la sécurité vient du retrait, pas du '
'drapeau.'
: 'Activer fait entrer la charge dans l\'arbitrage au prochain cycle.',
child: Switch.adaptive(
value: entry.enabled,
onChanged: (v) => provider.setEnabled(entry.id, v),
),
);
}
}
/// Renommage en ligne. Le libellé n'est pas du matériel — aucun verrou n'est réarmé.
class _RenameRow extends StatefulWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
const _RenameRow({required this.entry, required this.provider});
@override
State<_RenameRow> createState() => _RenameRowState();
}
class _RenameRowState extends State<_RenameRow> {
bool _editing = false;
late final TextEditingController _c =
TextEditingController(text: widget.entry.label);
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!_editing) {
return Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () => setState(() => _editing = true),
icon: const Icon(Icons.edit_outlined, size: 15),
label: const Text('Renommer'),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
foregroundColor: EtmTokens.mutedOf(context),
textStyle: const TextStyle(fontSize: 12),
),
),
);
}
return Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(children: [
Expanded(
child: TextField(
controller: _c,
autofocus: true,
style: const TextStyle(fontSize: 14),
decoration: const InputDecoration(
isDense: true,
labelText: 'Libellé',
helperText: 'Sans effet sur le matériel — aucun verrou réarmé.',
helperMaxLines: 2,
),
onSubmitted: (_) => _commit(),
),
),
IconButton(
icon: const Icon(Icons.check_rounded, size: 20),
onPressed: _commit,
),
IconButton(
icon: const Icon(Icons.close_rounded, size: 20),
onPressed: () => setState(() {
_c.text = widget.entry.label;
_editing = false;
}),
),
]),
);
}
void _commit() {
final v = _c.text.trim();
if (v.isNotEmpty && v != widget.entry.label) {
widget.provider.setLabel(widget.entry.id, v);
}
setState(() => _editing = false);
}
}
/// Barre d'enregistrement — et **annonce de ce que l'écriture va provoquer**.
///
/// C'est la règle §5-3 du lot : `priority`, `domain`, `label` et `enabled` se mettent à
/// jour sans rien perturber, mais toucher `relays[]`, le mécanisme ou les Things
/// **reconstruit l'adaptateur** — contacts ouverts, verrous réarmés à froid. Un
/// installateur qui change un relais ne doit pas découvrir après coup que sa PAC est
/// bloquée un quart d'heure.
class ImpactSaveBar extends StatelessWidget {
final LoadConfigProvider provider;
const ImpactSaveBar({super.key, required this.provider});
@override
Widget build(BuildContext context) {
final impacts = provider.pendingImpact();
final warnings = impacts.where((i) => i.needsWarning).toList();
final pending = provider.saveState == LoadSaveState.pending;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
border: Border.all(
color: warnings.isEmpty
? EtmTokens.lineOf(context)
: EtmTokens.orange.withValues(alpha: 0.6),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (warnings.isNotEmpty) ...[
for (final w in warnings) _ImpactLine(impact: w),
const SizedBox(height: 8),
],
Row(children: [
Expanded(
child: Text(
warnings.isEmpty
? 'Modifications en attente — rien n\'est parti vers la box.'
: 'À confirmer : l\'enregistrement touche au matériel.',
style: TextStyle(
fontSize: 12, color: EtmTokens.mutedOf(context), height: 1.3),
),
),
TextButton(
onPressed: pending ? null : provider.discardDraft,
child: const Text('Annuler'),
),
const SizedBox(width: 4),
FilledButton(
onPressed: pending ? null : provider.save,
child: Text(pending ? 'Envoi…' : 'Enregistrer'),
),
]),
if (provider.saveState == LoadSaveState.rejected)
_RejectionNote(provider: provider),
],
),
);
}
}
/// Une ligne d'avertissement d'impact, qui nomme **ce qui** l'a déclenché.
///
/// « Vous avez changé l'ordre des contacteurs » est actionnable ; « la charge sera
/// reconstruite » ne l'est pas.
class _ImpactLine extends StatelessWidget {
final LoadImpact impact;
const _ImpactLine({required this.impact});
static const _fieldNames = {
'relays': 'la liste des contacteurs',
'sgReady': 'l\'encodage SG-Ready',
'minOnS': 'la temporisation de marche',
'minOffS': 'la temporisation d\'arrêt',
'maxPowerW': 'la puissance nominale',
'powerLevels': 'les paliers déclarés',
'adapter': 'le mécanisme',
'mode': 'le mode',
};
@override
Widget build(BuildContext context) {
final quoi = impact.changedFields
.map((f) => _fieldNames[f] ?? f)
.join(', ');
final rearm = impact.rearmSeconds;
final duree = rearm == null
? ''
: rearm >= 60
? ' pendant ${(rearm / 60).round()} min'
: ' pendant $rearm s';
final texte = switch (impact.impact) {
LoadChangeImpact.rebuild =>
'« ${impact.label} » — vous avez modifié $quoi. La charge sera reconstruite : '
'ses contacts passent par l\'état sûr, puis son verrou se réarme$duree.',
LoadChangeImpact.removed =>
'« ${impact.label} » quitte l\'arbitrage. Elle est amenée à son état sûr '
'(contacts ouverts) avant retrait.',
_ => '',
};
if (texte.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.warning_amber_rounded, size: 16, color: EtmTokens.orange),
const SizedBox(width: 7),
Expanded(
child: Text(texte,
style: TextStyle(
fontSize: 12,
height: 1.35,
color: EtmTokens.inkOf(context))),
),
],
),
);
}
}
/// Refus de la box. **Ne rien inventer** : `SetLoadConfig` rend un code sans motif, et
/// la règle enfreinte reste au journal de la box. On affiche le refus et on y renvoie.
class _RejectionNote extends StatelessWidget {
final LoadConfigProvider provider;
const _RejectionNote({required this.provider});
@override
Widget build(BuildContext context) {
final conflit = provider.conflict;
final err = provider.saveError;
return Padding(
padding: const EdgeInsets.only(top: 10),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: EtmTokens.danger.withValues(alpha: 0.09),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
conflit != null
? 'Deux charges revendiquent le même appareil.'
: 'La box a refusé l\'enregistrement${err == null ? '' : ' ($err)'}.',
style: const TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w700,
color: EtmTokens.danger),
),
const SizedBox(height: 4),
Text(
conflit != null
// LM-201 : deux charges sur un canal unique se contrediraient, et le
// budget compterait la même puissance deux fois. Ce n'est pas une
// limite technique — c'est le modèle.
? 'Un appareil ne peut appartenir qu\'à une seule charge : deux '
'charges sur un même canal de commande se contrediraient.'
: 'Le refus porte sur l\'ensemble : rien n\'a été modifié. La règle '
'enfreinte et le libellé en cause sont dans le journal de la box '
'(journalctl -u nymead).',
style: TextStyle(
fontSize: 11.5,
height: 1.35,
color: EtmTokens.mutedOf(context)),
),
],
),
),
);
}
}
class _EmptyNote extends StatelessWidget {
final String? error;
const _EmptyNote({this.error});
@override
Widget build(BuildContext context) {
final muted = EtmTokens.mutedOf(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 18),
child: Text(
error == null
? 'Aucune charge pilotable déclarée sur cette box.'
: 'Configuration illisible : $error',
style: TextStyle(fontSize: 12.5, color: muted, height: 1.35),
),
);
}
}
/// Petite flèche de rang, à l'intérieur d'un domaine.
///
/// Le glisser-déposer appartient au domaine ; à l'intérieur, deux charges se réordonnent
/// par ces flèches. Rendre les deux niveaux glissables est impossible — les listes
/// réordonnables imbriquées se disputent le geste, et c'est l'extérieure qui gagne.
class _FlecheRang extends StatelessWidget {
final IconData icone;
final bool actif;
final VoidCallback onTap;
const _FlecheRang({
super.key,
required this.icone,
required this.actif,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final muted = EtmTokens.mutedOf(context);
// 44 px, pas 48. Les deux flèches sont EMPILÉES dans la colonne de rang, sous le
// badge : à 48 chacune, la carte gagne près de 100 px de haut pour deux commandes
// secondaires. 44 est le plancher au-dessous duquel un pouce rate régulièrement, et
// il tient dans la carte. Mesurées à **18 × 18 px** au premier passage sur appareil —
// la taille de l'icône, sans aucune marge de touche.
return InkWell(
onTap: actif ? onTap : null,
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: 44,
height: 44,
child: Icon(icone,
size: 18,
color: actif ? muted : muted.withValues(alpha: 0.3)),
),
);
}
}