fix(brouillon): la barre suit — et le déplacement a révélé un défaut de contrainte

La barre d'action passe en pied d'écran fixe (`bottomNavigationBar`), parce
qu'un correctif juste mais hors du champ reste inaccessible : le geste se fait
en haut de la liste, la barre était un écran entier plus bas. C'est le défaut
d'hier sous une autre forme — cause de disposition au lieu de cause de logique.

`bottomNavigationBar` plutôt qu'une superposition : il RÉDUIT la hauteur du
corps au lieu de le recouvrir, donc rien ne se cache derrière la barre.

⚠️ ET LE DÉPLACEMENT A CASSÉ L'ÉCRAN. Le `Column` d'`ImpactSaveBar` était en
`MainAxisSize.max` — le défaut. Dans un `ListView` la hauteur disponible est
infinie et le Column se contente de ses enfants : le défaut ne pouvait pas se
voir. En pied d'écran la contrainte est lâche mais BORNÉE, le Column a pris
toute la hauteur, le corps a été écrasé à zéro. Écran vide sous une barre
géante, vu en release sur appareil — les 319 tests étaient verts.

Un widget qui ne se contraint pas lui-même hérite du contexte : le déplacer
change ses contraintes, donc sa taille, sans qu'une ligne de son code ait bougé.

Le test ajouté monte la barre dans un `bottomNavigationBar` et mesure sa
hauteur. Éprouvé par son contraire : sans `mainAxisSize: min` il rend 600 sur
600 et échoue. Aucun test précédent ne pouvait mordre — tous montaient la barre
dans une hauteur infinie, où `max` se comporte comme `min`.

Vérifié sur appareil, chemin des flèches : brouillon réel -> barre en bas avec
Annuler/Enregistrer, corps intact ; ordre rétabli -> « L'ordre est revenu à
celui de la box » avec le seul « Terminer » ; Terminer -> barre absente. Banc
inchangé, aucune écriture.

Et la note gardée sur `draftIsNoOp` : « rien à enregistrer » veut dire « rien
par rapport à ce que tu as vu ». Comparer à l'état vivant conclurait à la place
de l'utilisateur sur des faits qu'il n'a pas vus. Le référentiel de la
CONCLUSION et celui de l'ÉCRITURE sont deux choses, et il faut les deux.

Capture : docs/captures/2026-09-08_barre-en-pied-terminer.png

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
This commit is contained in:
Patrick Schurig 2026-09-08 08:30:11 +02:00
parent 346f162273
commit 1dece8eb33
5 changed files with 141 additions and 4 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 216 KiB

View File

@ -96,6 +96,16 @@ class LoadConfigProvider extends ChangeNotifier {
/// Aucun test ne pouvait le trouver — il faut faire le geste, puis le défaire, sur le
/// seul geste de cet écran qui n'a pas de repli.
///
/// ⚠️ **« Rien à enregistrer » veut dire « rien par rapport à ce que tu as vu ».** La
/// comparaison porte sur la dernière lecture de l'app, **pas sur l'état vivant de la
/// box**, et c'est délibéré : l'utilisateur conclut sur ce qu'il avait sous les yeux.
/// Comparer à l'état vivant conclurait à sa place sur des faits qu'il n'a pas vus — la
/// box a pu bouger pendant qu'il hésitait, et lui annoncer « finalement si, ça change »
/// désignerait une modification qui n'est pas la sienne.
///
/// L'écriture, elle, relit toujours frais avant d'envoyer (voir [save]) : le référentiel
/// de la CONCLUSION et celui de l'ÉCRITURE sont deux choses, et il faut les deux.
///
/// ⚠️ **Et surtout : ne pas effacer le brouillon tout seul quand il devient sans
/// effet.** La barre disparaîtrait sous le doigt au moment précis où l'utilisateur
/// repose la plage, et « j'ai décidé » redeviendrait « il ne s'est rien passé ». Un

View File

@ -110,6 +110,12 @@ class _RolesDevicesScreenState extends State<RolesDevicesScreen> {
foregroundColor: EtmTokens.inkOf(context),
elevation: 0,
),
// **Le brouillon se conclut sans avoir à le chercher.**
//
// `bottomNavigationBar` plutôt qu'une superposition : il RÉDUIT la hauteur du corps
// au lieu de le recouvrir, donc la dernière carte reste atteignable et rien ne se
// cache derrière la barre.
bottomNavigationBar: const _PiedBrouillon(),
body: ListView(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
children: [
@ -174,6 +180,39 @@ class _RolesDevicesScreenState extends State<RolesDevicesScreen> {
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Pied d'écran : la sortie du brouillon, toujours visible
// ═══════════════════════════════════════════════════════════════════════════
/// La barre d'action du brouillon, **ancrée en bas de l'écran**.
///
/// Elle vivait à la suite des cartes, dans `ControllableLoadsSection`. Sur le banc, cela
/// la plaçait un écran entier sous le geste qui la fait apparaître : on réordonnait en
/// haut, et la seule porte de sortie était hors du champ.
///
/// Le correctif du brouillon sans issue était donc juste **et inaccessible**. Une barre
/// d'action qui sort du champ au moment précis où on en a besoin, c'est le défaut d'origine
/// sous une autre forme — cause de disposition au lieu de cause de logique.
///
/// Rien quand il n'y a pas de brouillon : une barre permanente cesse d'être lue, et celle-ci
/// n'a rien à dire tant que rien n'attend.
class _PiedBrouillon extends StatelessWidget {
const _PiedBrouillon();
@override
Widget build(BuildContext context) {
final provider = context.watch<LoadConfigProvider>();
if (!provider.hasPendingChanges) return const SizedBox.shrink();
return SafeArea(
top: false,
child: Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 12),
child: ImpactSaveBar(provider: provider),
),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// En-tête compteur X/6 + état d'écran
// ═══════════════════════════════════════════════════════════════════════════

View File

@ -212,10 +212,15 @@ class _ControllableLoadsSectionState extends State<ControllableLoadsSection> {
// apparaissent dans leur domaine comme n'importe quelle charge. La section qui
// existait pour les rattraper les ferait maintenant apparaître DEUX fois, en
// affirmant qu'elles n'ont « ni rang ni domaine » — ce qui n'est plus vrai.
if (provider.hasPendingChanges) ...[
const SizedBox(height: 4),
ImpactSaveBar(provider: provider),
],
// **La barre n'est plus ICI** — elle est en pied d'écran fixe
// (`roles_devices_screen`, `bottomNavigationBar`).
//
// Posée à la suite des cartes, elle sortait du champ à l'instant précis où on en
// avait besoin : le geste se fait en haut de la liste, la barre était un écran
// entier plus bas. Vu à la capture du 2026-09-08 — le correctif du brouillon sans
// issue était juste, et **restait inatteignable**. Une porte qu'on ne voit pas est
// une porte fermée : c'est le même défaut que celui d'hier, avec une cause de
// disposition au lieu d'une cause de logique.
// L'arbitrage en direct EN BAS.
//
// Il était en tête, avec cet argument : « Alloué 3 000 W ne veut rien dire tant
@ -1123,6 +1128,19 @@ class ImpactSaveBar extends StatelessWidget {
),
),
child: Column(
// ⚠️ **`min`, et ce n'est pas cosmétique.**
//
// `MainAxisSize.max` est le défaut. Tant que la barre vivait dans un `ListView`,
// la hauteur disponible était infinie et le `Column` se contentait donc de ses
// enfants — le défaut ne se voyait pas. Posée en `bottomNavigationBar`, elle
// reçoit une contrainte lâche bornée par l'écran : le `Column` a pris TOUTE la
// hauteur, le corps de l'écran a été écrasé à zéro, et il ne restait que la barre
// sur fond vide. Vu sur appareil le 2026-09-08, en release.
//
// Un widget qui ne se contraint pas lui-même hérite du contexte : déplacer un
// widget change ses contraintes, donc sa taille, sans qu'une ligne de son code
// ait bougé.
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (warnings.isNotEmpty) ...[

View File

@ -14,7 +14,10 @@
/// « j'ai décidé » redeviendrait « il ne s'est rien passé ».
library;
import 'package:etm_powersync_app/l10n/app_localizations.dart';
import 'package:etm_powersync_app/providers/load_config_provider.dart';
import 'package:etm_powersync_app/providers/load_telemetry_provider.dart';
import 'package:etm_powersync_app/services/nymea_service.dart';
import 'package:etm_powersync_app/widgets/controllable_loads_section.dart';
import 'package:etm_powersync_app/widgets/garde_brouillon.dart';
import 'package:flutter/material.dart';
@ -175,4 +178,71 @@ void main() {
expect(find.text('écran des charges'), findsOneWidget);
});
});
testWidgets('la barre n\'est plus DANS la liste — elle est en pied d\'écran',
(tester) async {
// Posée à la suite des cartes, elle sortait du champ à l'instant où le geste la fait
// apparaître : le correctif était juste et restait inatteignable. Une porte qu'on ne
// voit pas est une porte fermée.
//
// Ce test garde le NÉGATIF — qu'elle ne revienne pas s'ancrer dans la liste. Le
// positif (elle est bien en pied, visible sans défiler) se vérifie à l'appareil :
// monter l'écran entier ici demanderait cinq providers et ne prouverait toujours rien
// sur ce qui est à l'écran sans défilement.
final config = LoadConfigProvider()..adoptForTest(_config());
config.reorderDomains(2, 0);
expect(config.hasPendingChanges, isTrue);
await tester.pumpWidget(MultiProvider(
providers: [
ChangeNotifierProvider.value(value: config),
ChangeNotifierProvider.value(value: LoadTelemetryProvider()),
ChangeNotifierProvider.value(value: NymeaService()),
],
child: MaterialApp(
locale: const Locale('fr'),
localizationsDelegates: L10n.localizationsDelegates,
supportedLocales: L10n.supportedLocales,
home: Scaffold(
body: SingleChildScrollView(
child: ControllableLoadsSection(onConfigure: (_, __) {}),
),
),
),
));
await tester.pump();
expect(find.byType(ImpactSaveBar), findsNothing,
reason: 'la section ne doit plus porter la barre');
});
testWidgets('la barre tient dans un pied d\'écran — elle ne mange pas le corps',
(tester) async {
// Le contrôle qui MORD, et qu'aucun des 319 tests précédents ne pouvait faire :
// tous montaient la barre dans une hauteur INFINIE (un `ListView`), où
// `MainAxisSize.max` — le défaut d'un `Column` — se comporte comme `min`.
//
// En `bottomNavigationBar` la contrainte est lâche mais BORNÉE par l'écran, et le
// Column a pris toute la hauteur : corps écrasé à zéro, écran vide sous la barre.
// Trouvé à l'appareil, en release, pas ici.
//
// Un widget qui ne se contraint pas lui-même hérite du contexte : le déplacer change
// ses contraintes, donc sa taille, sans qu'une ligne de son code ait bougé.
final p = _provider()..reorderDomains(2, 0);
await tester.pumpWidget(MaterialApp(
home: Scaffold(
bottomNavigationBar: ImpactSaveBar(provider: p),
body: const Center(child: Text('le corps')),
),
));
await tester.pump();
final h = tester.getSize(find.byType(ImpactSaveBar)).height;
final ecran = tester.getSize(find.byType(Scaffold)).height;
expect(h, lessThan(ecran / 3),
reason: 'la barre doit se contenter de ses enfants, pas prendre l\'écran '
'(mesurée : $h sur $ecran)');
expect(find.text('le corps'), findsOneWidget);
});
}