etm-powersync-app/lib/widgets/controllable_loads_section.dart
Patrick Schurig 449c6ac50a feat(3g): les valeurs de borne sont du RUNTIME — deux natures derrière un même mot
MINIMUM. « Plancher » désigne désormais deux choses qu'il ne faut surtout pas ranger
ensemble :

- le plancher DÉCLARÉ d'une charge modulable — propriété de l'appareil, saisie une fois,
  stable, avec un champ de configuration à venir ;
- le minimum d'une BORNE — max(borne, voiture), qui dépend du véhicule branché et change
  avec lui. Il n'aura jamais de champ de configuration : il n'y a rien à y régler.

Les rendre modifiables de la même façon ferait croire qu'on peut régler ce qui se constate.
Le bloc « plancher » ne vit que dans la section etmvariableload, ce qui écarte les bornes
structurellement — elles ne sont pas des LoadConfig et n'ouvrent pas cet écran — et son
libellé le dit désormais explicitement.

PHASES. Le plancher effectif d'une borne dépend du nombre de phases visé : ~1,4 kW en
monophasé, ~4,1 kW en triphasé, pour le MÊME courant. La valeur est affichée telle que la
box la donne, sans conversion ni recalcul côté app. Si l'app convertissait, elle devrait
deviner laquelle des deux règles s'applique — et se tromperait un jour. C'est exactement le
miroir qui vient d'être retiré pour la combinatoire des paliers ; on ne va pas en
réintroduire un.

PARTAGE. Le budget ne se divise pas entre bornes : aujourd'hui chacune reçoit l'enveloppe
entière et ce sont le compteur puis la protection de surcharge qui rattrapent ; après 3g
une seule démarrera. Avec deux bornes maintenant déclarées sur le banc, le laisser
implicite invitait à déduire un partage qui n'existe ni avant ni après. Une note l'énonce
dès qu'il y en a plus d'une.

Le bloc de runtime d'une borne reste MUET tant que la recharge n'est pas arbitrée — la box
ne publie rien pour elle, et c'est la bonne réponse, pas un trou. Il affichera phases,
courant, minimum et présence du véhicule le jour où ils arriveront, en lecture seule.

Tests 105/105, analyze 0 erreur.

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

918 lines
34 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),
// Le niveau glissable est celui des DOMAINES, pas des cartes.
//
// Deux ReorderableListView imbriqués se disputent le geste — l'extérieur gagne et
// l'intérieur devient inerte. Il faut donc choisir, et le choix se décide par ce
// que l'installateur cherche à faire : sur une installation ordinaire, chaque
// domaine porte UNE charge, et l'ordre de service se règle donc entre domaines.
// C'est là que la prise doit être. Réordonner deux charges d'un même domaine reste
// possible, par les flèches de leur carte.
ReorderableListView.builder(
shrinkWrap: true,
primary: false,
physics: const NeverScrollableScrollPhysics(),
buildDefaultDragHandles: false,
itemCount: groups.length,
onReorder: provider.reorderDomains,
itemBuilder: (context, i) => _DomainBlock(
key: ValueKey('domain-${groups[i].domain}'),
group: groups[i],
index: i,
total: groups.length,
provider: provider,
telemetry: telemetry,
onConfigure: onConfigure,
),
),
// 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: [
ReorderableDragStartListener(
key: ValueKey('poignee-domaine-${group.domain}'),
index: index,
child: Container(
color: Colors.transparent,
padding: const EdgeInsets.only(right: 7, top: 4, bottom: 4),
child: Icon(Icons.drag_indicator_rounded, size: 19, color: muted),
),
),
Expanded(
child: Text(
domainLabel(t, group.domain),
style: TextStyle(
fontWeight: FontWeight.w700,
fontSize: 13,
letterSpacing: 0.2,
color: group.isUnclassified ? muted : EtmTokens.inkOf(context),
),
),
),
IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.keyboard_arrow_up_rounded, size: 20),
tooltip: 'Monter ce domaine',
onPressed:
index == 0 ? null : () => provider.reorderDomains(index, index - 1),
),
IconButton(
visualDensity: VisualDensity.compact,
icon: const Icon(Icons.keyboard_arrow_down_rounded, size: 20),
tooltip: 'Descendre ce domaine',
onPressed: index >= total - 1
? null
: () => provider.reorderDomains(index, index + 2),
),
]),
const SizedBox(height: 6),
for (var i = 0; i < group.loads.length; i++)
Padding(
key: ValueKey('load-${group.loads[i].id}'),
padding: const EdgeInsets.only(bottom: 10),
child: LoadConfigCard(
entry: group.loads[i],
indexDansDomaine: i,
totalDansDomaine: group.loads.length,
provider: provider,
telemetry: telemetry,
onConfigure: onConfigure,
),
),
],
),
);
}
}
/// Carte d'une charge : identité, classement, rang, télémétrie, et l'accès au mécanisme.
class LoadConfigCard extends StatelessWidget {
final LoadConfigEntry entry;
/// Position dans son domaine, et taille du domaine. Ensemble, ils disent si un
/// réordonnancement INTERNE a un sens — seul dans son domaine, il n'y a rien à déplacer,
/// et l'ordre se change alors en glissant le domaine.
final int indexDansDomaine;
final int totalDansDomaine;
final LoadConfigProvider provider;
final LoadTelemetryProvider telemetry;
final void Function(BuildContext, LoadConfigEntry) onConfigure;
const LoadConfigCard({
super.key,
required this.entry,
required this.indexDansDomaine,
required this.totalDansDomaine,
required this.provider,
required this.telemetry,
required this.onConfigure,
});
/// Libellé du mécanisme. Code inconnu → repli lisible, jamais une phrase vide : une
/// box plus récente peut publier un mécanisme que cette version d'app ignore.
String _adapterLabel(L10n t) => switch (entry.adapter) {
'relay-router' => t.adapterRelayRouter,
'etmvariableload' => t.adapterEtmVariableLoad,
'sg-ready' => t.adapterSgReady,
'evcharger' => t.adapterEvCharger,
_ => t.adapterUnknown(entry.adapter),
};
/// « Routeur de relais · 3 relais · min ON 60 s » — le mécanisme, son matériel, ses
/// verrous. Chaque morceau n'apparaît que s'il a une valeur à montrer.
String _resume(L10n t) {
final bouts = <String>[_adapterLabel(t)];
if (entry.isRelayRouter) {
bouts.add('${entry.relays.length} relais');
} else if (entry.isSgReady) {
bouts.add('${entry.sgReadyStates.length} états');
if (entry.minStateHoldS > 0) {
bouts.add('maintien d\'état ${entry.minStateHoldS} s');
}
} else if (entry.maxPowerW > 0) {
bouts.add('${entry.maxPowerW} W');
}
if (!entry.isSgReady) {
if (entry.minOnS > 0) bouts.add('min ON ${entry.minOnS} s');
if (entry.minOffS > 0) bouts.add('min OFF ${entry.minOffS} s');
}
return bouts.join(' · ');
}
@override
Widget build(BuildContext context) {
final t = context.l10n;
final muted = EtmTokens.mutedOf(context);
final live = telemetry.entryFor(entry.id);
return Container(
width: double.infinity,
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
boxShadow: EtmTokens.cardShadowOf(context),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Le rang, et — si le domaine porte plusieurs charges — de quoi les
// ordonner entre elles. Le glissement, lui, appartient au domaine.
Padding(
padding: const EdgeInsets.only(top: 1, right: 9),
child: Column(children: [
Container(
width: 20,
height: 20,
alignment: Alignment.center,
decoration: BoxDecoration(
color: EtmTokens.brand.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(6),
),
child: Text('${entry.priority}',
style: const TextStyle(
fontSize: 10.5,
fontWeight: FontWeight.w700,
color: EtmTokens.brand)),
),
if (totalDansDomaine > 1) ...[
const SizedBox(height: 2),
_FlecheRang(
key: ValueKey('monter-${entry.id}'),
icone: Icons.keyboard_arrow_up_rounded,
actif: indexDansDomaine > 0,
onTap: () => provider.reorderWithinDomain(
entry.domain ?? kUnclassifiedDomain,
indexDansDomaine,
indexDansDomaine - 1),
),
_FlecheRang(
key: ValueKey('descendre-${entry.id}'),
icone: Icons.keyboard_arrow_down_rounded,
actif: indexDansDomaine < totalDansDomaine - 1,
onTap: () => provider.reorderWithinDomain(
entry.domain ?? kUnclassifiedDomain,
indexDansDomaine,
indexDansDomaine + 2),
),
],
]),
),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
entry.label,
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: entry.enabled
? EtmTokens.inkOf(context)
: muted,
),
),
const SizedBox(height: 2),
// Ce que l'installateur doit reconnaître d'un coup d'œil : le
// mécanisme, ce qu'il commande, et les verrous qui le brident.
// « rang 1 » n'apprend rien ici — la pastille le dit déjà.
Text(_resume(t),
maxLines: 2,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 11.5, height: 1.3, color: muted)),
],
),
),
// `enabled` : entrée/sortie de l'arbitrage. Le libellé de l'aide dit ce que
// le drapeau fait VRAIMENT — voir _EnabledSwitch.
_EnabledSwitch(entry: entry, provider: provider),
],
),
const SizedBox(height: 10),
Row(children: [
DomainMenu(
key: ValueKey('chip-domaine-${entry.id}'),
entry: entry,
provider: provider),
const Spacer(),
TextButton.icon(
onPressed: () => onConfigure(context, entry),
icon: const Icon(Icons.tune_rounded, size: 17),
label: const Text('Configurer'),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
foregroundColor: EtmTokens.brand,
),
),
]),
_RenameRow(entry: entry, provider: provider),
const SizedBox(height: 8),
Divider(height: 1, color: EtmTokens.lineOf(context)),
const SizedBox(height: 8),
if (live != null)
LoadLiveBlock(
live: live,
stale: telemetry.stale,
telemetry: telemetry,
)
else
// Absente de `loads[]` : désactivée, pas perdue. Le dire explicitement —
// un vide se lirait comme une panne.
Row(children: [
Icon(Icons.pause_circle_outline_rounded, size: 14, color: muted),
const SizedBox(width: 5),
Expanded(
child: Text(
entry.enabled
? 'Non arbitrée à ce cycle — configurée, mais absente de la '
'télémétrie du dernier cycle.'
: 'Désactivée — rôle déclaré, exclue de l\'arbitrage.',
style: TextStyle(fontSize: 11.5, color: muted, height: 1.3),
),
),
]),
],
),
);
}
}
/// Interrupteur d'entrée dans l'arbitrage, avec ce que le geste fait réellement.
class _EnabledSwitch extends StatelessWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
const _EnabledSwitch({required this.entry, required this.provider});
@override
Widget build(BuildContext context) {
return Tooltip(
// Ce texte est le résultat d'une mesure, pas une précaution rédactionnelle :
// sur `.75`, PAC en état 4 (contacts fermés), `enabled: false` a bien OUVERT les
// contacts — par le retrait de l'adaptateur (ECS-413), pas par le drapeau.
message: entry.enabled
? 'Désactiver retire la charge de l\'arbitrage et l\'amène à son état sûr '
'(contacts ouverts) avant de la retirer.\n'
'Ce n\'est pas un sectionnement : la sécurité vient du retrait, pas du '
'drapeau.'
: 'Activer fait entrer la charge dans l\'arbitrage au prochain cycle.',
child: Switch.adaptive(
value: entry.enabled,
onChanged: (v) => provider.setEnabled(entry.id, v),
),
);
}
}
/// Renommage en ligne. Le libellé n'est pas du matériel — aucun verrou n'est réarmé.
class _RenameRow extends StatefulWidget {
final LoadConfigEntry entry;
final LoadConfigProvider provider;
const _RenameRow({required this.entry, required this.provider});
@override
State<_RenameRow> createState() => _RenameRowState();
}
class _RenameRowState extends State<_RenameRow> {
bool _editing = false;
late final TextEditingController _c =
TextEditingController(text: widget.entry.label);
@override
void dispose() {
_c.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
if (!_editing) {
return Align(
alignment: Alignment.centerLeft,
child: TextButton.icon(
onPressed: () => setState(() => _editing = true),
icon: const Icon(Icons.edit_outlined, size: 15),
label: const Text('Renommer'),
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
foregroundColor: EtmTokens.mutedOf(context),
textStyle: const TextStyle(fontSize: 12),
),
),
);
}
return Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(children: [
Expanded(
child: TextField(
controller: _c,
autofocus: true,
style: const TextStyle(fontSize: 14),
decoration: const InputDecoration(
isDense: true,
labelText: 'Libellé',
helperText: 'Sans effet sur le matériel — aucun verrou réarmé.',
helperMaxLines: 2,
),
onSubmitted: (_) => _commit(),
),
),
IconButton(
icon: const Icon(Icons.check_rounded, size: 20),
onPressed: _commit,
),
IconButton(
icon: const Icon(Icons.close_rounded, size: 20),
onPressed: () => setState(() {
_c.text = widget.entry.label;
_editing = false;
}),
),
]),
);
}
void _commit() {
final v = _c.text.trim();
if (v.isNotEmpty && v != widget.entry.label) {
widget.provider.setLabel(widget.entry.id, v);
}
setState(() => _editing = false);
}
}
/// Barre d'enregistrement — et **annonce de ce que l'écriture va provoquer**.
///
/// C'est la règle §5-3 du lot : `priority`, `domain`, `label` et `enabled` se mettent à
/// jour sans rien perturber, mais toucher `relays[]`, le mécanisme ou les Things
/// **reconstruit l'adaptateur** — contacts ouverts, verrous réarmés à froid. Un
/// installateur qui change un relais ne doit pas découvrir après coup que sa PAC est
/// bloquée un quart d'heure.
class ImpactSaveBar extends StatelessWidget {
final LoadConfigProvider provider;
const ImpactSaveBar({super.key, required this.provider});
@override
Widget build(BuildContext context) {
final impacts = provider.pendingImpact();
final warnings = impacts.where((i) => i.needsWarning).toList();
final pending = provider.saveState == LoadSaveState.pending;
return Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: EtmTokens.surfaceOf(context),
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
border: Border.all(
color: warnings.isEmpty
? EtmTokens.lineOf(context)
: EtmTokens.orange.withValues(alpha: 0.6),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (warnings.isNotEmpty) ...[
for (final w in warnings) _ImpactLine(impact: w),
const SizedBox(height: 8),
],
Row(children: [
Expanded(
child: Text(
warnings.isEmpty
? 'Modifications en attente — rien n\'est parti vers la box.'
: 'À confirmer : l\'enregistrement touche au matériel.',
style: TextStyle(
fontSize: 12, color: EtmTokens.mutedOf(context), height: 1.3),
),
),
TextButton(
onPressed: pending ? null : provider.discardDraft,
child: const Text('Annuler'),
),
const SizedBox(width: 4),
FilledButton(
onPressed: pending ? null : provider.save,
child: Text(pending ? 'Envoi…' : 'Enregistrer'),
),
]),
if (provider.saveState == LoadSaveState.rejected)
_RejectionNote(provider: provider),
],
),
);
}
}
/// Une ligne d'avertissement d'impact, qui nomme **ce qui** l'a déclenché.
///
/// « Vous avez changé l'ordre des contacteurs » est actionnable ; « la charge sera
/// reconstruite » ne l'est pas.
class _ImpactLine extends StatelessWidget {
final LoadImpact impact;
const _ImpactLine({required this.impact});
static const _fieldNames = {
'relays': 'la liste des contacteurs',
'sgReady': 'l\'encodage SG-Ready',
'minOnS': 'la temporisation de marche',
'minOffS': 'la temporisation d\'arrêt',
'maxPowerW': 'la puissance nominale',
'powerLevels': 'les paliers déclarés',
'adapter': 'le mécanisme',
'mode': 'le mode',
};
@override
Widget build(BuildContext context) {
final quoi = impact.changedFields
.map((f) => _fieldNames[f] ?? f)
.join(', ');
final rearm = impact.rearmSeconds;
final duree = rearm == null
? ''
: rearm >= 60
? ' pendant ${(rearm / 60).round()} min'
: ' pendant $rearm s';
final texte = switch (impact.impact) {
LoadChangeImpact.rebuild =>
'« ${impact.label} » — vous avez modifié $quoi. La charge sera reconstruite : '
'ses contacts passent par l\'état sûr, puis son verrou se réarme$duree.',
LoadChangeImpact.removed =>
'« ${impact.label} » quitte l\'arbitrage. Elle est amenée à son état sûr '
'(contacts ouverts) avant retrait.',
_ => '',
};
if (texte.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.warning_amber_rounded, size: 16, color: EtmTokens.orange),
const SizedBox(width: 7),
Expanded(
child: Text(texte,
style: TextStyle(
fontSize: 12,
height: 1.35,
color: EtmTokens.inkOf(context))),
),
],
),
);
}
}
/// Refus de la box. **Ne rien inventer** : `SetLoadConfig` rend un code sans motif, et
/// la règle enfreinte reste au journal de la box. On affiche le refus et on y renvoie.
class _RejectionNote extends StatelessWidget {
final LoadConfigProvider provider;
const _RejectionNote({required this.provider});
@override
Widget build(BuildContext context) {
final conflit = provider.conflict;
final err = provider.saveError;
return Padding(
padding: const EdgeInsets.only(top: 10),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(10),
decoration: BoxDecoration(
color: EtmTokens.danger.withValues(alpha: 0.09),
borderRadius: BorderRadius.circular(10),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
conflit != null
? 'Deux charges revendiquent le même appareil.'
: 'La box a refusé l\'enregistrement${err == null ? '' : ' ($err)'}.',
style: const TextStyle(
fontSize: 12.5,
fontWeight: FontWeight.w700,
color: EtmTokens.danger),
),
const SizedBox(height: 4),
Text(
conflit != null
// LM-201 : deux charges sur un canal unique se contrediraient, et le
// budget compterait la même puissance deux fois. Ce n'est pas une
// limite technique — c'est le modèle.
? 'Un appareil ne peut appartenir qu\'à une seule charge : deux '
'charges sur un même canal de commande se contrediraient.'
: 'Le refus porte sur l\'ensemble : rien n\'a été modifié. La règle '
'enfreinte et le libellé en cause sont dans le journal de la box '
'(journalctl -u nymead).',
style: TextStyle(
fontSize: 11.5,
height: 1.35,
color: EtmTokens.mutedOf(context)),
),
],
),
),
);
}
}
class _EmptyNote extends StatelessWidget {
final String? error;
const _EmptyNote({this.error});
@override
Widget build(BuildContext context) {
final muted = EtmTokens.mutedOf(context);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 18),
child: Text(
error == null
? 'Aucune charge pilotable déclarée sur cette box.'
: 'Configuration illisible : $error',
style: TextStyle(fontSize: 12.5, color: muted, height: 1.35),
),
);
}
}
/// 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),
// Le budget n'est PAS divisé entre les bornes, et le dire évite une déduction
// fausse dès qu'il y en a deux à l'écran.
if (bornes.length > 1)
Padding(
padding: const EdgeInsets.only(bottom: 10),
child: Container(
width: double.infinity,
padding: const EdgeInsets.all(11),
decoration: BoxDecoration(
color: EtmTokens.solar.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
),
child: Text(
'Deux bornes déclarées — le budget ne se partage pas entre elles. '
'Aujourd\'hui chacune reçoit l\'enveloppe entière, et ce sont le compteur '
'puis la protection de surcharge qui rattrapent. Une seule démarrera '
'quand l\'arbitrage prendra la recharge en charge.',
style: TextStyle(
fontSize: 11.5, height: 1.35, color: EtmTokens.inkOf(context)),
),
),
),
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),
),
// Les valeurs de RUNTIME, quand la box en publie. Read-only par nature :
// elles décrivent l'instant, elles ne se règlent pas.
_RuntimeBorne(thingId: b.id),
],
),
),
],
);
}
}
/// Petite flèche de rang, à l'intérieur d'un domaine.
///
/// Le glisser-déposer appartient au domaine ; à l'intérieur, deux charges se réordonnent
/// par ces flèches. Rendre les deux niveaux glissables est impossible — les listes
/// réordonnables imbriquées se disputent le geste, et c'est l'extérieure qui gagne.
class _FlecheRang extends StatelessWidget {
final IconData icone;
final bool actif;
final VoidCallback onTap;
const _FlecheRang({
super.key,
required this.icone,
required this.actif,
required this.onTap,
});
@override
Widget build(BuildContext context) {
final muted = EtmTokens.mutedOf(context);
return InkWell(
onTap: actif ? onTap : null,
borderRadius: BorderRadius.circular(4),
child: Icon(icone,
size: 18,
color: actif ? muted : muted.withValues(alpha: 0.3)),
);
}
}
/// Ce que la box publie d'une borne **à l'instant** — jamais un réglage.
///
/// ## Deux natures derrière un même mot
///
/// Le « minimum » d'une borne n'est pas le **plancher déclaré** d'une charge modulable.
/// Celui-là est une propriété de l'appareil, saisie une fois ; celui-ci vaut
/// `max(borne, voiture)` et **dépend de la voiture branchée** — il change quand on change
/// de véhicule. Les ranger au même endroit, ou les rendre modifiables de la même façon,
/// ferait croire qu'on peut régler ce qui se constate.
///
/// De même, le plancher effectif dépend du **nombre de phases visé** — de l'ordre de
/// 1,4 kW en monophasé contre 4,1 kW en triphasé. La valeur publiée correspond à ce nombre
/// de phases, pas au courant du moment. On l'affiche **telle que la box la donne** : la
/// recalculer ou la convertir côté app est précisément le genre de miroir qui finit par
/// diverger, et on en a déjà eu un avec la combinatoire des paliers.
///
/// Tant que la recharge n'est pas entrée dans l'arbitrage, la box ne publie rien pour la
/// borne et ce bloc reste muet — ce qui est la bonne réponse, pas un trou.
class _RuntimeBorne extends StatelessWidget {
final String thingId;
const _RuntimeBorne({required this.thingId});
@override
Widget build(BuildContext context) {
final live = context.watch<LoadTelemetryProvider>().entryFor(thingId);
final mech = live?.mechanism;
if (mech == null) return const SizedBox.shrink();
final muted = EtmTokens.mutedOf(context);
final lignes = <String>[];
if (mech.phaseCount != null) lignes.add('${mech.phaseCount} phase(s)');
if (mech.currentA != null) {
lignes.add('${mech.currentA!.toStringAsFixed(1)} A');
}
if (mech.minPowerW != null) {
lignes.add('minimum ${mech.minPowerW!.round()} W');
}
if (mech.pluggedIn != null) {
lignes.add(mech.pluggedIn! ? 'véhicule branché' : 'aucun véhicule');
}
if (lignes.isEmpty) return const SizedBox.shrink();
return Padding(
padding: const EdgeInsets.only(top: 6),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.sensors_rounded, size: 13, color: muted),
const SizedBox(width: 5),
Expanded(
child: Text(
'${lignes.join(' · ')} — mesuré, pas réglé : le minimum d\'une borne vaut '
'max(borne, voiture) et suit le véhicule branché.',
style: TextStyle(fontSize: 11, height: 1.3, color: muted),
),
),
],
),
);
}
}