Auto-sync: 20260428_150000

This commit is contained in:
Paolo 2026-04-28 15:00:01 +02:00
parent 94986ffd89
commit 3f7eeda2d6
12 changed files with 598 additions and 7 deletions

BIN
.DS_Store vendored

Binary file not shown.

BIN
assets/certificate.pfx Normal file

Binary file not shown.

View file

@ -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<Comp16Screen> with WidgetsBindingObserver
}
Future<void> _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<bool> 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<Comp16Screen> with WidgetsBindingObserver
setState(() {
_ioHoApprovato = true;
});
// Rigenera i documenti per applicare l'attestato FEA e il sigillo PKCS12
await _generaDocumenti();
}
}

View file

@ -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<String> puntiUrtoB_List = []; static String danni_visibili_B = ""; static String osservazioni_B = ""; static Map<int, bool> circostanzeB = {}; static int totaleCrocetteB = 0; static List<Offset?> 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<dynamic> tratti = [];
static List<dynamic> 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) ---

View file

@ -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<int> 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<int> bytesFinali = await docFinale.save();
@ -139,6 +164,127 @@ class PdfEngine {
}
}
// =================================================================
// CREAZIONE ATTESTATO FEA (PAGINA 2)
// =================================================================
static Future<void> _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

View file

@ -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<void> 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<FeaVerificationModal> createState() => _FeaVerificationModalState();
}
class _FeaVerificationModalState extends State<FeaVerificationModal> {
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<void> _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<void> _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)),
),
],
),
);
}
}

View file

@ -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<void> 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<UserCredential> 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<void> signOut() async {
await _auth.signOut();
}
}

View file

@ -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.

View file

@ -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.

View file

@ -91,6 +91,7 @@ flutter:
- assets/fonts/Roboto-Bold.ttf
- assets/sfondo_cid.jpg
- assets/icona.png
- assets/certificate.pfx
fonts:
- family: Roboto

View file

@ -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();
});
}

Binary file not shown.