- 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>
401 lines
12 KiB
Dart
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,
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|