import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../models/daily_balance.dart'; import '../main.dart' show DrawerMenuButton; import '../providers/app_settings_provider.dart'; import '../services/nymea_service.dart'; import '../theme/etm_tokens.dart'; import '../features/dashboard/widgets/energy_flow_card.dart'; import '../features/dashboard/widgets/kpi_row.dart'; import '../features/dashboard/widgets/heos_decision_card.dart'; import '../features/dashboard/widgets/day_plan_card.dart'; class DashboardScreen extends StatefulWidget { const DashboardScreen({super.key}); @override State createState() => _DashboardScreenState(); } class _DashboardScreenState extends State { // Plus d'auto-simulation ici : le gate de routage (Étape 6) garantit qu'on // n'atteint le dashboard qu'avec une session active (box réelle ou démo). // La démo est un choix explicite depuis l'écran Installations. /// Bilan du jour. `null` tant qu'il n'est pas lu, et `null` aussi s'il n'est pas /// mesurable — les tuiles font la même chose des deux : elles disent « — ». /// /// Il n'est **pas** dans `EnergyData` : celui-ci porte l'instantané poussé par les /// notifications, alors que le bilan demande un aller-retour sur les logs et ne change /// qu'au quart d'heure. Les mêler ferait relire 88 échantillons à chaque trame de /// puissance. DailyBalance? _bilan; /// Le bilan avance d'un point par quart d'heure : le relire plus souvent coûterait un /// aller-retour de 88 échantillons pour rien. Cinq minutes laissent la journée fraîche /// sans marteler la box. static const Duration _periode = Duration(minutes: 5); Timer? _tick; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) => _lireBilan()); _tick = Timer.periodic(_periode, (_) => _lireBilan()); } @override void dispose() { _tick?.cancel(); super.dispose(); } Future _lireBilan() async { if (!mounted) return; final b = await context.read().fetchDailyBalance(); if (mounted) setState(() => _bilan = b); } @override Widget build(BuildContext context) { return Consumer( builder: (context, service, _) { final data = service.energyData; return Scaffold( backgroundColor: EtmTokens.bgOf(context), body: RefreshIndicator( onRefresh: () async { service.startSimulation(); await _lireBilan(); }, color: EtmTokens.ecoOf(context), child: CustomScrollView( slivers: [ _DashTopBar(service: service), SliverPadding( padding: const EdgeInsets.fromLTRB(14, 0, 14, 32), sliver: SliverList( delegate: SliverChildListDelegate([ const SizedBox(height: 14), // La voiture du Sankey vient de la BORNE, pas d'un défaut de // modèle : `null` quand aucune borne n'est déclarée. EnergyFlowCard( data: data, evChargerPowerW: service.evCharger?.currentPowerW), const SizedBox(height: 14), KpiRow(data: data, balance: _bilan), const SizedBox(height: 14), const HeosDecisionCard(), const SizedBox(height: 14), const DayPlanCard(), const SizedBox(height: 8), ]), ), ), ], ), ), ); }, ); } } // ── TopBar ──────────────────────────────────────────────────────────────────── class _DashTopBar extends StatelessWidget { final NymeaService service; const _DashTopBar({required this.service}); @override Widget build(BuildContext context) { return SliverAppBar( floating: true, snap: true, backgroundColor: EtmTokens.bgOf(context), surfaceTintColor: Colors.transparent, elevation: 0, leading: const DrawerMenuButton(), leadingWidth: 64, title: Text( 'ETM PowerSync', style: EtmTokens.sans( size: 18, weight: FontWeight.w700, color: EtmTokens.inkOf(context), ), ), actions: [ const _ThemeToggle(), const SizedBox(width: 4), IconButton( icon: Icon( service.connected ? Icons.wifi_rounded : Icons.wifi_off_rounded, color: service.connected ? EtmTokens.ecoOf(context) : EtmTokens.danger, size: 20, ), onPressed: () => _showConnectionDialog(context, service), ), const SizedBox(width: 4), ], ); } void _showConnectionDialog(BuildContext context, NymeaService service) { showDialog( context: context, builder: (_) => AlertDialog( title: Text('Connexion nymea', style: EtmTokens.sans(size: 18, weight: FontWeight.w600, color: EtmTokens.inkOf(context))), content: Text( service.connected ? 'Connecté à ${service.host}' : service.isSimulation ? 'Mode simulation actif' : 'Non connecté', style: EtmTokens.sans(size: 14, color: EtmTokens.mutedOf(context)), ), actions: [ TextButton( onPressed: () => Navigator.pop(context), child: Text('OK', style: EtmTokens.sans(color: EtmTokens.ecoOf(context))), ), ], ), ); } } // ── Theme toggle ────────────────────────────────────────────────────────────── class _ThemeToggle extends StatelessWidget { const _ThemeToggle(); @override Widget build(BuildContext context) { final settings = context.watch(); final isDark = settings.themeMode == ThemeMode.dark; return GestureDetector( onTap: () => settings.setThemeMode( isDark ? ThemeMode.light : ThemeMode.dark, ), child: Container( width: 50, height: 28, margin: const EdgeInsets.symmetric(vertical: 8), decoration: BoxDecoration( color: isDark ? EtmTokens.brand.withValues(alpha: 0.2) : EtmTokens.bg2Of(context), borderRadius: BorderRadius.circular(14), border: Border.all( color: EtmTokens.lineOf(context), width: 1, ), ), child: Stack( children: [ AnimatedPositioned( duration: const Duration(milliseconds: 280), curve: Curves.easeOutBack, left: isDark ? 24 : 2, top: 2, child: Container( width: 22, height: 22, decoration: BoxDecoration( color: isDark ? EtmTokens.brand : Colors.white, shape: BoxShape.circle, boxShadow: [ BoxShadow( color: Colors.black.withValues(alpha: 0.12), blurRadius: 4, ), ], ), child: Icon( isDark ? Icons.dark_mode_rounded : Icons.light_mode_rounded, size: 13, color: isDark ? Colors.white : EtmTokens.amber, ), ), ), ], ), ), ); } }