feat: navigation drawer, EMS setup, scheduler, tarifs, paramètres app
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
8862dc2a72
commit
c19c9d1a98
396
lib/main.dart
396
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<MainShell> createState() => _MainShellState();
|
||||
}
|
||||
|
||||
class _MainShellState extends State<MainShell> {
|
||||
int _currentIndex = 0;
|
||||
class _MainShellState extends State<MainShell>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late AnimationController _animCtrl;
|
||||
late Animation<double> _slideAnim;
|
||||
late Animation<double> _overlayAnim;
|
||||
|
||||
final List<Widget> _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<NavigationProvider>();
|
||||
// Listener GoRouter → sync onglet actif (navigation depuis le drawer)
|
||||
_goRouter.routerDelegate.addListener(_syncTabFromRoute);
|
||||
// Listener NavigationProvider → animation drawer UNIQUEMENT
|
||||
_navProvider.addListener(_onDrawerStateChanged);
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_goRouter.routerDelegate.removeListener(_syncTabFromRoute);
|
||||
_navProvider.removeListener(_onDrawerStateChanged);
|
||||
_animCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
/// Détecte le changement de route GoRouter et met à jour l'onglet actif.
|
||||
void _syncTabFromRoute() {
|
||||
if (!mounted) return;
|
||||
final path = _goRouter.routerDelegate.currentConfiguration.uri.path;
|
||||
final tab = _routeToTab[path] ?? _currentTab;
|
||||
if (tab != _currentTab) {
|
||||
setState(() => _currentTab = tab);
|
||||
}
|
||||
}
|
||||
|
||||
/// Déclenche l'animation du drawer. NE touche pas à la navigation.
|
||||
void _onDrawerStateChanged() {
|
||||
if (!mounted) return;
|
||||
if (_navProvider.isDrawerOpen) {
|
||||
_animCtrl.forward();
|
||||
} else {
|
||||
_animCtrl.reverse();
|
||||
}
|
||||
}
|
||||
|
||||
void _onTabTap(int index) {
|
||||
if (index == _currentTab) return;
|
||||
setState(() => _currentTab = index);
|
||||
context.go(_tabToRoute[index]);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: 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<int> 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<NavigationProvider>().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)),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
173
lib/providers/app_settings_provider.dart
Normal file
173
lib/providers/app_settings_provider.dart
Normal file
@ -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<String, dynamic> 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<AppScreen> _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<AppScreen> get screens => _screens;
|
||||
List<AppScreen> get visibleScreens =>
|
||||
_screens.where((s) => s.visible).toList();
|
||||
|
||||
static const List<Color> 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<void> 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<dynamic> saved = jsonDecode(raw) as List;
|
||||
final byId = {for (final s in _screens) s.id: s};
|
||||
final restored = <AppScreen>[];
|
||||
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<void> _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;
|
||||
}
|
||||
}
|
||||
171
lib/providers/energy_setup_provider.dart
Normal file
171
lib/providers/energy_setup_provider.dart
Normal file
@ -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<String> 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<String, dynamic> params; // puissance, priorité, etc.
|
||||
|
||||
const RoleAssignment({
|
||||
required this.role,
|
||||
required this.thing,
|
||||
this.enabled = true,
|
||||
this.params = const {},
|
||||
});
|
||||
|
||||
RoleAssignment copyWith({
|
||||
bool? enabled,
|
||||
Map<String, dynamic>? 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<EmsRole, RoleAssignment?> _assignments = {
|
||||
for (final r in EmsRole.values) r: null,
|
||||
};
|
||||
|
||||
ConnectionTestResult _testResult =
|
||||
const ConnectionTestResult(ConnectionTestStatus.idle);
|
||||
|
||||
Map<EmsRole, RoleAssignment?> 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<String, dynamic> 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<NymeaThing> 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<void> 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();
|
||||
}
|
||||
}
|
||||
113
lib/providers/installer_mode_provider.dart
Normal file
113
lib/providers/installer_mode_provider.dart
Normal file
@ -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<void> 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<UnlockResult> 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<void> 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 }
|
||||
23
lib/providers/navigation_provider.dart
Normal file
23
lib/providers/navigation_provider.dart
Normal file
@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
111
lib/providers/scheduler_provider.dart
Normal file
111
lib/providers/scheduler_provider.dart
Normal file
@ -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<void> 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<void> 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();
|
||||
}
|
||||
}
|
||||
133
lib/providers/tariff_provider.dart
Normal file
133
lib/providers/tariff_provider.dart
Normal file
@ -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<void> 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();
|
||||
}
|
||||
}
|
||||
@ -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<ACScreen> {
|
||||
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),
|
||||
|
||||
@ -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<DashboardScreen> {
|
||||
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
|
||||
|
||||
265
lib/screens/drawer/installer_pin_dialog.dart
Normal file
265
lib/screens/drawer/installer_pin_dialog.dart
Normal file
@ -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<InstallerPinDialog> createState() => _InstallerPinDialogState();
|
||||
}
|
||||
|
||||
class _InstallerPinDialogState extends State<InstallerPinDialog> {
|
||||
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<void> _tryUnlock() async {
|
||||
final provider = context.read<InstallerModeProvider>();
|
||||
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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
659
lib/screens/drawer/main_drawer.dart
Normal file
659
lib/screens/drawer/main_drawer.dart
Normal file
@ -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<NavigationProvider>().closeDrawer(),
|
||||
child: Container(color: Colors.black.withValues(alpha: 0.45)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Panel du drawer (largeur fixe, fond sombre ETM).
|
||||
class DrawerPanel extends StatelessWidget {
|
||||
const DrawerPanel({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return SizedBox(
|
||||
width: 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<NymeaService>();
|
||||
final installer = context.watch<InstallerModeProvider>();
|
||||
|
||||
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<InstallerModeProvider>();
|
||||
|
||||
return ListView(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||
children: [
|
||||
// ── VUE PRINCIPALE ────────────────────────────────────────────────
|
||||
_SectionLabel('VUE PRINCIPALE'),
|
||||
_NavItem(icon: Icons.home_rounded, label: 'Dashboard', route: '/'),
|
||||
_NavItem(icon: Icons.bar_chart_rounded, label: 'Énergie', route: '/energy'),
|
||||
_NavItem(icon: Icons.device_hub_rounded, label: 'Things', route: '/things'),
|
||||
_NavItem(icon: Icons.star_rounded, label: 'Favoris', route: '/favorites'),
|
||||
_NavItem(icon: Icons.group_work_rounded, label: 'Groupes', route: '/groups'),
|
||||
_NavItem(icon: Icons.auto_awesome_rounded, label: 'Scènes', route: '/scenes'),
|
||||
_NavItem(icon: Icons.music_note_rounded, label: 'Médias', route: '/media'),
|
||||
_NavItem(icon: Icons.garage_rounded, label: 'Garages', route: '/garages'),
|
||||
_NavItem(icon: Icons.ac_unit_rounded, label: 'AC / Climatisation', route: '/ac'),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
_Divider(),
|
||||
|
||||
// ── CONFIGURATION ─────────────────────────────────────────────────
|
||||
_SectionLabel('CONFIGURATION'),
|
||||
_NavItem(icon: Icons.auto_fix_high_rounded, label: 'Magic / Automatisations', route: '/automations', push: true),
|
||||
|
||||
// Gestionnaire d'énergie (ExpansionTile)
|
||||
_EnergyManagerExpansion(),
|
||||
|
||||
// App Settings (ExpansionTile)
|
||||
_AppSettingsExpansion(),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
_Divider(),
|
||||
|
||||
// ── MODE INSTALLATEUR ─────────────────────────────────────────────
|
||||
_InstallerSection(isUnlocked: installer.isUnlocked),
|
||||
|
||||
const SizedBox(height: 4),
|
||||
_Divider(),
|
||||
|
||||
// ── SUPPORT ───────────────────────────────────────────────────────
|
||||
_SectionLabel('SUPPORT'),
|
||||
_LinkItem(
|
||||
icon: Icons.menu_book_rounded,
|
||||
label: 'Documentation',
|
||||
url: 'https://etm.at/powersync/docs',
|
||||
),
|
||||
_LinkItem(
|
||||
icon: Icons.telegram_rounded,
|
||||
label: 'Telegram',
|
||||
url: 'https://t.me/etm_support',
|
||||
),
|
||||
_LinkItem(
|
||||
icon: Icons.discord_rounded,
|
||||
label: 'Discord',
|
||||
url: 'https://discord.gg/etm',
|
||||
),
|
||||
_NavItem(
|
||||
icon: Icons.bug_report_rounded,
|
||||
label: 'Rapport de bug',
|
||||
route: '/bug-report',
|
||||
),
|
||||
|
||||
const SizedBox(height: 16),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Éléments de menu ──────────────────────────────────────────────────────────
|
||||
|
||||
class _SectionLabel extends StatelessWidget {
|
||||
final String text;
|
||||
const _SectionLabel(this.text);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.fromLTRB(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<NavigationProvider>().closeDrawer();
|
||||
if (push) {
|
||||
context.push(route);
|
||||
} else {
|
||||
context.go(route);
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Item qui ouvre une URL dans le navigateur externe.
|
||||
class _LinkItem extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final String url;
|
||||
|
||||
const _LinkItem({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.url,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return _DrawerTile(
|
||||
icon: icon,
|
||||
label: label,
|
||||
trailing: Icon(Icons.open_in_new_rounded,
|
||||
size: 14, color: ETMTheme.drawerTextMuted),
|
||||
onTap: () async {
|
||||
context.read<NavigationProvider>().closeDrawer();
|
||||
final uri = Uri.parse(url);
|
||||
if (!await launchUrl(uri, mode: LaunchMode.externalApplication)) {
|
||||
if (context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('Impossible d\'ouvrir : $url')),
|
||||
);
|
||||
}
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _DrawerTile extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final String label;
|
||||
final Widget? trailing;
|
||||
final VoidCallback onTap;
|
||||
|
||||
const _DrawerTile({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.onTap,
|
||||
this.trailing,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return InkWell(
|
||||
onTap: onTap,
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 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<Widget> children;
|
||||
|
||||
const _StyledExpansion({
|
||||
required this.icon,
|
||||
required this.label,
|
||||
required this.children,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Theme(
|
||||
data: Theme.of(context).copyWith(
|
||||
dividerColor: Colors.transparent,
|
||||
),
|
||||
child: ExpansionTile(
|
||||
leading: Icon(icon, size: 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<NavigationProvider>().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<InstallerModeProvider>().lock(),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _promptPin(BuildContext context) async {
|
||||
final result = await showDialog<bool>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) => const InstallerPinDialog(),
|
||||
);
|
||||
if (result == true && context.mounted) {
|
||||
// Le provider a déjà été mis à jour — pas besoin d'action supplémentaire
|
||||
}
|
||||
}
|
||||
}
|
||||
172
lib/screens/energy/energy_setup_screen.dart
Normal file
172
lib/screens/energy/energy_setup_screen.dart
Normal file
@ -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<InstallerModeProvider>();
|
||||
|
||||
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<EnergySetupProvider>();
|
||||
final service = context.read<NymeaService>();
|
||||
|
||||
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<void> _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),
|
||||
);
|
||||
}
|
||||
}
|
||||
688
lib/screens/energy/role_config_flow.dart
Normal file
688
lib/screens/energy/role_config_flow.dart
Normal file
@ -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<RoleConfigFlow> createState() => _RoleConfigFlowState();
|
||||
}
|
||||
|
||||
class _RoleConfigFlowState extends State<RoleConfigFlow> {
|
||||
int _step = 0;
|
||||
NymeaThing? _selectedThing;
|
||||
String _searchQuery = '';
|
||||
final Map<String, dynamic> _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<void> _runTest() async {
|
||||
final setup = context.read<EnergySetupProvider>();
|
||||
final service = context.read<NymeaService>();
|
||||
// Assigne temporairement pour le test
|
||||
setup.assign(widget.role, _selectedThing!, _params);
|
||||
await setup.testConnection(widget.role, service);
|
||||
if (mounted) setState(() {});
|
||||
}
|
||||
|
||||
void _save() {
|
||||
context
|
||||
.read<EnergySetupProvider>()
|
||||
.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<NymeaService>().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<String> onSearch;
|
||||
final ValueChanged<NymeaThing> 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<EnergySetupProvider>();
|
||||
final service = context.read<NymeaService>();
|
||||
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<String, dynamic> params;
|
||||
final List<NymeaThing> 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<Widget> _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<Widget> _relayParams() => [
|
||||
_NumField('Puissance nominale (W)', 'powerW', params, onChange,
|
||||
min: 100, max: 20000),
|
||||
_PriorityDropdown(params, onChange),
|
||||
];
|
||||
|
||||
List<Widget> _heatPumpParams(List<NymeaThing> things) => [
|
||||
_NumField('Puissance normale (W)', 'powerW', params, onChange,
|
||||
min: 500, max: 20000),
|
||||
_PriorityDropdown(params, onChange),
|
||||
];
|
||||
|
||||
List<Widget> _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<Widget> _solarMeterParams() => [
|
||||
_NumField('Puissance crête onduleur (W)', 'powerW', params, onChange,
|
||||
min: 500, max: 100000),
|
||||
];
|
||||
|
||||
List<Widget> _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<String, dynamic> 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<String, dynamic> 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<String>(
|
||||
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<EnergySetupProvider>().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<EnergySetupProvider>().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 →'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
400
lib/screens/energy/scheduler_screen.dart
Normal file
400
lib/screens/energy/scheduler_screen.dart
Normal file
@ -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<SchedulerScreen> createState() => _SchedulerScreenState();
|
||||
}
|
||||
|
||||
class _SchedulerScreenState extends State<SchedulerScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<SchedulerProvider>().load(context.read<NymeaService>());
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final scheduler = context.watch<SchedulerProvider>();
|
||||
|
||||
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<SchedulerStrategy>(
|
||||
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<SchedulerProvider>()
|
||||
.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<SchedulerProvider>()
|
||||
.updateConfig(cfg.copyWith(priceThreshold: v)),
|
||||
),
|
||||
_ConfigRow(
|
||||
label: 'Surplus minimum',
|
||||
value: cfg.minSurplus,
|
||||
unit: 'W',
|
||||
min: 50, max: 2000,
|
||||
onChanged: (v) => context
|
||||
.read<SchedulerProvider>()
|
||||
.updateConfig(cfg.copyWith(minSurplus: v)),
|
||||
),
|
||||
_ConfigRow(
|
||||
label: 'Horizon planification',
|
||||
value: cfg.planningHorizon.toDouble(),
|
||||
unit: 'h',
|
||||
min: 1, max: 72,
|
||||
onChanged: (v) => context
|
||||
.read<SchedulerProvider>()
|
||||
.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<SchedulerProvider>()
|
||||
.updateConfig(cfg.copyWith(recalcInterval: v.toInt())),
|
||||
),
|
||||
_ConfigRow(
|
||||
label: 'Objectif autosuffisance',
|
||||
value: cfg.selfSufficiencyGoal,
|
||||
unit: '%',
|
||||
min: 0, max: 100,
|
||||
onChanged: (v) => context
|
||||
.read<SchedulerProvider>()
|
||||
.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<double> 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<SchedulerProvider>()
|
||||
.forceRecalc(context.read<NymeaService>()),
|
||||
),
|
||||
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,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
537
lib/screens/energy/tariff_screen.dart
Normal file
537
lib/screens/energy/tariff_screen.dart
Normal file
@ -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<TariffScreen> createState() => _TariffScreenState();
|
||||
}
|
||||
|
||||
class _TariffScreenState extends State<TariffScreen> {
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||
context.read<TariffProvider>().load();
|
||||
});
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final tariff = context.watch<TariffProvider>();
|
||||
|
||||
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<TariffProvider>().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<TariffMode>(
|
||||
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<TariffProvider>().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<TariffProvider>()
|
||||
.updateHcHp(hchp.copyWith(offPeakPrice: v)),
|
||||
),
|
||||
_PriceRow(
|
||||
label: 'Prix HP',
|
||||
value: hchp.peakPrice,
|
||||
unit: '€/kWh',
|
||||
onChanged: (v) => context
|
||||
.read<TariffProvider>()
|
||||
.updateHcHp(hchp.copyWith(peakPrice: v)),
|
||||
),
|
||||
_TimeRow(
|
||||
label: 'Début HC',
|
||||
time: hchp.offPeakStart,
|
||||
onChanged: (t) => context
|
||||
.read<TariffProvider>()
|
||||
.updateHcHp(hchp.copyWith(offPeakStart: t)),
|
||||
),
|
||||
_TimeRow(
|
||||
label: 'Fin HC',
|
||||
time: hchp.offPeakEnd,
|
||||
onChanged: (t) => context
|
||||
.read<TariffProvider>()
|
||||
.updateHcHp(hchp.copyWith(offPeakEnd: t)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _PriceRow extends StatefulWidget {
|
||||
final String label;
|
||||
final double value;
|
||||
final String unit;
|
||||
final ValueChanged<double> 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<TimeOfDay> 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<TariffProvider>().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,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
432
lib/screens/energy/timeline_screen.dart
Normal file
432
lib/screens/energy/timeline_screen.dart
Normal file
@ -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<TimelineScreen> createState() => _TimelineScreenState();
|
||||
}
|
||||
|
||||
class _TimelineScreenState extends State<TimelineScreen> {
|
||||
String _horizon = '24h';
|
||||
bool _loading = true;
|
||||
List<TimelineSlot> _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<void> _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<TimelineSlot> _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 = <TimelineSlot>[];
|
||||
|
||||
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<String>(
|
||||
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,
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<EnergyScreen> {
|
||||
|
||||
int _tabIdx = 0;
|
||||
List<PowerBalanceEntry> _data = [];
|
||||
List<HistoryEntry> _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>();
|
||||
_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<void> _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<NymeaService>();
|
||||
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<void> _fetch() async {
|
||||
@ -62,16 +123,34 @@ class _EnergyScreenState extends State<EnergyScreen> {
|
||||
_selectedDate!.year, _selectedDate!.month, _selectedDate!.day,
|
||||
23, 59, 59)
|
||||
: DateTime.now();
|
||||
final data = await context.read<NymeaService>().fetchPowerBalanceLogs(
|
||||
from: to.subtract(tab.range),
|
||||
to: to,
|
||||
sampleRate: tab.sampleRate,
|
||||
);
|
||||
final from = to.subtract(tab.range);
|
||||
final service = context.read<NymeaService>();
|
||||
// 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<dynamic>([
|
||||
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<List<HistoryEntry>>.value([]),
|
||||
]);
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_data = data;
|
||||
_data = results[0] as List<PowerBalanceEntry>;
|
||||
_socData = results[1] as List<HistoryEntry>;
|
||||
_loading = false;
|
||||
_noData = data.isEmpty;
|
||||
_noData = _data.isEmpty;
|
||||
});
|
||||
}
|
||||
|
||||
@ -92,7 +171,7 @@ class _EnergyScreenState extends State<EnergyScreen> {
|
||||
),
|
||||
);
|
||||
if (picked != null && mounted) {
|
||||
setState(() => _selectedDate = picked);
|
||||
setState(() { _selectedDate = picked; _initialSocFetched = false; });
|
||||
_fetch();
|
||||
}
|
||||
}
|
||||
@ -106,6 +185,8 @@ class _EnergyScreenState extends State<EnergyScreen> {
|
||||
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<EnergyScreen> {
|
||||
|
||||
// ── ① 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<EnergyScreen> {
|
||||
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<EnergyScreen> {
|
||||
final prodSpots = <FlSpot>[];
|
||||
final consoSpots = <FlSpot>[];
|
||||
final autoSpots = <FlSpot>[];
|
||||
final batSpots = <FlSpot>[];
|
||||
final socSpots = <FlSpot>[]; // 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<EnergyScreen> {
|
||||
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<EnergyScreen> {
|
||||
),
|
||||
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<EnergyScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
// 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<EnergyScreen> {
|
||||
),
|
||||
),
|
||||
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(),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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,
|
||||
|
||||
168
lib/screens/settings/app_settings_screen.dart
Normal file
168
lib/screens/settings/app_settings_screen.dart
Normal file
@ -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<InstallerModeProvider>();
|
||||
|
||||
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,
|
||||
});
|
||||
}
|
||||
233
lib/screens/settings/appearance_screen.dart
Normal file
233
lib/screens/settings/appearance_screen.dart
Normal file
@ -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<AppSettingsProvider>();
|
||||
|
||||
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<ThemeMode>(
|
||||
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<AppSettingsProvider>()
|
||||
.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<AppSettingsProvider>().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<AppSettingsProvider>()
|
||||
.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<int>(
|
||||
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<AppSettingsProvider>()
|
||||
.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<AppSettingsProvider>()
|
||||
.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,
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
129
lib/screens/settings/screens_settings_screen.dart
Normal file
129
lib/screens/settings/screens_settings_screen.dart
Normal file
@ -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<AppSettingsProvider>();
|
||||
|
||||
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<AppSettingsProvider>()
|
||||
.reorderScreens(oldIdx, newIdx);
|
||||
},
|
||||
itemBuilder: (context, i) {
|
||||
final screen = settings.screens[i];
|
||||
return _ScreenTile(
|
||||
key: ValueKey(screen.id),
|
||||
screen: screen,
|
||||
onToggle: (v) => context
|
||||
.read<AppSettingsProvider>()
|
||||
.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<bool> 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)),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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(
|
||||
|
||||
@ -93,6 +93,41 @@ class NymeaService extends ChangeNotifier {
|
||||
List<NymeaThingClass> get thingClasses => _thingClasses;
|
||||
List<FavoriteWidget> 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<String, String>? 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 <String>[]; } }).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<HistoryEntry>((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<HistoryEntry>((e) {
|
||||
final ts = DateTime.fromMillisecondsSinceEpoch(
|
||||
(e['timestamp'] as num).toInt());
|
||||
final values = (e['values'] as Map?)?.cast<String, dynamic>() ?? {};
|
||||
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<num>();
|
||||
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
|
||||
|
||||
141
lib/theme/etm_theme.dart
Normal file
141
lib/theme/etm_theme.dart
Normal file
@ -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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
|
||||
|
||||
83
lib/widgets/power_bar.dart
Normal file
83
lib/widgets/power_bar.dart
Normal file
@ -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),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
93
lib/widgets/pro_lock_badge.dart
Normal file
93
lib/widgets/pro_lock_badge.dart
Normal file
@ -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'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
179
lib/widgets/role_card.dart
Normal file
179
lib/widgets/role_card.dart
Normal file
@ -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)),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
309
lib/widgets/timeline_slot_card.dart
Normal file
309
lib/widgets/timeline_slot_card.dart
Normal file
@ -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<double>(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)),
|
||||
],
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -6,6 +6,10 @@
|
||||
|
||||
#include "generated_plugin_registrant.h"
|
||||
|
||||
#include <url_launcher_linux/url_launcher_plugin.h>
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
@ -3,6 +3,7 @@
|
||||
#
|
||||
|
||||
list(APPEND FLUTTER_PLUGIN_LIST
|
||||
url_launcher_linux
|
||||
)
|
||||
|
||||
list(APPEND FLUTTER_FFI_PLUGIN_LIST
|
||||
|
||||
84
pubspec.lock
84
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"
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user