etm-powersync-app/lib/screens/settings/protocols_screen.dart
Patrick Schurig 3a535e05fa feat(installateur): écran Protocoles — CRUD masters Modbus RTU
- modbus_models.dart : ModbusRtuMaster, SerialPort, enums parity/dataBits/stopBits, modbusErrorMessage()
- nymea_service : 5 RPC typées (GetSerialPorts, GetModbusRtuMasters, Add/Reconfigure/Remove), erreurs ModbusRtuError -> FR, guards sim/déco
- protocols_screen : liste + empty state, form add/edit (dropdowns contraints), suppression confirmée (avert. orphelinage), garde-fou installateur
- main.dart : route /settings/system/protocols (remplace le stub)

Validé contre nymea réel (hems .75) : champs/enums/modbusError confirmés, cas port USB absent géré.
Réf. UI_DATA_CONTRACT.md §6.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 13:25:58 +02:00

680 lines
23 KiB
Dart
Raw Permalink 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: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<ProtocolsScreen> createState() => _ProtocolsScreenState();
}
class _ProtocolsScreenState extends State<ProtocolsScreen> {
bool _loading = true;
List<ModbusRtuMaster> _masters = const [];
List<SerialPort> _ports = const [];
@override
void initState() {
super.initState();
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
}
Future<void> _load() async {
if (!mounted) return;
setState(() => _loading = true);
final svc = context.read<NymeaService>();
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<void> _openForm({ModbusRtuMaster? existing}) async {
final cfg = await Navigator.of(context).push<MasterConfig>(
MaterialPageRoute(
builder: (_) => _MasterFormScreen(ports: _ports, existing: existing),
),
);
if (cfg == null || !mounted) return;
final svc = context.read<NymeaService>();
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<void> _confirmDelete(ModbusRtuMaster m) async {
final ok = await showDialog<bool>(
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<NymeaService>().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<InstallerModeProvider>().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<void> 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<SerialPort> 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<String> 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<int> 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<MasterConfig>((
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<String>(
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<int>(
label: 'Baudrate',
value: _baudrate,
items: [
for (final b in _baudOptions)
DropdownMenuItem(value: b, child: Text('$b bps')),
],
onChanged: (v) => setState(() => _baudrate = v ?? _baudrate),
),
_DropdownField<String>(
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<String>(
label: 'Parité',
value: _parity,
items: [
for (final v in kParityValues)
DropdownMenuItem(value: v, child: Text(parityLabel(v))),
],
onChanged: (v) => setState(() => _parity = v ?? _parity),
),
_DropdownField<String>(
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<SerialPort?>()
.firstWhere((e) => true, orElse: () => null);
return p?.label ?? systemLocation;
}
}
// ── Champs ────────────────────────────────────────────────────────────────
class _DropdownField<T> extends StatelessWidget {
final String label;
final T? value;
final List<DropdownMenuItem<T>> items;
final ValueChanged<T?> 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<T>(
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)),
),
],
),
);
}