Patrick Schurig ETM-Schurig d0a475a5d9 feat: refonte UI complète — design system EtmTokens + 4 écrans
Design system
- lib/theme/etm_tokens.dart : source de vérité couleurs + typo (IBM Plex Sans/Mono)
- lib/models/nymea_user.dart : modèle utilisateur nymea avec permissions EtmRole
- app_theme.dart : ThemeData migré vers IBM Plex Sans + couleurs EtmTokens

Navigation & drawer
- DrawerMenuButton : logo vert gradient + ombre
- Bottom nav : EtmTokens.green actif, EtmTokens.muted inactif
- DrawerPanel 320 px, restyled navy + gradient header + badges rôle

Dashboard (01_dashboard.html)
- Hero système : status pill + 3 métriques mono + illustration maison CustomPainter
- EnergyFlowWidget : 4 nœuds animés CustomPainter (flèches directionnelles)
  · gridPower > 0 = soutirage → flèche Grid→Home (amber)
  · gridPower < 0 = injection → flèche Home→Grid (bleu)
- EVChargingCard restyled : badge En charge + puissance mono 38px + 3 modes + SOC bar
- KPI 2×2 : spark bars, trend line, progress bar
- Consommateurs principaux + Décisions d'Héos (chips motifs)
- Prévisions placeholder explicite

Énergie
- KPI 2×2 avec icônes + fond soft + IBM Plex Mono
- Sélecteur période vert pill
- LineChart double axe : kW (gauche) / SOC % (droite, normalisé)
- BarChart bilan énergétique Wh (amber/bleu)
- Section Météo & prévision placeholder

Things
- Grille 2 col à hauteur intrinsèque (pas de childAspectRatio)
- Bandeau statut global (simulation / connecté / hors-ligne)
- _CategoryCard : header icon+label+count, séparateur coloré, liste tous items
- thing_category.dart : couleurs migrées vers EtmTokens

A/C — Climatisation / Chauffage
- Thermostats pièces EN HAUT : actives expandées, éteintes compactes
- Températures actuelle → cible ± avec EtmTokens.mono
- Sélecteur mode 4 boutons (Chauf/Clim/Auto/Vent)
- Chip "Chauffe au solaire en ce moment" (Héos)
- Sources pilotées par Héos EN BAS :
  · PAC SG-Ready : 4 états (Bloqué grisé / Normal / Recommandé / Forcé) + toggle Auto
  · Chauffe-eau : Surplus/Éco/Boost + temp eau 52°→60°C
  · Climatiseur : pré-refroidissement anticipé + info solaire

Packages ajoutés : google_fonts, flutter_staggered_grid_view, flutter_secure_storage
Asset : assets/house.svg (illustration maison CustomPainter)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 21:51:51 +02:00

1010 lines
36 KiB
Dart
Raw Permalink 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 '../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),
];
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
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: EtmTokens.bg,
body: CustomScrollView(
slivers: [
SliverAppBar(
floating: true,
backgroundColor: EtmTokens.bg,
elevation: 0,
leading: const DrawerMenuButton(),
leadingWidth: 64,
title: Text('Climatisation / Chauffage',
style: EtmTokens.sans(size: 18, weight: FontWeight.w600)),
actions: [
Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
icon: const Icon(Icons.schedule_rounded,
color: EtmTokens.muted, size: 22),
onPressed: () {},
tooltip: 'Planning hebdomadaire',
),
),
],
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(18, 4, 18, 32),
sliver: SliverList(
delegate: SliverChildListDelegate([
// ── ① Thermostats par pièce ─────────────────────────────────
...List.generate(_zones.length, (i) {
final zone = _zones[i];
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: zone.isOn
? _ActiveZoneCard(
zone: zone,
onChanged: (z) => setState(() => _zones[i] = z),
)
: _InactiveZoneCard(
zone: zone,
onTurnOn: () => setState(
() => _zones[i] = zone.copyWith(isOn: true)),
),
);
}),
const SizedBox(height: 8),
// ── ② Sources pilotées par Héos ─────────────────────────────
_SectionLabel('SOURCES PILOTÉES PAR HÉOS'),
const SizedBox(height: 12),
// PAC Atlantic — SG-Ready
_PACCard(
sgState: _sgReady,
sgAuto: _sgAuto,
onStateChanged: (s) => setState(() {
_sgReady = s;
_sgAuto = false;
}),
onAutoToggle: () => setState(() => _sgAuto = !_sgAuto),
),
const SizedBox(height: 12),
// Chauffe-eau thermodynamique
_DHWCard(
mode: _dhwMode,
currentTemp: _dhwCurrent,
targetTemp: _dhwTarget,
onModeChanged: (m) => setState(() => _dhwMode = m),
onTargetChanged: (t) => setState(() => _dhwTarget = t),
),
const SizedBox(height: 12),
// Climatiseur — rafraîchissement anticipé
_ClimCard(
currentTemp: _climCurrent,
targetTemp: _climTarget,
onTargetChanged: (t) => setState(() => _climTarget = t),
),
]),
),
),
],
),
);
}
}
// ─────────────────────────── Helpers de style ──────────────────────────────────
Color _modeColor(ACMode m) => switch (m) {
ACMode.heat => EtmTokens.orange,
ACMode.cool => EtmTokens.blue,
ACMode.auto => EtmTokens.green,
ACMode.fan => EtmTokens.muted,
};
IconData _modeIcon(ACMode m) => switch (m) {
ACMode.heat => Icons.whatshot_rounded,
ACMode.cool => Icons.ac_unit_rounded,
ACMode.auto => Icons.autorenew_rounded,
ACMode.fan => Icons.air_rounded,
};
String _modeLabel(ACMode m) => switch (m) {
ACMode.heat => 'Chauffage',
ACMode.cool => 'Clim',
ACMode.auto => 'Auto',
ACMode.fan => 'Ventilation',
};
String _modeLabelShort(ACMode m) => switch (m) {
ACMode.heat => 'Chauf.',
ACMode.cool => 'Clim',
ACMode.auto => 'Auto',
ACMode.fan => 'Vent.',
};
// ─────────────────────────── Carte zone active ─────────────────────────────────
class _ActiveZoneCard extends StatelessWidget {
final _Zone zone;
final ValueChanged<_Zone> onChanged;
const _ActiveZoneCard({required this.zone, required this.onChanged});
@override
Widget build(BuildContext context) {
final color = _modeColor(zone.mode);
return _Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Container(
width: 44, height: 44,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.14),
borderRadius: BorderRadius.circular(12),
),
child: Icon(_modeIcon(zone.mode), color: color, size: 24),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(zone.name,
style: EtmTokens.sans(size: 16, weight: FontWeight.w600)),
Text(_modeLabel(zone.mode),
style: EtmTokens.sans(size: 12, color: color)),
],
),
),
Switch(
value: zone.isOn,
onChanged: (v) => onChanged(zone.copyWith(isOn: v)),
activeColor: color,
),
],
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Divider(height: 1, color: EtmTokens.line),
),
// Températures
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Actuelle', style: EtmTokens.sans(size: 11, color: EtmTokens.muted)),
Text('${zone.currentTemp.toStringAsFixed(1)}°C',
style: EtmTokens.mono(size: 28, weight: FontWeight.w700)),
],
),
Icon(Icons.arrow_forward_rounded, color: EtmTokens.faint, size: 20),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('Cible', style: EtmTokens.sans(size: 11, color: EtmTokens.muted)),
Row(
children: [
_TempButton(
icon: Icons.remove,
color: color,
onTap: () => onChanged(
zone.copyWith(targetTemp: zone.targetTemp - 0.5)),
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text('${zone.targetTemp.toStringAsFixed(1)}°C',
style: EtmTokens.mono(size: 24, weight: FontWeight.w700,
color: color)),
),
_TempButton(
icon: Icons.add,
color: color,
onTap: () => onChanged(
zone.copyWith(targetTemp: zone.targetTemp + 0.5)),
),
],
),
],
),
],
),
const SizedBox(height: 14),
// Sélecteur mode
Row(
children: ACMode.values.map((m) {
final sel = zone.mode == m;
final mcol = _modeColor(m);
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: GestureDetector(
onTap: () => onChanged(zone.copyWith(mode: m)),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.symmetric(vertical: 8),
decoration: BoxDecoration(
color: sel ? mcol.withValues(alpha: 0.12) : EtmTokens.bg,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: sel ? mcol : EtmTokens.line,
width: sel ? 1.5 : 1,
),
),
child: Column(
children: [
Icon(_modeIcon(m),
size: 18,
color: sel ? mcol : EtmTokens.faint),
const SizedBox(height: 3),
Text(_modeLabelShort(m),
style: EtmTokens.sans(
size: 10,
color: sel ? mcol : EtmTokens.muted,
weight: sel ? FontWeight.w600 : FontWeight.w400)),
],
),
),
),
),
);
}).toList(),
),
// Chip Héos si actif
if (zone.heosActive) ...[
const SizedBox(height: 12),
_HeosChip('Chauffe au solaire en ce moment'),
],
],
),
);
}
}
// ─────────────────────────── Carte zone inactive ───────────────────────────────
class _InactiveZoneCard extends StatelessWidget {
final _Zone zone;
final VoidCallback onTurnOn;
const _InactiveZoneCard({required this.zone, required this.onTurnOn});
@override
Widget build(BuildContext context) {
return _Card(
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 14),
child: Row(
children: [
Container(
width: 40, height: 40,
decoration: BoxDecoration(
color: EtmTokens.bg,
borderRadius: BorderRadius.circular(12),
),
child: Icon(_modeIcon(zone.mode), color: EtmTokens.faint, size: 22),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(zone.name,
style: EtmTokens.sans(size: 15, weight: FontWeight.w600)),
Text('Éteint', style: EtmTokens.sans(size: 12, color: EtmTokens.faint)),
],
),
),
Switch(value: false, onChanged: (_) => onTurnOn(), activeColor: EtmTokens.green),
],
),
);
}
}
// ─────────────────────────── PAC SG-Ready ──────────────────────────────────────
class _PACCard extends StatelessWidget {
final SGReadyState sgState;
final bool sgAuto;
final ValueChanged<SGReadyState> onStateChanged;
final VoidCallback onAutoToggle;
const _PACCard({
required this.sgState,
required this.sgAuto,
required this.onStateChanged,
required this.onAutoToggle,
});
@override
Widget build(BuildContext context) {
return _Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Container(
width: 44, height: 44,
decoration: BoxDecoration(
color: EtmTokens.orange.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.heat_pump_outlined,
color: EtmTokens.orange, size: 24),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('PAC Atlantic',
style: EtmTokens.sans(size: 15, weight: FontWeight.w600)),
Text('Source de chauffage · SG-Ready',
style: EtmTokens.sans(size: 11, color: EtmTokens.muted)),
],
),
),
_HeosBadgeSmall(),
],
),
const SizedBox(height: 14),
// Label + toggle Auto
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('MODE SG-READY',
style: EtmTokens.sectionLabel()),
GestureDetector(
onTap: onAutoToggle,
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: sgAuto ? EtmTokens.greenSoft : EtmTokens.bg,
borderRadius: BorderRadius.circular(99),
border: Border.all(
color: sgAuto ? EtmTokens.green : EtmTokens.line,
),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.auto_awesome_rounded,
size: 12,
color: sgAuto ? EtmTokens.green : EtmTokens.muted),
const SizedBox(width: 4),
Text('Auto',
style: EtmTokens.sans(
size: 11,
weight: FontWeight.w600,
color: sgAuto ? EtmTokens.green : EtmTokens.muted)),
],
),
),
),
],
),
const SizedBox(height: 10),
// 4 états SG-Ready
Row(
children: SGReadyState.values.map((s) {
final isBlocked = s == SGReadyState.blocked;
final sel = !sgAuto && sgState == s;
final (label, color) = switch (s) {
SGReadyState.blocked => ('Bloqué', EtmTokens.danger),
SGReadyState.normal => ('Normal', EtmTokens.muted),
SGReadyState.recommended => ('Recommandé', EtmTokens.amber),
SGReadyState.forced => ('Forcé', EtmTokens.green),
};
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: GestureDetector(
onTap: isBlocked ? null : () => onStateChanged(s),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.symmetric(vertical: 9),
decoration: BoxDecoration(
color: isBlocked
? EtmTokens.bg
: sel
? color.withValues(alpha: 0.14)
: EtmTokens.bg,
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: sel ? color : EtmTokens.line,
width: sel ? 1.5 : 1,
),
),
child: Text(
label,
textAlign: TextAlign.center,
style: EtmTokens.sans(
size: 11,
weight: sel ? FontWeight.w600 : FontWeight.w400,
color: isBlocked
? EtmTokens.faint
: sel ? color : EtmTokens.muted,
),
),
),
),
),
);
}).toList(),
),
const SizedBox(height: 14),
// Info surplus
_SurplusInfo(
label: 'Surplus solaire 2,4 kW',
detail: 'chauffe au solaire en stockant l\'énergie gratuite.',
powerKw: 1.8,
solarPct: 100,
),
],
),
);
}
}
// ─────────────────────────── Chauffe-eau ───────────────────────────────────────
class _DHWCard extends StatelessWidget {
final DHWMode mode;
final double currentTemp;
final double targetTemp;
final ValueChanged<DHWMode> onModeChanged;
final ValueChanged<double> onTargetChanged;
const _DHWCard({
required this.mode,
required this.currentTemp,
required this.targetTemp,
required this.onModeChanged,
required this.onTargetChanged,
});
@override
Widget build(BuildContext context) {
final (modeColor, modeIcon, modeLabel) = switch (mode) {
DHWMode.surplus => (EtmTokens.green, Icons.wb_sunny_rounded, 'Surplus'),
DHWMode.eco => (EtmTokens.blue, Icons.eco_rounded, 'Éco'),
DHWMode.boost => (EtmTokens.amber, Icons.bolt_rounded, 'Boost'),
};
return _Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Container(
width: 44, height: 44,
decoration: BoxDecoration(
color: EtmTokens.blue.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.water_drop_outlined,
color: EtmTokens.blue, size: 24),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Chauffe-eau',
style: EtmTokens.sans(size: 15, weight: FontWeight.w600)),
Text('Ballon thermodynamique',
style: EtmTokens.sans(size: 11, color: EtmTokens.muted)),
],
),
),
_HeosBadgeSmall(),
],
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Divider(height: 1, color: EtmTokens.line),
),
// Températures
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Température eau',
style: EtmTokens.sans(size: 11, color: EtmTokens.muted)),
Text('${currentTemp.toStringAsFixed(0)}°C',
style: EtmTokens.mono(size: 28, weight: FontWeight.w700)),
],
),
Icon(Icons.arrow_forward_rounded, color: EtmTokens.faint, size: 20),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('Cible', style: EtmTokens.sans(size: 11, color: EtmTokens.muted)),
Row(
children: [
_TempButton(
icon: Icons.remove,
color: modeColor,
onTap: () => onTargetChanged(targetTemp - 1)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text('${targetTemp.toStringAsFixed(0)}°C',
style: EtmTokens.mono(size: 24, weight: FontWeight.w700,
color: modeColor)),
),
_TempButton(
icon: Icons.add,
color: modeColor,
onTap: () => onTargetChanged(targetTemp + 1)),
],
),
],
),
],
),
const SizedBox(height: 14),
// 3 modes DHW
Row(
children: DHWMode.values.map((m) {
final sel = mode == m;
final (mc, mi, ml) = switch (m) {
DHWMode.surplus => (EtmTokens.green, Icons.wb_sunny_rounded, 'Surplus'),
DHWMode.eco => (EtmTokens.blue, Icons.eco_rounded, 'Éco'),
DHWMode.boost => (EtmTokens.amber, Icons.bolt_rounded, 'Boost'),
};
return Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 3),
child: GestureDetector(
onTap: () => onModeChanged(m),
child: AnimatedContainer(
duration: const Duration(milliseconds: 180),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: sel ? mc.withValues(alpha: 0.12) : EtmTokens.bg,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: sel ? mc : EtmTokens.line,
width: sel ? 1.5 : 1,
),
),
child: Column(
children: [
Icon(mi, size: 20, color: sel ? mc : EtmTokens.faint),
const SizedBox(height: 4),
Text(ml,
style: EtmTokens.sans(
size: 12,
weight: sel ? FontWeight.w600 : FontWeight.w400,
color: sel ? mc : EtmTokens.muted)),
],
),
),
),
),
);
}).toList(),
),
const SizedBox(height: 14),
// Info
_SurplusInfo(
label: 'Surplus solaire',
detail: 'chauffe l\'eau avant le soir',
powerKw: 1.2,
solarPct: 100,
),
],
),
);
}
}
// ─────────────────────────── Climatiseur anticipé ──────────────────────────────
class _ClimCard extends StatelessWidget {
final double currentTemp;
final double targetTemp;
final ValueChanged<double> onTargetChanged;
const _ClimCard({
required this.currentTemp,
required this.targetTemp,
required this.onTargetChanged,
});
@override
Widget build(BuildContext context) {
return _Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
children: [
Container(
width: 44, height: 44,
decoration: BoxDecoration(
color: EtmTokens.blue.withValues(alpha: 0.10),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(Icons.ac_unit_rounded,
color: EtmTokens.blue, size: 24),
),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Climatiseur',
style: EtmTokens.sans(size: 15, weight: FontWeight.w600)),
Text('Rafraîchissement anticipé',
style: EtmTokens.sans(size: 11, color: EtmTokens.muted)),
],
),
),
],
),
const Padding(
padding: EdgeInsets.symmetric(vertical: 14),
child: Divider(height: 1, color: EtmTokens.line),
),
// Températures
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Actuelle', style: EtmTokens.sans(size: 11, color: EtmTokens.muted)),
Text('${currentTemp.toStringAsFixed(1)}°C',
style: EtmTokens.mono(size: 28, weight: FontWeight.w700)),
],
),
const Icon(Icons.arrow_forward_rounded, color: EtmTokens.faint, size: 20),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('Cible', style: EtmTokens.sans(size: 11, color: EtmTokens.muted)),
Row(
children: [
_TempButton(
icon: Icons.remove,
color: EtmTokens.blue,
onTap: () => onTargetChanged(targetTemp - 0.5)),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 4),
child: Text('${targetTemp.toStringAsFixed(1)}°C',
style: EtmTokens.mono(size: 24, weight: FontWeight.w700,
color: EtmTokens.blue)),
),
_TempButton(
icon: Icons.add,
color: EtmTokens.blue,
onTap: () => onTargetChanged(targetTemp + 0.5)),
],
),
],
),
],
),
const SizedBox(height: 14),
// Info Héos
Container(
padding: const EdgeInsets.all(14),
decoration: BoxDecoration(
color: EtmTokens.blueSoft,
borderRadius: BorderRadius.circular(12),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
const Icon(Icons.schedule_rounded,
size: 14, color: EtmTokens.blue),
const SizedBox(width: 6),
Text('Pré-refroidissement 13h16h',
style: EtmTokens.sans(size: 12, weight: FontWeight.w600,
color: EtmTokens.blue)),
],
),
const SizedBox(height: 4),
Text(
'Sur surplus solaire, pour traverser le pic tarifaire '
'17h21h sans clim payante.',
style: EtmTokens.sans(size: 11, color: EtmTokens.muted),
),
const SizedBox(height: 8),
Row(
children: [
Text('820 W', style: EtmTokens.mono(size: 12, color: EtmTokens.blue)),
Text(' · 100% solaire',
style: EtmTokens.sans(size: 12, color: EtmTokens.muted)),
],
),
],
),
),
],
),
);
}
}
// ─────────────────────────── Widgets partagés ──────────────────────────────────
class _Card extends StatelessWidget {
final Widget child;
final EdgeInsets? padding;
const _Card({required this.child, this.padding});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: EtmTokens.card,
borderRadius: BorderRadius.circular(EtmTokens.radiusLg),
boxShadow: EtmTokens.cardShadow,
),
padding: padding ?? const EdgeInsets.all(20),
child: child,
);
}
}
class _TempButton extends StatelessWidget {
final IconData icon;
final Color color;
final VoidCallback onTap;
const _TempButton({required this.icon, required this.color, required this.onTap});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: onTap,
child: Container(
width: 30, height: 30,
decoration: BoxDecoration(
color: color.withValues(alpha: 0.10),
shape: BoxShape.circle,
),
child: Icon(icon, size: 16, color: color),
),
);
}
}
class _HeosChip extends StatelessWidget {
final String label;
const _HeosChip(this.label);
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 6),
decoration: BoxDecoration(
color: EtmTokens.greenSoft,
borderRadius: BorderRadius.circular(8),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.wb_sunny_rounded, size: 12, color: EtmTokens.greenDark),
const SizedBox(width: 5),
Text(label,
style: EtmTokens.sans(size: 11, weight: FontWeight.w500,
color: EtmTokens.greenDark)),
],
),
);
}
}
class _HeosBadgeSmall extends StatelessWidget {
const _HeosBadgeSmall();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: EtmTokens.greenSoft,
borderRadius: BorderRadius.circular(6),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.auto_awesome_rounded, size: 10, color: EtmTokens.greenDark),
const SizedBox(width: 4),
Text('Piloté par Héos',
style: EtmTokens.sans(size: 10, weight: FontWeight.w600,
color: EtmTokens.greenDark)),
],
),
);
}
}
class _SurplusInfo extends StatelessWidget {
final String label;
final String detail;
final double powerKw;
final int solarPct;
const _SurplusInfo({
required this.label,
required this.detail,
required this.powerKw,
required this.solarPct,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
color: EtmTokens.greenSoft,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
const Icon(Icons.wb_sunny_rounded, size: 14, color: EtmTokens.greenDark),
const SizedBox(width: 8),
Expanded(
child: RichText(
text: TextSpan(
style: EtmTokens.sans(size: 11, color: EtmTokens.muted),
children: [
TextSpan(
text: '$label ',
style: EtmTokens.sans(size: 11, weight: FontWeight.w600,
color: EtmTokens.greenDark),
),
TextSpan(text: detail),
],
),
),
),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
Text('${powerKw.toStringAsFixed(1)} kW',
style: EtmTokens.mono(size: 11, color: EtmTokens.green)),
Text('$solarPct% solaire',
style: EtmTokens.sans(size: 10, color: EtmTokens.muted)),
],
),
],
),
);
}
}
class _SectionLabel extends StatelessWidget {
final String text;
const _SectionLabel(this.text);
@override
Widget build(BuildContext context) {
return Text(text, style: EtmTokens.sectionLabel());
}
}