etm-powersync-app/lib/screens/dashboard_screen.dart
Patrick Schurig fb5b0e7165 feat(accueil): carte « En ce moment » sur la télémétrie, plus de marque ni de démo
Consigne app §G (décision Patrick du 2026-10-16) et motifs etm70 (§H).

G — la carte « Héos pilote en ce moment » affichait des décisions
inventées (HeosDecision.demo : voiture 3,6 kW, batterie en charge sur un
banc sans batterie). Remplacée par EnCeMomentCard : une ligne par charge
de GetLoadTelemetry — libellé de la configuration, decision.code + params
rendus par decisionText, puissance MESURÉE seulement (jamais l'allocation
à sa place). Titre en clé l10n. Rien sur « optimiseur actif » tant que
l'objet de capacités n'existe pas.
Marque retirée des identifiants (HeosDecisionCard, HeosDecision,
HeosAction, _HeosChip, _HeosBadgeSmall, heosActive → pilotageActif…), des
fichiers (heos_decision_card.dart, dashboard_models.dart supprimés), des
textes affichés (hub du flux, écran A/C) et des commentaires :
grep -ri héos/heos vide sur lib/, test/, assets/ et les .arb.

H — contre INTERFACE.md local de la branche moteur, etm70 pas encore sur
le banc :
- motifs éco publiés à la racine (R7, part majoritaire) rendus en clair ;
  ventilation des niveaux affichée (« 83 W éco + 0 W confort », vu sur
  la trame réelle etm69) ;
- ECO_FLOOR_MISSED retenu par charge (publié un seul cycle), y compris
  pour une charge sortie de loads[] ; « manque non chiffrable » quand
  missingWh est absent, jamais 0 Wh ;
- PRICE_SLOT et OPTIMIZER_DECISION en FR / DE / EN (app_en.arb et
  app_de.arb créés pour ces seules clés ; le reste retombe sur le
  français, listé dans l10n_untranslated.json). Code inconnu : repli
  générique avec le code.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 21:26:16 +02:00

362 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/en_ce_moment_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),
// Ce que le moteur décide, charge par charge — lu dans la télémétrie
// d'arbitrage (consigne G, 2026-10-16). Plus de démo, plus de marque.
const EnCeMomentCard(),
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,
),
),
),
],
),
),
);
}
}