etm-powersync-app/lib/widgets/state_history_chart.dart
pakutz79 8862dc2a72 feat: historique énergie, navigation Things, actions nymea
É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>
2026-02-23 07:15:48 +01:00

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);
}