diff --git a/sigap-mobile/lib/src/cores/services/azure_ocr_service.dart b/sigap-mobile/lib/src/cores/services/azure_ocr_service.dart new file mode 100644 index 0000000..590b4b9 --- /dev/null +++ b/sigap-mobile/lib/src/cores/services/azure_ocr_service.dart @@ -0,0 +1,473 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:dio/dio.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:sigap/src/utils/dio.client/dio_client.dart'; + +class AzureOCRService { + // Replace with your Azure Computer Vision API endpoint and key + static const String endpoint = + 'https://your-azure-endpoint.cognitiveservices.azure.com/'; + static const String subscriptionKey = 'your-azure-subscription-key'; + static const String ocrApiPath = 'vision/v3.2/ocr'; + static const String faceApiPath = 'face/v1.0/detect'; + static const String faceVerifyPath = 'face/v1.0/verify'; + + // Process an ID card image and extract relevant information + Future> processIdCard( + XFile imageFile, + bool isOfficer, + ) async { + try { + // Read the file as bytes + final bytes = await File(imageFile.path).readAsBytes(); + + // Prepare the request to Azure OCR API + final uri = Uri.parse( + '$endpoint$ocrApiPath?language=id&detectOrientation=true', + ); + + final response = await DioClient().post( + uri.toString(), + data: bytes, + options: Options( + responseType: ResponseType.json, + headers: { + 'Content-Type': 'application/octet-stream', + 'Ocp-Apim-Subscription-Key': subscriptionKey, + }, + ), + ); + + if (response.statusCode == 200) { + final jsonResponse = json.decode(response.data); + return isOfficer + ? _extractKtaInfo(jsonResponse) + : _extractKtpInfo(jsonResponse); + } else { + throw Exception( + 'Failed to process image: ${response.statusCode} - ${response.data}', + ); + } + } catch (e) { + throw Exception('OCR processing error: $e'); + } + } + + // Extract KTP (Civilian ID) information + Map _extractKtpInfo(Map ocrResult) { + final Map extractedInfo = {}; + final List allLines = _getAllTextLines(ocrResult); + + for (int i = 0; i < allLines.length; i++) { + String line = allLines[i].toLowerCase(); + + // Extract NIK (usually prefixed with "NIK" or "NIK:") + if (line.contains('nik') && i + 1 < allLines.length) { + // NIK might be on the same line or the next line + String nikLine = + line.contains(':') + ? line.split(':')[1].trim() + : allLines[i + 1].trim(); + // Clean up the NIK (remove any non-numeric characters) + nikLine = nikLine.replaceAll(RegExp(r'[^0-9]'), ''); + if (nikLine.length >= 16) { + // Standard ID card NIK length + extractedInfo['nik'] = nikLine; + } + } + + // Extract name (usually prefixed with "Nama" or "Nama:") + if (line.contains('nama') && i + 1 < allLines.length) { + String name = + line.contains(':') + ? line.split(':')[1].trim() + : allLines[i + 1].trim(); + extractedInfo['nama'] = name; + } + + // Extract birth date (usually prefixed with "Tanggal Lahir" or similar) + if ((line.contains('lahir') || line.contains('ttl')) && + i + 1 < allLines.length) { + String birthInfo = + line.contains(':') + ? line.split(':')[1].trim() + : allLines[i + 1].trim(); + // Try to extract date in format DD-MM-YYYY or similar + RegExp dateRegex = RegExp(r'(\d{1,2}[-/\s\.]\d{1,2}[-/\s\.]\d{2,4})'); + var match = dateRegex.firstMatch(birthInfo); + if (match != null) { + extractedInfo['tanggal_lahir'] = match.group(0)!; + } + } + + // Extract address (usually prefixed with "Alamat" or similar) + if (line.contains('alamat') && i + 1 < allLines.length) { + // Address might span multiple lines, try to capture a reasonable amount + String address = ''; + int j = line.contains(':') ? i : i + 1; + int maxLines = 3; // Capture up to 3 lines for address + + while (j < allLines.length && j < i + maxLines) { + if (allLines[j].contains('provinsi') || + allLines[j].contains('rt/rw') || + allLines[j].contains('kota') || + allLines[j].contains('kecamatan')) { + address += ' ${allLines[j].trim()}'; + } else if (j > i) { + // Don't add the "Alamat:" line itself + address += ' ${allLines[j].trim()}'; + } + j++; + } + + extractedInfo['alamat'] = address.trim(); + } + } + + return extractedInfo; + } + + // Extract KTA (Officer ID) information + Map _extractKtaInfo(Map ocrResult) { + final Map extractedInfo = {}; + final List allLines = _getAllTextLines(ocrResult); + + for (int i = 0; i < allLines.length; i++) { + String line = allLines[i].toLowerCase(); + + // Extract name (usually prefixed with "Nama" or "Nama:") + if (line.contains('nama') && i + 1 < allLines.length) { + String name = + line.contains(':') + ? line.split(':')[1].trim() + : allLines[i + 1].trim(); + extractedInfo['nama'] = name; + } + + // Extract Pangkat (Rank) + if (line.contains('pangkat') && i + 1 < allLines.length) { + String rank = + line.contains(':') + ? line.split(':')[1].trim() + : allLines[i + 1].trim(); + extractedInfo['pangkat'] = rank; + } + + // Extract NRP (ID Number for officers) + if ((line.contains('nrp') || line.contains('nip')) && + i + 1 < allLines.length) { + String nrp = + line.contains(':') + ? line.split(':')[1].trim() + : allLines[i + 1].trim(); + // Clean up the NRP (remove any non-alphanumeric characters) + nrp = nrp.replaceAll(RegExp(r'[^a-zA-Z0-9]'), ''); + extractedInfo['nrp'] = nrp; + } + + // Extract Unit + if (line.contains('unit') || + line.contains('kesatuan') && i + 1 < allLines.length) { + String unit = + line.contains(':') + ? line.split(':')[1].trim() + : allLines[i + 1].trim(); + extractedInfo['unit'] = unit; + } + + // Extract birth date (usually prefixed with "Tanggal Lahir" or similar) + if ((line.contains('lahir') || line.contains('ttl')) && + i + 1 < allLines.length) { + String birthInfo = + line.contains(':') + ? line.split(':')[1].trim() + : allLines[i + 1].trim(); + // Try to extract date in format DD-MM-YYYY or similar + RegExp dateRegex = RegExp(r'(\d{1,2}[-/\s\.]\d{1,2}[-/\s\.]\d{2,4})'); + var match = dateRegex.firstMatch(birthInfo); + if (match != null) { + extractedInfo['tanggal_lahir'] = match.group(0)!; + } + } + } + + return extractedInfo; + } + + // Helper method to extract all text lines from OCR result + List _getAllTextLines(Map ocrResult) { + final List allText = []; + + // Navigate through the OCR JSON structure to extract text + if (ocrResult.containsKey('regions')) { + for (var region in ocrResult['regions']) { + if (region.containsKey('lines')) { + for (var line in region['lines']) { + if (line.containsKey('words')) { + String lineText = ''; + for (var word in line['words']) { + if (word.containsKey('text')) { + lineText += word['text'] + ' '; + } + } + if (lineText.isNotEmpty) { + allText.add(lineText.trim()); + } + } + } + } + } + } + + return allText; + } + + // Validate if the extracted OCR data contains all required fields based on role + bool validateRequiredFields( + Map extractedInfo, + bool isOfficer, + ) { + List requiredFields = + isOfficer + ? ['nama', 'pangkat', 'nrp', 'unit', 'tanggal_lahir'] + : ['nik', 'nama', 'tanggal_lahir']; + + // Check if all required fields are present and not empty + for (String field in requiredFields) { + if (!extractedInfo.containsKey(field) || extractedInfo[field]!.isEmpty) { + return false; + } + } + + // Check NIK format for KTP (must be at least 16 digits) + if (!isOfficer && extractedInfo.containsKey('nik')) { + String nik = extractedInfo['nik']!.replaceAll(RegExp(r'[^0-9]'), ''); + if (nik.length < 16) { + return false; + } + } + + // Check NRP format for KTA + if (isOfficer && extractedInfo.containsKey('nrp')) { + String nrp = extractedInfo['nrp']!; + if (nrp.length < 5) { + // Minimum expected length + return false; + } + } + + return true; + } + + // Get missing fields description for error message + String getMissingFieldsDescription( + Map extractedInfo, + bool isOfficer, + ) { + List missingFields = []; + + if (isOfficer) { + // KTA required fields + if (!extractedInfo.containsKey('nama') || + extractedInfo['nama']!.isEmpty) { + missingFields.add('Name'); + } + + if (!extractedInfo.containsKey('pangkat') || + extractedInfo['pangkat']!.isEmpty) { + missingFields.add('Rank (Pangkat)'); + } + + if (!extractedInfo.containsKey('nrp') || extractedInfo['nrp']!.isEmpty) { + missingFields.add('NRP'); + } else { + String nrp = extractedInfo['nrp']!; + if (nrp.length < 5) { + missingFields.add('Valid NRP'); + } + } + + if (!extractedInfo.containsKey('unit') || + extractedInfo['unit']!.isEmpty) { + missingFields.add('Unit'); + } + + if (!extractedInfo.containsKey('tanggal_lahir') || + extractedInfo['tanggal_lahir']!.isEmpty) { + missingFields.add('Birth Date'); + } + } else { + // KTP required fields + if (!extractedInfo.containsKey('nik') || extractedInfo['nik']!.isEmpty) { + missingFields.add('NIK (Identity Number)'); + } else { + String nik = extractedInfo['nik']!.replaceAll(RegExp(r'[^0-9]'), ''); + if (nik.length < 16) { + missingFields.add('Valid NIK (should be 16 digits)'); + } + } + + if (!extractedInfo.containsKey('nama') || + extractedInfo['nama']!.isEmpty) { + missingFields.add('Name'); + } + + if (!extractedInfo.containsKey('tanggal_lahir') || + extractedInfo['tanggal_lahir']!.isEmpty) { + missingFields.add('Birth Date'); + } + + if (!extractedInfo.containsKey('alamat') || + extractedInfo['alamat']!.isEmpty) { + missingFields.add('Address'); + } + } + + return missingFields.join(', '); + } + + // Process facial verification between ID card and selfie + Future> verifyFace( + XFile idCardImage, + XFile selfieImage, + ) async { + try { + // Detect faces in both images + final idCardFaces = await _detectFaces(idCardImage); + final selfieFaces = await _detectFaces(selfieImage); + + if (idCardFaces.isEmpty || selfieFaces.isEmpty) { + return { + 'isMatch': false, + 'message': 'No face detected in one or both images.', + 'confidence': 0.0, + }; + } + + // Get the first face from each image + final idCardFaceId = idCardFaces[0]['faceId']; + final selfieFaceId = selfieFaces[0]['faceId']; + + // Compare the two faces + final matchResult = await _compareFaces(idCardFaceId, selfieFaceId); + + final isMatch = matchResult['isIdentical'] ?? false; + final confidence = matchResult['confidence'] ?? 0.0; + + return { + 'isMatch': isMatch, + 'confidence': confidence, + 'message': + isMatch + ? 'Face verification successful! Confidence: ${(confidence * 100).toStringAsFixed(2)}%' + : 'Face verification failed. The faces do not match.', + }; + } catch (e) { + return { + 'isMatch': false, + 'confidence': 0.0, + 'message': 'Face verification error: ${e.toString()}', + }; + } + } + + // Perform liveness detection to ensure the selfie is real + Future> performLivenessCheck(XFile selfieImage) async { + try { + // In a real implementation, this would call a specialized liveness detection API + // For demonstration purposes, we're simulating a successful liveness check + + // Detect if there's a face in the image at minimum + final faces = await _detectFaces(selfieImage); + + if (faces.isEmpty) { + return { + 'isLive': false, + 'confidence': 0.0, + 'message': 'No face detected in the selfie.', + }; + } + + // Here we would analyze face attributes that indicate liveness + // For now, we'll return a simulated positive result + return { + 'isLive': true, + 'confidence': 0.95, + 'message': 'Liveness check passed successfully.', + }; + } catch (e) { + return { + 'isLive': false, + 'confidence': 0.0, + 'message': 'Liveness check error: ${e.toString()}', + }; + } + } + + // Detect faces in an image and return face IDs + Future> _detectFaces(XFile imageFile) async { + try { + final bytes = await File(imageFile.path).readAsBytes(); + + final uri = Uri.parse( + '$endpoint$faceApiPath?returnFaceId=true&returnFaceLandmarks=false', + ); + + final response = await DioClient().post( + uri.toString(), + data: bytes, + options: Options( + responseType: ResponseType.json, + headers: { + 'Content-Type': 'application/octet-stream', + 'Ocp-Apim-Subscription-Key': subscriptionKey, + }, + ), + ); + + if (response.statusCode == 200) { + return json.decode(response.data); + } else { + throw Exception( + 'Failed to detect faces: ${response.statusCode} - ${response.data}', + ); + } + } catch (e) { + throw Exception('Face detection error: $e'); + } + } + + // Compare two face IDs to determine if they are the same person + Future> _compareFaces( + String faceId1, + String faceId2, + ) async { + try { + final uri = Uri.parse('$endpoint$faceVerifyPath'); + + final response = await DioClient().post( + uri.toString(), + data: {'faceId1': faceId1, 'faceId2': faceId2}, + options: Options( + responseType: ResponseType.json, + headers: { + 'Content-Type': 'application/json', + 'Ocp-Apim-Subscription-Key': subscriptionKey, + }, + ), + ); + + if (response.statusCode == 200) { + return json.decode(response.data); + } else { + throw Exception( + 'Failed to compare faces: ${response.statusCode} - ${response.data}', + ); + } + } catch (e) { + throw Exception('Face comparison error: $e'); + } + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/step_form_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/step_form_controller.dart index d46a0f0..902d01d 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/controllers/step_form_controller.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/step_form_controller.dart @@ -1,5 +1,7 @@ import 'package:flutter/material.dart'; import 'package:get/get.dart'; +import 'package:image_picker/image_picker.dart'; +import 'package:sigap/src/cores/services/azure_ocr_service.dart'; import 'package:sigap/src/features/auth/data/models/user_metadata_model.dart'; import 'package:sigap/src/features/daily-ops/data/models/index.dart'; import 'package:sigap/src/features/personalization/data/models/index.dart'; @@ -64,6 +66,25 @@ class FormRegistrationController extends GetxController { // Available units for officer role final RxList availableUnits = [].obs; + // ID Card variables + final Rx idCardImage = Rx(null); + final RxString idCardError = RxString(''); + final RxBool isVerifying = RxBool(false); + final RxBool isVerified = RxBool(false); + final RxString verificationMessage = RxString(''); + + // Face verification variables + final Rx selfieImage = Rx(null); + final RxString selfieError = RxString(''); + final RxBool isVerifyingFace = RxBool(false); + final RxBool isFaceVerified = RxBool(false); + final RxString faceVerificationMessage = RxString(''); + final RxBool isLivenessCheckPassed = RxBool(false); + final RxBool isPerformingLivenessCheck = RxBool(false); + + // Azure OCR service + final AzureOCRService _ocrService = AzureOCRService(); + @override void onInit() { super.onInit(); @@ -342,14 +363,401 @@ class FormRegistrationController extends GetxController { return isValid; } - // Go to next step - void nextStep() { - if (!validateCurrentStep()) return; + // Pick ID Card Image + Future pickIdCardImage(ImageSource source) async { + try { + final ImagePicker picker = ImagePicker(); + final XFile? image = await picker.pickImage( + source: source, + imageQuality: 80, + ); - if (currentStep.value < stepFormKeys.length - 1) { - currentStep.value++; + if (image != null) { + idCardImage.value = image; + idCardError.value = ''; + // Reset verification status when a new image is picked + isVerified.value = false; + verificationMessage.value = ''; + } + } catch (e) { + idCardError.value = 'Failed to pick image: $e'; + } + } + + // Take or pick selfie image + Future pickSelfieImage(ImageSource source, bool isLivenessCheck) async { + try { + final ImagePicker picker = ImagePicker(); + final XFile? image = await picker.pickImage( + source: source, + preferredCameraDevice: CameraDevice.front, + imageQuality: 80, + ); + + if (image != null) { + selfieImage.value = image; + selfieError.value = ''; + + // Reset face verification status + isFaceVerified.value = false; + faceVerificationMessage.value = ''; + + // If this was taken for liveness check, perform the check + if (isLivenessCheck && source == ImageSource.camera) { + await performLivenessCheck(); + } + } + } catch (e) { + selfieError.value = 'Failed to capture selfie: $e'; + } + } + + // Perform liveness check on selfie + Future performLivenessCheck() async { + if (selfieImage.value == null) { + selfieError.value = 'Please take a selfie first'; + return; + } + + try { + isPerformingLivenessCheck.value = true; + + // Call liveness detection service + final result = await _ocrService.performLivenessCheck(selfieImage.value!); + + isLivenessCheckPassed.value = result['isLive'] ?? false; + faceVerificationMessage.value = result['message'] ?? ''; + + // If liveness check failed, clear the selfie image to force retake + if (!isLivenessCheckPassed.value) { + selfieError.value = 'Liveness check failed. Please retake your selfie.'; + } + } catch (e) { + isLivenessCheckPassed.value = false; + faceVerificationMessage.value = 'Liveness check failed: ${e.toString()}'; + selfieError.value = 'Error during liveness check: ${e.toString()}'; + } finally { + isPerformingLivenessCheck.value = false; + } + } + + // Clear ID Card Image + void clearIdCardImage() { + idCardImage.value = null; + idCardError.value = ''; + isVerified.value = false; + verificationMessage.value = ''; + } + + // Clear Selfie Image + void clearSelfieImage() { + selfieImage.value = null; + selfieError.value = ''; + isFaceVerified.value = false; + faceVerificationMessage.value = ''; + isLivenessCheckPassed.value = false; + } + + // Verify ID Card using OCR + Future verifyIdCardWithOCR() async { + if (idCardImage.value == null) { + idCardError.value = 'Please upload an ID card image first'; + return; + } + + try { + isVerifying.value = true; + final isOfficer = selectedRole.value?.isOfficer ?? false; + + // Determine ID card type based on role + final idCardType = isOfficer ? 'KTA' : 'KTP'; + + // Call Azure OCR service with the appropriate ID type + final result = await _ocrService.processIdCard( + idCardImage.value!, + isOfficer, + ); + + // Validate if all required fields are present in the image + if (!_ocrService.validateRequiredFields(result, isOfficer)) { + isVerified.value = false; + String missingFields = _ocrService.getMissingFieldsDescription( + result, + isOfficer, + ); + verificationMessage.value = + 'Invalid $idCardType image. The following information could not be detected: $missingFields. Please upload a clearer image of your ID card.'; + return; + } + + // Compare OCR results with user input + final bool isMatch = + isOfficer ? _verifyKtaResults(result) : _verifyKtpResults(result); + + isVerified.value = isMatch; + verificationMessage.value = + isMatch + ? '$idCardType verification successful! Your information matches.' + : 'Verification failed. Please ensure your information matches your $idCardType or upload a clearer image.'; + } catch (e) { + isVerified.value = false; + verificationMessage.value = 'OCR processing failed: ${e.toString()}'; + } finally { + isVerifying.value = false; + } + } + + // Verify selfie with ID card + Future verifyFaceMatch() async { + if (idCardImage.value == null) { + idCardError.value = 'Please upload an ID card image first'; + return; + } + + if (selfieImage.value == null) { + selfieError.value = 'Please take a selfie first'; + return; + } + + if (!isLivenessCheckPassed.value) { + selfieError.value = 'Please complete the liveness check first'; + return; + } + + try { + isVerifyingFace.value = true; + + // Compare face in ID card with selfie face + final result = await _ocrService.verifyFace( + idCardImage.value!, + selfieImage.value!, + ); + + isFaceVerified.value = result['isMatch'] ?? false; + faceVerificationMessage.value = result['message'] ?? ''; + } catch (e) { + isFaceVerified.value = false; + faceVerificationMessage.value = + 'Face verification failed: ${e.toString()}'; + } finally { + isVerifyingFace.value = false; + } + } + + // Compare OCR results with user input for KTP + bool _verifyKtpResults(Map ocrResults) { + int matchCount = 0; + int totalFields = 0; + + // Check name matches (case insensitive, accounting for name formatting differences) + String fullName = + '${firstNameController.text} ${lastNameController.text}' + .trim() + .toLowerCase(); + if (ocrResults.containsKey('nama') && fullName.isNotEmpty) { + totalFields++; + String ocrName = ocrResults['nama']!.toLowerCase(); + // Use fuzzy matching since OCR might not be perfect + if (ocrName.contains(firstNameController.text.toLowerCase()) || + fullName.contains(ocrName)) { + matchCount++; + } + } + + // Check NIK matches (exact match required for numbers) + if (ocrResults.containsKey('nik') && nikController.text.isNotEmpty) { + totalFields++; + // Clean up any spaces or special characters from OCR result + String ocrNik = ocrResults['nik']!.replaceAll(RegExp(r'[^0-9]'), ''); + if (ocrNik == nikController.text) { + matchCount++; + } + } + + // Check birth date (flexible format matching) + if (ocrResults.containsKey('tanggal_lahir') && + birthDateController.text.isNotEmpty) { + totalFields++; + // Convert both to a common format for comparison + String userDate = _normalizeDateFormat(birthDateController.text); + String ocrDate = _normalizeDateFormat(ocrResults['tanggal_lahir']!); + if (userDate == ocrDate) { + matchCount++; + } + } + + // Check address (partial matching is acceptable due to OCR limitations and address complexity) + if (ocrResults.containsKey('alamat') && addressController.text.isNotEmpty) { + totalFields++; + String userAddress = addressController.text.toLowerCase(); + String ocrAddress = ocrResults['alamat']!.toLowerCase(); + + // Check if there's significant overlap between the two addresses + List userAddressParts = userAddress.split(' '); + int addressMatchCount = 0; + + for (String part in userAddressParts) { + if (part.length > 3 && ocrAddress.contains(part)) { + addressMatchCount++; + } + } + + if (addressMatchCount >= userAddressParts.length * 0.5) { + matchCount++; + } + } + + // Require at least 60% of the fields to match + return totalFields > 0 && (matchCount / totalFields) >= 0.6; + } + + // Compare OCR results with user input for KTA (Officer ID) + bool _verifyKtaResults(Map ocrResults) { + int matchCount = 0; + int totalFields = 0; + + // Check name matches + String fullName = + '${firstNameController.text} ${lastNameController.text}' + .trim() + .toLowerCase(); + if (ocrResults.containsKey('nama') && fullName.isNotEmpty) { + totalFields++; + String ocrName = ocrResults['nama']!.toLowerCase(); + if (ocrName.contains(firstNameController.text.toLowerCase()) || + fullName.contains(ocrName)) { + matchCount++; + } + } + + // Check rank matches + if (ocrResults.containsKey('pangkat') && rankController.text.isNotEmpty) { + totalFields++; + String ocrRank = ocrResults['pangkat']!.toLowerCase(); + String userRank = rankController.text.toLowerCase(); + if (ocrRank.contains(userRank) || userRank.contains(ocrRank)) { + matchCount++; + } + } + + // Check NRP matches + if (ocrResults.containsKey('nrp') && nrpController.text.isNotEmpty) { + totalFields++; + String ocrNrp = ocrResults['nrp']!.replaceAll( + RegExp(r'[^0-9a-zA-Z]'), + '', + ); + String userNrp = nrpController.text.replaceAll( + RegExp(r'[^0-9a-zA-Z]'), + '', + ); + if (ocrNrp.contains(userNrp) || userNrp.contains(ocrNrp)) { + matchCount++; + } + } + + // Check unit matches + if (ocrResults.containsKey('unit') && unitIdController.text.isNotEmpty) { + totalFields++; + String ocrUnit = ocrResults['unit']!.toLowerCase(); + + // Find the matching unit from available units + final selectedUnit = availableUnits.firstWhere( + (unit) => unit.codeUnit == unitIdController.text, + orElse: () => UnitModel(), + ); + + String userUnit = selectedUnit.name.toLowerCase(); + + if (ocrUnit.contains(userUnit) || userUnit.contains(ocrUnit)) { + matchCount++; + } + } + + // Check birth date if available + if (ocrResults.containsKey('tanggal_lahir') && + birthDateController.text.isNotEmpty) { + totalFields++; + String userDate = _normalizeDateFormat(birthDateController.text); + String ocrDate = _normalizeDateFormat(ocrResults['tanggal_lahir']!); + if (userDate == ocrDate) { + matchCount++; + } + } + + // Require at least 60% of the fields to match + return totalFields > 0 && (matchCount / totalFields) >= 0.6; + } + + // Helper method to normalize date formats for comparison + String _normalizeDateFormat(String dateStr) { + // Remove non-numeric characters + String numericOnly = dateStr.replaceAll(RegExp(r'[^0-9]'), ''); + + // Try to extract year, month, day in a standardized format + if (numericOnly.length >= 8) { + // Assume YYYYMMDD format + return numericOnly.substring(0, 8); } else { - submitForm(); + return numericOnly; + } + } + + // Override existing nextStep method to include ID card and face verification + void nextStep() { + // For the identity step, ensure both ID card and face verification are completed + if (currentStep.value == 1 && + !isLoading.value && + selectedRole.value != null) { + if (!_validateCurrentStep()) return; + + // Check if ID card image is uploaded + if (idCardImage.value == null) { + final idCardType = selectedRole.value!.isOfficer ? 'KTA' : 'KTP'; + idCardError.value = + 'Please upload your $idCardType image for verification'; + return; + } + + // Check if ID card is verified + if (!isVerified.value) { + idCardError.value = 'Please verify your ID card first'; + return; + } + + // Check if selfie is captured + if (selfieImage.value == null) { + selfieError.value = 'Please take a selfie for face verification'; + return; + } + + // Check if liveness check is passed + if (!isLivenessCheckPassed.value) { + selfieError.value = 'Please complete the liveness check'; + return; + } + + // Check if face is verified + if (!isFaceVerified.value) { + selfieError.value = 'Please complete face verification'; + return; + } + + // Proceed with the original logic + if (currentStep.value < stepFormKeys.length - 1) { + currentStep.value++; + } else { + submitForm(); + } + } else { + // Original nextStep logic for other steps + if (!validateCurrentStep()) return; + + if (currentStep.value < stepFormKeys.length - 1) { + currentStep.value++; + } else { + submitForm(); + } } } diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/registraion_form_screen.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/registraion_form_screen.dart index 8ed0fd7..ae528a0 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/registraion_form_screen.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/registraion_form_screen.dart @@ -1,6 +1,9 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; +import 'package:image_picker/image_picker.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/step_form_controller.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart'; import 'package:sigap/src/features/personalization/data/models/index.dart'; @@ -277,6 +280,9 @@ class FormRegistrationScreen extends StatelessWidget { } Widget _buildPrivacyIdentity(FormRegistrationController controller) { + final bool isOfficer = controller.selectedRole.value?.isOfficer ?? false; + final String idCardType = isOfficer ? 'KTA' : 'KTP'; + return Form( key: controller.stepFormKeys[1], child: Column( @@ -300,23 +306,26 @@ class FormRegistrationScreen extends StatelessWidget { ), const SizedBox(height: TSizes.spaceBtwItems), - // NIK field - Obx( - () => CustomTextField( - label: 'NIK (Identity Number)', - controller: controller.nikController, - validator: - (value) => TValidators.validateUserInput('NIK', value, 16), - errorText: controller.nikError.value, - textInputAction: TextInputAction.next, - keyboardType: TextInputType.number, - hintText: 'e.g., 1234567890123456', - onChanged: (value) { - controller.nikController.text = value; - controller.nikError.value = ''; - }, + // Different fields based on role + if (!isOfficer) ...[ + // NIK field for viewers + Obx( + () => CustomTextField( + label: 'NIK (Identity Number)', + controller: controller.nikController, + validator: + (value) => TValidators.validateUserInput('NIK', value, 16), + errorText: controller.nikError.value, + textInputAction: TextInputAction.next, + keyboardType: TextInputType.number, + hintText: 'e.g., 1234567890123456', + onChanged: (value) { + controller.nikController.text = value; + controller.nikError.value = ''; + }, + ), ), - ), + ], // Bio field Obx( @@ -359,6 +368,689 @@ class FormRegistrationScreen extends StatelessWidget { }, ), ), + + // ID Card Upload Section + const SizedBox(height: TSizes.spaceBtwSections), + Text( + '$idCardType Verification', + style: TextStyle( + fontSize: TSizes.fontSizeLg, + fontWeight: FontWeight.bold, + color: TColors.textPrimary, + ), + ), + const SizedBox(height: TSizes.sm), + Text( + 'Upload your $idCardType for verification', + style: TextStyle( + fontSize: TSizes.fontSizeSm, + color: TColors.textSecondary, + ), + ), + const SizedBox(height: TSizes.xs), + Text( + 'Make sure your ID card is clearly visible and all information is readable', + style: TextStyle( + fontSize: TSizes.fontSizeXs, + fontStyle: FontStyle.italic, + color: TColors.textSecondary, + ), + ), + const SizedBox(height: TSizes.spaceBtwItems), + + // ID Card Upload Widget + Obx(() => _buildIdCardUploader(controller, isOfficer)), + + // Verification Status + Obx( + () => + controller.isVerifying.value + ? const Padding( + padding: EdgeInsets.symmetric( + vertical: TSizes.spaceBtwItems, + ), + child: Center( + child: Column( + children: [ + CircularProgressIndicator(), + SizedBox(height: TSizes.sm), + Text('Processing your ID card...'), + ], + ), + ), + ) + : const SizedBox.shrink(), + ), + + // Verification Message + Obx( + () => + controller.verificationMessage.value.isNotEmpty + ? Padding( + padding: const EdgeInsets.symmetric( + vertical: TSizes.spaceBtwItems, + ), + child: Container( + padding: const EdgeInsets.all(TSizes.md), + decoration: BoxDecoration( + color: + controller.isVerified.value + ? Colors.green.withOpacity(0.1) + : Colors.red.withOpacity(0.1), + borderRadius: BorderRadius.circular( + TSizes.borderRadiusSm, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + controller.isVerified.value + ? Icons.check_circle + : Icons.error, + color: + controller.isVerified.value + ? Colors.green + : Colors.red, + ), + ), + const SizedBox(width: TSizes.sm), + Expanded( + child: Text( + controller.verificationMessage.value, + style: TextStyle( + color: + controller.isVerified.value + ? Colors.green + : Colors.red, + ), + ), + ), + ], + ), + ), + ) + : const SizedBox.shrink(), + ), + + // Selfie and Face Verification Section + if (controller.isVerified.value) ...[ + const SizedBox(height: TSizes.spaceBtwSections), + Text( + 'Face Verification', + style: TextStyle( + fontSize: TSizes.fontSizeLg, + fontWeight: FontWeight.bold, + color: TColors.textPrimary, + ), + ), + const SizedBox(height: TSizes.sm), + Text( + 'Take a selfie for identity verification', + style: TextStyle( + fontSize: TSizes.fontSizeSm, + color: TColors.textSecondary, + ), + ), + const SizedBox(height: TSizes.xs), + Text( + 'Make sure your face is clearly visible and well-lit', + style: TextStyle( + fontSize: TSizes.fontSizeXs, + fontStyle: FontStyle.italic, + color: TColors.textSecondary, + ), + ), + const SizedBox(height: TSizes.spaceBtwItems), + + // Selfie Upload Widget + Obx(() => _buildSelfieUploader(controller)), + + // Liveness Check Status + Obx( + () => + controller.isPerformingLivenessCheck.value + ? const Padding( + padding: EdgeInsets.symmetric( + vertical: TSizes.spaceBtwItems, + ), + child: Center( + child: Column( + children: [ + CircularProgressIndicator(), + SizedBox(height: TSizes.sm), + Text('Performing liveness check...'), + ], + ), + ), + ) + : const SizedBox.shrink(), + ), + + // Face Verification Status + Obx( + () => + controller.isVerifyingFace.value + ? const Padding( + padding: EdgeInsets.symmetric( + vertical: TSizes.spaceBtwItems, + ), + child: Center( + child: Column( + children: [ + CircularProgressIndicator(), + SizedBox(height: TSizes.sm), + Text('Verifying your face...'), + ], + ), + ), + ) + : const SizedBox.shrink(), + ), + + // Face Verification Message + Obx( + () => + controller.faceVerificationMessage.value.isNotEmpty + ? Padding( + padding: const EdgeInsets.symmetric( + vertical: TSizes.spaceBtwItems, + ), + child: Container( + padding: const EdgeInsets.all(TSizes.md), + decoration: BoxDecoration( + color: + (controller.isFaceVerified.value || + controller.isLivenessCheckPassed.value) + ? Colors.green.withOpacity(0.1) + : Colors.red.withOpacity(0.1), + borderRadius: BorderRadius.circular( + TSizes.borderRadiusSm, + ), + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Padding( + padding: const EdgeInsets.only(top: 2), + child: Icon( + (controller.isFaceVerified.value || + controller + .isLivenessCheckPassed + .value) + ? Icons.check_circle + : Icons.error, + color: + (controller.isFaceVerified.value || + controller + .isLivenessCheckPassed + .value) + ? Colors.green + : Colors.red, + ), + ), + const SizedBox(width: TSizes.sm), + Expanded( + child: Text( + controller.faceVerificationMessage.value, + style: TextStyle( + color: + (controller.isFaceVerified.value || + controller + .isLivenessCheckPassed + .value) + ? Colors.green + : Colors.red, + ), + ), + ), + ], + ), + ), + ) + : const SizedBox.shrink(), + ), + ], + ], + ), + ); + } + + Widget _buildIdCardUploader( + FormRegistrationController controller, + bool isOfficer, + ) { + final String idCardType = isOfficer ? 'KTA' : 'KTP'; + + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (controller.idCardImage.value == null) + GestureDetector( + onTap: () => _showImageSourceDialog(controller, true), + child: Container( + height: 180, + width: double.infinity, + decoration: BoxDecoration( + color: Colors.grey.withOpacity(0.1), + borderRadius: BorderRadius.circular(TSizes.borderRadiusMd), + border: Border.all(color: Colors.grey.withOpacity(0.5)), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.add_a_photo, + size: TSizes.iconLg, + color: Colors.grey, + ), + const SizedBox(height: TSizes.sm), + Text( + 'Upload $idCardType Image', + style: TextStyle( + color: TColors.textSecondary, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: TSizes.xs), + Text( + 'Tap to select an image', + style: TextStyle( + fontSize: TSizes.fontSizeSm, + color: TColors.textSecondary, + ), + ), + ], + ), + ), + ) + else + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + alignment: Alignment.topRight, + children: [ + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + TSizes.borderRadiusMd, + ), + border: Border.all( + color: + controller.idCardError.value.isNotEmpty + ? Colors.red + : Colors.grey.withOpacity(0.5), + width: 2, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular( + TSizes.borderRadiusMd, + ), + child: Image.file( + File(controller.idCardImage.value!.path), + height: 200, + width: double.infinity, + fit: BoxFit.cover, + ), + ), + ), + Positioned( + top: 8, + right: 8, + child: GestureDetector( + onTap: () => controller.clearIdCardImage(), + child: Container( + padding: const EdgeInsets.all(TSizes.xs), + decoration: const BoxDecoration( + color: Colors.black54, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.close, + color: Colors.white, + size: TSizes.iconSm, + ), + ), + ), + ), + ], + ), + const SizedBox(height: TSizes.sm), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + controller.isVerified.value + ? '$idCardType verified successfully' + : 'Image uploaded. Please verify your $idCardType.', + style: TextStyle( + color: + controller.isVerified.value + ? Colors.green + : TColors.textSecondary, + fontSize: TSizes.fontSizeSm, + ), + ), + ), + TextButton( + onPressed: () => _showImageSourceDialog(controller, true), + child: const Text('Change'), + ), + ], + ), + ElevatedButton.icon( + onPressed: + controller.isVerifying.value + ? null + : () => controller.verifyIdCardWithOCR(), + icon: const Icon(Icons.verified_user), + label: Text( + controller.isVerified.value + ? 'Re-verify $idCardType' + : 'Verify $idCardType', + ), + style: ElevatedButton.styleFrom( + backgroundColor: TColors.primary, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 50), + ), + ), + ], + ), + if (controller.idCardError.value.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: TSizes.xs), + child: Text( + controller.idCardError.value, + style: const TextStyle( + color: Colors.red, + fontSize: TSizes.fontSizeSm, + ), + ), + ), + ], + ); + } + + Widget _buildSelfieUploader(FormRegistrationController controller) { + return Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + if (controller.selfieImage.value == null) + GestureDetector( + onTap: () => _showSelfieDialog(controller), + child: Container( + height: 180, + width: double.infinity, + decoration: BoxDecoration( + color: Colors.grey.withOpacity(0.1), + borderRadius: BorderRadius.circular(TSizes.borderRadiusMd), + border: Border.all(color: Colors.grey.withOpacity(0.5)), + ), + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.face, size: TSizes.iconLg, color: Colors.grey), + const SizedBox(height: TSizes.sm), + Text( + 'Take a Selfie', + style: TextStyle( + color: TColors.textSecondary, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: TSizes.xs), + Text( + 'Tap to open camera', + style: TextStyle( + fontSize: TSizes.fontSizeSm, + color: TColors.textSecondary, + ), + ), + ], + ), + ), + ) + else + Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Stack( + alignment: Alignment.topRight, + children: [ + Container( + decoration: BoxDecoration( + borderRadius: BorderRadius.circular( + TSizes.borderRadiusMd, + ), + border: Border.all( + color: + controller.selfieError.value.isNotEmpty + ? Colors.red + : Colors.grey.withOpacity(0.5), + width: 2, + ), + ), + child: ClipRRect( + borderRadius: BorderRadius.circular( + TSizes.borderRadiusMd, + ), + child: Image.file( + File(controller.selfieImage.value!.path), + height: 200, + width: double.infinity, + fit: BoxFit.cover, + ), + ), + ), + Positioned( + top: 8, + right: 8, + child: GestureDetector( + onTap: () => controller.clearSelfieImage(), + child: Container( + padding: const EdgeInsets.all(TSizes.xs), + decoration: const BoxDecoration( + color: Colors.black54, + shape: BoxShape.circle, + ), + child: const Icon( + Icons.close, + color: Colors.white, + size: TSizes.iconSm, + ), + ), + ), + ), + ], + ), + const SizedBox(height: TSizes.sm), + Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Expanded( + child: Text( + controller.isLivenessCheckPassed.value + ? 'Liveness check passed!' + : 'Selfie captured. Complete liveness check.', + style: TextStyle( + color: + controller.isLivenessCheckPassed.value + ? Colors.green + : TColors.textSecondary, + fontSize: TSizes.fontSizeSm, + ), + ), + ), + TextButton( + onPressed: () => _showSelfieDialog(controller), + child: const Text('Retake'), + ), + ], + ), + // Liveness check button + if (!controller.isLivenessCheckPassed.value) + ElevatedButton.icon( + onPressed: + controller.isPerformingLivenessCheck.value + ? null + : () => controller.performLivenessCheck(), + icon: const Icon(Icons.security), + label: const Text('Perform Liveness Check'), + style: ElevatedButton.styleFrom( + backgroundColor: Colors.blue, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 50), + ), + ), + + // Face verification button (only shown after liveness check passes) + if (controller.isLivenessCheckPassed.value) + ElevatedButton.icon( + onPressed: + controller.isVerifyingFace.value || + controller.isFaceVerified.value + ? null + : () => controller.verifyFaceMatch(), + icon: const Icon(Icons.face_retouching_natural), + label: Text( + controller.isFaceVerified.value + ? 'Face Verified Successfully' + : 'Verify Face Match', + ), + style: ElevatedButton.styleFrom( + backgroundColor: + controller.isFaceVerified.value + ? Colors.green + : TColors.primary, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 50), + disabledBackgroundColor: + controller.isFaceVerified.value + ? Colors.green.withOpacity(0.7) + : null, + ), + ), + ], + ), + if (controller.selfieError.value.isNotEmpty) + Padding( + padding: const EdgeInsets.only(top: TSizes.xs), + child: Text( + controller.selfieError.value, + style: const TextStyle( + color: Colors.red, + fontSize: TSizes.fontSizeSm, + ), + ), + ), + ], + ); + } + + void _showImageSourceDialog( + FormRegistrationController controller, + bool isIdCard, + ) { + final String title = + isIdCard + ? 'Select ${controller.selectedRole.value?.isOfficer ?? false ? "KTA" : "KTP"} Image Source' + : 'Select Selfie Source'; + + final String message = + isIdCard + ? 'Please ensure your ID card is clear, well-lit, and all text is readable' + : 'Please ensure your face is clearly visible and well-lit'; + + Get.dialog( + Dialog( + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(TSizes.borderRadiusMd), + ), + child: Padding( + padding: const EdgeInsets.all(TSizes.defaultSpace), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + title, + style: TextStyle( + fontSize: TSizes.fontSizeLg, + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: TSizes.md), + Text( + message, + textAlign: TextAlign.center, + style: TextStyle( + fontSize: TSizes.fontSizeSm, + color: TColors.textSecondary, + ), + ), + const SizedBox(height: TSizes.spaceBtwItems), + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + _buildImageSourceOption( + icon: Icons.camera_alt, + label: 'Camera', + onTap: () { + if (isIdCard) { + controller.pickIdCardImage(ImageSource.camera); + } else { + controller.pickSelfieImage(ImageSource.camera, true); + } + Get.back(); + }, + ), + if (isIdCard) // Only show gallery option for ID card + _buildImageSourceOption( + icon: Icons.image, + label: 'Gallery', + onTap: () { + controller.pickIdCardImage(ImageSource.gallery); + Get.back(); + }, + ), + ], + ), + ], + ), + ), + ), + ); + } + + void _showSelfieDialog(FormRegistrationController controller) { + // For selfie, we only use camera with liveness check + controller.pickSelfieImage(ImageSource.camera, true); + } + + Widget _buildImageSourceOption({ + required IconData icon, + required String label, + required VoidCallback onTap, + }) { + return GestureDetector( + onTap: onTap, + child: Column( + children: [ + Container( + padding: const EdgeInsets.all(TSizes.md), + decoration: BoxDecoration( + color: TColors.primary.withOpacity(0.1), + shape: BoxShape.circle, + ), + child: Icon(icon, color: TColors.primary, size: TSizes.iconLg), + ), + const SizedBox(height: TSizes.sm), + Text(label), ], ), ); diff --git a/sigap-mobile/pubspec.yaml b/sigap-mobile/pubspec.yaml index 4fdf4f6..bff421e 100644 --- a/sigap-mobile/pubspec.yaml +++ b/sigap-mobile/pubspec.yaml @@ -58,7 +58,7 @@ dependencies: # --- Input & Forms --- flutter_otp_text_field: - image_picker: + image_picker: file_picker: # --- Storage & Filesystem ---