FUSION. L'écran « Ordre de service » est supprimé ; sa route redirige. C'est son CHEMIN DE DONNÉES (LoadConfigProvider) qui a migré dans la présentation de « Rôles & appareils », jamais l'inverse. EmsRole fige six rôles dont dhw ET heatPump séparés — or LM-201 dit qu'une PAC sans ballon séparé est UNE charge : le modèle des rôles aurait exigé deux charges sur un canal unique, ce que validateSet() refuse à raison. Les charges viennent donc de GetLoadConfig. Les compteurs restent sur EnergySetupProvider (autre contrat, SetRootMeter). La note « le pilote peut bousculer l'ordre les jours Tempo rouge ou à l'approche d'une échéance » est retirée : aucun de ces comportements n'existe dans le moteur. ANNONCE AVANT ÉCRITURE. Miroir exact de EnergyArbitrator::sameHardware(). Le piège, vérifié sur .75 : la comparaison des relais est INDEXÉE, donc réordonner relays[] sans rien changer d'autre reconstruit l'adaptateur — contacts ouverts (applySafeState, force) et verrous réarmés à froid. Un glisser-déposer dans la liste des contacteurs est un geste matériel. L'écran le nomme, avec la durée de réarmement. GRISAGE PAR INTROSPECTION. RpcSchema lit JSONRPC.Introspect à la connexion ; trois états et non deux — écrivable, r: (mesure, pas fonction manquante), absent — plus « inconnu » quand le schéma n'a pas pu être lu. Chaque champ grisé porte sa raison et le nom du champ manquant. La version affichée est celle de l'API d'expérience (NymeaEnergy 0.8) : le paquet +etm15 n'est exposé par aucune méthode. CORRIGÉ AU PASSAGE — les modes de recharge étaient INOPÉRANTS. setChargingInfo() appelait EnergyPlugin.SetChargingInfo (namespace inexistant) avec mode/targetSoc/endTime/minCurrent au lieu de chargingMode/targetPercentage/endDateTime, et minCurrent n'existe dans aucun champ. Vérifié par appel réel : « Missing required key: chargingMode ». Le refus était de plus avalé et l'état local mis à jour quand même. Désormais l'état ne suit que si la box accepte, et le refus remonte sans en inventer la cause. ZONES — constaté, pas corrigé. L'hypothèse « champs grisés » est fausse : AirConditioning publie 8 méthodes toutes en écriture. ac_screen.dart n'en appelle aucune, ses 4 pièces sont des constantes de maquette, et .75 déclare zéro zone. getZones() ajouté + bandeau qui lit le vrai compte et annonce la maquette. Le câblage est un lot en soi, non exerçable sans zone. Tests 80/80, analyze 0 erreur. Relevé : docs/RELEVE_LOTC.md — dont §6, ce qui n'a PAS pu être vérifié : l'IU n'a pas pu être pilotée sur l'appareil, MIUI refusant INJECT_EVENTS. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
701 lines
25 KiB
Dart
701 lines
25 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/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);
|
|
|
|
// 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),
|
|
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 '';
|
|
}
|
|
}
|