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>
941 lines
30 KiB
Dart
941 lines
30 KiB
Dart
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)),
|
||
],
|
||
);
|
||
}
|
||
}
|