etm-powersync-app/lib/screens/dashboard_screen.dart
pakutz79 c19c9d1a98 feat: navigation drawer, EMS setup, scheduler, tarifs, paramètres app
- Drawer custom (overlay Stack) avec mode installateur PIN SHA-256
- GoRouter + ShellRoute : navigation préservée entre onglets
- 6 providers : NavigationProvider, InstallerModeProvider, AppSettingsProvider,
  EnergySetupProvider, SchedulerProvider, TariffProvider
- Écrans Energy Manager : RoleConfigFlow (3 étapes), Scheduler, Tarifs, Timeline
- Écrans Paramètres : Apparence, Écrans actifs, AppSettingsScreen
- DrawerMenuButton présent dans les 5 AppBars principaux
- Simulation : _thingClasses générées avec interfaces EMS pour filtrage des rôles
- Compteur solaire : ajout smartmeter aux interfaces compatibles
- Thème ETM (etm_theme.dart), ProLockBadge, widgets PowerBar/RoleCard/TimelineSlotCard
- Dépendances : go_router, shared_preferences, crypto, url_launcher

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-24 14:52:32 +01:00

545 lines
18 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../main.dart' show DrawerMenuButton;
import '../services/nymea_service.dart';
import '../theme/app_theme.dart';
import '../widgets/energy_flow_widget.dart';
import '../widgets/production_card.dart';
import '../widgets/ev_charging_card.dart';
import '../widgets/gains_card.dart';
class DashboardScreen extends StatefulWidget {
const DashboardScreen({super.key});
@override
State<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
@override
void initState() {
super.initState();
// Start simulation on first load
WidgetsBinding.instance.addPostFrameCallback((_) {
final service = context.read<NymeaService>();
if (!service.connected) {
service.startSimulation();
}
});
}
@override
Widget build(BuildContext context) {
return Consumer<NymeaService>(
builder: (context, service, _) {
final data = service.energyData;
return Scaffold(
backgroundColor: AppTheme.backgroundGray,
body: RefreshIndicator(
onRefresh: () async {
service.startSimulation();
},
color: AppTheme.primaryGreen,
child: CustomScrollView(
slivers: [
// App bar
SliverAppBar(
floating: true,
backgroundColor: AppTheme.backgroundGray,
elevation: 0,
leading: const DrawerMenuButton(),
leadingWidth: 56,
title: const Text(
'ETM PowerSync',
style: TextStyle(
fontWeight: FontWeight.bold,
color: AppTheme.textDark,
fontSize: 18,
),
),
actions: [
// Connection status
Padding(
padding: const EdgeInsets.only(right: 8),
child: IconButton(
icon: Icon(
service.connected
? Icons.wifi_rounded
: Icons.wifi_off_rounded,
color: service.connected
? AppTheme.primaryGreen
: Colors.red,
),
onPressed: () => _showConnectionDialog(context, service),
),
),
Padding(
padding: const EdgeInsets.only(right: 12),
child: IconButton(
icon: const Icon(Icons.notifications_outlined,
color: AppTheme.textDark),
onPressed: () {},
),
),
],
),
SliverPadding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
sliver: SliverList(
delegate: SliverChildListDelegate([
// Energy flow
EnergyFlowWidget(data: data),
const SizedBox(height: 12),
// Self-consumption rates
_RatesRow(data: data),
const SizedBox(height: 12),
// Production card
ProductionCard(data: data),
const SizedBox(height: 12),
// Gains
GainsCard(data: data),
const SizedBox(height: 12),
// EV Charging
EVChargingCard(data: data, service: service),
const SizedBox(height: 16),
]),
),
),
],
),
),
);
},
);
}
void _showConnectionDialog(BuildContext context, NymeaService service) {
showDialog(
context: context,
builder: (ctx) => _ConnectionDialog(service: service),
);
}
}
class _RatesRow extends StatelessWidget {
final dynamic data;
const _RatesRow({required this.data});
@override
Widget build(BuildContext context) {
return Row(
children: [
Expanded(
child: _RateCard(
label: 'Autoconsommation',
value: data.selfConsumptionRate,
icon: Icons.wb_sunny_rounded,
color: AppTheme.solarYellow,
),
),
const SizedBox(width: 12),
Expanded(
child: _RateCard(
label: 'Autonomie',
value: data.autonomyRate,
icon: Icons.home_rounded,
color: AppTheme.homeBlue,
),
),
],
);
}
}
class _RateCard extends StatelessWidget {
final String label;
final double value;
final IconData icon;
final Color color;
const _RateCard({
required this.label,
required this.value,
required this.icon,
required this.color,
});
@override
Widget build(BuildContext context) {
return Card(
child: Padding(
padding: const EdgeInsets.all(14),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(icon, color: color, size: 18),
const SizedBox(width: 6),
Expanded(
child: Text(
label,
style: const TextStyle(
fontSize: 12, color: AppTheme.textLight),
overflow: TextOverflow.ellipsis,
),
),
],
),
const SizedBox(height: 8),
Text(
'${value.toStringAsFixed(1)}%',
style: TextStyle(
fontSize: 26,
fontWeight: FontWeight.bold,
color: color,
),
),
const SizedBox(height: 6),
ClipRRect(
borderRadius: BorderRadius.circular(4),
child: LinearProgressIndicator(
value: (value / 100).clamp(0, 1),
backgroundColor: color.withValues(alpha:0.15),
valueColor: AlwaysStoppedAnimation(color),
minHeight: 6,
),
),
],
),
),
);
}
}
// ═══════════════════════════════════════════════════════════════════════════
// Dialog de connexion avec sélection du protocole TCP / WebSocket
// ═══════════════════════════════════════════════════════════════════════════
class _ConnectionDialog extends StatefulWidget {
final NymeaService service;
const _ConnectionDialog({required this.service});
@override
State<_ConnectionDialog> createState() => _ConnectionDialogState();
}
class _ConnectionDialogState extends State<_ConnectionDialog> {
late TextEditingController _hostCtrl;
late TextEditingController _portCtrl;
late TextEditingController _userCtrl;
late TextEditingController _passCtrl;
late NymeaProtocol _protocol;
bool _connecting = false;
bool _verbose = false;
// Ports par défaut selon protocole
static const _defaultPorts = {
NymeaProtocol.tcpRaw: 2222,
NymeaProtocol.webSocket: 4444,
};
@override
void initState() {
super.initState();
_protocol = widget.service.protocol;
_hostCtrl = TextEditingController(text: widget.service.host);
_portCtrl = TextEditingController(text: widget.service.port.toString());
_userCtrl = TextEditingController(text: widget.service.username);
_passCtrl = TextEditingController(text: widget.service.password);
_verbose = widget.service.verboseLog;
}
@override
void dispose() {
_hostCtrl.dispose();
_portCtrl.dispose();
_userCtrl.dispose();
_passCtrl.dispose();
super.dispose();
}
void _onProtocolChanged(NymeaProtocol p) {
setState(() {
_protocol = p;
// Mettre à jour le port par défaut si l'utilisateur n'a pas changé
final currentPort = int.tryParse(_portCtrl.text) ?? 0;
final otherDefault = _defaultPorts[_protocol == NymeaProtocol.tcpRaw
? NymeaProtocol.webSocket
: NymeaProtocol.tcpRaw]!;
if (currentPort == otherDefault || currentPort == 0) {
_portCtrl.text = _defaultPorts[p]!.toString();
}
});
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Row(
children: [
Container(
width: 36, height: 36,
decoration: BoxDecoration(
color: AppTheme.primaryGreen.withValues(alpha:0.12),
borderRadius: BorderRadius.circular(10),
),
child: const Icon(Icons.router_rounded,
color: AppTheme.primaryGreen, size: 20),
),
const SizedBox(width: 10),
const Text('Connexion Nymea'),
],
),
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(16)),
content: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Sélecteur de protocole ─────────────────────────────────────
const Text('Protocole',
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w600,
color: AppTheme.textLight)),
const SizedBox(height: 8),
Container(
decoration: BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
_ProtoTab(
label: 'TCP brut',
subtitle: 'port 2222',
icon: Icons.cable_rounded,
selected: _protocol == NymeaProtocol.tcpRaw,
onTap: () => _onProtocolChanged(NymeaProtocol.tcpRaw),
),
_ProtoTab(
label: 'WebSocket',
subtitle: 'port 4444',
icon: Icons.wifi_rounded,
selected: _protocol == NymeaProtocol.webSocket,
onTap: () => _onProtocolChanged(NymeaProtocol.webSocket),
),
],
),
),
const SizedBox(height: 6),
// Info protocole
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: AppTheme.primaryGreen.withValues(alpha:0.07),
borderRadius: BorderRadius.circular(8),
),
child: Text(
_protocol == NymeaProtocol.tcpRaw
? '💡 TCP brut : nymea envoie un message de bienvenue dès la connexion. Utilisé par l\'app nymea officielle.'
: '💡 WebSocket : le client envoie JSONRPC.Hello en premier. Activez-le dans nymea → Paramètres → Serveur WebSocket.',
style: const TextStyle(fontSize: 11, color: AppTheme.textLight),
),
),
const SizedBox(height: 14),
// ── Hôte ──────────────────────────────────────────────────────
TextField(
controller: _hostCtrl,
decoration: const InputDecoration(
labelText: 'Adresse IP / Hôte',
prefixIcon: Icon(Icons.dns_outlined),
border: OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: 10),
// ── Port ──────────────────────────────────────────────────────
TextField(
controller: _portCtrl,
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Port',
prefixIcon: Icon(Icons.settings_ethernet),
border: OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: 10),
// ── Identifiants ──────────────────────────────────────────
TextField(
controller: _userCtrl,
decoration: const InputDecoration(
labelText: 'Utilisateur',
prefixIcon: Icon(Icons.person_outline),
border: OutlineInputBorder(),
isDense: true,
),
),
const SizedBox(height: 10),
TextField(
controller: _passCtrl,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Mot de passe',
prefixIcon: Icon(Icons.lock_outline),
border: OutlineInputBorder(),
isDense: true,
),
),
// ── Debug verbose ─────────────────────────────────────────
const SizedBox(height: 8),
Row(
children: [
const Icon(Icons.terminal_rounded, size: 16,
color: AppTheme.textLight),
const SizedBox(width: 8),
const Expanded(
child: Text('Logs JSON verbose',
style: TextStyle(fontSize: 13, color: AppTheme.textDark)),
),
Switch(
value: _verbose,
activeThumbColor: AppTheme.primaryGreen,
onChanged: (v) {
setState(() => _verbose = v);
widget.service.verboseLog = v;
},
),
],
),
// ── Erreur ────────────────────────────────────────────────────
if (widget.service.connectionError != null) ...[
const SizedBox(height: 8),
Container(
padding: const EdgeInsets.all(8),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(8),
),
child: Row(
children: [
const Icon(Icons.error_outline, color: Colors.red, size: 16),
const SizedBox(width: 6),
Expanded(
child: Text(
widget.service.connectionError!,
style: const TextStyle(color: Colors.red, fontSize: 11),
),
),
],
),
),
],
],
),
actions: [
// Mode démo
TextButton.icon(
icon: const Icon(Icons.science_outlined, size: 16),
label: const Text('Mode démo'),
onPressed: () {
widget.service.startSimulation();
Navigator.pop(context);
},
),
// Connecter
ElevatedButton.icon(
icon: _connecting
? const SizedBox(
width: 14, height: 14,
child: CircularProgressIndicator(
strokeWidth: 2, color: Colors.white))
: const Icon(Icons.link_rounded, size: 16),
label: const Text('Connecter'),
onPressed: _connecting
? null
: () async {
setState(() => _connecting = true);
final ok = await widget.service.connect(
_hostCtrl.text.trim(),
int.tryParse(_portCtrl.text.trim()) ??
_defaultPorts[_protocol]!,
protocol: _protocol,
username: _userCtrl.text.trim(),
password: _passCtrl.text,
);
if (ok && context.mounted) Navigator.pop(context);
if (mounted) setState(() => _connecting = false);
},
style: ElevatedButton.styleFrom(
backgroundColor: AppTheme.primaryGreen,
foregroundColor: Colors.white,
),
),
],
);
}
}
class _ProtoTab extends StatelessWidget {
final String label;
final String subtitle;
final IconData icon;
final bool selected;
final VoidCallback onTap;
const _ProtoTab({
required this.label,
required this.subtitle,
required this.icon,
required this.selected,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Expanded(
child: GestureDetector(
onTap: onTap,
child: AnimatedContainer(
duration: const Duration(milliseconds: 200),
margin: const EdgeInsets.all(4),
padding: const EdgeInsets.symmetric(vertical: 10),
decoration: BoxDecoration(
color: selected ? Colors.white : Colors.transparent,
borderRadius: BorderRadius.circular(8),
boxShadow: selected
? [BoxShadow(
color: Colors.black.withValues(alpha:0.08),
blurRadius: 4, offset: const Offset(0, 1))]
: null,
),
child: Column(
children: [
Icon(icon,
size: 20,
color: selected ? AppTheme.primaryGreen : AppTheme.textLight),
const SizedBox(height: 4),
Text(label,
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.bold,
color: selected ? AppTheme.primaryGreen : AppTheme.textLight)),
Text(subtitle,
style: const TextStyle(
fontSize: 10, color: AppTheme.textLight)),
],
),
),
),
);
}
}