import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:p_tits_pas/models/relais_model.dart'; import 'package:p_tits_pas/services/relais_service.dart'; class RelaisManagementPanel extends StatefulWidget { const RelaisManagementPanel({super.key}); @override State createState() => _RelaisManagementPanelState(); } class _RelaisManagementPanelState extends State { bool _isLoading = false; String? _error; List _relais = []; String? _hoveredRelaisId; @override void initState() { super.initState(); _loadRelais(); } Future _loadRelais() async { setState(() { _isLoading = true; _error = null; }); try { final list = await RelaisService.getRelais(); if (!mounted) return; setState(() { _relais = list; _isLoading = false; }); } catch (e) { if (!mounted) return; setState(() { _error = e.toString().replaceAll('Exception: ', ''); _isLoading = false; }); } } Future _openRelaisForm({RelaisModel? relais}) async { final result = await showDialog<_RelaisDialogResult>( context: context, builder: (context) => _RelaisFormDialog(initial: relais), ); if (result == null) return; if (!mounted) return; try { if (result.action == _RelaisDialogAction.delete) { if (relais == null) return; final confirmed = await showDialog( context: context, builder: (ctx) => AlertDialog( title: const Text('Supprimer le relais'), content: Text('Confirmer la suppression de "${relais.nom}" ?'), actions: [ TextButton( onPressed: () => Navigator.of(ctx).pop(false), child: const Text('Annuler'), ), FilledButton( onPressed: () => Navigator.of(ctx).pop(true), style: FilledButton.styleFrom( backgroundColor: Colors.red.shade700, ), child: const Text('Supprimer'), ), ], ), ); if (confirmed != true) return; await RelaisService.deleteRelais(relais.id); } else if (relais == null) { await RelaisService.createRelais(result.payload!); } else { await RelaisService.updateRelais(relais.id, result.payload!); } if (!mounted) return; await _loadRelais(); if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( result.action == _RelaisDialogAction.delete ? 'Relais supprimé.' : (relais == null ? 'Relais créé.' : 'Relais mis à jour.'), ), ), ); } catch (e) { if (!mounted) return; ScaffoldMessenger.of(context).showSnackBar( SnackBar( content: Text( e.toString().replaceAll('Exception: ', ''), ), backgroundColor: Colors.red.shade600, ), ); } } String _horairesSummary(Map? horaires) { if (horaires == null || horaires.isEmpty) { return 'Horaires non renseignés'; } final actifs = horaires.entries.where((entry) { final value = entry.value; if (value is Map) { final ferme = value['ferme'] == true; if (ferme) return false; final matinOuverture = value['matin_ouverture']?.toString() ?? ''; final matinFermeture = value['matin_fermeture']?.toString() ?? ''; final soirOuverture = value['soir_ouverture']?.toString() ?? ''; final soirFermeture = value['soir_fermeture']?.toString() ?? ''; final matinOuvert = matinOuverture.isNotEmpty && matinFermeture.isNotEmpty; final soirOuvert = soirOuverture.isNotEmpty && soirFermeture.isNotEmpty; return matinOuvert || soirOuvert; } return false; }).length; return '$actifs jour(s) ouvert(s)'; } @override Widget build(BuildContext context) { return SingleChildScrollView( padding: const EdgeInsets.all(24), child: Center( child: ConstrainedBox( constraints: const BoxConstraints(maxWidth: 720), child: Card( elevation: 2, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(12), ), child: Padding( padding: const EdgeInsets.all(24), child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ Row( children: [ const Icon( Icons.location_city_outlined, size: 22, color: Color(0xFF9CC5C0), ), const SizedBox(width: 10), Text( 'Gestion des relais', style: Theme.of(context).textTheme.titleMedium?.copyWith( fontWeight: FontWeight.bold, color: const Color(0xFF2D6A4F), ), ), const Spacer(), ElevatedButton.icon( onPressed: () => _openRelaisForm(), icon: const Icon(Icons.add), label: const Text('Ajouter un relais'), ), ], ), const SizedBox(height: 16), Container( height: 390, padding: const EdgeInsets.all(10), decoration: BoxDecoration( color: Colors.white, borderRadius: BorderRadius.circular(10), border: Border.all(color: Colors.black12), ), child: _isLoading ? const Center(child: CircularProgressIndicator()) : _error != null ? Center( child: Text( _error!, style: TextStyle(color: Colors.red.shade700), ), ) : _relais.isEmpty ? const Center( child: Text('Aucun relais configuré.'), ) : ListView.separated( itemCount: _relais.length, separatorBuilder: (_, __) => const SizedBox(height: 10), itemBuilder: (context, index) { final relais = _relais[index]; final isInactive = !relais.actif; final isHovered = _hoveredRelaisId == relais.id; final subtitle = [ relais.adresse, if (relais.ligneFixe?.isNotEmpty == true) 'Ligne fixe : ${relais.ligneFixe}', _horairesSummary( relais.horairesOuverture), 'Statut : ${relais.actif ? 'Actif' : 'Inactif'}', if (relais.notes?.isNotEmpty == true) 'Notes : ${relais.notes}', ]; return MouseRegion( onEnter: (_) => setState( () => _hoveredRelaisId = relais.id, ), onExit: (_) => setState( () => _hoveredRelaisId = null, ), child: Card( color: isInactive ? const Color(0xFFFFF4F4) : null, shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(10), side: BorderSide( color: isInactive ? const Color(0xFFFFD0D0) : Colors.transparent, ), ), child: Padding( padding: const EdgeInsets.symmetric( horizontal: 12, vertical: 10, ), child: Row( children: [ const Icon( Icons.location_city_outlined, ), const SizedBox(width: 12), Expanded( child: Column( crossAxisAlignment: CrossAxisAlignment .start, children: [ Text( relais.nom, style: TextStyle( fontWeight: FontWeight.w600, color: isInactive ? const Color( 0xFF8A3A3A) : null, ), ), const SizedBox(height: 2), Text( subtitle .join(' • '), maxLines: 2, overflow: TextOverflow .ellipsis, style: const TextStyle( color: Colors.black54, fontSize: 12, ), ), ], ), ), SizedBox( width: 36, child: AnimatedOpacity( duration: const Duration( milliseconds: 120, ), opacity: isHovered ? 1 : 0, child: IgnorePointer( ignoring: !isHovered, child: IconButton( onPressed: () => _openRelaisForm( relais: relais), icon: const Icon( Icons.edit), tooltip: 'Modifier', ), ), ), ), ], ), ), ), ); }, ), ), ], ), ), ), ), ), ); } } class _RelaisFormDialog extends StatefulWidget { final RelaisModel? initial; const _RelaisFormDialog({required this.initial}); @override State<_RelaisFormDialog> createState() => _RelaisFormDialogState(); } class _RelaisFormDialogState extends State<_RelaisFormDialog> { static const double _targetTimeFieldWidth = 112; static const double _minTimeFieldWidth = 96; static const double _gap = 8; static const double _dayLabelWidth = 70; static const double _closedAreaWidth = 86; static const double _separatorLineWidth = 1; static const double _groupSeparatorWidth = (_gap * 2) + _separatorLineWidth; static const double _modalInnerPadding = 16; static const double _modalHorizontalSafetyMargin = 24; late final TextEditingController _nomCtrl; late final TextEditingController _streetCtrl; late final TextEditingController _postalCodeCtrl; late final TextEditingController _cityCtrl; late final TextEditingController _ligneFixeCtrl; late final TextEditingController _notesCtrl; final Map _morningOpenCtrls = {}; final Map _morningCloseCtrls = {}; final Map _eveningOpenCtrls = {}; final Map _eveningCloseCtrls = {}; final Map _closedByDay = {}; bool _actif = true; static const List _days = [ 'lundi', 'mardi', 'mercredi', 'jeudi', 'vendredi', 'samedi', 'dimanche', ]; @override void initState() { super.initState(); final initial = widget.initial; _nomCtrl = TextEditingController(text: initial?.nom ?? ''); final addressParts = _splitAddress(initial?.adresse); _streetCtrl = TextEditingController(text: addressParts.street); _postalCodeCtrl = TextEditingController(text: addressParts.postalCode); _cityCtrl = TextEditingController(text: addressParts.city); _ligneFixeCtrl = TextEditingController(text: initial?.ligneFixe ?? ''); _notesCtrl = TextEditingController(text: initial?.notes ?? ''); _actif = initial?.actif ?? true; final horaires = initial?.horairesOuverture ?? {}; for (final day in _days) { final value = horaires[day]; String matinOuverture = ''; String matinFermeture = ''; String soirOuverture = ''; String soirFermeture = ''; bool ferme = false; if (value is Map) { matinOuverture = _normalizeTime( value['matin_ouverture']?.toString() ?? value['ouverture']?.toString(), ); matinFermeture = _normalizeTime( value['matin_fermeture']?.toString() ?? value['fermeture']?.toString(), ); soirOuverture = _normalizeTime(value['soir_ouverture']?.toString()); soirFermeture = _normalizeTime(value['soir_fermeture']?.toString()); ferme = value['ferme'] == true; } else if (value is String && value.contains('-')) { final parts = value.split('-'); if (parts.length == 2) { matinOuverture = _normalizeTime(parts[0].trim()); matinFermeture = _normalizeTime(parts[1].trim()); } } _morningOpenCtrls[day] = TextEditingController(text: matinOuverture); _morningCloseCtrls[day] = TextEditingController(text: matinFermeture); _eveningOpenCtrls[day] = TextEditingController(text: soirOuverture); _eveningCloseCtrls[day] = TextEditingController(text: soirFermeture); final isWeekend = day == 'samedi' || day == 'dimanche'; _closedByDay[day] = widget.initial == null ? isWeekend : ferme; } } @override void dispose() { _nomCtrl.dispose(); _streetCtrl.dispose(); _postalCodeCtrl.dispose(); _cityCtrl.dispose(); _ligneFixeCtrl.dispose(); _notesCtrl.dispose(); for (final c in _morningOpenCtrls.values) { c.dispose(); } for (final c in _morningCloseCtrls.values) { c.dispose(); } for (final c in _eveningOpenCtrls.values) { c.dispose(); } for (final c in _eveningCloseCtrls.values) { c.dispose(); } super.dispose(); } Map _buildPayload() { final horaires = {}; for (final day in _days) { final ferme = _closedByDay[day] ?? false; final matinOuverture = _morningOpenCtrls[day]!.text.trim(); final matinFermeture = _morningCloseCtrls[day]!.text.trim(); final soirOuverture = _eveningOpenCtrls[day]!.text.trim(); final soirFermeture = _eveningCloseCtrls[day]!.text.trim(); horaires[day] = { 'matin_ouverture': matinOuverture, 'matin_fermeture': matinFermeture, 'soir_ouverture': soirOuverture, 'soir_fermeture': soirFermeture, 'ferme': ferme, }; } final payload = { 'nom': _nomCtrl.text.trim(), 'adresse': _composeAddress(), 'actif': _actif, 'horaires_ouverture': horaires, }; final ligneFixe = _ligneFixeCtrl.text.trim(); if (ligneFixe.isNotEmpty) { payload['ligne_fixe'] = ligneFixe; } final notes = _notesCtrl.text.trim(); if (notes.isNotEmpty) { payload['notes'] = notes; } return payload; } bool _isValid() { if (_nomCtrl.text.trim().isEmpty) { return false; } if (_streetCtrl.text.trim().isEmpty || _cityCtrl.text.trim().isEmpty) { return false; } if (!_isValidPostalCode(_postalCodeCtrl.text.trim())) { return false; } for (final day in _days) { final ferme = _closedByDay[day] ?? false; if (ferme) continue; final matinOuverture = _morningOpenCtrls[day]!.text.trim(); final matinFermeture = _morningCloseCtrls[day]!.text.trim(); final soirOuverture = _eveningOpenCtrls[day]!.text.trim(); final soirFermeture = _eveningCloseCtrls[day]!.text.trim(); if (!_isValidSlot(matinOuverture, matinFermeture) || !_isValidSlot(soirOuverture, soirFermeture)) { return false; } } return true; } bool _isValidSlot(String start, String end) { final bothEmpty = start.isEmpty && end.isEmpty; if (bothEmpty) return true; if (start.isEmpty || end.isEmpty) return false; return _isValidTime(start) && _isValidTime(end); } bool _isValidPostalCode(String value) { return RegExp(r'^\d{5}$').hasMatch(value); } _AddressParts _splitAddress(String? rawAddress) { if (rawAddress == null || rawAddress.trim().isEmpty) { return const _AddressParts(street: '', postalCode: '', city: ''); } final raw = rawAddress.trim().replaceAll(RegExp(r'\s+'), ' '); final postalCityMatch = RegExp(r'^(.+?)[,\s]+(\d{5})\s+(.+)$').firstMatch(raw); if (postalCityMatch != null) { final street = (postalCityMatch.group(1) ?? '') .replaceAll(RegExp(r'[,;\s]+$'), '') .trim(); final postalCode = (postalCityMatch.group(2) ?? '').trim(); final city = (postalCityMatch.group(3) ?? '').trim(); return _AddressParts( street: street, postalCode: postalCode, city: city, ); } return _AddressParts(street: raw, postalCode: '', city: ''); } String _composeAddress() { final street = _streetCtrl.text.trim(); final postalCode = _postalCodeCtrl.text.trim(); final city = _cityCtrl.text.trim(); if (postalCode.isEmpty && city.isEmpty) { return street; } return '$street, $postalCode $city'.trim(); } bool _isValidTime(String value) { final match = RegExp(r'^([01]\d|2[0-3]):([0-5]\d)$').firstMatch(value); return match != null; } String _normalizeTime(String? raw) { if (raw == null || raw.trim().isEmpty) return ''; final compact = raw.replaceAll(RegExp(r'\D'), ''); if (compact.length == 4) { return '${compact.substring(0, 2)}:${compact.substring(2, 4)}'; } final trimmed = raw.trim(); return _isValidTime(trimmed) ? trimmed : ''; } Future _pickTime(TextEditingController controller) async { final currentText = controller.text.trim(); TimeOfDay initial = const TimeOfDay(hour: 9, minute: 0); if (_isValidTime(currentText)) { final parts = currentText.split(':'); initial = TimeOfDay( hour: int.parse(parts[0]), minute: int.parse(parts[1]), ); } final picked = await showTimePicker( context: context, initialTime: initial, builder: (context, child) { return MediaQuery( data: MediaQuery.of(context).copyWith(alwaysUse24HourFormat: true), child: child ?? const SizedBox.shrink(), ); }, ); if (picked == null) return; final hh = picked.hour.toString().padLeft(2, '0'); final mm = picked.minute.toString().padLeft(2, '0'); setState(() { controller.text = '$hh:$mm'; }); } @override Widget build(BuildContext context) { final isCreation = widget.initial == null; final availableWidth = MediaQuery.of(context).size.width - _modalHorizontalSafetyMargin; const preferredHoursWidth = _dayLabelWidth + ((_targetTimeFieldWidth * 2) + _gap) + _groupSeparatorWidth + ((_targetTimeFieldWidth * 2) + _gap) + _gap + _closedAreaWidth; const preferredDialogWidth = preferredHoursWidth + (_modalInnerPadding * 2); final dialogWidth = preferredDialogWidth.clamp(360.0, availableWidth).toDouble(); return Dialog( child: ConstrainedBox( constraints: BoxConstraints( maxWidth: dialogWidth, minWidth: dialogWidth, maxHeight: 700, ), child: Padding( padding: const EdgeInsets.all(_modalInnerPadding), child: Column( mainAxisSize: MainAxisSize.min, children: [ Row( children: [ Text( isCreation ? 'Nouveau relais' : 'Modifier relais', style: const TextStyle( fontSize: 18, fontWeight: FontWeight.w700, ), ), const Spacer(), if (!isCreation) IconButton( onPressed: () => Navigator.of(context).pop(), icon: const Icon(Icons.close), ), ], ), const SizedBox(height: 8), Expanded( child: SingleChildScrollView( child: Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ const SizedBox(height: 6), _buildTechniqueFields(), const SizedBox(height: 16), _buildTerritorialFields(), ], ), ), ), const SizedBox(height: 12), Align( alignment: Alignment.centerRight, child: Row( mainAxisSize: MainAxisSize.min, children: [ if (isCreation) ...[ TextButton( onPressed: () => Navigator.of(context).pop(), child: const Text('Annuler'), ), const SizedBox(width: 8), FilledButton( onPressed: _isValid() ? () => Navigator.of(context).pop( _RelaisDialogResult( action: _RelaisDialogAction.save, payload: _buildPayload(), ), ) : null, child: const Text('Créer'), ), ] else ...[ OutlinedButton( onPressed: () => Navigator.of(context).pop( const _RelaisDialogResult( action: _RelaisDialogAction.delete, ), ), child: const Text('Supprimer'), ), const SizedBox(width: 8), FilledButton( onPressed: _isValid() ? () => Navigator.of(context).pop( _RelaisDialogResult( action: _RelaisDialogAction.save, payload: _buildPayload(), ), ) : null, child: const Text('Modifier'), ), ], ], ), ), ], ), ), ), ); } Widget _buildTechniqueFields() { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextField( controller: _nomCtrl, onChanged: (_) => setState(() {}), decoration: const InputDecoration( labelText: 'Nom du relais *', border: OutlineInputBorder(), ), ), const SizedBox(height: 12), TextField( controller: _ligneFixeCtrl, keyboardType: TextInputType.phone, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(10), _FrenchPhoneNumberFormatter(), ], decoration: const InputDecoration( labelText: 'Ligne fixe', hintText: '01 23 45 67 89', border: OutlineInputBorder(), ), ), const SizedBox(height: 12), SwitchListTile( contentPadding: EdgeInsets.zero, title: const Text('Relais actif'), value: _actif, onChanged: (value) => setState(() => _actif = value), ), ], ); } Widget _buildTerritorialFields() { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ _RelaisAddressFields( streetController: _streetCtrl, postalCodeController: _postalCodeCtrl, cityController: _cityCtrl, onChanged: () => setState(() {}), ), const SizedBox(height: 12), TextField( controller: _notesCtrl, maxLines: 3, decoration: const InputDecoration( labelText: 'Notes', border: OutlineInputBorder(), ), ), const SizedBox(height: 12), Align( alignment: Alignment.centerLeft, child: Text( 'Horaires hebdomadaires', style: TextStyle( fontWeight: FontWeight.w600, color: Colors.grey.shade800, ), ), ), const SizedBox(height: 6), LayoutBuilder( builder: (context, constraints) { final availableHoursWidth = constraints.maxWidth; const fixedWidth = _dayLabelWidth + _groupSeparatorWidth + _gap + _closedAreaWidth + (_gap * 2); final computedTimeFieldWidth = ((availableHoursWidth - fixedWidth) / 4) .clamp(_minTimeFieldWidth, _targetTimeFieldWidth) .toDouble(); final groupWidth = (computedTimeFieldWidth * 2) + _gap; final hoursContentWidth = _dayLabelWidth + groupWidth + _groupSeparatorWidth + groupWidth + _gap + _closedAreaWidth; return SizedBox( width: hoursContentWidth, child: Stack( children: [ Positioned( left: _dayLabelWidth + groupWidth + (_groupSeparatorWidth / 2), top: 0, bottom: 0, child: Container( width: _separatorLineWidth, color: Colors.grey.shade400, ), ), Column( children: [ Row( children: [ const SizedBox(width: _dayLabelWidth), SizedBox( width: groupWidth, child: Center( child: Text( 'Matin', style: TextStyle( fontWeight: FontWeight.w600, color: Colors.grey.shade800, ), ), ), ), const SizedBox(width: _groupSeparatorWidth), SizedBox( width: groupWidth, child: Center( child: Text( 'Après-midi', style: TextStyle( fontWeight: FontWeight.w600, color: Colors.grey.shade800, ), ), ), ), const SizedBox(width: _closedAreaWidth), ], ), const SizedBox(height: 4), Row( children: [ const SizedBox(width: _dayLabelWidth), SizedBox( width: computedTimeFieldWidth, child: const Center( child: Text( 'Début', style: TextStyle(fontSize: 12), ), ), ), const SizedBox(width: _gap), SizedBox( width: computedTimeFieldWidth, child: const Center( child: Text( 'Fin', style: TextStyle(fontSize: 12), ), ), ), const SizedBox(width: _groupSeparatorWidth), SizedBox( width: computedTimeFieldWidth, child: const Center( child: Text( 'Début', style: TextStyle(fontSize: 12), ), ), ), const SizedBox(width: _gap), SizedBox( width: computedTimeFieldWidth, child: const Center( child: Text( 'Fin', style: TextStyle(fontSize: 12), ), ), ), const SizedBox(width: _gap), const SizedBox(width: _closedAreaWidth), ], ), const SizedBox(height: 6), ..._days.map((day) { final ferme = _closedByDay[day] ?? false; return Padding( padding: const EdgeInsets.only(bottom: 8), child: Row( children: [ SizedBox( width: _dayLabelWidth, child: Text( '${day[0].toUpperCase()}${day.substring(1)}', style: const TextStyle(fontSize: 13), ), ), _buildTimeField( controller: _morningOpenCtrls[day]!, enabled: !ferme, placeholder: '. . : . .', width: computedTimeFieldWidth, ), const SizedBox(width: _gap), _buildTimeField( controller: _morningCloseCtrls[day]!, enabled: !ferme, placeholder: '. . : . .', width: computedTimeFieldWidth, ), const SizedBox(width: _groupSeparatorWidth), _buildTimeField( controller: _eveningOpenCtrls[day]!, enabled: !ferme, placeholder: '. . : . .', width: computedTimeFieldWidth, ), const SizedBox(width: _gap), _buildTimeField( controller: _eveningCloseCtrls[day]!, enabled: !ferme, placeholder: '. . : . .', width: computedTimeFieldWidth, ), const SizedBox(width: _gap), Checkbox( value: ferme, onChanged: (value) { setState(() { _closedByDay[day] = value ?? false; }); }, ), const Text('Fermé'), ], ), ); }), ], ), ], ), ); }, ), ], ); } Widget _buildTimeField({ required TextEditingController controller, required bool enabled, required String placeholder, required double width, }) { return SizedBox( width: width, child: TextField( controller: controller, enabled: enabled, onChanged: (_) => setState(() {}), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(4), _HourMinuteFormatter(), ], decoration: InputDecoration( hintText: placeholder, border: const OutlineInputBorder(), isDense: true, suffixIcon: ExcludeFocus( child: GestureDetector( onTap: enabled ? () => _pickTime(controller) : null, child: const Padding( padding: EdgeInsets.all(8), child: Icon(Icons.schedule, size: 18), ), ), ), ), ), ); } } class _FrenchPhoneNumberFormatter extends TextInputFormatter { @override TextEditingValue formatEditUpdate( TextEditingValue oldValue, TextEditingValue newValue, ) { final digits = newValue.text.replaceAll(RegExp(r'\D'), ''); final buffer = StringBuffer(); for (var i = 0; i < digits.length; i++) { if (i > 0 && i.isEven) { buffer.write(' '); } buffer.write(digits[i]); } final formatted = buffer.toString(); return TextEditingValue( text: formatted, selection: TextSelection.collapsed(offset: formatted.length), ); } } class _RelaisAddressFields extends StatelessWidget { final TextEditingController streetController; final TextEditingController postalCodeController; final TextEditingController cityController; final VoidCallback onChanged; const _RelaisAddressFields({ required this.streetController, required this.postalCodeController, required this.cityController, required this.onChanged, }); @override Widget build(BuildContext context) { return Column( crossAxisAlignment: CrossAxisAlignment.stretch, children: [ TextField( controller: streetController, onChanged: (_) => onChanged(), keyboardType: TextInputType.streetAddress, textCapitalization: TextCapitalization.words, autofillHints: const [AutofillHints.fullStreetAddress], decoration: const InputDecoration( labelText: 'Rue *', hintText: 'Numéro et nom de rue', border: OutlineInputBorder(), prefixIcon: Icon(Icons.location_on_outlined), ), ), const SizedBox(height: 10), Row( children: [ SizedBox( width: 140, child: TextField( controller: postalCodeController, onChanged: (_) => onChanged(), keyboardType: TextInputType.number, inputFormatters: [ FilteringTextInputFormatter.digitsOnly, LengthLimitingTextInputFormatter(5), ], decoration: const InputDecoration( labelText: 'Code postal *', hintText: '5 chiffres', border: OutlineInputBorder(), ), ), ), const SizedBox(width: 10), Expanded( child: TextField( controller: cityController, onChanged: (_) => onChanged(), textCapitalization: TextCapitalization.words, decoration: const InputDecoration( labelText: 'Ville *', hintText: 'Ville', border: OutlineInputBorder(), ), ), ), ], ), ], ); } } class _AddressParts { final String street; final String postalCode; final String city; const _AddressParts({ required this.street, required this.postalCode, required this.city, }); } enum _RelaisDialogAction { save, delete } class _RelaisDialogResult { final _RelaisDialogAction action; final Map? payload; const _RelaisDialogResult({ required this.action, this.payload, }); } class _HourMinuteFormatter extends TextInputFormatter { @override TextEditingValue formatEditUpdate( TextEditingValue oldValue, TextEditingValue newValue, ) { final digits = newValue.text.replaceAll(RegExp(r'\D'), ''); final limited = digits.length > 4 ? digits.substring(0, 4) : digits; String text; if (limited.length <= 2) { text = limited; } else { text = '${limited.substring(0, 2)}:${limited.substring(2)}'; } return TextEditingValue( text: text, selection: TextSelection.collapsed(offset: text.length), ); } }