diff --git a/.DS_Store b/.DS_Store index 7d01232..4a581b4 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/assets/certificate.pfx b/assets/certificate.pfx new file mode 100644 index 0000000..16e3b60 Binary files /dev/null and b/assets/certificate.pfx differ diff --git a/lib/comp_16.dart b/lib/comp_16.dart index b8b15bf..a31e9bc 100644 --- a/lib/comp_16.dart +++ b/lib/comp_16.dart @@ -16,7 +16,7 @@ import 'main.dart'; import 'comp_6-7.dart'; import 'comp_1-5.dart'; import 'screens/paywall_screen.dart'; - +import 'screens/fea_verification_screen.dart'; class Comp16Screen extends StatefulWidget { const Comp16Screen({super.key}); @@ -295,10 +295,62 @@ class _Comp16ScreenState extends State with WidgetsBindingObserver } Future _ioApprovo() async { + // Chiudiamo l'anteprima se aperta + Navigator.of(context).popUntil((route) => route.isFirst || route.settings.name == null); + + bool isA = GlobalData.latoCorrente == 'A'; + + // Telefoni dei due conducenti + String telA = GlobalData.N_tel_mail_cond_A.isNotEmpty ? GlobalData.N_tel_mail_cond_A : GlobalData.N_telefono_mail_contraente_A; + String telB = GlobalData.N_tel_mail_cond_B.isNotEmpty ? GlobalData.N_tel_mail_cond_B : GlobalData.N_telefono_mail_contraente_B; + + Future eseguiVerifica(String tel, String nome, Function(String, String) onSuccess) async { + if (tel.isEmpty) return true; // Se non c'è numero (improbabile), skippa + bool verified = false; + await FeaVerificationModal.show( + context: context, + phoneNumber: tel, + conducenteName: nome, + onVerificationSuccess: () { + verified = true; + // Format data e ora + DateTime now = DateTime.now(); + String dataOra = "${now.day.toString().padLeft(2,'0')}/${now.month.toString().padLeft(2,'0')}/${now.year} ${now.hour.toString().padLeft(2,'0')}:${now.minute.toString().padLeft(2,'0')}:${now.second.toString().padLeft(2,'0')}"; + onSuccess(dataOra, "otp-${now.millisecondsSinceEpoch}"); + Navigator.pop(context); + }, + onCancel: () { + Navigator.pop(context); + } + ); + return verified; + } + + // Flusso OTP per A + if (telA.isNotEmpty) { + String nomeA = "${GlobalData.Nome_cond_A} ${GlobalData.Cognome_cond_A}".trim(); + if (nomeA.isEmpty) nomeA = "${GlobalData.Nome_contraente_A} ${GlobalData.Cognome_contraente_A}".trim(); + bool okA = await eseguiVerifica(telA, "Conducente A: $nomeA", (dataOra, id) { + GlobalData.feaVerifiedA = true; GlobalData.otpDataOraA = dataOra; GlobalData.otpIdA = id; + }); + if (!okA) return; // Utente ha annullato + } + + // Flusso OTP per B + if (telB.isNotEmpty) { + String nomeB = "${GlobalData.Nome_cond_B} ${GlobalData.Cognome_cond_B}".trim(); + if (nomeB.isEmpty) nomeB = "${GlobalData.Nome_contraente_B} ${GlobalData.Cognome_contraente_B}".trim(); + bool okB = await eseguiVerifica(telB, "Conducente B: $nomeB", (dataOra, id) { + GlobalData.feaVerifiedB = true; GlobalData.otpDataOraB = dataOra; GlobalData.otpIdB = id; + }); + if (!okB) return; // Utente ha annullato + } + + // Terminate le verifiche, aggiorna Firebase e genera PDF definitivo sigillato String? id = GlobalData.idSessione ?? GlobalData.idScambioTemporaneo; if (id != null) { try { - String field = (GlobalData.latoCorrente == 'A') ? 'approved_A' : 'approved_B'; + String field = (isA) ? 'approved_A' : 'approved_B'; await FirebaseFirestore.instance.collection('scambi_cid').doc(id).update({field: true}); } catch (_) {} } @@ -307,6 +359,8 @@ class _Comp16ScreenState extends State with WidgetsBindingObserver setState(() { _ioHoApprovato = true; }); + // Rigenera i documenti per applicare l'attestato FEA e il sigillo PKCS12 + await _generaDocumenti(); } } diff --git a/lib/global_data.dart b/lib/global_data.dart index 805562e..c77b2a8 100644 --- a/lib/global_data.dart +++ b/lib/global_data.dart @@ -32,6 +32,14 @@ class GlobalData { static String Cognome_cond_B = ""; static String Nome_cond_B = ""; static String Data_nascita_cond_B = ""; static String Cod_fiscale_cond_B = ""; static String Indirizzo_cond_B = ""; static String Stato_cond_B = ""; static String N_tel_mail_cond_B = ""; static String N_Patente_cond_B = ""; static String Scadenza_cond_B = ""; static String Categoria_cond_B = ""; static List puntiUrtoB_List = []; static String danni_visibili_B = ""; static String osservazioni_B = ""; static Map circostanzeB = {}; static int totaleCrocetteB = 0; static List puntiFirmaB = []; + // --- DATI FEA (Firma Elettronica Avanzata) --- + static bool feaVerifiedA = false; + static String otpDataOraA = ""; + static String otpIdA = ""; + + static bool feaVerifiedB = false; + static String otpDataOraB = ""; + static String otpIdB = ""; // --- DATI GRAFICI --- static List tratti = []; static List elementi = []; @@ -142,6 +150,7 @@ class GlobalData { Denominazione_A = ""; Numero_Polizza_A = ""; N_carta_verde_A = ""; Data_Inizio_Dal_A = ""; Data_Scadenza_Al_A = ""; Agenzia_A = ""; Denominazione_agenzia_A = ""; Indirizzo_agenzia_A = ""; Stato_agenzia_A = ""; N_tel_mail_agenzia_A = ""; FLAG_danni_mat_assicurati_A = false; Cognome_cond_A = ""; Nome_cond_A = ""; Data_nascita_cond_A = ""; Cod_fiscale_cond_A = ""; Indirizzo_cond_A = ""; Stato_cond_A = ""; N_tel_mail_cond_A = ""; N_Patente_cond_A = ""; Scadenza_cond_A = ""; Categoria_cond_A = ""; puntiUrtoA_List = []; danni_visibili_A = ""; osservazioni_A = ""; circostanzeA = {}; totaleCrocetteA = 0; puntiFirmaA = []; + feaVerifiedA = false; otpDataOraA = ""; otpIdA = ""; } static void resetB() { @@ -150,6 +159,7 @@ class GlobalData { Denominazione_B = ""; Numero_Polizza_B = ""; N_carta_verde_B = ""; Data_Inizio_Dal_B = ""; Data_Scadenza_Al_B = ""; Agenzia_B = ""; Denominazione_agenzia_B = ""; Indirizzo_agenzia_B = ""; Stato_agenzia_B = ""; N_tel_mail_agenzia_B = ""; FLAG_danni_mat_assicurati_B = false; Cognome_cond_B = ""; Nome_cond_B = ""; Data_nascita_cond_B = ""; Cod_fiscale_cond_B = ""; Indirizzo_cond_B = ""; Stato_cond_B = ""; N_tel_mail_cond_B = ""; N_Patente_cond_B = ""; Scadenza_cond_B = ""; Categoria_cond_B = ""; puntiUrtoB_List = []; danni_visibili_B = ""; osservazioni_B = ""; circostanzeB = {}; totaleCrocetteB = 0; puntiFirmaB = []; + feaVerifiedB = false; otpDataOraB = ""; otpIdB = ""; } // --- DEBUG COMPLETO (Tutti i campi popolati) --- diff --git a/lib/pdf_engine.dart b/lib/pdf_engine.dart index 7668179..180210e 100644 --- a/lib/pdf_engine.dart +++ b/lib/pdf_engine.dart @@ -113,19 +113,44 @@ class PdfEngine { await _disegnaInBox(page, mappaCampi, CaiMapping.box_firma_B, await _renderFirmaTight(GlobalData.puntiFirmaB, Colors.black)); // ================================================================= - // SALVATAGGIO SICURO + // AGGIUNTA PAGINA 2 (FEA) // ================================================================= + await _aggiungiPaginaFea(document); + + // ================================================================= + // FLATTENING E SALVATAGGIO TEMPORANEO + // ================================================================= + try { + document.form.flattenAllFields(); + } catch (e) { + debugPrint("⚠️ Errore Flattening: $e"); + document.form.readOnly = true; + } List bytesTemporanei = await document.save(); document.dispose(); + // ================================================================= + // APPOSIZIONE SIGILLO CRITTOGRAFICO (FEA) + // ================================================================= PdfDocument docFinale = PdfDocument(inputBytes: bytesTemporanei); try { - docFinale.form.flattenAllFields(); + final ByteData certData = await rootBundle.load('assets/certificate.pfx'); + final Uint8List certBytes = certData.buffer.asUint8List(); + + PdfSignature signature = PdfSignature( + certificate: PdfCertificate(certBytes, 'caifacile123'), + contactInfo: 'CAI Facile App', + locationInfo: GlobalData.luogo.isNotEmpty ? GlobalData.luogo : 'Italia', + reason: 'Firma Elettronica Avanzata (FEA) tramite verifica OTP', + ); + + // Aggiungiamo un campo firma invisibile (bounds: 0,0,0,0) sulla prima pagina + PdfSignatureField signatureField = PdfSignatureField(docFinale.pages[0], 'FirmaFEA', bounds: const Rect.fromLTWH(0, 0, 0, 0), signature: signature); + docFinale.form.fields.add(signatureField); } catch (e) { - debugPrint("⚠️ Errore Flattening: $e"); - docFinale.form.readOnly = true; + debugPrint("⚠️ Certificato FEA non trovato o errore firma: $e"); } List bytesFinali = await docFinale.save(); @@ -139,6 +164,127 @@ class PdfEngine { } } + // ================================================================= + // CREAZIONE ATTESTATO FEA (PAGINA 2) + // ================================================================= + static Future _aggiungiPaginaFea(PdfDocument document) async { + PdfPage page = document.pages.add(); + final Size pageSize = page.getClientSize(); + + final PdfFont titleFont = PdfStandardFont(PdfFontFamily.helvetica, 16, style: PdfFontStyle.bold); + final PdfFont subTitleFont = PdfStandardFont(PdfFontFamily.helvetica, 9, style: PdfFontStyle.italic); + final PdfFont boldFont = PdfStandardFont(PdfFontFamily.helvetica, 11, style: PdfFontStyle.bold); + final PdfFont regularFont = PdfStandardFont(PdfFontFamily.helvetica, 10); + + double y = 0; + + String title = "ATTESTATO DI FIRMA ELETTRONICA AVANZATA (FEA)"; + page.graphics.drawString(title, titleFont, bounds: Rect.fromLTWH(0, y, pageSize.width, 30), format: PdfStringFormat(alignment: PdfTextAlignment.center)); + y += 25; + + String subtitle = "Ai sensi dell'art. 20 del D.Lgs. 7 marzo 2005, n. 82 (CAD) e del DPCM 22 Febbraio 2013."; + page.graphics.drawString(subtitle, subTitleFont, bounds: Rect.fromLTWH(0, y, pageSize.width, 30), format: PdfStringFormat(alignment: PdfTextAlignment.center)); + y += 40; + + page.graphics.drawLine(PdfPen(PdfColor(100, 100, 100), width: 1), Offset(0, y), Offset(pageSize.width, y)); + y += 20; + + double colWidth = (pageSize.width / 2) - 10; + + // Conducente A + double currentY = y; + page.graphics.drawString("VEICOLO A (Blu)", boldFont, bounds: Rect.fromLTWH(0, currentY, colWidth, 20), brush: PdfSolidBrush(PdfColor(0, 0, 200))); + currentY += 25; + + String nomeA = "${GlobalData.Nome_cond_A} ${GlobalData.Cognome_cond_A}".trim(); + if (nomeA.isEmpty) nomeA = "${GlobalData.Nome_contraente_A} ${GlobalData.Cognome_contraente_A}".trim(); + String cfA = GlobalData.Cod_fiscale_cond_A.isNotEmpty ? GlobalData.Cod_fiscale_cond_A : GlobalData.Codice_Fiscale_contraente_A; + String telA = GlobalData.N_tel_mail_cond_A.isNotEmpty ? GlobalData.N_tel_mail_cond_A : GlobalData.N_telefono_mail_contraente_A; + + page.graphics.drawString("Conducente: $nomeA", regularFont, bounds: Rect.fromLTWH(0, currentY, colWidth, 20)); + currentY += 20; + page.graphics.drawString("Codice Fiscale: $cfA", regularFont, bounds: Rect.fromLTWH(0, currentY, colWidth, 20)); + currentY += 20; + + if (GlobalData.feaVerifiedA) { + page.graphics.drawString("Cellulare Verificato: $telA", boldFont, bounds: Rect.fromLTWH(0, currentY, colWidth, 20), brush: PdfSolidBrush(PdfColor(0, 128, 0))); + currentY += 20; + page.graphics.drawString("Data/Ora Verifica: ${GlobalData.otpDataOraA}", regularFont, bounds: Rect.fromLTWH(0, currentY, colWidth, 20)); + currentY += 20; + page.graphics.drawString("ID Transazione OTP: ${GlobalData.otpIdA}", regularFont, bounds: Rect.fromLTWH(0, currentY, colWidth, 20)); + } else { + page.graphics.drawString("Firma Elettronica Semplice (FES)", boldFont, bounds: Rect.fromLTWH(0, currentY, colWidth, 20), brush: PdfSolidBrush(PdfColor(200, 100, 0))); + currentY += 40; + } + currentY += 30; + page.graphics.drawString("Firma Autografa:", boldFont, bounds: Rect.fromLTWH(0, currentY, colWidth, 20)); + currentY += 20; + page.graphics.drawRectangle(pen: PdfPen(PdfColor(200, 200, 200)), bounds: Rect.fromLTWH(0, currentY, colWidth, 80)); + + // Draw Firma A + Uint8List? firmaA = await _renderFirmaTight(GlobalData.puntiFirmaA, Colors.black); + if (firmaA != null) { + PdfBitmap bitmap = PdfBitmap(firmaA); + double scale = _getScale(bitmap.width.toDouble(), bitmap.height.toDouble(), colWidth - 10, 70); + page.graphics.drawImage(bitmap, Rect.fromLTWH(5, currentY + 5, bitmap.width * scale, bitmap.height * scale)); + } + + // Conducente B + currentY = y; + double startXB = pageSize.width / 2 + 10; + page.graphics.drawString("VEICOLO B (Giallo)", boldFont, bounds: Rect.fromLTWH(startXB, currentY, colWidth, 20), brush: PdfSolidBrush(PdfColor(200, 150, 0))); + currentY += 25; + + String nomeB = "${GlobalData.Nome_cond_B} ${GlobalData.Cognome_cond_B}".trim(); + if (nomeB.isEmpty) nomeB = "${GlobalData.Nome_contraente_B} ${GlobalData.Cognome_contraente_B}".trim(); + String cfB = GlobalData.Cod_fiscale_cond_B.isNotEmpty ? GlobalData.Cod_fiscale_cond_B : GlobalData.Codice_Fiscale_contraente_B; + String telB = GlobalData.N_tel_mail_cond_B.isNotEmpty ? GlobalData.N_tel_mail_cond_B : GlobalData.N_telefono_mail_contraente_B; + + page.graphics.drawString("Conducente: $nomeB", regularFont, bounds: Rect.fromLTWH(startXB, currentY, colWidth, 20)); + currentY += 20; + page.graphics.drawString("Codice Fiscale: $cfB", regularFont, bounds: Rect.fromLTWH(startXB, currentY, colWidth, 20)); + currentY += 20; + + if (GlobalData.feaVerifiedB) { + page.graphics.drawString("Cellulare Verificato: $telB", boldFont, bounds: Rect.fromLTWH(startXB, currentY, colWidth, 20), brush: PdfSolidBrush(PdfColor(0, 128, 0))); + currentY += 20; + page.graphics.drawString("Data/Ora Verifica: ${GlobalData.otpDataOraB}", regularFont, bounds: Rect.fromLTWH(startXB, currentY, colWidth, 20)); + currentY += 20; + page.graphics.drawString("ID Transazione OTP: ${GlobalData.otpIdB}", regularFont, bounds: Rect.fromLTWH(startXB, currentY, colWidth, 20)); + } else { + page.graphics.drawString("Firma Elettronica Semplice (FES)", boldFont, bounds: Rect.fromLTWH(startXB, currentY, colWidth, 20), brush: PdfSolidBrush(PdfColor(200, 100, 0))); + currentY += 40; + } + currentY += 30; + page.graphics.drawString("Firma Autografa:", boldFont, bounds: Rect.fromLTWH(startXB, currentY, colWidth, 20)); + currentY += 20; + page.graphics.drawRectangle(pen: PdfPen(PdfColor(200, 200, 200)), bounds: Rect.fromLTWH(startXB, currentY, colWidth, 80)); + + // Draw Firma B + Uint8List? firmaB = await _renderFirmaTight(GlobalData.puntiFirmaB, Colors.black); + if (firmaB != null) { + PdfBitmap bitmap = PdfBitmap(firmaB); + double scale = _getScale(bitmap.width.toDouble(), bitmap.height.toDouble(), colWidth - 10, 70); + page.graphics.drawImage(bitmap, Rect.fromLTWH(startXB + 5, currentY + 5, bitmap.width * scale, bitmap.height * scale)); + } + + y = currentY + 110; + + page.graphics.drawLine(PdfPen(PdfColor(100, 100, 100), width: 1), Offset(0, y), Offset(pageSize.width, y)); + y += 20; + String disclaimer = "Il presente documento è sigillato crittograficamente in modo inalterabile.\n" + "Qualsiasi modifica apportata al file dopo l'apposizione del sigillo invaliderà le firme.\n" + "Le identità dei firmatari (ove specificato 'Verificato') sono state accertate tramite associazione univoca " + "tra il numero di telefono cellulare e il codice OTP inserito sul dispositivo al momento della firma."; + page.graphics.drawString(disclaimer, subTitleFont, bounds: Rect.fromLTWH(0, y, pageSize.width, 100), format: PdfStringFormat(alignment: PdfTextAlignment.center)); + } + + static double _getScale(double imgW, double imgH, double boxW, double boxH) { + double ratioX = boxW / imgW; + double ratioY = boxH / imgH; + return (ratioX < ratioY) ? ratioX : ratioY; + } + // ... (Keep existing helper methods: _riempiCampoSplit, _scriviX, _scriviTesto, _scriviXRossa, _scriviTestoTotale, _disegnaInBox, _renderFirmaTight, _valoreDaGlobal) // Re-pasting them here for completeness to ensure no missing dependencies diff --git a/lib/screens/fea_verification_screen.dart b/lib/screens/fea_verification_screen.dart new file mode 100644 index 0000000..72cf445 --- /dev/null +++ b/lib/screens/fea_verification_screen.dart @@ -0,0 +1,210 @@ +import 'dart:ui'; +import 'package:flutter/material.dart'; +import 'package:firebase_auth/firebase_auth.dart'; +import '../services/otp_service.dart'; + +class FeaVerificationModal extends StatefulWidget { + final String phoneNumber; + final String conducenteName; + final VoidCallback onVerificationSuccess; + final VoidCallback onCancel; + + const FeaVerificationModal({ + super.key, + required this.phoneNumber, + required this.conducenteName, + required this.onVerificationSuccess, + required this.onCancel, + }); + + static Future show({ + required BuildContext context, + required String phoneNumber, + required String conducenteName, + required VoidCallback onVerificationSuccess, + required VoidCallback onCancel, + }) { + return showDialog( + context: context, + barrierDismissible: false, + builder: (context) => FeaVerificationModal( + phoneNumber: phoneNumber, + conducenteName: conducenteName, + onVerificationSuccess: onVerificationSuccess, + onCancel: onCancel, + ), + ); + } + + @override + State createState() => _FeaVerificationModalState(); +} + +class _FeaVerificationModalState extends State { + final OtpService _otpService = OtpService(); + final TextEditingController _otpController = TextEditingController(); + + bool _isLoading = false; + bool _codeSent = false; + String? _verificationId; + String? _errorMessage; + + @override + void initState() { + super.initState(); + _inviaSms(); + } + + Future _inviaSms() async { + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + await _otpService.sendOtp( + phoneNumber: widget.phoneNumber, + onCodeSent: (verId) { + if (mounted) { + setState(() { + _verificationId = verId; + _codeSent = true; + _isLoading = false; + }); + } + }, + onVerificationFailed: (e) { + if (mounted) { + setState(() { + _errorMessage = e.message ?? "Errore nell'invio dell'SMS. Controlla il numero."; + _isLoading = false; + }); + } + }, + onVerificationCompleted: (credential) async { + // Auto-resolution (su alcuni Android) + if (mounted) { + setState(() => _isLoading = true); + } + try { + await FirebaseAuth.instance.signInWithCredential(credential); + widget.onVerificationSuccess(); + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = "Errore auto-verifica."; + _isLoading = false; + }); + } + } + }, + onCodeAutoRetrievalTimeout: (verId) { + _verificationId = verId; + }, + ); + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = "Errore generico: $e"; + _isLoading = false; + }); + } + } + } + + Future _verificaPin() async { + final code = _otpController.text.trim(); + if (code.length != 6 || _verificationId == null) { + setState(() => _errorMessage = "Inserisci il codice a 6 cifre"); + return; + } + + setState(() { + _isLoading = true; + _errorMessage = null; + }); + + try { + await _otpService.verifyCode(verificationId: _verificationId!, smsCode: code); + widget.onVerificationSuccess(); + } catch (e) { + if (mounted) { + setState(() { + _errorMessage = "Codice errato o scaduto. Riprova."; + _isLoading = false; + }); + } + } + } + + @override + Widget build(BuildContext context) { + return BackdropFilter( + filter: ImageFilter.blur(sigmaX: 10, sigmaY: 10), + child: AlertDialog( + backgroundColor: Colors.white.withValues(alpha: 0.95), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(20)), + title: Column( + children: [ + const Icon(Icons.verified_user, color: Colors.green, size: 50), + const SizedBox(height: 10), + const Text("Firma Elettronica", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18), textAlign: TextAlign.center), + const SizedBox(height: 5), + Text(widget.conducenteName, style: const TextStyle(fontSize: 14, color: Colors.black87), textAlign: TextAlign.center), + ], + ), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + if (_errorMessage != null) + Padding( + padding: const EdgeInsets.only(bottom: 10), + child: Text(_errorMessage!, style: const TextStyle(color: Colors.red, fontSize: 13), textAlign: TextAlign.center), + ), + if (!_codeSent) ...[ + const Text("Invio SMS in corso...", textAlign: TextAlign.center), + const SizedBox(height: 15), + if (_isLoading) const CircularProgressIndicator(), + ] else ...[ + Text("Abbiamo inviato un codice a:\n${widget.phoneNumber}", textAlign: TextAlign.center), + const SizedBox(height: 15), + TextField( + controller: _otpController, + keyboardType: TextInputType.number, + maxLength: 6, + textAlign: TextAlign.center, + style: const TextStyle(fontSize: 24, letterSpacing: 8, fontWeight: FontWeight.bold), + decoration: InputDecoration( + counterText: "", + hintText: "000000", + filled: true, + fillColor: Colors.grey.shade100, + border: OutlineInputBorder(borderRadius: BorderRadius.circular(10), borderSide: BorderSide.none), + ), + ), + const SizedBox(height: 10), + if (_isLoading) const CircularProgressIndicator() + else ElevatedButton( + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue.shade800, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(12)), + elevation: 2, + ), + onPressed: _verificaPin, + child: const Text("VERIFICA", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)), + ), + ] + ], + ), + actions: [ + TextButton( + onPressed: widget.onCancel, + child: const Text("ANNULLA", style: TextStyle(color: Colors.red, fontWeight: FontWeight.bold)), + ), + ], + ), + ); + } +} diff --git a/lib/services/otp_service.dart b/lib/services/otp_service.dart new file mode 100644 index 0000000..6c6cc3d --- /dev/null +++ b/lib/services/otp_service.dart @@ -0,0 +1,71 @@ +import 'dart:async'; +import 'package:firebase_auth/firebase_auth.dart'; +import 'package:flutter/foundation.dart'; + +class OtpService { + final FirebaseAuth _auth = FirebaseAuth.instance; + + String? _verificationId; + int? _resendToken; + + /// Invia l'SMS al numero di telefono (deve avere il prefisso internazionale, es. +39). + Future sendOtp({ + required String phoneNumber, + required Function(String verificationId) onCodeSent, + required Function(FirebaseAuthException e) onVerificationFailed, + required Function(PhoneAuthCredential credential) onVerificationCompleted, + required Function(String verificationId) onCodeAutoRetrievalTimeout, + }) async { + // Normalizzazione numero (aggiunta +39 se manca) + String normalizedPhone = phoneNumber.trim().replaceAll(' ', ''); + if (!normalizedPhone.startsWith('+')) { + if (normalizedPhone.startsWith('00')) { + normalizedPhone = '+' + normalizedPhone.substring(2); + } else { + // Presumo Italia come default + normalizedPhone = '+39' + normalizedPhone; + } + } + + debugPrint("Invio SMS a: $normalizedPhone"); + + await _auth.verifyPhoneNumber( + phoneNumber: normalizedPhone, + verificationCompleted: (PhoneAuthCredential credential) { + // Solo su Android, a volte legge in automatico l'SMS + onVerificationCompleted(credential); + }, + verificationFailed: (FirebaseAuthException e) { + debugPrint("Verifica Fallita: ${e.message}"); + onVerificationFailed(e); + }, + codeSent: (String verificationId, int? resendToken) { + _verificationId = verificationId; + _resendToken = resendToken; + onCodeSent(verificationId); + }, + codeAutoRetrievalTimeout: (String verificationId) { + _verificationId = verificationId; + onCodeAutoRetrievalTimeout(verificationId); + }, + forceResendingToken: _resendToken, + timeout: const Duration(seconds: 60), + ); + } + + /// Verifica il codice PIN (OTP) inserito dall'utente. + /// Ritorna un UserCredential se la verifica ha successo, altrimenti lancia un'eccezione. + Future verifyCode({required String verificationId, required String smsCode}) async { + PhoneAuthCredential credential = PhoneAuthProvider.credential( + verificationId: verificationId, + smsCode: smsCode, + ); + // Firma e logga l'utente verificando l'OTP. + return await _auth.signInWithCredential(credential); + } + + /// Effettua il logout per ripulire lo stato se necessario (es. dopo l'invio del CID) + Future signOut() async { + await _auth.signOut(); + } +} diff --git a/memory-bank/current-state.md b/memory-bank/current-state.md index 02f64cb..141b6a5 100644 --- a/memory-bank/current-state.md +++ b/memory-bank/current-state.md @@ -1,6 +1,6 @@ # Current State -L'app "CAI Facile" è in uno stato avanzato di sviluppo. Lo scambio dati remoto e la generazione PDF sono attivi. +L'app "CAI Facile" è in uno stato avanzato di sviluppo. Lo scambio dati remoto, la monetizzazione e la generazione PDF sono attivi. Attualmente è in corso l'implementazione della Firma Elettronica Avanzata (FEA). ## Funzionalità Implementate - UI/UX completa per le sezioni 1-15 del modulo CID. diff --git a/memory-bank/decisions.md b/memory-bank/decisions.md index 93a4d8a..28ed0db 100644 --- a/memory-bank/decisions.md +++ b/memory-bank/decisions.md @@ -17,3 +17,8 @@ ## Monetizzazione & Paywall (RevenueCat) - **Decisione**: L'app utilizza un modello "Hard Paywall parziale". Gli utenti possono compilare il modulo offline gratuitamente, ma la **funzionalità core di Scambio Dati (QR Code e sincronizzazione Firebase)** è bloccata dietro un abbonamento annuale. - **Implementazione**: Utilizzo di RevenueCat (`purchases_flutter`) per delegare la validazione delle ricevute lato server. Interfaccia Paywall custom (`PaywallScreen`) piuttosto che il pacchetto UI di RevenueCat per mantenere coerenza stilistica e aumentare le conversioni con copy persuasivo ("Sblocca la sincronizzazione"). + +## Firma Elettronica Avanzata (FEA) +- **Decisione**: Per conferire pieno valore legale di "scrittura privata" al documento (ex art. 2702 c.c.) e rispettare i requisiti del CAD, il CID viene protetto con una Firma Elettronica Avanzata. +- **Flusso**: 1) Identificazione utente tramite OTP via SMS (Firebase Phone Auth). 2) Aggiunta di un "Attestato di Certificazione FEA" in Pagina 2 del PDF con firme autografe visibili. 3) Applicazione di un sigillo crittografico invisibile sull'intero PDF tramite certificato PKCS#12 (`assets/certificate.pfx`). +- **Privacy (GDPR)**: Nessun log viene salvato sul database Firebase. La prova crittografica e l'Audit Trail dell'OTP "viaggiano" esclusivamente fusi all'interno del PDF stesso, in modo inalterabile. diff --git a/pubspec.yaml b/pubspec.yaml index c538033..17d6298 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -91,6 +91,7 @@ flutter: - assets/fonts/Roboto-Bold.ttf - assets/sfondo_cid.jpg - assets/icona.png + - assets/certificate.pfx fonts: - family: Roboto diff --git a/test/generate_fea_preview_test.dart b/test/generate_fea_preview_test.dart new file mode 100644 index 0000000..a1acbf7 --- /dev/null +++ b/test/generate_fea_preview_test.dart @@ -0,0 +1,94 @@ +import 'dart:io'; +import 'dart:ui'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:syncfusion_flutter_pdf/pdf.dart'; + +void main() { + test('Genera Preview Pagina 2 FEA', () async { + PdfDocument document = PdfDocument(); + PdfPage page = document.pages.add(); + final Size pageSize = page.getClientSize(); + + // Draw Header + final PdfFont titleFont = PdfStandardFont(PdfFontFamily.helvetica, 18, style: PdfFontStyle.bold); + final PdfFont subTitleFont = PdfStandardFont(PdfFontFamily.helvetica, 10, style: PdfFontStyle.italic); + final PdfFont boldFont = PdfStandardFont(PdfFontFamily.helvetica, 12, style: PdfFontStyle.bold); + final PdfFont regularFont = PdfStandardFont(PdfFontFamily.helvetica, 11); + + double y = 0; + + // Title + String title = "ATTESTATO DI FIRMA ELETTRONICA AVANZATA (FEA)"; + page.graphics.drawString(title, titleFont, + bounds: Rect.fromLTWH(0, y, pageSize.width, 30), + format: PdfStringFormat(alignment: PdfTextAlignment.center)); + y += 25; + + String subtitle = "Ai sensi dell'art. 20 del D.Lgs. 7 marzo 2005, n. 82 (CAD) e del DPCM 22 Febbraio 2013."; + page.graphics.drawString(subtitle, subTitleFont, + bounds: Rect.fromLTWH(0, y, pageSize.width, 30), + format: PdfStringFormat(alignment: PdfTextAlignment.center)); + y += 50; + + // Draw separator + page.graphics.drawLine(PdfPen(PdfColor(100, 100, 100), width: 1), Offset(0, y), Offset(pageSize.width, y)); + y += 20; + + // Split into two columns for Conducente A and B + double colWidth = (pageSize.width / 2) - 20; + + // Conducente A + double currentY = y; + page.graphics.drawString("VEICOLO A (Blu)", boldFont, bounds: Rect.fromLTWH(0, currentY, colWidth, 20), brush: PdfSolidBrush(PdfColor(0, 0, 200))); + currentY += 25; + page.graphics.drawString("Conducente: Mario Rossi", regularFont, bounds: Rect.fromLTWH(0, currentY, colWidth, 20)); + currentY += 20; + page.graphics.drawString("Codice Fiscale: RSSMRA80A01H501U", regularFont, bounds: Rect.fromLTWH(0, currentY, colWidth, 20)); + currentY += 20; + page.graphics.drawString("Cellulare Verificato: +39 333 1234567", boldFont, bounds: Rect.fromLTWH(0, currentY, colWidth, 20), brush: PdfSolidBrush(PdfColor(0, 128, 0))); + currentY += 20; + page.graphics.drawString("Data/Ora Verifica: 28/04/2026 14:35:12", regularFont, bounds: Rect.fromLTWH(0, currentY, colWidth, 20)); + currentY += 20; + page.graphics.drawString("ID Transazione OTP: fb-otp-849201a", regularFont, bounds: Rect.fromLTWH(0, currentY, colWidth, 20)); + currentY += 30; + page.graphics.drawString("Firma Elettronica Autografa:", boldFont, bounds: Rect.fromLTWH(0, currentY, colWidth, 20)); + currentY += 20; + page.graphics.drawRectangle(pen: PdfPen(PdfColor(200, 200, 200)), bounds: Rect.fromLTWH(0, currentY, colWidth, 100)); + page.graphics.drawString("[ Disegno della Firma A ]", subTitleFont, bounds: Rect.fromLTWH(0, currentY + 40, colWidth, 20), format: PdfStringFormat(alignment: PdfTextAlignment.center)); + + // Conducente B + currentY = y; + page.graphics.drawString("VEICOLO B (Giallo)", boldFont, bounds: Rect.fromLTWH(pageSize.width / 2 + 10, currentY, colWidth, 20), brush: PdfSolidBrush(PdfColor(200, 150, 0))); + currentY += 25; + page.graphics.drawString("Conducente: Luigi Verdi", regularFont, bounds: Rect.fromLTWH(pageSize.width / 2 + 10, currentY, colWidth, 20)); + currentY += 20; + page.graphics.drawString("Codice Fiscale: VRDLGI90B02F205Z", regularFont, bounds: Rect.fromLTWH(pageSize.width / 2 + 10, currentY, colWidth, 20)); + currentY += 20; + page.graphics.drawString("Cellulare Verificato: +39 340 9876543", boldFont, bounds: Rect.fromLTWH(pageSize.width / 2 + 10, currentY, colWidth, 20), brush: PdfSolidBrush(PdfColor(0, 128, 0))); + currentY += 20; + page.graphics.drawString("Data/Ora Verifica: 28/04/2026 14:36:05", regularFont, bounds: Rect.fromLTWH(pageSize.width / 2 + 10, currentY, colWidth, 20)); + currentY += 20; + page.graphics.drawString("ID Transazione OTP: fb-otp-112399b", regularFont, bounds: Rect.fromLTWH(pageSize.width / 2 + 10, currentY, colWidth, 20)); + currentY += 30; + page.graphics.drawString("Firma Elettronica Autografa:", boldFont, bounds: Rect.fromLTWH(pageSize.width / 2 + 10, currentY, colWidth, 20)); + currentY += 20; + page.graphics.drawRectangle(pen: PdfPen(PdfColor(200, 200, 200)), bounds: Rect.fromLTWH(pageSize.width / 2 + 10, currentY, colWidth, 100)); + page.graphics.drawString("[ Disegno della Firma B ]", subTitleFont, bounds: Rect.fromLTWH(pageSize.width / 2 + 10, currentY + 40, colWidth, 20), format: PdfStringFormat(alignment: PdfTextAlignment.center)); + + y = currentY + 150; + + // Footer Disclaimer + page.graphics.drawLine(PdfPen(PdfColor(100, 100, 100), width: 1), Offset(0, y), Offset(pageSize.width, y)); + y += 20; + String disclaimer = "Il presente documento è sigillato crittograficamente in modo inalterabile.\n" + "Qualsiasi modifica apportata al file dopo l'apposizione del sigillo invaliderà le firme.\n" + "Le identità dei firmatari sono state verificate tramite associazione univoca " + "tra il numero di telefono cellulare e il codice OTP inserito sul dispositivo al momento della firma."; + page.graphics.drawString(disclaimer, subTitleFont, bounds: Rect.fromLTWH(0, y, pageSize.width, 100), format: PdfStringFormat(alignment: PdfTextAlignment.center)); + + // Salva il file + final file = File('/Volumes/NVME-2TB/Sviluppo/development/cid_app/test/pagina_2_fea_preview.pdf'); + file.writeAsBytesSync(await document.save()); + document.dispose(); + }); +} diff --git a/test/pagina_2_fea_preview.pdf b/test/pagina_2_fea_preview.pdf new file mode 100644 index 0000000..b11ac8c Binary files /dev/null and b/test/pagina_2_fea_preview.pdf differ