import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../models/nymea_models.dart'; import '../../models/thing_category.dart'; import '../../providers/installer_mode_provider.dart'; import '../../services/nymea_service.dart'; import '../../theme/etm_tokens.dart'; import 'thing_discovery_screen.dart'; /// Écran **Ajouter un thing** (LECTURE SEULE) — parcourir les thing classes que /// CE nymea connaît déjà (`GetThingClasses` + `GetVendors`), avec filtres. /// /// Aucun `AddThing`/`DiscoverThings`/`PairThing` ici (lots B/C). Catégories de /// type **dérivées des interfaces réelles** (thing_category.dart), pas d'enum /// inventé. Filtrage 100 % app-side sur les données déjà chargées. class ThingCatalogScreen extends StatefulWidget { const ThingCatalogScreen({super.key}); @override State createState() => _ThingCatalogScreenState(); } class _ThingCatalogScreenState extends State { bool _loading = true; List _classes = const []; Map _vendorById = const {}; // Filtres (combinables). String? _vendor; // displayName fabricant ThingCategory? _category; String _search = ''; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) => _load()); } Future _load() async { if (!mounted) return; setState(() => _loading = true); final svc = context.read(); final classes = await svc.getAllThingClasses(); final vendors = await svc.getVendors(); if (!mounted) return; setState(() { _classes = classes; _vendorById = {for (final v in vendors) v.id: v}; _loading = false; }); } String _vendorName(NymeaThingClass c) => _vendorById[c.vendorId]?.displayName ?? '(inconnu)'; /// Fabricants réellement présents (dédupliqués par displayName). List get _vendorOptions { final set = _classes.map(_vendorName).toSet().toList()..sort(); return set; } /// Catégories réellement présentes, dans l'ordre de l'enum. List get _categoryOptions { final present = _classes.map((c) => c.category).toSet(); return ThingCategory.values.where(present.contains).toList(); } List get _filtered { final q = _norm(_search); return _classes.where((c) { if (_vendor != null && _vendorName(c) != _vendor) return false; if (_category != null && c.category != _category) return false; if (q.isNotEmpty && !_norm(c.displayName).contains(q)) return false; return true; }).toList() ..sort((a, b) => a.displayName.compareTo(b.displayName)); } @override Widget build(BuildContext context) { final installer = context.watch().isUnlocked; if (!installer) return const _LockedScaffold(); final filtered = _filtered; // Regroupement par catégorie pour l'affichage. final groups = >{}; for (final c in filtered) { groups.putIfAbsent(c.category, () => []).add(c); } final orderedCats = ThingCategory.values.where(groups.containsKey).toList(); return Scaffold( backgroundColor: EtmTokens.bgOf(context), appBar: AppBar( title: const Text('Ajouter un thing'), backgroundColor: EtmTokens.surfaceOf(context), foregroundColor: EtmTokens.inkOf(context), elevation: 0, actions: [ IconButton( tooltip: 'Rafraîchir', icon: const Icon(Icons.refresh_rounded), onPressed: _load, ), ], ), body: _loading ? const Center(child: CircularProgressIndicator()) : Column( children: [ _FilterBar( search: _search, onSearch: (v) => setState(() => _search = v), vendor: _vendor, vendors: _vendorOptions, onVendor: (v) => setState(() => _vendor = v), category: _category, categories: _categoryOptions, onCategory: (c) => setState(() => _category = c), total: _classes.length, shown: filtered.length, ), Expanded( child: filtered.isEmpty ? const _EmptyResults() : ListView( padding: const EdgeInsets.fromLTRB(16, 4, 16, 24), children: [ for (final cat in orderedCats) ...[ _GroupHeader(categoryInfoMap[cat]!), for (final c in groups[cat]!) _ClassTile( cls: c, vendorName: _vendorName(c), onTap: () => Navigator.of(context).push( MaterialPageRoute( builder: (_) => _ClassDetailScreen( cls: c, vendorName: _vendorName(c)), ), ), ), const SizedBox(height: 8), ], ], ), ), ], ), ); } } // ── Normalisation recherche (casse + accents) ─────────────────────────────── String _norm(String s) { s = s.toLowerCase(); const map = { 'à': 'a', 'â': 'a', 'ä': 'a', 'é': 'e', 'è': 'e', 'ê': 'e', 'ë': 'e', 'î': 'i', 'ï': 'i', 'ô': 'o', 'ö': 'o', 'ù': 'u', 'û': 'u', 'ü': 'u', 'ç': 'c', }; map.forEach((k, v) => s = s.replaceAll(k, v)); return s; } // ═══════════════════════════════════════════════════════════════════════════ // Barre de filtres // ═══════════════════════════════════════════════════════════════════════════ class _FilterBar extends StatelessWidget { final String search; final ValueChanged onSearch; final String? vendor; final List vendors; final ValueChanged onVendor; final ThingCategory? category; final List categories; final ValueChanged onCategory; final int total; final int shown; const _FilterBar({ required this.search, required this.onSearch, required this.vendor, required this.vendors, required this.onVendor, required this.category, required this.categories, required this.onCategory, required this.total, required this.shown, }); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.fromLTRB(16, 10, 16, 8), color: EtmTokens.surfaceOf(context), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Expanded( child: TextField( onChanged: onSearch, style: EtmTokens.sans(size: 14, color: EtmTokens.inkOf(context)), decoration: InputDecoration( hintText: 'Rechercher un modèle…', prefixIcon: Icon(Icons.search_rounded, size: 20, color: EtmTokens.faintOf(context)), isDense: true, filled: true, fillColor: EtmTokens.bg2Of(context), contentPadding: const EdgeInsets.symmetric(vertical: 10), border: OutlineInputBorder( borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), borderSide: BorderSide.none, ), ), ), ), const SizedBox(width: 10), _VendorDropdown( vendor: vendor, vendors: vendors, onVendor: onVendor), ], ), const SizedBox(height: 8), SizedBox( height: 34, child: ListView( scrollDirection: Axis.horizontal, children: [ _TypeChip( label: 'Tous', selected: category == null, onTap: () => onCategory(null), ), for (final cat in categories) _TypeChip( label: categoryInfoMap[cat]!.label, icon: categoryInfoMap[cat]!.icon, selected: category == cat, onTap: () => onCategory(category == cat ? null : cat), ), ], ), ), const SizedBox(height: 4), Text('$shown / $total modèles', style: EtmTokens.sans(size: 11, color: EtmTokens.faintOf(context))), ], ), ); } } class _VendorDropdown extends StatelessWidget { final String? vendor; final List vendors; final ValueChanged onVendor; const _VendorDropdown( {required this.vendor, required this.vendors, required this.onVendor}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 10), decoration: BoxDecoration( color: EtmTokens.bg2Of(context), borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), ), child: DropdownButtonHideUnderline( child: DropdownButton( value: vendor, hint: Text('Fabricant', style: EtmTokens.sans(size: 13, color: EtmTokens.mutedOf(context))), isDense: true, items: [ DropdownMenuItem(value: null, child: Text('Tous fabricants')), for (final v in vendors) DropdownMenuItem(value: v, child: Text(v)), ], onChanged: onVendor, ), ), ); } } class _TypeChip extends StatelessWidget { final String label; final IconData? icon; final bool selected; final VoidCallback onTap; const _TypeChip( {required this.label, this.icon, required this.selected, required this.onTap}); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(right: 8), child: GestureDetector( onTap: onTap, child: Container( padding: const EdgeInsets.symmetric(horizontal: 12), alignment: Alignment.center, decoration: BoxDecoration( color: selected ? EtmTokens.brand : EtmTokens.bg2Of(context), borderRadius: BorderRadius.circular(EtmTokens.radiusPill), ), child: Row( children: [ if (icon != null) ...[ Icon(icon, size: 14, color: selected ? Colors.white : EtmTokens.mutedOf(context)), const SizedBox(width: 5), ], Text(label, style: EtmTokens.sans( size: 12.5, weight: FontWeight.w600, color: selected ? Colors.white : EtmTokens.mutedOf(context))), ], ), ), ), ); } } // ═══════════════════════════════════════════════════════════════════════════ // Liste // ═══════════════════════════════════════════════════════════════════════════ class _GroupHeader extends StatelessWidget { final ThingCategoryInfo info; const _GroupHeader(this.info); @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.fromLTRB(2, 14, 0, 8), child: Row( children: [ Icon(info.icon, size: 16, color: info.color), const SizedBox(width: 8), Text(info.label, style: EtmTokens.sans( size: 13, weight: FontWeight.w700, color: EtmTokens.inkOf(context))), ], ), ); } class _ClassTile extends StatelessWidget { final NymeaThingClass cls; final String vendorName; final VoidCallback onTap; const _ClassTile( {required this.cls, required this.vendorName, required this.onTap}); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, behavior: HitTestBehavior.opaque, child: Container( margin: const EdgeInsets.only(bottom: 8), 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: [ Text(cls.displayName, style: EtmTokens.sans( size: 14, weight: FontWeight.w700, color: EtmTokens.inkOf(context))), const SizedBox(height: 2), Text(vendorName, style: EtmTokens.sans( size: 12, color: EtmTokens.mutedOf(context))), ], ), ), Icon(Icons.chevron_right_rounded, color: EtmTokens.faintOf(context)), ], ), ), ); } } class _EmptyResults extends StatelessWidget { const _EmptyResults(); @override Widget build(BuildContext context) => Center( child: Padding( padding: const EdgeInsets.all(28), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.search_off_rounded, size: 36, color: EtmTokens.faintOf(context)), const SizedBox(height: 12), Text('Aucun modèle ne correspond', style: EtmTokens.sans( size: 14, color: EtmTokens.mutedOf(context))), ], ), ), ); } // ═══════════════════════════════════════════════════════════════════════════ // Détail (lecture seule) // ═══════════════════════════════════════════════════════════════════════════ class _ClassDetailScreen extends StatelessWidget { final NymeaThingClass cls; final String vendorName; const _ClassDetailScreen({required this.cls, required this.vendorName}); @override Widget build(BuildContext context) { final info = cls.categoryDetails; return Scaffold( backgroundColor: EtmTokens.bgOf(context), appBar: AppBar( title: Text(cls.displayName, overflow: TextOverflow.ellipsis), backgroundColor: EtmTokens.surfaceOf(context), foregroundColor: EtmTokens.inkOf(context), elevation: 0, ), body: ListView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 24), children: [ _Section('Identité', [ _Row('Modèle', cls.displayName), _Row('Fabricant', vendorName), _Row('Catégorie', info.label), _Row('Setup', _setupMethodLabel(cls.setupMethod)), if (cls.createMethods.isNotEmpty) _Row('Création', cls.createMethods.map(_createMethodLabel).join(', ')), ]), const SizedBox(height: 14), if (cls.interfaces.isNotEmpty) ...[ _SectionTitle('Interfaces (${cls.interfaces.length})'), const SizedBox(height: 8), Wrap( spacing: 6, runSpacing: 6, children: [for (final i in cls.interfaces) _Chip(i)], ), const SizedBox(height: 14), ], _SectionTitle('Paramètres requis (${cls.paramTypes.length})'), const SizedBox(height: 8), if (cls.paramTypes.isEmpty) Text('Aucun paramètre.', style: EtmTokens.sans(size: 13, color: EtmTokens.mutedOf(context))) else for (final p in cls.paramTypes) _ParamTile(p), const SizedBox(height: 20), // Lot C : discovery+JustAdd → bouton actif « Découvrir ». Sinon (ajout // direct lot B, ou pairing lot D) → désactivé « bientôt ». if (cls.isDiscoveryAddable) SizedBox( width: double.infinity, child: FilledButton.icon( style: FilledButton.styleFrom( backgroundColor: EtmTokens.brand, padding: const EdgeInsets.symmetric(vertical: 14), ), onPressed: () => Navigator.of(context).push( MaterialPageRoute( builder: (_) => ThingDiscoveryScreen(cls: cls), ), ), icon: const Icon(Icons.search_rounded, color: Colors.white), label: Text('Découvrir des appareils', style: EtmTokens.sans( size: 14, weight: FontWeight.w700, color: Colors.white)), ), ) else SizedBox( width: double.infinity, child: FilledButton( style: FilledButton.styleFrom( backgroundColor: EtmTokens.brand.withValues(alpha: 0.4), disabledBackgroundColor: EtmTokens.brand.withValues(alpha: 0.35), padding: const EdgeInsets.symmetric(vertical: 14), ), onPressed: null, // ajout direct (lot B) / pairing (lot D) child: Text('Ajouter · bientôt', style: EtmTokens.sans( size: 14, weight: FontWeight.w700, color: Colors.white)), ), ), ], ), ); } } class _ParamTile extends StatelessWidget { final Map p; const _ParamTile(this.p); @override Widget build(BuildContext context) { final name = (p['displayName'] as String?)?.isNotEmpty == true ? p['displayName'] as String : (p['name'] as String? ?? '—'); final type = (p['type'] as String?) ?? ''; final unit = _unitLabel((p['unit'] as String?) ?? ''); final def = p['defaultValue']; final readOnly = p['readOnly'] == true; final meta = [ if (type.isNotEmpty) type, if (unit.isNotEmpty) unit, if (def != null && '$def'.isNotEmpty && '$def' != 'null') 'défaut $def', if (readOnly) 'lecture seule', ].join(' · '); return Container( margin: const EdgeInsets.only(bottom: 8), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: EtmTokens.surfaceOf(context), borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), boxShadow: EtmTokens.cardShadowOf(context), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(name, style: EtmTokens.sans( size: 13.5, weight: FontWeight.w600, color: EtmTokens.inkOf(context))), if (meta.isNotEmpty) ...[ const SizedBox(height: 2), Text(meta, style: EtmTokens.mono( size: 11.5, color: EtmTokens.mutedOf(context))), ], ], ), ); } } // ── Petits composants détail ──────────────────────────────────────────────── class _Section extends StatelessWidget { final String title; final List rows; const _Section(this.title, this.rows); @override Widget build(BuildContext context) => Container( 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: [ Text(title, style: EtmTokens.sans( size: 12, weight: FontWeight.w700, color: EtmTokens.faintOf(context), letterSpacing: 0.5)), const SizedBox(height: 6), ...rows, ], ), ); } class _SectionTitle extends StatelessWidget { final String text; const _SectionTitle(this.text); @override Widget build(BuildContext context) => Text(text, style: EtmTokens.sans( size: 13, weight: FontWeight.w700, color: EtmTokens.inkOf(context))); } class _Row extends StatelessWidget { final String k; final String v; const _Row(this.k, this.v); @override Widget build(BuildContext context) => Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ SizedBox( width: 96, child: Text(k, style: EtmTokens.sans( size: 12.5, color: EtmTokens.mutedOf(context))), ), Expanded( child: Text(v, style: EtmTokens.sans( size: 13, weight: FontWeight.w600, color: EtmTokens.inkOf(context))), ), ], ), ); } class _Chip extends StatelessWidget { final String text; const _Chip(this.text); @override Widget build(BuildContext context) => Container( padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 5), decoration: BoxDecoration( color: EtmTokens.bg2Of(context), borderRadius: BorderRadius.circular(8), ), child: Text(text, style: EtmTokens.mono( size: 11.5, color: EtmTokens.mutedOf(context))), ); } class _LockedScaffold extends StatelessWidget { const _LockedScaffold(); @override Widget build(BuildContext context) => Scaffold( backgroundColor: EtmTokens.bgOf(context), appBar: AppBar( title: const Text('Ajouter un thing'), backgroundColor: EtmTokens.surfaceOf(context), foregroundColor: EtmTokens.inkOf(context), elevation: 0, ), body: Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.lock_outline_rounded, size: 40, color: EtmTokens.faintOf(context)), const SizedBox(height: 12), Text('Réservé au mode installateur', style: EtmTokens.sans( size: 14, color: EtmTokens.mutedOf(context))), ], ), ), ), ); } // ── Libellés d'enums (lecture seule) ──────────────────────────────────────── String _setupMethodLabel(String v) => switch (v) { 'SetupMethodJustAdd' => 'Ajout direct', 'SetupMethodDisplayPin' => 'PIN affiché sur l\'appareil', 'SetupMethodEnterPin' => 'Saisie d\'un PIN', 'SetupMethodPushButton' => 'Bouton physique', 'SetupMethodUserAndPassword' => 'Identifiant / mot de passe', 'SetupMethodOAuth' => 'OAuth (web)', '' => '—', _ => v, }; String _createMethodLabel(String v) => switch (v) { 'CreateMethodJustAdd' => 'Ajout manuel', 'CreateMethodDiscovery' => 'Découverte', 'CreateMethodAuto' => 'Automatique', _ => v, }; String _unitLabel(String v) => (v.isEmpty || v == 'UnitNone') ? '' : v.replaceFirst('Unit', '');