etm-powersync-app/lib/widgets/ev_charging_card.dart
etm d5dc0c7ca5 Initial commit : Flutter app nymea energy monitor
- NymeaService : auth complète (Hello → Authenticate → SetNotificationStatus)
- Token top-level dans chaque requête JSON-RPC (fix critique GetThings)
- Persistance token via shared_preferences par hôte
- Dashboard : champs utilisateur/mot de passe dans le dialog de connexion
- ThingDetailScreen : renommer, réglages (settingsTypes) et supprimer
- NymeaThingClass : champ settingsTypes parsé depuis l'API
- NymeaThing : copyWith(name) + settingValue()
- Fix overflow _StateChip dans ThingsScreen

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 16:57:46 +01:00

425 lines
13 KiB
Dart

import 'package:flutter/material.dart';
import '../models/energy_data.dart';
import '../services/nymea_service.dart';
import '../theme/app_theme.dart';
class EVChargingCard extends StatelessWidget {
final EnergyData data;
final NymeaService service;
const EVChargingCard({
super.key,
required this.data,
required this.service,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Recharge du véhicule',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.bold,
color: AppTheme.textDark,
),
),
Text(
_statusLabel(data.chargingMode),
style: const TextStyle(
fontSize: 13,
color: AppTheme.textLight,
),
),
],
),
IconButton(
icon: const Icon(Icons.settings_outlined),
onPressed: () => _showSettings(context),
color: AppTheme.textLight,
),
],
),
const SizedBox(height: 16),
// Mode buttons
Row(
children: [
_ModeButton(
label: 'PV',
icon: Icons.wb_sunny_rounded,
color: AppTheme.pvGreen,
isSelected: data.chargingMode == ChargingMode.pv,
onTap: () => service.setChargingMode(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),
),
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),
),
],
),
const SizedBox(height: 16),
// Stats
_InfoRow(
label: 'Puissance borne',
value: '${data.chargingPower.toStringAsFixed(1)} kW',
),
const SizedBox(height: 6),
_InfoRow(
label: 'Source',
value: '${data.solarSourcePercent.toStringAsFixed(0)}% solaire',
valueColor: AppTheme.pvGreen,
),
const SizedBox(height: 6),
_InfoRow(
label: 'Limite réseau',
value: data.gridLimitOk ? 'OK ✓' : 'Dépassée !',
valueColor: data.gridLimitOk ? AppTheme.primaryGreen : AppTheme.boostRed,
),
// Charging indicator
const SizedBox(height: 12),
if (data.chargingMode != ChargingMode.pv || data.pvPower > 0)
_ChargingIndicator(
mode: data.chargingMode,
pvPower: data.pvPower,
chargingPower: data.chargingPower,
),
],
),
),
);
}
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,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
),
builder: (_) => const _ChargingSettingsSheet(),
);
}
}
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: 12),
decoration: BoxDecoration(
color: isSelected ? color : color.withValues(alpha:0.1),
borderRadius: BorderRadius.circular(30),
),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(icon,
color: isSelected ? Colors.white : color, size: 16),
const SizedBox(width: 4),
Text(
label,
style: TextStyle(
color: isSelected ? Colors.white : color,
fontWeight: FontWeight.bold,
fontSize: 13,
),
),
],
),
),
),
);
}
}
class _InfoRow extends StatelessWidget {
final String label;
final String value;
final Color? valueColor;
const _InfoRow({required this.label, required this.value, this.valueColor});
@override
Widget build(BuildContext context) {
return Row(
children: [
Text(label,
style: const TextStyle(fontSize: 13, color: AppTheme.textLight)),
const Text(' : ', style: TextStyle(color: AppTheme.textLight)),
Text(
value,
style: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: valueColor ?? AppTheme.textDark,
),
),
],
);
}
}
class _ChargingIndicator extends StatelessWidget {
final ChargingMode mode;
final double pvPower;
final double chargingPower;
const _ChargingIndicator({
required this.mode,
required this.pvPower,
required this.chargingPower,
});
@override
Widget build(BuildContext context) {
final pvPercent = (pvPower / (chargingPower * 1000)).clamp(0.0, 1.0);
final gridPercent = mode == ChargingMode.boost ? 1.0 : (1 - pvPercent).clamp(0.0, 1.0);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text('Répartition charge',
style: TextStyle(fontSize: 12, color: AppTheme.textLight)),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(8),
child: SizedBox(
height: 10,
child: Row(
children: [
Flexible(
flex: (pvPercent * 100).toInt(),
child: Container(color: AppTheme.solarYellow),
),
Flexible(
flex: (gridPercent * 100).toInt(),
child: Container(color: Colors.orange.shade300),
),
],
),
),
),
const SizedBox(height: 4),
Row(
children: [
_legend(AppTheme.solarYellow, 'Solaire'),
const SizedBox(width: 12),
_legend(Colors.orange.shade300, 'Réseau'),
],
),
],
);
}
Widget _legend(Color color, String label) => Row(
children: [
Container(width: 10, height: 10, color: color),
const SizedBox(width: 4),
Text(label, style: const TextStyle(fontSize: 11, color: AppTheme.textLight)),
],
);
}
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),
const Text('Courant minimum (A)',
style: TextStyle(fontWeight: FontWeight.w500)),
Slider(
value: _minPower,
min: 6,
max: 16,
divisions: 10,
label: '${_minPower.toStringAsFixed(0)} A',
onChanged: (v) => setState(() => _minPower = v),
activeColor: AppTheme.primaryGreen,
),
const Text('Courant maximum (A)',
style: TextStyle(fontWeight: FontWeight.w500)),
Slider(
value: _maxPower,
min: 6,
max: 32,
divisions: 26,
label: '${_maxPower.toStringAsFixed(0)} A',
onChanged: (v) => setState(() => _maxPower = v),
activeColor: AppTheme.primaryGreen,
),
Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text('Programmation horaire',
style: TextStyle(fontWeight: FontWeight.w500)),
Switch(
value: _scheduleEnabled,
onChanged: (v) => setState(() => _scheduleEnabled = v),
activeThumbColor: AppTheme.primaryGreen,
),
],
),
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: AppTheme.primaryGreen,
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: AppTheme.textLight),
const SizedBox(width: 8),
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(label,
style: const TextStyle(
fontSize: 11, color: AppTheme.textLight)),
Text(
'${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}',
style: const TextStyle(
fontWeight: FontWeight.bold,
color: AppTheme.textDark),
),
],
),
],
),
),
);
}
}