Deux retours d'usage, tous deux sur la même carte. 1. « Placer en dernier » poussait « Configurer » hors de sa place. Il rejoint « Renommer » sur une ligne à part : ce sont les deux gestes qui ne touchent pas au mécanisme, et un bouton d'action principale ne se fait pas déplacer par un geste secondaire. 2. Quitter l'écran avec des modifications non enregistrées ne demandait rien — et ce silence cachait pire qu'une perte : le brouillon vit dans LoadConfigProvider, pas dans l'écran, donc il SURVIT à la navigation. On repartait en laissant une configuration modifiée invisible, qui réapparaissait au retour sans contexte, ou disparaissait à la première relecture de la box (LoadConfigChanged, reconnexion) sans que personne ait choisi. Les deux issues sont mauvaises pour la même raison : l'utilisateur ne sait pas ce qu'il laisse derrière lui. GardeBrouillon (PopScope) s'interpose sur les DEUX écrans qui partagent ce brouillon — la liste des charges et l'écran de mécanisme. L'alerte dit l'état de la BOX, pas seulement celui de l'écran : « vos changements n'ont pas été envoyés, elle porte toujours la configuration d'avant ». Deux issues, toutes deux explicites : rester, ou quitter en abandonnant — un brouillon fantôme qui ressort trois écrans plus loin est pire qu'une modification perdue qu'on a accepté de perdre. Aucune écriture n'est proposée depuis la boîte de dialogue : enregistrer depuis une sortie ferait partir une configuration que personne n'a relue. Quatre tests, dont le contrôle négatif — sans brouillon, on sort sans être interrogé. Une alerte qui se déclenche pour rien apprend à passer outre sans lire. 235 tests (+4), flutter analyze inchangé à 27 remarques. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
1243 lines
51 KiB
Dart
1243 lines
51 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.
|
||
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)),
|
||
),
|
||
);
|
||
}
|
||
}
|