etm-powersync-app/lib/screens/dashboard_screen.dart
Patrick Schurig 763df68a2a feat(bilan): deux grandeurs, deux phrases — les ratios de la box à côté de la journée
Les tuiles disent la JOURNÉE, par différence de compteurs sur la fenêtre publiée.
La nouvelle ligne dit ce que la BOX publie, qui part de la baseline — et elle ne
s'affiche QUE lorsque cette origine diffère du début de journée.

Le cas est arrivé de lui-même : `nymead` redémarré à 09:23, sonde à 09:26, et la
box publiait 100 % d'autoconsommation. Vrai sur trois minutes ; absurde intitulé
« aujourd'hui ». La ligne le rend lisible — « Instantané depuis 09:23 » — au lieu
de le laisser passer pour la journée.

Vérifié avec le MODÈLE de l'app et non à l'œil sur le JSON : `ratios_check.dart`
importe `EnergyRatiosSnapshot` (idiome de `measurement_check.dart`) et rend
`baselineRepriseEnJournee: true`, ratios couvrant 3 minutes. Le banc est passé en
Europe/Paris entre-temps, et `periodStartedAt` suit sans que le code bouge —
c'est l'intérêt d'une frontière publiée.

Quand les deux coïncident, rien n'est ajouté : une phrase permanente cesse d'être
lue, comme un avertissement permanent.

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

254 lines
8.4 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 '../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>();
// 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;
});
}
}
@override
Widget build(BuildContext context) {
return Consumer<NymeaService>(
builder: (context, 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),
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,
),
),
),
],
),
),
);
}
}