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