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