1) GLISSEMENT — diagnostic d'abord, et il a donné DEUX réponses. Le geste n'est PAS avalé par le ListView parent : les deux montages du test (section seule / section imbriquée) glissent identiquement. La cause du symptôme était bien que .75 n'a qu'une charge par domaine — rien à réordonner. Mais l'enquête a trouvé un vrai défaut au passage : le centre de la poignée tombait dans le vide de 3 px séparant l'icône de la pastille de rang, et ReorderableDragStartListener défère le test de collision à ses enfants. Une bande morte invisible au milieu d'une poignée qui paraît d'un seul tenant. Corrigé par un Container transparent. Tentative faite et ANNULÉE : rendre les domaines glissables aussi. Deux ReorderableListView imbriqués se disputent le geste et l'extérieur gagne — le glissement des cartes cassait. Les domaines gardent leurs flèches, et une charge seule dans son domaine n'offre plus de prise qui mènerait nulle part (avec l'infobulle qui renvoie au bon geste). Vérifié SUR L'APPAREIL, contre .75, avec les deux charges mises dans le même domaine : [chauffe-eau, pac-terrain] → [pac-terrain, chauffe-eau], brouillon abandonné sans rien écrire. Classement ecs/hvac rétabli ensuite. 2) SÉLECTEUR DE MÉCANISME — actif, et c'était le morceau délicat. LoadConfig est une union discriminée : écrire `adapter` sans nettoyer le reste produit une configuration que la box refuse EN BLOC, et le refus emporte toutes les charges de l'appel. D'où basculerMecanisme(), qui retire les clés du mécanisme quitté et pose celles du nouveau, en récupérant ce qui peut l'être — les deux premiers contacts deviennent SG1/SG2 dans l'encodage standard, les relais gardent leur puissance. Ce qui n'est PAS transposé : l'estimation d'un état SG-Ready n'est pas la puissance d'une résistance. La charge produite est alors incomplète, et c'est voulu — le formulaire réclame plutôt que d'inventer une valeur de plaque. La bascule franchit sameHardware() : confirmation préalable qui annonce la reconstruction et la durée de réarmement (900 s par défaut vers SG-Ready — la valeur d'une PAC réelle, pas celle du banc). Le mode fixed d'etmvariableload reste LISIBLE sans être proposé : bloc « Configuration héritée » en lecture seule, et conversion seulement sur geste explicite, avec le plafond que la déclaration en paliers ne porte pas. Ouvrir l'écran ne convertit personne. 3) CHIP DE DOMAINE — il ne rendait qu'une icône muette : le domaine d'une charge était illisible sur sa carte, alors qu'il décide de son groupe et de son rang. Il porte maintenant son libellé, et sa couleur suit le thème. 4) THÈME — les 11 derniers sites (pas 9) basculés. Trois cas : contexte disponible (main, ac_screen) ; peintre sans contexte, à qui la couleur est passée résolue (energy_flow_widget) ; tables statiques, où la teinte devient nullable et se résout au rendu (categoryInfoMap, favoris). Plus AUCUN jeton hors thème dans lib/. 5) ZONES — non entreprises, comme convenu. Tests 93/93, analyze 0 erreur. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
816 lines
30 KiB
Dart
816 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 '../services/nymea_service.dart';
|
|
import '../theme/etm_tokens.dart';
|
|
import 'load_cards.dart';
|
|
|
|
/// Les **charges pilotables** de l'écran « Rôles & appareils », telles que la box les
|
|
/// déclare.
|
|
///
|
|
/// ## Le sens de la fusion
|
|
///
|
|
/// Cette section remplace l'ancien écran « Ordre de service » — c'est son **chemin de
|
|
/// données** qui a migré ici, pas l'inverse. La distinction n'est pas de forme : l'écran
|
|
/// des rôles reposait sur une énumération figée de six rôles
|
|
/// (`gridMeter`, `solarMeter`, `evCharger`, `dhw`, `heatPump`, `battery`), et une
|
|
/// énumération figée ne peut pas décrire une installation.
|
|
///
|
|
/// **LM-201 le dit sans détour : une charge existe quand il y a un canal de commande
|
|
/// distinct pour un service.** Un canal, une charge. Une PAC air/eau qui fait chauffage
|
|
/// ET ECS sans ballon séparé est **une** charge — le SG-Ready commande la machine
|
|
/// entière et la répartition appartient à sa régulation. Le modèle des rôles, lui, aurait
|
|
/// exigé d'en déclarer deux (`dhw` **et** `heatPump`) pour une seule machine, donc deux
|
|
/// entrées se disputant un canal unique. C'est exactement ce que `validateSet()` refuse
|
|
/// côté box, et ce refus n'est pas une limite subie : c'est le modèle.
|
|
///
|
|
/// La liste affichée vient donc de `GetLoadConfig`, avec ses identifiants et ses domaines
|
|
/// tels quels — y compris ceux qu'aucune version d'app ne connaît encore.
|
|
///
|
|
/// ## Deux sources qu'il ne faut pas confondre
|
|
///
|
|
/// - **la configuration** (`GetLoadConfig`) — ce que l'installateur a déclaré, éditable ;
|
|
/// - **la télémétrie** (`GetLoadTelemetry`) — ce que l'arbitre a fait au dernier cycle,
|
|
/// en lecture seule et jamais reconstruite côté app.
|
|
///
|
|
/// Depuis le lot plugin B-bis, toute charge publiée en télémétrie a une entrée en
|
|
/// configuration. L'inverse reste faux : une charge configurée absente de la télémétrie
|
|
/// est **désactivée**, jamais perdue — et « non arbitrée » n'est donc pas une erreur.
|
|
class ControllableLoadsSection extends StatelessWidget {
|
|
/// Ouvre l'écran de mécanisme d'une charge. Fourni par l'écran hôte, qui connaît la
|
|
/// navigation ; les trois contrats ne se ressemblent pas, donc il n'y a pas de
|
|
/// formulaire unique à ouvrir.
|
|
final void Function(BuildContext, LoadConfigEntry) onConfigure;
|
|
|
|
const ControllableLoadsSection({super.key, required this.onConfigure});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final provider = context.watch<LoadConfigProvider>();
|
|
final telemetry = context.watch<LoadTelemetryProvider>();
|
|
final groups = provider.groups;
|
|
|
|
if (provider.loading && groups.isEmpty) {
|
|
return const Padding(
|
|
padding: EdgeInsets.symmetric(vertical: 24),
|
|
child: Center(child: CircularProgressIndicator()),
|
|
);
|
|
}
|
|
|
|
if (groups.isEmpty) {
|
|
return _EmptyNote(error: provider.error);
|
|
}
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Le budget du cycle AVANT les charges : « Alloué 3 000 W » ne veut rien dire
|
|
// tant qu'on ne sait pas s'il y avait 3 100 ou 12 000 W à distribuer.
|
|
TelemetryHeader(telemetry: telemetry),
|
|
// Les domaines se réordonnent par les FLÈCHES, pas au glisser-déposer.
|
|
//
|
|
// Tentative faite et annulée : envelopper les domaines dans un second
|
|
// `ReorderableListView` rend bien les domaines glissables, mais **casse le
|
|
// glissement des cartes** — deux listes réordonnables imbriquées se disputent le
|
|
// geste, et c'est l'extérieure qui gagne. Le test `reorder_drag_test.dart` le
|
|
// montre en une exécution. On garde donc un seul niveau glissable, celui des
|
|
// charges, et des flèches explicites pour les domaines.
|
|
for (var i = 0; i < groups.length; i++)
|
|
_DomainBlock(
|
|
key: ValueKey('domain-${groups[i].domain}'),
|
|
group: groups[i],
|
|
index: i,
|
|
total: groups.length,
|
|
provider: provider,
|
|
telemetry: telemetry,
|
|
onConfigure: onConfigure,
|
|
),
|
|
// La borne n'est PAS une LoadConfig : elle n'apparaît donc dans aucun domaine
|
|
// ci-dessus, et sans cette section elle serait tout simplement invisible — ce
|
|
// qu'un installateur qui vient de la configurer lit comme une perte.
|
|
const BorneSection(),
|
|
if (provider.hasPendingChanges) ...[
|
|
const SizedBox(height: 4),
|
|
ImpactSaveBar(provider: provider),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Un domaine et ses charges. Les domaines se déplacent entre eux, les charges à
|
|
/// l'intérieur — les deux niveaux que l'ordre de service demande.
|
|
class _DomainBlock extends StatelessWidget {
|
|
final DomainGroup group;
|
|
final int index;
|
|
final int total;
|
|
final LoadConfigProvider provider;
|
|
final LoadTelemetryProvider telemetry;
|
|
final void Function(BuildContext, LoadConfigEntry) onConfigure;
|
|
|
|
const _DomainBlock({
|
|
super.key,
|
|
required this.group,
|
|
required this.index,
|
|
required this.total,
|
|
required this.provider,
|
|
required this.telemetry,
|
|
required this.onConfigure,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.l10n;
|
|
final muted = EtmTokens.mutedOf(context);
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(children: [
|
|
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),
|
|
ReorderableListView.builder(
|
|
shrinkWrap: true,
|
|
primary: false,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
buildDefaultDragHandles: false,
|
|
itemCount: group.loads.length,
|
|
onReorder: (o, n) => provider.reorderWithinDomain(group.domain, o, n),
|
|
itemBuilder: (context, i) => Padding(
|
|
key: ValueKey('load-${group.loads[i].id}'),
|
|
padding: const EdgeInsets.only(bottom: 10),
|
|
child: LoadConfigCard(
|
|
entry: group.loads[i],
|
|
handleIndex: i,
|
|
reordonnable: group.loads.length > 1,
|
|
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;
|
|
final int handleIndex;
|
|
|
|
/// Faux quand la charge est **seule dans son domaine** : il n'y a alors rien à
|
|
/// réordonner ici, et l'ordre se change au niveau des domaines.
|
|
final bool reordonnable;
|
|
|
|
final LoadConfigProvider provider;
|
|
final LoadTelemetryProvider telemetry;
|
|
final void Function(BuildContext, LoadConfigEntry) onConfigure;
|
|
|
|
const LoadConfigCard({
|
|
super.key,
|
|
required this.entry,
|
|
required this.handleIndex,
|
|
this.reordonnable = true,
|
|
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: [
|
|
// Seule dans son domaine, une charge n'a nulle part où aller : la poignée
|
|
// serait un mensonge. On garde le rang, on retire la prise.
|
|
_PoigneeOuPas(
|
|
key: ValueKey('poignee-charge-${entry.id}'),
|
|
actif: reordonnable,
|
|
index: handleIndex,
|
|
// `Container` transparent : rend la ZONE ENTIÈRE préhensible.
|
|
// `ReorderableDragStartListener` défère le test de collision à ses
|
|
// enfants ; sans ça, le vide de 3 px entre l'icône et la pastille ne
|
|
// saisit rien — une bande morte invisible, au milieu d'une poignée qui
|
|
// paraît d'un seul tenant.
|
|
child: Container(
|
|
color: Colors.transparent,
|
|
padding: const EdgeInsets.only(top: 1, right: 9),
|
|
child: Column(children: [
|
|
Icon(
|
|
reordonnable
|
|
? Icons.drag_indicator_rounded
|
|
: Icons.remove_rounded,
|
|
size: 20,
|
|
color: reordonnable ? muted : muted.withValues(alpha: 0.35),
|
|
),
|
|
const SizedBox(height: 3),
|
|
// Le rang est ce que le glisser-déposer produit : il se lit ici,
|
|
// pas noyé dans une phrase.
|
|
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)),
|
|
),
|
|
]),
|
|
),
|
|
),
|
|
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(entry: entry, provider: provider),
|
|
const Spacer(),
|
|
TextButton.icon(
|
|
onPressed: () => onConfigure(context, entry),
|
|
icon: const Icon(Icons.tune_rounded, size: 17),
|
|
label: const Text('Configurer'),
|
|
style: TextButton.styleFrom(
|
|
visualDensity: VisualDensity.compact,
|
|
foregroundColor: EtmTokens.brand,
|
|
),
|
|
),
|
|
]),
|
|
_RenameRow(entry: entry, provider: provider),
|
|
const SizedBox(height: 8),
|
|
Divider(height: 1, color: EtmTokens.lineOf(context)),
|
|
const SizedBox(height: 8),
|
|
if (live != null)
|
|
LoadLiveBlock(
|
|
live: live,
|
|
stale: telemetry.stale,
|
|
telemetry: telemetry,
|
|
)
|
|
else
|
|
// Absente de `loads[]` : désactivée, pas perdue. Le dire explicitement —
|
|
// un vide se lirait comme une panne.
|
|
Row(children: [
|
|
Icon(Icons.pause_circle_outline_rounded, size: 14, color: muted),
|
|
const SizedBox(width: 5),
|
|
Expanded(
|
|
child: Text(
|
|
entry.enabled
|
|
? 'Non arbitrée à ce cycle — configurée, mais absente de la '
|
|
'télémétrie du dernier cycle.'
|
|
: 'Désactivée — rôle déclaré, exclue de l\'arbitrage.',
|
|
style: TextStyle(fontSize: 11.5, color: muted, height: 1.3),
|
|
),
|
|
),
|
|
]),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Interrupteur d'entrée dans l'arbitrage, avec ce que le geste fait réellement.
|
|
class _EnabledSwitch extends StatelessWidget {
|
|
final LoadConfigEntry entry;
|
|
final LoadConfigProvider provider;
|
|
|
|
const _EnabledSwitch({required this.entry, required this.provider});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Tooltip(
|
|
// Ce texte est le résultat d'une mesure, pas une précaution rédactionnelle :
|
|
// sur `.75`, PAC en état 4 (contacts fermés), `enabled: false` a bien OUVERT les
|
|
// contacts — par le retrait de l'adaptateur (ECS-413), pas par le drapeau.
|
|
message: entry.enabled
|
|
? 'Désactiver retire la charge de l\'arbitrage et l\'amène à son état sûr '
|
|
'(contacts ouverts) avant de la retirer.\n'
|
|
'Ce n\'est pas un sectionnement : la sécurité vient du retrait, pas du '
|
|
'drapeau.'
|
|
: 'Activer fait entrer la charge dans l\'arbitrage au prochain cycle.',
|
|
child: Switch.adaptive(
|
|
value: entry.enabled,
|
|
onChanged: (v) => provider.setEnabled(entry.id, v),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Renommage en ligne. Le libellé n'est pas du matériel — aucun verrou n'est réarmé.
|
|
class _RenameRow extends StatefulWidget {
|
|
final LoadConfigEntry entry;
|
|
final LoadConfigProvider provider;
|
|
|
|
const _RenameRow({required this.entry, required this.provider});
|
|
|
|
@override
|
|
State<_RenameRow> createState() => _RenameRowState();
|
|
}
|
|
|
|
class _RenameRowState extends State<_RenameRow> {
|
|
bool _editing = false;
|
|
late final TextEditingController _c =
|
|
TextEditingController(text: widget.entry.label);
|
|
|
|
@override
|
|
void dispose() {
|
|
_c.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (!_editing) {
|
|
return Align(
|
|
alignment: Alignment.centerLeft,
|
|
child: TextButton.icon(
|
|
onPressed: () => setState(() => _editing = true),
|
|
icon: const Icon(Icons.edit_outlined, size: 15),
|
|
label: const Text('Renommer'),
|
|
style: TextButton.styleFrom(
|
|
visualDensity: VisualDensity.compact,
|
|
foregroundColor: EtmTokens.mutedOf(context),
|
|
textStyle: const TextStyle(fontSize: 12),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 6),
|
|
child: Row(children: [
|
|
Expanded(
|
|
child: TextField(
|
|
controller: _c,
|
|
autofocus: true,
|
|
style: const TextStyle(fontSize: 14),
|
|
decoration: const InputDecoration(
|
|
isDense: true,
|
|
labelText: 'Libellé',
|
|
helperText: 'Sans effet sur le matériel — aucun verrou réarmé.',
|
|
helperMaxLines: 2,
|
|
),
|
|
onSubmitted: (_) => _commit(),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.check_rounded, size: 20),
|
|
onPressed: _commit,
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.close_rounded, size: 20),
|
|
onPressed: () => setState(() {
|
|
_c.text = widget.entry.label;
|
|
_editing = false;
|
|
}),
|
|
),
|
|
]),
|
|
);
|
|
}
|
|
|
|
void _commit() {
|
|
final v = _c.text.trim();
|
|
if (v.isNotEmpty && v != widget.entry.label) {
|
|
widget.provider.setLabel(widget.entry.id, v);
|
|
}
|
|
setState(() => _editing = false);
|
|
}
|
|
}
|
|
|
|
/// Barre d'enregistrement — et **annonce de ce que l'écriture va provoquer**.
|
|
///
|
|
/// C'est la règle §5-3 du lot : `priority`, `domain`, `label` et `enabled` se mettent à
|
|
/// jour sans rien perturber, mais toucher `relays[]`, le mécanisme ou les Things
|
|
/// **reconstruit l'adaptateur** — contacts ouverts, verrous réarmés à froid. Un
|
|
/// installateur qui change un relais ne doit pas découvrir après coup que sa PAC est
|
|
/// bloquée un quart d'heure.
|
|
class ImpactSaveBar extends StatelessWidget {
|
|
final LoadConfigProvider provider;
|
|
|
|
const ImpactSaveBar({super.key, required this.provider});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final impacts = provider.pendingImpact();
|
|
final warnings = impacts.where((i) => i.needsWarning).toList();
|
|
final pending = provider.saveState == LoadSaveState.pending;
|
|
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: EtmTokens.surfaceOf(context),
|
|
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
|
|
border: Border.all(
|
|
color: warnings.isEmpty
|
|
? EtmTokens.lineOf(context)
|
|
: EtmTokens.orange.withValues(alpha: 0.6),
|
|
),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
if (warnings.isNotEmpty) ...[
|
|
for (final w in warnings) _ImpactLine(impact: w),
|
|
const SizedBox(height: 8),
|
|
],
|
|
Row(children: [
|
|
Expanded(
|
|
child: Text(
|
|
warnings.isEmpty
|
|
? 'Modifications en attente — rien n\'est parti vers la box.'
|
|
: 'À confirmer : l\'enregistrement touche au matériel.',
|
|
style: TextStyle(
|
|
fontSize: 12, color: EtmTokens.mutedOf(context), height: 1.3),
|
|
),
|
|
),
|
|
TextButton(
|
|
onPressed: pending ? null : provider.discardDraft,
|
|
child: const Text('Annuler'),
|
|
),
|
|
const SizedBox(width: 4),
|
|
FilledButton(
|
|
onPressed: pending ? null : provider.save,
|
|
child: Text(pending ? 'Envoi…' : 'Enregistrer'),
|
|
),
|
|
]),
|
|
if (provider.saveState == LoadSaveState.rejected)
|
|
_RejectionNote(provider: provider),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Une ligne d'avertissement d'impact, qui nomme **ce qui** l'a déclenché.
|
|
///
|
|
/// « Vous avez changé l'ordre des contacteurs » est actionnable ; « la charge sera
|
|
/// reconstruite » ne l'est pas.
|
|
class _ImpactLine extends StatelessWidget {
|
|
final LoadImpact impact;
|
|
|
|
const _ImpactLine({required this.impact});
|
|
|
|
static const _fieldNames = {
|
|
'relays': 'la liste des contacteurs',
|
|
'sgReady': 'l\'encodage SG-Ready',
|
|
'minOnS': 'la temporisation de marche',
|
|
'minOffS': 'la temporisation d\'arrêt',
|
|
'maxPowerW': 'la puissance nominale',
|
|
'powerLevels': 'les paliers déclarés',
|
|
'adapter': 'le mécanisme',
|
|
'mode': 'le mode',
|
|
};
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final quoi = impact.changedFields
|
|
.map((f) => _fieldNames[f] ?? f)
|
|
.join(', ');
|
|
final rearm = impact.rearmSeconds;
|
|
final duree = rearm == null
|
|
? ''
|
|
: rearm >= 60
|
|
? ' pendant ${(rearm / 60).round()} min'
|
|
: ' pendant $rearm s';
|
|
|
|
final texte = switch (impact.impact) {
|
|
LoadChangeImpact.rebuild =>
|
|
'« ${impact.label} » — vous avez modifié $quoi. La charge sera reconstruite : '
|
|
'ses contacts passent par l\'état sûr, puis son verrou se réarme$duree.',
|
|
LoadChangeImpact.removed =>
|
|
'« ${impact.label} » quitte l\'arbitrage. Elle est amenée à son état sûr '
|
|
'(contacts ouverts) avant retrait.',
|
|
_ => '',
|
|
};
|
|
if (texte.isEmpty) return const SizedBox.shrink();
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 6),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Icon(Icons.warning_amber_rounded, size: 16, color: EtmTokens.orange),
|
|
const SizedBox(width: 7),
|
|
Expanded(
|
|
child: Text(texte,
|
|
style: TextStyle(
|
|
fontSize: 12,
|
|
height: 1.35,
|
|
color: EtmTokens.inkOf(context))),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Refus de la box. **Ne rien inventer** : `SetLoadConfig` rend un code sans motif, et
|
|
/// la règle enfreinte reste au journal de la box. On affiche le refus et on y renvoie.
|
|
class _RejectionNote extends StatelessWidget {
|
|
final LoadConfigProvider provider;
|
|
|
|
const _RejectionNote({required this.provider});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final conflit = provider.conflict;
|
|
final err = provider.saveError;
|
|
return Padding(
|
|
padding: const EdgeInsets.only(top: 10),
|
|
child: Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(
|
|
color: EtmTokens.danger.withValues(alpha: 0.09),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
conflit != null
|
|
? 'Deux charges revendiquent le même appareil.'
|
|
: 'La box a refusé l\'enregistrement${err == null ? '' : ' ($err)'}.',
|
|
style: const TextStyle(
|
|
fontSize: 12.5,
|
|
fontWeight: FontWeight.w700,
|
|
color: EtmTokens.danger),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
conflit != null
|
|
// LM-201 : deux charges sur un canal unique se contrediraient, et le
|
|
// budget compterait la même puissance deux fois. Ce n'est pas une
|
|
// limite technique — c'est le modèle.
|
|
? 'Un appareil ne peut appartenir qu\'à une seule charge : deux '
|
|
'charges sur un même canal de commande se contrediraient.'
|
|
: 'Le refus porte sur l\'ensemble : rien n\'a été modifié. La règle '
|
|
'enfreinte et le libellé en cause sont dans le journal de la box '
|
|
'(journalctl -u nymead).',
|
|
style: TextStyle(
|
|
fontSize: 11.5,
|
|
height: 1.35,
|
|
color: EtmTokens.mutedOf(context)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _EmptyNote extends StatelessWidget {
|
|
final String? error;
|
|
|
|
const _EmptyNote({this.error});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final muted = EtmTokens.mutedOf(context);
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 18),
|
|
child: Text(
|
|
error == null
|
|
? 'Aucune charge pilotable déclarée sur cette box.'
|
|
: 'Configuration illisible : $error',
|
|
style: TextStyle(fontSize: 12.5, color: muted, height: 1.35),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
/// Les bornes de recharge, qui **ne sont pas des charges pilotables**.
|
|
///
|
|
/// ## Pourquoi elles ont leur propre section
|
|
///
|
|
/// Une borne n'a ni `adapter`, ni `priority`, ni `domain` : elle ne passe pas par
|
|
/// `SetLoadConfig` mais par `SetChargingInfo`, un **autre contrat**. Et elle n'apparaît
|
|
/// pas dans `GetLoadTelemetry` en beta — elle est décidée **en amont** du waterfall, sa
|
|
/// puissance étant seulement réservée dans `budget.evReservedW`. Elle rejoindra `loads[]`
|
|
/// à l'étape 3g.
|
|
///
|
|
/// Conséquence pratique, et c'est le défaut que cette section corrige : configurée ou non,
|
|
/// la borne n'apparaissait **nulle part** dans cet écran. Un installateur qui vient de la
|
|
/// régler et ne la retrouve pas conclut, raisonnablement, que son réglage s'est perdu.
|
|
class BorneSection extends StatelessWidget {
|
|
const BorneSection({super.key});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final service = context.watch<NymeaService>();
|
|
final classesBorne = {
|
|
for (final c in service.thingClasses)
|
|
if (c.interfaces.contains('evcharger')) c.id
|
|
};
|
|
final bornes = [
|
|
for (final t in service.things)
|
|
if (classesBorne.contains(t.thingClassId)) t
|
|
];
|
|
if (bornes.isEmpty) return const SizedBox.shrink();
|
|
|
|
final muted = EtmTokens.mutedOf(context);
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const SizedBox(height: 6),
|
|
Text('Recharge véhicule',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w700,
|
|
letterSpacing: 0.2,
|
|
color: EtmTokens.inkOf(context))),
|
|
const SizedBox(height: 8),
|
|
for (final b in bornes)
|
|
Container(
|
|
width: double.infinity,
|
|
margin: const EdgeInsets.only(bottom: 10),
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: EtmTokens.surfaceOf(context),
|
|
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
|
|
border: Border.all(color: EtmTokens.lineOf(context)),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(children: [
|
|
Icon(Icons.ev_station_rounded, size: 19, color: muted),
|
|
const SizedBox(width: 9),
|
|
Expanded(
|
|
child: Text(b.name,
|
|
maxLines: 1,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 15,
|
|
fontWeight: FontWeight.w700,
|
|
color: EtmTokens.inkOf(context))),
|
|
),
|
|
]),
|
|
const SizedBox(height: 6),
|
|
Text(
|
|
'Pas une charge pilotable : la borne se règle par un autre contrat '
|
|
'(SetChargingInfo) et n\'a ni rang ni domaine. Elle est décidée en '
|
|
'amont du waterfall — sa puissance est réservée dans le budget, pas '
|
|
'allouée depuis lui.',
|
|
style: TextStyle(fontSize: 11.5, height: 1.35, color: muted),
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
'Le mode de recharge se règle depuis la carte véhicule du tableau de '
|
|
'bord.',
|
|
style: TextStyle(fontSize: 11.5, height: 1.35, color: muted),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
|
|
/// Enveloppe une poignée : prise réelle si [actif], simple décor sinon.
|
|
///
|
|
/// `ReorderableDragStartListener` s'attend à vivre sous un `ReorderableListView` ; l'utiliser
|
|
/// pour une liste d'un seul élément produirait une poignée qui s'agrippe et ne mène nulle
|
|
/// part. Mieux vaut ne pas offrir la prise.
|
|
class _PoigneeOuPas extends StatelessWidget {
|
|
final bool actif;
|
|
final int index;
|
|
final Widget child;
|
|
|
|
const _PoigneeOuPas({
|
|
super.key,
|
|
required this.actif,
|
|
required this.index,
|
|
required this.child,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
if (!actif) {
|
|
return Tooltip(
|
|
message: 'Seule charge de ce domaine — son rang se change en déplaçant le '
|
|
'domaine lui-même.',
|
|
child: child,
|
|
);
|
|
}
|
|
return ReorderableDragStartListener(index: index, child: child);
|
|
}
|
|
}
|