- 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>
538 lines
16 KiB
Dart
538 lines
16 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../../providers/tariff_provider.dart';
|
|
import '../../theme/app_theme.dart';
|
|
import '../../theme/etm_theme.dart';
|
|
import '../../widgets/pro_lock_badge.dart';
|
|
|
|
/// Écran "Tarifs & providers".
|
|
class TariffScreen extends StatefulWidget {
|
|
const TariffScreen({super.key});
|
|
|
|
@override
|
|
State<TariffScreen> createState() => _TariffScreenState();
|
|
}
|
|
|
|
class _TariffScreenState extends State<TariffScreen> {
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
context.read<TariffProvider>().load();
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final tariff = context.watch<TariffProvider>();
|
|
|
|
return Scaffold(
|
|
backgroundColor: const Color(0xFFF0F2F5),
|
|
appBar: AppBar(
|
|
title: const Text('Tarifs & providers'),
|
|
backgroundColor: Colors.white,
|
|
foregroundColor: const Color(0xFF1A1A2E),
|
|
elevation: 0,
|
|
),
|
|
body: tariff.loading
|
|
? const Center(child: CircularProgressIndicator())
|
|
: ListView(
|
|
padding: const EdgeInsets.all(16),
|
|
children: [
|
|
_ProviderCard(tariff),
|
|
// Carte tarif manuel uniquement si provider = manual
|
|
if (tariff.activeProvider == TariffProviderType.manual) ...[
|
|
const SizedBox(height: 12),
|
|
_ManualTariffCard(tariff),
|
|
] else ...[
|
|
const SizedBox(height: 12),
|
|
_ModeCard(tariff),
|
|
if (tariff.mode == TariffMode.hchp) ...[
|
|
const SizedBox(height: 12),
|
|
_HcHpCard(tariff),
|
|
],
|
|
const SizedBox(height: 12),
|
|
_PricesCard(tariff),
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Carte provider ────────────────────────────────────────────────────────────
|
|
class _ProviderCard extends StatelessWidget {
|
|
final TariffProvider tariff;
|
|
const _ProviderCard(this.tariff);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return _SectionCard(
|
|
title: 'Provider actif',
|
|
child: Column(
|
|
children: TariffProviderType.values.map((p) {
|
|
final selected = tariff.activeProvider == p;
|
|
return _ProviderTile(
|
|
provider: p,
|
|
selected: selected,
|
|
onTap: p.isPro
|
|
? null
|
|
: () => context.read<TariffProvider>().setProvider(p),
|
|
);
|
|
}).toList(),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ProviderTile extends StatelessWidget {
|
|
final TariffProviderType provider;
|
|
final bool selected;
|
|
final VoidCallback? onTap;
|
|
|
|
const _ProviderTile({
|
|
required this.provider,
|
|
required this.selected,
|
|
this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
child: Opacity(
|
|
opacity: provider.isPro ? 0.6 : 1.0,
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
|
child: Row(
|
|
children: [
|
|
Icon(
|
|
selected
|
|
? Icons.check_circle_rounded
|
|
: Icons.radio_button_unchecked_rounded,
|
|
color: selected ? AppTheme.primaryGreen : Colors.grey,
|
|
size: 20,
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(provider.label,
|
|
style: const TextStyle(fontSize: 13)),
|
|
),
|
|
if (provider.isPro)
|
|
ProLockBadge(featureName: provider.label),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Carte mode tarifaire ──────────────────────────────────────────────────────
|
|
class _ModeCard extends StatelessWidget {
|
|
final TariffProvider tariff;
|
|
const _ModeCard(this.tariff);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return _SectionCard(
|
|
title: 'Mode tarifaire',
|
|
child: DropdownButtonFormField<TariffMode>(
|
|
value: tariff.mode,
|
|
decoration: InputDecoration(
|
|
border: OutlineInputBorder(
|
|
borderRadius: BorderRadius.circular(12)),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 14, vertical: 12),
|
|
),
|
|
items: TariffMode.values
|
|
.map((m) =>
|
|
DropdownMenuItem(value: m, child: Text(m.label)))
|
|
.toList(),
|
|
onChanged: (v) {
|
|
if (v != null) context.read<TariffProvider>().setMode(v);
|
|
},
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Carte HC/HP ───────────────────────────────────────────────────────────────
|
|
class _HcHpCard extends StatelessWidget {
|
|
final TariffProvider tariff;
|
|
const _HcHpCard(this.tariff);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final hchp = tariff.hchp;
|
|
|
|
return _SectionCard(
|
|
title: 'Tarif HC/HP',
|
|
child: Column(
|
|
children: [
|
|
_PriceRow(
|
|
label: 'Prix HC',
|
|
value: hchp.offPeakPrice,
|
|
unit: '€/kWh',
|
|
onChanged: (v) => context
|
|
.read<TariffProvider>()
|
|
.updateHcHp(hchp.copyWith(offPeakPrice: v)),
|
|
),
|
|
_PriceRow(
|
|
label: 'Prix HP',
|
|
value: hchp.peakPrice,
|
|
unit: '€/kWh',
|
|
onChanged: (v) => context
|
|
.read<TariffProvider>()
|
|
.updateHcHp(hchp.copyWith(peakPrice: v)),
|
|
),
|
|
_TimeRow(
|
|
label: 'Début HC',
|
|
time: hchp.offPeakStart,
|
|
onChanged: (t) => context
|
|
.read<TariffProvider>()
|
|
.updateHcHp(hchp.copyWith(offPeakStart: t)),
|
|
),
|
|
_TimeRow(
|
|
label: 'Fin HC',
|
|
time: hchp.offPeakEnd,
|
|
onChanged: (t) => context
|
|
.read<TariffProvider>()
|
|
.updateHcHp(hchp.copyWith(offPeakEnd: t)),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PriceRow extends StatefulWidget {
|
|
final String label;
|
|
final double value;
|
|
final String unit;
|
|
final ValueChanged<double> onChanged;
|
|
|
|
const _PriceRow({
|
|
required this.label,
|
|
required this.value,
|
|
required this.unit,
|
|
required this.onChanged,
|
|
});
|
|
|
|
@override
|
|
State<_PriceRow> createState() => _PriceRowState();
|
|
}
|
|
|
|
class _PriceRowState extends State<_PriceRow> {
|
|
late TextEditingController _ctrl;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_ctrl = TextEditingController(
|
|
text: widget.value.toStringAsFixed(3));
|
|
}
|
|
|
|
@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: 90,
|
|
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);
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(width: 6),
|
|
Text(widget.unit,
|
|
style: const TextStyle(
|
|
fontSize: 12, color: AppTheme.textLight)),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TimeRow extends StatelessWidget {
|
|
final String label;
|
|
final TimeOfDay time;
|
|
final ValueChanged<TimeOfDay> onChanged;
|
|
|
|
const _TimeRow({
|
|
required this.label,
|
|
required this.time,
|
|
required this.onChanged,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final formatted =
|
|
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}';
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.only(bottom: 12),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(label,
|
|
style: const TextStyle(fontSize: 13))),
|
|
GestureDetector(
|
|
onTap: () async {
|
|
final picked = await showTimePicker(
|
|
context: context,
|
|
initialTime: time,
|
|
);
|
|
if (picked != null) onChanged(picked);
|
|
},
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(
|
|
horizontal: 16, vertical: 8),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: Colors.grey.shade300),
|
|
borderRadius: BorderRadius.circular(8),
|
|
color: Colors.grey.shade50,
|
|
),
|
|
child: Text(formatted,
|
|
style: const TextStyle(
|
|
fontSize: 13, fontWeight: FontWeight.w500)),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Carte tarif manuel ────────────────────────────────────────────────────────
|
|
class _ManualTariffCard extends StatefulWidget {
|
|
final TariffProvider tariff;
|
|
const _ManualTariffCard(this.tariff);
|
|
|
|
@override
|
|
State<_ManualTariffCard> createState() => _ManualTariffCardState();
|
|
}
|
|
|
|
class _ManualTariffCardState extends State<_ManualTariffCard> {
|
|
late TextEditingController _ctrl;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_ctrl = TextEditingController(
|
|
text: widget.tariff.manualPrice.toStringAsFixed(4));
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_ctrl.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return _SectionCard(
|
|
title: 'Tarif manuel',
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text(
|
|
'Prix unique appliqué à toutes les décisions du scheduler.',
|
|
style: TextStyle(fontSize: 12, color: AppTheme.textLight),
|
|
),
|
|
const SizedBox(height: 16),
|
|
Row(
|
|
children: [
|
|
const Expanded(
|
|
child: Text('Prix de l\'électricité',
|
|
style: TextStyle(fontSize: 13))),
|
|
SizedBox(
|
|
width: 100,
|
|
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(10)),
|
|
contentPadding: const EdgeInsets.symmetric(
|
|
horizontal: 8, vertical: 10),
|
|
),
|
|
onFieldSubmitted: (v) {
|
|
final n = double.tryParse(v);
|
|
if (n != null) {
|
|
context.read<TariffProvider>().setManualPrice(n);
|
|
}
|
|
},
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
const Text('€/kWh',
|
|
style: TextStyle(fontSize: 12, color: AppTheme.textLight)),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Container(
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(
|
|
color: ETMTheme.accentColor.withValues(alpha: 0.07),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.info_outline_rounded,
|
|
size: 15, color: ETMTheme.accentColor),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(
|
|
'Prix actuel : ${widget.tariff.manualPrice.toStringAsFixed(4)} €/kWh',
|
|
style: const TextStyle(
|
|
fontSize: 12, color: ETMTheme.accentColor),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Carte prix actuels ────────────────────────────────────────────────────────
|
|
class _PricesCard extends StatelessWidget {
|
|
final TariffProvider tariff;
|
|
const _PricesCard(this.tariff);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final p = tariff.prices;
|
|
|
|
String _fmtTime(DateTime? dt) {
|
|
if (dt == null) return '';
|
|
return ' (${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')})';
|
|
}
|
|
|
|
return _SectionCard(
|
|
title: 'Prix actuels (live)',
|
|
child: Column(
|
|
children: [
|
|
_PriceItem(
|
|
label: 'Maintenant',
|
|
value: '${p.now.toStringAsFixed(3)} €/kWh',
|
|
),
|
|
_PriceItem(
|
|
label: 'Dans 1h',
|
|
value: '${p.inOneHour.toStringAsFixed(3)} €/kWh',
|
|
),
|
|
_PriceItem(
|
|
label: 'Minimum 24h',
|
|
value:
|
|
'${p.min24h.toStringAsFixed(3)} €/kWh${_fmtTime(p.minTime)}',
|
|
color: AppTheme.primaryGreen,
|
|
),
|
|
_PriceItem(
|
|
label: 'Maximum 24h',
|
|
value:
|
|
'${p.max24h.toStringAsFixed(3)} €/kWh${_fmtTime(p.maxTime)}',
|
|
color: AppTheme.boostRed,
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _PriceItem extends StatelessWidget {
|
|
final String label;
|
|
final String value;
|
|
final Color? color;
|
|
|
|
const _PriceItem({
|
|
required this.label,
|
|
required this.value,
|
|
this.color,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 6),
|
|
child: Row(
|
|
children: [
|
|
Expanded(
|
|
child: Text(label,
|
|
style: const TextStyle(fontSize: 13))),
|
|
Text(
|
|
value,
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
fontWeight: FontWeight.w600,
|
|
color: color ?? AppTheme.textDark,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Card générique ────────────────────────────────────────────────────────────
|
|
class _SectionCard extends StatelessWidget {
|
|
final String title;
|
|
final Widget child;
|
|
|
|
const _SectionCard({required this.title, required this.child});
|
|
|
|
@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: [
|
|
Text(title,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.bold, fontSize: 14)),
|
|
const SizedBox(height: 14),
|
|
child,
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|