import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/energy_data.dart'; import '../providers/schema_provider.dart'; import '../services/nymea_service.dart'; import '../services/rpc_schema.dart'; import '../theme/etm_tokens.dart'; import 'schema_gated.dart'; class EVChargingCard extends StatefulWidget { final EnergyData data; final NymeaService service; const EVChargingCard({ super.key, required this.data, required this.service, }); @override State createState() => _EVChargingCardState(); } class _EVChargingCardState extends State { // Deadline option state — local UI, not persisted bool _deadlineEnabled = false; int _targetSoc = 80; DateTime _endTime = DateTime.now().add(const Duration(hours: 4)); void _selectMode(ChargingMode mode) { // Selecting Boost auto-disables deadline if (mode == ChargingMode.boost && _deadlineEnabled) { setState(() => _deadlineEnabled = false); } _send( mode: mode, deadline: mode != ChargingMode.boost && _deadlineEnabled, ); } /// Envoie, et **annonce le refus** au lieu de le perdre. La cause exacte reste au /// journal de la box : on ne la devine pas. Future _send({required ChargingMode mode, required bool deadline}) async { final err = await widget.service.setChargingInfo( mode: mode, deadline: deadline, targetSoc: _targetSoc, endTime: _endTime, ); if (err == null || !mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text('La box a refusé le mode de recharge ($err). ' 'Détail dans son journal.'), )); } void _toggleDeadline(bool enabled) { setState(() => _deadlineEnabled = enabled); _send(mode: widget.data.chargingMode, deadline: enabled); } void _applyDeadline() { _send(mode: widget.data.chargingMode, deadline: _deadlineEnabled); } /// Support des champs d'échéance, lu dans le schéma de la box. /// /// Les deux vont ensemble : une échéance sans cible de charge n'a pas de sens, et /// l'inverse non plus. On grise donc le bloc entier dès que l'un manque. FieldSupport _socSupport(BuildContext context) { final schema = context.watch().schema; final soc = schema.typeSupport('ChargingInfo', ['targetPercentage']); final end = schema.typeSupport('ChargingInfo', ['endDateTime']); if (soc == FieldSupport.unknown || end == FieldSupport.unknown) { return FieldSupport.unknown; } if (soc == FieldSupport.absent || end == FieldSupport.absent) { return FieldSupport.absent; } return FieldSupport.writable; } /// Vrai si le contrat de recharge ne porte aucun champ de courant minimal. bool _minCurrentAbsent(BuildContext context) { final schema = context.watch().schema; if (!schema.loaded) return false; return schema.typeSupport('ChargingInfo', ['minCurrent']) == FieldSupport.absent; } String _statusLabel(ChargingMode mode) { switch (mode) { case ChargingMode.pv: return _deadlineEnabled ? 'Surplus PV → Boost auto avant deadline' : 'Surplus PV disponible'; case ChargingMode.minPv: return _deadlineEnabled ? 'Min garanti + PV → Boost si deadline' : 'Minimum + surplus PV'; case ChargingMode.boost: return 'Charge rapide (réseau)'; } } Future _pickEndTime() async { final initial = TimeOfDay.fromDateTime(_endTime); final picked = await showTimePicker(context: context, initialTime: initial); if (picked != null) { setState(() { final now = DateTime.now(); _endTime = DateTime(now.year, now.month, now.day, picked.hour, picked.minute); // If picked time is in the past, roll to tomorrow if (_endTime.isBefore(now)) { _endTime = _endTime.add(const Duration(days: 1)); } }); _applyDeadline(); } } @override Widget build(BuildContext context) { final mode = widget.data.chargingMode; final data = widget.data; final showDeadlineOption = mode != ChargingMode.boost; return Container( decoration: BoxDecoration( color: EtmTokens.surfaceOf(context), borderRadius: BorderRadius.circular(EtmTokens.radiusLg), boxShadow: EtmTokens.cardShadow, ), padding: const EdgeInsets.all(20), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // Header Row( children: [ Container( width: 34, height: 34, decoration: BoxDecoration( color: EtmTokens.navy.withValues(alpha: 0.07), borderRadius: BorderRadius.circular(10), ), child: const Icon(Icons.ev_station_rounded, color: EtmTokens.navy, size: 20), ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text('Borne de recharge (EVSE)', style: EtmTokens.sans(size: 15, weight: FontWeight.w600)), Text(_statusLabel(mode), style: EtmTokens.sans(size: 12, color: EtmTokens.mutedOf(context))), ], ), ), GestureDetector( onTap: () => _showSettings(context), child: Icon(Icons.tune, color: EtmTokens.faintOf(context), size: 20), ), ], ), const SizedBox(height: 14), // Status badge + power Row( crossAxisAlignment: CrossAxisAlignment.end, children: [ Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Container( padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4), decoration: BoxDecoration( color: EtmTokens.greenSoft, borderRadius: BorderRadius.circular(99), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ Container(width: 6, height: 6, decoration: const BoxDecoration(color: EtmTokens.green, shape: BoxShape.circle)), const SizedBox(width: 5), Text('En charge', style: EtmTokens.sans(size: 12, weight: FontWeight.w600, color: EtmTokens.greenDark)), ], ), ), const SizedBox(height: 8), Text( '${(data.chargingPower * 1000).toStringAsFixed(0)}', style: EtmTokens.mono(size: 38, weight: FontWeight.w700), ), Text('W Puissance actuelle', style: EtmTokens.sans(size: 12, color: EtmTokens.mutedOf(context))), ], ), const Spacer(), Column( crossAxisAlignment: CrossAxisAlignment.end, children: [ Text('${data.solarSourcePercent.toStringAsFixed(0)}%', style: EtmTokens.mono(size: 22, color: EtmTokens.green)), Text('solaire', style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))), ], ), ], ), const SizedBox(height: 16), // Mode buttons — 3 boutons : PV / Min+PV / Boost Row( children: [ _ModeButton( label: 'PV', icon: Icons.wb_sunny_rounded, color: EtmTokens.amber, isSelected: mode == ChargingMode.pv, onTap: () => _selectMode(ChargingMode.pv), ), const SizedBox(width: 8), _ModeButton( label: 'Min+PV', icon: Icons.bolt, color: EtmTokens.blue, isSelected: mode == ChargingMode.minPv, onTap: () => _selectMode(ChargingMode.minPv), ), const SizedBox(width: 8), _ModeButton( label: 'Boost', icon: Icons.rocket_launch_rounded, color: EtmTokens.green, isSelected: mode == ChargingMode.boost, onTap: () => _selectMode(ChargingMode.boost), ), ], ), // Échéance — visible pour PV et Min+PV, et **active seulement si la box // porte les champs**. Le grisage vient du schéma, jamais d'une table de // versions : le jour où le plugin expose `endDateTime`, le réglage s'active // seul sur les box à jour et reste gris sur les autres. if (showDeadlineOption) ...[ const SizedBox(height: 8), SchemaGated( support: _socSupport(context), fieldName: 'targetPercentage / endDateTime', label: 'Échéance de recharge', child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ _DeadlineToggleRow( enabled: _deadlineEnabled, onChanged: _toggleDeadline, ), if (_deadlineEnabled) ...[ const SizedBox(height: 10), _DeadlineParamsRow( targetSoc: _targetSoc, endTime: _endTime, onSocChanged: (v) { setState(() => _targetSoc = v); _applyDeadline(); }, onPickTime: _pickEndTime, ), ], ], ), ), // Le mode « minimum garanti » existe côté box, mais la VALEUR du courant // minimal n'est dans aucun champ de `ChargingInfo` : elle ne se règle pas // ici. Le dire, plutôt que d'offrir un réglage sans effet. if (_minCurrentAbsent(context)) ...[ const SizedBox(height: 8), _AbsentFieldNote( 'Le courant minimal du mode « Min + PV » n\'est pas réglable depuis ' 'l\'app : la box choisit le mode, mais n\'expose aucun champ ' '« minCurrent » dans son contrat de recharge.', ), ], ], // Cible de charge — affichée SEULEMENT quand l'écran l'a lui-même écrite. // // `targetPercentage` n'est pas un réglage que l'app peut lire et présenter // comme un choix de l'utilisateur : `SetChargingInfo` reconstruit l'objet // entier, et le défaut C++ du champ vaut **80**. Une écriture qui ne parlait // que du mode de charge laisse donc 80 derrière elle, sans que rien ne le // signale — reproduit sur `.75` le 2026-08-27. Montrer « cible 80 % » hors // échéance afficherait cette empreinte comme une intention. // // Hors échéance, l'app n'a pas de cible : elle n'en montre pas. if (_deadlineEnabled) ...[ const SizedBox(height: 14), _SocProgress(targetSoc: _targetSoc), ], ], ), ); } void _showSettings(BuildContext context) { showModalBottomSheet( context: context, shape: const RoundedRectangleBorder( borderRadius: BorderRadius.vertical(top: Radius.circular(20)), ), builder: (_) => const _ChargingSettingsSheet(), ); } } class _DeadlineToggleRow extends StatelessWidget { final bool enabled; final ValueChanged onChanged; const _DeadlineToggleRow({required this.enabled, required this.onChanged}); @override Widget build(BuildContext context) { return Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Row( children: [ Icon(Icons.flag_outlined, size: 16, color: enabled ? EtmTokens.blue : EtmTokens.faintOf(context)), const SizedBox(width: 6), Text('Cible deadline', style: EtmTokens.sans(size: 13, color: enabled ? EtmTokens.blue : EtmTokens.mutedOf(context), weight: enabled ? FontWeight.w600 : FontWeight.w400)), ], ), Switch( value: enabled, onChanged: onChanged, activeThumbColor: EtmTokens.blue, materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, ), ], ); } } class _DeadlineParamsRow extends StatelessWidget { final int targetSoc; final DateTime endTime; final ValueChanged onSocChanged; final VoidCallback onPickTime; const _DeadlineParamsRow({ required this.targetSoc, required this.endTime, required this.onSocChanged, required this.onPickTime, }); @override Widget build(BuildContext context) { final timeLabel = '${endTime.hour.toString().padLeft(2, '0')}:${endTime.minute.toString().padLeft(2, '0')}'; return Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( color: EtmTokens.blueSoft, borderRadius: BorderRadius.circular(12), ), child: Column( children: [ Row( children: [ const Icon(Icons.battery_charging_full, size: 16, color: EtmTokens.blue), const SizedBox(width: 6), Text('SOC cible : $targetSoc %', style: EtmTokens.sans(size: 12, color: EtmTokens.blue)), Expanded( child: Slider( value: targetSoc.toDouble(), min: 0, max: 100, divisions: 20, label: '$targetSoc %', activeColor: EtmTokens.blue, onChanged: (v) => onSocChanged(v.round()), ), ), ], ), GestureDetector( onTap: onPickTime, child: Row( children: [ const Icon(Icons.access_time, size: 16, color: EtmTokens.blue), const SizedBox(width: 6), Text('Heure d\'arrivée :', style: EtmTokens.sans(size: 12, color: EtmTokens.blue)), const SizedBox(width: 8), Text(timeLabel, style: EtmTokens.mono(size: 13, color: EtmTokens.blue)), const SizedBox(width: 4), const Icon(Icons.edit, size: 12, color: EtmTokens.blue), ], ), ), ], ), ); } } class _ModeButton extends StatelessWidget { final String label; final IconData icon; final Color color; final bool isSelected; final VoidCallback onTap; const _ModeButton({ required this.label, required this.icon, required this.color, required this.isSelected, required this.onTap, }); @override Widget build(BuildContext context) { return Expanded( child: GestureDetector( onTap: onTap, child: AnimatedContainer( duration: const Duration(milliseconds: 200), padding: const EdgeInsets.symmetric(vertical: 11), decoration: BoxDecoration( color: isSelected ? color : color.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(12), border: Border.all(color: isSelected ? color : color.withValues(alpha: 0.3)), ), child: Row( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(icon, color: isSelected ? Colors.white : color, size: 15), const SizedBox(width: 4), Text( label, style: EtmTokens.sans( size: 13, weight: FontWeight.w600, color: isSelected ? Colors.white : color, ), ), ], ), ), ), ); } } /// Barre de progression SOC de la voiture avec target et valeur courante. /// Cible de charge du véhicule — **la cible seule, jamais un état de charge**. /// /// Cette carte affichait « 62 % » et « Aujourd'hui à 07:30 » en dur. Les deux sont /// retirés, et il faut dire pourquoi le second correctif possible n'en est pas un : /// /// **aucun SOC de véhicule n'existe dans ce contrat.** Vérifié sur `.75` le 2026-08-27, /// sur les 58 classes de la box : aucune classe portant l'interface `evcharger` ne /// déclare d'état de charge, aucune classe ne porte l'interface `car`, et les deux bornes /// du banc publient `pluggedIn`, `charging`, `maxChargingCurrent`, `phaseCount`, /// `sessionEnergy` — jamais un pourcentage. /// /// Le seul `batteryLevel` de l'installation est celui de la **batterie de la maison** /// (Fronius Storage, 50 %), rendu visible par le correctif `energystorage` du plugin /// SunSpec. C'est une autre grandeur : la brancher ici afficherait la charge du bâtiment /// en face d'une cible de voiture. Un chiffre faux et plausible est pire qu'un chiffre /// absent — c'était déjà le défaut du 62 en dur, le remplacer par 50 le garderait en le /// rendant crédible. /// /// Reste donc ce que l'app SAIT : la cible qu'elle vient elle-même d'écrire. La barre de /// progression disparaît avec la mesure — une jauge sans grandeur mesurée est un dessin. class _SocProgress extends StatelessWidget { final int targetSoc; const _SocProgress({required this.targetSoc}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12), decoration: BoxDecoration( color: EtmTokens.blueSoft, borderRadius: BorderRadius.circular(13), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('Charge demandée jusqu\'à', style: EtmTokens.sans(size: 13, color: EtmTokens.navy)), Text('$targetSoc%', style: EtmTokens.mono(size: 13, color: EtmTokens.blue)), ], ), const SizedBox(height: 2), Text( 'La box ne publie pas l\'état de charge du véhicule : l\'avancement ne peut ' 'pas être affiché.', style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context)), ), ], ), ); } } class _ChargingSettingsSheet extends StatefulWidget { const _ChargingSettingsSheet(); @override State<_ChargingSettingsSheet> createState() => _ChargingSettingsSheetState(); } class _ChargingSettingsSheetState extends State<_ChargingSettingsSheet> { double _minPower = 6; double _maxPower = 16; bool _scheduleEnabled = false; TimeOfDay _startTime = const TimeOfDay(hour: 22, minute: 0); TimeOfDay _endTime = const TimeOfDay(hour: 6, minute: 0); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.all(20), child: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ const Text('Paramètres de charge', style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)), const SizedBox(height: 20), Text('Courant minimum (A)', style: EtmTokens.sans(size: 14, weight: FontWeight.w500)), Slider( value: _minPower, min: 6, max: 16, divisions: 10, label: '${_minPower.toStringAsFixed(0)} A', onChanged: (v) => setState(() => _minPower = v), activeColor: EtmTokens.green, ), Text('Courant maximum (A)', style: EtmTokens.sans(size: 14, weight: FontWeight.w500)), Slider( value: _maxPower, min: 6, max: 32, divisions: 26, label: '${_maxPower.toStringAsFixed(0)} A', onChanged: (v) => setState(() => _maxPower = v), activeColor: EtmTokens.green, ), Row( mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ Text('Programmation horaire', style: EtmTokens.sans(size: 14, weight: FontWeight.w500)), Switch( value: _scheduleEnabled, onChanged: (v) => setState(() => _scheduleEnabled = v), activeColor: EtmTokens.green, ), ], ), if (_scheduleEnabled) ...[ Row( children: [ Expanded( child: _TimePicker( label: 'Début', time: _startTime, onChanged: (t) => setState(() => _startTime = t), ), ), const SizedBox(width: 16), Expanded( child: _TimePicker( label: 'Fin', time: _endTime, onChanged: (t) => setState(() => _endTime = t), ), ), ], ), ], const SizedBox(height: 16), SizedBox( width: double.infinity, child: ElevatedButton( onPressed: () => Navigator.pop(context), style: ElevatedButton.styleFrom( backgroundColor: EtmTokens.green, foregroundColor: Colors.white, shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), ), child: const Text('Enregistrer'), ), ), ], ), ); } } class _TimePicker extends StatelessWidget { final String label; final TimeOfDay time; final ValueChanged onChanged; const _TimePicker({ required this.label, required this.time, required this.onChanged, }); @override Widget build(BuildContext context) { return GestureDetector( onTap: () async { final picked = await showTimePicker(context: context, initialTime: time); if (picked != null) onChanged(picked); }, child: Container( padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), decoration: BoxDecoration( border: Border.all(color: Colors.grey.shade300), borderRadius: BorderRadius.circular(8), ), child: Row( children: [ Icon(Icons.access_time, size: 16, color: EtmTokens.faintOf(context)), const SizedBox(width: 8), Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))), Text( '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}', style: EtmTokens.mono(size: 13), ), ], ), ], ), ), ); } } /// Note d'un champ que la box n'expose pas — **avec sa raison**, jamais un simple gris. class _AbsentFieldNote extends StatelessWidget { final String text; const _AbsentFieldNote(this.text); @override Widget build(BuildContext context) { final muted = EtmTokens.mutedOf(context); return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ Icon(Icons.cloud_off_outlined, size: 13, color: muted), const SizedBox(width: 5), Expanded( child: Text(text, style: TextStyle(fontSize: 11.5, color: muted, height: 1.3)), ), ], ); } }