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
450 lines
15 KiB
Dart
450 lines
15 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.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),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
},
|
||
),
|
||
),
|
||
],
|
||
);
|
||
}
|
||
}
|