diff --git a/lib/providers/load_config_provider.dart b/lib/providers/load_config_provider.dart index 65f0f2b..167d590 100644 --- a/lib/providers/load_config_provider.dart +++ b/lib/providers/load_config_provider.dart @@ -138,6 +138,64 @@ class LoadConfigProvider extends ChangeNotifier { notifyListeners(); } + /// **Placer une charge en dernier** — la sortir de sa plage, seule, en queue. + /// + /// ## Le manque qu'elle comble + /// + /// Les flèches déplacent une charge **dans sa plage** ; le glissement déplace une plage + /// **entière**. Rien ne sortait une charge de son groupe : une borne regroupée avec les + /// autres ne pouvait plus être renvoyée en queue, et son rang devenait prisonnier du + /// bloc. Le geste manquait d'autant plus que le regroupement, lui, existe. + /// + /// ## Pourquoi « en dernier » et pas « n'importe où » + /// + /// C'est la position qu'une borne détectée reçoit d'office depuis `+etm27` (`1 + max`), + /// donc un repère que l'installateur connaît. Et une fois la charge **seule dans sa + /// plage**, le glissement de plage la porte partout ailleurs : ce geste rend la liberté + /// de placement sans qu'il faille inventer un glisser-déposer entre blocs, qui + /// demanderait de choisir un point d'insertion au pixel près sur un téléphone. + /// + /// Brouillon seulement : rien ne part avant [save]. + void moveToTail(String loadId) { + // Déjà la dernière de la dernière plage : ne rien faire plutôt que de poser un + // brouillon identique, qui allumerait la barre d'enregistrement pour une écriture sans + // effet. Le test porte sur l'état AVANT retrait — après, la plage d'origine a pu + // disparaître, et la comparaison ne veut plus rien dire. + final avant = groups; + if (avant.isNotEmpty && + avant.last.loads.isNotEmpty && + avant.last.loads.last.id == loadId) { + return; + } + + final current = [for (final g in groups) g.copyWith(loads: [...g.loads])]; + + LoadConfigEntry? deplacee; + for (final g in current) { + final i = g.loads.indexWhere((e) => e.id == loadId); + if (i >= 0) { + deplacee = g.loads.removeAt(i); + break; + } + } + if (deplacee == null) return; + current.removeWhere((g) => g.loads.isEmpty); + + final domaine = deplacee.domain ?? kUnclassifiedDomain; + // Elle rejoint la dernière plage si celle-ci porte son domaine — sinon elle ouvre la + // sienne. Deux plages de même domaine collées seraient artificielles : elles n'en font + // qu'une à la relecture, puisqu'une plage est une suite contiguë. + if (current.isNotEmpty && current.last.domain == domaine) { + current[current.length - 1] = + current.last.copyWith(loads: [...current.last.loads, deplacee]); + } else { + current.add(DomainGroup(domain: domaine, loads: [deplacee])); + } + + _draft = current; + notifyListeners(); + } + /// **Regrouper par domaine, à la demande** — jamais à l'enregistrement. /// /// Rassemble les plages d'un même domaine à la position de leur première apparition, et diff --git a/lib/widgets/controllable_loads_section.dart b/lib/widgets/controllable_loads_section.dart index de98882..008ebc6 100644 --- a/lib/widgets/controllable_loads_section.dart +++ b/lib/widgets/controllable_loads_section.dart @@ -216,11 +216,11 @@ class _NoteOrdreEntrelace extends StatelessWidget { child: TextButton.icon( onPressed: provider.groupByDomainNow, icon: const Icon(Icons.merge_rounded, size: 16), - label: const Text('Regrouper par domaine'), + label: const Text('Regrouper par domaine', + style: TextStyle(fontSize: 12)), style: TextButton.styleFrom( visualDensity: VisualDensity.compact, foregroundColor: EtmTokens.brand, - textStyle: const TextStyle(fontSize: 12), ), ), ), @@ -351,12 +351,12 @@ class _BadgeRangDOfficeState extends State<_BadgeRangDOffice> { height: 11, child: CircularProgressIndicator(strokeWidth: 2)) : const Icon(Icons.check_rounded, size: 15), - label: const Text('Ce rang me convient'), + label: const Text('Ce rang me convient', + style: TextStyle(fontSize: 11.5)), style: TextButton.styleFrom( visualDensity: VisualDensity.compact, padding: const EdgeInsets.symmetric(horizontal: 6), foregroundColor: EtmTokens.brand, - textStyle: const TextStyle(fontSize: 11.5), ), ), ), @@ -664,6 +664,12 @@ class LoadConfigCard extends StatelessWidget { final muted = EtmTokens.mutedOf(context); final live = telemetry.entryFor(entry.id); + // Dernière charge de la dernière plage : elle est déjà en queue de l'ordre de service. + final plages = provider.groups; + final estDejaEnQueue = plages.isNotEmpty && + plages.last.loads.isNotEmpty && + plages.last.loads.last.id == entry.id; + return Container( width: double.infinity, padding: const EdgeInsets.all(14), @@ -771,6 +777,29 @@ class LoadConfigCard extends StatelessWidget { entry: entry, provider: provider), const Spacer(), + // « Placer en dernier » — le seul geste qui SORT une charge de sa plage. + // + // Les flèches la déplacent dans son bloc, le glissement déplace le bloc + // entier : sans lui, une charge regroupée était prisonnière de son groupe. + // Une fois seule en queue, le glissement de plage la porte partout ailleurs. + // + // Masqué quand elle y est déjà : un bouton qui ne fait rien use la confiance + // dans ceux qui font quelque chose. + if (!estDejaEnQueue) + TextButton.icon( + onPressed: () => provider.moveToTail(entry.id), + icon: const Icon(Icons.vertical_align_bottom_rounded, size: 16), + // ⚠️ Le style va sur le LIBELLÉ, pas dans `styleFrom(textStyle:)` : celui-ci + // porte `inherit: true` là où le thème porte `inherit: false`, et Flutter + // refuse d'interpoler les deux — l'assertion tombe pendant l'animation du + // glissement, très loin de sa cause. + label: const Text('Placer en dernier', + style: TextStyle(fontSize: 11.5)), + style: TextButton.styleFrom( + visualDensity: VisualDensity.compact, + foregroundColor: EtmTokens.mutedOf(context), + ), + ), TextButton.icon( onPressed: () => onConfigure(context, entry), icon: const Icon(Icons.tune_rounded, size: 17), diff --git a/test/rangs_distincts_test.dart b/test/rangs_distincts_test.dart index 9f7a076..9727aa8 100644 --- a/test/rangs_distincts_test.dart +++ b/test/rangs_distincts_test.dart @@ -16,6 +16,7 @@ import 'dart:convert'; import 'dart:io'; 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/services/load_priority.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -204,4 +205,70 @@ void main() { }); }); + + group('sortir une charge de sa plage — « Placer en dernier »', () { + /// L'ordre du banc au 2026-08-29, une fois REGROUPÉ : la wallbox 2 est prisonnière du + /// bloc `ev`, et plus rien ne l'en sort — c'est le manque que ce geste comble. + List> groupe() => [ + {'id': 'chauffe-eau', 'label': 'chauffe-eau', 'priority': 1, 'domain': 'ecs'}, + {'id': 'v2c', 'label': 'V2C', 'priority': 2, 'domain': 'ev'}, + {'id': 'sim1', 'label': 'wallbox', 'priority': 3, 'domain': 'ev'}, + {'id': 'sim2', 'label': 'wallbox 2', 'priority': 4, 'domain': 'ev'}, + {'id': 'pac', 'label': 'PAC', 'priority': 5, 'domain': 'heating'}, + ]; + + test('la charge quitte sa plage et rejoint la queue', () { + final p = LoadConfigProvider()..adoptForTest(groupe()); + p.moveToTail('sim2'); + + final payload = flattenToPayload(p.groups); + expect([for (final m in payload) m['id']], + ['chauffe-eau', 'v2c', 'sim1', 'pac', 'sim2']); + expect(payload.last['priority'], 5); + // Elle est SEULE dans sa plage : le glissement de plage la portera partout ailleurs. + expect(p.groups.last.loads.length, 1); + expect(p.groups.last.loads.single.id, 'sim2'); + }); + + test('rien ne part : c\'est un brouillon', () { + final p = LoadConfigProvider()..adoptForTest(groupe()); + p.moveToTail('sim2'); + expect(p.hasPendingChanges, isTrue); + // La configuration adoptée, elle, n'a pas bougé. + expect(p.entries.firstWhere((e) => e.id == 'sim2').priority, 4); + }); + + test('déjà en queue : aucun brouillon — un bouton qui ne fait rien use la confiance', + () { + final p = LoadConfigProvider()..adoptForTest(groupe()); + p.moveToTail('pac'); + expect(p.hasPendingChanges, isFalse); + }); + + test('les rangs restent distincts, et rien d\'autre ne bouge', () { + final p = LoadConfigProvider()..adoptForTest(groupe()); + p.moveToTail('v2c'); + + final payload = flattenToPayload(p.groups); + final rangs = [for (final m in payload) m['priority']]; + expect(rangs.toSet().length, rangs.length, reason: '+etm27 refuse les doublons'); + // Les autres gardent leur ordre relatif : sortir une charge n'est pas reclasser. + expect([for (final m in payload) m['id']], + ['chauffe-eau', 'sim1', 'sim2', 'pac', 'v2c']); + }); + + test('une charge seule dans sa plage RESTE sortable ailleurs qu\'en queue', () { + // Cas du banc avant regroupement : sim2 est déjà seule et déjà dernière. Le geste + // ne s'offre pas — mais le glissement de plage, lui, reste disponible. + final p = LoadConfigProvider()..adoptForTest([ + {'id': 'a', 'label': 'A', 'priority': 1, 'domain': 'ecs'}, + {'id': 'b', 'label': 'B', 'priority': 2, 'domain': 'ev'}, + ]); + p.moveToTail('b'); + expect(p.hasPendingChanges, isFalse); + p.reorderDomains(1, 0); + expect(p.hasPendingChanges, isTrue); + expect([for (final m in flattenToPayload(p.groups)) m['id']], ['b', 'a']); + }); + }); }