Patrick Schurig ETM-Schurig d0a475a5d9 feat: refonte UI complète — design system EtmTokens + 4 écrans
Design system
- lib/theme/etm_tokens.dart : source de vérité couleurs + typo (IBM Plex Sans/Mono)
- lib/models/nymea_user.dart : modèle utilisateur nymea avec permissions EtmRole
- app_theme.dart : ThemeData migré vers IBM Plex Sans + couleurs EtmTokens

Navigation & drawer
- DrawerMenuButton : logo vert gradient + ombre
- Bottom nav : EtmTokens.green actif, EtmTokens.muted inactif
- DrawerPanel 320 px, restyled navy + gradient header + badges rôle

Dashboard (01_dashboard.html)
- Hero système : status pill + 3 métriques mono + illustration maison CustomPainter
- EnergyFlowWidget : 4 nœuds animés CustomPainter (flèches directionnelles)
  · gridPower > 0 = soutirage → flèche Grid→Home (amber)
  · gridPower < 0 = injection → flèche Home→Grid (bleu)
- EVChargingCard restyled : badge En charge + puissance mono 38px + 3 modes + SOC bar
- KPI 2×2 : spark bars, trend line, progress bar
- Consommateurs principaux + Décisions d'Héos (chips motifs)
- Prévisions placeholder explicite

Énergie
- KPI 2×2 avec icônes + fond soft + IBM Plex Mono
- Sélecteur période vert pill
- LineChart double axe : kW (gauche) / SOC % (droite, normalisé)
- BarChart bilan énergétique Wh (amber/bleu)
- Section Météo & prévision placeholder

Things
- Grille 2 col à hauteur intrinsèque (pas de childAspectRatio)
- Bandeau statut global (simulation / connecté / hors-ligne)
- _CategoryCard : header icon+label+count, séparateur coloré, liste tous items
- thing_category.dart : couleurs migrées vers EtmTokens

A/C — Climatisation / Chauffage
- Thermostats pièces EN HAUT : actives expandées, éteintes compactes
- Températures actuelle → cible ± avec EtmTokens.mono
- Sélecteur mode 4 boutons (Chauf/Clim/Auto/Vent)
- Chip "Chauffe au solaire en ce moment" (Héos)
- Sources pilotées par Héos EN BAS :
  · PAC SG-Ready : 4 états (Bloqué grisé / Normal / Recommandé / Forcé) + toggle Auto
  · Chauffe-eau : Surplus/Éco/Boost + temp eau 52°→60°C
  · Climatiseur : pré-refroidissement anticipé + info solaire

Packages ajoutés : google_fonts, flutter_staggered_grid_view, flutter_secure_storage
Asset : assets/house.svg (illustration maison CustomPainter)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-29 21:51:51 +02:00

605 lines
21 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/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>();
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 du site ──────────────────────────────────────────────────
Text(
service.isSimulation ? 'Site démo' : 'Mon installation',
style: EtmTokens.sans(size: 15, weight: FontWeight.w600, color: Colors.white),
),
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
}
}
}