From c19c9d1a98d28aa4aebaea0d49817766a0289a72 Mon Sep 17 00:00:00 2001 From: pakutz79 Date: Tue, 24 Feb 2026 14:52:32 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20navigation=20drawer,=20EMS=20setup,=20s?= =?UTF-8?q?cheduler,=20tarifs,=20param=C3=A8tres=20app?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Drawer custom (overlay Stack) avec mode installateur PIN SHA-256 - GoRouter + ShellRoute : navigation préservée entre onglets - 6 providers : NavigationProvider, InstallerModeProvider, AppSettingsProvider, EnergySetupProvider, SchedulerProvider, TariffProvider - Écrans Energy Manager : RoleConfigFlow (3 étapes), Scheduler, Tarifs, Timeline - Écrans Paramètres : Apparence, Écrans actifs, AppSettingsScreen - DrawerMenuButton présent dans les 5 AppBars principaux - Simulation : _thingClasses générées avec interfaces EMS pour filtrage des rôles - Compteur solaire : ajout smartmeter aux interfaces compatibles - Thème ETM (etm_theme.dart), ProLockBadge, widgets PowerBar/RoleCard/TimelineSlotCard - Dépendances : go_router, shared_preferences, crypto, url_launcher Co-Authored-By: Claude Sonnet 4.6 --- lib/main.dart | 396 ++++++++-- lib/providers/app_settings_provider.dart | 173 +++++ lib/providers/energy_setup_provider.dart | 171 +++++ lib/providers/installer_mode_provider.dart | 113 +++ lib/providers/navigation_provider.dart | 23 + lib/providers/scheduler_provider.dart | 111 +++ lib/providers/tariff_provider.dart | 133 ++++ lib/screens/ac_screen.dart | 3 + lib/screens/dashboard_screen.dart | 31 +- lib/screens/drawer/installer_pin_dialog.dart | 265 +++++++ lib/screens/drawer/main_drawer.dart | 659 +++++++++++++++++ lib/screens/energy/energy_setup_screen.dart | 172 +++++ lib/screens/energy/role_config_flow.dart | 688 ++++++++++++++++++ lib/screens/energy/scheduler_screen.dart | 400 ++++++++++ lib/screens/energy/tariff_screen.dart | 537 ++++++++++++++ lib/screens/energy/timeline_screen.dart | 432 +++++++++++ lib/screens/energy_screen.dart | 191 ++++- lib/screens/favorites_screen.dart | 3 + lib/screens/settings/app_settings_screen.dart | 168 +++++ lib/screens/settings/appearance_screen.dart | 233 ++++++ .../settings/screens_settings_screen.dart | 129 ++++ lib/screens/things_screen.dart | 3 + lib/services/nymea_service.dart | 159 +++- lib/theme/etm_theme.dart | 141 ++++ lib/widgets/energy_flow_widget.dart | 27 +- lib/widgets/power_bar.dart | 83 +++ lib/widgets/pro_lock_badge.dart | 93 +++ lib/widgets/role_card.dart | 179 +++++ lib/widgets/timeline_slot_card.dart | 309 ++++++++ linux/flutter/generated_plugin_registrant.cc | 4 + linux/flutter/generated_plugins.cmake | 1 + pubspec.lock | 84 ++- pubspec.yaml | 9 +- 33 files changed, 6005 insertions(+), 118 deletions(-) create mode 100644 lib/providers/app_settings_provider.dart create mode 100644 lib/providers/energy_setup_provider.dart create mode 100644 lib/providers/installer_mode_provider.dart create mode 100644 lib/providers/navigation_provider.dart create mode 100644 lib/providers/scheduler_provider.dart create mode 100644 lib/providers/tariff_provider.dart create mode 100644 lib/screens/drawer/installer_pin_dialog.dart create mode 100644 lib/screens/drawer/main_drawer.dart create mode 100644 lib/screens/energy/energy_setup_screen.dart create mode 100644 lib/screens/energy/role_config_flow.dart create mode 100644 lib/screens/energy/scheduler_screen.dart create mode 100644 lib/screens/energy/tariff_screen.dart create mode 100644 lib/screens/energy/timeline_screen.dart create mode 100644 lib/screens/settings/app_settings_screen.dart create mode 100644 lib/screens/settings/appearance_screen.dart create mode 100644 lib/screens/settings/screens_settings_screen.dart create mode 100644 lib/theme/etm_theme.dart create mode 100644 lib/widgets/power_bar.dart create mode 100644 lib/widgets/pro_lock_badge.dart create mode 100644 lib/widgets/role_card.dart create mode 100644 lib/widgets/timeline_slot_card.dart diff --git a/lib/main.dart b/lib/main.dart index 5154688..934fb2b 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,16 +1,114 @@ import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; +import 'package:go_router/go_router.dart'; import 'package:provider/provider.dart'; -import 'services/nymea_service.dart'; -import 'screens/dashboard_screen.dart'; -import 'screens/energy_screen.dart'; -import 'screens/things_screen.dart'; +import 'providers/app_settings_provider.dart'; +import 'providers/energy_setup_provider.dart'; +import 'providers/installer_mode_provider.dart'; +import 'providers/navigation_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/energy_setup_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/settings/app_settings_screen.dart'; +import 'screens/settings/appearance_screen.dart'; +import 'screens/settings/screens_settings_screen.dart'; +import 'screens/things_screen.dart'; +import 'services/nymea_service.dart'; import 'theme/app_theme.dart'; +import 'theme/etm_theme.dart'; -void main() { +// ───────────────────────────────────────────────────────────────────────────── +// Router GoRouter +// ───────────────────────────────────────────────────────────────────────────── + +final _router = GoRouter( + initialLocation: '/', + 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')), + ], + ), + + // ── Écrans énergie (push par-dessus le shell) ───────────────────────── + GoRoute( + path: '/energy/setup', + builder: (c, s) => const EnergySetupScreen(), + ), + 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) => _StubScreen('Configuration Things')), + GoRoute(path: '/settings/system/network', builder: (c, s) => _StubScreen('Système & réseau')), + GoRoute(path: '/settings/system/protocols',builder: (c, s) => _StubScreen('Protocoles')), + 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( @@ -20,83 +118,219 @@ void main() { ), ); + // Initialisation des providers qui ont besoin d'I/O asynchrone + final appSettings = AppSettingsProvider(); + final installerMode = InstallerModeProvider(); + await Future.wait([ + appSettings.load(), + installerMode.init(), + ]); + runApp( - ChangeNotifierProvider( - create: (_) => NymeaService(), + MultiProvider( + providers: [ + ChangeNotifierProvider(create: (_) => NymeaService()), + ChangeNotifierProvider(create: (_) => NavigationProvider()), + ChangeNotifierProvider.value(value: installerMode), + ChangeNotifierProvider.value(value: appSettings), + ChangeNotifierProvider(create: (_) => EnergySetupProvider()), + ChangeNotifierProvider(create: (_) => SchedulerProvider()), + ChangeNotifierProvider(create: (_) => TariffProvider()), + ], child: const NymeaEnergyApp(), ), ); } +// ───────────────────────────────────────────────────────────────────────────── +// Application root +// ───────────────────────────────────────────────────────────────────────────── + class NymeaEnergyApp extends StatelessWidget { const NymeaEnergyApp({super.key}); @override Widget build(BuildContext context) { - return MaterialApp( - title: 'Nymea Energy', + return MaterialApp.router( + title: 'ETM PowerSync', debugShowCheckedModeBanner: false, theme: AppTheme.theme, - home: const MainShell(), + routerConfig: _router, ); } } +// ───────────────────────────────────────────────────────────────────────────── +// MainShell — Scaffold principal avec bottom nav + drawer overlay +// ───────────────────────────────────────────────────────────────────────────── + class MainShell extends StatefulWidget { - const MainShell({super.key}); + final Widget child; + const MainShell({super.key, required this.child}); @override State createState() => _MainShellState(); } -class _MainShellState extends State { - int _currentIndex = 0; +class _MainShellState extends State + with SingleTickerProviderStateMixin { + late AnimationController _animCtrl; + late Animation _slideAnim; + late Animation _overlayAnim; - final List _screens = const [ - DashboardScreen(), - EnergyScreen(), - ThingsScreen(), - ACScreen(), - FavoritesScreen(), + // 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(); + // 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: IndexedStack( - index: _currentIndex, - children: _screens, - ), - bottomNavigationBar: _BottomNav( - currentIndex: _currentIndex, - onTap: (i) => setState(() => _currentIndex = i), + 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 = ETMTheme.drawerWidth * (_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 int currentIndex; + final List<_NavItem> items; final ValueChanged onTap; - const _BottomNav({required this.currentIndex, required this.onTap}); + const _BottomNav({ + required this.currentIndex, + required this.items, + required this.onTap, + }); @override Widget build(BuildContext context) { - final items = [ - _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'), - ]; - return Container( decoration: BoxDecoration( color: Colors.white, boxShadow: [ BoxShadow( - color: Colors.black.withValues(alpha:0.08), + color: Colors.black.withValues(alpha: 0.08), blurRadius: 12, offset: const Offset(0, -2), ), @@ -107,9 +341,9 @@ class _BottomNav extends StatelessWidget { height: 62, child: Row( children: items.asMap().entries.map((e) { - final i = e.key; - final item = e.value; - final isSelected = currentIndex == i; + final i = e.key; + final item = e.value; + final selected = currentIndex == i; return Expanded( child: GestureDetector( @@ -123,14 +357,15 @@ class _BottomNav extends StatelessWidget { padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 4), decoration: BoxDecoration( - color: isSelected - ? AppTheme.primaryGreen.withValues(alpha:0.12) + color: selected + ? AppTheme.primaryGreen + .withValues(alpha: 0.12) : Colors.transparent, borderRadius: BorderRadius.circular(12), ), child: Icon( item.icon, - color: isSelected + color: selected ? AppTheme.primaryGreen : AppTheme.textLight, size: 22, @@ -141,10 +376,10 @@ class _BottomNav extends StatelessWidget { item.label, style: TextStyle( fontSize: 10, - color: isSelected + color: selected ? AppTheme.primaryGreen : AppTheme.textLight, - fontWeight: isSelected + fontWeight: selected ? FontWeight.bold : FontWeight.normal, ), @@ -163,6 +398,75 @@ class _BottomNav extends StatelessWidget { class _NavItem { final IconData icon; - final String label; + 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: 8), + child: GestureDetector( + onTap: () => context.read().openDrawer(), + child: Container( + width: 36, height: 36, + decoration: BoxDecoration( + color: AppTheme.primaryGreen, + borderRadius: BorderRadius.circular(10), + ), + 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)), + ), + ], + ), + ), + ); + } +} diff --git a/lib/providers/app_settings_provider.dart b/lib/providers/app_settings_provider.dart new file mode 100644 index 0000000..6a67a53 --- /dev/null +++ b/lib/providers/app_settings_provider.dart @@ -0,0 +1,173 @@ +import 'dart:convert'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Décrit un onglet du menu principal (bottom nav). +class AppScreen { + final String id; + final String label; + final IconData icon; + bool visible; + + AppScreen({ + required this.id, + required this.label, + required this.icon, + this.visible = true, + }); + + AppScreen copyWith({bool? visible}) => AppScreen( + id: id, label: label, icon: icon, + visible: visible ?? this.visible, + ); + + Map toJson() => {'id': id, 'visible': visible}; +} + +/// Gère les préférences d'apparence et les écrans visibles. +class AppSettingsProvider extends ChangeNotifier { + static const _themeKey = 'app_theme_mode'; + static const _accentKey = 'app_accent_color'; + static const _textScaleKey = 'app_text_scale'; + static const _screensKey = 'app_screens_config'; + static const _densityKey = 'app_ui_density'; + + ThemeMode _themeMode = ThemeMode.system; + int _accentIndex = 0; // 0=ETM blue, 1=vert, 2=orange, 3=violet + double _textScale = 1.0; + VisualDensity _density = VisualDensity.standard; + + // Ordre et visibilité des écrans principaux + List _screens = [ + AppScreen(id: 'dashboard', label: 'Dashboard', icon: Icons.home_rounded), + AppScreen(id: 'energy', label: 'Énergie', icon: Icons.bar_chart_rounded), + AppScreen(id: 'things', label: 'Things', icon: Icons.device_hub_rounded), + AppScreen(id: 'favorites', label: 'Favoris', icon: Icons.star_rounded), + AppScreen(id: 'groups', label: 'Groupes', icon: Icons.group_work_rounded, visible: false), + AppScreen(id: 'scenes', label: 'Scènes', icon: Icons.auto_awesome_rounded, visible: false), + AppScreen(id: 'media', label: 'Médias', icon: Icons.music_note_rounded, visible: false), + AppScreen(id: 'garages', label: 'Garages', icon: Icons.garage_rounded, visible: false), + AppScreen(id: 'ac', label: 'AC / Climatisation', icon: Icons.ac_unit_rounded), + ]; + + ThemeMode get themeMode => _themeMode; + int get accentIndex => _accentIndex; + double get textScale => _textScale; + VisualDensity get density => _density; + List get screens => _screens; + List get visibleScreens => + _screens.where((s) => s.visible).toList(); + + static const List accentColors = [ + Color(0xFF2E75B6), // ETM blue + Color(0xFF4CAF50), // vert + Color(0xFFFF7043), // orange + Color(0xFF7B1FA2), // violet + ]; + + Color get accentColor => accentColors[_accentIndex.clamp(0, 3)]; + + // ── Chargement depuis SharedPreferences ───────────────────────────────────── + Future load() async { + final prefs = await SharedPreferences.getInstance(); + + final themeVal = prefs.getInt(_themeKey) ?? 0; + _themeMode = ThemeMode.values[themeVal.clamp(0, 2)]; + + _accentIndex = (prefs.getInt(_accentKey) ?? 0).clamp(0, 3); + _textScale = (prefs.getDouble(_textScaleKey) ?? 1.0).clamp(0.8, 1.4); + + final densityVal = prefs.getInt(_densityKey) ?? 1; + _density = _densityFromIndex(densityVal); + + // Restaure l'ordre et la visibilité des écrans + final raw = prefs.getString(_screensKey); + if (raw != null) { + try { + final List saved = jsonDecode(raw) as List; + final byId = {for (final s in _screens) s.id: s}; + final restored = []; + for (final item in saved) { + final id = item['id'] as String?; + final visible = item['visible'] as bool? ?? true; + if (id != null && byId.containsKey(id)) { + restored.add(byId[id]!.copyWith(visible: visible)); + byId.remove(id); + } + } + // Ajoute les écrans nouveaux non présents dans la config sauvegardée + restored.addAll(byId.values); + _screens = restored; + } catch (_) {} + } + + notifyListeners(); + } + + // ── Sauvegarde ─────────────────────────────────────────────────────────────── + Future _save() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setInt(_themeKey, _themeMode.index); + await prefs.setInt(_accentKey, _accentIndex); + await prefs.setDouble(_textScaleKey, _textScale); + await prefs.setInt(_densityKey, _densityIndex(_density)); + await prefs.setString( + _screensKey, jsonEncode(_screens.map((s) => s.toJson()).toList())); + } + + // ── Setters ────────────────────────────────────────────────────────────────── + void setThemeMode(ThemeMode mode) { + _themeMode = mode; _save(); notifyListeners(); + } + + void setAccentIndex(int idx) { + _accentIndex = idx.clamp(0, 3); _save(); notifyListeners(); + } + + void setTextScale(double v) { + _textScale = v.clamp(0.8, 1.4); _save(); notifyListeners(); + } + + void setDensity(VisualDensity d) { + _density = d; _save(); notifyListeners(); + } + + void setScreenVisible(String id, bool visible) { + final idx = _screens.indexWhere((s) => s.id == id); + if (idx >= 0) { + _screens[idx] = _screens[idx].copyWith(visible: visible); + _save(); + notifyListeners(); + } + } + + void reorderScreens(int oldIdx, int newIdx) { + final item = _screens.removeAt(oldIdx); + _screens.insert(newIdx, item); + _save(); + notifyListeners(); + } + + void resetAppearance() { + _themeMode = ThemeMode.system; + _accentIndex = 0; + _textScale = 1.0; + _density = VisualDensity.standard; + _save(); + notifyListeners(); + } + + // ── Helpers ────────────────────────────────────────────────────────────────── + VisualDensity _densityFromIndex(int i) => switch (i) { + 0 => VisualDensity.compact, + 2 => const VisualDensity(horizontal: 2, vertical: 2), + _ => VisualDensity.standard, + }; + + int _densityIndex(VisualDensity d) { + if (d == VisualDensity.compact) return 0; + if (d.horizontal > 0) return 2; + return 1; + } +} diff --git a/lib/providers/energy_setup_provider.dart b/lib/providers/energy_setup_provider.dart new file mode 100644 index 0000000..8489bb8 --- /dev/null +++ b/lib/providers/energy_setup_provider.dart @@ -0,0 +1,171 @@ +import 'package:flutter/foundation.dart'; +import '../services/nymea_service.dart'; +import '../models/nymea_models.dart'; + +/// Rôles de l'EMS (Energy Management System). +enum EmsRole { + evCharger, + dhw, // Chauffe-eau + heatPump, // Pompe à chaleur / SG-Ready + battery, + solarMeter, // Compteur solaire + gridMeter, // Compteur réseau +} + +extension EmsRoleExt on EmsRole { + String get label => switch (this) { + EmsRole.evCharger => 'EV Charger', + EmsRole.dhw => 'Chauffe-eau', + EmsRole.heatPump => 'Pompe à chaleur', + EmsRole.battery => 'Batterie', + EmsRole.solarMeter => 'Compteur solaire', + EmsRole.gridMeter => 'Compteur réseau', + }; + + String get icon => switch (this) { + EmsRole.evCharger => '🚗', + EmsRole.dhw => '🌡️', + EmsRole.heatPump => '♨️', + EmsRole.battery => '🔋', + EmsRole.solarMeter => '☀️', + EmsRole.gridMeter => '⚡', + }; + + /// Interfaces nymea compatibles avec ce rôle. + List get compatibleInterfaces => switch (this) { + EmsRole.evCharger => ['evcharger'], + EmsRole.dhw => ['simpleheatpump', 'relay', 'smartplug', 'powerswitch'], + EmsRole.heatPump => ['sgready', 'sgrelay', 'heatpump'], + EmsRole.battery => ['battery', 'energystorage', 'batterymonitor'], + EmsRole.solarMeter => ['solarinverter', 'energymeter', 'meter', 'inverter', 'smartmeter'], + EmsRole.gridMeter => ['energymeter', 'smartmeter', 'meter'], + }; + + /// Label d'interface affiché dans la liste de sélection (Step 1). + String interfaceLabelFor(String iface) => switch (iface.toLowerCase()) { + 'sgready' => 'SG-Ready', + 'sgrelay' => 'SG-Ready', + 'heatpump' => 'Heat Pump', + 'simpleheatpump' => 'SimpleHeatpump', + 'solarinverter' => 'Inverter', + 'energymeter' => 'Meter', + 'meter' => 'Meter', + 'inverter' => 'Inverter', + 'smartmeter' => 'Smart Meter', + 'evcharger' => 'EV Charger', + 'battery' => 'Battery', + 'energystorage' => 'Energy Storage', + _ => iface, + }; +} + +/// Assignation d'un thing à un rôle EMS. +class RoleAssignment { + final EmsRole role; + final NymeaThing thing; + final bool enabled; + final Map params; // puissance, priorité, etc. + + const RoleAssignment({ + required this.role, + required this.thing, + this.enabled = true, + this.params = const {}, + }); + + RoleAssignment copyWith({ + bool? enabled, + Map? params, + }) => RoleAssignment( + role: role, + thing: thing, + enabled: enabled ?? this.enabled, + params: params ?? this.params, + ); +} + +/// Résultat d'un test de connexion de rôle. +enum ConnectionTestStatus { idle, testing, success, failure } + +class ConnectionTestResult { + final ConnectionTestStatus status; + final String? message; + const ConnectionTestResult(this.status, [this.message]); +} + +/// Gère les assignations de rôles EMS et les flux de configuration. +class EnergySetupProvider extends ChangeNotifier { + final Map _assignments = { + for (final r in EmsRole.values) r: null, + }; + + ConnectionTestResult _testResult = + const ConnectionTestResult(ConnectionTestStatus.idle); + + Map get assignments => _assignments; + ConnectionTestResult get testResult => _testResult; + + RoleAssignment? assignmentFor(EmsRole role) => _assignments[role]; + bool isConfigured(EmsRole role) => _assignments[role] != null; + + // ── Assignation locale ─────────────────────────────────────────────────────── + void assign(EmsRole role, NymeaThing thing, Map params) { + _assignments[role] = RoleAssignment(role: role, thing: thing, params: params); + notifyListeners(); + } + + void unassign(EmsRole role) { + _assignments[role] = null; + notifyListeners(); + } + + void setEnabled(EmsRole role, bool enabled) { + final current = _assignments[role]; + if (current != null) { + _assignments[role] = current.copyWith(enabled: enabled); + notifyListeners(); + } + } + + // ── Filtrage des things compatibles avec un rôle ───────────────────────────── + List compatibleThings( + EmsRole role, NymeaService service) { + return service.things.where((thing) { + try { + final cls = service.thingClasses + .firstWhere((c) => c.id == thing.thingClassId); + return cls.interfaces.any( + (i) => role.compatibleInterfaces + .contains(i.toLowerCase())); + } catch (_) { + return false; + } + }).toList(); + } + + // ── Test de connexion (simulé — à brancher sur RPC réel) ──────────────────── + Future testConnection(EmsRole role, NymeaService service) async { + _testResult = const ConnectionTestResult(ConnectionTestStatus.testing); + notifyListeners(); + + await Future.delayed(const Duration(seconds: 2)); + + final assignment = _assignments[role]; + if (assignment == null) { + _testResult = const ConnectionTestResult( + ConnectionTestStatus.failure, + 'Aucun appareil configuré pour ce rôle.'); + } else { + // En mode simulation, retourne toujours succès + _testResult = ConnectionTestResult( + ConnectionTestStatus.success, + '${assignment.thing.name} a répondu correctement.'); + } + notifyListeners(); + } + + void resetTest() { + _testResult = const ConnectionTestResult(ConnectionTestStatus.idle); + notifyListeners(); + } +} diff --git a/lib/providers/installer_mode_provider.dart b/lib/providers/installer_mode_provider.dart new file mode 100644 index 0000000..72d754a --- /dev/null +++ b/lib/providers/installer_mode_provider.dart @@ -0,0 +1,113 @@ +import 'dart:async'; +import 'dart:convert'; +import 'package:crypto/crypto.dart'; +import 'package:flutter/foundation.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +/// Gère le mode installateur : déverrouillage par PIN, auto-lock, tentatives. +/// +/// Le PIN n'est jamais stocké en clair — SHA-256 uniquement. +/// PIN par défaut au premier lancement : "1234". +class InstallerModeProvider extends ChangeNotifier { + static const _pinHashKey = 'installer_pin_hash'; + static const _defaultPin = '1234'; + static const _maxAttempts = 3; + static const _lockDuration = Duration(seconds: 30); + static const _autoLockDuration = Duration(minutes: 10); + + bool _isUnlocked = false; + int _failedAttempts = 0; + DateTime? _lockedUntil; + Timer? _autoLockTimer; + + bool get isUnlocked => _isUnlocked; + bool get isLocked => + _lockedUntil != null && DateTime.now().isBefore(_lockedUntil!); + int get failedAttempts => _failedAttempts; + + /// Durée restante avant déverrouillage (0 si non verrouillé). + Duration get lockRemaining { + if (!isLocked) return Duration.zero; + return _lockedUntil!.difference(DateTime.now()); + } + + // ── SHA-256 helper ────────────────────────────────────────────────────────── + static String _hash(String pin) { + final bytes = utf8.encode(pin); + return sha256.convert(bytes).toString(); + } + + // ── Initialisation ────────────────────────────────────────────────────────── + Future init() async { + final prefs = await SharedPreferences.getInstance(); + // Crée le hash du PIN par défaut si aucun n'est encore défini + if (!prefs.containsKey(_pinHashKey)) { + await prefs.setString(_pinHashKey, _hash(_defaultPin)); + } + } + + // ── Déverrouillage ────────────────────────────────────────────────────────── + Future unlock(String pin) async { + if (isLocked) return UnlockResult.locked; + + final prefs = await SharedPreferences.getInstance(); + final storedHash = prefs.getString(_pinHashKey) ?? _hash(_defaultPin); + + if (_hash(pin) == storedHash) { + _isUnlocked = true; + _failedAttempts = 0; + _lockedUntil = null; + _resetAutoLockTimer(); + notifyListeners(); + return UnlockResult.success; + } else { + _failedAttempts++; + if (_failedAttempts >= _maxAttempts) { + _lockedUntil = DateTime.now().add(_lockDuration); + _failedAttempts = 0; + // Déclenche rebuild après le délai pour effacer le verrou + Timer(_lockDuration, () { + _lockedUntil = null; + notifyListeners(); + }); + } + notifyListeners(); + return UnlockResult.wrongPin; + } + } + + // ── Verrouillage ──────────────────────────────────────────────────────────── + void lock() { + _isUnlocked = false; + _autoLockTimer?.cancel(); + notifyListeners(); + } + + // ── Changement de PIN ─────────────────────────────────────────────────────── + Future changePin(String newPin) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_pinHashKey, _hash(newPin)); + } + + // ── Auto-lock après inactivité ─────────────────────────────────────────────── + void _resetAutoLockTimer() { + _autoLockTimer?.cancel(); + _autoLockTimer = Timer(_autoLockDuration, () { + if (_isUnlocked) lock(); + }); + } + + /// Appeler lors d'une interaction en mode installateur pour réinitialiser + /// le timer d'auto-lock. + void onInstallerActivity() { + if (_isUnlocked) _resetAutoLockTimer(); + } + + @override + void dispose() { + _autoLockTimer?.cancel(); + super.dispose(); + } +} + +enum UnlockResult { success, wrongPin, locked } diff --git a/lib/providers/navigation_provider.dart b/lib/providers/navigation_provider.dart new file mode 100644 index 0000000..9debedd --- /dev/null +++ b/lib/providers/navigation_provider.dart @@ -0,0 +1,23 @@ +import 'package:flutter/foundation.dart'; + +/// Gère uniquement l'état ouvert/fermé du drawer. +/// La navigation entre onglets est gérée directement par GoRouter dans MainShell. +class NavigationProvider extends ChangeNotifier { + bool _isDrawerOpen = false; + + bool get isDrawerOpen => _isDrawerOpen; + + void openDrawer() { + if (!_isDrawerOpen) { + _isDrawerOpen = true; + notifyListeners(); + } + } + + void closeDrawer() { + if (_isDrawerOpen) { + _isDrawerOpen = false; + notifyListeners(); + } + } +} diff --git a/lib/providers/scheduler_provider.dart b/lib/providers/scheduler_provider.dart new file mode 100644 index 0000000..d170823 --- /dev/null +++ b/lib/providers/scheduler_provider.dart @@ -0,0 +1,111 @@ +import 'dart:async'; +import 'package:flutter/foundation.dart'; +import '../services/nymea_service.dart'; + +enum SchedulerStrategy { rulesBased, manual, aiPro } + +extension SchedulerStrategyExt on SchedulerStrategy { + String get label => switch (this) { + SchedulerStrategy.rulesBased => 'Basée sur les règles', + SchedulerStrategy.manual => 'Manuelle', + SchedulerStrategy.aiPro => 'AI (Pro)', + }; + bool get isPro => this == SchedulerStrategy.aiPro; +} + +class SchedulerConfig { + final double priceThreshold; // €/kWh + final double minSurplus; // W + final int planningHorizon; // heures + final int recalcInterval; // minutes + final double selfSufficiencyGoal; // % + + const SchedulerConfig({ + this.priceThreshold = 0.08, + this.minSurplus = 200, + this.planningHorizon = 24, + this.recalcInterval = 15, + this.selfSufficiencyGoal = 70, + }); + + SchedulerConfig copyWith({ + double? priceThreshold, + double? minSurplus, + int? planningHorizon, + int? recalcInterval, + double? selfSufficiencyGoal, + }) => SchedulerConfig( + priceThreshold: priceThreshold ?? this.priceThreshold, + minSurplus: minSurplus ?? this.minSurplus, + planningHorizon: planningHorizon ?? this.planningHorizon, + recalcInterval: recalcInterval ?? this.recalcInterval, + selfSufficiencyGoal: selfSufficiencyGoal ?? this.selfSufficiencyGoal, + ); +} + +enum SchedulerStatus { ok, degraded, error } + +class SchedulerState { + final SchedulerStatus status; + final String reason; + final DateTime? lastPlanning; + final DateTime? nextPlanning; + + const SchedulerState({ + this.status = SchedulerStatus.ok, + this.reason = '', + this.lastPlanning, + this.nextPlanning, + }); +} + +/// Gère la stratégie, la configuration et l'état du scheduler EMS. +class SchedulerProvider extends ChangeNotifier { + SchedulerStrategy _strategy = SchedulerStrategy.rulesBased; + SchedulerConfig _config = const SchedulerConfig(); + SchedulerState _state = const SchedulerState(); + bool _loading = false; + + SchedulerStrategy get strategy => _strategy; + SchedulerConfig get config => _config; + SchedulerState get state => _state; + bool get loading => _loading; + + // ── Chargement depuis nymea (stub — à brancher sur RPC réel) ──────────────── + Future load(NymeaService service) async { + _loading = true; + notifyListeners(); + + await Future.delayed(const Duration(milliseconds: 300)); + _state = SchedulerState( + status: SchedulerStatus.ok, + reason: 'TariffManager: aWATTar AT actif', + lastPlanning: DateTime.now().subtract(const Duration(minutes: 8)), + nextPlanning: DateTime.now().add(const Duration(minutes: 7)), + ); + _loading = false; + notifyListeners(); + } + + // ── Mutations ──────────────────────────────────────────────────────────────── + void setStrategy(SchedulerStrategy s) { + _strategy = s; + notifyListeners(); + // TODO: _sendRequest('Energy.SetSchedulerStrategy', ...) + } + + void updateConfig(SchedulerConfig c) { + _config = c; + notifyListeners(); + // TODO: _sendRequest('Energy.SetSchedulerConfig', ...) + } + + Future forceRecalc(NymeaService service) async { + _loading = true; + notifyListeners(); + await Future.delayed(const Duration(seconds: 1)); + _state = _state; // Mettre à jour depuis la réponse RPC + _loading = false; + notifyListeners(); + } +} diff --git a/lib/providers/tariff_provider.dart b/lib/providers/tariff_provider.dart new file mode 100644 index 0000000..67038a0 --- /dev/null +++ b/lib/providers/tariff_provider.dart @@ -0,0 +1,133 @@ +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart' show TimeOfDay; + +enum TariffProviderType { + manual, // Tarif manuel saisi par l'utilisateur + customJson, + linkyTIC, + tibberPro, + octopusPro, + entsoe, + jsonRpc, +} + +extension TariffProviderTypeExt on TariffProviderType { + String get label => switch (this) { + TariffProviderType.manual => 'Tarif manuel', + TariffProviderType.customJson => 'Fichier JSON custom', + TariffProviderType.linkyTIC => 'Linky TIC', + TariffProviderType.tibberPro => 'Tibber', + TariffProviderType.octopusPro => 'Octopus', + TariffProviderType.entsoe => 'ENTSO-E', + TariffProviderType.jsonRpc => 'JSON-RPC externe', + }; + bool get isPro => this == TariffProviderType.tibberPro || + this == TariffProviderType.octopusPro || + this == TariffProviderType.entsoe; +} + +enum TariffMode { + spotMarket, + hchp, // Heures Creuses / Heures Pleines + tou, // TOU contractuel + fixed, +} + +extension TariffModeExt on TariffMode { + String get label => switch (this) { + TariffMode.spotMarket => 'Spot Market', + TariffMode.hchp => 'Heures Creuses / Heures Pleines', + TariffMode.tou => 'TOU contractuel', + TariffMode.fixed => 'Tarif fixe', + }; +} + +class HcHpConfig { + final double offPeakPrice; // €/kWh HC + final double peakPrice; // €/kWh HP + final TimeOfDay offPeakStart; + final TimeOfDay offPeakEnd; + + const HcHpConfig({ + this.offPeakPrice = 0.096, + this.peakPrice = 0.146, + this.offPeakStart = const TimeOfDay(hour: 22, minute: 0), + this.offPeakEnd = const TimeOfDay(hour: 6, minute: 0), + }); + + HcHpConfig copyWith({ + double? offPeakPrice, + double? peakPrice, + TimeOfDay? offPeakStart, + TimeOfDay? offPeakEnd, + }) => HcHpConfig( + offPeakPrice: offPeakPrice ?? this.offPeakPrice, + peakPrice: peakPrice ?? this.peakPrice, + offPeakStart: offPeakStart ?? this.offPeakStart, + offPeakEnd: offPeakEnd ?? this.offPeakEnd, + ); +} + +class CurrentPrices { + final double now; + final double inOneHour; + final double min24h; + final double max24h; + final DateTime? minTime; + final DateTime? maxTime; + + const CurrentPrices({ + this.now = 0, + this.inOneHour = 0, + this.min24h = 0, + this.max24h = 0, + this.minTime, + this.maxTime, + }); +} + +/// Gère le provider tarifaire actif, le mode et les prix courants. +class TariffProvider extends ChangeNotifier { + TariffProviderType _activeProvider = TariffProviderType.manual; + TariffMode _mode = TariffMode.fixed; + double _manualPrice = 0.25; // €/kWh tarif manuel + HcHpConfig _hchp = const HcHpConfig(); + CurrentPrices _prices = const CurrentPrices( + now: 0.092, inOneHour: 0.085, + min24h: 0.048, max24h: 0.182, + ); + bool _loading = false; + + TariffProviderType get activeProvider => _activeProvider; + TariffMode get mode => _mode; + HcHpConfig get hchp => _hchp; + CurrentPrices get prices => _prices; + bool get loading => _loading; + double get manualPrice => _manualPrice; + + Future load() async { + _loading = true; + notifyListeners(); + await Future.delayed(const Duration(milliseconds: 200)); + _loading = false; + notifyListeners(); + // TODO: charger depuis nymea RPC + } + + void setProvider(TariffProviderType p) { + _activeProvider = p; notifyListeners(); + } + + void setMode(TariffMode m) { + _mode = m; notifyListeners(); + } + + void updateHcHp(HcHpConfig c) { + _hchp = c; notifyListeners(); + } + + void setManualPrice(double price) { + _manualPrice = price.clamp(0.001, 9.999); + notifyListeners(); + } +} diff --git a/lib/screens/ac_screen.dart b/lib/screens/ac_screen.dart index 2e691c5..911422c 100644 --- a/lib/screens/ac_screen.dart +++ b/lib/screens/ac_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../theme/app_theme.dart'; +import '../main.dart' show DrawerMenuButton; class ACScreen extends StatefulWidget { const ACScreen({super.key}); @@ -23,6 +24,8 @@ class _ACScreenState extends State { appBar: AppBar( backgroundColor: AppTheme.backgroundGray, elevation: 0, + leading: const DrawerMenuButton(), + leadingWidth: 56, title: const Text( 'Climatisation / Chauffage', style: TextStyle(fontWeight: FontWeight.bold, color: AppTheme.textDark), diff --git a/lib/screens/dashboard_screen.dart b/lib/screens/dashboard_screen.dart index e7d9e77..c14e1d2 100644 --- a/lib/screens/dashboard_screen.dart +++ b/lib/screens/dashboard_screen.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import '../main.dart' show DrawerMenuButton; import '../services/nymea_service.dart'; import '../theme/app_theme.dart'; import '../widgets/energy_flow_widget.dart'; @@ -47,27 +48,15 @@ class _DashboardScreenState extends State { floating: true, backgroundColor: AppTheme.backgroundGray, elevation: 0, - title: Row( - children: [ - Container( - width: 36, - height: 36, - decoration: BoxDecoration( - color: AppTheme.primaryGreen, - borderRadius: BorderRadius.circular(10), - ), - child: const Icon(Icons.bolt, color: Colors.white, size: 22), - ), - const SizedBox(width: 10), - const Text( - 'Nymea Energy', - style: TextStyle( - fontWeight: FontWeight.bold, - color: AppTheme.textDark, - fontSize: 18, - ), - ), - ], + leading: const DrawerMenuButton(), + leadingWidth: 56, + title: const Text( + 'ETM PowerSync', + style: TextStyle( + fontWeight: FontWeight.bold, + color: AppTheme.textDark, + fontSize: 18, + ), ), actions: [ // Connection status diff --git a/lib/screens/drawer/installer_pin_dialog.dart b/lib/screens/drawer/installer_pin_dialog.dart new file mode 100644 index 0000000..f2456d4 --- /dev/null +++ b/lib/screens/drawer/installer_pin_dialog.dart @@ -0,0 +1,265 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../providers/installer_mode_provider.dart'; +import '../../theme/app_theme.dart'; +import '../../theme/etm_theme.dart'; + +/// Dialog de saisie du PIN installateur. +/// Affiche un clavier numérique avec indicateurs de saisie, +/// gestion des tentatives échouées et countdown en cas de verrouillage. +class InstallerPinDialog extends StatefulWidget { + const InstallerPinDialog({super.key}); + + @override + State createState() => _InstallerPinDialogState(); +} + +class _InstallerPinDialogState extends State { + String _pin = ''; + String? _error; + bool _checking = false; + Timer? _countdownTimer; + int _countdown = 0; + + @override + void dispose() { + _countdownTimer?.cancel(); + super.dispose(); + } + + void _append(String digit) { + if (_checking || _pin.length >= 6) return; + setState(() { + _pin = _pin + digit; + _error = null; + }); + if (_pin.length >= 4) { + _tryUnlock(); + } + } + + void _delete() { + if (_pin.isEmpty) return; + setState(() => _pin = _pin.substring(0, _pin.length - 1)); + } + + Future _tryUnlock() async { + final provider = context.read(); + if (provider.isLocked) { + _startCountdown(provider.lockRemaining.inSeconds); + return; + } + + setState(() => _checking = true); + final result = await provider.unlock(_pin); + if (!mounted) return; + + switch (result) { + case UnlockResult.success: + Navigator.of(context).pop(true); + break; + case UnlockResult.wrongPin: + setState(() { + _checking = false; + _pin = ''; + _error = provider.isLocked + ? 'PIN incorrect. Veuillez patienter.' + : 'PIN incorrect (${provider.failedAttempts} tentative${provider.failedAttempts > 1 ? 's' : ''} restante${(3 - provider.failedAttempts) > 1 ? 's' : ''})'; + }); + if (provider.isLocked) { + _startCountdown(provider.lockRemaining.inSeconds); + } + break; + case UnlockResult.locked: + setState(() { + _checking = false; + _pin = ''; + }); + _startCountdown(provider.lockRemaining.inSeconds); + break; + } + } + + void _startCountdown(int seconds) { + _countdown = seconds; + _countdownTimer?.cancel(); + _countdownTimer = Timer.periodic(const Duration(seconds: 1), (t) { + if (!mounted) { t.cancel(); return; } + setState(() { + _countdown--; + if (_countdown <= 0) { + t.cancel(); + _error = null; + } else { + _error = 'Trop de tentatives. Réessayez dans $_countdown s'; + } + }); + }); + } + + @override + Widget build(BuildContext context) { + return Dialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + child: Padding( + padding: const EdgeInsets.fromLTRB(24, 28, 24, 24), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + // ── Icône ──────────────────────────────────────────────────────── + Container( + width: 52, height: 52, + decoration: BoxDecoration( + color: ETMTheme.installerBadgeColor.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: const Icon( + Icons.build_rounded, + color: ETMTheme.installerBadgeColor, + size: 26, + ), + ), + const SizedBox(height: 14), + const Text( + 'Mode Installateur', + style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 4), + const Text( + 'Entrez votre PIN pour continuer', + style: TextStyle(fontSize: 13, color: Color(0xFF6B7280)), + ), + const SizedBox(height: 20), + + // ── Indicateurs PIN ────────────────────────────────────────────── + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: List.generate(6, (i) { + final filled = i < _pin.length; + return Container( + width: 14, height: 14, + margin: const EdgeInsets.symmetric(horizontal: 5), + decoration: BoxDecoration( + shape: BoxShape.circle, + color: filled + ? ETMTheme.installerBadgeColor + : Colors.grey.shade200, + border: Border.all( + color: filled + ? ETMTheme.installerBadgeColor + : Colors.grey.shade400, + ), + ), + ); + }), + ), + + if (_checking) ...[ + const SizedBox(height: 16), + const SizedBox( + width: 24, height: 24, + child: CircularProgressIndicator( + strokeWidth: 2.5, + color: ETMTheme.installerBadgeColor, + ), + ), + ] else if (_error != null) ...[ + const SizedBox(height: 10), + Text( + _error!, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 12, + color: ETMTheme.errorColor, + ), + ), + ], + + const SizedBox(height: 20), + + // ── Clavier numérique ──────────────────────────────────────────── + ...[ + ['1', '2', '3'], + ['4', '5', '6'], + ['7', '8', '9'], + ['', '0', '⌫'], + ].map((row) => Padding( + padding: const EdgeInsets.only(bottom: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: row.map((k) { + if (k.isEmpty) { + return const SizedBox(width: 70, height: 52); + } + return _KeyButton( + label: k, + onTap: () { + if (k == '⌫') { + _delete(); + } else { + _append(k); + } + }, + isDelete: k == '⌫', + disabled: _checking || _countdown > 0, + ); + }).toList(), + ), + )), + + // ── Bouton Annuler ─────────────────────────────────────────────── + TextButton( + onPressed: () => Navigator.of(context).pop(false), + child: const Text('Annuler'), + ), + ], + ), + ), + ); + } +} + +class _KeyButton extends StatelessWidget { + final String label; + final VoidCallback onTap; + final bool isDelete; + final bool disabled; + + const _KeyButton({ + required this.label, + required this.onTap, + this.isDelete = false, + this.disabled = false, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: disabled ? null : onTap, + child: Container( + width: 70, height: 52, + margin: const EdgeInsets.symmetric(horizontal: 6), + decoration: BoxDecoration( + color: isDelete + ? Colors.grey.shade100 + : Colors.grey.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all(color: Colors.grey.shade200), + ), + child: Center( + child: Text( + label, + style: TextStyle( + fontSize: isDelete ? 18 : 22, + fontWeight: FontWeight.w500, + color: disabled + ? Colors.grey.shade400 + : AppTheme.textDark, + ), + ), + ), + ), + ); + } +} diff --git a/lib/screens/drawer/main_drawer.dart b/lib/screens/drawer/main_drawer.dart new file mode 100644 index 0000000..663b8c4 --- /dev/null +++ b/lib/screens/drawer/main_drawer.dart @@ -0,0 +1,659 @@ +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 '../../providers/app_settings_provider.dart'; +import '../../services/nymea_service.dart'; +import '../../theme/app_theme.dart'; +import '../../theme/etm_theme.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().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: ETMTheme.drawerWidth, + child: Material( + color: ETMTheme.drawerBackground, + 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(); + final installer = context.watch(); + + return Container( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 12), + decoration: BoxDecoration( + color: ETMTheme.drawerSurface, + border: Border( + bottom: BorderSide( + color: Colors.white.withValues(alpha: 0.08), + ), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── Logo + statut connexion ────────────────────────────────────── + Row( + children: [ + Container( + width: 44, height: 44, + decoration: BoxDecoration( + color: ETMTheme.accentColor.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(12), + ), + child: const Icon( + Icons.bolt_rounded, + color: Colors.white, + size: 26, + ), + ), + 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: TextStyle( + color: ETMTheme.drawerTextMuted, + fontSize: 11, + ), + ), + ], + ), + ), + // Indicateur de connexion + _ConnectionDot( + connected: service.connected, + simulation: service.isSimulation, + ), + ], + ), + + const SizedBox(height: 12), + + // ── Nom du site ────────────────────────────────────────────────── + Text( + service.isSimulation ? 'Site démo' : 'Mon installation', + style: const TextStyle( + color: Colors.white, + fontSize: 13, + fontWeight: FontWeight.w600, + ), + ), + + const SizedBox(height: 6), + + // ── Utilisateur + badge rôle ───────────────────────────────────── + Row( + children: [ + Icon(Icons.person_outline_rounded, + size: 14, color: ETMTheme.drawerTextMuted), + const SizedBox(width: 5), + Text( + service.username.isNotEmpty ? service.username : 'Utilisateur', + style: TextStyle( + color: ETMTheme.drawerTextMuted, + fontSize: 12, + ), + ), + 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 + ? Colors.orange + : connected + ? AppTheme.primaryGreen + : AppTheme.boostRed; + return Container( + width: 10, height: 10, + decoration: BoxDecoration(shape: BoxShape.circle, color: color), + ); + } +} + +class _RoleBadge extends StatelessWidget { + final bool isInstaller; + const _RoleBadge({required this.isInstaller}); + + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: isInstaller + ? ETMTheme.installerBadgeColor.withValues(alpha: 0.2) + : ETMTheme.accentColor.withValues(alpha: 0.2), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + isInstaller ? 'INSTALLATEUR' : 'UTILISATEUR', + style: TextStyle( + fontSize: 9, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + color: isInstaller + ? ETMTheme.installerBadgeColor + : ETMTheme.accentColor, + ), + ), + ); + } +} + +// ── Menu du drawer ──────────────────────────────────────────────────────────── +class _DrawerMenu extends StatelessWidget { + @override + Widget build(BuildContext context) { + final installer = context.watch(); + + 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(16, 12, 16, 4), + child: Text( + text, + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + letterSpacing: 1.2, + color: ETMTheme.drawerTextMuted, + ), + ), + ); + } +} + +class _Divider extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Divider( + color: Colors.white.withValues(alpha: 0.08), + height: 1, + indent: 16, + endIndent: 16, + ); + } +} + +/// 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().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: Icon(Icons.open_in_new_rounded, + size: 14, color: ETMTheme.drawerTextMuted), + onTap: () async { + context.read().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: 12, vertical: 4), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + Icon(icon, size: 19, color: ETMTheme.drawerTextPrimary), + const SizedBox(width: 12), + Expanded( + child: Text( + label, + style: TextStyle( + color: ETMTheme.drawerTextPrimary, + fontSize: 13.5, + ), + ), + ), + 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 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: 19, color: ETMTheme.drawerTextPrimary), + title: Text( + label, + style: TextStyle( + color: ETMTheme.drawerTextPrimary, + fontSize: 13.5, + ), + ), + iconColor: ETMTheme.drawerTextMuted, + collapsedIconColor: ETMTheme.drawerTextMuted, + tilePadding: const EdgeInsets.symmetric(horizontal: 22), + 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().closeDrawer(); + context.push(route); + }, + child: Padding( + padding: const EdgeInsets.fromLTRB(54, 9, 16, 9), + child: Text( + label, + style: TextStyle( + color: ETMTheme.drawerTextMuted, + fontSize: 13, + ), + ), + ), + ); + } +} + +// ── 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), + borderRadius: BorderRadius.circular(10), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 10), + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(10), + border: Border.all( + color: ETMTheme.installerBadgeColor.withValues(alpha: 0.3), + ), + ), + child: Row( + children: [ + Icon(Icons.build_rounded, + size: 19, color: ETMTheme.installerBadgeColor), + const SizedBox(width: 12), + Expanded( + child: Text( + '🔧 Mode Installateur', + style: TextStyle( + color: ETMTheme.installerBadgeColor, + fontSize: 13.5, + fontWeight: FontWeight.w600, + ), + ), + ), + Icon(Icons.lock_rounded, + size: 14, color: ETMTheme.drawerTextMuted), + ], + ), + ), + ), + ); + } + + // 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, + ), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: TextButton.icon( + style: TextButton.styleFrom( + foregroundColor: AppTheme.boostRed, + padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 8), + ), + icon: const Icon(Icons.lock_open_rounded, size: 16), + label: const Text('Verrouiller mode installateur'), + onPressed: () => + context.read().lock(), + ), + ), + ], + ); + } + + Future _promptPin(BuildContext context) async { + final result = await showDialog( + 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 + } + } +} diff --git a/lib/screens/energy/energy_setup_screen.dart b/lib/screens/energy/energy_setup_screen.dart new file mode 100644 index 0000000..1eecf05 --- /dev/null +++ b/lib/screens/energy/energy_setup_screen.dart @@ -0,0 +1,172 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../providers/energy_setup_provider.dart'; +import '../../providers/installer_mode_provider.dart'; +import '../../services/nymea_service.dart'; +import '../../theme/etm_theme.dart'; +import '../../widgets/role_card.dart'; +import 'role_config_flow.dart'; + +/// Écran "Rôles & appareils" — configuration des rôles EMS. +/// Accessible uniquement en mode installateur. +class EnergySetupScreen extends StatelessWidget { + const EnergySetupScreen({super.key}); + + @override + Widget build(BuildContext context) { + final installer = context.watch(); + + return Scaffold( + backgroundColor: const Color(0xFFF0F2F5), + appBar: AppBar( + title: const Text('Rôles & appareils'), + backgroundColor: Colors.white, + foregroundColor: const Color(0xFF1A1A2E), + elevation: 0, + actions: [ + IconButton( + icon: const Icon(Icons.help_outline_rounded), + tooltip: 'Aide', + onPressed: () => _showHelp(context), + ), + ], + ), + body: installer.isUnlocked + ? _SetupBody() + : _LockedOverlay(), + ); + } + + void _showHelp(BuildContext context) { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => Padding( + padding: const EdgeInsets.all(24), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: const [ + Text('Rôles & appareils', + style: TextStyle(fontSize: 17, fontWeight: FontWeight.bold)), + SizedBox(height: 12), + Text( + 'Assignez chaque appareil physique à son rôle dans ' + 'le système de gestion d\'énergie.\n\n' + 'Le gestionnaire d\'énergie utilise ces assignations pour ' + 'optimiser la consommation et maximiser l\'autoconsommation.', + style: TextStyle(fontSize: 14), + ), + ], + ), + ), + ); + } +} + +class _LockedOverlay extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.lock_rounded, + size: 48, color: Colors.grey.shade400), + const SizedBox(height: 16), + const Text( + 'Mode installateur requis', + style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + const Text( + 'Activez le mode installateur depuis le menu\npour accéder à cet écran.', + textAlign: TextAlign.center, + style: TextStyle(color: Color(0xFF6B7280)), + ), + ], + ), + ); + } +} + +class _SetupBody extends StatelessWidget { + @override + Widget build(BuildContext context) { + final setup = context.watch(); + final service = context.read(); + + return ListView( + padding: const EdgeInsets.all(16), + children: [ + // Bannière d'info + Container( + padding: const EdgeInsets.all(12), + margin: const EdgeInsets.only(bottom: 16), + decoration: BoxDecoration( + color: ETMTheme.accentColor.withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: ETMTheme.accentColor.withValues(alpha: 0.3)), + ), + child: Row( + children: [ + const Icon(Icons.info_outline_rounded, + color: ETMTheme.accentColor, size: 18), + const SizedBox(width: 10), + Expanded( + child: Text( + '${EmsRole.values.where((r) => setup.isConfigured(r)).length} / ' + '${EmsRole.values.length} rôles configurés', + style: const TextStyle( + fontSize: 13, + color: ETMTheme.accentColor, + fontWeight: FontWeight.w500, + ), + ), + ), + ], + ), + ), + + ...EmsRole.values.map((role) => RoleCard( + role: role, + assignment: setup.assignmentFor(role), + isReachable: true, + onConfigure: () => _openConfigFlow(context, role, null), + onToggle: setup.isConfigured(role) + ? () => setup.setEnabled( + role, + !(setup.assignmentFor(role)?.enabled ?? false), + ) + : null, + onEdit: setup.isConfigured(role) + ? () => _openConfigFlow( + context, + role, + setup.assignmentFor(role), + ) + : null, + onTest: setup.isConfigured(role) + ? () => setup.testConnection(role, service) + : null, + )), + ], + ); + } + + Future _openConfigFlow( + BuildContext context, EmsRole role, RoleAssignment? existing) async { + await showModalBottomSheet( + context: context, + isScrollControlled: true, + useSafeArea: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => RoleConfigFlow(role: role, existing: existing), + ); + } +} diff --git a/lib/screens/energy/role_config_flow.dart b/lib/screens/energy/role_config_flow.dart new file mode 100644 index 0000000..73961e3 --- /dev/null +++ b/lib/screens/energy/role_config_flow.dart @@ -0,0 +1,688 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../models/nymea_models.dart'; +import '../../providers/energy_setup_provider.dart'; +import '../../services/nymea_service.dart'; +import '../../theme/app_theme.dart'; +import '../../theme/etm_theme.dart'; + +/// Bottom sheet en 3 étapes pour configurer un rôle EMS. +/// +/// Étape 1 : Choisir un thing compatible +/// Étape 2 : Paramètres spécifiques au rôle +/// Étape 3 : Test de connexion +class RoleConfigFlow extends StatefulWidget { + final EmsRole role; + final RoleAssignment? existing; + + const RoleConfigFlow({ + super.key, + required this.role, + this.existing, + }); + + @override + State createState() => _RoleConfigFlowState(); +} + +class _RoleConfigFlowState extends State { + int _step = 0; + NymeaThing? _selectedThing; + String _searchQuery = ''; + final Map _params = {}; + + // Paramètres par défaut selon le rôle + void _initParams() { + switch (widget.role) { + case EmsRole.evCharger: + _params['phases'] = 1; + _params['minA'] = 6; + _params['maxA'] = 32; + _params['priority'] = 'Normal'; + case EmsRole.dhw: + _params['powerW'] = 2000; + _params['priority'] = 'Normal'; + case EmsRole.heatPump: + _params['powerW'] = 5000; + _params['priority'] = 'High'; + case EmsRole.battery: + _params['capacityWh'] = 10000; + _params['maxChargeW'] = 3000; + _params['maxDischargeW'] = 3000; + case EmsRole.solarMeter: + _params['powerW'] = 6000; + case EmsRole.gridMeter: + _params['breakerKVA'] = 12; + } + // Pré-remplir avec les valeurs existantes + if (widget.existing != null) { + _params.addAll(widget.existing!.params); + _selectedThing = widget.existing!.thing; + } + } + + @override + void initState() { + super.initState(); + _initParams(); + if (widget.existing != null) { + _step = 1; // Va directement à l'étape paramètres si édition + } + } + + @override + Widget build(BuildContext context) { + return DraggableScrollableSheet( + initialChildSize: 0.85, + maxChildSize: 0.95, + minChildSize: 0.5, + expand: false, + builder: (context, sc) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom), + child: Column( + children: [ + // ── Drag handle ─────────────────────────────────────────────── + const SizedBox(height: 12), + Center( + child: Container( + width: 36, height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + ), + const SizedBox(height: 16), + + // ── Titre + stepper ─────────────────────────────────────────── + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Row( + children: [ + Text( + '${widget.role.icon} ${widget.role.label}', + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.bold, + ), + ), + const Spacer(), + _StepDots(current: _step, total: 3), + ], + ), + ), + const SizedBox(height: 4), + Padding( + padding: const EdgeInsets.symmetric(horizontal: 20), + child: Text( + _stepTitle(), + style: const TextStyle( + fontSize: 13, + color: AppTheme.textLight, + ), + ), + ), + + const Divider(height: 20), + + // ── Contenu de l'étape ──────────────────────────────────────── + Expanded( + child: SingleChildScrollView( + controller: sc, + padding: const EdgeInsets.symmetric(horizontal: 20), + child: _stepContent(context), + ), + ), + + // ── Boutons de navigation ───────────────────────────────────── + _StepButtons( + step: _step, + canNext: _canProceed(), + onBack: _step > 0 ? () => setState(() => _step--) : null, + onNext: _canProceed() ? _next : null, + onCancel: () => Navigator.pop(context), + ), + ], + ), + ); + }, + ); + } + + String _stepTitle() => switch (_step) { + 0 => 'Choisissez un appareil compatible', + 1 => 'Paramètres de configuration', + _ => 'Test de connexion', + }; + + bool _canProceed() => switch (_step) { + 0 => _selectedThing != null, + 1 => true, + _ => true, + }; + + void _next() { + if (_step < 2) { + setState(() => _step++); + if (_step == 2) _runTest(); + } else { + _save(); + } + } + + Future _runTest() async { + final setup = context.read(); + final service = context.read(); + // Assigne temporairement pour le test + setup.assign(widget.role, _selectedThing!, _params); + await setup.testConnection(widget.role, service); + if (mounted) setState(() {}); + } + + void _save() { + context + .read() + .assign(widget.role, _selectedThing!, _params); + Navigator.pop(context); + } + + // ── Contenu des étapes ──────────────────────────────────────────────────── + Widget _stepContent(BuildContext context) { + return switch (_step) { + 0 => _Step1ThingList( + role: widget.role, + selected: _selectedThing, + searchQuery: _searchQuery, + onSearch: (q) => setState(() => _searchQuery = q), + onSelect: (t) => setState(() => _selectedThing = t), + ), + 1 => _Step2Params( + role: widget.role, + params: _params, + things: context.read().things, + onChange: (k, v) => setState(() => _params[k] = v), + ), + _ => _Step3Test( + role: widget.role, + onRetry: _runTest, + onSkip: _save, + ), + }; + } +} + +// ── Étape 1 — Choix du thing ────────────────────────────────────────────────── +class _Step1ThingList extends StatelessWidget { + final EmsRole role; + final NymeaThing? selected; + final String searchQuery; + final ValueChanged onSearch; + final ValueChanged onSelect; + + const _Step1ThingList({ + required this.role, + required this.selected, + required this.searchQuery, + required this.onSearch, + required this.onSelect, + }); + + /// Retourne le label d'interface le plus pertinent d'un thing pour ce rôle. + String _ifaceLabel(NymeaThing thing, NymeaService service) { + try { + final cls = service.thingClasses + .firstWhere((c) => c.id == thing.thingClassId); + for (final iface in role.compatibleInterfaces) { + if (cls.interfaces.any((i) => i.toLowerCase() == iface)) { + return role.interfaceLabelFor(iface); + } + } + return cls.interfaces.firstOrNull ?? ''; + } catch (_) { + return ''; + } + } + + @override + Widget build(BuildContext context) { + final setup = context.read(); + final service = context.read(); + final things = setup.compatibleThings(role, service) + .where((t) => searchQuery.isEmpty || + t.name.toLowerCase().contains(searchQuery.toLowerCase())) + .toList(); + + return Column( + children: [ + // Barre de recherche + TextField( + decoration: InputDecoration( + hintText: 'Rechercher un appareil...', + prefixIcon: const Icon(Icons.search_rounded, size: 20), + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12), + borderSide: BorderSide.none, + ), + contentPadding: const EdgeInsets.symmetric(vertical: 10), + ), + onChanged: onSearch, + ), + const SizedBox(height: 16), + + if (things.isEmpty) + Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: Column( + children: [ + Icon(Icons.device_unknown_rounded, + size: 48, color: Colors.grey.shade400), + const SizedBox(height: 12), + Text( + searchQuery.isEmpty + ? 'Aucun appareil compatible pour ce rôle' + : 'Aucun résultat pour "$searchQuery"', + textAlign: TextAlign.center, + style: const TextStyle(color: AppTheme.textLight), + ), + ], + ), + ) + else + ...things.map((t) { + final isSelected = selected?.id == t.id; + return _ThingTile( + thing: t, + ifaceLabel: _ifaceLabel(t, service), + isSelected: isSelected, + onTap: () => onSelect(t), + ); + }), + const SizedBox(height: 16), + ], + ); + } +} + +class _ThingTile extends StatelessWidget { + final NymeaThing thing; + final String ifaceLabel; + final bool isSelected; + final VoidCallback onTap; + + const _ThingTile({ + required this.thing, + required this.ifaceLabel, + required this.isSelected, + required this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Container( + margin: const EdgeInsets.only(bottom: 8), + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: isSelected + ? ETMTheme.accentColor.withValues(alpha: 0.08) + : Colors.grey.shade50, + borderRadius: BorderRadius.circular(12), + border: Border.all( + color: isSelected + ? ETMTheme.accentColor + : Colors.grey.shade200, + ), + ), + child: Row( + children: [ + Container( + width: 40, height: 40, + decoration: BoxDecoration( + color: Colors.grey.shade100, + borderRadius: BorderRadius.circular(10), + ), + child: const Icon(Icons.devices_rounded, + color: AppTheme.textLight), + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(thing.name, + style: const TextStyle( + fontWeight: FontWeight.w600, fontSize: 13)), + Text( + ifaceLabel.isNotEmpty ? ifaceLabel : thing.thingClassId, + style: const TextStyle( + fontSize: 11, color: AppTheme.textLight), + ), + ], + ), + ), + if (isSelected) + const Icon(Icons.check_circle_rounded, + color: ETMTheme.accentColor, size: 20), + ], + ), + ), + ); + } +} + +// ── Étape 2 — Paramètres ────────────────────────────────────────────────────── +class _Step2Params extends StatelessWidget { + final EmsRole role; + final Map params; + final List things; + final void Function(String, dynamic) onChange; + + const _Step2Params({ + required this.role, + required this.params, + required this.things, + required this.onChange, + }); + + @override + Widget build(BuildContext context) { + return Column( + children: [ + ...switch (role) { + EmsRole.evCharger => _evChargerParams(), + EmsRole.dhw => _relayParams(), + EmsRole.heatPump => _heatPumpParams(things), + EmsRole.battery => _batteryParams(), + EmsRole.solarMeter => _solarMeterParams(), + EmsRole.gridMeter => _gridMeterParams(), + }, + const SizedBox(height: 16), + ], + ); + } + + List _evChargerParams() => [ + _NumField('Courant min (A)', 'minA', params, onChange, min: 6, max: 16), + _NumField('Courant max (A)', 'maxA', params, onChange, min: 6, max: 32), + _PriorityDropdown(params, onChange), + ]; + + List _relayParams() => [ + _NumField('Puissance nominale (W)', 'powerW', params, onChange, + min: 100, max: 20000), + _PriorityDropdown(params, onChange), + ]; + + List _heatPumpParams(List things) => [ + _NumField('Puissance normale (W)', 'powerW', params, onChange, + min: 500, max: 20000), + _PriorityDropdown(params, onChange), + ]; + + List _batteryParams() => [ + _NumField('Capacité (Wh)', 'capacityWh', params, onChange, + min: 1000, max: 100000), + _NumField('Puissance max charge (W)', 'maxChargeW', params, onChange, + min: 100, max: 20000), + _NumField('Puissance max décharge (W)', 'maxDischargeW', params, onChange, + min: 100, max: 20000), + ]; + + List _solarMeterParams() => [ + _NumField('Puissance crête onduleur (W)', 'powerW', params, onChange, + min: 500, max: 100000), + ]; + + List _gridMeterParams() => [ + _NumField('Puissance de coupure (kVA)', 'breakerKVA', params, onChange, + min: 3, max: 630), + ]; +} + +class _NumField extends StatelessWidget { + final String label; + final String paramKey; + final Map params; + final void Function(String, dynamic) onChange; + final double min; + final double max; + + const _NumField(this.label, this.paramKey, this.params, this.onChange, + {required this.min, required this.max}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: TextFormField( + initialValue: params[paramKey]?.toString() ?? '', + decoration: InputDecoration( + labelText: label, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12)), + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 12), + ), + keyboardType: const TextInputType.numberWithOptions(decimal: true), + onChanged: (v) { + final n = num.tryParse(v); + if (n != null) onChange(paramKey, n); + }, + ), + ); + } +} + +class _PriorityDropdown extends StatelessWidget { + final Map params; + final void Function(String, dynamic) onChange; + + const _PriorityDropdown(this.params, this.onChange); + + @override + Widget build(BuildContext context) { + final priorities = ['Critical', 'High', 'Normal', 'Low']; + return Padding( + padding: const EdgeInsets.only(bottom: 16), + child: DropdownButtonFormField( + value: params['priority'] as String? ?? 'Normal', + decoration: InputDecoration( + labelText: 'Priorité', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12)), + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 12), + ), + items: priorities + .map((p) => DropdownMenuItem(value: p, child: Text(p))) + .toList(), + onChanged: (v) { if (v != null) onChange('priority', v); }, + ), + ); + } +} + +// ── Étape 3 — Test de connexion ─────────────────────────────────────────────── +class _Step3Test extends StatelessWidget { + final EmsRole role; + final VoidCallback onRetry; + final VoidCallback onSkip; + + const _Step3Test({ + required this.role, + required this.onRetry, + required this.onSkip, + }); + + @override + Widget build(BuildContext context) { + final result = context.watch().testResult; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 32), + child: switch (result.status) { + ConnectionTestStatus.idle || ConnectionTestStatus.testing => Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + const CircularProgressIndicator(), + const SizedBox(height: 20), + const Text( + 'Test de connexion en cours...', + style: TextStyle(fontSize: 15), + ), + ], + ), + ConnectionTestStatus.success => Column( + children: [ + Container( + width: 60, height: 60, + decoration: BoxDecoration( + color: AppTheme.primaryGreen.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: const Icon(Icons.check_rounded, + color: AppTheme.primaryGreen, size: 32), + ), + const SizedBox(height: 16), + const Text('Connexion réussie', + style: TextStyle( + fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Text( + result.message ?? '', + textAlign: TextAlign.center, + style: const TextStyle(color: AppTheme.textLight), + ), + ], + ), + ConnectionTestStatus.failure => Column( + children: [ + Container( + width: 60, height: 60, + decoration: BoxDecoration( + color: AppTheme.boostRed.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: const Icon(Icons.close_rounded, + color: AppTheme.boostRed, size: 32), + ), + const SizedBox(height: 16), + const Text('Échec de la connexion', + style: TextStyle( + fontSize: 16, fontWeight: FontWeight.bold)), + const SizedBox(height: 8), + Text( + result.message ?? + "L'appareil n'a pas répondu dans les 5 secondes", + textAlign: TextAlign.center, + style: const TextStyle(color: AppTheme.textLight), + ), + const SizedBox(height: 24), + ElevatedButton.icon( + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('Réessayer'), + style: ElevatedButton.styleFrom( + backgroundColor: ETMTheme.accentColor, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + ), + onPressed: onRetry, + ), + const SizedBox(height: 10), + TextButton( + onPressed: onSkip, + child: const Text('Configurer quand même'), + ), + ], + ), + }, + ); + } +} + +// ── Composants partagés ─────────────────────────────────────────────────────── +class _StepDots extends StatelessWidget { + final int current; + final int total; + const _StepDots({required this.current, required this.total}); + + @override + Widget build(BuildContext context) { + return Row( + children: List.generate(total, (i) { + final active = i == current; + return Container( + width: active ? 16 : 8, + height: 8, + margin: const EdgeInsets.only(left: 4), + decoration: BoxDecoration( + color: active ? ETMTheme.accentColor : Colors.grey.shade300, + borderRadius: BorderRadius.circular(4), + ), + ); + }), + ); + } +} + +class _StepButtons extends StatelessWidget { + final int step; + final bool canNext; + final VoidCallback? onBack; + final VoidCallback? onNext; + final VoidCallback onCancel; + + const _StepButtons({ + required this.step, + required this.canNext, + required this.onBack, + required this.onNext, + required this.onCancel, + }); + + @override + Widget build(BuildContext context) { + final result = context.watch().testResult; + final isTesting = + result.status == ConnectionTestStatus.testing; + + return Padding( + padding: const EdgeInsets.fromLTRB(20, 8, 20, 20), + child: Row( + children: [ + if (onBack != null) + TextButton( + onPressed: onBack, + child: const Text('← Retour'), + ) + else + TextButton( + onPressed: onCancel, + child: const Text('Annuler'), + ), + const Spacer(), + if (step < 2 || result.status == ConnectionTestStatus.success) + ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: ETMTheme.accentColor, + foregroundColor: Colors.white, + disabledBackgroundColor: Colors.grey.shade200, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric( + horizontal: 24, vertical: 12), + ), + onPressed: isTesting ? null : onNext, + child: Text(step == 2 ? 'Terminer' : 'Suivant →'), + ), + ], + ), + ); + } +} diff --git a/lib/screens/energy/scheduler_screen.dart b/lib/screens/energy/scheduler_screen.dart new file mode 100644 index 0000000..82eb329 --- /dev/null +++ b/lib/screens/energy/scheduler_screen.dart @@ -0,0 +1,400 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../providers/scheduler_provider.dart'; +import '../../services/nymea_service.dart'; +import '../../theme/app_theme.dart'; +import '../../theme/etm_theme.dart'; +import '../../widgets/pro_lock_badge.dart'; + +/// Écran "Scheduler & stratégie". +class SchedulerScreen extends StatefulWidget { + const SchedulerScreen({super.key}); + + @override + State createState() => _SchedulerScreenState(); +} + +class _SchedulerScreenState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().load(context.read()); + }); + } + + @override + Widget build(BuildContext context) { + final scheduler = context.watch(); + + return Scaffold( + backgroundColor: const Color(0xFFF0F2F5), + appBar: AppBar( + title: const Text('Scheduler & stratégie'), + backgroundColor: Colors.white, + foregroundColor: const Color(0xFF1A1A2E), + elevation: 0, + ), + body: scheduler.loading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.all(16), + children: [ + _StrategyCard(scheduler), + const SizedBox(height: 12), + _ConfigCard(scheduler), + const SizedBox(height: 12), + _StatusCard(scheduler), + ], + ), + ); + } +} + +// ── Carte stratégie ─────────────────────────────────────────────────────────── +class _StrategyCard extends StatelessWidget { + final SchedulerProvider scheduler; + const _StrategyCard(this.scheduler); + + @override + Widget build(BuildContext context) { + return _Card( + title: 'Stratégie active', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + DropdownButtonFormField( + value: scheduler.strategy.isPro + ? SchedulerStrategy.rulesBased + : scheduler.strategy, + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12)), + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 12), + ), + items: SchedulerStrategy.values.map((s) { + return DropdownMenuItem( + value: s.isPro ? null : s, + enabled: !s.isPro, + child: Row( + children: [ + Text(s.label), + if (s.isPro) ...[ + const SizedBox(width: 8), + const ProLockBadge(featureName: 'Stratégie AI'), + ], + ], + ), + ); + }).toList(), + onChanged: (v) { + if (v != null) { + context + .read() + .setStrategy(v); + } + }, + ), + ], + ), + ); + } +} + +// ── Carte paramètres ────────────────────────────────────────────────────────── +class _ConfigCard extends StatelessWidget { + final SchedulerProvider scheduler; + const _ConfigCard(this.scheduler); + + @override + Widget build(BuildContext context) { + final cfg = scheduler.config; + + return _Card( + title: 'Paramètres', + child: Column( + children: [ + _ConfigRow( + label: 'Seuil prix recharge', + value: cfg.priceThreshold, + unit: '€/kWh', + min: 0.01, max: 0.5, decimals: 3, + onChanged: (v) => context + .read() + .updateConfig(cfg.copyWith(priceThreshold: v)), + ), + _ConfigRow( + label: 'Surplus minimum', + value: cfg.minSurplus, + unit: 'W', + min: 50, max: 2000, + onChanged: (v) => context + .read() + .updateConfig(cfg.copyWith(minSurplus: v)), + ), + _ConfigRow( + label: 'Horizon planification', + value: cfg.planningHorizon.toDouble(), + unit: 'h', + min: 1, max: 72, + onChanged: (v) => context + .read() + .updateConfig(cfg.copyWith(planningHorizon: v.toInt())), + ), + _ConfigRow( + label: 'Recalcul toutes les', + value: cfg.recalcInterval.toDouble(), + unit: 'min', + min: 5, max: 60, + onChanged: (v) => context + .read() + .updateConfig(cfg.copyWith(recalcInterval: v.toInt())), + ), + _ConfigRow( + label: 'Objectif autosuffisance', + value: cfg.selfSufficiencyGoal, + unit: '%', + min: 0, max: 100, + onChanged: (v) => context + .read() + .updateConfig(cfg.copyWith(selfSufficiencyGoal: v)), + ), + ], + ), + ); + } +} + +class _ConfigRow extends StatefulWidget { + final String label; + final double value; + final String unit; + final double min; + final double max; + final int decimals; + final ValueChanged onChanged; + + const _ConfigRow({ + required this.label, + required this.value, + required this.unit, + required this.min, + required this.max, + required this.onChanged, + this.decimals = 0, + }); + + @override + State<_ConfigRow> createState() => _ConfigRowState(); +} + +class _ConfigRowState extends State<_ConfigRow> { + late TextEditingController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = TextEditingController( + text: widget.value.toStringAsFixed(widget.decimals)); + } + + @override + void didUpdateWidget(_ConfigRow old) { + super.didUpdateWidget(old); + final formatted = widget.value.toStringAsFixed(widget.decimals); + if (_ctrl.text != formatted) _ctrl.text = formatted; + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Expanded( + child: Text(widget.label, + style: const TextStyle(fontSize: 13)), + ), + SizedBox( + width: 80, + child: TextFormField( + controller: _ctrl, + textAlign: TextAlign.center, + keyboardType: const TextInputType.numberWithOptions( + decimal: true), + decoration: InputDecoration( + isDense: true, + filled: true, + fillColor: Colors.grey.shade50, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8)), + contentPadding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 8), + ), + onFieldSubmitted: (v) { + final n = double.tryParse(v); + if (n != null) { + widget.onChanged(n.clamp(widget.min, widget.max)); + } + }, + ), + ), + const SizedBox(width: 6), + Text(widget.unit, + style: const TextStyle( + fontSize: 12, color: AppTheme.textLight)), + ], + ), + ); + } +} + +// ── Carte état du scheduler ─────────────────────────────────────────────────── +class _StatusCard extends StatelessWidget { + final SchedulerProvider scheduler; + const _StatusCard(this.scheduler); + + @override + Widget build(BuildContext context) { + final state = scheduler.state; + final statusColor = switch (state.status) { + SchedulerStatus.ok => AppTheme.primaryGreen, + SchedulerStatus.degraded => Colors.orange, + SchedulerStatus.error => AppTheme.boostRed, + }; + final statusLabel = switch (state.status) { + SchedulerStatus.ok => '✅ OK', + SchedulerStatus.degraded => '⚠️ Dégradé', + SchedulerStatus.error => '❌ Erreur', + }; + + String _ago(DateTime? dt) { + if (dt == null) return '—'; + final diff = DateTime.now().difference(dt); + if (diff.inMinutes < 1) return 'à l\'instant'; + return 'il y a ${diff.inMinutes} min'; + } + + String _in(DateTime? dt) { + if (dt == null) return '—'; + final diff = dt.difference(DateTime.now()); + if (diff.isNegative) return 'dépassé'; + return 'dans ${diff.inMinutes} min'; + } + + return _Card( + title: 'État du scheduler', + trailing: ElevatedButton.icon( + icon: scheduler.loading + ? const SizedBox( + width: 14, height: 14, + child: CircularProgressIndicator( + strokeWidth: 2, color: Colors.white)) + : const Icon(Icons.refresh_rounded, size: 16), + label: const Text('Forcer recalcul'), + style: ElevatedButton.styleFrom( + backgroundColor: ETMTheme.accentColor, + foregroundColor: Colors.white, + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + textStyle: const TextStyle(fontSize: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + ), + onPressed: () => context + .read() + .forceRecalc(context.read()), + ), + child: Column( + children: [ + _StatusRow('Dernière planification', _ago(state.lastPlanning)), + _StatusRow('Prochaine planification', _in(state.nextPlanning)), + Row( + children: [ + const Expanded( + child: Text('État', style: TextStyle(fontSize: 13))), + Text(statusLabel, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: statusColor)), + ], + ), + if (state.reason.isNotEmpty) ...[ + const SizedBox(height: 6), + Text( + 'Raison : ${state.reason}', + style: const TextStyle( + fontSize: 12, color: AppTheme.textLight), + ), + ], + ], + ), + ); + } +} + +class _StatusRow extends StatelessWidget { + final String label; + final String value; + const _StatusRow(this.label, this.value); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 6), + child: Row( + children: [ + Expanded( + child: Text(label, + style: const TextStyle(fontSize: 13))), + Text(value, + style: const TextStyle( + fontSize: 13, + color: AppTheme.textLight)), + ], + ), + ); + } +} + +// ── Card générique ──────────────────────────────────────────────────────────── +class _Card extends StatelessWidget { + final String title; + final Widget child; + final Widget? trailing; + + const _Card({required this.title, required this.child, this.trailing}); + + @override + Widget build(BuildContext context) { + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Text(title, + style: const TextStyle( + fontWeight: FontWeight.bold, fontSize: 14)), + const Spacer(), + if (trailing != null) trailing!, + ], + ), + const SizedBox(height: 14), + child, + ], + ), + ), + ); + } +} diff --git a/lib/screens/energy/tariff_screen.dart b/lib/screens/energy/tariff_screen.dart new file mode 100644 index 0000000..22a41a8 --- /dev/null +++ b/lib/screens/energy/tariff_screen.dart @@ -0,0 +1,537 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../providers/tariff_provider.dart'; +import '../../theme/app_theme.dart'; +import '../../theme/etm_theme.dart'; +import '../../widgets/pro_lock_badge.dart'; + +/// Écran "Tarifs & providers". +class TariffScreen extends StatefulWidget { + const TariffScreen({super.key}); + + @override + State createState() => _TariffScreenState(); +} + +class _TariffScreenState extends State { + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) { + context.read().load(); + }); + } + + @override + Widget build(BuildContext context) { + final tariff = context.watch(); + + return Scaffold( + backgroundColor: const Color(0xFFF0F2F5), + appBar: AppBar( + title: const Text('Tarifs & providers'), + backgroundColor: Colors.white, + foregroundColor: const Color(0xFF1A1A2E), + elevation: 0, + ), + body: tariff.loading + ? const Center(child: CircularProgressIndicator()) + : ListView( + padding: const EdgeInsets.all(16), + children: [ + _ProviderCard(tariff), + // Carte tarif manuel uniquement si provider = manual + if (tariff.activeProvider == TariffProviderType.manual) ...[ + const SizedBox(height: 12), + _ManualTariffCard(tariff), + ] else ...[ + const SizedBox(height: 12), + _ModeCard(tariff), + if (tariff.mode == TariffMode.hchp) ...[ + const SizedBox(height: 12), + _HcHpCard(tariff), + ], + const SizedBox(height: 12), + _PricesCard(tariff), + ], + ], + ), + ); + } +} + +// ── Carte provider ──────────────────────────────────────────────────────────── +class _ProviderCard extends StatelessWidget { + final TariffProvider tariff; + const _ProviderCard(this.tariff); + + @override + Widget build(BuildContext context) { + return _SectionCard( + title: 'Provider actif', + child: Column( + children: TariffProviderType.values.map((p) { + final selected = tariff.activeProvider == p; + return _ProviderTile( + provider: p, + selected: selected, + onTap: p.isPro + ? null + : () => context.read().setProvider(p), + ); + }).toList(), + ), + ); + } +} + +class _ProviderTile extends StatelessWidget { + final TariffProviderType provider; + final bool selected; + final VoidCallback? onTap; + + const _ProviderTile({ + required this.provider, + required this.selected, + this.onTap, + }); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: onTap, + child: Opacity( + opacity: provider.isPro ? 0.6 : 1.0, + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Icon( + selected + ? Icons.check_circle_rounded + : Icons.radio_button_unchecked_rounded, + color: selected ? AppTheme.primaryGreen : Colors.grey, + size: 20, + ), + const SizedBox(width: 12), + Expanded( + child: Text(provider.label, + style: const TextStyle(fontSize: 13)), + ), + if (provider.isPro) + ProLockBadge(featureName: provider.label), + ], + ), + ), + ), + ); + } +} + +// ── Carte mode tarifaire ────────────────────────────────────────────────────── +class _ModeCard extends StatelessWidget { + final TariffProvider tariff; + const _ModeCard(this.tariff); + + @override + Widget build(BuildContext context) { + return _SectionCard( + title: 'Mode tarifaire', + child: DropdownButtonFormField( + value: tariff.mode, + decoration: InputDecoration( + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12)), + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 12), + ), + items: TariffMode.values + .map((m) => + DropdownMenuItem(value: m, child: Text(m.label))) + .toList(), + onChanged: (v) { + if (v != null) context.read().setMode(v); + }, + ), + ); + } +} + +// ── Carte HC/HP ─────────────────────────────────────────────────────────────── +class _HcHpCard extends StatelessWidget { + final TariffProvider tariff; + const _HcHpCard(this.tariff); + + @override + Widget build(BuildContext context) { + final hchp = tariff.hchp; + + return _SectionCard( + title: 'Tarif HC/HP', + child: Column( + children: [ + _PriceRow( + label: 'Prix HC', + value: hchp.offPeakPrice, + unit: '€/kWh', + onChanged: (v) => context + .read() + .updateHcHp(hchp.copyWith(offPeakPrice: v)), + ), + _PriceRow( + label: 'Prix HP', + value: hchp.peakPrice, + unit: '€/kWh', + onChanged: (v) => context + .read() + .updateHcHp(hchp.copyWith(peakPrice: v)), + ), + _TimeRow( + label: 'Début HC', + time: hchp.offPeakStart, + onChanged: (t) => context + .read() + .updateHcHp(hchp.copyWith(offPeakStart: t)), + ), + _TimeRow( + label: 'Fin HC', + time: hchp.offPeakEnd, + onChanged: (t) => context + .read() + .updateHcHp(hchp.copyWith(offPeakEnd: t)), + ), + ], + ), + ); + } +} + +class _PriceRow extends StatefulWidget { + final String label; + final double value; + final String unit; + final ValueChanged onChanged; + + const _PriceRow({ + required this.label, + required this.value, + required this.unit, + required this.onChanged, + }); + + @override + State<_PriceRow> createState() => _PriceRowState(); +} + +class _PriceRowState extends State<_PriceRow> { + late TextEditingController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = TextEditingController( + text: widget.value.toStringAsFixed(3)); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Expanded( + child: Text(widget.label, + style: const TextStyle(fontSize: 13)), + ), + SizedBox( + width: 90, + child: TextFormField( + controller: _ctrl, + textAlign: TextAlign.center, + keyboardType: const TextInputType.numberWithOptions( + decimal: true), + decoration: InputDecoration( + isDense: true, + filled: true, + fillColor: Colors.grey.shade50, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(8)), + contentPadding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 8), + ), + onFieldSubmitted: (v) { + final n = double.tryParse(v); + if (n != null) widget.onChanged(n); + }, + ), + ), + const SizedBox(width: 6), + Text(widget.unit, + style: const TextStyle( + fontSize: 12, color: AppTheme.textLight)), + ], + ), + ); + } +} + +class _TimeRow extends StatelessWidget { + final String label; + final TimeOfDay time; + final ValueChanged onChanged; + + const _TimeRow({ + required this.label, + required this.time, + required this.onChanged, + }); + + @override + Widget build(BuildContext context) { + final formatted = + '${time.hour.toString().padLeft(2, '0')}:${time.minute.toString().padLeft(2, '0')}'; + + return Padding( + padding: const EdgeInsets.only(bottom: 12), + child: Row( + children: [ + Expanded( + child: Text(label, + style: const TextStyle(fontSize: 13))), + GestureDetector( + onTap: () async { + final picked = await showTimePicker( + context: context, + initialTime: time, + ); + if (picked != null) onChanged(picked); + }, + child: Container( + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 8), + decoration: BoxDecoration( + border: Border.all(color: Colors.grey.shade300), + borderRadius: BorderRadius.circular(8), + color: Colors.grey.shade50, + ), + child: Text(formatted, + style: const TextStyle( + fontSize: 13, fontWeight: FontWeight.w500)), + ), + ), + ], + ), + ); + } +} + +// ── Carte tarif manuel ──────────────────────────────────────────────────────── +class _ManualTariffCard extends StatefulWidget { + final TariffProvider tariff; + const _ManualTariffCard(this.tariff); + + @override + State<_ManualTariffCard> createState() => _ManualTariffCardState(); +} + +class _ManualTariffCardState extends State<_ManualTariffCard> { + late TextEditingController _ctrl; + + @override + void initState() { + super.initState(); + _ctrl = TextEditingController( + text: widget.tariff.manualPrice.toStringAsFixed(4)); + } + + @override + void dispose() { + _ctrl.dispose(); + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return _SectionCard( + title: 'Tarif manuel', + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Prix unique appliqué à toutes les décisions du scheduler.', + style: TextStyle(fontSize: 12, color: AppTheme.textLight), + ), + const SizedBox(height: 16), + Row( + children: [ + const Expanded( + child: Text('Prix de l\'électricité', + style: TextStyle(fontSize: 13))), + SizedBox( + width: 100, + child: TextFormField( + controller: _ctrl, + textAlign: TextAlign.center, + keyboardType: const TextInputType.numberWithOptions( + decimal: true), + decoration: InputDecoration( + isDense: true, + filled: true, + fillColor: Colors.grey.shade50, + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(10)), + contentPadding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 10), + ), + onFieldSubmitted: (v) { + final n = double.tryParse(v); + if (n != null) { + context.read().setManualPrice(n); + } + }, + ), + ), + const SizedBox(width: 8), + const Text('€/kWh', + style: TextStyle(fontSize: 12, color: AppTheme.textLight)), + ], + ), + const SizedBox(height: 12), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: ETMTheme.accentColor.withValues(alpha: 0.07), + borderRadius: BorderRadius.circular(10), + ), + child: Row( + children: [ + const Icon(Icons.info_outline_rounded, + size: 15, color: ETMTheme.accentColor), + const SizedBox(width: 8), + Expanded( + child: Text( + 'Prix actuel : ${widget.tariff.manualPrice.toStringAsFixed(4)} €/kWh', + style: const TextStyle( + fontSize: 12, color: ETMTheme.accentColor), + ), + ), + ], + ), + ), + ], + ), + ); + } +} + +// ── Carte prix actuels ──────────────────────────────────────────────────────── +class _PricesCard extends StatelessWidget { + final TariffProvider tariff; + const _PricesCard(this.tariff); + + @override + Widget build(BuildContext context) { + final p = tariff.prices; + + String _fmtTime(DateTime? dt) { + if (dt == null) return ''; + return ' (${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')})'; + } + + return _SectionCard( + title: 'Prix actuels (live)', + child: Column( + children: [ + _PriceItem( + label: 'Maintenant', + value: '${p.now.toStringAsFixed(3)} €/kWh', + ), + _PriceItem( + label: 'Dans 1h', + value: '${p.inOneHour.toStringAsFixed(3)} €/kWh', + ), + _PriceItem( + label: 'Minimum 24h', + value: + '${p.min24h.toStringAsFixed(3)} €/kWh${_fmtTime(p.minTime)}', + color: AppTheme.primaryGreen, + ), + _PriceItem( + label: 'Maximum 24h', + value: + '${p.max24h.toStringAsFixed(3)} €/kWh${_fmtTime(p.maxTime)}', + color: AppTheme.boostRed, + ), + ], + ), + ); + } +} + +class _PriceItem extends StatelessWidget { + final String label; + final String value; + final Color? color; + + const _PriceItem({ + required this.label, + required this.value, + this.color, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.symmetric(vertical: 6), + child: Row( + children: [ + Expanded( + child: Text(label, + style: const TextStyle(fontSize: 13))), + Text( + value, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w600, + color: color ?? AppTheme.textDark, + ), + ), + ], + ), + ); + } +} + +// ── Card générique ──────────────────────────────────────────────────────────── +class _SectionCard extends StatelessWidget { + final String title; + final Widget child; + + const _SectionCard({required this.title, required this.child}); + + @override + Widget build(BuildContext context) { + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: const TextStyle( + fontWeight: FontWeight.bold, fontSize: 14)), + const SizedBox(height: 14), + child, + ], + ), + ), + ); + } +} diff --git a/lib/screens/energy/timeline_screen.dart b/lib/screens/energy/timeline_screen.dart new file mode 100644 index 0000000..5e7bad0 --- /dev/null +++ b/lib/screens/energy/timeline_screen.dart @@ -0,0 +1,432 @@ +import 'dart:async'; +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../services/nymea_service.dart'; +import '../../theme/app_theme.dart'; +import '../../theme/etm_theme.dart'; +import '../../widgets/timeline_slot_card.dart'; + +/// Écran "Timeline & décisions" — vue horaire des décisions EMS. +class TimelineScreen extends StatefulWidget { + const TimelineScreen({super.key}); + + @override + State createState() => _TimelineScreenState(); +} + +class _TimelineScreenState extends State { + String _horizon = '24h'; + bool _loading = true; + List _slots = []; + Timer? _autoRefresh; + + static const _horizons = ['12h', '24h', '48h']; + + @override + void initState() { + super.initState(); + WidgetsBinding.instance.addPostFrameCallback((_) => _load()); + // Auto-refresh toutes les minutes + _autoRefresh = Timer.periodic(const Duration(minutes: 1), (_) { + if (mounted) _load(silent: true); + }); + } + + @override + void dispose() { + _autoRefresh?.cancel(); + super.dispose(); + } + + Future _load({bool silent = false}) async { + if (!mounted) return; + if (!silent) setState(() => _loading = true); + // Génère des créneaux de démonstration (à brancher sur GetEnergyTimeline) + await Future.delayed(const Duration(milliseconds: 400)); + if (!mounted) return; + setState(() { + _slots = _generateDemoSlots(); + _loading = false; + }); + } + + List _generateDemoSlots() { + final now = DateTime.now(); + final start = now.subtract(Duration(hours: now.hour)).copyWith( + minute: 0, second: 0, microsecond: 0); + final hours = _horizonHours(); + final slots = []; + + for (int i = 0; i < hours; i++) { + final slotStart = start.add(Duration(hours: i)); + final isNow = now.isAfter(slotStart) && + now.isBefore(slotStart.add(const Duration(hours: 1))); + + // Simulation d'une production PV en cloche + final h = slotStart.hour.toDouble(); + final solar = (h >= 7 && h <= 19) + ? (2800 * _bell(h, 13, 4)).clamp(0.0, 5000.0) + : 0.0; + final home = 800 + (i % 3) * 200.0; + final evW = (h >= 10 && h <= 15) ? 1200.0 : 0.0; + final battery = (solar > home + evW) ? (solar - home - evW).clamp(0.0, 3000.0) : 0.0; + final grid = (solar - home - evW - battery); + + slots.add(TimelineSlot( + start: slotStart, + end: slotStart.add(const Duration(hours: 1)), + solarW: solar, + homeW: home, + evW: evW, + batteryW: battery, + gridW: grid, + reasoning: _reasoning(solar, home, evW, battery, grid), + savings: grid < 0 ? grid.abs() / 1000 * 0.12 : 0, + selfSufficiency: solar > 0 + ? (solar / (home + evW.abs())).clamp(0.0, 1.5) * 100 + : 0, + isNow: isNow, + )); + } + return slots; + } + + double _bell(double x, double mu, double sigma) { + final d = (x - mu) / sigma; + return 1.0 / (sigma * 2.5066) * (1 / (1 + d * d)); + } + + String _reasoning(double solar, double home, double ev, + double battery, double grid) { + if (solar > home + ev + 500) { + return 'Surplus solaire ${_kw(solar - home - ev)} — ' + '${battery > 0 ? 'charge batterie + ' : ''}' + '${ev > 0 ? 'recharge VE' : 'export réseau'}'; + } else if (solar > 0 && solar >= home * 0.8) { + return 'Solaire ≈ consommation de base — ' + '${ev == 0 ? 'VE en pause' : 'recharge VE en cours'}'; + } else { + return 'Faible production solaire — import réseau ${_kw(grid.abs())}'; + } + } + + String _kw(double w) { + return w >= 1000 + ? '+${(w / 1000).toStringAsFixed(1)} kW' + : '+${w.toStringAsFixed(0)} W'; + } + + int _horizonHours() => switch (_horizon) { + '12h' => 12, + '48h' => 48, + _ => 24, + }; + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFFF0F2F5), + appBar: AppBar( + title: const Text('Timeline & décisions'), + backgroundColor: Colors.white, + foregroundColor: const Color(0xFF1A1A2E), + elevation: 0, + actions: [ + // Sélecteur d'horizon + Padding( + padding: const EdgeInsets.only(right: 4), + child: DropdownButton( + value: _horizon, + underline: const SizedBox.shrink(), + style: const TextStyle( + fontSize: 13, + color: Color(0xFF1A1A2E), + fontWeight: FontWeight.w500), + items: _horizons + .map((h) => + DropdownMenuItem(value: h, child: Text(h))) + .toList(), + onChanged: (v) { + if (v != null && v != _horizon) { + setState(() => _horizon = v); + _load(); + } + }, + ), + ), + // Bouton actualiser + IconButton( + icon: const Icon(Icons.refresh_rounded), + tooltip: 'Actualiser', + onPressed: _load, + ), + ], + ), + body: _loading + ? const Center(child: CircularProgressIndicator()) + : _slots.isEmpty + ? _EmptyState(onRetry: _load) + : ListView.builder( + padding: const EdgeInsets.symmetric(vertical: 8), + itemCount: _slots.length, + itemBuilder: (context, i) { + final slot = _slots[i]; + return TimelineSlotCard( + slot: slot, + onOverride: () => _showOverrideSheet(context, slot), + ); + }, + ), + ); + } + + void _showOverrideSheet(BuildContext context, TimelineSlot slot) { + showModalBottomSheet( + context: context, + isScrollControlled: true, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => _OverrideSheet(slot: slot), + ); + } +} + +// ── Feuille d'override ──────────────────────────────────────────────────────── +class _OverrideSheet extends StatefulWidget { + final TimelineSlot slot; + const _OverrideSheet({required this.slot}); + + @override + State<_OverrideSheet> createState() => _OverrideSheetState(); +} + +class _OverrideSheetState extends State<_OverrideSheet> { + double _evW = 0; + bool _dhwOn = false; + double _batW = 0; // >0 charge, <0 décharge + String _reason = ''; + + @override + void initState() { + super.initState(); + _evW = widget.slot.evW; + _batW = widget.slot.batteryW; + } + + @override + Widget build(BuildContext context) { + return Padding( + padding: EdgeInsets.only( + bottom: MediaQuery.of(context).viewInsets.bottom), + child: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.all(20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + mainAxisSize: MainAxisSize.min, + children: [ + const Center( + child: _DragHandle(), + ), + const SizedBox(height: 16), + + const Text('Modifier ce créneau', + style: TextStyle( + fontSize: 17, fontWeight: FontWeight.bold)), + const SizedBox(height: 4), + Text( + '${_fmt(widget.slot.start)} – ${_fmt(widget.slot.end)}', + style: const TextStyle( + fontSize: 13, color: AppTheme.textLight), + ), + const SizedBox(height: 20), + + // VE Charger + if (widget.slot.evW >= 0) ...[ + const Text('🚗 VE Charger', + style: TextStyle( + fontWeight: FontWeight.w600, fontSize: 13)), + const SizedBox(height: 6), + Row( + children: [ + const Text('0 W', style: TextStyle(fontSize: 11)), + Expanded( + child: Slider( + value: _evW, + min: 0, max: 7400, + divisions: 37, + label: '${_evW.toStringAsFixed(0)} W', + activeColor: AppTheme.accentTeal, + onChanged: (v) => setState(() => _evW = v), + ), + ), + const Text('7.4 kW', style: TextStyle(fontSize: 11)), + ], + ), + const SizedBox(height: 12), + ], + + // Chauffe-eau + Row( + children: [ + const Text('🌡️ Chauffe-eau', + style: TextStyle( + fontWeight: FontWeight.w600, fontSize: 13)), + const Spacer(), + Switch.adaptive( + value: _dhwOn, + activeColor: AppTheme.primaryGreen, + onChanged: (v) => setState(() => _dhwOn = v), + ), + ], + ), + const SizedBox(height: 12), + + // Batterie + const Text('🔋 Batterie', + style: TextStyle( + fontWeight: FontWeight.w600, fontSize: 13)), + const SizedBox(height: 6), + Row( + children: [ + const Text('Décharge', + style: TextStyle(fontSize: 11)), + Expanded( + child: Slider( + value: _batW, + min: -3000, max: 3000, + divisions: 30, + label: _batW == 0 + ? '0 (pause)' + : '${_batW > 0 ? '+' : ''}${_batW.toStringAsFixed(0)} W', + activeColor: AppTheme.batteryGreen, + onChanged: (v) => setState(() => _batW = v), + ), + ), + const Text('Charge', + style: TextStyle(fontSize: 11)), + ], + ), + const SizedBox(height: 16), + + // Raison + TextFormField( + initialValue: _reason, + decoration: InputDecoration( + labelText: 'Raison (optionnel)', + hintText: 'Ex. Départ annulé ce soir', + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(12)), + contentPadding: const EdgeInsets.symmetric( + horizontal: 14, vertical: 12), + ), + onChanged: (v) => _reason = v, + ), + const SizedBox(height: 20), + + // Boutons + Row( + children: [ + Expanded( + child: OutlinedButton( + onPressed: () => Navigator.pop(context), + style: OutlinedButton.styleFrom( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + padding: + const EdgeInsets.symmetric(vertical: 14), + ), + child: const Text('Annuler'), + ), + ), + const SizedBox(width: 12), + Expanded( + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: ETMTheme.accentColor, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + padding: + const EdgeInsets.symmetric(vertical: 14), + ), + onPressed: () { + Navigator.pop(context); + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('Override appliqué'), + behavior: SnackBarBehavior.floating, + ), + ); + }, + child: const Text('Appliquer'), + ), + ), + ], + ), + const SizedBox(height: 8), + ], + ), + ), + ), + ); + } + + String _fmt(DateTime dt) => + '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; +} + +class _DragHandle extends StatelessWidget { + const _DragHandle(); + + @override + Widget build(BuildContext context) { + return Container( + width: 36, height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ); + } +} + +class _EmptyState extends StatelessWidget { + final VoidCallback onRetry; + const _EmptyState({required this.onRetry}); + + @override + Widget build(BuildContext context) { + return Center( + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Icon(Icons.schedule_rounded, + size: 48, color: Colors.grey.shade400), + const SizedBox(height: 16), + const Text('Aucune donnée de timeline', + style: TextStyle(fontSize: 15)), + const SizedBox(height: 8), + const Text( + 'Le scheduler n\'a pas encore généré de planification.', + textAlign: TextAlign.center, + style: TextStyle(color: AppTheme.textLight), + ), + const SizedBox(height: 16), + ElevatedButton.icon( + icon: const Icon(Icons.refresh_rounded, size: 18), + label: const Text('Réessayer'), + style: ElevatedButton.styleFrom( + backgroundColor: ETMTheme.accentColor, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + ), + onPressed: onRetry, + ), + ], + ), + ); + } +} diff --git a/lib/screens/energy_screen.dart b/lib/screens/energy_screen.dart index 8afaf10..b9aceb4 100644 --- a/lib/screens/energy_screen.dart +++ b/lib/screens/energy_screen.dart @@ -1,10 +1,13 @@ +import 'dart:async'; import 'dart:math' show max, min; import 'package:fl_chart/fl_chart.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/energy_data.dart'; +import '../models/nymea_models.dart'; import '../services/nymea_service.dart'; import '../theme/app_theme.dart'; +import '../main.dart' show DrawerMenuButton; // ───────────────────────────────────────────────────────────────────────────── // EnergyScreen — historique énergétique @@ -42,14 +45,72 @@ class _EnergyScreenState extends State { int _tabIdx = 0; List _data = []; + List _socData = []; // historique SOC batterie (%) bool _loading = true; // true dès le départ → spinner jusqu'au premier fetch bool _noData = false; DateTime? _selectedDate; // null = aujourd'hui (date courante) + Timer? _refreshTimer; + + // Fix IndexedStack : initState de tous les écrans se déclenche au démarrage, + // avant que les things soient chargés → on ajoute un listener pour re-fetcher + // le SOC dès que les things deviennent disponibles. + NymeaService? _nymeaService; + bool _initialSocFetched = false; // évite les re-fetch infinis via le listener @override void initState() { super.initState(); - WidgetsBinding.instance.addPostFrameCallback((_) => _fetch()); + WidgetsBinding.instance.addPostFrameCallback((_) { + _nymeaService = context.read(); + _nymeaService!.addListener(_onServiceChangedForSoc); + _fetch(); + }); + // Rafraîchit le graphe toutes les 5 minutes pour garder les données à jour + _refreshTimer = Timer.periodic(const Duration(minutes: 5), (_) { + if (mounted) _fetch(); + }); + } + + @override + void dispose() { + _refreshTimer?.cancel(); + _nymeaService?.removeListener(_onServiceChangedForSoc); + super.dispose(); + } + + /// Déclenche un fetch SOC silencieux quand les things deviennent disponibles + /// (cas où l'IndexedStack a construit l'écran avant la connexion nymea). + void _onServiceChangedForSoc() { + if (!mounted || _initialSocFetched || _loading) return; + if (_nymeaService?.batterySOCSource != null && _socData.isEmpty) { + _initialSocFetched = true; + _fetchSocOnly(); + } + } + + /// Récupère uniquement l'historique SOC sans re-fetcher le bilan de puissance. + Future _fetchSocOnly() async { + if (!mounted) return; + final tab = _tabs[_tabIdx]; + final to = _selectedDate != null + ? DateTime( + _selectedDate!.year, _selectedDate!.month, _selectedDate!.day, + 23, 59, 59) + : DateTime.now(); + final service = context.read(); + final socSource = service.batterySOCSource; + if (socSource == null) return; + final soc = await service.fetchHistory( + thingId: socSource['thingId']!, + stateTypeName: socSource['stateName']!, + from: to.subtract(tab.range), + to: to, + sampleRate: tab.sampleRate, + ); + if (!mounted) return; + if (soc.isNotEmpty) { + setState(() => _socData = soc); + } } Future _fetch() async { @@ -62,16 +123,34 @@ class _EnergyScreenState extends State { _selectedDate!.year, _selectedDate!.month, _selectedDate!.day, 23, 59, 59) : DateTime.now(); - final data = await context.read().fetchPowerBalanceLogs( - from: to.subtract(tab.range), - to: to, - sampleRate: tab.sampleRate, - ); + final from = to.subtract(tab.range); + final service = context.read(); + // SOC indisponible en simulation (fetchHistory retourne des W, pas des %) + final socSource = service.batterySOCSource; // null si pas de batterie + + // Récupère bilan de puissance et historique SOC en parallèle + final results = await Future.wait([ + service.fetchPowerBalanceLogs( + from: from, + to: to, + sampleRate: tab.sampleRate, + ), + socSource != null + ? service.fetchHistory( + thingId: socSource['thingId']!, + stateTypeName: socSource['stateName']!, + from: from, + to: to, + sampleRate: tab.sampleRate, + ) + : Future>.value([]), + ]); if (!mounted) return; setState(() { - _data = data; + _data = results[0] as List; + _socData = results[1] as List; _loading = false; - _noData = data.isEmpty; + _noData = _data.isEmpty; }); } @@ -92,7 +171,7 @@ class _EnergyScreenState extends State { ), ); if (picked != null && mounted) { - setState(() => _selectedDate = picked); + setState(() { _selectedDate = picked; _initialSocFetched = false; }); _fetch(); } } @@ -106,6 +185,8 @@ class _EnergyScreenState extends State { appBar: AppBar( backgroundColor: AppTheme.backgroundGray, elevation: 0, + leading: const DrawerMenuButton(), + leadingWidth: 56, title: const Text('Énergie', style: TextStyle( fontWeight: FontWeight.bold, color: AppTheme.textDark)), @@ -185,12 +266,12 @@ class _EnergyScreenState extends State { // ── ① Line chart ──────────────────────────────────────────── _ChartCard( - title: 'Puissances (W)', + title: 'Puissances (W) · SOC %', legend: const [ _LegendItem(color: AppTheme.solarYellow, label: 'Production'), _LegendItem(color: AppTheme.homeBlue, label: 'Consommation'), _LegendItem(color: AppTheme.accentTeal, label: 'Autoconso'), - _LegendItem(color: AppTheme.batteryGreen, label: 'Batterie', dashed: true), + _LegendItem(color: AppTheme.batteryGreen, label: 'SOC %', dashed: true), ], child: SizedBox( height: 200, @@ -243,7 +324,7 @@ class _EnergyScreenState extends State { child: GestureDetector( onTap: () { if (_tabIdx != e.key) { - setState(() => _tabIdx = e.key); + setState(() { _tabIdx = e.key; _initialSocFetched = false; }); _fetch(); } }, @@ -281,7 +362,7 @@ class _EnergyScreenState extends State { final prodSpots = []; final consoSpots = []; final autoSpots = []; - final batSpots = []; + final socSpots = []; // SOC % mis à l'échelle des W for (int i = 0; i < _data.length; i++) { final d = _data[i]; @@ -289,24 +370,38 @@ class _EnergyScreenState extends State { prodSpots .add(FlSpot(x, d.productionW)); consoSpots.add(FlSpot(x, d.consumptionW)); autoSpots .add(FlSpot(x, d.autoconsommationW)); - batSpots .add(FlSpot(x, d.storageW)); } - final allY = _data - .expand((d) => [d.productionW, d.consumptionW, d.storageW]) - .toList(); - final minY = allY.reduce(min); - final maxY = allY.reduce(max); + // min/max sur production + consommation (toujours ≥ 0) + final allY = _data.expand((d) => [d.productionW, d.consumptionW]).toList(); + final minY = allY.isEmpty ? 0.0 : allY.reduce(min); + final maxY = allY.isEmpty ? 1000.0 : allY.reduce(max); final spread = (maxY - minY) > 0 ? maxY - minY : 200.0; final yPad = spread * 0.12; + // Facteur d'échelle : SOC 100 % → y = socMaxW (sommet du graphe) + final socMaxW = max(maxY + yPad, 100.0); + // SOC disponible si on a au moins 2 points (pour interpoler sur l'axe X) + final hasSoc = _socData.length > 1; + + if (hasSoc) { + final n = _socData.length; + final xMax = (_data.length - 1).toDouble(); + for (int i = 0; i < n; i++) { + // Normalise l'index SOC sur la plage X du power chart + final x = xMax * i / (n - 1); + final scaled = _socData[i].value * socMaxW / 100.0; + socSpots.add(FlSpot(x, scaled)); + } + } + final xInterval = _xInterval(); return LineChart( LineChartData( clipData: const FlClipData.all(), minY: minY - yPad, - maxY: maxY + yPad, + maxY: socMaxW, gridData: FlGridData( show: true, drawVerticalLine: false, @@ -315,8 +410,7 @@ class _EnergyScreenState extends State { ), borderData: FlBorderData(show: false), titlesData: FlTitlesData( - topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), - rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), + topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)), leftTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, @@ -330,6 +424,29 @@ class _EnergyScreenState extends State { ), ), ), + // Axe Y droit : SOC % (affiché seulement si données SOC disponibles) + rightTitles: hasSoc + ? AxisTitles( + sideTitles: SideTitles( + showTitles: true, + reservedSize: 36, + // interval : socMaxW / 4 → labels à 0 / 25 / 50 / 75 / 100 % + interval: socMaxW / 4, + getTitlesWidget: (v, _) { + final pct = (v / socMaxW * 100).round(); + if (pct < 0 || pct > 100) return const SizedBox.shrink(); + return Padding( + padding: const EdgeInsets.only(left: 4), + child: Text('$pct%', + style: const TextStyle( + fontSize: 8.5, + color: AppTheme.batteryGreen), + textAlign: TextAlign.left), + ); + }, + ), + ) + : const AxisTitles(sideTitles: SideTitles(showTitles: false)), bottomTitles: AxisTitles( sideTitles: SideTitles( showTitles: true, @@ -348,20 +465,28 @@ class _EnergyScreenState extends State { ), ), lineBarsData: [ - _lineSeries(prodSpots, AppTheme.solarYellow), - _lineSeries(consoSpots, AppTheme.homeBlue), - _lineSeries(autoSpots, AppTheme.accentTeal), - _lineSeries(batSpots, AppTheme.batteryGreen, dashed: true), + _lineSeries(prodSpots, AppTheme.solarYellow), // index 0 + _lineSeries(consoSpots, AppTheme.homeBlue), // index 1 + _lineSeries(autoSpots, AppTheme.accentTeal), // index 2 + if (hasSoc) + _lineSeries(socSpots, AppTheme.batteryGreen, dashed: true), // index 3 ], lineTouchData: LineTouchData( touchTooltipData: LineTouchTooltipData( - getTooltipItems: (spots) => spots.map((s) => LineTooltipItem( - _fmtW(s.y), - TextStyle( - color: s.bar.color ?? Colors.white, - fontSize: 10, - fontWeight: FontWeight.bold), - )).toList(), + getTooltipItems: (spots) => spots.map((s) { + // Série index 3 = SOC → tooltip en % + final isSoc = hasSoc && s.barIndex == 3; + final label = isSoc + ? '${(s.y / socMaxW * 100).toStringAsFixed(0)} %' + : _fmtW(s.y); + return LineTooltipItem( + label, + TextStyle( + color: s.bar.color ?? Colors.white, + fontSize: 10, + fontWeight: FontWeight.bold), + ); + }).toList(), ), ), ), diff --git a/lib/screens/favorites_screen.dart b/lib/screens/favorites_screen.dart index d86a8d3..ce50a75 100644 --- a/lib/screens/favorites_screen.dart +++ b/lib/screens/favorites_screen.dart @@ -4,6 +4,7 @@ import '../models/energy_data.dart'; import '../models/nymea_models.dart'; import '../services/nymea_service.dart'; import '../theme/app_theme.dart'; +import '../main.dart' show DrawerMenuButton; class FavoritesScreen extends StatelessWidget { const FavoritesScreen({super.key}); @@ -19,6 +20,8 @@ class FavoritesScreen extends StatelessWidget { appBar: AppBar( backgroundColor: AppTheme.backgroundGray, elevation: 0, + leading: const DrawerMenuButton(), + leadingWidth: 56, title: const Text('Favoris', style: TextStyle( fontWeight: FontWeight.bold, diff --git a/lib/screens/settings/app_settings_screen.dart b/lib/screens/settings/app_settings_screen.dart new file mode 100644 index 0000000..54febc2 --- /dev/null +++ b/lib/screens/settings/app_settings_screen.dart @@ -0,0 +1,168 @@ +import 'package:flutter/material.dart'; +import 'package:go_router/go_router.dart'; +import 'package:provider/provider.dart'; +import '../../providers/installer_mode_provider.dart'; +import '../../theme/app_theme.dart'; +import '../../theme/etm_theme.dart'; + +/// Écran racine "App Settings" — liste des catégories. +class AppSettingsScreen extends StatelessWidget { + const AppSettingsScreen({super.key}); + + @override + Widget build(BuildContext context) { + final installer = context.watch(); + + return Scaffold( + backgroundColor: const Color(0xFFF0F2F5), + appBar: AppBar( + title: const Text('App Settings'), + backgroundColor: Colors.white, + foregroundColor: const Color(0xFF1A1A2E), + elevation: 0, + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + _SettingsGroup( + title: 'PERSONNALISATION', + items: [ + _SettingsItem( + icon: Icons.palette_rounded, + label: 'Apparence', + subtitle: 'Thème, couleurs, taille du texte', + onTap: () => context.push('/settings/app/appearance'), + ), + _SettingsItem( + icon: Icons.view_list_rounded, + label: 'Écrans actifs', + subtitle: 'Choisir et ordonner les onglets', + onTap: () => context.push('/settings/app/screens'), + ), + ], + ), + + const SizedBox(height: 12), + + _SettingsGroup( + title: 'DÉVELOPPEUR', + items: [ + _SettingsItem( + icon: Icons.code_rounded, + label: 'Options développeur', + subtitle: 'Logs verbeux, PIN installateur', + onTap: () => context.push('/settings/app/developer'), + ), + _SettingsItem( + icon: Icons.info_outline_rounded, + label: 'À propos PowerSync', + subtitle: 'Version, licences', + onTap: () => context.push('/settings/app/about'), + ), + ], + ), + + if (installer.isUnlocked) ...[ + const SizedBox(height: 12), + _SettingsGroup( + title: 'INSTALLATEUR', + items: [ + _SettingsItem( + icon: Icons.settings_rounded, + label: 'Configuration système', + subtitle: 'Réseau, protocoles, plugins', + onTap: () => context.push('/settings/system'), + ), + ], + ), + ], + ], + ), + ); + } +} + +class _SettingsGroup extends StatelessWidget { + final String title; + final List<_SettingsItem> items; + + const _SettingsGroup({required this.title, required this.items}); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(left: 4, bottom: 8), + child: Text( + title, + style: const TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + letterSpacing: 0.8, + color: Color(0xFF6B7280), + ), + ), + ), + Card( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14)), + child: Column( + children: items.asMap().entries.map((e) { + final item = e.value; + final isLast = e.key == items.length - 1; + return Column( + children: [ + ListTile( + dense: true, + contentPadding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 4), + leading: Container( + width: 36, height: 36, + decoration: BoxDecoration( + color: ETMTheme.accentColor + .withValues(alpha: 0.1), + borderRadius: BorderRadius.circular(10), + ), + child: Icon(item.icon, + color: ETMTheme.accentColor, size: 20), + ), + title: Text(item.label, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500)), + subtitle: Text(item.subtitle, + style: const TextStyle( + fontSize: 12, + color: AppTheme.textLight)), + trailing: const Icon(Icons.chevron_right_rounded, + color: AppTheme.textLight), + onTap: item.onTap, + ), + if (!isLast) + const Divider( + height: 1, indent: 68, endIndent: 16), + ], + ); + }).toList(), + ), + ), + ], + ); + } +} + +class _SettingsItem { + final IconData icon; + final String label; + final String subtitle; + final VoidCallback onTap; + + const _SettingsItem({ + required this.icon, + required this.label, + required this.subtitle, + required this.onTap, + }); +} diff --git a/lib/screens/settings/appearance_screen.dart b/lib/screens/settings/appearance_screen.dart new file mode 100644 index 0000000..61d9c37 --- /dev/null +++ b/lib/screens/settings/appearance_screen.dart @@ -0,0 +1,233 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../providers/app_settings_provider.dart'; +import '../../theme/app_theme.dart'; +import '../../theme/etm_theme.dart'; + +/// Écran "Apparence" — thème, couleur d'accent, taille de texte, densité. +class AppearanceScreen extends StatelessWidget { + const AppearanceScreen({super.key}); + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + + return Scaffold( + backgroundColor: const Color(0xFFF0F2F5), + appBar: AppBar( + title: const Text('Apparence'), + backgroundColor: Colors.white, + foregroundColor: const Color(0xFF1A1A2E), + elevation: 0, + ), + body: ListView( + padding: const EdgeInsets.all(16), + children: [ + // ── Thème ────────────────────────────────────────────────────── + _SettingsCard( + title: 'Thème', + child: Column( + children: ThemeMode.values.map((m) { + final labels = { + ThemeMode.light: 'Clair', + ThemeMode.dark: 'Sombre', + ThemeMode.system: 'Système', + }; + return RadioListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: Text(labels[m]!, + style: const TextStyle(fontSize: 13)), + value: m, + groupValue: settings.themeMode, + activeColor: ETMTheme.accentColor, + onChanged: (v) { + if (v != null) + context + .read() + .setThemeMode(v); + }, + ); + }).toList(), + ), + ), + + const SizedBox(height: 12), + + // ── Couleur d'accent ─────────────────────────────────────────── + _SettingsCard( + title: 'Couleur d\'accent', + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + ('ETM Blue', AppSettingsProvider.accentColors[0]), + ('Vert', AppSettingsProvider.accentColors[1]), + ('Orange', AppSettingsProvider.accentColors[2]), + ('Violet', AppSettingsProvider.accentColors[3]), + ].asMap().entries.map((entry) { + final idx = entry.key; + final label = entry.value.$1; + final color = entry.value.$2; + final selected = settings.accentIndex == idx; + return GestureDetector( + onTap: () => + context.read().setAccentIndex(idx), + child: Column( + children: [ + Container( + width: 44, height: 44, + decoration: BoxDecoration( + color: color, + shape: BoxShape.circle, + border: selected + ? Border.all( + color: Colors.white, width: 3) + : null, + boxShadow: selected + ? [ + BoxShadow( + color: + color.withValues(alpha: 0.4), + blurRadius: 8, + spreadRadius: 2, + ) + ] + : null, + ), + child: selected + ? const Icon(Icons.check_rounded, + color: Colors.white, size: 20) + : null, + ), + const SizedBox(height: 6), + Text(label, + style: const TextStyle(fontSize: 11)), + ], + ), + ); + }).toList(), + ), + ), + + const SizedBox(height: 12), + + // ── Taille du texte ──────────────────────────────────────────── + _SettingsCard( + title: 'Taille du texte', + child: Column( + children: [ + Slider( + value: settings.textScale, + min: 0.8, max: 1.4, + divisions: 6, + activeColor: ETMTheme.accentColor, + label: + '${(settings.textScale * 100).toStringAsFixed(0)}%', + onChanged: (v) => context + .read() + .setTextScale(v), + ), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: const [ + Text('Petit', style: TextStyle(fontSize: 11)), + Text('Normal', style: TextStyle(fontSize: 11)), + Text('Grand', style: TextStyle(fontSize: 11)), + ], + ), + ], + ), + ), + + const SizedBox(height: 12), + + // ── Densité de l'interface ───────────────────────────────────── + _SettingsCard( + title: 'Densité de l\'interface', + child: Column( + children: [ + (0, 'Compacte', VisualDensity.compact), + (1, 'Normale', VisualDensity.standard), + (2, 'Confortable', + const VisualDensity(horizontal: 2, vertical: 2)), + ].map((entry) { + final idx = entry.$1; + final label = entry.$2; + final density = entry.$3; + return RadioListTile( + dense: true, + contentPadding: EdgeInsets.zero, + title: Text(label, + style: const TextStyle(fontSize: 13)), + value: idx, + groupValue: settings.density == VisualDensity.compact + ? 0 + : settings.density.horizontal > 0 + ? 2 + : 1, + activeColor: ETMTheme.accentColor, + onChanged: (v) { + if (v != null) { + context + .read() + .setDensity(density); + } + }, + ); + }).toList(), + ), + ), + + const SizedBox(height: 16), + + // ── Réinitialiser ────────────────────────────────────────────── + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + icon: const Icon(Icons.restore_rounded, size: 18), + label: const Text('Réinitialiser les paramètres d\'apparence'), + style: OutlinedButton.styleFrom( + foregroundColor: AppTheme.boostRed, + side: BorderSide( + color: AppTheme.boostRed.withValues(alpha: 0.4)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(vertical: 14), + ), + onPressed: () => context + .read() + .resetAppearance(), + ), + ), + ], + ), + ); + } +} + +class _SettingsCard extends StatelessWidget { + final String title; + final Widget child; + + const _SettingsCard({required this.title, required this.child}); + + @override + Widget build(BuildContext context) { + return Card( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)), + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(title, + style: const TextStyle( + fontWeight: FontWeight.bold, fontSize: 14)), + const SizedBox(height: 12), + child, + ], + ), + ), + ); + } +} diff --git a/lib/screens/settings/screens_settings_screen.dart b/lib/screens/settings/screens_settings_screen.dart new file mode 100644 index 0000000..fcb56ee --- /dev/null +++ b/lib/screens/settings/screens_settings_screen.dart @@ -0,0 +1,129 @@ +import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; +import '../../providers/app_settings_provider.dart'; +import '../../theme/etm_theme.dart'; + +/// Écran "Écrans actifs" — choisir et réordonner les onglets du menu principal. +class ScreensSettingsScreen extends StatelessWidget { + const ScreensSettingsScreen({super.key}); + + @override + Widget build(BuildContext context) { + final settings = context.watch(); + + return Scaffold( + backgroundColor: const Color(0xFFF0F2F5), + appBar: AppBar( + title: const Text('Écrans actifs'), + backgroundColor: Colors.white, + foregroundColor: const Color(0xFF1A1A2E), + elevation: 0, + ), + body: Column( + children: [ + // Sous-titre + Padding( + padding: const EdgeInsets.fromLTRB(16, 12, 16, 4), + child: Text( + 'Choisissez les écrans visibles dans le menu principal', + style: const TextStyle( + color: Color(0xFF6B7280), fontSize: 13), + ), + ), + + Expanded( + child: ReorderableListView.builder( + padding: const EdgeInsets.all(12), + itemCount: settings.screens.length, + onReorder: (oldIdx, newIdx) { + if (newIdx > oldIdx) newIdx--; + context + .read() + .reorderScreens(oldIdx, newIdx); + }, + itemBuilder: (context, i) { + final screen = settings.screens[i]; + return _ScreenTile( + key: ValueKey(screen.id), + screen: screen, + onToggle: (v) => context + .read() + .setScreenVisible(screen.id, v), + ); + }, + ), + ), + + // Astuce + Padding( + padding: const EdgeInsets.fromLTRB(16, 8, 16, 20), + child: Row( + children: const [ + Icon(Icons.drag_handle_rounded, + size: 16, color: Color(0xFF9CA3AF)), + SizedBox(width: 6), + Text( + 'Faites glisser pour réordonner', + style: TextStyle( + fontSize: 12, color: Color(0xFF9CA3AF)), + ), + ], + ), + ), + ], + ), + ); + } +} + +class _ScreenTile extends StatelessWidget { + final AppScreen screen; + final ValueChanged onToggle; + + const _ScreenTile({ + super.key, + required this.screen, + required this.onToggle, + }); + + @override + Widget build(BuildContext context) { + return Card( + margin: const EdgeInsets.only(bottom: 8), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 4), + child: Row( + children: [ + // Checkbox + Checkbox( + value: screen.visible, + activeColor: ETMTheme.accentColor, + onChanged: (v) => onToggle(v ?? false), + ), + const SizedBox(width: 4), + // Icône + Icon(screen.icon, + size: 22, color: const Color(0xFF1A1A2E)), + const SizedBox(width: 12), + // Label + Expanded( + child: Text( + screen.label, + style: TextStyle( + fontSize: 14, + color: screen.visible + ? const Color(0xFF1A1A2E) + : const Color(0xFF9CA3AF), + ), + ), + ), + // Drag handle + const Icon(Icons.drag_handle_rounded, + size: 22, color: Color(0xFF9CA3AF)), + ], + ), + ), + ); + } +} diff --git a/lib/screens/things_screen.dart b/lib/screens/things_screen.dart index c15fa04..a93ee4f 100644 --- a/lib/screens/things_screen.dart +++ b/lib/screens/things_screen.dart @@ -5,6 +5,7 @@ import '../models/nymea_models.dart'; import '../models/thing_category.dart'; import '../services/nymea_service.dart'; import '../theme/app_theme.dart'; +import '../main.dart' show DrawerMenuButton; import 'category_overview_screen.dart'; // ───────────────────────────────────────────────────────────────────────────── @@ -94,6 +95,8 @@ class ThingsScreen extends StatelessWidget { return AppBar( backgroundColor: AppTheme.backgroundGray, elevation: 0, + leading: const DrawerMenuButton(), + leadingWidth: 56, title: Row(children: [ const Text('Things', style: TextStyle( diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index f023d3a..970d532 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -93,6 +93,41 @@ class NymeaService extends ChangeNotifier { List get thingClasses => _thingClasses; List get favoriteWidgets => _favoriteWidgets; + /// Retourne {thingId, stateName} de la première batterie trouvée, + /// ou null si aucune batterie configurée / pas encore chargée. + /// Non disponible en simulation (fetchHistory retourne des W, pas des %). + Map? get batterySOCSource { + if (_isSimulation) return null; + if (_things.isEmpty) { + _log('🔋 batterySOCSource: things pas encore chargés', force: true); + return null; + } + for (final thing in _things) { + NymeaThingClass? cls; + try { + cls = _thingClasses.firstWhere((c) => c.id == thing.thingClassId); + } catch (_) { + continue; + } + final isBattery = cls.interfaces.any((i) => const [ + 'battery', 'energystorage', 'batterymonitor' + ].contains(i.toLowerCase())); + if (!isBattery) continue; + try { + final stateType = cls.stateTypes.firstWhere( + (st) => st.name.toLowerCase() == 'batterylevel' + || st.name.toLowerCase() == 'soc'); + _log('🔋 batterySOCSource: trouvé "${thing.name}" / état "${stateType.name}"', force: true); + return {'thingId': thing.id, 'stateName': stateType.name}; + } catch (_) { + _log('🔋 batterySOCSource: batterie "${thing.name}" sans état batteryLevel/soc (états: ${cls.stateTypes.map((s) => s.name).toList()})', force: true); + continue; + } + } + _log('🔋 batterySOCSource: aucune batterie trouvée (${_things.length} things, interfaces: ${_things.map((t) { try { return _thingClasses.firstWhere((c) => c.id == t.thingClassId).interfaces; } catch(_) { return []; } }).toList()})', force: true); + return null; + } + // ═══════════════════════════════════════════════════════════════════════════ // CONNEXION — entrée publique // ═══════════════════════════════════════════════════════════════════════════ @@ -441,10 +476,68 @@ class NymeaService extends ChangeNotifier { states.add(NymeaStateValue(stateTypeId: stateTypeId, value: value)); } _things[idx] = _things[idx].copyWith(states: states); + // Synchronise batterySOC si l'état modifié est batteryLevel d'une batterie + _maybeUpdateBatterySOC(_things[idx], stateTypeId, value); notifyListeners(); } } + /// Met à jour _energyData.batterySOC si [thing] est une batterie/stockage + /// et que [stateTypeId] correspond à l'état batteryLevel. + void _maybeUpdateBatterySOC( + NymeaThing thing, String stateTypeId, dynamic value) { + NymeaThingClass? cls; + try { + cls = _thingClasses.firstWhere((c) => c.id == thing.thingClassId); + } catch (_) { + return; + } + final isBattery = cls.interfaces.any((i) => const [ + 'battery', 'energystorage', 'batterymonitor' + ].contains(i.toLowerCase())); + if (!isBattery) return; + final stateType = cls.stateTypeById(stateTypeId); + if (stateType != null && value is num) { + final n = stateType.name.toLowerCase(); + // 'batterylevel' = convention nymea standard ; 'soc' = fallback certains plugins + if (n == 'batterylevel' || n == 'soc') { + _log('🔋 batterySOC mis à jour (${stateType.name}): ${value.toDouble()}%', force: true); + _energyData = _energyData.copyWith(batterySOC: value.toDouble()); + } + } + } + + /// Extrait le SOC initial depuis les états déjà chargés des things batterie. + void _syncBatterySOCFromThings() { + for (final thing in _things) { + NymeaThingClass? cls; + try { + cls = _thingClasses.firstWhere((c) => c.id == thing.thingClassId); + } catch (_) { + continue; + } + final isBattery = cls.interfaces.any((i) => const [ + 'battery', 'energystorage', 'batterymonitor' + ].contains(i.toLowerCase())); + if (!isBattery) continue; + // Cherche l'état SOC : 'batteryLevel' (convention nymea) ou 'soc' (fallback) + NymeaStateType? stateType; + try { + stateType = cls.stateTypes.firstWhere( + (st) => st.name.toLowerCase() == 'batterylevel' + || st.name.toLowerCase() == 'soc'); + } catch (_) { + continue; + } + final val = thing.stateValue(stateType.id); + if (val is num) { + _log('🔋 batterySOC initialisé (${stateType.name}): ${val.toDouble()}%', force: true); + _energyData = _energyData.copyWith(batterySOC: val.toDouble()); + return; // on prend la première batterie trouvée + } + } + } + void _onError(dynamic error) { _log('❌ Connection error: $error', force: true); _connectionError = error.toString(); @@ -763,6 +856,8 @@ class NymeaService extends ChangeNotifier { } _thingsLoaded = true; + // Initialise le SOC batterie depuis les états des things déjà chargés + _syncBatterySOCFromThings(); notifyListeners(); } @@ -969,6 +1064,7 @@ class NymeaService extends ChangeNotifier { try { // Source nymea : "state-{thingId}-{stateTypeName}" final source = 'state-$thingId-$stateTypeName'; + _log('📊 fetchHistory: source=$source sampleRate=$sampleRate', force: true); final r = await _sendRequest('Logging.GetLogEntries', { 'sources': [source], 'startTime': from.millisecondsSinceEpoch, @@ -978,15 +1074,30 @@ class NymeaService extends ChangeNotifier { }); final logEntries = (r['params']?['logEntries'] ?? r['logEntries']) as List? ?? []; - return logEntries.map((e) { + _log('📊 fetchHistory: ${logEntries.length} entrées reçues pour $source', force: true); + if (logEntries.isNotEmpty) { + // Log le premier entry pour vérifier le format + _log('📊 fetchHistory: exemple entry[0] = ${logEntries.first}', force: true); + } + final entries = logEntries.map((e) { final ts = DateTime.fromMillisecondsSinceEpoch( (e['timestamp'] as num).toInt()); final values = (e['values'] as Map?)?.cast() ?? {}; - final v = (values[stateTypeName] as num?)?.toDouble() ?? 0.0; + // Essaie d'abord le nom de l'état, puis toutes les clés disponibles + num? raw = values[stateTypeName] as num?; + if (raw == null && values.isNotEmpty) { + final nums = values.values.whereType(); + if (nums.isNotEmpty) { + raw = nums.first; + _log('📊 fetchHistory: clé "$stateTypeName" absente, fallback sur première valeur num (clés: ${values.keys.toList()})', force: true); + } + } + final v = raw?.toDouble() ?? 0.0; return HistoryEntry(timestamp: ts, value: v); }).toList(); + return entries; } catch (e) { - _log('fetchHistory: $e'); + _log('fetchHistory: $e', force: true); return []; } } @@ -1107,19 +1218,57 @@ class NymeaService extends ChangeNotifier { NymeaThing(id: 'sim-inverter', name: 'Onduleur SolarEdge', thingClassId: 'solaredge', setupStatus: 'ThingSetupStatusComplete', paramValues: [], states: [NymeaStateValue(stateTypeId: 'power', value: 4200.0)]), + NymeaThing(id: 'sim-meter', name: 'Compteur Linky', thingClassId: 'linky', + setupStatus: 'ThingSetupStatusComplete', paramValues: [], + states: [NymeaStateValue(stateTypeId: 'power', value: 180.0)]), NymeaThing(id: 'sim-battery', name: 'Batterie BYD', thingClassId: 'byd', setupStatus: 'ThingSetupStatusComplete', paramValues: [], states: [NymeaStateValue(stateTypeId: 'soc', value: 42.0)]), NymeaThing(id: 'sim-ev', name: 'Borne Wallbox', thingClassId: 'wallbox', setupStatus: 'ThingSetupStatusComplete', paramValues: [], states: [NymeaStateValue(stateTypeId: 'power', value: 3600.0)]), - NymeaThing(id: 'sim-meter', name: 'Compteur Linky', thingClassId: 'linky', + NymeaThing(id: 'sim-pac', name: 'PAC Atlantic', thingClassId: 'atlantic-pac', setupStatus: 'ThingSetupStatusComplete', paramValues: [], - states: [NymeaStateValue(stateTypeId: 'power', value: 180.0)]), + states: [NymeaStateValue(stateTypeId: 'sgReadyState', value: 'normal')]), + NymeaThing(id: 'sim-dhw', name: 'Chauffe-eau Atlantic', thingClassId: 'atlantic-dhw', + setupStatus: 'ThingSetupStatusComplete', paramValues: [], + states: [NymeaStateValue(stateTypeId: 'power', value: false)]), NymeaThing(id: 'sim-ac', name: 'Climatiseur Salon', thingClassId: 'mitsubishi', setupStatus: 'ThingSetupStatusComplete', paramValues: [], states: [NymeaStateValue(stateTypeId: 'power', value: false)]), ]; + + // Classes simulées avec leurs interfaces — nécessaires pour le filtrage de rôles EMS + _thingClasses = [ + const NymeaThingClass( + id: 'solaredge', name: 'solaredge', displayName: 'SolarEdge', + interfaces: ['solarinverter', 'inverter', 'energymeter'], + ), + const NymeaThingClass( + id: 'linky', name: 'linky', displayName: 'Linky', + interfaces: ['energymeter', 'smartmeter', 'meter'], + ), + const NymeaThingClass( + id: 'byd', name: 'byd', displayName: 'BYD Battery', + interfaces: ['battery', 'energystorage', 'batterymonitor'], + ), + const NymeaThingClass( + id: 'wallbox', name: 'wallbox', displayName: 'Wallbox', + interfaces: ['evcharger'], + ), + const NymeaThingClass( + id: 'atlantic-pac', name: 'atlantic-pac', displayName: 'PAC Atlantic', + interfaces: ['sgready', 'heatpump'], + ), + const NymeaThingClass( + id: 'atlantic-dhw', name: 'atlantic-dhw', displayName: 'Chauffe-eau', + interfaces: ['simpleheatpump', 'smartplug'], + ), + const NymeaThingClass( + id: 'mitsubishi', name: 'mitsubishi', displayName: 'Mitsubishi AC', + interfaces: ['airconditioning'], + ), + ]; } @override diff --git a/lib/theme/etm_theme.dart b/lib/theme/etm_theme.dart new file mode 100644 index 0000000..592270d --- /dev/null +++ b/lib/theme/etm_theme.dart @@ -0,0 +1,141 @@ +import 'package:flutter/material.dart'; + +/// Système de design ETM PowerSync +/// Complète AppTheme (couleurs énergie) avec les couleurs de marque ETM. +class ETMTheme { + // ── Couleurs de marque ──────────────────────────────────────────────────────── + static const Color primaryColor = Color(0xFF1B3A5C); // ETM dark blue + static const Color accentColor = Color(0xFF2E75B6); // ETM mid blue + static const Color successColor = Color(0xFF1E6B3C); // green + static const Color warningColor = Color(0xFFC55A11); // orange + static const Color errorColor = Color(0xFFB71C1C); // red + static const Color proColor = Color(0xFF5B2C8D); // purple (Pro features) + + // ── Drawer ─────────────────────────────────────────────────────────────────── + static const Color drawerBackground = Color(0xFF0F2133); + static const Color drawerSurface = Color(0xFF1A3249); + static const Color drawerItemActive = Color(0xFF2E75B6); + static const Color drawerTextPrimary = Color(0xFFEEF2F7); + static const Color drawerTextMuted = Color(0xFF8FA9C4); + static const double drawerWidth = 285.0; + + // ── Badge "INSTALLATEUR" ───────────────────────────────────────────────────── + static const Color installerBadgeColor = Color(0xFFE65100); + + // ── Widget Pro Lock ────────────────────────────────────────────────────────── + static Widget proLock( + BuildContext context, + String featureName, { + Widget? child, + }) { + return GestureDetector( + onTap: () => _showProBottomSheet(context, featureName), + child: Stack( + children: [ + if (child != null) + Opacity(opacity: 0.5, child: child), + Positioned( + top: 2, + right: 2, + child: _ProBadge(), + ), + ], + ), + ); + } + + static void _showProBottomSheet(BuildContext context, String featureName) { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => _ProBottomSheet(featureName: featureName), + ); + } +} + +class _ProBadge extends StatelessWidget { + @override + Widget build(BuildContext context) { + return Container( + padding: const EdgeInsets.symmetric(horizontal: 5, vertical: 2), + decoration: BoxDecoration( + color: ETMTheme.proColor, + borderRadius: BorderRadius.circular(6), + ), + child: const Text( + '🔒 Pro', + style: TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.bold, + ), + ), + ); + } +} + +class _ProBottomSheet extends StatelessWidget { + final String featureName; + const _ProBottomSheet({required this.featureName}); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.fromLTRB(24, 20, 24, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 36, height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 20), + Container( + width: 56, height: 56, + decoration: BoxDecoration( + color: ETMTheme.proColor.withValues(alpha: 0.12), + shape: BoxShape.circle, + ), + child: const Icon(Icons.lock_rounded, color: ETMTheme.proColor, size: 28), + ), + const SizedBox(height: 16), + Text( + featureName, + style: const TextStyle( + fontSize: 18, + fontWeight: FontWeight.bold, + color: Color(0xFF1A1A2E), + ), + ), + const SizedBox(height: 8), + const Text( + 'Cette fonctionnalité est disponible en version Pro.', + textAlign: TextAlign.center, + style: TextStyle(fontSize: 14, color: Color(0xFF6B7280)), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: ETMTheme.proColor, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12), + ), + padding: const EdgeInsets.symmetric(vertical: 14), + ), + onPressed: () => Navigator.pop(context), + child: const Text('En savoir plus'), + ), + ), + ], + ), + ); + } +} diff --git a/lib/widgets/energy_flow_widget.dart b/lib/widgets/energy_flow_widget.dart index 0647685..ec3ffc8 100644 --- a/lib/widgets/energy_flow_widget.dart +++ b/lib/widgets/energy_flow_widget.dart @@ -208,25 +208,38 @@ class _FlowPainter extends CustomPainter { ..strokeWidth = 2 ..style = PaintingStyle.stroke; - // PV -> Home (always if PV > 0) + // PV → Home (si PV produit) if (data.pvPower > 0) { _drawArrowLine(canvas, Offset(nodePositions[0], centerY), Offset(nodePositions[1], centerY), AppTheme.solarYellow, linePaint); } - // Home -> Battery (if battery charging) + // Batterie en charge : source = PV si disponible, sinon Réseau → Batterie if (data.batteryPower > 0) { - _drawArrowLine(canvas, Offset(nodePositions[1], centerY), - Offset(nodePositions[2], centerY), AppTheme.batteryGreen, linePaint); + if (data.pvPower > 0) { + // PV → Battery (solaire charge la batterie) + _drawArrowLine(canvas, Offset(nodePositions[0], centerY), + Offset(nodePositions[2], centerY), AppTheme.batteryGreen, linePaint); + } else { + // Grid → Battery (réseau charge la batterie) + _drawArrowLine(canvas, Offset(nodePositions[3], centerY), + Offset(nodePositions[2], centerY), AppTheme.batteryGreen, linePaint); + } } - // Grid -> Home (if importing) + // Batterie en décharge → Home + if (data.batteryPower < 0) { + _drawArrowLine(canvas, Offset(nodePositions[2], centerY), + Offset(nodePositions[1], centerY), AppTheme.batteryGreen, linePaint); + } + + // Réseau → Home (si import) if (data.gridPower > 0) { _drawArrowLine(canvas, Offset(nodePositions[3], centerY), Offset(nodePositions[1], centerY), Colors.orange, linePaint); } else if (data.gridPower < 0) { - // Exporting - _drawArrowLine(canvas, Offset(nodePositions[2], centerY), + // PV → Réseau (export / injection réseau) + _drawArrowLine(canvas, Offset(nodePositions[0], centerY), Offset(nodePositions[3], centerY), AppTheme.solarYellow, linePaint); } diff --git a/lib/widgets/power_bar.dart b/lib/widgets/power_bar.dart new file mode 100644 index 0000000..35a244d --- /dev/null +++ b/lib/widgets/power_bar.dart @@ -0,0 +1,83 @@ +import 'package:flutter/material.dart'; + +/// Barre de puissance horizontale normalisée. +/// +/// Affiche une barre colorée proportionnelle à [value] / [maxValue]. +/// Si [signed] = true, la barre est centrée (positif = droite, négatif = gauche). +class PowerBar extends StatelessWidget { + final double value; + final double maxValue; + final Color color; + final bool signed; + final double height; + final double borderRadius; + + const PowerBar({ + super.key, + required this.value, + required this.maxValue, + required this.color, + this.signed = false, + this.height = 8, + this.borderRadius = 4, + }); + + @override + Widget build(BuildContext context) { + final frac = maxValue > 0 + ? (value.abs() / maxValue).clamp(0.0, 1.0) + : 0.0; + + return LayoutBuilder( + builder: (context, constraints) { + final w = constraints.maxWidth; + if (signed) { + return Stack( + children: [ + Container( + height: height, + decoration: BoxDecoration( + color: Colors.grey.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(borderRadius), + ), + ), + Positioned( + left: value >= 0 ? w / 2 : w / 2 - frac * w / 2, + child: Container( + width: frac * w / 2, + height: height, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(borderRadius), + ), + ), + ), + ], + ); + } + + return Stack( + children: [ + Container( + height: height, + decoration: BoxDecoration( + color: Colors.grey.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(borderRadius), + ), + ), + FractionallySizedBox( + widthFactor: frac, + child: Container( + height: height, + decoration: BoxDecoration( + color: color, + borderRadius: BorderRadius.circular(borderRadius), + ), + ), + ), + ], + ); + }, + ); + } +} diff --git a/lib/widgets/pro_lock_badge.dart b/lib/widgets/pro_lock_badge.dart new file mode 100644 index 0000000..28563ff --- /dev/null +++ b/lib/widgets/pro_lock_badge.dart @@ -0,0 +1,93 @@ +import 'package:flutter/material.dart'; +import '../theme/etm_theme.dart'; + +/// Badge 🔒 violet pour les fonctionnalités Pro. +/// S'utilise en tant que widget standalone ou via ETMTheme.proLock(). +class ProLockBadge extends StatelessWidget { + final String featureName; + + const ProLockBadge({super.key, required this.featureName}); + + @override + Widget build(BuildContext context) { + return GestureDetector( + onTap: () => _showProSheet(context), + child: Container( + padding: const EdgeInsets.symmetric(horizontal: 6, vertical: 3), + decoration: BoxDecoration( + color: ETMTheme.proColor.withValues(alpha: 0.15), + border: Border.all(color: ETMTheme.proColor.withValues(alpha: 0.4)), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + mainAxisSize: MainAxisSize.min, + children: [ + const Icon(Icons.lock_rounded, size: 11, color: ETMTheme.proColor), + const SizedBox(width: 3), + Text( + 'Pro', + style: TextStyle( + fontSize: 10, + fontWeight: FontWeight.bold, + color: ETMTheme.proColor, + ), + ), + ], + ), + ), + ); + } + + void _showProSheet(BuildContext context) { + showModalBottomSheet( + context: context, + shape: const RoundedRectangleBorder( + borderRadius: BorderRadius.vertical(top: Radius.circular(20)), + ), + builder: (_) => Padding( + padding: const EdgeInsets.fromLTRB(24, 16, 24, 32), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Container( + width: 36, height: 4, + decoration: BoxDecoration( + color: Colors.grey.shade300, + borderRadius: BorderRadius.circular(2), + ), + ), + const SizedBox(height: 20), + const Icon(Icons.lock_rounded, color: ETMTheme.proColor, size: 36), + const SizedBox(height: 12), + Text( + featureName, + style: const TextStyle( + fontSize: 17, fontWeight: FontWeight.bold), + ), + const SizedBox(height: 8), + const Text( + 'Cette fonctionnalité est disponible en version Pro.', + textAlign: TextAlign.center, + style: TextStyle(color: Color(0xFF6B7280)), + ), + const SizedBox(height: 24), + SizedBox( + width: double.infinity, + child: ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: ETMTheme.proColor, + foregroundColor: Colors.white, + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(12)), + padding: const EdgeInsets.symmetric(vertical: 14), + ), + onPressed: () => Navigator.pop(context), + child: const Text('En savoir plus'), + ), + ), + ], + ), + ), + ); + } +} diff --git a/lib/widgets/role_card.dart b/lib/widgets/role_card.dart new file mode 100644 index 0000000..fa172ef --- /dev/null +++ b/lib/widgets/role_card.dart @@ -0,0 +1,179 @@ +import 'package:flutter/material.dart'; +import '../providers/energy_setup_provider.dart'; +import '../theme/app_theme.dart'; +import '../theme/etm_theme.dart'; + +/// Carte de statut pour un rôle EMS (EV Charger, Chauffe-eau, Batterie, etc.). +class RoleCard extends StatelessWidget { + final EmsRole role; + final RoleAssignment? assignment; + final bool isReachable; + final VoidCallback onConfigure; + final VoidCallback? onToggle; + final VoidCallback? onEdit; + final VoidCallback? onTest; + + const RoleCard({ + super.key, + required this.role, + required this.assignment, + required this.onConfigure, + this.isReachable = true, + this.onToggle, + this.onEdit, + this.onTest, + }); + + @override + Widget build(BuildContext context) { + final isConfigured = assignment != null; + final isEnabled = assignment?.enabled ?? false; + + return Card( + margin: const EdgeInsets.only(bottom: 12), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(14), + side: BorderSide( + color: isEnabled + ? AppTheme.primaryGreen.withValues(alpha: 0.3) + : Colors.grey.withValues(alpha: 0.15), + ), + ), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── En-tête : icône + nom + switch ────────────────────────────── + Row( + children: [ + Text(role.icon, + style: const TextStyle(fontSize: 22)), + const SizedBox(width: 10), + Expanded( + child: Text( + role.label, + style: const TextStyle( + fontWeight: FontWeight.bold, + fontSize: 15, + ), + ), + ), + if (isConfigured) + Switch.adaptive( + value: isEnabled, + activeColor: AppTheme.primaryGreen, + onChanged: onToggle != null + ? (_) => onToggle!() + : null, + ), + ], + ), + + const SizedBox(height: 8), + + // ── Corps : état de la configuration ───────────────────────────── + if (!isConfigured) ...[ + const Text( + 'Aucun appareil configuré', + style: TextStyle( + color: AppTheme.textLight, + fontSize: 13, + ), + ), + const SizedBox(height: 10), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + icon: const Icon(Icons.add_rounded, size: 16), + label: const Text('Configurer'), + style: OutlinedButton.styleFrom( + foregroundColor: ETMTheme.accentColor, + side: BorderSide( + color: ETMTheme.accentColor.withValues(alpha: 0.5)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + padding: const EdgeInsets.symmetric(vertical: 8), + ), + onPressed: onConfigure, + ), + ), + ] else ...[ + Row( + children: [ + Icon( + isReachable + ? Icons.check_circle_rounded + : Icons.error_rounded, + size: 14, + color: isReachable + ? AppTheme.primaryGreen + : AppTheme.boostRed, + ), + const SizedBox(width: 6), + Expanded( + child: Text( + assignment!.thing.name, + style: const TextStyle(fontSize: 13), + ), + ), + ], + ), + if (assignment!.params.containsKey('powerW')) ...[ + const SizedBox(height: 4), + Text( + '${assignment!.params['powerW']} W · Priorité: ${assignment!.params['priority'] ?? 'Normal'}', + style: const TextStyle( + fontSize: 12, + color: AppTheme.textLight, + ), + ), + ], + const SizedBox(height: 10), + Row( + children: [ + if (onEdit != null) + Expanded( + child: OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: ETMTheme.accentColor, + side: BorderSide( + color: + ETMTheme.accentColor.withValues(alpha: 0.4)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + padding: const EdgeInsets.symmetric(vertical: 8), + ), + onPressed: onEdit, + child: const Text('Modifier', + style: TextStyle(fontSize: 13)), + ), + ), + if (onEdit != null && onTest != null) + const SizedBox(width: 8), + if (onTest != null) + Expanded( + child: OutlinedButton( + style: OutlinedButton.styleFrom( + foregroundColor: AppTheme.primaryGreen, + side: BorderSide( + color: AppTheme.primaryGreen + .withValues(alpha: 0.4)), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(10)), + padding: const EdgeInsets.symmetric(vertical: 8), + ), + onPressed: onTest, + child: const Text('Tester', + style: TextStyle(fontSize: 13)), + ), + ), + ], + ), + ], + ], + ), + ), + ); + } +} diff --git a/lib/widgets/timeline_slot_card.dart b/lib/widgets/timeline_slot_card.dart new file mode 100644 index 0000000..9604ca1 --- /dev/null +++ b/lib/widgets/timeline_slot_card.dart @@ -0,0 +1,309 @@ +import 'package:flutter/material.dart'; +import '../theme/app_theme.dart'; +import '../theme/etm_theme.dart'; +import 'power_bar.dart'; + +/// Données d'un créneau horaire dans la timeline EMS. +class TimelineSlot { + final DateTime start; + final DateTime end; + final double solarW; + final double homeW; + final double evW; // > 0 = actif + final double batteryW; // > 0 = charge, < 0 = décharge + final double gridW; // > 0 = import, < 0 = export + final String reasoning; // Explication de la décision + final double savings; // € + final double selfSufficiency; // % + final bool isNow; + final bool hasOverride; + + const TimelineSlot({ + required this.start, + required this.end, + required this.solarW, + required this.homeW, + this.evW = 0, + this.batteryW = 0, + this.gridW = 0, + this.reasoning = '', + this.savings = 0, + this.selfSufficiency = 0, + this.isNow = false, + this.hasOverride = false, + }); +} + +/// Carte d'un créneau horaire dans la timeline EMS. +class TimelineSlotCard extends StatelessWidget { + final TimelineSlot slot; + final VoidCallback? onOverride; + + const TimelineSlotCard({ + super.key, + required this.slot, + this.onOverride, + }); + + @override + Widget build(BuildContext context) { + final maxW = [ + slot.solarW, slot.homeW, slot.evW.abs(), + slot.batteryW.abs(), slot.gridW.abs(), + ].fold(0, (a, b) => a > b ? a : b).clamp(100.0, double.infinity); + + return Container( + margin: const EdgeInsets.symmetric(horizontal: 16, vertical: 4), + decoration: BoxDecoration( + color: slot.isNow + ? ETMTheme.accentColor.withValues(alpha: 0.08) + : Colors.white, + borderRadius: BorderRadius.circular(14), + border: Border.all( + color: slot.isNow + ? ETMTheme.accentColor.withValues(alpha: 0.4) + : Colors.grey.withValues(alpha: 0.12), + ), + ), + child: Padding( + padding: const EdgeInsets.all(14), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ── En-tête ────────────────────────────────────────────────────── + Row( + children: [ + Text( + '${_fmt(slot.start)} – ${_fmt(slot.end)}', + style: TextStyle( + fontWeight: FontWeight.bold, + fontSize: 13, + color: slot.isNow + ? ETMTheme.accentColor + : AppTheme.textDark, + ), + ), + if (slot.isNow) ...[ + const SizedBox(width: 8), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 8, vertical: 2), + decoration: BoxDecoration( + color: ETMTheme.accentColor, + borderRadius: BorderRadius.circular(6), + ), + child: const Text( + 'MAINTENANT', + style: TextStyle( + color: Colors.white, + fontSize: 9, + fontWeight: FontWeight.bold, + letterSpacing: 0.5, + ), + ), + ), + ], + if (slot.hasOverride) ...[ + const SizedBox(width: 6), + Container( + padding: const EdgeInsets.symmetric( + horizontal: 7, vertical: 2), + decoration: BoxDecoration( + color: ETMTheme.warningColor.withValues(alpha: 0.15), + borderRadius: BorderRadius.circular(6), + ), + child: Text( + 'Override', + style: TextStyle( + color: ETMTheme.warningColor, + fontSize: 9, + fontWeight: FontWeight.bold, + ), + ), + ), + ], + ], + ), + + const SizedBox(height: 10), + + // ── Barres de puissance ────────────────────────────────────────── + if (slot.solarW > 0) + _PowerRow( + emoji: '☀️', label: 'Solaire', + value: slot.solarW, maxValue: maxW, + color: AppTheme.solarYellow, + sign: '+', + ), + _PowerRow( + emoji: '🏠', label: 'Maison', + value: slot.homeW, maxValue: maxW, + color: AppTheme.homeBlue, + sign: '-', + ), + if (slot.evW != 0) + _PowerRow( + emoji: '🚗', label: 'VE', + value: slot.evW.abs(), maxValue: maxW, + color: AppTheme.accentTeal, + sign: slot.evW > 0 ? '-' : '+', + badge: slot.evW > 0 ? 'ON' : null, + ), + if (slot.batteryW != 0) + _PowerRow( + emoji: '🔋', label: 'Batterie', + value: slot.batteryW.abs(), maxValue: maxW, + color: AppTheme.batteryGreen, + sign: slot.batteryW > 0 ? '-' : '+', + badge: slot.batteryW > 0 ? '↑' : '↓', + ), + if (slot.gridW != 0) + _PowerRow( + emoji: '⚡', label: 'Réseau', + value: slot.gridW.abs(), maxValue: maxW, + color: slot.gridW > 0 + ? AppTheme.powerAcquisitionColor + : AppTheme.powerReturnColor, + sign: slot.gridW > 0 ? '-' : '+', + badge: slot.gridW > 0 ? '↓' : '↑', + ), + + if (slot.reasoning.isNotEmpty) ...[ + const SizedBox(height: 10), + Container( + padding: const EdgeInsets.all(10), + decoration: BoxDecoration( + color: Colors.grey.shade50, + borderRadius: BorderRadius.circular(8), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text('💬 ', style: TextStyle(fontSize: 13)), + Expanded( + child: Text( + '"${slot.reasoning}"', + style: const TextStyle( + fontSize: 12, + fontStyle: FontStyle.italic, + color: AppTheme.textLight, + ), + ), + ), + ], + ), + ), + ], + + const SizedBox(height: 8), + + // ── Métriques + Override ───────────────────────────────────────── + Row( + children: [ + if (slot.savings != 0) + Text( + '💰 ${slot.savings >= 0 ? '-' : '+'}${slot.savings.abs().toStringAsFixed(3)}€', + style: TextStyle( + fontSize: 11, + color: slot.savings > 0 + ? ETMTheme.successColor + : ETMTheme.errorColor, + ), + ), + if (slot.savings != 0 && slot.selfSufficiency > 0) + const SizedBox(width: 12), + if (slot.selfSufficiency > 0) + Text( + 'Autosuff.: ${slot.selfSufficiency.toStringAsFixed(0)}%', + style: const TextStyle( + fontSize: 11, color: AppTheme.textLight), + ), + const Spacer(), + if (onOverride != null) + TextButton( + style: TextButton.styleFrom( + foregroundColor: ETMTheme.accentColor, + padding: const EdgeInsets.symmetric( + horizontal: 10, vertical: 4), + ), + onPressed: onOverride, + child: const Text('Override manuel', + style: TextStyle(fontSize: 11)), + ), + ], + ), + ], + ), + ), + ); + } + + String _fmt(DateTime dt) => + '${dt.hour.toString().padLeft(2, '0')}:${dt.minute.toString().padLeft(2, '0')}'; +} + +class _PowerRow extends StatelessWidget { + final String emoji; + final String label; + final double value; + final double maxValue; + final Color color; + final String sign; + final String? badge; + + const _PowerRow({ + required this.emoji, + required this.label, + required this.value, + required this.maxValue, + required this.color, + required this.sign, + this.badge, + }); + + @override + Widget build(BuildContext context) { + final displayW = value >= 1000 + ? '${(value / 1000).toStringAsFixed(1)} kW' + : '${value.toStringAsFixed(0)} W'; + + return Padding( + padding: const EdgeInsets.symmetric(vertical: 3), + child: Row( + children: [ + Text(emoji, style: const TextStyle(fontSize: 14)), + const SizedBox(width: 6), + SizedBox( + width: 70, + child: Text( + label, + style: const TextStyle(fontSize: 12, color: AppTheme.textLight), + ), + ), + Text( + '$sign$displayW', + style: TextStyle( + fontSize: 12, + fontWeight: FontWeight.w600, + color: AppTheme.textDark, + ), + ), + const SizedBox(width: 8), + Expanded( + child: PowerBar( + value: value, maxValue: maxValue, color: color, height: 6, + ), + ), + if (badge != null) ...[ + const SizedBox(width: 6), + Text(badge!, + style: TextStyle( + fontSize: 11, + fontWeight: FontWeight.bold, + color: color)), + ], + ], + ), + ); + } +} diff --git a/linux/flutter/generated_plugin_registrant.cc b/linux/flutter/generated_plugin_registrant.cc index e71a16d..f6f23bf 100644 --- a/linux/flutter/generated_plugin_registrant.cc +++ b/linux/flutter/generated_plugin_registrant.cc @@ -6,6 +6,10 @@ #include "generated_plugin_registrant.h" +#include void fl_register_plugins(FlPluginRegistry* registry) { + g_autoptr(FlPluginRegistrar) url_launcher_linux_registrar = + fl_plugin_registry_get_registrar_for_plugin(registry, "UrlLauncherPlugin"); + url_launcher_plugin_register_with_registrar(url_launcher_linux_registrar); } diff --git a/linux/flutter/generated_plugins.cmake b/linux/flutter/generated_plugins.cmake index 2e1de87..f16b4c3 100644 --- a/linux/flutter/generated_plugins.cmake +++ b/linux/flutter/generated_plugins.cmake @@ -3,6 +3,7 @@ # list(APPEND FLUTTER_PLUGIN_LIST + url_launcher_linux ) list(APPEND FLUTTER_FFI_PLUGIN_LIST diff --git a/pubspec.lock b/pubspec.lock index 3d1be9a..f743bee 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -42,7 +42,7 @@ packages: source: hosted version: "1.19.1" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf @@ -120,6 +120,14 @@ packages: description: flutter source: sdk version: "0.0.0" + go_router: + dependency: "direct main" + description: + name: go_router + sha256: f02fd7d2a4dc512fec615529824fdd217fecb3a3d3de68360293a551f21634b3 + url: "https://pub.dev" + source: hosted + version: "14.8.1" leak_tracker: dependency: transitive description: @@ -152,6 +160,14 @@ packages: url: "https://pub.dev" source: hosted version: "6.1.0" + logging: + dependency: transitive + description: + name: logging + sha256: c8245ada5f1717ed44271ed1c26b8ce85ca3228fd2ffdb75468ab01979309d61 + url: "https://pub.dev" + source: hosted + version: "1.3.0" matcher: dependency: transitive description: @@ -365,6 +381,70 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.0" + url_launcher: + dependency: "direct main" + description: + name: url_launcher + sha256: f6a7e5c4835bb4e3026a04793a4199ca2d14c739ec378fdfe23fc8075d0439f8 + url: "https://pub.dev" + source: hosted + version: "6.3.2" + url_launcher_android: + dependency: transitive + description: + name: url_launcher_android + sha256: "767344bf3063897b5cf0db830e94f904528e6dd50a6dfaf839f0abf509009611" + url: "https://pub.dev" + source: hosted + version: "6.3.28" + url_launcher_ios: + dependency: transitive + description: + name: url_launcher_ios + sha256: "580fe5dfb51671ae38191d316e027f6b76272b026370708c2d898799750a02b0" + url: "https://pub.dev" + source: hosted + version: "6.4.1" + url_launcher_linux: + dependency: transitive + description: + name: url_launcher_linux + sha256: d5e14138b3bc193a0f63c10a53c94b91d399df0512b1f29b94a043db7482384a + url: "https://pub.dev" + source: hosted + version: "3.2.2" + url_launcher_macos: + dependency: transitive + description: + name: url_launcher_macos + sha256: "368adf46f71ad3c21b8f06614adb38346f193f3a59ba8fe9a2fd74133070ba18" + url: "https://pub.dev" + source: hosted + version: "3.2.5" + url_launcher_platform_interface: + dependency: transitive + description: + name: url_launcher_platform_interface + sha256: "552f8a1e663569be95a8190206a38187b531910283c3e982193e4f2733f01029" + url: "https://pub.dev" + source: hosted + version: "2.3.2" + url_launcher_web: + dependency: transitive + description: + name: url_launcher_web + sha256: d0412fcf4c6b31ecfdb7762359b7206ffba3bbffd396c6d9f9c4616ece476c1f + url: "https://pub.dev" + source: hosted + version: "2.4.2" + url_launcher_windows: + dependency: transitive + description: + name: url_launcher_windows + sha256: "712c70ab1b99744ff066053cbe3e80c73332b38d46e5e945c98689b2e66fc15f" + url: "https://pub.dev" + source: hosted + version: "3.1.5" vector_math: dependency: transitive description: @@ -415,4 +495,4 @@ packages: version: "1.1.0" sdks: dart: ">=3.11.0 <4.0.0" - flutter: ">=3.35.0" + flutter: ">=3.38.0" diff --git a/pubspec.yaml b/pubspec.yaml index 06a32ed..f848941 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -39,6 +39,9 @@ dependencies: fl_chart: ^0.70.2 percent_indicator: ^4.2.3 shared_preferences: ^2.3.2 + go_router: ^14.6.3 + url_launcher: ^6.3.1 + crypto: ^3.0.6 dev_dependencies: flutter_test: @@ -62,10 +65,8 @@ flutter: # the material Icons class. uses-material-design: true - # To add assets to your application, add an assets section, like this: - # assets: - # - images/a_dot_burr.jpeg - # - images/a_dot_ham.jpeg + assets: + - assets/images/ # An image asset can refer to one or more resolution-specific "variants", see # https://flutter.dev/to/resolution-aware-images