import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../models/nymea_models.dart'; import '../../providers/installer_mode_provider.dart'; import '../../services/nymea_service.dart'; import '../../theme/etm_tokens.dart'; /// **Lot C** — ajout d'un thing par **DISCOVERY** (cible : Eastron SDM). /// /// Flux : form discoveryParams (SDM = `slaveAddress`, AUCUN sélecteur de master, /// scan global) → `DiscoverThings` → liste des descriptors (title + adresse + /// master résolu depuis les params) → `AddThing(thingDescriptorId)`. /// /// Discovery → `setupMethod JustAdd` uniquement (pas de pairing → lot D). class ThingDiscoveryScreen extends StatefulWidget { final NymeaThingClass cls; const ThingDiscoveryScreen({super.key, required this.cls}); @override State createState() => _ThingDiscoveryScreenState(); } class _ThingDiscoveryScreenState extends State { // discoveryParams : paramTypeId -> controller (rendu générique des // discoveryParamTypes ; pour les SDM = un seul champ slaveAddress). final Map _ctl = {}; bool _scanning = false; bool _scanned = false; String? _error; List _descriptors = const []; final Set _addedIds = {}; // descriptors ajoutés dans cette session // Résolution d'affichage : uuid master -> port série (depuis le lot Modbus). Map _masterPort = const {}; // paramTypeId -> name (depuis paramTypes de la classe) pour lire le descriptor. late final Map _paramName = { for (final p in widget.cls.paramTypes) (p['id'] as String? ?? ''): (p['name'] as String? ?? ''), }; @override void initState() { super.initState(); for (final p in widget.cls.discoveryParamTypes) { final id = p['id'] as String? ?? ''; _ctl[id] = TextEditingController(text: '${p['defaultValue'] ?? ''}'); } WidgetsBinding.instance.addPostFrameCallback((_) async { final masters = await context.read().getModbusRtuMasters(); if (!mounted) return; setState(() => _masterPort = {for (final m in masters) m.modbusUuid: m.serialPort}); }); } @override void dispose() { for (final c in _ctl.values) { c.dispose(); } super.dispose(); } // ── Discovery ────────────────────────────────────────────────────────────── Future _scan() async { final params = >[]; for (final p in widget.cls.discoveryParamTypes) { final id = p['id'] as String? ?? ''; final txt = _ctl[id]?.text.trim() ?? ''; if (txt.isEmpty) continue; final isInt = (p['type'] == 'Int' || p['type'] == 'Uint'); params.add({'paramTypeId': id, 'value': isInt ? int.tryParse(txt) ?? 0 : txt}); } setState(() { _scanning = true; _error = null; }); final res = await context.read().discoverThings(widget.cls.id, params); if (!mounted) return; setState(() { _scanning = false; _scanned = true; _error = res.error; _descriptors = res.descriptors; }); } Future _add(ThingDescriptor d) async { final name = await _askName(d); if (name == null || !mounted) return; final err = await context.read().addThingFromDescriptor( thingClassId: widget.cls.id, thingDescriptorId: d.id, name: name, ); if (!mounted) return; final messenger = ScaffoldMessenger.of(context); if (err == null) { setState(() => _addedIds.add(d.id)); messenger.showSnackBar(SnackBar(content: Text('« $name » ajouté.'))); } else { messenger.showSnackBar( SnackBar(content: Text(err), backgroundColor: EtmTokens.danger)); } } Future _askName(ThingDescriptor d) { final ctl = TextEditingController( text: d.title.isNotEmpty ? d.title : widget.cls.displayName); return showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Nom de l\'appareil'), content: TextField( controller: ctl, autofocus: true, decoration: const InputDecoration(hintText: 'Nom'), ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx), child: const Text('Annuler')), TextButton( onPressed: () { final n = ctl.text.trim(); if (n.isNotEmpty) Navigator.pop(ctx, n); }, child: const Text('Ajouter'), ), ], ), ); } // ── Résolution descriptor (⚠️ à revalider sur SDM réel : noms/forme params) ─ String _descMaster(ThingDescriptor d) { for (final entry in _paramName.entries) { final n = entry.value.toLowerCase(); if (n.contains('master') || n.contains('modbus')) { final uuid = '${d.paramValue(entry.key) ?? ''}'; if (uuid.isEmpty) return '—'; return _masterPort[uuid] ?? uuid; // port série si connu, sinon uuid } } return '—'; } String? _descSlave(ThingDescriptor d) { for (final entry in _paramName.entries) { if (entry.value.toLowerCase().contains('slave')) { final v = d.paramValue(entry.key); return v == null ? null : '$v'; } } return null; } @override Widget build(BuildContext context) { final installer = context.watch().isUnlocked; if (!installer) return const _Locked(); return Scaffold( backgroundColor: EtmTokens.bgOf(context), appBar: AppBar( title: Text('Découvrir · ${widget.cls.displayName}', overflow: TextOverflow.ellipsis), backgroundColor: EtmTokens.surfaceOf(context), foregroundColor: EtmTokens.inkOf(context), elevation: 0, ), body: ListView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), children: [ // Form discovery (générique sur discoveryParamTypes). for (final p in widget.cls.discoveryParamTypes) _ParamField( label: _paramLabel(p), controller: _ctl[p['id'] as String? ?? '']!, isInt: p['type'] == 'Int' || p['type'] == 'Uint', ), const SizedBox(height: 4), Text( 'Le scan interroge tous les masters RTU configurés (pas de sélection ' 'de master). Le master répondant apparaît dans chaque résultat.', style: EtmTokens.sans( size: 11.5, color: EtmTokens.faintOf(context), height: 1.3), ), const SizedBox(height: 14), SizedBox( width: double.infinity, child: FilledButton.icon( style: FilledButton.styleFrom( backgroundColor: EtmTokens.brand, padding: const EdgeInsets.symmetric(vertical: 14), ), onPressed: _scanning ? null : _scan, icon: _scanning ? const SizedBox( width: 18, height: 18, child: CircularProgressIndicator( strokeWidth: 2, color: Colors.white)) : const Icon(Icons.search_rounded, color: Colors.white), label: Text(_scanning ? 'Recherche…' : 'Lancer la recherche', style: EtmTokens.sans( size: 14, weight: FontWeight.w700, color: Colors.white)), ), ), const SizedBox(height: 18), ..._results(), ], ), ); } List _results() { if (!_scanned) return const []; if (_error != null) { return [_Banner(_error!, error: true)]; } if (_descriptors.isEmpty) { // 0 descriptor + NoError = aucun appareil (distinct d'une erreur). return [ _Banner( 'Aucun appareil trouvé. Vérifiez le câblage du bus, l\'adresse Modbus ' 'et que le master RTU est connecté.', ), ]; } return [ Text('${_descriptors.length} appareil(s) trouvé(s)', style: EtmTokens.sans( size: 13, weight: FontWeight.w700, color: EtmTokens.inkOf(context))), const SizedBox(height: 10), for (final d in _descriptors) _DescriptorCard( descriptor: d, master: _descMaster(d), slave: _descSlave(d), // déjà ajouté (o:thingId présent) OU ajouté dans cette session added: d.alreadyAdded || _addedIds.contains(d.id), onAdd: () => _add(d), ), ]; } String _paramLabel(Map p) { final dn = p['displayName'] as String?; if (dn != null && dn.isNotEmpty) return dn; final n = (p['name'] as String? ?? '').toLowerCase(); return n == 'slaveaddress' ? 'Adresse Modbus (1–247)' : (p['name'] as String? ?? 'Paramètre'); } } // ═══════════════════════════════════════════════════════════════════════════ class _ParamField extends StatelessWidget { final String label; final TextEditingController controller; final bool isInt; const _ParamField( {required this.label, required this.controller, required this.isInt}); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: 12), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: EtmTokens.sans( size: 12, weight: FontWeight.w600, color: EtmTokens.mutedOf(context))), const SizedBox(height: 6), TextField( controller: controller, keyboardType: isInt ? TextInputType.number : TextInputType.text, inputFormatters: isInt ? [FilteringTextInputFormatter.digitsOnly] : null, style: EtmTokens.mono(size: 14, color: EtmTokens.inkOf(context)), decoration: InputDecoration( isDense: true, filled: true, fillColor: EtmTokens.bg2Of(context), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), border: OutlineInputBorder( borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), borderSide: BorderSide.none, ), ), ), ], ), ); } } class _DescriptorCard extends StatelessWidget { final ThingDescriptor descriptor; final String master; final String? slave; final bool added; final VoidCallback onAdd; const _DescriptorCard({ required this.descriptor, required this.master, required this.slave, required this.added, required this.onAdd, }); @override Widget build(BuildContext context) { // ⚠️ REVALIDER SDM réel : title/description peuvent être vides ou génériques. final title = descriptor.title.isNotEmpty ? descriptor.title : descriptor.description.isNotEmpty ? descriptor.description : 'Appareil'; return Opacity( opacity: added ? 0.55 : 1, child: Container( margin: const EdgeInsets.only(bottom: 10), padding: const EdgeInsets.all(13), decoration: BoxDecoration( color: EtmTokens.surfaceOf(context), borderRadius: BorderRadius.circular(EtmTokens.radiusCard), boxShadow: EtmTokens.cardShadowOf(context), ), child: Row( children: [ Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Flexible( child: Text(title, overflow: TextOverflow.ellipsis, style: EtmTokens.sans( size: 14, weight: FontWeight.w700, color: EtmTokens.inkOf(context))), ), if (added) ...[ const SizedBox(width: 8), _Badge('déjà ajouté'), ], ], ), const SizedBox(height: 3), Text( [ if (slave != null) 'addr $slave', 'master $master', ].join(' · '), style: EtmTokens.mono( size: 11.5, color: EtmTokens.mutedOf(context)), ), ], ), ), if (!added) FilledButton( style: FilledButton.styleFrom( backgroundColor: EtmTokens.brand, padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 8), ), onPressed: onAdd, child: Text('Ajouter', style: EtmTokens.sans( size: 13, weight: FontWeight.w700, color: Colors.white)), ), ], ), ), ); } } class _Badge extends StatelessWidget { final String text; const _Badge(this.text); @override Widget build(BuildContext context) => Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: EtmTokens.eco.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(EtmTokens.radiusPill), ), child: Text(text, style: EtmTokens.sans( size: 10.5, weight: FontWeight.w700, color: EtmTokens.eco)), ); } class _Banner extends StatelessWidget { final String text; final bool error; const _Banner(this.text, {this.error = false}); @override Widget build(BuildContext context) { final c = error ? EtmTokens.danger : EtmTokens.brand; return Container( padding: const EdgeInsets.all(13), decoration: BoxDecoration( color: c.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), border: Border.all(color: c.withValues(alpha: 0.25)), ), child: Row( children: [ Icon(error ? Icons.error_outline_rounded : Icons.info_outline_rounded, size: 18, color: c), const SizedBox(width: 10), Expanded( child: Text(text, style: EtmTokens.sans( size: 12.5, color: EtmTokens.inkOf(context), height: 1.3)), ), ], ), ); } } class _Locked extends StatelessWidget { const _Locked(); @override Widget build(BuildContext context) => Scaffold( backgroundColor: EtmTokens.bgOf(context), appBar: AppBar( title: const Text('Découvrir'), backgroundColor: EtmTokens.surfaceOf(context), foregroundColor: EtmTokens.inkOf(context), elevation: 0, ), body: Center( child: Text('Réservé au mode installateur', style: EtmTokens.sans(size: 14, color: EtmTokens.mutedOf(context))), ), ); }