etm-powersync-app/lib/screens/energy/roles_devices_screen.dart
Patrick Schurig 438f261de7 feat(config): quitter avec un brouillon demande, et « Placer en dernier » rejoint « Renommer »
Deux retours d'usage, tous deux sur la même carte.

1. « Placer en dernier » poussait « Configurer » hors de sa place. Il rejoint « Renommer »
   sur une ligne à part : ce sont les deux gestes qui ne touchent pas au mécanisme, et un
   bouton d'action principale ne se fait pas déplacer par un geste secondaire.

2. Quitter l'écran avec des modifications non enregistrées ne demandait rien — et ce
   silence cachait pire qu'une perte : le brouillon vit dans LoadConfigProvider, pas dans
   l'écran, donc il SURVIT à la navigation. On repartait en laissant une configuration
   modifiée invisible, qui réapparaissait au retour sans contexte, ou disparaissait à la
   première relecture de la box (LoadConfigChanged, reconnexion) sans que personne ait
   choisi. Les deux issues sont mauvaises pour la même raison : l'utilisateur ne sait pas
   ce qu'il laisse derrière lui.

GardeBrouillon (PopScope) s'interpose sur les DEUX écrans qui partagent ce brouillon — la
liste des charges et l'écran de mécanisme. L'alerte dit l'état de la BOX, pas seulement
celui de l'écran : « vos changements n'ont pas été envoyés, elle porte toujours la
configuration d'avant ». Deux issues, toutes deux explicites : rester, ou quitter en
abandonnant — un brouillon fantôme qui ressort trois écrans plus loin est pire qu'une
modification perdue qu'on a accepté de perdre.

Aucune écriture n'est proposée depuis la boîte de dialogue : enregistrer depuis une sortie
ferait partir une configuration que personne n'a relue.

Quatre tests, dont le contrôle négatif — sans brouillon, on sort sans être interrogé. Une
alerte qui se déclenche pour rien apprend à passer outre sans lire.

235 tests (+4), flutter analyze inchangé à 27 remarques.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SQbZKrWqsMFP1Lh2jjjd9f
2026-08-29 12:02:04 +02:00

713 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/garde_brouillon.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;
// Le brouillon de configuration des charges survit à la navigation : sans ce garde,
// on quitte l'écran en laissant derrière soi une modification invisible.
return GardeBrouillon(
child: 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 '';
}
}