Lot B côté app, partie NON bloquée par le lot plugin (voir §Limites).
§1 Locale (indépendant) — JSONRPC.Hello portait {} : l'app était en en_US de
fait alors que nymea gère la locale PAR CONNEXION (o:locale). Envoi de la
locale à chaque Hello, donc aussi après reconnexion subie (m_clientLocales
repart au défaut de la box à chaque socket). Locale demandée ET retenue
journalisées : un .qm introuvable rend exactement comme une absence de
traduction, sans cette trace un catalogue mal packagé passe inaperçu.
§2 i18n — flutter_localizations + intl + ARB (français seul). Aucune chaîne
en dur dans le nouvel écran. Les codes reçus de la box (domaine, mécanisme)
sont des clés ARB avec repli explicite sur code brut si l'app ne les connaît
pas : une box en avance sur l'app est un cas normal en parc déployé.
§3 Modèle — LoadConfigEntry est une VUE en lecture au-dessus de la map brute ;
l'écriture repart de la map d'origine (patched()). Pas de couche de filtrage
préventive : l'aller-retour neutre passe contre .75, donc l'écho verbatim est
sûr par construction (§7-1).
§4 Priorité à deux niveaux — groupByDomain / flattenToPayload. Le rang de
domaine se déduit de l'ordre d'apparition à plat ; la box ne sait rien des
groupes. Config non contiguë : signalée, ordre réel affiché, normalisation
seulement sur confirmation explicite. Miroir client de validateSet().
§5 Écritures asynchrones — LoadConfigProvider ne conclut jamais sur
l'acquittement du RPC : pending → LoadConfigChanged → confirmed|diverged.
NymeaEnergy.GetLoadConfig / SetLoadConfig deviennent RÉELS (ils étaient des
stubs journalisés). Conséquence traitée : EnergySetupProvider.persist()
n'émet plus setLoadConfig(buildLoadDescriptors()) — SetLoadConfig remplace
TOUT l'ensemble, et _commit() l'appelait à chaque changement d'UI : une
reconstruction depuis le modèle typé aurait écrasé la config réelle de la
box sans geste explicite.
Écran /energy/loads, gaté installateur (drawer + garde de routeur).
Limites — le lot plugin n'est PAS déployé sur .75 : GetLoadTelemetry,
LoadTelemetryChanged et o:domain sont absents (19 méthodes / 13 notifications
inchangées). Toute la moitié télémétrie du brief est donc hors d'atteinte, et
o:domain n'étant pas au schéma, la box rejetterait la clé (jsonvalidator.cpp:143).
Fixtures multi-domaines synthétiques : .75 n'a qu'une charge.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
494 lines
17 KiB
Dart
494 lines
17 KiB
Dart
import 'package:flutter/material.dart';
|
|
import 'package:provider/provider.dart';
|
|
|
|
import '../../l10n/app_localizations.dart';
|
|
import '../../l10n/l10n_ext.dart';
|
|
import '../../models/load_config_entry.dart';
|
|
import '../../providers/load_config_provider.dart';
|
|
import '../../services/load_priority.dart';
|
|
import '../../services/nymea_service.dart';
|
|
|
|
/// Ordre de service des charges pilotables — écran installateur.
|
|
///
|
|
/// Deux niveaux : l'ordre des domaines entre eux, puis l'ordre des appareils dans un
|
|
/// domaine. Ce que l'app envoie reste un `priority` plat : la box ne sait rien des
|
|
/// groupes.
|
|
class LoadOrderScreen extends StatefulWidget {
|
|
const LoadOrderScreen({super.key});
|
|
|
|
@override
|
|
State<LoadOrderScreen> createState() => _LoadOrderScreenState();
|
|
}
|
|
|
|
class _LoadOrderScreenState extends State<LoadOrderScreen> {
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
final p = context.read<LoadConfigProvider>();
|
|
p.attach(context.read<NymeaService>());
|
|
p.load();
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.l10n;
|
|
final p = context.watch<LoadConfigProvider>();
|
|
|
|
return Scaffold(
|
|
backgroundColor: const Color(0xFFF0F2F5),
|
|
appBar: AppBar(
|
|
title: Text(t.installerLoadsTitle),
|
|
backgroundColor: Colors.white,
|
|
foregroundColor: const Color(0xFF1A1A2E),
|
|
elevation: 0,
|
|
actions: [
|
|
IconButton(
|
|
icon: const Icon(Icons.refresh_rounded),
|
|
onPressed: p.loading ? null : () => p.load(),
|
|
),
|
|
],
|
|
),
|
|
bottomNavigationBar: _SaveBar(provider: p),
|
|
body: p.loading && p.entries.isEmpty
|
|
? const Center(child: CircularProgressIndicator())
|
|
: p.error != null
|
|
? _Message(icon: Icons.error_outline_rounded, text: t.loadsLoadError(p.error!))
|
|
: p.entries.isEmpty
|
|
? _Message(icon: Icons.inbox_rounded, text: t.loadsEmpty)
|
|
: _Body(provider: p),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Body extends StatelessWidget {
|
|
final LoadConfigProvider provider;
|
|
const _Body({required this.provider});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.l10n;
|
|
final groups = provider.groups;
|
|
|
|
return ListView(
|
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 24),
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(4, 0, 4, 12),
|
|
child: Text(t.installerLoadsSubtitle,
|
|
style: const TextStyle(color: Color(0xFF6B7280), fontSize: 13)),
|
|
),
|
|
|
|
// ── Ordre non groupé : signaler, montrer le réel, ne rien écrire ────────
|
|
if (provider.isNotGrouped) _NotGroupedBanner(provider: provider),
|
|
|
|
// ── La télémétrie n'existe pas sur cette box ───────────────────────────
|
|
const _TelemetryUnavailableCard(),
|
|
|
|
for (var gi = 0; gi < groups.length; gi++)
|
|
_DomainSection(
|
|
key: ValueKey('domain-${groups[gi].domain}'),
|
|
group: groups[gi],
|
|
index: gi,
|
|
total: groups.length,
|
|
provider: provider,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Bandeau « ordre non groupé » ─────────────────────────────────────────────
|
|
class _NotGroupedBanner extends StatelessWidget {
|
|
final LoadConfigProvider provider;
|
|
const _NotGroupedBanner({required this.provider});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.l10n;
|
|
return Card(
|
|
color: const Color(0xFFFFF4E5),
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(14),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(children: [
|
|
const Icon(Icons.warning_amber_rounded, color: Color(0xFFB26A00), size: 20),
|
|
const SizedBox(width: 8),
|
|
Text(t.orderNotGroupedTitle,
|
|
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 14)),
|
|
]),
|
|
const SizedBox(height: 8),
|
|
Text(t.orderNotGroupedBody, style: const TextStyle(fontSize: 13)),
|
|
const SizedBox(height: 8),
|
|
Text(t.orderNotGroupedWarning,
|
|
style: const TextStyle(fontSize: 12, color: Color(0xFF8A5300))),
|
|
const SizedBox(height: 10),
|
|
Align(
|
|
alignment: Alignment.centerRight,
|
|
child: OutlinedButton.icon(
|
|
icon: const Icon(Icons.low_priority_rounded, size: 18),
|
|
label: Text(t.orderNotGroupedAction),
|
|
onPressed: () => _confirm(context),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
Future<void> _confirm(BuildContext context) async {
|
|
final t = context.l10n;
|
|
final ok = await showDialog<bool>(
|
|
context: context,
|
|
builder: (c) => AlertDialog(
|
|
title: Text(t.orderNotGroupedAction),
|
|
content: Text(t.orderNotGroupedWarning),
|
|
actions: [
|
|
TextButton(onPressed: () => Navigator.pop(c, false), child: Text(t.cancel)),
|
|
FilledButton(
|
|
onPressed: () => Navigator.pop(c, true),
|
|
child: Text(t.orderNormalizeConfirm)),
|
|
],
|
|
),
|
|
);
|
|
// La normalisation ne fait que préparer un brouillon : rien ne part tant que
|
|
// l'installateur n'a pas enregistré.
|
|
if (ok == true) provider.prepareNormalization();
|
|
}
|
|
}
|
|
|
|
// ── Télémétrie absente de cette box ──────────────────────────────────────────
|
|
class _TelemetryUnavailableCard extends StatelessWidget {
|
|
const _TelemetryUnavailableCard();
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.l10n;
|
|
return Card(
|
|
color: const Color(0xFFEFF3F7),
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)),
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(14),
|
|
child: Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
const Icon(Icons.sensors_off_rounded, size: 20, color: Color(0xFF5B6B7B)),
|
|
const SizedBox(width: 10),
|
|
Expanded(
|
|
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Text(t.telemetryUnavailableTitle,
|
|
style: const TextStyle(fontWeight: FontWeight.w700, fontSize: 13)),
|
|
const SizedBox(height: 4),
|
|
Text(t.telemetryUnavailableBody,
|
|
style: const TextStyle(fontSize: 12, color: Color(0xFF5B6B7B))),
|
|
]),
|
|
),
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Un domaine et ses charges ────────────────────────────────────────────────
|
|
class _DomainSection extends StatelessWidget {
|
|
final DomainGroup group;
|
|
final int index;
|
|
final int total;
|
|
final LoadConfigProvider provider;
|
|
|
|
const _DomainSection({
|
|
super.key,
|
|
required this.group,
|
|
required this.index,
|
|
required this.total,
|
|
required this.provider,
|
|
});
|
|
|
|
String _domainLabel(L10n t) => switch (group.domain) {
|
|
'ev' => t.domainEv,
|
|
'ecs' => t.domainEcs,
|
|
'hvac' => t.domainHvac,
|
|
'heating' => t.domainHeating,
|
|
kUnclassifiedDomain => t.sectionUnclassified,
|
|
// Domaine que cette version d'app ne connaît pas (box plus récente) :
|
|
// repli lisible, jamais une phrase vide.
|
|
_ => t.domainUnknown(group.domain),
|
|
};
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.l10n;
|
|
return Card(
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.fromLTRB(14, 12, 6, 4),
|
|
child: Row(children: [
|
|
Expanded(
|
|
child: Text(
|
|
_domainLabel(t),
|
|
style: TextStyle(
|
|
fontWeight: FontWeight.w700,
|
|
fontSize: 14,
|
|
color: group.isUnclassified
|
|
? const Color(0xFF9CA3AF)
|
|
: const Color(0xFF1A1A2E),
|
|
),
|
|
),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.keyboard_arrow_up_rounded),
|
|
tooltip: 'Monter',
|
|
onPressed: index == 0
|
|
? null
|
|
: () => provider.reorderDomains(index, index - 1),
|
|
),
|
|
IconButton(
|
|
icon: const Icon(Icons.keyboard_arrow_down_rounded),
|
|
tooltip: 'Descendre',
|
|
onPressed: index >= total - 1
|
|
? null
|
|
: () => provider.reorderDomains(index, index + 2),
|
|
),
|
|
]),
|
|
),
|
|
ReorderableListView.builder(
|
|
shrinkWrap: true,
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
buildDefaultDragHandles: false,
|
|
itemCount: group.loads.length,
|
|
onReorder: (o, n) => provider.reorderWithinDomain(group.domain, o, n),
|
|
itemBuilder: (context, i) => _LoadTile(
|
|
key: ValueKey('load-${group.loads[i].id}'),
|
|
entry: group.loads[i],
|
|
handleIndex: i,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Une charge ───────────────────────────────────────────────────────────────
|
|
class _LoadTile extends StatelessWidget {
|
|
final LoadConfigEntry entry;
|
|
final int handleIndex;
|
|
|
|
const _LoadTile({super.key, required this.entry, required this.handleIndex});
|
|
|
|
String _adapterLabel(L10n t) => switch (entry.adapter) {
|
|
'relay-router' => t.adapterRelayRouter,
|
|
'etmvariableload' => t.adapterEtmVariableLoad,
|
|
'sg-ready' => t.adapterSgReady,
|
|
'evcharger' => t.adapterEvCharger,
|
|
_ => t.adapterUnknown(entry.adapter),
|
|
};
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.l10n;
|
|
final details = <String>[
|
|
_adapterLabel(t),
|
|
if (entry.isRelayRouter) t.loadRelayCount(entry.relays.length),
|
|
if (entry.minOnS > 0) t.loadMinOn(entry.minOnS),
|
|
if (entry.minOffS > 0) t.loadMinOff(entry.minOffS),
|
|
];
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
|
child: Row(
|
|
children: [
|
|
ReorderableDragStartListener(
|
|
index: handleIndex,
|
|
child: const Padding(
|
|
padding: EdgeInsets.symmetric(horizontal: 8),
|
|
child: Icon(Icons.drag_handle_rounded, size: 22, color: Color(0xFF9CA3AF)),
|
|
),
|
|
),
|
|
Expanded(
|
|
child: Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(children: [
|
|
Flexible(
|
|
child: Text(
|
|
entry.label,
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
fontWeight: FontWeight.w600,
|
|
color: entry.enabled
|
|
? const Color(0xFF1A1A2E)
|
|
: const Color(0xFF9CA3AF),
|
|
),
|
|
),
|
|
),
|
|
const SizedBox(width: 8),
|
|
if (!entry.enabled)
|
|
_Chip(text: t.loadDisabled, color: const Color(0xFF9CA3AF)),
|
|
]),
|
|
const SizedBox(height: 2),
|
|
Text(details.join(' · '),
|
|
style: const TextStyle(fontSize: 12, color: Color(0xFF6B7280))),
|
|
const SizedBox(height: 2),
|
|
// Emplacement réservé : LoadConfig n'a AUCUN champ pour rattacher un
|
|
// compteur à une charge. Rien n'est inventé côté app.
|
|
Text(t.loadMeterSlot,
|
|
style: const TextStyle(fontSize: 11, color: Color(0xFFB0B7BF))),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
_Chip(text: t.loadPriority(entry.priority), color: const Color(0xFF2E75B6)),
|
|
const SizedBox(width: 8),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Chip extends StatelessWidget {
|
|
final String text;
|
|
final Color color;
|
|
const _Chip({required this.text, required this.color});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3),
|
|
decoration: BoxDecoration(
|
|
color: color.withValues(alpha: 0.12),
|
|
borderRadius: BorderRadius.circular(6),
|
|
),
|
|
child: Text(text,
|
|
style: TextStyle(fontSize: 11, fontWeight: FontWeight.w600, color: color)),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Barre d'enregistrement ───────────────────────────────────────────────────
|
|
class _SaveBar extends StatelessWidget {
|
|
final LoadConfigProvider provider;
|
|
const _SaveBar({required this.provider});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.l10n;
|
|
|
|
final (String? message, Color color) = switch (provider.saveState) {
|
|
LoadSaveState.pending => (t.saveWaitingBox, const Color(0xFF6B7280)),
|
|
LoadSaveState.confirmed => (t.saveConfirmed, const Color(0xFF2E7D32)),
|
|
LoadSaveState.diverged => (t.saveDiverged, const Color(0xFFB26A00)),
|
|
LoadSaveState.rejected => (
|
|
_rejectionText(t),
|
|
const Color(0xFFC62828),
|
|
),
|
|
LoadSaveState.idle => (null, Colors.transparent),
|
|
};
|
|
|
|
return SafeArea(
|
|
child: Container(
|
|
padding: const EdgeInsets.fromLTRB(16, 10, 16, 10),
|
|
decoration: const BoxDecoration(
|
|
color: Colors.white,
|
|
border: Border(top: BorderSide(color: Color(0xFFE5E7EB))),
|
|
),
|
|
child: Column(
|
|
mainAxisSize: MainAxisSize.min,
|
|
children: [
|
|
if (message != null)
|
|
Padding(
|
|
padding: const EdgeInsets.only(bottom: 8),
|
|
child: Row(children: [
|
|
Icon(
|
|
provider.saveState == LoadSaveState.confirmed
|
|
? Icons.check_circle_outline_rounded
|
|
: Icons.info_outline_rounded,
|
|
size: 16,
|
|
color: color,
|
|
),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: Text(message,
|
|
style: TextStyle(fontSize: 12, color: color)),
|
|
),
|
|
]),
|
|
),
|
|
Row(children: [
|
|
if (provider.hasPendingChanges)
|
|
TextButton(
|
|
onPressed: provider.saveState == LoadSaveState.pending
|
|
? null
|
|
: provider.discardDraft,
|
|
child: Text(t.cancel),
|
|
),
|
|
const Spacer(),
|
|
FilledButton.icon(
|
|
icon: provider.saveState == LoadSaveState.pending
|
|
? const SizedBox(
|
|
width: 14, height: 14,
|
|
child: CircularProgressIndicator(strokeWidth: 2))
|
|
: const Icon(Icons.save_rounded, size: 18),
|
|
label: Text(provider.saveState == LoadSaveState.pending
|
|
? t.savePending
|
|
: t.saveOrder),
|
|
// Aucune écriture possible sans geste explicite : le bouton n'est
|
|
// actif que s'il existe un brouillon.
|
|
onPressed: (!provider.hasPendingChanges ||
|
|
provider.saveState == LoadSaveState.pending)
|
|
? null
|
|
: provider.save,
|
|
),
|
|
]),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
|
|
String _rejectionText(L10n t) {
|
|
final c = provider.conflict;
|
|
if (c == null) return t.saveRejected(provider.saveError ?? '?');
|
|
return switch (c.kind) {
|
|
'duplicateId' => t.validationDuplicateId(c.id ?? '?'),
|
|
'thingClaimedTwice' => t.validationThingClaimedTwice(
|
|
c.thingId ?? '?', c.firstLabel ?? '?', c.secondLabel ?? '?'),
|
|
_ => t.saveRejected(provider.saveError ?? '?'),
|
|
};
|
|
}
|
|
}
|
|
|
|
class _Message extends StatelessWidget {
|
|
final IconData icon;
|
|
final String text;
|
|
const _Message({required this.icon, required this.text});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Center(
|
|
child: Padding(
|
|
padding: const EdgeInsets.all(32),
|
|
child: Column(mainAxisSize: MainAxisSize.min, children: [
|
|
Icon(icon, size: 44, color: Colors.grey.shade400),
|
|
const SizedBox(height: 14),
|
|
Text(text,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(color: Color(0xFF6B7280))),
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
}
|