diff --git a/lib/screens/energy_screen.dart b/lib/screens/energy_screen.dart index e5c2305..7bcdc13 100644 --- a/lib/screens/energy_screen.dart +++ b/lib/screens/energy_screen.dart @@ -351,7 +351,7 @@ class _EnergyScreenState extends State { const _LegendDot(EtmTokens.brand, 'Reste du foyer'), for (final s in _conso.series) _LegendDot( - EtmTokens.domaineOf(context, s.domaine) ?? + _couleursSeries(context)[s.thingId] ?? EtmTokens.mutedOf(context), s.label), ], @@ -611,6 +611,27 @@ class _EnergyScreenState extends State { /// le reste devient **négatif**. On ne le rabote pas à zéro : un reste négatif est la /// preuve d'un périmètre faux, et l'écraser effacerait la seule chose qui le dise. /// « Qu'est-ce que ce contrôle aurait attrapé s'il avait échoué ? » — exactement ça. + /// La couleur de chaque série, **rang dans son domaine compris**. + /// + /// Calculée à un seul endroit pour que la légende et le tracé ne puissent pas diverger : + /// deux sources de couleur, c'est une légende qui finit par désigner la mauvaise bande. + Map _couleursSeries(BuildContext context) { + final rangs = {}; + final out = {}; + for (final s in _conso.series) { + final base = EtmTokens.domaineOf(context, s.domaine); + if (base == null) { + out[s.thingId] = EtmTokens.mutedOf(context); + continue; + } + final cle = s.domaine ?? ''; + final rang = rangs[cle] ?? 0; + rangs[cle] = rang + 1; + out[s.thingId] = EtmTokens.nuance(base, rang); + } + return out; + } + Widget _buildConsumerChart() { if (_loading || _noData || _data.isEmpty) return const SizedBox.shrink(); if (_conso.estVide) return const SizedBox.shrink(); @@ -651,6 +672,7 @@ class _EnergyScreenState extends State { final resteNegatif = restes.any((v) => v < -1); final alignees = series; + final couleurs = _couleursSeries(context); final bandes = <({String label, Color couleur, List valeurs})>[ ( label: 'Reste du foyer', @@ -660,8 +682,7 @@ class _EnergyScreenState extends State { for (final s in alignees) ( label: s.label, - couleur: EtmTokens.domaineOf(context, s.domaine) ?? - EtmTokens.mutedOf(context), + couleur: couleurs[s.thingId] ?? EtmTokens.mutedOf(context), valeurs: [for (int i = 0; i < n; i++) valeurAt(s, i) ?? 0.0] ), ]; diff --git a/lib/theme/etm_tokens.dart b/lib/theme/etm_tokens.dart index 1a63fa1..b6bc164 100644 --- a/lib/theme/etm_tokens.dart +++ b/lib/theme/etm_tokens.dart @@ -108,6 +108,29 @@ class EtmTokens { static Color ecsOf(BuildContext c) => _isDark(c) ? ecsDark : ecs; static Color evOf(BuildContext c) => _isDark(c) ? evDark : ev; + /// **Décliner la couleur d'un domaine quand il porte plusieurs charges.** + /// + /// Deux bornes dans le domaine `ev` recevaient exactement le même violet : indiscernables + /// dans l'empilement, et la légende répétait la même pastille deux fois. Vu à l'écran le + /// 2026-09-08, dès qu'une seconde borne est apparue. + /// + /// La nuance se calcule sur le RANG dans le domaine, donc une troisième borne prend sa + /// teinte toute seule — rien à déclarer. On joue sur la **luminosité** en HSL et pas sur + /// la teinte : glisser la teinte finirait par heurter le domaine voisin, alors que + /// l'éclaircir garde la famille lisible. + /// + /// ⚠️ **La série est bornée à quatre.** Au-delà, l'écart de luminosité devient trop fin + /// pour être vu, et prétendre distinguer huit nuances d'un même violet serait une + /// promesse que l'œil ne tient pas. À partir de la cinquième charge d'un même domaine, la + /// couleur cesse d'identifier — il faudra autre chose (étiquette au survol, séparation + /// des domaines), et c'est un vrai choix d'écran, pas un réglage de palette. + static Color nuance(Color base, int rang) { + if (rang <= 0) return base; + final h = HSLColor.fromColor(base); + final pas = 0.11 * (rang.clamp(0, 3)); + return h.withLightness((h.lightness + pas).clamp(0.0, 0.85)).toColor(); + } + /// La couleur d'un domaine de charge, ou `null` s'il n'en a pas. /// /// `null` n'est pas un défaut à combler : une charge sans domaine déclaré n'a pas de diff --git a/test/nuance_domaine_test.dart b/test/nuance_domaine_test.dart new file mode 100644 index 0000000..d272cd0 --- /dev/null +++ b/test/nuance_domaine_test.dart @@ -0,0 +1,55 @@ +/// **Deux charges d'un même domaine ne peuvent pas porter la même couleur.** +/// +/// Vu à l'écran le 2026-09-08 : la V2C et la borne simulée, toutes deux en `ev`, recevaient +/// exactement le même violet — indiscernables dans l'empilement, et la légende répétait la +/// même pastille. La nuance se calcule sur le RANG dans le domaine, donc une troisième +/// borne prend sa teinte sans qu'on déclare quoi que ce soit. +library; + +import 'package:etm_powersync_app/theme/etm_tokens.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + const base = EtmTokens.ev; + + test('le rang 0 ne touche pas à la couleur du domaine', () { + expect(EtmTokens.nuance(base, 0), base); + }); + + test('trois charges d\'un même domaine donnent trois couleurs DISTINCTES', () { + final c = [for (int i = 0; i < 3; i++) EtmTokens.nuance(base, i)]; + expect(c.toSet(), hasLength(3), + reason: 'une troisième borne doit se distinguer sans rien déclarer'); + }); + + test('la luminosité monte, la teinte ne bouge pas', () { + // Glisser la teinte finirait par heurter le domaine voisin ; l'éclaircir garde la + // famille lisible. + final h0 = HSLColor.fromColor(EtmTokens.nuance(base, 0)); + final h2 = HSLColor.fromColor(EtmTokens.nuance(base, 2)); + expect(h2.lightness, greaterThan(h0.lightness)); + expect((h2.hue - h0.hue).abs(), lessThan(1.0)); + }); + + test('la série est BORNÉE, et ne prétend pas distinguer l\'indistinguable', () { + // Au-delà de quatre, l'écart de luminosité ne se voit plus. Le plafond est explicite + // plutôt que subi : le rang 3 et le rang 9 rendent la même couleur, et c'est le signe + // qu'il faut autre chose que la couleur pour identifier — pas une nuance de plus. + expect(EtmTokens.nuance(base, 9), EtmTokens.nuance(base, 3)); + }); + + testWidgets('un domaine inconnu n\'a pas de couleur — et pas une couleur par défaut', + (tester) async { + // Une charge sans domaine déclaré n'est pas classée : lui donner une couleur de + // domaine la ferait passer pour classée. + late BuildContext ctx; + await tester.pumpWidget(MaterialApp(home: Builder(builder: (c) { + ctx = c; + return const SizedBox(); + }))); + expect(EtmTokens.domaineOf(ctx, null), isNull); + expect(EtmTokens.domaineOf(ctx, 'inconnu'), isNull); + expect(EtmTokens.domaineOf(ctx, 'ev'), isNotNull); + }); +}