Je n'avais pas ouvert docs/mockups/ : les écrans ont été construits depuis le texte du brief seul, et l'écart est franc. Les maquettes du 2026-08-25 font autorité, elles sont désormais suivies. ÉCRANS DE MÉCANISME, refaits : - sélecteur de Things PAR NOM, filtré sur l'interface nymea (power / etmvariableload). Taper un UUID au doigt est une erreur garantie ET muette : la box accepte un UUID valide qui ne désigne rien, et la charge ne commande jamais rien. Le sélecteur grise les Things déjà pris — la box refuse en bloc un même Thing sur deux lignes — et signale en rouge un ThingId configuré qui n'existe plus sur la box ; - routeur de relais : ajout/retrait de contacteurs, puissance par ligne, et les paliers atteignables AFFICHÉS. Ils sont déduits (2^N combinaisons, doublons écartés), jamais enregistrés — la maquette est explicite là-dessus, ma note précédente disant l'inverse était une sur-interprétation du brief ; - SG-Ready : SG1/SG2 se choisissent EN HAUT, une fois, et la table des quatre états devient éditable — ce qui s'y coche est fermé/ouvert, et c'est ça qui devient relays[]. Les étiquettes SG1/SG2 sont une convention de l'écran (SG1 = fermé en état 1, SG2 = en état 3) et elles TOMBENT sur un encodage non standard, où les contacts s'affichent tels quels ; - modulable : plafond requis > 0, et l'avertissement que changer d'appareil crée une autre charge puisque l'identifiant EST le ThingId ; - temporisations minOnS/minOffS (et minStateHoldS pour la PAC) en pas de 30/60 s ; - « Compteur dédié » présent et GRISÉ, avec sa raison : aucun champ n'existe côté box, et inventer meterThingId ferait rejeter l'enregistrement de TOUTES les charges ; - « Inclure dans l'arbitrage » avec rang et domaine, et ce que la désactivation fait vraiment selon le mécanisme ; - validation AVANT envoi : relays[] vide, powerW ≤ 0, Thing en double, état 2 absent, maxPowerW ≤ 0. SetLoadConfig refuse en bloc et sans motif — laisser partir une config invalide condamne l'installateur à un refus muet qui perd tout l'appel. LISTE : - l'en-tête d'arbitrage et le tableau de budget, PERDUS à la fusion, sont rétablis. Sans eux « Alloué 3 000 W » ne se rattache à rien ; - le rang passe en pastille près de la poignée, et le sous-titre devient lisible : « Routeur de relais · 3 relais · min ON 60 s » au lieu de « Routeur de relais · rang 1 » ; - section « Recharge véhicule » : la borne n'est pas une LoadConfig, elle n'apparaissait donc NULLE PART — ce qu'un installateur qui vient de la régler lit comme une perte. MENU : l'entrée « Ordre de service des charges » est retirée ; elle pointait sur une redirection, donc sur le même écran sous deux noms. Tests 80/80, analyze 0 erreur. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
1503 lines
53 KiB
Dart
1503 lines
53 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../../l10n/l10n_ext.dart';
|
|
import '../../models/load_config_entry.dart';
|
|
import '../../providers/load_config_provider.dart';
|
|
import '../../providers/schema_provider.dart';
|
|
import '../../services/load_hardware.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/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 e = _entree(provider);
|
|
|
|
return 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(
|
|
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),
|
|
const SizedBox(height: 14),
|
|
if (e.isRelayRouter)
|
|
_SectionRelais(entry: e, provider: provider, service: service)
|
|
else if (e.isSgReady)
|
|
_SectionSgReady(entry: e, provider: provider, service: service)
|
|
else
|
|
_SectionModulable(
|
|
entry: e, provider: provider, service: service),
|
|
const SizedBox(height: 14),
|
|
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),
|
|
_CompteurDedie(schema: schema.schema),
|
|
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)}.',
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Rappelle quel mécanisme porte cette charge — et pourquoi il ne se change pas ici.
|
|
class _Mecanisme extends StatelessWidget {
|
|
final LoadConfigEntry entry;
|
|
|
|
const _Mecanisme({required this.entry});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
const noms = {
|
|
'relay-router': 'Routeur de relais',
|
|
'etmvariableload': 'Modulable',
|
|
'sg-ready': 'SG-Ready',
|
|
};
|
|
return _Carte(
|
|
titre: 'Mécanisme',
|
|
badge: 'enregistré · adapter',
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
for (final e in noms.entries)
|
|
Padding(
|
|
padding: const EdgeInsets.only(right: 8),
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 11, vertical: 7),
|
|
decoration: BoxDecoration(
|
|
color: e.key == entry.adapter
|
|
? EtmTokens.brand.withValues(alpha: 0.14)
|
|
: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(9),
|
|
border: Border.all(
|
|
color: e.key == entry.adapter
|
|
? EtmTokens.brand.withValues(alpha: 0.5)
|
|
: EtmTokens.lineOf(context)),
|
|
),
|
|
child: Text(e.value,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: e.key == entry.adapter
|
|
? FontWeight.w700
|
|
: FontWeight.w400,
|
|
color: e.key == entry.adapter
|
|
? EtmTokens.brand
|
|
: EtmTokens.mutedOf(context),
|
|
)),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 8),
|
|
// Changer de mécanisme, ce n'est pas régler une option : c'est remplacer la
|
|
// charge utile entière (relays[] ⇄ sgReady ⇄ maxPowerW), que la box refuse de
|
|
// voir coexister. Le geste appartient à la création d'une charge, pas à
|
|
// l'édition de celle-ci.
|
|
_Aide(
|
|
'Le mécanisme se choisit à la déclaration de la charge. En changer ici '
|
|
'remplacerait toute la charge utile — les trois ne peuvent pas coexister, '
|
|
'et la box refuse en bloc.',
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Routeur de relais
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
/// Les contacteurs, et **rien d'autre de matériel**.
|
|
///
|
|
/// Les paliers atteignables sont **déduits, jamais enregistrés** : la box les compose
|
|
/// elle-même en essayant toutes les combinaisons de contacts. On les affiche parce qu'ils
|
|
/// sont ce que l'installateur veut vérifier d'un coup d'œil — et on dit qu'ils sont
|
|
/// déduits, pour que personne ne les cherche dans la configuration.
|
|
class _SectionRelais extends StatelessWidget {
|
|
final LoadConfigEntry entry;
|
|
final LoadConfigProvider provider;
|
|
final NymeaService service;
|
|
|
|
const _SectionRelais({
|
|
required this.entry,
|
|
required this.provider,
|
|
required this.service,
|
|
});
|
|
|
|
/// Toutes les puissances atteignables par combinaison de contacts, dédupliquées.
|
|
///
|
|
/// Miroir de ce que fait le `RelayRouter` : 2^N combinaisons, doublons écartés (deux
|
|
/// relais de même puissance ne créent pas de palier supplémentaire), plafond 16 relais.
|
|
static List<int> paliers(List<LoadRelay> relais) {
|
|
if (relais.isEmpty || relais.length > 16) return const [];
|
|
final vus = <int>{0};
|
|
for (var masque = 1; masque < (1 << relais.length); masque++) {
|
|
var somme = 0;
|
|
for (var i = 0; i < relais.length; i++) {
|
|
if (masque & (1 << i) != 0) somme += relais[i].powerW;
|
|
}
|
|
vus.add(somme);
|
|
}
|
|
return vus.toList()..sort();
|
|
}
|
|
|
|
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) {
|
|
final p = paliers(entry.relays);
|
|
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(
|
|
'${entry.relays.length} relais → paliers atteignables :',
|
|
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(
|
|
'Déduit, jamais enregistré. La box compose elle-même les paliers en '
|
|
'essayant toutes les combinaisons de contacts ; deux relais de même '
|
|
'puissance ne créent pas de palier supplémentaire.',
|
|
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;
|
|
|
|
const _SectionModulable({
|
|
required this.entry,
|
|
required this.provider,
|
|
required this.service,
|
|
});
|
|
|
|
@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.',
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// 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é — **prévu, pas encore supporté**, et l'écran le dit.
|
|
///
|
|
/// Le champ n'est pas grisé « au cas où » : il est grisé parce que le schéma de la box ne
|
|
/// le porte pas, et le grisage se dérive de ce schéma. Inventer `meterThingId` ferait
|
|
/// échouer l'enregistrement de **toutes** les charges — nymea rejette toute clé inconnue
|
|
/// **avant** le handler, et le refus porte sur l'appel entier.
|
|
class _CompteurDedie extends StatelessWidget {
|
|
final RpcSchema schema;
|
|
|
|
const _CompteurDedie({required this.schema});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final support = schema.paramSupport(
|
|
'NymeaEnergy.SetLoadConfig', ['loadConfigs', '[]', 'meterThingId']);
|
|
return _Carte(
|
|
titre: 'Compteur dédié',
|
|
badge: 'prévu · pas encore supporté',
|
|
sousTitre: 'Rattacher un compteur à cette charge.',
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
SchemaGated(
|
|
support: support,
|
|
fieldName: 'meterThingId',
|
|
label: 'Compteur dédié',
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(color: EtmTokens.lineOf(context)),
|
|
),
|
|
child: Row(children: [
|
|
Expanded(
|
|
child: Text('Choisir un appareil de mesure…',
|
|
style: TextStyle(
|
|
fontSize: 13.5, color: EtmTokens.mutedOf(context))),
|
|
),
|
|
Icon(Icons.expand_more_rounded,
|
|
size: 20, color: EtmTokens.mutedOf(context)),
|
|
]),
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
_Aide(
|
|
'La box estime la consommation depuis les paliers déclarés, elle ne la '
|
|
'mesure pas — c\'est ici que le besoin est le plus fort. Mais aucun champ '
|
|
'n\'existe : en inventer un ferait rejeter l\'enregistrement de toutes les '
|
|
'charges, pas seulement de celle-ci.',
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// 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.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 StatelessWidget {
|
|
final String titre;
|
|
final String? badge;
|
|
final String? sousTitre;
|
|
final Widget child;
|
|
|
|
const _Carte({
|
|
required this.titre,
|
|
this.badge,
|
|
this.sousTitre,
|
|
required this.child,
|
|
});
|
|
|
|
@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: [
|
|
_SousTitre(titre, badge: badge),
|
|
if (sousTitre != null) ...[
|
|
const SizedBox(height: 3),
|
|
Text(sousTitre!,
|
|
style: TextStyle(
|
|
fontSize: 11.5,
|
|
height: 1.35,
|
|
color: EtmTokens.mutedOf(context))),
|
|
],
|
|
const SizedBox(height: 12),
|
|
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)),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|