Troisième tentative sur le même symptôme, et la bonne : le détail de consommation
croise la TÉLÉMÉTRIE (la mesure) et la CONFIGURATION (le domaine), et ni l'un ni
l'autre n'était attaché ailleurs que sur l'écran des rôles. Sur le tableau de
bord, `telemetry` était null ET `entries` vide.
Les deux correctifs précédents ne traitaient que la configuration — la moitié que
le premier diagnostic avait désignée. C'est la recherche auto-confirmante en
version « je n'ai regardé qu'un provider » : j'ai vérifié la partie que je venais
d'écrire, jamais l'autre, et l'écran a redit trois fois la même chose.
La donnée était pourtant sans ambiguïté à chaque étape : `chauffe-eau` → `ecs`,
`pac-terrain` → `heating`, et `{powerW: 0, source: "meter"}` sur les deux. Rien
ne manquait côté box ; tout manquait côté app, à l'endroit où on va chercher.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
334 lines
13 KiB
Dart
334 lines
13 KiB
Dart
import 'dart:async';
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
import '../models/daily_balance.dart';
|
|
import '../models/energy_ratios_snapshot.dart';
|
|
import '../main.dart' show DrawerMenuButton;
|
|
import '../providers/app_settings_provider.dart';
|
|
import '../providers/load_config_provider.dart';
|
|
import '../providers/load_telemetry_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.
|
|
|
|
/// 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;
|
|
|
|
/// Les ratios de la box — lus à côté du bilan, et affichés à côté de lui. Voir [KpiRow].
|
|
EnergyRatiosSnapshot? _ratios;
|
|
|
|
/// 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<void> _lireBilan() async {
|
|
if (!mounted) return;
|
|
final svc = context.read<NymeaService>();
|
|
// ── La configuration des charges, et pourquoi ICI plutôt qu'au montage ──────
|
|
//
|
|
// Le détail de consommation a besoin du `domain` de chaque charge, porté par
|
|
// `GetLoadConfig` — sans lui, il affiche « non publié » sur des charges que la box
|
|
// publie : une absence côté APP présentée comme une absence côté BOX.
|
|
//
|
|
// Un chargement unique au montage ne suffit pas : l'écran se construit avant que la
|
|
// connexion soit établie, l'appel échoue en silence, et **rien ne réessaie**. Vérifié
|
|
// à l'écran le 2026-09-06 — le correctif « charger au montage » n'a rien changé.
|
|
// Accroché au tick, il se rattrape de lui-même dès que la box répond.
|
|
final cfg = context.read<LoadConfigProvider>();
|
|
if (cfg.entries.isEmpty) cfg..attach(svc)..load();
|
|
final tel = context.read<LoadTelemetryProvider>();
|
|
if (tel.telemetry == null) tel..attach(svc)..load();
|
|
// Les ratios de la box sont lus à côté du bilan, pas à sa place : ils ne couvrent pas
|
|
// la même fenêtre, et c'est justement ce que l'écran doit pouvoir dire.
|
|
final r = await svc.fetchEnergyRatios();
|
|
final b = await svc.fetchDailyBalance();
|
|
if (mounted) {
|
|
setState(() {
|
|
_bilan = b;
|
|
_ratios = r;
|
|
});
|
|
}
|
|
}
|
|
|
|
/// Puissance mesurée par domaine (`ecs`, `heating`), lue dans `GetLoadTelemetry`.
|
|
///
|
|
/// Une charge dont la mesure n'est pas publiée — `source: "none"`, ou charge absente de
|
|
/// `loads[]` — n'entre pas dans la carte : son domaine reste **sans valeur**, et le
|
|
/// détail affichera « non publié ». C'est la seule lecture honnête : un compteur ECS à
|
|
/// 0 W dit « rien ne chauffe », une absence dit « on ne sait pas », et un pourcentage de
|
|
/// la consommation du foyer ne dit ni l'un ni l'autre.
|
|
Map<String, double?> _mesuresParDomaine(BuildContext context) {
|
|
final tel = context.watch<LoadTelemetryProvider>();
|
|
final cfg = context.watch<LoadConfigProvider>();
|
|
final out = <String, double?>{};
|
|
for (final e in tel.telemetry?.loads ?? const []) {
|
|
if (!e.measurement.mesuree) continue;
|
|
// Les identifiants voyagent avec ou sans accolades selon l'appel (UI_data_contract
|
|
// §9) : on compare sur la forme nue, jamais sur la chaîne brute.
|
|
String nu(String x) => x.replaceAll('{', '').replaceAll('}', '').toLowerCase();
|
|
final cible = nu(e.loadId);
|
|
String? domaine;
|
|
for (final c in cfg.entries) {
|
|
if (nu(c.id) == cible) {
|
|
domaine = c.domain;
|
|
break;
|
|
}
|
|
}
|
|
if (domaine == null || domaine.isEmpty) continue;
|
|
out[domaine] = (out[domaine] ?? 0) + e.measurement.powerW!;
|
|
}
|
|
return out;
|
|
}
|
|
|
|
/// Vrai une fois les deux providers demandés avec une box joignable — évite de relancer
|
|
/// les appels à chaque trame pendant que les réponses arrivent.
|
|
bool _chargesDemandees = false;
|
|
|
|
/// **Les DEUX providers, pas un seul.**
|
|
///
|
|
/// Le détail de consommation croise la télémétrie (la mesure) et la configuration (le
|
|
/// domaine). Or ni l'un ni l'autre n'était attaché ailleurs que sur l'écran des rôles :
|
|
/// sur le tableau de bord, `telemetry` était `null` **et** `entries` vide.
|
|
///
|
|
/// Deux correctifs ont échoué avant celui-ci parce qu'ils ne traitaient que la
|
|
/// configuration — la moitié que le premier diagnostic avait désignée. Vérifier le
|
|
/// correctif sur la partie qu'on vient d'écrire ne dit rien de l'autre moitié :
|
|
/// c'est la recherche auto-confirmante, en version « je n'ai regardé qu'un provider ».
|
|
///
|
|
/// Le tick à cinq minutes rattrape ; c'est ici que ça se joue au premier affichage.
|
|
void _assurerCharges(NymeaService service) {
|
|
if (_chargesDemandees || !service.isConnected) return;
|
|
final cfg = context.read<LoadConfigProvider>();
|
|
final tel = context.read<LoadTelemetryProvider>();
|
|
if (cfg.entries.isNotEmpty && tel.telemetry != null) return;
|
|
_chargesDemandees = true;
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
if (cfg.entries.isEmpty) cfg..attach(service)..load();
|
|
if (tel.telemetry == null) tel..attach(service)..load();
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Consumer<NymeaService>(
|
|
builder: (context, service, _) {
|
|
_assurerCharges(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,
|
|
// Les mesures RÉELLES par domaine, prises dans la télémétrie
|
|
// d'arbitrage. Un domaine absent de la carte = la box ne le
|
|
// publie pas, et le détail le dira au lieu d'inventer une part.
|
|
mesuresParDomaine: _mesuresParDomaine(context)),
|
|
const SizedBox(height: 14),
|
|
KpiRow(data: data, balance: _bilan, ratios: _ratios),
|
|
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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|