Variante E de la maquette, retenue par Patrick le 2026-10-15 (« deux colonnes à la Victron ») ; remplace la carte « Production du jour ». - Par heure, deux colonnes : à gauche la consommation, Maison (bleu) puis chaque consommateur déclaré avec compteur (couleur de son domaine, même règle que l'écran Énergie) ; à droite le solaire, autoconsommé (doré) puis injecté (pâle), la prévision en fond pour comparer. Heures à venir : la prévision seule, en contour — aucun plan n'est publié par la box. - Échantillon horaire horodaté T = heure qui commence à T − 1 h, pour le bilan comme pour les Things. - Météo de maintenant lue sur le Thing site openmeteo, et seulement si updateTime a été écrit : les défauts du plugin (clear-day, 0 °C) afficheraient sinon un soleil à 0 °C. Les icônes horaires attendent openmeteo etm16 (consignes/CONSIGNE-openmeteo-meteo-horaire.md). - Charges sans compteur déclaré nommées sous la carte (« comptée dans Maison »), jamais devinées. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
505 lines
19 KiB
Dart
505 lines
19 KiB
Dart
import 'dart:math';
|
||
|
||
import 'package:fl_chart/fl_chart.dart';
|
||
import 'package:flutter/material.dart';
|
||
import 'package:provider/provider.dart';
|
||
|
||
import '../../../models/consumer_series.dart';
|
||
import '../../../models/energy_data.dart';
|
||
import '../../../models/journee.dart';
|
||
import '../../../models/pv_forecast.dart';
|
||
import '../../../providers/load_config_provider.dart';
|
||
import '../../../providers/pv_forecast_provider.dart';
|
||
import '../../../services/nymea_service.dart';
|
||
import '../../../theme/etm_tokens.dart';
|
||
|
||
/// **Carte « Journée »** de l'accueil — variante E de la maquette, retenue par Patrick le
|
||
/// 2026-10-15 : par heure, deux colonnes « à la Victron ».
|
||
///
|
||
/// - à gauche, la **consommation**, décomposée par consommateur déclaré (borne, PAC, ECS…)
|
||
/// au-dessus de la « Maison », c'est-à-dire ce que les compteurs déclarés n'expliquent pas ;
|
||
/// - à droite, le **solaire** : autoconsommé (jaune doré) puis injecté (jaune pâle) pour les
|
||
/// heures écoulées, la prévision en contour pour les heures à venir.
|
||
///
|
||
/// Code couleur de Patrick : jaune = solaire, bleu = maison, vert = batterie.
|
||
///
|
||
/// Les icônes météo HEURE PAR HEURE n'existent pas encore côté box : demandées à openmeteo
|
||
/// (`consignes/CONSIGNE-openmeteo-meteo-horaire.md`, etm16). D'ici là, seule la météo de
|
||
/// maintenant est affichée — rien n'est deviné pour les autres heures.
|
||
class JourneeCard extends StatefulWidget {
|
||
const JourneeCard({super.key});
|
||
|
||
@override
|
||
State<JourneeCard> createState() => _JourneeCardState();
|
||
}
|
||
|
||
class _JourneeCardState extends State<JourneeCard> {
|
||
List<PowerBalanceEntry> _bilan = const [];
|
||
ConsumerHistory _conso = ConsumerHistory.vide;
|
||
List<String> _sansCompteur = const [];
|
||
DateTime? _luePour;
|
||
bool _lue = false;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
if (!mounted) return;
|
||
context.read<PvForecastProvider>().assurer();
|
||
final cfg = context.read<LoadConfigProvider>();
|
||
if (cfg.entries.isEmpty) {
|
||
cfg
|
||
..attach(context.read<NymeaService>())
|
||
..load();
|
||
}
|
||
});
|
||
}
|
||
|
||
/// La mesure suit la prévision : relue à chaque lecture du provider (12 min), sans second
|
||
/// minuteur — et quand la liste des charges arrive.
|
||
@override
|
||
void didChangeDependencies() {
|
||
super.didChangeDependencies();
|
||
final lue = context.watch<PvForecastProvider>().snapshot?.capturedAt;
|
||
final n = context.watch<LoadConfigProvider>().entries.length;
|
||
final cle = lue?.add(Duration(microseconds: n));
|
||
if (cle != null && cle != _luePour) {
|
||
_luePour = cle;
|
||
_lire();
|
||
}
|
||
}
|
||
|
||
Future<void> _lire() async {
|
||
final service = context.read<NymeaService>();
|
||
final cfg = context.read<LoadConfigProvider>();
|
||
final now = DateTime.now();
|
||
final debut = DateTime(now.year, now.month, now.day);
|
||
// Seules les charges qui déclarent un compteur ont une colonne : rien n'est deviné à
|
||
// partir d'un nom d'appareil. Les autres sont nommées sous la carte.
|
||
final appareils = [
|
||
for (final e in cfg.entries)
|
||
if (e.meterThingId.isNotEmpty)
|
||
(id: e.meterThingId, label: e.label, domaine: e.domain)
|
||
];
|
||
final sansCompteur = [
|
||
for (final e in cfg.entries)
|
||
if (e.meterThingId.isEmpty && e.adapter != 'evcharger') e.label
|
||
];
|
||
final r = await Future.wait<dynamic>([
|
||
service.fetchPowerBalanceLogs(
|
||
from: debut, to: now, sampleRate: 'SampleRate1Hour'),
|
||
service.fetchThingPowerLogs(
|
||
appareils: appareils, from: debut, to: now, sampleRate: 'SampleRate1Hour'),
|
||
]);
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_bilan = r[0] as List<PowerBalanceEntry>;
|
||
_conso = r[1] as ConsumerHistory;
|
||
_sansCompteur = sansCompteur;
|
||
_lue = true;
|
||
});
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final pv = context.watch<PvForecastProvider>();
|
||
final service = context.watch<NymeaService>();
|
||
final snap = pv.snapshot?.etat == PvForecastEtat.courbe ? pv.snapshot : null;
|
||
final plans = <String>[for (final f in snap?.perField ?? const []) f.id];
|
||
final fourchette = context.select<NymeaService, FourchetteMeteo?>((s) =>
|
||
plans.isEmpty ? null : FourchetteMeteo.depuisPlans(s.plansMeteo(plans)));
|
||
final maintenant = DateTime.now();
|
||
final heures = construireJournee(
|
||
jourLocal: maintenant,
|
||
snapshot: snap,
|
||
bilanHoraire: _bilan,
|
||
consoHoraire: _conso);
|
||
final etats = service.etatsParNom('openMeteoSite');
|
||
return JourneeView(
|
||
heures: heures,
|
||
series: _conso.series,
|
||
meteo: etats == null ? null : MeteoActuelle.depuisEtats(etats),
|
||
bande: snap == null
|
||
? null
|
||
: bandePrevision(snap, fourchette, maintenant: maintenant),
|
||
etatPrevision: pv.chargement && pv.snapshot == null
|
||
? null
|
||
: pv.snapshot?.etat ?? (pv.erreur != null ? null : PvForecastEtat.rienPublie),
|
||
previsionNonLue: pv.erreur != null && pv.snapshot == null,
|
||
sansCompteur: _sansCompteur,
|
||
sansHistorique: _conso.sansHistorique,
|
||
chargement: !_lue,
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Rendu pur de la carte — tout ce qu'il affiche lui est donné.
|
||
class JourneeView extends StatelessWidget {
|
||
final List<HeureJournee> heures;
|
||
final List<ConsumerSeries> series;
|
||
final MeteoActuelle? meteo;
|
||
final BandePrevision? bande;
|
||
final PvForecastEtat? etatPrevision;
|
||
final bool previsionNonLue;
|
||
final List<String> sansCompteur;
|
||
final List<String> sansHistorique;
|
||
final bool chargement;
|
||
|
||
const JourneeView({
|
||
super.key,
|
||
required this.heures,
|
||
this.series = const [],
|
||
this.meteo,
|
||
this.bande,
|
||
this.etatPrevision,
|
||
this.previsionNonLue = false,
|
||
this.sansCompteur = const [],
|
||
this.sansHistorique = const [],
|
||
this.chargement = false,
|
||
});
|
||
|
||
/// Couleur d'un consommateur : celle de son domaine, nuancée s'il y en a plusieurs —
|
||
/// la même règle que le graphe « Qui consomme » de l'écran Énergie.
|
||
static Map<String, Color> couleurs(BuildContext c, List<ConsumerSeries> series) {
|
||
final rangs = <String, int>{};
|
||
final out = <String, Color>{};
|
||
for (final s in series) {
|
||
final base = EtmTokens.domaineOf(c, s.domaine);
|
||
if (base == null) {
|
||
out[s.thingId] = EtmTokens.mutedOf(c);
|
||
continue;
|
||
}
|
||
final cle = s.domaine ?? '';
|
||
final rang = rangs[cle] ?? 0;
|
||
rangs[cle] = rang + 1;
|
||
out[s.thingId] = EtmTokens.nuance(base, rang);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final muted = EtmTokens.mutedOf(context);
|
||
final teintes = couleurs(context, series);
|
||
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('Journée', style: EtmTokens.sans(size: 16, weight: FontWeight.w600)),
|
||
Text('aujourd\'hui', style: EtmTokens.sans(size: 12, color: muted)),
|
||
]),
|
||
const SizedBox(height: 8),
|
||
_meteo(context),
|
||
const SizedBox(height: 10),
|
||
SizedBox(
|
||
height: 190,
|
||
child: chargement
|
||
? const Center(
|
||
child: SizedBox(
|
||
width: 22,
|
||
height: 22,
|
||
child: CircularProgressIndicator(strokeWidth: 2)))
|
||
: JourneeChart(heures: heures, series: series, teintes: teintes),
|
||
),
|
||
const SizedBox(height: 10),
|
||
Wrap(spacing: 12, runSpacing: 4, children: [
|
||
_Puce(EtmTokens.brand, 'Maison'),
|
||
for (final s in series) _Puce(teintes[s.thingId] ?? muted, s.label),
|
||
_Puce(EtmTokens.selfUse, 'Solaire autoconsommé'),
|
||
_Puce(EtmTokens.exportSolaire, 'Solaire injecté'),
|
||
_Puce(EtmTokens.solar, 'Prévision', contour: true),
|
||
]),
|
||
const SizedBox(height: 10),
|
||
_totaux(context),
|
||
..._notes(context),
|
||
]),
|
||
);
|
||
}
|
||
|
||
Widget _meteo(BuildContext context) {
|
||
final muted = EtmTokens.mutedOf(context);
|
||
final m = meteo;
|
||
final style = EtmTokens.sans(size: 12, color: muted);
|
||
if (m == null) {
|
||
return Text('Météo non publiée par la box', style: style);
|
||
}
|
||
String deg(double v) => '${v.toStringAsFixed(1).replaceAll('.', ',')} °C';
|
||
String hm(DateTime t) =>
|
||
'${t.hour.toString().padLeft(2, '0')}:${t.minute.toString().padLeft(2, '0')}';
|
||
return Wrap(spacing: 14, runSpacing: 4, crossAxisAlignment: WrapCrossAlignment.center, children: [
|
||
Row(mainAxisSize: MainAxisSize.min, children: [
|
||
Icon(iconeMeteo(m.condition), size: 18, color: EtmTokens.inkOf(context)),
|
||
const SizedBox(width: 6),
|
||
Text('${m.libelle}${m.temperature != null ? ' ${deg(m.temperature!)}' : ''}',
|
||
style: EtmTokens.sans(size: 12.5, weight: FontWeight.w500)),
|
||
]),
|
||
if (m.min != null && m.max != null)
|
||
Text('min ${deg(m.min!)} · max ${deg(m.max!)}', style: style),
|
||
if (m.lever != null && m.coucher != null)
|
||
Row(mainAxisSize: MainAxisSize.min, children: [
|
||
Icon(Icons.wb_twilight_rounded, size: 15, color: EtmTokens.solarOf(context)),
|
||
const SizedBox(width: 4),
|
||
Text('${hm(m.lever!)} – ${hm(m.coucher!)}', style: style),
|
||
]),
|
||
]);
|
||
}
|
||
|
||
/// Icône de l'état du ciel publié par le plugin (`weatherCondition`).
|
||
static IconData iconeMeteo(String c) => switch (c) {
|
||
'clear-day' => Icons.wb_sunny_rounded,
|
||
'clear-night' => Icons.nightlight_round,
|
||
'few-clouds-day' => Icons.wb_cloudy_outlined,
|
||
'few-clouds-night' => Icons.nights_stay_outlined,
|
||
'clouds' || 'overcast' => Icons.cloud_rounded,
|
||
'light-rain' || 'shower-rain' => Icons.grain_rounded,
|
||
'thunderstorm' => Icons.thunderstorm_rounded,
|
||
'snow' => Icons.ac_unit_rounded,
|
||
'fog' => Icons.foggy,
|
||
_ => Icons.help_outline_rounded,
|
||
};
|
||
|
||
Widget _totaux(BuildContext context) {
|
||
String kwh(double wh) => wh.abs() < 1000
|
||
? '${(wh / 1000).toStringAsFixed(2).replaceAll('.', ',')} kWh'
|
||
: '${(wh / 1000).toStringAsFixed(1).replaceAll('.', ',')} kWh';
|
||
final mes = heures.where((h) => h.mesuree).toList();
|
||
final lignes = <String>[];
|
||
if (mes.isNotEmpty) {
|
||
final prod = mes.fold(0.0, (a, h) => a + max(0.0, h.prodWh ?? 0));
|
||
final cons = mes.fold(0.0, (a, h) => a + (h.consWh ?? 0));
|
||
final imp = mes.fold(0.0, (a, h) => a + (h.importWh ?? 0));
|
||
lignes.add('Solaire ${kwh(prod)} · consommé ${kwh(cons)} dont réseau ${kwh(imp)}');
|
||
}
|
||
// Prévu sur la journée : seulement si CHAQUE heure en a une ; la fourchette, seulement
|
||
// si chaque créneau en a une aussi.
|
||
if (heures.isNotEmpty && heures.every((h) => h.prevuWh != null)) {
|
||
var l = 'Prévu aujourd\'hui ${kwh(heures.fold(0.0, (a, h) => a + h.prevuWh!))}';
|
||
final b = bande;
|
||
if (b != null) {
|
||
final debut = heures.first.debut.toUtc();
|
||
final fin = heures.last.debut.toUtc().add(const Duration(hours: 1));
|
||
final creneaux = b.parCreneau.keys
|
||
.where((t) => !t.isBefore(debut) && t.isBefore(fin))
|
||
.toList();
|
||
final attendus = fin.difference(debut).inHours;
|
||
if (creneaux.length >= attendus) {
|
||
final bas = creneaux.fold(0.0, (a, t) => a + b.parCreneau[t]!.basWh);
|
||
final haut = creneaux.fold(0.0, (a, t) => a + b.parCreneau[t]!.hautWh);
|
||
l += b.source == SourceBande.p10p90
|
||
? ' (p10–p90 ${kwh(bas)} – ${kwh(haut)})'
|
||
: ' (fourchette météo ${kwh(bas)} – ${kwh(haut)}'
|
||
'${b.attenuee ? ', non datée' : ''})';
|
||
}
|
||
}
|
||
lignes.add(l);
|
||
}
|
||
if (lignes.isEmpty) return const SizedBox.shrink();
|
||
return Text(lignes.join('\n'),
|
||
style: EtmTokens.mono(size: 11, color: EtmTokens.mutedOf(context)));
|
||
}
|
||
|
||
List<Widget> _notes(BuildContext context) {
|
||
final notes = <String>[
|
||
if (previsionNonLue)
|
||
'Prévision non lue : la box n\'a pas répondu.'
|
||
else
|
||
switch (etatPrevision) {
|
||
PvForecastEtat.siteManquant =>
|
||
'Pas de prévision : aucun site désigné — à choisir dans l\'écran Énergie.',
|
||
PvForecastEtat.chargeRefusee =>
|
||
'Pas de prévision : le producteur météo publie une courbe illisible.',
|
||
PvForecastEtat.faultInconnu => 'Pas de prévision : défaut publié par la box.',
|
||
PvForecastEtat.rienPublie => 'Aucune prévision publiée par la box.',
|
||
_ => '',
|
||
},
|
||
for (final l in sansCompteur)
|
||
'$l : aucun compteur déclaré, comptée dans « Maison ».',
|
||
for (final l in sansHistorique) '$l : pas d\'historique de puissance.',
|
||
if (heures.any((h) => (h.resteWh ?? 0) < -1))
|
||
'Les compteurs déclarés dépassent la consommation totale sur certaines heures.',
|
||
].where((n) => n.isNotEmpty).toList();
|
||
return [
|
||
for (final n in notes)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 4),
|
||
child: Text(n,
|
||
style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))),
|
||
),
|
||
];
|
||
}
|
||
}
|
||
|
||
/// Le graphe : deux colonnes par heure.
|
||
class JourneeChart extends StatelessWidget {
|
||
final List<HeureJournee> heures;
|
||
final List<ConsumerSeries> series;
|
||
final Map<String, Color> teintes;
|
||
|
||
const JourneeChart(
|
||
{super.key, required this.heures, required this.series, required this.teintes});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final solaire = EtmTokens.solarOf(context);
|
||
final label = EtmTokens.sans(size: 9, color: EtmTokens.mutedOf(context));
|
||
final haut = heures.fold<double>(
|
||
0,
|
||
(a, h) => [
|
||
a,
|
||
h.consWh ?? 0,
|
||
max(0.0, h.prodWh ?? 0),
|
||
h.prevuWh ?? 0,
|
||
].reduce(max));
|
||
final yMax = max(0.5, haut / 1000 * 1.15);
|
||
|
||
return LayoutBuilder(builder: (context, c) {
|
||
// Deux colonnes par heure, et un vide entre les heures pour qu'on lise les paires.
|
||
final groupe = max(4.0, (c.maxWidth - 40) / max(1, heures.length));
|
||
final largeur = max(1.5, groupe * 0.36);
|
||
return BarChart(
|
||
BarChartData(
|
||
maxY: yMax,
|
||
minY: 0,
|
||
alignment: BarChartAlignment.spaceAround,
|
||
borderData: FlBorderData(show: false),
|
||
barTouchData: BarTouchData(enabled: false),
|
||
gridData: FlGridData(
|
||
show: true,
|
||
drawVerticalLine: false,
|
||
horizontalInterval: yMax / 4,
|
||
getDrawingHorizontalLine: (_) =>
|
||
FlLine(color: EtmTokens.lineOf(context), strokeWidth: 1),
|
||
),
|
||
titlesData: FlTitlesData(
|
||
topTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||
rightTitles: const AxisTitles(sideTitles: SideTitles(showTitles: false)),
|
||
leftTitles: AxisTitles(
|
||
sideTitles: SideTitles(
|
||
showTitles: true,
|
||
reservedSize: 36,
|
||
interval: yMax / 4,
|
||
getTitlesWidget: (v, meta) => v == meta.max
|
||
? const SizedBox.shrink()
|
||
: SideTitleWidget(
|
||
meta: meta,
|
||
child: Text(v.toStringAsFixed(1), style: label)),
|
||
),
|
||
),
|
||
bottomTitles: AxisTitles(
|
||
sideTitles: SideTitles(
|
||
showTitles: true,
|
||
reservedSize: 20,
|
||
getTitlesWidget: (v, meta) {
|
||
final i = v.toInt();
|
||
if (i < 0 || i >= heures.length) return const SizedBox.shrink();
|
||
final t = heures[i].debut;
|
||
if (t.hour % 6 != 0) return const SizedBox.shrink();
|
||
return SideTitleWidget(
|
||
meta: meta, child: Text('${t.hour}h', style: label));
|
||
},
|
||
),
|
||
),
|
||
),
|
||
barGroups: [
|
||
for (var i = 0; i < heures.length; i++)
|
||
BarChartGroupData(x: i, barsSpace: 1, barRods: [
|
||
_conso(context, heures[i], largeur),
|
||
_solaire(heures[i], largeur, solaire),
|
||
]),
|
||
],
|
||
),
|
||
);
|
||
});
|
||
}
|
||
|
||
/// Colonne de gauche : la Maison en bas, puis chaque consommateur déclaré.
|
||
BarChartRodData _conso(BuildContext context, HeureJournee h, double w) {
|
||
if (!h.mesuree) {
|
||
return BarChartRodData(toY: 0, width: w, color: Colors.transparent);
|
||
}
|
||
final parts = <(double, Color)>[
|
||
(max(0.0, h.resteWh ?? 0), EtmTokens.brand),
|
||
for (final s in series)
|
||
if ((h.parConsommateur[s.thingId] ?? 0) > 0)
|
||
(h.parConsommateur[s.thingId]!, teintes[s.thingId] ?? EtmTokens.mutedOf(context)),
|
||
];
|
||
final items = <BarChartRodStackItem>[];
|
||
var acc = 0.0;
|
||
for (final (wh, couleur) in parts) {
|
||
if (wh <= 0) continue;
|
||
items.add(BarChartRodStackItem(acc / 1000, (acc + wh) / 1000, couleur));
|
||
acc += wh;
|
||
}
|
||
return BarChartRodData(
|
||
toY: acc / 1000,
|
||
width: w,
|
||
color: Colors.transparent,
|
||
rodStackItems: items,
|
||
borderRadius: BorderRadius.zero,
|
||
);
|
||
}
|
||
|
||
/// Colonne de droite : le solaire mesuré (doré + pâle), ou la prévision en contour.
|
||
BarChartRodData _solaire(HeureJournee h, double w, Color solaire) {
|
||
final prevu = h.prevuWh;
|
||
if (h.mesuree) {
|
||
final auto = h.autoWh ?? 0;
|
||
final exp = h.exportWh ?? 0;
|
||
return BarChartRodData(
|
||
toY: (auto + exp) / 1000,
|
||
width: w,
|
||
color: Colors.transparent,
|
||
borderRadius: BorderRadius.zero,
|
||
rodStackItems: [
|
||
if (auto > 0) BarChartRodStackItem(0, auto / 1000, EtmTokens.selfUse),
|
||
if (exp > 0)
|
||
BarChartRodStackItem(auto / 1000, (auto + exp) / 1000, EtmTokens.exportSolaire),
|
||
],
|
||
// Le niveau qui était prévu, en fond pâle : comparer sans cacher la mesure.
|
||
backDrawRodData: BackgroundBarChartRodData(
|
||
show: prevu != null && prevu > 0,
|
||
toY: (prevu ?? 0) / 1000,
|
||
color: solaire.withValues(alpha: 0.18),
|
||
),
|
||
);
|
||
}
|
||
if (prevu == null || prevu <= 0) {
|
||
return BarChartRodData(toY: 0, width: w, color: Colors.transparent);
|
||
}
|
||
return BarChartRodData(
|
||
toY: prevu / 1000,
|
||
width: w,
|
||
color: solaire.withValues(alpha: 0.30),
|
||
borderRadius: BorderRadius.zero,
|
||
borderSide: BorderSide(color: solaire, width: 1),
|
||
);
|
||
}
|
||
}
|
||
|
||
class _Puce extends StatelessWidget {
|
||
final Color couleur;
|
||
final String texte;
|
||
final bool contour;
|
||
const _Puce(this.couleur, this.texte, {this.contour = false});
|
||
|
||
@override
|
||
Widget build(BuildContext context) => Row(mainAxisSize: MainAxisSize.min, children: [
|
||
Container(
|
||
width: 10,
|
||
height: 10,
|
||
decoration: BoxDecoration(
|
||
color: contour ? couleur.withValues(alpha: 0.30) : couleur,
|
||
border: contour ? Border.all(color: couleur) : null,
|
||
borderRadius: BorderRadius.circular(2),
|
||
),
|
||
),
|
||
const SizedBox(width: 5),
|
||
Text(texte, style: EtmTokens.sans(size: 11, color: EtmTokens.mutedOf(context))),
|
||
]);
|
||
}
|