Design system - lib/theme/etm_tokens.dart : source de vérité couleurs + typo (IBM Plex Sans/Mono) - lib/models/nymea_user.dart : modèle utilisateur nymea avec permissions EtmRole - app_theme.dart : ThemeData migré vers IBM Plex Sans + couleurs EtmTokens Navigation & drawer - DrawerMenuButton : logo vert gradient + ombre - Bottom nav : EtmTokens.green actif, EtmTokens.muted inactif - DrawerPanel 320 px, restyled navy + gradient header + badges rôle Dashboard (01_dashboard.html) - Hero système : status pill + 3 métriques mono + illustration maison CustomPainter - EnergyFlowWidget : 4 nœuds animés CustomPainter (flèches directionnelles) · gridPower > 0 = soutirage → flèche Grid→Home (amber) · gridPower < 0 = injection → flèche Home→Grid (bleu) - EVChargingCard restyled : badge En charge + puissance mono 38px + 3 modes + SOC bar - KPI 2×2 : spark bars, trend line, progress bar - Consommateurs principaux + Décisions d'Héos (chips motifs) - Prévisions placeholder explicite Énergie - KPI 2×2 avec icônes + fond soft + IBM Plex Mono - Sélecteur période vert pill - LineChart double axe : kW (gauche) / SOC % (droite, normalisé) - BarChart bilan énergétique Wh (amber/bleu) - Section Météo & prévision placeholder Things - Grille 2 col à hauteur intrinsèque (pas de childAspectRatio) - Bandeau statut global (simulation / connecté / hors-ligne) - _CategoryCard : header icon+label+count, séparateur coloré, liste tous items - thing_category.dart : couleurs migrées vers EtmTokens A/C — Climatisation / Chauffage - Thermostats pièces EN HAUT : actives expandées, éteintes compactes - Températures actuelle → cible ± avec EtmTokens.mono - Sélecteur mode 4 boutons (Chauf/Clim/Auto/Vent) - Chip "Chauffe au solaire en ce moment" (Héos) - Sources pilotées par Héos EN BAS : · PAC SG-Ready : 4 états (Bloqué grisé / Normal / Recommandé / Forcé) + toggle Auto · Chauffe-eau : Surplus/Éco/Boost + temp eau 52°→60°C · Climatiseur : pré-refroidissement anticipé + info solaire Packages ajoutés : google_fonts, flutter_staggered_grid_view, flutter_secure_storage Asset : assets/house.svg (illustration maison CustomPainter) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
505 lines
20 KiB
Dart
505 lines
20 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 'package:flutter/foundation.dart' show kIsWeb;
|
|
|
|
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_tokens.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,
|
|
builder: (context, child) {
|
|
// Sur mobile natif, on rend tel quel
|
|
if (!kIsWeb) return child!;
|
|
// Sur web : si la fenêtre est déjà étroite (smartphone qui visite la PWA),
|
|
// on rend plein écran. Sur desktop, on encadre dans un viewport "mobile".
|
|
final width = MediaQuery.sizeOf(context).width;
|
|
if (width < 600) return child!;
|
|
return Container(
|
|
color: const Color(0xFF0a1f2b), // navy ETM en arrière-plan
|
|
alignment: Alignment.center,
|
|
child: Container(
|
|
constraints: const BoxConstraints(maxWidth: 440, maxHeight: 900),
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(24),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: Colors.black.withValues(alpha: 0.5),
|
|
blurRadius: 40,
|
|
),
|
|
],
|
|
),
|
|
child: ClipRRect(
|
|
borderRadius: BorderRadius.circular(24),
|
|
child: child,
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// 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 = 320.0 * (_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: 14, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: selected
|
|
? EtmTokens.green.withValues(alpha: 0.12)
|
|
: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Icon(
|
|
item.icon,
|
|
color: selected ? EtmTokens.green : EtmTokens.muted,
|
|
size: 22,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
item.label,
|
|
style: EtmTokens.sans(
|
|
size: 10,
|
|
color: selected ? EtmTokens.green : EtmTokens.muted,
|
|
weight: selected ? FontWeight.w600 : FontWeight.w400,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}).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: 12),
|
|
child: GestureDetector(
|
|
onTap: () => context.read<NavigationProvider>().openDrawer(),
|
|
child: Container(
|
|
width: 38, height: 38,
|
|
decoration: BoxDecoration(
|
|
borderRadius: BorderRadius.circular(12),
|
|
gradient: const LinearGradient(
|
|
begin: Alignment.topLeft,
|
|
end: Alignment.bottomRight,
|
|
colors: [EtmTokens.green, EtmTokens.greenDark],
|
|
),
|
|
boxShadow: [
|
|
BoxShadow(
|
|
color: EtmTokens.green.withValues(alpha: 0.35),
|
|
blurRadius: 12,
|
|
offset: const Offset(0, 4),
|
|
),
|
|
],
|
|
),
|
|
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)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|