- 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>
473 lines
18 KiB
Dart
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)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|