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
367 lines
12 KiB
Dart
367 lines
12 KiB
Dart
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.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),
|
||
);
|
||
}
|
||
}
|