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/app_theme.dart'; import '../main.dart' show DrawerMenuButton; // ───────────────────────────────────────────────────────────────────────────── // EnergyScreen — historique énergétique // // ① Line chart : Production · Consommation · Autoconsommation · Batterie // ② Bar chart : Bilan Production vs Consommation par période // // Onglets : Heures (24 h, 15 min) · Jour (7 j, 1 h) · // Semaine (4 sem, 1 j) · Mois (12 mois, 1 sem) // ───────────────────────────────────────────────────────────────────────────── class _Tab { final String label; final Duration range; final String sampleRate; final bool showTime; // true → HH:mm, false → DD/MM 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 = []; // historique SOC batterie (%) bool _loading = true; // true dès le départ → spinner jusqu'au premier fetch bool _noData = false; DateTime? _selectedDate; // null = aujourd'hui (date courante) Timer? _refreshTimer; // Fix IndexedStack : initState de tous les écrans se déclenche au démarrage, // avant que les things soient chargés → on ajoute un listener pour re-fetcher // le SOC dès que les things deviennent disponibles. NymeaService? _nymeaService; bool _initialSocFetched = false; // évite les re-fetch infinis via le listener @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) { _nymeaService = context.read(); _nymeaService!.addListener(_onServiceChangedForSoc); _fetch(); }); // Rafraîchit le graphe toutes les 5 minutes pour garder les données à jour _refreshTimer = Timer.periodic(const Duration(minutes: 5), (_) { if (mounted) _fetch(); }); } @override void dispose() { _refreshTimer?.cancel(); _nymeaService?.removeListener(_onServiceChangedForSoc); super.dispose(); } /// Déclenche un fetch SOC silencieux quand les things deviennent disponibles /// (cas où l'IndexedStack a construit l'écran avant la connexion nymea). void _onServiceChangedForSoc() { if (!mounted || _initialSocFetched || _loading) return; if (_nymeaService?.batterySOCSource != null && _socData.isEmpty) { _initialSocFetched = true; _fetchSocOnly(); } } /// Récupère uniquement l'historique SOC sans re-fetcher le bilan de puissance. Future _fetchSocOnly() async { if (!mounted) return; final tab = _tabs[_tabIdx]; final to = _selectedDate != null ? DateTime( _selectedDate!.year, _selectedDate!.month, _selectedDate!.day, 23, 59, 59) : DateTime.now(); 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) return; if (soc.isNotEmpty) { setState(() => _socData = soc); } } Future _fetch() async { if (!mounted) return; setState(() { _loading = true; _noData = false; }); final tab = _tabs[_tabIdx]; // Ancre : fin de la journée sélectionnée, ou maintenant si aucune date final to = _selectedDate != null ? DateTime( _selectedDate!.year, _selectedDate!.month, _selectedDate!.day, 23, 59, 59) : DateTime.now(); final from = to.subtract(tab.range); final service = context.read(); // SOC indisponible en simulation (fetchHistory retourne des W, pas des %) final socSource = service.batterySOCSource; // null si pas de batterie // Récupère bilan de puissance et historique SOC en parallèle 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; }); } 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: AppTheme.accentTeal, 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: AppTheme.backgroundGray, appBar: AppBar( backgroundColor: AppTheme.backgroundGray, elevation: 0, leading: const DrawerMenuButton(), leadingWidth: 56, title: const Text('Énergie', style: TextStyle( fontWeight: FontWeight.bold, color: AppTheme.textDark)), actions: [ IconButton( icon: Icon( Icons.calendar_today_rounded, color: _selectedDate != null ? AppTheme.accentTeal : AppTheme.textDark, ), onPressed: _pickDate, ), IconButton( icon: const Icon(Icons.refresh_rounded, color: AppTheme.textDark), onPressed: _fetch, ), ], ), body: SingleChildScrollView( padding: const EdgeInsets.fromLTRB(16, 4, 16, 32), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ // ── Tuiles résumé ─────────────────────────────────────────── _SummaryRow(service: service), const SizedBox(height: 16), // ── Sélecteur d'onglet ────────────────────────────────────── _buildTabBar(), const SizedBox(height: 8), // ── Date sélectionnée (chip dismissible) ──────────────────── if (_selectedDate != null) Align( alignment: Alignment.centerLeft, child: InkWell( onTap: () { setState(() => _selectedDate = null); _fetch(); }, borderRadius: BorderRadius.circular(20), child: Container( padding: const EdgeInsets.symmetric( horizontal: 10, vertical: 4), decoration: BoxDecoration( color: AppTheme.accentTeal.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(20), border: Border.all( color: AppTheme.accentTeal, width: 1), ), child: Row( mainAxisSize: MainAxisSize.min, children: [ const Icon(Icons.calendar_today_rounded, size: 12, color: AppTheme.accentTeal), const SizedBox(width: 5), Text( '${_selectedDate!.day.toString().padLeft(2, '0')}/' '${_selectedDate!.month.toString().padLeft(2, '0')}/' '${_selectedDate!.year}', style: const TextStyle( fontSize: 12, color: AppTheme.accentTeal, fontWeight: FontWeight.bold), ), const SizedBox(width: 5), const Icon(Icons.close_rounded, size: 12, color: AppTheme.accentTeal), ], ), ), ), ), const SizedBox(height: 8), // ── ① Line chart ──────────────────────────────────────────── _ChartCard( title: 'Puissances (W) · SOC %', legend: const [ _LegendItem(color: AppTheme.solarYellow, label: 'Production'), _LegendItem(color: AppTheme.homeBlue, label: 'Consommation'), _LegendItem(color: AppTheme.accentTeal, label: 'Autoconso'), _LegendItem(color: AppTheme.batteryGreen, label: 'SOC %', dashed: true), ], child: SizedBox( height: 200, child: _loading ? _spinner() : _noData ? _empty() : _buildLineChart(), ), ), const SizedBox(height: 12), // ── ② Bar chart ───────────────────────────────────────────── _ChartCard( title: 'Bilan énergétique (Wh)', legend: const [ _LegendItem(color: AppTheme.solarYellow, label: 'Production'), _LegendItem(color: AppTheme.homeBlue, label: 'Consommation'), ], child: SizedBox( height: 180, child: _loading ? _spinner() : _noData ? _empty() : _buildBarChart(), ), ), ], ), ), ); }, ); } // ── Tab bar ─────────────────────────────────────────────────────────────── Widget _buildTabBar() { return Container( padding: const EdgeInsets.all(3), decoration: BoxDecoration( color: AppTheme.cardWhite, borderRadius: BorderRadius.circular(AppTheme.cornerRadius), ), child: Row( children: _tabs.asMap().entries.map((e) { final sel = _tabIdx == e.key; return Expanded( child: GestureDetector( onTap: () { if (_tabIdx != e.key) { setState(() { _tabIdx = e.key; _initialSocFetched = false; }); _fetch(); } }, child: AnimatedContainer( duration: const Duration(milliseconds: 200), padding: const EdgeInsets.symmetric(vertical: 8), decoration: BoxDecoration( color: sel ? AppTheme.accentTeal : Colors.transparent, borderRadius: BorderRadius.circular(AppTheme.cornerRadius - 2), ), child: Text( e.value.label, textAlign: TextAlign.center, style: TextStyle( fontSize: 13, fontWeight: sel ? FontWeight.bold : FontWeight.normal, color: sel ? Colors.white : AppTheme.textLight, ), ), ), ), ); }).toList(), ), ); } // ── ① Line chart ───────────────────────────────────────────────────────── Widget _buildLineChart() { if (_data.isEmpty) return _empty(); final prodSpots = []; final consoSpots = []; final autoSpots = []; final socSpots = []; // SOC % mis à l'échelle des W for (int i = 0; i < _data.length; i++) { final d = _data[i]; final x = i.toDouble(); prodSpots .add(FlSpot(x, d.productionW)); consoSpots.add(FlSpot(x, d.consumptionW)); autoSpots .add(FlSpot(x, d.autoconsommationW)); } // min/max sur production + consommation (toujours ≥ 0) final allY = _data.expand((d) => [d.productionW, d.consumptionW]).toList(); final minY = allY.isEmpty ? 0.0 : allY.reduce(min); final maxY = allY.isEmpty ? 1000.0 : allY.reduce(max); final spread = (maxY - minY) > 0 ? maxY - minY : 200.0; final yPad = spread * 0.12; // Facteur d'échelle : SOC 100 % → y = socMaxW (sommet du graphe) final socMaxW = max(maxY + yPad, 100.0); // SOC disponible si on a au moins 2 points (pour interpoler sur l'axe X) final hasSoc = _socData.length > 1; if (hasSoc) { final n = _socData.length; final xMax = (_data.length - 1).toDouble(); for (int i = 0; i < n; i++) { // Normalise l'index SOC sur la plage X du power chart final x = xMax * i / (n - 1); final scaled = _socData[i].value * socMaxW / 100.0; socSpots.add(FlSpot(x, scaled)); } } final xInterval = _xInterval(); return LineChart( LineChartData( clipData: const FlClipData.all(), minY: minY - yPad, maxY: socMaxW, gridData: FlGridData( show: true, drawVerticalLine: false, getDrawingHorizontalLine: (_) => const FlLine(color: Color(0xFFEEEEEE), strokeWidth: 1), ), borderData: FlBorderData(show: false), titlesData: FlTitlesData( topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 50, getTitlesWidget: (v, _) => Padding( padding: const EdgeInsets.only(right: 4), child: Text(_fmtW(v), style: const TextStyle( fontSize: 8.5, color: AppTheme.textLight), textAlign: TextAlign.right), ), ), ), // Axe Y droit : SOC % (affiché seulement si données SOC disponibles) rightTitles: hasSoc ? AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 36, // interval : socMaxW / 4 → labels à 0 / 25 / 50 / 75 / 100 % interval: socMaxW / 4, getTitlesWidget: (v, _) { final pct = (v / socMaxW * 100).round(); if (pct < 0 || pct > 100) return const SizedBox.shrink(); return Padding( padding: const EdgeInsets.only(left: 4), child: Text('$pct%', style: const TextStyle( fontSize: 8.5, color: AppTheme.batteryGreen), textAlign: TextAlign.left), ); }, ), ) : const AxisTitles(sideTitles: SideTitles(showTitles: false)), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 18, interval: xInterval, getTitlesWidget: (v, _) { final idx = v.round(); if (idx < 0 || idx >= _data.length) { return const SizedBox.shrink(); } return Text(_fmtTime(_data[idx].timestamp), style: const TextStyle( fontSize: 8.5, color: AppTheme.textLight)); }, ), ), ), lineBarsData: [ _lineSeries(prodSpots, AppTheme.solarYellow), // index 0 _lineSeries(consoSpots, AppTheme.homeBlue), // index 1 _lineSeries(autoSpots, AppTheme.accentTeal), // index 2 if (hasSoc) _lineSeries(socSpots, AppTheme.batteryGreen, dashed: true), // index 3 ], lineTouchData: LineTouchData( touchTooltipData: LineTouchTooltipData( getTooltipItems: (spots) => spots.map((s) { // Série index 3 = SOC → tooltip en % final isSoc = hasSoc && s.barIndex == 3; final label = isSoc ? '${(s.y / socMaxW * 100).toStringAsFixed(0)} %' : _fmtW(s.y); return LineTooltipItem( label, TextStyle( color: s.bar.color ?? Colors.white, fontSize: 10, fontWeight: FontWeight.bold), ); }).toList(), ), ), ), ); } LineChartBarData _lineSeries(List spots, Color color, {bool dashed = false}) => LineChartBarData( spots: spots, isCurved: true, curveSmoothness: 0.2, color: color, barWidth: dashed ? 1.5 : 2, dotData: const FlDotData(show: false), dashArray: dashed ? [4, 4] : null, belowBarData: BarAreaData( show: !dashed, color: color.withValues(alpha: 0.07), ), ); // ── ② Bar chart ────────────────────────────────────────────────────────── Widget _buildBarChart() { // Énergie par période = delta des cumuls entre entrées successives final groups = []; double maxE = 1.0; // n barres (même étendue que le line chart x=0..n-1) // barre[i] = énergie de la période [data[i-1] … data[i]] // barre[0] = 0 (pas de période précédente) 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); final bw = _barWidth(); groups.add(BarChartGroupData( x: i, barsSpace: 2, barRods: [ BarChartRodData( toY: prodWh, color: AppTheme.solarYellow, width: bw, borderRadius: const BorderRadius.vertical(top: Radius.circular(3)), ), BarChartRodData( toY: consoWh, color: AppTheme.homeBlue, width: bw, borderRadius: const BorderRadius.vertical(top: Radius.circular(3)), ), ], )); } return BarChart( BarChartData( maxY: maxE * 1.15, alignment: BarChartAlignment.spaceAround, barTouchData: BarTouchData( touchTooltipData: BarTouchTooltipData( getTooltipItem: (group, groupIdx, rod, rodIdx) => BarTooltipItem( _fmtWh(rod.toY), TextStyle( color: rod.color ?? Colors.white, fontSize: 10, fontWeight: FontWeight.bold), ), ), ), gridData: FlGridData( show: true, drawVerticalLine: false, getDrawingHorizontalLine: (_) => const FlLine(color: Color(0xFFEEEEEE), 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: 50, getTitlesWidget: (v, _) => Padding( padding: const EdgeInsets.only(right: 4), child: Text(_fmtWh(v), style: const TextStyle( fontSize: 8.5, color: AppTheme.textLight), textAlign: TextAlign.right), ), ), ), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 18, // Pas d'interval ici : fl_chart passe l'entier exact de chaque // groupe → on filtre manuellement avec le même pas que le line chart getTitlesWidget: (v, _) { 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 Text(_fmtTime(_data[idx].timestamp), style: const TextStyle( fontSize: 8.5, color: AppTheme.textLight)); }, ), ), ), 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 _fmtW(double v) { if (v.abs() >= 1000) return '${(v / 1000).toStringAsFixed(1)}kW'; return '${v.toStringAsFixed(0)}W'; } String _fmtWh(double v) { if (v.abs() >= 1000) return '${(v / 1000).toStringAsFixed(1)}kWh'; return '${v.toStringAsFixed(0)}Wh'; } String _fmtTime(DateTime t) { if (_tabs[_tabIdx].showTime) { return '${t.hour.toString().padLeft(2, '0')}:' '${t.minute.toString().padLeft(2, '0')}'; } return '${t.day}/${t.month}'; } Widget _spinner() => const Center( child: SizedBox( width: 24, height: 24, child: CircularProgressIndicator( strokeWidth: 2, color: AppTheme.accentTeal), ), ); Widget _empty() => const Center( child: Text('Aucune donnée', style: TextStyle(color: AppTheme.textLight, fontSize: 12)), ); } // ───────────────────────────────────────────────────────────────────────────── // Widgets helpers // ───────────────────────────────────────────────────────────────────────────── class _ChartCard extends StatelessWidget { final String title; final List<_LegendItem> legend; final Widget child; const _ChartCard({ required this.title, required this.legend, required this.child, }); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.fromLTRB(14, 14, 14, 12), decoration: BoxDecoration( color: AppTheme.cardWhite, borderRadius: BorderRadius.circular(AppTheme.cornerRadius), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(title, style: const TextStyle( fontWeight: FontWeight.bold, color: AppTheme.textDark, fontSize: 13)), const SizedBox(height: 6), Wrap( spacing: 12, runSpacing: 4, children: legend, ), const SizedBox(height: 12), child, ], ), ); } } class _LegendItem extends StatelessWidget { final Color color; final String label; final bool dashed; const _LegendItem({ required this.color, required 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: 12, height: 3, decoration: BoxDecoration( color: color, borderRadius: BorderRadius.circular(2)), ), const SizedBox(width: 4), Text(label, style: const TextStyle( fontSize: 10, color: AppTheme.textLight)), ], ); } } // ── Tuiles résumé temps-réel ────────────────────────────────────────────────── class _SummaryRow extends StatelessWidget { final NymeaService service; const _SummaryRow({required this.service}); @override Widget build(BuildContext context) { final d = service.energyData; return Row( children: [ _Tile( icon: Icons.wb_sunny_rounded, color: AppTheme.solarYellow, label: 'Production', value: _kWh(d.dayProductionWh), ), const SizedBox(width: 8), _Tile( icon: Icons.home_rounded, color: AppTheme.homeBlue, label: 'Consommation', value: _kWh(d.daySelfConsumptionWh), ), const SizedBox(width: 8), _Tile( icon: Icons.recycling_rounded, color: AppTheme.accentTeal, label: 'Autoconso', value: '${d.selfConsumptionRate.toStringAsFixed(0)} %', ), const SizedBox(width: 8), _Tile( icon: Icons.euro_rounded, color: Colors.amber, label: 'Gains', value: '${d.dayGains.toStringAsFixed(2)} €', ), ], ); } static String _kWh(double wh) => '${(wh / 1000).toStringAsFixed(2)} kWh'; } class _Tile extends StatelessWidget { final IconData icon; final Color color; final String label; final String value; const _Tile({ required this.icon, required this.color, required this.label, required this.value, }); @override Widget build(BuildContext context) { return Expanded( child: Container( padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 6), decoration: BoxDecoration( color: AppTheme.cardWhite, borderRadius: BorderRadius.circular(AppTheme.cornerRadius), ), child: Column( children: [ Icon(icon, color: color, size: 18), const SizedBox(height: 4), Text(value, style: TextStyle( fontWeight: FontWeight.bold, color: color, fontSize: 11), maxLines: 1, overflow: TextOverflow.ellipsis), const SizedBox(height: 2), Text(label, style: const TextStyle( fontSize: 9, color: AppTheme.textLight)), ], ), ), ); } }