etm-powersync-app/lib/widgets/controllable_loads_section.dart
Patrick Schurig cd76df3a73 feat(etm16): s'aligner sur le nouveau contrat — table de paliers, stagesW, compteur et sonde
RELAIS : mon avertissement de reconstruction sur un réordonnancement est devenu FAUX, et
il est corrigé. `+etm16` compare la TABLE que les relais produisent (deriveStages), plus la
liste par index. Le miroir Dart suit, y compris le détail qui compte : les ThingIds d'une
combinaison se comparent comme un ENSEMBLE — collectés dans l'ordre de déclaration, fermer
{K1,K2} ou {K2,K1} est le même geste électrique. Mais l'appariement palier par palier
reste positionnel, ce qui préserve le seul cas où l'ordre signifie encore quelque chose :
deux relais de MÊME puissance, où il décide quel contact sert ce palier.

Conséquence pour l'écran : réordonner des contacteurs de puissances distinctes n'avertit
plus de rien. Avertir sur un geste devenu gratuit serait aussi trompeur que de se taire sur
un geste coûteux.

PALIERS : l'app ne dérive plus la combinatoire pour l'affichage — la box les publie
(mechanism.stagesW). Vérifié sur appareil : [0, 500, 1000, 1500, 2000, 2500, 3000, 3500].
deriveStages reste côté app pour la seule annonce d'impact, qui se fait avant l'écriture.
Tant que le brouillon n'a pas touché aux contacteurs, c'est la table PUBLIÉE qui s'affiche ;
dès qu'il y touche elle devient périmée, et l'écran montre une prévision annoncée comme
telle plutôt qu'une table qui décrit l'état d'avant.

COMPTEUR ET SONDE : ils ne « vont pas arriver », ils SONT au schéma de .75
(o:meterThingId, o:sensorThingId). Le grisage dérivé les a donc activés seul, sans nouvelle
version de l'app — exactement ce qui était promis. Mais un bloc actif sur un placeholder
mort est pire qu'un bloc grisé : les deux sont désormais câblés sur un vrai sélecteur,
filtré par INTERFACE (smartmeter, temperaturesensor) et jamais par nom de plugin.

Ni l'un ni l'autre n'entre dans sameHardware() : les rattacher ne reconstruit rien. Ni dans
le contrôle d'unicité : deux charges peuvent partager une sonde, un compteur divisionnaire
en couvrir plusieurs — le sélecteur ne grise donc rien ici, contrairement aux contacteurs.
Et la mesure sert à VÉRIFIER, jamais à décider : elle n'entre pas dans le budget, la charge
étant déjà comptée dans le racine dont elle n'est qu'une décomposition.

COMPTEURS CUMULÉS : la base purgée, les valeurs sont saines (55 730 / 50 / 6 327 / 62 007
kWh) et la tuile « compteur box invalide » a disparu d'elle-même. Le plafond de
plausibilité reste : un zéro réel s'affiche « 0,0 kWh », jamais « — ».

hasNeverRun était déjà traité comme un état distinct — « l'absence de cycle est dite pour
elle-même, jamais rendue par un âge de zéro ».

Vérifié sur appareil contre .75 : compteur racine relu, « à configurer » vide, deux bornes
listées (Simulated wallbox + Terra AC Charger), stagesW de la box, les deux champs
writable, 5/5 sections de l'écran SG-Ready. Tests 98/98, analyze 0 erreur.

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

829 lines
31 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),
// 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: 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: [
ReorderableDragStartListener(
key: ValueKey('poignee-domaine-${group.domain}'),
index: index,
child: Container(
color: Colors.transparent,
padding: const EdgeInsets.only(right: 7, top: 4, bottom: 4),
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),
),
),
),
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),
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)),
),
if (totalDansDomaine > 1) ...[
const SizedBox(height: 2),
_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),
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),
),
],
),
),
],
);
}
}
/// 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);
return InkWell(
onTap: actif ? onTap : null,
borderRadius: BorderRadius.circular(4),
child: Icon(icone,
size: 18,
color: actif ? muted : muted.withValues(alpha: 0.3)),
);
}
}