fix(config): glissement, sélecteur de mécanisme, chip de domaine, thème — les 4 points ouverts

1) GLISSEMENT — diagnostic d'abord, et il a donné DEUX réponses.

   Le geste n'est PAS avalé par le ListView parent : les deux montages du test
   (section seule / section imbriquée) glissent identiquement. La cause du symptôme était
   bien que .75 n'a qu'une charge par domaine — rien à réordonner.

   Mais l'enquête a trouvé un vrai défaut au passage : 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.

   Tentative faite et ANNULÉE : rendre les domaines glissables aussi. Deux
   ReorderableListView imbriqués se disputent le geste et l'extérieur gagne — le
   glissement des cartes cassait. Les domaines gardent leurs flèches, et une charge seule
   dans son domaine n'offre plus de prise qui mènerait nulle part (avec l'infobulle qui
   renvoie au bon geste).

   Vérifié SUR L'APPAREIL, contre .75, avec les deux charges mises dans le même domaine :
   [chauffe-eau, pac-terrain] → [pac-terrain, chauffe-eau], brouillon abandonné sans rien
   écrire. Classement ecs/hvac rétabli ensuite.

2) SÉLECTEUR DE MÉCANISME — actif, et c'était le morceau délicat.

   LoadConfig est une union discriminée : écrire `adapter` sans nettoyer le reste produit
   une configuration que la box refuse EN BLOC, et le refus emporte toutes les charges de
   l'appel. D'où basculerMecanisme(), qui retire les clés du mécanisme quitté et pose
   celles du nouveau, en récupérant ce qui peut l'être — les deux premiers contacts
   deviennent SG1/SG2 dans l'encodage standard, les relais gardent leur puissance.

   Ce qui n'est PAS transposé : l'estimation d'un état SG-Ready n'est pas la puissance
   d'une résistance. La charge produite est alors incomplète, et c'est voulu — le
   formulaire réclame plutôt que d'inventer une valeur de plaque.

   La bascule franchit sameHardware() : confirmation préalable qui annonce la
   reconstruction et la durée de réarmement (900 s par défaut vers SG-Ready — la valeur
   d'une PAC réelle, pas celle du banc).

   Le mode fixed d'etmvariableload reste LISIBLE sans être proposé : bloc « Configuration
   héritée » en lecture seule, et conversion seulement sur geste explicite, avec le
   plafond que la déclaration en paliers ne porte pas. Ouvrir l'écran ne convertit personne.

3) CHIP DE DOMAINE — il ne rendait qu'une icône muette : le domaine d'une charge était
   illisible sur sa carte, alors qu'il décide de son groupe et de son rang. Il porte
   maintenant son libellé, et sa couleur suit le thème.

4) THÈME — les 11 derniers sites (pas 9) basculés. Trois cas : contexte disponible
   (main, ac_screen) ; peintre sans contexte, à qui la couleur est passée résolue
   (energy_flow_widget) ; tables statiques, où la teinte devient nullable et se résout au
   rendu (categoryInfoMap, favoris). Plus AUCUN jeton hors thème dans lib/.

5) ZONES — non entreprises, comme convenu.

Tests 93/93, analyze 0 erreur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
This commit is contained in:
Patrick Schurig 2026-08-26 08:49:20 +02:00
parent 798bf2bdb5
commit aada8e2ba5
17 changed files with 1000 additions and 78 deletions

View File

@ -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 = <String, int>{};
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

View File

@ -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,
),
),

View File

@ -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<String, dynamic> 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,

View File

@ -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<ThingCategory, ThingCategoryInfo> 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<ThingCategory, ThingCategoryInfo> categoryInfoMap = {
category: ThingCategory.other,
label: 'Autres',
icon: Icons.device_hub_rounded,
color: EtmTokens.faint,
),
};

View File

@ -198,11 +198,13 @@ class _ACScreenState extends State<ACScreen> {
// ─────────────────────────── 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),

View File

@ -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,
),
),

View File

@ -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<LoadMechanismScreen> {
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<void> _demander(BuildContext context, String vers) async {
if (vers == entry.adapter) return;
final ok = await showDialog<bool>(
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<void> _convertir(BuildContext context) async {
final ctrl = TextEditingController(
text: entry.powerLevelsInt.isEmpty
? ''
: entry.powerLevelsInt.last.toString());
final w = await showDialog<int>(
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…'),
),
),
],
),

View File

@ -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);

View File

@ -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(

View File

@ -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);

View File

@ -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,
);

View File

@ -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)
/// <mécanisme> : 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<String> kMecanismes = ['relay-router', 'etmvariableload', 'sg-ready'];
/// Libellés d'interface. Des **codes** côté contrat, des mots côté écran.
const Map<String, String> 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<String, dynamic> basculerMecanisme(LoadConfigEntry e, String vers) {
assert(kMecanismes.contains(vers));
// Les contacts que l'on peut récupérer, tous mécanismes confondus.
final contacts = <String>[];
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 <String, dynamic>{},
powerLevels: const <int>[],
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 <Map<String, dynamic>>[],
sgReady: const <String, dynamic>{},
powerLevels: const <int>[],
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 <Map<String, dynamic>>[],
powerLevels: const <int>[],
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': <String>[], '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<String, dynamic> convertirEnModulation(LoadConfigEntry e, int maxPowerW) {
return e.patched(
adapter: 'etmvariableload',
mode: 'dynamic',
powerLevels: const <int>[],
relays: const <Map<String, dynamic>>[],
sgReady: const <String, dynamic>{},
maxPowerW: maxPowerW,
);
}

View File

@ -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);
}
}

View File

@ -85,13 +85,14 @@ class _EnergyFlowWidgetState extends State<EnergyFlowWidget>
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),
);
}

View File

@ -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<String>(
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<String>(
enabled: false,

View File

@ -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': <dynamic>[],
'priority': 1,
'relays': [
{'powerW': 500, 'thingId': _k1},
{'powerW': 1000, 'thingId': _k2},
{'powerW': 2000, 'thingId': _r3},
],
'sgReady': <String, dynamic>{},
});
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': <dynamic>[],
'priority': 2,
'relays': <dynamic>[],
'sgReady': {
'minStateHoldS': 300,
'states': [
{'state': 1, 'relays': [_k1], 'estimatedPowerW': 0.0},
{'state': 2, 'relays': <String>[], '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': <dynamic>[],
'sgReady': <String, dynamic>{},
});
/// Ce que la box exige : les charges utiles étrangères doivent être VIDES.
void _chargesUtilesEtrangeresVides(Map<String, dynamic> 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);
});
}

208
test/reorder_drag_test.dart Normal file
View File

@ -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<Map<String, dynamic>> _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': <dynamic>[],
'priority': 1,
'relays': [
{'powerW': 2000, 'thingId': '{b033b212-1adb-4df0-ba2b-8fa477de52a2}'}
],
'sgReady': <String, dynamic>{},
},
{
'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': <dynamic>[],
'priority': 2,
'relays': [
{'powerW': 500, 'thingId': '{8538782f-2c8c-4a30-bfce-c8140f791c9b}'}
],
'sgReady': <String, dynamic>{},
},
];
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<String> _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<void> _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<Map<String, dynamic>> _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]);
});
}