etm-powersync-app/lib/screens/energy/scheduler_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

401 lines
12 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../../providers/scheduler_provider.dart';
import '../../services/nymea_service.dart';
import '../../theme/app_theme.dart';
import '../../theme/etm_theme.dart';
import '../../widgets/pro_lock_badge.dart';
/// Écran "Scheduler & stratégie".
class SchedulerScreen extends StatefulWidget {
const SchedulerScreen({super.key});
@override
State<SchedulerScreen> createState() => _SchedulerScreenState();
}
class _SchedulerScreenState extends State<SchedulerScreen> {
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) {
context.read<SchedulerProvider>().load(context.read<NymeaService>());
});
}
@override
Widget build(BuildContext context) {
final scheduler = context.watch<SchedulerProvider>();
return Scaffold(
backgroundColor: const Color(0xFFF0F2F5),
appBar: AppBar(
title: const Text('Scheduler & stratégie'),
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF1A1A2E),
elevation: 0,
),
body: scheduler.loading
? const Center(child: CircularProgressIndicator())
: ListView(
padding: const EdgeInsets.all(16),
children: [
_StrategyCard(scheduler),
const SizedBox(height: 12),
_ConfigCard(scheduler),
const SizedBox(height: 12),
_StatusCard(scheduler),
],
),
);
}
}
// ── Carte stratégie ───────────────────────────────────────────────────────────
class _StrategyCard extends StatelessWidget {
final SchedulerProvider scheduler;
const _StrategyCard(this.scheduler);
@override
Widget build(BuildContext context) {
return _Card(
title: 'Stratégie active',
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
DropdownButtonFormField<SchedulerStrategy>(
value: scheduler.strategy.isPro
? SchedulerStrategy.rulesBased
: scheduler.strategy,
decoration: InputDecoration(
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12)),
contentPadding: const EdgeInsets.symmetric(
horizontal: 14, vertical: 12),
),
items: SchedulerStrategy.values.map((s) {
return DropdownMenuItem(
value: s.isPro ? null : s,
enabled: !s.isPro,
child: Row(
children: [
Text(s.label),
if (s.isPro) ...[
const SizedBox(width: 8),
const ProLockBadge(featureName: 'Stratégie AI'),
],
],
),
);
}).toList(),
onChanged: (v) {
if (v != null) {
context
.read<SchedulerProvider>()
.setStrategy(v);
}
},
),
],
),
);
}
}
// ── Carte paramètres ──────────────────────────────────────────────────────────
class _ConfigCard extends StatelessWidget {
final SchedulerProvider scheduler;
const _ConfigCard(this.scheduler);
@override
Widget build(BuildContext context) {
final cfg = scheduler.config;
return _Card(
title: 'Paramètres',
child: Column(
children: [
_ConfigRow(
label: 'Seuil prix recharge',
value: cfg.priceThreshold,
unit: '€/kWh',
min: 0.01, max: 0.5, decimals: 3,
onChanged: (v) => context
.read<SchedulerProvider>()
.updateConfig(cfg.copyWith(priceThreshold: v)),
),
_ConfigRow(
label: 'Surplus minimum',
value: cfg.minSurplus,
unit: 'W',
min: 50, max: 2000,
onChanged: (v) => context
.read<SchedulerProvider>()
.updateConfig(cfg.copyWith(minSurplus: v)),
),
_ConfigRow(
label: 'Horizon planification',
value: cfg.planningHorizon.toDouble(),
unit: 'h',
min: 1, max: 72,
onChanged: (v) => context
.read<SchedulerProvider>()
.updateConfig(cfg.copyWith(planningHorizon: v.toInt())),
),
_ConfigRow(
label: 'Recalcul toutes les',
value: cfg.recalcInterval.toDouble(),
unit: 'min',
min: 5, max: 60,
onChanged: (v) => context
.read<SchedulerProvider>()
.updateConfig(cfg.copyWith(recalcInterval: v.toInt())),
),
_ConfigRow(
label: 'Objectif autosuffisance',
value: cfg.selfSufficiencyGoal,
unit: '%',
min: 0, max: 100,
onChanged: (v) => context
.read<SchedulerProvider>()
.updateConfig(cfg.copyWith(selfSufficiencyGoal: v)),
),
],
),
);
}
}
class _ConfigRow extends StatefulWidget {
final String label;
final double value;
final String unit;
final double min;
final double max;
final int decimals;
final ValueChanged<double> onChanged;
const _ConfigRow({
required this.label,
required this.value,
required this.unit,
required this.min,
required this.max,
required this.onChanged,
this.decimals = 0,
});
@override
State<_ConfigRow> createState() => _ConfigRowState();
}
class _ConfigRowState extends State<_ConfigRow> {
late TextEditingController _ctrl;
@override
void initState() {
super.initState();
_ctrl = TextEditingController(
text: widget.value.toStringAsFixed(widget.decimals));
}
@override
void didUpdateWidget(_ConfigRow old) {
super.didUpdateWidget(old);
final formatted = widget.value.toStringAsFixed(widget.decimals);
if (_ctrl.text != formatted) _ctrl.text = formatted;
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Row(
children: [
Expanded(
child: Text(widget.label,
style: const TextStyle(fontSize: 13)),
),
SizedBox(
width: 80,
child: TextFormField(
controller: _ctrl,
textAlign: TextAlign.center,
keyboardType: const TextInputType.numberWithOptions(
decimal: true),
decoration: InputDecoration(
isDense: true,
filled: true,
fillColor: Colors.grey.shade50,
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(8)),
contentPadding: const EdgeInsets.symmetric(
horizontal: 8, vertical: 8),
),
onFieldSubmitted: (v) {
final n = double.tryParse(v);
if (n != null) {
widget.onChanged(n.clamp(widget.min, widget.max));
}
},
),
),
const SizedBox(width: 6),
Text(widget.unit,
style: const TextStyle(
fontSize: 12, color: AppTheme.textLight)),
],
),
);
}
}
// ── Carte état du scheduler ───────────────────────────────────────────────────
class _StatusCard extends StatelessWidget {
final SchedulerProvider scheduler;
const _StatusCard(this.scheduler);
@override
Widget build(BuildContext context) {
final state = scheduler.state;
final statusColor = switch (state.status) {
SchedulerStatus.ok => AppTheme.primaryGreen,
SchedulerStatus.degraded => Colors.orange,
SchedulerStatus.error => AppTheme.boostRed,
};
final statusLabel = switch (state.status) {
SchedulerStatus.ok => '✅ OK',
SchedulerStatus.degraded => '⚠️ Dégradé',
SchedulerStatus.error => '❌ Erreur',
};
String _ago(DateTime? dt) {
if (dt == null) return '';
final diff = DateTime.now().difference(dt);
if (diff.inMinutes < 1) return 'à l\'instant';
return 'il y a ${diff.inMinutes} min';
}
String _in(DateTime? dt) {
if (dt == null) return '';
final diff = dt.difference(DateTime.now());
if (diff.isNegative) return 'dépassé';
return 'dans ${diff.inMinutes} min';
}
return _Card(
title: 'État du scheduler',
trailing: ElevatedButton.icon(
icon: scheduler.loading
? const SizedBox(
width: 14, height: 14,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white))
: const Icon(Icons.refresh_rounded, size: 16),
label: const Text('Forcer recalcul'),
style: ElevatedButton.styleFrom(
backgroundColor: ETMTheme.accentColor,
foregroundColor: Colors.white,
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8),
textStyle: const TextStyle(fontSize: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10)),
),
onPressed: () => context
.read<SchedulerProvider>()
.forceRecalc(context.read<NymeaService>()),
),
child: Column(
children: [
_StatusRow('Dernière planification', _ago(state.lastPlanning)),
_StatusRow('Prochaine planification', _in(state.nextPlanning)),
Row(
children: [
const Expanded(
child: Text('État', style: TextStyle(fontSize: 13))),
Text(statusLabel,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600,
color: statusColor)),
],
),
if (state.reason.isNotEmpty) ...[
const SizedBox(height: 6),
Text(
'Raison : ${state.reason}',
style: const TextStyle(
fontSize: 12, color: AppTheme.textLight),
),
],
],
),
);
}
}
class _StatusRow extends StatelessWidget {
final String label;
final String value;
const _StatusRow(this.label, this.value);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 6),
child: Row(
children: [
Expanded(
child: Text(label,
style: const TextStyle(fontSize: 13))),
Text(value,
style: const TextStyle(
fontSize: 13,
color: AppTheme.textLight)),
],
),
);
}
}
// ── Card générique ────────────────────────────────────────────────────────────
class _Card extends StatelessWidget {
final String title;
final Widget child;
final Widget? trailing;
const _Card({required this.title, required this.child, this.trailing});
@override
Widget build(BuildContext context) {
return Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Text(title,
style: const TextStyle(
fontWeight: FontWeight.bold, fontSize: 14)),
const Spacer(),
if (trailing != null) trailing!,
],
),
const SizedBox(height: 14),
child,
],
),
),
);
}
}