diff --git a/CLAUDE.md b/CLAUDE.md index c176382..9b5bbb4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -291,10 +291,19 @@ le glisser-déposer marche sur une seule liste. véhicule branché (depuis `+etm24`), pas de voiture assignée, ou mode manuel. Jamais « perdue », jamais « désactivée ». -**Aucun SOC de véhicule n'existe dans ce contrat.** Vérifié sur `.75` : sur 58 classes, -aucune classe `evcharger` ne déclare d'état de charge et aucune ne porte l'interface `car`. -Le seul `batteryLevel` est celui de la **batterie de la maison**. Ne pas l'afficher en face -d'une cible de recharge : c'est une autre grandeur. +**Aucun SOC de véhicule mesuré n'existe.** Vérifié sur `.75` : sur 58 classes, aucune +classe `evcharger` ne déclare d'état de charge. Le seul `batteryLevel` d'une installation +énergie est celui de la **batterie de la maison** — autre grandeur, à ne pas afficher en +face d'une cible de recharge. + +> Et il y a pire qu'une mesure manquante : le moteur compare `targetPercentage` à +> `carBatteryLevel`, une valeur **qu'il écrit lui-même** (intégration de la puissance, × +> facteur de pertes, ÷ capacité déclarée). Elle sort par la frontière RPC sous le nom +> `batteryLevel`, **indistinguable d'une mesure**. Décision LM-1009 : l'avancement +> s'affiche en **énergie livrée** (`sessionEnergy`, par session), jamais en pourcentage — +> le pourcentage reste bon pour SAISIR l'intention, pas pour rendre compte. Certaines +> bornes ne publient pas `sessionEnergy` : l'écran doit alors dire « pas mesurable », et +> surtout **pas zéro**. **Motif `BATTERY_RESERVE`** — params `socPercent`, `reservePercent`, `withheldW`, tous entiers **déjà en pourcentage** (ne pas confondre `reservePercent` = 40 avec diff --git a/docs/BRIEF_depuis_plugin.md b/docs/BRIEF_depuis_plugin.md index cdd6e09..5dbf4f2 100644 --- a/docs/BRIEF_depuis_plugin.md +++ b/docs/BRIEF_depuis_plugin.md @@ -5,8 +5,9 @@ **Contexte** : `docs/RELEVE_3g2.md` (ce dépôt) porte les traces machine, cycle par cycle. **Contrat** : `INTERFACE.md` fait autorité — ce brief ne fait qu'en signaler les mouvements. -> Quatre points. Le deuxième **retire une mise en garde** que l'app portait, et il est le plus -> intéressant pour vous : du code conditionnel peut disparaître. +> Cinq points. Le deuxième **retire une mise en garde** que vous portiez — du code conditionnel +> peut disparaître. Le cinquième répond à **votre** question sur `targetPercentage`, et c'est +> celui qui change un écran : l'avancement s'affichera en énergie livrée, pas en pourcentage. --- @@ -126,6 +127,57 @@ reproductible d'un cycle à l'autre, même avant tout réglage. --- +## 5. `targetPercentage` — ta question est tranchée, et elle change ton écran + +Tu avais remonté que `targetPercentage` est une cible sans mesure d'avancement : aucune classe +`evcharger` ne déclare d'état de charge, aucune ne porte d'interface véhicule. **C'est exact** — +vérifié sur les cinq classes du banc. + +**Et le code fait pire que « il manque une mesure ».** `targetPercentage` est comparé à +`carBatteryLevel`, lu sur le Thing *voiture*. Or dans le seul cas où ce mécanisme s'active — une +voiture manuelle, dont `batteryLevel` est **inscriptible** — c'est **le moteur qui écrit cette +valeur** : il intègre la puissance de la borne, applique un facteur de pertes, divise par une +capacité déclarée, et réinjecte le résultat dans l'état du Thing. Il compare donc sa cible à sa +propre estimation, bâtie sur trois grandeurs dont aucune n'est mesurée. + +**Ce que ça te coûte aujourd'hui** : l'estimation sort par la frontière RPC sous le nom +`batteryLevel`, **indistinguable d'une mesure**. Un écran qui affiche « 62 % » affiche une +intégration. Tu mens de bonne foi, et rien dans la charge utile ne te permet de t'en apercevoir. + +### Les décisions (Patrick, 2026-08-27) — `specs/spec_loadmodel.md` LM-1009 + +**D1 — L'avancement s'affiche en ÉNERGIE LIVRÉE, jamais en pourcentage.** « 8,4 kWh livrés depuis +le branchement » est vérifiable ; « 62 % » suppose une capacité que personne ne connaît. Le +pourcentage garde sa place dans la **saisie de l'intention** — l'utilisateur pense sa voiture en +pourcentage — mais **sort de l'affichage de l'avancement**. + +**D2 — Obligation par SESSION d'abord.** La source est `sessionEnergy`, l'énergie livrée depuis le +branchement, publiée par la borne. C'est aussi l'usage réel : on branche le soir pour partir le +matin. L'obligation *quotidienne* (`minEnergyWhPerDay`) est un lot à part, qui attend +l'historique du §11 — ne l'anticipe pas dans l'écran. + +**D3 — L'estimation sera ÉTIQUETÉE à la publication.** Tant que le chemin `carBatteryLevel` +survit, il ne franchira plus la frontière RPC sous le même nom qu'une mesure. Le précédent existe +déjà côté moteur : `estimatedPowerW` porte son préfixe exprès. + +### Ce que ça implique pour toi, concrètement + +- **Deux régimes de vérité, et il faut pouvoir dire lequel s'applique** (condition C2 de la spec) : + une obligation en **énergie** peut annoncer son atteinte ; une cible en **pourcentage** publie + une intention et un avancement *estimé* — **jamais « tenue »**. Un avancement mesuré et un + avancement estimé ne se distinguent par aucune de leurs valeurs : les deux sont un nombre qui + monte. Si le régime ne se lit pas, le second se fait passer pour le premier. +- **Certaines bornes ne publient pas `sessionEnergy`** (condition C1) — mesuré : `wallboxNoMeter` + et l'une des deux classes `wallbox`. Sur celles-là l'avancement n'est pas mesurable, et + **l'écran doit le montrer** : une échéance qui cesse silencieusement d'être mesurable est pire + qu'une échéance absente, parce qu'elle continue de s'afficher. Surtout, ne pas afficher zéro — + « pas mesurable » et « rien livré » sont deux états différents. +- **Rien n'est encore codé côté moteur** : `sessionEnergy` n'est lue nulle part et + `LoadContextTelemetry::sessionWh` est déclarée mais jamais remplie. Le §10 le fera. Ce brief te + donne la forme d'arrivée pour que tu ne bâtisses pas sur le pourcentage entre-temps. + +--- + ## Ce qui n'a PAS changé, et qu'il ne faut pas déduire - **L'échéance de départ et le tarif dynamique restent au proxy**, financés au réseau diff --git a/docs/CADRAGE_zones_clim.md b/docs/CADRAGE_zones_clim.md new file mode 100644 index 0000000..afbd1be --- /dev/null +++ b/docs/CADRAGE_zones_clim.md @@ -0,0 +1,169 @@ +# Cadrage — écran des zones de climatisation + +**Date** : 2026-08-27 · **Banc** : `.75`, nymea `1.15.2+202606191336~trixie1` +**Objet** : ce qu'il faut sur le banc pour rendre l'écran **exerçable**, avant d'écrire une +ligne de code d'affichage. + +> Ce document ne propose pas de maquette. C'est un cadrage : il dit ce qui existe, ce qui +> manque, et quelles décisions doivent être prises avant de coder. La maquette se valide +> avec Patrick, comme tout nouvel écran. + +--- + +## 1. Où en est l'écran aujourd'hui + +`lib/screens/ac_screen.dart`, 1 105 lignes. Il appelle **exactement une** méthode du +contrat : `AirConditioning.GetZones`, et **seulement pour en compter le résultat** +(`_zonesSurLaBox`). Tout le reste est de la maquette : + +```dart +const _Zone(name: 'Salon', currentTemp: 19.5, targetTemp: 21.0, …), +const _Zone(name: 'Chambre', currentTemp: 18.0, targetTemp: 19.0, …), +const _Zone(name: 'Bureau', currentTemp: 20.0, targetTemp: 22.0, …), +const _Zone(name: 'Cuisine', currentTemp: 21.5, targetTemp: 21.0, …), +``` + +Quatre pièces, huit températures, un état SG-Ready et deux consignes ECS — **aucun de ces +nombres ne vient de la box.** C'est le même défaut que le SOC véhicule à 62 %, en plus +grand : des grandeurs physiques crédibles, affichées comme des mesures. + +`.75` déclare **zéro zone** (`GetZones` → `zones: []`). L'écran affiche donc quatre pièces +là où l'installation n'en connaît aucune. + +--- + +## 2. Le contrat, tel que la box le publie + +Huit méthodes, trois notifications (`ZoneAdded` · `ZoneChanged` · `ZoneRemoved`). + +`ZoneInfo` est **presque entièrement en lecture** (`r:`) — température, humidité, PM2.5, +COV, consigne courante, dérogation et sa fin, `zoneStatus`, et les six listes d'appareils. +On n'écrit **jamais** un objet complet : on écrit par les six `Set*`. + +| Écriture | Ce qu'elle pose | +|---|---| +| `AddZone(name, o:thermostats, o:valves, o:indoorSensors, o:outdoorSensors, o:windowSensors, o:notifications)` | crée | +| `RemoveZone(zoneId)` | supprime | +| `SetZoneName(zoneId, name)` | renomme | +| `SetZoneThings(zoneId, …les six listes…)` | rattache | +| `SetZoneSetpointOverride(zoneId, mode, setpointOverride, o:minutes)` | dérogation manuelle | +| `SetZoneStandbySetpoint(zoneId, …)` | consigne de réduit | +| `SetZoneWeekSchedule(zoneId, …)` | planning 7 jours | + +`SetpointOverrideMode` : `None` · `Timed` · `Unlimited` · `Eventual`. +`ZoneStatusFlag` : `None` · `TimeScheduleActive` · `SetpointOverrideActive` · +`WindowOpen` · `BadAir` · `HighHumidity` — **un drapeau, donc combinable**. + +Le planning est un `TemperatureWeekSchedule` = 7 × `TemperatureDaySchedule` = +liste de `{startTime, endTime, temperature}`. + +--- + +## 3. ⚠️ Le banc ne peut PAS héberger une zone — et ce n'est pas « il manque un appareil » + +Vérifié le 2026-08-27 sur les **58 classes** déclarées par `.75` : **aucune classe** ne +porte l'une des interfaces qu'une zone exige. Pas « aucun appareil créé » — aucune classe, +donc **aucun plugin installé ne sait en créer un**. + +Les huit plugins installés sont : `AbbB2x`, `AbbTerra`, `GpioController`, `SunSpec`, +`eastron`, `energySimulation`, `genericEnergy`, `mqttclient`. Tous énergie ou entrées/ +sorties : rien de thermique, rien de capteur d'ambiance. + +### Ce que chaque emplacement exige exactement + +Lu dans `airconditioningmanager.cpp:605` (`verifyThingIds`), qui refuse par +`AirConditioningErrorInvalidThingType` : + +| Emplacement | Interface exigée | +|---|---| +| `thermostats` | `thermostat` | +| `windowSensors` | `closablesensor` | +| `indoorSensors` + `outdoorSensors` | `temperaturesensor` **ou** `humiditysensor` **ou** `vocsensor` **ou** `pm25sensor` | +| `notifications` | `notifications` | +| `valves` | **aucune — non vérifié** | + +> `valves` est au schéma d'`AddZone` et de `SetZoneThings`, mais `verifyThingIds` ne le +> reçoit pas : la box accepte n'importe quel ThingId dans cet emplacement sans le +> contrôler. À ne pas exposer comme un choix guidé tant que le point n'est pas tranché — +> un champ que la box accepte sans vérifier produit une configuration qui « marche » et ne +> fait rien. + +--- + +## 4. Ce qu'il faut sur le banc — la liste courte + +Trois paquets, tous **présents dans le dépôt apt du banc** (`apt-cache search` +le 2026-08-27) : + +```sh +ssh etm@192.168.1.75 +sudo apt install nymea-plugin-generic-heatingcooling \ + nymea-plugin-generic-sensors +# optionnel, pour le drapeau WindowOpen : +# les capteurs d'ouverture sont DÉJÀ dans generic-sensors (voir tableau) +``` + +Ce que ces deux paquets apportent, vérifié dans +`nymea-plugins-genericthings/*/integrationplugin*.json` : + +| Classe à créer | Interfaces | Remplit | +|---|---|---| +| **Generic thermostat** | `thermostat`, `temperaturesensor` | `thermostats` — et sert aussi de sonde | +| **Generic temperature sensor** | `temperaturesensor` | `indoorSensors` / `outdoorSensors` | +| **Generic humidity sensor** | `humiditysensor` | `indoorSensors` | +| **Generic door or window sensor** | `closablesensor` | `windowSensors` → drapeau `WindowOpen` | + +Les paquets `*-simulation` (`heating-simulation`, `sensors-simulation`, +`closables-simulation`) sont une alternative : ils **animent** leurs valeurs au lieu de les +laisser fixes, ce qui est préférable pour voir un écran vivre. Les classes `generic*`, elles, +sont pilotables à la main — préférable pour **provoquer** un état précis (fenêtre ouverte, +humidité haute) sans attendre. + +**Recommandation** : les deux. `generic*` pour fabriquer un cas à volonté, `*-simulation` +pour regarder l'écran bouger tout seul. + +### Le banc minimal qui exerce tout le contrat + +Deux zones, parce qu'une seule ne montre ni le tri ni le cas « la pièce d'à côté » : + +| Zone | Contenu | Ce qu'elle rend exerçable | +|---|---|---| +| **Salon** | 1 thermostat + 1 capteur d'humidité + 1 capteur d'ouverture | dérogation, planning, `WindowOpen`, `HighHumidity` | +| **Chambre** | 1 thermostat seul | le cas pauvre : aucune sonde d'ambiance, et l'écran doit le dire au lieu d'afficher des tirets | + +Plus **un capteur de température déclaré `outdoorSensors`**, pour que la distinction +intérieur/extérieur ne reste pas théorique. + +--- + +## 5. Ce qui doit être tranché avant de coder + +1. **Le drapeau `zoneStatus` est un ensemble, pas un état.** `WindowOpen` + `BadAir` + peuvent être vrais ensemble. Un écran qui affiche « le » statut choisira lequel taire — + c'est une décision d'interface, à prendre explicitement. +2. **`vocsensor` et `pm25sensor` sont acceptés comme sondes d'ambiance**, et `ZoneInfo` + publie `voc` et `pm25`. La qualité d'air entre donc dans cet écran par la porte du + contrat. Est-ce qu'on l'affiche, ou est-ce qu'on la garde pour plus tard ? +3. **`valves` — exposé ou pas ?** Voir la réserve du §3. +4. **Le rattachement d'appareils appartient-il à cet écran ou au mode installateur ?** + `AddZone` et `SetZoneThings` sont `PermissionScopeAdmin` ; les six autres sont + `PermissionScopeControlThings`. La box sépare déjà les deux : l'écran devrait suivre. +5. **Le lien avec les charges pilotées reste à définir.** La PAC du banc est une charge + `sg-ready` arbitrée par le waterfall ; une zone est un objet du contrat + `AirConditioning`. **Les deux ne se connaissent pas.** L'écran actuel les mélange (un + bloc SG-Ready sous les pièces), et ce mélange n'a aucun support dans le contrat. À + trancher avant de le reconduire. +6. **Tier `Auto`.** Les zones sont derrière ce palier ; `pro_lock_badge.dart` existe déjà. + +--- + +## 6. Ordre de marche proposé + +1. Installer les deux paquets sur `.75` et créer le banc minimal du §4. +2. Écrire la sonde `tools/rpc/zones.dart` — instantané + écoute des trois notifications, + comme `telemetry_watch.dart`. **Aucun écran avant elle** : c'est elle qui dira ce que la + box publie vraiment quand une fenêtre s'ouvre. +3. Modéliser `ZoneInfo` en lecture seule, avec la règle maison — omis, jamais nul. +4. Retirer les quatre pièces de maquette. Tant que le modèle n'est pas branché, un écran + vide qui dit « aucune zone déclarée » est plus juste que quatre pièces inventées. +5. Maquette, validation avec Patrick, puis l'écran. diff --git a/lib/main.dart b/lib/main.dart index d17389d..729e25f 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -25,6 +25,7 @@ import 'screens/favorites_screen.dart'; import 'screens/installations/connection_screen.dart'; import 'screens/installations/installations_screen.dart'; import 'screens/settings/app_settings_screen.dart'; +import 'screens/settings/bug_report_screen.dart'; import 'screens/settings/protocols_screen.dart'; import 'screens/settings/thing_catalog_screen.dart'; import 'screens/settings/appearance_screen.dart'; @@ -91,7 +92,7 @@ GoRouter buildAppRouter( GoRoute(path: '/media', builder: (c, s) => _StubScreen('Médias')), GoRoute(path: '/garages', builder: (c, s) => _StubScreen('Garages')), GoRoute(path: '/automations', builder: (c, s) => _StubScreen('Magic / Automatisations')), - GoRoute(path: '/bug-report', builder: (c, s) => _StubScreen('Rapport de bug')), + GoRoute(path: '/bug-report', builder: (c, s) => const BugReportScreen()), ], ), diff --git a/lib/screens/settings/bug_report_screen.dart b/lib/screens/settings/bug_report_screen.dart new file mode 100644 index 0000000..236a95c --- /dev/null +++ b/lib/screens/settings/bug_report_screen.dart @@ -0,0 +1,291 @@ +/// **Rapport de bug pré-rempli** — ce que l'utilisateur ne doit pas avoir à taper. +/// +/// ## Pourquoi il est pré-rempli, et pas un simple champ libre +/// +/// Un rapport utile tient à des identifiants que personne ne connaît par cœur : version du +/// plugin, version d'expérience, état de l'arbitrage au moment du défaut. Un champ libre +/// seul produit « ça ne marche pas », et l'aller-retour pour obtenir le reste coûte des +/// jours. Tout ce que l'app SAIT déjà est donc collecté d'office. +/// +/// ## Ce qu'il ne collecte PAS, et c'est délibéré +/// +/// Aucun secret ne franchit ce rapport : ni le PIN installateur (même haché), ni le jeton +/// de connexion, ni le mot de passe. Un rapport de bug voyage — par courriel, par capture +/// d'écran, dans un fil de discussion — et ce qui y entre en sort. +/// +/// Les identifiants de Things sont conservés : ce sont des UUID locaux, sans valeur hors de +/// l'installation, et ce sont eux qui permettent de recouper avec le journal de la box. +/// +/// ## L'envoi n'existe pas, et le dire vaut mieux que le simuler +/// +/// Il n'y a pas de service de collecte. Le rapport se **copie** dans le presse-papier : +/// c'est vérifiable par l'utilisateur, là où un bouton « Envoyer » sans destinataire +/// donnerait le sentiment que quelqu'un l'a reçu. +library; + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; +import 'package:package_info_plus/package_info_plus.dart'; +import 'package:provider/provider.dart'; + +import '../../providers/load_config_provider.dart'; +import '../../providers/load_telemetry_provider.dart'; +import '../../providers/schema_provider.dart'; +import '../../services/nymea_service.dart'; +import '../../theme/etm_tokens.dart'; + +class BugReportScreen extends StatefulWidget { + const BugReportScreen({super.key}); + + @override + State createState() => _BugReportScreenState(); +} + +class _BugReportScreenState extends State { + final _quoi = TextEditingController(); + final _attendu = TextEditingController(); + PackageInfo? _paquet; + + @override + void initState() { + super.initState(); + // La version vient du paquet installé, jamais d'une constante recopiée à la main : + // une constante dérive de `pubspec.yaml` en silence, et un rapport qui annonce la + // mauvaise version envoie chercher un défaut dans un code qui n'est pas celui-là. + PackageInfo.fromPlatform().then((p) { + if (mounted) setState(() => _paquet = p); + }); + } + + @override + void dispose() { + _quoi.dispose(); + _attendu.dispose(); + super.dispose(); + } + + /// Le rapport, en texte brut — le seul format qui se colle partout. + String _rapport(BuildContext context) { + final svc = context.read(); + final schema = context.read(); + final config = context.read(); + final tel = context.read().telemetry; + + final b = StringBuffer() + ..writeln('## Rapport ETM-PowerSync') + ..writeln() + ..writeln('### Ce qui s\'est passé') + ..writeln(_quoi.text.trim().isEmpty ? '(non renseigné)' : _quoi.text.trim()) + ..writeln() + ..writeln('### Ce qui était attendu') + ..writeln( + _attendu.text.trim().isEmpty ? '(non renseigné)' : _attendu.text.trim()) + ..writeln() + ..writeln('### Application') + ..writeln('- version : ${_paquet == null ? "en cours de lecture" : "${_paquet!.version}+${_paquet!.buildNumber}"}') + ..writeln('- paquet : ${_paquet?.packageName ?? "—"}') + ..writeln() + ..writeln('### Box') + ..writeln('- hôte : ${svc.host.isEmpty ? "aucun" : svc.host}') + ..writeln('- connectée : ${svc.isConnected ? "oui" : "NON"}' + '${svc.isSimulation ? " (SIMULATION — aucune box réelle)" : ""}') + ..writeln('- versions : ${schema.schema.loaded ? schema.versionLine() : "schéma non lu"}') + ..writeln('- things : ${svc.things.length}'); + + b + ..writeln() + ..writeln('### Charges') + ..writeln('- configurées : ${config.entries.length}' + '${config.error != null ? " (erreur de lecture : ${config.error})" : ""}'); + for (final e in config.entries) { + b.writeln(' - ${e.id} · ${e.adapter} · rang ${e.priority} · ' + 'domaine ${e.domain ?? "non classé"} · ${e.enabled ? "active" : "désactivée"}'); + } + + b + ..writeln() + ..writeln('### Arbitrage (dernier cycle publié)'); + if (tel == null) { + // Pas de télémétrie ≠ arbitrage à zéro. Le distinguer ici évite un diagnostic + // fondé sur des zéros que personne n'a publiés. + b.writeln('- aucune télémétrie reçue'); + } else { + b + ..writeln('- horodatage : ${tel.timestamp?.toIso8601String() ?? "AUCUN CYCLE depuis le démarrage du service"}') + ..writeln('- mode dégradé : ${tel.degradedMode ? "OUI (watchdog L2)" : "non"}'); + final bud = tel.budget; + if (bud == null) { + b.writeln('- budget : ABSENT (aucun budget pour ce cycle)'); + } else { + b + ..writeln('- budget : surplus ${bud.surplusW.round()} W · ' + 'réservé VE ${bud.evReservedW.round()} W · ' + 'recrédité ${bud.recreditedW.round()} W · ' + 'alloué ${bud.allocatedW.round()} W · ' + 'reliquat ${bud.remainingW.round()} W') + // Les deux identités que le plugin déclare vérifiables. Leur verdict vaut + // souvent plus que la description du symptôme : un rapport qui les porte + // fausses désigne le moteur, un rapport qui les porte vraies désigne l'écran. + ..writeln('- identité du budget : ${bud.identityHolds ? "OK" : "FAUSSE"}') + ..writeln('- identité surplus/allocations : ' + '${switch (tel.surplusIdentityHolds) { + null => "sans objet (pas de budget)", + true => "OK", + false => "FAUSSE (somme ${tel.surplusAllocatedW.round()} W " + "contre ${bud.allocatedW.round()} W)", + }}'); + } + for (final l in tel.loads) { + final m = l.mechanism; + b.writeln(' - ${l.loadId} · alloué ${l.allocatedW.round()} W' + '${l.measuredW != null ? " · mesuré ${l.measuredW!.round()} W" : ""}' + ' · ${l.funding ?? "financement omis"}' + ' · motif ${l.decision.code}${formatParamsBrut(l.decision.params)}' + '${l.inFault ? " · EN DÉFAUT ${l.faultCode ?? ""}" : ""}' + '${m != null ? " · mécanisme ${m.kind}" : ""}'); + } + } + return b.toString(); + } + + @override + Widget build(BuildContext context) { + final ink = EtmTokens.inkOf(context); + final muted = EtmTokens.mutedOf(context); + + return Scaffold( + backgroundColor: EtmTokens.bgOf(context), + appBar: AppBar( + title: const Text('Rapport de bug'), + backgroundColor: EtmTokens.surfaceOf(context), + foregroundColor: ink, + elevation: 0, + ), + body: ListView( + padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), + children: [ + Text( + 'Décrivez le problème en deux phrases. Tout le contexte technique est déjà ' + 'joint : versions, charges déclarées, et l\'état du dernier cycle ' + 'd\'arbitrage. Vous pouvez le relire avant de copier.', + style: TextStyle(fontSize: 13, height: 1.4, color: muted), + ), + const SizedBox(height: 16), + _Champ( + libelle: 'Ce qui s\'est passé', + aide: 'Ce que vous avez fait, et ce que l\'écran a montré.', + controller: _quoi, + ), + const SizedBox(height: 12), + _Champ( + libelle: 'Ce qui était attendu', + aide: 'Sans cette ligne, un défaut de compréhension ressemble à un défaut ' + 'de logiciel.', + controller: _attendu, + ), + const SizedBox(height: 18), + Text('Contexte joint automatiquement', + style: TextStyle( + fontSize: 13, fontWeight: FontWeight.w700, color: ink)), + const SizedBox(height: 6), + Text( + 'Aucun secret n\'y figure : ni PIN installateur, ni jeton, ni mot de passe. ' + 'Les identifiants d\'appareils sont conservés — ce sont eux qui permettent de ' + 'recouper avec le journal de la box.', + style: TextStyle(fontSize: 11.5, height: 1.35, color: muted), + ), + const SizedBox(height: 10), + Container( + width: double.infinity, + padding: const EdgeInsets.all(12), + decoration: BoxDecoration( + color: EtmTokens.surfaceOf(context), + borderRadius: BorderRadius.circular(EtmTokens.radiusCard), + border: Border.all(color: EtmTokens.lineOf(context)), + ), + child: SelectableText( + _rapport(context), + style: TextStyle( + fontSize: 11, height: 1.4, fontFamily: 'monospace', color: ink), + ), + ), + const SizedBox(height: 16), + FilledButton.icon( + onPressed: () async { + final texte = _rapport(context); + await Clipboard.setData(ClipboardData(text: texte)); + if (!context.mounted) return; + ScaffoldMessenger.of(context).showSnackBar(const SnackBar( + content: Text('Rapport copié — collez-le dans votre message.'), + )); + }, + icon: const Icon(Icons.copy_all_rounded), + label: const Text('Copier le rapport'), + ), + const SizedBox(height: 8), + Text( + 'Il n\'y a pas d\'envoi automatique : le rapport est copié dans le ' + 'presse-papier, et c\'est vous qui décidez où il va.', + style: TextStyle(fontSize: 11.5, height: 1.35, color: muted), + ), + ], + ), + ); + } +} + +/// Rendu compact des paramètres d'un motif, trié — deux rapports du même état doivent +/// être comparables ligne à ligne. +String formatParamsBrut(Map params) { + if (params.isEmpty) return ''; + final keys = params.keys.toList()..sort(); + return ' {${[for (final k in keys) '$k=${params[k]}'].join(', ')}}'; +} + +class _Champ extends StatelessWidget { + final String libelle; + final String aide; + final TextEditingController controller; + + const _Champ({ + required this.libelle, + required this.aide, + required this.controller, + }); + + @override + Widget build(BuildContext context) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text(libelle, + style: TextStyle( + fontSize: 13, + fontWeight: FontWeight.w700, + color: EtmTokens.inkOf(context))), + const SizedBox(height: 2), + Text(aide, + style: TextStyle( + fontSize: 11.5, height: 1.3, color: EtmTokens.mutedOf(context))), + const SizedBox(height: 6), + TextField( + controller: controller, + maxLines: 3, + minLines: 2, + textInputAction: TextInputAction.newline, + keyboardType: TextInputType.multiline, + decoration: InputDecoration( + filled: true, + fillColor: EtmTokens.surfaceOf(context), + border: OutlineInputBorder( + borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), + borderSide: BorderSide(color: EtmTokens.lineOf(context)), + ), + contentPadding: const EdgeInsets.all(12), + ), + style: TextStyle(fontSize: 13, color: EtmTokens.inkOf(context)), + ), + ], + ); + } +} diff --git a/macos/Flutter/GeneratedPluginRegistrant.swift b/macos/Flutter/GeneratedPluginRegistrant.swift index d385217..7a91361 100644 --- a/macos/Flutter/GeneratedPluginRegistrant.swift +++ b/macos/Flutter/GeneratedPluginRegistrant.swift @@ -6,11 +6,13 @@ import FlutterMacOS import Foundation import flutter_secure_storage_macos +import package_info_plus import shared_preferences_foundation import url_launcher_macos func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { FlutterSecureStoragePlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStoragePlugin")) + FPPPackageInfoPlusPlugin.register(with: registry.registrar(forPlugin: "FPPPackageInfoPlusPlugin")) SharedPreferencesPlugin.register(with: registry.registrar(forPlugin: "SharedPreferencesPlugin")) UrlLauncherPlugin.register(with: registry.registrar(forPlugin: "UrlLauncherPlugin")) } diff --git a/pubspec.lock b/pubspec.lock index fd74aed..a1b5604 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -372,6 +372,22 @@ packages: url: "https://pub.dev" source: hosted version: "2.2.0" + package_info_plus: + dependency: "direct main" + description: + name: package_info_plus + sha256: "468c26b4254ab01979fa5e4a98cb343ea3631b9acee6f21028997419a80e1a20" + url: "https://pub.dev" + source: hosted + version: "9.0.1" + package_info_plus_platform_interface: + dependency: transitive + description: + name: package_info_plus_platform_interface + sha256: "202a487f08836a592a6bd4f901ac69b3a8f146af552bbd14407b6b41e1c3f086" + url: "https://pub.dev" + source: hosted + version: "3.2.1" path: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index e481618..9e17b4d 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -48,6 +48,7 @@ dependencies: flutter_localizations: sdk: flutter intl: any + package_info_plus: ^9.0.1 dev_dependencies: flutter_test: