import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:provider/provider.dart'; import '../../models/modbus_models.dart'; import '../../providers/installer_mode_provider.dart'; import '../../services/nymea_service.dart'; import '../../theme/etm_tokens.dart'; /// Config renvoyée par le form ajout/édition. typedef MasterConfig = ({ String serialPort, int baudrate, String parity, String dataBits, String stopBits, int numberOfRetries, int timeout, }); /// Écran **Protocoles → Modbus RTU master** (zone installateur). /// /// CRUD des masters RTU (`ModbusRtu.*`). Prérequis des meters SDM/abbterra /// (le thing meter prend le master comme parent). Aucun plugin, aucune série. class ProtocolsScreen extends StatefulWidget { const ProtocolsScreen({super.key}); @override State createState() => _ProtocolsScreenState(); } class _ProtocolsScreenState extends State { bool _loading = true; List _masters = const []; List _ports = const []; @override void initState() { super.initState(); WidgetsBinding.instance.addPostFrameCallback((_) => _load()); } Future _load() async { if (!mounted) return; setState(() => _loading = true); final svc = context.read(); final masters = await svc.getModbusRtuMasters(); final ports = await svc.getSerialPorts(); if (!mounted) return; setState(() { _masters = masters; _ports = ports; _loading = false; }); } void _snack(String msg, {bool error = false}) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar(SnackBar( content: Text(msg), backgroundColor: error ? EtmTokens.danger : null, )); } Future _openForm({ModbusRtuMaster? existing}) async { final cfg = await Navigator.of(context).push( MaterialPageRoute( builder: (_) => _MasterFormScreen(ports: _ports, existing: existing), ), ); if (cfg == null || !mounted) return; final svc = context.read(); final err = existing == null ? await svc.addModbusRtuMaster( serialPort: cfg.serialPort, baudrate: cfg.baudrate, parity: cfg.parity, dataBits: cfg.dataBits, stopBits: cfg.stopBits, numberOfRetries: cfg.numberOfRetries, timeout: cfg.timeout, ) : await svc.reconfigureModbusRtuMaster( modbusUuid: existing.modbusUuid, serialPort: cfg.serialPort, baudrate: cfg.baudrate, parity: cfg.parity, dataBits: cfg.dataBits, stopBits: cfg.stopBits, numberOfRetries: cfg.numberOfRetries, timeout: cfg.timeout, ); if (!mounted) return; if (err == null) { _snack(existing == null ? 'Master ajouté.' : 'Master mis à jour.'); _load(); } else { _snack(err, error: true); } } Future _confirmDelete(ModbusRtuMaster m) async { final ok = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Supprimer ce master ?'), content: Text( 'Le master ${m.serialPort} sera supprimé. ⚠️ Les compteurs (SDM, ' 'abbterra…) rattachés à ce master deviendront inutilisables.', ), actions: [ TextButton( onPressed: () => Navigator.pop(ctx, false), child: const Text('Annuler')), TextButton( onPressed: () => Navigator.pop(ctx, true), child: Text('Supprimer', style: EtmTokens.sans( color: EtmTokens.danger, weight: FontWeight.w700)), ), ], ), ); if (ok != true || !mounted) return; final err = await context.read().removeModbusRtuMaster(m.modbusUuid); if (!mounted) return; if (err == null) { _snack('Master supprimé.'); _load(); } else { _snack(err, error: true); } } @override Widget build(BuildContext context) { final installer = context.watch().isUnlocked; // Garde-fou défensif (le drawer masque déjà cette route hors installateur). if (!installer) { return _Scaffold( body: Center( child: Padding( padding: const EdgeInsets.all(32), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.lock_outline_rounded, size: 40, color: EtmTokens.faintOf(context)), const SizedBox(height: 12), Text('Réservé au mode installateur', textAlign: TextAlign.center, style: EtmTokens.sans( size: 14, color: EtmTokens.mutedOf(context))), ], ), ), ), ); } return _Scaffold( onRefresh: _load, fab: _masters.isEmpty || _loading ? null : FloatingActionButton.extended( backgroundColor: EtmTokens.brand, onPressed: () => _openForm(), icon: const Icon(Icons.add, color: Colors.white), label: Text('Ajouter', style: EtmTokens.sans( weight: FontWeight.w700, color: Colors.white)), ), body: _loading ? const Center(child: CircularProgressIndicator()) : _masters.isEmpty ? _EmptyState(onAdd: () => _openForm()) : ListView.separated( padding: const EdgeInsets.fromLTRB(16, 16, 16, 96), itemCount: _masters.length, separatorBuilder: (_, _) => const SizedBox(height: 10), itemBuilder: (_, i) => _MasterCard( master: _masters[i], onEdit: () => _openForm(existing: _masters[i]), onDelete: () => _confirmDelete(_masters[i]), ), ), ); } } // ═══════════════════════════════════════════════════════════════════════════ // Scaffold commun // ═══════════════════════════════════════════════════════════════════════════ class _Scaffold extends StatelessWidget { final Widget body; final Widget? fab; final Future Function()? onRefresh; const _Scaffold({required this.body, this.fab, this.onRefresh}); @override Widget build(BuildContext context) { return Scaffold( backgroundColor: EtmTokens.bgOf(context), appBar: AppBar( title: const Text('Protocoles · Modbus RTU'), backgroundColor: EtmTokens.surfaceOf(context), foregroundColor: EtmTokens.inkOf(context), elevation: 0, actions: [ if (onRefresh != null) IconButton( tooltip: 'Rafraîchir', icon: const Icon(Icons.refresh_rounded), onPressed: () => onRefresh!(), ), ], ), floatingActionButton: fab, body: body, ); } } // ═══════════════════════════════════════════════════════════════════════════ // Liste / empty state / carte // ═══════════════════════════════════════════════════════════════════════════ class _EmptyState extends StatelessWidget { final VoidCallback onAdd; const _EmptyState({required this.onAdd}); @override Widget build(BuildContext context) { return Center( child: Padding( padding: const EdgeInsets.all(28), child: Column( mainAxisSize: MainAxisSize.min, children: [ Icon(Icons.cable_rounded, size: 40, color: EtmTokens.faintOf(context)), const SizedBox(height: 14), Text('Aucun master Modbus RTU', style: EtmTokens.sans( size: 15, weight: FontWeight.w700, color: EtmTokens.inkOf(context))), const SizedBox(height: 6), Text( 'Ajoutez un master sur un port série pour y raccorder vos ' 'compteurs (SDM, abbterra…).', textAlign: TextAlign.center, style: EtmTokens.sans( size: 12.5, color: EtmTokens.mutedOf(context), height: 1.35), ), const SizedBox(height: 18), FilledButton.icon( style: FilledButton.styleFrom( backgroundColor: EtmTokens.brand, padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 12), ), onPressed: onAdd, icon: const Icon(Icons.add, color: Colors.white), label: Text('Ajouter un master', style: EtmTokens.sans( weight: FontWeight.w700, color: Colors.white)), ), ], ), ), ); } } class _MasterCard extends StatelessWidget { final ModbusRtuMaster master; final VoidCallback onEdit; final VoidCallback onDelete; const _MasterCard( {required this.master, required this.onEdit, required this.onDelete}); @override Widget build(BuildContext context) { return Container( padding: const EdgeInsets.all(14), decoration: BoxDecoration( color: EtmTokens.surfaceOf(context), borderRadius: BorderRadius.circular(EtmTokens.radiusCard), boxShadow: EtmTokens.cardShadowOf(context), ), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Row( children: [ Container( width: 8, height: 8, margin: const EdgeInsets.only(right: 10), decoration: BoxDecoration( shape: BoxShape.circle, color: master.connected ? EtmTokens.eco : EtmTokens.faintOf(context), ), ), Expanded( child: Text(master.serialPort, style: EtmTokens.mono( size: 14, weight: FontWeight.w700, color: EtmTokens.inkOf(context))), ), _StateChip( master.connected ? 'connecté' : 'hors-ligne', master.connected ? EtmTokens.eco : EtmTokens.faintOf(context), ), ], ), const SizedBox(height: 8), Text(master.summary, style: EtmTokens.sans( size: 12.5, color: EtmTokens.mutedOf(context))), const SizedBox(height: 2), Text('${master.numberOfRetries} retries · timeout ${master.timeout} ms', style: EtmTokens.sans(size: 11.5, color: EtmTokens.faintOf(context))), const SizedBox(height: 6), Row( mainAxisAlignment: MainAxisAlignment.end, children: [ TextButton.icon( onPressed: onEdit, icon: const Icon(Icons.edit_outlined, size: 18), label: const Text('Éditer'), ), TextButton.icon( onPressed: onDelete, style: TextButton.styleFrom(foregroundColor: EtmTokens.danger), icon: const Icon(Icons.delete_outline_rounded, size: 18), label: const Text('Supprimer'), ), ], ), ], ), ); } } class _StateChip extends StatelessWidget { final String text; final Color color; const _StateChip(this.text, this.color); @override Widget build(BuildContext context) => Container( padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 3), decoration: BoxDecoration( color: color.withValues(alpha: 0.14), borderRadius: BorderRadius.circular(EtmTokens.radiusPill), ), child: Text(text, style: EtmTokens.sans( size: 10.5, weight: FontWeight.w700, color: color)), ); } // ═══════════════════════════════════════════════════════════════════════════ // Form ajout / édition // ═══════════════════════════════════════════════════════════════════════════ class _MasterFormScreen extends StatefulWidget { final List ports; final ModbusRtuMaster? existing; const _MasterFormScreen({required this.ports, this.existing}); @override State<_MasterFormScreen> createState() => _MasterFormScreenState(); } class _MasterFormScreenState extends State<_MasterFormScreen> { String? _serialPort; late int _baudrate; late String _parity; late String _dataBits; late String _stopBits; late TextEditingController _retries; late TextEditingController _timeout; String? _error; @override void initState() { super.initState(); final e = widget.existing; _serialPort = e?.serialPort; _baudrate = e?.baudrate ?? kDefaultBaudrate; _parity = e?.parity ?? kDefaultParity; _dataBits = e?.dataBits ?? kDefaultDataBits; _stopBits = e?.stopBits ?? kDefaultStopBits; _retries = TextEditingController(text: '${e?.numberOfRetries ?? kDefaultRetries}'); _timeout = TextEditingController(text: '${e?.timeout ?? kDefaultTimeout}'); } @override void dispose() { _retries.dispose(); _timeout.dispose(); super.dispose(); } /// Ports proposés = ports détectés ∪ port courant (édition d'un port absent). List get _portOptions { final opts = widget.ports.map((p) => p.systemLocation).toList(); final cur = widget.existing?.serialPort; if (cur != null && cur.isNotEmpty && !opts.contains(cur)) opts.add(cur); return opts; } List get _baudOptions { final opts = [...kBaudrates]; if (!opts.contains(_baudrate)) opts.add(_baudrate); opts.sort(); return opts; } void _save() { final port = _serialPort; if (port == null || port.isEmpty) { setState(() => _error = 'Choisissez un port série.'); return; } final retries = int.tryParse(_retries.text.trim()) ?? -1; final timeout = int.tryParse(_timeout.text.trim()) ?? -1; if (retries < 0) { setState(() => _error = 'Nombre de tentatives invalide.'); return; } if (timeout < 10) { setState(() => _error = 'Timeout minimum : 10 ms.'); return; } Navigator.of(context).pop(( serialPort: port, baudrate: _baudrate, parity: _parity, dataBits: _dataBits, stopBits: _stopBits, numberOfRetries: retries, timeout: timeout, )); } @override Widget build(BuildContext context) { final ports = _portOptions; return Scaffold( backgroundColor: EtmTokens.bgOf(context), appBar: AppBar( title: Text(widget.existing == null ? 'Nouveau master' : 'Éditer master'), backgroundColor: EtmTokens.surfaceOf(context), foregroundColor: EtmTokens.inkOf(context), elevation: 0, ), body: ListView( padding: const EdgeInsets.fromLTRB(16, 16, 16, 32), children: [ if (ports.isEmpty) _Banner( 'Aucun port série détecté. Branchez un adaptateur USB-RS485 puis ' 'rafraîchissez.', ), _DropdownField( label: 'Port série', value: ports.contains(_serialPort) ? _serialPort : null, items: [ for (final p in ports) DropdownMenuItem(value: p, child: Text(_portLabel(p))), ], onChanged: (v) => setState(() => _serialPort = v), hint: 'Sélectionner…', ), _DropdownField( label: 'Baudrate', value: _baudrate, items: [ for (final b in _baudOptions) DropdownMenuItem(value: b, child: Text('$b bps')), ], onChanged: (v) => setState(() => _baudrate = v ?? _baudrate), ), _DropdownField( label: 'Bits de données', value: _dataBits, items: [ for (final v in kDataBitsValues) DropdownMenuItem(value: v, child: Text(dataBitsLabel(v))), ], onChanged: (v) => setState(() => _dataBits = v ?? _dataBits), ), _DropdownField( label: 'Parité', value: _parity, items: [ for (final v in kParityValues) DropdownMenuItem(value: v, child: Text(parityLabel(v))), ], onChanged: (v) => setState(() => _parity = v ?? _parity), ), _DropdownField( label: 'Bits de stop', value: _stopBits, items: [ for (final v in kStopBitsValues) DropdownMenuItem(value: v, child: Text(stopBitsLabel(v))), ], onChanged: (v) => setState(() => _stopBits = v ?? _stopBits), ), _NumberField(label: 'Tentatives (retries)', controller: _retries), _NumberField(label: 'Timeout (ms, min 10)', controller: _timeout), if (_error != null) ...[ const SizedBox(height: 4), Text(_error!, style: EtmTokens.sans(size: 12.5, color: EtmTokens.danger)), ], const SizedBox(height: 18), SizedBox( width: double.infinity, child: FilledButton( style: FilledButton.styleFrom( backgroundColor: EtmTokens.brand, padding: const EdgeInsets.symmetric(vertical: 14), shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), ), ), onPressed: _save, child: Text(widget.existing == null ? 'Ajouter' : 'Enregistrer', style: EtmTokens.sans( size: 14, weight: FontWeight.w700, color: Colors.white)), ), ), ], ), ); } String _portLabel(String systemLocation) { final p = widget.ports .where((e) => e.systemLocation == systemLocation) .cast() .firstWhere((e) => true, orElse: () => null); return p?.label ?? systemLocation; } } // ── Champs ──────────────────────────────────────────────────────────────── class _DropdownField extends StatelessWidget { final String label; final T? value; final List> items; final ValueChanged onChanged; final String? hint; const _DropdownField({ required this.label, required this.value, required this.items, required this.onChanged, this.hint, }); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: EtmTokens.sans( size: 12, weight: FontWeight.w600, color: EtmTokens.mutedOf(context))), const SizedBox(height: 6), DropdownButtonFormField( initialValue: value, isExpanded: true, hint: hint != null ? Text(hint!) : null, items: items, onChanged: onChanged, decoration: InputDecoration( isDense: true, filled: true, fillColor: EtmTokens.bg2Of(context), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), border: OutlineInputBorder( borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), borderSide: BorderSide.none, ), ), ), ], ), ); } } class _NumberField extends StatelessWidget { final String label; final TextEditingController controller; const _NumberField({required this.label, required this.controller}); @override Widget build(BuildContext context) { return Padding( padding: const EdgeInsets.only(bottom: 14), child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ Text(label, style: EtmTokens.sans( size: 12, weight: FontWeight.w600, color: EtmTokens.mutedOf(context))), const SizedBox(height: 6), TextField( controller: controller, keyboardType: TextInputType.number, inputFormatters: [FilteringTextInputFormatter.digitsOnly], style: EtmTokens.mono(size: 14, color: EtmTokens.inkOf(context)), decoration: InputDecoration( isDense: true, filled: true, fillColor: EtmTokens.bg2Of(context), contentPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 12), border: OutlineInputBorder( borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), borderSide: BorderSide.none, ), ), ), ], ), ); } } class _Banner extends StatelessWidget { final String text; const _Banner(this.text); @override Widget build(BuildContext context) => Container( margin: const EdgeInsets.only(bottom: 14), padding: const EdgeInsets.all(12), decoration: BoxDecoration( color: EtmTokens.solar.withValues(alpha: 0.12), borderRadius: BorderRadius.circular(EtmTokens.radiusCtrl), ), child: Row( children: [ const Icon(Icons.info_outline_rounded, size: 18, color: EtmTokens.heat), const SizedBox(width: 10), Expanded( child: Text(text, style: EtmTokens.sans( size: 12.5, color: EtmTokens.inkOf(context), height: 1.3)), ), ], ), ); }