import 'package:flutter/material.dart'; import '../models/energy_data.dart' show ChargingMode; import '../models/ev_charger_live.dart'; import '../theme/etm_tokens.dart'; /// Les trois modes de recharge — **le seul endroit de l'app qui écrit `SetChargingInfo`**. /// /// ## Un seul lieu d'écriture, délibérément /// /// Ces boutons ont vécu aux favoris, sur la tuile de la borne. Ils sont ici depuis le /// 2026-08-28 : « Configurer » est l'écran de **mise en service**, celui où un installateur /// découvre qu'une borne neuve est hors arbitrage — et deux écrans qui écrivent la même /// chose sont deux comportements qui divergent. La tuile des favoris garde la LECTURE et /// la note « hors arbitrage » : le client doit voir que sa borne n'est pas pilotée, même /// là où il ne peut pas la régler. /// /// ## Ce qui est affiché comme sélectionné /// /// Le mode que la **box** porte, jamais celui que l'app vient de demander. Un mode non lu /// n'allume rien : trois boutons éteints valent mieux qu'un allumé au hasard. Après une /// écriture acceptée, `NymeaService` relit `GetChargingInfos` — la sélection ne bouge que /// quand la box l'a dit. /// /// ## Un refus se voit /// /// `setChargingInfo` rend un code d'erreur ; il était avalé. Une commande muette est pire /// qu'une lecture seule — l'utilisateur croit avoir réglé la borne. class EvModeButtons extends StatefulWidget { final EvChargerLive live; /// Écrit le mode et rend `null` si la box a accepté, sinon le code de refus. final Future Function(ChargingMode) onSetMode; /// Disposition : en colonne sur une tuile étroite, en ligne sur un écran. final Axis axis; const EvModeButtons({ super.key, required this.live, required this.onSetMode, this.axis = Axis.horizontal, }); @override State createState() => _EvModeButtonsState(); } class _EvModeButtonsState extends State { /// Mode en cours d'écriture — jamais affiché comme sélectionné : tant que la box n'a pas /// répondu, l'app ne sait pas ce que porte la borne. ChargingMode? _pending; /// **Passer en Manuel se confirme.** Ce n'est pas de la prudence décorative. /// /// `ChargingModeNormal` sort la borne de l'arbitrage et lui **restitue ses valeurs /// manuelles stockées** — `manualChargingEnabled`, `manualMaxChargingCurrent`, /// `manualDesiredPhaseCount`. Elles peuvent dire n'importe quoi, y compris « charge /// désactivée » : sur `.75` elles valaient `false / 6 A / 3 phases`, et le bouton — qui /// s'appelait alors « Boost » — a **arrêté la charge** le 2026-09-08 à 11:09:51, avec un /// verrou de 300 s derrière. /// /// Le renommage seul ne suffisait pas : « Manuel » dit bien QUI commande, mais rien de /// ce que la borne va faire. Tant que l'app n'offre pas de régler ces valeurs, Manuel /// veut dire « ce qui traînait la dernière fois », et l'utilisateur a le droit de le /// savoir avant, pas de le découvrir après. Future _confirmerManuel() async { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Rendre la main à la borne ?'), content: const Text( 'En mode manuel, le HEMS ne pilote plus cette borne : elle reprend ses propres ' 'réglages enregistrés (charge autorisée ou non, courant, nombre de phases).\n\n' 'Selon ce qu\'ils contiennent, la charge peut s\'arrêter.', ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('Annuler')), FilledButton( onPressed: () => Navigator.pop(ctx, true), child: const Text('Passer en manuel')), ], ), ); return ok == true; } Future _set(ChargingMode m) async { if (m == ChargingMode.manuel && widget.live.uiMode != ChargingMode.manuel) { if (!await _confirmerManuel()) return; if (!mounted) return; } setState(() => _pending = m); final err = await widget.onSetMode(m); if (!mounted) return; setState(() => _pending = null); if (err == null) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text('La box a refusé le mode de recharge ($err). ' 'Détail dans son journal.'), )); } @override Widget build(BuildContext context) { final boutons = [for (final m in ChargingMode.values) _bouton(m)]; return widget.axis == Axis.horizontal ? Row(children: [ for (final b in boutons) ...[Expanded(child: b), const SizedBox(width: 6)] ]..removeLast()) : Column(mainAxisSize: MainAxisSize.min, children: boutons); } /// 44 px de cible tactile — les pastilles d'origine faisaient 7 à 10 px, et écrivaient /// pour de vrai. Widget _bouton(ChargingMode m) { final (label, couleur) = switch (m) { ChargingMode.manuel => ('Manuel', EtmTokens.mutedOf(context)), ChargingMode.pv => ('PV', EtmTokens.amber), ChargingMode.minPv => ('Min+PV', EtmTokens.blue), }; final selected = widget.live.uiMode == m; final busy = _pending != null; return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: SizedBox( height: 44, width: widget.axis == Axis.vertical ? 76 : null, child: OutlinedButton( onPressed: busy ? null : () => _set(m), style: OutlinedButton.styleFrom( padding: EdgeInsets.zero, backgroundColor: selected ? couleur.withValues(alpha: 0.14) : Colors.transparent, side: BorderSide( color: selected ? couleur : EtmTokens.lineOf(context).withValues(alpha: 0.6)), shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), ), child: _pending == m ? const SizedBox( width: 14, height: 14, child: CircularProgressIndicator(strokeWidth: 2)) : Text(label, style: EtmTokens.sans( size: 12, weight: selected ? FontWeight.w700 : FontWeight.w400, color: selected ? couleur : EtmTokens.mutedOf(context))), ), ), ); } }