- Dossiers unifiés #119, pending-families enrichi, validation admin (wizards) - Front: modèles dossier_unifie / pending_family, NIR, auth - Migrations dossier_famille, scripts de test API - Résolution conflits: parents.*, docs tickets, auth_service, nir_utils Made-with: Cursor
731 lines
25 KiB
Dart
731 lines
25 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:flutter/gestures.dart';
|
||
import 'package:google_fonts/google_fonts.dart';
|
||
import 'package:intl/intl.dart';
|
||
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
||
import 'package:p_tits_pas/utils/phone_utils.dart';
|
||
import 'package:p_tits_pas/services/user_service.dart';
|
||
import 'package:p_tits_pas/services/api/api_config.dart';
|
||
import 'package:p_tits_pas/widgets/admin/common/admin_detail_modal.dart';
|
||
import 'package:p_tits_pas/widgets/admin/common/validation_detail_section.dart';
|
||
import 'validation_modal_theme.dart';
|
||
import 'validation_refus_form.dart';
|
||
import 'validation_valider_confirm_dialog.dart';
|
||
|
||
/// Wizard de validation dossier famille : étapes sobres (label/valeur), récap, Valider/Refuser/Annuler, page refus. Ticket #107.
|
||
class ValidationFamilyWizard extends StatefulWidget {
|
||
final DossierFamille dossier;
|
||
final VoidCallback onClose;
|
||
final VoidCallback onSuccess;
|
||
final void Function(int step, int total)? onStepChanged;
|
||
|
||
const ValidationFamilyWizard({
|
||
super.key,
|
||
required this.dossier,
|
||
required this.onClose,
|
||
required this.onSuccess,
|
||
this.onStepChanged,
|
||
});
|
||
|
||
@override
|
||
State<ValidationFamilyWizard> createState() => _ValidationFamilyWizardState();
|
||
}
|
||
|
||
class _ValidationFamilyWizardState extends State<ValidationFamilyWizard> {
|
||
int _step = 0;
|
||
bool _showRefusForm = false;
|
||
bool _submitting = false;
|
||
final ScrollController _enfantsScrollController = ScrollController();
|
||
|
||
/// Même logique que [ParentRegisterStep3Screen] : masque alpha sur les bords (ShaderMask dstIn).
|
||
bool _enfantsIsScrollable = false;
|
||
bool _enfantsFadeLeft = false;
|
||
bool _enfantsFadeRight = false;
|
||
|
||
/// Fraction de la largeur du viewport pour le fondu (identique inscription étape 3).
|
||
static const double _enfantsFadeExtent = 0.05;
|
||
|
||
int get _stepCount => 4;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_enfantsScrollController.addListener(_syncEnfantsScrollFades);
|
||
WidgetsBinding.instance.addPostFrameCallback((_) => _emitStep());
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_enfantsScrollController.removeListener(_syncEnfantsScrollFades);
|
||
_enfantsScrollController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _emitStep() => widget.onStepChanged?.call(_step, _stepCount);
|
||
|
||
void _syncEnfantsScrollFades() {
|
||
if (!mounted) return;
|
||
if (!_enfantsScrollController.hasClients) {
|
||
if (_enfantsFadeLeft || _enfantsFadeRight || _enfantsIsScrollable) {
|
||
setState(() {
|
||
_enfantsIsScrollable = false;
|
||
_enfantsFadeLeft = false;
|
||
_enfantsFadeRight = false;
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
final p = _enfantsScrollController.position;
|
||
final scrollable = p.maxScrollExtent > 0;
|
||
final left = scrollable &&
|
||
p.pixels > (p.viewportDimension * _enfantsFadeExtent / 2);
|
||
final right = scrollable &&
|
||
p.pixels <
|
||
(p.maxScrollExtent -
|
||
(p.viewportDimension * _enfantsFadeExtent / 2));
|
||
if (scrollable != _enfantsIsScrollable ||
|
||
left != _enfantsFadeLeft ||
|
||
right != _enfantsFadeRight) {
|
||
setState(() {
|
||
_enfantsIsScrollable = scrollable;
|
||
_enfantsFadeLeft = left;
|
||
_enfantsFadeRight = right;
|
||
});
|
||
}
|
||
}
|
||
|
||
bool get _isEnAttente => widget.dossier.isEnAttente;
|
||
|
||
String? get _firstParentId => widget.dossier.parents.isNotEmpty
|
||
? widget.dossier.parents.first.id
|
||
: null;
|
||
|
||
static String _v(String? s) =>
|
||
(s != null && s.trim().isNotEmpty) ? s.trim() : 'Non défini';
|
||
|
||
/// Date de naissance en jour/mois/année (dd/MM/yyyy).
|
||
static String _formatBirthDate(String? s) {
|
||
if (s == null || s.trim().isEmpty) return 'Non défini';
|
||
try {
|
||
final d = DateTime.parse(s.trim());
|
||
return DateFormat('dd/MM/yyyy').format(d);
|
||
} catch (_) {
|
||
return s.trim();
|
||
}
|
||
}
|
||
|
||
/// Même ordre et disposition que le formulaire de création (Nom/Prénom, Tél/Email, Adresse, CP/Ville).
|
||
List<AdminDetailField> _parentFields(ParentDossier p) => [
|
||
AdminDetailField(label: 'Nom', value: _v(p.nom)),
|
||
AdminDetailField(label: 'Prénom', value: _v(p.prenom)),
|
||
AdminDetailField(
|
||
label: 'Téléphone',
|
||
value: _v(p.telephone) != 'Non défini'
|
||
? formatPhoneForDisplay(_v(p.telephone))
|
||
: 'Non défini'),
|
||
AdminDetailField(label: 'Email', value: _v(p.email)),
|
||
AdminDetailField(label: 'Adresse (N° et Rue)', value: _v(p.adresse)),
|
||
AdminDetailField(label: 'Code postal', value: _v(p.codePostal)),
|
||
AdminDetailField(label: 'Ville', value: _v(p.ville)),
|
||
];
|
||
|
||
static const List<int> _parentRowLayout = [2, 2, 1, 2];
|
||
static const Map<int, List<int>> _parentRowFlex = {
|
||
3: [2, 5]
|
||
}; // Code postal étroit, Ville large
|
||
|
||
static String _fullPhotoUrl(String? url) {
|
||
if (url == null || url.trim().isEmpty) return '';
|
||
final u = url.trim();
|
||
if (u.startsWith('http://') || u.startsWith('https://')) return u;
|
||
final base = ApiConfig.baseUrl;
|
||
final origin = base.replaceAll(RegExp(r'/api/v1.*'), '');
|
||
return u.startsWith('/') ? '$origin$u' : '$origin/$u';
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
if (_showRefusForm) {
|
||
return _buildRefusPage();
|
||
}
|
||
return Padding(
|
||
padding: const EdgeInsets.all(20),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
const SizedBox(height: 4),
|
||
Expanded(child: _buildStepContent()),
|
||
const SizedBox(height: 24),
|
||
_buildNavigation(),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildStepContent() {
|
||
final d = widget.dossier;
|
||
switch (_step) {
|
||
case 0:
|
||
return ValidationDetailSection(
|
||
title: 'Parent principal',
|
||
fields: _parentFields(d.parents.first),
|
||
rowLayout: _parentRowLayout,
|
||
rowFlex: _parentRowFlex,
|
||
);
|
||
case 1:
|
||
return _buildParent2Step();
|
||
case 2:
|
||
return _buildEnfantsStep();
|
||
case 3:
|
||
return _buildPresentationStep();
|
||
default:
|
||
return const SizedBox();
|
||
}
|
||
}
|
||
|
||
Widget _buildParent2Step() {
|
||
if (widget.dossier.parents.length < 2) {
|
||
return const Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 12),
|
||
child: Text('Un seul parent pour ce dossier.',
|
||
style: TextStyle(color: Colors.black87)),
|
||
);
|
||
}
|
||
return ValidationDetailSection(
|
||
title: 'Deuxième parent',
|
||
fields: _parentFields(widget.dossier.parents[1]),
|
||
rowLayout: _parentRowLayout,
|
||
rowFlex: _parentRowFlex,
|
||
);
|
||
}
|
||
|
||
static const double _idPhotoAspectRatio = 35 / 45;
|
||
|
||
Widget _buildEnfantsStep() {
|
||
final enfants = widget.dossier.enfants;
|
||
if (enfants.isEmpty) {
|
||
return const Padding(
|
||
padding: EdgeInsets.symmetric(vertical: 12),
|
||
child: Text('Aucun enfant renseigné.',
|
||
style: TextStyle(color: Colors.black87)),
|
||
);
|
||
}
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
const Text(
|
||
'Enfants',
|
||
style: TextStyle(
|
||
fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87),
|
||
),
|
||
const SizedBox(height: 16),
|
||
Expanded(
|
||
child: LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
final cardHeight = constraints.maxHeight;
|
||
// Carte large : 1/3 photo + 2/3 champs (scroll horizontal si plusieurs enfants).
|
||
final cardWidth = (cardHeight * 1.72).clamp(500.0, 700.0);
|
||
return NotificationListener<ScrollMetricsNotification>(
|
||
onNotification: (_) {
|
||
_syncEnfantsScrollFades();
|
||
return false;
|
||
},
|
||
child: ShaderMask(
|
||
blendMode: BlendMode.dstIn,
|
||
shaderCallback: (Rect bounds) {
|
||
final stops = <double>[
|
||
0.0,
|
||
_enfantsFadeExtent,
|
||
1.0 - _enfantsFadeExtent,
|
||
1.0,
|
||
];
|
||
if (!_enfantsIsScrollable) {
|
||
return LinearGradient(
|
||
begin: Alignment.centerLeft,
|
||
end: Alignment.centerRight,
|
||
colors: const <Color>[
|
||
Colors.black,
|
||
Colors.black,
|
||
Colors.black,
|
||
Colors.black,
|
||
],
|
||
stops: stops,
|
||
).createShader(bounds);
|
||
}
|
||
final leftMask =
|
||
_enfantsFadeLeft ? Colors.transparent : Colors.black;
|
||
final rightMask =
|
||
_enfantsFadeRight ? Colors.transparent : Colors.black;
|
||
return LinearGradient(
|
||
begin: Alignment.centerLeft,
|
||
end: Alignment.centerRight,
|
||
colors: <Color>[
|
||
leftMask,
|
||
Colors.black,
|
||
Colors.black,
|
||
rightMask,
|
||
],
|
||
stops: stops,
|
||
).createShader(bounds);
|
||
},
|
||
child: Listener(
|
||
onPointerSignal: (event) {
|
||
if (event is PointerScrollEvent &&
|
||
_enfantsScrollController.hasClients) {
|
||
final offset = _enfantsScrollController.offset +
|
||
event.scrollDelta.dy;
|
||
_enfantsScrollController.jumpTo(offset.clamp(
|
||
_enfantsScrollController.position.minScrollExtent,
|
||
_enfantsScrollController.position.maxScrollExtent,
|
||
));
|
||
}
|
||
},
|
||
child: ListView.builder(
|
||
controller: _enfantsScrollController,
|
||
scrollDirection: Axis.horizontal,
|
||
itemCount: enfants.length,
|
||
itemBuilder: (_, i) => Padding(
|
||
padding: EdgeInsets.only(
|
||
right: i < enfants.length - 1 ? 16 : 0),
|
||
child: SizedBox(
|
||
width: cardWidth,
|
||
height: cardHeight,
|
||
child: _buildEnfantCard(enfants[i]),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// Fond carte enfant : teintes très pastel ; bordure discrète ; accent léger (barre).
|
||
static const Color _enfantCardBoyBg = Color(0xFFF0F7FB);
|
||
static const Color _enfantCardBoyBorder = Color(0xFFE3EDF4);
|
||
static const Color _enfantCardGirlBg = Color(0xFFFCF5F8);
|
||
static const Color _enfantCardGirlBorder = Color(0xFFEAE3E7);
|
||
|
||
static const double _enfantCardRadius = 12;
|
||
|
||
static List<BoxShadow> _enfantCardShadows() => [
|
||
BoxShadow(
|
||
color: Colors.black.withValues(alpha: 0.06),
|
||
blurRadius: 14,
|
||
offset: const Offset(0, 4),
|
||
),
|
||
];
|
||
|
||
static BoxDecoration _enfantCardDecoration(String? gender) {
|
||
final g = (gender ?? '').trim().toUpperCase();
|
||
if (g == 'H') {
|
||
return BoxDecoration(
|
||
color: _enfantCardBoyBg,
|
||
borderRadius: BorderRadius.circular(_enfantCardRadius),
|
||
border: Border.all(color: _enfantCardBoyBorder, width: 1),
|
||
boxShadow: _enfantCardShadows(),
|
||
);
|
||
}
|
||
if (g == 'F') {
|
||
return BoxDecoration(
|
||
color: _enfantCardGirlBg,
|
||
borderRadius: BorderRadius.circular(_enfantCardRadius),
|
||
border: Border.all(color: _enfantCardGirlBorder, width: 1),
|
||
boxShadow: _enfantCardShadows(),
|
||
);
|
||
}
|
||
return BoxDecoration(
|
||
color: Colors.grey.shade50,
|
||
borderRadius: BorderRadius.circular(_enfantCardRadius),
|
||
border: Border.all(color: Colors.grey.shade300),
|
||
boxShadow: _enfantCardShadows(),
|
||
);
|
||
}
|
||
|
||
/// Carte enfant : prénom pleine largeur, puis ligne photo 1/3 + colonne 2/3 (champs + statut hors TF si besoin).
|
||
Widget _buildEnfantCard(EnfantDossier e) {
|
||
final photoUrl = _fullPhotoUrl(e.photoUrl);
|
||
final columnStatusLabel = _enfantColumnStatusLabel(e);
|
||
return ClipRRect(
|
||
borderRadius: BorderRadius.circular(_enfantCardRadius),
|
||
child: Container(
|
||
decoration: _enfantCardDecoration(e.gender),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 8),
|
||
child: _enfantLabeledField('Prénom', _v(e.firstName)),
|
||
),
|
||
Expanded(
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Expanded(
|
||
flex: 1,
|
||
child: LayoutBuilder(
|
||
builder: (context, c) {
|
||
// Même marge gauche que le bloc « Prénom » (12) ; droite / haut / bas 8.
|
||
const padL = 12.0;
|
||
const padR = 8.0;
|
||
const padV = 8.0;
|
||
final maxW =
|
||
(c.maxWidth - padL - padR).clamp(0.0, double.infinity);
|
||
final maxH =
|
||
(c.maxHeight - 2 * padV).clamp(0.0, double.infinity);
|
||
const ar = _idPhotoAspectRatio;
|
||
double ph = maxH;
|
||
double pw = ph * ar;
|
||
if (pw > maxW) {
|
||
pw = maxW;
|
||
ph = pw / ar;
|
||
}
|
||
return Padding(
|
||
padding: const EdgeInsets.fromLTRB(padL, padV, padR, padV),
|
||
child: Align(
|
||
alignment: Alignment.centerLeft,
|
||
child: _buildEnfantPhotoSlot(photoUrl, pw, ph),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
Expanded(
|
||
flex: 2,
|
||
child: Padding(
|
||
padding: const EdgeInsets.fromLTRB(4, 4, 14, 12),
|
||
child: columnStatusLabel == null
|
||
? SingleChildScrollView(
|
||
child: _buildEnfantInfoFields(e),
|
||
)
|
||
: CustomScrollView(
|
||
slivers: [
|
||
SliverToBoxAdapter(
|
||
child: _buildEnfantInfoFields(e),
|
||
),
|
||
SliverFillRemaining(
|
||
hasScrollBody: false,
|
||
child: Center(
|
||
child: Text(
|
||
columnStatusLabel,
|
||
textAlign: TextAlign.center,
|
||
style: GoogleFonts.merienda(
|
||
fontSize: 14,
|
||
fontStyle: FontStyle.italic,
|
||
fontWeight: FontWeight.w600,
|
||
color: Colors.grey.shade800,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// « Scolarisé » / « Scolarisée » selon le genre enfant (`F` / sinon masculin par défaut).
|
||
static String _scolariseAccordeAuGenre(String? gender) {
|
||
final g = (gender ?? '').trim().toUpperCase();
|
||
if (g == 'F') return 'Scolarisée';
|
||
return 'Scolarisé';
|
||
}
|
||
|
||
/// Statut dans la colonne 2/3 uniquement (pas de [ValidationReadOnlyField]) : scolarisé·e ou « À naître ».
|
||
/// `actif` : pas de ligne statut.
|
||
String? _enfantColumnStatusLabel(EnfantDossier e) {
|
||
final s = (e.status ?? '').trim().toLowerCase();
|
||
if (s == 'a_naitre') return 'À naître';
|
||
if (s == 'scolarise') return _scolariseAccordeAuGenre(e.gender);
|
||
return null;
|
||
}
|
||
|
||
/// Nom ; date de naissance et genre sur une ligne (prénom au-dessus, pleine largeur).
|
||
Widget _buildEnfantInfoFields(EnfantDossier e) {
|
||
final isANaitre = (e.status ?? '').trim().toLowerCase() == 'a_naitre';
|
||
final dueDateRenseignee = e.dueDate != null && e.dueDate!.trim().isNotEmpty;
|
||
final dateValue = isANaitre
|
||
? (dueDateRenseignee ? '${_formatBirthDate(e.dueDate)} (P)' : '– (P)')
|
||
: _formatBirthDate(e.birthDate);
|
||
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Padding(
|
||
padding: const EdgeInsets.only(bottom: 12),
|
||
child: _enfantLabeledField('Nom', _formatNom(e.lastName)),
|
||
),
|
||
Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Expanded(
|
||
flex: 3,
|
||
child: _enfantLabeledField('Date de naissance', dateValue),
|
||
),
|
||
const SizedBox(width: 16),
|
||
Expanded(
|
||
flex: 2,
|
||
child: _enfantLabeledField(
|
||
'Genre',
|
||
_genreEnfantLabel(e.gender, e.status),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _enfantLabeledField(String label, String value) {
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
label,
|
||
style: TextStyle(
|
||
fontSize: 12,
|
||
fontWeight: FontWeight.w500,
|
||
color: Colors.grey.shade700,
|
||
),
|
||
),
|
||
const SizedBox(height: 4),
|
||
ValidationReadOnlyField(value: value),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildEnfantPhotoSlot(String photoUrl, double width, double height) {
|
||
return Container(
|
||
width: width,
|
||
height: height,
|
||
decoration: BoxDecoration(
|
||
color: Colors.white,
|
||
borderRadius: BorderRadius.circular(8),
|
||
border: Border.all(color: Colors.black.withValues(alpha: 0.08)),
|
||
boxShadow: [
|
||
BoxShadow(
|
||
color: Colors.black.withValues(alpha: 0.05),
|
||
blurRadius: 6,
|
||
offset: const Offset(0, 2),
|
||
),
|
||
],
|
||
),
|
||
clipBehavior: Clip.antiAlias,
|
||
child: photoUrl.isEmpty
|
||
? ColoredBox(
|
||
color: Colors.grey.shade100,
|
||
child: Center(
|
||
child: Icon(Icons.person_outline, size: 32, color: Colors.grey.shade400),
|
||
),
|
||
)
|
||
: Image.network(
|
||
photoUrl,
|
||
fit: BoxFit.contain,
|
||
width: width,
|
||
height: height,
|
||
errorBuilder: (_, __, ___) => ColoredBox(
|
||
color: Colors.grey.shade100,
|
||
child: Center(
|
||
child: Icon(Icons.broken_image_outlined, size: 32, color: Colors.grey.shade400),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
static String _formatNom(String? lastName) {
|
||
final n = (lastName ?? '').trim().toUpperCase();
|
||
return n.isEmpty ? 'Non défini' : n;
|
||
}
|
||
|
||
/// Genre enfant : Garçon, Fille, ou "Non connu" (uniquement si l'enfant est à naître).
|
||
static String _genreEnfantLabel(String? gender, String? status) {
|
||
final g = (gender ?? '').trim().toUpperCase();
|
||
final isANaitre = (status ?? '').trim().toLowerCase() == 'a_naitre';
|
||
if (g == 'H') return 'Garçon';
|
||
if (g == 'F') return 'Fille';
|
||
if (isANaitre) return 'Non connu';
|
||
if (g.isEmpty) return 'Non défini';
|
||
return (gender ?? '').trim();
|
||
}
|
||
|
||
Widget _buildPresentationStep() {
|
||
final p = widget.dossier.presentation ?? '';
|
||
final text = p.trim().isEmpty ? 'Non défini' : p;
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
const Text(
|
||
'Présentation / Motivation',
|
||
style: TextStyle(
|
||
fontSize: 16, fontWeight: FontWeight.w600, color: Colors.black87),
|
||
),
|
||
const SizedBox(height: 12),
|
||
Expanded(
|
||
child: LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
return SingleChildScrollView(
|
||
child: ConstrainedBox(
|
||
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||
child: Container(
|
||
width: double.infinity,
|
||
padding: const EdgeInsets.symmetric(
|
||
horizontal: 12, vertical: 10),
|
||
decoration: BoxDecoration(
|
||
color: Colors.grey.shade50,
|
||
borderRadius: BorderRadius.circular(6),
|
||
border: Border.all(color: Colors.grey.shade300),
|
||
),
|
||
child: SelectableText(
|
||
text,
|
||
style:
|
||
const TextStyle(color: Colors.black87, fontSize: 14),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildNavigation() {
|
||
if (_step == 3) {
|
||
return Row(
|
||
children: [
|
||
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
|
||
const Spacer(),
|
||
Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
TextButton(
|
||
onPressed: () {
|
||
setState(() => _step = 2);
|
||
_emitStep();
|
||
},
|
||
child: const Text('Précédent'),
|
||
),
|
||
const SizedBox(width: 8),
|
||
if (_isEnAttente && _firstParentId != null) ...[
|
||
OutlinedButton(
|
||
onPressed: _submitting ? null : _refuser,
|
||
child: const Text('Refuser')),
|
||
const SizedBox(width: 12),
|
||
ElevatedButton(
|
||
style: ValidationModalTheme.primaryElevatedStyle,
|
||
onPressed: _submitting ? null : _onValiderPressed,
|
||
child: Text(_submitting ? 'Envoi...' : 'Valider'),
|
||
),
|
||
] else if (!_isEnAttente)
|
||
ElevatedButton(
|
||
style: ValidationModalTheme.primaryElevatedStyle,
|
||
onPressed: widget.onClose,
|
||
child: const Text('Fermer'),
|
||
),
|
||
],
|
||
),
|
||
],
|
||
);
|
||
}
|
||
return Row(
|
||
children: [
|
||
TextButton(onPressed: widget.onClose, child: const Text('Annuler')),
|
||
const Spacer(),
|
||
if (_step > 0) ...[
|
||
TextButton(
|
||
onPressed: () {
|
||
setState(() => _step--);
|
||
_emitStep();
|
||
},
|
||
child: const Text('Précédent'),
|
||
),
|
||
const SizedBox(width: 8),
|
||
],
|
||
ElevatedButton(
|
||
style: ValidationModalTheme.primaryElevatedStyle,
|
||
onPressed: () {
|
||
setState(() => _step++);
|
||
_emitStep();
|
||
},
|
||
child: const Text('Suivant'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
Future<void> _onValiderPressed() async {
|
||
if (_submitting || _firstParentId == null) return;
|
||
final ok = await showValidationValiderConfirmDialog(
|
||
context,
|
||
body:
|
||
'Voulez-vous valider ce dossier famille ? Les comptes parents concernés seront confirmés.',
|
||
);
|
||
if (!mounted || !ok) return;
|
||
await _valider();
|
||
}
|
||
|
||
Future<void> _valider() async {
|
||
if (_submitting || _firstParentId == null) return;
|
||
setState(() => _submitting = true);
|
||
try {
|
||
await UserService.validerDossierFamille(_firstParentId!);
|
||
if (!mounted) return;
|
||
widget.onSuccess();
|
||
} catch (e) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(
|
||
content: Text(e is Exception
|
||
? e.toString().replaceFirst('Exception: ', '')
|
||
: 'Erreur'),
|
||
backgroundColor: Colors.red.shade700,
|
||
),
|
||
);
|
||
} finally {
|
||
if (mounted) setState(() => _submitting = false);
|
||
}
|
||
}
|
||
|
||
void _refuser() => setState(() => _showRefusForm = true);
|
||
|
||
Widget _buildRefusPage() {
|
||
return Padding(
|
||
padding: const EdgeInsets.all(20),
|
||
child: Column(
|
||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||
children: [
|
||
Expanded(
|
||
child: ValidationRefusForm(
|
||
onCancel: widget.onClose,
|
||
onPrevious: () => setState(() => _showRefusForm = false),
|
||
onSubmit: (comment) {
|
||
if (!mounted) return;
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text(
|
||
'Refus (à brancher sur l’API refus – ticket #110)')),
|
||
);
|
||
widget.onClose();
|
||
},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|