feat(#112): reprise après refus — dossier complet, email resoumission
- GET/PATCH reprise-dossier enrichis (parents, enfants, motivation, fiche AM) - Front: lien mail, modale identify login, wizards préremplis, PATCH complet - Email accusé resoumission aux parents avec n° de dossier - Fixes préremplissage AM (dates, places, ValueKey étape 2) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -21,6 +21,7 @@ import '../screens/auth/am_register_step4_screen.dart';
|
||||
import '../screens/auth/create_password_screen.dart';
|
||||
import '../screens/auth/forgot_password_screen.dart';
|
||||
import '../screens/auth/reset_password_screen.dart';
|
||||
import '../screens/auth/reprise_entry_screen.dart';
|
||||
import '../screens/home/home_screen.dart';
|
||||
import '../screens/administrateurs/admin_dashboardScreen.dart';
|
||||
import '../screens/gestionnaire/gestionnaire_dashboard_screen.dart';
|
||||
@@ -66,6 +67,11 @@ class AppRouter {
|
||||
builder: (BuildContext context, GoRouterState state) =>
|
||||
ResetPasswordScreen(token: state.uri.queryParameters['token']),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/reprise',
|
||||
builder: (BuildContext context, GoRouterState state) =>
|
||||
RepriseEntryScreen(token: state.uri.queryParameters['token']),
|
||||
),
|
||||
GoRoute(
|
||||
path: '/home',
|
||||
builder: (BuildContext context, GoRouterState state) => const HomeScreen(),
|
||||
|
||||
@@ -33,6 +33,9 @@ class AmRegistrationData extends ChangeNotifier {
|
||||
/// Places libres actuellement (0 ≤ valeur ≤ capacité) — API `places_disponibles`.
|
||||
int? placesAvailable;
|
||||
|
||||
/// Photo déjà en base lors d'une reprise (#112) — sert pour l'affichage et `photo_url` API.
|
||||
String? repriseExistingPhotoUrl;
|
||||
|
||||
// Step 3: Presentation & CGU
|
||||
String presentationText = '';
|
||||
bool cguAccepted = false;
|
||||
@@ -104,6 +107,53 @@ class AmRegistrationData extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Reprise après refus (#112).
|
||||
void resetForReprise({
|
||||
required String firstName,
|
||||
required String lastName,
|
||||
required String phone,
|
||||
required String email,
|
||||
required String streetAddress,
|
||||
required String postalCode,
|
||||
required String city,
|
||||
String? existingPhotoUrl,
|
||||
bool consentementPhoto = false,
|
||||
DateTime? dateOfBirth,
|
||||
String birthCity = '',
|
||||
String birthCountry = '',
|
||||
String nir = '',
|
||||
String agrementNumber = '',
|
||||
DateTime? agreementDate,
|
||||
int? capacity,
|
||||
int? placesAvailable,
|
||||
String presentationText = '',
|
||||
}) {
|
||||
this.firstName = firstName;
|
||||
this.lastName = lastName;
|
||||
this.phone = phone;
|
||||
this.email = email;
|
||||
this.streetAddress = streetAddress;
|
||||
this.postalCode = postalCode;
|
||||
this.city = city;
|
||||
password = '';
|
||||
repriseExistingPhotoUrl = existingPhotoUrl;
|
||||
photoPath = existingPhotoUrl;
|
||||
photoBytes = null;
|
||||
photoFilename = null;
|
||||
photoConsent = consentementPhoto;
|
||||
this.dateOfBirth = dateOfBirth;
|
||||
this.birthCity = birthCity;
|
||||
this.birthCountry = birthCountry;
|
||||
this.nir = nir;
|
||||
this.agrementNumber = agrementNumber;
|
||||
this.agreementDate = agreementDate;
|
||||
this.capacity = capacity;
|
||||
this.placesAvailable = placesAvailable;
|
||||
this.presentationText = presentationText;
|
||||
cguAccepted = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
// --- Getters for validation or display ---
|
||||
bool get isStep1Complete =>
|
||||
firstName.trim().length >= 2 &&
|
||||
@@ -118,6 +168,8 @@ class AmRegistrationData extends ChangeNotifier {
|
||||
/// Photo réelle (pas seulement un placeholder asset).
|
||||
bool get _hasUserPhoto =>
|
||||
(photoBytes != null && photoBytes!.isNotEmpty) ||
|
||||
(repriseExistingPhotoUrl != null &&
|
||||
repriseExistingPhotoUrl!.trim().isNotEmpty) ||
|
||||
(photoPath != null &&
|
||||
photoPath!.isNotEmpty &&
|
||||
!photoPath!.startsWith('assets/'));
|
||||
@@ -145,6 +197,9 @@ class AmRegistrationData extends ChangeNotifier {
|
||||
bool get isRegistrationComplete =>
|
||||
isStep1Complete && isStep2Complete && isStep3Complete;
|
||||
|
||||
/// Reprise (#112) : dossier AM complet renvoyé par PATCH reprise-resoumettre.
|
||||
bool get isRepriseSubmitReady => isRegistrationComplete;
|
||||
|
||||
@override
|
||||
String toString() {
|
||||
return 'AmRegistrationData('
|
||||
|
||||
@@ -184,6 +184,7 @@ class EnfantDossier {
|
||||
final String? dueDate;
|
||||
final String? photoUrl;
|
||||
final bool consentPhoto;
|
||||
final bool estMultiple;
|
||||
|
||||
EnfantDossier({
|
||||
required this.id,
|
||||
@@ -195,6 +196,7 @@ class EnfantDossier {
|
||||
this.dueDate,
|
||||
this.photoUrl,
|
||||
this.consentPhoto = false,
|
||||
this.estMultiple = false,
|
||||
});
|
||||
|
||||
String get fullName => '${firstName ?? ''} ${lastName ?? ''}'.trim();
|
||||
@@ -223,6 +225,8 @@ class EnfantDossier {
|
||||
photoUrl: resolvedPhoto,
|
||||
consentPhoto:
|
||||
json['consent_photo'] == true || json['consentPhoto'] == true,
|
||||
estMultiple:
|
||||
json['est_multiple'] == true || json['estMultiple'] == true,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
||||
import 'package:p_tits_pas/utils/reprise_mapper.dart';
|
||||
|
||||
/// Réponse GET /auth/reprise-dossier. Tickets #111, #112.
|
||||
class RepriseDossier {
|
||||
final String id;
|
||||
final String email;
|
||||
final String? prenom;
|
||||
final String? nom;
|
||||
final String? telephone;
|
||||
final String? adresse;
|
||||
final String? ville;
|
||||
final String? codePostal;
|
||||
final String? numeroDossier;
|
||||
final String role;
|
||||
final String? photoUrl;
|
||||
final String? genre;
|
||||
final String? situationFamiliale;
|
||||
|
||||
final List<ParentDossier> parents;
|
||||
final List<EnfantDossier> enfants;
|
||||
final String? texteMotivation;
|
||||
|
||||
final bool consentementPhoto;
|
||||
final String? dateNaissance;
|
||||
final String? lieuNaissanceVille;
|
||||
final String? lieuNaissancePays;
|
||||
final String? numeroAgrement;
|
||||
final String? nir;
|
||||
final String? dateAgrement;
|
||||
final int? nbMaxEnfants;
|
||||
final int? placeDisponible;
|
||||
final String? biographie;
|
||||
|
||||
const RepriseDossier({
|
||||
required this.id,
|
||||
required this.email,
|
||||
this.prenom,
|
||||
this.nom,
|
||||
this.telephone,
|
||||
this.adresse,
|
||||
this.ville,
|
||||
this.codePostal,
|
||||
this.numeroDossier,
|
||||
required this.role,
|
||||
this.photoUrl,
|
||||
this.genre,
|
||||
this.situationFamiliale,
|
||||
this.parents = const [],
|
||||
this.enfants = const [],
|
||||
this.texteMotivation,
|
||||
this.consentementPhoto = false,
|
||||
this.dateNaissance,
|
||||
this.lieuNaissanceVille,
|
||||
this.lieuNaissancePays,
|
||||
this.numeroAgrement,
|
||||
this.nir,
|
||||
this.dateAgrement,
|
||||
this.nbMaxEnfants,
|
||||
this.placeDisponible,
|
||||
this.biographie,
|
||||
});
|
||||
|
||||
bool get isParent => role == 'parent';
|
||||
bool get isAm => role == 'assistante_maternelle';
|
||||
|
||||
static Map<String, dynamic>? _nestedMap(dynamic value) {
|
||||
if (value is Map) return Map<String, dynamic>.from(value);
|
||||
return null;
|
||||
}
|
||||
|
||||
static dynamic _firstNonNull(List<dynamic> values) {
|
||||
for (final v in values) {
|
||||
if (v != null) return v;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
factory RepriseDossier.fromJson(Map<String, dynamic> json) {
|
||||
final nestedUser = _nestedMap(json['user']);
|
||||
final nestedDossier = _nestedMap(json['dossier']);
|
||||
|
||||
final parentsRaw = json['parents'];
|
||||
final parentsList = parentsRaw is List
|
||||
? parentsRaw
|
||||
.where((e) => e is Map)
|
||||
.map((e) => ParentDossier.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||
.toList()
|
||||
: <ParentDossier>[];
|
||||
|
||||
final enfantsRaw = json['enfants'];
|
||||
final enfantsList = enfantsRaw is List
|
||||
? enfantsRaw
|
||||
.where((e) => e is Map)
|
||||
.map((e) => EnfantDossier.fromJson(Map<String, dynamic>.from(e as Map)))
|
||||
.toList()
|
||||
: <EnfantDossier>[];
|
||||
|
||||
return RepriseDossier(
|
||||
id: json['id']?.toString() ?? '',
|
||||
email: json['email']?.toString() ?? '',
|
||||
prenom: json['prenom']?.toString(),
|
||||
nom: json['nom']?.toString(),
|
||||
telephone: json['telephone']?.toString(),
|
||||
adresse: json['adresse']?.toString(),
|
||||
ville: json['ville']?.toString(),
|
||||
codePostal: json['code_postal']?.toString(),
|
||||
numeroDossier: json['numero_dossier']?.toString(),
|
||||
role: json['role']?.toString() ?? '',
|
||||
photoUrl: json['photo_url']?.toString(),
|
||||
genre: json['genre']?.toString(),
|
||||
situationFamiliale: json['situation_familiale']?.toString(),
|
||||
parents: parentsList,
|
||||
enfants: enfantsList,
|
||||
texteMotivation: (json['texte_motivation'] ?? json['presentation_dossier'])
|
||||
?.toString(),
|
||||
consentementPhoto: RepriseMapper.optionalBool(json['consentement_photo']) ||
|
||||
RepriseMapper.optionalBool(json['consent_photo']) ||
|
||||
RepriseMapper.optionalBool(nestedUser?['consentement_photo']),
|
||||
dateNaissance: RepriseMapper.optionalDateString(
|
||||
_firstNonNull([
|
||||
json['date_naissance'],
|
||||
json['dateNaissance'],
|
||||
nestedUser?['date_naissance'],
|
||||
nestedUser?['dateNaissance'],
|
||||
]),
|
||||
),
|
||||
lieuNaissanceVille: _firstNonNull([
|
||||
json['lieu_naissance_ville'],
|
||||
json['lieuNaissanceVille'],
|
||||
nestedUser?['lieu_naissance_ville'],
|
||||
nestedUser?['lieuNaissanceVille'],
|
||||
])?.toString(),
|
||||
lieuNaissancePays: _firstNonNull([
|
||||
json['lieu_naissance_pays'],
|
||||
json['lieuNaissancePays'],
|
||||
nestedUser?['lieu_naissance_pays'],
|
||||
nestedUser?['lieuNaissancePays'],
|
||||
])?.toString(),
|
||||
numeroAgrement: _firstNonNull([
|
||||
json['numero_agrement'],
|
||||
json['numero_agrement_am'],
|
||||
json['numeroAgrement'],
|
||||
nestedDossier?['numero_agrement'],
|
||||
nestedDossier?['numeroAgrement'],
|
||||
])?.toString(),
|
||||
nir: _firstNonNull([
|
||||
json['nir'],
|
||||
nestedDossier?['nir'],
|
||||
])?.toString(),
|
||||
dateAgrement: RepriseMapper.optionalDateString(
|
||||
_firstNonNull([
|
||||
json['date_agrement'],
|
||||
json['dateAgrement'],
|
||||
nestedDossier?['date_agrement'],
|
||||
nestedDossier?['dateAgrement'],
|
||||
]),
|
||||
),
|
||||
nbMaxEnfants: RepriseMapper.optionalInt(
|
||||
_firstNonNull([
|
||||
json['nb_max_enfants'],
|
||||
json['capacite_accueil'],
|
||||
json['nbMaxEnfants'],
|
||||
nestedDossier?['nb_max_enfants'],
|
||||
nestedDossier?['capacite_accueil'],
|
||||
]),
|
||||
),
|
||||
placeDisponible: RepriseMapper.optionalInt(
|
||||
_firstNonNull([
|
||||
json['place_disponible'],
|
||||
json['places_disponibles'],
|
||||
json['placesDisponibles'],
|
||||
nestedDossier?['place_disponible'],
|
||||
nestedDossier?['places_disponibles'],
|
||||
]),
|
||||
),
|
||||
biographie: (json['biographie'] ?? json['presentation'])?.toString(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -30,6 +30,7 @@ class ParentData {
|
||||
class ChildData {
|
||||
static const Object _unsetImage = Object();
|
||||
static const Object _unsetImageBytes = Object();
|
||||
static const Object _unsetExistingPhotoUrl = Object();
|
||||
|
||||
String firstName;
|
||||
String lastName;
|
||||
@@ -43,6 +44,10 @@ class ChildData {
|
||||
/// Octets de la photo (fiable à l’envoi API ; [imageFile] peut être absent sur le web).
|
||||
Uint8List? imageBytes;
|
||||
CardColorVertical cardColor; // Nouveau champ pour la couleur de la carte
|
||||
/// UUID enfant en base (reprise #112) — requis pour PATCH reprise-resoumettre.
|
||||
String? repriseChildId;
|
||||
/// Photo déjà stockée (affichage reprise sans re-upload).
|
||||
String? existingPhotoUrl;
|
||||
|
||||
ChildData({
|
||||
this.firstName = '',
|
||||
@@ -55,6 +60,8 @@ class ChildData {
|
||||
this.imageFile,
|
||||
this.imageBytes,
|
||||
required this.cardColor, // Rendre requis dans le constructeur
|
||||
this.repriseChildId,
|
||||
this.existingPhotoUrl,
|
||||
});
|
||||
|
||||
ChildData copyWith({
|
||||
@@ -68,6 +75,8 @@ class ChildData {
|
||||
Object? imageFile = _unsetImage,
|
||||
Object? imageBytes = _unsetImageBytes,
|
||||
CardColorVertical? cardColor,
|
||||
String? repriseChildId,
|
||||
Object? existingPhotoUrl = _unsetExistingPhotoUrl,
|
||||
}) {
|
||||
return ChildData(
|
||||
firstName: firstName ?? this.firstName,
|
||||
@@ -81,6 +90,10 @@ class ChildData {
|
||||
imageBytes:
|
||||
identical(imageBytes, _unsetImageBytes) ? this.imageBytes : imageBytes as Uint8List?,
|
||||
cardColor: cardColor ?? this.cardColor,
|
||||
repriseChildId: repriseChildId ?? this.repriseChildId,
|
||||
existingPhotoUrl: identical(existingPhotoUrl, _unsetExistingPhotoUrl)
|
||||
? this.existingPhotoUrl
|
||||
: existingPhotoUrl as String?,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -178,6 +191,38 @@ class UserRegistrationData extends ChangeNotifier {
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Reprise après refus (#112) : réinitialise le flux parent avec les données dossier.
|
||||
void resetForReprise({
|
||||
required ParentData parent1Data,
|
||||
ParentData? parent2Data,
|
||||
List<ChildData>? childrenData,
|
||||
String motivation = '',
|
||||
}) {
|
||||
parent1 = parent1Data;
|
||||
parent2 = parent2Data;
|
||||
children
|
||||
..clear()
|
||||
..addAll(childrenData ?? const []);
|
||||
motivationText = motivation;
|
||||
cguAccepted = false;
|
||||
bankDetails = null;
|
||||
attestationCafNumber = '';
|
||||
consentQuotientFamilial = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
/// Reprise (#112) : coordonnées + enfants connus + CGU.
|
||||
bool get isRepriseSubmitReady =>
|
||||
parent1.firstName.isNotEmpty &&
|
||||
parent1.lastName.isNotEmpty &&
|
||||
parent1.email.isNotEmpty &&
|
||||
children.isNotEmpty &&
|
||||
children.every(
|
||||
(c) =>
|
||||
c.repriseChildId != null && c.repriseChildId!.trim().isNotEmpty,
|
||||
) &&
|
||||
cguAccepted;
|
||||
|
||||
// Méthode pour vérifier si toutes les données requises sont là (simplifié)
|
||||
bool isRegistrationComplete() {
|
||||
// Ajouter ici les validations nécessaires
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../models/am_registration_data.dart';
|
||||
import '../../widgets/personal_info_form_screen.dart';
|
||||
import '../../services/reprise_session.dart';
|
||||
import '../../models/card_assets.dart';
|
||||
|
||||
class AmRegisterStep1Screen extends StatelessWidget {
|
||||
@@ -29,7 +30,7 @@ class AmRegisterStep1Screen extends StatelessWidget {
|
||||
cardColor: CardColorHorizontal.blue,
|
||||
initialData: initialData,
|
||||
minPersonNameLength: 2,
|
||||
previousRoute: '/register-choice',
|
||||
previousRoute: RepriseSession.isActive ? '/login' : '/register-choice',
|
||||
onSubmit: (data, {hasSecondPerson, sameAddress}) {
|
||||
registrationData.updateIdentityInfo(
|
||||
firstName: data.firstName,
|
||||
|
||||
@@ -11,45 +11,53 @@ class AmRegisterStep2Screen extends StatelessWidget {
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final registrationData = Provider.of<AmRegistrationData>(context, listen: false);
|
||||
return Consumer<AmRegistrationData>(
|
||||
builder: (context, registrationData, _) {
|
||||
final initialData = ProfessionalInfoData(
|
||||
photoPath: registrationData.photoPath,
|
||||
photoBytes: registrationData.photoBytes,
|
||||
photoFilename: registrationData.photoFilename,
|
||||
photoConsent: registrationData.photoConsent,
|
||||
dateOfBirth: registrationData.dateOfBirth,
|
||||
birthCity: registrationData.birthCity,
|
||||
birthCountry: registrationData.birthCountry,
|
||||
nir: registrationData.nir,
|
||||
agrementNumber: registrationData.agrementNumber,
|
||||
agreementDate: registrationData.agreementDate,
|
||||
capacity: registrationData.capacity,
|
||||
placesAvailable: registrationData.placesAvailable,
|
||||
);
|
||||
|
||||
final initialData = ProfessionalInfoData(
|
||||
photoPath: registrationData.photoPath,
|
||||
photoBytes: registrationData.photoBytes,
|
||||
photoFilename: registrationData.photoFilename,
|
||||
photoConsent: registrationData.photoConsent,
|
||||
dateOfBirth: registrationData.dateOfBirth,
|
||||
birthCity: registrationData.birthCity,
|
||||
birthCountry: registrationData.birthCountry,
|
||||
nir: registrationData.nir,
|
||||
agrementNumber: registrationData.agrementNumber,
|
||||
agreementDate: registrationData.agreementDate,
|
||||
capacity: registrationData.capacity,
|
||||
placesAvailable: registrationData.placesAvailable,
|
||||
);
|
||||
|
||||
return ProfessionalInfoFormScreen(
|
||||
stepText: 'Étape 2/4',
|
||||
title: 'Vos informations professionnelles',
|
||||
cardColor: CardColorHorizontal.green,
|
||||
initialData: initialData,
|
||||
previousRoute: '/am-register-step1',
|
||||
onSubmit: (data) {
|
||||
registrationData.updateProfessionalInfo(
|
||||
photoPath: data.photoPath,
|
||||
photoBytes: data.photoBytes,
|
||||
photoFilename: data.photoFilename,
|
||||
photoConsent: data.photoConsent,
|
||||
dateOfBirth: data.dateOfBirth,
|
||||
birthCity: data.birthCity,
|
||||
birthCountry: data.birthCountry,
|
||||
nir: data.nir,
|
||||
agrementNumber: data.agrementNumber,
|
||||
agreementDate: data.agreementDate,
|
||||
capacity: data.capacity,
|
||||
placesAvailable: data.placesAvailable,
|
||||
return ProfessionalInfoFormScreen(
|
||||
key: ValueKey(
|
||||
'am-pro-${registrationData.dateOfBirth?.millisecondsSinceEpoch}'
|
||||
'-${registrationData.agreementDate?.millisecondsSinceEpoch}'
|
||||
'-${registrationData.placesAvailable}'
|
||||
'-${registrationData.nir}',
|
||||
),
|
||||
stepText: 'Étape 2/4',
|
||||
title: 'Vos informations professionnelles',
|
||||
cardColor: CardColorHorizontal.green,
|
||||
initialData: initialData,
|
||||
previousRoute: '/am-register-step1',
|
||||
onSubmit: (data) {
|
||||
registrationData.updateProfessionalInfo(
|
||||
photoPath: data.photoPath,
|
||||
photoBytes: data.photoBytes,
|
||||
photoFilename: data.photoFilename,
|
||||
photoConsent: data.photoConsent,
|
||||
dateOfBirth: data.dateOfBirth,
|
||||
birthCity: data.birthCity,
|
||||
birthCountry: data.birthCountry,
|
||||
nir: data.nir,
|
||||
agrementNumber: data.agrementNumber,
|
||||
agreementDate: data.agreementDate,
|
||||
capacity: data.capacity,
|
||||
placesAvailable: data.placesAvailable,
|
||||
);
|
||||
context.go('/am-register-step3');
|
||||
},
|
||||
);
|
||||
context.go('/am-register-step3');
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
@@ -8,6 +8,8 @@ import '../../models/am_registration_data.dart';
|
||||
import '../../models/card_assets.dart';
|
||||
import '../../config/display_config.dart';
|
||||
import '../../services/auth_service.dart';
|
||||
import '../../services/reprise_session.dart';
|
||||
import '../../utils/reprise_payload.dart';
|
||||
import '../../widgets/hover_relief_widget.dart';
|
||||
import '../../widgets/image_button.dart';
|
||||
import '../../widgets/custom_navigation_button.dart';
|
||||
@@ -27,7 +29,21 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
|
||||
|
||||
Future<void> _submitAMRegistration(AmRegistrationData registrationData) async {
|
||||
if (_isSubmitting) return;
|
||||
if (!registrationData.isRegistrationComplete) {
|
||||
if (RepriseSession.isActive) {
|
||||
if (!registrationData.isRepriseSubmitReady) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Vérifiez vos coordonnées et acceptez les conditions.',
|
||||
style: GoogleFonts.merienda(fontSize: 14),
|
||||
),
|
||||
backgroundColor: Colors.red.shade700,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} else if (!registrationData.isRegistrationComplete) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
@@ -42,6 +58,19 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
|
||||
}
|
||||
setState(() => _isSubmitting = true);
|
||||
try {
|
||||
if (RepriseSession.isActive) {
|
||||
await AuthService.resoumettreReprise(
|
||||
await ReprisePayload.amPatch(
|
||||
registrationData,
|
||||
RepriseSession.token!,
|
||||
existingPhotoUrl: RepriseSession.photoUrlForApi,
|
||||
),
|
||||
);
|
||||
RepriseSession.clear();
|
||||
if (!mounted) return;
|
||||
_showRepriseConfirmationModal(context);
|
||||
return;
|
||||
}
|
||||
await AuthService.registerAM(registrationData);
|
||||
if (!mounted) return;
|
||||
_showConfirmationModal(context);
|
||||
@@ -244,6 +273,34 @@ class _AmRegisterStep4ScreenState extends State<AmRegisterStep4Screen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showRepriseConfirmationModal(BuildContext context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
'Dossier resoumis',
|
||||
style: GoogleFonts.merienda(fontWeight: FontWeight.bold),
|
||||
),
|
||||
content: Text(
|
||||
'Vos modifications ont été enregistrées. Votre dossier est de nouveau en attente de validation.',
|
||||
style: GoogleFonts.merienda(fontSize: 14),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
child: Text('OK', style: GoogleFonts.merienda(fontWeight: FontWeight.bold)),
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
context.go('/login');
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showConfirmationModal(BuildContext context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
|
||||
@@ -10,6 +10,7 @@ import '../../widgets/image_button.dart';
|
||||
import '../../widgets/custom_app_text_field.dart';
|
||||
import '../../services/auth_service.dart';
|
||||
import '../../widgets/auth/change_password_dialog.dart';
|
||||
import '../../widgets/auth/reprise_identify_dialog.dart';
|
||||
|
||||
class LoginScreen extends StatefulWidget {
|
||||
const LoginScreen({super.key});
|
||||
@@ -100,6 +101,31 @@ class _LoginPageState extends State<LoginScreen> {
|
||||
_handleLogin();
|
||||
}
|
||||
|
||||
Future<void> _openRepriseIdentify() async {
|
||||
final token = await showDialog<String>(
|
||||
context: context,
|
||||
builder: (ctx) => RepriseIdentifyDialog(
|
||||
initialEmail: _emailController.text.trim(),
|
||||
),
|
||||
);
|
||||
if (!mounted || token == null || token.isEmpty) return;
|
||||
context.go('/reprise?token=${Uri.encodeComponent(token)}');
|
||||
}
|
||||
|
||||
Widget _buildRepriseDossierLink({double fontSize = 14}) {
|
||||
return TextButton(
|
||||
onPressed: _isLoading ? null : _openRepriseIdentify,
|
||||
child: Text(
|
||||
'J’ai un numéro de dossier',
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: fontSize,
|
||||
color: const Color(0xFF2D6A4F),
|
||||
decoration: TextDecoration.underline,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
/// Gère la connexion de l'utilisateur
|
||||
Future<void> _handleLogin() async {
|
||||
// Réinitialiser le message d'erreur
|
||||
@@ -384,6 +410,7 @@ class _LoginPageState extends State<LoginScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
Center(child: _buildRepriseDossierLink()),
|
||||
],
|
||||
),
|
||||
),
|
||||
@@ -640,6 +667,7 @@ class _LoginPageState extends State<LoginScreen> {
|
||||
),
|
||||
),
|
||||
),
|
||||
_buildRepriseDossierLink(),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -4,6 +4,7 @@ import 'package:go_router/go_router.dart';
|
||||
|
||||
import '../../models/user_registration_data.dart';
|
||||
import '../../widgets/personal_info_form_screen.dart';
|
||||
import '../../services/reprise_session.dart';
|
||||
import '../../models/card_assets.dart';
|
||||
|
||||
class ParentRegisterStep1Screen extends StatelessWidget {
|
||||
@@ -29,7 +30,7 @@ class ParentRegisterStep1Screen extends StatelessWidget {
|
||||
title: 'Informations du Parent Principal',
|
||||
cardColor: CardColorHorizontal.peach,
|
||||
initialData: initialData,
|
||||
previousRoute: '/register-choice',
|
||||
previousRoute: RepriseSession.isActive ? '/login' : '/register-choice',
|
||||
onSubmit: (data, {hasSecondPerson, sameAddress}) {
|
||||
registrationData.updateParent1(ParentData(
|
||||
firstName: data.firstName,
|
||||
|
||||
@@ -166,7 +166,11 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
|
||||
if (await f.exists()) file = f;
|
||||
} catch (_) {}
|
||||
}
|
||||
final updatedChild = oldChild.copyWith(imageBytes: bytes, imageFile: file);
|
||||
final updatedChild = oldChild.copyWith(
|
||||
imageBytes: bytes,
|
||||
imageFile: file,
|
||||
existingPhotoUrl: null,
|
||||
);
|
||||
registrationData.updateChild(childIndex, updatedChild);
|
||||
}
|
||||
}
|
||||
@@ -306,7 +310,7 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
|
||||
onClearImage: () => setState(() {
|
||||
final c = registrationData.children[index];
|
||||
registrationData.updateChild(
|
||||
index, c.copyWith(imageFile: null, imageBytes: null));
|
||||
index, c.copyWith(imageFile: null, imageBytes: null, existingPhotoUrl: null));
|
||||
}),
|
||||
onDateSelect: () => _selectDate(context, index, registrationData),
|
||||
onFirstNameChanged: (value) => setState(() {
|
||||
@@ -414,7 +418,7 @@ class _ParentRegisterStep3ScreenState extends State<ParentRegisterStep3Screen> {
|
||||
onClearImage: () => setState(() {
|
||||
final c = registrationData.children[index];
|
||||
registrationData.updateChild(
|
||||
index, c.copyWith(imageFile: null, imageBytes: null));
|
||||
index, c.copyWith(imageFile: null, imageBytes: null, existingPhotoUrl: null));
|
||||
}),
|
||||
onDateSelect: () => _selectDate(context, index, registrationData),
|
||||
onFirstNameChanged: (value) => setState(() {
|
||||
|
||||
@@ -14,6 +14,8 @@ import '../../widgets/personal_info_form_screen.dart';
|
||||
import '../../widgets/child_card_widget.dart';
|
||||
import '../../widgets/presentation_form_screen.dart';
|
||||
import '../../services/auth_service.dart';
|
||||
import '../../services/reprise_session.dart';
|
||||
import '../../utils/reprise_payload.dart';
|
||||
|
||||
class ParentRegisterStep5Screen extends StatefulWidget {
|
||||
const ParentRegisterStep5Screen({super.key});
|
||||
@@ -27,8 +29,32 @@ class _ParentRegisterStep5ScreenState extends State<ParentRegisterStep5Screen> {
|
||||
|
||||
Future<void> _submitRegistration(BuildContext context, UserRegistrationData data) async {
|
||||
if (_isSubmitting) return;
|
||||
if (RepriseSession.isActive) {
|
||||
if (!data.isRepriseSubmitReady) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
'Vérifiez vos coordonnées, les enfants et acceptez les conditions.',
|
||||
style: GoogleFonts.merienda(fontSize: 14),
|
||||
),
|
||||
backgroundColor: Colors.red.shade700,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setState(() => _isSubmitting = true);
|
||||
try {
|
||||
if (RepriseSession.isActive) {
|
||||
final token = RepriseSession.token!;
|
||||
await AuthService.resoumettreReprise(
|
||||
ReprisePayload.parentPatch(data, token),
|
||||
);
|
||||
RepriseSession.clear();
|
||||
if (!context.mounted) return;
|
||||
_showRepriseSuccessModal(context);
|
||||
return;
|
||||
}
|
||||
await AuthService.registerParent(data);
|
||||
if (!context.mounted) return;
|
||||
_showSuccessModal(context);
|
||||
@@ -279,6 +305,34 @@ class _ParentRegisterStep5ScreenState extends State<ParentRegisterStep5Screen> {
|
||||
);
|
||||
}
|
||||
|
||||
void _showRepriseSuccessModal(BuildContext context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (BuildContext dialogContext) {
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
'Dossier resoumis',
|
||||
style: GoogleFonts.merienda(fontWeight: FontWeight.bold),
|
||||
),
|
||||
content: Text(
|
||||
'Vos modifications ont été enregistrées. Votre dossier est de nouveau en attente de validation.',
|
||||
style: GoogleFonts.merienda(fontSize: 14),
|
||||
),
|
||||
actions: <Widget>[
|
||||
TextButton(
|
||||
child: Text('OK', style: GoogleFonts.merienda(fontWeight: FontWeight.bold)),
|
||||
onPressed: () {
|
||||
Navigator.of(dialogContext).pop();
|
||||
context.go('/login');
|
||||
},
|
||||
),
|
||||
],
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
void _showSuccessModal(BuildContext context) {
|
||||
showDialog<void>(
|
||||
context: context,
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:go_router/go_router.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
import '../../config/app_router.dart';
|
||||
import '../../services/auth_service.dart';
|
||||
import '../../services/reprise_session.dart';
|
||||
|
||||
/// Point d'entrée `/reprise?token=` — charge le dossier et ouvre le wizard (#112).
|
||||
class RepriseEntryScreen extends StatefulWidget {
|
||||
final String? token;
|
||||
|
||||
const RepriseEntryScreen({super.key, this.token});
|
||||
|
||||
@override
|
||||
State<RepriseEntryScreen> createState() => _RepriseEntryScreenState();
|
||||
}
|
||||
|
||||
class _RepriseEntryScreenState extends State<RepriseEntryScreen> {
|
||||
bool _loading = true;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) => _load());
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
final token = widget.token?.trim() ?? '';
|
||||
if (token.isEmpty) {
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = 'Lien invalide ou expiré.';
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_loading = true;
|
||||
_error = null;
|
||||
});
|
||||
|
||||
try {
|
||||
final dossier = await AuthService.getRepriseDossier(token);
|
||||
if (!mounted) return;
|
||||
|
||||
RepriseSession.clear();
|
||||
RepriseSession.start(token: token, dossier: dossier);
|
||||
|
||||
if (dossier.isParent) {
|
||||
RepriseSession.applyToParent(userRegistrationDataNotifier, dossier);
|
||||
context.go('/parent-register-step1');
|
||||
return;
|
||||
}
|
||||
if (dossier.isAm) {
|
||||
RepriseSession.applyToAm(amRegistrationDataNotifier, dossier);
|
||||
context.go('/am-register-step1');
|
||||
return;
|
||||
}
|
||||
|
||||
RepriseSession.clear();
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = 'Type de dossier non pris en charge pour la reprise.';
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
RepriseSession.clear();
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = e is Exception
|
||||
? e.toString().replaceFirst('Exception: ', '')
|
||||
: 'Impossible de charger votre dossier.';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
body: Stack(
|
||||
children: [
|
||||
Positioned.fill(
|
||||
child: Image.asset(
|
||||
'assets/images/paper2.png',
|
||||
fit: BoxFit.cover,
|
||||
repeat: ImageRepeat.repeat,
|
||||
),
|
||||
),
|
||||
Center(
|
||||
child: ConstrainedBox(
|
||||
constraints: const BoxConstraints(maxWidth: 420),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(24),
|
||||
child: _buildBody(),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildBody() {
|
||||
if (_loading) {
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
const CircularProgressIndicator(),
|
||||
const SizedBox(height: 20),
|
||||
Text(
|
||||
'Chargement de votre dossier…',
|
||||
style: GoogleFonts.merienda(fontSize: 16),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
final err = _error ?? 'Lien invalide ou expiré.';
|
||||
return Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Icon(Icons.error_outline, size: 48, color: Colors.red.shade700),
|
||||
const SizedBox(height: 16),
|
||||
Text(
|
||||
'Reprise du dossier',
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 20,
|
||||
fontWeight: FontWeight.bold,
|
||||
),
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
err,
|
||||
style: GoogleFonts.merienda(fontSize: 14, color: Colors.black87),
|
||||
textAlign: TextAlign.center,
|
||||
),
|
||||
const SizedBox(height: 24),
|
||||
TextButton(
|
||||
onPressed: () => context.go('/login'),
|
||||
child: Text(
|
||||
'Retour à la connexion',
|
||||
style: GoogleFonts.merienda(
|
||||
decoration: TextDecoration.underline,
|
||||
color: const Color(0xFF2D6A4F),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -49,6 +49,10 @@ class ApiConfig {
|
||||
/// Ticket #127 — mot de passe oublié (back à livrer en parallèle).
|
||||
static const String forgotPassword = '/auth/forgot-password';
|
||||
static const String resetPassword = '/auth/reset-password';
|
||||
/// Ticket #112 — reprise après refus (#111 back).
|
||||
static const String repriseDossier = '/auth/reprise-dossier';
|
||||
static const String repriseResoumettre = '/auth/reprise-resoumettre';
|
||||
static const String repriseIdentify = '/auth/reprise-identify';
|
||||
|
||||
// Users endpoints
|
||||
static const String users = '/users';
|
||||
|
||||
@@ -8,6 +8,7 @@ import 'package:shared_preferences/shared_preferences.dart';
|
||||
import '../models/user.dart';
|
||||
import '../models/am_registration_data.dart';
|
||||
import '../models/user_registration_data.dart';
|
||||
import '../models/reprise_dossier.dart';
|
||||
import '../utils/parent_registration_payload.dart';
|
||||
import 'api/api_config.dart';
|
||||
import 'api/tokenService.dart';
|
||||
@@ -258,6 +259,116 @@ class AuthService {
|
||||
throw Exception(msg);
|
||||
}
|
||||
|
||||
/// Charge le dossier pour reprise (lien e-mail). GET /auth/reprise-dossier. Ticket #112.
|
||||
static Future<RepriseDossier> getRepriseDossier(String token) async {
|
||||
final cleaned = token.trim();
|
||||
if (cleaned.isEmpty) {
|
||||
throw Exception('Lien invalide ou expiré.');
|
||||
}
|
||||
final uri = Uri.parse(
|
||||
'${ApiConfig.baseUrl}${ApiConfig.repriseDossier}',
|
||||
).replace(queryParameters: {'token': cleaned});
|
||||
late final http.Response response;
|
||||
try {
|
||||
response = await http.get(uri, headers: ApiConfig.headers);
|
||||
} on http.ClientException {
|
||||
throw Exception(
|
||||
'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau puis réessayez.',
|
||||
);
|
||||
}
|
||||
if (response.statusCode == 404) {
|
||||
throw Exception('Lien invalide ou expiré.');
|
||||
}
|
||||
if (response.statusCode != 200) {
|
||||
final decoded = _tryDecodeJsonMap(response.body);
|
||||
throw Exception(_extractErrorMessage(decoded, response.statusCode));
|
||||
}
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw Exception('Réponse invalide du serveur.');
|
||||
}
|
||||
final payload = decoded['data'] is Map
|
||||
? Map<String, dynamic>.from(decoded['data'] as Map)
|
||||
: decoded;
|
||||
return RepriseDossier.fromJson(payload);
|
||||
}
|
||||
|
||||
/// Modale login : numéro + e-mail → token reprise. POST /auth/reprise-identify. #112.
|
||||
static Future<String> identifyReprise({
|
||||
required String numeroDossier,
|
||||
required String email,
|
||||
}) async {
|
||||
final num = numeroDossier.trim();
|
||||
final mail = normalizeEmailText(email);
|
||||
if (num.isEmpty || mail.isEmpty) {
|
||||
throw Exception('Numéro de dossier et e-mail requis.');
|
||||
}
|
||||
late final http.Response response;
|
||||
try {
|
||||
response = await http.post(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.repriseIdentify}'),
|
||||
headers: ApiConfig.headers,
|
||||
body: jsonEncode({
|
||||
'numero_dossier': num,
|
||||
'email': mail,
|
||||
}),
|
||||
);
|
||||
} on http.ClientException {
|
||||
throw Exception(
|
||||
'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau puis réessayez.',
|
||||
);
|
||||
}
|
||||
if (response.statusCode == 404) {
|
||||
throw Exception(
|
||||
'Aucun dossier en reprise trouvé pour ce numéro et cet e-mail.',
|
||||
);
|
||||
}
|
||||
if (response.statusCode != 200 && response.statusCode != 201) {
|
||||
final decoded = _tryDecodeJsonMap(response.body);
|
||||
throw Exception(_extractErrorMessage(decoded, response.statusCode));
|
||||
}
|
||||
final decoded = jsonDecode(response.body);
|
||||
if (decoded is! Map<String, dynamic>) {
|
||||
throw Exception('Réponse invalide du serveur.');
|
||||
}
|
||||
final token = decoded['token']?.toString().trim() ?? '';
|
||||
if (token.isEmpty) {
|
||||
throw Exception('Réponse invalide du serveur.');
|
||||
}
|
||||
return token;
|
||||
}
|
||||
|
||||
/// Resoumission après refus. PATCH /auth/reprise-resoumettre. Ticket #112.
|
||||
static Future<void> resoumettreReprise(Map<String, dynamic> body) async {
|
||||
final cleaned = body['token']?.toString().trim() ?? '';
|
||||
if (cleaned.isEmpty) {
|
||||
throw Exception('Lien invalide ou expiré.');
|
||||
}
|
||||
final payload = Map<String, dynamic>.from(body);
|
||||
payload['token'] = cleaned;
|
||||
|
||||
late final http.Response response;
|
||||
try {
|
||||
response = await http.patch(
|
||||
Uri.parse('${ApiConfig.baseUrl}${ApiConfig.repriseResoumettre}'),
|
||||
headers: ApiConfig.headers,
|
||||
body: jsonEncode(payload),
|
||||
);
|
||||
} on http.ClientException {
|
||||
throw Exception(
|
||||
'Connexion à ${ApiConfig.baseUrl} impossible. Vérifiez votre réseau puis réessayez.',
|
||||
);
|
||||
}
|
||||
if (response.statusCode == 404) {
|
||||
throw Exception('Lien invalide ou expiré.');
|
||||
}
|
||||
if (response.statusCode == 200 || response.statusCode == 201) {
|
||||
return;
|
||||
}
|
||||
final decoded = _tryDecodeJsonMap(response.body);
|
||||
throw Exception(_extractErrorMessage(decoded, response.statusCode));
|
||||
}
|
||||
|
||||
/// Déconnexion de l'utilisateur
|
||||
static Future<void> logout() async {
|
||||
await TokenService.clearAll();
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import 'package:p_tits_pas/models/am_registration_data.dart';
|
||||
import 'package:p_tits_pas/models/reprise_dossier.dart';
|
||||
import 'package:p_tits_pas/models/user_registration_data.dart';
|
||||
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||
import 'package:p_tits_pas/utils/reprise_mapper.dart';
|
||||
|
||||
/// Contexte reprise après refus (token e-mail ou identify). Ticket #112.
|
||||
class RepriseSession {
|
||||
RepriseSession._();
|
||||
|
||||
static String? _token;
|
||||
static String? _role;
|
||||
static String? _photoUrl;
|
||||
static String? _photoUrlForApi;
|
||||
|
||||
static bool get isActive =>
|
||||
_token != null && _token!.trim().isNotEmpty;
|
||||
|
||||
static String? get token => _token;
|
||||
|
||||
static bool get isParent => _role == 'parent';
|
||||
|
||||
static bool get isAm => _role == 'assistante_maternelle';
|
||||
|
||||
/// URL absolue pour l'affichage.
|
||||
static String? get photoUrl => _photoUrl;
|
||||
|
||||
/// Chemin relatif API (`/uploads/…`) pour PATCH sans re-upload.
|
||||
static String? get photoUrlForApi => _photoUrlForApi;
|
||||
|
||||
static void start({
|
||||
required String token,
|
||||
required RepriseDossier dossier,
|
||||
}) {
|
||||
_token = token.trim();
|
||||
_role = dossier.role;
|
||||
final raw = dossier.photoUrl?.trim();
|
||||
_photoUrlForApi = raw != null && raw.isNotEmpty ? raw : null;
|
||||
_photoUrl = _photoUrlForApi != null
|
||||
? ApiConfig.absoluteMediaUrl(_photoUrlForApi)
|
||||
: null;
|
||||
}
|
||||
|
||||
static void clear() {
|
||||
_token = null;
|
||||
_role = null;
|
||||
_photoUrl = null;
|
||||
_photoUrlForApi = null;
|
||||
}
|
||||
|
||||
static void applyToParent(UserRegistrationData data, RepriseDossier dossier) {
|
||||
RepriseMapper.applyParentDossier(data, dossier);
|
||||
}
|
||||
|
||||
static void applyToAm(AmRegistrationData data, RepriseDossier dossier) {
|
||||
RepriseMapper.applyAmDossier(data, dossier);
|
||||
}
|
||||
}
|
||||
@@ -149,6 +149,20 @@ class ParentRegistrationPayload {
|
||||
return body;
|
||||
}
|
||||
|
||||
/// Enfant pour PATCH reprise (inclut `id` si connu).
|
||||
static Map<String, dynamic> childToRepriseJson(
|
||||
ChildData c,
|
||||
int index,
|
||||
String parentNom,
|
||||
) {
|
||||
final map = _childToJson(c, index, parentNom);
|
||||
final id = c.repriseChildId?.trim();
|
||||
if (id != null && id.isNotEmpty) {
|
||||
map['id'] = id;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
static Map<String, dynamic> _childToJson(ChildData c, int index, String parentNom) {
|
||||
final map = <String, dynamic>{
|
||||
'genre': apiGenres.contains(c.genre) ? c.genre : 'Autre',
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
import 'package:p_tits_pas/models/am_registration_data.dart';
|
||||
import 'package:p_tits_pas/models/card_assets.dart';
|
||||
import 'package:p_tits_pas/models/dossier_unifie.dart';
|
||||
import 'package:p_tits_pas/models/reprise_dossier.dart';
|
||||
import 'package:p_tits_pas/models/user_registration_data.dart';
|
||||
import 'package:p_tits_pas/services/api/api_config.dart';
|
||||
|
||||
/// Mapping GET reprise-dossier → modèles wizard. Ticket #112.
|
||||
class RepriseMapper {
|
||||
RepriseMapper._();
|
||||
|
||||
static const List<CardColorVertical> _childCardColors = [
|
||||
CardColorVertical.lavender,
|
||||
CardColorVertical.pink,
|
||||
CardColorVertical.peach,
|
||||
CardColorVertical.lime,
|
||||
CardColorVertical.red,
|
||||
CardColorVertical.green,
|
||||
CardColorVertical.blue,
|
||||
];
|
||||
|
||||
static ParentData parentFromDossier(ParentDossier p) {
|
||||
return ParentData(
|
||||
firstName: p.prenom ?? '',
|
||||
lastName: p.nom ?? '',
|
||||
phone: p.telephone ?? '',
|
||||
email: p.email,
|
||||
address: p.adresse ?? '',
|
||||
postalCode: p.codePostal ?? '',
|
||||
city: p.ville ?? '',
|
||||
password: '',
|
||||
);
|
||||
}
|
||||
|
||||
static ChildData childFromEnfant(EnfantDossier e, int index) {
|
||||
final isUnborn = e.status == 'a_naitre';
|
||||
final dob = isUnborn
|
||||
? isoToDdMmYyyy(e.dueDate)
|
||||
: isoToDdMmYyyy(e.birthDate);
|
||||
final photo = e.photoUrl?.trim();
|
||||
final hasPhoto = photo != null && photo.isNotEmpty;
|
||||
return ChildData(
|
||||
firstName: e.firstName ?? '',
|
||||
lastName: e.lastName ?? '',
|
||||
dob: dob,
|
||||
genre: e.gender ?? '',
|
||||
// Inscription initiale exigeait la coche pour envoyer la photo ; le back
|
||||
// ne persistait pas toujours consent_photo — on pré-coche si photo en base.
|
||||
photoConsent: e.consentPhoto || hasPhoto,
|
||||
multipleBirth: e.estMultiple,
|
||||
isUnbornChild: isUnborn,
|
||||
cardColor: _childCardColors[index % _childCardColors.length],
|
||||
repriseChildId: e.id,
|
||||
existingPhotoUrl: photo != null && photo.isNotEmpty
|
||||
? ApiConfig.absoluteMediaUrl(photo)
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
static void applyParentDossier(UserRegistrationData data, RepriseDossier dossier) {
|
||||
ParentDossier? titulaire;
|
||||
ParentDossier? coParent;
|
||||
|
||||
if (dossier.parents.isNotEmpty) {
|
||||
for (final p in dossier.parents) {
|
||||
if (p.id == dossier.id) {
|
||||
titulaire = p;
|
||||
} else {
|
||||
coParent = p;
|
||||
}
|
||||
}
|
||||
titulaire ??= dossier.parents.first;
|
||||
if (coParent == null && dossier.parents.length > 1) {
|
||||
coParent = dossier.parents.firstWhere(
|
||||
(p) => p.id != titulaire!.id,
|
||||
orElse: () => dossier.parents.last,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
final p1 = titulaire != null
|
||||
? parentFromDossier(titulaire)
|
||||
: ParentData(
|
||||
firstName: dossier.prenom ?? '',
|
||||
lastName: dossier.nom ?? '',
|
||||
phone: dossier.telephone ?? '',
|
||||
email: dossier.email,
|
||||
address: dossier.adresse ?? '',
|
||||
postalCode: dossier.codePostal ?? '',
|
||||
city: dossier.ville ?? '',
|
||||
password: '',
|
||||
);
|
||||
|
||||
final children = dossier.enfants
|
||||
.asMap()
|
||||
.entries
|
||||
.map((e) => childFromEnfant(e.value, e.key))
|
||||
.toList();
|
||||
|
||||
data.resetForReprise(
|
||||
parent1Data: p1,
|
||||
parent2Data: coParent != null ? parentFromDossier(coParent) : null,
|
||||
childrenData: children,
|
||||
motivation: dossier.texteMotivation ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
static DateTime? parseIsoDate(String? raw) {
|
||||
if (raw == null || raw.trim().isEmpty) return null;
|
||||
final s = raw.trim();
|
||||
final iso = RegExp(r'^(\d{4})-(\d{2})-(\d{2})');
|
||||
final isoMatch = iso.firstMatch(s);
|
||||
if (isoMatch != null) {
|
||||
return DateTime(
|
||||
int.parse(isoMatch.group(1)!),
|
||||
int.parse(isoMatch.group(2)!),
|
||||
int.parse(isoMatch.group(3)!),
|
||||
);
|
||||
}
|
||||
try {
|
||||
return DateTime.parse(s);
|
||||
} catch (_) {
|
||||
final parts = s.split('/');
|
||||
if (parts.length == 3) {
|
||||
final day = int.tryParse(parts[0]);
|
||||
final month = int.tryParse(parts[1]);
|
||||
final year = int.tryParse(parts[2]);
|
||||
if (day != null && month != null && year != null) {
|
||||
return DateTime(year, month, day);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
static String? optionalDateString(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is String) {
|
||||
final s = value.trim();
|
||||
return s.isEmpty || s == 'null' ? null : s;
|
||||
}
|
||||
if (value is DateTime) {
|
||||
return '${value.year.toString().padLeft(4, '0')}-'
|
||||
'${value.month.toString().padLeft(2, '0')}-'
|
||||
'${value.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
if (value is num) {
|
||||
final ms = value.abs() > 9999999999
|
||||
? value.toInt()
|
||||
: value.toInt() * 1000;
|
||||
final dt = DateTime.fromMillisecondsSinceEpoch(ms, isUtc: true);
|
||||
return '${dt.year.toString().padLeft(4, '0')}-'
|
||||
'${dt.month.toString().padLeft(2, '0')}-'
|
||||
'${dt.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
if (value is Map) {
|
||||
final y = value['year'];
|
||||
final m = value['month'] ?? value['monthValue'];
|
||||
final d = value['day'] ?? value['dayOfMonth'];
|
||||
if (y is num && m is num && d is num) {
|
||||
return '${y.toInt().toString().padLeft(4, '0')}-'
|
||||
'${m.toInt().toString().padLeft(2, '0')}-'
|
||||
'${d.toInt().toString().padLeft(2, '0')}';
|
||||
}
|
||||
}
|
||||
final s = value.toString().trim();
|
||||
return s.isEmpty || s == 'null' ? null : s;
|
||||
}
|
||||
|
||||
static int? optionalInt(dynamic value) {
|
||||
if (value == null) return null;
|
||||
if (value is int) return value;
|
||||
if (value is num) return value.toInt();
|
||||
if (value is String) {
|
||||
final s = value.trim();
|
||||
if (s.isEmpty) return null;
|
||||
return int.tryParse(s);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static bool optionalBool(dynamic value) {
|
||||
if (value == true) return true;
|
||||
if (value is String && value.toLowerCase() == 'true') return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
static void applyAmDossier(AmRegistrationData data, RepriseDossier dossier) {
|
||||
final rawPhoto = dossier.photoUrl?.trim();
|
||||
final hasPhoto = rawPhoto != null && rawPhoto.isNotEmpty;
|
||||
final displayPhoto =
|
||||
hasPhoto ? ApiConfig.absoluteMediaUrl(rawPhoto) : null;
|
||||
|
||||
data.resetForReprise(
|
||||
firstName: dossier.prenom ?? '',
|
||||
lastName: dossier.nom ?? '',
|
||||
phone: dossier.telephone ?? '',
|
||||
email: dossier.email,
|
||||
streetAddress: dossier.adresse ?? '',
|
||||
postalCode: dossier.codePostal ?? '',
|
||||
city: dossier.ville ?? '',
|
||||
existingPhotoUrl: displayPhoto,
|
||||
consentementPhoto: dossier.consentementPhoto || hasPhoto,
|
||||
dateOfBirth: parseIsoDate(dossier.dateNaissance),
|
||||
birthCity: dossier.lieuNaissanceVille ?? '',
|
||||
birthCountry: dossier.lieuNaissancePays ?? '',
|
||||
nir: dossier.nir ?? '',
|
||||
agrementNumber: dossier.numeroAgrement ?? '',
|
||||
agreementDate: parseIsoDate(dossier.dateAgrement),
|
||||
capacity: dossier.nbMaxEnfants,
|
||||
placesAvailable: dossier.placeDisponible,
|
||||
presentationText: dossier.biographie ?? '',
|
||||
);
|
||||
}
|
||||
|
||||
static String isoToDdMmYyyy(String? iso) {
|
||||
final dt = parseIsoDate(iso);
|
||||
if (dt == null) return '';
|
||||
return '${dt.day.toString().padLeft(2, '0')}/'
|
||||
'${dt.month.toString().padLeft(2, '0')}/'
|
||||
'${dt.year}';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/foundation.dart';
|
||||
|
||||
import '../models/am_registration_data.dart';
|
||||
import '../models/user_registration_data.dart';
|
||||
import 'nir_utils.dart';
|
||||
import 'parent_registration_payload.dart';
|
||||
|
||||
/// Body PATCH /auth/reprise-resoumettre. Ticket #112.
|
||||
class ReprisePayload {
|
||||
ReprisePayload._();
|
||||
|
||||
static Map<String, dynamic> parentPatch(
|
||||
UserRegistrationData data,
|
||||
String token,
|
||||
) {
|
||||
final base = ParentRegistrationPayload.toJson(data);
|
||||
base.remove('email');
|
||||
base.remove('acceptation_cgu');
|
||||
base.remove('acceptation_privacy');
|
||||
base['token'] = token.trim();
|
||||
|
||||
final enfants = data.children.asMap().entries.map((e) {
|
||||
final childMap = ParentRegistrationPayload.childToRepriseJson(
|
||||
e.value,
|
||||
e.key,
|
||||
data.parent1.lastName.trim(),
|
||||
);
|
||||
return childMap;
|
||||
}).toList();
|
||||
base['enfants'] = enfants;
|
||||
|
||||
return base;
|
||||
}
|
||||
|
||||
static Future<Map<String, dynamic>> amPatch(
|
||||
AmRegistrationData data,
|
||||
String token, {
|
||||
String? existingPhotoUrl,
|
||||
}) async {
|
||||
final body = <String, dynamic>{'token': token.trim()};
|
||||
|
||||
_put(body, 'prenom', data.firstName.trim());
|
||||
_put(body, 'nom', data.lastName.trim());
|
||||
_put(body, 'telephone', data.phone.trim());
|
||||
_put(body, 'adresse', data.streetAddress.trim());
|
||||
_put(body, 'code_postal', data.postalCode.trim());
|
||||
_put(body, 'ville', data.city.trim());
|
||||
|
||||
body['consentement_photo'] = data.photoConsent;
|
||||
|
||||
if (data.dateOfBirth != null) {
|
||||
final d = data.dateOfBirth!;
|
||||
body['date_naissance'] =
|
||||
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
_put(body, 'lieu_naissance_ville', data.birthCity.trim());
|
||||
_put(body, 'lieu_naissance_pays', data.birthCountry.trim());
|
||||
_put(body, 'numero_agrement', data.agrementNumber.trim());
|
||||
if (data.nir.trim().isNotEmpty) {
|
||||
body['nir'] = normalizeNir(data.nir);
|
||||
}
|
||||
if (data.agreementDate != null) {
|
||||
final d = data.agreementDate!;
|
||||
body['date_agrement'] =
|
||||
'${d.year}-${d.month.toString().padLeft(2, '0')}-${d.day.toString().padLeft(2, '0')}';
|
||||
}
|
||||
if (data.capacity != null) {
|
||||
body['capacite_accueil'] = data.capacity;
|
||||
}
|
||||
if (data.placesAvailable != null) {
|
||||
body['places_disponibles'] = data.placesAvailable;
|
||||
}
|
||||
if (data.presentationText.trim().isNotEmpty) {
|
||||
body['biographie'] = data.presentationText.trim();
|
||||
}
|
||||
|
||||
final photo = await _amPhotoPayload(data, existingPhotoUrl);
|
||||
if (photo != null) {
|
||||
body.addAll(photo);
|
||||
}
|
||||
|
||||
return body;
|
||||
}
|
||||
|
||||
static Future<Map<String, String>?> _amPhotoPayload(
|
||||
AmRegistrationData data,
|
||||
String? existingPhotoUrl,
|
||||
) async {
|
||||
if (data.photoBytes != null && data.photoBytes!.isNotEmpty) {
|
||||
final mime = _imageMimeForBytes(data.photoBytes!);
|
||||
final fn = (data.photoFilename ?? '').trim();
|
||||
return {
|
||||
'photo_base64':
|
||||
'data:$mime;base64,${base64Encode(data.photoBytes!)}',
|
||||
'photo_filename': fn.isNotEmpty ? fn : 'photo_am.jpg',
|
||||
};
|
||||
}
|
||||
|
||||
if (!kIsWeb &&
|
||||
data.photoPath != null &&
|
||||
data.photoPath!.isNotEmpty &&
|
||||
!data.photoPath!.startsWith('assets/') &&
|
||||
!data.photoPath!.startsWith('http')) {
|
||||
try {
|
||||
final file = File(data.photoPath!);
|
||||
if (await file.exists()) {
|
||||
final bytes = await file.readAsBytes();
|
||||
final mime = _imageMimeForBytes(bytes);
|
||||
return {
|
||||
'photo_base64': 'data:$mime;base64,${base64Encode(bytes)}',
|
||||
'photo_filename':
|
||||
_basenameFromPath(data.photoPath!) ?? 'photo_am.jpg',
|
||||
};
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
|
||||
final url = (existingPhotoUrl ?? data.repriseExistingPhotoUrl ?? '').trim();
|
||||
if (url.isNotEmpty) {
|
||||
return {'photo_url': url};
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
static String _imageMimeForBytes(Uint8List bytes) {
|
||||
if (bytes.length >= 2 && bytes[0] == 0xFF && bytes[1] == 0xD8) {
|
||||
return 'image/jpeg';
|
||||
}
|
||||
if (bytes.length >= 8 &&
|
||||
bytes[0] == 0x89 &&
|
||||
bytes[1] == 0x50 &&
|
||||
bytes[2] == 0x4E &&
|
||||
bytes[3] == 0x47) {
|
||||
return 'image/png';
|
||||
}
|
||||
return 'image/jpeg';
|
||||
}
|
||||
|
||||
static String? _basenameFromPath(String path) {
|
||||
final parts = path.replaceAll('\\', '/').split('/');
|
||||
return parts.isEmpty ? null : parts.last;
|
||||
}
|
||||
|
||||
static void _put(Map<String, dynamic> m, String key, String value) {
|
||||
if (value.isNotEmpty) m[key] = value;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
|
||||
import '../../services/auth_service.dart';
|
||||
import '../../utils/email_utils.dart';
|
||||
import '../custom_app_text_field.dart';
|
||||
|
||||
/// Modale login : numéro de dossier + e-mail → token reprise (#112).
|
||||
class RepriseIdentifyDialog extends StatefulWidget {
|
||||
final String? initialEmail;
|
||||
|
||||
const RepriseIdentifyDialog({super.key, this.initialEmail});
|
||||
|
||||
@override
|
||||
State<RepriseIdentifyDialog> createState() => _RepriseIdentifyDialogState();
|
||||
}
|
||||
|
||||
class _RepriseIdentifyDialogState extends State<RepriseIdentifyDialog> {
|
||||
final _formKey = GlobalKey<FormState>();
|
||||
late final TextEditingController _numeroCtrl;
|
||||
late final TextEditingController _emailCtrl;
|
||||
|
||||
bool _loading = false;
|
||||
String? _error;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_numeroCtrl = TextEditingController();
|
||||
_emailCtrl = TextEditingController(text: widget.initialEmail ?? '');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_numeroCtrl.dispose();
|
||||
_emailCtrl.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
String? _validateNumero(String? value) {
|
||||
final v = value?.trim() ?? '';
|
||||
if (v.isEmpty) {
|
||||
return 'Indiquez votre numéro de dossier.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
String? _validateEmail(String? value) {
|
||||
final v = value?.trim() ?? '';
|
||||
if (v.isEmpty) {
|
||||
return 'Indiquez votre adresse e-mail.';
|
||||
}
|
||||
if (!isValidEmailFormat(v)) {
|
||||
return 'L’adresse e-mail n’est pas valide.';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
Future<void> _submit() async {
|
||||
if (_loading) return;
|
||||
setState(() => _error = null);
|
||||
if (!(_formKey.currentState?.validate() ?? false)) return;
|
||||
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final token = await AuthService.identifyReprise(
|
||||
numeroDossier: _numeroCtrl.text,
|
||||
email: _emailCtrl.text,
|
||||
);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop(token);
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_loading = false;
|
||||
_error = e is Exception
|
||||
? e.toString().replaceFirst('Exception: ', '')
|
||||
: 'Impossible de retrouver votre dossier.';
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return AlertDialog(
|
||||
title: Text(
|
||||
'Reprendre mon dossier',
|
||||
style: GoogleFonts.merienda(fontWeight: FontWeight.bold),
|
||||
),
|
||||
content: SizedBox(
|
||||
width: 420,
|
||||
child: Form(
|
||||
key: _formKey,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
Text(
|
||||
'Saisissez le numéro de dossier et l’e-mail utilisés lors '
|
||||
'de l’inscription (dossier refusé en attente de correction).',
|
||||
style: GoogleFonts.merienda(fontSize: 13),
|
||||
),
|
||||
const SizedBox(height: 16),
|
||||
CustomAppTextField(
|
||||
controller: _numeroCtrl,
|
||||
labelText: 'Numéro de dossier',
|
||||
hintText: 'Ex. 2026-000021',
|
||||
textInputAction: TextInputAction.next,
|
||||
validator: _validateNumero,
|
||||
style: CustomAppTextFieldStyle.lavande,
|
||||
fieldHeight: 48,
|
||||
fieldWidth: double.infinity,
|
||||
),
|
||||
const SizedBox(height: 12),
|
||||
CustomAppTextField(
|
||||
controller: _emailCtrl,
|
||||
labelText: 'E-mail',
|
||||
hintText: 'Votre adresse e-mail',
|
||||
keyboardType: TextInputType.emailAddress,
|
||||
autocorrect: false,
|
||||
enableSuggestions: false,
|
||||
inputFormatters: const [EmailMaxLengthFormatter()],
|
||||
textInputAction: TextInputAction.done,
|
||||
onFieldSubmitted: (_) => _submit(),
|
||||
validator: _validateEmail,
|
||||
style: CustomAppTextFieldStyle.lavande,
|
||||
fieldHeight: 48,
|
||||
fieldWidth: double.infinity,
|
||||
),
|
||||
if (_error != null) ...[
|
||||
const SizedBox(height: 12),
|
||||
Text(
|
||||
_error!,
|
||||
style: GoogleFonts.merienda(
|
||||
fontSize: 12,
|
||||
color: Colors.red.shade700,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: _loading ? null : () => Navigator.of(context).pop(),
|
||||
child: Text('Annuler', style: GoogleFonts.merienda()),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: _loading ? null : _submit,
|
||||
child: _loading
|
||||
? const SizedBox(
|
||||
width: 18,
|
||||
height: 18,
|
||||
child: CircularProgressIndicator(strokeWidth: 2),
|
||||
)
|
||||
: Text(
|
||||
'Continuer',
|
||||
style: GoogleFonts.merienda(
|
||||
fontWeight: FontWeight.bold,
|
||||
color: const Color(0xFF2D6A4F),
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:math' as math;
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:google_fonts/google_fonts.dart';
|
||||
@@ -20,7 +21,7 @@ bool _hasChildPhoto(ChildData c) {
|
||||
return registrationPhotoSlotHasImage(
|
||||
imageBytes: c.imageBytes,
|
||||
imageFile: c.imageFile,
|
||||
imagePathOrAsset: null,
|
||||
imagePathOrAsset: c.existingPhotoUrl,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -33,6 +34,20 @@ Widget _buildChildPhotoImage(ChildData c, {required BoxFit fit}) {
|
||||
if (f != null) {
|
||||
return kIsWeb ? Image.network(f.path, fit: fit) : Image.file(f, fit: fit);
|
||||
}
|
||||
final url = c.existingPhotoUrl?.trim();
|
||||
if (url != null && url.isNotEmpty) {
|
||||
if (url.startsWith('http://') || url.startsWith('https://')) {
|
||||
return Image.network(url, fit: fit);
|
||||
}
|
||||
if (!kIsWeb) {
|
||||
try {
|
||||
final file = File(url);
|
||||
if (file.existsSync()) {
|
||||
return Image.file(file, fit: fit);
|
||||
}
|
||||
} catch (_) {}
|
||||
}
|
||||
}
|
||||
return Image.asset('assets/images/photo.png', fit: BoxFit.contain);
|
||||
}
|
||||
|
||||
@@ -221,6 +236,7 @@ class _ChildCardWidgetState extends State<ChildCardWidget> {
|
||||
scaleFactor: scaleFactor,
|
||||
imageBytes: widget.childData.imageBytes,
|
||||
imageFile: widget.childData.imageFile,
|
||||
imagePathOrAsset: widget.childData.existingPhotoUrl,
|
||||
onTapPick: !config.isReadonly ? widget.onPickImage : null,
|
||||
onClear: !config.isReadonly ? widget.onClearImage : null,
|
||||
baseShadowColor: baseCardColorForShadow,
|
||||
|
||||
@@ -134,30 +134,7 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
|
||||
final data = widget.initialData;
|
||||
if (data != null) {
|
||||
_selectedDate = data.dateOfBirth;
|
||||
_dateOfBirthController.text = data.dateOfBirth != null
|
||||
? DateFormat('dd/MM/yyyy').format(data.dateOfBirth!)
|
||||
: '';
|
||||
_birthCityController.text = data.birthCity;
|
||||
_birthCountryController.text = data.birthCountry;
|
||||
final nirRaw = nirToRaw(data.nir);
|
||||
_nirController.text = nirRaw.length == 15 ? formatNir(nirRaw) : data.nir;
|
||||
_agrementController.text = data.agrementNumber;
|
||||
_selectedAgreementDate = data.agreementDate;
|
||||
_agreementDateController.text = data.agreementDate != null
|
||||
? DateFormat('dd/MM/yyyy').format(data.agreementDate!)
|
||||
: '';
|
||||
_capacityController.text = data.capacity?.toString() ?? '';
|
||||
_placesAvailableController.text = data.placesAvailable?.toString() ?? '';
|
||||
_photoPathFramework = data.photoPath;
|
||||
_photoFile = data.photoFile;
|
||||
_photoBytes = data.photoBytes;
|
||||
_photoFilename = data.photoFilename;
|
||||
_photoConsent = data.photoConsent;
|
||||
}
|
||||
_applyInitialData(widget.initialData);
|
||||
|
||||
if (widget.mode == DisplayMode.editable) {
|
||||
_birthCityFocus = FocusNode();
|
||||
@@ -168,6 +145,51 @@ class _ProfessionalInfoFormScreenState extends State<ProfessionalInfoFormScreen>
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
void didUpdateWidget(covariant ProfessionalInfoFormScreen oldWidget) {
|
||||
super.didUpdateWidget(oldWidget);
|
||||
final next = widget.initialData;
|
||||
final prev = oldWidget.initialData;
|
||||
if (next == null) return;
|
||||
if (prev == null ||
|
||||
prev.dateOfBirth != next.dateOfBirth ||
|
||||
prev.agreementDate != next.agreementDate ||
|
||||
prev.placesAvailable != next.placesAvailable ||
|
||||
prev.capacity != next.capacity ||
|
||||
prev.nir != next.nir ||
|
||||
prev.birthCity != next.birthCity ||
|
||||
prev.birthCountry != next.birthCountry ||
|
||||
prev.agrementNumber != next.agrementNumber ||
|
||||
prev.photoPath != next.photoPath ||
|
||||
prev.photoConsent != next.photoConsent) {
|
||||
_applyInitialData(next);
|
||||
}
|
||||
}
|
||||
|
||||
void _applyInitialData(ProfessionalInfoData? data) {
|
||||
if (data == null) return;
|
||||
_selectedDate = data.dateOfBirth;
|
||||
_dateOfBirthController.text = data.dateOfBirth != null
|
||||
? DateFormat('dd/MM/yyyy').format(data.dateOfBirth!)
|
||||
: '';
|
||||
_birthCityController.text = data.birthCity;
|
||||
_birthCountryController.text = data.birthCountry;
|
||||
final nirRaw = nirToRaw(data.nir);
|
||||
_nirController.text = nirRaw.length == 15 ? formatNir(nirRaw) : data.nir;
|
||||
_agrementController.text = data.agrementNumber;
|
||||
_selectedAgreementDate = data.agreementDate;
|
||||
_agreementDateController.text = data.agreementDate != null
|
||||
? DateFormat('dd/MM/yyyy').format(data.agreementDate!)
|
||||
: '';
|
||||
_capacityController.text = data.capacity?.toString() ?? '';
|
||||
_placesAvailableController.text = data.placesAvailable?.toString() ?? '';
|
||||
_photoPathFramework = data.photoPath;
|
||||
_photoFile = data.photoFile;
|
||||
_photoBytes = data.photoBytes;
|
||||
_photoFilename = data.photoFilename;
|
||||
_photoConsent = data.photoConsent;
|
||||
}
|
||||
|
||||
void _onBirthCityFocusChange() {
|
||||
if (_birthCityFocus == null || _birthCityFocus!.hasFocus) return;
|
||||
_applyPlaceNameFormat(_birthCityController);
|
||||
|
||||
@@ -71,6 +71,9 @@ class RegistrationPhotoSlot extends StatelessWidget {
|
||||
if (p.startsWith('assets/')) {
|
||||
return Image.asset(p, fit: fit);
|
||||
}
|
||||
if (p.startsWith('http://') || p.startsWith('https://')) {
|
||||
return Image.network(p, fit: fit);
|
||||
}
|
||||
if (!kIsWeb) {
|
||||
try {
|
||||
final file = File(p);
|
||||
|
||||
Reference in New Issue
Block a user