L'arrivée de la V2C sur .75 a révélé deux défauts que le banc ne pouvait pas montrer avant, plus deux pièges de contrat à documenter. 1. ⚠️ isNotGrouped ÉTAIT CALCULÉ ET AFFICHÉ NULLE PART. GroupedOrder détecte depuis le premier jour qu'un ordre n'est pas groupé par domaine, le provider l'expose, et aucune vue ne le lisait — le cas ne s'était jamais produit, chaque domaine du banc ayant ses charges d'un seul tenant. Il se produit maintenant : la V2C, créée d'office au rang 1 que le chauffe-eau occupait déjà, donne l'ordre à plat « ecs · ev · heating · ev · ev ». Le domaine ev est coupé en deux par le chauffage. Ce n'était pas un détail d'affichage. L'écran groupe pour afficher, donc ce qu'on voit n'est plus l'ordre réel de la box — et le premier enregistrement réaplatit EN GROUPANT, ce qui change l'arbitrage sans que personne ne l'ait demandé. Chiffré par test sur la configuration réelle : la PAC passerait du rang 2 au rang 5. C'est exactement ce que le commentaire de GroupedOrder interdit depuis le début ; le garde-fou existait, il n'était pas branché. 2. « L'APPAREIL N'EXISTE PLUS » N'EST PAS UN DÉFAUT À LEVER. La Terra AC a été supprimée de nymea ; son entrée LoadConfig survit, occupe le rang 4, et s'affiche en THING_MISSING avec un bouton « Lever le défaut ». ClearLoadFault y répondrait EnergyErrorNoError sans rien changer, et le défaut reviendrait au cycle suivant — un bouton qui ne peut pas tenir sa promesse fait douter de la box au lieu de désigner la cause. Le bouton disparaît sur ce code, remplacé par ce qu'il faut faire : réinstaller l'appareil, ou retirer la charge. Et le libellé cesse de se lire « votre configuration est cassée » : le cas normal est un appareil remplacé, le plugin crée d'office une entrée par borne détectée mais ne la retire pas quand le Thing s'en va. 3. Deux pièges du Thing V2C, documentés là où quelqu'un serait tenté de les câbler : chargeEnergy est EXACT pendant la charge — sa croissance recoupe currentPower à mieux d'un pour cent — et REMIS À ZÉRO à l'arrêt de la charge, pas au débranchement. Mesuré : 0,3456 kWh → 0 dès que charging passe à faux, 119 secondes avant que le câble ne bouge. Sur du pilotage par surplus il repartirait de zéro à chaque nuage, et après coup une nuit entière vaudrait 0 — soit « rien livré » affiché là où la vérité est « pas mesurable ». Le plugin publiera un sessionEnergy construit ; rien n'est bâti en attendant, et surtout aucune accumulation côté app. powerL1/L2/L3 sont des ampères mal nommés, renommage currentL* en cours côté plugin. Ni affichage, ni conversion : une conversion écrite ici survivrait au correctif et le contredirait. Fixture ajoutée : la configuration réelle à cinq entrées, avec ses deux enseignements de terrain — deux rangs à 1, et une entrée orpheline qui consomme un rang. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
940 lines
37 KiB
Dart
940 lines
37 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 '../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 StatefulWidget {
|
||
/// 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
|
||
State<ControllableLoadsSection> createState() =>
|
||
_ControllableLoadsSectionState();
|
||
}
|
||
|
||
class _ControllableLoadsSectionState extends State<ControllableLoadsSection> {
|
||
/// Domaines repliés, par leur code.
|
||
///
|
||
/// Clé par **code de domaine** et non par index : un domaine qui se déplace emporte
|
||
/// son repli, ce qu'un index ne saurait pas faire — il replierait le voisin.
|
||
///
|
||
/// **Non persisté**, délibérément : c'est une commodité de lecture, pas une
|
||
/// configuration. La rendre durable ferait retrouver un écran à moitié caché des
|
||
/// semaines plus tard, sans savoir pourquoi.
|
||
final Set<String> _replies = <String>{};
|
||
|
||
@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: [
|
||
// La note de rang reste EN TÊTE : elle prévient que l'ordre affiché juste en
|
||
// dessous n'en est pas un. La lire après les cartes arriverait trop tard.
|
||
_NoteOrdreEntrelace(provider: provider),
|
||
_NoteRangsExAequo(entries: provider.entries),
|
||
// 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: widget.onConfigure,
|
||
replie: _replies.contains(groups[i].domain),
|
||
onReplier: () => setState(() {
|
||
final d = groups[i].domain;
|
||
_replies.contains(d) ? _replies.remove(d) : _replies.add(d);
|
||
}),
|
||
),
|
||
),
|
||
// Plus de section séparée pour les bornes : depuis `+etm23` elles ONT une entrée
|
||
// `GetLoadConfig` (`adapter: "evcharger"`, `domain: "ev"`, un rang), donc elles
|
||
// apparaissent dans leur domaine comme n'importe quelle charge. La section qui
|
||
// existait pour les rattraper les ferait maintenant apparaître DEUX fois, en
|
||
// affirmant qu'elles n'ont « ni rang ni domaine » — ce qui n'est plus vrai.
|
||
if (provider.hasPendingChanges) ...[
|
||
const SizedBox(height: 4),
|
||
ImpactSaveBar(provider: provider),
|
||
],
|
||
// L'arbitrage en direct EN BAS.
|
||
//
|
||
// Il était en tête, avec cet argument : « 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 ». L'argument
|
||
// reste juste pour LIRE une allocation — mais cet écran sert à CONFIGURER, et
|
||
// faire descendre l'installateur sous un pavé de télémétrie à chaque ouverture
|
||
// met la lecture devant le geste. Le budget reste là, sous les charges qu'il
|
||
// explique, atteignable quand on le cherche.
|
||
const SizedBox(height: 4),
|
||
TelemetryHeader(telemetry: telemetry),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// **L'ordre en place n'est PAS groupé par domaine — et l'enregistrer le changerait.**
|
||
///
|
||
/// `GroupedOrder` calcule cette contiguïté depuis le premier jour, et le provider
|
||
/// l'expose (`isNotGrouped`). Rien ne l'affichait : le cas ne s'était jamais produit sur
|
||
/// le banc, chaque domaine y ayant ses charges d'un seul tenant.
|
||
///
|
||
/// Il s'est produit le 2026-08-28, à l'arrivée de la V2C. Créée d'office au rang 1 — que
|
||
/// le chauffe-eau occupait déjà — elle a donné l'ordre à plat `ecs · ev · heating · ev ·
|
||
/// ev` : le domaine `ev` est coupé en deux par le chauffage.
|
||
///
|
||
/// **Pourquoi il faut le dire, et pas seulement le savoir.** L'écran groupe les charges
|
||
/// par domaine pour les afficher. Sur un ordre entrelacé, ce regroupement ne correspond
|
||
/// plus à l'ordre réel de la box, et le premier enregistrement réaplatit en groupant —
|
||
/// donc **change l'arbitrage** sans que personne ne l'ait demandé. Mesuré sur la
|
||
/// configuration du banc : la PAC passerait du rang 2 au rang 5.
|
||
///
|
||
/// La note ne bloque rien : elle nomme l'écart et annonce ce que l'enregistrement fera.
|
||
/// Décider reste à l'installateur — mais en connaissance de cause.
|
||
class _NoteOrdreEntrelace extends StatelessWidget {
|
||
final LoadConfigProvider provider;
|
||
|
||
const _NoteOrdreEntrelace({required this.provider});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (!provider.isNotGrouped) return const SizedBox.shrink();
|
||
final doms = provider.interleavedDomains
|
||
.map((d) => domainLabel(context.l10n, d))
|
||
.join(', ');
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 10),
|
||
child: Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(11),
|
||
decoration: BoxDecoration(
|
||
color: EtmTokens.orange.withValues(alpha: 0.10),
|
||
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
|
||
border: Border.all(color: EtmTokens.orange.withValues(alpha: 0.45)),
|
||
),
|
||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Icon(Icons.shuffle_rounded, size: 15, color: EtmTokens.orange),
|
||
const SizedBox(width: 7),
|
||
Expanded(
|
||
child: Text(
|
||
'L\'ordre en place sur la box n\'est pas groupé par domaine : $doms '
|
||
'est coupé en deux par une autre famille de charges. L\'écran les regroupe '
|
||
'pour les afficher, donc ce que vous voyez n\'est pas l\'ordre réel.\n\n'
|
||
'Le premier enregistrement REGROUPERA pour de bon, et changera l\'ordre de '
|
||
'service du surplus. Vérifiez les rangs avant d\'enregistrer.',
|
||
style: TextStyle(
|
||
fontSize: 11.5, height: 1.35, color: EtmTokens.inkOf(context)),
|
||
),
|
||
),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// **L'ordre affiché n'est pas un choix quand des rangs sont à égalité.**
|
||
///
|
||
/// À la détection d'une borne, le plugin lui attribue « le plus petit rang existant moins
|
||
/// un, borné à 1 ». Dès qu'une charge occupe déjà le rang 1 — le cas de toute installation
|
||
/// déjà réglée — l'intention « en tête » dégénère en égalité, et plusieurs charges se
|
||
/// retrouvent au même rang.
|
||
///
|
||
/// Le tri reste **total** : l'identifiant départage, donc l'ordre ne bouge pas d'un cycle
|
||
/// à l'autre. Mais reproductible n'est pas choisi, et c'est toute la différence pour qui
|
||
/// regarde cette liste à la mise en service. Sans cette note, l'installateur lit un
|
||
/// classement, le trouve plausible, et n'y touche pas — validant par inadvertance un
|
||
/// ordre que personne n'a posé.
|
||
///
|
||
/// Elle disparaît d'elle-même dès que les rangs sont distincts, c'est-à-dire dès le
|
||
/// premier vrai réglage.
|
||
class _NoteRangsExAequo extends StatelessWidget {
|
||
final List<LoadConfigEntry> entries;
|
||
|
||
const _NoteRangsExAequo({required this.entries});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final exAequo = tiedByPriority(entries);
|
||
if (exAequo.isEmpty) return const SizedBox.shrink();
|
||
|
||
final noms = exAequo.map((e) => e.label).join(', ');
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 10),
|
||
child: Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(11),
|
||
decoration: BoxDecoration(
|
||
color: EtmTokens.solar.withValues(alpha: 0.10),
|
||
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
|
||
),
|
||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Icon(Icons.low_priority_rounded,
|
||
size: 15, color: EtmTokens.inkOf(context)),
|
||
const SizedBox(width: 7),
|
||
Expanded(
|
||
child: Text(
|
||
'Rangs à égalité : $noms. L\'ordre affiché entre elles est stable, mais '
|
||
'ce n\'est pas un réglage — c\'est ce qui reste quand personne n\'a '
|
||
'tranché. Les bornes détectées arrivent au rang 1 par défaut, sur lequel '
|
||
'une autre charge est souvent déjà posée. Classez-les avant la mise en '
|
||
'service.',
|
||
style: TextStyle(
|
||
fontSize: 11.5, height: 1.35, color: EtmTokens.inkOf(context)),
|
||
),
|
||
),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// 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;
|
||
|
||
/// Bloc replié : l'en-tête reste, les cartes sont retirées de l'arbre.
|
||
final bool replie;
|
||
final VoidCallback onReplier;
|
||
|
||
const _DomainBlock({
|
||
super.key,
|
||
required this.group,
|
||
required this.index,
|
||
required this.total,
|
||
required this.provider,
|
||
required this.telemetry,
|
||
required this.onConfigure,
|
||
required this.replie,
|
||
required this.onReplier,
|
||
});
|
||
|
||
@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: [
|
||
// Cible tactile de 48 px — le minimum Material, et il n'est pas décoratif.
|
||
//
|
||
// Mesurée à **26 × 27 px** sur le Redmi Note 9S au premier passage du test
|
||
// d'appareil. Le `Container` transparent posé lors de l'enquête précédente
|
||
// avait supprimé la bande morte entre l'icône et le reste, mais il n'avait
|
||
// pas AGRANDI la cible : le geste accrochait quand on visait juste, et ratait
|
||
// sinon. Aucun test unitaire ne pouvait le montrer — ils ne rendent pas ces
|
||
// widgets à une taille d'écran réelle.
|
||
//
|
||
// C'est le seul geste de cet écran qui n'a pas de repli : sans lui, l'ordre
|
||
// des domaines ne se change pas du tout.
|
||
ReorderableDragStartListener(
|
||
key: ValueKey('poignee-domaine-${group.domain}'),
|
||
index: index,
|
||
child: Container(
|
||
color: Colors.transparent,
|
||
width: 48,
|
||
height: 48,
|
||
alignment: Alignment.centerLeft,
|
||
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),
|
||
),
|
||
),
|
||
),
|
||
// Quand le bloc est replié, dire ce qu'il contient — sinon replier revient à
|
||
// faire disparaître des charges, et une charge qu'on ne voit plus se lit
|
||
// comme une charge perdue.
|
||
if (replie)
|
||
Padding(
|
||
padding: const EdgeInsets.only(right: 4),
|
||
child: Text(
|
||
group.loads.length > 1
|
||
? '${group.loads.length} charges'
|
||
: '${group.loads.length} charge',
|
||
style: TextStyle(fontSize: 11.5, color: muted),
|
||
),
|
||
),
|
||
// Un SEUL chevron, qui replie — à la place des deux flèches monter/descendre.
|
||
//
|
||
// Elles faisaient doublon avec la poignée de glissement, désormais vérifiée
|
||
// au doigt sur appareil (48 px, réordonnancement mesuré). Deux chemins pour
|
||
// le même geste, dont l'un n'a pas de repli, valaient moins qu'un chemin sûr
|
||
// plus une commande qui manquait.
|
||
IconButton(
|
||
key: ValueKey('replier-${group.domain}'),
|
||
visualDensity: VisualDensity.compact,
|
||
icon: Icon(
|
||
replie
|
||
? Icons.keyboard_arrow_down_rounded
|
||
: Icons.keyboard_arrow_up_rounded,
|
||
size: 22),
|
||
tooltip: replie ? 'Déplier ce domaine' : 'Replier ce domaine',
|
||
onPressed: onReplier,
|
||
),
|
||
]),
|
||
if (!replie) const SizedBox(height: 6),
|
||
if (!replie)
|
||
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)),
|
||
),
|
||
// Les deux flèches CÔTE À CÔTE, pas empilées.
|
||
//
|
||
// Empilées, deux cibles de 44 px font 88 px de haut pour deux icônes de
|
||
// 18 : la colonne de rang devient une colonne vide, constaté à la
|
||
// capture d'appareil. Côte à côte, la hauteur retombe à 44 et la
|
||
// largeur — 88 px sur une carte pleine largeur — ne coûte rien au
|
||
// libellé.
|
||
//
|
||
// La cible tactile, elle, ne bouge pas : c'est la contrainte, pas la
|
||
// disposition.
|
||
if (totalDansDomaine > 1)
|
||
Row(mainAxisSize: MainAxisSize.min, children: [
|
||
_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),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
|
||
/// 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);
|
||
// 44 px, pas 48. Les deux flèches sont EMPILÉES dans la colonne de rang, sous le
|
||
// badge : à 48 chacune, la carte gagne près de 100 px de haut pour deux commandes
|
||
// secondaires. 44 est le plancher au-dessous duquel un pouce rate régulièrement, et
|
||
// il tient dans la carte. Mesurées à **18 × 18 px** au premier passage sur appareil —
|
||
// la taille de l'icône, sans aucune marge de touche.
|
||
return InkWell(
|
||
onTap: actif ? onTap : null,
|
||
borderRadius: BorderRadius.circular(8),
|
||
child: SizedBox(
|
||
width: 44,
|
||
height: 44,
|
||
child: Icon(icone,
|
||
size: 18,
|
||
color: actif ? muted : muted.withValues(alpha: 0.3)),
|
||
),
|
||
);
|
||
}
|
||
}
|