Patrick Schurig f33b8c243a feat(support): le rapport de bug pré-rempli, repoussé depuis le premier jour
/bug-report était un stub. Un champ libre seul produit « ça ne marche pas », et l'aller-retour
pour obtenir versions, charges et état d'arbitrage coûte des jours. Tout ce que l'app SAIT
est donc joint d'office : version du paquet, hôte et état de connexion, ligne de versions du
schéma, les charges déclarées avec adaptateur / rang / domaine, et le dernier cycle publié.

Deux choix qui ne sont pas cosmétiques :

- LES DEUX IDENTITÉS DU BUDGET sont dans le rapport, avec leur verdict. Elles valent souvent
  plus que la description du symptôme : portées fausses, elles désignent le moteur ; portées
  vraies, elles désignent l'écran. Sans elles, le premier échange consiste à les demander.

- Aucun secret n'y entre : ni PIN installateur même haché, ni jeton, ni mot de passe. Un
  rapport voyage — courriel, capture, fil de discussion — et ce qui y entre en sort. Les
  UUID de Things restent : ce sont eux qui permettent de recouper avec le journal de la box,
  et ils n'ont aucune valeur hors de l'installation.

Les absences gardent leur sens, comme partout ailleurs : « aucune télémétrie reçue » n'est
pas un arbitrage à zéro, « budget ABSENT » n'est pas un budget de zéro watt, et un
financement omis se dit omis.

Pas d'envoi automatique : il n'existe aucun service de collecte, et un bouton « Envoyer »
sans destinataire donnerait le sentiment que quelqu'un l'a reçu. Le rapport se copie, ce qui
est vérifiable.

package_info_plus est ajouté plutôt qu'une constante de version recopiée à la main : une
constante dérive de pubspec.yaml en silence, et un rapport qui annonce la mauvaise version
envoie chercher un défaut dans un code qui n'est pas celui-là.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
2026-08-27 14:54:27 +02:00

612 lines
25 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/bug_report_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) => const BugReportScreen()),
],
),
// ── 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)),
),
],
),
),
);
}
}