FUSION. L'écran « Ordre de service » est supprimé ; sa route redirige. C'est son CHEMIN DE DONNÉES (LoadConfigProvider) qui a migré dans la présentation de « Rôles & appareils », jamais l'inverse. EmsRole fige six rôles dont dhw ET heatPump séparés — or LM-201 dit qu'une PAC sans ballon séparé est UNE charge : le modèle des rôles aurait exigé deux charges sur un canal unique, ce que validateSet() refuse à raison. Les charges viennent donc de GetLoadConfig. Les compteurs restent sur EnergySetupProvider (autre contrat, SetRootMeter). La note « le pilote peut bousculer l'ordre les jours Tempo rouge ou à l'approche d'une échéance » est retirée : aucun de ces comportements n'existe dans le moteur. ANNONCE AVANT ÉCRITURE. Miroir exact de EnergyArbitrator::sameHardware(). Le piège, vérifié sur .75 : la comparaison des relais est INDEXÉE, donc réordonner relays[] sans rien changer d'autre reconstruit l'adaptateur — contacts ouverts (applySafeState, force) et verrous réarmés à froid. Un glisser-déposer dans la liste des contacteurs est un geste matériel. L'écran le nomme, avec la durée de réarmement. GRISAGE PAR INTROSPECTION. RpcSchema lit JSONRPC.Introspect à la connexion ; trois états et non deux — écrivable, r: (mesure, pas fonction manquante), absent — plus « inconnu » quand le schéma n'a pas pu être lu. Chaque champ grisé porte sa raison et le nom du champ manquant. La version affichée est celle de l'API d'expérience (NymeaEnergy 0.8) : le paquet +etm15 n'est exposé par aucune méthode. CORRIGÉ AU PASSAGE — les modes de recharge étaient INOPÉRANTS. setChargingInfo() appelait EnergyPlugin.SetChargingInfo (namespace inexistant) avec mode/targetSoc/endTime/minCurrent au lieu de chargingMode/targetPercentage/endDateTime, et minCurrent n'existe dans aucun champ. Vérifié par appel réel : « Missing required key: chargingMode ». Le refus était de plus avalé et l'état local mis à jour quand même. Désormais l'état ne suit que si la box accepte, et le refus remonte sans en inventer la cause. ZONES — constaté, pas corrigé. L'hypothèse « champs grisés » est fausse : AirConditioning publie 8 méthodes toutes en écriture. ac_screen.dart n'en appelle aucune, ses 4 pièces sont des constantes de maquette, et .75 déclare zéro zone. getZones() ajouté + bandeau qui lit le vrai compte et annonce la maquette. Le câblage est un lot en soi, non exerçable sans zone. Tests 80/80, analyze 0 erreur. Relevé : docs/RELEVE_LOTC.md — dont §6, ce qui n'a PAS pu être vérifié : l'IU n'a pas pu être pilotée sur l'appareil, MIUI refusant INJECT_EVENTS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
664 lines
22 KiB
Dart
664 lines
22 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../models/energy_data.dart';
|
|
import '../providers/schema_provider.dart';
|
|
import '../services/nymea_service.dart';
|
|
import '../services/rpc_schema.dart';
|
|
import '../theme/etm_tokens.dart';
|
|
import 'schema_gated.dart';
|
|
|
|
class EVChargingCard extends StatefulWidget {
|
|
final EnergyData data;
|
|
final NymeaService service;
|
|
|
|
const EVChargingCard({
|
|
super.key,
|
|
required this.data,
|
|
required this.service,
|
|
});
|
|
|
|
@override
|
|
State<EVChargingCard> createState() => _EVChargingCardState();
|
|
}
|
|
|
|
class _EVChargingCardState extends State<EVChargingCard> {
|
|
// Deadline option state — local UI, not persisted
|
|
bool _deadlineEnabled = false;
|
|
int _targetSoc = 80;
|
|
DateTime _endTime = DateTime.now().add(const Duration(hours: 4));
|
|
|
|
void _selectMode(ChargingMode mode) {
|
|
// Selecting Boost auto-disables deadline
|
|
if (mode == ChargingMode.boost && _deadlineEnabled) {
|
|
setState(() => _deadlineEnabled = false);
|
|
}
|
|
_send(
|
|
mode: mode,
|
|
deadline: mode != ChargingMode.boost && _deadlineEnabled,
|
|
);
|
|
}
|
|
|
|
/// Envoie, et **annonce le refus** au lieu de le perdre. La cause exacte reste au
|
|
/// journal de la box : on ne la devine pas.
|
|
Future<void> _send({required ChargingMode mode, required bool deadline}) async {
|
|
final err = await widget.service.setChargingInfo(
|
|
mode: mode,
|
|
deadline: deadline,
|
|
targetSoc: _targetSoc,
|
|
endTime: _endTime,
|
|
);
|
|
if (err == null || !mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
|
|
content: Text('La box a refusé le mode de recharge ($err). '
|
|
'Détail dans son journal.'),
|
|
));
|
|
}
|
|
|
|
void _toggleDeadline(bool enabled) {
|
|
setState(() => _deadlineEnabled = enabled);
|
|
_send(mode: widget.data.chargingMode, deadline: enabled);
|
|
}
|
|
|
|
void _applyDeadline() {
|
|
_send(mode: widget.data.chargingMode, deadline: _deadlineEnabled);
|
|
}
|
|
|
|
/// Support des champs d'échéance, lu dans le schéma de la box.
|
|
///
|
|
/// Les deux vont ensemble : une échéance sans cible de charge n'a pas de sens, et
|
|
/// l'inverse non plus. On grise donc le bloc entier dès que l'un manque.
|
|
FieldSupport _socSupport(BuildContext context) {
|
|
final schema = context.watch<SchemaProvider>().schema;
|
|
final soc = schema.typeSupport('ChargingInfo', ['targetPercentage']);
|
|
final end = schema.typeSupport('ChargingInfo', ['endDateTime']);
|
|
if (soc == FieldSupport.unknown || end == FieldSupport.unknown) {
|
|
return FieldSupport.unknown;
|
|
}
|
|
if (soc == FieldSupport.absent || end == FieldSupport.absent) {
|
|
return FieldSupport.absent;
|
|
}
|
|
return FieldSupport.writable;
|
|
}
|
|
|
|
/// Vrai si le contrat de recharge ne porte aucun champ de courant minimal.
|
|
bool _minCurrentAbsent(BuildContext context) {
|
|
final schema = context.watch<SchemaProvider>().schema;
|
|
if (!schema.loaded) return false;
|
|
return schema.typeSupport('ChargingInfo', ['minCurrent']) ==
|
|
FieldSupport.absent;
|
|
}
|
|
|
|
String _statusLabel(ChargingMode mode) {
|
|
switch (mode) {
|
|
case ChargingMode.pv:
|
|
return _deadlineEnabled
|
|
? 'Surplus PV → Boost auto avant deadline'
|
|
: 'Surplus PV disponible';
|
|
case ChargingMode.minPv:
|
|
return _deadlineEnabled
|
|
? 'Min garanti + PV → Boost si deadline'
|
|
: 'Minimum + surplus PV';
|
|
case ChargingMode.boost:
|
|
return 'Charge rapide (réseau)';
|
|
}
|
|
}
|
|
|
|
Future<void> _pickEndTime() async {
|
|
final initial = TimeOfDay.fromDateTime(_endTime);
|
|
final picked = await showTimePicker(context: context, initialTime: initial);
|
|
if (picked != null) {
|
|
setState(() {
|
|
final now = DateTime.now();
|
|
_endTime = DateTime(now.year, now.month, now.day, picked.hour, picked.minute);
|
|
// If picked time is in the past, roll to tomorrow
|
|
if (_endTime.isBefore(now)) {
|
|
_endTime = _endTime.add(const Duration(days: 1));
|
|
}
|
|
});
|
|
_applyDeadline();
|
|
}
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final mode = widget.data.chargingMode;
|
|
final data = widget.data;
|
|
final showDeadlineOption = mode != ChargingMode.boost;
|
|
|
|
return Container(
|
|
decoration: BoxDecoration(
|
|
color: EtmTokens.card,
|
|
borderRadius: BorderRadius.circular(EtmTokens.radiusLg),
|
|
boxShadow: EtmTokens.cardShadow,
|
|
),
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
// Header
|
|
Row(
|
|
children: [
|
|
Container(
|
|
width: 34, height: 34,
|
|
decoration: BoxDecoration(
|
|
color: EtmTokens.navy.withValues(alpha: 0.07),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: const Icon(Icons.ev_station_rounded, color: EtmTokens.navy, size: 20),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('Borne de recharge (EVSE)',
|
|
style: EtmTokens.sans(size: 15, weight: FontWeight.w600)),
|
|
Text(_statusLabel(mode),
|
|
style: EtmTokens.sans(size: 12, color: EtmTokens.muted)),
|
|
],
|
|
),
|
|
),
|
|
GestureDetector(
|
|
onTap: () => _showSettings(context),
|
|
child: const Icon(Icons.tune, color: EtmTokens.faint, size: 20),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 14),
|
|
|
|
// Status badge + power
|
|
Row(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: EtmTokens.greenSoft,
|
|
borderRadius: BorderRadius.circular(99),
|
|
),
|
|
child: Row(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
Container(width: 6, height: 6,
|
|
decoration: const BoxDecoration(color: EtmTokens.green, shape: BoxShape.circle)),
|
|
const SizedBox(width: 5),
|
|
Text('En charge',
|
|
style: EtmTokens.sans(size: 12, weight: FontWeight.w600, color: EtmTokens.greenDark)),
|
|
],
|
|
),
|
|
),
|
|
const SizedBox(height: 8),
|
|
Text(
|
|
'${(data.chargingPower * 1000).toStringAsFixed(0)}',
|
|
style: EtmTokens.mono(size: 38, weight: FontWeight.w700),
|
|
),
|
|
Text('W Puissance actuelle',
|
|
style: EtmTokens.sans(size: 12, color: EtmTokens.muted)),
|
|
],
|
|
),
|
|
const Spacer(),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
Text('${data.solarSourcePercent.toStringAsFixed(0)}%',
|
|
style: EtmTokens.mono(size: 22, color: EtmTokens.green)),
|
|
Text('solaire', style: EtmTokens.sans(size: 11, color: EtmTokens.muted)),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// Mode buttons — 3 boutons : PV / Min+PV / Boost
|
|
Row(
|
|
children: [
|
|
_ModeButton(
|
|
label: 'PV',
|
|
icon: Icons.wb_sunny_rounded,
|
|
color: EtmTokens.amber,
|
|
isSelected: mode == ChargingMode.pv,
|
|
onTap: () => _selectMode(ChargingMode.pv),
|
|
),
|
|
const SizedBox(width: 8),
|
|
_ModeButton(
|
|
label: 'Min+PV',
|
|
icon: Icons.bolt,
|
|
color: EtmTokens.blue,
|
|
isSelected: mode == ChargingMode.minPv,
|
|
onTap: () => _selectMode(ChargingMode.minPv),
|
|
),
|
|
const SizedBox(width: 8),
|
|
_ModeButton(
|
|
label: 'Boost',
|
|
icon: Icons.rocket_launch_rounded,
|
|
color: EtmTokens.green,
|
|
isSelected: mode == ChargingMode.boost,
|
|
onTap: () => _selectMode(ChargingMode.boost),
|
|
),
|
|
],
|
|
),
|
|
|
|
// Échéance — visible pour PV et Min+PV, et **active seulement si la box
|
|
// porte les champs**. Le grisage vient du schéma, jamais d'une table de
|
|
// versions : le jour où le plugin expose `endDateTime`, le réglage s'active
|
|
// seul sur les box à jour et reste gris sur les autres.
|
|
if (showDeadlineOption) ...[
|
|
const SizedBox(height: 8),
|
|
SchemaGated(
|
|
support: _socSupport(context),
|
|
fieldName: 'targetPercentage / endDateTime',
|
|
label: 'Échéance de recharge',
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
_DeadlineToggleRow(
|
|
enabled: _deadlineEnabled,
|
|
onChanged: _toggleDeadline,
|
|
),
|
|
if (_deadlineEnabled) ...[
|
|
const SizedBox(height: 10),
|
|
_DeadlineParamsRow(
|
|
targetSoc: _targetSoc,
|
|
endTime: _endTime,
|
|
onSocChanged: (v) {
|
|
setState(() => _targetSoc = v);
|
|
_applyDeadline();
|
|
},
|
|
onPickTime: _pickEndTime,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
// Le mode « minimum garanti » existe côté box, mais la VALEUR du courant
|
|
// minimal n'est dans aucun champ de `ChargingInfo` : elle ne se règle pas
|
|
// ici. Le dire, plutôt que d'offrir un réglage sans effet.
|
|
if (_minCurrentAbsent(context)) ...[
|
|
const SizedBox(height: 8),
|
|
_AbsentFieldNote(
|
|
'Le courant minimal du mode « Min + PV » n\'est pas réglable depuis '
|
|
'l\'app : la box choisit le mode, mais n\'expose aucun champ '
|
|
'« minCurrent » dans son contrat de recharge.',
|
|
),
|
|
],
|
|
],
|
|
|
|
const SizedBox(height: 14),
|
|
|
|
// SOC progress
|
|
_SocProgress(targetSoc: _targetSoc, currentSoc: 62),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
|
|
void _showSettings(BuildContext context) {
|
|
showModalBottomSheet(
|
|
context: context,
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
|
),
|
|
builder: (_) => const _ChargingSettingsSheet(),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DeadlineToggleRow extends StatelessWidget {
|
|
final bool enabled;
|
|
final ValueChanged<bool> onChanged;
|
|
|
|
const _DeadlineToggleRow({required this.enabled, required this.onChanged});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
Icon(Icons.flag_outlined, size: 16,
|
|
color: enabled ? EtmTokens.blue : EtmTokens.faint),
|
|
const SizedBox(width: 6),
|
|
Text('Cible deadline',
|
|
style: EtmTokens.sans(size: 13,
|
|
color: enabled ? EtmTokens.blue : EtmTokens.muted,
|
|
weight: enabled ? FontWeight.w600 : FontWeight.w400)),
|
|
],
|
|
),
|
|
Switch(
|
|
value: enabled,
|
|
onChanged: onChanged,
|
|
activeThumbColor: EtmTokens.blue,
|
|
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _DeadlineParamsRow extends StatelessWidget {
|
|
final int targetSoc;
|
|
final DateTime endTime;
|
|
final ValueChanged<int> onSocChanged;
|
|
final VoidCallback onPickTime;
|
|
|
|
const _DeadlineParamsRow({
|
|
required this.targetSoc,
|
|
required this.endTime,
|
|
required this.onSocChanged,
|
|
required this.onPickTime,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final timeLabel =
|
|
'${endTime.hour.toString().padLeft(2, '0')}:${endTime.minute.toString().padLeft(2, '0')}';
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
color: EtmTokens.blueSoft,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Column(
|
|
children: [
|
|
Row(
|
|
children: [
|
|
const Icon(Icons.battery_charging_full, size: 16, color: EtmTokens.blue),
|
|
const SizedBox(width: 6),
|
|
Text('SOC cible : $targetSoc %',
|
|
style: EtmTokens.sans(size: 12, color: EtmTokens.blue)),
|
|
Expanded(
|
|
child: Slider(
|
|
value: targetSoc.toDouble(),
|
|
min: 0,
|
|
max: 100,
|
|
divisions: 20,
|
|
label: '$targetSoc %',
|
|
activeColor: EtmTokens.blue,
|
|
onChanged: (v) => onSocChanged(v.round()),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
GestureDetector(
|
|
onTap: onPickTime,
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.access_time, size: 16, color: EtmTokens.blue),
|
|
const SizedBox(width: 6),
|
|
Text('Heure d\'arrivée :', style: EtmTokens.sans(size: 12, color: EtmTokens.blue)),
|
|
const SizedBox(width: 8),
|
|
Text(timeLabel, style: EtmTokens.mono(size: 13, color: EtmTokens.blue)),
|
|
const SizedBox(width: 4),
|
|
const Icon(Icons.edit, size: 12, color: EtmTokens.blue),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ModeButton extends StatelessWidget {
|
|
final String label;
|
|
final IconData icon;
|
|
final Color color;
|
|
final bool isSelected;
|
|
final VoidCallback onTap;
|
|
|
|
const _ModeButton({
|
|
required this.label,
|
|
required this.icon,
|
|
required this.color,
|
|
required this.isSelected,
|
|
required this.onTap,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Expanded(
|
|
child: GestureDetector(
|
|
onTap: onTap,
|
|
child: AnimatedContainer(
|
|
duration: const Duration(milliseconds: 200),
|
|
padding: const EdgeInsets.symmetric(vertical: 11),
|
|
decoration: BoxDecoration(
|
|
color: isSelected ? color : color.withValues(alpha: 0.10),
|
|
borderRadius: BorderRadius.circular(12),
|
|
border: Border.all(color: isSelected ? color : color.withValues(alpha: 0.3)),
|
|
),
|
|
child: Row(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
Icon(icon, color: isSelected ? Colors.white : color, size: 15),
|
|
const SizedBox(width: 4),
|
|
Text(
|
|
label,
|
|
style: EtmTokens.sans(
|
|
size: 13,
|
|
weight: FontWeight.w600,
|
|
color: isSelected ? Colors.white : color,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Barre de progression SOC de la voiture avec target et valeur courante.
|
|
class _SocProgress extends StatelessWidget {
|
|
final int targetSoc;
|
|
final int currentSoc;
|
|
const _SocProgress({required this.targetSoc, required this.currentSoc});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
|
|
decoration: BoxDecoration(
|
|
color: EtmTokens.blueSoft,
|
|
borderRadius: BorderRadius.circular(13),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text('Charge prévue jusqu\'à $targetSoc%',
|
|
style: EtmTokens.sans(size: 13, color: EtmTokens.navy)),
|
|
Text('$currentSoc%', style: EtmTokens.mono(size: 13, color: EtmTokens.blue)),
|
|
],
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text('Aujourd\'hui à 07:30', style: EtmTokens.sans(size: 11, color: EtmTokens.muted)),
|
|
const SizedBox(height: 8),
|
|
ClipRRect(
|
|
borderRadius: BorderRadius.circular(99),
|
|
child: SizedBox(
|
|
height: 7,
|
|
child: LinearProgressIndicator(
|
|
value: currentSoc / 100,
|
|
backgroundColor: const Color(0xFFD6E6F2),
|
|
valueColor: const AlwaysStoppedAnimation<Color>(EtmTokens.blue),
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _ChargingSettingsSheet extends StatefulWidget {
|
|
const _ChargingSettingsSheet();
|
|
|
|
@override
|
|
State<_ChargingSettingsSheet> createState() => _ChargingSettingsSheetState();
|
|
}
|
|
|
|
class _ChargingSettingsSheetState extends State<_ChargingSettingsSheet> {
|
|
double _minPower = 6;
|
|
double _maxPower = 16;
|
|
bool _scheduleEnabled = false;
|
|
TimeOfDay _startTime = const TimeOfDay(hour: 22, minute: 0);
|
|
TimeOfDay _endTime = const TimeOfDay(hour: 6, minute: 0);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Padding(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Text('Paramètres de charge',
|
|
style: TextStyle(fontSize: 18, fontWeight: FontWeight.bold)),
|
|
const SizedBox(height: 20),
|
|
Text('Courant minimum (A)', style: EtmTokens.sans(size: 14, weight: FontWeight.w500)),
|
|
Slider(
|
|
value: _minPower,
|
|
min: 6,
|
|
max: 16,
|
|
divisions: 10,
|
|
label: '${_minPower.toStringAsFixed(0)} A',
|
|
onChanged: (v) => setState(() => _minPower = v),
|
|
activeColor: EtmTokens.green,
|
|
),
|
|
Text('Courant maximum (A)', style: EtmTokens.sans(size: 14, weight: FontWeight.w500)),
|
|
Slider(
|
|
value: _maxPower,
|
|
min: 6,
|
|
max: 32,
|
|
divisions: 26,
|
|
label: '${_maxPower.toStringAsFixed(0)} A',
|
|
onChanged: (v) => setState(() => _maxPower = v),
|
|
activeColor: EtmTokens.green,
|
|
),
|
|
Row(
|
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
|
children: [
|
|
Text('Programmation horaire', style: EtmTokens.sans(size: 14, weight: FontWeight.w500)),
|
|
Switch(
|
|
value: _scheduleEnabled,
|
|
onChanged: (v) => setState(() => _scheduleEnabled = v),
|
|
activeColor: EtmTokens.green,
|
|
),
|
|
],
|
|
),
|
|
if (_scheduleEnabled) ...[
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _TimePicker(
|
|
label: 'Début',
|
|
time: _startTime,
|
|
onChanged: (t) => setState(() => _startTime = t),
|
|
),
|
|
),
|
|
const SizedBox(width: 16),
|
|
Expanded(
|
|
child: _TimePicker(
|
|
label: 'Fin',
|
|
time: _endTime,
|
|
onChanged: (t) => setState(() => _endTime = t),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
const SizedBox(height: 16),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton(
|
|
onPressed: () => Navigator.pop(context),
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: EtmTokens.green,
|
|
foregroundColor: Colors.white,
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
),
|
|
child: const Text('Enregistrer'),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _TimePicker extends StatelessWidget {
|
|
final String label;
|
|
final TimeOfDay time;
|
|
final ValueChanged<TimeOfDay> onChanged;
|
|
|
|
const _TimePicker({
|
|
required this.label,
|
|
required this.time,
|
|
required this.onChanged,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: () async {
|
|
final picked = await showTimePicker(context: context, initialTime: time);
|
|
if (picked != null) onChanged(picked);
|
|
},
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: Colors.grey.shade300),
|
|
borderRadius: BorderRadius.circular(8),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.access_time, size: 16, color: EtmTokens.faint),
|
|
const SizedBox(width: 8),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(label, style: EtmTokens.sans(size: 11, color: EtmTokens.muted)),
|
|
Text(
|
|
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}',
|
|
style: EtmTokens.mono(size: 13),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
/// Note d'un champ que la box n'expose pas — **avec sa raison**, jamais un simple gris.
|
|
class _AbsentFieldNote extends StatelessWidget {
|
|
final String text;
|
|
|
|
const _AbsentFieldNote(this.text);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final muted = EtmTokens.mutedOf(context);
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Icon(Icons.cloud_off_outlined, size: 13, color: muted),
|
|
const SizedBox(width: 5),
|
|
Expanded(
|
|
child: Text(text,
|
|
style: TextStyle(fontSize: 11.5, color: muted, height: 1.3)),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|