- Drawer custom (overlay Stack) avec mode installateur PIN SHA-256 - GoRouter + ShellRoute : navigation préservée entre onglets - 6 providers : NavigationProvider, InstallerModeProvider, AppSettingsProvider, EnergySetupProvider, SchedulerProvider, TariffProvider - Écrans Energy Manager : RoleConfigFlow (3 étapes), Scheduler, Tarifs, Timeline - Écrans Paramètres : Apparence, Écrans actifs, AppSettingsScreen - DrawerMenuButton présent dans les 5 AppBars principaux - Simulation : _thingClasses générées avec interfaces EMS pour filtrage des rôles - Compteur solaire : ajout smartmeter aux interfaces compatibles - Thème ETM (etm_theme.dart), ProLockBadge, widgets PowerBar/RoleCard/TimelineSlotCard - Dépendances : go_router, shared_preferences, crypto, url_launcher Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
289 lines
8.5 KiB
Dart
289 lines
8.5 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 (si PV produit)
|
|
if (data.pvPower > 0) {
|
|
_drawArrowLine(canvas, Offset(nodePositions[0], centerY),
|
|
Offset(nodePositions[1], centerY), AppTheme.solarYellow, linePaint);
|
|
}
|
|
|
|
// Batterie en charge : source = PV si disponible, sinon Réseau → Batterie
|
|
if (data.batteryPower > 0) {
|
|
if (data.pvPower > 0) {
|
|
// PV → Battery (solaire charge la batterie)
|
|
_drawArrowLine(canvas, Offset(nodePositions[0], centerY),
|
|
Offset(nodePositions[2], centerY), AppTheme.batteryGreen, linePaint);
|
|
} else {
|
|
// Grid → Battery (réseau charge la batterie)
|
|
_drawArrowLine(canvas, Offset(nodePositions[3], centerY),
|
|
Offset(nodePositions[2], centerY), AppTheme.batteryGreen, linePaint);
|
|
}
|
|
}
|
|
|
|
// Batterie en décharge → Home
|
|
if (data.batteryPower < 0) {
|
|
_drawArrowLine(canvas, Offset(nodePositions[2], centerY),
|
|
Offset(nodePositions[1], centerY), AppTheme.batteryGreen, linePaint);
|
|
}
|
|
|
|
// Réseau → Home (si import)
|
|
if (data.gridPower > 0) {
|
|
_drawArrowLine(canvas, Offset(nodePositions[3], centerY),
|
|
Offset(nodePositions[1], centerY), Colors.orange, linePaint);
|
|
} else if (data.gridPower < 0) {
|
|
// PV → Réseau (export / injection réseau)
|
|
_drawArrowLine(canvas, Offset(nodePositions[0], 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;
|
|
}
|