etm-powersync-app/lib/screens/ac_screen.dart
Patrick Schurig 2a8ad95120 feat(config): lot C — un seul écran de configuration, et le grisage vient du schéma
FUSION. L'écran « Ordre de service » est supprimé ; sa route redirige. C'est son CHEMIN DE
DONNÉES (LoadConfigProvider) qui a migré dans la présentation de « Rôles & appareils »,
jamais l'inverse. EmsRole fige six rôles dont dhw ET heatPump séparés — or LM-201 dit qu'une
PAC sans ballon séparé est UNE charge : le modèle des rôles aurait exigé deux charges sur un
canal unique, ce que validateSet() refuse à raison. Les charges viennent donc de
GetLoadConfig. Les compteurs restent sur EnergySetupProvider (autre contrat, SetRootMeter).

La note « le pilote peut bousculer l'ordre les jours Tempo rouge ou à l'approche d'une
échéance » est retirée : aucun de ces comportements n'existe dans le moteur.

ANNONCE AVANT ÉCRITURE. Miroir exact de EnergyArbitrator::sameHardware(). Le piège, vérifié
sur .75 : la comparaison des relais est INDEXÉE, donc réordonner relays[] sans rien changer
d'autre reconstruit l'adaptateur — contacts ouverts (applySafeState, force) et verrous
réarmés à froid. Un glisser-déposer dans la liste des contacteurs est un geste matériel.
L'écran le nomme, avec la durée de réarmement.

GRISAGE PAR INTROSPECTION. RpcSchema lit JSONRPC.Introspect à la connexion ; trois états et
non deux — écrivable, r: (mesure, pas fonction manquante), absent — plus « inconnu » quand le
schéma n'a pas pu être lu. Chaque champ grisé porte sa raison et le nom du champ manquant.
La version affichée est celle de l'API d'expérience (NymeaEnergy 0.8) : le paquet +etm15
n'est exposé par aucune méthode.

CORRIGÉ AU PASSAGE — les modes de recharge étaient INOPÉRANTS. setChargingInfo() appelait
EnergyPlugin.SetChargingInfo (namespace inexistant) avec mode/targetSoc/endTime/minCurrent
au lieu de chargingMode/targetPercentage/endDateTime, et minCurrent n'existe dans aucun
champ. Vérifié par appel réel : « Missing required key: chargingMode ». Le refus était de
plus avalé et l'état local mis à jour quand même. Désormais l'état ne suit que si la box
accepte, et le refus remonte sans en inventer la cause.

ZONES — constaté, pas corrigé. L'hypothèse « champs grisés » est fausse : AirConditioning
publie 8 méthodes toutes en écriture. ac_screen.dart n'en appelle aucune, ses 4 pièces sont
des constantes de maquette, et .75 déclare zéro zone. getZones() ajouté + bandeau qui lit le
vrai compte et annonce la maquette. Le câblage est un lot en soi, non exerçable sans zone.

Tests 80/80, analyze 0 erreur. Relevé : docs/RELEVE_LOTC.md — dont §6, ce qui n'a PAS pu
être vérifié : l'IU n'a pas pu être pilotée sur l'appareil, MIUI refusant INJECT_EVENTS.

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

1104 lines
40 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../providers/schema_provider.dart';
import '../services/nymea_service.dart';
import '../widgets/schema_gated.dart';
import '../theme/etm_tokens.dart';
import '../main.dart' show DrawerMenuButton;
// ─────────────────────────────────────────────────────────────────────────────
// ACScreen — Climatisation / Chauffage
//
// Structure (brief) :
// ① Thermostats par pièce EN HAUT (geste fréquent)
// - pièces actives : expandées avec temp + modes
// - pièces éteintes : compactes
// ② Sources pilotées par Héos EN BAS
// - PAC Atlantic (SG-Ready : 4 états)
// - Chauffe-eau thermodynamique (Surplus / Eco / Boost)
// - Climatiseur (rafraîchissement anticipé)
//
// Saison-conscient : hiver = PAC + ECS, été = Clim + ECS.
// La maquette les montre tous pour valider les widgets.
// ─────────────────────────────────────────────────────────────────────────────
// ── Enums ────────────────────────────────────────────────────────────────────
enum ACMode { heat, cool, auto, fan }
enum SGReadyState { blocked, normal, recommended, forced }
enum DHWMode { surplus, eco, boost }
// ── Data models ───────────────────────────────────────────────────────────────
class _Zone {
final String name;
final double currentTemp;
final double targetTemp;
final ACMode mode;
final bool isOn;
final bool heosActive; // Héos pilote en ce moment
const _Zone({
required this.name,
required this.currentTemp,
required this.targetTemp,
required this.mode,
required this.isOn,
this.heosActive = false,
});
_Zone copyWith({double? targetTemp, ACMode? mode, bool? isOn}) => _Zone(
name: name,
currentTemp: currentTemp,
targetTemp: targetTemp ?? this.targetTemp,
mode: mode ?? this.mode,
isOn: isOn ?? this.isOn,
heosActive: heosActive,
);
}
// ─────────────────────────────────────────────────────────────────────────────
class ACScreen extends StatefulWidget {
const ACScreen({super.key});
@override
State<ACScreen> createState() => _ACScreenState();
}
class _ACScreenState extends State<ACScreen> {
final List<_Zone> _zones = [
const _Zone(name: 'Salon', currentTemp: 19.5, targetTemp: 21.0, mode: ACMode.heat, isOn: true, heosActive: true),
const _Zone(name: 'Chambre', currentTemp: 18.0, targetTemp: 19.0, mode: ACMode.cool, isOn: false),
const _Zone(name: 'Bureau', currentTemp: 20.0, targetTemp: 22.0, mode: ACMode.heat, isOn: true),
const _Zone(name: 'Cuisine', currentTemp: 21.5, targetTemp: 21.0, mode: ACMode.auto, isOn: false),
];
/// Nombre de zones RÉELLEMENT déclarées sur la box. `null` tant qu'on n'a pas lu.
int? _zonesSurLaBox;
SGReadyState _sgReady = SGReadyState.forced;
bool _sgAuto = true; // Héos pilote SG-Ready
DHWMode _dhwMode = DHWMode.surplus;
double _dhwCurrent = 52;
double _dhwTarget = 60;
double _climCurrent = 24.0;
double _climTarget = 22.0;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) async {
if (!mounted) return;
final zones = await context.read<NymeaService>().getZones();
if (mounted) setState(() => _zonesSurLaBox = zones.length);
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: EtmTokens.bg,
body: CustomScrollView(
slivers: [
SliverAppBar(
floating: true,
backgroundColor: EtmTokens.bg,
elevation: 0,
leading: const DrawerMenuButton(),
leadingWidth: 64,
title: Text('Climatisation / Chauffage',
style: EtmTokens.sans(size: 18, weight: FontWeight.w600)),
actions: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
icon: const Icon(Icons.schedule_rounded,
color: EtmTokens.muted, size: 22),
onPressed: () {},
tooltip: 'Planning hebdomadaire',
),
),
],
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(18, 4, 18, 32),
sliver: SliverList(
delegate: SliverChildListDelegate([
_EtatDuCablage(zonesSurLaBox: _zonesSurLaBox),
const SizedBox(height: 14),
// ── ① Thermostats par pièce ─────────────────────────────────
...List.generate(_zones.length, (i) {
final zone = _zones[i];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: zone.isOn
? _ActiveZoneCard(
zone: zone,
onChanged: (z) => setState(() => _zones[i] = z),
)
: _InactiveZoneCard(
zone: zone,
onTurnOn: () => setState(
() => _zones[i] = zone.copyWith(isOn: true)),
),
);
}),
const SizedBox(height: 8),
// ── ② Sources pilotées par Héos ─────────────────────────────
_SectionLabel('SOURCES PILOTÉES PAR HÉOS'),
const SizedBox(height: 12),
// PAC Atlantic — SG-Ready
_PACCard(
sgState: _sgReady,
sgAuto: _sgAuto,
onStateChanged: (s) => setState(() {
_sgReady = s;
_sgAuto = false;
}),
onAutoToggle: () => setState(() => _sgAuto = !_sgAuto),
),
const SizedBox(height: 12),
// Chauffe-eau thermodynamique
_DHWCard(
mode: _dhwMode,
currentTemp: _dhwCurrent,
targetTemp: _dhwTarget,
onModeChanged: (m) => setState(() => _dhwMode = m),
onTargetChanged: (t) => setState(() => _dhwTarget = t),
),
const SizedBox(height: 12),
// Climatiseur — rafraîchissement anticipé
_ClimCard(
currentTemp: _climCurrent,
targetTemp: _climTarget,
onTargetChanged: (t) => setState(() => _climTarget = t),
),
]),
),
),
],
),
);
}
}
// ─────────────────────────── Helpers de style ──────────────────────────────────
Color _modeColor(ACMode m) => switch (m) {
ACMode.heat => EtmTokens.orange,
ACMode.cool => EtmTokens.blue,
ACMode.auto => EtmTokens.green,
ACMode.fan => EtmTokens.muted,
};
IconData _modeIcon(ACMode m) => switch (m) {
ACMode.heat => Icons.whatshot_rounded,
ACMode.cool => Icons.ac_unit_rounded,
ACMode.auto => Icons.autorenew_rounded,
ACMode.fan => Icons.air_rounded,
};
String _modeLabel(ACMode m) => switch (m) {
ACMode.heat => 'Chauffage',
ACMode.cool => 'Clim',
ACMode.auto => 'Auto',
ACMode.fan => 'Ventilation',
};
String _modeLabelShort(ACMode m) => switch (m) {
ACMode.heat => 'Chauf.',
ACMode.cool => 'Clim',
ACMode.auto => 'Auto',
ACMode.fan => 'Vent.',
};
// ─────────────────────────── Carte zone active ─────────────────────────────────
class _ActiveZoneCard extends StatelessWidget {
final _Zone zone;
final ValueChanged<_Zone> onChanged;
const _ActiveZoneCard({required this.zone, required this.onChanged});
@override
Widget build(BuildContext context) {
final color = _modeColor(zone.mode);
return _Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Container(
width: 44, height: 44,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(12),
),
child: Icon(_modeIcon(zone.mode), color: color, size: 24),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(zone.name,
style: EtmTokens.sans(size: 16, weight: FontWeight.w600)),
Text(_modeLabel(zone.mode),
style: EtmTokens.sans(size: 12, color: color)),
],
),
),
Switch(
value: zone.isOn,
onChanged: (v) => onChanged(zone.copyWith(isOn: v)),
activeColor: color,
),
],
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Divider(height: 1, color: EtmTokens.line),
),
// Températures
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Actuelle', style: EtmTokens.sans(size: 11, color: EtmTokens.muted)),
Text('${zone.currentTemp.toStringAsFixed(1)}°C',
style: EtmTokens.mono(size: 28, weight: FontWeight.w700)),
],
),
Icon(Icons.arrow_forward_rounded, color: EtmTokens.faint, size: 20),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('Cible', style: EtmTokens.sans(size: 11, color: EtmTokens.muted)),
Row(
children: [
_TempButton(
icon: Icons.remove,
color: color,
onTap: () => onChanged(
zone.copyWith(targetTemp: zone.targetTemp - 0.5)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text('${zone.targetTemp.toStringAsFixed(1)}°C',
style: EtmTokens.mono(size: 24, weight: FontWeight.w700,
color: color)),
),
_TempButton(
icon: Icons.add,
color: color,
onTap: () => onChanged(
zone.copyWith(targetTemp: zone.targetTemp + 0.5)),
),
],
),
],
),
],
),
const SizedBox(height: 14),
// Sélecteur mode
Row(
children: ACMode.values.map((m) {
final sel = zone.mode == m;
final mcol = _modeColor(m);
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: GestureDetector(
onTap: () => onChanged(zone.copyWith(mode: m)),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: sel ? mcol.withValues(alpha: 0.12) : EtmTokens.bg,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: sel ? mcol : EtmTokens.line,
width: sel ? 1.5 : 1,
),
),
child: Column(
children: [
Icon(_modeIcon(m),
size: 18,
color: sel ? mcol : EtmTokens.faint),
const SizedBox(height: 3),
Text(_modeLabelShort(m),
style: EtmTokens.sans(
size: 10,
color: sel ? mcol : EtmTokens.muted,
weight: sel ? FontWeight.w600 : FontWeight.w400)),
],
),
),
),
),
);
}).toList(),
),
// Chip Héos si actif
if (zone.heosActive) ...[
const SizedBox(height: 12),
_HeosChip('Chauffe au solaire en ce moment'),
],
],
),
);
}
}
// ─────────────────────────── Carte zone inactive ───────────────────────────────
class _InactiveZoneCard extends StatelessWidget {
final _Zone zone;
final VoidCallback onTurnOn;
const _InactiveZoneCard({required this.zone, required this.onTurnOn});
@override
Widget build(BuildContext context) {
return _Card(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
child: Row(
children: [
Container(
width: 40, height: 40,
decoration: BoxDecoration(
color: EtmTokens.bg,
borderRadius: BorderRadius.circular(12),
),
child: Icon(_modeIcon(zone.mode), color: EtmTokens.faint, size: 22),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(zone.name,
style: EtmTokens.sans(size: 15, weight: FontWeight.w600)),
Text('Éteint', style: EtmTokens.sans(size: 12, color: EtmTokens.faint)),
],
),
),
Switch(value: false, onChanged: (_) => onTurnOn(), activeColor: EtmTokens.green),
],
),
);
}
}
// ─────────────────────────── PAC SG-Ready ──────────────────────────────────────
class _PACCard extends StatelessWidget {
final SGReadyState sgState;
final bool sgAuto;
final ValueChanged<SGReadyState> onStateChanged;
final VoidCallback onAutoToggle;
const _PACCard({
required this.sgState,
required this.sgAuto,
required this.onStateChanged,
required this.onAutoToggle,
});
@override
Widget build(BuildContext context) {
return _Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Container(
width: 44, height: 44,
decoration: BoxDecoration(
color: EtmTokens.orange.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.heat_pump_outlined,
color: EtmTokens.orange, size: 24),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('PAC Atlantic',
style: EtmTokens.sans(size: 15, weight: FontWeight.w600)),
Text('Source de chauffage · SG-Ready',
style: EtmTokens.sans(size: 11, color: EtmTokens.muted)),
],
),
),
_HeosBadgeSmall(),
],
),
const SizedBox(height: 14),
// Label + toggle Auto
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('MODE SG-READY',
style: EtmTokens.sectionLabel()),
GestureDetector(
onTap: onAutoToggle,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: sgAuto ? EtmTokens.greenSoft : EtmTokens.bg,
borderRadius: BorderRadius.circular(99),
border: Border.all(
color: sgAuto ? EtmTokens.green : EtmTokens.line,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.auto_awesome_rounded,
size: 12,
color: sgAuto ? EtmTokens.green : EtmTokens.muted),
const SizedBox(width: 4),
Text('Auto',
style: EtmTokens.sans(
size: 11,
weight: FontWeight.w600,
color: sgAuto ? EtmTokens.green : EtmTokens.muted)),
],
),
),
),
],
),
const SizedBox(height: 10),
// 4 états SG-Ready
Row(
children: SGReadyState.values.map((s) {
final isBlocked = s == SGReadyState.blocked;
final sel = !sgAuto && sgState == s;
final (label, color) = switch (s) {
SGReadyState.blocked => ('Bloqué', EtmTokens.danger),
SGReadyState.normal => ('Normal', EtmTokens.muted),
SGReadyState.recommended => ('Recommandé', EtmTokens.amber),
SGReadyState.forced => ('Forcé', EtmTokens.green),
};
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: GestureDetector(
onTap: isBlocked ? null : () => onStateChanged(s),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.symmetric(vertical: 9),
decoration: BoxDecoration(
color: isBlocked
? EtmTokens.bg
: sel
? color.withValues(alpha: 0.14)
: EtmTokens.bg,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: sel ? color : EtmTokens.line,
width: sel ? 1.5 : 1,
),
),
child: Text(
label,
textAlign: TextAlign.center,
style: EtmTokens.sans(
size: 11,
weight: sel ? FontWeight.w600 : FontWeight.w400,
color: isBlocked
? EtmTokens.faint
: sel ? color : EtmTokens.muted,
),
),
),
),
),
);
}).toList(),
),
const SizedBox(height: 14),
// Info surplus
_SurplusInfo(
label: 'Surplus solaire 2,4 kW',
detail: 'chauffe au solaire en stockant l\'énergie gratuite.',
powerKw: 1.8,
solarPct: 100,
),
],
),
);
}
}
// ─────────────────────────── Chauffe-eau ───────────────────────────────────────
class _DHWCard extends StatelessWidget {
final DHWMode mode;
final double currentTemp;
final double targetTemp;
final ValueChanged<DHWMode> onModeChanged;
final ValueChanged<double> onTargetChanged;
const _DHWCard({
required this.mode,
required this.currentTemp,
required this.targetTemp,
required this.onModeChanged,
required this.onTargetChanged,
});
@override
Widget build(BuildContext context) {
final (modeColor, modeIcon, modeLabel) = switch (mode) {
DHWMode.surplus => (EtmTokens.green, Icons.wb_sunny_rounded, 'Surplus'),
DHWMode.eco => (EtmTokens.blue, Icons.eco_rounded, 'Éco'),
DHWMode.boost => (EtmTokens.amber, Icons.bolt_rounded, 'Boost'),
};
return _Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Container(
width: 44, height: 44,
decoration: BoxDecoration(
color: EtmTokens.blue.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.water_drop_outlined,
color: EtmTokens.blue, size: 24),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Chauffe-eau',
style: EtmTokens.sans(size: 15, weight: FontWeight.w600)),
Text('Ballon thermodynamique',
style: EtmTokens.sans(size: 11, color: EtmTokens.muted)),
],
),
),
_HeosBadgeSmall(),
],
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Divider(height: 1, color: EtmTokens.line),
),
// Températures
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Température eau',
style: EtmTokens.sans(size: 11, color: EtmTokens.muted)),
Text('${currentTemp.toStringAsFixed(0)}°C',
style: EtmTokens.mono(size: 28, weight: FontWeight.w700)),
],
),
Icon(Icons.arrow_forward_rounded, color: EtmTokens.faint, size: 20),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('Cible', style: EtmTokens.sans(size: 11, color: EtmTokens.muted)),
Row(
children: [
_TempButton(
icon: Icons.remove,
color: modeColor,
onTap: () => onTargetChanged(targetTemp - 1)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text('${targetTemp.toStringAsFixed(0)}°C',
style: EtmTokens.mono(size: 24, weight: FontWeight.w700,
color: modeColor)),
),
_TempButton(
icon: Icons.add,
color: modeColor,
onTap: () => onTargetChanged(targetTemp + 1)),
],
),
],
),
],
),
const SizedBox(height: 14),
// 3 modes DHW
Row(
children: DHWMode.values.map((m) {
final sel = mode == m;
final (mc, mi, ml) = switch (m) {
DHWMode.surplus => (EtmTokens.green, Icons.wb_sunny_rounded, 'Surplus'),
DHWMode.eco => (EtmTokens.blue, Icons.eco_rounded, 'Éco'),
DHWMode.boost => (EtmTokens.amber, Icons.bolt_rounded, 'Boost'),
};
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
child: GestureDetector(
onTap: () => onModeChanged(m),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: sel ? mc.withValues(alpha: 0.12) : EtmTokens.bg,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: sel ? mc : EtmTokens.line,
width: sel ? 1.5 : 1,
),
),
child: Column(
children: [
Icon(mi, size: 20, color: sel ? mc : EtmTokens.faint),
const SizedBox(height: 4),
Text(ml,
style: EtmTokens.sans(
size: 12,
weight: sel ? FontWeight.w600 : FontWeight.w400,
color: sel ? mc : EtmTokens.muted)),
],
),
),
),
),
);
}).toList(),
),
const SizedBox(height: 14),
// Info
_SurplusInfo(
label: 'Surplus solaire',
detail: 'chauffe l\'eau avant le soir',
powerKw: 1.2,
solarPct: 100,
),
],
),
);
}
}
// ─────────────────────────── Climatiseur anticipé ──────────────────────────────
class _ClimCard extends StatelessWidget {
final double currentTemp;
final double targetTemp;
final ValueChanged<double> onTargetChanged;
const _ClimCard({
required this.currentTemp,
required this.targetTemp,
required this.onTargetChanged,
});
@override
Widget build(BuildContext context) {
return _Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Container(
width: 44, height: 44,
decoration: BoxDecoration(
color: EtmTokens.blue.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.ac_unit_rounded,
color: EtmTokens.blue, size: 24),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Climatiseur',
style: EtmTokens.sans(size: 15, weight: FontWeight.w600)),
Text('Rafraîchissement anticipé',
style: EtmTokens.sans(size: 11, color: EtmTokens.muted)),
],
),
),
],
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Divider(height: 1, color: EtmTokens.line),
),
// Températures
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Actuelle', style: EtmTokens.sans(size: 11, color: EtmTokens.muted)),
Text('${currentTemp.toStringAsFixed(1)}°C',
style: EtmTokens.mono(size: 28, weight: FontWeight.w700)),
],
),
const Icon(Icons.arrow_forward_rounded, color: EtmTokens.faint, size: 20),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('Cible', style: EtmTokens.sans(size: 11, color: EtmTokens.muted)),
Row(
children: [
_TempButton(
icon: Icons.remove,
color: EtmTokens.blue,
onTap: () => onTargetChanged(targetTemp - 0.5)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text('${targetTemp.toStringAsFixed(1)}°C',
style: EtmTokens.mono(size: 24, weight: FontWeight.w700,
color: EtmTokens.blue)),
),
_TempButton(
icon: Icons.add,
color: EtmTokens.blue,
onTap: () => onTargetChanged(targetTemp + 0.5)),
],
),
],
),
],
),
const SizedBox(height: 14),
// Info Héos
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: EtmTokens.blueSoft,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.schedule_rounded,
size: 14, color: EtmTokens.blue),
const SizedBox(width: 6),
Text('Pré-refroidissement 13h–16h',
style: EtmTokens.sans(size: 12, weight: FontWeight.w600,
color: EtmTokens.blue)),
],
),
const SizedBox(height: 4),
Text(
'Sur surplus solaire, pour traverser le pic tarifaire '
'17h–21h sans clim payante.',
style: EtmTokens.sans(size: 11, color: EtmTokens.muted),
),
const SizedBox(height: 8),
Row(
children: [
Text('820 W', style: EtmTokens.mono(size: 12, color: EtmTokens.blue)),
Text(' · 100% solaire',
style: EtmTokens.sans(size: 12, color: EtmTokens.muted)),
],
),
],
),
),
],
),
);
}
}
// ─────────────────────────── Widgets partagés ──────────────────────────────────
class _Card extends StatelessWidget {
final Widget child;
final EdgeInsets? padding;
const _Card({required this.child, this.padding});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: EtmTokens.card,
borderRadius: BorderRadius.circular(EtmTokens.radiusLg),
boxShadow: EtmTokens.cardShadow,
),
padding: padding ?? const EdgeInsets.all(20),
child: child,
);
}
}
class _TempButton extends StatelessWidget {
final IconData icon;
final Color color;
final VoidCallback onTap;
const _TempButton({required this.icon, required this.color, required this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 30, height: 30,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.10),
shape: BoxShape.circle,
),
child: Icon(icon, size: 16, color: color),
),
);
}
}
class _HeosChip extends StatelessWidget {
final String label;
const _HeosChip(this.label);
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: EtmTokens.greenSoft,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.wb_sunny_rounded, size: 12, color: EtmTokens.greenDark),
const SizedBox(width: 5),
Text(label,
style: EtmTokens.sans(size: 11, weight: FontWeight.w500,
color: EtmTokens.greenDark)),
],
),
);
}
}
class _HeosBadgeSmall extends StatelessWidget {
const _HeosBadgeSmall();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: EtmTokens.greenSoft,
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.auto_awesome_rounded, size: 10, color: EtmTokens.greenDark),
const SizedBox(width: 4),
Text('Piloté par Héos',
style: EtmTokens.sans(size: 10, weight: FontWeight.w600,
color: EtmTokens.greenDark)),
],
),
);
}
}
class _SurplusInfo extends StatelessWidget {
final String label;
final String detail;
final double powerKw;
final int solarPct;
const _SurplusInfo({
required this.label,
required this.detail,
required this.powerKw,
required this.solarPct,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: EtmTokens.greenSoft,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
const Icon(Icons.wb_sunny_rounded, size: 14, color: EtmTokens.greenDark),
const SizedBox(width: 8),
Expanded(
child: RichText(
text: TextSpan(
style: EtmTokens.sans(size: 11, color: EtmTokens.muted),
children: [
TextSpan(
text: '$label ',
style: EtmTokens.sans(size: 11, weight: FontWeight.w600,
color: EtmTokens.greenDark),
),
TextSpan(text: detail),
],
),
),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('${powerKw.toStringAsFixed(1)} kW',
style: EtmTokens.mono(size: 11, color: EtmTokens.green)),
Text('$solarPct% solaire',
style: EtmTokens.sans(size: 10, color: EtmTokens.muted)),
],
),
],
),
);
}
}
class _SectionLabel extends StatelessWidget {
final String text;
const _SectionLabel(this.text);
@override
Widget build(BuildContext context) {
return Text(text, style: EtmTokens.sectionLabel());
}
}
/// Dit franchement ce que cet écran montre — et ce qu'il ne montre pas encore.
///
/// ## Pourquoi ce bandeau plutôt qu'un grisage
///
/// Le lot prévoyait de **griser** les champs que la box n'expose pas. L'introspection de
/// `.75` a montré l'inverse de l'hypothèse : `AirConditioning` publie **huit** méthodes,
/// toutes en écriture — nom, thermostats, vannes, sondes, consigne de veille, dérogation
/// avec mode et durée, programme hebdomadaire. Il n'y a donc **rien à griser** : la box
/// est prête, c'est le câblage de cet écran qui manque.
///
/// Griser des champs ici aurait raconté une histoire fausse — « la box ne sait pas
/// faire » au lieu de « l'app ne le demande pas encore ». Et les quatre pièces affichées
/// plus bas sont des **exemples de maquette**, pas des zones lues : le dire est le
/// minimum, puisqu'un écran qui invente quatre pièces sur une installation qui n'en
/// déclare aucune est indistinguable d'une panne de lecture.
class _EtatDuCablage extends StatelessWidget {
final int? zonesSurLaBox;
const _EtatDuCablage({required this.zonesSurLaBox});
@override
Widget build(BuildContext context) {
final schema = context.watch<SchemaProvider>();
final n = zonesSurLaBox;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SchemaVersionBanner(
versionLine: schema.versionLine(),
schemaLoaded: schema.schema.loaded,
),
const SizedBox(height: 8),
Container(
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: EtmTokens.solar.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Icon(Icons.construction_rounded, size: 16, color: EtmTokens.heat),
const SizedBox(width: 8),
Expanded(
child: Text(
n == null
? 'Zones non lues sur la box. Les pièces ci-dessous sont des '
'exemples de maquette, pas des zones réelles.'
: n == 0
? 'Cette box ne déclare aucune zone de climatisation. Les '
'pièces ci-dessous sont des exemples de maquette, pas des '
'zones réelles — cet écran n\'est pas encore branché sur '
'AirConditioning.'
: '$n zone(s) déclarée(s) sur la box, mais cet écran n\'est '
'pas encore branché sur AirConditioning : les pièces '
'ci-dessous restent des exemples de maquette.',
style: EtmTokens.sans(
size: 11.5,
color: EtmTokens.mutedOf(context),
height: 1.35),
),
),
],
),
),
],
);
}
}