petitspas/frontend/lib/widgets/admin/relais_management_panel.dart
Julien Martin 4b176b7083 feat: livrer ticket #93 et finaliser #17 avec gestion des Relais (#95)
Homogénéise le dashboard admin (onglets/listes/cartes/états) via composants réutilisables, finalise la création gestionnaire côté backend, et intègre la gestion des Relais avec rattachement gestionnaire.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-23 23:07:04 +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),
);
}
}