etm-powersync-app/lib/screens/energy/roles_devices_screen.dart
Patrick Schurig 801ba15094 feat(3g): la réserve batterie sort de l'usine — un seuil qui peut tout geler doit se voir
`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
2026-08-26 12:01:30 +02:00

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 '';
}
}