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 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 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 _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 candidats) { showModalBottomSheet( 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), ], ), ), ); } }