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
352 lines
14 KiB
Dart
352 lines
14 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';
|
||
|
||
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,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|