/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
292 lines
12 KiB
Dart
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)),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|