etm-powersync-app/lib/screens/energy/load_mechanism_screen.dart
Patrick Schurig 71bb26c81b feat(saisie): l'horloge se qualifie, elle ne s'offre pas en réglage
« 12:00 heure de la box (Europe/London) — pas celle de cet appareil », et non
« Fuseau : Europe/Paris » : sous un champ, un intitulé de réglage laisse croire
qu'on choisit le fuseau DE CETTE échéance. Il n'y en a qu'un, il est système, et
il déplace d'un coup toutes les heures murales de l'installation — échéances,
frontière de la journée de reporting, et demain les plages tarifaires
(LM-1005-b-iii).

Affichage CONDITIONNEL : seulement quand les deux horloges divergent. C'est la
règle déjà appliquée deux fois cette semaine — ligne des ratios instantanés,
avertissement « partiel » du bilan — et sa raison tient en une phrase : un texte
permanent cesse d'être lu le jour exact où il devient vrai.

La divergence se DÉDUIT de ce que la box publie, sans base de fuseaux :
`periodStartedAt` est minuit de l'installation en epoch, donc rendu dans le
fuseau de l'appareil il tombe sur 00:00 si et seulement si les deux partagent le
même décalage. Exact pour l'instant présent, y compris les jours à 23 ou 25
heures — et c'est justement pourquoi nous avions demandé cette frontière plutôt
qu'un nom IANA.

Quatre silences testés : fuseaux identiques, échéance vide, box qui ne publie pas
la date, et l'intitulé « Fuseau : » qui ne doit jamais apparaître.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-06 09:22:33 +02:00

2312 lines
90 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../l10n/l10n_ext.dart';
import '../../models/energy_data.dart' show ChargingMode;
import '../../models/ev_charger_live.dart';
import '../../models/load_config_entry.dart';
import '../../models/load_telemetry.dart';
import '../../providers/load_config_provider.dart';
import '../../providers/load_telemetry_provider.dart';
import '../../providers/schema_provider.dart';
import '../../services/load_hardware.dart';
import '../../models/energy_ratios_snapshot.dart';
import '../../services/telemetry_text.dart' show energieCourte;
import '../../services/load_mechanism_switch.dart';
import '../../services/load_priority.dart';
import '../../services/nymea_service.dart';
import '../../services/rpc_schema.dart';
import '../../theme/etm_tokens.dart';
import '../../widgets/load_cards.dart';
import '../../widgets/ev_mode_buttons.dart';
import '../../widgets/garde_brouillon.dart';
import '../../widgets/schema_gated.dart';
import '../../widgets/thing_picker.dart';
/// **Configurer** une charge — un écran, trois charges utiles qui ne se ressemblent pas.
///
/// Le bouton « Configurer » ne peut pas ouvrir un formulaire unique : un routeur de relais
/// déclare des **contacteurs**, une charge modulable un **plafond**, une PAC SG-Ready un
/// **encodage à quatre états normés**. Ce qui est commun — nom, domaine, temporisations,
/// arbitrage — encadre une section centrale qui, elle, change entièrement.
///
/// ## Tout ce qui se règle ici n'a pas le même coût
///
/// `label`, `domain` et `enabled` passent en **mise à jour sur place**. Tout le reste —
/// contacteurs, table d'états, plafond, temporisations — franchit `sameHardware()` :
/// l'adaptateur est **reconstruit**, ses contacts passent par l'état sûr et ses verrous se
/// réarment à froid. L'écran l'annonce **avant** d'écrire.
///
/// ## Valider avant d'envoyer
///
/// `SetLoadConfig` refuse **en bloc et sans motif** : la règle enfreinte reste au journal
/// de la box. Un formulaire qui laisse partir une configuration invalide condamne donc
/// l'installateur à un refus muet, et fait perdre **toutes** les charges de l'appel. D'où
/// [_valider], qui reprend les règles du plugin côté app.
class LoadMechanismScreen extends StatefulWidget {
final String loadId;
const LoadMechanismScreen({super.key, required this.loadId});
@override
State<LoadMechanismScreen> createState() => _LoadMechanismScreenState();
}
class _LoadMechanismScreenState extends State<LoadMechanismScreen> {
LoadConfigEntry? _entree(LoadConfigProvider p) {
for (final g in p.groups) {
for (final e in g.loads) {
if (e.id == widget.loadId) return e;
}
}
return null;
}
@override
Widget build(BuildContext context) {
final provider = context.watch<LoadConfigProvider>();
final schema = context.watch<SchemaProvider>();
final service = context.watch<NymeaService>();
final telemetry = context.watch<LoadTelemetryProvider>();
final e = _entree(provider);
// Cet écran modifie le MÊME brouillon que la liste des charges : quitter sans
// enregistrer y laisse la même modification invisible.
return GardeBrouillon(
child: Scaffold(
backgroundColor: EtmTokens.bgOf(context),
appBar: AppBar(
title: Text(e == null ? 'Charge' : 'Configurer · ${e.label}'),
backgroundColor: EtmTokens.surfaceOf(context),
foregroundColor: EtmTokens.inkOf(context),
elevation: 0,
),
body: e == null
? const Center(child: Text('Charge introuvable dans la configuration.'))
: ListView(
key: const ValueKey('liste-mecanisme'),
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
children: [
SchemaVersionBanner(
versionLine: schema.versionLine(),
schemaLoaded: schema.schema.loaded,
),
const SizedBox(height: 14),
_Identite(entry: e, provider: provider),
const SizedBox(height: 14),
_Mecanisme(entry: e, provider: provider),
const SizedBox(height: 14),
// Une borne ne porte AUCUNE charge utile de mécanisme, et pas non plus
// de temporisation : `minOnS`/`minOffS` valent 0 et ne se règlent pas
// ici. Le repli `_SectionModulable` lui proposait une puissance nominale
// et des paliers — des réglages qui n'existent pas pour elle, et dont
// l'écriture partirait quand même dans `SetLoadConfig`.
if (e.isEvCharger)
_SectionBorne(
entry: e, telemetry: telemetry, service: service)
else if (e.isRelayRouter)
_SectionRelais(
entry: e,
provider: provider,
service: service,
telemetry: telemetry)
else if (e.isSgReady)
_SectionSgReady(entry: e, provider: provider, service: service)
else
_SectionModulable(
entry: e,
provider: provider,
service: service,
schema: schema,
telemetry: telemetry),
const SizedBox(height: 14),
if (e.isEvCharger)
const SizedBox.shrink()
else if (e.isSgReady)
_Temporisation(
titre: 'Anti court-cycle',
sousTitre: 'Durée avant tout changement d\'état.',
lignes: [
_LigneDuree(
libelle: 'Maintien d\'état minimal',
aide: 'produit le verrou LOCK_MIN_STATE_HOLD',
valeur: e.minStateHoldS,
onChange: (v) => _majSgReady(provider, e, hold: v),
),
],
)
else
_Temporisation(
titre: 'Temporisations',
sousTitre:
'Ces deux durées produisent les verrous que la carte de la '
'charge affiche en secondes restantes.',
lignes: [
_LigneDuree(
libelle: 'Maintien ON minimal',
aide: 'anti court-cycle à l\'enclenchement',
valeur: e.minOnS,
onChange: (v) => provider.replaceEntry(
e.id, e.patched(minOnS: v)),
),
_LigneDuree(
libelle: 'Maintien OFF minimal',
aide: 'délai avant réenclenchement',
valeur: e.minOffS,
onChange: (v) => provider.replaceEntry(
e.id, e.patched(minOffS: v)),
),
],
),
const SizedBox(height: 14),
_CompteurEtSonde(
entry: e,
provider: provider,
schema: schema.schema,
service: service,
telemetry: telemetry),
const SizedBox(height: 14),
_SectionObligation(entry: e, provider: provider),
const SizedBox(height: 14),
_Arbitrage(entry: e, provider: provider),
const SizedBox(height: 18),
_BarreEnregistrement(
entry: e, provider: provider, service: service),
],
),
));
}
void _majSgReady(LoadConfigProvider p, LoadConfigEntry e, {required int hold}) {
p.replaceEntry(
e.id,
e.patched(sgReady: {
'minStateHoldS': hold,
'states': [for (final s in e.sgReadyStates) s.toMap()],
}),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Identité — nom, domaine
// ═══════════════════════════════════════════════════════════════════════════
class _Identite extends StatefulWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
const _Identite({required this.entry, required this.provider});
@override
State<_Identite> createState() => _IdentiteState();
}
class _IdentiteState extends State<_Identite> {
late final TextEditingController _c =
TextEditingController(text: widget.entry.label);
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final t = context.l10n;
return _Carte(
titre: 'Nom de la charge',
badge: 'enregistré · label',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextField(
controller: _c,
style: const TextStyle(fontSize: 14),
decoration: const InputDecoration(isDense: true),
onChanged: (v) {
if (v.trim().isNotEmpty) {
widget.provider.setLabel(widget.entry.id, v.trim());
}
},
),
const SizedBox(height: 6),
_Aide(
'Le seul texte de l\'installation que l\'utilisateur voit dans chaque motif '
'de décision.',
),
const SizedBox(height: 14),
_SousTitre('Domaine', badge: 'enregistré · domain'),
const SizedBox(height: 6),
Align(
alignment: Alignment.centerLeft,
child: DomainMenu(entry: widget.entry, provider: widget.provider),
),
const SizedBox(height: 4),
_Aide(
'Métadonnée d\'intention : elle groupe l\'ordre de service. '
'L\'arbitre ne la lit pas — ${domainLabel(t, widget.entry.domain ?? kUnclassifiedDomain)}.',
),
],
),
);
}
}
/// Le mécanisme, et il **se change** — c'est un champ enregistré (`adapter`).
///
/// Le geste n'est pas anodin : `adapter` et `mode` entrent dans `sameHardware()`, donc
/// basculer **reconstruit l'adaptateur** (contacts à l'état sûr, verrous réarmés à froid).
/// Et la charge utile du mécanisme quitté doit **partir** — la box refuse en bloc une
/// configuration qui porterait celle d'un autre. D'où la confirmation, qui dit les deux.
class _Mecanisme extends StatelessWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
const _Mecanisme({required this.entry, required this.provider});
Future<void> _demander(BuildContext context, String vers) async {
if (vers == entry.adapter) return;
final ok = await showDialog<bool>(
context: context,
builder: (c) => AlertDialog(
title: Text('Passer en « ${kMecanismeLabels[vers]} » ?'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'La charge sera RECONSTRUITE : ses contacts passent par leur état sûr, '
'puis ses verrous se réarment à froid.',
style: TextStyle(fontSize: 13, height: 1.4, color: EtmTokens.inkOf(c)),
),
const SizedBox(height: 10),
Text(
_cePartQuoi(vers),
style: TextStyle(
fontSize: 12, height: 1.4, color: EtmTokens.mutedOf(c)),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(c).pop(false),
child: const Text('Annuler')),
FilledButton(
onPressed: () => Navigator.of(c).pop(true),
child: const Text('Changer')),
],
),
);
if (ok == true) {
provider.replaceEntry(entry.id, basculerMecanisme(entry, vers));
}
}
/// Ce que la bascule emporte et ce qu'elle récupère — dit AVANT, pas après.
String _cePartQuoi(String vers) => switch (vers) {
'relay-router' =>
'Les contacts déjà déclarés deviennent des lignes de relais ; leur puissance '
'reste à saisir — une estimation d\'état SG-Ready n\'est pas la puissance '
'd\'une résistance.',
'etmvariableload' =>
'Les contacts et la table d\'états sont retirés : ce mécanisme ne commande '
'aucun relais. Une puissance nominale sera requise.',
_ =>
'Les deux premiers contacts deviennent SG1 et SG2 dans l\'encodage standard, '
'et les puissances estimées restent à renseigner.',
};
@override
Widget build(BuildContext context) {
// Une BORNE n'est pas un mécanisme au choix. Son entrée est créée d'office par le
// plugin à la détection du Thing, et `adapter: "evcharger"` n'est pas dans
// [kMecanismes] : proposer les trois autres reviendrait à offrir de transformer une
// borne en routeur de relais — une bascule que `basculerMecanisme()` sait construire
// et qui détruirait le rattachement, sans qu'aucun écran n'ait de quoi le défaire.
if (entry.isEvCharger) {
return _Carte(
titre: 'Mécanisme',
badge: 'imposé · evcharger',
sousTitre: 'La borne est déclarée par la box à la détection de l\'appareil.',
child: Text(
'Une borne ne porte aucun réglage de mécanisme : ni contacts, ni paliers, ni '
'puissance nominale. Ses limites viennent de la borne et de la voiture '
'branchée, et changent avec elle — les figer ici en ferait une seconde source '
'de vérité, fausse dès le prochain véhicule.\n\n'
'Ce qui se règle depuis cet écran : le libellé, le domaine, le rang. Le mode de '
'recharge (PV, Min+PV, Boost) se règle depuis la tuile « Borne EV » des '
'favoris — c\'est le seul endroit de l\'app qui écrit SetChargingInfo.',
style: TextStyle(
fontSize: 12, height: 1.4, color: EtmTokens.mutedOf(context)),
),
);
}
return _Carte(
titre: 'Mécanisme',
badge: 'enregistré · adapter',
sousTitre: 'Où sont les contacteurs ? Sur les sorties de la box → routeur de '
'relais. Dans l\'appareil, piloté par une consigne → modulable.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Wrap(
spacing: 8,
runSpacing: 8,
children: [
for (final m in kMecanismes)
InkWell(
borderRadius: BorderRadius.circular(9),
onTap: () => _demander(context, m),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: 11, vertical: 7),
decoration: BoxDecoration(
color: m == entry.adapter
? EtmTokens.brand.withValues(alpha: 0.14)
: Colors.transparent,
borderRadius: BorderRadius.circular(9),
border: Border.all(
color: m == entry.adapter
? EtmTokens.brand.withValues(alpha: 0.5)
: EtmTokens.lineOf(context)),
),
child: Text(kMecanismeLabels[m]!,
style: TextStyle(
fontSize: 12,
fontWeight: m == entry.adapter
? FontWeight.w700
: FontWeight.w400,
color: m == entry.adapter
? EtmTokens.brand
: EtmTokens.mutedOf(context),
)),
),
),
],
),
if (estHeritageFixed(entry)) ...[
const SizedBox(height: 10),
_HeritageFixed(entry: entry, provider: provider),
],
],
),
);
}
}
/// Borne de recharge — **rien à régler ici, et le dire vaut mieux que se taire**.
///
/// L'entrée `evcharger` est créée d'office par le plugin à la détection du Thing, pour que
/// l'invariant `loads[] ⊆ GetLoadConfig` tienne : une charge publiée en télémétrie doit
/// pouvoir résoudre son libellé, son domaine et son rang quelque part. Elle ne porte
/// **aucune** charge utile de mécanisme.
///
/// Ce que cette section montre est donc du **runtime**, en lecture seule : le nombre de
/// phases, le courant, le plancher, et la présence d'un véhicule. Les afficher n'est pas
/// décoratif — c'est ici que se lit l'écart entre ce que l'arbitre a commandé et ce que la
/// borne fait, écart que l'allocation seule ne montre jamais.
///
/// Et le plancher d'une borne n'est pas un réglage : il vaut `max(borne, voiture)` et
/// change avec le véhicule branché. Le proposer à la saisie créerait une seconde source de
/// vérité, fausse dès la voiture suivante.
class _SectionBorne extends StatelessWidget {
final LoadConfigEntry entry;
final LoadTelemetryProvider telemetry;
final NymeaService service;
const _SectionBorne(
{required this.entry, required this.telemetry, required this.service});
@override
Widget build(BuildContext context) {
final muted = EtmTokens.mutedOf(context);
final live = telemetry.entryFor(entry.id);
final mech = live?.mechanism;
// La borne DE CET ÉCRAN, pas « la première configurée » : le banc en porte trois.
final borne = service.evChargerFor(entry.id);
final lignes = <String>[];
if (mech != null) {
if (mech.phaseCount != null) lignes.add('${mech.phaseCount} phase(s)');
if (mech.currentA != null) {
lignes.add('${mech.currentA!.toStringAsFixed(1)} A');
}
if (mech.minPowerW != null) {
lignes.add('plancher ${mech.minPowerW!.round()} W');
}
if (mech.pluggedIn != null) {
lignes.add(mech.pluggedIn! ? 'véhicule branché' : 'aucun véhicule');
}
}
return _Carte(
titre: 'Borne',
badge: 'lecture seule · runtime',
sousTitre: 'Ce que la box publie pour cette borne au dernier cycle.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (lignes.isEmpty)
// Absente de la télémétrie : configurée, mais hors arbitrage EN CE MOMENT.
// Depuis +etm24, c'est notamment le cas quand aucun véhicule n'est branché.
// « Perdue » serait faux, et « désactivée » aussi.
Text(
'Hors arbitrage en ce moment — la box ne publie rien pour elle au dernier '
'cycle. C\'est le cas sans véhicule branché, sans voiture assignée, ou en '
'mode manuel. La borne n\'est ni perdue ni désactivée : son entrée de '
'configuration est là, au-dessus.',
style: TextStyle(fontSize: 12, height: 1.4, color: muted),
)
else ...[
Text(lignes.join(' · '),
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: EtmTokens.inkOf(context))),
const SizedBox(height: 6),
Text(
'Mesuré, pas réglé. Le plancher d\'une borne vaut max(borne, voiture) et '
'suit le véhicule branché : il n\'y a pas de champ à saisir, et il ne faut '
'pas en fabriquer un.',
style: TextStyle(fontSize: 11.5, height: 1.35, color: muted),
),
],
// ── LE SEUL RÉGLAGE d'une borne, et le seul lieu où il s'écrit ────
//
// Il vivait sur la tuile des favoris. Il est ici depuis le 2026-08-28 : c'est
// l'écran de mise en service, celui où on découvre qu'une borne neuve est en
// ChargingModeNormal — pleine puissance, hors arbitrage. La tuile garde la
// lecture et l'avertissement, pas l'écriture : deux endroits qui écrivent
// SetChargingInfo sont deux comportements qui divergent.
if (borne != null) ...[
const SizedBox(height: 14),
Divider(height: 1, color: EtmTokens.lineOf(context)),
const SizedBox(height: 12),
Row(children: [
Text('Mode de recharge',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: EtmTokens.inkOf(context))),
const SizedBox(width: 8),
Expanded(
child: Text(_modeLu(borne),
overflow: TextOverflow.ellipsis,
style: TextStyle(fontSize: 11.5, color: muted)),
),
]),
const SizedBox(height: 8),
EvModeButtons(
live: borne,
onSetMode: (m) =>
service.setChargingInfo(mode: m, evChargerId: borne.thingId),
),
if (borne.isUnmanaged) ...[
const SizedBox(height: 8),
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
Icon(Icons.flash_on_rounded, size: 15, color: EtmTokens.solar),
const SizedBox(width: 6),
Expanded(
child: Text(kEvUnmanagedNote,
style:
TextStyle(fontSize: 11.5, height: 1.35, color: muted)),
),
]),
],
] else ...[
const SizedBox(height: 10),
Text(
'Mode de recharge non lu : la box n\'a pas encore rendu de ChargingInfo '
'pour cette borne. Sans lecture, l\'écran ne propose pas d\'écriture — un '
'bouton allumé au hasard vaudrait moins que rien.',
style: TextStyle(fontSize: 11.5, height: 1.35, color: muted),
),
],
],
),
);
}
/// Le mode **que la box porte**, jamais celui que l'app vient de demander.
String _modeLu(EvChargerLive b) {
if (b.apiMode == null) return 'mode non lu';
final ui = b.uiMode;
if (ui == null) return 'mode non reconnu (${b.apiMode})';
final base = switch (ui) {
ChargingMode.pv => 'PV',
ChargingMode.minPv => 'Min+PV',
ChargingMode.boost => 'Boost',
};
return b.hasDeadlineMode ? 'lu : $base + échéance' : 'lu : $base';
}
}
/// Configuration **héritée** : `etmvariableload` en multipalier natif (`fixed`).
///
/// L'app ne propose plus ce mode — le plugin lui-même le qualifie de *legacy* — mais elle
/// ne le réécrit pas en douce : basculer `fixed` vers `dynamic` change l'arbitrage réel de
/// la charge, et cela demande un geste explicite. **Ouvrir cet écran ne convertit
/// personne.**
class _HeritageFixed extends StatelessWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
const _HeritageFixed({required this.entry, required this.provider});
Future<void> _convertir(BuildContext context) async {
final ctrl = TextEditingController(
text: entry.powerLevelsInt.isEmpty
? ''
: entry.powerLevelsInt.last.toString());
final w = await showDialog<int>(
context: context,
builder: (c) => AlertDialog(
title: const Text('Convertir en modulation continue'),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'La box refuse « dynamic » sans plafond, et refuse les paliers avec. La '
'déclaration en paliers ne porte pas de puissance nominale : il faut donc '
'la fournir.',
style: TextStyle(
fontSize: 12.5, height: 1.4, color: EtmTokens.mutedOf(c)),
),
const SizedBox(height: 12),
TextField(
controller: ctrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
isDense: true,
labelText: 'Puissance nominale',
suffixText: 'W'),
),
],
),
actions: [
TextButton(
onPressed: () => Navigator.of(c).pop(),
child: const Text('Annuler')),
FilledButton(
onPressed: () {
final n = int.tryParse(ctrl.text.trim());
if (n != null && n > 0) Navigator.of(c).pop(n);
},
child: const Text('Convertir'),
),
],
),
);
if (w != null && w > 0) {
provider.replaceEntry(entry.id, convertirEnModulation(entry, w));
}
}
@override
Widget build(BuildContext context) {
return Container(
width: double.infinity,
padding: const EdgeInsets.all(11),
decoration: BoxDecoration(
color: EtmTokens.orange.withValues(alpha: 0.09),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Configuration héritée — lecture seule · mode fixed',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w700,
color: EtmTokens.inkOf(context))),
const SizedBox(height: 4),
Text('Paliers déclarés : ${entry.powerLevelsInt.join(' · ')} W',
style: TextStyle(
fontSize: 11.5,
fontFamily: 'monospace',
color: EtmTokens.inkOf(context))),
const SizedBox(height: 5),
Text(
'Le multipalier natif n\'est plus proposé, mais il n\'est pas réécrit sans '
'votre accord : passer en modulation continue change l\'arbitrage réel de '
'cette charge.',
style: TextStyle(
fontSize: 11, height: 1.35, color: EtmTokens.mutedOf(context)),
),
Align(
alignment: Alignment.centerLeft,
child: TextButton(
onPressed: () => _convertir(context),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
foregroundColor: EtmTokens.orange),
child: const Text('Convertir en modulation continue…'),
),
),
],
),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Routeur de relais
// ═══════════════════════════════════════════════════════════════════════════
/// Les contacteurs, et **rien d'autre de matériel**.
///
/// Les paliers atteignables sont **publiés par la box** (`mechanism.stagesW`, depuis
/// `+etm16`) et jamais enregistrés : ce sont les relais qui se déclarent, les paliers s'en
/// déduisent. L'app les LIT au lieu de refaire la combinatoire — une seule fonction moteur
/// sert la construction du routeur, la comparaison matérielle et cette publication, donc
/// plus de divergence possible.
///
/// Tant que le brouillon n'a pas touché aux contacteurs, c'est cette table publiée qui
/// s'affiche. Dès qu'il y touche, elle devient périmée : on montre alors une **prévision**,
/// annoncée comme telle — mieux vaut dire « voici ce que ça donnera » que d'afficher une
/// table qui décrit encore l'état d'avant.
class _SectionRelais extends StatelessWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
final NymeaService service;
final LoadTelemetryProvider telemetry;
const _SectionRelais({
required this.entry,
required this.provider,
required this.service,
required this.telemetry,
});
/// Vrai si le brouillon a touché aux contacteurs — la table publiée est alors périmée.
bool get _relaisModifies {
for (final e in provider.entries) {
if (e.id == entry.id) return hardwareDiff(e, entry).contains('relays');
}
return false;
}
void _remplacer(List<Map<String, dynamic>> relais) =>
provider.replaceEntry(entry.id, entry.patched(relays: relais));
List<Map<String, dynamic>> get _actuels =>
[for (final r in entry.relays) r.toMap()];
@override
Widget build(BuildContext context) {
// La table PUBLIÉE par la box fait foi. Elle décrit ce qui tourne — donc elle est
// périmée dès que le brouillon touche aux contacteurs.
final publiee = telemetry.entryFor(entry.id)?.mechanism?.stagesW;
final aJour = publiee != null && !_relaisModifies;
final p = aJour ? publiee : deriveStages(entry.relays).levels;
final pris = {
for (final r in entry.relays) normalizeThingId(r.thingId),
};
return _Carte(
titre: 'Relais commandés',
badge: 'enregistré · relays[]',
sousTitre:
'Un contacteur par ligne — puissance de la résistance câblée derrière.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
for (var i = 0; i < entry.relays.length; i++)
_LigneRelais(
index: i,
relais: entry.relays[i],
service: service,
dejaPris: pris,
onThing: (id) {
final l = _actuels;
l[i] = {'thingId': id, 'powerW': entry.relays[i].powerW};
_remplacer(l);
},
onPuissance: (w) {
final l = _actuels;
l[i] = {'thingId': entry.relays[i].thingId, 'powerW': w};
_remplacer(l);
},
onSupprimer: entry.relays.length <= 1
? null
: () {
final l = _actuels..removeAt(i);
_remplacer(l);
},
),
Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () =>
_remplacer([..._actuels, {'thingId': '', 'powerW': 0}]),
icon: const Icon(Icons.add_rounded, size: 18),
label: const Text('Ajouter un relais'),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
foregroundColor: EtmTokens.brand),
),
),
const SizedBox(height: 6),
Container(
width: double.infinity,
padding: const EdgeInsets.all(11),
decoration: BoxDecoration(
color: EtmTokens.brand.withValues(alpha: 0.07),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
aJour
? '${entry.relays.length} relais → paliers publiés par la box :'
: '${entry.relays.length} relais → paliers prévus :',
style: TextStyle(
fontSize: 11.5,
fontWeight: FontWeight.w700,
color: EtmTokens.inkOf(context)),
),
const SizedBox(height: 3),
Text(
p.isEmpty ? '—' : '${p.join(' · ')} W',
style: TextStyle(
fontSize: 12.5,
fontFamily: 'monospace',
color: EtmTokens.inkOf(context)),
),
const SizedBox(height: 5),
Text(
aJour
? 'Publiés par la box en télémétrie, jamais enregistrés : ce sont '
'les contacteurs qui se déclarent, les paliers s\'en déduisent.'
: 'PRÉVISION — les contacteurs ont changé dans ce brouillon. La box '
'publiera sa propre table au premier cycle après enregistrement.',
style: TextStyle(
fontSize: 10.5,
height: 1.35,
color: EtmTokens.mutedOf(context)),
),
],
),
),
const SizedBox(height: 8),
_Aide(
'La puissance saisie est celle de la RÉSISTANCE, pas le calibre du '
'contacteur : rien dans nymea ne dit ce qu\'un relais commande.',
),
],
),
);
}
}
class _LigneRelais extends StatelessWidget {
final int index;
final LoadRelay relais;
final NymeaService service;
final Set<String> dejaPris;
final ValueChanged<String> onThing;
final ValueChanged<int> onPuissance;
final VoidCallback? onSupprimer;
const _LigneRelais({
required this.index,
required this.relais,
required this.service,
required this.dejaPris,
required this.onThing,
required this.onPuissance,
this.onSupprimer,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 10),
child: Container(
width: 26,
height: 26,
alignment: Alignment.center,
decoration: BoxDecoration(
color: EtmTokens.brand.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Text('K${index + 1}',
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: EtmTokens.brand)),
),
),
const SizedBox(width: 10),
Expanded(
child: ThingPicker(
service: service,
interfaceRequise: 'power',
valeur: relais.thingId.isEmpty ? null : relais.thingId,
dejaPris: dejaPris,
placeholder: 'Choisir un contacteur…',
onChoisi: onThing,
),
),
const SizedBox(width: 10),
SizedBox(
width: 86,
child: TextFormField(
key: ValueKey('w-$index-${relais.thingId}'),
initialValue: '${relais.powerW}',
keyboardType: TextInputType.number,
textAlign: TextAlign.right,
decoration: const InputDecoration(isDense: true, suffixText: 'W'),
onChanged: (v) {
final n = int.tryParse(v.trim());
if (n != null) onPuissance(n);
},
),
),
IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.remove_circle_outline, size: 19),
color: EtmTokens.mutedOf(context),
onPressed: onSupprimer,
tooltip: onSupprimer == null
? 'Une charge relais doit garder au moins un contacteur'
: 'Retirer ce contacteur',
),
],
),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Modulable
// ═══════════════════════════════════════════════════════════════════════════
class _SectionModulable extends StatelessWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
final NymeaService service;
final SchemaProvider schema;
final LoadTelemetryProvider telemetry;
const _SectionModulable({
required this.entry,
required this.provider,
required this.service,
required this.schema,
required this.telemetry,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
_Carte(
titre: 'Appareil piloté',
badge: 'enregistré · id',
sousTitre: 'Un seul Thing, portant l\'interface etmvariableload.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// L'identifiant de la charge EST le ThingId : le sélecteur ne peut donc
// pas « changer d'appareil » sur place. On montre lequel est piloté.
ThingPicker(
service: service,
interfaceRequise: 'etmvariableload',
valeur: entry.id,
onChoisi: (_) {},
placeholder: 'Appareil introuvable',
),
const SizedBox(height: 8),
_Aide(
'⚠ Changer d\'appareil, c\'est changer de charge. L\'identifiant de '
'cette charge EST le ThingId piloté : choisir un autre appareil ne la '
'modifie pas, cela en crée une autre et supprime celle-ci — avec son '
'passage par l\'état sûr (consigne 0 W). Le geste se fait donc depuis la '
'liste, pas ici.',
alerte: true,
),
],
),
),
const SizedBox(height: 14),
_Carte(
titre: 'Puissance nominale',
badge: 'enregistré · maxPowerW',
sousTitre:
'Plafond de modulation — la consigne ira de 0 à cette valeur.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
initialValue: '${entry.maxPowerW}',
keyboardType: TextInputType.number,
decoration: const InputDecoration(
isDense: true, suffixText: 'W'),
onChanged: (v) {
final n = int.tryParse(v.trim());
if (n != null) {
provider.replaceEntry(
entry.id, entry.patched(maxPowerW: n));
}
},
),
const SizedBox(height: 6),
_Aide(
'Requis, et strictement positif : la box refuse une charge modulable '
'sans plafond. C\'est la plaque signalétique de la résistance, pas une '
'estimation — et elle sert au recrédit du budget rendu au reste de la '
'cascade.',
),
const SizedBox(height: 14),
_Plancher(
entry: entry,
provider: provider,
schema: schema.schema,
telemetry: telemetry),
],
),
),
],
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// SG-Ready
// ═══════════════════════════════════════════════════════════════════════════
/// Les deux contacts, puis la table des quatre états.
///
/// ## « SG1 » et « SG2 » n'existent pas côté box
///
/// `sgReady.states[]` ne stocke qu'une **liste de relais par état**, sans nom ni ordre.
/// Les deux étiquettes sont une convention de cet écran, retrouvée à la lecture :
/// **SG1 = le contact fermé en état 1** (blocage), **SG2 = celui fermé en état 3**
/// (recommandation). C'est l'encodage SG-Ready standard.
///
/// Si la configuration lue ne correspond pas à ce schéma — trois contacts, un état 1 sans
/// contact fermé — l'écran **renonce aux noms** et affiche les relais tels quels. Attribuer
/// une étiquette au hasard serait pire que de ne pas en donner. Le tableau reste éditable ;
/// ce sont les **noms** qui disparaissent, pas la configuration.
class _SectionSgReady extends StatelessWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
final NymeaService service;
const _SectionSgReady({
required this.entry,
required this.provider,
required this.service,
});
static const _nomsEtats = {
1: ('Blocage', 'arrêt forcé'),
2: ('Normal', 'aucune consigne'),
3: ('Recommandation', 'incitation douce'),
4: ('Forcé', 'marche imposée'),
};
/// Contacts déduits : SG1 depuis l'état 1, SG2 depuis l'état 3.
///
/// `null` quand l'encodage ne suit pas la convention — c'est ce qui fait tomber les
/// étiquettes.
({String? sg1, String? sg2, bool standard}) _contacts() {
String? unSeul(int etat) {
for (final s in entry.sgReadyStates) {
if (s.state == etat) return s.relays.length == 1 ? s.relays.first : null;
}
return null;
}
final sg1 = unSeul(1);
final sg2 = unSeul(3);
final tous = {
for (final s in entry.sgReadyStates) ...s.relays.map(normalizeThingId)
};
final standard = sg1 != null &&
sg2 != null &&
normalizeThingId(sg1) != normalizeThingId(sg2) &&
tous.length == 2;
return (sg1: sg1, sg2: sg2, standard: standard);
}
void _ecrire(List<SgReadyState> etats) {
provider.replaceEntry(
entry.id,
entry.patched(sgReady: {
'minStateHoldS': entry.minStateHoldS,
'states': [for (final s in etats) s.toMap()],
}),
);
}
/// Remplace un contact partout où il apparaît — le choix des Things se fait **une fois,
/// en haut**, pas état par état.
void _majContact(String? ancien, String neuf) {
final etats = [
for (final s in entry.sgReadyStates)
SgReadyState(
state: s.state,
relays: [
for (final r in s.relays)
if (ancien != null && normalizeThingId(r) == normalizeThingId(ancien))
neuf
else
r
],
estimatedPowerW: s.estimatedPowerW,
)
];
_ecrire(etats);
}
/// Coche/décoche un contact pour un état — c'est ça qui devient `relays[]`.
void _basculer(int etat, String contact, bool ferme) {
final etats = [
for (final s in entry.sgReadyStates)
if (s.state != etat)
s
else
SgReadyState(
state: s.state,
relays: ferme
? [
...s.relays.where(
(r) => normalizeThingId(r) != normalizeThingId(contact)),
contact
]
: [
...s.relays.where(
(r) => normalizeThingId(r) != normalizeThingId(contact))
],
estimatedPowerW: s.estimatedPowerW,
)
];
_ecrire(etats);
}
void _majEstimation(int etat, double w) {
final etats = [
for (final s in entry.sgReadyStates)
if (s.state != etat)
s
else
SgReadyState(
state: s.state, relays: s.relays, estimatedPowerW: w)
];
_ecrire(etats);
}
@override
Widget build(BuildContext context) {
final c = _contacts();
final etats = [...entry.sgReadyStates]..sort((a, b) => a.state - b.state);
final tousContacts = <String>[];
for (final s in etats) {
for (final r in s.relays) {
if (!tousContacts.any((x) => normalizeThingId(x) == normalizeThingId(r))) {
tousContacts.add(r);
}
}
}
return Column(
children: [
_Carte(
titre: 'Contacts SG-Ready',
badge: 'déduit · pas de champ dédié',
sousTitre:
'Les deux entrées de la pompe à chaleur, et le relais qui les commande.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (c.standard) ...[
_LigneContact(
etiquette: 'SG1',
service: service,
valeur: c.sg1,
dejaPris: {if (c.sg2 != null) normalizeThingId(c.sg2!)},
onChoisi: (id) => _majContact(c.sg1, id),
),
_LigneContact(
etiquette: 'SG2',
service: service,
valeur: c.sg2,
dejaPris: {if (c.sg1 != null) normalizeThingId(c.sg1!)},
onChoisi: (id) => _majContact(c.sg2, id),
),
const SizedBox(height: 6),
_Aide(
'« SG1 » et « SG2 » n\'existent pas côté box : la configuration ne '
'stocke qu\'une liste de relais par état. Ces deux noms sont une '
'convention de cet écran — SG1 est le contact fermé en état 1, SG2 '
'celui fermé en état 3.',
),
] else ...[
for (var i = 0; i < tousContacts.length; i++)
_LigneContact(
etiquette: 'C${i + 1}',
service: service,
valeur: tousContacts[i],
dejaPris: {
for (final x in tousContacts)
if (normalizeThingId(x) != normalizeThingId(tousContacts[i]))
normalizeThingId(x)
},
onChoisi: (id) => _majContact(tousContacts[i], id),
),
const SizedBox(height: 6),
_Aide(
'⚠ Encodage non standard : les étiquettes SG1/SG2 sont abandonnées et '
'les contacts affichés tels quels. Leur attribuer un nom au hasard '
'serait pire que de n\'en donner aucun — la configuration, elle, reste '
'éditable.',
alerte: true,
),
],
],
),
),
const SizedBox(height: 14),
_Carte(
titre: 'Table d\'états',
badge: 'enregistré · sgReady.states[]',
sousTitre:
'Quels contacts fermer pour chaque état, et ce que la PAC consomme alors.',
child: Column(
children: [
for (final s in etats)
_LigneEtat(
etat: s,
nom: _nomsEtats[s.state]?.$1 ?? 'État ${s.state}',
detail: _nomsEtats[s.state]?.$2 ?? '',
contacts: tousContacts,
etiquettes: c.standard
? {
normalizeThingId(c.sg1!): 'SG1',
normalizeThingId(c.sg2!): 'SG2',
}
: {
for (var i = 0; i < tousContacts.length; i++)
normalizeThingId(tousContacts[i]): 'C${i + 1}'
},
onBasculer: (contact, ferme) =>
_basculer(s.state, contact, ferme),
onEstimation: (w) => _majEstimation(s.state, w),
),
const SizedBox(height: 4),
_Aide(
'estimatedPowerW est une ESTIMATION, jamais un engagement. Elle sert de '
'base au recrédit de budget et au choix d\'état : une valeur fausse ne '
'décale pas seulement la PAC, elle décale l\'allocation de tout le '
'waterfall en aval. À relever sur la plaque, puis à mesurer.',
),
],
),
),
],
);
}
}
class _LigneContact extends StatelessWidget {
final String etiquette;
final NymeaService service;
final String? valeur;
final Set<String> dejaPris;
final ValueChanged<String> onChoisi;
const _LigneContact({
required this.etiquette,
required this.service,
required this.valeur,
required this.dejaPris,
required this.onChoisi,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 10),
child: Container(
width: 34,
height: 26,
alignment: Alignment.center,
decoration: BoxDecoration(
color: EtmTokens.brand.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(8),
),
child: Text(etiquette,
style: const TextStyle(
fontSize: 10,
fontWeight: FontWeight.w700,
color: EtmTokens.brand)),
),
),
const SizedBox(width: 10),
Expanded(
child: ThingPicker(
service: service,
interfaceRequise: 'power',
valeur: valeur,
dejaPris: dejaPris,
placeholder: 'Choisir le relais…',
onChoisi: onChoisi,
),
),
],
),
);
}
}
class _LigneEtat extends StatelessWidget {
final SgReadyState etat;
final String nom;
final String detail;
final List<String> contacts;
final Map<String, String> etiquettes;
final void Function(String contact, bool ferme) onBasculer;
final ValueChanged<double> onEstimation;
const _LigneEtat({
required this.etat,
required this.nom,
required this.detail,
required this.contacts,
required this.etiquettes,
required this.onBasculer,
required this.onEstimation,
});
bool _ferme(String c) =>
etat.relays.any((r) => normalizeThingId(r) == normalizeThingId(c));
@override
Widget build(BuildContext context) {
final muted = EtmTokens.mutedOf(context);
return Container(
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(color: EtmTokens.lineOf(context)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Container(
width: 24,
height: 24,
alignment: Alignment.center,
decoration: BoxDecoration(
color: EtmTokens.brand.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(7),
),
child: Text('${etat.state}',
style: const TextStyle(
fontSize: 11,
fontWeight: FontWeight.w700,
color: EtmTokens.brand)),
),
const SizedBox(width: 8),
Text(nom,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
color: EtmTokens.inkOf(context))),
const SizedBox(width: 6),
Expanded(
child: Text(detail,
style: TextStyle(fontSize: 11, color: muted),
overflow: TextOverflow.ellipsis),
),
]),
const SizedBox(height: 8),
Wrap(
spacing: 8,
runSpacing: 6,
children: [
for (final c in contacts)
_Bascule(
libelle: etiquettes[normalizeThingId(c)] ?? '?',
ferme: _ferme(c),
onChange: (v) => onBasculer(c, v),
),
],
),
const SizedBox(height: 8),
Row(children: [
Text('Puissance estimée',
style: TextStyle(fontSize: 11.5, color: muted)),
const Spacer(),
SizedBox(
width: 96,
child: TextFormField(
key: ValueKey('est-${etat.state}'),
initialValue: etat.estimatedPowerW.round().toString(),
keyboardType: TextInputType.number,
textAlign: TextAlign.right,
decoration:
const InputDecoration(isDense: true, suffixText: 'W'),
onChanged: (v) {
final n = double.tryParse(v.trim().replaceAll(',', '.'));
if (n != null && n >= 0) onEstimation(n);
},
),
),
]),
],
),
);
}
}
/// Contact fermé / ouvert pour un état donné.
class _Bascule extends StatelessWidget {
final String libelle;
final bool ferme;
final ValueChanged<bool> onChange;
const _Bascule({
required this.libelle,
required this.ferme,
required this.onChange,
});
@override
Widget build(BuildContext context) {
return InkWell(
borderRadius: BorderRadius.circular(20),
onTap: () => onChange(!ferme),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: ferme
? EtmTokens.eco.withValues(alpha: 0.15)
: Colors.transparent,
borderRadius: BorderRadius.circular(20),
border: Border.all(
color: ferme
? EtmTokens.eco.withValues(alpha: 0.55)
: EtmTokens.lineOf(context)),
),
child: Row(mainAxisSize: MainAxisSize.min, children: [
Icon(ferme ? Icons.circle : Icons.circle_outlined,
size: 11,
color: ferme ? EtmTokens.eco : EtmTokens.mutedOf(context)),
const SizedBox(width: 5),
Text('$libelle ${ferme ? "fermé" : "ouvert"}',
style: TextStyle(
fontSize: 11.5,
fontWeight: ferme ? FontWeight.w600 : FontWeight.w400,
color: ferme
? EtmTokens.inkOf(context)
: EtmTokens.mutedOf(context))),
]),
),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Communs : temporisations, compteur, arbitrage, enregistrement
// ═══════════════════════════════════════════════════════════════════════════
class _Temporisation extends StatelessWidget {
final String titre;
final String sousTitre;
final List<_LigneDuree> lignes;
const _Temporisation({
required this.titre,
required this.sousTitre,
required this.lignes,
});
@override
Widget build(BuildContext context) => _Carte(
titre: titre,
badge: 'enregistré',
sousTitre: sousTitre,
child: Column(children: lignes),
);
}
class _LigneDuree extends StatelessWidget {
final String libelle;
final String aide;
final int valeur;
final ValueChanged<int> onChange;
const _LigneDuree({
required this.libelle,
required this.aide,
required this.valeur,
required this.onChange,
});
/// Pas de réglage : 30 s en dessous de 5 min, 60 s au-delà. Une PAC se règle en
/// centaines de secondes, un relais en dizaines.
int get _pas => valeur >= 300 ? 60 : 30;
@override
Widget build(BuildContext context) {
final muted = EtmTokens.mutedOf(context);
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(libelle,
style: TextStyle(
fontSize: 13, color: EtmTokens.inkOf(context))),
Text(aide, style: TextStyle(fontSize: 10.5, color: muted)),
],
),
),
IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.remove_circle_outline, size: 21),
onPressed: valeur <= 0
? null
: () => onChange((valeur - _pas).clamp(0, 86400)),
),
SizedBox(
width: 58,
child: Text('$valeur s',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 13,
fontFamily: 'monospace',
fontWeight: FontWeight.w600,
color: EtmTokens.inkOf(context))),
),
IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.add_circle_outline, size: 21),
onPressed: () => onChange((valeur + _pas).clamp(0, 86400)),
),
]),
);
}
}
/// Compteur dédié et sonde — deux champs de **même forme**, une même règle.
///
/// ## Ils ne sont ni du matériel, ni exclusifs
///
/// Rattacher un compteur ne reconstruit rien : ces deux champs n'entrent pas dans
/// `sameHardware()`, donc la mise à jour est **en place**, verrous préservés. Ils n'entrent
/// pas non plus dans le contrôle d'unicité des Things — deux charges peuvent légitimement
/// partager une sonde de pièce, et un compteur de tableau divisionnaire en couvrir
/// plusieurs. Le sélecteur ne grise donc **rien** ici, contrairement aux contacteurs.
///
/// ## Ce que le compteur autorise à conclure, et rien de plus
///
/// Il sert à **vérifier**, jamais à décider : commandé 3 000 W, mesuré 0 W sur plusieurs
/// cycles, la charge ne fait pas ce qu'on lui demande. La mesure n'entre pas dans le
/// budget — elle est en retard sur la commande, et la charge est déjà comptée dans le
/// compteur racine dont elle n'est qu'une décomposition. L'ajouter compterait deux fois la
/// même énergie.
///
/// ## Sélection par INTERFACE, jamais par plugin
///
/// Le compteur se choisit parmi les Things portant `smartmeter`, la sonde parmi celles qui
/// portent une interface de température. Nommer un plugin rendrait le modèle faux au
/// prochain qui rend le même service, et obligerait à tenir une table à la main.
///
/// ## Et si la box ne les porte pas
///
/// Le grisage vient du **schéma**, jamais d'une table de versions : sur une box antérieure
/// le champ est absent, le bloc se grise et le dit. Sur `.75` en `+etm16` il est là — le
/// réglage s'est donc activé **seul**, sans nouvelle version de l'app, exactement comme
/// promis.
class _CompteurEtSonde extends StatelessWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
final RpcSchema schema;
final NymeaService service;
final LoadTelemetryProvider telemetry;
const _CompteurEtSonde({
required this.entry,
required this.provider,
required this.schema,
required this.service,
required this.telemetry,
});
FieldSupport _support(String champ) => schema.paramSupport(
'NymeaEnergy.SetLoadConfig', ['loadConfigs', '[]', champ]);
@override
Widget build(BuildContext context) {
final supportCompteur = _support('meterThingId');
final supportSonde = _support('sensorThingId');
// ⚠️ LA RÈGLE PORTE SUR `measurement.source`, JAMAIS SUR `adapter`.
//
// « Masqué dès que la charge mesure, sauf SG-Ready » paraît équivalent et ne l'est
// pas : un routeur de relais à topologie MIXTE publie `none` lui aussi — une mesure
// partielle n'est pas une mesure — et on masquerait le champ sur un ECS qui ne peut
// être mesuré QUE par un compteur dédié. Redéduire le régime du mécanisme
// remettrait ici la déduction que le moteur vient d'en retirer, et il faudrait la
// maintenir à chaque mécanisme ajouté.
//
// source == device → réglage avancé, replié (l'appareil publie déjà)
// sinon → proposé, visible
//
// Le cas SG-Ready est couvert sans avoir à le connaître : son contact commande un
// signal, la PAC est alimentée ailleurs, la source est `none` par construction
// (LM-1105-b) — donc le compteur reste visible, et c'est sa SEULE voie vers une
// mesure.
// `null` = la charge n'est PAS dans la télémétrie du dernier cycle (hors arbitrage,
// borne sans véhicule branché, charge désactivée). Ce n'est pas `none` : `none` est un
// état publié — « pas mesurable » — alors qu'ici le régime n'est simplement pas dit.
// Les confondre ferait annoncer « seule voie vers une mesure » sur une borne qui, une
// fois arbitrée, publiera très bien la sienne.
final source = telemetry.entryFor(entry.id)?.measurement.source;
final replie = source == MeasurementSource.device;
return Column(
children: [
_Carte(
titre: 'Compteur dédié',
badge: supportCompteur.isWritable
? (replie ? 'avancé · meterThingId' : 'enregistré · meterThingId')
: 'pas encore supporté',
sousTitre: replie
? 'Cet appareil publie déjà sa puissance : un compteur séparé est un '
'réglage avancé, pas un manque.'
: 'Mesure de CETTE charge — pour vérifier ce qui est commandé, '
'jamais pour décider.',
replie: replie,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SchemaGated(
support: supportCompteur,
fieldName: 'meterThingId',
label: 'Compteur dédié',
child: ThingPicker(
service: service,
interfaceRequise: 'smartmeter',
// Le moteur ne filtre sur aucune interface : `findConfiguredThing` puis
// `hasState("currentPower")`. Une borne qui publie sa puissance est donc
// un compteur valable — et c'est le seul moyen de voir « mesuré » sur
// la carte d'une borne dont la classe ne déclare pas `smartmeter`
// (la V2C Trydan, contrairement à la wallbox simulée).
interfacesAussi: const ['evcharger'],
etatRequis: 'currentPower',
valeur: entry.meterThingId.isEmpty ? null : entry.meterThingId,
placeholder: 'Choisir un appareil de mesure…',
onChoisi: (id) => provider.replaceEntry(
entry.id, entry.patched(meterThingId: id)),
),
),
const SizedBox(height: 6),
_Aide(
'La mesure n\'entre pas dans le budget : elle est en retard sur la '
'commande, et cette charge est déjà comptée dans le compteur racine dont '
'elle n\'est qu\'une décomposition. L\'y ajouter compterait deux fois la '
'même énergie.',
),
const SizedBox(height: 6),
_Aide(_aideSelonSource(source)),
],
),
),
// ── Pas de sonde sur une BORNE ────────────────────────────────────
//
// `sensorThingId` est un champ générique de `LoadConfig` : le schéma l'accepte
// sur n'importe quelle charge, y compris une borne. Mais le moteur ne le lit que
// pour en publier une **température** (`attachMeasurements`, LM-1103), et une
// borne de recharge ne rend aucun service thermique : le champ ne mènerait nulle
// part. Proposé sur cet écran, il faisait chercher à quoi il sert.
//
// Le compteur, lui, RESTE : c'est le seul chemin par lequel une mesure apparaît
// pour cette charge dans la télémétrie — et sur une borne qui publie
// `currentPower`, on peut la désigner elle-même.
if (!entry.isEvCharger) ...[
const SizedBox(height: 14),
_Carte(
titre: 'Sonde de température',
badge: supportSonde.isWritable
? 'enregistré · sensorThingId'
: 'pas encore supporté',
sousTitre: 'La grandeur physique du service rendu — ce que la charge produit, '
'pas ce qu\'elle consomme.',
child: SchemaGated(
support: supportSonde,
fieldName: 'sensorThingId',
label: 'Sonde de température',
child: ThingPicker(
service: service,
interfaceRequise: 'temperaturesensor',
valeur: entry.sensorThingId.isEmpty ? null : entry.sensorThingId,
placeholder: 'Choisir une sonde…',
onChoisi: (id) => provider.replaceEntry(
entry.id, entry.patched(sensorThingId: id)),
),
),
),
],
],
);
}
}
/// Ce qu'un compteur dédié apporte **à cette charge-ci**, selon ce que la box publie.
String _aideSelonSource(MeasurementSource? source) => switch (source) {
null =>
'Cette charge n\'est pas dans la télémétrie du dernier cycle : la box ne dit donc '
'pas d\'où viendrait sa mesure. Le compteur reste réglable — il servira dès '
'qu\'elle sera arbitrée.',
MeasurementSource.device =>
'Cet appareil publie déjà sa puissance, et elle s\'affiche sur sa carte. Un '
'compteur dédié reste permis, et il apporte une chose que l\'appareil ne peut '
'pas donner : une mesure INDÉPENDANTE de l\'organe commandé. Sans elle, une '
'concordance entre commande et mesure ne prouve rien — plusieurs bornes '
'renvoient leur consigne en guise de puissance.',
MeasurementSource.meter =>
'Un compteur est rattaché : la mesure affichée sur la carte vient de lui, pas de '
'l\'appareil commandé. C\'est la seule configuration où une concordance entre '
'commande et mesure constitue une preuve.',
MeasurementSource.none =>
'La box ne publie AUCUNE mesure pour cette charge — un contact SG-Ready commande '
'un signal et ne voit pas le courant de la machine, et une topologie de relais '
'où un seul mesure ne mesure rien d\'exploitable. Un compteur dédié est donc '
'ici la seule voie vers une mesure.',
MeasurementSource.inconnue =>
'La box publie une origine de mesure que cette version de l\'app ne connaît pas : '
'la valeur affichée sur la carte est celle de la box, mais ce qu\'un écart '
'prouverait reste indéterminé ici.',
};
/// Inclusion dans l'arbitrage — et ce que la désactivation fait vraiment.
class _Arbitrage extends StatelessWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
const _Arbitrage({required this.entry, required this.provider});
String get _etatSur => entry.isSgReady
? 'état 2 (normal), jamais le blocage'
: entry.isRelayRouter
? 'tous les relais ouverts'
: 'consigne 0 W';
@override
Widget build(BuildContext context) {
final t = context.l10n;
return _Carte(
titre: 'Arbitrage',
badge: 'enregistré · enabled',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Inclure dans l\'arbitrage',
style: TextStyle(
fontSize: 13.5,
fontWeight: FontWeight.w600,
color: EtmTokens.inkOf(context))),
Text(
'rang ${entry.priority} · '
'${domainLabel(t, entry.domain ?? kUnclassifiedDomain)}',
style: TextStyle(
fontSize: 11.5, color: EtmTokens.mutedOf(context)),
),
],
),
),
Switch.adaptive(
value: entry.enabled,
onChanged: (v) => provider.setEnabled(entry.id, v),
),
]),
const SizedBox(height: 6),
_Aide(
'À la désactivation, le retrait passe par l\'état sûr : $_etatSur. '
'Vérifié sur matériel côté plugin — mais c\'est le RETRAIT qui protège, pas '
'le drapeau : il n\'agit ni au redémarrage du service, ni sur une charge '
'déjà désactivée.',
),
],
),
);
}
}
/// Valide **avant** d'envoyer, annonce l'impact, puis enregistre.
class _BarreEnregistrement extends StatelessWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
final NymeaService service;
const _BarreEnregistrement({
required this.entry,
required this.provider,
required this.service,
});
/// Règles que la box applique et **ne motive pas**. Les reprendre ici, c'est la
/// différence entre « corrigez la ligne 2 » et un refus muet qui perd tout l'appel.
List<String> _valider() {
final erreurs = <String>[];
if (entry.label.trim().isEmpty) erreurs.add('Le nom ne peut pas être vide.');
if (entry.isRelayRouter) {
if (entry.relays.isEmpty) {
erreurs.add('Au moins un contacteur est requis.');
}
final vus = <String>{};
for (var i = 0; i < entry.relays.length; i++) {
final r = entry.relays[i];
if (r.thingId.trim().isEmpty) {
erreurs.add('Ligne ${i + 1} : aucun contacteur choisi.');
} else if (!vus.add(normalizeThingId(r.thingId))) {
erreurs.add(
'Ligne ${i + 1} : ce contacteur est déjà utilisé par une autre ligne.');
}
if (r.powerW <= 0) {
erreurs.add('Ligne ${i + 1} : la puissance doit être supérieure à 0 W.');
}
}
} else if (entry.isSgReady) {
final etats = {for (final s in entry.sgReadyStates) s.state};
if (!etats.contains(2)) {
// ECS-110 : sans état 2, la PAC n'est jamais pilotable — la box publie
// UNUSABLE_ENCODING et la charge sort de l'arbitrage définitivement.
erreurs.add(
'L\'état 2 (normal) doit être exprimable, sans quoi la pompe à chaleur '
'ne sera jamais pilotable.');
}
for (final s in entry.sgReadyStates) {
if (s.state < 1 || s.state > 4) {
erreurs.add('État ${s.state} hors plage (1 à 4).');
}
if (s.estimatedPowerW < 0) {
erreurs.add('État ${s.state} : puissance estimée négative.');
}
for (final r in s.relays) {
if (r.trim().isEmpty) {
erreurs.add('État ${s.state} : un contact n\'est pas choisi.');
}
}
}
} else if (entry.isEvCharger) {
// ⚠️ RIEN à valider sur une borne, et surtout pas une puissance nominale.
//
// Elle tombait dans la branche `else` avec les charges modulables, et l'écran
// refusait donc d'enregistrer un simple renommage : « La puissance nominale est
// requise » — sur la carte qui explique deux blocs plus haut qu'une borne n'en porte
// pas. Constaté sur téléphone le 2026-08-28, contre `.75`.
//
// Le contrat le confirme : l'entrée d'une borne porte `adapter`, `mode`, `domain`,
// `label`, `priority`, `enabled` — aucune charge utile de mécanisme. Ses limites
// viennent de la borne et du véhicule branché.
} else {
if (entry.maxPowerW <= 0) {
erreurs.add(
'La puissance nominale est requise et doit être supérieure à 0 W.');
}
}
return erreurs;
}
@override
Widget build(BuildContext context) {
final erreurs = _valider();
final impacts =
provider.pendingImpact().where((i) => i.loadId == entry.id).toList();
final rebuild = impacts.any((i) => i.impact == LoadChangeImpact.rebuild);
final rearm = impacts
.map((i) => i.rearmSeconds)
.whereType<int>()
.fold<int>(0, (a, b) => a > b ? a : b);
final enCours = provider.saveState == LoadSaveState.pending;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (erreurs.isNotEmpty)
Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 12),
padding: const EdgeInsets.all(11),
decoration: BoxDecoration(
color: EtmTokens.danger.withValues(alpha: 0.09),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('La box refuserait cette configuration',
style: TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w700,
color: EtmTokens.danger)),
const SizedBox(height: 5),
for (final e in erreurs)
Padding(
padding: const EdgeInsets.only(bottom: 2),
child: Text('• $e',
style: TextStyle(
fontSize: 11.5,
height: 1.35,
color: EtmTokens.inkOf(context))),
),
const SizedBox(height: 4),
Text(
'Le refus porterait sur l\'ensemble des charges, sans motif : la règle '
'enfreinte ne serait visible que dans le journal de la box.',
style: TextStyle(
fontSize: 10.5,
height: 1.35,
color: EtmTokens.mutedOf(context)),
),
],
),
),
if (rebuild && erreurs.isEmpty)
Padding(
padding: const EdgeInsets.only(bottom: 12),
child: _Aide(
'À l\'enregistrement : les contacts de cette charge passent par leur état '
'sûr, l\'adaptateur est reconstruit, et son verrou se réarme'
'${rearm > 0 ? rearm >= 60 ? ' pendant ${(rearm / 60).round()} min' : ' pendant $rearm s' : ''}.',
alerte: true,
),
),
Row(children: [
TextButton(
onPressed: provider.hasPendingChanges ? provider.discardDraft : null,
child: const Text('Annuler'),
),
const Spacer(),
FilledButton(
onPressed: (!provider.hasPendingChanges ||
erreurs.isNotEmpty ||
enCours)
? null
: provider.save,
child: Text(enCours ? 'Envoi…' : 'Enregistrer'),
),
]),
],
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Habillage
// ═══════════════════════════════════════════════════════════════════════════
class _Carte extends StatefulWidget {
final String titre;
final String? badge;
final String? sousTitre;
final Widget child;
/// Carte **repliée à l'ouverture** — un réglage qui existe sans être une étape.
///
/// Le contenu reste atteignable en un geste : replier n'est pas masquer. Un réglage
/// caché pour de bon finit par être réinventé ailleurs.
final bool replie;
const _Carte({
required this.titre,
this.badge,
this.sousTitre,
required this.child,
this.replie = false,
});
@override
State<_Carte> createState() => _CarteState();
}
class _CarteState extends State<_Carte> {
late bool _ouverte = !widget.replie;
@override
Widget build(BuildContext context) {
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: [
InkWell(
onTap: widget.replie
? () => setState(() => _ouverte = !_ouverte)
: null,
child: Row(children: [
Expanded(child: _SousTitre(widget.titre, badge: widget.badge)),
if (widget.replie)
Icon(
_ouverte
? Icons.expand_less_rounded
: Icons.expand_more_rounded,
size: 20,
color: EtmTokens.mutedOf(context)),
]),
),
if (widget.sousTitre != null) ...[
const SizedBox(height: 3),
Text(widget.sousTitre!,
style: TextStyle(
fontSize: 11.5,
height: 1.35,
color: EtmTokens.mutedOf(context))),
],
if (_ouverte) ...[
const SizedBox(height: 12),
widget.child,
],
],
),
);
}
}
class _SousTitre extends StatelessWidget {
final String texte;
final String? badge;
const _SousTitre(this.texte, {this.badge});
@override
Widget build(BuildContext context) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Flexible(
child: Text(texte,
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w700,
color: EtmTokens.inkOf(context))),
),
if (badge != null) ...[
const SizedBox(width: 8),
Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 3),
decoration: BoxDecoration(
color: EtmTokens.lineOf(context),
borderRadius: BorderRadius.circular(6),
),
child: Text(badge!,
style: TextStyle(
fontSize: 9.5,
fontFamily: 'monospace',
color: EtmTokens.mutedOf(context))),
),
],
],
);
}
}
class _Aide extends StatelessWidget {
final String texte;
final bool alerte;
const _Aide(this.texte, {this.alerte = false});
@override
Widget build(BuildContext context) {
final couleur = alerte ? EtmTokens.orange : EtmTokens.mutedOf(context);
return Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(alerte ? Icons.warning_amber_rounded : Icons.info_outline,
size: 14, color: couleur),
const SizedBox(width: 6),
Expanded(
child: Text(texte,
style: TextStyle(
fontSize: 11,
height: 1.4,
color: alerte ? EtmTokens.inkOf(context) : couleur)),
),
],
);
}
}
/// Plancher de modulation — **préparé avant son arrivée au schéma**, délibérément.
///
/// ## Pourquoi l'écrire maintenant
///
/// Le grisage de cet écran se dérive de `JSONRPC.Introspect` : le jour où le plugin ajoute
/// le champ, le bloc s'active **tout seul**. C'est la promesse, et elle a déjà été tenue —
/// `meterThingId` et `sensorThingId` se sont activés sans nouvelle version de l'app.
///
/// Mais ce jour-là, ils se sont activés sur un **placeholder mort** : un contrôle d'allure
/// utilisable qui n'écrivait nulle part. Pire qu'un bloc grisé, parce qu'un bloc grisé dit
/// ce qu'il est. La leçon vaut d'être appliquée en avance : le plancher est câblé
/// maintenant, il restera grisé tant que le champ n'existe pas, et il fonctionnera le jour
/// où il apparaîtra — sans que personne n'ait à y revenir.
///
/// ## Ce que la box publie déjà
///
/// `mechanism.minPowerW` est **en télémétrie** avant d'être configurable. On l'affiche
/// donc en lecture, ce qui donne la valeur réellement en vigueur même quand le réglage,
/// lui, n'est pas encore ouvert.
///
/// ## Ce plancher-ci n'est PAS celui d'une borne de recharge
///
/// Même mot, deux natures. Ici, une **propriété déclarée de l'appareil** : la consigne
/// non nulle la plus basse qu'il accepte, saisie une fois et stable.
///
/// Le minimum d'une borne, lui, vaut `max(borne, voiture)` et **dépend du véhicule
/// branché** — il change quand la voiture change. Il dépend aussi du nombre de phases
/// visé, de l'ordre de 1,4 kW en monophasé contre 4,1 kW en triphasé. C'est du **runtime**,
/// il se constate et ne se règle pas ; il s'affiche donc sur la carte de la borne, en
/// lecture, tel que la box le donne — sans conversion côté app.
///
/// Ce bloc ne vit que dans la section `etmvariableload`, ce qui l'écarte structurellement
/// des bornes : elles ne sont pas des `LoadConfig` et n'ouvrent pas cet écran.
class _Plancher extends StatelessWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
final RpcSchema schema;
final LoadTelemetryProvider telemetry;
const _Plancher({
required this.entry,
required this.provider,
required this.schema,
required this.telemetry,
});
/// Nom pressenti du champ. S'il diffère à l'arrivée, le bloc restera simplement grisé —
/// une erreur visible et sans conséquence, là où une écriture inventée ferait rejeter
/// l'enregistrement de TOUTES les charges.
static const String champ = 'minPowerW';
@override
Widget build(BuildContext context) {
final support = schema
.paramSupport('NymeaEnergy.SetLoadConfig', ['loadConfigs', '[]', champ]);
final publie = telemetry.entryFor(entry.id)?.mechanism?.minPowerW;
return _Carte(
titre: 'Plancher de modulation',
badge: support.isWritable ? 'enregistré · $champ' : 'pas encore supporté',
sousTitre: 'La consigne non nulle la plus basse que CET appareil accepte — en '
'dessous, il vaut mieux ne rien commander du tout. Propriété déclarée, stable : '
'à ne pas confondre avec le minimum d\'une borne, qui suit le véhicule branché.',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SchemaGated(
support: support,
fieldName: champ,
label: 'Plancher de modulation',
child: TextFormField(
key: ValueKey('plancher-${entry.id}'),
initialValue: (publie ?? 0).round().toString(),
enabled: support.isWritable,
keyboardType: TextInputType.number,
decoration:
const InputDecoration(isDense: true, suffixText: 'W'),
onChanged: (v) {
final n = int.tryParse(v.trim());
// Rien ne part tant que le champ n'est pas au schéma : nymea rejette
// l'appel ENTIER sur une clé inconnue, et le refus emporterait toutes
// les charges.
if (n != null && n >= 0 && support.isWritable) {
provider.replaceEntry(
entry.id, entry.patchedRaw({champ: n}));
}
},
),
),
if (publie != null) ...[
const SizedBox(height: 6),
_Aide('Valeur en vigueur, publiée par la box : ${publie.round()} W.'),
],
],
),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Obligation quotidienne (§10) — et l'avertissement qui la rend honnête
// ═══════════════════════════════════════════════════════════════════════════
/// **Le plancher éco, et ce qu'il coûtera quand la box ne peut pas le vérifier.**
///
/// ## Pourquoi l'avertissement est ICI et pas au résultat
///
/// Au résultat, l'heure est passée : l'obligation a déjà acheté. Or sous le régime
/// `unmeasurable` elle ne peut **jamais** être close — ni annoncée tenue (R6 interdit
/// `ECO_FLOOR_MET`), ni chiffrée dans son manque (`missingWh` est absent, faute de savoir ce
/// qui a été livré). `ECO_FLOOR_MISSED` sortira donc **tous les jours, par construction**, et
/// l'achat se fera à taux constant sans que rien ne le corrige.
///
/// Un mécanisme dont le succès est inobservable et l'échec quotidien ne doit pas, en plus,
/// dépenser en silence. D'où : on le dit **à la saisie**.
///
/// ## Et c'est une consigne, pas une fatalité
///
/// Deux des trois causes se réparent par l'installateur — rattacher un compteur, en mettre un
/// qui totalise l'énergie. La troisième est le matériel, et le dire évite de chercher un
/// réglage qui n'existe pas. C'est pour ça que la cause est affichée, jamais le seul verdict.
class _SectionObligation extends StatefulWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
const _SectionObligation({required this.entry, required this.provider});
@override
State<_SectionObligation> createState() => _SectionObligationState();
}
class _SectionObligationState extends State<_SectionObligation> {
/// Le fuseau ne sert qu'à **qualifier une heure**, jamais à en proposer un réglage : il
/// est système, et il déplace d'un coup toutes les heures murales de l'installation.
/// Lu une fois, sans bloquer l'écran — absent, la ligne ne s'affiche simplement pas.
EnergyRatiosSnapshot? _ratios;
@override
void initState() {
super.initState();
final svc = context.read<NymeaService>();
_ratios = svc.energyRatios;
if (_ratios == null) {
svc.fetchEnergyRatios().then((r) {
if (mounted && r != null) setState(() => _ratios = r);
});
}
}
LoadConfigEntry get entry => widget.entry;
LoadConfigProvider get provider => widget.provider;
/// Le coût quotidien, **chiffré et non estimé** : le taux est constant depuis `+etm55`
/// (cible ÷ jour, LM-1013-b-v), donc le total acheté vaut exactement la cible.
String _coutQuotidien() => energieCourte(entry.minEnergyWhPerDay.toDouble());
@override
Widget build(BuildContext context) {
final t = context.l10n;
final mesurable = entry.progressMeasurable;
final cause = entry.progressUnmeasurableCause;
// `null` = box antérieure à `+etm49` : on n'avertit pas sur une absence, on se tait.
final avertir = mesurable == false && entry.minEnergyWhPerDay > 0;
return _Carte(
titre: t.obligationTitre,
badge: 'enregistré · needs',
sousTitre: t.obligationSousTitre,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
TextFormField(
initialValue: entry.minEnergyWhPerDay == 0
? ''
: '${entry.minEnergyWhPerDay}',
keyboardType: TextInputType.number,
decoration: InputDecoration(
isDense: true,
labelText: t.obligationEnergie,
suffixText: 'Wh'),
onChanged: (v) {
final n = v.trim().isEmpty ? 0 : int.tryParse(v.trim());
if (n == null) return;
provider.replaceEntry(
entry.id,
entry.patched(needs: {
'dailyDeadline': entry.dailyDeadline,
'minEnergyWhPerDay': n,
}));
},
),
const SizedBox(height: 10),
TextFormField(
initialValue: entry.dailyDeadline,
decoration: InputDecoration(
isDense: true,
labelText: t.obligationEcheance,
hintText: '07:00'),
onChanged: (v) => provider.replaceEntry(
entry.id,
entry.patched(needs: {
'dailyDeadline': v.trim(),
'minEnergyWhPerDay': entry.minEnergyWhPerDay,
})),
),
// ── L'HORLOGE, et seulement quand elle diverge ────────────────────────
//
// La phrase **qualifie une heure**, elle n'offre pas un choix : « Fuseau :
// Europe/Paris » sous ce champ laisserait croire qu'on règle le fuseau de CETTE
// échéance. Il n'y en a qu'un, il est système, et il déplace d'un coup toutes les
// heures murales de l'installation — échéances, frontière de la journée de
// reporting, et demain les plages tarifaires (LM-1005-b-iii).
//
// Conditionnel, et c'est la règle déjà appliquée deux fois cette semaine — ligne
// des ratios instantanés, avertissement « partiel » du bilan : un texte permanent
// cesse d'être lu le jour exact où il devient vrai.
if (entry.dailyDeadline.isNotEmpty &&
(_ratios?.fuseauDiffereDeLAppareil ?? false) &&
_ratios?.timeZone != null) ...[
const SizedBox(height: 6),
_Aide(t.obligationEcheanceHorloge(
entry.dailyDeadline, _ratios!.timeZone!)),
],
if (avertir) ...[
const SizedBox(height: 12),
_Aide(
'${t.obligationNonMesurableTitre}. '
'${t.obligationNonMesurableCorps(_coutQuotidien())} '
'${switch (cause) {
'noMeter' => t.obligationCauseNoMeter,
'noEnergyMeter' => t.obligationCauseNoEnergyMeter,
'noSessionEnergy' => t.obligationCauseNoSessionEnergy,
_ => t.obligationCauseInconnue(cause ?? '—'),
}}',
alerte: true,
),
],
],
),
);
}
}