Lot B côté app, partie NON bloquée par le lot plugin (voir §Limites).
§1 Locale (indépendant) — JSONRPC.Hello portait {} : l'app était en en_US de
fait alors que nymea gère la locale PAR CONNEXION (o:locale). Envoi de la
locale à chaque Hello, donc aussi après reconnexion subie (m_clientLocales
repart au défaut de la box à chaque socket). Locale demandée ET retenue
journalisées : un .qm introuvable rend exactement comme une absence de
traduction, sans cette trace un catalogue mal packagé passe inaperçu.
§2 i18n — flutter_localizations + intl + ARB (français seul). Aucune chaîne
en dur dans le nouvel écran. Les codes reçus de la box (domaine, mécanisme)
sont des clés ARB avec repli explicite sur code brut si l'app ne les connaît
pas : une box en avance sur l'app est un cas normal en parc déployé.
§3 Modèle — LoadConfigEntry est une VUE en lecture au-dessus de la map brute ;
l'écriture repart de la map d'origine (patched()). Pas de couche de filtrage
préventive : l'aller-retour neutre passe contre .75, donc l'écho verbatim est
sûr par construction (§7-1).
§4 Priorité à deux niveaux — groupByDomain / flattenToPayload. Le rang de
domaine se déduit de l'ordre d'apparition à plat ; la box ne sait rien des
groupes. Config non contiguë : signalée, ordre réel affiché, normalisation
seulement sur confirmation explicite. Miroir client de validateSet().
§5 Écritures asynchrones — LoadConfigProvider ne conclut jamais sur
l'acquittement du RPC : pending → LoadConfigChanged → confirmed|diverged.
NymeaEnergy.GetLoadConfig / SetLoadConfig deviennent RÉELS (ils étaient des
stubs journalisés). Conséquence traitée : EnergySetupProvider.persist()
n'émet plus setLoadConfig(buildLoadDescriptors()) — SetLoadConfig remplace
TOUT l'ensemble, et _commit() l'appelait à chaque changement d'UI : une
reconstruction depuis le modèle typé aurait écrasé la config réelle de la
box sans geste explicite.
Écran /energy/loads, gaté installateur (drawer + garde de routeur).
Limites — le lot plugin n'est PAS déployé sur .75 : GetLoadTelemetry,
LoadTelemetryChanged et o:domain sont absents (19 méthodes / 13 notifications
inchangées). Toute la moitié télémétrie du brief est donc hors d'atteinte, et
o:domain n'étant pas au schéma, la box rejetterait la clé (jsonvalidator.cpp:143).
Fixtures multi-domaines synthétiques : .75 n'a qu'une charge.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
589 lines
23 KiB
Dart
589 lines
23 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/load_config_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/load_order_screen.dart';
|
|
import 'screens/energy/roles_devices_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/installations/connection_screen.dart';
|
|
import 'screens/installations/installations_screen.dart';
|
|
import 'screens/settings/app_settings_screen.dart';
|
|
import 'screens/settings/protocols_screen.dart';
|
|
import 'screens/settings/thing_catalog_screen.dart';
|
|
import 'screens/settings/appearance_screen.dart';
|
|
import 'screens/settings/screens_settings_screen.dart';
|
|
import 'screens/things_screen.dart';
|
|
import 'services/connection_manager.dart';
|
|
import 'services/installation_store.dart';
|
|
import 'services/nymea_service.dart';
|
|
import 'l10n/app_localizations.dart';
|
|
import 'theme/app_theme.dart';
|
|
import 'theme/etm_tokens.dart';
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Router GoRouter — gate de connexion (Étape 6)
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
/// Routes qui exigent le mode installateur déverrouillé.
|
|
bool _isInstallerRoute(String loc) =>
|
|
loc.startsWith('/settings/system') ||
|
|
loc == '/settings/app/developer' ||
|
|
loc == '/energy/setup' ||
|
|
loc == '/energy/loads';
|
|
|
|
/// Construit le routeur avec le **gate de connexion**.
|
|
///
|
|
/// Condition = « pas de connexion active » ([ConnectionManager.hasActiveConnection],
|
|
/// qui inclut la démo). `refreshListenable` écoute le manager **et** le service :
|
|
/// une déconnexion subie (socket qui tombe → notif du service, pas du manager)
|
|
/// doit ré-évaluer le gate et renvoyer vers Installations.
|
|
GoRouter buildAppRouter(
|
|
ConnectionManager cm,
|
|
NymeaService svc,
|
|
InstallerModeProvider installer,
|
|
) => GoRouter(
|
|
initialLocation: '/',
|
|
refreshListenable: Listenable.merge([cm, svc, installer]),
|
|
redirect: (context, state) {
|
|
final loc = state.matchedLocation;
|
|
final onInstall =
|
|
loc == '/installations' || loc.startsWith('/installations/');
|
|
if (!cm.hasActiveConnection) {
|
|
// Pas de session (ni box réelle, ni démo) → Installations en racine.
|
|
return onInstall ? null : '/installations';
|
|
}
|
|
// Écrans réservés à l'installateur : masqués dans le drawer ET bloqués ici
|
|
// (accès direct par URL, deep-link, ou auto-lock pendant la navigation).
|
|
if (!installer.isUnlocked && _isInstallerRoute(loc)) return '/';
|
|
// Actif (ou démo) → on laisse tout ; Installations reste accessible (drawer).
|
|
return null;
|
|
},
|
|
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')),
|
|
],
|
|
),
|
|
|
|
// ── Installations (multi-HEMS) — plein écran ──────────────────────────
|
|
GoRoute(
|
|
path: '/installations',
|
|
builder: (c, s) => const InstallationsScreen(),
|
|
),
|
|
GoRoute(
|
|
path: '/installations/connect',
|
|
builder: (c, s) => const ConnectionScreen(),
|
|
),
|
|
|
|
// ── Écrans énergie (push par-dessus le shell) ─────────────────────────
|
|
GoRoute(
|
|
path: '/energy/setup',
|
|
builder: (c, s) => const RolesDevicesScreen(),
|
|
),
|
|
GoRoute(
|
|
path: '/energy/loads',
|
|
builder: (c, s) => const LoadOrderScreen(),
|
|
),
|
|
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) => const ThingCatalogScreen()),
|
|
GoRoute(path: '/settings/system/network', builder: (c, s) => _StubScreen('Système & réseau')),
|
|
GoRoute(path: '/settings/system/protocols',builder: (c, s) => const ProtocolsScreen()),
|
|
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();
|
|
// NymeaService partagé avec le ConnectionManager (même instance).
|
|
final nymeaService = NymeaService();
|
|
final connectionManager =
|
|
ConnectionManager(nymeaService, InstallationStore());
|
|
// Providers à état par-box : instanciés eager pour les vider au switch.
|
|
final energySetup = EnergySetupProvider();
|
|
final scheduler = SchedulerProvider();
|
|
final tariff = TariffProvider();
|
|
final loadConfig = LoadConfigProvider();
|
|
connectionManager.registerCacheClear(loadConfig.clearForSwitch);
|
|
connectionManager.registerCacheClear(energySetup.clearForSwitch);
|
|
connectionManager.registerCacheClear(scheduler.clearForSwitch);
|
|
connectionManager.registerCacheClear(tariff.clearForSwitch);
|
|
await Future.wait([
|
|
appSettings.load(),
|
|
installerMode.init(),
|
|
connectionManager.load(),
|
|
]);
|
|
|
|
runApp(
|
|
MultiProvider(
|
|
providers: [
|
|
ChangeNotifierProvider.value(value: nymeaService),
|
|
ChangeNotifierProvider.value(value: connectionManager),
|
|
ChangeNotifierProvider(create: (_) => NavigationProvider()),
|
|
ChangeNotifierProvider.value(value: installerMode),
|
|
ChangeNotifierProvider.value(value: appSettings),
|
|
ChangeNotifierProvider.value(value: energySetup),
|
|
ChangeNotifierProvider.value(value: scheduler),
|
|
ChangeNotifierProvider.value(value: tariff),
|
|
ChangeNotifierProvider.value(value: loadConfig),
|
|
],
|
|
child: NymeaEnergyApp(
|
|
router: buildAppRouter(connectionManager, nymeaService, installerMode),
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
// Application root
|
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
|
|
class NymeaEnergyApp extends StatelessWidget {
|
|
const NymeaEnergyApp({super.key, required this.router});
|
|
|
|
final GoRouter router;
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final settings = context.watch<AppSettingsProvider>();
|
|
return MaterialApp.router(
|
|
title: 'ETM PowerSync',
|
|
debugShowCheckedModeBanner: false,
|
|
theme: AppTheme.theme,
|
|
darkTheme: AppTheme.darkTheme,
|
|
themeMode: settings.themeMode,
|
|
// i18n — français seul pour l'instant. L'ossature ARB est en place pour que
|
|
// l'ajout d'une langue soit un fichier à remplir, pas une extraction
|
|
// rétroactive de chaînes noyées dans les widgets.
|
|
localizationsDelegates: L10n.localizationsDelegates,
|
|
supportedLocales: L10n.supportedLocales,
|
|
locale: const Locale('fr'),
|
|
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.brand.withValues(alpha: 0.12)
|
|
: Colors.transparent,
|
|
borderRadius: BorderRadius.circular(12),
|
|
),
|
|
child: Icon(
|
|
item.icon,
|
|
color: selected ? EtmTokens.brand : EtmTokens.muted,
|
|
size: 22,
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
item.label,
|
|
style: EtmTokens.sans(
|
|
size: 10,
|
|
color: selected ? EtmTokens.brand : 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)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|