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
1104 lines
40 KiB
Dart
1104 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 ──────────────────────────────────
|
||
|
||
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),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|