petitspas/frontend/lib/widgets/cgu_privacy_validation_dialog.dart
Julien Martin d91caef0c5 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
2026-04-17 17:33:47 +02:00

450 lines
15 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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),
),
],
),
),
),
],
),
);
},
),
),
],
);
}
}