import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/nymea_models.dart'; import '../models/thing_category.dart'; import '../services/nymea_service.dart'; import '../theme/app_theme.dart'; import 'thing_detail_screen.dart'; class ThingsScreen extends StatefulWidget { const ThingsScreen({super.key}); @override State createState() => _ThingsScreenState(); } class _ThingsScreenState extends State { final Set _collapsed = {}; String _searchQuery = ''; bool _showSearch = false; @override Widget build(BuildContext context) { final service = context.watch(); // Garder tous les things avec un id valide (le filtre setupStatus était trop strict) final things = service.things.where((t) => t.id.isNotEmpty).toList(); final thingClasses = service.thingClasses; final filtered = _searchQuery.isEmpty ? things : things.where((t) => t.name.toLowerCase().contains(_searchQuery.toLowerCase())).toList(); final Map> grouped = {}; for (final thing in filtered) { final cls = _classFor(thing, thingClasses); final cat = cls?.category ?? ThingCategory.other; grouped.putIfAbsent(cat, () => []).add(thing); } const orderedCats = [ ThingCategory.energy, ThingCategory.solar, ThingCategory.battery, ThingCategory.evCharger, ThingCategory.hvac, ThingCategory.lighting, ThingCategory.sensors, ThingCategory.network, ThingCategory.notifications, ThingCategory.weather, ThingCategory.media, ThingCategory.other, ]; final cats = orderedCats.where((c) => grouped.containsKey(c)).toList(); return Scaffold( backgroundColor: AppTheme.backgroundGray, appBar: _buildAppBar(service, things.length), body: !service.isConnected ? _buildDisconnected() : (things.isEmpty && !service.thingsLoaded) ? _buildLoading() : things.isEmpty ? _buildEmpty() : cats.isEmpty // Classes pas encore chargées — afficher things en mode simple ? _buildThingsSimple(things, service) : ListView.builder( padding: const EdgeInsets.fromLTRB(16, 8, 16, 100), itemCount: cats.length, itemBuilder: (context, i) { final cat = cats[i]; return _CategorySection( info: categoryInfoMap[cat]!, things: grouped[cat]!, thingClasses: thingClasses, collapsed: _collapsed.contains(cat), onToggle: () => setState(() { if (_collapsed.contains(cat)) { _collapsed.remove(cat); } else { _collapsed.add(cat); } }), onTap: (t) => Navigator.push( context, MaterialPageRoute( builder: (_) => ThingDetailScreen(thing: t)), ), ); }, ), ); } AppBar _buildAppBar(NymeaService service, int count) { return AppBar( backgroundColor: AppTheme.backgroundGray, elevation: 0, title: _showSearch ? TextField( autofocus: true, decoration: const InputDecoration( hintText: 'Rechercher un appareil...', border: InputBorder.none, hintStyle: TextStyle(color: AppTheme.textLight), ), style: const TextStyle(color: AppTheme.textDark, fontSize: 18), onChanged: (v) => setState(() => _searchQuery = v), ) : Row(children: [ const Text('Things', style: TextStyle( fontWeight: FontWeight.bold, color: AppTheme.textDark, fontSize: 20)), const SizedBox(width: 8), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: AppTheme.primaryGreen.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(10), ), child: Text('$count', style: const TextStyle( fontSize: 12, fontWeight: FontWeight.bold, color: AppTheme.primaryGreen)), ), ]), actions: [ IconButton( icon: Icon(_showSearch ? Icons.close : Icons.search, color: AppTheme.textDark), onPressed: () => setState(() { _showSearch = !_showSearch; _searchQuery = ''; }), ), IconButton( icon: const Icon(Icons.refresh_rounded, color: AppTheme.textDark), onPressed: () => service.refresh(), ), ], ); } Widget _buildLoading() => const Center( child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ CircularProgressIndicator(color: AppTheme.primaryGreen), SizedBox(height: 16), Text('Chargement des appareils...', style: TextStyle(color: AppTheme.textLight)), ]), ); Widget _buildDisconnected() => Center( child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.cloud_off_rounded, size: 64, color: Colors.grey[400]), const SizedBox(height: 12), const Text('Non connecté à nymea', style: TextStyle(color: AppTheme.textLight, fontSize: 16)), ]), ); /// Affichage simple quand les ThingClasses ne sont pas encore chargées Widget _buildThingsSimple(List things, NymeaService service) { return ListView.builder( padding: const EdgeInsets.fromLTRB(16, 8, 16, 100), itemCount: things.length, itemBuilder: (context, i) { final t = things[i]; return Card( margin: const EdgeInsets.only(bottom: 8), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), child: ListTile( leading: Container( width: 40, height: 40, decoration: BoxDecoration( color: AppTheme.primaryGreen.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(10), ), child: const Icon(Icons.device_hub_rounded, color: AppTheme.primaryGreen, size: 22), ), title: Text(t.name, style: const TextStyle(fontWeight: FontWeight.w600, fontSize: 14)), subtitle: Text(t.setupStatus.replaceAll('ThingSetupStatus', ''), style: const TextStyle(fontSize: 12, color: AppTheme.textLight)), trailing: t.setupStatus == 'ThingSetupStatusComplete' ? Container( width: 8, height: 8, decoration: const BoxDecoration( color: AppTheme.primaryGreen, shape: BoxShape.circle, ), ) : null, ), ); }, ); } Widget _buildEmpty() => Center( child: Column(mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.search_off_rounded, size: 64, color: Colors.grey[400]), const SizedBox(height: 12), Text( _searchQuery.isEmpty ? 'Aucun appareil' : 'Aucun résultat pour "$_searchQuery"', style: const TextStyle(color: AppTheme.textLight, fontSize: 16), ), ]), ); NymeaThingClass? _classFor(NymeaThing thing, List classes) { try { return classes.firstWhere((c) => c.id == thing.thingClassId); } catch (_) { return null; } } } // ─── Section catégorie ──────────────────────────────────────────────────────── class _CategorySection extends StatelessWidget { final ThingCategoryInfo info; final List things; final List thingClasses; final bool collapsed; final VoidCallback onToggle; final void Function(NymeaThing) onTap; const _CategorySection({ required this.info, required this.things, required this.thingClasses, required this.collapsed, required this.onToggle, required this.onTap, }); NymeaThingClass? _cls(NymeaThing t) { try { return thingClasses.firstWhere((c) => c.id == t.thingClassId); } catch (_) { return null; } } @override Widget build(BuildContext context) { return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ // ── Header collapsable ───────────────────────────────────────────────── InkWell( onTap: onToggle, borderRadius: BorderRadius.circular(12), child: Padding( padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 4), child: Row(children: [ Container( width: 34, height: 34, decoration: BoxDecoration( color: info.color.withValues(alpha: 0.15), borderRadius: BorderRadius.circular(9), ), child: Icon(info.icon, color: info.color, size: 19), ), const SizedBox(width: 10), Expanded( child: Text(info.label, style: const TextStyle( fontWeight: FontWeight.w700, fontSize: 13, color: AppTheme.textDark, letterSpacing: 0.2)), ), Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2), decoration: BoxDecoration( color: info.color.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(10), ), child: Text('${things.length}', style: TextStyle( fontSize: 11, fontWeight: FontWeight.bold, color: info.color)), ), const SizedBox(width: 4), AnimatedRotation( turns: collapsed ? -0.25 : 0, duration: const Duration(milliseconds: 200), child: const Icon(Icons.expand_more, color: AppTheme.textLight, size: 20), ), ]), ), ), // ── Things ──────────────────────────────────────────────────────────── AnimatedCrossFade( duration: const Duration(milliseconds: 220), crossFadeState: collapsed ? CrossFadeState.showSecond : CrossFadeState.showFirst, firstChild: Column( children: [ ...things.map((t) => Padding( padding: const EdgeInsets.only(bottom: 10), child: ThingCard( thing: t, thingClass: _cls(t), categoryInfo: info, onTap: () => onTap(t), ), )), const SizedBox(height: 4), ], ), secondChild: const SizedBox.shrink(), ), ]); } } // ─── ThingCard ──────────────────────────────────────────────────────────────── class ThingCard extends StatelessWidget { final NymeaThing thing; final NymeaThingClass? thingClass; final ThingCategoryInfo categoryInfo; final VoidCallback onTap; const ThingCard({ super.key, required this.thing, required this.thingClass, required this.categoryInfo, required this.onTap, }); @override Widget build(BuildContext context) { final primaryType = thingClass?.primaryStateType; final primaryValue = primaryType?.formatValue(thing.stateValue(primaryType.id)); final secondaryStates = _secondaryStates(); final isOnline = _isOnline(); return Card( child: InkWell( borderRadius: BorderRadius.circular(16), onTap: onTap, child: Padding( padding: const EdgeInsets.all(14), child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ // Ligne principale Row(children: [ Container( width: 48, height: 48, decoration: BoxDecoration( color: categoryInfo.color.withValues(alpha: isOnline ? 0.15 : 0.07), borderRadius: BorderRadius.circular(13), ), child: Icon(categoryInfo.icon, color: isOnline ? categoryInfo.color : categoryInfo.color.withValues(alpha: 0.4), size: 26), ), const SizedBox(width: 12), Expanded( child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(thing.name, style: const TextStyle( fontWeight: FontWeight.w600, fontSize: 15, color: AppTheme.textDark), maxLines: 1, overflow: TextOverflow.ellipsis), const SizedBox(height: 2), Text(thingClass?.displayName ?? 'Appareil', style: const TextStyle(fontSize: 12, color: AppTheme.textLight), maxLines: 1, overflow: TextOverflow.ellipsis), ]), ), Column(crossAxisAlignment: CrossAxisAlignment.end, children: [ if (primaryValue != null) Text(primaryValue, style: TextStyle( fontWeight: FontWeight.bold, fontSize: 16, color: isOnline ? categoryInfo.color : AppTheme.textLight)), const SizedBox(height: 4), _StatusDot(isOnline: isOnline), ]), const SizedBox(width: 2), _ThingMenu(thing: thing, onDetail: onTap), ]), // Chips états secondaires if (secondaryStates.isNotEmpty) ...[ const SizedBox(height: 10), const Divider(height: 1, color: Color(0xFFEEEEEE)), const SizedBox(height: 10), Wrap( spacing: 7, runSpacing: 6, children: secondaryStates.map((s) => _StateChip( label: s.displayName, value: s.formatValue(thing.stateValue(s.id)), color: categoryInfo.color, )).toList(), ), ], ]), ), ), ); } bool _isOnline() { final connType = thingClass?.stateTypes .where((s) => s.name.toLowerCase() == 'connected'); if (connType != null && connType.isNotEmpty) { final val = thing.stateValue(connType.first.id); return val == true || val == 'true'; } return thing.isSetupComplete; } List _secondaryStates() { if (thingClass == null) return []; final primary = thingClass!.primaryStateType; const interesting = [ 'voltage', 'current', 'frequency', 'power', 'totalenergyconsumed', 'totalenergyproduced', 'temperature', 'humidity', 'soc', 'chargingmode', 'mode', 'maxchargingcurrent', 'status', ]; return thingClass!.stateTypes .where((s) => s != primary && s.name.toLowerCase() != 'connected' && interesting.any((i) => s.name.toLowerCase().contains(i))) .take(3) .toList(); } } // ─── Widgets utilitaires ────────────────────────────────────────────────────── class _StatusDot extends StatelessWidget { final bool isOnline; const _StatusDot({required this.isOnline}); @override Widget build(BuildContext context) => Row(mainAxisSize: MainAxisSize.min, children: [ Container( width: 7, height: 7, decoration: BoxDecoration( color: isOnline ? AppTheme.primaryGreen : Colors.grey[400], shape: BoxShape.circle, ), ), const SizedBox(width: 4), Text( isOnline ? 'En ligne' : 'Hors ligne', style: TextStyle( fontSize: 10, color: isOnline ? AppTheme.primaryGreen : AppTheme.textLight), ), ]); } class _StateChip extends StatelessWidget { final String label; final String value; final Color color; const _StateChip({required this.label, required this.value, required this.color}); @override Widget build(BuildContext context) => Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: color.withValues(alpha: 0.08), borderRadius: BorderRadius.circular(20), border: Border.all(color: color.withValues(alpha: 0.2)), ), child: Row(mainAxisSize: MainAxisSize.min, children: [ Flexible( child: Text(label, overflow: TextOverflow.ellipsis, style: TextStyle( fontSize: 11, color: color.withValues(alpha: 0.8), fontWeight: FontWeight.w500)), ), const SizedBox(width: 5), Flexible( child: Text(value, overflow: TextOverflow.ellipsis, style: TextStyle(fontSize: 11, color: color, fontWeight: FontWeight.bold)), ), ]), ); } // ─── Menu contextuel ────────────────────────────────────────────────────────── class _ThingMenu extends StatelessWidget { final NymeaThing thing; final VoidCallback onDetail; const _ThingMenu({required this.thing, required this.onDetail}); @override Widget build(BuildContext context) { return PopupMenuButton( icon: const Icon(Icons.more_vert, color: AppTheme.textLight, size: 20), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), itemBuilder: (_) => [ const PopupMenuItem(value: 'detail', child: Row(children: [ Icon(Icons.info_outline, size: 18, color: AppTheme.textDark), SizedBox(width: 10), Text('Détails & États'), ])), const PopupMenuItem(value: 'params', child: Row(children: [ Icon(Icons.tune, size: 18, color: AppTheme.textDark), SizedBox(width: 10), Text('Paramètres'), ])), const PopupMenuItem(value: 'rename', child: Row(children: [ Icon(Icons.edit_outlined, size: 18, color: AppTheme.textDark), SizedBox(width: 10), Text('Renommer'), ])), const PopupMenuItem(value: 'favorite', child: Row(children: [ Icon(Icons.star_outline, size: 18, color: AppTheme.textDark), SizedBox(width: 10), Text('Ajouter aux favoris'), ])), ], onSelected: (value) { switch (value) { case 'detail': onDetail(); break; case 'params': _showParamsSheet(context); break; case 'rename': _showRenameDialog(context); break; case 'favorite': ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text('${thing.name} ajouté aux favoris'), backgroundColor: AppTheme.primaryGreen, duration: const Duration(seconds: 2), )); } }, ); } void _showParamsSheet(BuildContext context) { showModalBottomSheet( context: context, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20))), builder: (_) => Padding( padding: const EdgeInsets.all(20), child: Column(mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Row(children: [ const Icon(Icons.tune, color: AppTheme.primaryGreen), const SizedBox(width: 8), Expanded(child: Text('Paramètres — ${thing.name}', style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 16, color: AppTheme.textDark))), ]), const SizedBox(height: 4), SelectableText('ID: ${thing.id}', style: const TextStyle(fontSize: 10, color: AppTheme.textLight)), const Divider(height: 24), if (thing.paramValues.isEmpty) const Text('Aucun paramètre configuré.', style: TextStyle(color: AppTheme.textLight)) else ...thing.paramValues.map((p) => Padding( padding: const EdgeInsets.symmetric(vertical: 4), child: Row(children: [ Expanded(child: Text(p['paramTypeId']?.toString() ?? '?', style: const TextStyle(color: AppTheme.textLight))), Text(p['value']?.toString() ?? '—', style: const TextStyle( fontWeight: FontWeight.w600, color: AppTheme.textDark)), ]), )), const SizedBox(height: 20), ]), ), ); } void _showRenameDialog(BuildContext context) { final ctrl = TextEditingController(text: thing.name); showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Renommer'), content: TextField( controller: ctrl, autofocus: true, decoration: const InputDecoration(labelText: 'Nouveau nom'), ), actions: [ TextButton(onPressed: () => Navigator.pop(ctx), child: const Text('Annuler')), FilledButton( onPressed: () { context.read().renameThing(thing.id, ctrl.text.trim()); Navigator.pop(ctx); }, child: const Text('Renommer'), ), ], ), ); } }