fix(config): les écrans suivent enfin la maquette — sélecteurs, table d'états, budget, borne
Je n'avais pas ouvert docs/mockups/ : les écrans ont été construits depuis le texte du brief seul, et l'écart est franc. Les maquettes du 2026-08-25 font autorité, elles sont désormais suivies. ÉCRANS DE MÉCANISME, refaits : - sélecteur de Things PAR NOM, filtré sur l'interface nymea (power / etmvariableload). Taper un UUID au doigt est une erreur garantie ET muette : la box accepte un UUID valide qui ne désigne rien, et la charge ne commande jamais rien. Le sélecteur grise les Things déjà pris — la box refuse en bloc un même Thing sur deux lignes — et signale en rouge un ThingId configuré qui n'existe plus sur la box ; - routeur de relais : ajout/retrait de contacteurs, puissance par ligne, et les paliers atteignables AFFICHÉS. Ils sont déduits (2^N combinaisons, doublons écartés), jamais enregistrés — la maquette est explicite là-dessus, ma note précédente disant l'inverse était une sur-interprétation du brief ; - SG-Ready : SG1/SG2 se choisissent EN HAUT, une fois, et la table des quatre états devient éditable — ce qui s'y coche est fermé/ouvert, et c'est ça qui devient relays[]. Les étiquettes SG1/SG2 sont une convention de l'écran (SG1 = fermé en état 1, SG2 = en état 3) et elles TOMBENT sur un encodage non standard, où les contacts s'affichent tels quels ; - modulable : plafond requis > 0, et l'avertissement que changer d'appareil crée une autre charge puisque l'identifiant EST le ThingId ; - temporisations minOnS/minOffS (et minStateHoldS pour la PAC) en pas de 30/60 s ; - « Compteur dédié » présent et GRISÉ, avec sa raison : aucun champ n'existe côté box, et inventer meterThingId ferait rejeter l'enregistrement de TOUTES les charges ; - « Inclure dans l'arbitrage » avec rang et domaine, et ce que la désactivation fait vraiment selon le mécanisme ; - validation AVANT envoi : relays[] vide, powerW ≤ 0, Thing en double, état 2 absent, maxPowerW ≤ 0. SetLoadConfig refuse en bloc et sans motif — laisser partir une config invalide condamne l'installateur à un refus muet qui perd tout l'appel. LISTE : - l'en-tête d'arbitrage et le tableau de budget, PERDUS à la fusion, sont rétablis. Sans eux « Alloué 3 000 W » ne se rattache à rien ; - le rang passe en pastille près de la poignée, et le sous-titre devient lisible : « Routeur de relais · 3 relais · min ON 60 s » au lieu de « Routeur de relais · rang 1 » ; - section « Recharge véhicule » : la borne n'est pas une LoadConfig, elle n'apparaissait donc NULLE PART — ce qu'un installateur qui vient de la régler lit comme une perte. MENU : l'entrée « Ordre de service des charges » est retirée ; elle pointait sur une redirection, donc sur le même écran sous deux noms. Tests 80/80, analyze 0 erreur. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
This commit is contained in:
parent
38475e0007
commit
3d2aab2b42
@ -572,12 +572,6 @@ class _InstallerSection extends StatelessWidget {
|
||||
route: '/energy/setup',
|
||||
push: true,
|
||||
),
|
||||
_NavItem(
|
||||
icon: Icons.low_priority_rounded,
|
||||
label: 'Ordre de service des charges',
|
||||
route: '/energy/loads',
|
||||
push: true,
|
||||
),
|
||||
_NavItem(
|
||||
icon: Icons.tune_rounded,
|
||||
label: 'Configuration Things',
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@ -8,6 +8,7 @@ import '../providers/load_config_provider.dart';
|
||||
import '../providers/load_telemetry_provider.dart';
|
||||
import '../services/load_hardware.dart';
|
||||
import '../services/load_priority.dart';
|
||||
import '../services/nymea_service.dart';
|
||||
import '../theme/etm_tokens.dart';
|
||||
import 'load_cards.dart';
|
||||
|
||||
@ -70,6 +71,9 @@ class ControllableLoadsSection extends StatelessWidget {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Le budget du cycle AVANT les charges : « Alloué 3 000 W » ne veut rien dire
|
||||
// tant qu'on ne sait pas s'il y avait 3 100 ou 12 000 W à distribuer.
|
||||
TelemetryHeader(telemetry: telemetry),
|
||||
for (var i = 0; i < groups.length; i++)
|
||||
_DomainBlock(
|
||||
key: ValueKey('domain-${groups[i].domain}'),
|
||||
@ -80,6 +84,10 @@ class ControllableLoadsSection extends StatelessWidget {
|
||||
telemetry: telemetry,
|
||||
onConfigure: onConfigure,
|
||||
),
|
||||
// La borne n'est PAS une LoadConfig : elle n'apparaît donc dans aucun domaine
|
||||
// ci-dessus, et sans cette section elle serait tout simplement invisible — ce
|
||||
// qu'un installateur qui vient de la configurer lit comme une perte.
|
||||
const BorneSection(),
|
||||
if (provider.hasPendingChanges) ...[
|
||||
const SizedBox(height: 4),
|
||||
ImpactSaveBar(provider: provider),
|
||||
@ -200,6 +208,27 @@ class LoadConfigCard extends StatelessWidget {
|
||||
_ => t.adapterUnknown(entry.adapter),
|
||||
};
|
||||
|
||||
/// « Routeur de relais · 3 relais · min ON 60 s » — le mécanisme, son matériel, ses
|
||||
/// verrous. Chaque morceau n'apparaît que s'il a une valeur à montrer.
|
||||
String _resume(L10n t) {
|
||||
final bouts = <String>[_adapterLabel(t)];
|
||||
if (entry.isRelayRouter) {
|
||||
bouts.add('${entry.relays.length} relais');
|
||||
} else if (entry.isSgReady) {
|
||||
bouts.add('${entry.sgReadyStates.length} états');
|
||||
if (entry.minStateHoldS > 0) {
|
||||
bouts.add('maintien d\'état ${entry.minStateHoldS} s');
|
||||
}
|
||||
} else if (entry.maxPowerW > 0) {
|
||||
bouts.add('${entry.maxPowerW} W');
|
||||
}
|
||||
if (!entry.isSgReady) {
|
||||
if (entry.minOnS > 0) bouts.add('min ON ${entry.minOnS} s');
|
||||
if (entry.minOffS > 0) bouts.add('min OFF ${entry.minOffS} s');
|
||||
}
|
||||
return bouts.join(' · ');
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final t = context.l10n;
|
||||
@ -223,8 +252,27 @@ class LoadConfigCard extends StatelessWidget {
|
||||
ReorderableDragStartListener(
|
||||
index: handleIndex,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.only(top: 2, right: 8),
|
||||
child: Icon(Icons.drag_indicator_rounded, size: 20, color: muted),
|
||||
padding: const EdgeInsets.only(top: 1, right: 9),
|
||||
child: Column(children: [
|
||||
Icon(Icons.drag_indicator_rounded, size: 20, color: muted),
|
||||
const SizedBox(height: 3),
|
||||
// Le rang est ce que le glisser-déposer produit : il se lit ici,
|
||||
// pas noyé dans une phrase.
|
||||
Container(
|
||||
width: 20,
|
||||
height: 20,
|
||||
alignment: Alignment.center,
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.brand.withValues(alpha: 0.12),
|
||||
borderRadius: BorderRadius.circular(6),
|
||||
),
|
||||
child: Text('${entry.priority}',
|
||||
style: const TextStyle(
|
||||
fontSize: 10.5,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: EtmTokens.brand)),
|
||||
),
|
||||
]),
|
||||
),
|
||||
),
|
||||
Expanded(
|
||||
@ -242,8 +290,14 @@ class LoadConfigCard extends StatelessWidget {
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 2),
|
||||
Text('${_adapterLabel(t)} · rang ${entry.priority}',
|
||||
style: TextStyle(fontSize: 11.5, color: muted)),
|
||||
// Ce que l'installateur doit reconnaître d'un coup d'œil : le
|
||||
// mécanisme, ce qu'il commande, et les verrous qui le brident.
|
||||
// « rang 1 » n'apprend rien ici — la pastille le dit déjà.
|
||||
Text(_resume(t),
|
||||
maxLines: 2,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 11.5, height: 1.3, color: muted)),
|
||||
],
|
||||
),
|
||||
),
|
||||
@ -607,3 +661,93 @@ class _EmptyNote extends StatelessWidget {
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/// Les bornes de recharge, qui **ne sont pas des charges pilotables**.
|
||||
///
|
||||
/// ## Pourquoi elles ont leur propre section
|
||||
///
|
||||
/// Une borne n'a ni `adapter`, ni `priority`, ni `domain` : elle ne passe pas par
|
||||
/// `SetLoadConfig` mais par `SetChargingInfo`, un **autre contrat**. Et elle n'apparaît
|
||||
/// pas dans `GetLoadTelemetry` en beta — elle est décidée **en amont** du waterfall, sa
|
||||
/// puissance étant seulement réservée dans `budget.evReservedW`. Elle rejoindra `loads[]`
|
||||
/// à l'étape 3g.
|
||||
///
|
||||
/// Conséquence pratique, et c'est le défaut que cette section corrige : configurée ou non,
|
||||
/// la borne n'apparaissait **nulle part** dans cet écran. Un installateur qui vient de la
|
||||
/// régler et ne la retrouve pas conclut, raisonnablement, que son réglage s'est perdu.
|
||||
class BorneSection extends StatelessWidget {
|
||||
const BorneSection({super.key});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final service = context.watch<NymeaService>();
|
||||
final classesBorne = {
|
||||
for (final c in service.thingClasses)
|
||||
if (c.interfaces.contains('evcharger')) c.id
|
||||
};
|
||||
final bornes = [
|
||||
for (final t in service.things)
|
||||
if (classesBorne.contains(t.thingClassId)) t
|
||||
];
|
||||
if (bornes.isEmpty) return const SizedBox.shrink();
|
||||
|
||||
final muted = EtmTokens.mutedOf(context);
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const SizedBox(height: 6),
|
||||
Text('Recharge véhicule',
|
||||
style: TextStyle(
|
||||
fontSize: 13,
|
||||
fontWeight: FontWeight.w700,
|
||||
letterSpacing: 0.2,
|
||||
color: EtmTokens.inkOf(context))),
|
||||
const SizedBox(height: 8),
|
||||
for (final b in bornes)
|
||||
Container(
|
||||
width: double.infinity,
|
||||
margin: const EdgeInsets.only(bottom: 10),
|
||||
padding: const EdgeInsets.all(14),
|
||||
decoration: BoxDecoration(
|
||||
color: EtmTokens.surfaceOf(context),
|
||||
borderRadius: BorderRadius.circular(EtmTokens.radiusCard),
|
||||
border: Border.all(color: EtmTokens.lineOf(context)),
|
||||
),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(children: [
|
||||
Icon(Icons.ev_station_rounded, size: 19, color: muted),
|
||||
const SizedBox(width: 9),
|
||||
Expanded(
|
||||
child: Text(b.name,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: EtmTokens.inkOf(context))),
|
||||
),
|
||||
]),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'Pas une charge pilotable : la borne se règle par un autre contrat '
|
||||
'(SetChargingInfo) et n\'a ni rang ni domaine. Elle est décidée en '
|
||||
'amont du waterfall — sa puissance est réservée dans le budget, pas '
|
||||
'allouée depuis lui.',
|
||||
style: TextStyle(fontSize: 11.5, height: 1.35, color: muted),
|
||||
),
|
||||
const SizedBox(height: 4),
|
||||
Text(
|
||||
'Le mode de recharge se règle depuis la carte véhicule du tableau de '
|
||||
'bord.',
|
||||
style: TextStyle(fontSize: 11.5, height: 1.35, color: muted),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -236,3 +236,160 @@ class LoadFaultBox extends StatelessWidget {
|
||||
}
|
||||
}
|
||||
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
// En-tête d'arbitrage — repris de l'écran « Ordre de service » à sa disparition.
|
||||
//
|
||||
// Il avait été perdu à la fusion, et c'est un manque qui se voit : sans le budget du
|
||||
// cycle, les allocations affichées sur chaque carte ne se rattachent à rien. « Alloué
|
||||
// 3 000 W » ne veut rien dire tant qu'on ne sait pas s'il y avait 3 100 ou 12 000 W à
|
||||
// distribuer.
|
||||
// ══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
class TelemetryHeader extends StatelessWidget {
|
||||
final LoadTelemetryProvider telemetry;
|
||||
const TelemetryHeader({super.key, 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 ────────────────────────────────────────────────
|
||||
|
||||
/// Ligne d'information de l'en-tête d'arbitrage.
|
||||
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)),
|
||||
),
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
214
lib/widgets/thing_picker.dart
Normal file
214
lib/widgets/thing_picker.dart
Normal file
@ -0,0 +1,214 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/load_config_entry.dart';
|
||||
import '../models/nymea_models.dart';
|
||||
import '../services/nymea_service.dart';
|
||||
import '../theme/etm_tokens.dart';
|
||||
|
||||
/// Choisit un Thing **par son nom**, filtré sur une interface nymea.
|
||||
///
|
||||
/// ## Pourquoi un sélecteur, et pas un champ de saisie
|
||||
///
|
||||
/// La configuration d'une charge ne contient que des `ThingId` — des UUID. Les faire taper
|
||||
/// au doigt est une erreur garantie, et une erreur **muette** : la box accepte un UUID
|
||||
/// syntaxiquement valide qui ne désigne rien, et la charge ne commandera jamais rien.
|
||||
///
|
||||
/// Le sélecteur résout **nom → ThingId** et n'ajoute **aucun champ au contrat** : ce qui
|
||||
/// part reste `{thingId, powerW}`. Il est purement d'interface.
|
||||
///
|
||||
/// ## Le filtre par interface est ce qui rend la liste utilisable
|
||||
///
|
||||
/// Un routeur de relais commande des Things portant l'interface `power` ; une charge
|
||||
/// modulable un Thing portant `etmvariableload`. Sans filtre, l'installateur choisit dans
|
||||
/// une liste où figurent l'onduleur, la batterie et le compteur — et rien ne l'avertirait
|
||||
/// de son erreur avant le premier cycle d'arbitrage.
|
||||
class ThingPicker extends StatelessWidget {
|
||||
/// Interface nymea exigée (`power`, `etmvariableload`, …).
|
||||
final String interfaceRequise;
|
||||
|
||||
/// ThingId actuellement choisi, forme `{uuid}` ou nue. `null` = rien de choisi.
|
||||
final String? valeur;
|
||||
|
||||
/// Appelé avec le ThingId **dans la forme rendue par la box**.
|
||||
final ValueChanged<String> onChoisi;
|
||||
|
||||
/// Texte affiché quand rien n'est choisi.
|
||||
final String placeholder;
|
||||
|
||||
final NymeaService service;
|
||||
|
||||
/// ThingIds déjà pris par d'autres lignes — grisés, parce que la box refuse **en bloc**
|
||||
/// une configuration où un même Thing apparaît deux fois (LM-302-b / ECS-110-b).
|
||||
final Set<String> dejaPris;
|
||||
|
||||
const ThingPicker({
|
||||
super.key,
|
||||
required this.service,
|
||||
required this.interfaceRequise,
|
||||
required this.valeur,
|
||||
required this.onChoisi,
|
||||
this.placeholder = 'Choisir un appareil…',
|
||||
this.dejaPris = const {},
|
||||
});
|
||||
|
||||
/// Things portant [interfaceRequise], triés par nom.
|
||||
List<NymeaThing> _candidats() {
|
||||
final classesOk = {
|
||||
for (final c in service.thingClasses)
|
||||
if (c.interfaces.contains(interfaceRequise)) c.id
|
||||
};
|
||||
final out = [
|
||||
for (final t in service.things)
|
||||
if (classesOk.contains(t.thingClassId)) t
|
||||
]..sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||
return out;
|
||||
}
|
||||
|
||||
/// Nom lisible du Thing choisi, ou `null` s'il n'est pas (ou plus) sur la box.
|
||||
String? nomDe(String? id) {
|
||||
if (id == null) return null;
|
||||
for (final t in service.things) {
|
||||
if (normalizeThingId(t.id) == normalizeThingId(id)) return t.name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final candidats = _candidats();
|
||||
final nom = nomDe(valeur);
|
||||
final muted = EtmTokens.mutedOf(context);
|
||||
|
||||
// Un Thing configuré qui a disparu de la box est un état À DIRE : la charge le
|
||||
// commande toujours dans la config, mais plus rien ne répond en face.
|
||||
final orphelin = valeur != null && valeur!.isNotEmpty && nom == null;
|
||||
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
InkWell(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
onTap: candidats.isEmpty ? null : () => _ouvrir(context, candidats),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 11),
|
||||
decoration: BoxDecoration(
|
||||
borderRadius: BorderRadius.circular(10),
|
||||
border: Border.all(
|
||||
color: orphelin
|
||||
? EtmTokens.danger.withValues(alpha: 0.6)
|
||||
: EtmTokens.lineOf(context)),
|
||||
),
|
||||
child: Row(children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
nom ?? (orphelin ? 'Appareil absent de la box' : placeholder),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
style: TextStyle(
|
||||
fontSize: 13.5,
|
||||
color: nom == null ? muted : EtmTokens.inkOf(context),
|
||||
fontWeight: nom == null ? FontWeight.w400 : FontWeight.w600,
|
||||
),
|
||||
),
|
||||
),
|
||||
Icon(Icons.expand_more_rounded, size: 20, color: muted),
|
||||
]),
|
||||
),
|
||||
),
|
||||
if (orphelin)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'Ce ThingId est dans la configuration mais n\'existe plus sur la box '
|
||||
'(${valeur!}). La charge ne commandera rien tant qu\'il n\'est pas remplacé.',
|
||||
style: const TextStyle(
|
||||
fontSize: 11, height: 1.3, color: EtmTokens.danger),
|
||||
),
|
||||
)
|
||||
else if (candidats.isEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 4),
|
||||
child: Text(
|
||||
'Aucun appareil « $interfaceRequise » sur cette box. Il faut d\'abord '
|
||||
'l\'appairer dans les Things.',
|
||||
style: TextStyle(fontSize: 11, height: 1.3, color: muted),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _ouvrir(BuildContext context, List<NymeaThing> candidats) {
|
||||
showModalBottomSheet<void>(
|
||||
context: context,
|
||||
backgroundColor: EtmTokens.surfaceOf(context),
|
||||
shape: const RoundedRectangleBorder(
|
||||
borderRadius: BorderRadius.vertical(top: Radius.circular(20)),
|
||||
),
|
||||
builder: (c) => SafeArea(
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 16, 18, 4),
|
||||
child: Text('Appareils « $interfaceRequise »',
|
||||
style: TextStyle(
|
||||
fontSize: 15,
|
||||
fontWeight: FontWeight.w700,
|
||||
color: EtmTokens.inkOf(c))),
|
||||
),
|
||||
Padding(
|
||||
padding: const EdgeInsets.fromLTRB(18, 0, 18, 10),
|
||||
child: Text(
|
||||
'Choisis par nom ; l\'app enregistre son identifiant.',
|
||||
style:
|
||||
TextStyle(fontSize: 11.5, color: EtmTokens.mutedOf(c)),
|
||||
),
|
||||
),
|
||||
Flexible(
|
||||
child: ListView.builder(
|
||||
shrinkWrap: true,
|
||||
itemCount: candidats.length,
|
||||
itemBuilder: (_, i) {
|
||||
final t = candidats[i];
|
||||
final pris = dejaPris.contains(normalizeThingId(t.id)) &&
|
||||
normalizeThingId(t.id) != normalizeThingId(valeur ?? '');
|
||||
final choisi = normalizeThingId(t.id) == normalizeThingId(valeur ?? '');
|
||||
return ListTile(
|
||||
enabled: !pris,
|
||||
dense: true,
|
||||
leading: Icon(
|
||||
choisi
|
||||
? Icons.radio_button_checked
|
||||
: Icons.radio_button_unchecked,
|
||||
size: 20,
|
||||
color: choisi ? EtmTokens.brand : EtmTokens.mutedOf(c),
|
||||
),
|
||||
title: Text(t.name,
|
||||
style: TextStyle(
|
||||
fontSize: 14,
|
||||
color: pris
|
||||
? EtmTokens.mutedOf(c)
|
||||
: EtmTokens.inkOf(c))),
|
||||
subtitle: pris
|
||||
? const Text('déjà utilisé par une autre ligne',
|
||||
style: TextStyle(fontSize: 11))
|
||||
: null,
|
||||
onTap: pris
|
||||
? null
|
||||
: () {
|
||||
onChoisi(t.id);
|
||||
Navigator.of(c).pop();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 8),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -16,6 +16,7 @@ import 'package:etm_powersync_app/models/load_config_entry.dart';
|
||||
import 'package:etm_powersync_app/providers/load_config_provider.dart';
|
||||
import 'package:etm_powersync_app/providers/load_telemetry_provider.dart';
|
||||
import 'package:etm_powersync_app/services/load_hardware.dart';
|
||||
import 'package:etm_powersync_app/services/nymea_service.dart';
|
||||
import 'package:etm_powersync_app/services/rpc_schema.dart';
|
||||
import 'package:etm_powersync_app/widgets/controllable_loads_section.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@ -45,6 +46,10 @@ Widget _harness({
|
||||
providers: [
|
||||
ChangeNotifierProvider.value(value: config),
|
||||
ChangeNotifierProvider.value(value: telemetry),
|
||||
// La section « Recharge véhicule » lit les Things de la box pour savoir s'il y a
|
||||
// une borne. Hors connexion, la liste est vide et la section s'efface — c'est
|
||||
// exactement le comportement attendu sur une installation sans borne.
|
||||
ChangeNotifierProvider.value(value: NymeaService()),
|
||||
],
|
||||
child: MaterialApp(
|
||||
locale: const Locale('fr'),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user