import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../../models/load_config_entry.dart'; import '../../models/nymea_models.dart'; import '../../providers/energy_setup_provider.dart'; import '../../providers/load_config_provider.dart'; import '../../providers/load_telemetry_provider.dart'; import '../../providers/schema_provider.dart'; import '../../services/nymea_service.dart'; import '../../theme/etm_tokens.dart'; import '../../widgets/battery_reserve_card.dart'; import '../../widgets/controllable_loads_section.dart'; import '../../widgets/schema_gated.dart'; import 'load_mechanism_screen.dart'; /// **Rôles & appareils** — l'unique écran de configuration des charges. /// /// ## Fusion : l'ancien « Ordre de service » a disparu ici /// /// Deux écrans décrivaient la même chose par deux chemins différents. Celui-ci portait la /// présentation ; l'autre portait le **chemin de données correct**. C'est donc le chemin /// de données qui a migré, jamais l'inverse — le modèle des rôles ne survit pas comme /// source de vérité pour les charges. /// /// La raison est de fond, pas d'hygiène. `EmsRole` fige six rôles, dont `dhw` et /// `heatPump` **séparés**. Or LM-201 — *un canal de commande, une charge* — dit qu'une PAC /// air/eau sans ballon séparé est **une seule** charge : le SG-Ready commande la machine /// entière. Le modèle des rôles aurait exigé d'en déclarer deux pour une seule machine, /// c'est-à-dire deux charges se disputant un canal unique — ce que `validateSet()` refuse /// côté box, et à raison. /// /// **Ce qui reste sur `EnergySetupProvider`** : les compteurs, « à configurer » et « sans /// appareil ». Ce sont des rôles d'inférence sur les Things, et le compteur principal /// relève d'un autre contrat (`Energy.SetRootMeter`). Seules les **charges** viennent /// désormais de `LoadConfigProvider`, donc de `GetLoadConfig`. class RolesDevicesScreen extends StatefulWidget { const RolesDevicesScreen({super.key}); @override State createState() => _RolesDevicesScreenState(); } class _RolesDevicesScreenState extends State { NymeaService? _svc; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { if (!mounted) return; final svc = context.read(); _svc = svc; context.read() ..attach(svc) // Le compteur racine vit sur la BOX : le relire, sinon il se présente comme // perdu à chaque démarrage. ..syncRootMeter(svc); // Chemin de données des CHARGES : la configuration de la box, et rien d'autre. context.read()..attach(svc)..load(); context.read()..attach(svc)..load(); // Le schéma décide de ce qui s'active ou se grise, ici comme dans les écrans de // mécanisme. Lu une fois : il ne change pas tant que la box ne redémarre pas. context.read()..attach(svc)..load(); svc.addListener(_sync); // re-inférer quand les things se chargent _sync(); }); } /// Ouvre le détail du mécanisme. Il n'y a **pas** de formulaire unique : les trois /// contrats (routeur de relais, charge continue, SG-Ready) ne se ressemblent pas, et /// l'écran de détail choisit lui-même sa forme selon `adapter`. void _openMechanism(BuildContext context, LoadConfigEntry entry) { Navigator.of(context).push(MaterialPageRoute( builder: (_) => LoadMechanismScreen(loadId: entry.id), )); } void _sync() { if (!mounted) return; context.read().syncFromThings(context.read()); } @override void dispose() { _svc?.removeListener(_sync); super.dispose(); } @override Widget build(BuildContext context) { final setup = context.watch(); final svc = context.watch(); final meters = setup.meterRoles; final todo = setup.toConfigureRoles; final absent = setup.absentRoles; return Scaffold( backgroundColor: EtmTokens.bgOf(context), appBar: AppBar( title: const Text('Rôles & appareils'), backgroundColor: EtmTokens.surfaceOf(context), foregroundColor: EtmTokens.inkOf(context), elevation: 0, ), body: ListView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), children: [ _CounterHeader(setup), if (setup.screenState == SetupScreenState.blank) ...[ const SizedBox(height: 16), const _BlankInvite(), ], // ── Compteurs ──────────────────────────────────────────────────── if (meters.isNotEmpty) ...[ const SizedBox(height: 20), const _SectionHeader('Compteurs', subtitle: 'Mesure — hors priorité'), const SizedBox(height: 10), for (final role in meters) ...[ _MeterCard(role: role, setup: setup, service: svc), const SizedBox(height: 10), ], ], // ── Charges pilotables — depuis GetLoadConfig, pas depuis les rôles ── const SizedBox(height: 10), const _SectionHeader('Charges pilotables', subtitle: 'Glissez pour l\'ordre de service du surplus'), const SizedBox(height: 10), SchemaVersionBanner( versionLine: context.watch().versionLine(), schemaLoaded: context.watch().schema.loaded, ), const SizedBox(height: 12), // AVANT les charges : un seuil qui peut toutes les geler doit se lire avant // elles, pas après. const BatteryReserveCard(), ControllableLoadsSection(onConfigure: _openMechanism), const _Legend(), // ── À configurer ───────────────────────────────────────────────── if (todo.isNotEmpty) ...[ const SizedBox(height: 18), const _SectionHeader('À configurer'), const SizedBox(height: 10), for (final role in todo) ...[ _TodoCard(role: role, setup: setup, service: svc), const SizedBox(height: 10), ], ], // ── Sans appareil (actés absents) ──────────────────────────────── if (absent.isNotEmpty) ...[ const SizedBox(height: 18), const _SectionHeader('Sans appareil'), const SizedBox(height: 10), for (final role in absent) ...[ _AbsentCard(role: role, setup: setup), const SizedBox(height: 10), ], ], ], ), ); } } // ═══════════════════════════════════════════════════════════════════════════ // En-tête compteur X/6 + état d'écran // ═══════════════════════════════════════════════════════════════════════════ class _CounterHeader extends StatelessWidget { final EnergySetupProvider setup; const _CounterHeader(this.setup); @override Widget build(BuildContext context) { final detected = EmsRole.values.where(setup.isDetected).length; final remaining = setup.toConfigureRoles.length; final complete = setup.screenState == SetupScreenState.complete; final parts = [ if (detected > 0) '$detected détecté${detected > 1 ? 's' : ''} automatiquement', if (remaining > 0) '$remaining reste${remaining > 1 ? 'nt' : ''} à configurer' else 'tout est assigné', ]; return Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: EtmTokens.surfaceOf(context), borderRadius: BorderRadius.circular(EtmTokens.radiusCard), boxShadow: EtmTokens.cardShadowOf(context), ), child: Row( children: [ Container( width: 40, height: 40, decoration: BoxDecoration( color: (complete ? EtmTokens.eco : EtmTokens.brand) .withValues(alpha: 0.12), borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), ), child: Icon( complete ? Icons.check_rounded : Icons.tune_rounded, color: complete ? EtmTokens.eco : EtmTokens.brand, size: 22, ), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text( '${setup.configuredCount} / ${setup.totalRoles} rôles configurés', style: EtmTokens.sans( size: 15, weight: FontWeight.w700, color: EtmTokens.inkOf(context), ), ), const SizedBox(height: 2), Text( parts.join(' · '), style: EtmTokens.sans( size: 12, color: EtmTokens.mutedOf(context)), ), ], ), ), ], ), ); } } class _BlankInvite extends StatelessWidget { const _BlankInvite(); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(18), decoration: BoxDecoration( color: EtmTokens.brand.withValues(alpha: 0.07), borderRadius: BorderRadius.circular(EtmTokens.radiusCard), border: Border.all(color: EtmTokens.brand.withValues(alpha: 0.18)), ), child: Row( children: [ const Icon(Icons.bolt_rounded, color: EtmTokens.brand), const SizedBox(width: 12), Expanded( child: Text( 'Commencez par assigner vos compteurs et charges pilotables. ' 'Le solaire et la batterie sont détectés automatiquement.', style: EtmTokens.sans( size: 13, color: EtmTokens.inkOf(context), height: 1.35), ), ), ], ), ); } } // ═══════════════════════════════════════════════════════════════════════════ // Cartes // ═══════════════════════════════════════════════════════════════════════════ class _MeterCard extends StatelessWidget { final EmsRole role; final EnergySetupProvider setup; final NymeaService service; const _MeterCard( {required this.role, required this.setup, required this.service}); @override Widget build(BuildContext context) { final thing = _thingById(service, setup.thingIdOf(role)); final sub = thing == null ? '—' : [thing.name, _thingClassName(service, thing)] .where((s) => s.isNotEmpty) .join(' · '); return _CardShell( child: Row( children: [ _Emoji(role.icon), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(role.label, style: EtmTokens.sans( size: 14.5, weight: FontWeight.w700, color: EtmTokens.inkOf(context))), const SizedBox(height: 2), Text(sub, style: EtmTokens.sans( size: 12, color: EtmTokens.mutedOf(context))), ], ), ), if (role == EmsRole.gridMeter) _Badge('rootMeter', EtmTokens.brand) else if (setup.isDetected(role)) _Badge('auto · lecture seule', EtmTokens.eco), ], ), ); } } // _LoadCard a été retiré à la fusion : il affichait un EmsRole, c'est-à-dire un rôle // INFÉRÉ des Things, là où une charge est désormais lue dans `GetLoadConfig`. Son // remplaçant est `LoadConfigCard` (widgets/controllable_loads_section.dart), qui porte en // plus la télémétrie, le renommage, l'activation et l'accès au mécanisme. class _TodoCard extends StatelessWidget { final EmsRole role; final EnergySetupProvider setup; final NymeaService service; const _TodoCard( {required this.role, required this.setup, required this.service}); @override Widget build(BuildContext context) { return _CardShell( dashed: true, child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ _Emoji(role.icon, faded: true), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(role.label, style: EtmTokens.sans( size: 14.5, weight: FontWeight.w700, color: EtmTokens.inkOf(context))), const SizedBox(height: 2), Text('Aucun appareil configuré', style: EtmTokens.sans( size: 12, color: EtmTokens.mutedOf(context))), ], ), ), ], ), const SizedBox(height: 12), Row( children: [ Expanded( child: _Btn( label: '+ Configurer', primary: true, onTap: () => _showThingPicker(context, role, setup, service), ), ), const SizedBox(width: 10), Expanded( child: _Btn( label: 'Pas d\'appareil', onTap: () => setup.markAbsent(role), ), ), ], ), ], ), ); } } class _AbsentCard extends StatelessWidget { final EmsRole role; final EnergySetupProvider setup; const _AbsentCard({required this.role, required this.setup}); @override Widget build(BuildContext context) { return _CardShell( child: Row( children: [ _Emoji(role.icon, faded: true), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(role.label, style: EtmTokens.sans( size: 14.5, weight: FontWeight.w700, color: EtmTokens.mutedOf(context))), const SizedBox(height: 2), Text('Pas d\'appareil', style: EtmTokens.sans( size: 12, color: EtmTokens.faintOf(context))), ], ), ), _Btn( label: 'Reconfigurer', onTap: () => setup.unconfigure(role), ), ], ), ); } } // ═══════════════════════════════════════════════════════════════════════════ // Sélecteur d'appareil (assign) // ═══════════════════════════════════════════════════════════════════════════ void _showThingPicker(BuildContext context, EmsRole role, EnergySetupProvider setup, NymeaService service) { final matches = setup.compatibleThings(role, service); showModalBottomSheet( context: context, backgroundColor: EtmTokens.surfaceOf(context), shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(22)), ), builder: (sheetCtx) { return SafeArea( child: Padding( padding: const EdgeInsets.fromLTRB(16, 16, 16, 16), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Assigner — ${role.label}', style: EtmTokens.sans( size: 16, weight: FontWeight.w700, color: EtmTokens.inkOf(sheetCtx))), const SizedBox(height: 4), Text('Appareils compatibles détectés', style: EtmTokens.sans( size: 12, color: EtmTokens.mutedOf(sheetCtx))), const SizedBox(height: 12), if (matches.isEmpty) Padding( padding: const EdgeInsets.symmetric(vertical: 18), child: Text('Aucun appareil compatible.', style: EtmTokens.sans( size: 13, color: EtmTokens.mutedOf(sheetCtx))), ) else ...matches.map((t) => ListTile( contentPadding: EdgeInsets.zero, leading: _Emoji(role.icon), title: Text(t.name, style: EtmTokens.sans( size: 14, weight: FontWeight.w600, color: EtmTokens.inkOf(sheetCtx))), subtitle: Text(_thingClassName(service, t), style: EtmTokens.sans( size: 12, color: EtmTokens.mutedOf(sheetCtx))), onTap: () { setup.assignThing(role, t.id); Navigator.of(sheetCtx).pop(); }, )), const SizedBox(height: 8), Align( alignment: Alignment.centerRight, child: _Btn( label: 'Pas d\'appareil', onTap: () { setup.markAbsent(role); Navigator.of(sheetCtx).pop(); }, ), ), ], ), ), ); }, ); } // ═══════════════════════════════════════════════════════════════════════════ // Petits composants // ═══════════════════════════════════════════════════════════════════════════ class _SectionHeader extends StatelessWidget { final String title; final String? subtitle; const _SectionHeader(this.title, {this.subtitle}); @override Widget build(BuildContext context) { return Row( crossAxisAlignment: CrossAxisAlignment.baseline, textBaseline: TextBaseline.alphabetic, children: [ Text(title, style: EtmTokens.sans( size: 13.5, weight: FontWeight.w700, color: EtmTokens.inkOf(context))), if (subtitle != null) ...[ const SizedBox(width: 8), Expanded( child: Text(subtitle!, style: EtmTokens.sans( size: 11, color: EtmTokens.faintOf(context))), ), ], ], ); } } class _Legend extends StatelessWidget { const _Legend(); @override Widget build(BuildContext context) { return Container( margin: const EdgeInsets.only(top: 2), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: EtmTokens.solar.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), ), child: Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ const Icon(Icons.info_outline_rounded, size: 16, color: EtmTokens.heat), const SizedBox(width: 8), Expanded( child: RichText( text: TextSpan( style: EtmTokens.sans( size: 11.5, color: EtmTokens.mutedOf(context), height: 1.35), children: [ TextSpan( text: 'Ordre indicatif. ', style: EtmTokens.sans( size: 11.5, weight: FontWeight.w700, color: EtmTokens.inkOf(context))), // La phrase qui suivait annonçait que « le pilote peut bousculer // l'ordre les jours Tempo rouge ou à l'approche d'une échéance ». // AUCUN de ces deux comportements n'existe dans le moteur : l'ordre // de service est celui que l'installateur pose, point. Promettre une // intelligence absente est pire que de ne rien dire — l'installateur // aurait attribué au pilote un arbitrage qu'il aurait dû configurer. const TextSpan( text: 'Il fixe le service du surplus : chaque charge se sert ' 'dans cet ordre, sur ce que la précédente a laissé.'), ], ), ), ), ], ), ); } } class _CardShell extends StatelessWidget { final Widget child; final bool dashed; const _CardShell({required this.child, this.dashed = false}); @override Widget build(BuildContext context) { return Container( width: double.infinity, padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: dashed ? EtmTokens.surfaceOf(context).withValues(alpha: 0.6) : EtmTokens.surfaceOf(context), borderRadius: BorderRadius.circular(EtmTokens.radiusCard), boxShadow: dashed ? null : EtmTokens.cardShadowOf(context), border: dashed ? Border.all(color: EtmTokens.lineOf(context)) : null, ), child: child, ); } } class _Emoji extends StatelessWidget { final String emoji; final bool faded; const _Emoji(this.emoji, {this.faded = false}); @override Widget build(BuildContext context) { return Opacity( opacity: faded ? 0.55 : 1, child: Container( width: 38, height: 38, alignment: Alignment.center, decoration: BoxDecoration( color: EtmTokens.bg2Of(context), borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), ), child: Text(emoji, style: const TextStyle(fontSize: 18)), ), ); } } class _Badge extends StatelessWidget { final String text; final Color color; const _Badge(this.text, this.color); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4), decoration: BoxDecoration( color: color.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(EtmTokens.radiusPill), ), child: Text(text, style: EtmTokens.sans( size: 11, weight: FontWeight.w600, color: color)), ); } } // _Chip est parti avec _LoadCard : il n'affichait que le résumé d'un EmsRole. class _Btn extends StatelessWidget { final String label; final bool primary; final VoidCallback onTap; const _Btn({required this.label, required this.onTap, this.primary = false}); @override Widget build(BuildContext context) { return GestureDetector( onTap: onTap, behavior: HitTestBehavior.opaque, child: Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10), alignment: Alignment.center, decoration: BoxDecoration( color: primary ? EtmTokens.brand : Colors.transparent, borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), border: primary ? null : Border.all(color: EtmTokens.lineStrongOf(context)), ), child: Text(label, style: EtmTokens.sans( size: 13, weight: FontWeight.w600, color: primary ? Colors.white : EtmTokens.mutedOf(context))), ), ); } } // ═══════════════════════════════════════════════════════════════════════════ // Résolution d'affichage des things // ═══════════════════════════════════════════════════════════════════════════ NymeaThing? _thingById(NymeaService service, String? id) { if (id == null) return null; for (final t in service.things) { if (t.id == id) return t; } return null; } String _thingClassName(NymeaService service, NymeaThing thing) { try { return service.thingClasses .firstWhere((c) => c.id == thing.thingClassId) .displayName; } catch (_) { return ''; } }