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 Function({required int durationS, double? targetWh}) onSet; /// Annule la dérogation en cours. final Future Function() onClear; const EvBoostButton({ super.key, required this.entry, required this.onSet, required this.onClear, }); @override State createState() => _EvBoostButtonState(); } class _EvBoostButtonState extends State { 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 _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 _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 _choisirDuree(BuildContext context) async { final choix = await showModalBottomSheet( 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().join(' · '); } }