etm-powersync-app/lib/widgets/ev_charging_card.dart
Patrick Schurig a92e9b2f62 fix(recharge): lire-patcher-réécrire — SetChargingInfo n'est pas une écriture partielle
La box promet le contraire de ce qu'elle fait, et par écrit : sa propre description dans
JSONRPC.Introspect dit « Only given properties will be set, others will be untouched ».
ChargingInfo est en réalité reconstruit entier à chaque appel, et un champ absent retombe
sur son défaut C++ — 80 pour targetPercentage.

Reproduit sur .75 le 2026-08-27 (+etm22), borne 88160e45 : le champ valait 0, un
SetChargingInfo portant le seul chargingMode l'a laissé à 80, réponse EnergyErrorNoError.
Banc restauré.

L'app envoyait targetPercentage uniquement avec une échéance. Changer de mode de charge
reposait donc une cible de 80 % à l'insu de l'utilisateur, sans que rien ne le signale.
Désormais : GetChargingInfos, patch, réécriture de l'objet complet — sans chargingState,
qui est r: au schéma et ferait rejeter l'appel. Sans échéance, on repose explicitement la
valeur que la box portait déjà ; si elle n'en portait aucune, le neutre est 0, pas 80.

Et la cible n'est plus affichée hors échéance : 80 peut être l'empreinte d'une écriture qui
ne parlait pas de cible du tout, la montrer en ferait une intention.

Deux notes de contrat au passage :
- fetchPowerBalanceLogs porte enfin le piège qui a fait croire à un défaut de la box côté
  plugin — les entrées de log ne portent PAS le préfixe currentPower. Réutiliser les clés
  de GetPowerBalance ne produit aucune erreur, tout tombe sur 0. Vérifié ici avec les clés
  de l'app : 7 960 échantillons sur 90 jours en SampleRate15Mins, dont 4 975 de production
  non nulle. Rien ne bloque l'historique 90 jours.
- le défaut d'usine de batteryLevelConsideration est passé de 0,9 à 0,2. La carte reste
  nécessaire : le nouveau défaut ne rattrape pas une box qui a déjà persisté 0,9, et le
  seuil reste réglable jusqu'à 1,0.

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

674 lines
23 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, currentSoc: 62),
],
],
),
);
}
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.
class _SocProgress extends StatelessWidget {
final int targetSoc;
final int currentSoc;
const _SocProgress({required this.targetSoc, required this.currentSoc});
@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 prévue jusqu\'à $targetSoc%',
style: EtmTokens.sans(size: 13, color: EtmTokens.navy)),
Text('$currentSoc%', style: EtmTokens.mono(size: 13, color: EtmTokens.blue)),
],
),
const SizedBox(height: 2),
Text('Aujourd\'hui à 07:30', style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))),
const SizedBox(height: 8),
ClipRRect(
borderRadius: BorderRadius.circular(99),
child: SizedBox(
height: 7,
child: LinearProgressIndicator(
value: currentSoc / 100,
backgroundColor: const Color(0xFFD6E6F2),
valueColor: const AlwaysStoppedAnimation<Color>(EtmTokens.blue),
),
),
),
],
),
);
}
}
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)),
),
],
);
}
}