etm-powersync-app/lib/screens/dashboard_screen.dart
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

941 lines
30 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 'package:provider/provider.dart';
import '../main.dart' show DrawerMenuButton;
import '../models/energy_data.dart';
import '../services/nymea_service.dart';
import '../theme/etm_tokens.dart';
import '../widgets/energy_flow_widget.dart';
import '../widgets/ev_charging_card.dart';
class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key});
@override
State<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
final service = context.read<NymeaService>();
if (!service.connected) service.startSimulation();
});
}
@override
Widget build(BuildContext context) {
return Consumer<NymeaService>(
builder: (context, service, _) {
final data = service.energyData;
return Scaffold(
backgroundColor: EtmTokens.bg,
body: RefreshIndicator(
onRefresh: () async => service.startSimulation(),
color: EtmTokens.green,
child: CustomScrollView(
slivers: [
_DashAppBar(service: service),
SliverPadding(
padding: const EdgeInsets.fromLTRB(18, 4, 18, 32),
sliver: SliverList(
delegate: SliverChildListDelegate([
const SizedBox(height: 8),
_SystemHeroCard(data: data),
const SizedBox(height: 16),
EnergyFlowWidget(data: data),
const SizedBox(height: 16),
EVChargingCard(data: data, service: service),
const SizedBox(height: 16),
_KpiGrid(data: data),
const SizedBox(height: 16),
_ConsumersCard(data: data),
const SizedBox(height: 16),
_HeosDecisionsCard(),
const SizedBox(height: 16),
_ForecastCard(),
]),
),
),
],
),
),
);
},
);
}
}
// ─────────────────────────────── AppBar ────────────────────────────────────────
class _DashAppBar extends StatelessWidget {
final NymeaService service;
const _DashAppBar({required this.service});
@override
Widget build(BuildContext context) {
return SliverAppBar(
floating: true,
backgroundColor: EtmTokens.bg,
elevation: 0,
leading: const DrawerMenuButton(),
leadingWidth: 64,
title: Text('ETM PowerSync',
style: EtmTokens.sans(size: 20, weight: FontWeight.w600)),
actions: [
IconButton(
icon: Icon(
service.connected ? Icons.wifi_rounded : Icons.wifi_off_rounded,
color: service.connected ? EtmTokens.green : EtmTokens.danger,
),
onPressed: () => _showConnectionDialog(context, service),
),
Padding(
padding: const EdgeInsets.only(right: 16),
child: Stack(
clipBehavior: Clip.none,
children: [
const Icon(Icons.notifications_outlined, color: EtmTokens.navy, size: 24),
Positioned(
right: -1, top: -1,
child: Container(
width: 8, height: 8,
decoration: BoxDecoration(
color: EtmTokens.green,
shape: BoxShape.circle,
border: Border.all(color: EtmTokens.bg, width: 1.5),
),
),
),
],
),
),
],
);
}
void _showConnectionDialog(BuildContext context, NymeaService service) {
showDialog(
context: context,
builder: (_) => AlertDialog(
title: Text('Connexion nymea',
style: EtmTokens.sans(size: 18, weight: FontWeight.w600)),
content: Text(
service.connected
? 'Connecté à ${service.host}'
: service.isSimulation
? 'Mode simulation actif'
: 'Non connecté',
style: EtmTokens.sans(size: 14, color: EtmTokens.muted),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: Text('OK', style: EtmTokens.sans(color: EtmTokens.green)),
),
],
),
);
}
}
// ─────────────────────────── Hero système ──────────────────────────────────────
class _SystemHeroCard extends StatelessWidget {
final EnergyData data;
const _SystemHeroCard({required this.data});
String get _statusLabel {
if (data.gridPower.abs() < 50) return 'Optimal';
return data.gridPower > 0 ? 'Soutirage réseau' : 'Injection réseau';
}
Color get _statusColor {
if (data.gridPower.abs() < 50) return EtmTokens.green;
return data.gridPower > 0 ? EtmTokens.orange : EtmTokens.blue;
}
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: EtmTokens.card,
borderRadius: BorderRadius.circular(EtmTokens.radiusLg),
boxShadow: EtmTokens.cardShadow,
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Left — status + metrics
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(22, 22, 12, 22),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
_StatusPill(label: _statusLabel, color: _statusColor),
const SizedBox(width: 10),
Text('État du système',
style: EtmTokens.sans(
size: 11,
color: EtmTokens.faint,
letterSpacing: 0.12)),
],
),
const SizedBox(height: 12),
Text.rich(
TextSpan(
text: 'Maison ',
style: EtmTokens.sans(size: 17, weight: FontWeight.w500),
children: [
TextSpan(
text: '${data.selfConsumptionRate.toStringAsFixed(0)}%',
style: EtmTokens.sans(size: 17, weight: FontWeight.w700),
),
const TextSpan(text: ' autonome'),
],
),
),
const SizedBox(height: 4),
Text(
'${data.gridPower.abs().toStringAsFixed(0)} W soutiré du réseau',
style: EtmTokens.sans(size: 13, color: EtmTokens.muted),
),
const SizedBox(height: 16),
Row(
children: [
_Metric(label: 'Autocons.', value: '${data.selfConsumptionRate.toStringAsFixed(0)}%'),
const SizedBox(width: 20),
_Metric(label: 'Soutirage', value: '${data.gridPower.abs().toStringAsFixed(0)} W'),
const SizedBox(width: 20),
_Metric(label: 'Batterie', value: '${data.batterySOC.toStringAsFixed(0)}%'),
],
),
],
),
),
),
// Right — illustration maison
Container(
width: 120,
decoration: const BoxDecoration(
border: Border(left: BorderSide(color: EtmTokens.line)),
borderRadius: BorderRadius.only(
topRight: Radius.circular(EtmTokens.radiusLg),
bottomRight: Radius.circular(EtmTokens.radiusLg),
),
),
child: const _HouseIllustration(),
),
],
),
);
}
}
class _StatusPill extends StatelessWidget {
final String label;
final Color color;
const _StatusPill({required this.label, required this.color});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 5),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(99),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Container(width: 6, height: 6,
decoration: BoxDecoration(color: color, shape: BoxShape.circle)),
const SizedBox(width: 5),
Text(label,
style: EtmTokens.sans(size: 12, weight: FontWeight.w600, color: color)),
],
),
);
}
}
class _Metric extends StatelessWidget {
final String label;
final String value;
const _Metric({required this.label, required this.value});
@override
Widget build(BuildContext context) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: EtmTokens.sans(size: 11, color: EtmTokens.muted)),
Text(value, style: EtmTokens.mono(size: 18, weight: FontWeight.w700)),
],
);
}
}
// Illustration maison simplifiée en CustomPainter
class _HouseIllustration extends StatelessWidget {
const _HouseIllustration();
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.all(10),
child: AspectRatio(
aspectRatio: 270 / 200,
child: CustomPaint(painter: _HousePainter()),
),
);
}
}
class _HousePainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final sx = size.width / 270;
final sy = size.height / 200;
Offset p(double x, double y) => Offset(x * sx, y * sy);
final outlinePaint = Paint()
..color = const Color(0xFF0D2B3B).withValues(alpha: 0.45)
..strokeWidth = 1.0
..style = PaintingStyle.stroke;
// Ground
canvas.drawOval(Rect.fromCenter(center: p(138, 176), width: 236 * sx, height: 24 * sy),
Paint()..color = const Color(0xFFE9EEF1));
// Front wall
canvas.drawRect(Rect.fromLTWH(p(66, 96).dx, p(66, 96).dy, 104 * sx, 72 * sy),
Paint()..color = const Color(0xFFF6F9FB));
canvas.drawRect(Rect.fromLTWH(p(66, 96).dx, p(66, 96).dy, 104 * sx, 72 * sy), outlinePaint);
// Side wall
final side = Path()
..moveTo(p(170, 96).dx, p(170, 96).dy)
..lineTo(p(200, 84).dx, p(200, 84).dy)
..lineTo(p(200, 158).dx, p(200, 158).dy)
..lineTo(p(170, 168).dx, p(170, 168).dy)..close();
canvas.drawPath(side, Paint()..color = const Color(0xFFDDE6EC));
canvas.drawPath(side, outlinePaint);
// Roof front slope
final roofF = Path()
..moveTo(p(58, 98).dx, p(58, 98).dy)
..lineTo(p(118, 56).dx, p(118, 56).dy)
..lineTo(p(170, 56).dx, p(170, 56).dy)
..lineTo(p(122, 98).dx, p(122, 98).dy)..close();
canvas.drawPath(roofF, Paint()..color = const Color(0xFF3A4A55));
canvas.drawPath(roofF, outlinePaint);
// PV panels
final pv = Path()
..moveTo(p(70, 92).dx, p(70, 92).dy)
..lineTo(p(120, 60).dx, p(120, 60).dy)
..lineTo(p(162, 60).dx, p(162, 60).dy)
..lineTo(p(116, 92).dx, p(116, 92).dy)..close();
canvas.drawPath(pv, Paint()..color = const Color(0xFF173A52));
// Amber sheen
canvas.drawPath(pv,
Paint()..color = const Color(0xFFFEC113).withValues(alpha: 0.10)..style = PaintingStyle.fill);
// Wallbox (blue)
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(p(70, 120).dx, p(70, 120).dy, 13 * sx, 22 * sy),
const Radius.circular(3)),
Paint()..color = const Color(0xFF31A3DD),
);
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(p(70, 120).dx, p(70, 120).dy, 13 * sx, 22 * sy),
const Radius.circular(3)),
outlinePaint,
);
// Door
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(p(86, 128).dx, p(86, 128).dy, 22 * sx, 40 * sy),
const Radius.circular(2)),
Paint()..color = const Color(0xFFC98A4A),
);
// Window
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(p(124, 112).dx, p(124, 112).dy, 32 * sx, 22 * sy),
const Radius.circular(2)),
Paint()..color = const Color(0xFFBFE2F3),
);
canvas.drawRRect(
RRect.fromRectAndRadius(
Rect.fromLTWH(p(124, 112).dx, p(124, 112).dy, 32 * sx, 22 * sy),
const Radius.circular(2)),
outlinePaint,
);
// Labels
void drawLabel(String text, Offset pos) {
final tp = TextPainter(
text: TextSpan(
text: text,
style: const TextStyle(
fontSize: 7,
fontWeight: FontWeight.w600,
color: Color(0xFF6B7D88),
letterSpacing: 0.5),
),
textDirection: TextDirection.ltr,
)..layout();
tp.paint(canvas, pos);
}
drawLabel('PV', p(150, 32));
drawLabel('BORNE', p(34, 127));
}
@override
bool shouldRepaint(_HousePainter _) => false;
}
// ─────────────────────────── KPI 2×2 ──────────────────────────────────────────
class _KpiGrid extends StatelessWidget {
final EnergyData data;
const _KpiGrid({required this.data});
@override
Widget build(BuildContext context) {
final prodKwh = (data.dayProductionWh / 1000);
return Column(
children: [
Row(
children: [
Expanded(child: _KpiCard(
title: 'Production',
value: prodKwh.toStringAsFixed(1),
unit: ' kWh',
chart: _SparkBars(values: const [18, 12, 15, 22, 40, 55, 70, 85, 100, 78, 60, 42]),
)),
const SizedBox(width: 14),
Expanded(child: _KpiCard(
title: 'Autonomie',
value: data.autonomyRate.toStringAsFixed(0),
unit: '%',
color: EtmTokens.blue,
chart: _ProgressBar(value: data.autonomyRate / 100, color: EtmTokens.blue),
note: 'Objectif : 80%',
)),
],
),
const SizedBox(height: 14),
Row(
children: [
Expanded(child: _KpiCard(
title: 'Économie',
value: data.dayGains.toStringAsFixed(2),
unit: '',
color: EtmTokens.green,
chart: _TrendLine(),
note: 'vs maison non pilotée',
)),
const SizedBox(width: 14),
Expanded(child: _KpiCard(
title: 'Soutirage',
value: (data.dayGridInjectionWh.abs() / 1000).toStringAsFixed(1),
unit: ' kWh',
chart: _SparkBars(
values: const [30, 55, 40, 20, 8, 5, 6, 10],
color: EtmTokens.faint),
note: '0.42 €',
)),
],
),
],
);
}
}
class _KpiCard extends StatelessWidget {
final String title;
final String value;
final String unit;
final Color color;
final Widget chart;
final String? note;
const _KpiCard({
required this.title,
required this.value,
required this.unit,
this.color = EtmTokens.navy,
required this.chart,
this.note,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: EtmTokens.card,
borderRadius: BorderRadius.circular(EtmTokens.radius),
boxShadow: EtmTokens.cardShadow,
),
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: EtmTokens.sans(size: 13, color: EtmTokens.muted, weight: FontWeight.w500)),
const SizedBox(height: 6),
Text.rich(
TextSpan(
text: value,
style: EtmTokens.mono(size: 26, weight: FontWeight.w700, color: color),
children: [
TextSpan(
text: unit,
style: EtmTokens.sans(size: 13, color: EtmTokens.muted, weight: FontWeight.w600),
),
],
),
),
const SizedBox(height: 8),
chart,
if (note != null) ...[
const SizedBox(height: 6),
Text(note!, style: EtmTokens.sans(size: 11, color: EtmTokens.muted)),
],
],
),
);
}
}
class _SparkBars extends StatelessWidget {
final List<double> values;
final Color color;
const _SparkBars({required this.values, this.color = EtmTokens.amber});
@override
Widget build(BuildContext context) {
final max = values.reduce((a, b) => a > b ? a : b);
return SizedBox(
height: 34,
child: Row(
crossAxisAlignment: CrossAxisAlignment.end,
children: values.map((v) => Expanded(
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 1),
child: FractionallySizedBox(
heightFactor: v / max,
alignment: Alignment.bottomCenter,
child: DecoratedBox(
decoration: BoxDecoration(
color: color.withValues(alpha: 0.85),
borderRadius: BorderRadius.circular(2),
),
),
),
),
)).toList(),
),
);
}
}
class _ProgressBar extends StatelessWidget {
final double value;
final Color color;
const _ProgressBar({required this.value, required this.color});
@override
Widget build(BuildContext context) {
return ClipRRect(
borderRadius: BorderRadius.circular(99),
child: SizedBox(
height: 7,
child: LinearProgressIndicator(
value: value,
backgroundColor: EtmTokens.line,
valueColor: AlwaysStoppedAnimation(color),
),
),
);
}
}
class _TrendLine extends StatelessWidget {
const _TrendLine();
@override
Widget build(BuildContext context) {
return SizedBox(
height: 38,
child: CustomPaint(painter: _TrendPainter()),
);
}
}
class _TrendPainter extends CustomPainter {
@override
void paint(Canvas canvas, Size size) {
final points = [34.0, 30.0, 31.0, 24.0, 22.0, 14.0, 11.0, 3.0];
final w = size.width;
final h = size.height;
final dx = w / (points.length - 1);
final max = points.reduce((a, b) => a > b ? a : b);
final pts = List.generate(points.length,
(i) => Offset(i * dx, h - (points[i] / max) * (h - 4)));
// Fill
final fill = Path()..moveTo(pts.first.dx, h);
for (final p in pts) {
fill.lineTo(p.dx, p.dy);
}
fill.lineTo(pts.last.dx, h);
fill.close();
canvas.drawPath(
fill,
Paint()
..shader = LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
EtmTokens.green.withValues(alpha: 0.25),
EtmTokens.green.withValues(alpha: 0),
],
).createShader(Rect.fromLTWH(0, 0, w, h)));
// Line
final linePaint = Paint()
..color = EtmTokens.green
..strokeWidth = 2.2
..style = PaintingStyle.stroke
..strokeCap = StrokeCap.round;
final path = Path()..moveTo(pts.first.dx, pts.first.dy);
for (final p in pts.skip(1)) {
path.lineTo(p.dx, p.dy);
}
canvas.drawPath(path, linePaint);
}
@override
bool shouldRepaint(_TrendPainter _) => false;
}
// ─────────────────────────── Consommateurs ─────────────────────────────────────
class _ConsumersCard extends StatelessWidget {
final EnergyData data;
const _ConsumersCard({required this.data});
@override
Widget build(BuildContext context) {
final total = data.homePower.clamp(1.0, double.infinity);
final consumers = [
_Consumer('PAC', Icons.heat_pump_outlined, EtmTokens.blue,
EtmTokens.blueSoft, total * 0.40),
_Consumer('EVSE', Icons.ev_station_rounded, EtmTokens.green,
EtmTokens.greenSoft, data.chargingPower * 1000),
_Consumer('Chauffe-eau', Icons.water_drop_outlined, EtmTokens.amber,
EtmTokens.amberSoft, total * 0.18),
];
return _Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CardHeader('Consommateurs principaux', link: 'Voir tout'),
const SizedBox(height: 4),
...consumers.map((c) => _ConsumerRow(c: c, total: total)),
],
),
);
}
}
class _Consumer {
final String name;
final IconData icon;
final Color color;
final Color bgColor;
final double watts;
const _Consumer(this.name, this.icon, this.color, this.bgColor, this.watts);
}
class _ConsumerRow extends StatelessWidget {
final _Consumer c;
final double total;
const _ConsumerRow({required this.c, required this.total});
@override
Widget build(BuildContext context) {
final pct = (c.watts / total).clamp(0.0, 1.0);
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
children: [
Container(
width: 34, height: 34,
decoration: BoxDecoration(color: c.bgColor, borderRadius: BorderRadius.circular(10)),
child: Icon(c.icon, color: c.color, size: 18),
),
const SizedBox(width: 12),
Expanded(child: Text(c.name, style: EtmTokens.sans(size: 14, weight: FontWeight.w500))),
Text('${c.watts.toStringAsFixed(0)} W',
style: EtmTokens.mono(size: 13, color: EtmTokens.muted)),
const SizedBox(width: 12),
SizedBox(
width: 70,
child: ClipRRect(
borderRadius: BorderRadius.circular(99),
child: SizedBox(
height: 6,
child: LinearProgressIndicator(
value: pct,
backgroundColor: EtmTokens.line,
valueColor: AlwaysStoppedAnimation(c.color),
),
),
),
),
const SizedBox(width: 8),
SizedBox(
width: 36,
child: Text('${(pct * 100).toStringAsFixed(0)}%',
style: EtmTokens.mono(size: 13, weight: FontWeight.w700, color: c.color),
textAlign: TextAlign.right),
),
],
),
);
}
}
// ─────────────────────────── Décisions Héos ────────────────────────────────────
class _HeosDecisionsCard extends StatelessWidget {
const _HeosDecisionsCard();
@override
Widget build(BuildContext context) {
return _Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_CardHeader('Décisions d\'Héos', link: 'Voir tout'),
const SizedBox(height: 4),
_DecisionRow(
icon: Icons.ev_station_rounded,
bgColor: EtmTokens.greenSoft,
iconColor: EtmTokens.green,
title: 'Charge voiture décalée',
time: '02h 06h',
tags: [_Tag.tarif, _Tag.pv],
),
_DecisionRow(
icon: Icons.heat_pump_outlined,
bgColor: EtmTokens.blueSoft,
iconColor: EtmTokens.blue,
title: 'PAC optimisée',
time: '06h30 08h30',
tags: [_Tag.heuresCreuses, _Tag.confort],
),
_DecisionRow(
icon: Icons.battery_charging_full_rounded,
bgColor: EtmTokens.greenSoft,
iconColor: EtmTokens.green,
title: 'Charge batterie planifiée',
time: '12h 15h',
tags: [_Tag.pv, _Tag.pic],
),
Padding(
padding: const EdgeInsets.only(top: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Impact consolidé aujourd\'hui',
style: EtmTokens.sans(size: 12, color: EtmTokens.muted)),
Text.rich(TextSpan(
text: '4,60 € ',
style: EtmTokens.mono(size: 13, weight: FontWeight.w700, color: EtmTokens.greenDark),
children: [
TextSpan(text: 'économisés',
style: EtmTokens.sans(size: 12, color: EtmTokens.muted)),
],
)),
],
),
),
],
),
);
}
}
enum _Tag { tarif, pv, heuresCreuses, confort, pic }
class _DecisionRow extends StatelessWidget {
final IconData icon;
final Color bgColor;
final Color iconColor;
final String title;
final String time;
final List<_Tag> tags;
const _DecisionRow({
required this.icon,
required this.bgColor,
required this.iconColor,
required this.title,
required this.time,
required this.tags,
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 10),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
width: 34, height: 34,
decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(10)),
child: Icon(icon, color: iconColor, size: 18),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title, style: EtmTokens.sans(size: 14, weight: FontWeight.w500)),
Text(time, style: EtmTokens.mono(size: 12, color: EtmTokens.muted)),
const SizedBox(height: 6),
Wrap(
spacing: 5, runSpacing: 5,
children: tags.map(_buildTag).toList(),
),
],
),
),
],
),
);
}
Widget _buildTag(_Tag tag) {
final (label, bg, fg) = switch (tag) {
_Tag.tarif => ('Tarif bas', EtmTokens.amberSoft, const Color(0xFF9A7510)),
_Tag.pv => ('Surplus PV', EtmTokens.greenSoft, EtmTokens.greenDark),
_Tag.heuresCreuses=> ('Heures creuses',EtmTokens.amberSoft, const Color(0xFF9A7510)),
_Tag.confort => ('Confort', const Color(0xFFEEF2F5), EtmTokens.muted),
_Tag.pic => ('Couvre le pic', EtmTokens.blueSoft, const Color(0xFF1F6F97)),
};
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(color: bg, borderRadius: BorderRadius.circular(8)),
child: Text(label, style: EtmTokens.sans(size: 11, weight: FontWeight.w500, color: fg)),
);
}
}
// ─────────────────────────── Prévisions ────────────────────────────────────────
class _ForecastCard extends StatelessWidget {
const _ForecastCard();
@override
Widget build(BuildContext context) {
return _Card(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Prévisions & recommandations',
style: EtmTokens.sans(size: 17, weight: FontWeight.w600)),
Text('aujourd\'hui', style: EtmTokens.sans(size: 13, color: EtmTokens.muted)),
],
),
const SizedBox(height: 16),
Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: EtmTokens.bg,
borderRadius: BorderRadius.circular(14),
),
child: Row(
children: [
Icon(Icons.cloud_outlined, color: EtmTokens.faint, size: 32),
const SizedBox(width: 16),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Prévisions non disponibles',
style: EtmTokens.sans(size: 14, weight: FontWeight.w500, color: EtmTokens.muted)),
const SizedBox(height: 4),
Text('Configurez un provider dans Tarifs & Héos pour activer les prévisions PV et tarifaires.',
style: EtmTokens.sans(size: 12, color: EtmTokens.faint)),
],
),
),
],
),
),
],
),
);
}
}
// ─────────────────────────── 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 _CardHeader extends StatelessWidget {
final String title;
final String? link;
const _CardHeader(this.title, {this.link});
@override
Widget build(BuildContext context) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(title, style: EtmTokens.sans(size: 17, weight: FontWeight.w600)),
if (link != null)
Text(link!, style: EtmTokens.sans(size: 13, weight: FontWeight.w600, color: EtmTokens.blue)),
],
);
}
}