feat(#175,#176): modale générique Ajouter une carte + CTA pilule

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>
This commit is contained in:
2026-10-05 22:36:29 +02:00
co-authored by Cursor
parent 5d36b0f4c1
commit a36e9ef176
8 changed files with 677 additions and 32 deletions
@@ -0,0 +1,408 @@
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),
),
),
);
}
}