etm-powersync-app/lib/screens/ac_screen.dart
Patrick Schurig 38475e0007 fix(dashboard+thème): compteurs réseau honnêtes, et le sélecteur clair/sombre s'applique partout
1) « Vers réseau » — TROIS défauts, dont un seul était visible.

   La valeur aberrante (9,10184409403335e+33 kWh) ne vient PAS de l'app : Energy.GetPowerBalance
   la publie telle quelle sur .75. Trois des quatre compteurs cumulés sont corrompus
   (totalReturn 9,1e+33, totalAcquisition 7,5e+33, totalConsumption -1,6e+33) alors que
   totalProduction reste sain, et que les états du Thing compteur sont tous plausibles
   (1429 kWh importés, 88 kWh exportés). C'est donc l'accumulation de l'experience-plugin
   énergie qui déraille, côté box — signalé, pas corrigé ici.

   Les deux autres défauts étaient bien dans l'app :
   - « Depuis réseau » lisait l'OPPOSÉ du compteur d'export au lieu de totalAcquisition,
     qui a son propre compteur. Dès que l'export était positif, la tuile affichait
     0,0 kWh en permanence, quelle que soit l'électricité réellement achetée ;
   - les deux tuiles étaient libellées « aujourd'hui » alors que ces compteurs sont
     CUMULÉS depuis l'origine, jamais remis à zéro.

   Corrigé : EnergyData porte gridExportKwh / gridImportKwh, transportés tels quels — l'app
   ne maquille pas une donnée que la box publie fausse. Un plafond de plausibilité
   (10 GWh, ~3000 ans de consommation) distingue « mesure » de « compteur cassé » : au-delà
   la tuile affiche « — · compteur box invalide ». Imprimer le nombre le présenterait comme
   une mesure ; l'effacer laisserait croire à zéro.

2) Thème : 126 sites basculés des constantes claires vers les variantes contextuelles
   (EtmTokens.bg → bgOf(context), card → surfaceOf, ink → inkOf, muted → mutedOf,
   faint → faintOf, line → lineOf) dans ac_screen, things_screen, energy_screen,
   favorites_screen, main_drawer, ev_charging_card, energy_flow_widget. Ces écrans
   restaient en palette claire quel que soit le sélecteur.

   9 sites RESTENT en constante, et c'est délibéré : ce sont des tables statiques et des
   helpers de haut niveau (categoryInfoMap, _options des favoris, _modeColor, peintres du
   flux) où la couleur est une identité d'icône et où aucun BuildContext n'est en portée.
   Les faire suivre le thème demande de threader le contexte jusqu'à ces tables — un
   refactor à part entière, pas un correctif de fin de soirée.

Tests 80/80, analyze 0 erreur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-25 23:13:20 +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.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 ──────────────────────────────────
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,
),
],
),
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(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),
),
),
],
),
),
],
);
}
}