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:
parent
71d88418fd
commit
b65b5474fb
@ -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),
|
||||
],
|
||||
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user