- 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>
680 lines
23 KiB
Dart
680 lines
23 KiB
Dart
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)),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|