1) GLISSEMENT — diagnostic d'abord, et il a donné DEUX réponses. Le geste n'est PAS avalé par le ListView parent : les deux montages du test (section seule / section imbriquée) glissent identiquement. La cause du symptôme était bien que .75 n'a qu'une charge par domaine — rien à réordonner. Mais l'enquête a trouvé un vrai défaut au passage : le centre de la poignée tombait dans le vide de 3 px séparant l'icône de la pastille de rang, et ReorderableDragStartListener défère le test de collision à ses enfants. Une bande morte invisible au milieu d'une poignée qui paraît d'un seul tenant. Corrigé par un Container transparent. Tentative faite et ANNULÉE : rendre les domaines glissables aussi. Deux ReorderableListView imbriqués se disputent le geste et l'extérieur gagne — le glissement des cartes cassait. Les domaines gardent leurs flèches, et une charge seule dans son domaine n'offre plus de prise qui mènerait nulle part (avec l'infobulle qui renvoie au bon geste). Vérifié SUR L'APPAREIL, contre .75, avec les deux charges mises dans le même domaine : [chauffe-eau, pac-terrain] → [pac-terrain, chauffe-eau], brouillon abandonné sans rien écrire. Classement ecs/hvac rétabli ensuite. 2) SÉLECTEUR DE MÉCANISME — actif, et c'était le morceau délicat. LoadConfig est une union discriminée : écrire `adapter` sans nettoyer le reste produit une configuration que la box refuse EN BLOC, et le refus emporte toutes les charges de l'appel. D'où basculerMecanisme(), qui retire les clés du mécanisme quitté et pose celles du nouveau, en récupérant ce qui peut l'être — les deux premiers contacts deviennent SG1/SG2 dans l'encodage standard, les relais gardent leur puissance. Ce qui n'est PAS transposé : l'estimation d'un état SG-Ready n'est pas la puissance d'une résistance. La charge produite est alors incomplète, et c'est voulu — le formulaire réclame plutôt que d'inventer une valeur de plaque. La bascule franchit sameHardware() : confirmation préalable qui annonce la reconstruction et la durée de réarmement (900 s par défaut vers SG-Ready — la valeur d'une PAC réelle, pas celle du banc). Le mode fixed d'etmvariableload reste LISIBLE sans être proposé : bloc « Configuration héritée » en lecture seule, et conversion seulement sur geste explicite, avec le plafond que la déclaration en paliers ne porte pas. Ouvrir l'écran ne convertit personne. 3) CHIP DE DOMAINE — il ne rendait qu'une icône muette : le domaine d'une charge était illisible sur sa carte, alors qu'il décide de son groupe et de son rang. Il porte maintenant son libellé, et sa couleur suit le thème. 4) THÈME — les 11 derniers sites (pas 9) basculés. Trois cas : contexte disponible (main, ac_screen) ; peintre sans contexte, à qui la couleur est passée résolue (energy_flow_widget) ; tables statiques, où la teinte devient nullable et se résout au rendu (categoryInfoMap, favoris). Plus AUCUN jeton hors thème dans lib/. 5) ZONES — non entreprises, comme convenu. Tests 93/93, analyze 0 erreur. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
1106 lines
40 KiB
Dart
1106 lines
40 KiB
Dart
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.bgOf(context),
|
||
body: CustomScrollView(
|
||
slivers: [
|
||
SliverAppBar(
|
||
floating: true,
|
||
backgroundColor: EtmTokens.bgOf(context),
|
||
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: Icon(Icons.schedule_rounded,
|
||
color: EtmTokens.mutedOf(context), 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 ──────────────────────────────────
|
||
|
||
/// Couleur d'un mode. Prend le [BuildContext] parce que le mode « ventilation » n'a pas
|
||
/// de teinte propre : il emprunte le gris neutre, qui dépend du thème.
|
||
Color _modeColor(BuildContext c, ACMode m) => switch (m) {
|
||
ACMode.heat => EtmTokens.orange,
|
||
ACMode.cool => EtmTokens.blue,
|
||
ACMode.auto => EtmTokens.green,
|
||
ACMode.fan => EtmTokens.mutedOf(c),
|
||
};
|
||
|
||
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(context, 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,
|
||
),
|
||
],
|
||
),
|
||
|
||
Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 14),
|
||
child: Divider(height: 1, color: EtmTokens.lineOf(context)),
|
||
),
|
||
|
||
// Températures
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text('Actuelle', style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))),
|
||
Text('${zone.currentTemp.toStringAsFixed(1)}°C',
|
||
style: EtmTokens.mono(size: 28, weight: FontWeight.w700)),
|
||
],
|
||
),
|
||
Icon(Icons.arrow_forward_rounded, color: EtmTokens.faintOf(context), size: 20),
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
Text('Cible', style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))),
|
||
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(context, 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.bgOf(context),
|
||
borderRadius: BorderRadius.circular(10),
|
||
border: Border.all(
|
||
color: sel ? mcol : EtmTokens.lineOf(context),
|
||
width: sel ? 1.5 : 1,
|
||
),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
Icon(_modeIcon(m),
|
||
size: 18,
|
||
color: sel ? mcol : EtmTokens.faintOf(context)),
|
||
const SizedBox(height: 3),
|
||
Text(_modeLabelShort(m),
|
||
style: EtmTokens.sans(
|
||
size: 10,
|
||
color: sel ? mcol : EtmTokens.mutedOf(context),
|
||
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.bgOf(context),
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Icon(_modeIcon(zone.mode), color: EtmTokens.faintOf(context), 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.faintOf(context))),
|
||
],
|
||
),
|
||
),
|
||
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.mutedOf(context))),
|
||
],
|
||
),
|
||
),
|
||
_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.bgOf(context),
|
||
borderRadius: BorderRadius.circular(99),
|
||
border: Border.all(
|
||
color: sgAuto ? EtmTokens.green : EtmTokens.lineOf(context),
|
||
),
|
||
),
|
||
child: Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Icon(Icons.auto_awesome_rounded,
|
||
size: 12,
|
||
color: sgAuto ? EtmTokens.green : EtmTokens.mutedOf(context)),
|
||
const SizedBox(width: 4),
|
||
Text('Auto',
|
||
style: EtmTokens.sans(
|
||
size: 11,
|
||
weight: FontWeight.w600,
|
||
color: sgAuto ? EtmTokens.green : EtmTokens.mutedOf(context))),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
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.mutedOf(context)),
|
||
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.bgOf(context)
|
||
: sel
|
||
? color.withValues(alpha: 0.14)
|
||
: EtmTokens.bgOf(context),
|
||
borderRadius: BorderRadius.circular(10),
|
||
border: Border.all(
|
||
color: sel ? color : EtmTokens.lineOf(context),
|
||
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.faintOf(context)
|
||
: sel ? color : EtmTokens.mutedOf(context),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}).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.mutedOf(context))),
|
||
],
|
||
),
|
||
),
|
||
_HeosBadgeSmall(),
|
||
],
|
||
),
|
||
|
||
Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 14),
|
||
child: Divider(height: 1, color: EtmTokens.lineOf(context)),
|
||
),
|
||
|
||
// Températures
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text('Température eau',
|
||
style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))),
|
||
Text('${currentTemp.toStringAsFixed(0)}°C',
|
||
style: EtmTokens.mono(size: 28, weight: FontWeight.w700)),
|
||
],
|
||
),
|
||
Icon(Icons.arrow_forward_rounded, color: EtmTokens.faintOf(context), size: 20),
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
Text('Cible', style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))),
|
||
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.bgOf(context),
|
||
borderRadius: BorderRadius.circular(12),
|
||
border: Border.all(
|
||
color: sel ? mc : EtmTokens.lineOf(context),
|
||
width: sel ? 1.5 : 1,
|
||
),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
Icon(mi, size: 20, color: sel ? mc : EtmTokens.faintOf(context)),
|
||
const SizedBox(height: 4),
|
||
Text(ml,
|
||
style: EtmTokens.sans(
|
||
size: 12,
|
||
weight: sel ? FontWeight.w600 : FontWeight.w400,
|
||
color: sel ? mc : EtmTokens.mutedOf(context))),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}).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.mutedOf(context))),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
|
||
Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 14),
|
||
child: Divider(height: 1, color: EtmTokens.lineOf(context)),
|
||
),
|
||
|
||
// Températures
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Text('Actuelle', style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))),
|
||
Text('${currentTemp.toStringAsFixed(1)}°C',
|
||
style: EtmTokens.mono(size: 28, weight: FontWeight.w700)),
|
||
],
|
||
),
|
||
Icon(Icons.arrow_forward_rounded, color: EtmTokens.faintOf(context), size: 20),
|
||
Column(
|
||
crossAxisAlignment: CrossAxisAlignment.end,
|
||
children: [
|
||
Text('Cible', style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))),
|
||
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.mutedOf(context)),
|
||
),
|
||
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.mutedOf(context))),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ─────────────────────────── 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.surfaceOf(context),
|
||
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.mutedOf(context)),
|
||
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.mutedOf(context))),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
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),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|