Patrick Schurig aada8e2ba5 fix(config): glissement, sélecteur de mécanisme, chip de domaine, thème — les 4 points ouverts
1) GLISSEMENT — diagnostic d'abord, et il a donné DEUX réponses.

   Le geste n'est PAS avalé par le ListView parent : les deux montages du test
   (section seule / section imbriquée) glissent identiquement. La cause du symptôme était
   bien que .75 n'a qu'une charge par domaine — rien à réordonner.

   Mais l'enquête a trouvé un vrai défaut au passage : le centre de la poignée tombait dans
   le vide de 3 px séparant l'icône de la pastille de rang, et ReorderableDragStartListener
   défère le test de collision à ses enfants. Une bande morte invisible au milieu d'une
   poignée qui paraît d'un seul tenant. Corrigé par un Container transparent.

   Tentative faite et ANNULÉE : rendre les domaines glissables aussi. Deux
   ReorderableListView imbriqués se disputent le geste et l'extérieur gagne — le
   glissement des cartes cassait. Les domaines gardent leurs flèches, et une charge seule
   dans son domaine n'offre plus de prise qui mènerait nulle part (avec l'infobulle qui
   renvoie au bon geste).

   Vérifié SUR L'APPAREIL, contre .75, avec les deux charges mises dans le même domaine :
   [chauffe-eau, pac-terrain] → [pac-terrain, chauffe-eau], brouillon abandonné sans rien
   écrire. Classement ecs/hvac rétabli ensuite.

2) SÉLECTEUR DE MÉCANISME — actif, et c'était le morceau délicat.

   LoadConfig est une union discriminée : écrire `adapter` sans nettoyer le reste produit
   une configuration que la box refuse EN BLOC, et le refus emporte toutes les charges de
   l'appel. D'où basculerMecanisme(), qui retire les clés du mécanisme quitté et pose
   celles du nouveau, en récupérant ce qui peut l'être — les deux premiers contacts
   deviennent SG1/SG2 dans l'encodage standard, les relais gardent leur puissance.

   Ce qui n'est PAS transposé : l'estimation d'un état SG-Ready n'est pas la puissance
   d'une résistance. La charge produite est alors incomplète, et c'est voulu — le
   formulaire réclame plutôt que d'inventer une valeur de plaque.

   La bascule franchit sameHardware() : confirmation préalable qui annonce la
   reconstruction et la durée de réarmement (900 s par défaut vers SG-Ready — la valeur
   d'une PAC réelle, pas celle du banc).

   Le mode fixed d'etmvariableload reste LISIBLE sans être proposé : bloc « Configuration
   héritée » en lecture seule, et conversion seulement sur geste explicite, avec le
   plafond que la déclaration en paliers ne porte pas. Ouvrir l'écran ne convertit personne.

3) CHIP DE DOMAINE — il ne rendait qu'une icône muette : le domaine d'une charge était
   illisible sur sa carte, alors qu'il décide de son groupe et de son rang. Il porte
   maintenant son libellé, et sa couleur suit le thème.

4) THÈME — les 11 derniers sites (pas 9) basculés. Trois cas : contexte disponible
   (main, ac_screen) ; peintre sans contexte, à qui la couleur est passée résolue
   (energy_flow_widget) ; tables statiques, où la teinte devient nullable et se résout au
   rendu (categoryInfoMap, favoris). Plus AUCUN jeton hors thème dans lib/.

5) ZONES — non entreprises, comme convenu.

Tests 93/93, analyze 0 erreur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-26 08:49:20 +02:00

611 lines
24 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'package:flutter/foundation.dart' show kIsWeb;
import 'providers/app_settings_provider.dart';
import 'providers/energy_setup_provider.dart';
import 'providers/installer_mode_provider.dart';
import 'providers/load_config_provider.dart';
import 'providers/load_telemetry_provider.dart';
import 'providers/navigation_provider.dart';
import 'providers/schema_provider.dart';
import 'providers/scheduler_provider.dart';
import 'providers/tariff_provider.dart';
import 'screens/ac_screen.dart';
import 'screens/dashboard_screen.dart';
import 'screens/drawer/main_drawer.dart';
import 'screens/energy/roles_devices_screen.dart';
import 'screens/energy/scheduler_screen.dart';
import 'screens/energy/tariff_screen.dart';
import 'screens/energy/timeline_screen.dart';
import 'screens/energy_screen.dart';
import 'screens/favorites_screen.dart';
import 'screens/installations/connection_screen.dart';
import 'screens/installations/installations_screen.dart';
import 'screens/settings/app_settings_screen.dart';
import 'screens/settings/protocols_screen.dart';
import 'screens/settings/thing_catalog_screen.dart';
import 'screens/settings/appearance_screen.dart';
import 'screens/settings/screens_settings_screen.dart';
import 'screens/things_screen.dart';
import 'services/connection_manager.dart';
import 'services/installation_store.dart';
import 'services/nymea_service.dart';
import 'l10n/app_localizations.dart';
import 'theme/app_theme.dart';
import 'theme/etm_tokens.dart';
// ─────────────────────────────────────────────────────────────────────────────
// Router GoRouter — gate de connexion (Étape 6)
// ─────────────────────────────────────────────────────────────────────────────
/// Routes qui exigent le mode installateur déverrouillé.
bool _isInstallerRoute(String loc) =>
loc.startsWith('/settings/system') ||
loc == '/settings/app/developer' ||
loc == '/energy/setup' ||
loc == '/energy/loads';
/// Construit le routeur avec le **gate de connexion**.
///
/// Condition = « pas de connexion active » ([ConnectionManager.hasActiveConnection],
/// qui inclut la démo). `refreshListenable` écoute le manager **et** le service :
/// une déconnexion subie (socket qui tombe → notif du service, pas du manager)
/// doit ré-évaluer le gate et renvoyer vers Installations.
GoRouter buildAppRouter(
ConnectionManager cm,
NymeaService svc,
InstallerModeProvider installer,
) => GoRouter(
initialLocation: '/',
refreshListenable: Listenable.merge([cm, svc, installer]),
redirect: (context, state) {
final loc = state.matchedLocation;
final onInstall =
loc == '/installations' || loc.startsWith('/installations/');
if (!cm.hasActiveConnection) {
// Pas de session (ni box réelle, ni démo) → Installations en racine.
return onInstall ? null : '/installations';
}
// Écrans réservés à l'installateur : masqués dans le drawer ET bloqués ici
// (accès direct par URL, deep-link, ou auto-lock pendant la navigation).
if (!installer.isUnlocked && _isInstallerRoute(loc)) return '/';
// Actif (ou démo) → on laisse tout ; Installations reste accessible (drawer).
return null;
},
routes: [
// ── Shell principal (bottom nav + drawer overlay) ─────────────────────
ShellRoute(
builder: (context, state, child) => MainShell(child: child),
routes: [
GoRoute(path: '/', builder: (c, s) => const DashboardScreen()),
GoRoute(path: '/energy', builder: (c, s) => const EnergyScreen()),
GoRoute(path: '/things', builder: (c, s) => const ThingsScreen()),
GoRoute(path: '/ac', builder: (c, s) => const ACScreen()),
GoRoute(path: '/favorites', builder: (c, s) => const FavoritesScreen()),
// Routes stubées — à implémenter plus tard
GoRoute(path: '/groups', builder: (c, s) => _StubScreen('Groupes')),
GoRoute(path: '/scenes', builder: (c, s) => _StubScreen('Scènes')),
GoRoute(path: '/media', builder: (c, s) => _StubScreen('Médias')),
GoRoute(path: '/garages', builder: (c, s) => _StubScreen('Garages')),
GoRoute(path: '/automations', builder: (c, s) => _StubScreen('Magic / Automatisations')),
GoRoute(path: '/bug-report', builder: (c, s) => _StubScreen('Rapport de bug')),
],
),
// ── Installations (multi-HEMS) — plein écran ──────────────────────────
GoRoute(
path: '/installations',
builder: (c, s) => const InstallationsScreen(),
),
GoRoute(
path: '/installations/connect',
builder: (c, s) => const ConnectionScreen(),
),
// ── Écrans énergie (push par-dessus le shell) ─────────────────────────
GoRoute(
path: '/energy/setup',
builder: (c, s) => const RolesDevicesScreen(),
),
// `/energy/loads` a été FUSIONNÉ dans `/energy/setup` : il n'existait qu'un seul
// objet à configurer, décrit par deux écrans. La route est conservée et redirige,
// pour ne pas casser les liens déjà posés ailleurs dans l'app.
GoRoute(
path: '/energy/loads',
redirect: (c, s) => '/energy/setup',
),
GoRoute(
path: '/energy/scheduler',
builder: (c, s) => const SchedulerScreen(),
),
GoRoute(
path: '/energy/tariffs',
builder: (c, s) => const TariffScreen(),
),
GoRoute(
path: '/energy/timeline',
builder: (c, s) => const TimelineScreen(),
),
// ── Écrans settings (push par-dessus le shell) ────────────────────────
GoRoute(
path: '/settings/app',
builder: (c, s) => const AppSettingsScreen(),
),
GoRoute(
path: '/settings/app/appearance',
builder: (c, s) => const AppearanceScreen(),
),
GoRoute(
path: '/settings/app/screens',
builder: (c, s) => const ScreensSettingsScreen(),
),
GoRoute(
path: '/settings/app/developer',
builder: (c, s) => _StubScreen('Options développeur'),
),
GoRoute(
path: '/settings/app/about',
builder: (c, s) => _StubScreen('À propos PowerSync'),
),
// Routes installateur stubées
GoRoute(path: '/settings/system', builder: (c, s) => _StubScreen('Configuration système')),
GoRoute(path: '/settings/system/things', builder: (c, s) => const ThingCatalogScreen()),
GoRoute(path: '/settings/system/network', builder: (c, s) => _StubScreen('Système & réseau')),
GoRoute(path: '/settings/system/protocols',builder: (c, s) => const ProtocolsScreen()),
GoRoute(path: '/settings/system/mqtt', builder: (c, s) => _StubScreen('MQTT / Web server')),
GoRoute(path: '/settings/system/plugins', builder: (c, s) => _StubScreen('Plugins')),
GoRoute(path: '/settings/system/update', builder: (c, s) => _StubScreen('Mise à jour système')),
GoRoute(path: '/settings/system/devtools', builder: (c, s) => _StubScreen('Outils développeur')),
GoRoute(path: '/settings/system/about', builder: (c, s) => _StubScreen('À propos ETM')),
],
);
// ─────────────────────────────────────────────────────────────────────────────
// main()
// ─────────────────────────────────────────────────────────────────────────────
void main() async {
WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
),
);
// Initialisation des providers qui ont besoin d'I/O asynchrone
final appSettings = AppSettingsProvider();
final installerMode = InstallerModeProvider();
// NymeaService partagé avec le ConnectionManager (même instance).
final nymeaService = NymeaService();
final connectionManager =
ConnectionManager(nymeaService, InstallationStore());
// Providers à état par-box : instanciés eager pour les vider au switch.
final energySetup = EnergySetupProvider();
final scheduler = SchedulerProvider();
final tariff = TariffProvider();
final loadConfig = LoadConfigProvider();
final loadTelemetry = LoadTelemetryProvider();
final rpcSchema = SchemaProvider();
connectionManager.registerCacheClear(loadConfig.clearForSwitch);
connectionManager.registerCacheClear(loadTelemetry.clearForSwitch);
connectionManager.registerCacheClear(rpcSchema.clearForSwitch);
// Le schéma de la box décide de ce que les écrans activent ou grisent — y compris la
// carte de recharge du tableau de bord, qui n'est pas sous l'écran de configuration.
// Il se lit donc au niveau de l'app, dès que la connexion est établie, et non dans
// l'initState d'un écran particulier : sinon un champ resterait gris jusqu'à ce que
// l'utilisateur ait ouvert la bonne page.
rpcSchema.attach(nymeaService);
nymeaService.addListener(() {
if (nymeaService.isConnected && !rpcSchema.schema.loaded && !rpcSchema.loading) {
rpcSchema.load();
}
});
connectionManager.registerCacheClear(energySetup.clearForSwitch);
connectionManager.registerCacheClear(scheduler.clearForSwitch);
connectionManager.registerCacheClear(tariff.clearForSwitch);
await Future.wait([
appSettings.load(),
installerMode.init(),
connectionManager.load(),
]);
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider.value(value: nymeaService),
ChangeNotifierProvider.value(value: connectionManager),
ChangeNotifierProvider(create: (_) => NavigationProvider()),
ChangeNotifierProvider.value(value: installerMode),
ChangeNotifierProvider.value(value: appSettings),
ChangeNotifierProvider.value(value: energySetup),
ChangeNotifierProvider.value(value: scheduler),
ChangeNotifierProvider.value(value: tariff),
ChangeNotifierProvider.value(value: loadConfig),
ChangeNotifierProvider.value(value: loadTelemetry),
ChangeNotifierProvider.value(value: rpcSchema),
],
child: NymeaEnergyApp(
router: buildAppRouter(connectionManager, nymeaService, installerMode),
),
),
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Application root
// ─────────────────────────────────────────────────────────────────────────────
class NymeaEnergyApp extends StatelessWidget {
const NymeaEnergyApp({super.key, required this.router});
final GoRouter router;
@override
Widget build(BuildContext context) {
final settings = context.watch<AppSettingsProvider>();
return MaterialApp.router(
title: 'ETM PowerSync',
debugShowCheckedModeBanner: false,
theme: AppTheme.theme,
darkTheme: AppTheme.darkTheme,
themeMode: settings.themeMode,
// i18n — français seul pour l'instant. L'ossature ARB est en place pour que
// l'ajout d'une langue soit un fichier à remplir, pas une extraction
// rétroactive de chaînes noyées dans les widgets.
localizationsDelegates: L10n.localizationsDelegates,
supportedLocales: L10n.supportedLocales,
locale: const Locale('fr'),
routerConfig: router,
builder: (context, child) {
// Sur mobile natif, on rend tel quel
if (!kIsWeb) return child!;
// Sur web : si la fenêtre est déjà étroite (smartphone qui visite la PWA),
// on rend plein écran. Sur desktop, on encadre dans un viewport "mobile".
final width = MediaQuery.sizeOf(context).width;
if (width < 600) return child!;
return Container(
color: const Color(0xFF0a1f2b), // navy ETM en arrière-plan
alignment: Alignment.center,
child: Container(
constraints: const BoxConstraints(maxWidth: 440, maxHeight: 900),
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(24),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.5),
blurRadius: 40,
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(24),
child: child,
),
),
);
},
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// MainShell — Scaffold principal avec bottom nav + drawer overlay
// ─────────────────────────────────────────────────────────────────────────────
class MainShell extends StatefulWidget {
final Widget child;
const MainShell({super.key, required this.child});
@override
State<MainShell> createState() => _MainShellState();
}
class _MainShellState extends State<MainShell>
with SingleTickerProviderStateMixin {
late AnimationController _animCtrl;
late Animation<double> _slideAnim;
late Animation<double> _overlayAnim;
// Mapping route → index onglet
static const _routeToTab = {
'/': 0,
'/energy': 1,
'/things': 2,
'/ac': 3,
'/favorites': 4,
};
static const _tabToRoute = ['/', '/energy', '/things', '/ac', '/favorites'];
static const _navItems = [
_NavItem(icon: Icons.home_rounded, label: 'Dashboard'),
_NavItem(icon: Icons.bar_chart_rounded, label: 'Énergie'),
_NavItem(icon: Icons.device_hub_rounded, label: 'Things'),
_NavItem(icon: Icons.ac_unit_rounded, label: 'A/C'),
_NavItem(icon: Icons.star_rounded, label: 'Favoris'),
];
int _currentTab = 0;
// Listener GoRouter pour syncer _currentTab sur les navigations du drawer
late final GoRouter _goRouter;
late final NavigationProvider _navProvider;
@override
void initState() {
super.initState();
_animCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 260),
);
_slideAnim = CurvedAnimation(parent: _animCtrl, curve: Curves.easeOutCubic);
_overlayAnim = CurvedAnimation(parent: _animCtrl, curve: Curves.easeOut);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Récupère GoRouter et NavigationProvider une seule fois
_goRouter = GoRouter.of(context);
_navProvider = context.read<NavigationProvider>();
// Listener GoRouter → sync onglet actif (navigation depuis le drawer)
_goRouter.routerDelegate.addListener(_syncTabFromRoute);
// Listener NavigationProvider → animation drawer UNIQUEMENT
_navProvider.addListener(_onDrawerStateChanged);
}
@override
void dispose() {
_goRouter.routerDelegate.removeListener(_syncTabFromRoute);
_navProvider.removeListener(_onDrawerStateChanged);
_animCtrl.dispose();
super.dispose();
}
/// Détecte le changement de route GoRouter et met à jour l'onglet actif.
void _syncTabFromRoute() {
if (!mounted) return;
final path = _goRouter.routerDelegate.currentConfiguration.uri.path;
final tab = _routeToTab[path] ?? _currentTab;
if (tab != _currentTab) {
setState(() => _currentTab = tab);
}
}
/// Déclenche l'animation du drawer. NE touche pas à la navigation.
void _onDrawerStateChanged() {
if (!mounted) return;
if (_navProvider.isDrawerOpen) {
_animCtrl.forward();
} else {
_animCtrl.reverse();
}
}
void _onTabTap(int index) {
if (index == _currentTab) return;
setState(() => _currentTab = index);
context.go(_tabToRoute[index]);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
// ── Contenu principal + bottom nav ───────────────────────────────
Column(
children: [
Expanded(child: widget.child),
_BottomNav(
currentIndex: _currentTab,
items: _navItems,
onTap: _onTabTap,
),
],
),
// ── Overlay sombre ────────────────────────────────────────────────
AnimatedBuilder(
animation: _overlayAnim,
builder: (context, _) {
if (_overlayAnim.value == 0) return const SizedBox.shrink();
return Opacity(
opacity: _overlayAnim.value,
child: const DrawerScrim(),
);
},
),
// ── Drawer panneau ────────────────────────────────────────────────
AnimatedBuilder(
animation: _slideAnim,
builder: (context, _) {
final offset = 320.0 * (_slideAnim.value - 1);
return Transform.translate(
offset: Offset(offset, 0),
child: const Align(
alignment: Alignment.centerLeft,
child: DrawerPanel(),
),
);
},
),
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Bottom navigation bar
// ─────────────────────────────────────────────────────────────────────────────
class _BottomNav extends StatelessWidget {
final int currentIndex;
final List<_NavItem> items;
final ValueChanged<int> onTap;
const _BottomNav({
required this.currentIndex,
required this.items,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 12,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
child: SizedBox(
height: 62,
child: Row(
children: items.asMap().entries.map((e) {
final i = e.key;
final item = e.value;
final selected = currentIndex == i;
return Expanded(
child: GestureDetector(
onTap: () => onTap(i),
behavior: HitTestBehavior.opaque,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 4),
decoration: BoxDecoration(
color: selected
? EtmTokens.brand.withValues(alpha: 0.12)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
item.icon,
color: selected ? EtmTokens.brand : EtmTokens.mutedOf(context),
size: 22,
),
),
const SizedBox(height: 2),
Text(
item.label,
style: EtmTokens.sans(
size: 10,
color: selected ? EtmTokens.brand : EtmTokens.mutedOf(context),
weight: selected ? FontWeight.w600 : FontWeight.w400,
),
),
],
),
),
);
}).toList(),
),
),
),
);
}
}
class _NavItem {
final IconData icon;
final String label;
const _NavItem({required this.icon, required this.label});
}
// ─────────────────────────────────────────────────────────────────────────────
// Widget bouton d'ouverture du drawer (à placer dans l'AppBar de chaque écran)
// ─────────────────────────────────────────────────────────────────────────────
/// Bouton hamburger/logo ETM qui ouvre le drawer depuis n'importe quel écran.
class DrawerMenuButton extends StatelessWidget {
const DrawerMenuButton({super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 12),
child: GestureDetector(
onTap: () => context.read<NavigationProvider>().openDrawer(),
child: Container(
width: 38, height: 38,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(12),
gradient: const LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [EtmTokens.green, EtmTokens.greenDark],
),
boxShadow: [
BoxShadow(
color: EtmTokens.green.withValues(alpha: 0.35),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
),
child: const Icon(Icons.bolt, color: Colors.white, size: 22),
),
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Écran stub (pour les routes non encore implémentées)
// ─────────────────────────────────────────────────────────────────────────────
class _StubScreen extends StatelessWidget {
final String title;
const _StubScreen(this.title);
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(title),
backgroundColor: Colors.white,
foregroundColor: const Color(0xFF1A1A2E),
elevation: 0,
),
backgroundColor: const Color(0xFFF0F2F5),
body: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(Icons.construction_rounded,
size: 48, color: Colors.grey.shade400),
const SizedBox(height: 16),
Text(
title,
style: const TextStyle(
fontSize: 18, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const Text(
'Cet écran sera disponible prochainement.',
style: TextStyle(color: Color(0xFF6B7280)),
),
],
),
),
);
}
}