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:
Patrick Schurig 2026-02-24 14:52:32 +01:00
parent 8862dc2a72
commit c19c9d1a98
33 changed files with 6005 additions and 118 deletions

View File

@ -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)),
),
],
),
),
);
}
}

View 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;
}
}

View 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();
}
}

View 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 }

View 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();
}
}
}

View 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();
}
}

View 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();
}
}

View File

@ -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),

View File

@ -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

View 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,
),
),
),
),
);
}
}

View 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à é mis à jour pas besoin d'action supplémentaire
}
}
}

View 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),
);
}
}

View 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 →'),
),
],
),
);
}
}

View 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,
],
),
),
);
}
}

View 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,
],
),
),
);
}
}

View 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,
),
],
),
);
}
}

View File

@ -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 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(),
),
),
),

View File

@ -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,

View 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,
});
}

View 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,
],
),
),
);
}
}

View 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)),
],
),
),
);
}
}

View File

@ -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(

View File

@ -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
View 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'),
),
),
],
),
);
}
}

View File

@ -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);
}

View 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),
),
),
),
],
);
},
);
}
}

View 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
View 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)),
),
),
],
),
],
],
),
),
);
}
}

View 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)),
],
],
),
);
}
}

View File

@ -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);
}

View File

@ -3,6 +3,7 @@
#
list(APPEND FLUTTER_PLUGIN_LIST
url_launcher_linux
)
list(APPEND FLUTTER_FFI_PLUGIN_LIST

View File

@ -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"

View File

@ -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