etm-powersync-app/lib/widgets/controllable_loads_section.dart
Patrick Schurig 3d2aab2b42 fix(config): les écrans suivent enfin la maquette — sélecteurs, table d'états, budget, borne
Je n'avais pas ouvert docs/mockups/ : les écrans ont été construits depuis le texte du brief
seul, et l'écart est franc. Les maquettes du 2026-08-25 font autorité, elles sont désormais
suivies.

ÉCRANS DE MÉCANISME, refaits :
- sélecteur de Things PAR NOM, filtré sur l'interface nymea (power / etmvariableload).
  Taper un UUID au doigt est une erreur garantie ET muette : la box accepte un UUID valide
  qui ne désigne rien, et la charge ne commande jamais rien. Le sélecteur grise les Things
  déjà pris — la box refuse en bloc un même Thing sur deux lignes — et signale en rouge un
  ThingId configuré qui n'existe plus sur la box ;
- routeur de relais : ajout/retrait de contacteurs, puissance par ligne, et les paliers
  atteignables AFFICHÉS. Ils sont déduits (2^N combinaisons, doublons écartés), jamais
  enregistrés — la maquette est explicite là-dessus, ma note précédente disant l'inverse
  était une sur-interprétation du brief ;
- SG-Ready : SG1/SG2 se choisissent EN HAUT, une fois, et la table des quatre états devient
  éditable — ce qui s'y coche est fermé/ouvert, et c'est ça qui devient relays[]. Les
  étiquettes SG1/SG2 sont une convention de l'écran (SG1 = fermé en état 1, SG2 = en état 3)
  et elles TOMBENT sur un encodage non standard, où les contacts s'affichent tels quels ;
- modulable : plafond requis > 0, et l'avertissement que changer d'appareil crée une autre
  charge puisque l'identifiant EST le ThingId ;
- temporisations minOnS/minOffS (et minStateHoldS pour la PAC) en pas de 30/60 s ;
- « Compteur dédié » présent et GRISÉ, avec sa raison : aucun champ n'existe côté box, et
  inventer meterThingId ferait rejeter l'enregistrement de TOUTES les charges ;
- « Inclure dans l'arbitrage » avec rang et domaine, et ce que la désactivation fait
  vraiment selon le mécanisme ;
- validation AVANT envoi : relays[] vide, powerW ≤ 0, Thing en double, état 2 absent,
  maxPowerW ≤ 0. SetLoadConfig refuse en bloc et sans motif — laisser partir une config
  invalide condamne l'installateur à un refus muet qui perd tout l'appel.

LISTE :
- l'en-tête d'arbitrage et le tableau de budget, PERDUS à la fusion, sont rétablis. Sans
  eux « Alloué 3 000 W » ne se rattache à rien ;
- le rang passe en pastille près de la poignée, et le sous-titre devient lisible :
  « Routeur de relais · 3 relais · min ON 60 s » au lieu de « Routeur de relais · rang 1 » ;
- section « Recharge véhicule » : la borne n'est pas une LoadConfig, elle n'apparaissait donc
  NULLE PART — ce qu'un installateur qui vient de la régler lit comme une perte.

MENU : l'entrée « Ordre de service des charges » est retirée ; elle pointait sur une
redirection, donc sur le même écran sous deux noms.

Tests 80/80, analyze 0 erreur.

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

754 lines
28 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../l10n/l10n_ext.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/load_priority.dart';
import '../services/nymea_service.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 StatelessWidget {
/// 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
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: [
// Le budget du cycle AVANT les charges : « 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.
TelemetryHeader(telemetry: telemetry),
for (var i = 0; i < groups.length; i++)
_DomainBlock(
key: ValueKey('domain-${groups[i].domain}'),
group: groups[i],
index: i,
total: groups.length,
provider: provider,
telemetry: telemetry,
onConfigure: onConfigure,
),
// La borne n'est PAS une LoadConfig : elle n'apparaît donc dans aucun domaine
// ci-dessus, et sans cette section elle serait tout simplement invisible — ce
// qu'un installateur qui vient de la configurer lit comme une perte.
const BorneSection(),
if (provider.hasPendingChanges) ...[
const SizedBox(height: 4),
ImpactSaveBar(provider: provider),
],
],
);
}
}
/// 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;
const _DomainBlock({
super.key,
required this.group,
required this.index,
required this.total,
required this.provider,
required this.telemetry,
required this.onConfigure,
});
@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: [
Expanded(
child: Text(
domainLabel(t, group.domain),
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 13,
letterSpacing: 0.2,
color: group.isUnclassified ? muted : EtmTokens.inkOf(context),
),
),
),
IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.keyboard_arrow_up_rounded, size: 20),
tooltip: 'Monter ce domaine',
onPressed:
index == 0 ? null : () => provider.reorderDomains(index, index - 1),
),
IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.keyboard_arrow_down_rounded, size: 20),
tooltip: 'Descendre ce domaine',
onPressed: index >= total - 1
? null
: () => provider.reorderDomains(index, index + 2),
),
]),
const SizedBox(height: 6),
ReorderableListView.builder(
shrinkWrap: true,
primary: false,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
itemCount: group.loads.length,
onReorder: (o, n) => provider.reorderWithinDomain(group.domain, o, n),
itemBuilder: (context, i) => Padding(
key: ValueKey('load-${group.loads[i].id}'),
padding: const EdgeInsets.only(bottom: 10),
child: LoadConfigCard(
entry: group.loads[i],
handleIndex: i,
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;
final int handleIndex;
final LoadConfigProvider provider;
final LoadTelemetryProvider telemetry;
final void Function(BuildContext, LoadConfigEntry) onConfigure;
const LoadConfigCard({
super.key,
required this.entry,
required this.handleIndex,
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: [
ReorderableDragStartListener(
index: handleIndex,
child: Padding(
padding: const EdgeInsets.only(top: 1, right: 9),
child: Column(children: [
Icon(Icons.drag_indicator_rounded, size: 20, color: muted),
const SizedBox(height: 3),
// Le rang est ce que le glisser-déposer produit : il se lit ici,
// pas noyé dans une phrase.
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)),
),
]),
),
),
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(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),
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),
),
);
}
}
/// Les bornes de recharge, qui **ne sont pas des charges pilotables**.
///
/// ## Pourquoi elles ont leur propre section
///
/// Une borne n'a ni `adapter`, ni `priority`, ni `domain` : elle ne passe pas par
/// `SetLoadConfig` mais par `SetChargingInfo`, un **autre contrat**. Et elle n'apparaît
/// pas dans `GetLoadTelemetry` en beta — elle est décidée **en amont** du waterfall, sa
/// puissance étant seulement réservée dans `budget.evReservedW`. Elle rejoindra `loads[]`
/// à l'étape 3g.
///
/// Conséquence pratique, et c'est le défaut que cette section corrige : configurée ou non,
/// la borne n'apparaissait **nulle part** dans cet écran. Un installateur qui vient de la
/// régler et ne la retrouve pas conclut, raisonnablement, que son réglage s'est perdu.
class BorneSection extends StatelessWidget {
const BorneSection({super.key});
@override
Widget build(BuildContext context) {
final service = context.watch<NymeaService>();
final classesBorne = {
for (final c in service.thingClasses)
if (c.interfaces.contains('evcharger')) c.id
};
final bornes = [
for (final t in service.things)
if (classesBorne.contains(t.thingClassId)) t
];
if (bornes.isEmpty) return const SizedBox.shrink();
final muted = EtmTokens.mutedOf(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 6),
Text('Recharge véhicule',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
color: EtmTokens.inkOf(context))),
const SizedBox(height: 8),
for (final b in bornes)
Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
border: Border.all(color: EtmTokens.lineOf(context)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(Icons.ev_station_rounded, size: 19, color: muted),
const SizedBox(width: 9),
Expanded(
child: Text(b.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: EtmTokens.inkOf(context))),
),
]),
const SizedBox(height: 6),
Text(
'Pas une charge pilotable : la borne se règle par un autre contrat '
'(SetChargingInfo) et n\'a ni rang ni domaine. Elle est décidée en '
'amont du waterfall — sa puissance est réservée dans le budget, pas '
'allouée depuis lui.',
style: TextStyle(fontSize: 11.5, height: 1.35, color: muted),
),
const SizedBox(height: 4),
Text(
'Le mode de recharge se règle depuis la carte véhicule du tableau de '
'bord.',
style: TextStyle(fontSize: 11.5, height: 1.35, color: muted),
),
],
),
),
],
);
}
}