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>
1135 lines
41 KiB
Dart
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),
|
|
);
|
|
}
|
|
}
|