feat(accueil): choisir le jour de la carte « Journée »

Demande de Patrick du 2026-10-16 (inspiré du sélecteur Victron, réduit) :
‹ jour › avec calendrier, raccourcis Hier / Aujourd'hui / Demain, et un
lien « Plusieurs jours » vers l'écran Énergie — pas de plage de plusieurs
jours dans une carte à colonnes horaires.

- Jour passé : mesures du jour et prévision ENREGISTRÉE telle qu'elle
  était connue à son début (GetPvForecast + knownAt) — sinon la table
  rendrait la dernière révision, faite après coup.
- Demain : prévision seule ; au-delà, flèche désactivée (horizon).
- Météo de maintenant sur aujourd'hui seulement ; rangée horaire si la
  série couvre le jour (aujourd'hui, demain).
- Une réponse tardive d'un jour quitté est jetée.
- Correctif trouvé par le test de navigation : la carte ne relisait ses
  mesures qu'après une prévision lue avec succès ; sur une box sans
  prévision, elle restait sur le sablier. Elle suit désormais le compteur
  de lectures du provider.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Patrick Schurig 2026-10-16 06:12:01 +02:00
parent 71d88418fd
commit b65b5474fb
4 changed files with 277 additions and 22 deletions

View File

@ -2,6 +2,7 @@ import 'dart:math';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:go_router/go_router.dart';
import 'package:provider/provider.dart';
import '../../../models/consumer_series.dart';
@ -36,9 +37,39 @@ class JourneeCard extends StatefulWidget {
class _JourneeCardState extends State<JourneeCard> {
List<PowerBalanceEntry> _bilan = const [];
ConsumerHistory _conso = ConsumerHistory.vide;
DateTime? _luePour;
String? _luePour;
bool _lue = false;
/// Le jour affiché (minuit local). Aujourd'hui par défaut.
DateTime _jour = _minuit(DateTime.now());
/// Prévision ENREGISTRÉE pour un jour passé (`GetPvForecast`, connue au début du jour).
/// `null` pour aujourd'hui et demain : c'est alors la prévision en vigueur du provider.
PvForecastSnapshot? _historique;
/// Numéro de la dernière lecture lancée : une réponse plus ancienne est jetée — sinon un
/// tap rapide sur ‹ ‹ ‹ afficherait le jour de la première réponse arrivée.
int _lecture = 0;
static DateTime _minuit(DateTime t) => DateTime(t.year, t.month, t.day);
static DateTime _decale(DateTime j, int jours) => DateTime(j.year, j.month, j.day + jours);
DateTime get _aujourdhui => _minuit(DateTime.now());
/// La prévision s'arrête à la fin de demain ; l'historique horaire remonte à un an.
DateTime get _dernier => _decale(_aujourdhui, 1);
DateTime get _premier => _decale(_aujourdhui, -365);
void _aller(DateTime jour) {
final j = _minuit(jour);
if (j.isBefore(_premier) || j.isAfter(_dernier) || j == _jour) return;
setState(() {
_jour = j;
_lue = false;
});
_lire();
}
@override
void initState() {
super.initState();
@ -59,12 +90,16 @@ class _JourneeCardState extends State<JourneeCard> {
@override
void didChangeDependencies() {
super.didChangeDependencies();
final lue = context.watch<PvForecastProvider>().snapshot?.capturedAt;
// Sur le compteur de lectures, pas sur `capturedAt` : une box sans prévision lisible
// ne doit pas laisser la carte sans mesures (trouvé par le test de navigation).
final lectures = context.watch<PvForecastProvider>().lectures;
final n = context.watch<LoadConfigProvider>().entries.length;
final cle = lue?.add(Duration(microseconds: n));
if (cle != null && cle != _luePour) {
final cle = '$lectures/$n';
if (lectures > 0 && cle != _luePour) {
_luePour = cle;
_lire();
// Un jour passé ne change plus : seule la journée en cours se relit au rythme du
// provider (et toute journée pas encore lue).
if (_jour == _aujourdhui || !_lue) _lire();
}
}
@ -72,7 +107,10 @@ class _JourneeCardState extends State<JourneeCard> {
final service = context.read<NymeaService>();
final cfg = context.read<LoadConfigProvider>();
final now = DateTime.now();
final debut = DateTime(now.year, now.month, now.day);
final lecture = ++_lecture;
final debut = _jour;
final fin = _decale(_jour, 1);
final jusqua = fin.isBefore(now) ? fin : now;
// Seules les charges qui déclarent un compteur ont une colonne : rien n'est deviné à
// partir d'un nom d'appareil. Les autres comptent dans « Maison ».
final appareils = [
@ -80,28 +118,132 @@ class _JourneeCardState extends State<JourneeCard> {
if (e.meterThingId.isNotEmpty)
(id: e.meterThingId, label: e.label, domaine: e.domain)
];
// Demain : rien à mesurer. Un jour passé : la prévision telle qu'elle était connue à son
// début — sans `knownAt`, la table rendrait la dernière révision, faite après coup.
final futur = !debut.isBefore(now);
final passe = !fin.isAfter(_aujourdhui);
final r = await Future.wait<dynamic>([
service.fetchPowerBalanceLogs(
from: debut, to: now, sampleRate: 'SampleRate1Hour'),
service.fetchThingPowerLogs(
appareils: appareils, from: debut, to: now, sampleRate: 'SampleRate1Hour'),
futur
? Future.value(const <PowerBalanceEntry>[])
: service.fetchPowerBalanceLogs(
from: debut, to: jusqua, sampleRate: 'SampleRate1Hour'),
futur
? Future.value(ConsumerHistory.vide)
: service.fetchThingPowerLogs(
appareils: appareils, from: debut, to: jusqua, sampleRate: 'SampleRate1Hour'),
passe
? service.fetchPvForecast(de: debut, a: fin, connueA: debut)
: Future<PvForecastSnapshot?>.value(null),
]);
if (!mounted) return;
if (!mounted || lecture != _lecture) return;
setState(() {
_bilan = r[0] as List<PowerBalanceEntry>;
_conso = r[1] as ConsumerHistory;
_historique = r[2] as PvForecastSnapshot?;
_lue = true;
});
}
static const _jours = ['lun.', 'mar.', 'mer.', 'jeu.', 'ven.', 'sam.', 'dim.'];
static const _mois = [
'janv.', 'févr.', 'mars', 'avr.', 'mai', 'juin',
'juil.', 'août', 'sept.', 'oct.', 'nov.', 'déc.'
];
String _libelle(DateTime j) {
final date = '${_jours[j.weekday - 1]} ${j.day} ${_mois[j.month - 1]}';
final ecart = j.difference(_aujourdhui).inHours.round() ~/ 24;
return switch (ecart) {
0 => 'Aujourd\'hui · $date',
-1 => 'Hier · $date',
1 => 'Demain · $date',
_ => date,
};
}
Future<void> _calendrier() async {
final choisi = await showDatePicker(
context: context,
initialDate: _jour,
firstDate: _premier,
lastDate: _dernier,
);
if (choisi != null) _aller(choisi);
}
/// La rangée de navigation : ‹ jour › et trois raccourcis. Pas de plage de plusieurs jours :
/// des colonnes horaires sur une semaine seraient illisibles — l'écran Énergie s'en charge.
Widget _navigation(BuildContext context) {
final muted = EtmTokens.mutedOf(context);
Widget raccourci(String t, int jours) {
final j = _decale(_aujourdhui, jours);
return ChoiceChip(
label: Text(t),
labelStyle: EtmTokens.sans(size: 11.5),
visualDensity: VisualDensity.compact,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
selected: _jour == j,
onSelected: (_) => _aller(j),
);
}
return Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(children: [
IconButton(
visualDensity: VisualDensity.compact,
tooltip: 'Jour précédent',
icon: const Icon(Icons.chevron_left_rounded),
onPressed: _jour.isAfter(_premier) ? () => _aller(_decale(_jour, -1)) : null,
),
Expanded(
child: InkWell(
borderRadius: BorderRadius.circular(8),
onTap: _calendrier,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 6),
child: Row(mainAxisAlignment: MainAxisAlignment.center, children: [
Flexible(
child: Text(_libelle(_jour),
overflow: TextOverflow.ellipsis,
style: EtmTokens.sans(size: 13, weight: FontWeight.w600)),
),
const SizedBox(width: 6),
Icon(Icons.calendar_today_rounded, size: 14, color: muted),
]),
),
),
),
IconButton(
visualDensity: VisualDensity.compact,
tooltip: 'Jour suivant',
icon: const Icon(Icons.chevron_right_rounded),
onPressed: _jour.isBefore(_dernier) ? () => _aller(_decale(_jour, 1)) : null,
),
]),
Wrap(spacing: 6, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [
raccourci('Hier', -1),
raccourci('Aujourd\'hui', 0),
raccourci('Demain', 1),
TextButton(
style: TextButton.styleFrom(
visualDensity: VisualDensity.compact,
textStyle: EtmTokens.sans(size: 11.5)),
onPressed: () => context.go('/energy'),
child: const Text('Plusieurs jours ›'),
),
]),
]);
}
@override
Widget build(BuildContext context) {
final pv = context.watch<PvForecastProvider>();
final service = context.watch<NymeaService>();
final snap = pv.snapshot?.etat == PvForecastEtat.courbe ? pv.snapshot : null;
final maintenant = DateTime.now();
final passe = !_decale(_jour, 1).isAfter(_aujourdhui);
final source = passe ? _historique : pv.snapshot;
final snap = source?.etat == PvForecastEtat.courbe ? source : null;
final heures = construireJournee(
jourLocal: maintenant,
jourLocal: _jour,
snapshot: snap,
bilanHoraire: _bilan,
consoHoraire: _conso);
@ -113,7 +255,12 @@ class _JourneeCardState extends State<JourneeCard> {
return JourneeView(
heures: heures,
series: _conso.series,
meteo: etats == null ? null : MeteoActuelle.depuisEtats(etats),
// La météo de MAINTENANT n'a de sens que sur aujourd'hui.
meteo: etats == null || _jour != _aujourdhui
? null
: MeteoActuelle.depuisEtats(etats),
meteoAttendue: _jour == _aujourdhui,
navigation: _navigation(context),
meteoHoraire: meteoHoraireDepuis(etats?['forecastHourlyWeather']),
chargement: !_lue,
);
@ -130,6 +277,13 @@ class JourneeView extends StatelessWidget {
final Map<DateTime, MeteoHeure>? meteoHoraire;
final bool chargement;
/// Faux sur un autre jour qu'aujourd'hui : la ligne « météo de maintenant » disparaît au
/// lieu de dire « non publiée » — elle n'est pas absente, elle est hors sujet.
final bool meteoAttendue;
/// La rangée de navigation entre les jours, fournie par la carte.
final Widget? navigation;
const JourneeView({
super.key,
required this.heures,
@ -137,6 +291,8 @@ class JourneeView extends StatelessWidget {
this.meteo,
this.meteoHoraire,
this.chargement = false,
this.meteoAttendue = true,
this.navigation,
});
/// Couleur d'un consommateur : celle de son domaine, nuancée s'il y en a plusieurs —
@ -170,14 +326,14 @@ class JourneeView extends StatelessWidget {
boxShadow: EtmTokens.cardShadow,
),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Row(mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [
Text('Journée', style: EtmTokens.sans(size: 16, weight: FontWeight.w600)),
Text('aujourd\'hui', style: EtmTokens.sans(size: 12, color: muted)),
]),
const SizedBox(height: 8),
_meteo(context),
Text('Journée', style: EtmTokens.sans(size: 16, weight: FontWeight.w600)),
if (navigation != null) ...[const SizedBox(height: 4), navigation!],
if (meteoAttendue) ...[const SizedBox(height: 8), _meteo(context)],
const SizedBox(height: 10),
if (meteoHoraire != null) ...[
// La rangée horaire ne s'affiche que si la série couvre ce jour (aujourd'hui, demain) :
// la box ne garde pas la météo des jours passés.
if (meteoHoraire != null &&
heures.any((h) => meteoHoraire!.containsKey(h.debut.toUtc()))) ...[
_rangeeMeteo(context),
const SizedBox(height: 2),
],

View File

@ -27,6 +27,7 @@ class PvForecastProvider extends ChangeNotifier {
String? _erreur;
PvForecastSite? _site;
String? _refusDesignation;
int _lectures = 0;
bool get chargement => _chargement || !_luUneFois;
PvForecastSnapshot? get snapshot => _snapshot;
@ -36,6 +37,11 @@ class PvForecastProvider extends ChangeNotifier {
PvForecastSite? get site => _site;
String? get refusDesignation => _refusDesignation;
/// Nombre de lectures TERMINÉES, réussies ou non. Un écran qui relit ses propres données
/// au rythme de la prévision s'accroche à ce compteur, pas à `capturedAt` : sans
/// prévision lisible, `capturedAt` ne bouge jamais, et l'écran attendrait pour rien.
int get lectures => _lectures;
void attach(NymeaService s) {
if (identical(_service, s)) return;
_service = s;
@ -63,6 +69,7 @@ class PvForecastProvider extends ChangeNotifier {
} finally {
_chargement = false;
_luUneFois = true;
_lectures++;
notifyListeners();
}
}

View File

@ -1085,6 +1085,42 @@ class NymeaService extends ChangeNotifier {
}
}
/// `NymeaEnergy.GetPvForecast` — la prévision ENREGISTRÉE sur une fenêtre, telle qu'elle
/// était connue à [connueA] (`knownAt`). Pour un jour passé, c'est ce qui permet de comparer
/// le prévu au mesuré : sans `knownAt`, la table rend la dernière révision, faite après coup.
///
/// `null` = non lu, ou refusé (`energyError` ≠ `NoError` : la liste rendue alors ne veut
/// rien dire — `INTERFACE.md`).
Future<PvForecastSnapshot?> fetchPvForecast({
required DateTime de,
required DateTime a,
DateTime? connueA,
}) async {
if (_isSimulation || !_connected) return null;
// Instants ISO 8601 AVEC décalage, exigés par la méthode : on les écrit en UTC « +00:00 ».
String iso(DateTime t) {
final u = t.toUtc();
String d2(int v) => v.toString().padLeft(2, '0');
return '${u.year}-${d2(u.month)}-${d2(u.day)}T${d2(u.hour)}:${d2(u.minute)}:'
'${d2(u.second)}+00:00';
}
try {
final r = await _sendRequest('NymeaEnergy.GetPvForecast', {
'from': iso(de),
'to': iso(a),
if (connueA != null) 'knownAt': iso(connueA),
});
final p = r['params'];
if (r['status'] != 'success' || p is! Map) return null;
if (p['energyError'] != null && p['energyError'] != 'EnergyErrorNoError') return null;
return PvForecastSnapshot.fromJson(Map<String, dynamic>.from(p));
} catch (e) {
_log('fetchPvForecast: $e');
return null;
}
}
/// `NymeaEnergy.GetForecastSite`. `null` = non lu.
Future<PvForecastSite?> fetchForecastSite() async {
if (_isSimulation || !_connected) return null;

View File

@ -13,6 +13,10 @@ import 'package:etm_powersync_app/models/consumer_series.dart';
import 'package:etm_powersync_app/models/energy_data.dart';
import 'package:etm_powersync_app/models/journee.dart';
import 'package:etm_powersync_app/models/pv_forecast.dart';
import 'package:etm_powersync_app/providers/load_config_provider.dart';
import 'package:etm_powersync_app/providers/pv_forecast_provider.dart';
import 'package:etm_powersync_app/services/nymea_service.dart';
import 'package:provider/provider.dart';
import 'package:fl_chart/fl_chart.dart';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
@ -249,4 +253,56 @@ void main() {
expect(JourneeView.iconeWmo(42, true), isNull);
});
});
group('navigation entre les jours', () {
Future<PvForecastProvider> monte(WidgetTester t) async {
final service = NymeaService(); // non connecté : chaque lecture rend vide
final pv = PvForecastProvider()..attach(service);
await t.pumpWidget(MultiProvider(
providers: [
ChangeNotifierProvider.value(value: service),
ChangeNotifierProvider.value(value: pv),
ChangeNotifierProvider(create: (_) => LoadConfigProvider()),
],
child: MaterialApp(
home: Scaffold(body: ListView(children: const [JourneeCard()]))),
));
await t.pumpAndSettle();
return pv;
}
Future<void> demonte(WidgetTester t, PvForecastProvider pv) async {
await t.pumpWidget(const SizedBox());
pv.dispose(); // arrête le minuteur de relecture
}
testWidgets('ouvre sur aujourd\'hui, ‹ va à hier, la butée est demain', (t) async {
final pv = await monte(t);
expect(find.textContaining('Aujourd\'hui ·'), findsOneWidget);
await t.tap(find.byTooltip('Jour précédent'));
await t.pumpAndSettle();
expect(find.textContaining('Hier ·'), findsOneWidget);
await t.tap(find.text('Demain'));
await t.pumpAndSettle();
expect(find.textContaining('Demain ·'), findsOneWidget);
// Au-delà de demain, la prévision n'existe pas : la flèche est désactivée.
final suivant = t.widget<IconButton>(find.ancestor(
of: find.byIcon(Icons.chevron_right_rounded),
matching: find.byType(IconButton)));
expect(suivant.onPressed, isNull);
await demonte(t, pv);
});
testWidgets('la météo de maintenant n\'apparaît que sur aujourd\'hui', (t) async {
final pv = await monte(t);
expect(find.text('Météo non publiée par la box'), findsOneWidget);
await t.tap(find.text('Hier'));
await t.pumpAndSettle();
expect(find.text('Météo non publiée par la box'), findsNothing);
await demonte(t, pv);
});
});
}