feat(support): le rapport de bug pré-rempli, repoussé depuis le premier jour

/bug-report était un stub. Un champ libre seul produit « ça ne marche pas », et l'aller-retour
pour obtenir versions, charges et état d'arbitrage coûte des jours. Tout ce que l'app SAIT
est donc joint d'office : version du paquet, hôte et état de connexion, ligne de versions du
schéma, les charges déclarées avec adaptateur / rang / domaine, et le dernier cycle publié.

Deux choix qui ne sont pas cosmétiques :

- LES DEUX IDENTITÉS DU BUDGET sont dans le rapport, avec leur verdict. Elles valent souvent
  plus que la description du symptôme : portées fausses, elles désignent le moteur ; portées
  vraies, elles désignent l'écran. Sans elles, le premier échange consiste à les demander.

- Aucun secret n'y entre : ni PIN installateur même haché, ni jeton, ni mot de passe. Un
  rapport voyage — courriel, capture, fil de discussion — et ce qui y entre en sort. Les
  UUID de Things restent : ce sont eux qui permettent de recouper avec le journal de la box,
  et ils n'ont aucune valeur hors de l'installation.

Les absences gardent leur sens, comme partout ailleurs : « aucune télémétrie reçue » n'est
pas un arbitrage à zéro, « budget ABSENT » n'est pas un budget de zéro watt, et un
financement omis se dit omis.

Pas d'envoi automatique : il n'existe aucun service de collecte, et un bouton « Envoyer »
sans destinataire donnerait le sentiment que quelqu'un l'a reçu. Le rapport se copie, ce qui
est vérifiable.

package_info_plus est ajouté plutôt qu'une constante de version 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à.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016vrTVifar2GN5rtUh89Wkq
This commit is contained in:
Patrick Schurig 2026-08-27 14:54:27 +02:00
parent 963ab98f13
commit f33b8c243a
8 changed files with 548 additions and 7 deletions

View File

@ -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

View File

@ -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

169
docs/CADRAGE_zones_clim.md Normal file
View File

@ -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.

View File

@ -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()),
],
),

View File

@ -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<BugReportScreen> createState() => _BugReportScreenState();
}
class _BugReportScreenState extends State<BugReportScreen> {
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<NymeaService>();
final schema = context.read<SchemaProvider>();
final config = context.read<LoadConfigProvider>();
final tel = context.read<LoadTelemetryProvider>().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<String, dynamic> 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)),
),
],
);
}
}

View File

@ -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"))
}

View File

@ -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:

View File

@ -48,6 +48,7 @@ dependencies:
flutter_localizations:
sdk: flutter
intl: any
package_info_plus: ^9.0.1
dev_dependencies:
flutter_test: