merge(master): intégration develop — ticket #50, documents légaux et validation CGU

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
This commit is contained in:
2026-04-17 17:33:47 +02:00
parent 241ce36b85
commit d91caef0c5
34 changed files with 2169 additions and 912 deletions
@@ -27,6 +27,12 @@ class ApiConfig {
return u;
}
final base = baseUrl.replaceAll(RegExp(r'/+$'), '');
// Le back renvoie parfois des chemins déjà préfixés `/api/v1/...` (ex. documents
// légaux). Ne pas les coller à [baseUrl] sous peine de doubler `/api/v1`.
if (u.startsWith('/api/')) {
final origin = apiOrigin.replaceAll(RegExp(r'/+$'), '');
return '$origin$u';
}
return u.startsWith('/') ? '$base$u' : '$base/$u';
}
+73
View File
@@ -8,6 +8,39 @@ import 'package:p_tits_pas/models/dossier_unifie.dart';
import 'package:p_tits_pas/services/api/api_config.dart';
import 'package:p_tits_pas/services/api/tokenService.dart';
class DocumentActifInfo {
final String id;
final String type;
final int version;
final String url;
const DocumentActifInfo({
required this.id,
required this.type,
required this.version,
required this.url,
});
factory DocumentActifInfo.fromJson(Map<String, dynamic> json) {
return DocumentActifInfo(
id: (json['id'] ?? '').toString(),
type: (json['type'] ?? '').toString(),
version: int.tryParse((json['version'] ?? '').toString()) ?? 0,
url: (json['url'] ?? '').toString(),
);
}
}
class DocumentsActifsInfo {
final DocumentActifInfo cgu;
final DocumentActifInfo privacy;
const DocumentsActifsInfo({
required this.cgu,
required this.privacy,
});
}
class UserService {
static Future<Map<String, String>> _headers() async {
final token = await TokenService.getToken();
@@ -34,6 +67,46 @@ class UserService {
return _toStr(err) ?? 'Erreur inconnue';
}
/// Documents légaux actifs (CGU + privacy). GET /documents-legaux/actifs.
static Future<DocumentsActifsInfo> getDocumentsLegauxActifs() async {
final response = await http.get(
Uri.parse('${ApiConfig.baseUrl}/documents-legaux/actifs'),
headers: ApiConfig.headers,
);
if (response.statusCode != 200) {
try {
final err = jsonDecode(response.body);
throw Exception(_errMessage(err is Map ? err['message'] : err));
} catch (e) {
if (e is Exception) rethrow;
throw Exception(
'Erreur chargement documents légaux (${response.statusCode})',
);
}
}
try {
final decoded = jsonDecode(response.body);
if (decoded is! Map<String, dynamic>) {
throw const FormatException('Réponse invalide');
}
final cgu = decoded['cgu'];
final privacy = decoded['privacy'];
if (cgu is! Map || privacy is! Map) {
throw const FormatException('Documents actifs manquants');
}
return DocumentsActifsInfo(
cgu: DocumentActifInfo.fromJson(Map<String, dynamic>.from(cgu)),
privacy: DocumentActifInfo.fromJson(Map<String, dynamic>.from(privacy)),
);
} catch (e) {
if (e is FormatException) rethrow;
throw Exception(
'Réponse invalide (documents légaux): ${e is Exception ? e.toString() : "format inattendu"}',
);
}
}
/// Utilisateurs en attente de validation (GET /users/pending). Ticket #107.
static Future<List<AppUser>> getPendingUsers({String? role}) async {
final query = role != null ? '?role=$role' : '';
@@ -0,0 +1,449 @@
import 'dart:typed_data';
import 'package:flutter/material.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:http/http.dart' as http;
import 'package:pdfx/pdfx.dart';
import 'package:universal_platform/universal_platform.dart';
import '../services/api/api_config.dart';
import '../services/user_service.dart';
/// Tons **turquoise / menthe** (charte : `#8AD0C8`) — plus doux que le vert
/// Material « gazon ».
abstract final class _ModaleCouleur {
static const Color turquoise = Color(0xFF8AD0C8);
static const Color turquoiseFonce = Color(0xFF5A9D94);
static const Color mentheTresClair = Color(0xFFF3FAF8);
static const Color mentheClair = Color(0xFFE2F3EF);
static const Color ivoire = Color(0xFFFFFEF9);
static const Color encre = Color(0xFF2F2F2F);
}
enum LegalDocType {
cgu,
privacy,
}
class CguPrivacyValidationDialog extends StatefulWidget {
const CguPrivacyValidationDialog({super.key});
@override
State<CguPrivacyValidationDialog> createState() =>
_CguPrivacyValidationDialogState();
}
class _CguPrivacyValidationDialogState extends State<CguPrivacyValidationDialog> {
bool _loadingLegalDocs = false;
DocumentActifInfo? _cguDoc;
DocumentActifInfo? _privacyDoc;
LegalDocType _selectedDocType = LegalDocType.cgu;
static const double _cornerRadius = 16;
@override
void initState() {
super.initState();
_loadActiveLegalDocs();
}
Future<void> _loadActiveLegalDocs() async {
setState(() => _loadingLegalDocs = true);
try {
final docs = await UserService.getDocumentsLegauxActifs();
if (!mounted) return;
setState(() {
_cguDoc = docs.cgu;
_privacyDoc = docs.privacy;
});
} catch (_) {
// Silencieux : message dans la zone PDF si besoin.
} finally {
if (mounted) setState(() => _loadingLegalDocs = false);
}
}
/// Sur le web, [PdfDocument.openFile] nest pas implémenté : on charge les
/// octets puis [PdfDocument.openData].
Future<Uint8List> _fetchPdfBytes(String pathOrUrl) async {
final absolute = ApiConfig.absoluteMediaUrl(pathOrUrl);
final response = await http.get(Uri.parse(absolute));
if (response.statusCode != 200) {
throw StateError('PDF indisponible (HTTP ${response.statusCode})');
}
return response.bodyBytes;
}
Future<PdfDocument>? get _currentPdfDocumentFuture {
switch (_selectedDocType) {
case LegalDocType.cgu:
if (_cguDoc == null) return null;
return PdfDocument.openData(_fetchPdfBytes(_cguDoc!.url));
case LegalDocType.privacy:
if (_privacyDoc == null) return null;
return PdfDocument.openData(_fetchPdfBytes(_privacyDoc!.url));
}
}
String get _dialogTitle {
switch (_selectedDocType) {
case LegalDocType.cgu:
return 'Conditions Générales dUtilisation';
case LegalDocType.privacy:
return 'Politique de Confidentialité';
}
}
bool get _canValidate {
return (_cguDoc != null && _privacyDoc != null) ||
(_cguDoc != null &&
_privacyDoc == null &&
_selectedDocType == LegalDocType.cgu) ||
(_privacyDoc != null &&
_cguDoc == null &&
_selectedDocType == LegalDocType.privacy);
}
/// [PdfViewPinch] nest pas supporté sur Windows (pdfx) : repli [PdfView].
Widget _buildPdfBody() {
final doc = _currentPdfDocumentFuture!;
final key = ValueKey(_selectedDocType);
if (UniversalPlatform.isWindows) {
return PdfView(
key: key,
scrollDirection: Axis.vertical,
controller: PdfController(document: doc),
backgroundDecoration: const BoxDecoration(color: Colors.white),
);
}
return _PdfPaneWithScrollbar(
key: key,
documentFuture: doc,
);
}
@override
Widget build(BuildContext context) {
final size = MediaQuery.sizeOf(context);
final dialogW = size.width * 0.92;
final dialogH = size.height * 0.88;
return Dialog(
insetPadding: const EdgeInsets.symmetric(horizontal: 12, vertical: 20),
backgroundColor: Colors.transparent,
elevation: 0,
child: ClipRRect(
borderRadius: BorderRadius.circular(_cornerRadius),
child: Material(
color: _ModaleCouleur.ivoire,
elevation: 6,
shadowColor: _ModaleCouleur.encre.withValues(alpha: 0.12),
child: SizedBox(
width: dialogW,
height: dialogH,
child: Column(
children: [
_buildHeader(context),
Padding(
padding: const EdgeInsets.fromLTRB(12, 8, 12, 4),
child: Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
_tabButton(
context,
label: 'CGU',
selected: _selectedDocType == LegalDocType.cgu,
enabled: !_loadingLegalDocs && _cguDoc != null,
onTap: () => setState(
() => _selectedDocType = LegalDocType.cgu,
),
),
const SizedBox(width: 10),
_tabButton(
context,
label: 'Confidentialité',
selected: _selectedDocType == LegalDocType.privacy,
enabled: !_loadingLegalDocs && _privacyDoc != null,
onTap: () => setState(
() => _selectedDocType = LegalDocType.privacy,
),
),
],
),
),
Expanded(
child: Padding(
padding: const EdgeInsets.fromLTRB(12, 4, 12, 8),
child: DecoratedBox(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(12),
border: Border.all(
color: _ModaleCouleur.turquoiseFonce.withValues(
alpha: 0.35,
),
),
),
child: ClipRRect(
borderRadius: BorderRadius.circular(12),
child: _loadingLegalDocs
? const Center(
child: CircularProgressIndicator(
color: _ModaleCouleur.turquoiseFonce,
strokeWidth: 3,
),
)
: _currentPdfDocumentFuture != null
? _buildPdfBody()
: Center(
child: Text(
'Document non disponible.',
style: GoogleFonts.merienda(
color: _ModaleCouleur.encre
.withValues(alpha: 0.75),
),
),
),
),
),
),
),
Padding(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 16),
child: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 520),
child: FilledButton(
onPressed: _canValidate
? () => Navigator.of(context).pop(true)
: null,
style: FilledButton.styleFrom(
backgroundColor: _ModaleCouleur.turquoiseFonce,
foregroundColor: Colors.white,
disabledBackgroundColor:
_ModaleCouleur.mentheClair.withValues(alpha: 0.7),
disabledForegroundColor:
_ModaleCouleur.encre.withValues(alpha: 0.45),
padding: const EdgeInsets.symmetric(
horizontal: 20,
vertical: 14,
),
),
child: Text(
'Valider les CGU et la Politique '
'de Confidentialité',
textAlign: TextAlign.center,
style: GoogleFonts.merienda(
fontSize: 14,
fontWeight: FontWeight.w600,
),
),
),
),
),
),
],
),
),
),
),
);
}
Widget _buildHeader(BuildContext context) {
return DecoratedBox(
decoration: const BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
_ModaleCouleur.mentheTresClair,
_ModaleCouleur.mentheClair,
],
),
),
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
child: Row(
children: [
Expanded(
child: Padding(
padding: const EdgeInsets.only(left: 8),
child: Text(
_dialogTitle,
style: GoogleFonts.merienda(
fontSize: 17,
fontWeight: FontWeight.w600,
color: _ModaleCouleur.encre,
height: 1.25,
),
maxLines: 2,
overflow: TextOverflow.ellipsis,
),
),
),
IconButton(
tooltip: 'Fermer',
icon: Icon(
Icons.close,
color: _ModaleCouleur.encre.withValues(alpha: 0.55),
),
onPressed: () => Navigator.of(context).pop(false),
),
],
),
),
);
}
Widget _tabButton(
BuildContext context, {
required String label,
required bool selected,
required bool enabled,
required VoidCallback onTap,
}) {
return OutlinedButton(
onPressed: enabled ? onTap : null,
style: OutlinedButton.styleFrom(
foregroundColor: _ModaleCouleur.encre,
backgroundColor: selected
? _ModaleCouleur.turquoise
: _ModaleCouleur.mentheTresClair,
side: BorderSide(
color: _ModaleCouleur.turquoiseFonce.withValues(alpha: 0.55),
width: 1.5,
),
padding: const EdgeInsets.symmetric(horizontal: 18, vertical: 10),
),
child: Text(
label,
style: GoogleFonts.merienda(
fontSize: 13,
fontWeight: selected ? FontWeight.w600 : FontWeight.w500,
),
),
);
}
}
/// [PdfViewPinch] sappuie sur un [InteractiveViewer], sans vrai [Scrollable] :
/// barre verticale reliée à [PdfControllerPinch.documentProgress] pour que le
/// « chariot » reste visible (surtout sur le web où la barre OS est souvent
/// masquée).
class _PdfPaneWithScrollbar extends StatefulWidget {
const _PdfPaneWithScrollbar({
super.key,
required this.documentFuture,
});
final Future<PdfDocument> documentFuture;
@override
State<_PdfPaneWithScrollbar> createState() => _PdfPaneWithScrollbarState();
}
class _PdfPaneWithScrollbarState extends State<_PdfPaneWithScrollbar> {
static const double _barW = 14;
static const double _thumbW = 9;
static const double _minThumbH = 44;
late final PdfControllerPinch _ctrl;
@override
void initState() {
super.initState();
_ctrl = PdfControllerPinch(document: widget.documentFuture);
_ctrl.addListener(_onCtrl);
}
void _onCtrl() {
// [PdfViewPinch] met à jour documentProgress dans son propre listener :
// replanifier pour lire la valeur après ce tour de notifications.
Future.microtask(() {
if (mounted) setState(() {});
});
}
@override
void dispose() {
_ctrl.removeListener(_onCtrl);
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final raw = _ctrl.documentProgress;
final safeP = (!raw.isFinite || raw.isNaN) ? 0.0 : raw.clamp(0.0, 1.0);
return Stack(
clipBehavior: Clip.hardEdge,
fit: StackFit.expand,
children: [
Padding(
padding: const EdgeInsets.only(right: _barW),
child: PdfViewPinch(
padding: 10,
scrollDirection: Axis.vertical,
minScale: 1,
maxScale: 4,
controller: _ctrl,
backgroundDecoration: const BoxDecoration(color: Colors.white),
),
),
Positioned(
top: 6,
right: 1,
bottom: 6,
width: _barW,
child: LayoutBuilder(
builder: (context, c) {
final trackH = c.maxHeight;
final thumbH = (trackH * 0.2).clamp(_minThumbH, trackH * 0.5);
final maxTop = (trackH - thumbH).clamp(0.0, double.infinity);
final top = safeP * maxTop;
return Semantics(
label: 'Position dans le document',
child: Stack(
children: [
Positioned.fill(
child: DecoratedBox(
decoration: BoxDecoration(
color: _ModaleCouleur.turquoise.withValues(
alpha: 0.35,
),
borderRadius: BorderRadius.circular(8),
),
),
),
Positioned(
top: top,
left: (_barW - _thumbW) / 2,
child: Container(
width: _thumbW,
height: thumbH,
decoration: BoxDecoration(
color: _ModaleCouleur.turquoiseFonce,
borderRadius: BorderRadius.circular(_thumbW / 2),
boxShadow: const [
BoxShadow(
color: Color(0x40000000),
blurRadius: 3,
offset: Offset(0, 1),
),
],
),
),
),
],
),
);
},
),
),
],
);
}
}
@@ -1,7 +1,8 @@
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 'dart:math' as math;
import 'custom_decorated_text_field.dart';
import 'app_custom_checkbox.dart';
@@ -9,6 +10,7 @@ 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
@@ -75,6 +77,52 @@ class _PresentationFormScreenState extends State<PresentationFormScreen> {
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
? 'Jai validé les CGU et la\nPolitique de confidentialité'
: 'Jai validé les Conditions Générales\ndUtilisation 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;
@@ -451,11 +499,13 @@ class _PresentationFormScreenState extends State<PresentationFormScreen> {
),
),
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),
),
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),
],
),
),
@@ -507,12 +557,17 @@ class _PresentationFormScreenState extends State<PresentationFormScreen> {
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),
),
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,
),
),
],
),
@@ -549,13 +604,19 @@ class _PresentationFormScreenState extends State<PresentationFormScreen> {
const SizedBox(width: 16),
Expanded(
child: HoverReliefWidget(
child: CustomNavigationButton(
text: 'Suivant',
style: NavigationButtonStyle.green,
onPressed: _handleSubmit,
width: double.infinity,
height: 50,
fontSize: 16,
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,
),
),
),
),
),