etm-powersync-app/lib/screens/dashboard_screen.dart
Patrick Schurig c638ec6c52 feat: connexions multi-HEMS + rôles & appareils (beta add/config)
Lot Connexions multi-HEMS :
- modèle Installation + InstallationStore (persistance par UUID ;
  métadonnées SharedPreferences, token via flutter_secure_storage)
- ConnectionManager au-dessus de nymea_service : mono-connexion, switchTo,
  connectNew, enterDemo (démo = active factice), contrôle d'identité DHCP
- nymea_service : connect() réveillé (ws://4444, token par UUID), capture
  Hello (uuid/name/initialSetupRequired), authenticate/createUser, resetState
- écran Installations (3 états) + déverrouillage installateur depuis la racine
- écran Connexion : auth 2 branches (JSONRPC.Authenticate / CreateUser),
  détection auto via initialSetupRequired, segmenteur de repli
- gate de routage (redirect + refreshListenable merge[cm,svc]) ;
  hasActiveConnection = isSimulation || (connected && authenticated)
- en-tête drawer cliquable → Installations
- invalidation des caches au switch (service.resetState + clearForSwitch
  rôles/scheduler/tariff), sans persist()

Lot Rôles & appareils :
- EnergySetupProvider refondu en tri-état (present/absent/non-configuré)
- écran roles_devices_screen (3 zones, drag-priorité, inférence solaire/batterie)
- LoadDescriptor (etmvariableload, rév.2 : PAC exclue → SG-Ready) ;
  Energy.SetRootMeter réel, Get/SetLoadConfig en stub loggé

Correctifs :
- bug signe énergie : production +, consommation NÉGATIVE sur nymea 1.15.2
  → consumptionW/home en .abs() (autoconso n'est plus clouée à 0)
- UUID Hello brace-wrapped normalisé

Tests : flutter analyze 0 erreur ; gate + zéro-fuite au switch (5/5 verts).
Validé en vrai : login auth .120, .75 ouvert, switch, déverrouillage installateur.
Note : embarque aussi le WIP dashboard/thème déjà présent dans l'arbre.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 08:39:08 +02:00

196 lines
6.3 KiB
Dart

import 'package:flutter/material.dart';
import 'package:provider/provider.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<DashboardScreen> createState() => _DashboardScreenState();
}
class _DashboardScreenState extends State<DashboardScreen> {
// 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.
@override
Widget build(BuildContext context) {
return Consumer<NymeaService>(
builder: (context, service, _) {
final data = service.energyData;
return Scaffold(
backgroundColor: EtmTokens.bgOf(context),
body: RefreshIndicator(
onRefresh: () async => service.startSimulation(),
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),
EnergyFlowCard(data: data),
const SizedBox(height: 14),
KpiRow(data: data),
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<AppSettingsProvider>();
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,
),
),
),
],
),
),
);
}
}