feat(frontend): #50 — validation CGU et politique de confidentialité
- Modale de validation avec chargement des PDF distants, PdfViewPinch et barre de progression latérale (repli PdfView sous Windows). - Service documents légaux actifs, correction des URLs média, intégration au formulaire de présentation. - Documentation juridique et réorganisation (archive, index, tickets). Made-with: Cursor
This commit is contained in:
@@ -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] n’est 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 d’Utilisation';
|
||||
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] n’est 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] s’appuie 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),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user