pakutz79 c19c9d1a98 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>
2026-02-24 14:52:32 +01:00

473 lines
18 KiB
Dart

import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import 'providers/app_settings_provider.dart';
import 'providers/energy_setup_provider.dart';
import 'providers/installer_mode_provider.dart';
import 'providers/navigation_provider.dart';
import 'providers/scheduler_provider.dart';
import 'providers/tariff_provider.dart';
import 'screens/ac_screen.dart';
import 'screens/dashboard_screen.dart';
import 'screens/drawer/main_drawer.dart';
import 'screens/energy/energy_setup_screen.dart';
import 'screens/energy/scheduler_screen.dart';
import 'screens/energy/tariff_screen.dart';
import 'screens/energy/timeline_screen.dart';
import 'screens/energy_screen.dart';
import 'screens/favorites_screen.dart';
import 'screens/settings/app_settings_screen.dart';
import 'screens/settings/appearance_screen.dart';
import 'screens/settings/screens_settings_screen.dart';
import 'screens/things_screen.dart';
import 'services/nymea_service.dart';
import 'theme/app_theme.dart';
import 'theme/etm_theme.dart';
// ─────────────────────────────────────────────────────────────────────────────
// Router GoRouter
// ─────────────────────────────────────────────────────────────────────────────
final _router = GoRouter(
initialLocation: '/',
routes: [
// ── Shell principal (bottom nav + drawer overlay) ─────────────────────
ShellRoute(
builder: (context, state, child) => MainShell(child: child),
routes: [
GoRoute(path: '/', builder: (c, s) => const DashboardScreen()),
GoRoute(path: '/energy', builder: (c, s) => const EnergyScreen()),
GoRoute(path: '/things', builder: (c, s) => const ThingsScreen()),
GoRoute(path: '/ac', builder: (c, s) => const ACScreen()),
GoRoute(path: '/favorites', builder: (c, s) => const FavoritesScreen()),
// Routes stubées — à implémenter plus tard
GoRoute(path: '/groups', builder: (c, s) => _StubScreen('Groupes')),
GoRoute(path: '/scenes', builder: (c, s) => _StubScreen('Scènes')),
GoRoute(path: '/media', builder: (c, s) => _StubScreen('Médias')),
GoRoute(path: '/garages', builder: (c, s) => _StubScreen('Garages')),
GoRoute(path: '/automations', builder: (c, s) => _StubScreen('Magic / Automatisations')),
GoRoute(path: '/bug-report', builder: (c, s) => _StubScreen('Rapport de bug')),
],
),
// ── Écrans énergie (push par-dessus le shell) ─────────────────────────
GoRoute(
path: '/energy/setup',
builder: (c, s) => const EnergySetupScreen(),
),
GoRoute(
path: '/energy/scheduler',
builder: (c, s) => const SchedulerScreen(),
),
GoRoute(
path: '/energy/tariffs',
builder: (c, s) => const TariffScreen(),
),
GoRoute(
path: '/energy/timeline',
builder: (c, s) => const TimelineScreen(),
),
// ── Écrans settings (push par-dessus le shell) ────────────────────────
GoRoute(
path: '/settings/app',
builder: (c, s) => const AppSettingsScreen(),
),
GoRoute(
path: '/settings/app/appearance',
builder: (c, s) => const AppearanceScreen(),
),
GoRoute(
path: '/settings/app/screens',
builder: (c, s) => const ScreensSettingsScreen(),
),
GoRoute(
path: '/settings/app/developer',
builder: (c, s) => _StubScreen('Options développeur'),
),
GoRoute(
path: '/settings/app/about',
builder: (c, s) => _StubScreen('À propos PowerSync'),
),
// Routes installateur stubées
GoRoute(path: '/settings/system', builder: (c, s) => _StubScreen('Configuration système')),
GoRoute(path: '/settings/system/things', builder: (c, s) => _StubScreen('Configuration Things')),
GoRoute(path: '/settings/system/network', builder: (c, s) => _StubScreen('Système & réseau')),
GoRoute(path: '/settings/system/protocols',builder: (c, s) => _StubScreen('Protocoles')),
GoRoute(path: '/settings/system/mqtt', builder: (c, s) => _StubScreen('MQTT / Web server')),
GoRoute(path: '/settings/system/plugins', builder: (c, s) => _StubScreen('Plugins')),
GoRoute(path: '/settings/system/update', builder: (c, s) => _StubScreen('Mise à jour système')),
GoRoute(path: '/settings/system/devtools', builder: (c, s) => _StubScreen('Outils développeur')),
GoRoute(path: '/settings/system/about', builder: (c, s) => _StubScreen('À propos ETM')),
],
);
// ─────────────────────────────────────────────────────────────────────────────
// main()
// ─────────────────────────────────────────────────────────────────────────────
void main() async {
WidgetsFlutterBinding.ensureInitialized();
SystemChrome.setPreferredOrientations([DeviceOrientation.portraitUp]);
SystemChrome.setSystemUIOverlayStyle(
const SystemUiOverlayStyle(
statusBarColor: Colors.transparent,
statusBarIconBrightness: Brightness.dark,
),
);
// Initialisation des providers qui ont besoin d'I/O asynchrone
final appSettings = AppSettingsProvider();
final installerMode = InstallerModeProvider();
await Future.wait([
appSettings.load(),
installerMode.init(),
]);
runApp(
MultiProvider(
providers: [
ChangeNotifierProvider(create: (_) => NymeaService()),
ChangeNotifierProvider(create: (_) => NavigationProvider()),
ChangeNotifierProvider.value(value: installerMode),
ChangeNotifierProvider.value(value: appSettings),
ChangeNotifierProvider(create: (_) => EnergySetupProvider()),
ChangeNotifierProvider(create: (_) => SchedulerProvider()),
ChangeNotifierProvider(create: (_) => TariffProvider()),
],
child: const NymeaEnergyApp(),
),
);
}
// ─────────────────────────────────────────────────────────────────────────────
// Application root
// ─────────────────────────────────────────────────────────────────────────────
class NymeaEnergyApp extends StatelessWidget {
const NymeaEnergyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp.router(
title: 'ETM PowerSync',
debugShowCheckedModeBanner: false,
theme: AppTheme.theme,
routerConfig: _router,
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// MainShell — Scaffold principal avec bottom nav + drawer overlay
// ─────────────────────────────────────────────────────────────────────────────
class MainShell extends StatefulWidget {
final Widget child;
const MainShell({super.key, required this.child});
@override
State<MainShell> createState() => _MainShellState();
}
class _MainShellState extends State<MainShell>
with SingleTickerProviderStateMixin {
late AnimationController _animCtrl;
late Animation<double> _slideAnim;
late Animation<double> _overlayAnim;
// Mapping route → index onglet
static const _routeToTab = {
'/': 0,
'/energy': 1,
'/things': 2,
'/ac': 3,
'/favorites': 4,
};
static const _tabToRoute = ['/', '/energy', '/things', '/ac', '/favorites'];
static const _navItems = [
_NavItem(icon: Icons.home_rounded, label: 'Dashboard'),
_NavItem(icon: Icons.bar_chart_rounded, label: 'Énergie'),
_NavItem(icon: Icons.device_hub_rounded, label: 'Things'),
_NavItem(icon: Icons.ac_unit_rounded, label: 'A/C'),
_NavItem(icon: Icons.star_rounded, label: 'Favoris'),
];
int _currentTab = 0;
// Listener GoRouter pour syncer _currentTab sur les navigations du drawer
late final GoRouter _goRouter;
late final NavigationProvider _navProvider;
@override
void initState() {
super.initState();
_animCtrl = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 260),
);
_slideAnim = CurvedAnimation(parent: _animCtrl, curve: Curves.easeOutCubic);
_overlayAnim = CurvedAnimation(parent: _animCtrl, curve: Curves.easeOut);
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
// Récupère GoRouter et NavigationProvider une seule fois
_goRouter = GoRouter.of(context);
_navProvider = context.read<NavigationProvider>();
// Listener GoRouter → sync onglet actif (navigation depuis le drawer)
_goRouter.routerDelegate.addListener(_syncTabFromRoute);
// Listener NavigationProvider → animation drawer UNIQUEMENT
_navProvider.addListener(_onDrawerStateChanged);
}
@override
void dispose() {
_goRouter.routerDelegate.removeListener(_syncTabFromRoute);
_navProvider.removeListener(_onDrawerStateChanged);
_animCtrl.dispose();
super.dispose();
}
/// Détecte le changement de route GoRouter et met à jour l'onglet actif.
void _syncTabFromRoute() {
if (!mounted) return;
final path = _goRouter.routerDelegate.currentConfiguration.uri.path;
final tab = _routeToTab[path] ?? _currentTab;
if (tab != _currentTab) {
setState(() => _currentTab = tab);
}
}
/// Déclenche l'animation du drawer. NE touche pas à la navigation.
void _onDrawerStateChanged() {
if (!mounted) return;
if (_navProvider.isDrawerOpen) {
_animCtrl.forward();
} else {
_animCtrl.reverse();
}
}
void _onTabTap(int index) {
if (index == _currentTab) return;
setState(() => _currentTab = index);
context.go(_tabToRoute[index]);
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Stack(
children: [
// ── Contenu principal + bottom nav ───────────────────────────────
Column(
children: [
Expanded(child: widget.child),
_BottomNav(
currentIndex: _currentTab,
items: _navItems,
onTap: _onTabTap,
),
],
),
// ── Overlay sombre ────────────────────────────────────────────────
AnimatedBuilder(
animation: _overlayAnim,
builder: (context, _) {
if (_overlayAnim.value == 0) return const SizedBox.shrink();
return Opacity(
opacity: _overlayAnim.value,
child: const DrawerScrim(),
);
},
),
// ── Drawer panneau ────────────────────────────────────────────────
AnimatedBuilder(
animation: _slideAnim,
builder: (context, _) {
final offset = ETMTheme.drawerWidth * (_slideAnim.value - 1);
return Transform.translate(
offset: Offset(offset, 0),
child: const Align(
alignment: Alignment.centerLeft,
child: DrawerPanel(),
),
);
},
),
],
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Bottom navigation bar
// ─────────────────────────────────────────────────────────────────────────────
class _BottomNav extends StatelessWidget {
final int currentIndex;
final List<_NavItem> items;
final ValueChanged<int> onTap;
const _BottomNav({
required this.currentIndex,
required this.items,
required this.onTap,
});
@override
Widget build(BuildContext context) {
return Container(
decoration: BoxDecoration(
color: Colors.white,
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.08),
blurRadius: 12,
offset: const Offset(0, -2),
),
],
),
child: SafeArea(
child: SizedBox(
height: 62,
child: Row(
children: items.asMap().entries.map((e) {
final i = e.key;
final item = e.value;
final selected = currentIndex == i;
return Expanded(
child: GestureDetector(
onTap: () => onTap(i),
behavior: HitTestBehavior.opaque,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: const EdgeInsets.symmetric(
horizontal: 12, vertical: 4),
decoration: BoxDecoration(
color: selected
? AppTheme.primaryGreen
.withValues(alpha: 0.12)
: Colors.transparent,
borderRadius: BorderRadius.circular(12),
),
child: Icon(
item.icon,
color: selected
? AppTheme.primaryGreen
: AppTheme.textLight,
size: 22,
),
),
const SizedBox(height: 2),
Text(
item.label,
style: TextStyle(
fontSize: 10,
color: selected
? AppTheme.primaryGreen
: AppTheme.textLight,
fontWeight: selected
? FontWeight.bold
: FontWeight.normal,
),
),
],
),
),
);
}).toList(),
),
),
),
);
}
}
class _NavItem {
final IconData icon;
final String label;
const _NavItem({required this.icon, required this.label});
}
// ─────────────────────────────────────────────────────────────────────────────
// Widget bouton d'ouverture du drawer (à placer dans l'AppBar de chaque écran)
// ─────────────────────────────────────────────────────────────────────────────
/// Bouton hamburger/logo ETM qui ouvre le drawer depuis n'importe quel écran.
class DrawerMenuButton extends StatelessWidget {
const DrawerMenuButton({super.key});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(left: 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)),
),
],
),
),
);
}
}