Geste de mise en service, signalé par le moteur : toute borne détectée reçoit d'office ChargingModeNormal, c'est-à-dire pleine puissance HORS arbitrage. Une borne neuve n'est donc jamais pilotée tant que personne n'a posé un mode éco, et rien ne le disait — l'installateur pose une borne, elle charge à fond, et il cherche du côté du HEMS un défaut qui n'existe pas. La note apparaît aux deux endroits qui comptent, mot pour mot (kEvUnmanagedNote) : · la carte de configuration, celle qu'on ouvre à la mise en service ; · la tuile des favoris, où les trois boutons de mode sont juste à côté. ⚠️ Elle nomme l'ÉTAT, pas une négligence. La box publie le même chargingMode pour un boost délibéré et pour une borne jamais réglée : l'app ne peut pas les distinguer, donc elle dit « pleine puissance, hors arbitrage » et rappelle que c'est aussi le mode d'office. Même règle que la note de rangs ex æquo — reproductible n'est pas choisi, et on ne fait pas dire à un écran ce que la donnée ne porte pas. Un mode NON LU ne déclenche rien non plus : on ne conclut pas d'une absence. Le piège qui aurait rendu la note invisible est couvert par un test : GetLoadConfig rend l'id de charge AVEC accolades ({4d1adafd-…}), GetChargingInfos rend l'evChargerId SANS. Comparer les deux tels quels ne rend jamais rien — en silence : l'écran aurait conclu « mode non lu » sur une borne parfaitement lue. chargingModeOf() réconcilie les deux. Six tests montés (trois par écran), dont les deux contrôles négatifs. Et la réserve sessionEnergy est documentée à sa juste place : le champ est CONSTRUIT par le plugin V2C 1.15.2+etm13 — il accumule les incréments de chargeEnergy, détecte les remises à zéro, ne repart qu'à l'ouverture d'une session — donc l'égalité observée hier s'explique d'elle-même. La réserve tient jusqu'à la trace que la campagne produira (chargeEnergy à zéro, sessionEnergy qui continue) : aucun avancement affiché d'ici là. Noté aussi, pour la suite : sessionEnergy n'est PAS historisé (suggestLogging écrasé par things.conf sur un Thing déjà appairé ; la V2C n'y a que connected, charging, power). La valeur courante est juste, il n'y aura pas de courbe — le choix des états enregistrés est côté app, entrée au TODO. 164 tests, flutter analyze inchangé à 27 remarques. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
996 lines
40 KiB
Dart
996 lines
40 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:provider/provider.dart';
|
||
|
||
import '../l10n/app_localizations.dart';
|
||
import '../l10n/l10n_ext.dart';
|
||
import '../models/ev_charger_live.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/nymea_service.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)),
|
||
),
|
||
),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// **Une borne en pleine puissance ne passe pas par l'arbitrage — et c'est son défaut.**
|
||
///
|
||
/// Le moteur crée d'office une entrée de configuration par borne détectée, en
|
||
/// `ChargingModeNormal` : pleine puissance, hors arbitrage. Tant que personne n'a posé un
|
||
/// mode éco, le HEMS ne pilote donc pas cette borne — et rien ne le disait. C'est le
|
||
/// pendant exact de la note de rangs ex æquo : un état par défaut qui a toutes les
|
||
/// apparences d'un réglage.
|
||
///
|
||
/// ⚠️ Elle ne dit pas « personne n'a choisi » : la box publie le même `chargingMode` pour
|
||
/// un boost délibéré et pour une borne jamais réglée. Elle nomme l'ÉTAT, et rappelle qu'il
|
||
/// est aussi le défaut.
|
||
class _NoteBornePleinePuissance extends StatelessWidget {
|
||
final LoadConfigEntry entry;
|
||
|
||
const _NoteBornePleinePuissance({required this.entry});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
// `GetChargingInfos` porte l'`evChargerId` sans accolades, `GetLoadConfig` l'id de
|
||
// charge avec : `chargingModeOf` réconcilie les deux.
|
||
final mode = context.watch<NymeaService>().chargingModeOf(entry.id);
|
||
if (mode != kChargingModeNormal) return const SizedBox.shrink();
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.only(bottom: 8),
|
||
child: Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(9),
|
||
decoration: BoxDecoration(
|
||
color: EtmTokens.solar.withValues(alpha: 0.10),
|
||
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
|
||
),
|
||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Icon(Icons.flash_on_rounded, size: 15, color: EtmTokens.solar),
|
||
const SizedBox(width: 7),
|
||
Expanded(
|
||
child: Text(kEvUnmanagedNote,
|
||
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),
|
||
// ── Une borne à pleine puissance n'est pas arbitrée ────────────────
|
||
//
|
||
// Elle se lit ici et nulle part ailleurs : la télémétrie n'en dit rien (une
|
||
// borne en `ChargingModeNormal` sans véhicule branché est simplement absente de
|
||
// `loads[]`), et l'écran de configuration est celui que l'installateur ouvre à
|
||
// la mise en service. Sans cette ligne, il pose une borne, elle charge à fond,
|
||
// et il cherche du côté du HEMS un défaut qui n'existe pas.
|
||
if (entry.isEvCharger) _NoteBornePleinePuissance(entry: entry),
|
||
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)),
|
||
),
|
||
);
|
||
}
|
||
}
|