feat(config): « Placer en dernier » — le seul geste qui sort une charge de sa plage

Manque trouvé en usage, pas en test : les flèches déplacent une charge DANS son bloc, le
glissement déplace le bloc ENTIER. Rien ne sortait une charge de son groupe. Une borne
regroupée avec les autres devenait donc prisonnière du bloc — et depuis que « Regrouper par
domaine » existe, ce cas se produit d'un simple geste.

Le bouton la renvoie en queue, seule dans sa plage. C'est la position qu'une borne détectée
reçoit d'office depuis +etm27 (1 + max), donc un repère connu — et une fois seule, le
glissement de plage la porte partout ailleurs. Ça rend la liberté de placement sans
inventer un glisser-déposer entre blocs, qui demanderait de viser un point d'insertion au
pixel près sur un téléphone.

Brouillon seulement : rien ne part avant save(). Masqué quand la charge y est déjà — un
bouton qui ne fait rien use la confiance dans ceux qui font quelque chose. Le contrôle
porte sur l'état AVANT retrait : après, la plage d'origine a pu disparaître et la
comparaison ne veut plus rien dire (première version fausse, prise par son propre test).

⚠️ Et une assertion Flutter trouvée par les tests de glissement, dont la cause est à trois
widgets de son symptôme : `TextButton.styleFrom(textStyle:)` porte `inherit: true` là où le
style du thème porte `inherit: false`, et Flutter refuse d'interpoler les deux pendant une
animation — « Failed to interpolate TextStyles with different inherit values ». Le style va
donc sur le LIBELLÉ, pas dans le ButtonStyle. Les deux boutons posés hier (« Regrouper par
domaine », « Ce rang me convient ») portaient la même bombe à retardement : corrigés aussi.

231 tests (+5), flutter analyze inchangé à 27 remarques.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
This commit is contained in:
Patrick Schurig 2026-08-29 11:48:58 +02:00
parent a00a9209e9
commit 462ca4a2fe
3 changed files with 158 additions and 4 deletions

View File

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

View File

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

View File

@ -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<Map<String, dynamic>> 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']);
});
});
}