473 lines
No EOL
18 KiB
Dart
473 lines
No EOL
18 KiB
Dart
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:math';
|
|
import 'package:cloud_firestore/cloud_firestore.dart';
|
|
import 'package:flutter/material.dart';
|
|
import 'package:qr_flutter/qr_flutter.dart';
|
|
import 'package:mobile_scanner/mobile_scanner.dart';
|
|
import 'global_data.dart';
|
|
import 'cid_data_manager.dart';
|
|
import 'security_service.dart';
|
|
|
|
class ScambioDatiScreen extends StatefulWidget {
|
|
const ScambioDatiScreen({super.key});
|
|
|
|
@override
|
|
State<ScambioDatiScreen> createState() => _ScambioDatiScreenState();
|
|
}
|
|
|
|
class _ScambioDatiScreenState extends State<ScambioDatiScreen> with SingleTickerProviderStateMixin {
|
|
late TabController _tabController;
|
|
final MobileScannerController _cameraController = MobileScannerController();
|
|
final TextEditingController _manualCodeController = TextEditingController();
|
|
|
|
String? _codiceSessione;
|
|
String? _chiaveSegreta;
|
|
String? _shortCode; // IL PIN DI 6 LETTERE
|
|
StreamSubscription? _streamSubscription;
|
|
|
|
bool _isLoading = false;
|
|
bool _scambioConcluso = false;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
|
|
// 🧹 AVVIO DEL NETTURBINO (con 10% di probabilità per non intasare il server)
|
|
if (Random().nextInt(10) == 0) {
|
|
_pulisciVecchieSessioni();
|
|
}
|
|
// RIMOZIONE DELLA SECONDA CHIAMATA LIBERA
|
|
|
|
// LOGICA DI APERTURA INTELLIGENTE:
|
|
// Lato A -> Inizia con la Fotocamera (Indice 1)
|
|
// Lato B -> Inizia mostrando il QR (Indice 0)
|
|
int initialIndex = (GlobalData.latoCorrente == 'A') ? 1 : 0;
|
|
|
|
_tabController = TabController(length: 2, vsync: this, initialIndex: initialIndex);
|
|
|
|
// Avvio automatico Host (prepara il QR in background a prescindere dalla Tab aperta)
|
|
_avviaHost();
|
|
}
|
|
|
|
// --- 🧹 IL NETTURBINO (GARBAGE COLLECTOR) ---
|
|
// Cerca nel database le sessioni più vecchie di 2 ore e le distrugge
|
|
Future<void> _pulisciVecchieSessioni() async {
|
|
try {
|
|
final dueOreFa = DateTime.now().subtract(const Duration(hours: 2));
|
|
|
|
final snapshot = await FirebaseFirestore.instance
|
|
.collection('scambi_cid')
|
|
.where('timestamp_scambio', isLessThan: Timestamp.fromDate(dueOreFa))
|
|
.get();
|
|
|
|
int cancellati = 0;
|
|
for (var doc in snapshot.docs) {
|
|
await doc.reference.delete();
|
|
cancellati++;
|
|
}
|
|
|
|
debugPrint("🧹 [NETTURBINO] Pulizia completata: eliminate $cancellati sessioni vecchie.");
|
|
} catch (e) {
|
|
debugPrint("⚠️ [NETTURBINO] Errore durante la pulizia: $e");
|
|
}
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
// Cancello la sessione se esco senza concludere, a meno che non sia l'ID della firma
|
|
if (!_scambioConcluso && _codiceSessione != null && _codiceSessione != GlobalData.idSessione) {
|
|
FirebaseFirestore.instance.collection('scambi_cid').doc(_codiceSessione).delete();
|
|
}
|
|
|
|
_streamSubscription?.cancel();
|
|
_tabController.dispose();
|
|
_cameraController.dispose();
|
|
_manualCodeController.dispose();
|
|
super.dispose();
|
|
}
|
|
|
|
// --- GENERATORE DI PIN A 6 LETTERE MAIUSCOLE ---
|
|
String _generaShortCode() {
|
|
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789';
|
|
Random rnd = Random();
|
|
return String.fromCharCodes(Iterable.generate(6, (_) => chars.codeUnitAt(rnd.nextInt(chars.length))));
|
|
}
|
|
|
|
// --- 1. LOGICA HOST (CHI MOSTRA IL QR) ---
|
|
Future<void> _avviaHost() async {
|
|
if (_codiceSessione != null) return;
|
|
|
|
if ((GlobalData.latoCorrente == 'A' && GlobalData.puntiFirmaA.isEmpty) ||
|
|
(GlobalData.latoCorrente == 'B' && GlobalData.puntiFirmaB.isEmpty)) {
|
|
return;
|
|
}
|
|
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
String sessionId = GlobalData.idSessione ?? "CID_${DateTime.now().millisecondsSinceEpoch}";
|
|
String shortCode = _generaShortCode();
|
|
|
|
GlobalData.idSessione = sessionId;
|
|
GlobalData.idScambioTemporaneo = sessionId;
|
|
|
|
String chiave = GlobalData.chiaveSegretaCorrente ?? SecurityService.generaChiaveSessione();
|
|
GlobalData.chiaveSegretaCorrente = chiave;
|
|
|
|
Map<String, dynamic> mieiDati = CidDataManager.estraiDatiPerExport();
|
|
String payloadCriptato = SecurityService.criptaDati(mieiDati, chiave);
|
|
|
|
await FirebaseFirestore.instance.collection('scambi_cid').doc(sessionId).set({
|
|
"timestamp_scambio": FieldValue.serverTimestamp(),
|
|
"host_lato": GlobalData.latoCorrente,
|
|
"secure_payload_host": payloadCriptato,
|
|
"secure_payload_guest": null,
|
|
"status": "waiting_guest",
|
|
"short_code": shortCode,
|
|
"chiave_temporanea": chiave
|
|
}, SetOptions(merge: true));
|
|
|
|
if (mounted) {
|
|
setState(() {
|
|
_codiceSessione = sessionId;
|
|
_chiaveSegreta = chiave;
|
|
_shortCode = shortCode;
|
|
_isLoading = false;
|
|
});
|
|
}
|
|
|
|
// Ascolto risposta dell'altro telefono
|
|
_streamSubscription = FirebaseFirestore.instance.collection('scambi_cid').doc(sessionId).snapshots().listen((snapshot) {
|
|
if (!snapshot.exists) {
|
|
if (mounted) setState(() => _codiceSessione = null);
|
|
return;
|
|
}
|
|
var data = snapshot.data();
|
|
if (data != null && data['secure_payload_guest'] != null) {
|
|
_streamSubscription?.cancel();
|
|
_completaSync(data['secure_payload_guest'], chiave);
|
|
}
|
|
});
|
|
|
|
} catch (e) {
|
|
debugPrint("Err Host: $e");
|
|
if (mounted) setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
|
|
void _completaSync(String encryptedData, String chiave) {
|
|
try {
|
|
Map<String, dynamic> dati = SecurityService.decriptaDati(encryptedData, chiave);
|
|
CidDataManager.importaDati(dati);
|
|
_scambioConcluso = true;
|
|
_showSuccessAndExit();
|
|
} catch (e) {
|
|
if (mounted) ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("Errore decriptazione")));
|
|
}
|
|
}
|
|
|
|
// --- 2. LOGICA GUEST: VIA FOTOCAMERA ---
|
|
void _onDetect(BarcodeCapture capture) {
|
|
if (_isLoading || _scambioConcluso) return;
|
|
|
|
final List<Barcode> barcodes = capture.barcodes;
|
|
for (final barcode in barcodes) {
|
|
if (barcode.rawValue != null) {
|
|
_partecipaGuestScansione(barcode.rawValue!);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
Future<void> _partecipaGuestScansione(String qrRawData) async {
|
|
if (qrRawData.isEmpty) return;
|
|
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
if (!qrRawData.contains('|')) throw Exception("QR non valido");
|
|
var parts = qrRawData.split('|');
|
|
String sessionId = parts[0];
|
|
String chiave = parts[1];
|
|
|
|
await _eseguiPartecipazione(sessionId, chiave);
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Errore Scansione: $e")));
|
|
await Future.delayed(const Duration(seconds: 2));
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- 3. LOGICA GUEST: INSERIMENTO MANUALE ---
|
|
Future<void> _partecipaGuestManuale() async {
|
|
String codiceInserito = _manualCodeController.text.trim().toUpperCase();
|
|
|
|
if (codiceInserito.length < 5) {
|
|
ScaffoldMessenger.of(context).showSnackBar(const SnackBar(content: Text("Inserisci un codice valido!"), backgroundColor: Colors.orange));
|
|
return;
|
|
}
|
|
|
|
setState(() => _isLoading = true);
|
|
|
|
try {
|
|
// 1. Cerca la sessione tramite codice corto su Firebase
|
|
final querySnapshot = await FirebaseFirestore.instance
|
|
.collection('scambi_cid')
|
|
.where('short_code', isEqualTo: codiceInserito)
|
|
.limit(1)
|
|
.get();
|
|
|
|
if (querySnapshot.docs.isEmpty) {
|
|
throw Exception("Codice non trovato o scaduto.");
|
|
}
|
|
|
|
final doc = querySnapshot.docs.first;
|
|
String sessionId = doc.id;
|
|
String chiave = doc['chiave_temporanea'] ?? "CHIAVE_DI_BACKUP";
|
|
|
|
await _eseguiPartecipazione(sessionId, chiave, manualHostData: doc.data());
|
|
|
|
} catch (e) {
|
|
if (mounted) {
|
|
ScaffoldMessenger.of(context).showSnackBar(SnackBar(content: Text("Errore: $e"), backgroundColor: Colors.red));
|
|
setState(() => _isLoading = false);
|
|
}
|
|
}
|
|
}
|
|
|
|
// --- FUNZIONE CENTRALE PER GUEST (USATA DA FOTOCAMERA E MANUALE) ---
|
|
Future<void> _eseguiPartecipazione(String sessionId, String chiave, {Map<String, dynamic>? manualHostData}) async {
|
|
if (GlobalData.idSessione != null && GlobalData.idSessione != sessionId) {
|
|
debugPrint("🗑️ Cancello vecchio file firma orfano: ${GlobalData.idSessione}");
|
|
await FirebaseFirestore.instance.collection('scambi_cid').doc(GlobalData.idSessione).delete();
|
|
GlobalData.idSessione = null;
|
|
}
|
|
|
|
Map<String, dynamic> data;
|
|
|
|
if (manualHostData != null) {
|
|
data = manualHostData;
|
|
} else {
|
|
DocumentSnapshot doc = await FirebaseFirestore.instance.collection('scambi_cid').doc(sessionId).get();
|
|
if (!doc.exists) throw Exception("Sessione scaduta.");
|
|
data = doc.data() as Map<String, dynamic>;
|
|
}
|
|
|
|
String? hostDataEnc = data['secure_payload_host'];
|
|
if (hostDataEnc == null) throw Exception("Dati host mancanti.");
|
|
|
|
Map<String, dynamic> datiHost = SecurityService.decriptaDati(hostDataEnc, chiave);
|
|
CidDataManager.importaDati(datiHost);
|
|
|
|
Map<String, dynamic> mieiDati = CidDataManager.estraiDatiPerExport();
|
|
String myDataEnc = SecurityService.criptaDati(mieiDati, chiave);
|
|
|
|
await FirebaseFirestore.instance.collection('scambi_cid').doc(sessionId).update({
|
|
"secure_payload_guest": myDataEnc,
|
|
"status": "completed"
|
|
});
|
|
|
|
GlobalData.idSessione = sessionId;
|
|
GlobalData.idScambioTemporaneo = sessionId;
|
|
GlobalData.chiaveSegretaCorrente = chiave;
|
|
_scambioConcluso = true;
|
|
|
|
_showSuccessAndExit();
|
|
}
|
|
|
|
void _showSuccessAndExit() {
|
|
if (!mounted) return;
|
|
ScaffoldMessenger.of(context).showSnackBar(
|
|
const SnackBar(content: Text("SCAMBIO EFFETTUATO!"), backgroundColor: Colors.green, duration: Duration(seconds: 1))
|
|
);
|
|
Navigator.pop(context);
|
|
}
|
|
|
|
@override
|
|
Widget build(BuildContext context) {
|
|
String qrString = "";
|
|
if (_codiceSessione != null && _chiaveSegreta != null) {
|
|
qrString = "$_codiceSessione|$_chiaveSegreta";
|
|
}
|
|
|
|
return Scaffold(
|
|
backgroundColor: Colors.white,
|
|
appBar: AppBar(
|
|
title: const Text("Scambio Dati"),
|
|
backgroundColor: Colors.blue.shade900,
|
|
foregroundColor: Colors.white,
|
|
bottom: TabBar(
|
|
controller: _tabController,
|
|
indicatorColor: Colors.white,
|
|
labelColor: Colors.white,
|
|
unselectedLabelColor: Colors.white70,
|
|
tabs: const [
|
|
Tab(icon: Icon(Icons.qr_code), text: "IL TUO QR"),
|
|
Tab(icon: Icon(Icons.camera_alt), text: "SCANSIONA / CODICE")
|
|
]
|
|
),
|
|
),
|
|
body: TabBarView(
|
|
controller: _tabController,
|
|
// Impedisce di strusciare accidentalmente e chiudere lo scanner
|
|
physics: const NeverScrollableScrollPhysics(),
|
|
children: [
|
|
// ================= TAB 1: HOST (MOSTRA QR E PIN) =================
|
|
Center(
|
|
child: SingleChildScrollView(
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
if (_codiceSessione == null) ...[
|
|
const CircularProgressIndicator(),
|
|
const SizedBox(height: 20),
|
|
const Text("Generazione in corso...")
|
|
] else ...[
|
|
const Text("Fai scansionare questo QR:", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 18)),
|
|
const SizedBox(height: 20),
|
|
Container(
|
|
padding: const EdgeInsets.all(10),
|
|
decoration: BoxDecoration(color: Colors.white, border: Border.all(color: Colors.black12)),
|
|
child: QrImageView(data: qrString, size: 240)
|
|
),
|
|
const SizedBox(height: 30),
|
|
|
|
const Text("Oppure fai inserire questo PIN:", style: TextStyle(color: Colors.black54)),
|
|
const SizedBox(height: 5),
|
|
Container(
|
|
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 10),
|
|
decoration: BoxDecoration(
|
|
color: Colors.orange.shade50,
|
|
borderRadius: BorderRadius.circular(10),
|
|
border: Border.all(color: Colors.orange.shade800, width: 2)
|
|
),
|
|
child: Text(
|
|
_shortCode ?? "---",
|
|
style: TextStyle(fontWeight: FontWeight.bold, fontSize: 32, letterSpacing: 6, color: Colors.orange.shade900)
|
|
),
|
|
),
|
|
|
|
const SizedBox(height: 30),
|
|
const CircularProgressIndicator(),
|
|
const SizedBox(height: 10),
|
|
const Text("In attesa dell'altro utente...", style: TextStyle(color: Colors.grey))
|
|
]
|
|
]
|
|
)
|
|
)
|
|
),
|
|
|
|
// ================= TAB 2: GUEST (SCANNER SPLIT SCREEN) =================
|
|
Stack(
|
|
children: [
|
|
Column(
|
|
children: [
|
|
// METÀ SUPERIORE: FOTOCAMERA
|
|
Expanded(
|
|
flex: 5,
|
|
child: Stack(
|
|
children: [
|
|
MobileScanner(
|
|
controller: _cameraController,
|
|
onDetect: _onDetect,
|
|
),
|
|
// Overlay mirino per far capire all'utente dove inquadrare
|
|
Center(
|
|
child: Container(
|
|
width: 200, height: 200,
|
|
decoration: BoxDecoration(
|
|
border: Border.all(color: Colors.greenAccent, width: 3),
|
|
borderRadius: BorderRadius.circular(20)
|
|
),
|
|
),
|
|
),
|
|
Positioned(
|
|
bottom: 10,
|
|
left: 0,
|
|
right: 0,
|
|
child: Container(
|
|
color: Colors.black54,
|
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
|
child: const Text(
|
|
"Inquadra il QR Code",
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(color: Colors.white, fontWeight: FontWeight.bold),
|
|
),
|
|
)
|
|
)
|
|
],
|
|
),
|
|
),
|
|
|
|
// DIVISORIO
|
|
Container(height: 5, color: Colors.blue.shade900),
|
|
|
|
// METÀ INFERIORE: INSERIMENTO MANUALE
|
|
Expanded(
|
|
flex: 5,
|
|
child: Container(
|
|
color: Colors.grey.shade100,
|
|
width: double.infinity,
|
|
child: SingleChildScrollView(
|
|
padding: const EdgeInsets.all(20),
|
|
child: Column(
|
|
mainAxisAlignment: MainAxisAlignment.center,
|
|
children: [
|
|
const Icon(Icons.keyboard, size: 40, color: Colors.blueGrey),
|
|
const SizedBox(height: 10),
|
|
const Text(
|
|
"La fotocamera non funziona?\nInserisci qui il PIN a 6 lettere dell'altro utente:",
|
|
textAlign: TextAlign.center,
|
|
style: TextStyle(fontSize: 14, fontWeight: FontWeight.w600, color: Colors.black87),
|
|
),
|
|
const SizedBox(height: 20),
|
|
TextField(
|
|
controller: _manualCodeController,
|
|
textCapitalization: TextCapitalization.characters,
|
|
maxLength: 6,
|
|
textAlign: TextAlign.center,
|
|
style: const TextStyle(fontWeight: FontWeight.bold, fontSize: 24, letterSpacing: 8),
|
|
decoration: InputDecoration(
|
|
hintText: "PIN",
|
|
counterText: "",
|
|
border: OutlineInputBorder(borderRadius: BorderRadius.circular(15)),
|
|
filled: true,
|
|
fillColor: Colors.white,
|
|
prefixIcon: const Icon(Icons.password, color: Colors.blueGrey),
|
|
),
|
|
),
|
|
const SizedBox(height: 20),
|
|
SizedBox(
|
|
width: double.infinity,
|
|
child: ElevatedButton.icon(
|
|
style: ElevatedButton.styleFrom(
|
|
backgroundColor: Colors.orange.shade800,
|
|
foregroundColor: Colors.white,
|
|
padding: const EdgeInsets.symmetric(vertical: 16),
|
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(15)),
|
|
),
|
|
onPressed: _partecipaGuestManuale,
|
|
icon: const Icon(Icons.download),
|
|
label: const Text("SCARICA DATI", style: TextStyle(fontWeight: FontWeight.bold, fontSize: 16)),
|
|
),
|
|
)
|
|
],
|
|
),
|
|
),
|
|
),
|
|
)
|
|
],
|
|
),
|
|
|
|
// OVERLAY CARICAMENTO
|
|
if (_isLoading) const ColoredBox(color: Colors.black54, child: Center(child: CircularProgressIndicator(color: Colors.white))),
|
|
],
|
|
)
|
|
],
|
|
),
|
|
);
|
|
}
|
|
} |