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(); 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? params; if (action.paramTypes.isEmpty) { params = {}; } else { // Collecter les valeurs des paramètres via un dialog params = await showDialog>( 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> 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 _values; late Map _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 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(); 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( value: current, underline: const SizedBox(), isExpanded: true, items: allowed .map((v) => DropdownMenuItem( 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 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 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( 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( 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)), ]); }