etm-powersync-app/lib/screens/dashboard_screen.dart
Patrick Schurig 28c4196b9b feat(accueil): carte « Journée » — consommation par consommateur et solaire
Variante E de la maquette, retenue par Patrick le 2026-10-15 (« deux
colonnes à la Victron ») ; remplace la carte « Production du jour ».

- Par heure, deux colonnes : à gauche la consommation, Maison (bleu) puis
  chaque consommateur déclaré avec compteur (couleur de son domaine, même
  règle que l'écran Énergie) ; à droite le solaire, autoconsommé (doré)
  puis injecté (pâle), la prévision en fond pour comparer. Heures à venir :
  la prévision seule, en contour — aucun plan n'est publié par la box.
- Échantillon horaire horodaté T = heure qui commence à T − 1 h, pour le
  bilan comme pour les Things.
- Météo de maintenant lue sur le Thing site openmeteo, et seulement si
  updateTime a été écrit : les défauts du plugin (clear-day, 0 °C)
  afficheraient sinon un soleil à 0 °C. Les icônes horaires attendent
  openmeteo etm16 (consignes/CONSIGNE-openmeteo-meteo-horaire.md).
- Charges sans compteur déclaré nommées sous la carte (« comptée dans
  Maison »), jamais devinées.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-15 07:53:24 +02:00

360 lines
15 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';
import '../features/dashboard/widgets/journee_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),
// La journée heure par heure — APRÈS les décisions (Patrick,
// 2026-10-14). Variante E de la maquette, retenue le 2026-10-15 :
// consommation par consommateur et solaire, deux colonnes par heure.
// Ce n'est pas le « Plan » retiré ci-dessous : le passé est mesuré,
// l'avenir n'a que la prévision PV — aucun plan n'est publié.
const JourneeCard(),
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,
),
),
),
],
),
),
);
}
}