import 'dart:math' show min, max; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/nymea_models.dart'; import '../services/nymea_service.dart'; import '../theme/app_theme.dart'; // ───────────────────────────────────────────────────────────────────────────── // StateHistoryChart — graphique historique d'un état nymea // // Utilise Logging.GetLogEntries (source: "state-{thingId}-{stateTypeName}") // Sélecteur de plage : 1H / 24H / 7J / 30J // ───────────────────────────────────────────────────────────────────────────── class StateHistoryChart extends StatefulWidget { final String thingId; final NymeaStateType stateType; final Color accentColor; const StateHistoryChart({ super.key, required this.thingId, required this.stateType, required this.accentColor, }); @override State createState() => _StateHistoryChartState(); } class _StateHistoryChartState extends State { // Plages disponibles static const _ranges = [ _Range('1H', Duration(hours: 1), 'SampleRate1Min'), _Range('24H', Duration(hours: 24), 'SampleRate15Mins'), _Range('7J', Duration(days: 7), 'SampleRate1Hour'), _Range('30J', Duration(days: 30), 'SampleRate3Hours'), ]; int _rangeIdx = 1; // 24H par défaut List _data = []; bool _loading = false; bool _noData = false; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) => _fetch()); } Future _fetch() async { if (!mounted || _loading) return; setState(() { _loading = true; _noData = false; }); final range = _ranges[_rangeIdx]; final now = DateTime.now(); final from = now.subtract(range.duration); final service = context.read(); final data = await service.fetchHistory( thingId: widget.thingId, stateTypeName: widget.stateType.name, from: from, to: now, sampleRate: range.sampleRate, ); if (!mounted) return; setState(() { _data = data; _loading = false; _noData = data.isEmpty; }); } @override Widget build(BuildContext context) { return Container( margin: const EdgeInsets.fromLTRB(16, 0, 16, 0), padding: const EdgeInsets.fromLTRB(14, 14, 14, 10), decoration: BoxDecoration( color: AppTheme.cardWhite, borderRadius: BorderRadius.circular(AppTheme.cornerRadius), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ // ── En-tête : nom + sélecteur de plage ──────────────────────────── Row( children: [ Expanded( child: Text( widget.stateType.displayName, style: const TextStyle( fontSize: 13, fontWeight: FontWeight.w600, color: AppTheme.textDark, ), overflow: TextOverflow.ellipsis, ), ), for (int i = 0; i < _ranges.length; i++) GestureDetector( onTap: () { if (_rangeIdx != i) { setState(() => _rangeIdx = i); _fetch(); } }, child: AnimatedContainer( duration: const Duration(milliseconds: 200), padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 4), margin: const EdgeInsets.only(left: 4), decoration: BoxDecoration( color: _rangeIdx == i ? widget.accentColor : widget.accentColor.withValues(alpha: 0.10), borderRadius: BorderRadius.circular(6), ), child: Text( _ranges[i].label, style: TextStyle( fontSize: 10, fontWeight: FontWeight.w700, color: _rangeIdx == i ? Colors.white : widget.accentColor, ), ), ), ), ], ), const SizedBox(height: 12), // ── Zone du graphique ────────────────────────────────────────────── SizedBox( height: 130, child: _loading ? Center( child: SizedBox( width: 22, height: 22, child: CircularProgressIndicator( strokeWidth: 2, color: widget.accentColor, ), ), ) : _noData ? const Center( child: Text( 'Aucune donnée historique', style: TextStyle( color: AppTheme.textLight, fontSize: 12), ), ) : _buildChart(), ), ], ), ); } Widget _buildChart() { if (_data.isEmpty) return const SizedBox.shrink(); // Spots : x = index flottant, y = valeur de l'état final spots = []; for (int i = 0; i < _data.length; i++) { spots.add(FlSpot(i.toDouble(), _data[i].value)); } final minY = _data.map((e) => e.value).reduce(min); final maxY = _data.map((e) => e.value).reduce(max); final yPad = (maxY - minY) > 0 ? (maxY - minY) * 0.12 : 1.0; final yInterval = (maxY - minY) > 0 ? (maxY - minY) / 3 : 1.0; // Interval de l'axe X : afficher ~4 labels final xInterval = spots.length > 4 ? (spots.length / 4).ceilToDouble() : 1.0; final showDate = _rangeIdx >= 2; // 7J ou 30J → afficher jour/mois return LineChart( LineChartData( clipData: const FlClipData.all(), lineBarsData: [ LineChartBarData( spots: spots, isCurved: true, curveSmoothness: 0.25, color: widget.accentColor, barWidth: 2, dotData: const FlDotData(show: false), belowBarData: BarAreaData( show: true, color: widget.accentColor.withValues(alpha: 0.08), ), ), ], minY: minY - yPad, maxY: maxY + yPad, gridData: FlGridData( show: true, drawVerticalLine: false, horizontalInterval: yInterval, 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)), // ── Axe Y ──────────────────────────────────────────────────────── leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 46, interval: yInterval, getTitlesWidget: (v, meta) { // Formater sans répéter l'unité à chaque label final raw = widget.stateType.formatValue(v); return Padding( padding: const EdgeInsets.only(right: 4), child: Text( raw, style: const TextStyle( fontSize: 8.5, color: AppTheme.textLight), textAlign: TextAlign.right, ), ); }, ), ), // ── Axe X ──────────────────────────────────────────────────────── bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, reservedSize: 18, interval: xInterval, getTitlesWidget: (v, meta) { final idx = v.round(); if (idx < 0 || idx >= _data.length) { return const SizedBox.shrink(); } final t = _data[idx].timestamp; final label = showDate ? '${t.day}/${t.month}' : '${t.hour.toString().padLeft(2, '0')}:' '${t.minute.toString().padLeft(2, '0')}'; return Text( label, style: const TextStyle( fontSize: 8.5, color: AppTheme.textLight), ); }, ), ), ), ), ); } } // ── Plage de temps + sampleRate associé ─────────────────────────────────────── class _Range { final String label; final Duration duration; final String sampleRate; const _Range(this.label, this.duration, this.sampleRate); }