etm-powersync-app/lib/widgets/ev_charging_card.dart
Patrick Schurig f7fd91edde fix(recharge): le SOC véhicule inventé disparaît — la grandeur n'existe pas
_SocProgress affichait « 62 % » et « Aujourd'hui à 07:30 » en dur, sur un écran client.
Laissé de côté au lot précédent comme préexistant ; ça ne tient plus, une promesse fausse
ne se périme pas.

Le correctif attendu — brancher la vraie valeur — n'existe pas. Vérifié sur .75 : sur les
58 classes de la box, aucune classe portant l'interface evcharger ne déclare d'état de
charge, aucune 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 : l'afficher en face d'une cible de recharge remplacerait un chiffre faux
par un chiffre faux et crédible. Retiré, avec la barre de progression — une jauge sans
grandeur mesurée est un dessin — et la carte dit maintenant pourquoi l'avancement n'est pas
affichable. Reste ce que l'app SAIT : la cible qu'elle vient elle-même d'écrire.

Le brief 3g-2 est rapatrié depuis le dépôt plugin (la forge étant à terre, il n'avait pas
pu voyager) et CLAUDE.md est recalé dessus. À noter, la description trompeuse de
SetChargingInfo signalée hier est corrigée sur la box : elle énumère désormais tous les
défauts et conclut « send it back complete ».

Le prompt 3g-2 déposé à la racine est commité tel quel, il était déjà indexé.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
2026-08-27 14:33:25 +02:00

686 lines
24 KiB
Dart

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<EVChargingCard> createState() => _EVChargingCardState();
}
class _EVChargingCardState extends State<EVChargingCard> {
// 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<void> _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<SchemaProvider>().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<SchemaProvider>().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<void> _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<bool> 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<int> 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<TimeOfDay> 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)),
),
],
);
}
}