etm-powersync-app/lib/screens/settings/bug_report_screen.dart
Patrick Schurig f33b8c243a 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
2026-08-27 14:54:27 +02:00

292 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.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)),
),
],
);
}
}