RELAIS : mon avertissement de reconstruction sur un réordonnancement est devenu FAUX, et
il est corrigé. `+etm16` compare la TABLE que les relais produisent (deriveStages), plus la
liste par index. Le miroir Dart suit, y compris le détail qui compte : les ThingIds d'une
combinaison se comparent comme un ENSEMBLE — collectés dans l'ordre de déclaration, fermer
{K1,K2} ou {K2,K1} est le même geste électrique. Mais l'appariement palier par palier
reste positionnel, ce qui préserve le seul cas où l'ordre signifie encore quelque chose :
deux relais de MÊME puissance, où il décide quel contact sert ce palier.
Conséquence pour l'écran : réordonner des contacteurs de puissances distinctes n'avertit
plus de rien. Avertir sur un geste devenu gratuit serait aussi trompeur que de se taire sur
un geste coûteux.
PALIERS : l'app ne dérive plus la combinatoire pour l'affichage — la box les publie
(mechanism.stagesW). Vérifié sur appareil : [0, 500, 1000, 1500, 2000, 2500, 3000, 3500].
deriveStages reste côté app pour la seule annonce d'impact, qui se fait avant l'écriture.
Tant que le brouillon n'a pas touché aux contacteurs, c'est la table PUBLIÉE qui s'affiche ;
dès qu'il y touche elle devient périmée, et l'écran montre une prévision annoncée comme
telle plutôt qu'une table qui décrit l'état d'avant.
COMPTEUR ET SONDE : ils ne « vont pas arriver », ils SONT au schéma de .75
(o:meterThingId, o:sensorThingId). Le grisage dérivé les a donc activés seul, sans nouvelle
version de l'app — exactement ce qui était promis. Mais un bloc actif sur un placeholder
mort est pire qu'un bloc grisé : les deux sont désormais câblés sur un vrai sélecteur,
filtré par INTERFACE (smartmeter, temperaturesensor) et jamais par nom de plugin.
Ni l'un ni l'autre n'entre dans sameHardware() : les rattacher ne reconstruit rien. Ni dans
le contrôle d'unicité : deux charges peuvent partager une sonde, un compteur divisionnaire
en couvrir plusieurs — le sélecteur ne grise donc rien ici, contrairement aux contacteurs.
Et la mesure sert à VÉRIFIER, jamais à décider : elle n'entre pas dans le budget, la charge
étant déjà comptée dans le racine dont elle n'est qu'une décomposition.
COMPTEURS CUMULÉS : la base purgée, les valeurs sont saines (55 730 / 50 / 6 327 / 62 007
kWh) et la tuile « compteur box invalide » a disparu d'elle-même. Le plafond de
plausibilité reste : un zéro réel s'affiche « 0,0 kWh », jamais « — ».
hasNeverRun était déjà traité comme un état distinct — « l'absence de cycle est dite pour
elle-même, jamais rendue par un âge de zéro ».
Vérifié sur appareil contre .75 : compteur racine relu, « à configurer » vide, deux bornes
listées (Simulated wallbox + Terra AC Charger), stagesW de la box, les deux champs
writable, 5/5 sections de l'écran SG-Ready. Tests 98/98, analyze 0 erreur.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
829 lines
31 KiB
Dart
829 lines
31 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../l10n/app_localizations.dart';
|
|
import '../l10n/l10n_ext.dart';
|
|
import '../models/load_config_entry.dart';
|
|
import '../providers/load_config_provider.dart';
|
|
import '../providers/load_telemetry_provider.dart';
|
|
import '../services/load_hardware.dart';
|
|
import '../services/load_priority.dart';
|
|
import '../services/nymea_service.dart';
|
|
import '../theme/etm_tokens.dart';
|
|
import 'load_cards.dart';
|
|
|
|
/// Les **charges pilotables** de l'écran « Rôles & appareils », telles que la box les
|
|
/// déclare.
|
|
///
|
|
/// ## Le sens de la fusion
|
|
///
|
|
/// Cette section remplace l'ancien écran « Ordre de service » — c'est son **chemin de
|
|
/// données** qui a migré ici, pas l'inverse. La distinction n'est pas de forme : l'écran
|
|
/// des rôles reposait sur une énumération figée de six rôles
|
|
/// (`gridMeter`, `solarMeter`, `evCharger`, `dhw`, `heatPump`, `battery`), et une
|
|
/// énumération figée ne peut pas décrire une installation.
|
|
///
|
|
/// **LM-201 le dit sans détour : une charge existe quand il y a un canal de commande
|
|
/// distinct pour un service.** Un canal, une charge. Une PAC air/eau qui fait chauffage
|
|
/// ET ECS sans ballon séparé est **une** charge — le SG-Ready commande la machine
|
|
/// entière et la répartition appartient à sa régulation. Le modèle des rôles, lui, aurait
|
|
/// exigé d'en déclarer deux (`dhw` **et** `heatPump`) pour une seule machine, donc deux
|
|
/// entrées se disputant un canal unique. C'est exactement ce que `validateSet()` refuse
|
|
/// côté box, et ce refus n'est pas une limite subie : c'est le modèle.
|
|
///
|
|
/// La liste affichée vient donc de `GetLoadConfig`, avec ses identifiants et ses domaines
|
|
/// tels quels — y compris ceux qu'aucune version d'app ne connaît encore.
|
|
///
|
|
/// ## Deux sources qu'il ne faut pas confondre
|
|
///
|
|
/// - **la configuration** (`GetLoadConfig`) — ce que l'installateur a déclaré, éditable ;
|
|
/// - **la télémétrie** (`GetLoadTelemetry`) — ce que l'arbitre a fait au dernier cycle,
|
|
/// en lecture seule et jamais reconstruite côté app.
|
|
///
|
|
/// Depuis le lot plugin B-bis, toute charge publiée en télémétrie a une entrée en
|
|
/// configuration. L'inverse reste faux : une charge configurée absente de la télémétrie
|
|
/// est **désactivée**, jamais perdue — et « non arbitrée » n'est donc pas une erreur.
|
|
class ControllableLoadsSection extends StatelessWidget {
|
|
/// Ouvre l'écran de mécanisme d'une charge. Fourni par l'écran hôte, qui connaît la
|
|
/// navigation ; les trois contrats ne se ressemblent pas, donc il n'y a pas de
|
|
/// formulaire unique à ouvrir.
|
|
final void Function(BuildContext, LoadConfigEntry) onConfigure;
|
|
|
|
const ControllableLoadsSection({super.key, required this.onConfigure});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final provider = context.watch<LoadConfigProvider>();
|
|
final telemetry = context.watch<LoadTelemetryProvider>();
|
|
final groups = provider.groups;
|
|
|
|
if (provider.loading && groups.isEmpty) {
|
|
return const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 24),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
);
|
|
}
|
|
|
|
if (groups.isEmpty) {
|
|
return _EmptyNote(error: provider.error);
|
|
}
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Le budget du cycle AVANT les charges : « Alloué 3 000 W » ne veut rien dire
|
|
// tant qu'on ne sait pas s'il y avait 3 100 ou 12 000 W à distribuer.
|
|
TelemetryHeader(telemetry: telemetry),
|
|
// Le niveau glissable est celui des DOMAINES, pas des cartes.
|
|
//
|
|
// Deux ReorderableListView imbriqués se disputent le geste — l'extérieur gagne et
|
|
// l'intérieur devient inerte. Il faut donc choisir, et le choix se décide par ce
|
|
// que l'installateur cherche à faire : sur une installation ordinaire, chaque
|
|
// domaine porte UNE charge, et l'ordre de service se règle donc entre domaines.
|
|
// C'est là que la prise doit être. Réordonner deux charges d'un même domaine reste
|
|
// possible, par les flèches de leur carte.
|
|
ReorderableListView.builder(
|
|
shrinkWrap: true,
|
|
primary: false,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
buildDefaultDragHandles: false,
|
|
itemCount: groups.length,
|
|
onReorder: provider.reorderDomains,
|
|
itemBuilder: (context, i) => _DomainBlock(
|
|
key: ValueKey('domain-${groups[i].domain}'),
|
|
group: groups[i],
|
|
index: i,
|
|
total: groups.length,
|
|
provider: provider,
|
|
telemetry: telemetry,
|
|
onConfigure: onConfigure,
|
|
),
|
|
),
|
|
// La borne n'est PAS une LoadConfig : elle n'apparaît donc dans aucun domaine
|
|
// ci-dessus, et sans cette section elle serait tout simplement invisible — ce
|
|
// qu'un installateur qui vient de la configurer lit comme une perte.
|
|
const BorneSection(),
|
|
if (provider.hasPendingChanges) ...[
|
|
const SizedBox(height: 4),
|
|
ImpactSaveBar(provider: provider),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Un domaine et ses charges. Les domaines se déplacent entre eux, les charges à
|
|
/// l'intérieur — les deux niveaux que l'ordre de service demande.
|
|
class _DomainBlock extends StatelessWidget {
|
|
final DomainGroup group;
|
|
final int index;
|
|
final int total;
|
|
final LoadConfigProvider provider;
|
|
final LoadTelemetryProvider telemetry;
|
|
final void Function(BuildContext, LoadConfigEntry) onConfigure;
|
|
|
|
const _DomainBlock({
|
|
super.key,
|
|
required this.group,
|
|
required this.index,
|
|
required this.total,
|
|
required this.provider,
|
|
required this.telemetry,
|
|
required this.onConfigure,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.l10n;
|
|
final muted = EtmTokens.mutedOf(context);
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(children: [
|
|
ReorderableDragStartListener(
|
|
key: ValueKey('poignee-domaine-${group.domain}'),
|
|
index: index,
|
|
child: Container(
|
|
color: Colors.transparent,
|
|
padding: const EdgeInsets.only(right: 7, top: 4, bottom: 4),
|
|
child: Icon(Icons.drag_indicator_rounded, size: 19, color: muted),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Text(
|
|
domainLabel(t, group.domain),
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 13,
|
|
letterSpacing: 0.2,
|
|
color: group.isUnclassified ? muted : EtmTokens.inkOf(context),
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
visualDensity: VisualDensity.compact,
|
|
icon: const Icon(Icons.keyboard_arrow_up_rounded, size: 20),
|
|
tooltip: 'Monter ce domaine',
|
|
onPressed:
|
|
index == 0 ? null : () => provider.reorderDomains(index, index - 1),
|
|
),
|
|
IconButton(
|
|
visualDensity: VisualDensity.compact,
|
|
icon: const Icon(Icons.keyboard_arrow_down_rounded, size: 20),
|
|
tooltip: 'Descendre ce domaine',
|
|
onPressed: index >= total - 1
|
|
? null
|
|
: () => provider.reorderDomains(index, index + 2),
|
|
),
|
|
]),
|
|
const SizedBox(height: 6),
|
|
for (var i = 0; i < group.loads.length; i++)
|
|
Padding(
|
|
key: ValueKey('load-${group.loads[i].id}'),
|
|
padding: const EdgeInsets.only(bottom: 10),
|
|
child: LoadConfigCard(
|
|
entry: group.loads[i],
|
|
indexDansDomaine: i,
|
|
totalDansDomaine: group.loads.length,
|
|
provider: provider,
|
|
telemetry: telemetry,
|
|
onConfigure: onConfigure,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Carte d'une charge : identité, classement, rang, télémétrie, et l'accès au mécanisme.
|
|
class LoadConfigCard extends StatelessWidget {
|
|
final LoadConfigEntry entry;
|
|
/// Position dans son domaine, et taille du domaine. Ensemble, ils disent si un
|
|
/// réordonnancement INTERNE a un sens — seul dans son domaine, il n'y a rien à déplacer,
|
|
/// et l'ordre se change alors en glissant le domaine.
|
|
final int indexDansDomaine;
|
|
final int totalDansDomaine;
|
|
|
|
final LoadConfigProvider provider;
|
|
final LoadTelemetryProvider telemetry;
|
|
final void Function(BuildContext, LoadConfigEntry) onConfigure;
|
|
|
|
const LoadConfigCard({
|
|
super.key,
|
|
required this.entry,
|
|
required this.indexDansDomaine,
|
|
required this.totalDansDomaine,
|
|
required this.provider,
|
|
required this.telemetry,
|
|
required this.onConfigure,
|
|
});
|
|
|
|
/// Libellé du mécanisme. Code inconnu → repli lisible, jamais une phrase vide : une
|
|
/// box plus récente peut publier un mécanisme que cette version d'app ignore.
|
|
String _adapterLabel(L10n t) => switch (entry.adapter) {
|
|
'relay-router' => t.adapterRelayRouter,
|
|
'etmvariableload' => t.adapterEtmVariableLoad,
|
|
'sg-ready' => t.adapterSgReady,
|
|
'evcharger' => t.adapterEvCharger,
|
|
_ => t.adapterUnknown(entry.adapter),
|
|
};
|
|
|
|
/// « Routeur de relais · 3 relais · min ON 60 s » — le mécanisme, son matériel, ses
|
|
/// verrous. Chaque morceau n'apparaît que s'il a une valeur à montrer.
|
|
String _resume(L10n t) {
|
|
final bouts = <String>[_adapterLabel(t)];
|
|
if (entry.isRelayRouter) {
|
|
bouts.add('${entry.relays.length} relais');
|
|
} else if (entry.isSgReady) {
|
|
bouts.add('${entry.sgReadyStates.length} états');
|
|
if (entry.minStateHoldS > 0) {
|
|
bouts.add('maintien d\'état ${entry.minStateHoldS} s');
|
|
}
|
|
} else if (entry.maxPowerW > 0) {
|
|
bouts.add('${entry.maxPowerW} W');
|
|
}
|
|
if (!entry.isSgReady) {
|
|
if (entry.minOnS > 0) bouts.add('min ON ${entry.minOnS} s');
|
|
if (entry.minOffS > 0) bouts.add('min OFF ${entry.minOffS} s');
|
|
}
|
|
return bouts.join(' · ');
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.l10n;
|
|
final muted = EtmTokens.mutedOf(context);
|
|
final live = telemetry.entryFor(entry.id);
|
|
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: EtmTokens.surfaceOf(context),
|
|
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
|
|
boxShadow: EtmTokens.cardShadowOf(context),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Le rang, et — si le domaine porte plusieurs charges — de quoi les
|
|
// ordonner entre elles. Le glissement, lui, appartient au domaine.
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 1, right: 9),
|
|
child: Column(children: [
|
|
Container(
|
|
width: 20,
|
|
height: 20,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: EtmTokens.brand.withValues(alpha: 0.12),
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Text('${entry.priority}',
|
|
style: const TextStyle(
|
|
fontSize: 10.5,
|
|
fontWeight: FontWeight.w700,
|
|
color: EtmTokens.brand)),
|
|
),
|
|
if (totalDansDomaine > 1) ...[
|
|
const SizedBox(height: 2),
|
|
_FlecheRang(
|
|
key: ValueKey('monter-${entry.id}'),
|
|
icone: Icons.keyboard_arrow_up_rounded,
|
|
actif: indexDansDomaine > 0,
|
|
onTap: () => provider.reorderWithinDomain(
|
|
entry.domain ?? kUnclassifiedDomain,
|
|
indexDansDomaine,
|
|
indexDansDomaine - 1),
|
|
),
|
|
_FlecheRang(
|
|
key: ValueKey('descendre-${entry.id}'),
|
|
icone: Icons.keyboard_arrow_down_rounded,
|
|
actif: indexDansDomaine < totalDansDomaine - 1,
|
|
onTap: () => provider.reorderWithinDomain(
|
|
entry.domain ?? kUnclassifiedDomain,
|
|
indexDansDomaine,
|
|
indexDansDomaine + 2),
|
|
),
|
|
],
|
|
]),
|
|
),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
entry.label,
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w700,
|
|
color: entry.enabled
|
|
? EtmTokens.inkOf(context)
|
|
: muted,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
// Ce que l'installateur doit reconnaître d'un coup d'œil : le
|
|
// mécanisme, ce qu'il commande, et les verrous qui le brident.
|
|
// « rang 1 » n'apprend rien ici — la pastille le dit déjà.
|
|
Text(_resume(t),
|
|
maxLines: 2,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 11.5, height: 1.3, color: muted)),
|
|
],
|
|
),
|
|
),
|
|
// `enabled` : entrée/sortie de l'arbitrage. Le libellé de l'aide dit ce que
|
|
// le drapeau fait VRAIMENT — voir _EnabledSwitch.
|
|
_EnabledSwitch(entry: entry, provider: provider),
|
|
],
|
|
),
|
|
const SizedBox(height: 10),
|
|
Row(children: [
|
|
DomainMenu(
|
|
key: ValueKey('chip-domaine-${entry.id}'),
|
|
entry: entry,
|
|
provider: provider),
|
|
const Spacer(),
|
|
TextButton.icon(
|
|
onPressed: () => onConfigure(context, entry),
|
|
icon: const Icon(Icons.tune_rounded, size: 17),
|
|
label: const Text('Configurer'),
|
|
style: TextButton.styleFrom(
|
|
visualDensity: VisualDensity.compact,
|
|
foregroundColor: EtmTokens.brand,
|
|
),
|
|
),
|
|
]),
|
|
_RenameRow(entry: entry, provider: provider),
|
|
const SizedBox(height: 8),
|
|
Divider(height: 1, color: EtmTokens.lineOf(context)),
|
|
const SizedBox(height: 8),
|
|
if (live != null)
|
|
LoadLiveBlock(
|
|
live: live,
|
|
stale: telemetry.stale,
|
|
telemetry: telemetry,
|
|
)
|
|
else
|
|
// Absente de `loads[]` : désactivée, pas perdue. Le dire explicitement —
|
|
// un vide se lirait comme une panne.
|
|
Row(children: [
|
|
Icon(Icons.pause_circle_outline_rounded, size: 14, color: muted),
|
|
const SizedBox(width: 5),
|
|
Expanded(
|
|
child: Text(
|
|
entry.enabled
|
|
? 'Non arbitrée à ce cycle — configurée, mais absente de la '
|
|
'télémétrie du dernier cycle.'
|
|
: 'Désactivée — rôle déclaré, exclue de l\'arbitrage.',
|
|
style: TextStyle(fontSize: 11.5, color: muted, height: 1.3),
|
|
),
|
|
),
|
|
]),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Interrupteur d'entrée dans l'arbitrage, avec ce que le geste fait réellement.
|
|
class _EnabledSwitch extends StatelessWidget {
|
|
final LoadConfigEntry entry;
|
|
final LoadConfigProvider provider;
|
|
|
|
const _EnabledSwitch({required this.entry, required this.provider});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Tooltip(
|
|
// Ce texte est le résultat d'une mesure, pas une précaution rédactionnelle :
|
|
// sur `.75`, PAC en état 4 (contacts fermés), `enabled: false` a bien OUVERT les
|
|
// contacts — par le retrait de l'adaptateur (ECS-413), pas par le drapeau.
|
|
message: entry.enabled
|
|
? 'Désactiver retire la charge de l\'arbitrage et l\'amène à son état sûr '
|
|
'(contacts ouverts) avant de la retirer.\n'
|
|
'Ce n\'est pas un sectionnement : la sécurité vient du retrait, pas du '
|
|
'drapeau.'
|
|
: 'Activer fait entrer la charge dans l\'arbitrage au prochain cycle.',
|
|
child: Switch.adaptive(
|
|
value: entry.enabled,
|
|
onChanged: (v) => provider.setEnabled(entry.id, v),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Renommage en ligne. Le libellé n'est pas du matériel — aucun verrou n'est réarmé.
|
|
class _RenameRow extends StatefulWidget {
|
|
final LoadConfigEntry entry;
|
|
final LoadConfigProvider provider;
|
|
|
|
const _RenameRow({required this.entry, required this.provider});
|
|
|
|
@override
|
|
State<_RenameRow> createState() => _RenameRowState();
|
|
}
|
|
|
|
class _RenameRowState extends State<_RenameRow> {
|
|
bool _editing = false;
|
|
late final TextEditingController _c =
|
|
TextEditingController(text: widget.entry.label);
|
|
|
|
@override
|
|
void dispose() {
|
|
_c.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (!_editing) {
|
|
return Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: TextButton.icon(
|
|
onPressed: () => setState(() => _editing = true),
|
|
icon: const Icon(Icons.edit_outlined, size: 15),
|
|
label: const Text('Renommer'),
|
|
style: TextButton.styleFrom(
|
|
visualDensity: VisualDensity.compact,
|
|
foregroundColor: EtmTokens.mutedOf(context),
|
|
textStyle: const TextStyle(fontSize: 12),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 6),
|
|
child: Row(children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _c,
|
|
autofocus: true,
|
|
style: const TextStyle(fontSize: 14),
|
|
decoration: const InputDecoration(
|
|
isDense: true,
|
|
labelText: 'Libellé',
|
|
helperText: 'Sans effet sur le matériel — aucun verrou réarmé.',
|
|
helperMaxLines: 2,
|
|
),
|
|
onSubmitted: (_) => _commit(),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.check_rounded, size: 20),
|
|
onPressed: _commit,
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.close_rounded, size: 20),
|
|
onPressed: () => setState(() {
|
|
_c.text = widget.entry.label;
|
|
_editing = false;
|
|
}),
|
|
),
|
|
]),
|
|
);
|
|
}
|
|
|
|
void _commit() {
|
|
final v = _c.text.trim();
|
|
if (v.isNotEmpty && v != widget.entry.label) {
|
|
widget.provider.setLabel(widget.entry.id, v);
|
|
}
|
|
setState(() => _editing = false);
|
|
}
|
|
}
|
|
|
|
/// Barre d'enregistrement — et **annonce de ce que l'écriture va provoquer**.
|
|
///
|
|
/// C'est la règle §5-3 du lot : `priority`, `domain`, `label` et `enabled` se mettent à
|
|
/// jour sans rien perturber, mais toucher `relays[]`, le mécanisme ou les Things
|
|
/// **reconstruit l'adaptateur** — contacts ouverts, verrous réarmés à froid. Un
|
|
/// installateur qui change un relais ne doit pas découvrir après coup que sa PAC est
|
|
/// bloquée un quart d'heure.
|
|
class ImpactSaveBar extends StatelessWidget {
|
|
final LoadConfigProvider provider;
|
|
|
|
const ImpactSaveBar({super.key, required this.provider});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final impacts = provider.pendingImpact();
|
|
final warnings = impacts.where((i) => i.needsWarning).toList();
|
|
final pending = provider.saveState == LoadSaveState.pending;
|
|
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: EtmTokens.surfaceOf(context),
|
|
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
|
|
border: Border.all(
|
|
color: warnings.isEmpty
|
|
? EtmTokens.lineOf(context)
|
|
: EtmTokens.orange.withValues(alpha: 0.6),
|
|
),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (warnings.isNotEmpty) ...[
|
|
for (final w in warnings) _ImpactLine(impact: w),
|
|
const SizedBox(height: 8),
|
|
],
|
|
Row(children: [
|
|
Expanded(
|
|
child: Text(
|
|
warnings.isEmpty
|
|
? 'Modifications en attente — rien n\'est parti vers la box.'
|
|
: 'À confirmer : l\'enregistrement touche au matériel.',
|
|
style: TextStyle(
|
|
fontSize: 12, color: EtmTokens.mutedOf(context), height: 1.3),
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: pending ? null : provider.discardDraft,
|
|
child: const Text('Annuler'),
|
|
),
|
|
const SizedBox(width: 4),
|
|
FilledButton(
|
|
onPressed: pending ? null : provider.save,
|
|
child: Text(pending ? 'Envoi…' : 'Enregistrer'),
|
|
),
|
|
]),
|
|
if (provider.saveState == LoadSaveState.rejected)
|
|
_RejectionNote(provider: provider),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Une ligne d'avertissement d'impact, qui nomme **ce qui** l'a déclenché.
|
|
///
|
|
/// « Vous avez changé l'ordre des contacteurs » est actionnable ; « la charge sera
|
|
/// reconstruite » ne l'est pas.
|
|
class _ImpactLine extends StatelessWidget {
|
|
final LoadImpact impact;
|
|
|
|
const _ImpactLine({required this.impact});
|
|
|
|
static const _fieldNames = {
|
|
'relays': 'la liste des contacteurs',
|
|
'sgReady': 'l\'encodage SG-Ready',
|
|
'minOnS': 'la temporisation de marche',
|
|
'minOffS': 'la temporisation d\'arrêt',
|
|
'maxPowerW': 'la puissance nominale',
|
|
'powerLevels': 'les paliers déclarés',
|
|
'adapter': 'le mécanisme',
|
|
'mode': 'le mode',
|
|
};
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final quoi = impact.changedFields
|
|
.map((f) => _fieldNames[f] ?? f)
|
|
.join(', ');
|
|
final rearm = impact.rearmSeconds;
|
|
final duree = rearm == null
|
|
? ''
|
|
: rearm >= 60
|
|
? ' pendant ${(rearm / 60).round()} min'
|
|
: ' pendant $rearm s';
|
|
|
|
final texte = switch (impact.impact) {
|
|
LoadChangeImpact.rebuild =>
|
|
'« ${impact.label} » — vous avez modifié $quoi. La charge sera reconstruite : '
|
|
'ses contacts passent par l\'état sûr, puis son verrou se réarme$duree.',
|
|
LoadChangeImpact.removed =>
|
|
'« ${impact.label} » quitte l\'arbitrage. Elle est amenée à son état sûr '
|
|
'(contacts ouverts) avant retrait.',
|
|
_ => '',
|
|
};
|
|
if (texte.isEmpty) return const SizedBox.shrink();
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 6),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Icon(Icons.warning_amber_rounded, size: 16, color: EtmTokens.orange),
|
|
const SizedBox(width: 7),
|
|
Expanded(
|
|
child: Text(texte,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
height: 1.35,
|
|
color: EtmTokens.inkOf(context))),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Refus de la box. **Ne rien inventer** : `SetLoadConfig` rend un code sans motif, et
|
|
/// la règle enfreinte reste au journal de la box. On affiche le refus et on y renvoie.
|
|
class _RejectionNote extends StatelessWidget {
|
|
final LoadConfigProvider provider;
|
|
|
|
const _RejectionNote({required this.provider});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final conflit = provider.conflict;
|
|
final err = provider.saveError;
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 10),
|
|
child: Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(
|
|
color: EtmTokens.danger.withValues(alpha: 0.09),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
conflit != null
|
|
? 'Deux charges revendiquent le même appareil.'
|
|
: 'La box a refusé l\'enregistrement${err == null ? '' : ' ($err)'}.',
|
|
style: const TextStyle(
|
|
fontSize: 12.5,
|
|
fontWeight: FontWeight.w700,
|
|
color: EtmTokens.danger),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
conflit != null
|
|
// LM-201 : deux charges sur un canal unique se contrediraient, et le
|
|
// budget compterait la même puissance deux fois. Ce n'est pas une
|
|
// limite technique — c'est le modèle.
|
|
? 'Un appareil ne peut appartenir qu\'à une seule charge : deux '
|
|
'charges sur un même canal de commande se contrediraient.'
|
|
: 'Le refus porte sur l\'ensemble : rien n\'a été modifié. La règle '
|
|
'enfreinte et le libellé en cause sont dans le journal de la box '
|
|
'(journalctl -u nymead).',
|
|
style: TextStyle(
|
|
fontSize: 11.5,
|
|
height: 1.35,
|
|
color: EtmTokens.mutedOf(context)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _EmptyNote extends StatelessWidget {
|
|
final String? error;
|
|
|
|
const _EmptyNote({this.error});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final muted = EtmTokens.mutedOf(context);
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 18),
|
|
child: Text(
|
|
error == null
|
|
? 'Aucune charge pilotable déclarée sur cette box.'
|
|
: 'Configuration illisible : $error',
|
|
style: TextStyle(fontSize: 12.5, color: muted, height: 1.35),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
/// Les bornes de recharge, qui **ne sont pas des charges pilotables**.
|
|
///
|
|
/// ## Pourquoi elles ont leur propre section
|
|
///
|
|
/// Une borne n'a ni `adapter`, ni `priority`, ni `domain` : elle ne passe pas par
|
|
/// `SetLoadConfig` mais par `SetChargingInfo`, un **autre contrat**. Et elle n'apparaît
|
|
/// pas dans `GetLoadTelemetry` en beta — elle est décidée **en amont** du waterfall, sa
|
|
/// puissance étant seulement réservée dans `budget.evReservedW`. Elle rejoindra `loads[]`
|
|
/// à l'étape 3g.
|
|
///
|
|
/// Conséquence pratique, et c'est le défaut que cette section corrige : configurée ou non,
|
|
/// la borne n'apparaissait **nulle part** dans cet écran. Un installateur qui vient de la
|
|
/// régler et ne la retrouve pas conclut, raisonnablement, que son réglage s'est perdu.
|
|
class BorneSection extends StatelessWidget {
|
|
const BorneSection({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final service = context.watch<NymeaService>();
|
|
final classesBorne = {
|
|
for (final c in service.thingClasses)
|
|
if (c.interfaces.contains('evcharger')) c.id
|
|
};
|
|
final bornes = [
|
|
for (final t in service.things)
|
|
if (classesBorne.contains(t.thingClassId)) t
|
|
];
|
|
if (bornes.isEmpty) return const SizedBox.shrink();
|
|
|
|
final muted = EtmTokens.mutedOf(context);
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const SizedBox(height: 6),
|
|
Text('Recharge véhicule',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 0.2,
|
|
color: EtmTokens.inkOf(context))),
|
|
const SizedBox(height: 8),
|
|
for (final b in bornes)
|
|
Container(
|
|
width: double.infinity,
|
|
margin: const EdgeInsets.only(bottom: 10),
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: EtmTokens.surfaceOf(context),
|
|
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
|
|
border: Border.all(color: EtmTokens.lineOf(context)),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(children: [
|
|
Icon(Icons.ev_station_rounded, size: 19, color: muted),
|
|
const SizedBox(width: 9),
|
|
Expanded(
|
|
child: Text(b.name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w700,
|
|
color: EtmTokens.inkOf(context))),
|
|
),
|
|
]),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
'Pas une charge pilotable : la borne se règle par un autre contrat '
|
|
'(SetChargingInfo) et n\'a ni rang ni domaine. Elle est décidée en '
|
|
'amont du waterfall — sa puissance est réservée dans le budget, pas '
|
|
'allouée depuis lui.',
|
|
style: TextStyle(fontSize: 11.5, height: 1.35, color: muted),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Le mode de recharge se règle depuis la carte véhicule du tableau de '
|
|
'bord.',
|
|
style: TextStyle(fontSize: 11.5, height: 1.35, color: muted),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
/// Petite flèche de rang, à l'intérieur d'un domaine.
|
|
///
|
|
/// Le glisser-déposer appartient au domaine ; à l'intérieur, deux charges se réordonnent
|
|
/// par ces flèches. Rendre les deux niveaux glissables est impossible — les listes
|
|
/// réordonnables imbriquées se disputent le geste, et c'est l'extérieure qui gagne.
|
|
class _FlecheRang extends StatelessWidget {
|
|
final IconData icone;
|
|
final bool actif;
|
|
final VoidCallback onTap;
|
|
|
|
const _FlecheRang({
|
|
super.key,
|
|
required this.icone,
|
|
required this.actif,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final muted = EtmTokens.mutedOf(context);
|
|
return InkWell(
|
|
onTap: actif ? onTap : null,
|
|
borderRadius: BorderRadius.circular(4),
|
|
child: Icon(icone,
|
|
size: 18,
|
|
color: actif ? muted : muted.withValues(alpha: 0.3)),
|
|
);
|
|
}
|
|
}
|
|
|