petitspas/frontend/lib/widgets/cgu_privacy_validation_dialog.dart
Julien Martin dff108c7d5 merge(develop): clôture ticket #28 et stabilisation build/prod
Intègre en un seul commit master les changements de develop : templates email de validation (#28), alignement schéma validations + patches SQL, montée Flutter 3.29 (CI/Docker), correctifs bootstrap web/nginx/API origin et gestion HTTP 201 sur validation dossier famille.

Made-with: Cursor
2026-04-23 11:44:09 +02:00

367 lines
12 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.withOpacity(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.withOpacity(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
.withOpacity(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.withOpacity(0.7),
disabledForegroundColor:
_ModaleCouleur.encre.withOpacity(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.withOpacity(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.withOpacity(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] pour le web / mobile avec `pdfx` 2.6 (pas de
/// `documentProgress` / `minScale` dans cette API).
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> {
late final PdfControllerPinch _ctrl;
@override
void initState() {
super.initState();
_ctrl = PdfControllerPinch(document: widget.documentFuture);
}
@override
void dispose() {
_ctrl.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return PdfViewPinch(
padding: 10,
scrollDirection: Axis.vertical,
controller: _ctrl,
backgroundDecoration: const BoxDecoration(color: Colors.white),
);
}
}