etm-powersync-app/lib/screens/energy/timeline_screen.dart
pakutz79 c19c9d1a98 feat: navigation drawer, EMS setup, scheduler, tarifs, paramètres app
- 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>
2026-02-24 14:52:32 +01:00

433 lines
14 KiB
Dart
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'dart:async';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../services/nymea_service.dart';
import '../../theme/app_theme.dart';
import '../../theme/etm_theme.dart';
import '../../widgets/timeline_slot_card.dart';
/// Écran "Timeline & décisions" — vue horaire des décisions EMS.
class TimelineScreen extends StatefulWidget {
const TimelineScreen({super.key});
@override
State<TimelineScreen> createState() => _TimelineScreenState();
}
class _TimelineScreenState extends State<TimelineScreen> {
String _horizon = '24h';
bool _loading = true;
List<TimelineSlot> _slots = [];
Timer? _autoRefresh;
static const _horizons = ['12h', '24h', '48h'];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
// Auto-refresh toutes les minutes
_autoRefresh = Timer.periodic(const Duration(minutes: 1), (_) {
if (mounted) _load(silent: true);
});
}
@override
void dispose() {
_autoRefresh?.cancel();
super.dispose();
}
Future<void> _load({bool silent = false}) async {
if (!mounted) return;
if (!silent) setState(() => _loading = true);
// Génère des créneaux de démonstration (à brancher sur GetEnergyTimeline)
await Future.delayed(const Duration(milliseconds: 400));
if (!mounted) return;
setState(() {
_slots = _generateDemoSlots();
_loading = false;
});
}
List<TimelineSlot> _generateDemoSlots() {
final now = DateTime.now();
final start = now.subtract(Duration(hours: now.hour)).copyWith(
minute: 0, second: 0, microsecond: 0);
final hours = _horizonHours();
final slots = <TimelineSlot>[];
for (int i = 0; i < hours; i++) {
final slotStart = start.add(Duration(hours: i));
final isNow = now.isAfter(slotStart) &&
now.isBefore(slotStart.add(const Duration(hours: 1)));
// Simulation d'une production PV en cloche
final h = slotStart.hour.toDouble();
final solar = (h >= 7 && h <= 19)
? (2800 * _bell(h, 13, 4)).clamp(0.0, 5000.0)
: 0.0;
final home = 800 + (i % 3) * 200.0;
final evW = (h >= 10 && h <= 15) ? 1200.0 : 0.0;
final battery = (solar > home + evW) ? (solar - home - evW).clamp(0.0, 3000.0) : 0.0;
final grid = (solar - home - evW - battery);
slots.add(TimelineSlot(
start: slotStart,
end: slotStart.add(const Duration(hours: 1)),
solarW: solar,
homeW: home,
evW: evW,
batteryW: battery,
gridW: grid,
reasoning: _reasoning(solar, home, evW, battery, grid),
savings: grid < 0 ? grid.abs() / 1000 * 0.12 : 0,
selfSufficiency: solar > 0
? (solar / (home + evW.abs())).clamp(0.0, 1.5) * 100
: 0,
isNow: isNow,
));
}
return slots;
}
double _bell(double x, double mu, double sigma) {
final d = (x - mu) / sigma;
return 1.0 / (sigma * 2.5066) * (1 / (1 + d * d));
}
String _reasoning(double solar, double home, double ev,
double battery, double grid) {
if (solar > home + ev + 500) {
return 'Surplus solaire ${_kw(solar - home - ev)}'
'${battery > 0 ? 'charge batterie + ' : ''}'
'${ev > 0 ? 'recharge VE' : 'export réseau'}';
} else if (solar > 0 && solar >= home * 0.8) {
return 'Solaire ≈ consommation de base — '
'${ev == 0 ? 'VE en pause' : 'recharge VE en cours'}';
} else {
return 'Faible production solaire — import réseau ${_kw(grid.abs())}';
}
}
String _kw(double w) {
return w >= 1000
? '+${(w / 1000).toStringAsFixed(1)} kW'
: '+${w.toStringAsFixed(0)} W';
}
int _horizonHours() => switch (_horizon) {
'12h' => 12,
'48h' => 48,
_ => 24,
};
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xFFF0F2F5),
appBar: AppBar(
title: const Text('Timeline & décisions'),
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF1A1A2E),
elevation: 0,
actions: [
// Sélecteur d'horizon
Padding(
padding: const EdgeInsets.only(right: 4),
child: DropdownButton<String>(
value: _horizon,
underline: const SizedBox.shrink(),
style: const TextStyle(
fontSize: 13,
color: Color(0xFF1A1A2E),
fontWeight: FontWeight.w500),
items: _horizons
.map((h) =>
DropdownMenuItem(value: h, child: Text(h)))
.toList(),
onChanged: (v) {
if (v != null && v != _horizon) {
setState(() => _horizon = v);
_load();
}
},
),
),
// Bouton actualiser
IconButton(
icon: const Icon(Icons.refresh_rounded),
tooltip: 'Actualiser',
onPressed: _load,
),
],
),
body: _loading
? const Center(child: CircularProgressIndicator())
: _slots.isEmpty
? _EmptyState(onRetry: _load)
: ListView.builder(
padding: const EdgeInsets.symmetric(vertical: 8),
itemCount: _slots.length,
itemBuilder: (context, i) {
final slot = _slots[i];
return TimelineSlotCard(
slot: slot,
onOverride: () => _showOverrideSheet(context, slot),
);
},
),
);
}
void _showOverrideSheet(BuildContext context, TimelineSlot slot) {
showModalBottomSheet(
context: context,
isScrollControlled: true,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) => _OverrideSheet(slot: slot),
);
}
}
// ── Feuille d'override ────────────────────────────────────────────────────────
class _OverrideSheet extends StatefulWidget {
final TimelineSlot slot;
const _OverrideSheet({required this.slot});
@override
State<_OverrideSheet> createState() => _OverrideSheetState();
}
class _OverrideSheetState extends State<_OverrideSheet> {
double _evW = 0;
bool _dhwOn = false;
double _batW = 0; // >0 charge, <0 décharge
String _reason = '';
@override
void initState() {
super.initState();
_evW = widget.slot.evW;
_batW = widget.slot.batteryW;
}
@override
Widget build(BuildContext context) {
return Padding(
padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewInsets.bottom),
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.all(20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
const Center(
child: _DragHandle(),
),
const SizedBox(height: 16),
const Text('Modifier ce créneau',
style: TextStyle(
fontSize: 17, fontWeight: FontWeight.bold)),
const SizedBox(height: 4),
Text(
'${_fmt(widget.slot.start)} ${_fmt(widget.slot.end)}',
style: const TextStyle(
fontSize: 13, color: AppTheme.textLight),
),
const SizedBox(height: 20),
// VE Charger
if (widget.slot.evW >= 0) ...[
const Text('🚗 VE Charger',
style: TextStyle(
fontWeight: FontWeight.w600, fontSize: 13)),
const SizedBox(height: 6),
Row(
children: [
const Text('0 W', style: TextStyle(fontSize: 11)),
Expanded(
child: Slider(
value: _evW,
min: 0, max: 7400,
divisions: 37,
label: '${_evW.toStringAsFixed(0)} W',
activeColor: AppTheme.accentTeal,
onChanged: (v) => setState(() => _evW = v),
),
),
const Text('7.4 kW', style: TextStyle(fontSize: 11)),
],
),
const SizedBox(height: 12),
],
// Chauffe-eau
Row(
children: [
const Text('🌡️ Chauffe-eau',
style: TextStyle(
fontWeight: FontWeight.w600, fontSize: 13)),
const Spacer(),
Switch.adaptive(
value: _dhwOn,
activeColor: AppTheme.primaryGreen,
onChanged: (v) => setState(() => _dhwOn = v),
),
],
),
const SizedBox(height: 12),
// Batterie
const Text('🔋 Batterie',
style: TextStyle(
fontWeight: FontWeight.w600, fontSize: 13)),
const SizedBox(height: 6),
Row(
children: [
const Text('Décharge',
style: TextStyle(fontSize: 11)),
Expanded(
child: Slider(
value: _batW,
min: -3000, max: 3000,
divisions: 30,
label: _batW == 0
? '0 (pause)'
: '${_batW > 0 ? '+' : ''}${_batW.toStringAsFixed(0)} W',
activeColor: AppTheme.batteryGreen,
onChanged: (v) => setState(() => _batW = v),
),
),
const Text('Charge',
style: TextStyle(fontSize: 11)),
],
),
const SizedBox(height: 16),
// Raison
TextFormField(
initialValue: _reason,
decoration: InputDecoration(
labelText: 'Raison (optionnel)',
hintText: 'Ex. Départ annulé ce soir',
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12)),
contentPadding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 12),
),
onChanged: (v) => _reason = v,
),
const SizedBox(height: 20),
// Boutons
Row(
children: [
Expanded(
child: OutlinedButton(
onPressed: () => Navigator.pop(context),
style: OutlinedButton.styleFrom(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
padding:
const EdgeInsets.symmetric(vertical: 14),
),
child: const Text('Annuler'),
),
),
const SizedBox(width: 12),
Expanded(
child: ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: ETMTheme.accentColor,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
padding:
const EdgeInsets.symmetric(vertical: 14),
),
onPressed: () {
Navigator.pop(context);
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Override appliqué'),
behavior: SnackBarBehavior.floating,
),
);
},
child: const Text('Appliquer'),
),
),
],
),
const SizedBox(height: 8),
],
),
),
),
);
}
String _fmt(DateTime dt) =>
'${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}';
}
class _DragHandle extends StatelessWidget {
const _DragHandle();
@override
Widget build(BuildContext context) {
return Container(
width: 36, height: 4,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(2),
),
);
}
}
class _EmptyState extends StatelessWidget {
final VoidCallback onRetry;
const _EmptyState({required this.onRetry});
@override
Widget build(BuildContext context) {
return Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.schedule_rounded,
size: 48, color: Colors.grey.shade400),
const SizedBox(height: 16),
const Text('Aucune donnée de timeline',
style: TextStyle(fontSize: 15)),
const SizedBox(height: 8),
const Text(
'Le scheduler n\'a pas encore généré de planification.',
textAlign: TextAlign.center,
style: TextStyle(color: AppTheme.textLight),
),
const SizedBox(height: 16),
ElevatedButton.icon(
icon: const Icon(Icons.refresh_rounded, size: 18),
label: const Text('Réessayer'),
style: ElevatedButton.styleFrom(
backgroundColor: ETMTheme.accentColor,
foregroundColor: Colors.white,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(12)),
),
onPressed: onRetry,
),
],
),
);
}
}