Squash merge de la branche develop dans master : un seul commit sur master pour regrouper la livraison liée au ticket #50 (affichage dynamique des CGU et de la politique de confidentialité à l'inscription) ainsi que la documentation et les ajustements associés. Frontend (P'titsPas) : modale de validation bloquante avec onglets CGU / confidentialité, chargement des PDF depuis l'API des documents légaux actifs, affichage via pdfx (PdfViewPinch et barre de progression latérale sur le web, repli PdfView sous Windows). Branchement dans le flux d'inscription (écran de présentation), enrichissement du UserService et correction des chemins média pour les URL absolues. Documentation: jeux de fichiers juridiques (markdown et PDF de référence), réorganisation (dossier docs/juridique, archives, renommage du briefing), mises à jour index, liste de tickets et décisions projet. Scripts et métadonnées mineures (Gitea, pubspec, index web). Made-with: Cursor
648 lines
21 KiB
Dart
648 lines
21 KiB
Dart
import 'dart:math' as math;
|
||
|
||
import 'package:flutter/material.dart';
|
||
import 'package:google_fonts/google_fonts.dart';
|
||
import 'package:go_router/go_router.dart';
|
||
|
||
import 'custom_decorated_text_field.dart';
|
||
import 'app_custom_checkbox.dart';
|
||
import 'custom_navigation_button.dart';
|
||
import 'hover_relief_widget.dart';
|
||
import '../models/card_assets.dart';
|
||
import '../config/display_config.dart';
|
||
import 'cgu_privacy_validation_dialog.dart';
|
||
|
||
/// Widget générique pour le formulaire de présentation avec texte libre + CGU
|
||
/// Supporte mode éditable et readonly, responsive mobile/desktop
|
||
class PresentationFormScreen extends StatefulWidget {
|
||
final DisplayMode mode;
|
||
final String stepText; // Ex: "Étape 3/4" ou "Étape 4/5"
|
||
final String title; // Ex: "Présentation et Conditions" ou "Motivation de votre demande"
|
||
final CardColorHorizontal cardColor;
|
||
final String textFieldHint;
|
||
final String initialText;
|
||
final bool initialCguAccepted;
|
||
final String previousRoute;
|
||
final Function(String text, bool cguAccepted) onSubmit;
|
||
|
||
final bool embedContentOnly;
|
||
final VoidCallback? onEdit;
|
||
|
||
const PresentationFormScreen({
|
||
super.key,
|
||
this.mode = DisplayMode.editable,
|
||
required this.stepText,
|
||
required this.title,
|
||
required this.cardColor,
|
||
required this.textFieldHint,
|
||
required this.initialText,
|
||
required this.initialCguAccepted,
|
||
required this.previousRoute,
|
||
required this.onSubmit,
|
||
this.embedContentOnly = false,
|
||
this.onEdit,
|
||
});
|
||
|
||
@override
|
||
State<PresentationFormScreen> createState() => _PresentationFormScreenState();
|
||
}
|
||
|
||
class _PresentationFormScreenState extends State<PresentationFormScreen> {
|
||
late TextEditingController _textController;
|
||
late bool _cguAccepted;
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_textController = TextEditingController(text: widget.initialText);
|
||
_cguAccepted = widget.initialCguAccepted;
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_textController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _handleSubmit() {
|
||
if (!_cguAccepted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text('Vous devez accepter les CGU pour continuer.'),
|
||
backgroundColor: Colors.red,
|
||
),
|
||
);
|
||
return;
|
||
}
|
||
widget.onSubmit(_textController.text, _cguAccepted);
|
||
}
|
||
|
||
Future<void> _openCguValidationDialog() async {
|
||
final validated = await showDialog<bool>(
|
||
context: context,
|
||
builder: (context) => const CguPrivacyValidationDialog(),
|
||
);
|
||
|
||
if (validated == true && mounted) {
|
||
setState(() => _cguAccepted = true);
|
||
}
|
||
}
|
||
|
||
Widget _buildCguValidationControl({
|
||
required double fontSize,
|
||
bool compact = false,
|
||
}) {
|
||
final label = compact
|
||
? 'J’ai validé les CGU et la\nPolitique de confidentialité'
|
||
: 'J’ai validé les Conditions Générales\nd’Utilisation et la Politique de confidentialité';
|
||
return Column(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Checkbox(
|
||
value: _cguAccepted,
|
||
onChanged: null,
|
||
),
|
||
Flexible(
|
||
child: Text(
|
||
label,
|
||
style: GoogleFonts.merienda(fontSize: fontSize),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 6),
|
||
TextButton.icon(
|
||
onPressed: _openCguValidationDialog,
|
||
icon: const Icon(Icons.open_in_new, size: 18),
|
||
label: const Text('Consulter et valider les CGU'),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final screenSize = MediaQuery.of(context).size;
|
||
final config = DisplayConfig.fromContext(context, mode: widget.mode);
|
||
|
||
if (widget.embedContentOnly) {
|
||
return _buildCard(context, config, screenSize);
|
||
}
|
||
|
||
return Scaffold(
|
||
body: Stack(
|
||
children: [
|
||
Positioned.fill(
|
||
child: Image.asset('assets/images/paper2.png', fit: BoxFit.cover, repeat: ImageRepeat.repeat),
|
||
),
|
||
config.isMobile
|
||
? _buildMobileLayout(context, config, screenSize)
|
||
: _buildDesktopLayout(context, config, screenSize),
|
||
// Chevrons desktop uniquement
|
||
if (!config.isMobile) ...[
|
||
// Chevron Gauche (Retour)
|
||
Positioned(
|
||
top: screenSize.height / 2 - 20,
|
||
left: 40,
|
||
child: IconButton(
|
||
icon: Transform(
|
||
alignment: Alignment.center,
|
||
transform: Matrix4.rotationY(math.pi),
|
||
child: Image.asset('assets/images/chevron_right.png', height: 40),
|
||
),
|
||
onPressed: () {
|
||
if (context.canPop()) {
|
||
context.pop();
|
||
} else {
|
||
context.go(widget.previousRoute);
|
||
}
|
||
},
|
||
tooltip: 'Retour',
|
||
),
|
||
),
|
||
// Chevron Droit (Suivant)
|
||
Positioned(
|
||
top: screenSize.height / 2 - 20,
|
||
right: 40,
|
||
child: IconButton(
|
||
icon: Image.asset('assets/images/chevron_right.png', height: 40),
|
||
onPressed: _cguAccepted ? _handleSubmit : null,
|
||
tooltip: 'Suivant',
|
||
),
|
||
),
|
||
],
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Layout MOBILE : Plein écran sans scroll global
|
||
Widget _buildMobileLayout(BuildContext context, DisplayConfig config, Size screenSize) {
|
||
return Column(
|
||
children: [
|
||
// Header fixe
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 20.0),
|
||
child: Column(
|
||
children: [
|
||
Text(
|
||
widget.stepText,
|
||
style: GoogleFonts.merienda(
|
||
fontSize: 13,
|
||
color: Colors.black54,
|
||
),
|
||
),
|
||
const SizedBox(height: 6),
|
||
Text(
|
||
widget.title,
|
||
style: GoogleFonts.merienda(
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.bold,
|
||
color: Colors.black87,
|
||
),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
// Carte qui prend tout l'espace restant
|
||
Expanded(
|
||
child: _buildCard(context, config, screenSize),
|
||
),
|
||
// Boutons en bas
|
||
const SizedBox(height: 20),
|
||
_buildMobileButtons(context, config, screenSize),
|
||
const SizedBox(height: 10),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// Layout DESKTOP : Avec scroll
|
||
Widget _buildDesktopLayout(BuildContext context, DisplayConfig config, Size screenSize) {
|
||
return Center(
|
||
child: SingleChildScrollView(
|
||
padding: const EdgeInsets.symmetric(vertical: 40.0, horizontal: 50.0),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
crossAxisAlignment: CrossAxisAlignment.center,
|
||
children: [
|
||
Text(
|
||
widget.stepText,
|
||
style: GoogleFonts.merienda(fontSize: 16, color: Colors.black54),
|
||
),
|
||
const SizedBox(height: 20),
|
||
Text(
|
||
widget.title,
|
||
style: GoogleFonts.merienda(
|
||
fontSize: 22,
|
||
fontWeight: FontWeight.bold,
|
||
color: Colors.black87,
|
||
),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
const SizedBox(height: 30),
|
||
_buildCard(context, config, screenSize),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Wrapper pour la carte (Mobile ou Desktop)
|
||
Widget _buildCard(BuildContext context, DisplayConfig config, Size screenSize) {
|
||
// Si mode Readonly Desktop : Layout spécial "Vintage" horizontal (2:1)
|
||
if (config.isReadonly && !config.isMobile && widget.embedContentOnly) {
|
||
return _buildReadonlyDesktopCard(context, config, screenSize);
|
||
}
|
||
|
||
// Si mode Readonly Mobile : Layout spécial "Vintage" vertical (1:2)
|
||
if (config.isReadonly && config.isMobile && widget.embedContentOnly) {
|
||
return Padding(
|
||
padding: EdgeInsets.symmetric(horizontal: screenSize.width * 0.05),
|
||
child: _buildMobileReadonlyCard(context, config, screenSize),
|
||
);
|
||
}
|
||
|
||
final Widget cardContent = config.isMobile
|
||
? _buildMobileCard(context, config, screenSize)
|
||
: _buildDesktopCard(context, config, screenSize);
|
||
|
||
return Stack(
|
||
clipBehavior: Clip.none,
|
||
children: [
|
||
if (widget.embedContentOnly)
|
||
Column(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
Text(
|
||
widget.title,
|
||
style: GoogleFonts.merienda(
|
||
fontSize: config.isMobile ? 18 : 22,
|
||
fontWeight: FontWeight.bold,
|
||
color: Colors.black87,
|
||
),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
const SizedBox(height: 20),
|
||
cardContent,
|
||
],
|
||
)
|
||
else
|
||
cardContent,
|
||
|
||
if (config.isReadonly && widget.onEdit != null)
|
||
Positioned(
|
||
top: widget.embedContentOnly ? 50 : 10,
|
||
right: 10,
|
||
child: IconButton(
|
||
icon: const Icon(Icons.edit, color: Colors.black54),
|
||
onPressed: widget.onEdit,
|
||
tooltip: 'Modifier',
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
|
||
/// Carte en mode readonly MOBILE avec hauteur adaptative
|
||
Widget _buildMobileReadonlyCard(BuildContext context, DisplayConfig config, Size screenSize) {
|
||
return Container(
|
||
width: double.infinity,
|
||
// Pas de height fixe, s'adapte au contenu
|
||
padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 24.0),
|
||
decoration: BoxDecoration(
|
||
image: DecorationImage(
|
||
image: AssetImage(_getVerticalCardAsset()),
|
||
fit: BoxFit.fill, // Fill pour que l'image s'étire selon la hauteur du contenu
|
||
),
|
||
borderRadius: BorderRadius.circular(15),
|
||
),
|
||
child: Stack(
|
||
children: [
|
||
Column(
|
||
mainAxisSize: MainAxisSize.min, // S'adapte au contenu
|
||
children: [
|
||
// Titre + Edit Button
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
widget.title,
|
||
style: GoogleFonts.merienda(
|
||
fontSize: 18,
|
||
fontWeight: FontWeight.bold,
|
||
color: Colors.black87
|
||
),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
),
|
||
if (widget.onEdit != null)
|
||
const SizedBox(width: 28),
|
||
],
|
||
),
|
||
|
||
// Contenu aligné en haut (Texte scrollable + Checkbox)
|
||
Padding(
|
||
padding: const EdgeInsets.only(top: 20.0),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.start,
|
||
children: [
|
||
// Champ texte scrollable
|
||
// On utilise ConstrainedBox pour limiter la hauteur max
|
||
ConstrainedBox(
|
||
constraints: const BoxConstraints(maxHeight: 300), // Max height pour éviter une carte infinie
|
||
child: CustomDecoratedTextField(
|
||
controller: _textController,
|
||
hintText: widget.textFieldHint,
|
||
fieldHeight: null, // Flexible
|
||
maxLines: 100,
|
||
expandDynamically: true, // Scrollable
|
||
fontSize: 14.0,
|
||
readOnly: config.isReadonly,
|
||
),
|
||
),
|
||
const SizedBox(height: 16),
|
||
// Checkbox
|
||
Transform.scale(
|
||
scale: 0.85,
|
||
child: AppCustomCheckbox(
|
||
label: 'J\'accepte les CGU et la\nPolitique de confidentialité',
|
||
value: _cguAccepted,
|
||
onChanged: (v) {},
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
],
|
||
),
|
||
|
||
if (widget.onEdit != null)
|
||
Positioned(
|
||
top: 0,
|
||
right: 0,
|
||
child: IconButton(
|
||
padding: EdgeInsets.zero,
|
||
constraints: const BoxConstraints(),
|
||
icon: const Icon(Icons.edit, color: Colors.black54, size: 24),
|
||
onPressed: widget.onEdit,
|
||
tooltip: 'Modifier',
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Carte en mode readonly desktop avec AspectRatio 2:1 (format de l'ancien récapitulatif)
|
||
Widget _buildReadonlyDesktopCard(BuildContext context, DisplayConfig config, Size screenSize) {
|
||
// Largeur de la carte : 50% de l'écran
|
||
final cardWidth = screenSize.width / 2.0;
|
||
|
||
return SizedBox(
|
||
width: cardWidth,
|
||
child: AspectRatio(
|
||
aspectRatio: 2.0,
|
||
child: Container(
|
||
padding: const EdgeInsets.symmetric(vertical: 20.0, horizontal: 25.0),
|
||
decoration: BoxDecoration(
|
||
image: DecorationImage(
|
||
image: AssetImage(widget.cardColor.path),
|
||
fit: BoxFit.cover,
|
||
),
|
||
borderRadius: BorderRadius.circular(15),
|
||
),
|
||
child: Column(
|
||
children: [
|
||
// Titre + Edit Button
|
||
Row(
|
||
children: [
|
||
Expanded(
|
||
child: Text(
|
||
widget.title,
|
||
style: GoogleFonts.merienda(fontSize: 22, fontWeight: FontWeight.bold, color: Colors.black87),
|
||
textAlign: TextAlign.center,
|
||
),
|
||
),
|
||
if (widget.onEdit != null)
|
||
IconButton(
|
||
icon: const Icon(Icons.edit, color: Colors.black54, size: 28),
|
||
onPressed: widget.onEdit,
|
||
tooltip: 'Modifier',
|
||
),
|
||
],
|
||
),
|
||
const SizedBox(height: 18),
|
||
|
||
// Texte de motivation
|
||
Expanded(
|
||
child: CustomDecoratedTextField(
|
||
controller: _textController,
|
||
hintText: '',
|
||
fieldHeight: double.infinity, // Remplit l'espace disponible
|
||
maxLines: 10,
|
||
expandDynamically: false, // Fixe pour le readonly
|
||
fontSize: 18.0,
|
||
readOnly: true,
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
|
||
// CGU
|
||
AppCustomCheckbox(
|
||
label: 'J\'accepte les Conditions Générales\nd\'Utilisation et la Politique de confidentialité',
|
||
value: _cguAccepted,
|
||
onChanged: (v) {}, // Readonly
|
||
checkboxSize: 22.0,
|
||
fontSize: 16.0,
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Carte DESKTOP : Format horizontal 2:1
|
||
Widget _buildDesktopCard(BuildContext context, DisplayConfig config, Size screenSize) {
|
||
final cardWidth = screenSize.width * 0.6;
|
||
final double imageAspectRatio = 2.0;
|
||
final cardHeight = cardWidth / imageAspectRatio;
|
||
|
||
return Container(
|
||
width: cardWidth,
|
||
height: cardHeight,
|
||
decoration: BoxDecoration(
|
||
image: DecorationImage(
|
||
image: AssetImage(widget.cardColor.path),
|
||
fit: BoxFit.fill,
|
||
),
|
||
),
|
||
child: Padding(
|
||
padding: const EdgeInsets.all(40.0),
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Expanded(
|
||
child: CustomDecoratedTextField(
|
||
controller: _textController,
|
||
hintText: widget.textFieldHint,
|
||
fieldHeight: cardHeight * 0.6,
|
||
maxLines: 10,
|
||
expandDynamically: true,
|
||
fontSize: 18.0,
|
||
readOnly: config.isReadonly,
|
||
),
|
||
),
|
||
const SizedBox(height: 20),
|
||
config.isReadonly
|
||
? AppCustomCheckbox(
|
||
label: 'J\'accepte les Conditions Générales\nd\'Utilisation et la Politique de confidentialité',
|
||
value: _cguAccepted,
|
||
onChanged: (v) {},
|
||
)
|
||
: _buildCguValidationControl(fontSize: 16),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Carte MOBILE : Prend tout l'espace disponible
|
||
Widget _buildMobileCard(BuildContext context, DisplayConfig config, Size screenSize) {
|
||
// Le contenu du champ texte
|
||
Widget textFieldContent = LayoutBuilder(
|
||
builder: (context, constraints) {
|
||
// En mode embed (récap), constraints.maxHeight peut être infini, donc on fixe une hauteur par défaut
|
||
// En mode standalone, on utilise la hauteur disponible
|
||
double height = constraints.maxHeight;
|
||
if (height.isInfinite) height = 200.0;
|
||
|
||
return CustomDecoratedTextField(
|
||
controller: _textController,
|
||
hintText: widget.textFieldHint,
|
||
fieldHeight: height,
|
||
maxLines: 100,
|
||
expandDynamically: false,
|
||
fontSize: 14.0,
|
||
readOnly: config.isReadonly,
|
||
);
|
||
},
|
||
);
|
||
|
||
return Padding(
|
||
padding: EdgeInsets.symmetric(horizontal: screenSize.width * 0.05),
|
||
child: Container(
|
||
decoration: BoxDecoration(
|
||
image: DecorationImage(
|
||
image: AssetImage(_getVerticalCardAsset()),
|
||
fit: BoxFit.fill,
|
||
),
|
||
),
|
||
padding: const EdgeInsets.symmetric(vertical: 24, horizontal: 20),
|
||
child: Column(
|
||
children: [
|
||
// Champ de texte
|
||
if (widget.embedContentOnly)
|
||
// En mode récapitulatif, on donne une hauteur fixe pour éviter l'erreur d'Expanded
|
||
SizedBox(height: 200, child: textFieldContent)
|
||
else
|
||
// En mode écran complet, on prend tout l'espace restant
|
||
Expanded(child: textFieldContent),
|
||
|
||
const SizedBox(height: 16),
|
||
// Checkbox en bas
|
||
Transform.scale(
|
||
scale: 0.95,
|
||
child: config.isReadonly
|
||
? AppCustomCheckbox(
|
||
label: 'J\'accepte les CGU et la\nPolitique de confidentialité',
|
||
value: _cguAccepted,
|
||
onChanged: (v) {},
|
||
)
|
||
: _buildCguValidationControl(
|
||
fontSize: 14,
|
||
compact: true,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Boutons mobile
|
||
Widget _buildMobileButtons(BuildContext context, DisplayConfig config, Size screenSize) {
|
||
return Padding(
|
||
padding: EdgeInsets.symmetric(
|
||
horizontal: screenSize.width * 0.05,
|
||
),
|
||
child: Row(
|
||
children: [
|
||
Expanded(
|
||
child: HoverReliefWidget(
|
||
child: CustomNavigationButton(
|
||
text: 'Précédent',
|
||
style: NavigationButtonStyle.purple,
|
||
onPressed: () {
|
||
if (context.canPop()) {
|
||
context.pop();
|
||
} else {
|
||
context.go(widget.previousRoute);
|
||
}
|
||
},
|
||
width: double.infinity,
|
||
height: 50,
|
||
fontSize: 16,
|
||
),
|
||
),
|
||
),
|
||
const SizedBox(width: 16),
|
||
Expanded(
|
||
child: HoverReliefWidget(
|
||
child: Opacity(
|
||
opacity: _cguAccepted ? 1 : 0.55,
|
||
child: AbsorbPointer(
|
||
absorbing: !_cguAccepted,
|
||
child: CustomNavigationButton(
|
||
text: 'Suivant',
|
||
style: NavigationButtonStyle.green,
|
||
onPressed: _handleSubmit,
|
||
width: double.infinity,
|
||
height: 50,
|
||
fontSize: 16,
|
||
),
|
||
),
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
/// Retourne l'asset de carte vertical correspondant à la couleur
|
||
String _getVerticalCardAsset() {
|
||
switch (widget.cardColor) {
|
||
case CardColorHorizontal.blue:
|
||
return CardColorVertical.blue.path;
|
||
case CardColorHorizontal.green:
|
||
return CardColorVertical.green.path;
|
||
case CardColorHorizontal.lavender:
|
||
return CardColorVertical.lavender.path;
|
||
case CardColorHorizontal.lime:
|
||
return CardColorVertical.lime.path;
|
||
case CardColorHorizontal.peach:
|
||
return CardColorVertical.peach.path;
|
||
case CardColorHorizontal.pink:
|
||
return CardColorVertical.pink.path;
|
||
case CardColorHorizontal.red:
|
||
return CardColorVertical.red.path;
|
||
}
|
||
}
|
||
}
|