etm-powersync-app/lib/widgets/energy_flow_widget.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

276 lines
7.9 KiB
Dart

import 'package:flutter/material.dart';
import '../models/energy_data.dart';
import '../theme/app_theme.dart';
class EnergyFlowWidget extends StatelessWidget {
final EnergyData data;
const EnergyFlowWidget({super.key, required this.data});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Expanded(
child: Text(
'Données en temps réel',
style: TextStyle(
fontSize: 17,
fontWeight: FontWeight.bold,
color: AppTheme.textDark,
),
overflow: TextOverflow.ellipsis,
),
),
Row(
children: [
Text(
'${data.temperature.toStringAsFixed(0)}°C',
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
color: AppTheme.textDark,
),
),
const SizedBox(width: 4),
const Icon(Icons.cloud, color: Colors.blueGrey, size: 22),
],
),
],
),
const SizedBox(height: 20),
// Energy nodes
Row(
mainAxisAlignment: MainAxisAlignment.spaceAround,
children: [
_EnergyNode(
icon: Icons.wb_sunny_rounded,
color: AppTheme.solarYellow,
power: data.pvPower,
label: 'Solaire',
),
_EnergyNode(
icon: Icons.home_rounded,
color: AppTheme.homeBlue,
power: data.homePower,
label: 'Maison',
),
_EnergyNode(
icon: Icons.battery_charging_full_rounded,
color: AppTheme.batteryGreen,
power: data.batteryPower.abs(),
label: 'Batterie',
badge: '${data.batterySOC.toStringAsFixed(0)}%',
),
_EnergyNode(
icon: Icons.electrical_services_rounded,
color: AppTheme.gridGray,
power: data.gridPower.abs(),
label: data.gridPower >= 0 ? 'Réseau ↓' : 'Réseau ↑',
),
],
),
const SizedBox(height: 16),
// Flow diagram
SizedBox(
height: 80,
child: CustomPaint(
painter: _FlowPainter(data: data),
size: Size.infinite,
),
),
],
),
),
);
}
}
class _EnergyNode extends StatelessWidget {
final IconData icon;
final Color color;
final double power;
final String label;
final String? badge;
const _EnergyNode({
required this.icon,
required this.color,
required this.power,
required this.label,
this.badge,
});
@override
Widget build(BuildContext context) {
return Column(
children: [
Stack(
clipBehavior: Clip.none,
children: [
Container(
width: 52,
height: 52,
decoration: BoxDecoration(
color: color,
shape: BoxShape.circle,
),
child: Icon(icon, color: Colors.white, size: 24),
),
if (badge != null)
Positioned(
bottom: -4,
left: 0,
right: 0,
child: Center(
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 1),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(8),
border: Border.all(color: color, width: 1.5),
),
child: Text(
badge!,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
color: color,
),
),
),
),
),
],
),
const SizedBox(height: 10),
Text(
_formatPower(power),
style: const TextStyle(
fontSize: 18,
fontWeight: FontWeight.bold,
color: AppTheme.textDark,
),
),
Text(
_powerUnit(power),
style: const TextStyle(
fontSize: 12,
color: AppTheme.textLight,
),
),
Text(
label,
style: const TextStyle(
fontSize: 11,
color: AppTheme.textLight,
),
),
],
);
}
String _formatPower(double w) {
if (w >= 1000) return (w / 1000).toStringAsFixed(1);
return w.toStringAsFixed(0);
}
String _powerUnit(double w) => w >= 1000 ? 'kW' : 'W';
}
class _FlowPainter extends CustomPainter {
final EnergyData data;
_FlowPainter({required this.data});
@override
void paint(Canvas canvas, Size size) {
// Draw animated flow lines between nodes
final centerY = size.height * 0.4;
final nodePositions = [
size.width * 0.125, // PV
size.width * 0.375, // Home
size.width * 0.625, // Battery
size.width * 0.875, // Grid
];
final linePaint = Paint()
..strokeWidth = 2
..style = PaintingStyle.stroke;
// PV -> Home (always if PV > 0)
if (data.pvPower > 0) {
_drawArrowLine(canvas, Offset(nodePositions[0], centerY),
Offset(nodePositions[1], centerY), AppTheme.solarYellow, linePaint);
}
// Home -> Battery (if battery charging)
if (data.batteryPower > 0) {
_drawArrowLine(canvas, Offset(nodePositions[1], centerY),
Offset(nodePositions[2], centerY), AppTheme.batteryGreen, linePaint);
}
// Grid -> Home (if importing)
if (data.gridPower > 0) {
_drawArrowLine(canvas, Offset(nodePositions[3], centerY),
Offset(nodePositions[1], centerY), Colors.orange, linePaint);
} else if (data.gridPower < 0) {
// Exporting
_drawArrowLine(canvas, Offset(nodePositions[2], centerY),
Offset(nodePositions[3], centerY), AppTheme.solarYellow, linePaint);
}
// Draw node dots
final dotPaint = Paint()..style = PaintingStyle.fill;
for (int i = 0; i < nodePositions.length; i++) {
final colors = [
AppTheme.solarYellow,
AppTheme.homeBlue,
AppTheme.batteryGreen,
AppTheme.gridGray,
];
dotPaint.color = colors[i];
canvas.drawCircle(Offset(nodePositions[i], centerY), 6, dotPaint);
}
}
void _drawArrowLine(Canvas canvas, Offset start, Offset end, Color color,
Paint paint) {
paint.color = color.withValues(alpha:0.7);
final path = Path()
..moveTo(start.dx, start.dy)
..lineTo(end.dx, end.dy);
canvas.drawPath(path, paint);
// Arrow head
final arrowPaint = Paint()
..color = color
..style = PaintingStyle.fill;
final dx = end.dx - start.dx;
final arrowSign = dx > 0 ? 1 : -1;
final arrowTip = Offset(end.dx - arrowSign * 8, end.dy);
final arrowPath = Path()
..moveTo(arrowTip.dx + arrowSign * 8, arrowTip.dy)
..lineTo(arrowTip.dx, arrowTip.dy - 5)
..lineTo(arrowTip.dx, arrowTip.dy + 5)
..close();
canvas.drawPath(arrowPath, arrowPaint);
}
@override
bool shouldRepaint(_FlowPainter oldDelegate) => true;
}