diff --git a/docs/captures/2026-09-08_instant-hier.png b/docs/captures/2026-09-08_instant-hier.png new file mode 100644 index 0000000..9d54098 Binary files /dev/null and b/docs/captures/2026-09-08_instant-hier.png differ diff --git a/docs/captures/2026-09-08_taux-sans-production.png b/docs/captures/2026-09-08_taux-sans-production.png new file mode 100644 index 0000000..a616023 Binary files /dev/null and b/docs/captures/2026-09-08_taux-sans-production.png differ diff --git a/lib/features/dashboard/widgets/kpi_row.dart b/lib/features/dashboard/widgets/kpi_row.dart index 88680fc..354eae2 100644 --- a/lib/features/dashboard/widgets/kpi_row.dart +++ b/lib/features/dashboard/widgets/kpi_row.dart @@ -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, + ), + ), ], ), ), diff --git a/lib/services/telemetry_text.dart b/lib/services/telemetry_text.dart index 250f670..51d315a 100644 --- a/lib/services/telemetry_text.dart +++ b/lib/services/telemetry_text.dart @@ -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. diff --git a/test/eco_levels_test.dart b/test/eco_levels_test.dart index a7bed1f..d67f555 100644 --- a/test/eco_levels_test.dart +++ b/test/eco_levels_test.dart @@ -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 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: { diff --git a/test/heure_lisible_test.dart b/test/heure_lisible_test.dart new file mode 100644 index 0000000..bc80dba --- /dev/null +++ b/test/heure_lisible_test.dart @@ -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'); + }); +} diff --git a/test/kpi_ratios_test.dart b/test/kpi_ratios_test.dart index 2b0a53d..d0f36db 100644 --- a/test/kpi_ratios_test.dart +++ b/test/kpi_ratios_test.dart @@ -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)); }); }); }