+etm23 rend à chaque borne détectée une entrée GetLoadConfig, et l'invariant loads[] ⊆ GetLoadConfig est rétabli. Sur .75 : 2 entrées → 4. L'app s'appuyait déjà sur cet invariant pour résoudre libellé, domaine et rang de toute charge publiée. Vérifié sur machine avant d'écrire quoi que ce soit : - l'aller-retour verbatim reste NEUTRE avec les quatre entrées (loadconfig_roundtrip.dart, verdict IDENTIQUE) ; - une entrée evcharger porte id/label/domain/priority/enabled, mode dynamic, et AUCUNE charge utile de mécanisme. Trois défauts que ce contrat révèle côté app : 1. isVariableLoad était défini par défaut (« ni relais ni sg-ready »), donc une borne y tombait. L'écran de mécanisme lui proposait une puissance nominale, des paliers et des temporisations — des réglages qui n'existent pas pour elle et dont l'écriture partirait quand même. Pire, le sélecteur offrait de la basculer en routeur de relais : une bascule que basculerMecanisme() sait construire et qu'aucun écran ne sait défaire. Une borne a maintenant sa section, en lecture seule, qui dit pourquoi il n'y a rien à régler. 2. BorneSection existait pour rattraper des bornes qui n'apparaissaient nulle part. Elles apparaissent maintenant dans leur domaine : la section les afficherait DEUX fois, en affirmant qu'elles n'ont « ni rang ni domaine » et qu'elles ne passent pas par SetLoadConfig. Trois affirmations devenues fausses. Retirée ; son contenu de runtime vit désormais dans l'écran de mécanisme, où il a un sens. 3. Le rang par défaut d'une borne est un ARTEFACT, et l'écran le disait comme un réglage. À la création le plugin donne « le plus petit rang existant moins un, borné à 1 » ; le chauffe-eau occupant déjà le rang 1, l'intention « en tête » dégénère en égalité — trois charges à 1 sur le banc. Le tri reste total (l'id départage), donc l'ordre est reproductible, et c'est précisément ce qui le rend crédible : l'installateur lit un classement plausible et n'y touche pas, validant un ordre que personne n'a posé. Une note le nomme, et disparaît dès le premier vrai réglage. Le glissement, lui, n'a demandé aucun cas particulier — c'était tout l'intérêt d'avoir une seule liste. Vérifié par test, y compris l'inversion borne / charge pilotée. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
795 lines
30 KiB
Dart
795 lines
30 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 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),
|
|
_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: onConfigure,
|
|
),
|
|
),
|
|
// 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'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;
|
|
|
|
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),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
/// 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)),
|
|
);
|
|
}
|
|
}
|