/// **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)), ), ], ); } }