etm-powersync-app/lib/screens/drawer/main_drawer.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

660 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 '../../providers/app_settings_provider.dart';
import '../../services/nymea_service.dart';
import '../../theme/app_theme.dart';
import '../../theme/etm_theme.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: ETMTheme.drawerWidth,
child: Material(
color: ETMTheme.drawerBackground,
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>();
return Container(
padding: const EdgeInsets.fromLTRB(16, 16, 16, 12),
decoration: BoxDecoration(
color: ETMTheme.drawerSurface,
border: Border(
bottom: BorderSide(
color: Colors.white.withValues(alpha: 0.08),
),
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ── Logo + statut connexion ──────────────────────────────────────
Row(
children: [
Container(
width: 44, height: 44,
decoration: BoxDecoration(
color: ETMTheme.accentColor.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(12),
),
child: const Icon(
Icons.bolt_rounded,
color: Colors.white,
size: 26,
),
),
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: TextStyle(
color: ETMTheme.drawerTextMuted,
fontSize: 11,
),
),
],
),
),
// Indicateur de connexion
_ConnectionDot(
connected: service.connected,
simulation: service.isSimulation,
),
],
),
const SizedBox(height: 12),
// ── Nom du site ──────────────────────────────────────────────────
Text(
service.isSimulation ? 'Site démo' : 'Mon installation',
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w600,
),
),
const SizedBox(height: 6),
// ── Utilisateur + badge rôle ─────────────────────────────────────
Row(
children: [
Icon(Icons.person_outline_rounded,
size: 14, color: ETMTheme.drawerTextMuted),
const SizedBox(width: 5),
Text(
service.username.isNotEmpty ? service.username : 'Utilisateur',
style: TextStyle(
color: ETMTheme.drawerTextMuted,
fontSize: 12,
),
),
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
? Colors.orange
: connected
? AppTheme.primaryGreen
: AppTheme.boostRed;
return Container(
width: 10, height: 10,
decoration: BoxDecoration(shape: BoxShape.circle, color: color),
);
}
}
class _RoleBadge extends StatelessWidget {
final bool isInstaller;
const _RoleBadge({required this.isInstaller});
@override
Widget build(BuildContext context) {
return Container(
padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2),
decoration: BoxDecoration(
color: isInstaller
? ETMTheme.installerBadgeColor.withValues(alpha: 0.2)
: ETMTheme.accentColor.withValues(alpha: 0.2),
borderRadius: BorderRadius.circular(6),
),
child: Text(
isInstaller ? 'INSTALLATEUR' : 'UTILISATEUR',
style: TextStyle(
fontSize: 9,
fontWeight: FontWeight.bold,
letterSpacing: 0.5,
color: isInstaller
? ETMTheme.installerBadgeColor
: ETMTheme.accentColor,
),
),
);
}
}
// ── 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(16, 12, 16, 4),
child: Text(
text,
style: TextStyle(
fontSize: 10,
fontWeight: FontWeight.bold,
letterSpacing: 1.2,
color: ETMTheme.drawerTextMuted,
),
),
);
}
}
class _Divider extends StatelessWidget {
@override
Widget build(BuildContext context) {
return Divider(
color: Colors.white.withValues(alpha: 0.08),
height: 1,
indent: 16,
endIndent: 16,
);
}
}
/// 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: Icon(Icons.open_in_new_rounded,
size: 14, color: ETMTheme.drawerTextMuted),
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: 12, vertical: 4),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
),
child: Row(
children: [
Icon(icon, size: 19, color: ETMTheme.drawerTextPrimary),
const SizedBox(width: 12),
Expanded(
child: Text(
label,
style: TextStyle(
color: ETMTheme.drawerTextPrimary,
fontSize: 13.5,
),
),
),
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: 19, color: ETMTheme.drawerTextPrimary),
title: Text(
label,
style: TextStyle(
color: ETMTheme.drawerTextPrimary,
fontSize: 13.5,
),
),
iconColor: ETMTheme.drawerTextMuted,
collapsedIconColor: ETMTheme.drawerTextMuted,
tilePadding: const EdgeInsets.symmetric(horizontal: 22),
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(54, 9, 16, 9),
child: Text(
label,
style: TextStyle(
color: ETMTheme.drawerTextMuted,
fontSize: 13,
),
),
),
);
}
}
// ── 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),
borderRadius: BorderRadius.circular(10),
child: Container(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(10),
border: Border.all(
color: ETMTheme.installerBadgeColor.withValues(alpha: 0.3),
),
),
child: Row(
children: [
Icon(Icons.build_rounded,
size: 19, color: ETMTheme.installerBadgeColor),
const SizedBox(width: 12),
Expanded(
child: Text(
'🔧 Mode Installateur',
style: TextStyle(
color: ETMTheme.installerBadgeColor,
fontSize: 13.5,
fontWeight: FontWeight.w600,
),
),
),
Icon(Icons.lock_rounded,
size: 14, color: ETMTheme.drawerTextMuted),
],
),
),
),
);
}
// 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,
),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
child: TextButton.icon(
style: TextButton.styleFrom(
foregroundColor: AppTheme.boostRed,
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8),
),
icon: const Icon(Icons.lock_open_rounded, size: 16),
label: const Text('Verrouiller mode installateur'),
onPressed: () =>
context.read<InstallerModeProvider>().lock(),
),
),
],
);
}
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
}
}
}