diff --git a/docs/captures/2026-08-27_charges-et-rangs-exaequo.png b/docs/captures/2026-08-27_charges-et-rangs-exaequo.png new file mode 100644 index 0000000..c858605 Binary files /dev/null and b/docs/captures/2026-08-27_charges-et-rangs-exaequo.png differ diff --git a/docs/captures/2026-08-27_mecanisme-borne.png b/docs/captures/2026-08-27_mecanisme-borne.png new file mode 100644 index 0000000..dfb4041 Binary files /dev/null and b/docs/captures/2026-08-27_mecanisme-borne.png differ diff --git a/integration_test/ui_3g2_on_device_test.dart b/integration_test/ui_3g2_on_device_test.dart index 7e26a6a..5371f13 100644 --- a/integration_test/ui_3g2_on_device_test.dart +++ b/integration_test/ui_3g2_on_device_test.dart @@ -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(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'); diff --git a/lib/widgets/controllable_loads_section.dart b/lib/widgets/controllable_loads_section.dart index c1cfcc6..82a9f54 100644 --- a/lib/widgets/controllable_loads_section.dart +++ b/lib/widgets/controllable_loads_section.dart @@ -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)), + ), ); } }