diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index 2f5a38b..8af3bc4 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -224,10 +224,10 @@ "obligationSousTitre": "Le plancher que la box servira quoi qu'il arrive, au réseau s'il le faut.", "obligationEnergie": "Énergie due par jour", "obligationEcheance": "Avant", - "obligationEcheanceAide": "Heure de l'INSTALLATION{fuseau} — pas celle de cet appareil. Une échéance est une habitude : elle suit la maison.", - "@obligationEcheanceAide": { - "description": "dailyDeadline est un « HH:MM » sans fuseau, interprété par la box dans le sien. Sur une box en Europe/London vue d'un téléphone français, « 12:00 » vaut 13:00 chez le client. Ne jamais présenter l'heure comme locale sans dire quelle horloge la box a lue.", - "placeholders": { "fuseau": { "type": "String" } } + "obligationEcheanceHorloge": "{heure} heure de la box ({fuseau}) — pas celle de cet appareil.", + "@obligationEcheanceHorloge": { + "description": "QUALIFIE UNE HORLOGE, N'OFFRE PAS UN CHOIX. « Fuseau : Europe/Paris » sous un champ laisserait croire qu'on règle le fuseau DE CETTE ÉCHÉANCE : il n'y en a qu'un, c'est un réglage système, et il déplace d'un coup toutes les heures murales de l'installation — échéances, frontière de journée de reporting, et demain les plages tarifaires (LM-1005-b-iii). D'où une phrase qui dit l'heure lue, pas un réglage offert.\n\nAFFICHAGE CONDITIONNEL : seulement quand le fuseau de la box diffère de celui de l'appareil. Sinon c'est un texte permanent — et un avertissement permanent cesse d'être lu le jour exact où il devient vrai.", + "placeholders": { "heure": { "type": "String" }, "fuseau": { "type": "String" } } }, "obligationNonMesurableTitre": "Cette obligation ne pourra jamais être close", "@obligationNonMesurableTitre": { diff --git a/lib/l10n/app_localizations.dart b/lib/l10n/app_localizations.dart index 4731187..a582c56 100644 --- a/lib/l10n/app_localizations.dart +++ b/lib/l10n/app_localizations.dart @@ -601,11 +601,13 @@ abstract class L10n { /// **'Avant'** String get obligationEcheance; - /// dailyDeadline est un « HH:MM » sans fuseau, interprété par la box dans le sien. Sur une box en Europe/London vue d'un téléphone français, « 12:00 » vaut 13:00 chez le client. Ne jamais présenter l'heure comme locale sans dire quelle horloge la box a lue. + /// QUALIFIE UNE HORLOGE, N'OFFRE PAS UN CHOIX. « Fuseau : Europe/Paris » sous un champ laisserait croire qu'on règle le fuseau DE CETTE ÉCHÉANCE : il n'y en a qu'un, c'est un réglage système, et il déplace d'un coup toutes les heures murales de l'installation — échéances, frontière de journée de reporting, et demain les plages tarifaires (LM-1005-b-iii). D'où une phrase qui dit l'heure lue, pas un réglage offert. + /// + /// AFFICHAGE CONDITIONNEL : seulement quand le fuseau de la box diffère de celui de l'appareil. Sinon c'est un texte permanent — et un avertissement permanent cesse d'être lu le jour exact où il devient vrai. /// /// In fr, this message translates to: - /// **'Heure de l\'INSTALLATION{fuseau} — pas celle de cet appareil. Une échéance est une habitude : elle suit la maison.'** - String obligationEcheanceAide(String fuseau); + /// **'{heure} heure de la box ({fuseau}) — pas celle de cet appareil.'** + String obligationEcheanceHorloge(String heure, String fuseau); /// Sous unmeasurable : ni tenue (R6 interdit ECO_FLOOR_MET), ni chiffrée dans son manque (missingWh absent). ECO_FLOOR_MISSED sortira tous les jours par construction. /// diff --git a/lib/l10n/app_localizations_fr.dart b/lib/l10n/app_localizations_fr.dart index c84992b..f64b9dc 100644 --- a/lib/l10n/app_localizations_fr.dart +++ b/lib/l10n/app_localizations_fr.dart @@ -349,8 +349,8 @@ class L10nFr extends L10n { String get obligationEcheance => 'Avant'; @override - String obligationEcheanceAide(String fuseau) { - return 'Heure de l\'INSTALLATION$fuseau — pas celle de cet appareil. Une échéance est une habitude : elle suit la maison.'; + String obligationEcheanceHorloge(String heure, String fuseau) { + return '$heure heure de la box ($fuseau) — pas celle de cet appareil.'; } @override diff --git a/lib/models/energy_ratios_snapshot.dart b/lib/models/energy_ratios_snapshot.dart index 29d09ac..a28659d 100644 --- a/lib/models/energy_ratios_snapshot.dart +++ b/lib/models/energy_ratios_snapshot.dart @@ -82,6 +82,24 @@ class EnergyRatiosSnapshot { return depuis.difference(debut) > const Duration(minutes: 1); } + /// **Les deux horloges divergent-elles ?** — sans base de données de fuseaux. + /// + /// [periodStartedAt] est **minuit de l'installation**, publié en epoch. Rendu dans le + /// fuseau de l'appareil, il tombe donc sur 00:00 **si et seulement si** les deux partagent + /// le même décalage. Sur `.75` en `Europe/London` vu d'un téléphone français, il tombait + /// sur 01:00 ; les deux étant passés en `Europe/Paris`, il tombe sur 00:00. + /// + /// C'est exact pour l'instant présent, y compris les jours de changement d'heure, et ça + /// n'exige **rien d'autre que ce que la box publie** — le nom IANA ne donnerait pas de + /// décalage, et une base embarquée pour le recalculer serait le mauvais geste. + /// + /// `false` quand la date manque : on ne conclut pas d'une absence. + bool get fuseauDiffereDeLAppareil { + final d = periodStartedAt?.toLocal(); + if (d == null) return false; + return d.hour != 0 || d.minute != 0; + } + /// Vrai quand la box publie de quoi dater la journée. Sans ça, l'app ne **peut pas** /// calculer la bonne fenêtre — et elle le dit plutôt que de prendre minuit du téléphone /// en silence. diff --git a/lib/screens/energy/load_mechanism_screen.dart b/lib/screens/energy/load_mechanism_screen.dart index 9777af3..59cf4d5 100644 --- a/lib/screens/energy/load_mechanism_screen.dart +++ b/lib/screens/energy/load_mechanism_screen.dart @@ -10,6 +10,7 @@ import '../../providers/load_config_provider.dart'; import '../../providers/load_telemetry_provider.dart'; import '../../providers/schema_provider.dart'; import '../../services/load_hardware.dart'; +import '../../models/energy_ratios_snapshot.dart'; import '../../services/telemetry_text.dart' show energieCourte; import '../../services/load_mechanism_switch.dart'; import '../../services/load_priority.dart'; @@ -2187,12 +2188,37 @@ class _Plancher extends StatelessWidget { /// Deux des trois causes se réparent par l'installateur — rattacher un compteur, en mettre un /// qui totalise l'énergie. La troisième est le matériel, et le dire évite de chercher un /// réglage qui n'existe pas. C'est pour ça que la cause est affichée, jamais le seul verdict. -class _SectionObligation extends StatelessWidget { +class _SectionObligation extends StatefulWidget { final LoadConfigEntry entry; final LoadConfigProvider provider; const _SectionObligation({required this.entry, required this.provider}); + @override + State<_SectionObligation> createState() => _SectionObligationState(); +} + +class _SectionObligationState extends State<_SectionObligation> { + /// Le fuseau ne sert qu'à **qualifier une heure**, jamais à en proposer un réglage : il + /// est système, et il déplace d'un coup toutes les heures murales de l'installation. + /// Lu une fois, sans bloquer l'écran — absent, la ligne ne s'affiche simplement pas. + EnergyRatiosSnapshot? _ratios; + + @override + void initState() { + super.initState(); + final svc = context.read(); + _ratios = svc.energyRatios; + if (_ratios == null) { + svc.fetchEnergyRatios().then((r) { + if (mounted && r != null) setState(() => _ratios = r); + }); + } + } + + LoadConfigEntry get entry => widget.entry; + LoadConfigProvider get provider => widget.provider; + /// Le coût quotidien, **chiffré et non estimé** : le taux est constant depuis `+etm55` /// (cible ÷ jour, LM-1013-b-v), donc le total acheté vaut exactement la cible. String _coutQuotidien() => energieCourte(entry.minEnergyWhPerDay.toDouble()); @@ -2246,13 +2272,24 @@ class _SectionObligation extends StatelessWidget { 'minEnergyWhPerDay': entry.minEnergyWhPerDay, })), ), - const SizedBox(height: 6), - // L'heure saisie est celle de la BOX, et la phrase le dit sans le fuseau — elle - // reste vraie sans lui. Le NOM du fuseau est publié par `GetEnergyRatios` - // (`timeZone`, vérifié en `+etm56` : `Europe/London`) ; il s'affichera ici quand le - // lot « bilan journalier » aura branché cet appel, et pas avant : nommer un fuseau - // qu'on n'a pas lu serait exactement la faute que cette phrase existe pour éviter. - _Aide(t.obligationEcheanceAide('')), + // ── L'HORLOGE, et seulement quand elle diverge ──────────────────────── + // + // La phrase **qualifie une heure**, elle n'offre pas un choix : « Fuseau : + // Europe/Paris » sous ce champ laisserait croire qu'on règle le fuseau de CETTE + // échéance. Il n'y en a qu'un, il est système, et il déplace d'un coup toutes les + // heures murales de l'installation — échéances, frontière de la journée de + // reporting, et demain les plages tarifaires (LM-1005-b-iii). + // + // Conditionnel, et c'est la règle déjà appliquée deux fois cette semaine — ligne + // des ratios instantanés, avertissement « partiel » du bilan : un texte permanent + // cesse d'être lu le jour exact où il devient vrai. + if (entry.dailyDeadline.isNotEmpty && + (_ratios?.fuseauDiffereDeLAppareil ?? false) && + _ratios?.timeZone != null) ...[ + const SizedBox(height: 6), + _Aide(t.obligationEcheanceHorloge( + entry.dailyDeadline, _ratios!.timeZone!)), + ], if (avertir) ...[ const SizedBox(height: 12), _Aide( diff --git a/lib/services/nymea_service.dart b/lib/services/nymea_service.dart index 0bda747..7ea6716 100644 --- a/lib/services/nymea_service.dart +++ b/lib/services/nymea_service.dart @@ -965,12 +965,22 @@ class NymeaService extends ChangeNotifier { final r = await _sendRequest('NymeaEnergy.GetEnergyRatios', {}); final p = r['params']; if (p is! Map) return null; - return EnergyRatiosSnapshot.fromJson(Map.from(p)); + return _ratios = EnergyRatiosSnapshot.fromJson(Map.from(p)); } catch (_) { return null; } } + EnergyRatiosSnapshot? _ratios; + + /// Le dernier instantané lu, ou `null` tant que rien ne l'a demandé. Les écrans de + /// configuration s'en servent pour **qualifier une heure**, pas pour afficher un taux. + EnergyRatiosSnapshot? get energyRatios => _ratios; + + /// Injecte un instantané — **tests uniquement**, même idiome que `adoptForTest`. + // ignore: use_setters_to_change_properties + void adoptRatiosForTest(EnergyRatiosSnapshot r) => _ratios = r; + Future> fetchPowerBalanceLogs({ required DateTime from, required DateTime to, diff --git a/test/obligation_saisie_test.dart b/test/obligation_saisie_test.dart index 64aa377..eb58878 100644 --- a/test/obligation_saisie_test.dart +++ b/test/obligation_saisie_test.dart @@ -15,6 +15,7 @@ library; import 'package:etm_powersync_app/l10n/app_localizations.dart'; +import 'package:etm_powersync_app/models/energy_ratios_snapshot.dart'; import 'package:etm_powersync_app/providers/load_config_provider.dart'; import 'package:etm_powersync_app/providers/load_telemetry_provider.dart'; import 'package:etm_powersync_app/providers/schema_provider.dart'; @@ -47,7 +48,9 @@ List> _charge({ } ]; -Widget _harness(List> config) => MultiProvider( +Widget _harness(List> config, + {EnergyRatiosSnapshot? ratios}) => + MultiProvider( providers: [ ChangeNotifierProvider.value( value: LoadConfigProvider()..adoptForTest(config)), @@ -55,7 +58,10 @@ Widget _harness(List> config) => MultiProvider( value: LoadTelemetryProvider() ..adoptForTest({'degradedMode': false, 'loads': const []})), ChangeNotifierProvider.value(value: SchemaProvider()), - ChangeNotifierProvider.value(value: NymeaService()), + ChangeNotifierProvider.value( + value: ratios == null + ? NymeaService() + : (NymeaService()..adoptRatiosForTest(ratios))), ], child: MaterialApp( locale: const Locale('fr'), @@ -138,12 +144,52 @@ void main() { }); }); - group('l\'échéance est à l\'heure de l\'installation', () { - testWidgets('et l\'écran le dit, plutôt que de choisir une horloge', + group('l\'horloge se QUALIFIE, elle ne s\'offre pas en réglage', () { + // « Fuseau : Europe/Paris » sous ce champ laisserait croire qu'on règle le fuseau de + // CETTE échéance. Il n'y en a qu'un, il est système, et il déplace d'un coup toutes les + // heures murales de l'installation — d'où une phrase qui dit l'heure lue. + EnergyRatiosSnapshot _instantane({required bool divergent}) => + EnergyRatiosSnapshot.fromJson({ + 'autonomyRate': 54.0, + 'selfConsumptionRate': 52.0, + // Minuit de l'installation, en epoch. Rendu dans le fuseau de l'appareil, il tombe + // sur 00:00 si et seulement si les deux partagent le même décalage. + 'periodStartedAt': + DateTime(2026, 9, 6).millisecondsSinceEpoch ~/ 1000 + + (divergent ? 3600 : 0), + 'measuredSince': DateTime(2026, 9, 6).millisecondsSinceEpoch ~/ 1000, + 'timeZone': divergent ? 'Europe/London' : 'Europe/Paris', + }); + + Widget harnessAvec(EnergyRatiosSnapshot? r, + {List>? config}) => + _harness(config ?? _charge(), ratios: r); + + testWidgets('fuseaux divergents : la phrase dit l\'heure ET l\'horloge', (tester) async { - await _poser(tester, _harness(_charge())); - expect(find.textContaining('Heure de l\'INSTALLATION'), findsOneWidget); + await _poser(tester, harnessAvec(_instantane(divergent: true))); + expect(find.textContaining('12:00 heure de la box (Europe/London)'), + findsOneWidget); expect(find.textContaining('pas celle de cet appareil'), findsOneWidget); + // Ce qu'on n'écrit surtout pas : un intitulé qui ressemble à un réglage. + expect(find.textContaining('Fuseau :'), findsNothing); + }); + + testWidgets('fuseaux identiques : RIEN — un texte permanent cesse d\'être lu', + (tester) async { + await _poser(tester, harnessAvec(_instantane(divergent: false))); + expect(find.textContaining('heure de la box'), findsNothing); + }); + + testWidgets('sans échéance saisie, rien à qualifier', (tester) async { + await _poser(tester, + harnessAvec(_instantane(divergent: true), config: _charge(echeance: ''))); + expect(find.textContaining('heure de la box'), findsNothing); + }); + + testWidgets('box qui ne publie pas la date : on se tait', (tester) async { + await _poser(tester, harnessAvec(null)); + expect(find.textContaining('heure de la box'), findsNothing); }); }); }