Lot B côté app, partie NON bloquée par le lot plugin (voir §Limites).
§1 Locale (indépendant) — JSONRPC.Hello portait {} : l'app était en en_US de
fait alors que nymea gère la locale PAR CONNEXION (o:locale). Envoi de la
locale à chaque Hello, donc aussi après reconnexion subie (m_clientLocales
repart au défaut de la box à chaque socket). Locale demandée ET retenue
journalisées : un .qm introuvable rend exactement comme une absence de
traduction, sans cette trace un catalogue mal packagé passe inaperçu.
§2 i18n — flutter_localizations + intl + ARB (français seul). Aucune chaîne
en dur dans le nouvel écran. Les codes reçus de la box (domaine, mécanisme)
sont des clés ARB avec repli explicite sur code brut si l'app ne les connaît
pas : une box en avance sur l'app est un cas normal en parc déployé.
§3 Modèle — LoadConfigEntry est une VUE en lecture au-dessus de la map brute ;
l'écriture repart de la map d'origine (patched()). Pas de couche de filtrage
préventive : l'aller-retour neutre passe contre .75, donc l'écho verbatim est
sûr par construction (§7-1).
§4 Priorité à deux niveaux — groupByDomain / flattenToPayload. Le rang de
domaine se déduit de l'ordre d'apparition à plat ; la box ne sait rien des
groupes. Config non contiguë : signalée, ordre réel affiché, normalisation
seulement sur confirmation explicite. Miroir client de validateSet().
§5 Écritures asynchrones — LoadConfigProvider ne conclut jamais sur
l'acquittement du RPC : pending → LoadConfigChanged → confirmed|diverged.
NymeaEnergy.GetLoadConfig / SetLoadConfig deviennent RÉELS (ils étaient des
stubs journalisés). Conséquence traitée : EnergySetupProvider.persist()
n'émet plus setLoadConfig(buildLoadDescriptors()) — SetLoadConfig remplace
TOUT l'ensemble, et _commit() l'appelait à chaque changement d'UI : une
reconstruction depuis le modèle typé aurait écrasé la config réelle de la
box sans geste explicite.
Écran /energy/loads, gaté installateur (drawer + garde de routeur).
Limites — le lot plugin n'est PAS déployé sur .75 : GetLoadTelemetry,
LoadTelemetryChanged et o:domain sont absents (19 méthodes / 13 notifications
inchangées). Toute la moitié télémétrie du brief est donc hors d'atteinte, et
o:domain n'étant pas au schéma, la box rejetterait la clé (jsonvalidator.cpp:143).
Fixtures multi-domaines synthétiques : .75 n'a qu'une charge.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
670 lines
22 KiB
Dart
670 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/app_settings_provider.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)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// Route associée à chaque `AppScreen.id` de [AppSettingsProvider].
|
|
/// Un id absent de cette table n'est jamais affiché dans le drawer.
|
|
const Map<String, String> kScreenRoutes = {
|
|
'dashboard': '/',
|
|
'energy': '/energy',
|
|
'things': '/things',
|
|
'favorites': '/favorites',
|
|
'groups': '/groups',
|
|
'scenes': '/scenes',
|
|
'media': '/media',
|
|
'garages': '/garages',
|
|
'ac': '/ac',
|
|
};
|
|
|
|
/// 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>();
|
|
final settings = context.watch<AppSettingsProvider>();
|
|
|
|
return ListView(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
children: [
|
|
// ── VUE PRINCIPALE ────────────────────────────────────────────────
|
|
// Liste pilotée par « App Settings → Écrans actifs » : ordre et
|
|
// visibilité viennent de AppSettingsProvider. Les écrans smarthome
|
|
// (Groupes/Scènes/Médias/Garages) sont masqués par défaut — ce HEMS
|
|
// est un gestionnaire d'énergie.
|
|
_SectionLabel('VUE PRINCIPALE'),
|
|
for (final screen in settings.visibleScreens)
|
|
if (kScreenRoutes[screen.id] != null)
|
|
_NavItem(
|
|
icon: screen.icon,
|
|
label: screen.label,
|
|
route: kScreenRoutes[screen.id]!,
|
|
),
|
|
|
|
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://docs.etm-powersync.fr/',
|
|
),
|
|
_LinkItem(
|
|
icon: Icons.telegram_rounded,
|
|
label: 'Telegram',
|
|
url: 'https://t.me/etm_powersync_support',
|
|
),
|
|
// Discord désactivé — pas de serveur ETM-PowerSync pour l'instant.
|
|
// Réactiver ici dès que l'invite permanente existe :
|
|
// _LinkItem(icon: Icons.discord_rounded, label: 'Discord', url: '...'),
|
|
_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: [
|
|
// 'Rôles & appareils' a migré dans la section MODE INSTALLATEUR :
|
|
// c'est de la configuration d'installation, pas un réglage utilisateur.
|
|
_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'),
|
|
// 'Options développeur' a migré dans la section MODE INSTALLATEUR.
|
|
_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.account_tree_rounded,
|
|
label: 'Rôles & appareils',
|
|
route: '/energy/setup',
|
|
push: true,
|
|
),
|
|
_NavItem(
|
|
icon: Icons.low_priority_rounded,
|
|
label: 'Ordre de service des charges',
|
|
route: '/energy/loads',
|
|
push: true,
|
|
),
|
|
_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.developer_mode_rounded,
|
|
label: 'Options développeur',
|
|
route: '/settings/app/developer',
|
|
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
|
|
}
|
|
}
|
|
}
|