Énergie : - Écran Énergie reécrit : line chart (production/conso/autoconso/batterie) et bar chart (bilan Wh par période) avec onglets 15 min / 1 h / 1 j / 1 sem - Datepicker pour sélectionner une période historique (chip dismissible) - Timelines des deux graphiques alignées (même x=i → data[i].timestamp) - PowerBalanceEntry + fetchPowerBalanceLogs() + simulation sinusoïdale - Overflow fixes : energy_flow_widget (Expanded sur titre), production_card Things : - Navigation 3 niveaux : ThingsScreen → CategoryOverviewScreen → ThingDetailScreen - Catégorie Cars ajoutée, carrousel corrigé (clamp RangeError) - ThingDetailScreen : executeAction, setStateValue, activeThumbColor fix - NymeaTile widget, state_history_chart widget (générique Logging.GetLogEntries) Modèles / service : - HistoryEntry, PowerBalanceEntry ajoutés - fetchHistory(), fetchPowerBalanceLogs() dans NymeaService - interfaceToCategoryMap étendu (Cars, etc.) - AppTheme : nouvelles couleurs (accentTeal, boostRed, pvGreen, minPvBlue…) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
811 lines
31 KiB
Dart
811 lines
31 KiB
Dart
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.color),
|
||
_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.color,
|
||
// 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.color),
|
||
_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.color,
|
||
// 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.color),
|
||
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.color,
|
||
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.color),
|
||
))
|
||
.toList(),
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
],
|
||
|
||
// ── Interfaces ─────────────────────────────────────────────────────
|
||
if (cls != null && cls.interfaces.isNotEmpty) ...[
|
||
_SectionLabel(label: 'Interfaces', accentColor: catInfo.color),
|
||
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.color.withValues(alpha: 0.1),
|
||
side: BorderSide(
|
||
color:
|
||
catInfo.color.withValues(alpha: 0.3)),
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 4),
|
||
))
|
||
.toList(),
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
],
|
||
|
||
// ── Informations ───────────────────────────────────────────────────
|
||
_SectionLabel(label: 'Informations', accentColor: catInfo.color),
|
||
_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.color),
|
||
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)),
|
||
]);
|
||
}
|