diff --git a/lib/screens/favorites_screen.dart b/lib/screens/favorites_screen.dart index ce50a75..fa3959d 100644 --- a/lib/screens/favorites_screen.dart +++ b/lib/screens/favorites_screen.dart @@ -437,7 +437,7 @@ class _EVWidget extends StatelessWidget { children: ChargingMode.values.map((m) { final c = modeColors[m]!; return GestureDetector( - onTap: () => service.setChargingMode(m), + onTap: () => service.setChargingInfo(mode: m), child: AnimatedContainer( duration: const Duration(milliseconds: 150), width: 8, diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index 970d532..b7bc052 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -763,16 +763,73 @@ class NymeaService extends ChangeNotifier { } catch (e) { _log('GetEnergyLogs: $e'); } } - Future setChargingMode(ChargingMode mode) async { - final modeStr = { - ChargingMode.pv: 'pv', - ChargingMode.minPv: 'minpv', - ChargingMode.boost: 'boost', - }[mode]!; - if (_connected && !_isSimulation) { + /// Returns the thingId of the first configured EV charger, or null if none. + String? _findEvChargerId() { + for (final thing in _things) { + NymeaThingClass? cls; try { - await _sendRequest('Energy.SetChargingMode', {'mode': modeStr}); - } catch (e) { _log('SetChargingMode: $e'); } + cls = _thingClasses.firstWhere((c) => c.id == thing.thingClassId); + } catch (_) { + continue; + } + if (cls.interfaces.any((i) => i.toLowerCase() == 'evcharger')) { + return thing.id; + } + } + return null; + } + + /// Send EnergyPlugin.SetChargingInfo with full mode + optional deadline params. + /// + /// [mode] : UI mode (pv → Eco, minPv → EcoWithMinCurrent, boost → Normal) + /// [deadline] : activate deadline variant (*WithTargetTime) — ignored for boost + /// [targetSoc] : target battery SOC % (1-100), required when deadline=true + /// [endTime] : desired arrival/completion time, required when deadline=true + Future setChargingInfo({ + required ChargingMode mode, + bool deadline = false, + int targetSoc = 80, + DateTime? endTime, + }) async { + // Map (mode, deadline) → API mode string + optional minCurrent + final String apiMode; + int? minCurrent; + + if (mode == ChargingMode.boost) { + apiMode = 'Normal'; + } else if (mode == ChargingMode.pv && !deadline) { + apiMode = 'Eco'; + } else if (mode == ChargingMode.minPv && !deadline) { + apiMode = 'EcoWithMinCurrent'; + minCurrent = 6; + } else if (mode == ChargingMode.pv && deadline) { + apiMode = 'EcoWithTargetTime'; + } else { + // minPv + deadline + apiMode = 'EcoMinWithTargetTime'; + minCurrent = 6; + } + + if (_connected && !_isSimulation) { + final evChargerId = _findEvChargerId(); + if (evChargerId != null) { + try { + final info = { + 'evChargerId': evChargerId, + 'mode': apiMode, + }; + if (minCurrent != null) info['minCurrent'] = minCurrent; + if (deadline && mode != ChargingMode.boost) { + info['targetSoc'] = targetSoc; + info['endTime'] = endTime != null + ? endTime.millisecondsSinceEpoch ~/ 1000 + : null; + } + await _sendRequest('EnergyPlugin.SetChargingInfo', {'chargingInfo': info}); + } catch (e) { _log('SetChargingInfo: $e'); } + } else { + _log('SetChargingInfo: no EV charger thing found'); + } } _energyData = _energyData.copyWith(chargingMode: mode); notifyListeners(); diff --git a/lib/widgets/ev_charging_card.dart b/lib/widgets/ev_charging_card.dart index 36e7426..3072eb8 100644 --- a/lib/widgets/ev_charging_card.dart +++ b/lib/widgets/ev_charging_card.dart @@ -3,7 +3,7 @@ import '../models/energy_data.dart'; import '../services/nymea_service.dart'; import '../theme/app_theme.dart'; -class EVChargingCard extends StatelessWidget { +class EVChargingCard extends StatefulWidget { final EnergyData data; final NymeaService service; @@ -13,8 +13,85 @@ class EVChargingCard extends StatelessWidget { required this.service, }); + @override + State createState() => _EVChargingCardState(); +} + +class _EVChargingCardState extends State { + // 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); + } + widget.service.setChargingInfo( + mode: mode, + deadline: mode != ChargingMode.boost && _deadlineEnabled, + targetSoc: _targetSoc, + endTime: _endTime, + ); + } + + void _toggleDeadline(bool enabled) { + setState(() => _deadlineEnabled = enabled); + widget.service.setChargingInfo( + mode: widget.data.chargingMode, + deadline: enabled, + targetSoc: _targetSoc, + endTime: _endTime, + ); + } + + void _applyDeadline() { + widget.service.setChargingInfo( + mode: widget.data.chargingMode, + deadline: _deadlineEnabled, + targetSoc: _targetSoc, + endTime: _endTime, + ); + } + + 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 _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 Card( child: Padding( padding: const EdgeInsets.all(16), @@ -37,7 +114,7 @@ class EVChargingCard extends StatelessWidget { ), ), Text( - _statusLabel(data.chargingMode), + _statusLabel(mode), style: const TextStyle( fontSize: 13, color: AppTheme.textLight, @@ -54,34 +131,56 @@ class EVChargingCard extends StatelessWidget { ), const SizedBox(height: 16), - // Mode buttons + // Mode buttons — 3 buttons: PV / Min+PV / Boost Row( children: [ _ModeButton( label: 'PV', icon: Icons.wb_sunny_rounded, color: AppTheme.pvGreen, - isSelected: data.chargingMode == ChargingMode.pv, - onTap: () => service.setChargingMode(ChargingMode.pv), + isSelected: mode == ChargingMode.pv, + onTap: () => _selectMode(ChargingMode.pv), ), const SizedBox(width: 8), _ModeButton( label: 'Min + PV', icon: Icons.bolt, color: AppTheme.minPvBlue, - isSelected: data.chargingMode == ChargingMode.minPv, - onTap: () => service.setChargingMode(ChargingMode.minPv), + isSelected: mode == ChargingMode.minPv, + onTap: () => _selectMode(ChargingMode.minPv), ), const SizedBox(width: 8), _ModeButton( label: 'Boost', icon: Icons.rocket_launch_rounded, color: AppTheme.boostRed, - isSelected: data.chargingMode == ChargingMode.boost, - onTap: () => service.setChargingMode(ChargingMode.boost), + isSelected: mode == ChargingMode.boost, + onTap: () => _selectMode(ChargingMode.boost), ), ], ), + + // Deadline option — visible for PV and Min+PV only + if (showDeadlineOption) ...[ + const SizedBox(height: 8), + _DeadlineToggleRow( + enabled: _deadlineEnabled, + onChanged: _toggleDeadline, + ), + if (_deadlineEnabled) ...[ + const SizedBox(height: 10), + _DeadlineParamsRow( + targetSoc: _targetSoc, + endTime: _endTime, + onSocChanged: (v) { + setState(() => _targetSoc = v); + _applyDeadline(); + }, + onPickTime: _pickEndTime, + ), + ], + ], + const SizedBox(height: 16), // Stats @@ -104,9 +203,9 @@ class EVChargingCard extends StatelessWidget { // Charging indicator const SizedBox(height: 12), - if (data.chargingMode != ChargingMode.pv || data.pvPower > 0) + if (mode != ChargingMode.pv || data.pvPower > 0) _ChargingIndicator( - mode: data.chargingMode, + mode: mode, pvPower: data.pvPower, chargingPower: data.chargingPower, ), @@ -116,17 +215,6 @@ class EVChargingCard extends StatelessWidget { ); } - String _statusLabel(ChargingMode mode) { - switch (mode) { - case ChargingMode.pv: - return 'Surplus PV disponible'; - case ChargingMode.minPv: - return 'Minimum + surplus PV'; - case ChargingMode.boost: - return 'Charge rapide (réseau)'; - } - } - void _showSettings(BuildContext context) { showModalBottomSheet( context: context, @@ -138,6 +226,121 @@ class EVChargingCard extends StatelessWidget { } } +class _DeadlineToggleRow extends StatelessWidget { + final bool enabled; + final ValueChanged 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 ? AppTheme.accentTeal : AppTheme.textLight), + const SizedBox(width: 6), + Text( + 'Cible', + style: TextStyle( + fontSize: 13, + color: enabled ? AppTheme.accentTeal : AppTheme.textLight, + fontWeight: + enabled ? FontWeight.w600 : FontWeight.normal), + ), + ], + ), + Switch( + value: enabled, + onChanged: onChanged, + activeColor: AppTheme.accentTeal, + materialTapTargetSize: MaterialTapTargetSize.shrinkWrap, + ), + ], + ); + } +} + +class _DeadlineParamsRow extends StatelessWidget { + final int targetSoc; + final DateTime endTime; + final ValueChanged 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: 8), + decoration: BoxDecoration( + color: AppTheme.accentTeal.withValues(alpha: 0.07), + borderRadius: BorderRadius.circular(10), + ), + child: Column( + children: [ + // SOC slider + Row( + children: [ + const Icon(Icons.battery_charging_full, + size: 16, color: AppTheme.accentTeal), + const SizedBox(width: 6), + Text('SOC cible : $targetSoc %', + style: const TextStyle( + fontSize: 12, color: AppTheme.accentTeal)), + Expanded( + child: Slider( + value: targetSoc.toDouble(), + min: 0, + max: 100, + divisions: 20, + label: '$targetSoc %', + activeColor: AppTheme.accentTeal, + onChanged: (v) => onSocChanged(v.round()), + ), + ), + ], + ), + // Time picker + GestureDetector( + onTap: onPickTime, + child: Row( + children: [ + const Icon(Icons.access_time, + size: 16, color: AppTheme.accentTeal), + const SizedBox(width: 6), + const Text('Heure d\'arrivée :', + style: TextStyle(fontSize: 12, color: AppTheme.accentTeal)), + const SizedBox(width: 8), + Text( + timeLabel, + style: const TextStyle( + fontSize: 13, + fontWeight: FontWeight.bold, + color: AppTheme.accentTeal), + ), + const SizedBox(width: 4), + const Icon(Icons.edit, size: 12, color: AppTheme.accentTeal), + ], + ), + ), + ], + ), + ); + } +} + class _ModeButton extends StatelessWidget { final String label; final IconData icon;