Le lot plugin B-bis (1.15.2+etm15) est sur .75 et GetLoadConfig/GetLoadTelemetry concordent : les critères 4 à 8 du brief deviennent atteignables. - models/load_telemetry.dart : vue en lecture. timestamp/budget/mechanism/lock/ faultCode nullables — omis porte un sens que zéro ne porterait pas. - services/telemetry_text.dart : rendu français des CODES (19 motifs, 3 défauts, 3 verrous, 4 mécanismes). Repli lisible sur un code inconnu ET sur un code connu privé d'un paramètre : une phrase trouée a l'air d'une donnée, le code brut a l'air de ce qu'il est. - providers/load_telemetry_provider.dart : instantané + LoadTelemetryChanged, fraîcheur à trois signaux (âge du timestamp, silence des trames, timestamp qui cesse d'avancer), machine à états de ClearLoadFault qui ne conclut JAMAIS sur l'acquittement du RPC. - load_config_provider : assignDomain() en brouillon, énumération fermée. - load_order_screen : budget, fraîcheur, mode dégradé, bloc par charge, encart défaut, menu de domaine. - nymea_service : getLoadTelemetry(), clearLoadFault(), aiguillage de la notification. Une box sans la méthode s'annonce comme telle, pas comme en panne. Seuil de fraîcheur à 180 s : le battement de cœur du plugin est de 60 s et le timestamp avance d'un cycle par minute. Un seuil plus court crierait « données anciennes » sur une installation vivante. Fixtures v3 prises sur .75 après déploiement, dont un cycle porteur d'un verrou — « lock » étant optionnel, un dump pris au mauvais moment ne l'éprouverait pas. 57 tests (30 avant), flutter analyze inchangé à 25 issues. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XPUo3RMr8SzK6qbFtfBm8H
906 lines
32 KiB
Dart
906 lines
32 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 '../../models/load_telemetry.dart';
|
|
import '../../providers/load_config_provider.dart';
|
|
import '../../providers/load_telemetry_provider.dart';
|
|
import '../../services/load_priority.dart';
|
|
import '../../services/nymea_service.dart';
|
|
import '../../services/telemetry_text.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.
|
|
///
|
|
/// L'écran superpose deux sources qu'il ne faut pas confondre :
|
|
///
|
|
/// - **la configuration** (`GetLoadConfig`) — ce que l'installateur a déclaré, éditable
|
|
/// ici pour le rang et le domaine ;
|
|
/// - **la télémétrie** (`GetLoadTelemetry`) — ce que l'arbitre a réellement fait au
|
|
/// dernier cycle, en lecture seule et jamais reconstruite côté app.
|
|
///
|
|
/// Depuis le lot plugin B-bis, toute charge publiée en télémétrie a une entrée en
|
|
/// configuration. L'inverse reste faux : une charge configurée absente de la télémétrie
|
|
/// est **désactivée**, jamais perdue.
|
|
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 service = context.read<NymeaService>();
|
|
final config = context.read<LoadConfigProvider>();
|
|
final telemetry = context.read<LoadTelemetryProvider>();
|
|
config.attach(service);
|
|
telemetry.attach(service);
|
|
config.load();
|
|
// Instantané au chargement ; la suite arrive en `LoadTelemetryChanged`, sans
|
|
// rechargement manuel.
|
|
telemetry.load();
|
|
});
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.l10n;
|
|
final p = context.watch<LoadConfigProvider>();
|
|
final tp = context.watch<LoadTelemetryProvider>();
|
|
|
|
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();
|
|
tp.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, telemetry: tp),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Body extends StatelessWidget {
|
|
final LoadConfigProvider provider;
|
|
final LoadTelemetryProvider telemetry;
|
|
const _Body({required this.provider, required this.telemetry});
|
|
|
|
@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),
|
|
|
|
// ── État de l'arbitrage ────────────────────────────────────────────────
|
|
if (!telemetry.supported)
|
|
const _TelemetryUnavailableCard()
|
|
else
|
|
_TelemetryHeader(telemetry: telemetry),
|
|
|
|
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,
|
|
telemetry: telemetry,
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── 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))),
|
|
]),
|
|
),
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── En-tête : fraîcheur, mode dégradé, budget du cycle ───────────────────────
|
|
class _TelemetryHeader extends StatelessWidget {
|
|
final LoadTelemetryProvider telemetry;
|
|
const _TelemetryHeader({required this.telemetry});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.l10n;
|
|
final snapshot = telemetry.telemetry;
|
|
if (snapshot == null) {
|
|
return telemetry.loading
|
|
? const Padding(
|
|
padding: EdgeInsets.only(bottom: 12),
|
|
child: LinearProgressIndicator(minHeight: 2))
|
|
: const SizedBox.shrink();
|
|
}
|
|
|
|
final age = telemetry.displayedAge;
|
|
final stale = telemetry.stale;
|
|
|
|
return Card(
|
|
margin: const EdgeInsets.only(bottom: 12),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(14)),
|
|
child: Padding(
|
|
padding: const EdgeInsets.fromLTRB(14, 12, 14, 14),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(children: [
|
|
Icon(
|
|
stale ? Icons.history_toggle_off_rounded : Icons.sensors_rounded,
|
|
size: 20,
|
|
color: stale ? const Color(0xFFB26A00) : const Color(0xFF2E7D32),
|
|
),
|
|
const SizedBox(width: 8),
|
|
Expanded(
|
|
child: Text(t.telemetryTitle,
|
|
style:
|
|
const TextStyle(fontWeight: FontWeight.w700, fontSize: 14)),
|
|
),
|
|
]),
|
|
const SizedBox(height: 8),
|
|
|
|
// Fraîcheur — un arbitre figé ne doit pas ressembler à une installation
|
|
// stable. L'absence de cycle est dite pour elle-même, jamais rendue par
|
|
// un âge de zéro.
|
|
if (snapshot.hasNeverRun)
|
|
_Note(
|
|
icon: Icons.hourglass_empty_rounded,
|
|
color: const Color(0xFFB26A00),
|
|
text: t.telemetryNeverRan)
|
|
else if (stale)
|
|
_Note(
|
|
icon: Icons.warning_amber_rounded,
|
|
color: const Color(0xFFB26A00),
|
|
text: t.telemetryStale(age?.inSeconds ?? 0))
|
|
else
|
|
_Note(
|
|
icon: Icons.schedule_rounded,
|
|
color: const Color(0xFF6B7280),
|
|
text: t.telemetryLastCycle(age?.inSeconds ?? 0)),
|
|
|
|
if (snapshot.degradedMode) ...[
|
|
const SizedBox(height: 6),
|
|
_Note(
|
|
icon: Icons.report_gmailerrorred_rounded,
|
|
color: const Color(0xFFC62828),
|
|
text: t.telemetryDegradedMode),
|
|
],
|
|
|
|
const SizedBox(height: 12),
|
|
Text(t.telemetryBudgetTitle,
|
|
style: const TextStyle(
|
|
fontWeight: FontWeight.w600,
|
|
fontSize: 12,
|
|
color: Color(0xFF6B7280))),
|
|
const SizedBox(height: 6),
|
|
|
|
// `budget` absent = repli L2 : il n'existe AUCUN budget pour ce cycle.
|
|
// Afficher des zéros se lirait « arbitrage exécuté, rien à allouer ».
|
|
if (snapshot.budget == null)
|
|
Text(t.telemetryBudgetAbsent,
|
|
style: const TextStyle(fontSize: 12, color: Color(0xFFC62828)))
|
|
else
|
|
_BudgetTable(budget: snapshot.budget!),
|
|
],
|
|
),
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _BudgetTable extends StatelessWidget {
|
|
final TelemetryBudget budget;
|
|
const _BudgetTable({required this.budget});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.l10n;
|
|
final rows = <(String, double)>[
|
|
(t.budgetSurplus, budget.surplusW),
|
|
(t.budgetEvReserved, budget.evReservedW),
|
|
(t.budgetRecredited, budget.recreditedW),
|
|
(t.budgetAllocated, budget.allocatedW),
|
|
(t.budgetRemaining, budget.remainingW),
|
|
];
|
|
return Column(
|
|
children: [
|
|
for (final (label, value) in rows)
|
|
Padding(
|
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
|
child: Row(children: [
|
|
Expanded(
|
|
child: Text(label,
|
|
style: const TextStyle(fontSize: 12, color: Color(0xFF6B7280))),
|
|
),
|
|
Text(t.watts(value.round()),
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w600,
|
|
fontFeatures: [FontFeature.tabularFigures()])),
|
|
]),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Un domaine et ses charges ────────────────────────────────────────────────
|
|
class _DomainSection extends StatelessWidget {
|
|
final DomainGroup group;
|
|
final int index;
|
|
final int total;
|
|
final LoadConfigProvider provider;
|
|
final LoadTelemetryProvider telemetry;
|
|
|
|
const _DomainSection({
|
|
super.key,
|
|
required this.group,
|
|
required this.index,
|
|
required this.total,
|
|
required this.provider,
|
|
required this.telemetry,
|
|
});
|
|
|
|
@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, group.domain),
|
|
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,
|
|
provider: provider,
|
|
telemetry: telemetry,
|
|
),
|
|
),
|
|
const SizedBox(height: 6),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Libellé d'un domaine. Un domaine inconnu de cette version d'app (box plus récente)
|
|
/// a un repli lisible : le code brut, jamais une phrase vide.
|
|
String domainLabel(L10n t, String domain) => switch (domain) {
|
|
'ev' => t.domainEv,
|
|
'ecs' => t.domainEcs,
|
|
'hvac' => t.domainHvac,
|
|
'heating' => t.domainHeating,
|
|
'battery' => t.domainBattery,
|
|
kUnclassifiedDomain => t.sectionUnclassified,
|
|
_ => t.domainUnknown(domain),
|
|
};
|
|
|
|
// ── Une charge ───────────────────────────────────────────────────────────────
|
|
class _LoadTile extends StatelessWidget {
|
|
final LoadConfigEntry entry;
|
|
final int handleIndex;
|
|
final LoadConfigProvider provider;
|
|
final LoadTelemetryProvider telemetry;
|
|
|
|
const _LoadTile({
|
|
super.key,
|
|
required this.entry,
|
|
required this.handleIndex,
|
|
required this.provider,
|
|
required this.telemetry,
|
|
});
|
|
|
|
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),
|
|
];
|
|
|
|
final snapshot = telemetry.telemetry;
|
|
final live = telemetry.entryFor(entry.id);
|
|
|
|
return Padding(
|
|
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 2),
|
|
child: Row(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Padding(
|
|
padding: const EdgeInsets.only(top: 10),
|
|
child: 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))),
|
|
|
|
// ── Ce que l'arbitre a fait au dernier cycle ────────────────
|
|
if (telemetry.supported && snapshot != null) ...[
|
|
const SizedBox(height: 8),
|
|
if (live == null)
|
|
Text(t.telemetryNotArbitrated,
|
|
style: const TextStyle(
|
|
fontSize: 11,
|
|
fontStyle: FontStyle.italic,
|
|
color: Color(0xFF9CA3AF)))
|
|
else
|
|
_LiveBlock(
|
|
live: live,
|
|
stale: telemetry.stale,
|
|
telemetry: telemetry,
|
|
),
|
|
],
|
|
],
|
|
),
|
|
),
|
|
),
|
|
Column(
|
|
crossAxisAlignment: CrossAxisAlignment.end,
|
|
children: [
|
|
const SizedBox(height: 6),
|
|
_Chip(text: t.loadPriority(entry.priority), color: const Color(0xFF2E75B6)),
|
|
_DomainMenu(entry: entry, provider: provider),
|
|
],
|
|
),
|
|
const SizedBox(width: 4),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Choix du domaine — énumération fermée côté plugin, donc liste fermée ici.
|
|
class _DomainMenu extends StatelessWidget {
|
|
final LoadConfigEntry entry;
|
|
final LoadConfigProvider provider;
|
|
const _DomainMenu({required this.entry, required this.provider});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.l10n;
|
|
final current = entry.domain ?? kUnclassifiedDomain;
|
|
|
|
return PopupMenuButton<String>(
|
|
tooltip: t.domainAssignAction,
|
|
icon: const Icon(Icons.label_outline_rounded, size: 20, color: Color(0xFF6B7280)),
|
|
onSelected: (d) => provider.assignDomain(entry.id, d),
|
|
itemBuilder: (c) => [
|
|
PopupMenuItem<String>(
|
|
enabled: false,
|
|
child: Text(t.domainAssignTitle(entry.label),
|
|
style: const TextStyle(fontSize: 12, fontWeight: FontWeight.w700)),
|
|
),
|
|
const PopupMenuDivider(),
|
|
for (final d in kAssignableDomains)
|
|
CheckedPopupMenuItem<String>(
|
|
value: d,
|
|
checked: current == d,
|
|
child: Text(domainLabel(t, d)),
|
|
),
|
|
const PopupMenuDivider(),
|
|
PopupMenuItem<String>(
|
|
value: kUnclassifiedDomain,
|
|
child: Text(t.domainAssignNone),
|
|
),
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
// ── Bloc télémétrie d'une charge ─────────────────────────────────────────────
|
|
class _LiveBlock extends StatelessWidget {
|
|
final LoadTelemetryEntry live;
|
|
final bool stale;
|
|
final LoadTelemetryProvider telemetry;
|
|
|
|
const _LiveBlock({
|
|
required this.live,
|
|
required this.stale,
|
|
required this.telemetry,
|
|
});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.l10n;
|
|
final mech = live.mechanism;
|
|
final mechText = mech == null ? null : mechanismText(t, mech);
|
|
final lock = live.lock;
|
|
|
|
// Les données anciennes restent affichées — les masquer priverait du seul état
|
|
// connu — mais grisées : elles décrivent un cycle, pas l'instant présent.
|
|
final tone = stale ? const Color(0xFF9CA3AF) : const Color(0xFF374151);
|
|
|
|
return Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(children: [
|
|
Icon(Icons.bolt_rounded,
|
|
size: 14,
|
|
color: stale ? const Color(0xFFB0B7BF) : const Color(0xFF2E7D32)),
|
|
const SizedBox(width: 4),
|
|
Text(t.telemetryAllocated(live.allocatedW.round()),
|
|
style: TextStyle(
|
|
fontSize: 12, fontWeight: FontWeight.w600, color: tone)),
|
|
if (mechText != null) ...[
|
|
const SizedBox(width: 6),
|
|
Flexible(
|
|
child: Text('· $mechText',
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(fontSize: 12, color: tone)),
|
|
),
|
|
],
|
|
]),
|
|
const SizedBox(height: 2),
|
|
Text(decisionText(t, live.decision),
|
|
style: TextStyle(fontSize: 11, color: tone)),
|
|
if (lock != null) ...[
|
|
const SizedBox(height: 2),
|
|
Row(children: [
|
|
Icon(Icons.lock_clock_rounded, size: 13, color: tone),
|
|
const SizedBox(width: 4),
|
|
Flexible(
|
|
child: Text(
|
|
// Les secondes affichées datent du cycle publié : l'app ne les décompte
|
|
// pas, le plugin exclut volontairement ce champ de sa détection de
|
|
// changement.
|
|
'${lockText(t, lock)} (${t.lockAsOfCycle})',
|
|
overflow: TextOverflow.ellipsis,
|
|
style: TextStyle(fontSize: 11, color: tone),
|
|
),
|
|
),
|
|
]),
|
|
],
|
|
if (live.inFault) ...[
|
|
const SizedBox(height: 6),
|
|
_FaultBox(live: live, telemetry: telemetry),
|
|
],
|
|
],
|
|
);
|
|
}
|
|
}
|
|
|
|
/// Charge en défaut, et levée du verrou.
|
|
///
|
|
/// Le bouton n'existe **que** sur une charge dont `available` est faux, et son résultat
|
|
/// n'est jamais annoncé sur le retour du RPC : `ClearLoadFault` répond
|
|
/// `EnergyErrorNoError` même sur une charge saine (vérifié sur `.75`). Le verdict vient
|
|
/// de la télémétrie relue.
|
|
class _FaultBox extends StatelessWidget {
|
|
final LoadTelemetryEntry live;
|
|
final LoadTelemetryProvider telemetry;
|
|
|
|
const _FaultBox({required this.live, required this.telemetry});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
final t = context.l10n;
|
|
final state = telemetry.clearStateOf(live.loadId);
|
|
final code = live.faultCode;
|
|
|
|
final (String? message, Color messageColor) = switch (state) {
|
|
FaultClearState.pending => (t.faultClearPending, const Color(0xFF6B7280)),
|
|
FaultClearState.cleared => (t.faultCleared, const Color(0xFF2E7D32)),
|
|
FaultClearState.stillFaulty => (t.faultClearStillFaulty, const Color(0xFFC62828)),
|
|
FaultClearState.rejected => (
|
|
t.faultClearRejected(telemetry.clearErrorOf(live.loadId) ?? '?'),
|
|
const Color(0xFFC62828)
|
|
),
|
|
FaultClearState.idle => (null, Colors.transparent),
|
|
};
|
|
|
|
return Container(
|
|
width: double.infinity,
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(
|
|
color: const Color(0xFFFDECEA),
|
|
borderRadius: BorderRadius.circular(10),
|
|
),
|
|
child: Column(
|
|
crossAxisAlignment: CrossAxisAlignment.start,
|
|
children: [
|
|
Row(children: [
|
|
const Icon(Icons.error_outline_rounded, size: 15, color: Color(0xFFC62828)),
|
|
const SizedBox(width: 6),
|
|
Text(t.faultTitle,
|
|
style: const TextStyle(
|
|
fontSize: 12,
|
|
fontWeight: FontWeight.w700,
|
|
color: Color(0xFFC62828))),
|
|
]),
|
|
const SizedBox(height: 4),
|
|
Text(code == null ? t.faultUnknown('—') : faultText(t, code),
|
|
style: const TextStyle(fontSize: 11, color: Color(0xFF7F1D1D))),
|
|
const SizedBox(height: 4),
|
|
Text(t.faultClearHint,
|
|
style: const TextStyle(fontSize: 10, color: Color(0xFF9A5B5B))),
|
|
if (message != null) ...[
|
|
const SizedBox(height: 6),
|
|
Text(message, style: TextStyle(fontSize: 11, color: messageColor)),
|
|
],
|
|
const SizedBox(height: 4),
|
|
Align(
|
|
alignment: Alignment.centerRight,
|
|
child: OutlinedButton.icon(
|
|
icon: state == FaultClearState.pending
|
|
? const SizedBox(
|
|
width: 12,
|
|
height: 12,
|
|
child: CircularProgressIndicator(strokeWidth: 2))
|
|
: const Icon(Icons.lock_open_rounded, size: 16),
|
|
label: Text(t.faultClearAction),
|
|
onPressed: state == FaultClearState.pending
|
|
? null
|
|
: () => telemetry.clearFault(live.loadId),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
class _Note extends StatelessWidget {
|
|
final IconData icon;
|
|
final Color color;
|
|
final String text;
|
|
const _Note({required this.icon, required this.color, required this.text});
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
return Row(crossAxisAlignment: CrossAxisAlignment.start, children: [
|
|
Icon(icon, size: 15, color: color),
|
|
const SizedBox(width: 6),
|
|
Expanded(
|
|
child: Text(text, style: TextStyle(fontSize: 12, color: color)),
|
|
),
|
|
]);
|
|
}
|
|
}
|
|
|
|
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))),
|
|
]),
|
|
),
|
|
);
|
|
}
|
|
}
|