etm-powersync-app/lib/widgets/controllable_loads_section.dart
Patrick Schurig 708914861e test(appareil): vérifier le chip de domaine et l'écran de mécanisme depuis le harnais
MIUI refuse l'injection d'événements adb : impossible de faire défiler ou de taper depuis
l'hôte pour contrôler visuellement. Les assertions descendent donc dans integration_test,
qui a la main sur l'IU : le chip porte bien un libellé, le sélecteur de mécanisme propose
les trois valeurs, et les sections exigées par la maquette (Relais commandés,
Temporisations, Compteur dédié, Arbitrage) sont présentes.

Une clé est posée sur le chip pour le viser sans compter des icônes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-26 08:59:00 +02:00

819 lines
30 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../l10n/app_localizations.dart';
import '../l10n/l10n_ext.dart';
import '../models/load_config_entry.dart';
import '../providers/load_config_provider.dart';
import '../providers/load_telemetry_provider.dart';
import '../services/load_hardware.dart';
import '../services/load_priority.dart';
import '../services/nymea_service.dart';
import '../theme/etm_tokens.dart';
import 'load_cards.dart';
/// Les **charges pilotables** de l'écran « Rôles & appareils », telles que la box les
/// déclare.
///
/// ## Le sens de la fusion
///
/// Cette section remplace l'ancien écran « Ordre de service » — c'est son **chemin de
/// données** qui a migré ici, pas l'inverse. La distinction n'est pas de forme : l'écran
/// des rôles reposait sur une énumération figée de six rôles
/// (`gridMeter`, `solarMeter`, `evCharger`, `dhw`, `heatPump`, `battery`), et une
/// énumération figée ne peut pas décrire une installation.
///
/// **LM-201 le dit sans détour : une charge existe quand il y a un canal de commande
/// distinct pour un service.** Un canal, une charge. Une PAC air/eau qui fait chauffage
/// ET ECS sans ballon séparé est **une** charge — le SG-Ready commande la machine
/// entière et la répartition appartient à sa régulation. Le modèle des rôles, lui, aurait
/// exigé d'en déclarer deux (`dhw` **et** `heatPump`) pour une seule machine, donc deux
/// entrées se disputant un canal unique. C'est exactement ce que `validateSet()` refuse
/// côté box, et ce refus n'est pas une limite subie : c'est le modèle.
///
/// La liste affichée vient donc de `GetLoadConfig`, avec ses identifiants et ses domaines
/// tels quels — y compris ceux qu'aucune version d'app ne connaît encore.
///
/// ## Deux sources qu'il ne faut pas confondre
///
/// - **la configuration** (`GetLoadConfig`) — ce que l'installateur a déclaré, éditable ;
/// - **la télémétrie** (`GetLoadTelemetry`) — ce que l'arbitre a fait au dernier cycle,
/// en lecture seule et jamais reconstruite côté app.
///
/// Depuis le lot plugin B-bis, toute charge publiée en télémétrie a une entrée en
/// configuration. L'inverse reste faux : une charge configurée absente de la télémétrie
/// est **désactivée**, jamais perdue — et « non arbitrée » n'est donc pas une erreur.
class ControllableLoadsSection extends StatelessWidget {
/// Ouvre l'écran de mécanisme d'une charge. Fourni par l'écran hôte, qui connaît la
/// navigation ; les trois contrats ne se ressemblent pas, donc il n'y a pas de
/// formulaire unique à ouvrir.
final void Function(BuildContext, LoadConfigEntry) onConfigure;
const ControllableLoadsSection({super.key, required this.onConfigure});
@override
Widget build(BuildContext context) {
final provider = context.watch<LoadConfigProvider>();
final telemetry = context.watch<LoadTelemetryProvider>();
final groups = provider.groups;
if (provider.loading && groups.isEmpty) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 24),
child: Center(child: CircularProgressIndicator()),
);
}
if (groups.isEmpty) {
return _EmptyNote(error: provider.error);
}
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Le budget du cycle AVANT les charges : « Alloué 3 000 W » ne veut rien dire
// tant qu'on ne sait pas s'il y avait 3 100 ou 12 000 W à distribuer.
TelemetryHeader(telemetry: telemetry),
// Les domaines se réordonnent par les FLÈCHES, pas au glisser-déposer.
//
// Tentative faite et annulée : envelopper les domaines dans un second
// `ReorderableListView` rend bien les domaines glissables, mais **casse le
// glissement des cartes** — deux listes réordonnables imbriquées se disputent le
// geste, et c'est l'extérieure qui gagne. Le test `reorder_drag_test.dart` le
// montre en une exécution. On garde donc un seul niveau glissable, celui des
// charges, et des flèches explicites pour les domaines.
for (var i = 0; i < groups.length; i++)
_DomainBlock(
key: ValueKey('domain-${groups[i].domain}'),
group: groups[i],
index: i,
total: groups.length,
provider: provider,
telemetry: telemetry,
onConfigure: onConfigure,
),
// La borne n'est PAS une LoadConfig : elle n'apparaît donc dans aucun domaine
// ci-dessus, et sans cette section elle serait tout simplement invisible — ce
// qu'un installateur qui vient de la configurer lit comme une perte.
const BorneSection(),
if (provider.hasPendingChanges) ...[
const SizedBox(height: 4),
ImpactSaveBar(provider: provider),
],
],
);
}
}
/// Un domaine et ses charges. Les domaines se déplacent entre eux, les charges à
/// l'intérieur — les deux niveaux que l'ordre de service demande.
class _DomainBlock extends StatelessWidget {
final DomainGroup group;
final int index;
final int total;
final LoadConfigProvider provider;
final LoadTelemetryProvider telemetry;
final void Function(BuildContext, LoadConfigEntry) onConfigure;
const _DomainBlock({
super.key,
required this.group,
required this.index,
required this.total,
required this.provider,
required this.telemetry,
required this.onConfigure,
});
@override
Widget build(BuildContext context) {
final t = context.l10n;
final muted = EtmTokens.mutedOf(context);
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Expanded(
child: Text(
domainLabel(t, group.domain),
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 13,
letterSpacing: 0.2,
color: group.isUnclassified ? muted : EtmTokens.inkOf(context),
),
),
),
IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.keyboard_arrow_up_rounded, size: 20),
tooltip: 'Monter ce domaine',
onPressed:
index == 0 ? null : () => provider.reorderDomains(index, index - 1),
),
IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.keyboard_arrow_down_rounded, size: 20),
tooltip: 'Descendre ce domaine',
onPressed: index >= total - 1
? null
: () => provider.reorderDomains(index, index + 2),
),
]),
const SizedBox(height: 6),
ReorderableListView.builder(
shrinkWrap: true,
primary: false,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
itemCount: group.loads.length,
onReorder: (o, n) => provider.reorderWithinDomain(group.domain, o, n),
itemBuilder: (context, i) => Padding(
key: ValueKey('load-${group.loads[i].id}'),
padding: const EdgeInsets.only(bottom: 10),
child: LoadConfigCard(
entry: group.loads[i],
handleIndex: i,
reordonnable: group.loads.length > 1,
provider: provider,
telemetry: telemetry,
onConfigure: onConfigure,
),
),
),
],
),
);
}
}
/// Carte d'une charge : identité, classement, rang, télémétrie, et l'accès au mécanisme.
class LoadConfigCard extends StatelessWidget {
final LoadConfigEntry entry;
final int handleIndex;
/// Faux quand la charge est **seule dans son domaine** : il n'y a alors rien à
/// réordonner ici, et l'ordre se change au niveau des domaines.
final bool reordonnable;
final LoadConfigProvider provider;
final LoadTelemetryProvider telemetry;
final void Function(BuildContext, LoadConfigEntry) onConfigure;
const LoadConfigCard({
super.key,
required this.entry,
required this.handleIndex,
this.reordonnable = true,
required this.provider,
required this.telemetry,
required this.onConfigure,
});
/// Libellé du mécanisme. Code inconnu → repli lisible, jamais une phrase vide : une
/// box plus récente peut publier un mécanisme que cette version d'app ignore.
String _adapterLabel(L10n t) => switch (entry.adapter) {
'relay-router' => t.adapterRelayRouter,
'etmvariableload' => t.adapterEtmVariableLoad,
'sg-ready' => t.adapterSgReady,
'evcharger' => t.adapterEvCharger,
_ => t.adapterUnknown(entry.adapter),
};
/// « Routeur de relais · 3 relais · min ON 60 s » — le mécanisme, son matériel, ses
/// verrous. Chaque morceau n'apparaît que s'il a une valeur à montrer.
String _resume(L10n t) {
final bouts = <String>[_adapterLabel(t)];
if (entry.isRelayRouter) {
bouts.add('${entry.relays.length} relais');
} else if (entry.isSgReady) {
bouts.add('${entry.sgReadyStates.length} états');
if (entry.minStateHoldS > 0) {
bouts.add('maintien d\'état ${entry.minStateHoldS} s');
}
} else if (entry.maxPowerW > 0) {
bouts.add('${entry.maxPowerW} W');
}
if (!entry.isSgReady) {
if (entry.minOnS > 0) bouts.add('min ON ${entry.minOnS} s');
if (entry.minOffS > 0) bouts.add('min OFF ${entry.minOffS} s');
}
return bouts.join(' · ');
}
@override
Widget build(BuildContext context) {
final t = context.l10n;
final muted = EtmTokens.mutedOf(context);
final live = telemetry.entryFor(entry.id);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
boxShadow: EtmTokens.cardShadowOf(context),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Seule dans son domaine, une charge n'a nulle part où aller : la poignée
// serait un mensonge. On garde le rang, on retire la prise.
_PoigneeOuPas(
key: ValueKey('poignee-charge-${entry.id}'),
actif: reordonnable,
index: handleIndex,
// `Container` transparent : rend la ZONE ENTIÈRE préhensible.
// `ReorderableDragStartListener` défère le test de collision à ses
// enfants ; sans ça, le vide de 3 px entre l'icône et la pastille ne
// saisit rien — une bande morte invisible, au milieu d'une poignée qui
// paraît d'un seul tenant.
child: Container(
color: Colors.transparent,
padding: const EdgeInsets.only(top: 1, right: 9),
child: Column(children: [
Icon(
reordonnable
? Icons.drag_indicator_rounded
: Icons.remove_rounded,
size: 20,
color: reordonnable ? muted : muted.withValues(alpha: 0.35),
),
const SizedBox(height: 3),
// Le rang est ce que le glisser-déposer produit : il se lit ici,
// pas noyé dans une phrase.
Container(
width: 20,
height: 20,
alignment: Alignment.center,
decoration: BoxDecoration(
color: EtmTokens.brand.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
),
child: Text('${entry.priority}',
style: const TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.w700,
color: EtmTokens.brand)),
),
]),
),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.label,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: entry.enabled
? EtmTokens.inkOf(context)
: muted,
),
),
const SizedBox(height: 2),
// Ce que l'installateur doit reconnaître d'un coup d'œil : le
// mécanisme, ce qu'il commande, et les verrous qui le brident.
// « rang 1 » n'apprend rien ici — la pastille le dit déjà.
Text(_resume(t),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11.5, height: 1.3, color: muted)),
],
),
),
// `enabled` : entrée/sortie de l'arbitrage. Le libellé de l'aide dit ce que
// le drapeau fait VRAIMENT — voir _EnabledSwitch.
_EnabledSwitch(entry: entry, provider: provider),
],
),
const SizedBox(height: 10),
Row(children: [
DomainMenu(
key: ValueKey('chip-domaine-${entry.id}'),
entry: entry,
provider: provider),
const Spacer(),
TextButton.icon(
onPressed: () => onConfigure(context, entry),
icon: const Icon(Icons.tune_rounded, size: 17),
label: const Text('Configurer'),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
foregroundColor: EtmTokens.brand,
),
),
]),
_RenameRow(entry: entry, provider: provider),
const SizedBox(height: 8),
Divider(height: 1, color: EtmTokens.lineOf(context)),
const SizedBox(height: 8),
if (live != null)
LoadLiveBlock(
live: live,
stale: telemetry.stale,
telemetry: telemetry,
)
else
// Absente de `loads[]` : désactivée, pas perdue. Le dire explicitement —
// un vide se lirait comme une panne.
Row(children: [
Icon(Icons.pause_circle_outline_rounded, size: 14, color: muted),
const SizedBox(width: 5),
Expanded(
child: Text(
entry.enabled
? 'Non arbitrée à ce cycle — configurée, mais absente de la '
'télémétrie du dernier cycle.'
: 'Désactivée — rôle déclaré, exclue de l\'arbitrage.',
style: TextStyle(fontSize: 11.5, color: muted, height: 1.3),
),
),
]),
],
),
);
}
}
/// Interrupteur d'entrée dans l'arbitrage, avec ce que le geste fait réellement.
class _EnabledSwitch extends StatelessWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
const _EnabledSwitch({required this.entry, required this.provider});
@override
Widget build(BuildContext context) {
return Tooltip(
// Ce texte est le résultat d'une mesure, pas une précaution rédactionnelle :
// sur `.75`, PAC en état 4 (contacts fermés), `enabled: false` a bien OUVERT les
// contacts — par le retrait de l'adaptateur (ECS-413), pas par le drapeau.
message: entry.enabled
? 'Désactiver retire la charge de l\'arbitrage et l\'amène à son état sûr '
'(contacts ouverts) avant de la retirer.\n'
'Ce n\'est pas un sectionnement : la sécurité vient du retrait, pas du '
'drapeau.'
: 'Activer fait entrer la charge dans l\'arbitrage au prochain cycle.',
child: Switch.adaptive(
value: entry.enabled,
onChanged: (v) => provider.setEnabled(entry.id, v),
),
);
}
}
/// Renommage en ligne. Le libellé n'est pas du matériel — aucun verrou n'est réarmé.
class _RenameRow extends StatefulWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
const _RenameRow({required this.entry, required this.provider});
@override
State<_RenameRow> createState() => _RenameRowState();
}
class _RenameRowState extends State<_RenameRow> {
bool _editing = false;
late final TextEditingController _c =
TextEditingController(text: widget.entry.label);
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!_editing) {
return Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () => setState(() => _editing = true),
icon: const Icon(Icons.edit_outlined, size: 15),
label: const Text('Renommer'),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
foregroundColor: EtmTokens.mutedOf(context),
textStyle: const TextStyle(fontSize: 12),
),
),
);
}
return Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(children: [
Expanded(
child: TextField(
controller: _c,
autofocus: true,
style: const TextStyle(fontSize: 14),
decoration: const InputDecoration(
isDense: true,
labelText: 'Libellé',
helperText: 'Sans effet sur le matériel — aucun verrou réarmé.',
helperMaxLines: 2,
),
onSubmitted: (_) => _commit(),
),
),
IconButton(
icon: const Icon(Icons.check_rounded, size: 20),
onPressed: _commit,
),
IconButton(
icon: const Icon(Icons.close_rounded, size: 20),
onPressed: () => setState(() {
_c.text = widget.entry.label;
_editing = false;
}),
),
]),
);
}
void _commit() {
final v = _c.text.trim();
if (v.isNotEmpty && v != widget.entry.label) {
widget.provider.setLabel(widget.entry.id, v);
}
setState(() => _editing = false);
}
}
/// Barre d'enregistrement — et **annonce de ce que l'écriture va provoquer**.
///
/// C'est la règle §5-3 du lot : `priority`, `domain`, `label` et `enabled` se mettent à
/// jour sans rien perturber, mais toucher `relays[]`, le mécanisme ou les Things
/// **reconstruit l'adaptateur** — contacts ouverts, verrous réarmés à froid. Un
/// installateur qui change un relais ne doit pas découvrir après coup que sa PAC est
/// bloquée un quart d'heure.
class ImpactSaveBar extends StatelessWidget {
final LoadConfigProvider provider;
const ImpactSaveBar({super.key, required this.provider});
@override
Widget build(BuildContext context) {
final impacts = provider.pendingImpact();
final warnings = impacts.where((i) => i.needsWarning).toList();
final pending = provider.saveState == LoadSaveState.pending;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
border: Border.all(
color: warnings.isEmpty
? EtmTokens.lineOf(context)
: EtmTokens.orange.withValues(alpha: 0.6),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (warnings.isNotEmpty) ...[
for (final w in warnings) _ImpactLine(impact: w),
const SizedBox(height: 8),
],
Row(children: [
Expanded(
child: Text(
warnings.isEmpty
? 'Modifications en attente — rien n\'est parti vers la box.'
: 'À confirmer : l\'enregistrement touche au matériel.',
style: TextStyle(
fontSize: 12, color: EtmTokens.mutedOf(context), height: 1.3),
),
),
TextButton(
onPressed: pending ? null : provider.discardDraft,
child: const Text('Annuler'),
),
const SizedBox(width: 4),
FilledButton(
onPressed: pending ? null : provider.save,
child: Text(pending ? 'Envoi…' : 'Enregistrer'),
),
]),
if (provider.saveState == LoadSaveState.rejected)
_RejectionNote(provider: provider),
],
),
);
}
}
/// Une ligne d'avertissement d'impact, qui nomme **ce qui** l'a déclenché.
///
/// « Vous avez changé l'ordre des contacteurs » est actionnable ; « la charge sera
/// reconstruite » ne l'est pas.
class _ImpactLine extends StatelessWidget {
final LoadImpact impact;
const _ImpactLine({required this.impact});
static const _fieldNames = {
'relays': 'la liste des contacteurs',
'sgReady': 'l\'encodage SG-Ready',
'minOnS': 'la temporisation de marche',
'minOffS': 'la temporisation d\'arrêt',
'maxPowerW': 'la puissance nominale',
'powerLevels': 'les paliers déclarés',
'adapter': 'le mécanisme',
'mode': 'le mode',
};
@override
Widget build(BuildContext context) {
final quoi = impact.changedFields
.map((f) => _fieldNames[f] ?? f)
.join(', ');
final rearm = impact.rearmSeconds;
final duree = rearm == null
? ''
: rearm >= 60
? ' pendant ${(rearm / 60).round()} min'
: ' pendant $rearm s';
final texte = switch (impact.impact) {
LoadChangeImpact.rebuild =>
'« ${impact.label} » — vous avez modifié $quoi. La charge sera reconstruite : '
'ses contacts passent par l\'état sûr, puis son verrou se réarme$duree.',
LoadChangeImpact.removed =>
'« ${impact.label} » quitte l\'arbitrage. Elle est amenée à son état sûr '
'(contacts ouverts) avant retrait.',
_ => '',
};
if (texte.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.warning_amber_rounded, size: 16, color: EtmTokens.orange),
const SizedBox(width: 7),
Expanded(
child: Text(texte,
style: TextStyle(
fontSize: 12,
height: 1.35,
color: EtmTokens.inkOf(context))),
),
],
),
);
}
}
/// Refus de la box. **Ne rien inventer** : `SetLoadConfig` rend un code sans motif, et
/// la règle enfreinte reste au journal de la box. On affiche le refus et on y renvoie.
class _RejectionNote extends StatelessWidget {
final LoadConfigProvider provider;
const _RejectionNote({required this.provider});
@override
Widget build(BuildContext context) {
final conflit = provider.conflict;
final err = provider.saveError;
return Padding(
padding: const EdgeInsets.only(top: 10),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: EtmTokens.danger.withValues(alpha: 0.09),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
conflit != null
? 'Deux charges revendiquent le même appareil.'
: 'La box a refusé l\'enregistrement${err == null ? '' : ' ($err)'}.',
style: const TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w700,
color: EtmTokens.danger),
),
const SizedBox(height: 4),
Text(
conflit != null
// LM-201 : deux charges sur un canal unique se contrediraient, et le
// budget compterait la même puissance deux fois. Ce n'est pas une
// limite technique — c'est le modèle.
? 'Un appareil ne peut appartenir qu\'à une seule charge : deux '
'charges sur un même canal de commande se contrediraient.'
: 'Le refus porte sur l\'ensemble : rien n\'a été modifié. La règle '
'enfreinte et le libellé en cause sont dans le journal de la box '
'(journalctl -u nymead).',
style: TextStyle(
fontSize: 11.5,
height: 1.35,
color: EtmTokens.mutedOf(context)),
),
],
),
),
);
}
}
class _EmptyNote extends StatelessWidget {
final String? error;
const _EmptyNote({this.error});
@override
Widget build(BuildContext context) {
final muted = EtmTokens.mutedOf(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 18),
child: Text(
error == null
? 'Aucune charge pilotable déclarée sur cette box.'
: 'Configuration illisible : $error',
style: TextStyle(fontSize: 12.5, color: muted, height: 1.35),
),
);
}
}
/// Les bornes de recharge, qui **ne sont pas des charges pilotables**.
///
/// ## Pourquoi elles ont leur propre section
///
/// Une borne n'a ni `adapter`, ni `priority`, ni `domain` : elle ne passe pas par
/// `SetLoadConfig` mais par `SetChargingInfo`, un **autre contrat**. Et elle n'apparaît
/// pas dans `GetLoadTelemetry` en beta — elle est décidée **en amont** du waterfall, sa
/// puissance étant seulement réservée dans `budget.evReservedW`. Elle rejoindra `loads[]`
/// à l'étape 3g.
///
/// Conséquence pratique, et c'est le défaut que cette section corrige : configurée ou non,
/// la borne n'apparaissait **nulle part** dans cet écran. Un installateur qui vient de la
/// régler et ne la retrouve pas conclut, raisonnablement, que son réglage s'est perdu.
class BorneSection extends StatelessWidget {
const BorneSection({super.key});
@override
Widget build(BuildContext context) {
final service = context.watch<NymeaService>();
final classesBorne = {
for (final c in service.thingClasses)
if (c.interfaces.contains('evcharger')) c.id
};
final bornes = [
for (final t in service.things)
if (classesBorne.contains(t.thingClassId)) t
];
if (bornes.isEmpty) return const SizedBox.shrink();
final muted = EtmTokens.mutedOf(context);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const SizedBox(height: 6),
Text('Recharge véhicule',
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w700,
letterSpacing: 0.2,
color: EtmTokens.inkOf(context))),
const SizedBox(height: 8),
for (final b in bornes)
Container(
width: double.infinity,
margin: const EdgeInsets.only(bottom: 10),
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
border: Border.all(color: EtmTokens.lineOf(context)),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
Icon(Icons.ev_station_rounded, size: 19, color: muted),
const SizedBox(width: 9),
Expanded(
child: Text(b.name,
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: EtmTokens.inkOf(context))),
),
]),
const SizedBox(height: 6),
Text(
'Pas une charge pilotable : la borne se règle par un autre contrat '
'(SetChargingInfo) et n\'a ni rang ni domaine. Elle est décidée en '
'amont du waterfall — sa puissance est réservée dans le budget, pas '
'allouée depuis lui.',
style: TextStyle(fontSize: 11.5, height: 1.35, color: muted),
),
const SizedBox(height: 4),
Text(
'Le mode de recharge se règle depuis la carte véhicule du tableau de '
'bord.',
style: TextStyle(fontSize: 11.5, height: 1.35, color: muted),
),
],
),
),
],
);
}
}
/// Enveloppe une poignée : prise réelle si [actif], simple décor sinon.
///
/// `ReorderableDragStartListener` s'attend à vivre sous un `ReorderableListView` ; l'utiliser
/// pour une liste d'un seul élément produirait une poignée qui s'agrippe et ne mène nulle
/// part. Mieux vaut ne pas offrir la prise.
class _PoigneeOuPas extends StatelessWidget {
final bool actif;
final int index;
final Widget child;
const _PoigneeOuPas({
super.key,
required this.actif,
required this.index,
required this.child,
});
@override
Widget build(BuildContext context) {
if (!actif) {
return Tooltip(
message: 'Seule charge de ce domaine — son rang se change en déplaçant le '
'domaine lui-même.',
child: child,
);
}
return ReorderableDragStartListener(index: index, child: child);
}
}