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/etm_tokens.dart'; import '../main.dart' show DrawerMenuButton; import 'category_overview_screen.dart'; // ───────────────────────────────────────────────────────────────────────────── // ThingsScreen — grille de catégories à hauteur intrinsèque // // Règle : pas de GridView avec childAspectRatio figé. // Layout : Column de Row(Expanded × 2) → hauteur naturelle par catégorie. // ───────────────────────────────────────────────────────────────────────────── class ThingsScreen extends StatefulWidget { const ThingsScreen({super.key}); @override State createState() => _ThingsScreenState(); } class _ThingsScreenState extends State { static const _orderedCats = [ ThingCategory.solar, ThingCategory.battery, ThingCategory.evCharger, ThingCategory.hvac, ThingCategory.energy, ThingCategory.cars, ThingCategory.lighting, ThingCategory.sensors, ThingCategory.network, ThingCategory.weather, ThingCategory.media, ThingCategory.notifications, ThingCategory.other, ]; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { final service = context.read(); if (!service.connected) service.startSimulation(); }); } @override Widget build(BuildContext context) { final service = context.watch(); final things = service.things.where((t) => t.id.isNotEmpty).toList(); final classes = service.thingClasses; // Groupement par catégorie final Map> grouped = {}; for (final t in things) { final cls = _classFor(t, classes); final cat = cls?.category ?? ThingCategory.other; grouped.putIfAbsent(cat, () => []).add(t); } final cats = _orderedCats.where((c) => grouped.containsKey(c)).toList(); // Statut global final allOnline = things.isNotEmpty && things.every((t) => _isOnline(t, _classFor(t, classes))); final offlineCount = things.where((t) => !_isOnline(t, _classFor(t, classes))).length; return Scaffold( backgroundColor: EtmTokens.bg, body: CustomScrollView( slivers: [ // AppBar SliverAppBar( floating: true, backgroundColor: EtmTokens.bg, elevation: 0, leading: const DrawerMenuButton(), leadingWidth: 64, title: Row( children: [ Text('Things', style: EtmTokens.sans(size: 20, weight: FontWeight.w600)), const SizedBox(width: 8), if (things.isNotEmpty) Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: EtmTokens.greenSoft, borderRadius: BorderRadius.circular(8), ), child: Text('${things.length}', style: EtmTokens.mono(size: 12, weight: FontWeight.w700, color: EtmTokens.greenDark)), ), ], ), actions: [ Padding( padding: const EdgeInsets.only(right: 8), child: IconButton( icon: const Icon(Icons.refresh_rounded, color: EtmTokens.muted, size: 20), onPressed: service.refresh, ), ), ], ), // Body SliverPadding( padding: const EdgeInsets.fromLTRB(18, 4, 18, 32), sliver: SliverList( delegate: SliverChildListDelegate([ // Bandeau statut global if (service.isConnected || service.isSimulation) ...[ _StatusBanner( allOnline: allOnline, offlineCount: offlineCount, simulation: service.isSimulation, thingCount: things.length, ), const SizedBox(height: 16), ], // État de chargement if (!service.isConnected && !service.isSimulation) _Placeholder( icon: Icons.cloud_off_rounded, label: 'Non connecté à nymea', ) else if (things.isEmpty && !service.thingsLoaded) const Padding( padding: EdgeInsets.only(top: 60), child: Center( child: CircularProgressIndicator( strokeWidth: 2, color: EtmTokens.green), ), ) else if (things.isEmpty) _Placeholder( icon: Icons.devices_other_rounded, label: 'Aucun appareil configuré', ) else _CategoryGrid( cats: cats, grouped: grouped, classes: classes, ), ]), ), ), ], ), ); } NymeaThingClass? _classFor(NymeaThing t, List cls) { try { return cls.firstWhere((c) => c.id == t.thingClassId); } catch (_) { return null; } } bool _isOnline(NymeaThing t, NymeaThingClass? cls) { final ct = cls?.stateTypes.where((s) => s.name.toLowerCase() == 'connected'); if (ct != null && ct.isNotEmpty) { final v = t.stateValue(ct.first.id); return v == true || v == 'true'; } return t.isSetupComplete; } } // ─────────────────────────── Bandeau statut ──────────────────────────────────── class _StatusBanner extends StatelessWidget { final bool allOnline; final int offlineCount; final bool simulation; final int thingCount; const _StatusBanner({ required this.allOnline, required this.offlineCount, required this.simulation, required this.thingCount, }); @override Widget build(BuildContext context) { final Color dotColor; final String text; if (simulation) { dotColor = EtmTokens.amber; text = 'Mode simulation · $thingCount appareil(s) simulé(s)'; } else if (allOnline) { dotColor = EtmTokens.green; text = 'Tous les appareils connectés · dernière synchro à l\'instant'; } else if (offlineCount > 0) { dotColor = EtmTokens.orange; text = '$offlineCount appareil(s) hors ligne'; } else { dotColor = EtmTokens.faint; text = 'Aucun appareil connecté'; } return Container( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12), decoration: BoxDecoration( color: EtmTokens.card, borderRadius: BorderRadius.circular(EtmTokens.radius), boxShadow: EtmTokens.cardShadow, ), child: Row( children: [ Container( width: 8, height: 8, decoration: BoxDecoration(color: dotColor, shape: BoxShape.circle), ), const SizedBox(width: 10), Expanded( child: Text(text, style: EtmTokens.sans(size: 13, color: EtmTokens.muted)), ), ], ), ); } } // ─────────────────────────── Grille intrinsèque ──────────────────────────────── class _CategoryGrid extends StatelessWidget { final List cats; final Map> grouped; final List classes; const _CategoryGrid({ required this.cats, required this.grouped, required this.classes, }); @override Widget build(BuildContext context) { final rows = []; for (int i = 0; i < cats.length; i += 2) { rows.add( Row( crossAxisAlignment: CrossAxisAlignment.start, // hauteur intrinsèque children: [ Expanded( child: _CategoryCard( cat: cats[i], things: grouped[cats[i]] ?? [], classes: classes, onTap: () => _openCategory(context, cats[i], grouped[cats[i]] ?? [], classes), ), ), const SizedBox(width: 14), Expanded( child: i + 1 < cats.length ? _CategoryCard( cat: cats[i + 1], things: grouped[cats[i + 1]] ?? [], classes: classes, onTap: () => _openCategory( context, cats[i + 1], grouped[cats[i + 1]] ?? [], classes), ) : const SizedBox(), ), ], ), ); if (i + 2 < cats.length) rows.add(const SizedBox(height: 14)); } return Column(children: rows); } void _openCategory(BuildContext context, ThingCategory cat, List things, List classes) { Navigator.push( context, MaterialPageRoute( builder: (_) => CategoryOverviewScreen( info: categoryInfoMap[cat]!, things: things, thingClasses: classes, ), ), ); } } // ─────────────────────────── Carte catégorie ─────────────────────────────────── class _CategoryCard extends StatelessWidget { final ThingCategory cat; final List things; final List classes; final VoidCallback onTap; const _CategoryCard({ required this.cat, required this.things, required this.classes, required this.onTap, }); NymeaThingClass? _classFor(NymeaThing t) { try { return classes.firstWhere((c) => c.id == t.thingClassId); } catch (_) { return null; } } bool _isOnline(NymeaThing t) { final cls = _classFor(t); final ct = cls?.stateTypes.where((s) => s.name.toLowerCase() == 'connected'); if (ct != null && ct.isNotEmpty) { final v = t.stateValue(ct.first.id); return v == true || v == 'true'; } return t.isSetupComplete; } String? _value(NymeaThing t) { final cls = _classFor(t); final p = cls?.primaryStateType; if (p == null) return null; return p.formatValue(t.stateValue(p.id)); } @override Widget build(BuildContext context) { final info = categoryInfoMap[cat]!; final count = things.length; // Container en dehors de Material pour que l'ombre soit visible return Container( decoration: BoxDecoration( color: EtmTokens.card, borderRadius: BorderRadius.circular(EtmTokens.radius), boxShadow: EtmTokens.cardShadow, ), child: Material( color: Colors.transparent, borderRadius: BorderRadius.circular(EtmTokens.radius), child: InkWell( onTap: onTap, borderRadius: BorderRadius.circular(EtmTokens.radius), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, mainAxisSize: MainAxisSize.min, children: [ // ── En-tête ──────────────────────────────────────────────────── Padding( padding: const EdgeInsets.fromLTRB(14, 14, 14, 10), child: Row( children: [ Container( width: 36, height: 36, decoration: BoxDecoration( color: info.color.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(10), ), child: Icon(info.icon, color: info.color, size: 20), ), const SizedBox(width: 10), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( info.label.toUpperCase(), style: EtmTokens.sans( size: 9, weight: FontWeight.w700, color: EtmTokens.navy.withValues(alpha: 0.6), letterSpacing: 0.6, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), Text( '$count appareil${count > 1 ? 's' : ''}', style: EtmTokens.sans(size: 11, color: EtmTokens.muted), ), ], ), ), ], ), ), // ── Séparateur coloré ────────────────────────────────────────── Container(height: 1, color: info.color.withValues(alpha: 0.20)), // ── Liste des things ─────────────────────────────────────────── ...things.map((t) => _ThingRow( thing: t, value: _value(t), isOnline: _isOnline(t), color: info.color, isLast: t == things.last, )), ], ), ), ), ); // closes InkWell } // closes build } // closes _CategoryCard // ─────────────────────────── Ligne thing ─────────────────────────────────────── class _ThingRow extends StatelessWidget { final NymeaThing thing; final String? value; final bool isOnline; final Color color; final bool isLast; const _ThingRow({ required this.thing, required this.value, required this.isOnline, required this.color, required this.isLast, }); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.fromLTRB(14, 9, 14, 9), decoration: BoxDecoration( border: isLast ? null : Border(bottom: BorderSide(color: EtmTokens.line)), borderRadius: isLast ? const BorderRadius.vertical(bottom: Radius.circular(EtmTokens.radius)) : null, ), child: Row( children: [ Container( width: 7, height: 7, decoration: BoxDecoration( color: isOnline ? color : EtmTokens.faint, shape: BoxShape.circle, ), ), const SizedBox(width: 8), Expanded( child: Text( thing.name, style: EtmTokens.sans(size: 12, weight: FontWeight.w500), maxLines: 1, overflow: TextOverflow.ellipsis, ), ), const SizedBox(width: 4), if (value != null) Text( value!, style: EtmTokens.mono( size: 12, weight: FontWeight.w700, color: isOnline ? color : EtmTokens.muted, ), maxLines: 1, overflow: TextOverflow.ellipsis, ) else Text( isOnline ? 'OK' : 'Veille', style: EtmTokens.sans( size: 12, color: isOnline ? color : EtmTokens.faint, ), ), ], ), ); } } // ─────────────────────────── Placeholder ─────────────────────────────────────── class _Placeholder extends StatelessWidget { final IconData icon; final String label; const _Placeholder({required this.icon, required this.label}); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(top: 80), child: Center( child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(icon, size: 56, color: EtmTokens.faint), const SizedBox(height: 14), Text(label, style: EtmTokens.sans(size: 15, color: EtmTokens.muted)), ], ), ), ); } } // ─────────────────────────── ThingCard (compat externe) ──────────────────────── 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 primary = thingClass?.primaryStateType; final value = primary?.formatValue(thing.stateValue(primary.id)); return ListTile( leading: Icon(categoryInfo.icon, color: categoryInfo.color), title: Text(thing.name, style: EtmTokens.sans(size: 14)), trailing: value != null ? Text(value, style: EtmTokens.mono(size: 13, color: categoryInfo.color)) : null, onTap: onTap, ); } }