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:
parent
da0c87e303
commit
01fdad73c5
BIN
docs/captures/2026-08-27_charges-et-rangs-exaequo.png
Normal file
BIN
docs/captures/2026-08-27_charges-et-rangs-exaequo.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 257 KiB |
BIN
docs/captures/2026-08-27_mecanisme-borne.png
Normal file
BIN
docs/captures/2026-08-27_mecanisme-borne.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 359 KiB |
@ -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');
|
||||
|
||||
|
||||
@ -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)),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user