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:
parent
346f162273
commit
1dece8eb33
BIN
docs/captures/2026-09-08_barre-en-pied-terminer.png
Normal file
BIN
docs/captures/2026-09-08_barre-en-pied-terminer.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 216 KiB |
@ -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
|
||||
|
||||
@ -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
|
||||
// ═══════════════════════════════════════════════════════════════════════════
|
||||
|
||||
@ -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) ...[
|
||||
|
||||
@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user