fix(ergonomie): les cibles tactiles étaient à 26 px — trouvé au premier passage sur appareil

Le test d'appareil passe. Il a trouvé deux défauts réels que 129 tests unitaires ne
pouvaient pas voir, parce qu'aucun ne rend ces widgets à une taille d'écran réelle.

1. LA POIGNÉE DE GLISSEMENT MESURAIT 26 × 27 px, pour un minimum Material de 48. Le
   Container transparent posé lors de l'enquête précédente avait supprimé la bande morte
   entre l'icône et le reste, mais il n'avait jamais AGRANDI la cible : le geste accrochait
   quand on visait juste et ratait sinon. C'est le seul geste de cet écran sans repli —
   sans lui, l'ordre des domaines ne se change pas du tout.

2. LES FLÈCHES DE RANG MESURAIENT 18 × 18 px, la taille de l'icône, sans aucune marge de
   touche. Elles n'ont pas de repli non plus : réordonner DANS un domaine ne se fait que
   par elles.

Portées à 48 et 44. Et la capture d'appareil a montré le coût de la correction naïve :
empilées, deux cibles de 44 font 88 px de haut pour deux icônes de 18, et la colonne de
rang devient une colonne vide. Côte à côte, la hauteur retombe à 44 et la largeur ne coûte
rien au libellé. La cible tactile est la contrainte ; la disposition ne l'est pas.

Le harnais a lui-même demandé trois corrections avant de dire la vérité, et chacune est un
piège qui se reproduira :

- il regardait le HAUT de l'écran. Dans un ListView, ce qui n'est pas visible n'est pas
  construit : les cibles n'étaient pas mesurables faute d'être montées.
- `find` trouve un widget POSÉ MAIS HORS VIEWPORT, et getCenter en rend une coordonnée qui
  n'existe pas — mesuré y=1129 sur un écran de 825 px. Le geste partait dans le vide, avec
  un symptôme identique à un glissement ignoré.
- la course était devinée. Une liste réordonnable ne permute qu'une fois passé le milieu du
  suivant, et un bloc de domaine porte ses cartes : 384 px mesurés, contre les ~150 devinés.
  Elle se calcule maintenant depuis l'écart réel entre deux poignées.

Le test mesure aussi le défilement de la page pendant le glissement : si le ListView
extérieur gagnait l'arène, le symptôme serait le même que celui d'une poignée qui
n'accroche pas, pour une cause opposée. Mesuré à 0 px — c'est bien la poignée qui prend.

Aucun débordement de mise en page sur cet écran. Deux captures d'appareil jointes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
This commit is contained in:
Patrick Schurig 2026-08-27 15:25:23 +02:00
parent da0c87e303
commit 01fdad73c5
4 changed files with 133 additions and 34 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 257 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 359 KiB

View File

@ -152,6 +152,19 @@ void main() {
debugPrint('[appareil] note de rang : ${taille.width}×${taille.height} px');
}
// Descendre jusqu'aux charges : dans un `ListView`, ce qui n'est pas à l'écran n'est
// pas construit, et l'en-tête des rôles, la ligne de schéma puis la carte de réserve
// batterie occupent tout le haut. Sans ce défilement, la capture ne montre pas la
// section qu'on prétend regarder — et les cibles tactiles ne sont pas mesurables,
// faute d'être montées.
for (var pas = 0; pas < 25; pas++) {
if (find.text('Configurer').evaluate().length >= 2) break;
await tester.drag(find.byType(Scrollable).first, const Offset(0, -220));
await tester.pump(const Duration(milliseconds: 200));
}
expect(find.text('Configurer'), findsWidgets,
reason: 'les cartes de charge doivent être atteignables');
await _tenir(tester, 'liste des charges + note de rang');
// ── 3. Les cibles tactiles, mesurées à la taille d'écran RÉELLE ──────────
@ -168,6 +181,21 @@ void main() {
reason: 'la poignée de glissement est le seul geste sans repli de cet écran : '
'sous $_cibleMini px, un pouce la rate');
// Les flèches de rang — l'autre commande tactile de cet écran, et elle n'a pas de
// repli non plus : réordonner DANS un domaine ne se fait que par elles.
final fleches = find.byWidgetPredicate(
(w) => w is InkWell && w.borderRadius != null, skipOffstage: true);
var minFleche = double.infinity;
for (var i = 0; i < fleches.evaluate().length; i++) {
final t = tester.getSize(fleches.at(i));
if (t.width > 200) continue; // pas une flèche : une carte cliquable
minFleche = [minFleche, t.width, t.height].reduce((a, b) => a < b ? a : b);
}
if (minFleche.isFinite) {
debugPrint('[appareil] plus petite flèche de rang : $minFleche px');
expect(minFleche, greaterThanOrEqualTo(_cibleMini));
}
// ── 4. Le glissement AU DOIGT — geste réel, écriture abandonnée ──────────
//
// `tester.drag` pose le pointeur et le relâche en un coup ; une liste réordonnable
@ -176,18 +204,57 @@ void main() {
// un geste avalé lors de l'enquête précédente.
final ordreAvant = config.groups.map((g) => g.domain).toList();
if (ordreAvant.length >= 2) {
final depart = tester.getCenter(poignees.first);
final geste = await tester.startGesture(depart);
await tester.pump(const Duration(milliseconds: 300)); // arme le long-press
for (var i = 0; i < 12; i++) {
await geste.moveBy(const Offset(0, 12));
await tester.pump(const Duration(milliseconds: 30));
// AMENER la première poignée dans l'écran, et le vérifier.
//
// Piège payé deux fois : `find` trouve un widget **posé mais hors du viewport**, et
// `getCenter` en rend alors une coordonnée qui n'existe pas à l'écran (mesuré :
// y = 1129 sur un écran de ${tester.view.physicalSize.height ~/ tester.view.devicePixelRatio} px de haut).
// Le geste part dans le vide et le symptôme est identique à un glissement ignoré.
// On positionne donc jusqu'à ce que la poignée soit RÉELLEMENT visible.
final hauteur =
tester.view.physicalSize.height / tester.view.devicePixelRatio;
for (var pas = 0; pas < 30; pas++) {
final y = tester.getCenter(poignees.at(0)).dy;
if (y > 140 && y < hauteur * 0.4) break;
await tester.drag(
find.byType(Scrollable).first, Offset(0, y > hauteur * 0.4 ? -160 : 160));
await tester.pump(const Duration(milliseconds: 150));
}
// La distance n'est PAS arbitraire : une liste réordonnable ne permute qu'une fois
// l'élément traîné passé le milieu du suivant. Un bloc de domaine porte ses cartes,
// donc il est haut — plusieurs centaines de pixels. Un déplacement « de quelques
// dizaines de pixels » ne franchit rien. On mesure donc l'écart réel entre deux
// poignées.
final p0 = tester.getCenter(poignees.at(0));
final p1 = tester.getCenter(poignees.at(1));
final course = (p1.dy - p0.dy) + 32;
debugPrint('[appareil] écran ${hauteur.round()} px · poignées à '
'${p0.dy.round()} et ${p1.dy.round()} · course ${course.round()} px');
expect(p0.dy, lessThan(hauteur),
reason: 'la poignée saisie doit être DANS l\'écran, pas seulement dans l\'arbre');
// Diagnostic : QUI prend le geste ? Si la page défile pendant le glissement, c'est
// le `ListView` extérieur qui a gagné l'arène — et le symptôme (« rien ne bouge »)
// est identique à celui d'une poignée qui n'accroche pas, pour une cause opposée.
double offsetPage() =>
tester.state<ScrollableState>(find.byType(Scrollable).first).position.pixels;
final avantOffset = offsetPage();
final geste = await tester.startGesture(p0);
await tester.pump(const Duration(milliseconds: 400)); // arme la prise
const pasDeGeste = 20;
for (var i = 0; i < pasDeGeste; i++) {
await geste.moveBy(Offset(0, course / pasDeGeste));
await tester.pump(const Duration(milliseconds: 40));
}
await tester.pump(const Duration(milliseconds: 300));
await geste.up();
await tester.pump(const Duration(milliseconds: 600));
await tester.pump(const Duration(seconds: 1));
final ordreApres = config.groups.map((g) => g.domain).toList();
debugPrint('[appareil] glissement : $ordreAvant → $ordreApres');
debugPrint('[appareil] glissement : $ordreAvant → $ordreApres · '
'défilement de la page ${(offsetPage() - avantOffset).round()} px');
expect(ordreApres, isNot(ordreAvant),
reason: 'le glissement au doigt doit réordonner les domaines');

View File

@ -200,12 +200,25 @@ class _DomainBlock extends StatelessWidget {
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(children: [
// Cible tactile de 48 px — le minimum Material, et il n'est pas décoratif.
//
// Mesurée à **26 × 27 px** sur le Redmi Note 9S au premier passage du test
// d'appareil. Le `Container` transparent posé lors de l'enquête précédente
// avait supprimé la bande morte entre l'icône et le reste, mais il n'avait
// pas AGRANDI la cible : le geste accrochait quand on visait juste, et ratait
// sinon. Aucun test unitaire ne pouvait le montrer — ils ne rendent pas ces
// widgets à une taille d'écran réelle.
//
// C'est le seul geste de cet écran qui n'a pas de repli : sans lui, l'ordre
// des domaines ne se change pas du tout.
ReorderableDragStartListener(
key: ValueKey('poignee-domaine-${group.domain}'),
index: index,
child: Container(
color: Colors.transparent,
padding: const EdgeInsets.only(right: 7, top: 4, bottom: 4),
width: 48,
height: 48,
alignment: Alignment.centerLeft,
child: Icon(Icons.drag_indicator_rounded, size: 19, color: muted),
),
),
@ -349,27 +362,37 @@ class LoadConfigCard extends StatelessWidget {
fontWeight: FontWeight.w700,
color: EtmTokens.brand)),
),
if (totalDansDomaine > 1) ...[
const SizedBox(height: 2),
_FlecheRang(
key: ValueKey('monter-${entry.id}'),
icone: Icons.keyboard_arrow_up_rounded,
actif: indexDansDomaine > 0,
onTap: () => provider.reorderWithinDomain(
entry.domain ?? kUnclassifiedDomain,
indexDansDomaine,
indexDansDomaine - 1),
),
_FlecheRang(
key: ValueKey('descendre-${entry.id}'),
icone: Icons.keyboard_arrow_down_rounded,
actif: indexDansDomaine < totalDansDomaine - 1,
onTap: () => provider.reorderWithinDomain(
entry.domain ?? kUnclassifiedDomain,
indexDansDomaine,
indexDansDomaine + 2),
),
],
// Les deux flèches CÔTE À CÔTE, pas empilées.
//
// Empilées, deux cibles de 44 px font 88 px de haut pour deux icônes de
// 18 : la colonne de rang devient une colonne vide, constaté à la
// capture d'appareil. Côte à côte, la hauteur retombe à 44 et la
// largeur — 88 px sur une carte pleine largeur — ne coûte rien au
// libellé.
//
// La cible tactile, elle, ne bouge pas : c'est la contrainte, pas la
// disposition.
if (totalDansDomaine > 1)
Row(mainAxisSize: MainAxisSize.min, children: [
_FlecheRang(
key: ValueKey('monter-${entry.id}'),
icone: Icons.keyboard_arrow_up_rounded,
actif: indexDansDomaine > 0,
onTap: () => provider.reorderWithinDomain(
entry.domain ?? kUnclassifiedDomain,
indexDansDomaine,
indexDansDomaine - 1),
),
_FlecheRang(
key: ValueKey('descendre-${entry.id}'),
icone: Icons.keyboard_arrow_down_rounded,
actif: indexDansDomaine < totalDansDomaine - 1,
onTap: () => provider.reorderWithinDomain(
entry.domain ?? kUnclassifiedDomain,
indexDansDomaine,
indexDansDomaine + 2),
),
]),
]),
),
Expanded(
@ -783,12 +806,21 @@ class _FlecheRang extends StatelessWidget {
@override
Widget build(BuildContext context) {
final muted = EtmTokens.mutedOf(context);
// 44 px, pas 48. Les deux flèches sont EMPILÉES dans la colonne de rang, sous le
// badge : à 48 chacune, la carte gagne près de 100 px de haut pour deux commandes
// secondaires. 44 est le plancher au-dessous duquel un pouce rate régulièrement, et
// il tient dans la carte. Mesurées à **18 × 18 px** au premier passage sur appareil —
// la taille de l'icône, sans aucune marge de touche.
return InkWell(
onTap: actif ? onTap : null,
borderRadius: BorderRadius.circular(4),
child: Icon(icone,
size: 18,
color: actif ? muted : muted.withValues(alpha: 0.3)),
borderRadius: BorderRadius.circular(8),
child: SizedBox(
width: 44,
height: 44,
child: Icon(icone,
size: 18,
color: actif ? muted : muted.withValues(alpha: 0.3)),
),
);
}
}