etm-powersync-app/lib/screens/ac_screen.dart
Patrick Schurig fb5b0e7165 feat(accueil): carte « En ce moment » sur la télémétrie, plus de marque ni de démo
Consigne app §G (décision Patrick du 2026-10-16) et motifs etm70 (§H).

G — la carte « Héos pilote en ce moment » affichait des décisions
inventées (HeosDecision.demo : voiture 3,6 kW, batterie en charge sur un
banc sans batterie). Remplacée par EnCeMomentCard : une ligne par charge
de GetLoadTelemetry — libellé de la configuration, decision.code + params
rendus par decisionText, puissance MESURÉE seulement (jamais l'allocation
à sa place). Titre en clé l10n. Rien sur « optimiseur actif » tant que
l'objet de capacités n'existe pas.
Marque retirée des identifiants (HeosDecisionCard, HeosDecision,
HeosAction, _HeosChip, _HeosBadgeSmall, heosActive → pilotageActif…), des
fichiers (heos_decision_card.dart, dashboard_models.dart supprimés), des
textes affichés (hub du flux, écran A/C) et des commentaires :
grep -ri héos/heos vide sur lib/, test/, assets/ et les .arb.

H — contre INTERFACE.md local de la branche moteur, etm70 pas encore sur
le banc :
- motifs éco publiés à la racine (R7, part majoritaire) rendus en clair ;
  ventilation des niveaux affichée (« 83 W éco + 0 W confort », vu sur
  la trame réelle etm69) ;
- ECO_FLOOR_MISSED retenu par charge (publié un seul cycle), y compris
  pour une charge sortie de loads[] ; « manque non chiffrable » quand
  missingWh est absent, jamais 0 Wh ;
- PRICE_SLOT et OPTIMIZER_DECISION en FR / DE / EN (app_en.arb et
  app_de.arb créés pour ces seules clés ; le reste retombe sur le
  français, listé dans l10n_untranslated.json). Code inconnu : repli
  générique avec le code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:26:16 +02:00

1106 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 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 pilotageActif; // piloté en ce moment
const _Zone({
required this.name,
required this.currentTemp,
required this.targetTemp,
required this.mode,
required this.isOn,
this.pilotageActif = 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,
pilotageActif: pilotageActif,
);
}
// ─────────────────────────────────────────────────────────────────────────────
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, pilotageActif: 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; // SG-Ready piloté
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 ─────────────────────────────
_SectionLabel('SOURCES PILOTÉES'),
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(),
),
// Puce « piloté » si actif
if (zone.pilotageActif) ...[
const SizedBox(height: 12),
_PilotageChip('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))),
],
),
),
_PilotageBadge(),
],
),
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))),
],
),
),
_PilotageBadge(),
],
),
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 pilotage
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 _PilotageChip extends StatelessWidget {
final String label;
const _PilotageChip(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 _PilotageBadge extends StatelessWidget {
const _PilotageBadge();
@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é',
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),
),
),
],
),
),
],
);
}
}