etm-powersync-app/lib/screens/energy_screen.dart
Patrick Schurig ETM-Schurig dd798ae85e fix: graphe énergie — double axe kW/SOC% visible + simulation SOC réaliste
NymeaService
- batterySOCSource : retourne {'thingId': 'sim-battery', 'stateName': 'soc'}
  en simulation au lieu de null — le SOC est désormais fetchable
- fetchHistory en simulation : données SOC réalistes en % (0-100) avec
  courbe charge solaire 8h-15h (20→85%) puis décharge soir (85→20%)
  au lieu de valeurs sinus 100-900 W incorrectes

EnergyScreen
- initState : appelle startSimulation() si non connecté (comme les autres écrans)
- leftTitles : interval explicite (yMax/4) + SideTitleWidget pour ancrage correct
- gridData : horizontalInterval aligné sur yInterval
- rightTitles (SOC %) : SideTitleWidget + interval aligné
- bottomTitles : SideTitleWidget sur les deux graphes
- barChart leftTitles : interval + SideTitleWidget

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

899 lines
31 KiB
Dart

import 'dart:async';
import 'dart:math' show max, min;
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/energy_data.dart';
import '../models/nymea_models.dart';
import '../services/nymea_service.dart';
import '../theme/etm_tokens.dart';
import '../main.dart' show DrawerMenuButton;
// ─────────────────────────────────────────────────────────────────────────────
// EnergyScreen — historique énergétique
//
// 4 KPIs · sélecteur période · line chart double axe (kW / SOC%) · bar chart
// ─────────────────────────────────────────────────────────────────────────────
class _Tab {
final String label;
final Duration range;
final String sampleRate;
final bool showTime;
const _Tab(this.label, this.range, this.sampleRate, {required this.showTime});
}
class EnergyScreen extends StatefulWidget {
const EnergyScreen({super.key});
@override
State<EnergyScreen> createState() => _EnergyScreenState();
}
class _EnergyScreenState extends State<EnergyScreen> {
static const _tabs = [
_Tab('Heures', Duration(hours: 24), 'SampleRate15Mins', showTime: true),
_Tab('Jour', Duration(days: 7), 'SampleRate1Hour', showTime: false),
_Tab('Semaine', Duration(days: 28), 'SampleRate1Day', showTime: false),
_Tab('Mois', Duration(days: 365), 'SampleRate1Week', showTime: false),
];
int _tabIdx = 0;
List<PowerBalanceEntry> _data = [];
List<HistoryEntry> _socData = [];
bool _loading = true;
bool _noData = false;
DateTime? _selectedDate;
Timer? _refreshTimer;
NymeaService? _nymeaService;
bool _initialSocFetched = false;
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
_nymeaService = context.read<NymeaService>();
if (!_nymeaService!.connected) _nymeaService!.startSimulation();
_nymeaService!.addListener(_onServiceChangedForSoc);
_fetch();
});
_refreshTimer = Timer.periodic(const Duration(minutes: 5), (_) {
if (mounted) _fetch();
});
}
@override
void dispose() {
_refreshTimer?.cancel();
_nymeaService?.removeListener(_onServiceChangedForSoc);
super.dispose();
}
void _onServiceChangedForSoc() {
if (!mounted || _initialSocFetched || _loading) return;
if (_nymeaService?.batterySOCSource != null && _socData.isEmpty) {
_initialSocFetched = true;
_fetchSocOnly();
}
}
Future<void> _fetchSocOnly() async {
if (!mounted) return;
final tab = _tabs[_tabIdx];
final to = _anchorDate();
final service = context.read<NymeaService>();
final socSource = service.batterySOCSource;
if (socSource == null) return;
final soc = await service.fetchHistory(
thingId: socSource['thingId']!,
stateTypeName: socSource['stateName']!,
from: to.subtract(tab.range),
to: to,
sampleRate: tab.sampleRate,
);
if (mounted && soc.isNotEmpty) setState(() => _socData = soc);
}
Future<void> _fetch() async {
if (!mounted) return;
setState(() { _loading = true; _noData = false; });
final tab = _tabs[_tabIdx];
final to = _anchorDate();
final from = to.subtract(tab.range);
final service = context.read<NymeaService>();
final socSource = service.batterySOCSource;
final results = await Future.wait<dynamic>([
service.fetchPowerBalanceLogs(from: from, to: to, sampleRate: tab.sampleRate),
socSource != null
? service.fetchHistory(
thingId: socSource['thingId']!, stateTypeName: socSource['stateName']!,
from: from, to: to, sampleRate: tab.sampleRate)
: Future<List<HistoryEntry>>.value([]),
]);
if (!mounted) return;
setState(() {
_data = results[0] as List<PowerBalanceEntry>;
_socData = results[1] as List<HistoryEntry>;
_loading = false;
_noData = _data.isEmpty;
});
}
DateTime _anchorDate() => _selectedDate != null
? DateTime(_selectedDate!.year, _selectedDate!.month, _selectedDate!.day, 23, 59, 59)
: DateTime.now();
Future<void> _pickDate() async {
final picked = await showDatePicker(
context: context,
initialDate: _selectedDate ?? DateTime.now(),
firstDate: DateTime(2020),
lastDate: DateTime.now(),
builder: (ctx, child) => Theme(
data: Theme.of(ctx).copyWith(
colorScheme: const ColorScheme.light(
primary: EtmTokens.green,
onPrimary: Colors.white,
),
),
child: child!,
),
);
if (picked != null && mounted) {
setState(() { _selectedDate = picked; _initialSocFetched = false; });
_fetch();
}
}
@override
Widget build(BuildContext context) {
return Consumer<NymeaService>(
builder: (context, service, _) {
return Scaffold(
backgroundColor: EtmTokens.bg,
body: CustomScrollView(
slivers: [
// AppBar
SliverAppBar(
floating: true,
backgroundColor: EtmTokens.bg,
elevation: 0,
leading: const DrawerMenuButton(),
leadingWidth: 64,
title: Text('Énergie',
style: EtmTokens.sans(size: 20, weight: FontWeight.w600)),
actions: [
IconButton(
icon: Icon(
Icons.calendar_today_rounded,
color: _selectedDate != null ? EtmTokens.blue : EtmTokens.muted,
size: 20,
),
onPressed: _pickDate,
),
Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
icon: const Icon(Icons.refresh_rounded,
color: EtmTokens.muted, size: 20),
onPressed: _fetch,
),
),
],
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(18, 4, 18, 32),
sliver: SliverList(
delegate: SliverChildListDelegate([
// KPI 2x2
_KpiSection(service: service),
const SizedBox(height: 16),
// Sélecteur période
_PeriodSelector(
tabs: _tabs,
selectedIndex: _tabIdx,
onSelect: (i) {
if (_tabIdx != i) {
setState(() { _tabIdx = i; _initialSocFetched = false; });
_fetch();
}
},
),
// Chip date sélectionnée
if (_selectedDate != null) ...[
const SizedBox(height: 10),
_DateChip(
date: _selectedDate!,
onClear: () {
setState(() => _selectedDate = null);
_fetch();
},
),
],
const SizedBox(height: 16),
// Graphe Puissances · SOC %
_EtmChartCard(
title: 'Puissances · SOC %',
subtitle: 'kW (gauche) · % batterie (droite)',
legend: [
_LegendDot(EtmTokens.amber, 'Production'),
_LegendDot(EtmTokens.blue, 'Consommation'),
_LegendDot(EtmTokens.green, 'Autoconso'),
_LegendDot(EtmTokens.green, 'SOC %', dashed: true),
],
height: 220,
loading: _loading,
noData: _noData,
child: _buildLineChart(),
),
const SizedBox(height: 14),
// Graphe Bilan énergétique
_EtmChartCard(
title: 'Bilan énergétique (Wh)',
subtitle: 'Énergie par période',
legend: [
_LegendDot(EtmTokens.amber, 'Production'),
_LegendDot(EtmTokens.blue, 'Consommation'),
],
height: 200,
loading: _loading,
noData: _noData,
child: _buildBarChart(),
),
const SizedBox(height: 14),
// Météo & prévision — placeholder
_ForecastPlaceholder(),
]),
),
),
],
),
);
},
);
}
// ── ① Line chart : puissances + SOC ────────────────────────────────────────
Widget _buildLineChart() {
if (_loading || _noData || _data.isEmpty) return const SizedBox.shrink();
final prodSpots = <FlSpot>[];
final consoSpots = <FlSpot>[];
final autoSpots = <FlSpot>[];
final socSpots = <FlSpot>[];
for (int i = 0; i < _data.length; i++) {
final d = _data[i];
final x = i.toDouble();
// Toujours en kW pour l'axe gauche
prodSpots .add(FlSpot(x, d.productionW / 1000));
consoSpots.add(FlSpot(x, d.consumptionW / 1000));
autoSpots .add(FlSpot(x, d.autoconsommationW / 1000));
}
// Plage Y gauche en kW
final allKw = _data.expand((d) => [d.productionW / 1000, d.consumptionW / 1000]).toList();
final maxKw = allKw.isEmpty ? 2.5 : allKw.reduce(max);
final yMax = max(maxKw * 1.15, 0.5); // min 0.5 kW pour éviter y écrasé
// SOC normalisé sur l'échelle kW (100% → yMax)
final hasSoc = _socData.length > 1;
if (hasSoc) {
final n = _socData.length;
final xMax = (_data.length - 1).toDouble();
for (int i = 0; i < n; i++) {
socSpots.add(FlSpot(
xMax * i / (n - 1),
_socData[i].value * yMax / 100.0,
));
}
}
final xInterval = _xInterval();
final labelStyle = EtmTokens.sans(size: 9, color: EtmTokens.muted);
final yInterval = yMax / 4; // 5 graduations : 0, 25%, 50%, 75%, 100% de yMax
return LineChart(
LineChartData(
clipData: const FlClipData.all(),
minY: 0,
maxY: yMax,
gridData: FlGridData(
show: true,
drawVerticalLine: false,
horizontalInterval: yInterval,
getDrawingHorizontalLine: (_) =>
FlLine(color: EtmTokens.line, strokeWidth: 1),
),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
// Axe gauche : kW
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 46,
interval: yInterval,
getTitlesWidget: (v, meta) {
if (v == meta.max) return const SizedBox.shrink(); // évite label dupliqué au sommet
return SideTitleWidget(
meta: meta,
child: Text(
'${v.toStringAsFixed(1)}kW',
style: labelStyle,
),
);
},
),
),
// Axe droit : SOC %
rightTitles: hasSoc
? AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 40,
interval: yInterval,
getTitlesWidget: (v, meta) {
if (v == meta.max) return const SizedBox.shrink();
final pct = (v / yMax * 100).round();
if (pct < 0 || pct > 100) return const SizedBox.shrink();
return SideTitleWidget(
meta: meta,
child: Text(
'$pct%',
style: EtmTokens.sans(size: 9, color: EtmTokens.green),
),
);
},
),
)
: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
// Axe bas : temps
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 22,
interval: xInterval,
getTitlesWidget: (v, meta) {
final idx = v.round();
if (idx < 0 || idx >= _data.length) return const SizedBox.shrink();
return SideTitleWidget(
meta: meta,
child: Text(_fmtTime(_data[idx].timestamp), style: labelStyle),
);
},
),
),
),
lineBarsData: [
_line(prodSpots, EtmTokens.amber),
_line(consoSpots, EtmTokens.blue),
_line(autoSpots, EtmTokens.green),
if (hasSoc) _line(socSpots, EtmTokens.green, dashed: true, width: 1.5),
],
lineTouchData: LineTouchData(
touchTooltipData: LineTouchTooltipData(
getTooltipColor: (_) => EtmTokens.navy.withValues(alpha: 0.85),
getTooltipItems: (spots) => spots.map((s) {
final isSoc = hasSoc && s.barIndex == 3;
final label = isSoc
? '${(s.y / yMax * 100).toStringAsFixed(0)} %'
: '${s.y.toStringAsFixed(2)} kW';
return LineTooltipItem(
label,
EtmTokens.mono(size: 11, color: s.bar.color ?? Colors.white),
);
}).toList(),
),
),
),
);
}
LineChartBarData _line(List<FlSpot> spots, Color color,
{bool dashed = false, double width = 2.0}) =>
LineChartBarData(
spots: spots,
isCurved: true,
curveSmoothness: 0.25,
color: color,
barWidth: width,
dotData: const FlDotData(show: false),
dashArray: dashed ? [4, 5] : null,
belowBarData: BarAreaData(
show: !dashed,
color: color.withValues(alpha: 0.07),
),
);
// ── ② Bar chart : bilan énergétique ────────────────────────────────────────
Widget _buildBarChart() {
if (_loading || _noData || _data.isEmpty) return const SizedBox.shrink();
final groups = <BarChartGroupData>[];
double maxE = 1.0;
final bw = _barWidth();
for (int i = 0; i < _data.length; i++) {
final prodWh = i == 0 ? 0.0 : max(0.0, _data[i].totalProductionWh - _data[i - 1].totalProductionWh);
final consoWh = i == 0 ? 0.0 : max(0.0, _data[i].totalConsumptionWh - _data[i - 1].totalConsumptionWh);
maxE = [maxE, prodWh, consoWh].reduce(max);
groups.add(BarChartGroupData(
x: i,
barsSpace: 2,
barRods: [
BarChartRodData(
toY: prodWh, color: EtmTokens.amber, width: bw,
borderRadius: const BorderRadius.vertical(top: Radius.circular(3)),
),
BarChartRodData(
toY: consoWh, color: EtmTokens.blue, width: bw,
borderRadius: const BorderRadius.vertical(top: Radius.circular(3)),
),
],
));
}
final labelStyle = EtmTokens.sans(size: 9, color: EtmTokens.muted);
return BarChart(
BarChartData(
maxY: maxE * 1.15,
alignment: BarChartAlignment.spaceAround,
barTouchData: BarTouchData(
touchTooltipData: BarTouchTooltipData(
getTooltipColor: (_) => EtmTokens.navy.withValues(alpha: 0.85),
getTooltipItem: (group, _, rod, __) => BarTooltipItem(
_fmtWh(rod.toY),
EtmTokens.mono(size: 10, color: rod.color ?? Colors.white),
),
),
),
gridData: FlGridData(
show: true,
drawVerticalLine: false,
getDrawingHorizontalLine: (_) => FlLine(color: EtmTokens.line, strokeWidth: 1),
),
borderData: FlBorderData(show: false),
titlesData: FlTitlesData(
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
leftTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 52,
interval: maxE * 1.15 / 4,
getTitlesWidget: (v, meta) {
if (v == meta.max) return const SizedBox.shrink();
return SideTitleWidget(
meta: meta,
child: Text(_fmtWh(v), style: labelStyle),
);
},
),
),
bottomTitles: AxisTitles(
sideTitles: SideTitles(
showTitles: true,
reservedSize: 22,
getTitlesWidget: (v, meta) {
final idx = v.toInt();
if (idx < 0 || idx >= _data.length) return const SizedBox.shrink();
final step = _xInterval().toInt().clamp(1, _data.length);
if (idx % step != 0) return const SizedBox.shrink();
return SideTitleWidget(
meta: meta,
child: Text(_fmtTime(_data[idx].timestamp), style: labelStyle),
);
},
),
),
),
barGroups: groups,
),
);
}
// ── Helpers ───────────────────────────────────────────────────────────────
double _xInterval() =>
_data.length > 8 ? (_data.length / 6).ceilToDouble() : 1.0;
double _barWidth() =>
_data.length > 40 ? 3 : _data.length > 20 ? 5 : 8;
String _fmtWh(double v) {
if (v.abs() >= 1000) return '${(v / 1000).toStringAsFixed(1)} kWh';
return '${v.toStringAsFixed(0)} Wh';
}
String _fmtTime(DateTime t) => _tabs[_tabIdx].showTime
? '${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}'
: '${t.day}/${t.month}';
}
// ─────────────────────────── KPI section ───────────────────────────────────────
class _KpiSection extends StatelessWidget {
final NymeaService service;
const _KpiSection({required this.service});
@override
Widget build(BuildContext context) {
final d = service.energyData;
return Column(
children: [
Row(
children: [
_KpiTile(
icon: Icons.wb_sunny_rounded,
color: EtmTokens.amber,
bgColor: EtmTokens.amberSoft,
label: 'Production',
value: _kwh(d.dayProductionWh),
),
const SizedBox(width: 14),
_KpiTile(
icon: Icons.home_rounded,
color: EtmTokens.blue,
bgColor: EtmTokens.blueSoft,
label: 'Consommation',
value: _kwh(d.daySelfConsumptionWh),
),
],
),
const SizedBox(height: 14),
Row(
children: [
_KpiTile(
icon: Icons.recycling_rounded,
color: EtmTokens.green,
bgColor: EtmTokens.greenSoft,
label: 'Autoconsommation',
value: '${d.selfConsumptionRate.toStringAsFixed(0)}%',
),
const SizedBox(width: 14),
_KpiTile(
icon: Icons.euro_rounded,
color: EtmTokens.greenDark,
bgColor: EtmTokens.greenSoft,
label: 'Gains',
value: '${d.dayGains.toStringAsFixed(2)}',
),
],
),
],
);
}
static String _kwh(double wh) => '${(wh / 1000).toStringAsFixed(2)} kWh';
}
class _KpiTile extends StatelessWidget {
final IconData icon;
final Color color;
final Color bgColor;
final String label;
final String value;
const _KpiTile({
required this.icon,
required this.color,
required this.bgColor,
required this.label,
required this.value,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: EtmTokens.card,
borderRadius: BorderRadius.circular(EtmTokens.radius),
boxShadow: EtmTokens.cardShadow,
),
child: Row(
children: [
Container(
width: 36, height: 36,
decoration: BoxDecoration(color: bgColor, borderRadius: BorderRadius.circular(10)),
child: Icon(icon, color: color, size: 20),
),
const SizedBox(width: 12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label, style: EtmTokens.sans(size: 11, color: EtmTokens.muted)),
const SizedBox(height: 2),
Text(value,
style: EtmTokens.mono(size: 15, weight: FontWeight.w700, color: color),
maxLines: 1,
overflow: TextOverflow.ellipsis),
],
),
),
],
),
),
);
}
}
// ─────────────────────────── Sélecteur période ─────────────────────────────────
class _PeriodSelector extends StatelessWidget {
final List<_Tab> tabs;
final int selectedIndex;
final ValueChanged<int> onSelect;
const _PeriodSelector({
required this.tabs,
required this.selectedIndex,
required this.onSelect,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(4),
decoration: BoxDecoration(
color: EtmTokens.card,
borderRadius: BorderRadius.circular(EtmTokens.radius),
boxShadow: EtmTokens.cardShadow,
),
child: Row(
children: tabs.asMap().entries.map((e) {
final selected = selectedIndex == e.key;
return Expanded(
child: GestureDetector(
onTap: () => onSelect(e.key),
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(vertical: 9),
decoration: BoxDecoration(
color: selected ? EtmTokens.green : Colors.transparent,
borderRadius: BorderRadius.circular(EtmTokens.radius - 4),
),
child: Text(
e.value.label,
textAlign: TextAlign.center,
style: EtmTokens.sans(
size: 13,
weight: selected ? FontWeight.w600 : FontWeight.w400,
color: selected ? Colors.white : EtmTokens.muted,
),
),
),
),
);
}).toList(),
),
);
}
}
// ─────────────────────────── Chip date ─────────────────────────────────────────
class _DateChip extends StatelessWidget {
final DateTime date;
final VoidCallback onClear;
const _DateChip({required this.date, required this.onClear});
@override
Widget build(BuildContext context) {
return Align(
alignment: Alignment.centerLeft,
child: GestureDetector(
onTap: onClear,
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 6),
decoration: BoxDecoration(
color: EtmTokens.blueSoft,
borderRadius: BorderRadius.circular(99),
border: Border.all(color: EtmTokens.blue.withValues(alpha: 0.4)),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.calendar_today_rounded, size: 12, color: EtmTokens.blue),
const SizedBox(width: 6),
Text(
'${date.day.toString().padLeft(2, '0')}/'
'${date.month.toString().padLeft(2, '0')}/'
'${date.year}',
style: EtmTokens.sans(size: 12, weight: FontWeight.w600, color: EtmTokens.blue),
),
const SizedBox(width: 6),
const Icon(Icons.close_rounded, size: 12, color: EtmTokens.blue),
],
),
),
),
);
}
}
// ─────────────────────────── Carte graphe ──────────────────────────────────────
class _EtmChartCard extends StatelessWidget {
final String title;
final String subtitle;
final List<Widget> legend;
final double height;
final bool loading;
final bool noData;
final Widget child;
const _EtmChartCard({
required this.title,
required this.subtitle,
required this.legend,
required this.height,
required this.loading,
required this.noData,
required this.child,
});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: EtmTokens.card,
borderRadius: BorderRadius.circular(EtmTokens.radiusLg),
boxShadow: EtmTokens.cardShadow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(title,
style: EtmTokens.sans(size: 16, weight: FontWeight.w600)),
Text(subtitle,
style: EtmTokens.sans(size: 11, color: EtmTokens.muted)),
],
),
],
),
const SizedBox(height: 10),
Wrap(spacing: 14, runSpacing: 4, children: legend),
const SizedBox(height: 14),
SizedBox(
height: height,
child: loading
? Center(child: CircularProgressIndicator(
strokeWidth: 2, color: EtmTokens.green))
: noData
? Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.bar_chart_rounded,
color: EtmTokens.faint, size: 32),
const SizedBox(height: 8),
Text('Aucune donnée',
style: EtmTokens.sans(size: 13, color: EtmTokens.muted)),
],
),
)
: child,
),
],
),
);
}
}
// ─────────────────────────── Légende ───────────────────────────────────────────
class _LegendDot extends StatelessWidget {
final Color color;
final String label;
final bool dashed;
const _LegendDot(this.color, this.label, {this.dashed = false});
@override
Widget build(BuildContext context) {
return Row(
mainAxisSize: MainAxisSize.min,
children: [
dashed
? Row(mainAxisSize: MainAxisSize.min, children: [
Container(width: 5, height: 2, color: color),
const SizedBox(width: 2),
Container(width: 5, height: 2, color: color),
])
: Container(
width: 14, height: 3,
decoration: BoxDecoration(
color: color, borderRadius: BorderRadius.circular(2))),
const SizedBox(width: 5),
Text(label, style: EtmTokens.sans(size: 11, color: EtmTokens.muted)),
],
);
}
}
// ─────────────────────────── Météo placeholder ─────────────────────────────────
class _ForecastPlaceholder extends StatelessWidget {
const _ForecastPlaceholder();
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.all(18),
decoration: BoxDecoration(
color: EtmTokens.card,
borderRadius: BorderRadius.circular(EtmTokens.radiusLg),
boxShadow: EtmTokens.cardShadow,
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text('Météo & prévision',
style: EtmTokens.sans(size: 16, weight: FontWeight.w600)),
Text('aujourd\'hui',
style: EtmTokens.sans(size: 12, color: EtmTokens.muted)),
],
),
const SizedBox(height: 14),
Container(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
color: EtmTokens.bg,
borderRadius: BorderRadius.circular(12),
),
child: Row(
children: [
const Icon(Icons.cloud_outlined, color: EtmTokens.faint, size: 30),
const SizedBox(width: 14),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Prévisions non disponibles',
style: EtmTokens.sans(size: 13, 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: 11, color: EtmTokens.faint)),
],
),
),
],
),
),
],
),
);
}
}