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:
parent
1278da2a04
commit
e42412fef8
@ -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,
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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;
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user