etm-powersync-app/lib/widgets/thing_picker.dart
Patrick Schurig 3d2aab2b42 fix(config): les écrans suivent enfin la maquette — sélecteurs, table d'états, budget, borne
Je n'avais pas ouvert docs/mockups/ : les écrans ont été construits depuis le texte du brief
seul, et l'écart est franc. Les maquettes du 2026-08-25 font autorité, elles sont désormais
suivies.

ÉCRANS DE MÉCANISME, refaits :
- sélecteur de Things PAR NOM, filtré sur l'interface nymea (power / etmvariableload).
  Taper un UUID au doigt est une erreur garantie ET muette : la box accepte un UUID valide
  qui ne désigne rien, et la charge ne commande jamais rien. Le sélecteur grise les Things
  déjà pris — la box refuse en bloc un même Thing sur deux lignes — et signale en rouge un
  ThingId configuré qui n'existe plus sur la box ;
- routeur de relais : ajout/retrait de contacteurs, puissance par ligne, et les paliers
  atteignables AFFICHÉS. Ils sont déduits (2^N combinaisons, doublons écartés), jamais
  enregistrés — la maquette est explicite là-dessus, ma note précédente disant l'inverse
  était une sur-interprétation du brief ;
- SG-Ready : SG1/SG2 se choisissent EN HAUT, une fois, et la table des quatre états devient
  éditable — ce qui s'y coche est fermé/ouvert, et c'est ça qui devient relays[]. Les
  étiquettes SG1/SG2 sont une convention de l'écran (SG1 = fermé en état 1, SG2 = en état 3)
  et elles TOMBENT sur un encodage non standard, où les contacts s'affichent tels quels ;
- modulable : plafond requis > 0, et l'avertissement que changer d'appareil crée une autre
  charge puisque l'identifiant EST le ThingId ;
- temporisations minOnS/minOffS (et minStateHoldS pour la PAC) en pas de 30/60 s ;
- « Compteur dédié » présent et GRISÉ, avec sa raison : aucun champ n'existe côté box, et
  inventer meterThingId ferait rejeter l'enregistrement de TOUTES les charges ;
- « Inclure dans l'arbitrage » avec rang et domaine, et ce que la désactivation fait
  vraiment selon le mécanisme ;
- validation AVANT envoi : relays[] vide, powerW ≤ 0, Thing en double, état 2 absent,
  maxPowerW ≤ 0. SetLoadConfig refuse en bloc et sans motif — laisser partir une config
  invalide condamne l'installateur à un refus muet qui perd tout l'appel.

LISTE :
- l'en-tête d'arbitrage et le tableau de budget, PERDUS à la fusion, sont rétablis. Sans
  eux « Alloué 3 000 W » ne se rattache à rien ;
- le rang passe en pastille près de la poignée, et le sous-titre devient lisible :
  « Routeur de relais · 3 relais · min ON 60 s » au lieu de « Routeur de relais · rang 1 » ;
- section « Recharge véhicule » : la borne n'est pas une LoadConfig, elle n'apparaissait donc
  NULLE PART — ce qu'un installateur qui vient de la régler lit comme une perte.

MENU : l'entrée « Ordre de service des charges » est retirée ; elle pointait sur une
redirection, donc sur le même écran sous deux noms.

Tests 80/80, analyze 0 erreur.

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

215 lines
8.0 KiB
Dart

import 'package:flutter/material.dart';
import '../models/load_config_entry.dart';
import '../models/nymea_models.dart';
import '../services/nymea_service.dart';
import '../theme/etm_tokens.dart';
/// Choisit un Thing **par son nom**, filtré sur une interface nymea.
///
/// ## Pourquoi un sélecteur, et pas un champ de saisie
///
/// La configuration d'une charge ne contient que des `ThingId` — des UUID. Les faire taper
/// au doigt est une erreur garantie, et une erreur **muette** : la box accepte un UUID
/// syntaxiquement valide qui ne désigne rien, et la charge ne commandera jamais rien.
///
/// Le sélecteur résout **nom → ThingId** et n'ajoute **aucun champ au contrat** : ce qui
/// part reste `{thingId, powerW}`. Il est purement d'interface.
///
/// ## Le filtre par interface est ce qui rend la liste utilisable
///
/// Un routeur de relais commande des Things portant l'interface `power` ; une charge
/// modulable un Thing portant `etmvariableload`. Sans filtre, l'installateur choisit dans
/// une liste où figurent l'onduleur, la batterie et le compteur — et rien ne l'avertirait
/// de son erreur avant le premier cycle d'arbitrage.
class ThingPicker extends StatelessWidget {
/// Interface nymea exigée (`power`, `etmvariableload`, …).
final String interfaceRequise;
/// ThingId actuellement choisi, forme `{uuid}` ou nue. `null` = rien de choisi.
final String? valeur;
/// Appelé avec le ThingId **dans la forme rendue par la box**.
final ValueChanged<String> onChoisi;
/// Texte affiché quand rien n'est choisi.
final String placeholder;
final NymeaService service;
/// ThingIds déjà pris par d'autres lignes — grisés, parce que la box refuse **en bloc**
/// une configuration où un même Thing apparaît deux fois (LM-302-b / ECS-110-b).
final Set<String> dejaPris;
const ThingPicker({
super.key,
required this.service,
required this.interfaceRequise,
required this.valeur,
required this.onChoisi,
this.placeholder = 'Choisir un appareil…',
this.dejaPris = const {},
});
/// Things portant [interfaceRequise], triés par nom.
List<NymeaThing> _candidats() {
final classesOk = {
for (final c in service.thingClasses)
if (c.interfaces.contains(interfaceRequise)) c.id
};
final out = [
for (final t in service.things)
if (classesOk.contains(t.thingClassId)) t
]..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
return out;
}
/// Nom lisible du Thing choisi, ou `null` s'il n'est pas (ou plus) sur la box.
String? nomDe(String? id) {
if (id == null) return null;
for (final t in service.things) {
if (normalizeThingId(t.id) == normalizeThingId(id)) return t.name;
}
return null;
}
@override
Widget build(BuildContext context) {
final candidats = _candidats();
final nom = nomDe(valeur);
final muted = EtmTokens.mutedOf(context);
// Un Thing configuré qui a disparu de la box est un état À DIRE : la charge le
// commande toujours dans la config, mais plus rien ne répond en face.
final orphelin = valeur != null && valeur!.isNotEmpty && nom == null;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
InkWell(
borderRadius: BorderRadius.circular(10),
onTap: candidats.isEmpty ? null : () => _ouvrir(context, candidats),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: orphelin
? EtmTokens.danger.withValues(alpha: 0.6)
: EtmTokens.lineOf(context)),
),
child: Row(children: [
Expanded(
child: Text(
nom ?? (orphelin ? 'Appareil absent de la box' : placeholder),
maxLines: 1,
overflow: TextOverflow.ellipsis,
style: TextStyle(
fontSize: 13.5,
color: nom == null ? muted : EtmTokens.inkOf(context),
fontWeight: nom == null ? FontWeight.w400 : FontWeight.w600,
),
),
),
Icon(Icons.expand_more_rounded, size: 20, color: muted),
]),
),
),
if (orphelin)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'Ce ThingId est dans la configuration mais n\'existe plus sur la box '
'(${valeur!}). La charge ne commandera rien tant qu\'il n\'est pas remplacé.',
style: const TextStyle(
fontSize: 11, height: 1.3, color: EtmTokens.danger),
),
)
else if (candidats.isEmpty)
Padding(
padding: const EdgeInsets.only(top: 4),
child: Text(
'Aucun appareil « $interfaceRequise » sur cette box. Il faut d\'abord '
'l\'appairer dans les Things.',
style: TextStyle(fontSize: 11, height: 1.3, color: muted),
),
),
],
);
}
void _ouvrir(BuildContext context, List<NymeaThing> candidats) {
showModalBottomSheet<void>(
context: context,
backgroundColor: EtmTokens.surfaceOf(context),
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (c) => SafeArea(
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.fromLTRB(18, 16, 18, 4),
child: Text('Appareils « $interfaceRequise »',
style: TextStyle(
fontSize: 15,
fontWeight: FontWeight.w700,
color: EtmTokens.inkOf(c))),
),
Padding(
padding: const EdgeInsets.fromLTRB(18, 0, 18, 10),
child: Text(
'Choisis par nom ; l\'app enregistre son identifiant.',
style:
TextStyle(fontSize: 11.5, color: EtmTokens.mutedOf(c)),
),
),
Flexible(
child: ListView.builder(
shrinkWrap: true,
itemCount: candidats.length,
itemBuilder: (_, i) {
final t = candidats[i];
final pris = dejaPris.contains(normalizeThingId(t.id)) &&
normalizeThingId(t.id) != normalizeThingId(valeur ?? '');
final choisi = normalizeThingId(t.id) == normalizeThingId(valeur ?? '');
return ListTile(
enabled: !pris,
dense: true,
leading: Icon(
choisi
? Icons.radio_button_checked
: Icons.radio_button_unchecked,
size: 20,
color: choisi ? EtmTokens.brand : EtmTokens.mutedOf(c),
),
title: Text(t.name,
style: TextStyle(
fontSize: 14,
color: pris
? EtmTokens.mutedOf(c)
: EtmTokens.inkOf(c))),
subtitle: pris
? const Text('déjà utilisé par une autre ligne',
style: TextStyle(fontSize: 11))
: null,
onTap: pris
? null
: () {
onChoisi(t.id);
Navigator.of(c).pop();
},
);
},
),
),
const SizedBox(height: 8),
],
),
),
);
}
}