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>
605 lines
21 KiB
Dart
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
|
|
}
|
|
}
|
|
}
|