- 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>
425 lines
13 KiB
Dart
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),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
} |