etm-powersync-app/lib/screens/settings/bug_report_screen.dart
Patrick Schurig 2792e07265 fix(télémétrie): funding a quitté la charge en +etm30 — le lire dans levels[]
R2 est livrée côté moteur (1.15.2+etm30 sur .75) : `funding` ne figure plus à la
racine d'une entrée de `loads[]`, il vit dans `levels[]`, un par passe. La rupture
est effective, pas seulement contractuelle — `entry.funding` est désormais `null`
partout, donc la mention « servie au réseau » avait disparu de la carte et une
charge achetée au réseau aurait été comptée comme surplus dans l'identité. Une
panne muette : rien n'échoue, tout se lit de travers.

- `LoadLevel` — une passe : `level`, `targetW` (du DÉCIDÉ, comme `allocatedW`),
  `funding` ;
- `resolvedFunding` / `isGridFunded` résolvent les DEUX formes ; `levels[]` vide
  = box antérieure, pas « aucune passe » ;
- `'mixed'` est NOMMÉ plutôt que replié : impossible aujourd'hui (une seule passe
  publiée), courant au §10, et l'affirmer d'un côté serait une demi-vérité ;
- `surplusShareW` somme les `targetW` financés au surplus, l'exception écrite
  `EV_GRID_START` (part de surplus = `params.budgetW`) passant avant.

Vérifié à la sonde contre `.75`, pas en test : 3 500 + 3 000 == 6 500 ==
`budget.allocatedW` au cycle 06:10, puis 1 500 == 1 500 à 06:14. Le niveau publié
est « comfort », et c'est déjà le bon nom.

Aucun écran à deux niveaux n'est commencé : le reste de la maquette attend R1 et
R3 à R8, et le §10 n'est pas implémenté.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01GKM8FDNHZWogAsjjfB8pSw
2026-08-30 08:16:29 +02:00

293 lines
12 KiB
Dart

/// **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.measurement.mesuree ? " · mesuré ${l.measurement.powerW!.round()} W"
" (${l.measurement.source.name})" : ""}'
' · ${l.resolvedFunding ?? "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)),
),
],
);
}
}