Énergie : - Écran Énergie reécrit : line chart (production/conso/autoconso/batterie) et bar chart (bilan Wh par période) avec onglets 15 min / 1 h / 1 j / 1 sem - Datepicker pour sélectionner une période historique (chip dismissible) - Timelines des deux graphiques alignées (même x=i → data[i].timestamp) - PowerBalanceEntry + fetchPowerBalanceLogs() + simulation sinusoïdale - Overflow fixes : energy_flow_widget (Expanded sur titre), production_card Things : - Navigation 3 niveaux : ThingsScreen → CategoryOverviewScreen → ThingDetailScreen - Catégorie Cars ajoutée, carrousel corrigé (clamp RangeError) - ThingDetailScreen : executeAction, setStateValue, activeThumbColor fix - NymeaTile widget, state_history_chart widget (générique Logging.GetLogEntries) Modèles / service : - HistoryEntry, PowerBalanceEntry ajoutés - fetchHistory(), fetchPowerBalanceLogs() dans NymeaService - interfaceToCategoryMap étendu (Cars, etc.) - AppTheme : nouvelles couleurs (accentTeal, boostRed, pvGreen, minPvBlue…) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
281 lines
9.8 KiB
Dart
281 lines
9.8 KiB
Dart
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<StateHistoryChart> createState() => _StateHistoryChartState();
|
|
}
|
|
|
|
class _StateHistoryChartState extends State<StateHistoryChart> {
|
|
// 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<HistoryEntry> _data = [];
|
|
bool _loading = false;
|
|
bool _noData = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) => _fetch());
|
|
}
|
|
|
|
Future<void> _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<NymeaService>();
|
|
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 = <FlSpot>[];
|
|
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);
|
|
}
|