Patrick Schurig ETM-Schurig d0a475a5d9 feat: refonte UI complète — design system EtmTokens + 4 écrans
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>
2026-05-29 21:51:51 +02:00

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