etm-powersync-app/lib/screens/drawer/main_drawer.dart
Patrick Schurig c638ec6c52 feat: connexions multi-HEMS + rôles & appareils (beta add/config)
Lot Connexions multi-HEMS :
- modèle Installation + InstallationStore (persistance par UUID ;
  métadonnées SharedPreferences, token via flutter_secure_storage)
- ConnectionManager au-dessus de nymea_service : mono-connexion, switchTo,
  connectNew, enterDemo (démo = active factice), contrôle d'identité DHCP
- nymea_service : connect() réveillé (ws://4444, token par UUID), capture
  Hello (uuid/name/initialSetupRequired), authenticate/createUser, resetState
- écran Installations (3 états) + déverrouillage installateur depuis la racine
- écran Connexion : auth 2 branches (JSONRPC.Authenticate / CreateUser),
  détection auto via initialSetupRequired, segmenteur de repli
- gate de routage (redirect + refreshListenable merge[cm,svc]) ;
  hasActiveConnection = isSimulation || (connected && authenticated)
- en-tête drawer cliquable → Installations
- invalidation des caches au switch (service.resetState + clearForSwitch
  rôles/scheduler/tariff), sans persist()

Lot Rôles & appareils :
- EnergySetupProvider refondu en tri-état (present/absent/non-configuré)
- écran roles_devices_screen (3 zones, drag-priorité, inférence solaire/batterie)
- LoadDescriptor (etmvariableload, rév.2 : PAC exclue → SG-Ready) ;
  Energy.SetRootMeter réel, Get/SetLoadConfig en stub loggé

Correctifs :
- bug signe énergie : production +, consommation NÉGATIVE sur nymea 1.15.2
  → consumptionW/home en .abs() (autoconso n'est plus clouée à 0)
- UUID Hello brace-wrapped normalisé

Tests : flutter analyze 0 erreur ; gate + zéro-fuite au switch (5/5 verts).
Validé en vrai : login auth .120, .75 ouvert, switch, déverrouillage installateur.
Note : embarque aussi le WIP dashboard/thème déjà présent dans l'arbre.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 08:39:08 +02:00

635 lines
22 KiB
Dart

import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:url_launcher/url_launcher.dart';
import '../../providers/installer_mode_provider.dart';
import '../../providers/navigation_provider.dart';
import '../../services/connection_manager.dart';
import '../../services/nymea_service.dart';
import '../../theme/etm_tokens.dart';
import 'installer_pin_dialog.dart';
// ─────────────────────────────────────────────────────────────────────────────
// MainDrawer — drawer custom qui glisse sur le contenu
//
// Pas de Drawer Flutter standard. L'overlay est géré dans MainShell via Stack :
// 1. Couche sombre (GestureDetector → closeDrawer)
// 2. DrawerPanel animé (Transform.translate)
// ─────────────────────────────────────────────────────────────────────────────
/// Couche sombre cliquable qui ferme le drawer.
class DrawerScrim extends StatelessWidget {
const DrawerScrim({super.key});
@override
Widget build(BuildContext context) {
return GestureDetector(
onTap: () => context.read<NavigationProvider>().closeDrawer(),
child: Container(color: Colors.black.withValues(alpha: 0.45)),
);
}
}
/// Panel du drawer (largeur fixe, fond sombre ETM).
class DrawerPanel extends StatelessWidget {
const DrawerPanel({super.key});
@override
Widget build(BuildContext context) {
return SizedBox(
width: 320,
child: Material(
color: EtmTokens.navy,
child: SafeArea(
child: Column(
children: [
// ── En-tête ────────────────────────────────────────────────────
_DrawerHeader(),
// ── Menu ──────────────────────────────────────────────────────
Expanded(
child: _DrawerMenu(),
),
],
),
),
),
);
}
}
// ── En-tête du drawer ─────────────────────────────────────────────────────────
class _DrawerHeader extends StatelessWidget {
@override
Widget build(BuildContext context) {
final service = context.watch<NymeaService>();
final installer = context.watch<InstallerModeProvider>();
final manager = context.watch<ConnectionManager>();
// Nom de l'installation active : box réelle > identité Hello > démo.
final boxName = service.isSimulation
? 'Mode démo'
: (manager.active?.name ??
(service.serverName.isNotEmpty
? service.serverName
: 'Non connecté'));
return Container(
padding: const EdgeInsets.fromLTRB(20, 18, 16, 18),
decoration: BoxDecoration(
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [EtmTokens.navy, EtmTokens.navy2],
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Logo + statut connexion ──────────────────────────────────────
Row(
children: [
Container(
width: 44, height: 44,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(13),
gradient: const LinearGradient(
colors: [EtmTokens.blue, Color(0xFF1F7FB3)],
),
),
child: const Icon(Icons.bolt, color: Colors.white, size: 24),
),
const SizedBox(width: 10),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'ETM PowerSync',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
fontSize: 15,
),
),
Text(
service.isSimulation
? 'Mode simulation'
: (service.host),
style: EtmTokens.sans(size: 12, color: const Color(0xFFA9C4D3)),
),
],
),
),
_ConnectionDot(
connected: service.connected,
simulation: service.isSimulation,
),
],
),
const SizedBox(height: 12),
// ── Nom de la box active (cliquable → Installations) ─────────────
// L'en-tête EST l'accès au gestionnaire multi-HEMS (pas d'entrée de
// menu dédiée) : tap → bascule d'installation.
GestureDetector(
onTap: () {
context.read<NavigationProvider>().closeDrawer();
context.go('/installations');
},
behavior: HitTestBehavior.opaque,
child: Row(
children: [
Flexible(
child: Text(
boxName,
overflow: TextOverflow.ellipsis,
style: EtmTokens.sans(
size: 15, weight: FontWeight.w600, color: Colors.white),
),
),
const SizedBox(width: 6),
const Icon(Icons.unfold_more_rounded,
size: 16, color: Color(0xFFA9C4D3)),
],
),
),
const SizedBox(height: 6),
// ── Utilisateur + badge rôle ─────────────────────────────────────
Row(
children: [
const Icon(Icons.person_outline, size: 16, color: Color(0xFFA9C4D3)),
const SizedBox(width: 6),
Text(
service.username.isNotEmpty ? service.username : 'Utilisateur',
style: EtmTokens.sans(size: 13, color: const Color(0xFFCFE6F3)),
),
const SizedBox(width: 8),
_RoleBadge(isInstaller: installer.isUnlocked),
],
),
],
),
);
}
}
class _ConnectionDot extends StatelessWidget {
final bool connected;
final bool simulation;
const _ConnectionDot(
{required this.connected, required this.simulation});
@override
Widget build(BuildContext context) {
final Color color = simulation
? EtmTokens.amber
: connected
? EtmTokens.green
: EtmTokens.danger;
return Container(
width: 9, height: 9,
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
);
}
}
class _RoleBadge extends StatelessWidget {
final bool isInstaller;
const _RoleBadge({required this.isInstaller});
@override
Widget build(BuildContext context) {
final color = isInstaller ? EtmTokens.orange : EtmTokens.blue;
return Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
decoration: BoxDecoration(
color: color.withValues(alpha: 0.18),
borderRadius: BorderRadius.circular(6),
border: Border.all(color: color.withValues(alpha: 0.5)),
),
child: Text(
isInstaller ? 'INSTALLATEUR' : 'UTILISATEUR',
style: EtmTokens.sans(size: 10, weight: FontWeight.w700, color: color, letterSpacing: 0.5),
),
);
}
}
// ── Menu du drawer ────────────────────────────────────────────────────────────
class _DrawerMenu extends StatelessWidget {
@override
Widget build(BuildContext context) {
final installer = context.watch<InstallerModeProvider>();
return ListView(
padding: const EdgeInsets.symmetric(vertical: 8),
children: [
// ── VUE PRINCIPALE ────────────────────────────────────────────────
_SectionLabel('VUE PRINCIPALE'),
_NavItem(icon: Icons.home_rounded, label: 'Dashboard', route: '/'),
_NavItem(icon: Icons.bar_chart_rounded, label: 'Énergie', route: '/energy'),
_NavItem(icon: Icons.device_hub_rounded, label: 'Things', route: '/things'),
_NavItem(icon: Icons.star_rounded, label: 'Favoris', route: '/favorites'),
_NavItem(icon: Icons.group_work_rounded, label: 'Groupes', route: '/groups'),
_NavItem(icon: Icons.auto_awesome_rounded, label: 'Scènes', route: '/scenes'),
_NavItem(icon: Icons.music_note_rounded, label: 'Médias', route: '/media'),
_NavItem(icon: Icons.garage_rounded, label: 'Garages', route: '/garages'),
_NavItem(icon: Icons.ac_unit_rounded, label: 'AC / Climatisation', route: '/ac'),
const SizedBox(height: 4),
_Divider(),
// ── CONFIGURATION ─────────────────────────────────────────────────
_SectionLabel('CONFIGURATION'),
_NavItem(icon: Icons.auto_fix_high_rounded, label: 'Magic / Automatisations', route: '/automations', push: true),
// Gestionnaire d'énergie (ExpansionTile)
_EnergyManagerExpansion(),
// App Settings (ExpansionTile)
_AppSettingsExpansion(),
const SizedBox(height: 4),
_Divider(),
// ── MODE INSTALLATEUR ─────────────────────────────────────────────
_InstallerSection(isUnlocked: installer.isUnlocked),
const SizedBox(height: 4),
_Divider(),
// ── SUPPORT ───────────────────────────────────────────────────────
_SectionLabel('SUPPORT'),
_LinkItem(
icon: Icons.menu_book_rounded,
label: 'Documentation',
url: 'https://etm.at/powersync/docs',
),
_LinkItem(
icon: Icons.telegram_rounded,
label: 'Telegram',
url: 'https://t.me/etm_support',
),
_LinkItem(
icon: Icons.discord_rounded,
label: 'Discord',
url: 'https://discord.gg/etm',
),
_NavItem(
icon: Icons.bug_report_rounded,
label: 'Rapport de bug',
route: '/bug-report',
),
const SizedBox(height: 16),
],
);
}
}
// ── Éléments de menu ──────────────────────────────────────────────────────────
class _SectionLabel extends StatelessWidget {
final String text;
const _SectionLabel(this.text);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.fromLTRB(20, 14, 20, 8),
child: Text(text, style: EtmTokens.sectionLabel()),
);
}
}
class _Divider extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Divider(
color: Colors.white.withValues(alpha: 0.08),
height: 16,
indent: 20,
endIndent: 20,
);
}
}
/// Item de navigation : ferme le drawer et navigue via GoRouter.
/// Utilise context.go() pour les routes dans le shell (bottom nav),
/// context.push() pour les écrans poussés par-dessus le shell.
class _NavItem extends StatelessWidget {
final IconData icon;
final String label;
final String route;
/// Si true, utilise context.push() (écrans hors shell).
final bool push;
const _NavItem({
required this.icon,
required this.label,
required this.route,
this.push = false,
});
@override
Widget build(BuildContext context) {
return _DrawerTile(
icon: icon,
label: label,
onTap: () {
context.read<NavigationProvider>().closeDrawer();
if (push) {
context.push(route);
} else {
context.go(route);
}
},
);
}
}
/// Item qui ouvre une URL dans le navigateur externe.
class _LinkItem extends StatelessWidget {
final IconData icon;
final String label;
final String url;
const _LinkItem({
required this.icon,
required this.label,
required this.url,
});
@override
Widget build(BuildContext context) {
return _DrawerTile(
icon: icon,
label: label,
trailing: const Icon(Icons.open_in_new_rounded,
size: 14, color: EtmTokens.faint),
onTap: () async {
context.read<NavigationProvider>().closeDrawer();
final uri = Uri.parse(url);
if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
if (context.mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('Impossible d\'ouvrir : $url')),
);
}
}
},
);
}
}
class _DrawerTile extends StatelessWidget {
final IconData icon;
final String label;
final Widget? trailing;
final VoidCallback onTap;
const _DrawerTile({
required this.icon,
required this.label,
required this.onTap,
this.trailing,
});
@override
Widget build(BuildContext context) {
return InkWell(
onTap: onTap,
borderRadius: BorderRadius.circular(10),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
children: [
Icon(icon, size: 20, color: Colors.white.withValues(alpha: 0.92)),
const SizedBox(width: 16),
Expanded(
child: Text(
label,
style: EtmTokens.sans(size: 14, color: Colors.white.withValues(alpha: 0.92)),
),
),
if (trailing != null) trailing!,
],
),
),
);
}
}
// ── ExpansionTile : Gestionnaire d'énergie ────────────────────────────────────
class _EnergyManagerExpansion extends StatelessWidget {
@override
Widget build(BuildContext context) {
return _StyledExpansion(
icon: Icons.energy_savings_leaf_rounded,
label: 'Gestionnaire d\'énergie',
children: [
_SubItem('Rôles & appareils', '/energy/setup'),
_SubItem('Scheduler & stratégie', '/energy/scheduler'),
_SubItem('Tarifs & providers', '/energy/tariffs'),
_SubItem('Timeline & décisions', '/energy/timeline'),
],
);
}
}
// ── ExpansionTile : App Settings ──────────────────────────────────────────────
class _AppSettingsExpansion extends StatelessWidget {
@override
Widget build(BuildContext context) {
return _StyledExpansion(
icon: Icons.settings_rounded,
label: 'App Settings',
children: [
_SubItem('Apparence', '/settings/app/appearance'),
_SubItem('Écrans actifs', '/settings/app/screens'),
_SubItem('Options développeur', '/settings/app/developer'),
_SubItem('À propos PowerSync', '/settings/app/about'),
],
);
}
}
class _StyledExpansion extends StatelessWidget {
final IconData icon;
final String label;
final List<Widget> children;
const _StyledExpansion({
required this.icon,
required this.label,
required this.children,
});
@override
Widget build(BuildContext context) {
return Theme(
data: Theme.of(context).copyWith(
dividerColor: Colors.transparent,
),
child: ExpansionTile(
leading: Icon(icon, size: 20, color: Colors.white.withValues(alpha: 0.92)),
title: Text(label, style: EtmTokens.sans(size: 14, color: Colors.white.withValues(alpha: 0.92))),
iconColor: EtmTokens.faint,
collapsedIconColor: EtmTokens.faint,
tilePadding: const EdgeInsets.symmetric(horizontal: 20),
childrenPadding: EdgeInsets.zero,
children: children,
),
);
}
}
class _SubItem extends StatelessWidget {
final String label;
final String route;
const _SubItem(this.label, this.route);
@override
Widget build(BuildContext context) {
return InkWell(
onTap: () {
context.read<NavigationProvider>().closeDrawer();
context.push(route);
},
child: Padding(
padding: const EdgeInsets.fromLTRB(56, 10, 16, 10),
child: Text(label, style: EtmTokens.sans(size: 13, color: EtmTokens.faint)),
),
);
}
}
// ── Section Mode Installateur ─────────────────────────────────────────────────
class _InstallerSection extends StatelessWidget {
final bool isUnlocked;
const _InstallerSection({required this.isUnlocked});
@override
Widget build(BuildContext context) {
if (!isUnlocked) {
// Bouton de déverrouillage
return Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: InkWell(
onTap: () => _promptPin(context),
child: Container(
margin: const EdgeInsets.fromLTRB(16, 4, 16, 4),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: EtmTokens.orange.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: EtmTokens.orange.withValues(alpha: 0.4)),
),
child: Row(
children: [
Icon(Icons.build_outlined, size: 18, color: EtmTokens.orange),
const SizedBox(width: 10),
Expanded(
child: Text('Mode installateur',
style: EtmTokens.sans(size: 14, weight: FontWeight.w600, color: EtmTokens.orange)),
),
Icon(Icons.lock_outline, size: 16, color: EtmTokens.orange),
],
),
),
),
);
}
// Section déverrouillée — menu installateur complet
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_SectionLabel('MODE INSTALLATEUR'),
_NavItem(
icon: Icons.tune_rounded,
label: 'Configuration Things',
route: '/settings/system/things',
push: true,
),
_NavItem(
icon: Icons.router_rounded,
label: 'Système & réseau',
route: '/settings/system/network',
push: true,
),
_NavItem(
icon: Icons.cable_rounded,
label: 'Protocoles',
route: '/settings/system/protocols',
push: true,
),
_NavItem(
icon: Icons.cloud_rounded,
label: 'MQTT / Web server',
route: '/settings/system/mqtt',
push: true,
),
_NavItem(
icon: Icons.extension_rounded,
label: 'Plugins',
route: '/settings/system/plugins',
push: true,
),
_NavItem(
icon: Icons.system_update_rounded,
label: 'Mise à jour système',
route: '/settings/system/update',
push: true,
),
_NavItem(
icon: Icons.code_rounded,
label: 'Outils développeur',
route: '/settings/system/devtools',
push: true,
),
_NavItem(
icon: Icons.info_outline_rounded,
label: 'À propos ETM',
route: '/settings/system/about',
push: true,
),
InkWell(
onTap: () => context.read<InstallerModeProvider>().lock(),
child: Container(
margin: const EdgeInsets.fromLTRB(16, 4, 16, 4),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 12),
decoration: BoxDecoration(
color: EtmTokens.danger.withValues(alpha: 0.12),
borderRadius: BorderRadius.circular(12),
border: Border.all(color: EtmTokens.danger.withValues(alpha: 0.4)),
),
child: Row(
children: [
Icon(Icons.lock_outline, size: 18, color: EtmTokens.danger),
const SizedBox(width: 10),
Text('Verrouiller mode installateur',
style: EtmTokens.sans(size: 14, weight: FontWeight.w600, color: EtmTokens.danger)),
],
),
),
),
],
);
}
Future<void> _promptPin(BuildContext context) async {
final result = await showDialog<bool>(
context: context,
barrierDismissible: false,
builder: (_) => const InstallerPinDialog(),
);
if (result == true && context.mounted) {
// Le provider a déjà été mis à jour — pas besoin d'action supplémentaire
}
}
}