feat(accueil): météo heure par heure sur la carte « Journée » (openmeteo etm16)
Consigne app §E, vérifiée sur le banc le 2026-10-15 : - lecture de forecastHourlyWeather (v 1) sur le Thing SITE, parent des plans de perField (parentId désormais lu sur les Things) ; v inconnu = rien d'affiché, jamais forecastCurveTransport ; - case i = valeur au DÉBUT du créneau t0 + i·step : posée sur l'heure qui commence là, comme la prévision PV ; - icône (code WMO → icône, variante de nuit par isDay) et température une heure sur trois, alignées sur les colonnes du graphe ; - une case null n'a ni icône ni température : WMO 0 = ciel dégagé, et un null lu comme 0 afficherait un soleil. Test à la lecture ajouté après qu'une mutation null → 0 n'eut fait échouer aucun test (la fixture du banc n'a pas de null). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
c7f857ecb3
commit
335e6a1290
@ -105,11 +105,16 @@ class _JourneeCardState extends State<JourneeCard> {
|
||||
snapshot: snap,
|
||||
bilanHoraire: _bilan,
|
||||
consoHoraire: _conso);
|
||||
final etats = service.etatsParNom('openMeteoSite');
|
||||
// Le site est le PARENT des plans sommés par la prévision ; à défaut, le premier site.
|
||||
final plan = pv.snapshot?.perField.firstOrNull?.id;
|
||||
final site = plan == null ? null : service.parentDe(plan);
|
||||
final etats = service.etatsParNom('openMeteoSite',
|
||||
preferer: site == null || site.isEmpty ? null : site);
|
||||
return JourneeView(
|
||||
heures: heures,
|
||||
series: _conso.series,
|
||||
meteo: etats == null ? null : MeteoActuelle.depuisEtats(etats),
|
||||
meteoHoraire: meteoHoraireDepuis(etats?['forecastHourlyWeather']),
|
||||
chargement: !_lue,
|
||||
);
|
||||
}
|
||||
@ -120,6 +125,9 @@ class JourneeView extends StatelessWidget {
|
||||
final List<HeureJournee> heures;
|
||||
final List<ConsumerSeries> series;
|
||||
final MeteoActuelle? meteo;
|
||||
|
||||
/// Météo par début d'heure (UTC), ou `null` si la box ne la publie pas (openmeteo < etm16).
|
||||
final Map<DateTime, MeteoHeure>? meteoHoraire;
|
||||
final bool chargement;
|
||||
|
||||
const JourneeView({
|
||||
@ -127,6 +135,7 @@ class JourneeView extends StatelessWidget {
|
||||
required this.heures,
|
||||
this.series = const [],
|
||||
this.meteo,
|
||||
this.meteoHoraire,
|
||||
this.chargement = false,
|
||||
});
|
||||
|
||||
@ -168,6 +177,10 @@ class JourneeView extends StatelessWidget {
|
||||
const SizedBox(height: 8),
|
||||
_meteo(context),
|
||||
const SizedBox(height: 10),
|
||||
if (meteoHoraire != null) ...[
|
||||
_rangeeMeteo(context),
|
||||
const SizedBox(height: 2),
|
||||
],
|
||||
SizedBox(
|
||||
height: 190,
|
||||
child: chargement
|
||||
@ -219,6 +232,66 @@ class JourneeView extends StatelessWidget {
|
||||
]);
|
||||
}
|
||||
|
||||
/// **Icône et température, une heure sur trois**, alignées sur les colonnes du graphe.
|
||||
///
|
||||
/// Le graphe réserve 36 px à gauche pour son axe et répartit les heures à parts égales
|
||||
/// (`spaceAround`) : la rangée reprend exactement ce découpage. Une heure sur trois, parce
|
||||
/// que 24 icônes ne tiennent pas lisiblement sur la largeur d'un téléphone.
|
||||
Widget _rangeeMeteo(BuildContext context) {
|
||||
final muted = EtmTokens.mutedOf(context);
|
||||
return Padding(
|
||||
padding: const EdgeInsets.only(left: 36),
|
||||
child: Row(children: [
|
||||
for (final h in heures)
|
||||
Expanded(
|
||||
child: h.debut.hour % 3 != 0
|
||||
? const SizedBox(height: 30)
|
||||
: Builder(builder: (context) {
|
||||
final m = meteoHoraire![h.debut.toUtc()];
|
||||
final icone = m?.code == null ? null : iconeWmo(m!.code!, m.jour);
|
||||
return SizedBox(
|
||||
height: 30,
|
||||
child: OverflowBox(
|
||||
maxWidth: 40,
|
||||
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
||||
if (icone != null)
|
||||
Icon(icone,
|
||||
size: 14,
|
||||
color: m!.code == 0 && m.jour != false
|
||||
? EtmTokens.solarOf(context)
|
||||
: muted)
|
||||
else
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
m?.tempC == null ? '' : '${m!.tempC!.round()}°',
|
||||
style: EtmTokens.mono(size: 9, color: muted)),
|
||||
]),
|
||||
),
|
||||
);
|
||||
}),
|
||||
),
|
||||
]),
|
||||
);
|
||||
}
|
||||
|
||||
/// **Code WMO → icône** (table de Patrick du 2026-10-15, inspirée de Weather Icons).
|
||||
/// `jour == false` donne la variante de nuit. Un code inconnu n'a pas d'icône.
|
||||
static IconData? iconeWmo(int code, bool? jour) {
|
||||
final nuit = jour == false;
|
||||
return switch (code) {
|
||||
0 => nuit ? Icons.nightlight_round : Icons.wb_sunny_rounded,
|
||||
1 || 2 => nuit ? Icons.nights_stay_rounded : Icons.wb_cloudy_rounded,
|
||||
3 => Icons.cloud_rounded,
|
||||
45 || 48 => Icons.foggy,
|
||||
51 || 53 || 55 => Icons.grain_rounded,
|
||||
56 || 57 || 66 || 67 => Icons.cloudy_snowing,
|
||||
61 || 63 || 65 || 80 || 81 || 82 => Icons.water_drop_rounded,
|
||||
71 || 73 || 75 || 77 || 85 || 86 => Icons.ac_unit_rounded,
|
||||
95 || 96 || 99 => Icons.thunderstorm_rounded,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
/// Icône de l'état du ciel publié par le plugin (`weatherCondition`).
|
||||
static IconData iconeMeteo(String c) => switch (c) {
|
||||
'clear-day' => Icons.wb_sunny_rounded,
|
||||
|
||||
@ -179,3 +179,37 @@ class MeteoActuelle {
|
||||
_ => condition,
|
||||
};
|
||||
}
|
||||
|
||||
/// Une heure de météo prévue, lue sur le Thing site. `null` = pas de valeur — jamais 0.
|
||||
typedef MeteoHeure = ({int? code, bool? jour, double? tempC});
|
||||
|
||||
/// **La météo heure par heure** — état `forecastHourlyWeather` du Thing site (openmeteo
|
||||
/// `etm16`, vérifié sur le banc le 2026-10-15).
|
||||
///
|
||||
/// La case `i` porte la valeur **au début du créneau** `t0 + i·step` (valeurs instantanées
|
||||
/// d'Open-Meteo : `convention` du plugin). La carte la pose donc sur l'heure qui COMMENCE à
|
||||
/// cet instant — le même `targetAt` que la prévision PV.
|
||||
///
|
||||
/// 🔴 `weatherCode` 0 veut dire « ciel dégagé ». Une case `null` reste `null` : pas d'icône,
|
||||
/// jamais un soleil. Une version de forme inconnue (`v` ≠ 1) ne se lit pas du tout.
|
||||
Map<DateTime, MeteoHeure>? meteoHoraireDepuis(Object? etat) {
|
||||
if (etat is! Map) return null;
|
||||
if (etat['v'] != 1) return null;
|
||||
final t0 = etat['t0'], step = etat['step'];
|
||||
if (t0 is! num || step is! num || step <= 0) return null;
|
||||
List<Object?> serie(String k) => etat[k] is List ? etat[k] as List : const [];
|
||||
final codes = serie('weatherCode'), jours = serie('isDay'), temps = serie('tempC');
|
||||
final n = [codes.length, jours.length, temps.length].reduce(max);
|
||||
final out = <DateTime, MeteoHeure>{};
|
||||
for (var i = 0; i < n; i++) {
|
||||
Object? at(List<Object?> l) => i < l.length ? l[i] : null;
|
||||
final c = at(codes), j = at(jours), t = at(temps);
|
||||
out[DateTime.fromMillisecondsSinceEpoch(((t0 + i * step) * 1000).toInt(), isUtc: true)] = (
|
||||
code: c is num ? c.toInt() : null,
|
||||
jour: j is num ? j != 0 : (j is bool ? j : null),
|
||||
tempC: t is num ? t.toDouble() : null,
|
||||
);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
@ -8,6 +8,9 @@ class NymeaThing {
|
||||
final List<Map<String, dynamic>> paramValues;
|
||||
final List<NymeaStateValue> states;
|
||||
|
||||
/// Thing parent, quand il y en a un — un plan openmeteo a pour parent son site. Vide sinon.
|
||||
final String parentId;
|
||||
|
||||
const NymeaThing({
|
||||
required this.id,
|
||||
required this.name,
|
||||
@ -15,6 +18,7 @@ class NymeaThing {
|
||||
required this.setupStatus,
|
||||
required this.paramValues,
|
||||
this.states = const [],
|
||||
this.parentId = '',
|
||||
});
|
||||
|
||||
factory NymeaThing.fromJson(Map<String, dynamic> j) {
|
||||
@ -27,6 +31,7 @@ class NymeaThing {
|
||||
states: (j['states'] as List? ?? [])
|
||||
.map((s) => NymeaStateValue.fromJson(s))
|
||||
.toList(),
|
||||
parentId: j['parentId'] as String? ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
@ -37,6 +42,7 @@ class NymeaThing {
|
||||
setupStatus: setupStatus,
|
||||
paramValues: paramValues,
|
||||
states: states ?? this.states,
|
||||
parentId: parentId,
|
||||
);
|
||||
|
||||
dynamic settingValue(String settingTypeId) {
|
||||
|
||||
@ -1813,6 +1813,15 @@ class NymeaService extends ChangeNotifier {
|
||||
};
|
||||
}
|
||||
|
||||
/// Le parent d'un Thing (`''` si aucun, ou Thing inconnu) — le site d'un plan openmeteo.
|
||||
String parentDe(String thingId) {
|
||||
String nu(String x) => x.replaceAll(RegExp(r'[{}]'), '').toLowerCase();
|
||||
for (final t in _things) {
|
||||
if (nu(t.id) == nu(thingId)) return t.parentId;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
/// Valeur d'un paramètre du Thing par son nom — même logique que [_stateByName].
|
||||
Object? _paramByName(NymeaThing thing, String name) {
|
||||
NymeaThingClass? cls;
|
||||
|
||||
163
test/fixtures/forecast_hourly_weather_hems_etm16.json
vendored
Normal file
163
test/fixtures/forecast_hourly_weather_hems_etm16.json
vendored
Normal file
@ -0,0 +1,163 @@
|
||||
{
|
||||
"convention": {
|
||||
"isDay": "1 day, 0 night, instantaneous at t",
|
||||
"tempC": "temperature_2m in C, instantaneous at t",
|
||||
"weatherCode": "WMO code, instantaneous at t"
|
||||
},
|
||||
"isDay": [
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
1,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0
|
||||
],
|
||||
"issuedAt": 1792033200,
|
||||
"model": "meteofrance_seamless",
|
||||
"regime": "forecast",
|
||||
"step": 3600,
|
||||
"t0": 1792015200,
|
||||
"tempC": [
|
||||
7.6,
|
||||
7.1,
|
||||
6.5,
|
||||
5.6,
|
||||
5,
|
||||
5.3,
|
||||
5.1,
|
||||
4.1,
|
||||
4,
|
||||
4.8,
|
||||
7.8,
|
||||
12.3,
|
||||
14.7,
|
||||
16.5,
|
||||
17.8,
|
||||
18.7,
|
||||
19.3,
|
||||
19.6,
|
||||
18.6,
|
||||
15.5,
|
||||
12.6,
|
||||
10.5,
|
||||
9.3,
|
||||
8.5,
|
||||
8.1,
|
||||
7.8,
|
||||
7.2,
|
||||
6.8,
|
||||
6.2,
|
||||
5.8,
|
||||
5.8,
|
||||
5.6,
|
||||
5.2,
|
||||
6.2,
|
||||
8.8,
|
||||
13.3,
|
||||
15.8,
|
||||
17.5,
|
||||
19.4,
|
||||
20.7,
|
||||
21,
|
||||
20.7,
|
||||
19.5,
|
||||
16.9,
|
||||
14.5,
|
||||
12.9,
|
||||
12.2,
|
||||
11.5
|
||||
],
|
||||
"v": 1,
|
||||
"weatherCode": [
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
2,
|
||||
0,
|
||||
3,
|
||||
2,
|
||||
3,
|
||||
1,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
0,
|
||||
0,
|
||||
2,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3,
|
||||
3
|
||||
]
|
||||
}
|
||||
@ -5,6 +5,9 @@
|
||||
/// Europe/Paris.
|
||||
library;
|
||||
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:etm_powersync_app/features/dashboard/widgets/journee_card.dart';
|
||||
import 'package:etm_powersync_app/models/consumer_series.dart';
|
||||
import 'package:etm_powersync_app/models/energy_data.dart';
|
||||
@ -175,4 +178,75 @@ void main() {
|
||||
expect(find.text('V2C Trydan'), findsOneWidget);
|
||||
expect(find.text('Maison'), findsOneWidget);
|
||||
});
|
||||
|
||||
group('météo heure par heure (openmeteo etm16)', () {
|
||||
final banc = jsonDecode(
|
||||
File('test/fixtures/forecast_hourly_weather_hems_etm16.json').readAsStringSync())
|
||||
as Map<String, dynamic>;
|
||||
|
||||
test('la réponse du banc : 48 cases, posées sur le DÉBUT de chaque heure', () {
|
||||
final m = meteoHoraireDepuis(banc)!;
|
||||
expect(m, hasLength(48));
|
||||
final t0 = DateTime.fromMillisecondsSinceEpoch((banc['t0'] as int) * 1000, isUtc: true);
|
||||
expect(m[t0]!.code, (banc['weatherCode'] as List).first);
|
||||
expect(m[t0.add(const Duration(hours: 1))]!.tempC,
|
||||
((banc['tempC'] as List)[1] as num).toDouble());
|
||||
});
|
||||
|
||||
test('à la LECTURE, une case null reste null — pas un 0, donc pas un soleil', () {
|
||||
// La fixture du banc n'a aucune null : sans ce test, une lecture qui écrirait 0 à la
|
||||
// place passerait inaperçue (mesuré : la mutation ne faisait échouer aucun test).
|
||||
final m = meteoHoraireDepuis({
|
||||
...banc,
|
||||
'weatherCode': [0, null],
|
||||
'isDay': [1, null],
|
||||
'tempC': [12.0, null],
|
||||
})!;
|
||||
final t0 = DateTime.fromMillisecondsSinceEpoch((banc['t0'] as int) * 1000, isUtc: true);
|
||||
expect(m[t0]!.code, 0);
|
||||
final apres = m[t0.add(const Duration(hours: 1))]!;
|
||||
expect(apres.code, isNull);
|
||||
expect(apres.jour, isNull);
|
||||
expect(apres.tempC, isNull);
|
||||
});
|
||||
|
||||
test('forme de version inconnue : rien n\'est lu', () {
|
||||
expect(meteoHoraireDepuis({...banc, 'v': 2}), isNull);
|
||||
});
|
||||
|
||||
testWidgets('un vrai 0 affiche un soleil, un null n\'affiche RIEN', (t) async {
|
||||
// Le piège nommé par la consigne : WMO 0 = ciel dégagé.
|
||||
final dix = DateTime(2026, 10, 14, 9); // heure multiple de 3 : affichée
|
||||
final douze = DateTime(2026, 10, 14, 12);
|
||||
final hs = construireJournee(
|
||||
jourLocal: jour,
|
||||
snapshot: null,
|
||||
bilanHoraire: const [],
|
||||
consoHoraire: ConsumerHistory.vide);
|
||||
await t.pumpWidget(MaterialApp(
|
||||
home: Scaffold(
|
||||
body: ListView(children: [
|
||||
JourneeView(heures: hs, meteoHoraire: {
|
||||
dix.toUtc(): (code: 0, jour: true, tempC: 12.0),
|
||||
douze.toUtc(): (code: null, jour: true, tempC: null),
|
||||
}),
|
||||
]))));
|
||||
expect(find.byIcon(Icons.wb_sunny_rounded), findsOneWidget);
|
||||
expect(find.text('12°'), findsOneWidget);
|
||||
// Aucune autre icône météo dans la rangée : la case null n'en a pas reçu.
|
||||
for (final i in [
|
||||
Icons.nightlight_round,
|
||||
Icons.cloud_rounded,
|
||||
Icons.wb_cloudy_rounded,
|
||||
]) {
|
||||
expect(find.byIcon(i), findsNothing);
|
||||
}
|
||||
});
|
||||
|
||||
test('la nuit a sa variante, un code inconnu n\'a pas d\'icône', () {
|
||||
expect(JourneeView.iconeWmo(0, false), Icons.nightlight_round);
|
||||
expect(JourneeView.iconeWmo(95, true), Icons.thunderstorm_rounded);
|
||||
expect(JourneeView.iconeWmo(42, true), isNull);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user