import 'package:flutter/material.dart'; import '../models/energy_data.dart' show ChargingMode; import '../models/ev_charger_live.dart'; import '../theme/etm_tokens.dart'; /// La tuile « Borne EV » des favoris — **rendue muette le 2026-08-28**. /// /// ## Ce qu'elle affichait, et pourquoi c'était grave /// /// Elle montrait « 3600 W », « 82 % solaire » et « Mode PV » : trois valeurs par défaut de /// `EnergyData` que l'app **n'a jamais lues sur la box**. Le mode n'était relu nulle part — /// il partait de `pv` et n'avançait qu'après une commande envoyée depuis l'app. Un client /// ouvrant ce favori voyait donc une borne en train de charger à 3,6 kW alors que rien /// n'était branché. /// /// La tuile **reste** — la retirer laisserait un trou dans une liste que le client a /// composée lui-même, sans explication. Elle dit maintenant ce que la box publie, et là où /// il n'y a rien à lire, elle le dit. /// /// ## Les trois grandeurs qui restent, et leur source /// /// | Affiché | Source | Absent = | /// |---|---|---| /// | branché / en charge | états `pluggedIn`, `charging` du Thing | « état non publié » | /// | puissance | état `currentPower` du Thing | « puissance non publiée » | /// | mode | `NymeaEnergy.GetChargingInfos` + `ChargingInfoChanged` | « mode non lu » | /// /// Pas de part solaire : la box ne dit pas d'où vient le courant d'une borne. Pas /// d'avancement de charge : LM-1009 §C1 — `chargeEnergy` est remis à zéro à chaque /// interruption, et l'égalité `sessionEnergy == chargeEnergy` relevée le 2026-08-28 ne /// prouve pas encore que le second y survive. /// /// ## L'écriture restitue le refus, ou n'existe pas /// /// Les trois modes s'écrivaient sur des pastilles de 7 à 10 px qui **avalaient les /// refus** : `setChargingInfo` rend un code d'erreur, personne ne le lisait. Une commande /// muette est pire qu'une lecture seule — l'utilisateur croit avoir réglé la borne. Les /// boutons font désormais 44 px, montrent l'attente, et affichent le refus tel que la box /// le nomme. class EvFavoriteTile extends StatefulWidget { /// `null` = aucune borne déclarée sur l'installation. final EvChargerLive? live; /// Écrit le mode et rend `null` si la box a accepté, sinon le code de refus. final Future Function(ChargingMode) onSetMode; const EvFavoriteTile({super.key, required this.live, required this.onSetMode}); @override State createState() => _EvFavoriteTileState(); } class _EvFavoriteTileState 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; Future _set(ChargingMode m) async { 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 live = widget.live; if (live == null) { return _Muet( icon: Icons.ev_station_outlined, titre: 'Borne de recharge', detail: 'Aucune borne déclarée sur cette installation.', ); } final couleur = live.charging == true ? EtmTokens.green : EtmTokens.blue; return Row( children: [ Container( width: 52, height: 52, decoration: BoxDecoration( color: couleur.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(14), ), child: Icon(Icons.ev_station_rounded, color: couleur, size: 28), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // La puissance MESURÉE, ou le fait qu'elle ne soit pas publiée. Jamais 0 W // par défaut : « 0 W » et « pas de mesure » sont deux choses différentes. if (live.currentPowerW != null) Text('${live.currentPowerW!.round()} W', style: EtmTokens.mono( size: 26, weight: FontWeight.w700, color: couleur)) else Text('Puissance non publiée', style: EtmTokens.sans( size: 13, color: EtmTokens.mutedOf(context))), Text(live.name, maxLines: 1, overflow: TextOverflow.ellipsis, style: EtmTokens.sans(size: 12, color: couleur)), Text(_etat(live), style: EtmTokens.sans( size: 11, color: EtmTokens.mutedOf(context))), Text(_mode(live), style: EtmTokens.sans( size: 11, color: EtmTokens.mutedOf(context))), ], ), ), const SizedBox(width: 8), Column( mainAxisSize: MainAxisSize.min, children: [ for (final m in ChargingMode.values) _bouton(live, m), ], ), ], ); } /// Branché ? En charge ? — et « non publié » quand la borne ne le dit pas. String _etat(EvChargerLive live) { if (live.pluggedIn == null && live.charging == null) { return 'Branchement non publié par cette borne'; } if (live.charging == true) return 'En charge'; if (live.pluggedIn == true) return 'Véhicule branché, à l\'arrêt'; if (live.pluggedIn == false) return 'Aucun véhicule branché'; return 'À l\'arrêt'; } /// Le mode **que la box porte**, jamais celui que l'app a demandé. String _mode(EvChargerLive live) { if (live.apiMode == null) return 'Mode non lu'; final ui = live.uiMode; if (ui == null) return 'Mode non reconnu (${live.apiMode})'; final base = switch (ui) { ChargingMode.pv => 'PV', ChargingMode.minPv => 'Min+PV', ChargingMode.boost => 'Boost', }; return live.hasDeadlineMode ? 'Mode $base + échéance' : 'Mode $base'; } /// 44 px de cible tactile — les pastilles précédentes faisaient 7 à 10 px, et le premier /// passage sur appareil avait déjà trouvé deux cibles hors norme sur d'autres écrans. Widget _bouton(EvChargerLive live, ChargingMode m) { final (label, couleur) = switch (m) { ChargingMode.pv => ('PV', EtmTokens.amber), ChargingMode.minPv => ('Min+PV', EtmTokens.blue), ChargingMode.boost => ('Boost', EtmTokens.green), }; // Sélection = ce que la BOX porte. Un mode non lu ne sélectionne rien : mieux vaut // trois boutons éteints qu'un bouton allumé au hasard. final selected = live.uiMode == m; final busy = _pending != null; return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: SizedBox( height: 44, width: 76, 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: 11, weight: selected ? FontWeight.w700 : FontWeight.w400, color: selected ? couleur : EtmTokens.mutedOf(context))), ), ), ); } } /// Tuile sans donnée : elle nomme ce qu'elle attend, plutôt que d'afficher un zéro. class _Muet extends StatelessWidget { final IconData icon; final String titre; final String detail; const _Muet({required this.icon, required this.titre, required this.detail}); @override Widget build(BuildContext context) { return Row( children: [ Container( width: 52, height: 52, decoration: BoxDecoration( color: EtmTokens.mutedOf(context).withValues(alpha: 0.10), borderRadius: BorderRadius.circular(14), ), child: Icon(icon, color: EtmTokens.mutedOf(context), size: 26), ), const SizedBox(width: 14), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(titre, style: EtmTokens.sans(size: 14, weight: FontWeight.w600)), const SizedBox(height: 2), Text(detail, style: EtmTokens.sans( size: 11.5, color: EtmTokens.mutedOf(context))), ], ), ), ], ); } }