Consigne app etm66/etm68, vérifiée contre le banc hems (1.15.2+etm68) le 2026-10-14.
- PvForecastCard remplace le placeholder « Météo & prévision » : lit
NymeaEnergy.GetForecastSnapshot à l'ouverture puis toutes les 12 min
(aucune notification n'existe). Pas des créneaux DÉDUIT, créneau absent
= trou (jamais zéro), fault ⇒ pas de courbe avec son motif, libellé
« plafond optimiste », corrections/plafond AC en mode installateur.
- MISSING_FORECAST_SITE : les candidats de GetForecastSite sont proposés,
la désignation (SetForecastSite) se confirme ; acLimitW omis = inchangé.
- Production mesurée superposée depuis GetPowerBalanceLogs, signe inversé
(pas .abs()), aucun cumul total*.
- Marché spot masqué dans Tarifs (SPOT_MARKET_NOT_IN_BUILD) : mode Spot
Market, fournisseurs Tibber/Octopus/ENTSO-E et la carte de prix codés en dur.
- Bornes VE envoyées en {uuid} minuscules jusqu'à etm69 (SetChargingInfo,
SetLoadConfig, Set/ClearLoadDerogation) via idBorneCanonique().
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
624 lines
22 KiB
Dart
624 lines
22 KiB
Dart
import 'dart:async';
|
||
import 'dart:math';
|
||
|
||
import 'package:fl_chart/fl_chart.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:provider/provider.dart';
|
||
|
||
import '../models/pv_forecast.dart';
|
||
import '../providers/installer_mode_provider.dart';
|
||
import '../services/nymea_service.dart';
|
||
import '../theme/etm_tokens.dart';
|
||
|
||
/// Une production mesurée, prête à superposer : instant absolu et puissance en W,
|
||
/// **déjà retournée** (nymea publie la production négative).
|
||
typedef ProductionMesuree = ({DateTime t, double w});
|
||
|
||
/// Intervalle de relecture. La prévision ne notifie RIEN (décision moteur) : sans
|
||
/// relecture, l'écran laissé ouvert montrerait la courbe du matin le soir.
|
||
const Duration kRelecturePrevision = Duration(minutes: 12);
|
||
|
||
/// **Carte « Prévision PV »** — lit la box, relit périodiquement, et délègue le rendu à
|
||
/// [PvForecastView].
|
||
///
|
||
/// Remplace le placeholder « Météo & prévision » de l'écran Énergie : la courbe existe
|
||
/// désormais sur la box (`NymeaEnergy.GetForecastSnapshot`, `+etm68`).
|
||
class PvForecastCard extends StatefulWidget {
|
||
const PvForecastCard({super.key});
|
||
|
||
@override
|
||
State<PvForecastCard> createState() => _PvForecastCardState();
|
||
}
|
||
|
||
class _PvForecastCardState extends State<PvForecastCard> {
|
||
Timer? _timer;
|
||
bool _chargement = true;
|
||
PvForecastSnapshot? _snapshot;
|
||
String? _erreur;
|
||
PvForecastSite? _site;
|
||
List<ProductionMesuree> _mesure = const [];
|
||
String? _refusDesignation;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
WidgetsBinding.instance.addPostFrameCallback((_) => _lire());
|
||
_timer = Timer.periodic(kRelecturePrevision, (_) => _lire());
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_timer?.cancel();
|
||
super.dispose();
|
||
}
|
||
|
||
Future<void> _lire() async {
|
||
if (!mounted) return;
|
||
final service = context.read<NymeaService>();
|
||
final r = await service.fetchForecastSnapshot();
|
||
if (!mounted) return;
|
||
|
||
PvForecastSite? site;
|
||
var mesure = const <ProductionMesuree>[];
|
||
final snap = r.snapshot;
|
||
if (snap?.etat == PvForecastEtat.siteManquant) {
|
||
site = await service.fetchForecastSite();
|
||
} else if (snap?.etat == PvForecastEtat.courbe) {
|
||
final debut = snap!.points.first.debutUtc;
|
||
final maintenant = DateTime.now().toUtc();
|
||
if (debut.isBefore(maintenant)) {
|
||
final logs = await service.fetchPowerBalanceLogs(
|
||
from: debut.toLocal(), to: maintenant.toLocal());
|
||
mesure = [
|
||
// `production` est NÉGATIVE dans nymea : on INVERSE le signe, on ne prend pas
|
||
// la valeur absolue. Un onduleur qui consomme la nuit publie une production
|
||
// positive ; `.abs()` la ferait lire comme une production.
|
||
for (final e in logs) (t: e.timestamp, w: -e.productionW),
|
||
];
|
||
}
|
||
}
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_chargement = false;
|
||
_snapshot = snap;
|
||
_erreur = r.erreur;
|
||
_site = site;
|
||
_mesure = mesure;
|
||
});
|
||
}
|
||
|
||
Future<void> _designer(String siteId) async {
|
||
final service = context.read<NymeaService>();
|
||
final err = await service.setForecastSite(siteId);
|
||
if (!mounted) return;
|
||
setState(() => _refusDesignation = err);
|
||
await _lire();
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final service = context.watch<NymeaService>();
|
||
final installateur = context.watch<InstallerModeProvider>().isUnlocked;
|
||
return PvForecastView(
|
||
chargement: _chargement,
|
||
snapshot: _snapshot,
|
||
erreur: _erreur,
|
||
site: _site,
|
||
mesure: _mesure,
|
||
maintenant: DateTime.now(),
|
||
installateur: installateur,
|
||
nomDuThing: (id) => _nom(service, id),
|
||
refusDesignation: _refusDesignation,
|
||
onDesigner: _designer,
|
||
onRelire: _lire,
|
||
);
|
||
}
|
||
|
||
static String _nom(NymeaService s, String id) {
|
||
final nu = id.replaceAll(RegExp(r'[{}]'), '').toLowerCase();
|
||
for (final t in s.things) {
|
||
if (t.id.replaceAll(RegExp(r'[{}]'), '').toLowerCase() == nu) return t.name;
|
||
}
|
||
return id;
|
||
}
|
||
}
|
||
|
||
/// Rendu pur de la prévision — tout ce qu'il affiche lui est donné.
|
||
class PvForecastView extends StatelessWidget {
|
||
final bool chargement;
|
||
final PvForecastSnapshot? snapshot;
|
||
|
||
/// Non nul = la prévision n'a pas été LUE. Ce n'est pas « rien de publié ».
|
||
final String? erreur;
|
||
final PvForecastSite? site;
|
||
final List<ProductionMesuree> mesure;
|
||
final DateTime maintenant;
|
||
final bool installateur;
|
||
final String Function(String thingId) nomDuThing;
|
||
final String? refusDesignation;
|
||
final Future<void> Function(String siteId)? onDesigner;
|
||
final Future<void> Function()? onRelire;
|
||
|
||
const PvForecastView({
|
||
super.key,
|
||
required this.chargement,
|
||
required this.snapshot,
|
||
this.erreur,
|
||
this.site,
|
||
this.mesure = const [],
|
||
required this.maintenant,
|
||
this.installateur = false,
|
||
required this.nomDuThing,
|
||
this.refusDesignation,
|
||
this.onDesigner,
|
||
this.onRelire,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final muted = EtmTokens.mutedOf(context);
|
||
final snap = snapshot;
|
||
final pas = snap?.pas;
|
||
return Container(
|
||
padding: const EdgeInsets.all(18),
|
||
decoration: BoxDecoration(
|
||
color: EtmTokens.surfaceOf(context),
|
||
borderRadius: BorderRadius.circular(EtmTokens.radiusLg),
|
||
boxShadow: EtmTokens.cardShadow,
|
||
),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||
children: [
|
||
Text('Prévision PV',
|
||
style: EtmTokens.sans(size: 16, weight: FontWeight.w600)),
|
||
if (snap?.etat == PvForecastEtat.courbe && pas != null)
|
||
Text('par créneau de ${_duree(pas)}',
|
||
style: EtmTokens.sans(size: 12, color: muted)),
|
||
],
|
||
),
|
||
const SizedBox(height: 12),
|
||
..._corps(context),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
List<Widget> _corps(BuildContext context) {
|
||
if (chargement) {
|
||
return const [
|
||
SizedBox(
|
||
height: 60,
|
||
child: Center(
|
||
child: SizedBox(
|
||
width: 22,
|
||
height: 22,
|
||
child: CircularProgressIndicator(strokeWidth: 2)))),
|
||
];
|
||
}
|
||
final snap = snapshot;
|
||
if (snap == null) {
|
||
if (erreur == 'NoSuchMethod') {
|
||
return [
|
||
_Encadre(
|
||
icone: Icons.cloud_off_outlined,
|
||
titre: 'Cette box ne publie pas de prévision PV',
|
||
texte: 'Le moteur installé ne connaît pas encore '
|
||
'« NymeaEnergy.GetForecastSnapshot ». La carte s\'activera après '
|
||
'mise à jour.'),
|
||
];
|
||
}
|
||
if (erreur == 'Simulation') {
|
||
return [
|
||
_Encadre(
|
||
icone: Icons.science_outlined,
|
||
titre: 'Pas de prévision en simulation',
|
||
texte: 'La prévision vient de la box ; aucune n\'est inventée ici.'),
|
||
];
|
||
}
|
||
return [
|
||
_Encadre(
|
||
icone: Icons.sync_problem_rounded,
|
||
titre: 'Prévision non lue',
|
||
texte: 'La box n\'a pas répondu (${erreur ?? 'raison inconnue'}). '
|
||
'Ce n\'est pas une absence de prévision : elle n\'a pas pu être lue.',
|
||
action: onRelire == null
|
||
? null
|
||
: TextButton(onPressed: onRelire, child: const Text('Relire'))),
|
||
];
|
||
}
|
||
|
||
switch (snap.etat) {
|
||
case PvForecastEtat.siteManquant:
|
||
return _siteManquant(context);
|
||
case PvForecastEtat.chargeRefusee:
|
||
final cause = snap.faultParams['cause'];
|
||
return [
|
||
_Encadre(
|
||
icone: Icons.report_gmailerrorred_rounded,
|
||
titre: 'Prévision refusée par la box',
|
||
texte: 'Le producteur météo publie une prévision que le moteur ne sait '
|
||
'pas lire — c\'est lui qu\'il faut regarder, pas ce réglage.'
|
||
'${cause != null ? '\nCause : $cause' : ''}',
|
||
code: 'FORECAST_PAYLOAD_REFUSED'),
|
||
];
|
||
case PvForecastEtat.faultInconnu:
|
||
return [
|
||
_Encadre(
|
||
icone: Icons.help_outline,
|
||
titre: 'Pas de courbe : défaut publié par la box',
|
||
texte: 'Ce défaut est inconnu de cette version de l\'app.'
|
||
'${snap.faultParams.isNotEmpty ? '\n${snap.faultParams}' : ''}',
|
||
code: snap.fault),
|
||
];
|
||
case PvForecastEtat.rienPublie:
|
||
return [
|
||
_Encadre(
|
||
icone: Icons.cloud_queue_rounded,
|
||
titre: 'Aucune prévision publiée',
|
||
texte: 'Pas de plugin météo sur cette box, ou ses données ne sont pas '
|
||
'encore arrivées. La box ne distingue pas les deux cas.'),
|
||
];
|
||
case PvForecastEtat.courbe:
|
||
return _courbe(context, snap);
|
||
}
|
||
}
|
||
|
||
List<Widget> _siteManquant(BuildContext context) {
|
||
final candidats = site?.candidats ?? const <String>[];
|
||
return [
|
||
_Encadre(
|
||
icone: Icons.place_outlined,
|
||
titre: 'Aucun site de prévision désigné',
|
||
texte: candidats.isEmpty
|
||
? 'Des champs PV publient une prévision, mais aucun site n\'est '
|
||
'désigné et la liste des sites possibles n\'a pas pu être lue.'
|
||
: 'Des champs PV publient une prévision. Choisissez le site dont la '
|
||
'production doit être prévue — tous ses champs seront additionnés.',
|
||
code: 'MISSING_FORECAST_SITE'),
|
||
for (final c in candidats)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 8),
|
||
child: Row(children: [
|
||
const Icon(Icons.solar_power_outlined, size: 18),
|
||
const SizedBox(width: 8),
|
||
Expanded(
|
||
child: Text(nomDuThing(c),
|
||
style: EtmTokens.sans(size: 13, weight: FontWeight.w500))),
|
||
TextButton(
|
||
onPressed: onDesigner == null ? null : () => _confirmer(context, c),
|
||
child: const Text('Désigner'),
|
||
),
|
||
]),
|
||
),
|
||
if (refusDesignation != null)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 8),
|
||
child: Text('La box a refusé la désignation : $refusDesignation',
|
||
style: EtmTokens.sans(size: 12, color: EtmTokens.danger)),
|
||
),
|
||
];
|
||
}
|
||
|
||
Future<void> _confirmer(BuildContext context, String siteId) async {
|
||
final ok = await showDialog<bool>(
|
||
context: context,
|
||
builder: (ctx) => AlertDialog(
|
||
title: const Text('Désigner ce site ?'),
|
||
content: Text('La prévision de « ${nomDuThing(siteId)} » sera gardée et '
|
||
'utilisée par le moteur. Le choix est enregistré sur la box.'),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(ctx, false),
|
||
child: const Text('Annuler')),
|
||
FilledButton(
|
||
onPressed: () => Navigator.pop(ctx, true),
|
||
child: const Text('Désigner')),
|
||
],
|
||
),
|
||
);
|
||
if (ok == true) await onDesigner?.call(siteId);
|
||
}
|
||
|
||
List<Widget> _courbe(BuildContext context, PvForecastSnapshot snap) {
|
||
final muted = EtmTokens.mutedOf(context);
|
||
final manquants = snap.creneauxManquants;
|
||
final change = snap.contentChangedAt;
|
||
return [
|
||
Wrap(spacing: 14, runSpacing: 4, children: [
|
||
_Legende(EtmTokens.solarOf(context), 'Prévision (plafond optimiste)'),
|
||
if (mesure.isNotEmpty) _Legende(EtmTokens.ecoOf(context), 'Production mesurée'),
|
||
]),
|
||
const SizedBox(height: 10),
|
||
SizedBox(height: 200, child: _graphe(context, snap)),
|
||
const SizedBox(height: 10),
|
||
_Note(
|
||
icone: Icons.info_outline_rounded,
|
||
texte: 'Une prévision, pas une mesure — et un plafond optimiste : ombrage et '
|
||
'salissure ne sont pas modélisés, la production réelle sera en général '
|
||
'en dessous.'),
|
||
if (manquants > 0)
|
||
_Note(
|
||
icone: Icons.more_horiz_rounded,
|
||
texte: '$manquants créneau${manquants > 1 ? 'x' : ''} sans prévision — '
|
||
'laissé${manquants > 1 ? 's' : ''} vide${manquants > 1 ? 's' : ''}, '
|
||
'pas à zéro.'),
|
||
_Note(
|
||
icone: Icons.update_rounded,
|
||
texte: change != null
|
||
? 'Courbe inchangée depuis ${_jourHeure(change)}.'
|
||
: 'Date du dernier changement de la courbe non publiée par la box.'),
|
||
if (installateur) ...[
|
||
const SizedBox(height: 6),
|
||
Text('Détails installateur',
|
||
style: EtmTokens.sans(size: 12, weight: FontWeight.w600, color: muted)),
|
||
const SizedBox(height: 4),
|
||
_ligne(context, 'Corrections',
|
||
snap.corrections == null
|
||
? 'non publié'
|
||
: snap.corrections!.isEmpty
|
||
? 'aucune (GTI × kWc × PR brut)'
|
||
: snap.corrections!.map(_correction).join(', ')),
|
||
if (snap.acLimitW != null)
|
||
_ligne(context, 'Plafond AC du site', '${snap.acLimitW} W (courbe écrêtée)'),
|
||
if (snap.perField.isNotEmpty)
|
||
_ligne(context, 'Champs additionnés', '${snap.perField.length}'),
|
||
for (final f in snap.perField)
|
||
_ligne(context, ' ${nomDuThing(f.id)}',
|
||
f.corrections.isEmpty ? 'aucune correction' : f.corrections.map(_correction).join(', ')),
|
||
if (snap.model != null) _ligne(context, 'Modèle', snap.model!),
|
||
if (snap.conversion != null) _ligne(context, 'Conversion', snap.conversion!),
|
||
_ligne(context, 'Run émis',
|
||
snap.issuedAt != null ? _jourHeure(snap.issuedAt!) : 'non publié'),
|
||
if (snap.capturedAt != null)
|
||
_ligne(context, 'Lue à', _jourHeure(snap.capturedAt!)),
|
||
],
|
||
];
|
||
}
|
||
|
||
Widget _graphe(BuildContext context, PvForecastSnapshot snap) {
|
||
final t0 = snap.points.first.debutUtc;
|
||
final pas = snap.pas;
|
||
double x(DateTime t) => t.toUtc().difference(t0).inSeconds / 3600.0;
|
||
|
||
// L'énergie d'un créneau devient une puissance MOYENNE sur ce créneau : seule unité
|
||
// commune avec la production mesurée. Simple changement d'unité, pas un calcul de
|
||
// prévision. Sans pas connu (un point seul), on ne sait pas sur quoi diviser.
|
||
final prev = <FlSpot>[];
|
||
if (pas != null) {
|
||
final h = pas.inSeconds / 3600.0;
|
||
for (final seg in snap.segments) {
|
||
if (prev.isNotEmpty) prev.add(FlSpot.nullSpot); // un trou, jamais un zéro
|
||
for (final p in seg) {
|
||
final kw = p.wh / h / 1000;
|
||
prev
|
||
..add(FlSpot(x(p.debutUtc), kw))
|
||
..add(FlSpot(x(p.debutUtc) + h, kw));
|
||
}
|
||
}
|
||
}
|
||
final xMax = pas == null
|
||
? max(1.0, x(snap.points.last.debutUtc))
|
||
: x(snap.points.last.debutUtc) + pas.inSeconds / 3600.0;
|
||
|
||
final mes = <FlSpot>[];
|
||
DateTime? avant;
|
||
for (final m in mesure) {
|
||
final xm = x(m.t);
|
||
if (xm < 0 || xm > xMax) continue;
|
||
// Un trou de mesure reste un trou : plus de deux fois l'écart attendu (15 min).
|
||
if (avant != null && m.t.difference(avant) > const Duration(minutes: 31)) {
|
||
mes.add(FlSpot.nullSpot);
|
||
}
|
||
mes.add(FlSpot(xm, m.w / 1000));
|
||
avant = m.t;
|
||
}
|
||
|
||
final valeurs = [
|
||
for (final s in [...prev, ...mes])
|
||
if (s != FlSpot.nullSpot) s.y
|
||
];
|
||
final yMax = max(0.5, (valeurs.isEmpty ? 0.0 : valeurs.reduce(max)) * 1.15);
|
||
final yMin = min(0.0, valeurs.isEmpty ? 0.0 : valeurs.reduce(min));
|
||
final xNow = x(maintenant);
|
||
final label = EtmTokens.sans(size: 9, color: EtmTokens.mutedOf(context));
|
||
final solaire = EtmTokens.solarOf(context);
|
||
final eco = EtmTokens.ecoOf(context);
|
||
|
||
return LineChart(
|
||
LineChartData(
|
||
clipData: const FlClipData.all(),
|
||
minX: 0,
|
||
maxX: xMax,
|
||
minY: yMin,
|
||
maxY: yMax,
|
||
borderData: FlBorderData(show: false),
|
||
gridData: FlGridData(
|
||
show: true,
|
||
drawVerticalLine: false,
|
||
horizontalInterval: yMax / 4,
|
||
getDrawingHorizontalLine: (_) =>
|
||
FlLine(color: EtmTokens.lineOf(context), strokeWidth: 1),
|
||
),
|
||
extraLinesData: ExtraLinesData(verticalLines: [
|
||
if (xNow > 0 && xNow < xMax)
|
||
VerticalLine(
|
||
x: xNow,
|
||
color: EtmTokens.mutedOf(context),
|
||
strokeWidth: 1,
|
||
dashArray: const [3, 3]),
|
||
]),
|
||
titlesData: FlTitlesData(
|
||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||
leftTitles: AxisTitles(
|
||
sideTitles: SideTitles(
|
||
showTitles: true,
|
||
reservedSize: 40,
|
||
interval: yMax / 4,
|
||
getTitlesWidget: (v, meta) => v == meta.max
|
||
? const SizedBox.shrink()
|
||
: SideTitleWidget(
|
||
meta: meta,
|
||
child: Text('${v.toStringAsFixed(1)}kW', style: label)),
|
||
),
|
||
),
|
||
bottomTitles: AxisTitles(
|
||
sideTitles: SideTitles(
|
||
showTitles: true,
|
||
reservedSize: 22,
|
||
interval: 1,
|
||
getTitlesWidget: (v, meta) {
|
||
if (v <= meta.min || v >= meta.max) return const SizedBox.shrink();
|
||
// Heure LOCALE à l'affichage seulement ; la grille reste en UTC.
|
||
final t = t0.add(Duration(seconds: (v * 3600).round())).toLocal();
|
||
if (t.minute != 0 || t.hour % 6 != 0) return const SizedBox.shrink();
|
||
final txt = t.hour == 0
|
||
? '${t.day}/${t.month}'
|
||
: '${t.hour.toString().padLeft(2, '0')}h';
|
||
return SideTitleWidget(meta: meta, child: Text(txt, style: label));
|
||
},
|
||
),
|
||
),
|
||
),
|
||
lineTouchData: const LineTouchData(enabled: false),
|
||
lineBarsData: [
|
||
LineChartBarData(
|
||
spots: prev,
|
||
isCurved: false,
|
||
color: solaire,
|
||
barWidth: 1.5,
|
||
dashArray: const [5, 3],
|
||
dotData: FlDotData(show: pas == null),
|
||
belowBarData:
|
||
BarAreaData(show: true, color: solaire.withValues(alpha: 0.18)),
|
||
),
|
||
if (mes.isNotEmpty)
|
||
LineChartBarData(
|
||
spots: mes,
|
||
isCurved: false,
|
||
color: eco,
|
||
barWidth: 1.8,
|
||
dotData: const FlDotData(show: false),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _ligne(BuildContext context, String k, String v) => Padding(
|
||
padding: const EdgeInsets.only(top: 2),
|
||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
SizedBox(
|
||
width: 130,
|
||
child: Text(k,
|
||
style: EtmTokens.sans(size: 11.5, color: EtmTokens.mutedOf(context)))),
|
||
Expanded(child: Text(v, style: EtmTokens.mono(size: 11.5))),
|
||
]),
|
||
);
|
||
|
||
static String _correction(String c) => switch (c) {
|
||
'temperature' => 'température de module',
|
||
'ac-limit' => 'plafond onduleur',
|
||
_ => c,
|
||
};
|
||
|
||
static String _duree(Duration d) => d.inMinutes % 60 == 0
|
||
? '${d.inHours} h'
|
||
: '${d.inMinutes} min';
|
||
|
||
static String _jourHeure(DateTime t) {
|
||
final l = t.toLocal();
|
||
String d2(int v) => v.toString().padLeft(2, '0');
|
||
return '${l.day}/${l.month} ${d2(l.hour)}:${d2(l.minute)}';
|
||
}
|
||
}
|
||
|
||
class _Encadre extends StatelessWidget {
|
||
final IconData icone;
|
||
final String titre;
|
||
final String texte;
|
||
final String? code;
|
||
final Widget? action;
|
||
|
||
const _Encadre({
|
||
required this.icone,
|
||
required this.titre,
|
||
required this.texte,
|
||
this.code,
|
||
this.action,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final muted = EtmTokens.mutedOf(context);
|
||
return Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.all(14),
|
||
decoration: BoxDecoration(
|
||
color: EtmTokens.bgOf(context),
|
||
borderRadius: BorderRadius.circular(12),
|
||
),
|
||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Icon(icone, color: EtmTokens.faintOf(context), size: 26),
|
||
const SizedBox(width: 12),
|
||
Expanded(
|
||
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Text(titre,
|
||
style: EtmTokens.sans(size: 13, weight: FontWeight.w600, color: muted)),
|
||
const SizedBox(height: 4),
|
||
Text(texte,
|
||
style: EtmTokens.sans(size: 11.5, color: muted).copyWith(height: 1.35)),
|
||
// Le code brut rend le rapport de terrain actionnable.
|
||
if (code != null) ...[
|
||
const SizedBox(height: 4),
|
||
Text(code!, style: EtmTokens.mono(size: 10.5, color: EtmTokens.faintOf(context))),
|
||
],
|
||
?action,
|
||
]),
|
||
),
|
||
]),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _Note extends StatelessWidget {
|
||
final IconData icone;
|
||
final String texte;
|
||
const _Note({required this.icone, required this.texte});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final muted = EtmTokens.mutedOf(context);
|
||
return Padding(
|
||
padding: const EdgeInsets.only(top: 4),
|
||
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
||
Icon(icone, size: 13, color: muted),
|
||
const SizedBox(width: 6),
|
||
Expanded(
|
||
child: Text(texte,
|
||
style: EtmTokens.sans(size: 11, color: muted).copyWith(height: 1.35))),
|
||
]),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _Legende extends StatelessWidget {
|
||
final Color couleur;
|
||
final String texte;
|
||
const _Legende(this.couleur, this.texte);
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Row(mainAxisSize: MainAxisSize.min, children: [
|
||
Container(
|
||
width: 14,
|
||
height: 3,
|
||
decoration: BoxDecoration(
|
||
color: couleur, borderRadius: BorderRadius.circular(2))),
|
||
const SizedBox(width: 5),
|
||
Text(texte, style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))),
|
||
]);
|
||
}
|