diff --git a/integration_test/config_on_device_test.dart b/integration_test/config_on_device_test.dart index 5ca0e4d..53ebbd3 100644 --- a/integration_test/config_on_device_test.dart +++ b/integration_test/config_on_device_test.dart @@ -194,6 +194,56 @@ void main() { ); debugPrint('[appareil] libellé restauré : ${avant.label}'); + // ── 5-bis. LE GLISSEMENT, sur l'appareil, contre la vraie box ─────────── + // + // Le test décisif. Il n'a de sens que si les deux charges partagent un domaine — + // sinon il n'y a rien à réordonner, et c'est précisément ce qui donnait l'impression + // que « rien ne fonctionne ». Poser le classement se fait hors de ce test + // (`tools/rpc/domain_assign.dart --set chauffe-eau=ecs --set pac-terrain=ecs`) ; ici + // on constate, et on saute proprement si la condition n'est pas réunie. + final parDomaine = {}; + for (final e in config.entries) { + parDomaine.update(e.domain ?? '', (v) => v + 1, ifAbsent: () => 1); + } + final memeDomaine = parDomaine.values.any((n) => n > 1); + debugPrint('[appareil] charges par domaine : $parDomaine'); + + if (!memeDomaine) { + debugPrint('[appareil] GLISSEMENT NON EXERCÉ — une seule charge par domaine, ' + 'il n\'y a rien à réordonner. Poser deux charges dans un même domaine, ' + 'puis relancer.'); + } else { + final ordreAvant = [ + for (final g in config.groups) ...g.loads.map((e) => e.id) + ]; + final premier = ordreAvant.first; + final poignee = find.byKey(ValueKey('poignee-charge-$premier')); + final cible = find.byKey(ValueKey('load-${ordreAvant[1]}')); + expect(poignee, findsOneWidget, reason: 'poignée de $premier absente'); + + final dy = tester.getSize(cible).height * 2; + final g = await tester.startGesture(tester.getCenter(poignee)); + await tester.pump(const Duration(milliseconds: 60)); + for (var i = 0; i < 12; i++) { + await g.moveBy(Offset(0, dy / 12)); + await tester.pump(const Duration(milliseconds: 16)); + } + await g.up(); + await tester.pump(const Duration(milliseconds: 400)); + + final ordreApres = [ + for (final g in config.groups) ...g.loads.map((e) => e.id) + ]; + debugPrint('[appareil] glissement : $ordreAvant → $ordreApres'); + expect(ordreApres.first, isNot(premier), + reason: 'le glissement doit réordonner sur l\'appareil aussi'); + + // Le brouillon ne part PAS : le rang du banc n'appartient pas au test. + config.discardDraft(); + await tester.pump(const Duration(milliseconds: 200)); + debugPrint('[appareil] brouillon abandonné — rien n\'est écrit sur la box'); + } + // ── 6. On laisse l'écran affiché ──────────────────────────────────────── // // `flutter test integration_test` referme l'app dès la dernière assertion. Sans diff --git a/lib/main.dart b/lib/main.dart index 439db23..d17389d 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -498,7 +498,7 @@ class _BottomNav extends StatelessWidget { ), child: Icon( item.icon, - color: selected ? EtmTokens.brand : EtmTokens.muted, + color: selected ? EtmTokens.brand : EtmTokens.mutedOf(context), size: 22, ), ), @@ -507,7 +507,7 @@ class _BottomNav extends StatelessWidget { item.label, style: EtmTokens.sans( size: 10, - color: selected ? EtmTokens.brand : EtmTokens.muted, + color: selected ? EtmTokens.brand : EtmTokens.mutedOf(context), weight: selected ? FontWeight.w600 : FontWeight.w400, ), ), diff --git a/lib/models/load_config_entry.dart b/lib/models/load_config_entry.dart index b4f7139..3f3001f 100644 --- a/lib/models/load_config_entry.dart +++ b/lib/models/load_config_entry.dart @@ -196,6 +196,10 @@ class LoadConfigEntry { /// **entières** : ce sont des unions discriminées côté plugin (LM-300), et y remplacer /// une sous-clé isolée produirait un état que `isValid()` refuse en bloc. Map patched({ + /// Le **discriminant** de l'union. Le changer sans nettoyer les charges utiles des + /// autres mécanismes produit une configuration que la box refuse en bloc — passer + /// par `basculerMecanisme()`, jamais par ce paramètre seul. + String? adapter, int? priority, String? domain, String? label, @@ -211,6 +215,7 @@ class LoadConfigEntry { }) { return { ...raw, + 'adapter': ?adapter, 'priority': ?priority, 'domain': ?domain, 'label': ?label, diff --git a/lib/models/thing_category.dart b/lib/models/thing_category.dart index 07f694b..52970df 100644 --- a/lib/models/thing_category.dart +++ b/lib/models/thing_category.dart @@ -26,14 +26,24 @@ class ThingCategoryInfo { final ThingCategory category; final String label; final IconData icon; - final Color color; + + /// Teinte d'identité de la catégorie, ou `null` pour « **neutre** ». + /// + /// Une table statique n'a pas de `BuildContext` : une catégorie sans teinte propre ne + /// peut donc pas y figer un gris, qui dépend du thème. `null` dit « neutre » et + /// [colorOf] le résout au rendu — c'est ce qui rend ces deux catégories lisibles en + /// sombre, où le gris clair devenait illisible. + final Color? color; const ThingCategoryInfo({ required this.category, required this.label, required this.icon, - required this.color, + this.color, }); + + /// Teinte effective : celle de la catégorie, ou le gris neutre du thème courant. + Color colorOf(BuildContext c) => color ?? EtmTokens.mutedOf(c); } // ───────────────────────────────────────────────────────────────────────────── @@ -107,7 +117,6 @@ Map categoryInfoMap = { category: ThingCategory.energy, label: 'Compteurs & Prises', icon: Icons.electrical_services_rounded, - color: EtmTokens.muted, ), ThingCategory.solar: ThingCategoryInfo( category: ThingCategory.solar, @@ -179,7 +188,6 @@ Map categoryInfoMap = { category: ThingCategory.other, label: 'Autres', icon: Icons.device_hub_rounded, - color: EtmTokens.faint, ), }; diff --git a/lib/screens/ac_screen.dart b/lib/screens/ac_screen.dart index 10ebca5..a5a40db 100644 --- a/lib/screens/ac_screen.dart +++ b/lib/screens/ac_screen.dart @@ -198,11 +198,13 @@ class _ACScreenState extends State { // ─────────────────────────── Helpers de style ────────────────────────────────── -Color _modeColor(ACMode m) => switch (m) { +/// Couleur d'un mode. Prend le [BuildContext] parce que le mode « ventilation » n'a pas +/// de teinte propre : il emprunte le gris neutre, qui dépend du thème. +Color _modeColor(BuildContext c, ACMode m) => switch (m) { ACMode.heat => EtmTokens.orange, ACMode.cool => EtmTokens.blue, ACMode.auto => EtmTokens.green, - ACMode.fan => EtmTokens.muted, + ACMode.fan => EtmTokens.mutedOf(c), }; IconData _modeIcon(ACMode m) => switch (m) { @@ -236,7 +238,7 @@ class _ActiveZoneCard extends StatelessWidget { @override Widget build(BuildContext context) { - final color = _modeColor(zone.mode); + final color = _modeColor(context, zone.mode); return _Card( child: Column( @@ -328,7 +330,7 @@ class _ActiveZoneCard extends StatelessWidget { Row( children: ACMode.values.map((m) { final sel = zone.mode == m; - final mcol = _modeColor(m); + final mcol = _modeColor(context, m); return Expanded( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 2), diff --git a/lib/screens/category_overview_screen.dart b/lib/screens/category_overview_screen.dart index 726af37..fea2106 100644 --- a/lib/screens/category_overview_screen.dart +++ b/lib/screens/category_overview_screen.dart @@ -88,7 +88,7 @@ class CategoryOverviewScreen extends StatelessWidget { title: Row( mainAxisSize: MainAxisSize.min, children: [ - Icon(info.icon, color: info.color, size: 20), + Icon(info.icon, color: info.colorOf(context), size: 20), const SizedBox(width: 8), Text( info.label, @@ -157,14 +157,14 @@ class _ThingListCard extends StatelessWidget { decoration: BoxDecoration( borderRadius: BorderRadius.circular(AppTheme.cornerRadius), border: Border.all( - color: info.color.withValues(alpha: 0.28), width: 1), + color: info.colorOf(context).withValues(alpha: 0.28), width: 1), ), child: Column( children: [ // ── Ligne 1 : nom du thing ───────────────────────────────── Container( decoration: BoxDecoration( - color: info.color.withValues(alpha: 0.10), + color: info.colorOf(context).withValues(alpha: 0.10), borderRadius: const BorderRadius.vertical( top: Radius.circular(9)), ), @@ -189,12 +189,12 @@ class _ThingListCard extends StatelessWidget { height: 9, decoration: BoxDecoration( color: - isOnline ? info.color : Colors.grey[400], + isOnline ? info.colorOf(context) : Colors.grey[400], shape: BoxShape.circle, boxShadow: isOnline ? [ BoxShadow( - color: info.color + color: info.colorOf(context) .withValues(alpha: 0.45), blurRadius: 4, ) @@ -218,7 +218,7 @@ class _ThingListCard extends StatelessWidget { width: 32, height: 32, decoration: BoxDecoration( - color: info.color + color: info.colorOf(context) .withValues(alpha: isOnline ? 0.12 : 0.06), borderRadius: BorderRadius.circular(8), ), @@ -226,8 +226,8 @@ class _ThingListCard extends StatelessWidget { info.icon, size: 18, color: isOnline - ? info.color - : info.color.withValues(alpha: 0.4), + ? info.colorOf(context) + : info.colorOf(context).withValues(alpha: 0.4), ), ), const SizedBox(width: 12), @@ -250,7 +250,7 @@ class _ThingListCard extends StatelessWidget { fontSize: 16, fontWeight: FontWeight.bold, color: isOnline - ? info.color + ? info.colorOf(context) : AppTheme.textLight, ), ), diff --git a/lib/screens/energy/load_mechanism_screen.dart b/lib/screens/energy/load_mechanism_screen.dart index 418960d..d34e8be 100644 --- a/lib/screens/energy/load_mechanism_screen.dart +++ b/lib/screens/energy/load_mechanism_screen.dart @@ -6,6 +6,7 @@ import '../../models/load_config_entry.dart'; import '../../providers/load_config_provider.dart'; import '../../providers/schema_provider.dart'; import '../../services/load_hardware.dart'; +import '../../services/load_mechanism_switch.dart'; import '../../services/load_priority.dart'; import '../../services/nymea_service.dart'; import '../../services/rpc_schema.dart'; @@ -80,7 +81,7 @@ class _LoadMechanismScreenState extends State { const SizedBox(height: 14), _Identite(entry: e, provider: provider), const SizedBox(height: 14), - _Mecanisme(entry: e), + _Mecanisme(entry: e, provider: provider), const SizedBox(height: 14), if (e.isRelayRouter) _SectionRelais(entry: e, provider: provider, service: service) @@ -215,50 +216,108 @@ class _IdentiteState extends State<_Identite> { } } -/// Rappelle quel mécanisme porte cette charge — et pourquoi il ne se change pas ici. +/// Le mécanisme, et il **se change** — c'est un champ enregistré (`adapter`). +/// +/// Le geste n'est pas anodin : `adapter` et `mode` entrent dans `sameHardware()`, donc +/// basculer **reconstruit l'adaptateur** (contacts à l'état sûr, verrous réarmés à froid). +/// Et la charge utile du mécanisme quitté doit **partir** — la box refuse en bloc une +/// configuration qui porterait celle d'un autre. D'où la confirmation, qui dit les deux. class _Mecanisme extends StatelessWidget { final LoadConfigEntry entry; + final LoadConfigProvider provider; - const _Mecanisme({required this.entry}); + const _Mecanisme({required this.entry, required this.provider}); + + Future _demander(BuildContext context, String vers) async { + if (vers == entry.adapter) return; + final ok = await showDialog( + context: context, + builder: (c) => AlertDialog( + title: Text('Passer en « ${kMecanismeLabels[vers]} » ?'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'La charge sera RECONSTRUITE : ses contacts passent par leur état sûr, ' + 'puis ses verrous se réarment à froid.', + style: TextStyle(fontSize: 13, height: 1.4, color: EtmTokens.inkOf(c)), + ), + const SizedBox(height: 10), + Text( + _cePartQuoi(vers), + style: TextStyle( + fontSize: 12, height: 1.4, color: EtmTokens.mutedOf(c)), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(c).pop(false), + child: const Text('Annuler')), + FilledButton( + onPressed: () => Navigator.of(c).pop(true), + child: const Text('Changer')), + ], + ), + ); + if (ok == true) { + provider.replaceEntry(entry.id, basculerMecanisme(entry, vers)); + } + } + + /// Ce que la bascule emporte et ce qu'elle récupère — dit AVANT, pas après. + String _cePartQuoi(String vers) => switch (vers) { + 'relay-router' => + 'Les contacts déjà déclarés deviennent des lignes de relais ; leur puissance ' + 'reste à saisir — une estimation d\'état SG-Ready n\'est pas la puissance ' + 'd\'une résistance.', + 'etmvariableload' => + 'Les contacts et la table d\'états sont retirés : ce mécanisme ne commande ' + 'aucun relais. Une puissance nominale sera requise.', + _ => + 'Les deux premiers contacts deviennent SG1 et SG2 dans l\'encodage standard, ' + 'et les puissances estimées restent à renseigner.', + }; @override Widget build(BuildContext context) { - const noms = { - 'relay-router': 'Routeur de relais', - 'etmvariableload': 'Modulable', - 'sg-ready': 'SG-Ready', - }; return _Carte( titre: 'Mécanisme', badge: 'enregistré · adapter', + sousTitre: 'Où sont les contacteurs ? Sur les sorties de la box → routeur de ' + 'relais. Dans l\'appareil, piloté par une consigne → modulable.', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - Row( + Wrap( + spacing: 8, + runSpacing: 8, children: [ - for (final e in noms.entries) - Padding( - padding: const EdgeInsets.only(right: 8), + for (final m in kMecanismes) + InkWell( + borderRadius: BorderRadius.circular(9), + onTap: () => _demander(context, m), child: Container( padding: const EdgeInsets.symmetric( horizontal: 11, vertical: 7), decoration: BoxDecoration( - color: e.key == entry.adapter + color: m == entry.adapter ? EtmTokens.brand.withValues(alpha: 0.14) : Colors.transparent, borderRadius: BorderRadius.circular(9), border: Border.all( - color: e.key == entry.adapter + color: m == entry.adapter ? EtmTokens.brand.withValues(alpha: 0.5) : EtmTokens.lineOf(context)), ), - child: Text(e.value, + child: Text(kMecanismeLabels[m]!, style: TextStyle( fontSize: 12, - fontWeight: e.key == entry.adapter + fontWeight: m == entry.adapter ? FontWeight.w700 : FontWeight.w400, - color: e.key == entry.adapter + color: m == entry.adapter ? EtmTokens.brand : EtmTokens.mutedOf(context), )), @@ -266,15 +325,118 @@ class _Mecanisme extends StatelessWidget { ), ], ), - const SizedBox(height: 8), - // Changer de mécanisme, ce n'est pas régler une option : c'est remplacer la - // charge utile entière (relays[] ⇄ sgReady ⇄ maxPowerW), que la box refuse de - // voir coexister. Le geste appartient à la création d'une charge, pas à - // l'édition de celle-ci. - _Aide( - 'Le mécanisme se choisit à la déclaration de la charge. En changer ici ' - 'remplacerait toute la charge utile — les trois ne peuvent pas coexister, ' - 'et la box refuse en bloc.', + if (estHeritageFixed(entry)) ...[ + const SizedBox(height: 10), + _HeritageFixed(entry: entry, provider: provider), + ], + ], + ), + ); + } +} + +/// Configuration **héritée** : `etmvariableload` en multipalier natif (`fixed`). +/// +/// L'app ne propose plus ce mode — le plugin lui-même le qualifie de *legacy* — mais elle +/// ne le réécrit pas en douce : basculer `fixed` vers `dynamic` change l'arbitrage réel de +/// la charge, et cela demande un geste explicite. **Ouvrir cet écran ne convertit +/// personne.** +class _HeritageFixed extends StatelessWidget { + final LoadConfigEntry entry; + final LoadConfigProvider provider; + + const _HeritageFixed({required this.entry, required this.provider}); + + Future _convertir(BuildContext context) async { + final ctrl = TextEditingController( + text: entry.powerLevelsInt.isEmpty + ? '' + : entry.powerLevelsInt.last.toString()); + final w = await showDialog( + context: context, + builder: (c) => AlertDialog( + title: const Text('Convertir en modulation continue'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + 'La box refuse « dynamic » sans plafond, et refuse les paliers avec. La ' + 'déclaration en paliers ne porte pas de puissance nominale : il faut donc ' + 'la fournir.', + style: TextStyle( + fontSize: 12.5, height: 1.4, color: EtmTokens.mutedOf(c)), + ), + const SizedBox(height: 12), + TextField( + controller: ctrl, + keyboardType: TextInputType.number, + decoration: const InputDecoration( + isDense: true, + labelText: 'Puissance nominale', + suffixText: 'W'), + ), + ], + ), + actions: [ + TextButton( + onPressed: () => Navigator.of(c).pop(), + child: const Text('Annuler')), + FilledButton( + onPressed: () { + final n = int.tryParse(ctrl.text.trim()); + if (n != null && n > 0) Navigator.of(c).pop(n); + }, + child: const Text('Convertir'), + ), + ], + ), + ); + if (w != null && w > 0) { + provider.replaceEntry(entry.id, convertirEnModulation(entry, w)); + } + } + + @override + Widget build(BuildContext context) { + return Container( + width: double.infinity, + padding: const EdgeInsets.all(11), + decoration: BoxDecoration( + color: EtmTokens.orange.withValues(alpha: 0.09), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text('Configuration héritée — lecture seule · mode fixed', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w700, + color: EtmTokens.inkOf(context))), + const SizedBox(height: 4), + Text('Paliers déclarés : ${entry.powerLevelsInt.join(' · ')} W', + style: TextStyle( + fontSize: 11.5, + fontFamily: 'monospace', + color: EtmTokens.inkOf(context))), + const SizedBox(height: 5), + Text( + 'Le multipalier natif n\'est plus proposé, mais il n\'est pas réécrit sans ' + 'votre accord : passer en modulation continue change l\'arbitrage réel de ' + 'cette charge.', + style: TextStyle( + fontSize: 11, height: 1.35, color: EtmTokens.mutedOf(context)), + ), + Align( + alignment: Alignment.centerLeft, + child: TextButton( + onPressed: () => _convertir(context), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + foregroundColor: EtmTokens.orange), + child: const Text('Convertir en modulation continue…'), + ), ), ], ), diff --git a/lib/screens/favorites_screen.dart b/lib/screens/favorites_screen.dart index 8d8f4f4..5d77d57 100644 --- a/lib/screens/favorites_screen.dart +++ b/lib/screens/favorites_screen.dart @@ -643,7 +643,7 @@ class _AddSheet extends StatelessWidget { (FavoriteType.pvPower, 'Production PV', 'Puissance solaire instantanée', Icons.wb_sunny_rounded, EtmTokens.amber), (FavoriteType.homePower, 'Consommation', 'Puissance consommée', Icons.home_rounded, EtmTokens.blue), (FavoriteType.batterySOC,'Batterie', 'État de charge', Icons.battery_charging_full_rounded,EtmTokens.green), - (FavoriteType.gridPower, 'Réseau', 'Soutirage / injection réseau', Icons.electrical_services_rounded, EtmTokens.muted), + (FavoriteType.gridPower, 'Réseau', 'Soutirage / injection réseau', Icons.electrical_services_rounded, null), (FavoriteType.rates, 'Taux', 'Autoconsommation & autonomie', Icons.pie_chart_rounded, EtmTokens.amber), (FavoriteType.evCharger, 'Borne EV', 'Recharge véhicule', Icons.ev_station_rounded, EtmTokens.blue), (FavoriteType.chart, 'Graphique PV', 'Production journalière (barres)', Icons.bar_chart_rounded, EtmTokens.green), @@ -693,7 +693,10 @@ class _AddSheet extends StatelessWidget { controller: ctrl, padding: const EdgeInsets.fromLTRB(18, 12, 18, 24), children: _options.map((opt) { - final (type, title, subtitle, icon, color) = opt; + // `null` = teinte neutre : la table est statique, le gris dépend du + // thème, il se résout donc ici. + final (type, title, subtitle, icon, teinte) = opt; + final color = teinte ?? EtmTokens.mutedOf(context); final added = service.favoriteWidgets .any((f) => f.type == type); diff --git a/lib/screens/settings/thing_catalog_screen.dart b/lib/screens/settings/thing_catalog_screen.dart index b3c7286..8f0503f 100644 --- a/lib/screens/settings/thing_catalog_screen.dart +++ b/lib/screens/settings/thing_catalog_screen.dart @@ -356,7 +356,7 @@ class _GroupHeader extends StatelessWidget { padding: const EdgeInsets.fromLTRB(2, 14, 0, 8), child: Row( children: [ - Icon(info.icon, size: 16, color: info.color), + Icon(info.icon, size: 16, color: info.colorOf(context)), const SizedBox(width: 8), Text(info.label, style: EtmTokens.sans( diff --git a/lib/screens/thing_detail_screen.dart b/lib/screens/thing_detail_screen.dart index 38a06db..e622d08 100644 --- a/lib/screens/thing_detail_screen.dart +++ b/lib/screens/thing_detail_screen.dart @@ -78,7 +78,7 @@ class ThingDetailScreen extends StatelessWidget { // ── States ───────────────────────────────────────────────────────── if (stateTypes.isNotEmpty) ...[ - _SectionLabel(label: 'States', accentColor: catInfo.color), + _SectionLabel(label: 'States', accentColor: catInfo.colorOf(context)), _FlatSection( children: stateTypes.asMap().entries.map((e) { final isLast = e.key == stateTypes.length - 1; @@ -87,7 +87,7 @@ class ThingDetailScreen extends StatelessWidget { stateType: st, value: live.stateValue(st.id), isLast: isLast, - accentColor: catInfo.color, + accentColor: catInfo.colorOf(context), // onChanged non-null si l'état est writable onChanged: st.writable ? (newVal) async { @@ -106,7 +106,7 @@ class ThingDetailScreen extends StatelessWidget { // ── Settings ─────────────────────────────────────────────────────── if (settingTypes.isNotEmpty) ...[ - _SectionLabel(label: 'Settings', accentColor: catInfo.color), + _SectionLabel(label: 'Settings', accentColor: catInfo.colorOf(context)), _FlatSection( children: settingTypes.asMap().entries.map((e) { final isLast = e.key == settingTypes.length - 1; @@ -115,7 +115,7 @@ class ThingDetailScreen extends StatelessWidget { stateType: st, value: live.settingValue(st.id), isLast: isLast, - accentColor: catInfo.color, + accentColor: catInfo.colorOf(context), // Settings sont toujours éditables onChanged: (newVal) async { final ok = await service.setThingSettings( @@ -132,7 +132,7 @@ class ThingDetailScreen extends StatelessWidget { // ── Actions ──────────────────────────────────────────────────────── if (actionTypes.isNotEmpty) ...[ - _SectionLabel(label: 'Actions', accentColor: catInfo.color), + _SectionLabel(label: 'Actions', accentColor: catInfo.colorOf(context)), Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), child: Wrap( @@ -141,7 +141,7 @@ class ThingDetailScreen extends StatelessWidget { children: actionTypes .map((action) => ElevatedButton.icon( style: ElevatedButton.styleFrom( - backgroundColor: catInfo.color, + backgroundColor: catInfo.colorOf(context), foregroundColor: Colors.white, elevation: 0, shape: RoundedRectangleBorder( @@ -158,7 +158,7 @@ class ThingDetailScreen extends StatelessWidget { fontWeight: FontWeight.w600)), onPressed: () => _executeAction( context, service, live, action, - catInfo.color), + catInfo.colorOf(context)), )) .toList(), ), @@ -168,7 +168,7 @@ class ThingDetailScreen extends StatelessWidget { // ── Interfaces ───────────────────────────────────────────────────── if (cls != null && cls.interfaces.isNotEmpty) ...[ - _SectionLabel(label: 'Interfaces', accentColor: catInfo.color), + _SectionLabel(label: 'Interfaces', accentColor: catInfo.colorOf(context)), Padding( padding: const EdgeInsets.fromLTRB(16, 0, 16, 0), child: Wrap( @@ -179,10 +179,10 @@ class ThingDetailScreen extends StatelessWidget { label: Text(iface, style: const TextStyle(fontSize: 12)), backgroundColor: - catInfo.color.withValues(alpha: 0.1), + catInfo.colorOf(context).withValues(alpha: 0.1), side: BorderSide( color: - catInfo.color.withValues(alpha: 0.3)), + catInfo.colorOf(context).withValues(alpha: 0.3)), padding: const EdgeInsets.symmetric( horizontal: 4), )) @@ -193,7 +193,7 @@ class ThingDetailScreen extends StatelessWidget { ], // ── Informations ─────────────────────────────────────────────────── - _SectionLabel(label: 'Informations', accentColor: catInfo.color), + _SectionLabel(label: 'Informations', accentColor: catInfo.colorOf(context)), _FlatSection(children: [ _InfoRow('ID', live.id, isLast: false), _InfoRow('Classe', live.thingClassId, isLast: false), @@ -277,7 +277,7 @@ class ThingDetailScreen extends StatelessWidget { borderRadius: BorderRadius.circular(2)), ), ListTile( - leading: Icon(Icons.edit_rounded, color: info.color), + leading: Icon(Icons.edit_rounded, color: info.colorOf(context)), title: const Text('Renommer'), onTap: () { Navigator.pop(context); diff --git a/lib/screens/things_screen.dart b/lib/screens/things_screen.dart index 506dc90..790d1a9 100644 --- a/lib/screens/things_screen.dart +++ b/lib/screens/things_screen.dart @@ -356,10 +356,10 @@ class _CategoryCard extends StatelessWidget { Container( width: 36, height: 36, decoration: BoxDecoration( - color: info.color.withValues(alpha: 0.12), + color: info.colorOf(context).withValues(alpha: 0.12), borderRadius: BorderRadius.circular(10), ), - child: Icon(info.icon, color: info.color, size: 20), + child: Icon(info.icon, color: info.colorOf(context), size: 20), ), const SizedBox(width: 10), Expanded( @@ -389,14 +389,14 @@ class _CategoryCard extends StatelessWidget { ), // ── Séparateur coloré ────────────────────────────────────────── - Container(height: 1, color: info.color.withValues(alpha: 0.20)), + Container(height: 1, color: info.colorOf(context).withValues(alpha: 0.20)), // ── Liste des things ─────────────────────────────────────────── ...things.map((t) => _ThingRow( thing: t, value: _value(t), isOnline: _isOnline(t), - color: info.color, + color: info.colorOf(context), isLast: t == things.last, )), ], @@ -526,10 +526,10 @@ class ThingCard extends StatelessWidget { final primary = thingClass?.primaryStateType; final value = primary?.formatValue(thing.stateValue(primary.id)); return ListTile( - leading: Icon(categoryInfo.icon, color: categoryInfo.color), + leading: Icon(categoryInfo.icon, color: categoryInfo.colorOf(context)), title: Text(thing.name, style: EtmTokens.sans(size: 14)), trailing: value != null - ? Text(value, style: EtmTokens.mono(size: 13, color: categoryInfo.color)) + ? Text(value, style: EtmTokens.mono(size: 13, color: categoryInfo.colorOf(context))) : null, onTap: onTap, ); diff --git a/lib/services/load_mechanism_switch.dart b/lib/services/load_mechanism_switch.dart new file mode 100644 index 0000000..6e9a907 --- /dev/null +++ b/lib/services/load_mechanism_switch.dart @@ -0,0 +1,173 @@ +/// Changer le **mécanisme** d'une charge — la bascule de charge utile, et rien d'autre. +/// +/// ## Pourquoi ça ne peut pas être un simple `adapter: …` +/// +/// `LoadConfig` est une **union discriminée** (LM-300) : chaque mécanisme a sa charge +/// utile, et celles des autres doivent être **absentes**. La box ne se contente pas de les +/// ignorer, elle **refuse en bloc** : +/// +/// ``` +/// sg-ready : relays[] interdit (charge utile d'un autre mécanisme) +/// etmvariableload : relays[] interdit (réservé à relay-router) +/// : sgReady interdit (charge utile du mécanisme sg-ready) +/// ``` +/// +/// Écrire `adapter` sans nettoyer le reste produit donc une configuration que la box +/// rejette — et le refus porte sur **toutes** les charges de l'appel, sans motif. +/// +/// ## Ce que la bascule coûte +/// +/// `adapter` et `mode` sont comparés par `sameHardware()` : changer de mécanisme +/// **reconstruit l'adaptateur**. Ses contacts passent par l'état sûr, ses verrous se +/// réarment à froid. Sur une PAC à 900 s, c'est un quart d'heure de machine figée. L'écran +/// doit l'annoncer **avant** de valider — jamais après. +/// +/// ## Le mode `fixed` d'`etmvariableload` n'est PAS proposé +/// +/// Le multipalier natif existe encore côté box, qualifié de *legacy* par le plugin. +/// [basculerMecanisme] ne le produit jamais : une bascule vers `etmvariableload` donne +/// toujours `dynamic`. Mais une configuration déployée qui l'utilise se **lit** sans être +/// réécrite — [estHeritageFixed] la reconnaît, et seule une conversion explicitement +/// demandée la change. Ouvrir un écran ne doit convertir personne. +library; + +import '../models/load_config_entry.dart'; + +/// Les trois mécanismes qu'une charge peut porter. +const List kMecanismes = ['relay-router', 'etmvariableload', 'sg-ready']; + +/// Libellés d'interface. Des **codes** côté contrat, des mots côté écran. +const Map kMecanismeLabels = { + 'relay-router': 'Routeur de relais', + 'etmvariableload': 'Modulable', + 'sg-ready': 'SG-Ready', +}; + +/// Vrai si cette charge est un `etmvariableload` en **multipalier natif** (`fixed`) — +/// la forme *legacy* que l'app lit sans la proposer ni la réécrire. +bool estHeritageFixed(LoadConfigEntry e) => + e.isVariableLoad && e.mode == 'fixed' && e.powerLevelsInt.isNotEmpty; + +/// Charge utile d'une charge basculée vers [vers]. +/// +/// Repart de la map d'origine — donc en préserve tout ce qui n'appartient à aucun +/// mécanisme (`id`, `label`, `domain`, `priority`, `enabled`, `needs`) — puis **retire les +/// clés du mécanisme quitté** et pose celles du nouveau. +/// +/// Ce qui est **récupéré** quand c'est possible, parce que le jeter ferait perdre à +/// l'installateur un travail qu'il vient de faire : +/// +/// - vers `sg-ready` depuis un routeur : les **deux premiers relais** deviennent SG1 et +/// SG2, dans l'encodage standard ; +/// - vers `relay-router` depuis une PAC : les contacts SG-Ready redeviennent des lignes de +/// relais, **puissance à saisir** (une estimation d'état n'est pas la puissance d'une +/// résistance) ; +/// - `minOnS`/`minOffS` survivent partout où ils ont un sens. +/// +/// La charge produite peut être **incomplète** — un `etmvariableload` sans `maxPowerW`, +/// par exemple. C'est voulu : le formulaire la refusera et dira quoi remplir, ce qui vaut +/// mieux que d'inventer une valeur de plaque. +Map basculerMecanisme(LoadConfigEntry e, String vers) { + assert(kMecanismes.contains(vers)); + + // Les contacts que l'on peut récupérer, tous mécanismes confondus. + final contacts = []; + void ajouter(String id) { + if (id.trim().isEmpty) return; + if (contacts.any((x) => normalizeThingId(x) == normalizeThingId(id))) return; + contacts.add(id); + } + + for (final r in e.relays) { + ajouter(r.thingId); + } + for (final s in e.sgReadyStates) { + for (final r in s.relays) { + ajouter(r); + } + } + + switch (vers) { + case 'relay-router': + // Les puissances d'origine si elles existent ; sinon 0, que le formulaire refusera + // en le disant — une estimation d'état SG-Ready n'est pas la puissance d'une + // résistance, et la transposer serait une invention. + final parThing = { + for (final r in e.relays) normalizeThingId(r.thingId): r.powerW + }; + return e.patched( + adapter: 'relay-router', + mode: 'fixed', + relays: [ + for (final c in contacts) + {'thingId': c, 'powerW': parThing[normalizeThingId(c)] ?? 0} + ], + sgReady: const {}, + powerLevels: const [], + maxPowerW: 0, + ); + + case 'etmvariableload': + // TOUJOURS `dynamic` : le multipalier natif est *legacy* et ne se propose pas. + return e.patched( + adapter: 'etmvariableload', + mode: 'dynamic', + relays: const >[], + sgReady: const {}, + powerLevels: const [], + maxPowerW: e.maxPowerW > 0 ? e.maxPowerW : 0, + ); + + case 'sg-ready': + default: + final sg1 = contacts.isNotEmpty ? contacts[0] : ''; + final sg2 = contacts.length > 1 ? contacts[1] : ''; + // Encodage SG-Ready standard, celui que l'écran sait étiqueter : + // SG1 fermé en 1 (blocage), SG2 fermé en 3 (recommandation), les deux en 4. + final estimations = { + for (final s in e.sgReadyStates) s.state: s.estimatedPowerW + }; + return e.patched( + adapter: 'sg-ready', + mode: 'fixed', + relays: const >[], + powerLevels: const [], + maxPowerW: 0, + sgReady: { + // 900 s par défaut : la valeur d'une PAC réelle, pas celle du banc. Mieux vaut + // une protection trop longue qu'un compresseur en court-cycle. + 'minStateHoldS': + e.minStateHoldS > 0 ? e.minStateHoldS : 900, + 'states': [ + {'state': 1, 'relays': [if (sg1.isNotEmpty) sg1], 'estimatedPowerW': estimations[1] ?? 0.0}, + {'state': 2, 'relays': [], 'estimatedPowerW': estimations[2] ?? 0.0}, + {'state': 3, 'relays': [if (sg2.isNotEmpty) sg2], 'estimatedPowerW': estimations[3] ?? 0.0}, + { + 'state': 4, + 'relays': [if (sg1.isNotEmpty) sg1, if (sg2.isNotEmpty) sg2], + 'estimatedPowerW': estimations[4] ?? 0.0, + }, + ], + }, + ); + } +} + +/// Convertit un `etmvariableload` **legacy** (`fixed` + paliers) en modulation continue. +/// +/// Geste **explicite**, jamais automatique : basculer `fixed` → `dynamic` change +/// l'arbitrage réel de la charge. La box refuse par ailleurs `dynamic` sans `maxPowerW` et +/// refuse `powerLevels` avec — d'où le plafond, que la déclaration en paliers ne porte pas +/// et qu'il faut donc fournir. +/// +/// \param maxPowerW plafond de modulation, strictement positif. +Map convertirEnModulation(LoadConfigEntry e, int maxPowerW) { + return e.patched( + adapter: 'etmvariableload', + mode: 'dynamic', + powerLevels: const [], + relays: const >[], + sgReady: const {}, + maxPowerW: maxPowerW, + ); +} diff --git a/lib/widgets/controllable_loads_section.dart b/lib/widgets/controllable_loads_section.dart index a757b1b..1c59cdb 100644 --- a/lib/widgets/controllable_loads_section.dart +++ b/lib/widgets/controllable_loads_section.dart @@ -74,6 +74,14 @@ class ControllableLoadsSection extends StatelessWidget { // Le budget du cycle AVANT les charges : « Alloué 3 000 W » ne veut rien dire // tant qu'on ne sait pas s'il y avait 3 100 ou 12 000 W à distribuer. TelemetryHeader(telemetry: telemetry), + // Les domaines se réordonnent par les FLÈCHES, pas au glisser-déposer. + // + // Tentative faite et annulée : envelopper les domaines dans un second + // `ReorderableListView` rend bien les domaines glissables, mais **casse le + // glissement des cartes** — deux listes réordonnables imbriquées se disputent le + // geste, et c'est l'extérieure qui gagne. Le test `reorder_drag_test.dart` le + // montre en une exécution. On garde donc un seul niveau glissable, celui des + // charges, et des flèches explicites pour les domaines. for (var i = 0; i < groups.length; i++) _DomainBlock( key: ValueKey('domain-${groups[i].domain}'), @@ -169,6 +177,7 @@ class _DomainBlock extends StatelessWidget { child: LoadConfigCard( entry: group.loads[i], handleIndex: i, + reordonnable: group.loads.length > 1, provider: provider, telemetry: telemetry, onConfigure: onConfigure, @@ -185,6 +194,11 @@ class _DomainBlock extends StatelessWidget { class LoadConfigCard extends StatelessWidget { final LoadConfigEntry entry; final int handleIndex; + + /// Faux quand la charge est **seule dans son domaine** : il n'y a alors rien à + /// réordonner ici, et l'ordre se change au niveau des domaines. + final bool reordonnable; + final LoadConfigProvider provider; final LoadTelemetryProvider telemetry; final void Function(BuildContext, LoadConfigEntry) onConfigure; @@ -193,6 +207,7 @@ class LoadConfigCard extends StatelessWidget { super.key, required this.entry, required this.handleIndex, + this.reordonnable = true, required this.provider, required this.telemetry, required this.onConfigure, @@ -249,12 +264,28 @@ class LoadConfigCard extends StatelessWidget { Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ - ReorderableDragStartListener( + // Seule dans son domaine, une charge n'a nulle part où aller : la poignée + // serait un mensonge. On garde le rang, on retire la prise. + _PoigneeOuPas( + key: ValueKey('poignee-charge-${entry.id}'), + actif: reordonnable, index: handleIndex, - child: Padding( + // `Container` transparent : rend la ZONE ENTIÈRE préhensible. + // `ReorderableDragStartListener` défère le test de collision à ses + // enfants ; sans ça, le vide de 3 px entre l'icône et la pastille ne + // saisit rien — une bande morte invisible, au milieu d'une poignée qui + // paraît d'un seul tenant. + child: Container( + color: Colors.transparent, padding: const EdgeInsets.only(top: 1, right: 9), child: Column(children: [ - Icon(Icons.drag_indicator_rounded, size: 20, color: muted), + Icon( + reordonnable + ? Icons.drag_indicator_rounded + : Icons.remove_rounded, + size: 20, + color: reordonnable ? muted : muted.withValues(alpha: 0.35), + ), const SizedBox(height: 3), // Le rang est ce que le glisser-déposer produit : il se lit ici, // pas noyé dans une phrase. @@ -751,3 +782,34 @@ class BorneSection extends StatelessWidget { ); } } + + +/// Enveloppe une poignée : prise réelle si [actif], simple décor sinon. +/// +/// `ReorderableDragStartListener` s'attend à vivre sous un `ReorderableListView` ; l'utiliser +/// pour une liste d'un seul élément produirait une poignée qui s'agrippe et ne mène nulle +/// part. Mieux vaut ne pas offrir la prise. +class _PoigneeOuPas extends StatelessWidget { + final bool actif; + final int index; + final Widget child; + + const _PoigneeOuPas({ + super.key, + required this.actif, + required this.index, + required this.child, + }); + + @override + Widget build(BuildContext context) { + if (!actif) { + return Tooltip( + message: 'Seule charge de ce domaine — son rang se change en déplaçant le ' + 'domaine lui-même.', + child: child, + ); + } + return ReorderableDragStartListener(index: index, child: child); + } +} diff --git a/lib/widgets/energy_flow_widget.dart b/lib/widgets/energy_flow_widget.dart index a343a6b..e6a0dbf 100644 --- a/lib/widgets/energy_flow_widget.dart +++ b/lib/widgets/energy_flow_widget.dart @@ -85,13 +85,14 @@ class _EnergyFlowWidgetState extends State nodes: nodes, data: data, t: _ctrl.value, + inactif: EtmTokens.lineOf(context), ), ), ), _sunNode(center: nodes.sun, r: nodeR, data: data), _homeNode(center: nodes.home, r: nodeR * 1.08, data: data), _battNode(center: nodes.batt, r: nodeR, data: data), - _gridNode(center: nodes.grid, r: nodeR, data: data), + _gridNode(context, center: nodes.grid, r: nodeR, data: data), ], ), ); @@ -129,7 +130,17 @@ class _FlowPainter extends CustomPainter { final EnergyData data; final double t; - const _FlowPainter({required this.nodes, required this.data, required this.t}); + /// Gris des liens INACTIFS. Un peintre n'a pas de `BuildContext` : la couleur qui dépend + /// du thème doit donc lui être **passée**, résolue par le widget qui le construit. + /// Sans ça, les liens éteints restaient en gris clair sur fond sombre. + final Color inactif; + + const _FlowPainter({ + required this.nodes, + required this.data, + required this.t, + required this.inactif, + }); @override void paint(Canvas canvas, Size size) { @@ -140,7 +151,7 @@ class _FlowPainter extends CustomPainter { canvas, from: Offset(nodes.sun.dx, nodes.sun.dy + r), to: Offset(nodes.home.dx, nodes.home.dy - r * 1.08), - color: data.pvPower > 0 ? EtmTokens.amber : EtmTokens.line, + color: data.pvPower > 0 ? EtmTokens.amber : inactif, animated: data.pvPower > 0, arrowDown: true, ); @@ -163,7 +174,7 @@ class _FlowPainter extends CustomPainter { _line(canvas, from: Offset(nodes.home.dx - r * 1.08, nodes.home.dy), to: Offset(nodes.batt.dx + r, nodes.batt.dy), - color: EtmTokens.line, animated: false); + color: inactif, animated: false); } // Réseau ↔ Maison (gridPower > 0 = soutirage = Grid → Home) @@ -180,7 +191,7 @@ class _FlowPainter extends CustomPainter { color: EtmTokens.blue, animated: true); } else { _line(canvas, from: homeRight, to: gridLeft, - color: EtmTokens.line, animated: false); + color: inactif, animated: false); } } @@ -300,15 +311,16 @@ Widget _battNode({required Offset center, required double r, required EnergyData ); } -Widget _gridNode({required Offset center, required double r, required EnergyData data}) { +Widget _gridNode(BuildContext context, + {required Offset center, required double r, required EnergyData data}) { return _Node( center: center, r: r, icon: Icons.electrical_services_rounded, - iconColor: EtmTokens.faint, + iconColor: EtmTokens.faintOf(context), label: 'Réseau', value: '${data.gridPower.abs().toStringAsFixed(0)} W', - valueColor: EtmTokens.muted, + valueColor: EtmTokens.mutedOf(context), ); } diff --git a/lib/widgets/load_cards.dart b/lib/widgets/load_cards.dart index 49d04e1..0d4e666 100644 --- a/lib/widgets/load_cards.dart +++ b/lib/widgets/load_cards.dart @@ -22,6 +22,7 @@ import '../providers/load_config_provider.dart'; import '../providers/load_telemetry_provider.dart'; import '../services/load_priority.dart'; import '../services/telemetry_text.dart'; +import '../theme/etm_tokens.dart'; /// Libellé d'un domaine. Un domaine inconnu de cette version d'app (box plus récente) /// a un repli lisible : le code brut, jamais une phrase vide. @@ -47,10 +48,48 @@ class DomainMenu extends StatelessWidget { final t = context.l10n; final current = entry.domain ?? kUnclassifiedDomain; + final classe = entry.domain == null; + + // Un chip qui PORTE son libellé, pas une icône muette. + // + // Il n'affichait qu'une étiquette dessinée : le domaine d'une charge était donc + // illisible sur sa carte, alors que c'est lui qui décide de son groupe et de son + // rang. Et la couleur était figée en clair — le chip restait pâle en thème sombre. return PopupMenuButton( tooltip: t.domainAssignAction, - icon: const Icon(Icons.label_outline_rounded, size: 20, color: Color(0xFF6B7280)), onSelected: (d) => provider.assignDomain(entry.id, d), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6), + decoration: BoxDecoration( + color: classe + ? Colors.transparent + : EtmTokens.brand.withValues(alpha: 0.10), + borderRadius: BorderRadius.circular(20), + border: Border.all( + color: classe + ? EtmTokens.lineOf(context) + : EtmTokens.brand.withValues(alpha: 0.35), + ), + ), + child: Row(mainAxisSize: MainAxisSize.min, children: [ + Icon(Icons.label_outline_rounded, + size: 14, + color: classe ? EtmTokens.mutedOf(context) : EtmTokens.brand), + const SizedBox(width: 5), + Text( + domainLabel(t, current), + style: TextStyle( + fontSize: 11.5, + fontWeight: classe ? FontWeight.w400 : FontWeight.w600, + color: classe ? EtmTokens.mutedOf(context) : EtmTokens.brand, + ), + ), + const SizedBox(width: 2), + Icon(Icons.arrow_drop_down_rounded, + size: 16, + color: classe ? EtmTokens.mutedOf(context) : EtmTokens.brand), + ]), + ), itemBuilder: (c) => [ PopupMenuItem( enabled: false, diff --git a/test/mechanism_switch_test.dart b/test/mechanism_switch_test.dart new file mode 100644 index 0000000..9039e3e --- /dev/null +++ b/test/mechanism_switch_test.dart @@ -0,0 +1,198 @@ +/// La bascule de mécanisme — **ce qui part doit partir**. +/// +/// `LoadConfig` est une union discriminée : la charge utile d'un mécanisme quitté doit +/// être absente, sinon la box refuse **en bloc et sans motif**, et le refus emporte toutes +/// les charges de l'appel. Ces tests reprennent les règles de `LoadConfig::isValid()` du +/// plugin, mécanisme par mécanisme. +library; + +import 'package:etm_powersync_app/models/load_config_entry.dart'; +import 'package:etm_powersync_app/services/load_hardware.dart'; +import 'package:etm_powersync_app/services/load_mechanism_switch.dart'; +import 'package:flutter_test/flutter_test.dart'; + +const _k1 = '{beaf92e1-aedc-4b84-9ce4-e423648638cc}'; +const _k2 = '{bf236e64-5ae7-4bf1-82a6-4ede03de75a6}'; +const _r3 = '{b033b212-1adb-4df0-ba2b-8fa477de52a2}'; + +LoadConfigEntry _routeur() => LoadConfigEntry({ + 'adapter': 'relay-router', + 'domain': 'ecs', + 'enabled': true, + 'id': 'chauffe-eau', + 'label': 'chauffe-eau', + 'maxPowerW': 0, + 'minOffS': 60, + 'minOnS': 60, + 'mode': 'fixed', + 'needs': {'dailyDeadline': '06:00', 'minEnergyWhPerDay': 4000}, + 'powerLevels': [], + 'priority': 1, + 'relays': [ + {'powerW': 500, 'thingId': _k1}, + {'powerW': 1000, 'thingId': _k2}, + {'powerW': 2000, 'thingId': _r3}, + ], + 'sgReady': {}, + }); + +LoadConfigEntry _pac() => LoadConfigEntry({ + 'adapter': 'sg-ready', + 'domain': 'hvac', + 'enabled': true, + 'id': 'pac-terrain', + 'label': 'PAC banc', + 'maxPowerW': 0, + 'minOffS': 0, + 'minOnS': 0, + 'mode': 'fixed', + 'needs': {'dailyDeadline': '', 'minEnergyWhPerDay': 0}, + 'powerLevels': [], + 'priority': 2, + 'relays': [], + 'sgReady': { + 'minStateHoldS': 300, + 'states': [ + {'state': 1, 'relays': [_k1], 'estimatedPowerW': 0.0}, + {'state': 2, 'relays': [], 'estimatedPowerW': 0.0}, + {'state': 3, 'relays': [_k2], 'estimatedPowerW': 1500.0}, + {'state': 4, 'relays': [_k1, _k2], 'estimatedPowerW': 3000.0}, + ], + }, + }); + +/// Legacy : `etmvariableload` en multipalier natif. +LoadConfigEntry _heritage() => LoadConfigEntry({ + 'adapter': 'etmvariableload', + 'domain': 'ecs', + 'enabled': true, + 'id': '{aaaaaaaa-0000-0000-0000-000000000001}', + 'label': 'ECS Modbus', + 'maxPowerW': 0, + 'minOffS': 0, + 'minOnS': 0, + 'mode': 'fixed', + 'needs': {'dailyDeadline': '', 'minEnergyWhPerDay': 0}, + 'powerLevels': [0, 600, 1200, 1800, 2400], + 'priority': 3, + 'relays': [], + 'sgReady': {}, + }); + +/// Ce que la box exige : les charges utiles étrangères doivent être VIDES. +void _chargesUtilesEtrangeresVides(Map m, String mecanisme) { + if (mecanisme != 'relay-router') { + expect((m['relays'] as List?) ?? const [], isEmpty, + reason: '$mecanisme : relays[] interdit'); + } + if (mecanisme != 'sg-ready') { + expect((m['sgReady'] as Map?) ?? const {}, isEmpty, + reason: '$mecanisme : sgReady interdit'); + } + if (mecanisme == 'sg-ready') { + expect((m['powerLevels'] as List?) ?? const [], isEmpty, + reason: 'sg-ready : powerLevels interdit'); + expect((m['maxPowerW'] as num?) ?? 0, 0, + reason: 'sg-ready : maxPowerW interdit'); + } +} + +void main() { + group('routeur → SG-Ready', () { + test('les relais partent, les deux premiers deviennent SG1 et SG2', () { + final m = basculerMecanisme(_routeur(), 'sg-ready'); + _chargesUtilesEtrangeresVides(m, 'sg-ready'); + + final e = LoadConfigEntry(m); + expect(e.adapter, 'sg-ready'); + expect(e.mode, 'fixed'); // la box refuse toute autre valeur sur ce mécanisme + final parEtat = {for (final s in e.sgReadyStates) s.state: s.relays}; + expect(parEtat[1], [_k1]); + expect(parEtat[2], isEmpty, reason: 'l\'état 2 doit rester exprimable'); + expect(parEtat[3], [_k2]); + expect(parEtat[4], [_k1, _k2]); + }); + + test('le maintien d\'état par défaut est celui d\'une PAC RÉELLE, pas du banc', () { + final e = LoadConfigEntry(basculerMecanisme(_routeur(), 'sg-ready')); + expect(e.minStateHoldS, 900, + reason: 'mieux vaut une protection trop longue qu\'un court-cycle'); + }); + + test('l\'identité et le classement survivent', () { + final e = LoadConfigEntry(basculerMecanisme(_routeur(), 'sg-ready')); + expect(e.id, 'chauffe-eau'); + expect(e.label, 'chauffe-eau'); + expect(e.domain, 'ecs'); + expect(e.priority, 1); + expect(e.dailyDeadline, '06:00', reason: 'needs n\'appartient à aucun mécanisme'); + }); + }); + + group('PAC → routeur', () { + test('les contacts redeviennent des relais, puissance À SAISIR', () { + final m = basculerMecanisme(_pac(), 'relay-router'); + _chargesUtilesEtrangeresVides(m, 'relay-router'); + + final e = LoadConfigEntry(m); + expect(e.adapter, 'relay-router'); + expect(e.relays.map((r) => r.thingId), [_k1, _k2]); + // Une estimation d'état n'est PAS la puissance d'une résistance : on ne la + // transpose pas, on laisse le formulaire réclamer. + expect(e.relays.every((r) => r.powerW == 0), isTrue); + }); + + test('la configuration produite est INCOMPLÈTE, et c\'est voulu', () { + final e = LoadConfigEntry(basculerMecanisme(_pac(), 'relay-router')); + // powerW ≤ 0 : la box refuserait. C'est au formulaire de le dire avant l'envoi. + expect(e.relays.any((r) => r.powerW <= 0), isTrue); + }); + }); + + group('→ modulable', () { + test('aucun contact ne survit, et le mode est TOUJOURS dynamic', () { + for (final source in [_routeur(), _pac()]) { + final m = basculerMecanisme(source, 'etmvariableload'); + _chargesUtilesEtrangeresVides(m, 'etmvariableload'); + final e = LoadConfigEntry(m); + expect(e.mode, 'dynamic', + reason: 'le multipalier natif est legacy : jamais produit'); + expect((m['powerLevels'] as List?) ?? const [], isEmpty, + reason: 'la box refuse powerLevels avec dynamic'); + } + }); + }); + + group('le mode fixed hérité se LIT sans être réécrit', () { + test('il est reconnu', () { + expect(estHeritageFixed(_heritage()), isTrue); + expect(estHeritageFixed(_routeur()), isFalse); + expect(estHeritageFixed(_pac()), isFalse); + }); + + test('ouvrir l\'écran ne convertit pas — seul un geste explicite le fait', () { + final avant = _heritage(); + // Rien n'est appelé : lire ne transforme pas. La conversion est un appel SÉPARÉ. + expect(avant.mode, 'fixed'); + expect(avant.powerLevelsInt, [0, 600, 1200, 1800, 2400]); + + final apres = LoadConfigEntry(convertirEnModulation(avant, 2400)); + expect(apres.mode, 'dynamic'); + expect(apres.maxPowerW, 2400); + expect(apres.powerLevelsInt, isEmpty, + reason: 'la box refuse powerLevels avec dynamic'); + }); + }); + + test('changer de mécanisme est une RECONSTRUCTION, jamais une mise à jour', () { + final avant = _routeur(); + final apres = LoadConfigEntry(basculerMecanisme(avant, 'sg-ready')); + + expect(sameHardware(avant, apres), isFalse); + final impact = impactOfSave(avant: [avant], apres: [apres]).single; + expect(impact.impact, LoadChangeImpact.rebuild); + expect(impact.changedFields, contains('adapter')); + // C'est CE nombre que l'écran doit annoncer avant de valider. + expect(impact.rearmSeconds, 900); + }); +} diff --git a/test/reorder_drag_test.dart b/test/reorder_drag_test.dart new file mode 100644 index 0000000..8308fdb --- /dev/null +++ b/test/reorder_drag_test.dart @@ -0,0 +1,208 @@ +/// Le glisser-déposer du rang — **est-ce le geste, ou l'imbrication ?** +/// +/// Constaté sur appareil le 2026-08-25 : déplacer une charge ne produit rien. Deux causes +/// possibles donnent exactement le même symptôme, et il faut les séparer avant de corriger +/// quoi que ce soit : +/// +/// 1. **il n'y avait rien à déplacer** — sur `.75` chaque domaine ne contient qu'une seule +/// charge, et glisser dans une liste d'un élément ne peut rien produire ; +/// 2. **le geste est avalé** — la section vit dans le `ListView` de l'écran, et un +/// `ReorderableListView` imbriqué dans un scrollable peut perdre le geste au profit du +/// parent. +/// +/// D'où deux montages : la section **seule**, puis la section **dans un `ListView`** comme +/// l'écran réel. +/// +/// **Verdict du 2026-08-26 : ni l'un ni l'autre.** Les deux montages glissent +/// identiquement — le `ListView` parent n'avale rien. La cause du symptôme était bien que +/// le banc n'a **qu'une charge par domaine**. +/// +/// Mais l'enquête a trouvé un **second défaut, réel celui-là** : le centre de la poignée +/// tombait dans le vide de 3 px séparant l'icône de la pastille de rang, et +/// `ReorderableDragStartListener` défère le test de collision à ses enfants. Une bande +/// morte invisible, au milieu d'une poignée qui paraît d'un seul tenant. Corrigé par un +/// `Container` transparent qui rend la zone entière préhensible — d'où le test qui saisit +/// délibérément le **centre du bloc**, pas l'icône. +library; + +import 'package:etm_powersync_app/l10n/app_localizations.dart'; +import 'package:etm_powersync_app/models/load_config_entry.dart'; +import 'package:etm_powersync_app/providers/load_config_provider.dart'; +import 'package:etm_powersync_app/providers/load_telemetry_provider.dart'; +import 'package:etm_powersync_app/services/load_priority.dart'; +import 'package:etm_powersync_app/services/nymea_service.dart'; +import 'package:etm_powersync_app/widgets/controllable_loads_section.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:provider/provider.dart'; + +/// Deux charges **dans le MÊME domaine** — la seule configuration où un glissement a un +/// sens. C'est précisément ce que le banc n'a pas. +List> _deuxDansUnDomaine() => [ + { + 'adapter': 'relay-router', + 'domain': 'ecs', + 'enabled': true, + 'id': 'chauffe-eau', + 'label': 'chauffe-eau', + 'maxPowerW': 0, + 'minOffS': 60, + 'minOnS': 60, + 'mode': 'fixed', + 'needs': {'dailyDeadline': '', 'minEnergyWhPerDay': 0}, + 'powerLevels': [], + 'priority': 1, + 'relays': [ + {'powerW': 2000, 'thingId': '{b033b212-1adb-4df0-ba2b-8fa477de52a2}'} + ], + 'sgReady': {}, + }, + { + 'adapter': 'relay-router', + 'domain': 'ecs', + 'enabled': true, + 'id': 'seche-serviettes', + 'label': 'Sèche-serviettes', + 'maxPowerW': 0, + 'minOffS': 60, + 'minOnS': 60, + 'mode': 'fixed', + 'needs': {'dailyDeadline': '', 'minEnergyWhPerDay': 0}, + 'powerLevels': [], + 'priority': 2, + 'relays': [ + {'powerW': 500, 'thingId': '{8538782f-2c8c-4a30-bfce-c8140f791c9b}'} + ], + 'sgReady': {}, + }, + ]; + +Widget _montage({ + required LoadConfigProvider config, + required bool dansUnListView, +}) { + final section = ControllableLoadsSection(onConfigure: (_, __) {}); + return MultiProvider( + providers: [ + ChangeNotifierProvider.value(value: config), + ChangeNotifierProvider.value(value: LoadTelemetryProvider()), + ChangeNotifierProvider.value(value: NymeaService()), + ], + child: MaterialApp( + locale: const Locale('fr'), + localizationsDelegates: L10n.localizationsDelegates, + supportedLocales: L10n.supportedLocales, + home: Scaffold( + body: dansUnListView + // Reproduit l'écran réel : la section est un enfant du ListView de + // « Rôles & appareils ». + ? ListView(children: [section]) + : SingleChildScrollView(child: section), + ), + ), + ); +} + +/// Ordre des charges du premier domaine, tel que la vue le porte. +List _ordre(LoadConfigProvider p) => + [for (final g in p.groups) ...g.loads.map((e) => e.id)]; + +/// Glisse une poignée désignée par sa clé, par-dessus la carte [parDessus]. +/// +/// La distance se CALCULE depuis la hauteur réelle de la carte visée : une valeur en dur +/// devient fausse dès qu'on ajoute une ligne à la carte, et le test échoue alors pour une +/// raison qui n'a rien à voir avec le geste. C'est exactement ce qui est arrivé. +Future _glisserCle( + WidgetTester tester, String cle, String parDessus) async { + final poignee = find.byKey(ValueKey(cle)); + expect(poignee, findsOneWidget, reason: 'poignée « $cle » introuvable'); + final cible = find.byKey(ValueKey(parDessus)); + expect(cible, findsOneWidget, reason: 'cible « $parDessus » introuvable'); + final dy = tester.getSize(cible).height * 2; + final g = await tester.startGesture(tester.getCenter(poignee)); + // ReorderableDragStartListener accepte le geste immédiatement : pas d'appui long. + await tester.pump(const Duration(milliseconds: 40)); + for (var i = 0; i < 12; i++) { + await g.moveBy(Offset(0, dy / 12)); + await tester.pump(const Duration(milliseconds: 16)); + } + await g.up(); + await tester.pumpAndSettle(); +} + +/// La configuration RÉELLE du banc : une charge par domaine. C'est là que le glissement +/// d'une carte ne peut rien produire — et que celui des domaines doit prendre le relais. +List> _uneParDomaine() { + final l = _deuxDansUnDomaine(); + l[1] = {...l[1], 'domain': 'hvac', 'id': 'pac-terrain', 'label': 'PAC banc'}; + return l; +} + +void main() { + testWidgets('section SEULE — glisser la 1re charge sous la 2e réordonne', + (tester) async { + final config = LoadConfigProvider()..adoptForTest(_deuxDansUnDomaine()); + expect(_ordre(config), ['chauffe-eau', 'seche-serviettes']); + + await tester.pumpWidget(_montage(config: config, dansUnListView: false)); + await tester.pumpAndSettle(); + + await _glisserCle(tester, 'poignee-charge-chauffe-eau', 'load-seche-serviettes'); + + expect(_ordre(config), ['seche-serviettes', 'chauffe-eau'], + reason: 'le glissement doit produire un nouvel ordre'); + expect(config.hasPendingChanges, isTrue); + }); + + testWidgets('section DANS UN LISTVIEW — le geste survit-il à l\'imbrication ?', + (tester) async { + final config = LoadConfigProvider()..adoptForTest(_deuxDansUnDomaine()); + + await tester.pumpWidget(_montage(config: config, dansUnListView: true)); + await tester.pumpAndSettle(); + + await _glisserCle(tester, 'poignee-charge-chauffe-eau', 'load-seche-serviettes'); + + expect(_ordre(config), ['seche-serviettes', 'chauffe-eau'], + reason: 'si CE test échoue seul, le ListView parent avale le geste'); + }); + + testWidgets('une charge par domaine — la poignée n\'agrippe pas, et le dit', + (tester) async { + // Le cas du banc, et celui qui donnait l'impression que « rien ne fonctionne » : il + // n'y a rien à réordonner à l'intérieur d'un domaine d'une seule charge. + final config = LoadConfigProvider()..adoptForTest(_uneParDomaine()); + expect(_ordre(config), ['chauffe-eau', 'pac-terrain']); + + await tester.pumpWidget(_montage(config: config, dansUnListView: true)); + await tester.pumpAndSettle(); + + // La poignée existe — le rang reste lisible — mais elle n'offre AUCUNE prise. + for (final id in ['chauffe-eau', 'pac-terrain']) { + final poignee = find.byKey(ValueKey('poignee-charge-$id')); + expect(poignee, findsOneWidget); + expect( + find.descendant( + of: poignee, matching: find.byType(ReorderableDragStartListener)), + findsNothing, + reason: 'seule dans son domaine, la charge ne doit pas faire croire ' + 'qu\'on peut la déplacer', + ); + } + + // Et l'ordre se change bien par les flèches de domaine. + config.reorderDomains(0, 2); + expect(_ordre(config), ['pac-terrain', 'chauffe-eau']); + }); + + test('le rang envoyé à la box se renumérote depuis la POSITION', () { + final config = LoadConfigProvider()..adoptForTest(_deuxDansUnDomaine()); + config.reorderWithinDomain('ecs', 0, 2); // 1re → après la 2e + + final payload = flattenToPayload(config.groups); + expect([for (final m in payload) m['id']], + ['seche-serviettes', 'chauffe-eau']); + // C'est bien la POSITION qui fait le rang, pas la valeur lue sur la box. + expect([for (final m in payload) m['priority']], [1, 2]); + }); +}