etm-powersync-app/lib/screens/thing_detail_screen.dart
Patrick Schurig aada8e2ba5 fix(config): glissement, sélecteur de mécanisme, chip de domaine, thème — les 4 points ouverts
1) GLISSEMENT — diagnostic d'abord, et il a donné DEUX réponses.

   Le geste n'est PAS avalé par le ListView parent : les deux montages du test
   (section seule / section imbriquée) glissent identiquement. La cause du symptôme était
   bien que .75 n'a qu'une charge par domaine — rien à réordonner.

   Mais l'enquête a trouvé un vrai défaut au passage : le centre de la poignée tombait dans
   le vide de 3 px séparant l'icône de la pastille de rang, et ReorderableDragStartListener
   défère le test de collision à ses enfants. Une bande morte invisible au milieu d'une
   poignée qui paraît d'un seul tenant. Corrigé par un Container transparent.

   Tentative faite et ANNULÉE : rendre les domaines glissables aussi. Deux
   ReorderableListView imbriqués se disputent le geste et l'extérieur gagne — le
   glissement des cartes cassait. Les domaines gardent leurs flèches, et une charge seule
   dans son domaine n'offre plus de prise qui mènerait nulle part (avec l'infobulle qui
   renvoie au bon geste).

   Vérifié SUR L'APPAREIL, contre .75, avec les deux charges mises dans le même domaine :
   [chauffe-eau, pac-terrain] → [pac-terrain, chauffe-eau], brouillon abandonné sans rien
   écrire. Classement ecs/hvac rétabli ensuite.

2) SÉLECTEUR DE MÉCANISME — actif, et c'était le morceau délicat.

   LoadConfig est une union discriminée : écrire `adapter` sans nettoyer le reste produit
   une configuration que la box refuse EN BLOC, et le refus emporte toutes les charges de
   l'appel. D'où basculerMecanisme(), qui retire les clés du mécanisme quitté et pose
   celles du nouveau, en récupérant ce qui peut l'être — les deux premiers contacts
   deviennent SG1/SG2 dans l'encodage standard, les relais gardent leur puissance.

   Ce qui n'est PAS transposé : l'estimation d'un état SG-Ready n'est pas la puissance
   d'une résistance. La charge produite est alors incomplète, et c'est voulu — le
   formulaire réclame plutôt que d'inventer une valeur de plaque.

   La bascule franchit sameHardware() : confirmation préalable qui annonce la
   reconstruction et la durée de réarmement (900 s par défaut vers SG-Ready — la valeur
   d'une PAC réelle, pas celle du banc).

   Le mode fixed d'etmvariableload reste LISIBLE sans être proposé : bloc « Configuration
   héritée » en lecture seule, et conversion seulement sur geste explicite, avec le
   plafond que la déclaration en paliers ne porte pas. Ouvrir l'écran ne convertit personne.

3) CHIP DE DOMAINE — il ne rendait qu'une icône muette : le domaine d'une charge était
   illisible sur sa carte, alors qu'il décide de son groupe et de son rang. Il porte
   maintenant son libellé, et sa couleur suit le thème.

4) THÈME — les 11 derniers sites (pas 9) basculés. Trois cas : contexte disponible
   (main, ac_screen) ; peintre sans contexte, à qui la couleur est passée résolue
   (energy_flow_widget) ; tables statiques, où la teinte devient nullable et se résout au
   rendu (categoryInfoMap, favoris). Plus AUCUN jeton hors thème dans lib/.

5) ZONES — non entreprises, comme convenu.

Tests 93/93, analyze 0 erreur.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HajXLUczEyZd22JeewRfff
2026-08-26 08:49:20 +02:00

811 lines
31 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../models/nymea_models.dart';
import '../models/thing_category.dart';
import '../services/nymea_service.dart';
import '../theme/app_theme.dart';
// ─────────────────────────────────────────────────────────────────────────────
// ThingDetailScreen — NIVEAU 3
//
// • AppBar : ‹ Nom du thing ≡
// • "States" right-aligned + trait coloré
// • Liste plate SANS Cards — lignes séparées par Divider
// - États writables : Switch interactif / bouton edit → setStateValue
// - Actions : dialog de collecte des paramètres → executeAction
// • "Settings" idem, éditables via setThingSettings
// ─────────────────────────────────────────────────────────────────────────────
class ThingDetailScreen extends StatelessWidget {
final NymeaThing thing;
const ThingDetailScreen({super.key, required this.thing});
@override
Widget build(BuildContext context) {
final service = context.watch<NymeaService>();
final live = service.things.firstWhere(
(t) => t.id == thing.id,
orElse: () => thing,
);
NymeaThingClass? cls;
try {
cls = service.thingClasses.firstWhere((c) => c.id == thing.thingClassId);
} catch (_) {}
final catInfo = cls != null
? categoryInfoMap[cls.category] ?? categoryInfoMap[ThingCategory.other]!
: categoryInfoMap[ThingCategory.other]!;
final stateTypes = cls?.stateTypes ?? [];
final settingTypes = cls?.settingsTypes ?? [];
final actionTypes = cls?.actionTypes ?? [];
return Scaffold(
backgroundColor: AppTheme.backgroundGray,
appBar: AppBar(
backgroundColor: AppTheme.backgroundGray,
elevation: 0,
centerTitle: true,
leading: IconButton(
icon: const Icon(Icons.chevron_left,
color: AppTheme.textDark, size: 30),
onPressed: () => Navigator.pop(context),
),
title: Text(
live.name,
style: const TextStyle(
color: AppTheme.textDark,
fontWeight: FontWeight.bold,
fontSize: 18),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
actions: [
if (!service.isSimulation)
IconButton(
icon: const Icon(Icons.menu,
color: AppTheme.textDark, size: 22),
onPressed: () =>
_showMenu(context, service, live, catInfo),
),
],
),
body: ListView(
children: [
// ── States ─────────────────────────────────────────────────────────
if (stateTypes.isNotEmpty) ...[
_SectionLabel(label: 'States', accentColor: catInfo.colorOf(context)),
_FlatSection(
children: stateTypes.asMap().entries.map((e) {
final isLast = e.key == stateTypes.length - 1;
final st = e.value;
return _StateRow(
stateType: st,
value: live.stateValue(st.id),
isLast: isLast,
accentColor: catInfo.colorOf(context),
// onChanged non-null si l'état est writable
onChanged: st.writable
? (newVal) async {
final ok = await service.setStateValue(
live.id, st.id, newVal);
if (!ok && context.mounted) {
_showError(context, 'Modification échouée');
}
}
: null,
);
}).toList(),
),
const SizedBox(height: 20),
],
// ── Settings ───────────────────────────────────────────────────────
if (settingTypes.isNotEmpty) ...[
_SectionLabel(label: 'Settings', accentColor: catInfo.colorOf(context)),
_FlatSection(
children: settingTypes.asMap().entries.map((e) {
final isLast = e.key == settingTypes.length - 1;
final st = e.value;
return _StateRow(
stateType: st,
value: live.settingValue(st.id),
isLast: isLast,
accentColor: catInfo.colorOf(context),
// Settings sont toujours éditables
onChanged: (newVal) async {
final ok = await service.setThingSettings(
live.id, st.id, newVal);
if (!ok && context.mounted) {
_showError(context, 'Paramètre non appliqué');
}
},
);
}).toList(),
),
const SizedBox(height: 20),
],
// ── Actions ────────────────────────────────────────────────────────
if (actionTypes.isNotEmpty) ...[
_SectionLabel(label: 'Actions', accentColor: catInfo.colorOf(context)),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
child: Wrap(
spacing: 10,
runSpacing: 8,
children: actionTypes
.map((action) => ElevatedButton.icon(
style: ElevatedButton.styleFrom(
backgroundColor: catInfo.colorOf(context),
foregroundColor: Colors.white,
elevation: 0,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(
AppTheme.cornerRadius)),
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 10),
),
icon: const Icon(Icons.play_arrow_rounded,
size: 18),
label: Text(action.displayName,
style: const TextStyle(
fontSize: 13,
fontWeight: FontWeight.w600)),
onPressed: () => _executeAction(
context, service, live, action,
catInfo.colorOf(context)),
))
.toList(),
),
),
const SizedBox(height: 20),
],
// ── Interfaces ─────────────────────────────────────────────────────
if (cls != null && cls.interfaces.isNotEmpty) ...[
_SectionLabel(label: 'Interfaces', accentColor: catInfo.colorOf(context)),
Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 0),
child: Wrap(
spacing: 8,
runSpacing: 6,
children: cls.interfaces
.map((iface) => Chip(
label: Text(iface,
style: const TextStyle(fontSize: 12)),
backgroundColor:
catInfo.colorOf(context).withValues(alpha: 0.1),
side: BorderSide(
color:
catInfo.colorOf(context).withValues(alpha: 0.3)),
padding: const EdgeInsets.symmetric(
horizontal: 4),
))
.toList(),
),
),
const SizedBox(height: 20),
],
// ── Informations ───────────────────────────────────────────────────
_SectionLabel(label: 'Informations', accentColor: catInfo.colorOf(context)),
_FlatSection(children: [
_InfoRow('ID', live.id, isLast: false),
_InfoRow('Classe', live.thingClassId, isLast: false),
_InfoRow(
'Statut',
live.setupStatus.replaceAll('ThingSetupStatus', ''),
isLast: true),
]),
const SizedBox(height: 60),
],
),
);
}
// ── Exécuter une action ─────────────────────────────────────────────────────
void _executeAction(
BuildContext context,
NymeaService service,
NymeaThing t,
NymeaActionType action,
Color accentColor,
) async {
Map<String, dynamic>? params;
if (action.paramTypes.isEmpty) {
params = {};
} else {
// Collecter les valeurs des paramètres via un dialog
params = await showDialog<Map<String, dynamic>>(
context: context,
builder: (_) => _ActionParamDialog(
actionName: action.displayName,
paramTypes: action.paramTypes,
accentColor: accentColor,
),
);
if (params == null) return; // annulé
}
final result = await service.executeAction(
thingId: t.id,
actionTypeId: action.id,
params: params,
);
if (!context.mounted) return;
if (result.success) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Row(children: [
const Icon(Icons.check_circle_outline,
color: Colors.white, size: 18),
const SizedBox(width: 8),
Expanded(child: Text('${action.displayName} exécutée')),
]),
backgroundColor: accentColor,
duration: const Duration(seconds: 2),
));
} else {
_showError(context, result.errorText);
}
}
// ── Menu ≡ ─────────────────────────────────────────────────────────────────
void _showMenu(BuildContext context, NymeaService service, NymeaThing t,
ThingCategoryInfo info) {
showModalBottomSheet(
context: context,
shape: const RoundedRectangleBorder(
borderRadius: BorderRadius.vertical(top: Radius.circular(16))),
builder: (_) => SafeArea(
child: Column(mainAxisSize: MainAxisSize.min, children: [
Container(
width: 36, height: 4,
margin: const EdgeInsets.only(top: 10, bottom: 12),
decoration: BoxDecoration(
color: Colors.grey[300],
borderRadius: BorderRadius.circular(2)),
),
ListTile(
leading: Icon(Icons.edit_rounded, color: info.colorOf(context)),
title: const Text('Renommer'),
onTap: () {
Navigator.pop(context);
_showRenameDialog(context, service, t);
},
),
const Divider(height: 1, color: Color(0xFFEEEEEE)),
ListTile(
leading:
const Icon(Icons.delete_outline_rounded, color: Colors.red),
title: const Text('Supprimer',
style: TextStyle(color: Colors.red)),
onTap: () {
Navigator.pop(context);
_showRemoveDialog(context, service, t);
},
),
const SizedBox(height: 8),
]),
),
);
}
void _showRenameDialog(
BuildContext ctx, NymeaService svc, NymeaThing t) {
final ctrl = TextEditingController(text: t.name);
showDialog(
context: ctx,
builder: (c) => AlertDialog(
title: const Text('Renommer'),
content: TextField(
controller: ctrl,
autofocus: true,
decoration:
const InputDecoration(border: OutlineInputBorder()),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(c),
child: const Text('Annuler')),
ElevatedButton(
onPressed: () async {
final name = ctrl.text.trim();
if (name.isNotEmpty) await svc.renameThing(t.id, name);
if (c.mounted) Navigator.pop(c);
},
child: const Text('Renommer'),
),
],
),
);
}
void _showRemoveDialog(
BuildContext ctx, NymeaService svc, NymeaThing t) {
showDialog(
context: ctx,
builder: (c) => AlertDialog(
title: const Text('Supprimer'),
content: Text('Supprimer "${t.name}" définitivement ?'),
actions: [
TextButton(
onPressed: () => Navigator.pop(c),
child: const Text('Annuler')),
ElevatedButton(
style: ElevatedButton.styleFrom(
backgroundColor: Colors.red,
foregroundColor: Colors.white),
onPressed: () async {
await svc.removeThing(t.id);
if (c.mounted) {
Navigator.pop(c);
Navigator.pop(ctx);
}
},
child: const Text('Supprimer'),
),
],
),
);
}
static void _showError(BuildContext context, String msg) {
ScaffoldMessenger.of(context).showSnackBar(SnackBar(
content: Row(children: [
const Icon(Icons.error_outline, color: Colors.white, size: 18),
const SizedBox(width: 8),
Expanded(child: Text(msg.isEmpty ? 'Erreur' : msg)),
]),
backgroundColor: Colors.redAccent,
duration: const Duration(seconds: 3),
));
}
}
// ─────────────────────────────────────────────────────────────────────────────
// _ActionParamDialog — collecte les paramètres d'une action nymea
//
// Construit automatiquement le formulaire selon le type de chaque paramètre :
// • Bool → Switch
// • allowedValues → DropdownButton
// • Int/Double → TextField numérique
// • String → TextField texte
// ─────────────────────────────────────────────────────────────────────────────
class _ActionParamDialog extends StatefulWidget {
final String actionName;
final List<Map<String, dynamic>> paramTypes;
final Color accentColor;
const _ActionParamDialog({
required this.actionName,
required this.paramTypes,
required this.accentColor,
});
@override
State<_ActionParamDialog> createState() => _ActionParamDialogState();
}
class _ActionParamDialogState extends State<_ActionParamDialog> {
late Map<String, dynamic> _values;
late Map<String, TextEditingController> _controllers;
@override
void initState() {
super.initState();
_values = {};
_controllers = {};
for (final p in widget.paramTypes) {
final id = p['id'] as String? ?? '';
final defVal = p['defaultValue'];
_values[id] = defVal;
// Contrôleur texte pour les types non-bool
if ((p['type'] as String? ?? '') != 'Bool') {
_controllers[id] =
TextEditingController(text: defVal?.toString() ?? '');
}
}
}
@override
void dispose() {
for (final c in _controllers.values) {
c.dispose();
}
super.dispose();
}
@override
Widget build(BuildContext context) {
return AlertDialog(
title: Row(children: [
Icon(Icons.play_arrow_rounded,
color: widget.accentColor, size: 22),
const SizedBox(width: 8),
Expanded(
child: Text(widget.actionName,
style: const TextStyle(fontSize: 16)),
),
]),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(AppTheme.cornerRadius + 4)),
content: SingleChildScrollView(
child: Column(
mainAxisSize: MainAxisSize.min,
children: widget.paramTypes.map(_buildParamWidget).toList(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('Annuler')),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor: widget.accentColor),
onPressed: () => Navigator.pop(context, _values),
child: const Text('Exécuter'),
),
],
);
}
Widget _buildParamWidget(Map<String, dynamic> param) {
final id = param['id'] as String? ?? '';
final name = param['displayName'] as String?
?? param['name'] as String?
?? id;
final type = param['type'] as String? ?? 'String';
final allowed = (param['allowedValues'] as List?)?.cast<dynamic>();
final unit = param['unit'] as String? ?? '';
// ── Bool ─────────────────────────────────────────────────────────────
if (type == 'Bool') {
final isTrue =
_values[id] == true || _values[id]?.toString() == 'true';
return SwitchListTile(
contentPadding: EdgeInsets.zero,
title: Text(name, style: const TextStyle(fontSize: 14)),
value: isTrue,
activeThumbColor: widget.accentColor,
onChanged: (v) => setState(() => _values[id] = v),
);
}
// ── Enum (allowedValues) ──────────────────────────────────────────────
if (allowed != null && allowed.isNotEmpty) {
final current = _values[id]?.toString() ?? allowed.first.toString();
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: Column(crossAxisAlignment: CrossAxisAlignment.start, children: [
Text(name,
style: const TextStyle(
fontSize: 12, color: AppTheme.textLight)),
const SizedBox(height: 4),
Container(
width: double.infinity,
padding:
const EdgeInsets.symmetric(horizontal: 12, vertical: 4),
decoration: BoxDecoration(
border: Border.all(color: Colors.grey[400]!),
borderRadius: BorderRadius.circular(8),
),
child: DropdownButton<String>(
value: current,
underline: const SizedBox(),
isExpanded: true,
items: allowed
.map((v) => DropdownMenuItem<String>(
value: v.toString(),
child: Text(v.toString()),
))
.toList(),
onChanged: (v) => setState(() => _values[id] = v),
),
),
]),
);
}
// ── Int / Double / String ─────────────────────────────────────────────
return Padding(
padding: const EdgeInsets.only(bottom: 12),
child: TextField(
controller: _controllers[id],
decoration: InputDecoration(
labelText: name,
suffixText: unit.isNotEmpty ? unit : null,
border: const OutlineInputBorder(),
isDense: true,
),
keyboardType: (type == 'Int' || type == 'Double')
? const TextInputType.numberWithOptions(decimal: true)
: TextInputType.text,
onChanged: (v) {
if (type == 'Int') {
_values[id] = int.tryParse(v) ?? _values[id];
} else if (type == 'Double') {
_values[id] = double.tryParse(v) ?? _values[id];
} else {
_values[id] = v;
}
},
),
);
}
}
// ─────────────────────────────────────────────────────────────────────────────
// _SectionLabel — label right-aligned + trait coloré (style nymea)
// ─────────────────────────────────────────────────────────────────────────────
class _SectionLabel extends StatelessWidget {
final String label;
final Color accentColor;
const _SectionLabel({required this.label, required this.accentColor});
@override
Widget build(BuildContext context) => Padding(
padding: const EdgeInsets.fromLTRB(16, 0, 16, 8),
child: Column(crossAxisAlignment: CrossAxisAlignment.end, children: [
Text(label,
style: const TextStyle(
fontSize: 13,
color: AppTheme.textLight,
fontWeight: FontWeight.w500)),
const SizedBox(height: 4),
Container(height: 1.5, color: accentColor),
]),
);
}
// ─────────────────────────────────────────────────────────────────────────────
// _FlatSection — conteneur blanc sans Card
// ─────────────────────────────────────────────────────────────────────────────
class _FlatSection extends StatelessWidget {
final List<Widget> children;
const _FlatSection({required this.children});
@override
Widget build(BuildContext context) => Container(
margin: const EdgeInsets.symmetric(horizontal: 16),
decoration: BoxDecoration(
color: AppTheme.cardWhite,
borderRadius: BorderRadius.circular(AppTheme.cornerRadius),
),
child: Column(children: children),
);
}
// ─────────────────────────────────────────────────────────────────────────────
// _StateRow — affiche un état nymea. Writable = interactif.
//
// onChanged != null → état writable (Switch interactif ou bouton edit)
// onChanged == null → read-only
// ─────────────────────────────────────────────────────────────────────────────
class _StateRow extends StatelessWidget {
final NymeaStateType stateType;
final dynamic value;
final bool isLast;
final Color accentColor;
/// Callback appelé avec la nouvelle valeur. Null = read-only.
final Future<void> Function(dynamic)? onChanged;
const _StateRow({
required this.stateType,
required this.value,
required this.isLast,
required this.accentColor,
this.onChanged,
});
bool get _isBool => stateType.type == 'Bool';
bool get _isTrue => value == true || value == 'true';
bool get _isConnected => stateType.name.toLowerCase() == 'connected';
bool get _isWritable => onChanged != null;
@override
Widget build(BuildContext context) {
return Column(children: [
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
child: Row(children: [
// Nom de l'état
Expanded(
child: Text(stateType.displayName,
style: const TextStyle(
fontSize: 14,
color: AppTheme.textDark,
fontWeight: FontWeight.w400)),
),
// ── Valeur / contrôle ────────────────────────────────────────
if (_isConnected)
// Pastille "Connected" avec glow
Container(
width: 14, height: 14,
decoration: BoxDecoration(
color: _isTrue ? accentColor : Colors.grey[400],
shape: BoxShape.circle,
boxShadow: _isTrue
? [BoxShadow(
color: accentColor.withValues(alpha: 0.4),
blurRadius: 4)]
: null,
),
)
else if (_isBool)
// Switch — interactif si writable
Switch(
value: _isTrue,
activeThumbColor: accentColor,
onChanged: _isWritable ? (v) => onChanged!(v) : null,
materialTapTargetSize: MaterialTapTargetSize.shrinkWrap,
)
else ...[
// Valeur texte
Text(
stateType.formatValue(value),
style: TextStyle(
fontSize: 14,
color: AppTheme.textDark,
fontWeight: FontWeight.w500),
),
// Bouton edit si writable
if (_isWritable) ...[
const SizedBox(width: 8),
GestureDetector(
onTap: () => _showEditDialog(context),
child: Icon(Icons.edit_outlined,
size: 17, color: accentColor.withValues(alpha: 0.7)),
),
],
],
]),
),
if (!isLast)
const Divider(
height: 1, indent: 16, endIndent: 16,
color: Color(0xFFEEEEEE)),
]);
}
/// Dialog d'édition pour les états non-bool writables.
void _showEditDialog(BuildContext context) async {
final allowed = stateType.allowedValues;
// ── Enum (allowedValues) ─────────────────────────────────────────────
if (allowed != null && allowed.isNotEmpty) {
final selected = await showDialog<dynamic>(
context: context,
builder: (ctx) => SimpleDialog(
title: Text(stateType.displayName),
children: allowed
.map((v) => SimpleDialogOption(
onPressed: () => Navigator.pop(ctx, v),
child: Text(v.toString(),
style: TextStyle(
color: v.toString() == value?.toString()
? accentColor
: AppTheme.textDark,
fontWeight:
v.toString() == value?.toString()
? FontWeight.bold
: FontWeight.normal)),
))
.toList(),
),
);
if (selected != null && context.mounted) {
await onChanged!(selected);
}
return;
}
// ── Numérique / texte ────────────────────────────────────────────────
final ctrl = TextEditingController(text: value?.toString() ?? '');
final newVal = await showDialog<dynamic>(
context: context,
builder: (ctx) => AlertDialog(
title: Text(stateType.displayName),
content: TextField(
controller: ctrl,
autofocus: true,
keyboardType: (stateType.type == 'Int' || stateType.type == 'Double')
? const TextInputType.numberWithOptions(decimal: true)
: TextInputType.text,
decoration: InputDecoration(
border: const OutlineInputBorder(),
suffixText: stateType.unit.isNotEmpty ? stateType.unit : null,
helperText: _rangeHint(),
),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(ctx),
child: const Text('Annuler')),
FilledButton(
style: FilledButton.styleFrom(backgroundColor: accentColor),
onPressed: () {
dynamic v = ctrl.text.trim();
if (stateType.type == 'Int') {
v = int.tryParse(ctrl.text.trim()) ?? value;
} else if (stateType.type == 'Double') {
v = double.tryParse(ctrl.text.trim()) ?? value;
}
Navigator.pop(ctx, v);
},
child: const Text('Appliquer'),
),
],
),
);
if (newVal != null && context.mounted) {
await onChanged!(newVal);
}
}
/// Hint de plage min/max pour le TextField.
String? _rangeHint() {
final min = stateType.minValue;
final max = stateType.maxValue;
if (min != null && max != null) {
return 'Entre ${min.toStringAsFixed(0)} et ${max.toStringAsFixed(0)}';
}
if (min != null) return 'Min : ${min.toStringAsFixed(0)}';
if (max != null) return 'Max : ${max.toStringAsFixed(0)}';
return null;
}
}
// ─────────────────────────────────────────────────────────────────────────────
// _InfoRow — ligne read-only (ID, classe, statut)
// ─────────────────────────────────────────────────────────────────────────────
class _InfoRow extends StatelessWidget {
final String label;
final String value;
final bool isLast;
const _InfoRow(this.label, this.value, {required this.isLast});
@override
Widget build(BuildContext context) => Column(children: [
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 11),
child: Row(children: [
Text(label,
style: const TextStyle(
fontSize: 13, color: AppTheme.textLight)),
const SizedBox(width: 16),
Expanded(
child: SelectableText(value,
textAlign: TextAlign.end,
style: const TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: AppTheme.textDark)),
),
]),
),
if (!isLast)
const Divider(
height: 1, indent: 16, endIndent: 16,
color: Color(0xFFEEEEEE)),
]);
}