etm-powersync-app/lib/screens/dashboard_screen.dart
Patrick Schurig 5944d3a24a fix(dashboard): « Plan de la journée » retirée — cinquième donnée inventée, et la dernière
DayPlan.demo() fabriquait vingt-quatre heures de flux : une gaussienne solaire
et des coefficients écrits en dur — solToEcs = excédent × 0,35, solToVe × 0,3,
solToBatt × 0,15, battToHouse = déficit × 0,55 — sous le titre « Plan de la
journée · aujourd'hui », sans la moindre mention. Y compris une batterie
alimentant la maison à partir de 17 h, sur une installation qui n'a pas de
batterie.

Cinquième après les deux MPPT, les 38 % de PAC, les 82 % de part solaire et le
SOC à 62 %. Même réponse à chaque fois : il n'y a rien derrière.

Un plan suppose de savoir ce qui VA arriver — il demande une prévision PV et une
source tarifaire, dont aucune n'existe. La carte reviendra avec elles.

Le code de fabrication part AVEC la carte : DayPlan, HourlyFlow, WeatherPoint,
WeatherKind et le painter sont supprimés, pas mis de côté. Un générateur de
données plausibles laissé dans le dépôt se rebranche — c'est exactement ce qui
s'est passé quatre fois déjà.

339 tests.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-09-08 13:26:30 +02:00

352 lines
14 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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';
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),
// ── « Plan de la journée » : RETIRÉE le 2026-09-08 ──────────
//
// Elle affichait `DayPlan.demo()` — vingt-quatre heures fabriquées
// par une gaussienne solaire et des coefficients écrits en dur
// (`solToEcs = excédent × 0,35`, `solToVe × 0,3`, `solToBatt × 0,15`,
// `battToHouse = déficit × 0,55`), sous le titre « Plan de la
// journée · aujourd'hui » et sans la moindre mention. Y compris une
// batterie alimentant la maison à partir de 17 h, sur une
// installation qui n'a pas de batterie.
//
// **Cinquième donnée inventée de ce tableau de bord** — après les
// deux MPPT, les 38 % de PAC, les 82 % de part solaire et le SOC à
// 62 %. Même réponse à chaque fois, et elle vaut encore : il n'y a
// rien derrière.
//
// Un plan suppose de savoir ce qui VA arriver : il demande une
// prévision PV et une source tarifaire, dont aucune n'existe. La
// carte reviendra avec elles, et elle dira alors quelque chose. Le
// code de fabrication part avec elle : laissé en place, il se
// rebranche.
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,
),
),
),
],
),
),
);
}
}