`batteryLevelConsideration` ne gouverne aujourd'hui que la recharge du véhicule. Une fois la règle montée dans le budget, il gouvernera TOUTE l'installation : sous le seuil le budget de surplus est annulé — pas réduit — donc ni ECS, ni PAC, ni bornes. Le défaut d'usine est 0,9. Le banc `.75` est exactement dans ce cas : seuil 0,9, batterie à 50 %. Le symptôme chez un client serait le pire qui soit — un HEMS qui ne pilote plus rien par grand soleil, sans erreur, sans panne, rien à lire dans un journal. Un paramètre qui peut produire ça n'est pas un paramètre d'usine. - `BatteryReserveCard` lit et écrit le réglage, et ALERTE quand le seuil dépasse le SOC réellement atteint — le cas silencieux. Elle n'invente aucune valeur par défaut : si la box ne l'expose pas, la carte se tait plutôt que d'afficher un curseur mort. - La portée future est dite sur la carte : régler ce seuil « pour la voiture » couperait le reste demain. - Clé ARB `decisionBatteryReserve` posée, et `kCodesReserveBatterie` volontairement VIDE côté `telemetry_text` : le crochet attend le code que publiera le moteur, on ne devine pas son nom. Vérifié sur appareil contre `.75` : réglage lu (0.9 / SOC 50 %), carte affichée, campagne verte. L'assertion des cartes de charge fait désormais DÉFILER avant de compter — dans un `ListView` ce qui n'est pas à l'écran n'est pas construit, et chercher sans défiler ne prouvait que la hauteur de l'écran. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
709 lines
26 KiB
Dart
709 lines
26 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../../models/load_config_entry.dart';
|
|
import '../../models/nymea_models.dart';
|
|
import '../../providers/energy_setup_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';
|
|
import '../../widgets/battery_reserve_card.dart';
|
|
import '../../widgets/controllable_loads_section.dart';
|
|
import '../../widgets/schema_gated.dart';
|
|
import 'load_mechanism_screen.dart';
|
|
|
|
/// **Rôles & appareils** — l'unique écran de configuration des charges.
|
|
///
|
|
/// ## Fusion : l'ancien « Ordre de service » a disparu ici
|
|
///
|
|
/// Deux écrans décrivaient la même chose par deux chemins différents. Celui-ci portait la
|
|
/// présentation ; l'autre portait le **chemin de données correct**. C'est donc le chemin
|
|
/// de données qui a migré, jamais l'inverse — le modèle des rôles ne survit pas comme
|
|
/// source de vérité pour les charges.
|
|
///
|
|
/// La raison est de fond, pas d'hygiène. `EmsRole` fige six rôles, dont `dhw` et
|
|
/// `heatPump` **séparés**. Or LM-201 — *un canal de commande, une charge* — dit qu'une PAC
|
|
/// air/eau sans ballon séparé est **une seule** charge : le SG-Ready commande la machine
|
|
/// entière. Le modèle des rôles aurait exigé d'en déclarer deux pour une seule machine,
|
|
/// c'est-à-dire deux charges se disputant un canal unique — ce que `validateSet()` refuse
|
|
/// côté box, et à raison.
|
|
///
|
|
/// **Ce qui reste sur `EnergySetupProvider`** : les compteurs, « à configurer » et « sans
|
|
/// appareil ». Ce sont des rôles d'inférence sur les Things, et le compteur principal
|
|
/// relève d'un autre contrat (`Energy.SetRootMeter`). Seules les **charges** viennent
|
|
/// désormais de `LoadConfigProvider`, donc de `GetLoadConfig`.
|
|
class RolesDevicesScreen extends StatefulWidget {
|
|
const RolesDevicesScreen({super.key});
|
|
|
|
@override
|
|
State<RolesDevicesScreen> createState() => _RolesDevicesScreenState();
|
|
}
|
|
|
|
class _RolesDevicesScreenState extends State<RolesDevicesScreen> {
|
|
NymeaService? _svc;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
if (!mounted) return;
|
|
final svc = context.read<NymeaService>();
|
|
_svc = svc;
|
|
context.read<EnergySetupProvider>()
|
|
..attach(svc)
|
|
// Le compteur racine vit sur la BOX : le relire, sinon il se présente comme
|
|
// perdu à chaque démarrage.
|
|
..syncRootMeter(svc);
|
|
|
|
// Chemin de données des CHARGES : la configuration de la box, et rien d'autre.
|
|
context.read<LoadConfigProvider>()..attach(svc)..load();
|
|
context.read<LoadTelemetryProvider>()..attach(svc)..load();
|
|
// Le schéma décide de ce qui s'active ou se grise, ici comme dans les écrans de
|
|
// mécanisme. Lu une fois : il ne change pas tant que la box ne redémarre pas.
|
|
context.read<SchemaProvider>()..attach(svc)..load();
|
|
|
|
svc.addListener(_sync); // re-inférer quand les things se chargent
|
|
_sync();
|
|
});
|
|
}
|
|
|
|
/// Ouvre le détail du mécanisme. Il n'y a **pas** de formulaire unique : les trois
|
|
/// contrats (routeur de relais, charge continue, SG-Ready) ne se ressemblent pas, et
|
|
/// l'écran de détail choisit lui-même sa forme selon `adapter`.
|
|
void _openMechanism(BuildContext context, LoadConfigEntry entry) {
|
|
Navigator.of(context).push(MaterialPageRoute(
|
|
builder: (_) => LoadMechanismScreen(loadId: entry.id),
|
|
));
|
|
}
|
|
|
|
void _sync() {
|
|
if (!mounted) return;
|
|
context.read<EnergySetupProvider>().syncFromThings(context.read<NymeaService>());
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_svc?.removeListener(_sync);
|
|
super.dispose();
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final setup = context.watch<EnergySetupProvider>();
|
|
final svc = context.watch<NymeaService>();
|
|
|
|
final meters = setup.meterRoles;
|
|
final todo = setup.toConfigureRoles;
|
|
final absent = setup.absentRoles;
|
|
|
|
return Scaffold(
|
|
backgroundColor: EtmTokens.bgOf(context),
|
|
appBar: AppBar(
|
|
title: const Text('Rôles & appareils'),
|
|
backgroundColor: EtmTokens.surfaceOf(context),
|
|
foregroundColor: EtmTokens.inkOf(context),
|
|
elevation: 0,
|
|
),
|
|
body: ListView(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 32),
|
|
children: [
|
|
_CounterHeader(setup),
|
|
|
|
if (setup.screenState == SetupScreenState.blank) ...[
|
|
const SizedBox(height: 16),
|
|
const _BlankInvite(),
|
|
],
|
|
|
|
// ── Compteurs ────────────────────────────────────────────────────
|
|
if (meters.isNotEmpty) ...[
|
|
const SizedBox(height: 20),
|
|
const _SectionHeader('Compteurs', subtitle: 'Mesure — hors priorité'),
|
|
const SizedBox(height: 10),
|
|
for (final role in meters) ...[
|
|
_MeterCard(role: role, setup: setup, service: svc),
|
|
const SizedBox(height: 10),
|
|
],
|
|
],
|
|
|
|
// ── Charges pilotables — depuis GetLoadConfig, pas depuis les rôles ──
|
|
const SizedBox(height: 10),
|
|
const _SectionHeader('Charges pilotables',
|
|
subtitle: 'Glissez pour l\'ordre de service du surplus'),
|
|
const SizedBox(height: 10),
|
|
SchemaVersionBanner(
|
|
versionLine: context.watch<SchemaProvider>().versionLine(),
|
|
schemaLoaded: context.watch<SchemaProvider>().schema.loaded,
|
|
),
|
|
const SizedBox(height: 12),
|
|
// AVANT les charges : un seuil qui peut toutes les geler doit se lire avant
|
|
// elles, pas après.
|
|
const BatteryReserveCard(),
|
|
ControllableLoadsSection(onConfigure: _openMechanism),
|
|
const _Legend(),
|
|
|
|
// ── À configurer ─────────────────────────────────────────────────
|
|
if (todo.isNotEmpty) ...[
|
|
const SizedBox(height: 18),
|
|
const _SectionHeader('À configurer'),
|
|
const SizedBox(height: 10),
|
|
for (final role in todo) ...[
|
|
_TodoCard(role: role, setup: setup, service: svc),
|
|
const SizedBox(height: 10),
|
|
],
|
|
],
|
|
|
|
// ── Sans appareil (actés absents) ────────────────────────────────
|
|
if (absent.isNotEmpty) ...[
|
|
const SizedBox(height: 18),
|
|
const _SectionHeader('Sans appareil'),
|
|
const SizedBox(height: 10),
|
|
for (final role in absent) ...[
|
|
_AbsentCard(role: role, setup: setup),
|
|
const SizedBox(height: 10),
|
|
],
|
|
],
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// En-tête compteur X/6 + état d'écran
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
class _CounterHeader extends StatelessWidget {
|
|
final EnergySetupProvider setup;
|
|
const _CounterHeader(this.setup);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final detected = EmsRole.values.where(setup.isDetected).length;
|
|
final remaining = setup.toConfigureRoles.length;
|
|
final complete = setup.screenState == SetupScreenState.complete;
|
|
|
|
final parts = <String>[
|
|
if (detected > 0) '$detected détecté${detected > 1 ? 's' : ''} automatiquement',
|
|
if (remaining > 0)
|
|
'$remaining reste${remaining > 1 ? 'nt' : ''} à configurer'
|
|
else
|
|
'tout est assigné',
|
|
];
|
|
|
|
return Container(
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: EtmTokens.surfaceOf(context),
|
|
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
|
|
boxShadow: EtmTokens.cardShadowOf(context),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
Container(
|
|
width: 40,
|
|
height: 40,
|
|
decoration: BoxDecoration(
|
|
color: (complete ? EtmTokens.eco : EtmTokens.brand)
|
|
.withValues(alpha: 0.12),
|
|
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
|
|
),
|
|
child: Icon(
|
|
complete ? Icons.check_rounded : Icons.tune_rounded,
|
|
color: complete ? EtmTokens.eco : EtmTokens.brand,
|
|
size: 22,
|
|
),
|
|
),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(
|
|
'${setup.configuredCount} / ${setup.totalRoles} rôles configurés',
|
|
style: EtmTokens.sans(
|
|
size: 15,
|
|
weight: FontWeight.w700,
|
|
color: EtmTokens.inkOf(context),
|
|
),
|
|
),
|
|
const SizedBox(height: 2),
|
|
Text(
|
|
parts.join(' · '),
|
|
style: EtmTokens.sans(
|
|
size: 12, color: EtmTokens.mutedOf(context)),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _BlankInvite extends StatelessWidget {
|
|
const _BlankInvite();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.all(18),
|
|
decoration: BoxDecoration(
|
|
color: EtmTokens.brand.withValues(alpha: 0.07),
|
|
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
|
|
border: Border.all(color: EtmTokens.brand.withValues(alpha: 0.18)),
|
|
),
|
|
child: Row(
|
|
children: [
|
|
const Icon(Icons.bolt_rounded, color: EtmTokens.brand),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Text(
|
|
'Commencez par assigner vos compteurs et charges pilotables. '
|
|
'Le solaire et la batterie sont détectés automatiquement.',
|
|
style: EtmTokens.sans(
|
|
size: 13, color: EtmTokens.inkOf(context), height: 1.35),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Cartes
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
class _MeterCard extends StatelessWidget {
|
|
final EmsRole role;
|
|
final EnergySetupProvider setup;
|
|
final NymeaService service;
|
|
const _MeterCard(
|
|
{required this.role, required this.setup, required this.service});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final thing = _thingById(service, setup.thingIdOf(role));
|
|
final sub = thing == null
|
|
? '—'
|
|
: [thing.name, _thingClassName(service, thing)]
|
|
.where((s) => s.isNotEmpty)
|
|
.join(' · ');
|
|
|
|
return _CardShell(
|
|
child: Row(
|
|
children: [
|
|
_Emoji(role.icon),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(role.label,
|
|
style: EtmTokens.sans(
|
|
size: 14.5,
|
|
weight: FontWeight.w700,
|
|
color: EtmTokens.inkOf(context))),
|
|
const SizedBox(height: 2),
|
|
Text(sub,
|
|
style: EtmTokens.sans(
|
|
size: 12, color: EtmTokens.mutedOf(context))),
|
|
],
|
|
),
|
|
),
|
|
if (role == EmsRole.gridMeter)
|
|
_Badge('rootMeter', EtmTokens.brand)
|
|
else if (setup.isDetected(role))
|
|
_Badge('auto · lecture seule', EtmTokens.eco),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// _LoadCard a été retiré à la fusion : il affichait un EmsRole, c'est-à-dire un rôle
|
|
// INFÉRÉ des Things, là où une charge est désormais lue dans `GetLoadConfig`. Son
|
|
// remplaçant est `LoadConfigCard` (widgets/controllable_loads_section.dart), qui porte en
|
|
// plus la télémétrie, le renommage, l'activation et l'accès au mécanisme.
|
|
|
|
class _TodoCard extends StatelessWidget {
|
|
final EmsRole role;
|
|
final EnergySetupProvider setup;
|
|
final NymeaService service;
|
|
const _TodoCard(
|
|
{required this.role, required this.setup, required this.service});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return _CardShell(
|
|
dashed: true,
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(
|
|
children: [
|
|
_Emoji(role.icon, faded: true),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(role.label,
|
|
style: EtmTokens.sans(
|
|
size: 14.5,
|
|
weight: FontWeight.w700,
|
|
color: EtmTokens.inkOf(context))),
|
|
const SizedBox(height: 2),
|
|
Text('Aucun appareil configuré',
|
|
style: EtmTokens.sans(
|
|
size: 12, color: EtmTokens.mutedOf(context))),
|
|
],
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 12),
|
|
Row(
|
|
children: [
|
|
Expanded(
|
|
child: _Btn(
|
|
label: '+ Configurer',
|
|
primary: true,
|
|
onTap: () => _showThingPicker(context, role, setup, service),
|
|
),
|
|
),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: _Btn(
|
|
label: 'Pas d\'appareil',
|
|
onTap: () => setup.markAbsent(role),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _AbsentCard extends StatelessWidget {
|
|
final EmsRole role;
|
|
final EnergySetupProvider setup;
|
|
const _AbsentCard({required this.role, required this.setup});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return _CardShell(
|
|
child: Row(
|
|
children: [
|
|
_Emoji(role.icon, faded: true),
|
|
const SizedBox(width: 12),
|
|
Expanded(
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text(role.label,
|
|
style: EtmTokens.sans(
|
|
size: 14.5,
|
|
weight: FontWeight.w700,
|
|
color: EtmTokens.mutedOf(context))),
|
|
const SizedBox(height: 2),
|
|
Text('Pas d\'appareil',
|
|
style: EtmTokens.sans(
|
|
size: 12, color: EtmTokens.faintOf(context))),
|
|
],
|
|
),
|
|
),
|
|
_Btn(
|
|
label: 'Reconfigurer',
|
|
onTap: () => setup.unconfigure(role),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Sélecteur d'appareil (assign)
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
void _showThingPicker(BuildContext context, EmsRole role,
|
|
EnergySetupProvider setup, NymeaService service) {
|
|
final matches = setup.compatibleThings(role, service);
|
|
showModalBottomSheet<void>(
|
|
context: context,
|
|
backgroundColor: EtmTokens.surfaceOf(context),
|
|
shape: const RoundedRectangleBorder(
|
|
borderRadius: BorderRadius.vertical(top: Radius.circular(22)),
|
|
),
|
|
builder: (sheetCtx) {
|
|
return SafeArea(
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(16, 16, 16, 16),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Text('Assigner — ${role.label}',
|
|
style: EtmTokens.sans(
|
|
size: 16,
|
|
weight: FontWeight.w700,
|
|
color: EtmTokens.inkOf(sheetCtx))),
|
|
const SizedBox(height: 4),
|
|
Text('Appareils compatibles détectés',
|
|
style: EtmTokens.sans(
|
|
size: 12, color: EtmTokens.mutedOf(sheetCtx))),
|
|
const SizedBox(height: 12),
|
|
if (matches.isEmpty)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 18),
|
|
child: Text('Aucun appareil compatible.',
|
|
style: EtmTokens.sans(
|
|
size: 13, color: EtmTokens.mutedOf(sheetCtx))),
|
|
)
|
|
else
|
|
...matches.map((t) => ListTile(
|
|
contentPadding: EdgeInsets.zero,
|
|
leading: _Emoji(role.icon),
|
|
title: Text(t.name,
|
|
style: EtmTokens.sans(
|
|
size: 14,
|
|
weight: FontWeight.w600,
|
|
color: EtmTokens.inkOf(sheetCtx))),
|
|
subtitle: Text(_thingClassName(service, t),
|
|
style: EtmTokens.sans(
|
|
size: 12, color: EtmTokens.mutedOf(sheetCtx))),
|
|
onTap: () {
|
|
setup.assignThing(role, t.id);
|
|
Navigator.of(sheetCtx).pop();
|
|
},
|
|
)),
|
|
const SizedBox(height: 8),
|
|
Align(
|
|
alignment: Alignment.centerRight,
|
|
child: _Btn(
|
|
label: 'Pas d\'appareil',
|
|
onTap: () {
|
|
setup.markAbsent(role);
|
|
Navigator.of(sheetCtx).pop();
|
|
},
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Petits composants
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
class _SectionHeader extends StatelessWidget {
|
|
final String title;
|
|
final String? subtitle;
|
|
const _SectionHeader(this.title, {this.subtitle});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(
|
|
crossAxisAlignment: CrossAxisAlignment.baseline,
|
|
textBaseline: TextBaseline.alphabetic,
|
|
children: [
|
|
Text(title,
|
|
style: EtmTokens.sans(
|
|
size: 13.5,
|
|
weight: FontWeight.w700,
|
|
color: EtmTokens.inkOf(context))),
|
|
if (subtitle != null) ...[
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(subtitle!,
|
|
style: EtmTokens.sans(
|
|
size: 11, color: EtmTokens.faintOf(context))),
|
|
),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Legend extends StatelessWidget {
|
|
const _Legend();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
margin: const EdgeInsets.only(top: 2),
|
|
padding: const EdgeInsets.all(12),
|
|
decoration: BoxDecoration(
|
|
color: EtmTokens.solar.withValues(alpha: 0.10),
|
|
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
|
|
),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
const Icon(Icons.info_outline_rounded,
|
|
size: 16, color: EtmTokens.heat),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: RichText(
|
|
text: TextSpan(
|
|
style: EtmTokens.sans(
|
|
size: 11.5, color: EtmTokens.mutedOf(context), height: 1.35),
|
|
children: [
|
|
TextSpan(
|
|
text: 'Ordre indicatif. ',
|
|
style: EtmTokens.sans(
|
|
size: 11.5,
|
|
weight: FontWeight.w700,
|
|
color: EtmTokens.inkOf(context))),
|
|
// La phrase qui suivait annonçait que « le pilote peut bousculer
|
|
// l'ordre les jours Tempo rouge ou à l'approche d'une échéance ».
|
|
// AUCUN de ces deux comportements n'existe dans le moteur : l'ordre
|
|
// de service est celui que l'installateur pose, point. Promettre une
|
|
// intelligence absente est pire que de ne rien dire — l'installateur
|
|
// aurait attribué au pilote un arbitrage qu'il aurait dû configurer.
|
|
const TextSpan(
|
|
text: 'Il fixe le service du surplus : chaque charge se sert '
|
|
'dans cet ordre, sur ce que la précédente a laissé.'),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _CardShell extends StatelessWidget {
|
|
final Widget child;
|
|
final bool dashed;
|
|
const _CardShell({required this.child, this.dashed = false});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(14),
|
|
decoration: BoxDecoration(
|
|
color: dashed
|
|
? EtmTokens.surfaceOf(context).withValues(alpha: 0.6)
|
|
: EtmTokens.surfaceOf(context),
|
|
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
|
|
boxShadow: dashed ? null : EtmTokens.cardShadowOf(context),
|
|
border: dashed
|
|
? Border.all(color: EtmTokens.lineOf(context))
|
|
: null,
|
|
),
|
|
child: child,
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Emoji extends StatelessWidget {
|
|
final String emoji;
|
|
final bool faded;
|
|
const _Emoji(this.emoji, {this.faded = false});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Opacity(
|
|
opacity: faded ? 0.55 : 1,
|
|
child: Container(
|
|
width: 38,
|
|
height: 38,
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: EtmTokens.bg2Of(context),
|
|
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
|
|
),
|
|
child: Text(emoji, style: const TextStyle(fontSize: 18)),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Badge extends StatelessWidget {
|
|
final String text;
|
|
final Color color;
|
|
const _Badge(this.text, this.color);
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 9, vertical: 4),
|
|
decoration: BoxDecoration(
|
|
color: color.withValues(alpha: 0.12),
|
|
borderRadius: BorderRadius.circular(EtmTokens.radiusPill),
|
|
),
|
|
child: Text(text,
|
|
style: EtmTokens.sans(
|
|
size: 11, weight: FontWeight.w600, color: color)),
|
|
);
|
|
}
|
|
}
|
|
|
|
// _Chip est parti avec _LoadCard : il n'affichait que le résumé d'un EmsRole.
|
|
|
|
class _Btn extends StatelessWidget {
|
|
final String label;
|
|
final bool primary;
|
|
final VoidCallback onTap;
|
|
const _Btn({required this.label, required this.onTap, this.primary = false});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return GestureDetector(
|
|
onTap: onTap,
|
|
behavior: HitTestBehavior.opaque,
|
|
child: Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 10),
|
|
alignment: Alignment.center,
|
|
decoration: BoxDecoration(
|
|
color: primary ? EtmTokens.brand : Colors.transparent,
|
|
borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl),
|
|
border: primary
|
|
? null
|
|
: Border.all(color: EtmTokens.lineStrongOf(context)),
|
|
),
|
|
child: Text(label,
|
|
style: EtmTokens.sans(
|
|
size: 13,
|
|
weight: FontWeight.w600,
|
|
color: primary ? Colors.white : EtmTokens.mutedOf(context))),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
// Résolution d'affichage des things
|
|
// ═══════════════════════════════════════════════════════════════════════════
|
|
|
|
NymeaThing? _thingById(NymeaService service, String? id) {
|
|
if (id == null) return null;
|
|
for (final t in service.things) {
|
|
if (t.id == id) return t;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
String _thingClassName(NymeaService service, NymeaThing thing) {
|
|
try {
|
|
return service.thingClasses
|
|
.firstWhere((c) => c.id == thing.thingClassId)
|
|
.displayName;
|
|
} catch (_) {
|
|
return '';
|
|
}
|
|
}
|