petitspas/frontend/lib/widgets/admin/relais_management_panel.dart
Julien Martin fbafef8f2c feat(#95): implémenter la gestion Relais admin et le rattachement gestionnaire
Ajoute la section Paramètres territoriaux avec CRUD Relais, modale de saisie structurée, états visuels harmonisés, et rattachement d'un relais principal aux gestionnaires via l'API.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-21 20:06:17 +01:00

1135 lines
41 KiB
Dart

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<RelaisManagementPanel> createState() => _RelaisManagementPanelState();
}
class _RelaisManagementPanelState extends State<RelaisManagementPanel> {
bool _isLoading = false;
String? _error;
List<RelaisModel> _relais = [];
String? _hoveredRelaisId;
@override
void initState() {
super.initState();
_loadRelais();
}
Future<void> _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<void> _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<bool>(
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<String, dynamic>? 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<String, dynamic>) {
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<String, TextEditingController> _morningOpenCtrls = {};
final Map<String, TextEditingController> _morningCloseCtrls = {};
final Map<String, TextEditingController> _eveningOpenCtrls = {};
final Map<String, TextEditingController> _eveningCloseCtrls = {};
final Map<String, bool> _closedByDay = {};
bool _actif = true;
static const List<String> _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 ?? <String, dynamic>{};
for (final day in _days) {
final value = horaires[day];
String matinOuverture = '';
String matinFermeture = '';
String soirOuverture = '';
String soirFermeture = '';
bool ferme = false;
if (value is Map<String, dynamic>) {
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<String, dynamic> _buildPayload() {
final horaires = <String, dynamic>{};
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] = <String, dynamic>{
'matin_ouverture': matinOuverture,
'matin_fermeture': matinFermeture,
'soir_ouverture': soirOuverture,
'soir_fermeture': soirFermeture,
'ferme': ferme,
};
}
final payload = <String, dynamic>{
'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<void> _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<String, dynamic>? 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),
);
}
}