Creation absence (parent) et conge/arret (AM) via une seule modale Material, catalogue UI extensible, calendrier de periode compact. Co-authored-by: Cursor <cursoragent@cursor.com>
409 lines
12 KiB
Dart
409 lines
12 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:google_fonts/google_fonts.dart';
|
||
import 'package:p_tits_pas/models/carte_bulle.dart';
|
||
import 'package:p_tits_pas/models/carte_type_ui.dart';
|
||
import 'package:p_tits_pas/services/api/cards_service.dart';
|
||
import 'package:p_tits_pas/widgets/quotidien/quotidien_theme.dart';
|
||
|
||
/// Ouvre la modale Material générique « Ajouter une carte » (#175).
|
||
/// Retourne la bulle créée, ou `null` si annulé.
|
||
Future<CarteBulle?> showAjouterCarteModal(
|
||
BuildContext context, {
|
||
required String placementId,
|
||
}) {
|
||
return showDialog<CarteBulle>(
|
||
context: context,
|
||
barrierDismissible: false,
|
||
builder: (ctx) => AjouterCarteModal(placementId: placementId),
|
||
);
|
||
}
|
||
|
||
/// Modale générique : types API + catalogue UI, période (pas de motif V1).
|
||
class AjouterCarteModal extends StatefulWidget {
|
||
final String placementId;
|
||
|
||
const AjouterCarteModal({super.key, required this.placementId});
|
||
|
||
@override
|
||
State<AjouterCarteModal> createState() => _AjouterCarteModalState();
|
||
}
|
||
|
||
class _AjouterCarteModalState extends State<AjouterCarteModal> {
|
||
List<CarteTypeEmissible> _types = const [];
|
||
CarteTypeEmissible? _selected;
|
||
String? _sousTypeCode;
|
||
DateTime _dateDebut = DateTime.now();
|
||
DateTime _dateFin = DateTime.now();
|
||
bool _loadingTypes = true;
|
||
bool _submitting = false;
|
||
String? _error;
|
||
|
||
CarteTypeUi get _ui =>
|
||
_selected?.ui ??
|
||
CarteTypeUi.resolve(typeCode: _selected?.code ?? 'inconnu');
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
final now = DateTime.now();
|
||
_dateDebut = DateTime(now.year, now.month, now.day);
|
||
_dateFin = _dateDebut;
|
||
_loadTypes();
|
||
}
|
||
|
||
Future<void> _loadTypes() async {
|
||
setState(() {
|
||
_loadingTypes = true;
|
||
_error = null;
|
||
});
|
||
try {
|
||
final types = await CardsService.listerTypes();
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_types = types;
|
||
_selected = types.isNotEmpty ? types.first : null;
|
||
_sousTypeCode = null;
|
||
_loadingTypes = false;
|
||
if (types.isEmpty) {
|
||
_error = 'Aucun type de carte disponible pour votre rôle.';
|
||
}
|
||
});
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_loadingTypes = false;
|
||
_error = e.toString().replaceFirst('Exception: ', '');
|
||
});
|
||
}
|
||
}
|
||
|
||
String _fmtIso(DateTime d) {
|
||
final y = d.year.toString().padLeft(4, '0');
|
||
final m = d.month.toString().padLeft(2, '0');
|
||
final day = d.day.toString().padLeft(2, '0');
|
||
return '$y-$m-$day';
|
||
}
|
||
|
||
String _fmtUi(DateTime d) {
|
||
final day = d.day.toString().padLeft(2, '0');
|
||
final m = d.month.toString().padLeft(2, '0');
|
||
return '$day/$m/${d.year}';
|
||
}
|
||
|
||
Future<void> _pickSingleDay() async {
|
||
final picked = await showDatePicker(
|
||
context: context,
|
||
useRootNavigator: true,
|
||
initialDate: _dateDebut,
|
||
firstDate: DateTime(2020),
|
||
lastDate: DateTime(2040),
|
||
helpText: 'Date',
|
||
cancelText: 'Annuler',
|
||
confirmText: 'Valider',
|
||
);
|
||
if (picked == null || !mounted) return;
|
||
final day = DateTime(picked.year, picked.month, picked.day);
|
||
setState(() {
|
||
_dateDebut = day;
|
||
_dateFin = day;
|
||
});
|
||
}
|
||
|
||
/// Plage Material (jours surlignés), en boîte compacte — pas plein écran.
|
||
/// Astuce : le mode calendrier de [DateRangePickerDialog] prend
|
||
/// `MediaQuery.size` comme taille de dialog ; on la bride.
|
||
Future<void> _pickDateRange() async {
|
||
final initialEnd =
|
||
_dateFin.isBefore(_dateDebut) ? _dateDebut : _dateFin;
|
||
final picked = await showDialog<DateTimeRange>(
|
||
context: context,
|
||
useRootNavigator: true,
|
||
barrierDismissible: true,
|
||
builder: (dialogContext) {
|
||
final base = MediaQuery.of(dialogContext);
|
||
final theme = Theme.of(dialogContext);
|
||
return MediaQuery(
|
||
data: base.copyWith(size: const Size(420, 560)),
|
||
child: Theme(
|
||
data: theme.copyWith(
|
||
datePickerTheme: theme.datePickerTheme.copyWith(
|
||
rangePickerShape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(20),
|
||
),
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(20),
|
||
),
|
||
),
|
||
dialogTheme: theme.dialogTheme.copyWith(
|
||
shape: RoundedRectangleBorder(
|
||
borderRadius: BorderRadius.circular(20),
|
||
),
|
||
),
|
||
),
|
||
child: DateRangePickerDialog(
|
||
initialDateRange: DateTimeRange(
|
||
start: _dateDebut,
|
||
end: initialEnd,
|
||
),
|
||
firstDate: DateTime(2020),
|
||
lastDate: DateTime(2040),
|
||
currentDate: DateTime.now(),
|
||
helpText: 'Début, puis fin (même jour : 2×)',
|
||
saveText: 'Valider',
|
||
cancelText: 'Annuler',
|
||
),
|
||
),
|
||
);
|
||
},
|
||
);
|
||
if (picked == null || !mounted) return;
|
||
setState(() {
|
||
_dateDebut = DateTime(
|
||
picked.start.year,
|
||
picked.start.month,
|
||
picked.start.day,
|
||
);
|
||
_dateFin = DateTime(
|
||
picked.end.year,
|
||
picked.end.month,
|
||
picked.end.day,
|
||
);
|
||
});
|
||
}
|
||
|
||
String? _sousTypeLabel() {
|
||
final sous = _sousTypeCode;
|
||
if (sous == null) return null;
|
||
for (final s in _ui.sousTypes) {
|
||
if (s.code == sous) return s.label;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
/// V1 : pas de texte libre ; seul un sous-type catalogue peut alimenter motif.
|
||
String? _motifOptionnel() => _sousTypeLabel();
|
||
|
||
Future<void> _submit() async {
|
||
if (_selected == null) return;
|
||
if (_dateFin.isBefore(_dateDebut)) {
|
||
setState(() => _error = 'La date de fin doit être après la date de début.');
|
||
return;
|
||
}
|
||
|
||
setState(() {
|
||
_submitting = true;
|
||
_error = null;
|
||
});
|
||
try {
|
||
final created = await CardsService.creer(
|
||
typeCode: _selected!.code,
|
||
idPlacement: widget.placementId,
|
||
dateDebut: _fmtIso(_dateDebut),
|
||
dateFin: _fmtIso(
|
||
_ui.periodeMode == CartePeriodeMode.singleDay
|
||
? _dateDebut
|
||
: _dateFin,
|
||
),
|
||
motif: _motifOptionnel(),
|
||
);
|
||
if (!mounted) return;
|
||
Navigator.of(context).pop(created);
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
setState(() {
|
||
_submitting = false;
|
||
_error = e.toString().replaceFirst('Exception: ', '');
|
||
});
|
||
}
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return AlertDialog(
|
||
title: Text(
|
||
'Ajouter une carte',
|
||
style: GoogleFonts.merienda(fontWeight: FontWeight.w600),
|
||
),
|
||
content: SizedBox(
|
||
width: 420,
|
||
child: _loadingTypes
|
||
? const Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 24),
|
||
child: Center(
|
||
child: SizedBox(
|
||
width: 28,
|
||
height: 28,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
),
|
||
),
|
||
)
|
||
: SingleChildScrollView(
|
||
child: Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
if (_error != null) ...[
|
||
Text(
|
||
_error!,
|
||
style: GoogleFonts.merriweather(
|
||
fontSize: 13,
|
||
color: Theme.of(context).colorScheme.error,
|
||
),
|
||
),
|
||
const SizedBox(height: 12),
|
||
],
|
||
_buildTypeSelector(),
|
||
const SizedBox(height: 16),
|
||
if (_ui.periodeMode == CartePeriodeMode.singleDay)
|
||
_DateField(
|
||
label: 'Date',
|
||
value: _fmtUi(_dateDebut),
|
||
onTap: _submitting ? null : _pickSingleDay,
|
||
)
|
||
else
|
||
_DateField(
|
||
label: 'Période',
|
||
value: _dateDebut == _dateFin
|
||
? _fmtUi(_dateDebut)
|
||
: '${_fmtUi(_dateDebut)} → ${_fmtUi(_dateFin)}',
|
||
onTap: _submitting ? null : _pickDateRange,
|
||
),
|
||
if (_ui.sousTypes.isNotEmpty) ...[
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
'Sous-type',
|
||
style: GoogleFonts.merriweather(
|
||
fontSize: 13,
|
||
color: QuotidienTheme.ink,
|
||
),
|
||
),
|
||
const SizedBox(height: 8),
|
||
Wrap(
|
||
spacing: 8,
|
||
runSpacing: 8,
|
||
children: [
|
||
for (final s in _ui.sousTypes)
|
||
ChoiceChip(
|
||
label: Text(s.label),
|
||
selected: _sousTypeCode == s.code,
|
||
onSelected: _submitting
|
||
? null
|
||
: (sel) {
|
||
setState(() {
|
||
_sousTypeCode = sel ? s.code : null;
|
||
});
|
||
},
|
||
),
|
||
],
|
||
),
|
||
],
|
||
],
|
||
),
|
||
),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: _submitting ? null : () => Navigator.pop(context),
|
||
child: const Text('Annuler'),
|
||
),
|
||
FilledButton(
|
||
onPressed: (_submitting ||
|
||
_loadingTypes ||
|
||
_selected == null ||
|
||
_types.isEmpty)
|
||
? null
|
||
: _submit,
|
||
child: _submitting
|
||
? const SizedBox(
|
||
width: 18,
|
||
height: 18,
|
||
child: CircularProgressIndicator(strokeWidth: 2),
|
||
)
|
||
: const Text('Envoyer'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildTypeSelector() {
|
||
if (_types.isEmpty) {
|
||
return Text(
|
||
'Aucun type disponible.',
|
||
style: GoogleFonts.merriweather(fontSize: 13),
|
||
);
|
||
}
|
||
if (_types.length == 1) {
|
||
return Text(
|
||
_types.first.label,
|
||
style: GoogleFonts.merienda(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w600,
|
||
color: QuotidienTheme.ink,
|
||
),
|
||
);
|
||
}
|
||
return DropdownButtonFormField<String>(
|
||
value: _selected?.code,
|
||
decoration: const InputDecoration(
|
||
labelText: 'Type de carte',
|
||
border: OutlineInputBorder(),
|
||
),
|
||
items: [
|
||
for (final t in _types)
|
||
DropdownMenuItem(value: t.code, child: Text(t.label)),
|
||
],
|
||
onChanged: _submitting
|
||
? null
|
||
: (code) {
|
||
if (code == null) return;
|
||
CarteTypeEmissible? next;
|
||
for (final t in _types) {
|
||
if (t.code == code) {
|
||
next = t;
|
||
break;
|
||
}
|
||
}
|
||
if (next == null) return;
|
||
setState(() {
|
||
_selected = next;
|
||
_sousTypeCode = null;
|
||
if (next!.ui.periodeMode == CartePeriodeMode.singleDay) {
|
||
_dateFin = _dateDebut;
|
||
}
|
||
});
|
||
},
|
||
);
|
||
}
|
||
}
|
||
|
||
class _DateField extends StatelessWidget {
|
||
final String label;
|
||
final String value;
|
||
final VoidCallback? onTap;
|
||
|
||
const _DateField({
|
||
required this.label,
|
||
required this.value,
|
||
required this.onTap,
|
||
});
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
return InkWell(
|
||
onTap: onTap,
|
||
borderRadius: BorderRadius.circular(8),
|
||
child: InputDecorator(
|
||
decoration: InputDecoration(
|
||
labelText: label,
|
||
border: const OutlineInputBorder(),
|
||
suffixIcon: const Icon(Icons.calendar_today_outlined, size: 18),
|
||
),
|
||
child: Text(
|
||
value,
|
||
style: GoogleFonts.merriweather(fontSize: 14),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|