fix(écran): un instant d'un autre jour porte son jour, et le motif ne rapetisse plus la grandeur

Deux défauts vus sur appareil après installation, tous deux d'affichage.

1. « mesuré depuis 16:37 » à 06:15, pour un `measuredSince` de la VEILLE. Le
   lecteur comprend 16:37 aujourd'hui — une heure qui n'est pas encore arrivée —
   et « 2,5 kWh achetés » juste à côté devient incompréhensible avec elle.
   Même défaut que l'heure UTC, d'un cran plus haut : là on perdait le fuseau,
   ici le jour. `heureLisible` rend « HH:MM » dans la journée du lecteur,
   « hier HH:MM » la veille, « le JJ/MM à HH:MM » au-delà — et la comparaison
   porte sur le jour civil, pas sur 24 h : à 00:05, un instant de 23:50 est hier
   même s'il a quinze minutes.

2. « Autoconsommation · sans production » rendu à la moitié de la taille
   d'« Autonomie » à côté. Cause : un FittedBox reçoit des contraintes de
   largeur infinies, donc son enfant tient sur une ligne aussi large qu'il veut
   et c'est tout le bloc qui rétrécit — `maxLines: 2` n'y sert à rien.
   Rallonger le texte d'un suffixe rapetissait donc le NOM de la grandeur, qui
   est ce qu'on lit en premier. Le motif passe sur sa propre ligne.

Les tests qui figeaient « HH:MM » dérivent maintenant de `heureLisible`, la
fonction même de l'écran — un attendu recomposé à la main aurait gelé la forme
d'avant, exactement comme les « 03:42 » en dur de vendredi.

Captures : docs/captures/2026-09-08_taux-sans-production.png
           docs/captures/2026-09-08_instant-hier.png

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
This commit is contained in:
Patrick Schurig 2026-09-08 06:31:52 +02:00
parent 0c5f0ee30d
commit 0df80b5b9b
7 changed files with 134 additions and 22 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 236 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 206 KiB

View File

@ -362,21 +362,42 @@ class _RingTileState extends State<_RingTile>
//
// `scaleDown` garde le mot ENTIER en réduisant ce qu'il faut, et ne touche
// à rien quand la place suffit — donc aucun effet à échelle normale.
// ⚠️ **Le motif va sur SA ligne, pas en suffixe.**
//
// Un `FittedBox` reçoit des contraintes de largeur infinies : son enfant se
// met donc sur une seule ligne, aussi large qu'il veut, et c'est ensuite
// tout le bloc qui est réduit pour rentrer. `maxLines: 2` ne sert à rien
// là-dedans — le texte n'a jamais de raison de passer à la ligne.
//
// Vu sur appareil le 2026-09-08 : « Autoconsommation · sans production »
// rendu à peu près à la moitié de la taille d'« Autonomie » juste à côté.
// Rallonger le texte d'un suffixe, c'est donc rapetisser le nom de la
// grandeur — et le nom est ce qu'on lit en premier.
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
widget.value == null
? '${widget.label} · ${widget.motifAbsence ?? 'pas mesurable'}'
: widget.label,
style: EtmTokens.sans(
size: 11,
color: EtmTokens.mutedOf(context),
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
widget.label,
style: EtmTokens.sans(
size: 11,
color: EtmTokens.mutedOf(context),
),
maxLines: 1,
),
),
if (widget.value == null)
FittedBox(
fit: BoxFit.scaleDown,
alignment: Alignment.centerLeft,
child: Text(
widget.motifAbsence ?? 'pas mesurable',
style: EtmTokens.sans(
size: 10,
color: EtmTokens.mutedOf(context),
),
maxLines: 1,
),
),
],
),
),

View File

@ -336,6 +336,32 @@ String ecoDecisionText(L10n t, LoadDecision d) {
/// n'y a **aucun étalement**, donc rien qui puisse cesser. Un booléen à `false` se lit
/// spontanément comme rassurant : c'est la nuance qu'un lecteur pressé inversera, et c'est
/// pour ça qu'aucune phrase d'ici ne le rend tel quel.
/// **Une heure nue ment dès que l'instant n'est pas d'aujourd'hui.**
///
/// Vu sur appareil le 2026-09-08 à 06:15 : « mesuré depuis 16:37 », pour un `measuredSince`
/// du **7 septembre**. Le lecteur comprend 16:37 aujourd'hui — une heure qui n'est pas encore
/// arrivée — et la ligne voisine « 2,4 kWh achetés » devient incompréhensible avec elle.
///
/// C'est le même défaut que l'heure UTC, d'un cran plus haut : là on perdait le fuseau, ici
/// on perd le jour. Un instant se rend **entier** ou il se lit de travers ; « HH:MM » n'est
/// une abréviation légitime que dans la journée du lecteur.
///
/// La date est comparée en jours calendaires locaux, pas en écart de 24 h : à 00:05, un
/// instant de 23:50 est bien « hier » même s'il a quinze minutes.
String heureLisible(DateTime t, {DateTime? maintenant}) {
final n = maintenant ?? DateTime.now();
final hhmm = '${t.hour.toString().padLeft(2, '0')}:'
'${t.minute.toString().padLeft(2, '0')}';
final jour = DateTime(t.year, t.month, t.day);
final aujourdhui = DateTime(n.year, n.month, n.day);
final ecart = aujourdhui.difference(jour).inDays;
if (ecart == 0) return hhmm;
if (ecart == 1) return 'hier $hhmm';
final jj = t.day.toString().padLeft(2, '0');
final mm = t.month.toString().padLeft(2, '0');
return 'le $jj/$mm à $hhmm';
}
String? ecoProgressText(L10n t, LoadLevel niveau) {
final p = niveau.progress;
if (p == null) return null;
@ -375,8 +401,7 @@ String? ecoProgressText(L10n t, LoadLevel niveau) {
// fuseau (cf. `obligationEcheanceHorloge`).
final depuis = p.measuredSince;
if (depuis == null) return base;
final h = '${depuis.hour.toString().padLeft(2, '0')}:'
'${depuis.minute.toString().padLeft(2, '0')}';
final h = heureLisible(depuis);
// La date reste le PREMIER instant mesuré ; l'intermittence se dit à part. Après une
// bascule en cours de période, `deliveredWh` ne compte que depuis elle pendant que la cible
// reste entière — la paire n'est donc plus une fraction, et c'est cette date qui l'explique.

View File

@ -19,6 +19,7 @@ library;
import 'package:etm_powersync_app/l10n/app_localizations.dart';
import 'package:etm_powersync_app/models/load_telemetry.dart';
import 'package:etm_powersync_app/services/telemetry_text.dart';
import 'package:etm_powersync_app/providers/load_telemetry_provider.dart';
import 'package:etm_powersync_app/widgets/load_cards.dart';
import 'package:flutter/material.dart';
@ -140,9 +141,10 @@ void main() {
// L'attendu se dérive donc, il ne se recopie pas : le fixer à « 03:42 » gelait
// l'heure UTC et validait le défaut trouvé sur appareil le 2026-09-06.
final _instant = DateTime.utc(2026, 9, 4, 3, 42);
final _local = _instant.toLocal();
final _heure = '${_local.hour.toString().padLeft(2, '0')}:'
'${_local.minute.toString().padLeft(2, '0')}';
// ⚠️ L'attendu se dérive de la MÊME fonction que l'écran — `heureLisible` — et pas
// d'un « HH:MM » recomposé à la main : depuis le 2026-09-08 un instant d'un autre jour
// porte son jour, et un attendu nu gèlerait la forme d'avant.
final _heure = heureLisible(_instant.toLocal());
Map<String, dynamic> mesure() => _payload(
progressEco: {
@ -231,6 +233,35 @@ void main() {
expect(find.textContaining('mesuré depuis'), findsOneWidget);
});
testWidgets('un instant d\'un AUTRE JOUR porte son jour', (tester) async {
// Vu sur appareil le 2026-09-08 à 06:15 : « mesuré depuis 16:37 » pour un
// `measuredSince` du 7. Le lecteur comprend 16:37 aujourd'hui — une heure qui n'est
// pas encore arrivée — et « 2,4 kWh achetés » à côté devient incompréhensible.
//
// Même défaut que l'heure UTC, d'un cran plus haut : là on perdait le fuseau, ici le
// jour. « HH:MM » n'est une abréviation légitime que dans la journée du lecteur.
final hier = DateTime.now().subtract(const Duration(days: 1));
final u = DateTime(hier.year, hier.month, hier.day, 16, 37).toUtc();
await tester.pumpWidget(_carte(_payload(
progressEco: {
'regime': 'measured', 'targetWh': 2000.0, 'deliveredWh': 0.0,
'purchasedWh': 2400.0,
'measuredSince': u.toIso8601String(),
},
decisionEco: {
'code': 'ECO_FLOOR_GRID',
'params': {
'gridW': 350, 'remainingWh': 2000, 'targetWh': 2000,
'remainingS': 3600, 'regime': 'measured', 'atMaximum': false,
},
},
)));
expect(find.textContaining('mesuré depuis hier 16:37'), findsOneWidget);
// Le contrôle qui MORD : l'heure nue ne doit plus passer seule.
expect(find.textContaining('mesuré depuis 16:37'), findsNothing);
});
testWidgets('« mesuré depuis » se rend dans le fuseau du LECTEUR',
(tester) async {
// Trouvé sur appareil le 2026-09-06, pendant que `.75` était volontairement en
@ -244,8 +275,7 @@ void main() {
// fuseau de la box (vérifié le 2026-09-07, les deux en Europe/Paris).
final instant = DateTime.utc(2026, 9, 6, 14, 38);
final local = instant.toLocal();
final attendu = '${local.hour.toString().padLeft(2, '0')}:'
'${local.minute.toString().padLeft(2, '0')}';
final attendu = heureLisible(local);
await tester.pumpWidget(_carte(_payload(
progressEco: {

View File

@ -0,0 +1,32 @@
/// **`heureLisible` — une heure nue n'est honnête que dans la journée du lecteur.**
library;
import 'package:etm_powersync_app/services/telemetry_text.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
final n = DateTime(2026, 9, 8, 6, 15);
test('aujourd\'hui : l\'heure seule suffit', () {
expect(heureLisible(DateTime(2026, 9, 8, 4, 33), maintenant: n), '04:33');
});
test('hier : le jour est dit', () {
expect(heureLisible(DateTime(2026, 9, 7, 16, 37), maintenant: n), 'hier 16:37');
});
test('plus ancien : la date est dite', () {
expect(heureLisible(DateTime(2026, 9, 4, 9, 5), maintenant: n), 'le 04/09 à 09:05');
});
test('la comparaison porte sur le JOUR, pas sur 24 h', () {
// À 00:05, un instant de 23:50 a quinze minutes — et c'est quand même hier. Comparer
// des durées au lieu de dates calendaires l'aurait rendu « aujourd'hui ».
final minuitPasse = DateTime(2026, 9, 8, 0, 5);
expect(heureLisible(DateTime(2026, 9, 7, 23, 50), maintenant: minuitPasse),
'hier 23:50');
// Et l'inverse : 23 h d'écart mais le même jour civil reste une heure nue.
expect(heureLisible(DateTime(2026, 9, 8, 0, 1), maintenant: DateTime(2026, 9, 8, 23, 30)),
'00:01');
});
}

View File

@ -161,9 +161,12 @@ void main() {
await tester.pumpWidget(_harnessBilan(_bilanDeNuit()));
await tester.pumpAndSettle();
expect(find.textContaining('Autoconsommation · sans production'), findsOneWidget);
// Le contrôle qui MORD : l'ancien motif ne doit plus être là pour cette tuile.
expect(find.textContaining('Autoconsommation · pas mesurable'), findsNothing);
// Le nom de la grandeur et le motif sont DEUX textes : un suffixe rapetissait le
// nom (le FittedBox réduit tout le bloc faute de pouvoir aller à la ligne).
expect(find.text('Autoconsommation'), findsOneWidget);
expect(find.text('sans production'), findsOneWidget);
// Le contrôle qui MORD : l'ancien motif ne doit plus être là.
expect(find.text('pas mesurable'), findsNothing);
// Et surtout PAS un zéro sur CETTE tuile : « 0 % » affirmerait qu'aucune production
// n'a été autoconsommée, une phrase sur une production qui n'existe pas. Le tiret y
// est, et il y est une seule fois — l'autre tuile, elle, a bien un nombre.
@ -184,7 +187,7 @@ void main() {
// voisines et leurs deux « rien » sont de natures opposées — c'est le cas qui
// interdit de traiter les taux ensemble.
expect(find.text('0 %'), findsOneWidget);
expect(find.textContaining('Autonomie · '), findsNothing);
expect(find.text('sans consommation'), findsNothing);
});
testWidgets('sans bilan du tout, le motif reste « pas mesurable »', (tester) async {
@ -199,8 +202,9 @@ void main() {
));
await tester.pumpAndSettle();
expect(find.textContaining('Autoconsommation · pas mesurable'), findsOneWidget);
expect(find.textContaining('Autonomie · pas mesurable'), findsOneWidget);
expect(find.text('Autoconsommation'), findsOneWidget);
expect(find.text('Autonomie'), findsOneWidget);
expect(find.text('pas mesurable'), findsNWidgets(2));
});
});
}