- 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>
310 lines
10 KiB
Dart
310 lines
10 KiB
Dart
import 'package:flutter/material.dart';
|
||
import '../theme/app_theme.dart';
|
||
import '../theme/etm_theme.dart';
|
||
import 'power_bar.dart';
|
||
|
||
/// Données d'un créneau horaire dans la timeline EMS.
|
||
class TimelineSlot {
|
||
final DateTime start;
|
||
final DateTime end;
|
||
final double solarW;
|
||
final double homeW;
|
||
final double evW; // > 0 = actif
|
||
final double batteryW; // > 0 = charge, < 0 = décharge
|
||
final double gridW; // > 0 = import, < 0 = export
|
||
final String reasoning; // Explication de la décision
|
||
final double savings; // €
|
||
final double selfSufficiency; // %
|
||
final bool isNow;
|
||
final bool hasOverride;
|
||
|
||
const TimelineSlot({
|
||
required this.start,
|
||
required this.end,
|
||
required this.solarW,
|
||
required this.homeW,
|
||
this.evW = 0,
|
||
this.batteryW = 0,
|
||
this.gridW = 0,
|
||
this.reasoning = '',
|
||
this.savings = 0,
|
||
this.selfSufficiency = 0,
|
||
this.isNow = false,
|
||
this.hasOverride = false,
|
||
});
|
||
}
|
||
|
||
/// Carte d'un créneau horaire dans la timeline EMS.
|
||
class TimelineSlotCard extends StatelessWidget {
|
||
final TimelineSlot slot;
|
||
final VoidCallback? onOverride;
|
||
|
||
const TimelineSlotCard({
|
||
super.key,
|
||
required this.slot,
|
||
this.onOverride,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final maxW = [
|
||
slot.solarW, slot.homeW, slot.evW.abs(),
|
||
slot.batteryW.abs(), slot.gridW.abs(),
|
||
].fold<double>(0, (a, b) => a > b ? a : b).clamp(100.0, double.infinity);
|
||
|
||
return Container(
|
||
margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4),
|
||
decoration: BoxDecoration(
|
||
color: slot.isNow
|
||
? ETMTheme.accentColor.withValues(alpha: 0.08)
|
||
: Colors.white,
|
||
borderRadius: BorderRadius.circular(14),
|
||
border: Border.all(
|
||
color: slot.isNow
|
||
? ETMTheme.accentColor.withValues(alpha: 0.4)
|
||
: Colors.grey.withValues(alpha: 0.12),
|
||
),
|
||
),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(14),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// ── En-tête ──────────────────────────────────────────────────────
|
||
Row(
|
||
children: [
|
||
Text(
|
||
'${_fmt(slot.start)} – ${_fmt(slot.end)}',
|
||
style: TextStyle(
|
||
fontWeight: FontWeight.bold,
|
||
fontSize: 13,
|
||
color: slot.isNow
|
||
? ETMTheme.accentColor
|
||
: AppTheme.textDark,
|
||
),
|
||
),
|
||
if (slot.isNow) ...[
|
||
const SizedBox(width: 8),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 8, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: ETMTheme.accentColor,
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
child: const Text(
|
||
'MAINTENANT',
|
||
style: TextStyle(
|
||
color: Colors.white,
|
||
fontSize: 9,
|
||
fontWeight: FontWeight.bold,
|
||
letterSpacing: 0.5,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
if (slot.hasOverride) ...[
|
||
const SizedBox(width: 6),
|
||
Container(
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 7, vertical: 2),
|
||
decoration: BoxDecoration(
|
||
color: ETMTheme.warningColor.withValues(alpha: 0.15),
|
||
borderRadius: BorderRadius.circular(6),
|
||
),
|
||
child: Text(
|
||
'Override',
|
||
style: TextStyle(
|
||
color: ETMTheme.warningColor,
|
||
fontSize: 9,
|
||
fontWeight: FontWeight.bold,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
|
||
const SizedBox(height: 10),
|
||
|
||
// ── Barres de puissance ──────────────────────────────────────────
|
||
if (slot.solarW > 0)
|
||
_PowerRow(
|
||
emoji: '☀️', label: 'Solaire',
|
||
value: slot.solarW, maxValue: maxW,
|
||
color: AppTheme.solarYellow,
|
||
sign: '+',
|
||
),
|
||
_PowerRow(
|
||
emoji: '🏠', label: 'Maison',
|
||
value: slot.homeW, maxValue: maxW,
|
||
color: AppTheme.homeBlue,
|
||
sign: '-',
|
||
),
|
||
if (slot.evW != 0)
|
||
_PowerRow(
|
||
emoji: '🚗', label: 'VE',
|
||
value: slot.evW.abs(), maxValue: maxW,
|
||
color: AppTheme.accentTeal,
|
||
sign: slot.evW > 0 ? '-' : '+',
|
||
badge: slot.evW > 0 ? 'ON' : null,
|
||
),
|
||
if (slot.batteryW != 0)
|
||
_PowerRow(
|
||
emoji: '🔋', label: 'Batterie',
|
||
value: slot.batteryW.abs(), maxValue: maxW,
|
||
color: AppTheme.batteryGreen,
|
||
sign: slot.batteryW > 0 ? '-' : '+',
|
||
badge: slot.batteryW > 0 ? '↑' : '↓',
|
||
),
|
||
if (slot.gridW != 0)
|
||
_PowerRow(
|
||
emoji: '⚡', label: 'Réseau',
|
||
value: slot.gridW.abs(), maxValue: maxW,
|
||
color: slot.gridW > 0
|
||
? AppTheme.powerAcquisitionColor
|
||
: AppTheme.powerReturnColor,
|
||
sign: slot.gridW > 0 ? '-' : '+',
|
||
badge: slot.gridW > 0 ? '↓' : '↑',
|
||
),
|
||
|
||
if (slot.reasoning.isNotEmpty) ...[
|
||
const SizedBox(height: 10),
|
||
Container(
|
||
padding: const EdgeInsets.all(10),
|
||
decoration: BoxDecoration(
|
||
color: Colors.grey.shade50,
|
||
borderRadius: BorderRadius.circular(8),
|
||
),
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
const Text('💬 ', style: TextStyle(fontSize: 13)),
|
||
Expanded(
|
||
child: Text(
|
||
'"${slot.reasoning}"',
|
||
style: const TextStyle(
|
||
fontSize: 12,
|
||
fontStyle: FontStyle.italic,
|
||
color: AppTheme.textLight,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
|
||
const SizedBox(height: 8),
|
||
|
||
// ── Métriques + Override ─────────────────────────────────────────
|
||
Row(
|
||
children: [
|
||
if (slot.savings != 0)
|
||
Text(
|
||
'💰 ${slot.savings >= 0 ? '-' : '+'}${slot.savings.abs().toStringAsFixed(3)}€',
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
color: slot.savings > 0
|
||
? ETMTheme.successColor
|
||
: ETMTheme.errorColor,
|
||
),
|
||
),
|
||
if (slot.savings != 0 && slot.selfSufficiency > 0)
|
||
const SizedBox(width: 12),
|
||
if (slot.selfSufficiency > 0)
|
||
Text(
|
||
'Autosuff.: ${slot.selfSufficiency.toStringAsFixed(0)}%',
|
||
style: const TextStyle(
|
||
fontSize: 11, color: AppTheme.textLight),
|
||
),
|
||
const Spacer(),
|
||
if (onOverride != null)
|
||
TextButton(
|
||
style: TextButton.styleFrom(
|
||
foregroundColor: ETMTheme.accentColor,
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 10, vertical: 4),
|
||
),
|
||
onPressed: onOverride,
|
||
child: const Text('Override manuel',
|
||
style: TextStyle(fontSize: 11)),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
String _fmt(DateTime dt) =>
|
||
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
|
||
}
|
||
|
||
class _PowerRow extends StatelessWidget {
|
||
final String emoji;
|
||
final String label;
|
||
final double value;
|
||
final double maxValue;
|
||
final Color color;
|
||
final String sign;
|
||
final String? badge;
|
||
|
||
const _PowerRow({
|
||
required this.emoji,
|
||
required this.label,
|
||
required this.value,
|
||
required this.maxValue,
|
||
required this.color,
|
||
required this.sign,
|
||
this.badge,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final displayW = value >= 1000
|
||
? '${(value / 1000).toStringAsFixed(1)} kW'
|
||
: '${value.toStringAsFixed(0)} W';
|
||
|
||
return Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 3),
|
||
child: Row(
|
||
children: [
|
||
Text(emoji, style: const TextStyle(fontSize: 14)),
|
||
const SizedBox(width: 6),
|
||
SizedBox(
|
||
width: 70,
|
||
child: Text(
|
||
label,
|
||
style: const TextStyle(fontSize: 12, color: AppTheme.textLight),
|
||
),
|
||
),
|
||
Text(
|
||
'$sign$displayW',
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w600,
|
||
color: AppTheme.textDark,
|
||
),
|
||
),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: PowerBar(
|
||
value: value, maxValue: maxValue, color: color, height: 6,
|
||
),
|
||
),
|
||
if (badge != null) ...[
|
||
const SizedBox(width: 6),
|
||
Text(badge!,
|
||
style: TextStyle(
|
||
fontSize: 11,
|
||
fontWeight: FontWeight.bold,
|
||
color: color)),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|