diff --git a/docs/captures/2026-09-08_barre-en-pied-terminer.png b/docs/captures/2026-09-08_barre-en-pied-terminer.png new file mode 100644 index 0000000..5cd0300 Binary files /dev/null and b/docs/captures/2026-09-08_barre-en-pied-terminer.png differ diff --git a/lib/providers/load_config_provider.dart b/lib/providers/load_config_provider.dart index e53f04d..850355e 100644 --- a/lib/providers/load_config_provider.dart +++ b/lib/providers/load_config_provider.dart @@ -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 diff --git a/lib/screens/energy/roles_devices_screen.dart b/lib/screens/energy/roles_devices_screen.dart index 40e9171..3f5016a 100644 --- a/lib/screens/energy/roles_devices_screen.dart +++ b/lib/screens/energy/roles_devices_screen.dart @@ -110,6 +110,12 @@ class _RolesDevicesScreenState extends State { 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 { } } +// ═══════════════════════════════════════════════════════════════════════════ +// 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(); + 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 // ═══════════════════════════════════════════════════════════════════════════ diff --git a/lib/widgets/controllable_loads_section.dart b/lib/widgets/controllable_loads_section.dart index 4a312ae..66fb998 100644 --- a/lib/widgets/controllable_loads_section.dart +++ b/lib/widgets/controllable_loads_section.dart @@ -212,10 +212,15 @@ class _ControllableLoadsSectionState extends State { // 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) ...[ diff --git a/test/brouillon_sans_issue_test.dart b/test/brouillon_sans_issue_test.dart index b75fc57..4f22a70 100644 --- a/test/brouillon_sans_issue_test.dart +++ b/test/brouillon_sans_issue_test.dart @@ -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); + }); }