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>
1010 lines
36 KiB
Dart
1010 lines
36 KiB
Dart
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 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.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());
|
||
}
|
||
}
|