diff --git a/lib/widgets/battery_reserve_card.dart b/lib/widgets/battery_reserve_card.dart index 61b9136..3399270 100644 --- a/lib/widgets/battery_reserve_card.dart +++ b/lib/widgets/battery_reserve_card.dart @@ -45,6 +45,14 @@ class _BatteryReserveCardState extends State { bool _charge = true; String? _erreur; + /// Carte repliée — l'en-tête et sa valeur restent, le réglage se retire. + /// + /// **Jamais repliée d'office**, et jamais retenue d'une session à l'autre : c'est le + /// réglage qui peut geler toute l'installation sans erreur ni panne, et le cacher par + /// défaut ramènerait le paramètre d'usine invisible que cette carte existe pour + /// supprimer. Se replier reste un geste de l'utilisateur, pour la session en cours. + bool _replie = false; + @override void initState() { super.initState(); @@ -121,7 +129,40 @@ class _BatteryReserveCardState extends State { fontWeight: FontWeight.w700, fontFamily: 'monospace', color: EtmTokens.inkOf(context))), + IconButton( + key: const ValueKey('replier-reserve-batterie'), + visualDensity: VisualDensity.compact, + icon: Icon( + _replie + ? Icons.keyboard_arrow_down_rounded + : Icons.keyboard_arrow_up_rounded, + size: 22), + tooltip: _replie ? 'Déplier la réserve' : 'Replier la réserve', + onPressed: () => setState(() => _replie = !_replie), + ), ]), + // Repliée, la carte garde le POURCENTAGE et, s'il y a lieu, l'alerte. Un seuil + // qui gèle l'installation ne doit pas pouvoir être caché : replier abrège, ça + // ne fait pas taire. + if (_replie && alerte) ...[ + const SizedBox(height: 4), + Row(crossAxisAlignment: CrossAxisAlignment.start, children: [ + const Icon(Icons.warning_amber_rounded, + size: 16, color: EtmTokens.orange), + const SizedBox(width: 7), + Expanded( + child: Text( + 'Seuil au-dessus de la charge actuelle (${soc.round()} %) — rien ne ' + 'sera piloté.', + style: TextStyle( + fontSize: 11.5, + height: 1.35, + color: EtmTokens.inkOf(context)), + ), + ), + ]), + ], + if (!_replie) ...[ const SizedBox(height: 2), Text( 'Sous ce niveau de charge, le budget de surplus est ANNULÉ — pas réduit. ' @@ -182,6 +223,7 @@ class _BatteryReserveCardState extends State { 'régler « pour la voiture » couperait alors le reste.', style: TextStyle(fontSize: 10.5, height: 1.35, color: muted), ), + ], ], ), ); diff --git a/lib/widgets/controllable_loads_section.dart b/lib/widgets/controllable_loads_section.dart index 82a9f54..15f4467 100644 --- a/lib/widgets/controllable_loads_section.dart +++ b/lib/widgets/controllable_loads_section.dart @@ -42,7 +42,7 @@ import 'load_cards.dart'; /// Depuis le lot plugin B-bis, toute charge publiée en télémétrie a une entrée en /// configuration. L'inverse reste faux : une charge configurée absente de la télémétrie /// est **désactivée**, jamais perdue — et « non arbitrée » n'est donc pas une erreur. -class ControllableLoadsSection extends StatelessWidget { +class ControllableLoadsSection extends StatefulWidget { /// Ouvre l'écran de mécanisme d'une charge. Fourni par l'écran hôte, qui connaît la /// navigation ; les trois contrats ne se ressemblent pas, donc il n'y a pas de /// formulaire unique à ouvrir. @@ -50,6 +50,22 @@ class ControllableLoadsSection extends StatelessWidget { const ControllableLoadsSection({super.key, required this.onConfigure}); + @override + State createState() => + _ControllableLoadsSectionState(); +} + +class _ControllableLoadsSectionState extends State { + /// Domaines repliés, par leur code. + /// + /// Clé par **code de domaine** et non par index : un domaine qui se déplace emporte + /// son repli, ce qu'un index ne saurait pas faire — il replierait le voisin. + /// + /// **Non persisté**, délibérément : c'est une commodité de lecture, pas une + /// configuration. La rendre durable ferait retrouver un écran à moitié caché des + /// semaines plus tard, sans savoir pourquoi. + final Set _replies = {}; + @override Widget build(BuildContext context) { final provider = context.watch(); @@ -70,9 +86,8 @@ class ControllableLoadsSection extends StatelessWidget { return Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ - // Le budget du cycle AVANT les charges : « Alloué 3 000 W » ne veut rien dire - // tant qu'on ne sait pas s'il y avait 3 100 ou 12 000 W à distribuer. - TelemetryHeader(telemetry: telemetry), + // La note de rang reste EN TÊTE : elle prévient que l'ordre affiché juste en + // dessous n'en est pas un. La lire après les cartes arriverait trop tard. _NoteRangsExAequo(entries: provider.entries), // Le niveau glissable est celui des DOMAINES, pas des cartes. // @@ -96,7 +111,12 @@ class ControllableLoadsSection extends StatelessWidget { total: groups.length, provider: provider, telemetry: telemetry, - onConfigure: onConfigure, + onConfigure: widget.onConfigure, + replie: _replies.contains(groups[i].domain), + onReplier: () => setState(() { + final d = groups[i].domain; + _replies.contains(d) ? _replies.remove(d) : _replies.add(d); + }), ), ), // Plus de section séparée pour les bornes : depuis `+etm23` elles ONT une entrée @@ -108,6 +128,16 @@ class ControllableLoadsSection extends StatelessWidget { const SizedBox(height: 4), ImpactSaveBar(provider: provider), ], + // L'arbitrage en direct EN BAS. + // + // Il était en tête, avec cet argument : « Alloué 3 000 W ne veut rien dire tant + // qu'on ne sait pas s'il y avait 3 100 ou 12 000 W à distribuer ». L'argument + // reste juste pour LIRE une allocation — mais cet écran sert à CONFIGURER, et + // faire descendre l'installateur sous un pavé de télémétrie à chaque ouverture + // met la lecture devant le geste. Le budget reste là, sous les charges qu'il + // explique, atteignable quand on le cherche. + const SizedBox(height: 4), + TelemetryHeader(telemetry: telemetry), ], ); } @@ -179,6 +209,10 @@ class _DomainBlock extends StatelessWidget { final LoadTelemetryProvider telemetry; final void Function(BuildContext, LoadConfigEntry) onConfigure; + /// Bloc replié : l'en-tête reste, les cartes sont retirées de l'arbre. + final bool replie; + final VoidCallback onReplier; + const _DomainBlock({ super.key, required this.group, @@ -187,6 +221,8 @@ class _DomainBlock extends StatelessWidget { required this.provider, required this.telemetry, required this.onConfigure, + required this.replie, + required this.onReplier, }); @override @@ -233,23 +269,39 @@ class _DomainBlock extends StatelessWidget { ), ), ), + // Quand le bloc est replié, dire ce qu'il contient — sinon replier revient à + // faire disparaître des charges, et une charge qu'on ne voit plus se lit + // comme une charge perdue. + if (replie) + Padding( + padding: const EdgeInsets.only(right: 4), + child: Text( + group.loads.length > 1 + ? '${group.loads.length} charges' + : '${group.loads.length} charge', + style: TextStyle(fontSize: 11.5, color: muted), + ), + ), + // Un SEUL chevron, qui replie — à la place des deux flèches monter/descendre. + // + // Elles faisaient doublon avec la poignée de glissement, désormais vérifiée + // au doigt sur appareil (48 px, réordonnancement mesuré). Deux chemins pour + // le même geste, dont l'un n'a pas de repli, valaient moins qu'un chemin sûr + // plus une commande qui manquait. IconButton( + key: ValueKey('replier-${group.domain}'), visualDensity: VisualDensity.compact, - icon: const Icon(Icons.keyboard_arrow_up_rounded, size: 20), - tooltip: 'Monter ce domaine', - onPressed: - index == 0 ? null : () => provider.reorderDomains(index, index - 1), - ), - IconButton( - visualDensity: VisualDensity.compact, - icon: const Icon(Icons.keyboard_arrow_down_rounded, size: 20), - tooltip: 'Descendre ce domaine', - onPressed: index >= total - 1 - ? null - : () => provider.reorderDomains(index, index + 2), + icon: Icon( + replie + ? Icons.keyboard_arrow_down_rounded + : Icons.keyboard_arrow_up_rounded, + size: 22), + tooltip: replie ? 'Déplier ce domaine' : 'Replier ce domaine', + onPressed: onReplier, ), ]), - const SizedBox(height: 6), + if (!replie) const SizedBox(height: 6), + if (!replie) for (var i = 0; i < group.loads.length; i++) Padding( key: ValueKey('load-${group.loads[i].id}'),