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 createState() => _EnergyScreenState(); } class _EnergyScreenState extends State { 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 _data = []; List _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(); 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 _fetchSocOnly() async { if (!mounted) return; final tab = _tabs[_tabIdx]; final to = _anchorDate(); final service = context.read(); 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 _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(); final socSource = service.batterySOCSource; final results = await Future.wait([ 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>.value([]), ]); if (!mounted) return; setState(() { _data = results[0] as List; _socData = results[1] as List; _loading = false; _noData = _data.isEmpty; }); } DateTime _anchorDate() => _selectedDate != null ? DateTime(_selectedDate!.year, _selectedDate!.month, _selectedDate!.day, 23, 59, 59) : DateTime.now(); Future _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( 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 = []; final consoSpots = []; final autoSpots = []; final socSpots = []; 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 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 = []; 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 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 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)), ], ), ), ], ), ), ], ), ); } }