Le moteur soupçonnait un ReorderableListView en conflit avec un défilement
parent. C'est exact, et le mécanisme se lit dans le code sans avoir à toucher
l'écran :
roles_devices_screen.dart:113 body: ListView(...) <- le parent défile
controllable_loads_section:104 ReorderableListView.builder(
shrinkWrap: true,
physics: NeverScrollableScrollPhysics())
En `shrinkWrap`, le viewport de la liste vaut la hauteur de son contenu : son
`maxScrollExtent` est NUL. Or l'auto-défilement du glisser-déposer agit sur le
Scrollable de la liste réordonnable, jamais sur un parent. Il n'a donc nulle
part où aller, et le ListView extérieur n'est pas prévenu.
Conséquence : on ne réordonne que ce qui tient à l'écran en même temps. Un bloc
de domaine déplié fait plus d'un écran, donc le geste qui compte — remonter un
domaine par-dessus un autre — est justement celui qui ne peut pas aboutir.
Replier les blocs le rend possible : contournement immédiat, et contrôle qui
prouve le diagnostic.
Remède structurel : un seul Scrollable, CustomScrollView + SliverReorderableList
— le montage déjà en place dans favorites_screen.dart. PAS fait tant que le
geste n'est pas éprouvé au doigt : l'entrée synthétique ne reproduit pas un
appui-glissé, donc aucun test de ce dépôt ne peut montrer ce défaut ni prouver
sa correction.
Et une piste abandonnée plutôt que remontée : j'ai soupçonné les écritures de
l'app d'effacer `rankOrigin`. Faux — l'aller-retour est verbatim sur les charges
utiles brutes (LIRE -> PATCHER -> RÉÉCRIRE), rien n'est reconstruit depuis le
modèle typé.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
1265 lines
53 KiB
Dart
1265 lines
53 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> {
|
||
/// Plages repliées, par l'**identifiant de leur première charge**.
|
||
///
|
||
/// Ni par index — il replierait le voisin dès qu'une plage se déplace — ni par code de
|
||
/// domaine, depuis que les groupes sont des plages contiguës : deux plages d'un même
|
||
/// domaine partageraient la clé et se replieraient ensemble.
|
||
///
|
||
/// **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.
|
||
//
|
||
// ⚠️ **MAIS ce montage ne peut pas défiler pendant un glissement**, et c'est un
|
||
// défaut ouvert, pas un choix. Établi par construction le 2026-09-08 :
|
||
//
|
||
// `roles_devices_screen.dart:113` est un `ListView` — le parent défile. Cette
|
||
// liste-ci est en `shrinkWrap: true`, donc son viewport vaut exactement la hauteur
|
||
// de son contenu et son `maxScrollExtent` est **nul**. Or l'auto-défilement du
|
||
// glisser-déposer agit sur le `Scrollable` de la liste RÉORDONNABLE, jamais sur un
|
||
// parent : il n'a donc nulle part où aller, et le `ListView` extérieur n'est même
|
||
// pas prévenu.
|
||
//
|
||
// Conséquence : on ne peut réordonner que ce qui tient à l'écran **en même temps**.
|
||
// Or un bloc de domaine déplié fait plus d'un écran à lui seul, donc le geste qui
|
||
// compte — remonter un domaine par-dessus un autre — est précisément celui qui ne
|
||
// peut pas aboutir. Replier les blocs le rend possible : c'est un contournement,
|
||
// et c'est aussi le contrôle qui prouve le diagnostic.
|
||
//
|
||
// Le remède est structurel : UN seul `Scrollable` sur l'écran, en `CustomScrollView`
|
||
// + `SliverReorderableList` — le montage déjà utilisé dans `favorites_screen.dart`.
|
||
// Pas fait ici tant que le geste n'a pas été éprouvé au doigt : l'entrée synthétique
|
||
// ne reproduit pas un appui-glissé (cf. `tools/rpc/README.md`), donc aucun test de
|
||
// ce dépôt ne peut ni montrer le défaut ni prouver sa correction.
|
||
ReorderableListView.builder(
|
||
shrinkWrap: true,
|
||
primary: false,
|
||
physics: const NeverScrollableScrollPhysics(),
|
||
buildDefaultDragHandles: false,
|
||
itemCount: groups.length,
|
||
onReorder: provider.reorderDomains,
|
||
itemBuilder: (context, i) => _DomainBlock(
|
||
// ⚠️ La clé porte l'id de la PREMIÈRE charge de la plage, pas le domaine :
|
||
// deux plages d'un même domaine partageraient sinon la même clé, et une liste
|
||
// réordonnable à clés dupliquées casse — l'assertion tombe dans les
|
||
// sémantiques, loin de la cause.
|
||
key: ValueKey('plage-${groups[i].loads.first.id}'),
|
||
group: groups[i],
|
||
index: i,
|
||
total: groups.length,
|
||
provider: provider,
|
||
telemetry: telemetry,
|
||
onConfigure: widget.onConfigure,
|
||
// Le repli suit la PLAGE, pas le domaine : deux plages d'un même domaine se
|
||
// replieraient ensemble, et l'une des deux se fermerait sans qu'on l'ait
|
||
// touchée.
|
||
replie: _replies.contains(groups[i].loads.first.id),
|
||
onReplier: () => setState(() {
|
||
final k = groups[i].loads.first.id;
|
||
_replies.contains(k) ? _replies.remove(k) : _replies.add(k);
|
||
}),
|
||
),
|
||
),
|
||
// 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 n'est pas groupé par domaine — et ce n'est pas un défaut.**
|
||
///
|
||
/// ## Elle a cessé d'être un avertissement le 2026-08-29
|
||
///
|
||
/// Elle annonçait un ordre « coupé en deux » et prévenait que l'enregistrement le
|
||
/// regrouperait. Les deux moitiés de cette phrase ont changé :
|
||
///
|
||
/// - **l'entrelacement n'est pas une incohérence.** La contiguïté n'a jamais été un
|
||
/// invariant : le waterfall trie sur le **rang seul**, et `domain` n'est pas lu par
|
||
/// l'arbitre. Depuis `+etm27`, une borne détectée est même rangée en queue de liste, ce
|
||
/// qui produit un ordre non groupé sur une installation parfaitement saine ;
|
||
/// - **l'enregistrement ne regroupe plus.** Un groupe est désormais une **plage
|
||
/// contiguë** : l'affichage épouse l'ordre réel, et réaplatir rend exactement ce qui a
|
||
/// été lu. Un regroupement subi serait une machine qui réordonne ce qu'un humain a
|
||
/// décidé — ce que LM-1209 interdit au moteur, et qu'on ne va pas faire à sa place.
|
||
///
|
||
/// Ce qui reste : une **information**, et un **geste** pour qui veut grouper — un bouton,
|
||
/// dont l'effet se voit en brouillon et s'annule avant écriture. L'annoncer comme un défaut
|
||
/// pousserait à réparer un ordre que personne n'a cassé.
|
||
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.surfaceOf(context),
|
||
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
|
||
border: Border.all(color: EtmTokens.lineOf(context)),
|
||
),
|
||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Icon(Icons.segment_rounded, size: 15,
|
||
color: EtmTokens.mutedOf(context)),
|
||
const SizedBox(width: 7),
|
||
Expanded(
|
||
child: Text(
|
||
'L\'ordre de service n\'est pas groupé par domaine : $doms apparaît en '
|
||
'plusieurs endroits. Ce n\'est pas un défaut — l\'arbitrage se fait sur le '
|
||
'rang seul, le domaine n\'est qu\'une étiquette. Les blocs ci-dessous '
|
||
'suivent l\'ordre réel de la box, et l\'enregistrement ne le regroupera pas.',
|
||
style: TextStyle(
|
||
fontSize: 11.5, height: 1.35, color: EtmTokens.inkOf(context)),
|
||
),
|
||
),
|
||
]),
|
||
Align(
|
||
alignment: Alignment.centerRight,
|
||
child: TextButton.icon(
|
||
onPressed: provider.groupByDomainNow,
|
||
icon: const Icon(Icons.merge_rounded, size: 16),
|
||
label: const Text('Regrouper par domaine',
|
||
style: TextStyle(fontSize: 12)),
|
||
style: TextButton.styleFrom(
|
||
visualDensity: VisualDensity.compact,
|
||
foregroundColor: EtmTokens.brand,
|
||
),
|
||
),
|
||
),
|
||
]),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
/// **« Rang par défaut, pas un choix »** — `rankOrigin == "auto"` (`1.15.2+etm27`).
|
||
///
|
||
/// ## Ce qu'il remplace, et pourquoi il le fait mieux
|
||
///
|
||
/// La note de rangs ex æquo portait cette idée jusqu'au 2026-08-29 : elle la **déduisait**
|
||
/// d'une égalité de rangs, donc elle ne pouvait rien dire d'une borne arrivée seule sur une
|
||
/// installation où son rang ne heurtait personne. Le moteur publie désormais la
|
||
/// provenance ; l'app la lit au lieu de la supposer, charge par charge.
|
||
///
|
||
/// Depuis `+etm27`, une borne auto-provisionnée entre en **queue** de liste (`1 + max`) et
|
||
/// ne passe donc plus devant l'ECS que l'installateur a classé exprès — le badge n'annonce
|
||
/// plus un problème d'ordre, seulement une **provenance** : ce rang est celui que le moteur
|
||
/// a posé, personne ne l'a regardé.
|
||
///
|
||
/// ## Ce qu'il n'affirme pas
|
||
///
|
||
/// Il n'apparaît que sur `"auto"`. Un champ **absent** n'affirme rien — ni choix humain, ni
|
||
/// pose d'office — et une box antérieure à `+etm27` n'en publie aucun : l'écran est alors
|
||
/// exactement celui d'avant, sans badge et sans mensonge.
|
||
///
|
||
/// ## Les deux façons de l'éteindre, et ce qu'elles affirment
|
||
///
|
||
/// - **Déplacer** la charge : le moteur efface `rankOrigin` dès qu'un rang différent arrive
|
||
/// pour cet id. C'est le geste de qui voulait un autre ordre.
|
||
/// - **Adopter** : écrire `rankOrigin: "user"`, c'est-à-dire « ce rang me va, je
|
||
/// l'assume ». Ce n'est PAS « j'ai vu le badge » — c'est un jugement porté sur l'ordre de
|
||
/// service, la seule voie pour éteindre le badge **sans changer l'arbitrage**. Il ne
|
||
/// s'annule pas tout seul : une fois posé, seul un déplacement le lève, côté moteur.
|
||
///
|
||
/// L'app ne peut jamais écrire `"auto"` — le moteur le refuse, et c'est ce qui empêche un
|
||
/// écran d'inventer une provenance.
|
||
class _BadgeRangDOffice extends StatefulWidget {
|
||
final LoadConfigEntry entry;
|
||
final LoadConfigProvider provider;
|
||
|
||
const _BadgeRangDOffice({required this.entry, required this.provider});
|
||
|
||
@override
|
||
State<_BadgeRangDOffice> createState() => _BadgeRangDOfficeState();
|
||
}
|
||
|
||
class _BadgeRangDOfficeState extends State<_BadgeRangDOffice> {
|
||
bool _enCours = false;
|
||
|
||
/// **« Ce rang me convient »** — écrit `rankOrigin: "user"`, et rien d'autre.
|
||
///
|
||
/// Le geste est **explicite**, jamais un effet de bord d'un autre enregistrement :
|
||
/// écrire `"user"` parce que l'utilisateur a touché à autre chose affirmerait un jugement
|
||
/// qu'il n'a pas porté — le reproche exact qu'on fait à un `"auto"` fabriqué côté client.
|
||
Future<void> _adopter() async {
|
||
final messenger = ScaffoldMessenger.of(context);
|
||
if (widget.provider.hasPendingChanges) {
|
||
messenger.showSnackBar(const SnackBar(
|
||
content: Text('Enregistrez ou annulez vos modifications en cours : adopter un '
|
||
'rang réécrit toute la configuration.'),
|
||
));
|
||
return;
|
||
}
|
||
|
||
setState(() => _enCours = true);
|
||
final err = await widget.provider.adoptRank(widget.entry.id);
|
||
if (!mounted) return;
|
||
setState(() => _enCours = false);
|
||
|
||
messenger.showSnackBar(SnackBar(
|
||
backgroundColor: err == null ? null : const Color(0xFF7F1D1D),
|
||
content: Text(err == null
|
||
? 'Rang adopté. Il ne bouge pas — seul un déplacement le changera.'
|
||
: err == 'TiedRanks'
|
||
? 'Deux charges partagent un rang : la box refuse toute la configuration '
|
||
'(1.15.2+etm27). Classez-les d\'abord.'
|
||
: err == 'PendingDraft'
|
||
? 'Modifications en cours : enregistrez-les ou annulez-les d\'abord.'
|
||
: 'La box a refusé ($err). Rien n\'a été modifié.'),
|
||
));
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: EtmTokens.solar.withValues(alpha: 0.14),
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
child: Row(mainAxisSize: MainAxisSize.min, children: [
|
||
Icon(Icons.auto_awesome_rounded, size: 11, color: EtmTokens.solar),
|
||
const SizedBox(width: 4),
|
||
Flexible(
|
||
child: Text(
|
||
'Rang posé par la box, pas un choix',
|
||
overflow: TextOverflow.ellipsis,
|
||
style: TextStyle(
|
||
fontSize: 10.5,
|
||
fontWeight: FontWeight.w600,
|
||
color: EtmTokens.inkOf(context)),
|
||
),
|
||
),
|
||
]),
|
||
),
|
||
const SizedBox(height: 2),
|
||
// ⚠️ Le libellé doit empêcher la confusion la plus coûteuse : adopter n'est PAS
|
||
// classer. Sans cette phrase, un installateur « valide » un ordre qu'il voulait
|
||
// changer — et il n'aura aucune raison d'y revenir, puisque le badge s'est éteint.
|
||
Text(
|
||
'Adopter ce rang ne le change pas. Pour en changer, glissez la charge.',
|
||
style: TextStyle(fontSize: 10.5, color: EtmTokens.mutedOf(context)),
|
||
),
|
||
Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: TextButton.icon(
|
||
onPressed: _enCours ? null : _adopter,
|
||
icon: _enCours
|
||
? const SizedBox(
|
||
width: 11,
|
||
height: 11,
|
||
child: CircularProgressIndicator(strokeWidth: 2))
|
||
: const Icon(Icons.check_rounded, size: 15),
|
||
label: const Text('Ce rang me convient',
|
||
style: TextStyle(fontSize: 11.5)),
|
||
style: TextButton.styleFrom(
|
||
visualDensity: VisualDensity.compact,
|
||
padding: const EdgeInsets.symmetric(horizontal: 6),
|
||
foregroundColor: EtmTokens.brand,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|
||
|
||
/// **Deux charges au même rang : la box REFUSERA cette configuration.**
|
||
///
|
||
/// ## Cette note a changé de nature le 2026-08-29
|
||
///
|
||
/// Elle disait « l'ordre affiché n'est pas un choix » — une information sur l'ordre de
|
||
/// service, née du rang par défaut d'une borne détectée. Ce rôle passe au **badge**
|
||
/// `rankOrigin` (`_BadgeRangDOffice`), qui le dit charge par charge et sans supposition :
|
||
/// deux formulations pour une même idée font qu'aucune n'est lue.
|
||
///
|
||
/// Ce qui reste ici est **autre chose, et plus urgent**. Depuis `1.15.2+etm27` la box
|
||
/// refuse **en bloc** tout ensemble où deux charges partagent un rang. En voir deux à
|
||
/// l'écran signifie donc une configuration **héritée d'avant `+etm27`** — le plugin
|
||
/// lui-même créait ces égalités — qui sera **rejetée à la première écriture**, sans motif
|
||
/// lisible : le refus ne franchit pas la frontière RPC.
|
||
///
|
||
/// D'où une alerte avec un remède, et non un constat : réordonner répare, et c'est le seul
|
||
/// geste qui répare. Le contrôle côté suppression le dit déjà au moment du geste ; l'écran
|
||
/// doit le dire **avant** qu'on le tente.
|
||
///
|
||
/// Elle disparaît d'elle-même dès que les rangs sont distincts — c'est-à-dire dès que la
|
||
/// configuration redevient acceptable.
|
||
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.danger.withValues(alpha: 0.10),
|
||
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
|
||
border: Border.all(color: EtmTokens.danger.withValues(alpha: 0.45)),
|
||
),
|
||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Icon(Icons.report_problem_rounded, size: 15, color: EtmTokens.danger),
|
||
const SizedBox(width: 7),
|
||
Expanded(
|
||
child: Text(
|
||
'Rangs à égalité : $noms. Depuis la version 1.15.2+etm27, la box REFUSE '
|
||
'toute configuration où deux charges partagent un rang — celle-ci sera '
|
||
'donc rejetée en bloc à la première écriture, y compris pour un simple '
|
||
'renommage.\n\n'
|
||
'Le remède est le classement : donnez-leur des rangs distincts, puis '
|
||
'enregistrez. Rien d\'autre ne répare cette configuration, et rien ne '
|
||
'part à la box tant qu\'elle porte l\'égalité.',
|
||
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-plage-${group.loads.first.id}'),
|
||
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-plage-${group.loads.first.id}'),
|
||
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],
|
||
groupIndex: index,
|
||
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;
|
||
|
||
/// Index de la PLAGE à laquelle la charge appartient. Depuis le 2026-08-29 un groupe est
|
||
/// une plage contiguë, donc un même domaine peut en occuper plusieurs : le désigner par
|
||
/// son code déplacerait la charge dans la première plage venue.
|
||
final int groupIndex;
|
||
|
||
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.groupIndex,
|
||
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);
|
||
|
||
// Dernière charge de la dernière plage : elle est déjà en queue de l'ordre de service.
|
||
final plages = provider.groups;
|
||
final estDejaEnQueue = plages.isNotEmpty &&
|
||
plages.last.loads.isNotEmpty &&
|
||
plages.last.loads.last.id == 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.reorderWithinGroup(
|
||
groupIndex, indexDansDomaine, indexDansDomaine - 1),
|
||
),
|
||
_FlecheRang(
|
||
key: ValueKey('descendre-${entry.id}'),
|
||
icone: Icons.keyboard_arrow_down_rounded,
|
||
actif: indexDansDomaine < totalDansDomaine - 1,
|
||
onTap: () => provider.reorderWithinGroup(
|
||
groupIndex, 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),
|
||
// Le rang de CETTE charge vient-il du moteur ? Le badge le dit ici,
|
||
// charge par charge — là où la note d'ensemble ne pouvait que le
|
||
// supposer d'une égalité.
|
||
if (entry.rangPoseDOffice) ...[
|
||
_BadgeRangDOffice(entry: entry, provider: provider),
|
||
const SizedBox(height: 3),
|
||
],
|
||
// 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,
|
||
),
|
||
),
|
||
]),
|
||
// « Renommer » et « Placer en dernier » sur la MÊME ligne : ce sont les deux
|
||
// gestes qui ne touchent pas au mécanisme, et les garder ensemble rend à
|
||
// « Configurer » sa place — un bouton d'action principale ne se fait pas
|
||
// pousser par un geste secondaire.
|
||
_LigneGestes(
|
||
entry: entry, provider: provider, dejaEnQueue: estDejaEnQueue),
|
||
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é.
|
||
/// Les deux gestes qui ne touchent pas au mécanisme : renommer, et sortir de sa plage.
|
||
class _LigneGestes extends StatelessWidget {
|
||
final LoadConfigEntry entry;
|
||
final LoadConfigProvider provider;
|
||
final bool dejaEnQueue;
|
||
|
||
const _LigneGestes({
|
||
required this.entry,
|
||
required this.provider,
|
||
required this.dejaEnQueue,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return Row(children: [
|
||
Flexible(child: _RenameRow(entry: entry, provider: provider)),
|
||
// « Placer en dernier » — le seul geste qui SORT une charge de sa plage.
|
||
//
|
||
// Les flèches la déplacent dans son bloc, le glissement déplace le bloc entier :
|
||
// sans lui, une charge regroupée est prisonnière de son groupe. Une fois seule en
|
||
// queue, le glissement de plage la porte partout ailleurs.
|
||
//
|
||
// Masqué quand elle y est déjà : un bouton qui ne fait rien use la confiance dans
|
||
// ceux qui font quelque chose.
|
||
if (!dejaEnQueue)
|
||
TextButton.icon(
|
||
onPressed: () => provider.moveToTail(entry.id),
|
||
icon: const Icon(Icons.vertical_align_bottom_rounded, size: 15),
|
||
// ⚠️ Le style va sur le LIBELLÉ, pas dans `styleFrom(textStyle:)` : celui-ci
|
||
// porte `inherit: true` là où le thème porte `inherit: false`, et Flutter refuse
|
||
// d'interpoler les deux — l'assertion tombe pendant l'animation du glissement,
|
||
// très loin de sa cause.
|
||
label: const Text('Placer en dernier', style: TextStyle(fontSize: 12)),
|
||
style: TextButton.styleFrom(
|
||
visualDensity: VisualDensity.compact,
|
||
foregroundColor: EtmTokens.mutedOf(context),
|
||
),
|
||
),
|
||
]);
|
||
}
|
||
}
|
||
|
||
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(
|
||
switch (conflit?.kind) {
|
||
'thingClaimedTwice' => 'Deux charges revendiquent le même appareil.',
|
||
'duplicateId' => 'Deux charges portent le même identifiant.',
|
||
'tiedPriority' =>
|
||
'Deux charges se retrouvent au rang ${conflit!.rank} : '
|
||
'${conflit.firstLabel} et ${conflit.secondLabel}.',
|
||
_ => '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(
|
||
switch (conflit?.kind) {
|
||
// 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.
|
||
'thingClaimedTwice' =>
|
||
'Un appareil ne peut appartenir qu\'à une seule charge : deux '
|
||
'charges sur un même canal de commande se contrediraient.',
|
||
'duplicateId' =>
|
||
'Chaque charge a un identifiant unique côté box — deux entrées ne '
|
||
'peuvent pas le partager.',
|
||
// Depuis +etm27 la box refuse TOUT l'ensemble sur une égalité de rang, et
|
||
// son motif ne franchit pas la frontière RPC : sans ce message, le
|
||
// symptôme serait « ça ne s'enregistre pas », sans cause visible.
|
||
'tiedPriority' =>
|
||
'Depuis la version 1.15.2+etm27, la box refuse toute configuration où '
|
||
'deux charges partagent un rang. Classez-les l\'une après l\'autre '
|
||
'— rien n\'a été envoyé.',
|
||
_ => '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)),
|
||
),
|
||
);
|
||
}
|
||
}
|