Le lot complet côté app, écrit sur le contrat vérifié dans Introspect sur .75 en
1.15.2+etm61 : SetLoadDerogation { loadId, durationS, o:targetWh } /
ClearLoadDerogation { loadId }, et derogationEnded[] { cause, loadId,
o:restoredMode } au niveau de la trame.
CE QUI EST UN BOUTON D'ACTION, PAS UN MODE. Une dérogation se superpose au mode
courant et s'efface ; elle ne le remplace pas. La mettre dans le sélecteur
ferait croire qu'on en sort en choisissant autre chose.
UNE DURÉE, TOUJOURS. Bornée [60, 21600] côté moteur. L'écran n'offre AUCUNE
option « jusqu'à annulation » — pas parce que l'appel échouerait, mais parce que
la promesse serait mauvaise : une dérogation qui ne sait pas dire quand elle
finit ne devrait pas exister (LM-1500). Le filet reste armé même avec une cible,
parce qu'une cible repose sur un instrument et qu'un instrument peut se taire.
L'ÉTAT NE SE LIT PAS DANS LE MODE. Si la borne était en manuel, le moteur force
Eco le temps de la dérogation : GetChargingInfos montrerait alors un mode que
personne n'a choisi. La vérité est sur la charge —
LoadTelemetryEntry.enDerogation.
Trois refus d'inventer, chacun figé par un test :
- entrée de télémétrie absente -> le bouton se TAIT. « Pas d'entrée » n'est pas
« pas de boost », c'est « on ne sait pas » ;
- échéance dépassée alors que la box annonce encore la dérogation -> on la NOMME
au lieu de rabattre à « 00:00 » : c'est une contradiction entre deux champs de
la même trame, et le zéro l'effacerait ;
- cause de fin inconnue -> aucun libellé. Une box plus récente peut en publier
une septième ; lui coller la phrase d'une autre serait pire.
Et `EnergyErrorInvalidParameter` sur l'arrêt ne crie pas au refus : la
dérogation a pu finir seule pendant l'appui, et une course que l'utilisateur ne
peut pas éviter n'est pas une erreur à lui montrer.
⚠️ boundBy a DEUX catalogues : drawCap|loadCeiling sur la dérogation, mais
« minimum » sur ECO_FLOOR_GRID — relevé sur la V2C aujourd'hui. Même nom, sens
différent : aucune traduction partagée.
⚠️ Écrit d'après le contrat, PAS observé : DEROGATION_ACTIVE, endsAt et boundBy
ne sont pas dans Introspect (les params d'une décision sont une carte libre), et
aucune dérogation n'a tourné pendant une sonde. Tous les champs restent
nullables. Noté aussi qu'Introspect SOUS-DÉCLARE — il annonce draw{committedW}
seul alors que la box publie authorisedW/binding/bindingPhase/perPhaseBound —
donc son silence ne prouve rien.
La feuille de choix défile : 47 px de débordement trouvés par le test sur une
surface courte, donc réels sur un téléphone à police agrandie.
358 tests.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
243 lines
9.7 KiB
Dart
243 lines
9.7 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
|
|
import '../models/load_telemetry.dart';
|
|
import '../theme/etm_tokens.dart';
|
|
|
|
/// **Le boost — un bouton d'ACTION, pas une quatrième position du sélecteur.**
|
|
///
|
|
/// Une dérogation se superpose au mode courant, sert la charge à son maximum en tête de
|
|
/// passe éco — devant toute autre charge, quel que soit son rang — financée au réseau, et
|
|
/// s'efface d'elle-même. Elle ne remplace donc pas un mode : elle passe par-dessus, et le
|
|
/// mode revient après. C'est pour ça qu'elle ne peut pas être un bouton de plus à côté de
|
|
/// Manuel / PV / Min+PV.
|
|
///
|
|
/// ## Une durée, toujours
|
|
///
|
|
/// `durationS` est obligatoire côté moteur et bornée `[60 s, 6 h]`. Ce n'est pas une
|
|
/// contrainte technique à contourner : une dérogation qui ne sait pas dire quand elle finit
|
|
/// ne devrait pas exister (LM-1500). L'écran n'offre donc **aucune option « jusqu'à
|
|
/// annulation »** — pas parce que l'appel échouerait, mais parce que la promesse serait
|
|
/// mauvaise.
|
|
///
|
|
/// Le filet reste armé **même quand une cible d'énergie est donnée** : une cible repose sur
|
|
/// un instrument, et un instrument peut se taire.
|
|
///
|
|
/// ## L'état ne se lit pas dans le mode
|
|
///
|
|
/// 🔴 Si la borne était en manuel, le moteur force Eco le temps de la dérogation. Lire
|
|
/// `GetChargingInfos` montrerait alors un mode que l'utilisateur n'a pas choisi. La vérité
|
|
/// est sur la charge : [LoadTelemetryEntry.enDerogation].
|
|
class EvBoostButton extends StatefulWidget {
|
|
/// L'entrée de télémétrie de CETTE borne, ou `null` si elle n'est pas arbitrée en ce
|
|
/// moment (pas de véhicule branché, mode manuel…).
|
|
///
|
|
/// `null` n'est pas « pas de boost » : c'est « on ne sait pas », et le bouton se tait
|
|
/// plutôt que d'annoncer un état qu'il n'a pas lu.
|
|
final LoadTelemetryEntry? entry;
|
|
|
|
/// Pose la dérogation. Rend `null` si la box a accepté, sinon son code de refus.
|
|
final Future<String?> Function({required int durationS, double? targetWh}) onSet;
|
|
|
|
/// Annule la dérogation en cours.
|
|
final Future<String?> Function() onClear;
|
|
|
|
const EvBoostButton({
|
|
super.key,
|
|
required this.entry,
|
|
required this.onSet,
|
|
required this.onClear,
|
|
});
|
|
|
|
@override
|
|
State<EvBoostButton> createState() => _EvBoostButtonState();
|
|
}
|
|
|
|
class _EvBoostButtonState extends State<EvBoostButton> {
|
|
bool _busy = false;
|
|
Timer? _tick;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
// Le compte à rebours avance à la seconde, mais **la vérité reste la trame** : on
|
|
// redessine, on ne décide pas. Si la box cesse d'annoncer la dérogation, elle
|
|
// disparaît au cycle suivant même si l'échéance locale n'est pas atteinte.
|
|
_tick = Timer.periodic(const Duration(seconds: 1), (_) {
|
|
if (mounted && (widget.entry?.enDerogation ?? false)) setState(() {});
|
|
});
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_tick?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
Future<void> _lancer(int durationS, double? targetWh) async {
|
|
setState(() => _busy = true);
|
|
final err = await widget.onSet(durationS: durationS, targetWh: targetWh);
|
|
if (!mounted) return;
|
|
setState(() => _busy = false);
|
|
if (err != null) _dire('La box a refusé le boost ($err).');
|
|
}
|
|
|
|
Future<void> _arreter() async {
|
|
setState(() => _busy = true);
|
|
final err = await widget.onClear();
|
|
if (!mounted) return;
|
|
setState(() => _busy = false);
|
|
// `EnergyErrorInvalidParameter` = aucune dérogation ne tournait. Ce n'est pas une
|
|
// panne : elle a pu finir toute seule pendant l'appui. On ne crie pas au refus pour
|
|
// une course que l'utilisateur ne peut pas éviter.
|
|
if (err != null && err != 'EnergyErrorInvalidParameter') {
|
|
_dire('La box a refusé l\'arrêt ($err).');
|
|
}
|
|
}
|
|
|
|
void _dire(String m) =>
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text(m)));
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final e = widget.entry;
|
|
if (e == null) return const SizedBox.shrink();
|
|
return e.enDerogation ? _enCours(context, e) : _repos(context);
|
|
}
|
|
|
|
// ── au repos ──────────────────────────────────────────────────────────────
|
|
Widget _repos(BuildContext context) => SizedBox(
|
|
height: 44,
|
|
child: OutlinedButton.icon(
|
|
onPressed: _busy ? null : () => _choisirDuree(context),
|
|
icon: const Icon(Icons.bolt_rounded, size: 18),
|
|
label: const Text('Boost…'),
|
|
style: OutlinedButton.styleFrom(
|
|
foregroundColor: EtmTokens.solarOf(context),
|
|
side: BorderSide(
|
|
color: EtmTokens.solarOf(context).withValues(alpha: 0.6)),
|
|
shape:
|
|
RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)),
|
|
),
|
|
),
|
|
);
|
|
|
|
/// Les durées proposées tiennent **toutes** dans `[60, 21600]`. Aucune n'est « illimitée »
|
|
/// — voir la note de tête.
|
|
static const _durees = <(String, int)>[
|
|
('30 min', 1800),
|
|
('1 h', 3600),
|
|
('2 h', 7200),
|
|
('4 h', 14400),
|
|
('6 h', 21600),
|
|
];
|
|
|
|
Future<void> _choisirDuree(BuildContext context) async {
|
|
final choix = await showModalBottomSheet<int>(
|
|
context: context,
|
|
// ⚠️ **La feuille défile.** Cinq durées, un titre et deux lignes d'explication
|
|
// dépassent la hauteur disponible dès qu'un écran est court ou que la police système
|
|
// est agrandie — un débordement de 47 px sur la surface de test, donc bien réel sur
|
|
// un téléphone. Un contenu qui peut excéder son viewport doit pouvoir défiler ; s'en
|
|
// remettre à « ça tient chez moi » est la même erreur que le montage trop lâche.
|
|
builder: (ctx) => SafeArea(
|
|
child: SingleChildScrollView(
|
|
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
|
const SizedBox(height: 12),
|
|
Text('Boost — pendant combien de temps ?',
|
|
style: EtmTokens.sans(
|
|
size: 15,
|
|
weight: FontWeight.w600,
|
|
color: EtmTokens.inkOf(ctx))),
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(20, 8, 20, 4),
|
|
child: Text(
|
|
'La charge est servie à son maximum, achetée au réseau, devant toutes '
|
|
'les autres. Elle s\'arrête d\'elle-même au bout du temps choisi.',
|
|
textAlign: TextAlign.center,
|
|
style: EtmTokens.sans(size: 12, color: EtmTokens.mutedOf(ctx)),
|
|
),
|
|
),
|
|
for (final (label, s) in _durees)
|
|
ListTile(
|
|
leading: const Icon(Icons.timer_outlined),
|
|
title: Text(label),
|
|
onTap: () => Navigator.pop(ctx, s),
|
|
),
|
|
const SizedBox(height: 8),
|
|
]),
|
|
),
|
|
),
|
|
);
|
|
if (choix != null) await _lancer(choix, null);
|
|
}
|
|
|
|
// ── en cours ──────────────────────────────────────────────────────────────
|
|
Widget _enCours(BuildContext context, LoadTelemetryEntry e) {
|
|
final d = e.derogation;
|
|
final restant = d?.restant();
|
|
final depasse = d?.depasse() ?? false;
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.fromLTRB(12, 10, 8, 10),
|
|
decoration: BoxDecoration(
|
|
color: EtmTokens.solarOf(context).withValues(alpha: 0.10),
|
|
borderRadius: BorderRadius.circular(10),
|
|
border:
|
|
Border.all(color: EtmTokens.solarOf(context).withValues(alpha: 0.5)),
|
|
),
|
|
child: Row(children: [
|
|
Icon(Icons.bolt_rounded, size: 18, color: EtmTokens.solarOf(context)),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Text(_titre(restant, depasse),
|
|
style: EtmTokens.sans(
|
|
size: 13,
|
|
weight: FontWeight.w600,
|
|
color: EtmTokens.inkOf(context))),
|
|
if (_detail(d) != null)
|
|
Text(_detail(d)!,
|
|
style:
|
|
EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))),
|
|
]),
|
|
),
|
|
TextButton(
|
|
onPressed: _busy ? null : _arreter,
|
|
child: const Text('Arrêter'),
|
|
),
|
|
]),
|
|
);
|
|
}
|
|
|
|
/// ⚠️ **Une échéance dépassée ne se rabat pas à « 00:00 ».** La box annonce encore la
|
|
/// dérogation active alors que son échéance est passée : c'est une contradiction entre
|
|
/// deux champs de la même trame, pas un compte à rebours terminé. L'afficher à zéro
|
|
/// effacerait la seule chose qui la signale.
|
|
static String _titre(Duration? restant, bool depasse) {
|
|
if (depasse) return 'Boost en cours — échéance dépassée';
|
|
if (restant == null) return 'Boost en cours';
|
|
final m = restant.inMinutes;
|
|
final s = restant.inSeconds % 60;
|
|
return m >= 60
|
|
? 'Boost — ${restant.inHours} h ${(m % 60).toString().padLeft(2, '0')} restantes'
|
|
: 'Boost — ${m.toString().padLeft(2, '0')}:${s.toString().padLeft(2, '0')} restantes';
|
|
}
|
|
|
|
/// Ce qui BRIDE, quand quelque chose bride. Silencieux sinon : « rien ne te bride » n'est
|
|
/// pas une information qu'un écran doit répéter à chaque seconde.
|
|
static String? _detail(Derogation? d) {
|
|
if (d == null) return null;
|
|
final max = d.maxW;
|
|
final borne = switch (d.boundBy) {
|
|
'drawCap' => 'limité par l\'abonnement',
|
|
'loadCeiling' => 'la borne est à son plafond',
|
|
_ => null,
|
|
};
|
|
if (max == null && borne == null) return null;
|
|
final puissance = max == null ? null : '${(max / 1000).toStringAsFixed(1)} kW';
|
|
return [puissance, borne].whereType<String>().join(' · ');
|
|
}
|
|
}
|