petitspas/frontend/lib/widgets/presentation_form_screen.dart
Julien Martin 08612c455d Fix recap screens layout (desktop/mobile) and widget styles
- Restore horizontal 2:1 layout for desktop readonly cards
- Implement adaptive height for mobile readonly cards
- Fix spacing and margins on mobile recap screens
- Update field styles to use beige background
- Adjust ChildCardWidget width for mobile editing
- Fix compilation errors and duplicate methods

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-07 13:29:11 +01:00

587 lines
19 KiB
Dart

import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:go_router/go_router.dart';
import 'dart:math' as math;
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';
/// 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);
}
@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),
AppCustomCheckbox(
label: 'J\'accepte les Conditions Générales\nd\'Utilisation et la Politique de confidentialité',
value: _cguAccepted,
onChanged: config.isReadonly ? (v) {} : (value) => setState(() => _cguAccepted = value ?? false),
),
],
),
),
);
}
/// 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.85,
child: AppCustomCheckbox(
label: 'J\'accepte les CGU et la\nPolitique de confidentialité',
value: _cguAccepted,
onChanged: config.isReadonly ? (v) {} : (value) => setState(() => _cguAccepted = value ?? false),
),
),
],
),
),
);
}
/// 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: 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;
}
}
}