feat: EV charging UI — 3 modes PV/Min+PV/Boost + deadline option

- setChargingInfo complet avec EcoWithMinCurrent + EcoMinWithTargetTime
- UI 3 boutons + toggle Cible + slider SOC 0-100% + time picker
This commit is contained in:
Patrick Schurig 2026-04-05 07:55:13 +02:00
parent 1278da2a04
commit e42412fef8
3 changed files with 292 additions and 32 deletions

View File

@ -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,

View File

@ -763,16 +763,73 @@ class NymeaService extends ChangeNotifier {
} catch (e) { _log('GetEnergyLogs: $e'); }
}
Future<void> 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<void> 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 = <String, dynamic>{
'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();

View File

@ -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<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);
}
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<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 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<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 ? 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<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: 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;