feat: Enhance selfie verification process with ID card comparison
- Added functionality to compare the uploaded selfie with the ID card photo. - Introduced new state variables in SelfieVerificationController to manage face comparison status and confidence levels. - Implemented face detection and ID comparison logic in the selfie verification workflow. - Updated UI components in SelfieVerificationStep to display face match results and provide retry options. - Refactored IdentityVerificationStep to utilize new widget structure for better organization. - Created separate widgets for identity verification fields and face verification section for improved readability and maintainability. - Updated API endpoints for Azure Face API to use the latest version. - Removed deprecated validation button from ImageUploader widget. - Added navigation utility class for better route management.
This commit is contained in:
parent
1908318769
commit
d9fffff68d
|
@ -41,4 +41,6 @@ NODE_ENV=development
|
|||
|
||||
# Azure AI API
|
||||
AZURE_RESOURCE_NAME="sigap"
|
||||
AZURE_FACE_RESOURCE_NAME="verify-face"
|
||||
AZURE_SUBSCRIPTION_KEY="ANeYAEr78MF7HzCEDg53DEHfKZJg19raPeJCubNEZP2tXGD6xREgJQQJ99BEAC3pKaRXJ3w3AAAFACOGAwA9"
|
||||
AZURE_FACE_SUBSCRIPTION_KEY="6pBJKuYEFWHkrCBaZh8hErDci6ZwYnG0tEaE3VA34P8XPAYj4ZvOJQQJ99BEACqBBLyXJ3w3AAAKACOGYqeW"
|
|
@ -1,7 +1,6 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_dotenv/flutter_dotenv.dart';
|
||||
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
||||
import 'package:get_storage/get_storage.dart';
|
||||
import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart';
|
||||
import 'package:sigap/app.dart';
|
||||
|
@ -15,7 +14,7 @@ Future<void> main() async {
|
|||
const SystemUiOverlayStyle(statusBarColor: Colors.transparent),
|
||||
);
|
||||
|
||||
FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
|
||||
// FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
|
||||
|
||||
// Load environment variables from the .env file
|
||||
await dotenv.load(fileName: ".env");
|
||||
|
|
|
@ -3,7 +3,6 @@ import 'dart:io';
|
|||
|
||||
import 'package:dio/dio.dart';
|
||||
import 'package:image_picker/image_picker.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
import 'package:sigap/src/features/daily-ops/data/models/models/kta_model.dart';
|
||||
import 'package:sigap/src/features/personalization/data/models/models/ktp_model.dart';
|
||||
import 'package:sigap/src/utils/constants/api_urls.dart';
|
||||
|
@ -17,6 +16,9 @@ class AzureOCRService {
|
|||
final String faceApiPath = Endpoints.faceApiPath;
|
||||
final String faceVerifyPath = Endpoints.faceVerifyPath;
|
||||
|
||||
bool isValidKtp = false;
|
||||
bool isValidKta = false;
|
||||
|
||||
// Process an ID card image and extract relevant information
|
||||
Future<Map<String, String>> processIdCard(
|
||||
XFile imageFile,
|
||||
|
@ -57,10 +59,8 @@ class AzureOCRService {
|
|||
// Poll for results
|
||||
final ocrResult = await _pollForOcrResults(operationLocation);
|
||||
|
||||
// Debug: LoggerLogger().i extracted content to help troubleshoot
|
||||
Logger().i(
|
||||
'Full extracted text: ${ocrResult['analyzeResult']['content']}',
|
||||
);
|
||||
// Debug: print extracted content to help troubleshoot
|
||||
print('Full extracted text: ${ocrResult['analyzeResult']['content']}');
|
||||
|
||||
// Parse the extracted information based on document type
|
||||
return isOfficer
|
||||
|
@ -72,7 +72,7 @@ class AzureOCRService {
|
|||
);
|
||||
}
|
||||
} catch (e) {
|
||||
Logger().i('OCR processing error: $e');
|
||||
print('OCR processing error: $e');
|
||||
throw Exception('OCR processing error: $e');
|
||||
}
|
||||
}
|
||||
|
@ -139,10 +139,10 @@ class AzureOCRService {
|
|||
final List<String> allLines = _getAllTextLinesFromReadAPI(ocrResult);
|
||||
final String fullText = _getFullText(ocrResult);
|
||||
|
||||
// LoggerLogger().i raw extraction for debugging
|
||||
Logger().i('Extracted ${allLines.length} lines from KTP');
|
||||
// print raw extraction for debugging
|
||||
print('Extracted ${allLines.length} lines from KTP');
|
||||
for (int i = 0; i < allLines.length; i++) {
|
||||
Logger().i('Line $i: ${allLines[i]}');
|
||||
print('Line $i: ${allLines[i]}');
|
||||
}
|
||||
|
||||
// Extract NIK using various methods (label-based, regex patterns)
|
||||
|
@ -187,8 +187,8 @@ class AzureOCRService {
|
|||
// Extract blood type
|
||||
_extractBloodTypeFromKtp(extractedInfo, allLines);
|
||||
|
||||
// LoggerLogger().i extracted information for debugging
|
||||
Logger().i('Extracted KTP info: ${extractedInfo.toString()}');
|
||||
// print extracted information for debugging
|
||||
print('Extracted KTP info: ${extractedInfo.toString()}');
|
||||
|
||||
return extractedInfo;
|
||||
}
|
||||
|
@ -242,42 +242,84 @@ class AzureOCRService {
|
|||
}
|
||||
}
|
||||
|
||||
// Extract name from KTP
|
||||
// Extract name from KTP - Fixed to properly separate from "Tempat" text
|
||||
void _extractNameFromKtp(
|
||||
Map<String, String> extractedInfo,
|
||||
List<String> allLines,
|
||||
String fullText,
|
||||
) {
|
||||
// Look specifically for lines with "Nama" followed by data
|
||||
for (int i = 0; i < allLines.length; i++) {
|
||||
String line = allLines[i].toLowerCase();
|
||||
if (line.contains('nama') && !line.contains('agama')) {
|
||||
// Check same line after colon
|
||||
if (line.contains(':')) {
|
||||
String name = line.split(':')[1].trim();
|
||||
if (name.isNotEmpty) {
|
||||
extractedInfo['nama'] = _normalizeCase(name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
// Check next line
|
||||
String line = allLines[i].toLowerCase().trim();
|
||||
|
||||
// Check if line contains only 'nama' or 'nama:'
|
||||
if (line == 'nama' || line == 'nama:') {
|
||||
// Name should be on the next line (typical KTP format)
|
||||
if (i + 1 < allLines.length) {
|
||||
String nextLine = allLines[i + 1].trim();
|
||||
if (nextLine.isNotEmpty && !nextLine.toLowerCase().contains(':')) {
|
||||
if (nextLine.startsWith(':')) {
|
||||
nextLine = nextLine.substring(1).trim();
|
||||
}
|
||||
|
||||
// Make sure we don't include "Tempat/Tgl Lahir" in the name
|
||||
if (nextLine.toLowerCase().contains('tempat') ||
|
||||
nextLine.toLowerCase().contains('tgl lahir')) {
|
||||
// The name might be split before "Tempat"
|
||||
int tempIndex = nextLine.toLowerCase().indexOf('tempat');
|
||||
if (tempIndex > 0) {
|
||||
nextLine = nextLine.substring(0, tempIndex).trim();
|
||||
} else {
|
||||
// If "Tempat" is at the beginning, this isn't a name
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
if (nextLine.isNotEmpty) {
|
||||
extractedInfo['nama'] = _normalizeCase(nextLine);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} else if (line.startsWith('nama:') || line.startsWith('nama :')) {
|
||||
// Name is on same line after colon
|
||||
String name = line.substring(line.indexOf(':') + 1).trim();
|
||||
if (name.isNotEmpty) {
|
||||
extractedInfo['nama'] = _normalizeCase(name);
|
||||
return;
|
||||
}
|
||||
} else if (line.contains('nama') && line.contains(':')) {
|
||||
// Handle format "Nama: JOHN DOE"
|
||||
String name = line.split(':')[1].trim();
|
||||
if (name.isNotEmpty) {
|
||||
extractedInfo['nama'] = _normalizeCase(name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Try to find name pattern in entire text
|
||||
RegExp nameRegex = RegExp(
|
||||
r'nama\s*:\s*([A-Za-z\s]+)',
|
||||
caseSensitive: false,
|
||||
);
|
||||
// Try looking for "Nama:" with name directly on same line
|
||||
for (int i = 0; i < allLines.length; i++) {
|
||||
String line = allLines[i];
|
||||
if (line.contains(':') &&
|
||||
(line.toLowerCase().startsWith('nama') ||
|
||||
line.toLowerCase().contains('nama:'))) {
|
||||
String name = line.split(':')[1].trim();
|
||||
if (name.isNotEmpty) {
|
||||
extractedInfo['nama'] = _normalizeCase(name);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Last attempt - direct regex search
|
||||
RegExp nameRegex = RegExp(r'nama\s*:\s*([^\n:]+)', caseSensitive: false);
|
||||
var nameMatch = nameRegex.firstMatch(fullText);
|
||||
if (nameMatch != null && nameMatch.groupCount >= 1) {
|
||||
extractedInfo['nama'] = _normalizeCase(nameMatch.group(1)!.trim());
|
||||
String name = nameMatch.group(1)!.trim();
|
||||
// Make sure it's not containing other fields
|
||||
if (!name.toLowerCase().contains('tempat') &&
|
||||
!name.toLowerCase().contains('lahir')) {
|
||||
extractedInfo['nama'] = _normalizeCase(name);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -323,12 +365,21 @@ class AzureOCRService {
|
|||
|
||||
// Process birth information text
|
||||
void _processBirthInfo(Map<String, String> extractedInfo, String birthInfo) {
|
||||
// Clean up the input - sometimes there's a leading colon
|
||||
if (birthInfo.startsWith(':')) {
|
||||
birthInfo = birthInfo.substring(1).trim();
|
||||
}
|
||||
|
||||
// Check if contains comma (indicating place, date format)
|
||||
if (birthInfo.contains(',')) {
|
||||
List<String> parts = birthInfo.split(',');
|
||||
if (parts.isNotEmpty) {
|
||||
extractedInfo['birth_place'] = _normalizeCase(parts[0].trim());
|
||||
extractedInfo['birthPlace'] = _normalizeCase(parts[0].trim());
|
||||
String place = parts[0].trim();
|
||||
// Remove any leading colons that might have been included
|
||||
if (place.startsWith(':')) {
|
||||
place = place.substring(1).trim();
|
||||
}
|
||||
extractedInfo['tempat_lahir'] = _normalizeCase(place);
|
||||
}
|
||||
|
||||
if (parts.length > 1) {
|
||||
|
@ -336,7 +387,6 @@ class AzureOCRService {
|
|||
RegExp dateRegex = RegExp(r'\d{1,2}[-/\s\.]\d{1,2}[-/\s\.]\d{2,4}');
|
||||
var match = dateRegex.firstMatch(parts[1]);
|
||||
if (match != null) {
|
||||
extractedInfo['birthDate'] = match.group(0)!;
|
||||
extractedInfo['tanggal_lahir'] = match.group(0)!;
|
||||
}
|
||||
}
|
||||
|
@ -347,19 +397,16 @@ class AzureOCRService {
|
|||
|
||||
if (match != null) {
|
||||
String dateStr = match.group(0)!;
|
||||
extractedInfo['birthDate'] = dateStr;
|
||||
extractedInfo['tanggal_lahir'] = dateStr;
|
||||
|
||||
// Extract birth place by removing the date
|
||||
String place = birthInfo.replaceAll(dateStr, '').trim();
|
||||
if (place.isNotEmpty) {
|
||||
extractedInfo['birth_place'] = _normalizeCase(place);
|
||||
extractedInfo['birthPlace'] = _normalizeCase(place);
|
||||
extractedInfo['tempat_lahir'] = _normalizeCase(place);
|
||||
}
|
||||
} else {
|
||||
// If no date is found, consider the entire text as birth place
|
||||
extractedInfo['birth_place'] = _normalizeCase(birthInfo);
|
||||
extractedInfo['birthPlace'] = _normalizeCase(birthInfo);
|
||||
extractedInfo['tempat_lahir'] = _normalizeCase(birthInfo);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -372,8 +419,7 @@ class AzureOCRService {
|
|||
) {
|
||||
for (int i = 0; i < allLines.length; i++) {
|
||||
String line = allLines[i].toLowerCase();
|
||||
if ((line.contains('jenis') && line.contains('kelamin')) ||
|
||||
line.contains('gender')) {
|
||||
if ((line.contains('jenis') && line.contains('kelamin'))) {
|
||||
// Extract from same line if contains colon
|
||||
if (line.contains(':')) {
|
||||
String genderText = line.split(':')[1].trim();
|
||||
|
@ -545,7 +591,6 @@ class AzureOCRService {
|
|||
if ((line.contains('kel') && line.contains('desa')) ||
|
||||
line.contains('kelurahan') ||
|
||||
line.contains('desa')) {
|
||||
|
||||
// Extract from same line if contains colon
|
||||
if (line.contains(':')) {
|
||||
String kelurahan = line.split(':')[1].trim();
|
||||
|
@ -600,11 +645,9 @@ class AzureOCRService {
|
|||
for (int i = 0; i < allLines.length; i++) {
|
||||
String line = allLines[i].toLowerCase();
|
||||
if (line.contains('agama')) {
|
||||
|
||||
// Extract from same line if contains colon
|
||||
if (line.contains(':')) {
|
||||
String religion = line.split(':')[1].trim();
|
||||
extractedInfo['religion'] = _normalizeCase(religion);
|
||||
extractedInfo['agama'] = _normalizeCase(religion);
|
||||
return;
|
||||
}
|
||||
|
@ -613,7 +656,6 @@ class AzureOCRService {
|
|||
if (i + 1 < allLines.length) {
|
||||
String nextLine = allLines[i + 1].trim();
|
||||
if (nextLine.isNotEmpty && !_isLabelLine(nextLine)) {
|
||||
extractedInfo['religion'] = _normalizeCase(nextLine);
|
||||
extractedInfo['agama'] = _normalizeCase(nextLine);
|
||||
return;
|
||||
}
|
||||
|
@ -634,7 +676,7 @@ class AzureOCRService {
|
|||
// Extract from same line if contains colon
|
||||
if (line.contains(':')) {
|
||||
String status = line.split(':')[1].trim();
|
||||
extractedInfo['marital_status'] = _normalizeCase(status);
|
||||
|
||||
extractedInfo['status_perkawinan'] = _normalizeCase(status);
|
||||
return;
|
||||
}
|
||||
|
@ -643,7 +685,6 @@ class AzureOCRService {
|
|||
if (i + 1 < allLines.length) {
|
||||
String nextLine = allLines[i + 1].trim();
|
||||
if (nextLine.isNotEmpty && !_isLabelLine(nextLine)) {
|
||||
extractedInfo['marital_status'] = _normalizeCase(nextLine);
|
||||
extractedInfo['status_perkawinan'] = _normalizeCase(nextLine);
|
||||
return;
|
||||
}
|
||||
|
@ -660,11 +701,9 @@ class AzureOCRService {
|
|||
for (int i = 0; i < allLines.length; i++) {
|
||||
String line = allLines[i].toLowerCase();
|
||||
if (line.contains('pekerjaan')) {
|
||||
|
||||
// Extract from same line if contains colon
|
||||
if (line.contains(':')) {
|
||||
String occupation = line.split(':')[1].trim();
|
||||
extractedInfo['occupation'] = _normalizeCase(occupation);
|
||||
extractedInfo['pekerjaan'] = _normalizeCase(occupation);
|
||||
return;
|
||||
}
|
||||
|
@ -673,7 +712,6 @@ class AzureOCRService {
|
|||
if (i + 1 < allLines.length) {
|
||||
String nextLine = allLines[i + 1].trim();
|
||||
if (nextLine.isNotEmpty && !_isLabelLine(nextLine)) {
|
||||
extractedInfo['occupation'] = _normalizeCase(nextLine);
|
||||
extractedInfo['pekerjaan'] = _normalizeCase(nextLine);
|
||||
return;
|
||||
}
|
||||
|
@ -689,11 +727,11 @@ class AzureOCRService {
|
|||
) {
|
||||
for (int i = 0; i < allLines.length; i++) {
|
||||
String line = allLines[i].toLowerCase();
|
||||
if (line.contains('kewarganegaraan') || line.contains('nationality')) {
|
||||
if (line.contains('kewarganegaraan')) {
|
||||
// Extract from same line if contains colon
|
||||
if (line.contains(':')) {
|
||||
String nationality = line.split(':')[1].trim();
|
||||
extractedInfo['nationality'] = _normalizeCase(nationality);
|
||||
// extractedInfo['nationality'] = _normalizeCase(nationality);
|
||||
extractedInfo['kewarganegaraan'] = _normalizeCase(nationality);
|
||||
return;
|
||||
}
|
||||
|
@ -702,7 +740,7 @@ class AzureOCRService {
|
|||
if (i + 1 < allLines.length) {
|
||||
String nextLine = allLines[i + 1].trim();
|
||||
if (nextLine.isNotEmpty && !_isLabelLine(nextLine)) {
|
||||
extractedInfo['nationality'] = _normalizeCase(nextLine);
|
||||
// extractedInfo['nationality'] = _normalizeCase(nextLine);
|
||||
extractedInfo['kewarganegaraan'] = _normalizeCase(nextLine);
|
||||
return;
|
||||
}
|
||||
|
@ -711,8 +749,8 @@ class AzureOCRService {
|
|||
}
|
||||
|
||||
// Default to WNI if not found explicitly
|
||||
if (!extractedInfo.containsKey('nationality')) {
|
||||
extractedInfo['nationality'] = 'WNI';
|
||||
if (!extractedInfo.containsKey('kewarganegaraan')) {
|
||||
// extractedInfo['nationality'] = 'WNI';
|
||||
extractedInfo['kewarganegaraan'] = 'WNI';
|
||||
}
|
||||
}
|
||||
|
@ -729,7 +767,7 @@ class AzureOCRService {
|
|||
// Extract from same line if contains colon
|
||||
if (line.contains(':')) {
|
||||
String validity = line.split(':')[1].trim();
|
||||
extractedInfo['validity_period'] = _normalizeCase(validity);
|
||||
|
||||
extractedInfo['berlaku_hingga'] = _normalizeCase(validity);
|
||||
return;
|
||||
}
|
||||
|
@ -738,7 +776,6 @@ class AzureOCRService {
|
|||
if (i + 1 < allLines.length) {
|
||||
String nextLine = allLines[i + 1].trim();
|
||||
if (nextLine.isNotEmpty && !_isLabelLine(nextLine)) {
|
||||
extractedInfo['validity_period'] = _normalizeCase(nextLine);
|
||||
extractedInfo['berlaku_hingga'] = _normalizeCase(nextLine);
|
||||
return;
|
||||
}
|
||||
|
@ -754,12 +791,11 @@ class AzureOCRService {
|
|||
) {
|
||||
for (int i = 0; i < allLines.length; i++) {
|
||||
String line = allLines[i].toLowerCase();
|
||||
if ((line.contains('gol') && line.contains('darah')) ||
|
||||
line.contains('blood')) {
|
||||
if ((line.contains('gol') && line.contains('darah'))) {
|
||||
// Extract from same line if contains colon
|
||||
if (line.contains(':')) {
|
||||
String bloodType = line.split(':')[1].trim();
|
||||
extractedInfo['blood_type'] = bloodType.toUpperCase();
|
||||
extractedInfo['Golongan_darah'] = bloodType.toUpperCase();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -767,7 +803,7 @@ class AzureOCRService {
|
|||
RegExp bloodTypeRegex = RegExp(r'([ABO]|AB)[-+]?');
|
||||
var match = bloodTypeRegex.firstMatch(line);
|
||||
if (match != null) {
|
||||
extractedInfo['blood_type'] = match.group(0)!.toUpperCase();
|
||||
extractedInfo['Golongan_darah'] = match.group(0)!.toUpperCase();
|
||||
return;
|
||||
}
|
||||
|
||||
|
@ -775,7 +811,7 @@ class AzureOCRService {
|
|||
if (i + 1 < allLines.length) {
|
||||
String nextLine = allLines[i + 1].trim();
|
||||
if (nextLine.isNotEmpty && !_isLabelLine(nextLine)) {
|
||||
extractedInfo['blood_type'] = nextLine.toUpperCase();
|
||||
extractedInfo['Golongan_darah'] = nextLine.toUpperCase();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
@ -789,10 +825,10 @@ class AzureOCRService {
|
|||
final List<String> allLines = _getAllTextLinesFromReadAPI(ocrResult);
|
||||
final String fullText = _getFullText(ocrResult);
|
||||
|
||||
// LoggerLogger().i raw extraction for debugging
|
||||
Logger().i('Extracted ${allLines.length} lines from KTA');
|
||||
// print raw extraction for debugging
|
||||
print('Extracted ${allLines.length} lines from KTA');
|
||||
for (int i = 0; i < allLines.length; i++) {
|
||||
Logger().i('Line $i: ${allLines[i]}');
|
||||
print('Line $i: ${allLines[i]}');
|
||||
}
|
||||
|
||||
// Extract officer name
|
||||
|
@ -813,8 +849,8 @@ class AzureOCRService {
|
|||
// Extract issue date (if available)
|
||||
_extractIssueDateFromKta(extractedInfo, allLines);
|
||||
|
||||
// LoggerLogger().i extracted information for debugging
|
||||
Logger().i('Extracted KTA info: ${extractedInfo.toString()}');
|
||||
// print extracted information for debugging
|
||||
print('Extracted KTA info: ${extractedInfo.toString()}');
|
||||
|
||||
return extractedInfo;
|
||||
}
|
||||
|
@ -1095,7 +1131,7 @@ class AzureOCRService {
|
|||
}
|
||||
return '';
|
||||
} catch (e) {
|
||||
Logger().i('Error getting full text: $e');
|
||||
print('Error getting full text: $e');
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
@ -1105,20 +1141,20 @@ class AzureOCRService {
|
|||
final List<String> allText = [];
|
||||
|
||||
try {
|
||||
// LoggerLogger().i raw structure for debugging
|
||||
Logger().i('OCR Result structure: ${ocrResult.keys}');
|
||||
// print raw structure for debugging
|
||||
print('OCR Result structure: ${ocrResult.keys}');
|
||||
|
||||
// Check if the response format uses readResults (v3.2 API)
|
||||
if (ocrResult.containsKey('analyzeResult') &&
|
||||
ocrResult['analyzeResult'].containsKey('readResults')) {
|
||||
final List<dynamic> readResults =
|
||||
ocrResult['analyzeResult']['readResults'];
|
||||
Logger().i('Found ReadResults format with ${readResults.length} pages');
|
||||
print('Found ReadResults format with ${readResults.length} pages');
|
||||
|
||||
for (var page in readResults) {
|
||||
if (page.containsKey('lines')) {
|
||||
final List<dynamic> lines = page['lines'];
|
||||
Logger().i('Found ${lines.length} lines in page');
|
||||
print('Found ${lines.length} lines in page');
|
||||
|
||||
for (var line in lines) {
|
||||
if (line.containsKey('text')) {
|
||||
|
@ -1132,7 +1168,7 @@ class AzureOCRService {
|
|||
else if (ocrResult.containsKey('analyzeResult') &&
|
||||
ocrResult['analyzeResult'].containsKey('pages')) {
|
||||
final List<dynamic> pages = ocrResult['analyzeResult']['pages'];
|
||||
Logger().i('Found Pages format with ${pages.length} pages');
|
||||
print('Found Pages format with ${pages.length} pages');
|
||||
|
||||
for (var page in pages) {
|
||||
if (page.containsKey('lines')) {
|
||||
|
@ -1149,9 +1185,7 @@ class AzureOCRService {
|
|||
ocrResult['analyzeResult'].containsKey('paragraphs')) {
|
||||
final List<dynamic> paragraphs =
|
||||
ocrResult['analyzeResult']['paragraphs'];
|
||||
Logger().i(
|
||||
'Found Paragraphs format with ${paragraphs.length} paragraphs',
|
||||
);
|
||||
print('Found Paragraphs format with ${paragraphs.length} paragraphs');
|
||||
|
||||
for (var paragraph in paragraphs) {
|
||||
if (paragraph.containsKey('content')) {
|
||||
|
@ -1159,15 +1193,15 @@ class AzureOCRService {
|
|||
}
|
||||
}
|
||||
} else {
|
||||
Logger().i(
|
||||
print(
|
||||
'Unrecognized OCR result format. Keys available: ${ocrResult.keys}',
|
||||
);
|
||||
if (ocrResult.containsKey('analyzeResult')) {
|
||||
Logger().i('AnalyzeResult keys: ${ocrResult['analyzeResult'].keys}');
|
||||
print('AnalyzeResult keys: ${ocrResult['analyzeResult'].keys}');
|
||||
}
|
||||
}
|
||||
|
||||
Logger().i('Extracted ${allText.length} text lines');
|
||||
print('Extracted ${allText.length} text lines');
|
||||
|
||||
// As a fallback, if no lines were extracted and there's a content field,
|
||||
// split content by newlines
|
||||
|
@ -1176,11 +1210,11 @@ class AzureOCRService {
|
|||
ocrResult['analyzeResult'].containsKey('content')) {
|
||||
String content = ocrResult['analyzeResult']['content'];
|
||||
allText.addAll(content.split('\n'));
|
||||
Logger().i('Used content fallback, extracted ${allText.length} lines');
|
||||
print('Used content fallback, extracted ${allText.length} lines');
|
||||
}
|
||||
} catch (e) {
|
||||
Logger().i('Error extracting text from OCR result: $e');
|
||||
Logger().i('OCR Result structure that caused error: ${ocrResult.keys}');
|
||||
print('Error extracting text from OCR result: $e');
|
||||
print('OCR Result structure that caused error: ${ocrResult.keys}');
|
||||
}
|
||||
|
||||
return allText;
|
||||
|
@ -1212,21 +1246,15 @@ class AzureOCRService {
|
|||
return KtpModel(
|
||||
nik: extractedInfo['nik'] ?? '',
|
||||
name: extractedInfo['nama'] ?? '',
|
||||
birthPlace:
|
||||
extractedInfo['birthPlace'] ?? extractedInfo['birth_place'] ?? '',
|
||||
birthDate:
|
||||
extractedInfo['birthDate'] ?? extractedInfo['tanggal_lahir'] ?? '',
|
||||
gender: extractedInfo['gender'] ?? extractedInfo['jenis_kelamin'] ?? '',
|
||||
address: extractedInfo['alamat'] ?? extractedInfo['address'] ?? '',
|
||||
nationality:
|
||||
extractedInfo['nationality'] ??
|
||||
extractedInfo['kewarganegaraan'] ??
|
||||
'WNI',
|
||||
religion: extractedInfo['religion'] ?? extractedInfo['agama'],
|
||||
occupation: extractedInfo['occupation'] ?? extractedInfo['pekerjaan'],
|
||||
maritalStatus:
|
||||
extractedInfo['marital_status'] ?? extractedInfo['status_perkawinan'],
|
||||
bloodType: extractedInfo['blood_type'],
|
||||
birthPlace: extractedInfo['tempat_lahir'] ?? '',
|
||||
birthDate: extractedInfo['tanggal_lahir'] ?? '',
|
||||
gender: extractedInfo['jenis_kelamin'] ?? '',
|
||||
address: extractedInfo['alamat'] ?? '',
|
||||
nationality: extractedInfo['kewarganegaraan'] ?? '',
|
||||
religion: extractedInfo['agama'],
|
||||
occupation: extractedInfo['pekerjaan'],
|
||||
maritalStatus: extractedInfo['status_perkawinan'],
|
||||
bloodType: extractedInfo['Golongan_darah'],
|
||||
rtRw: extractedInfo['rt_rw'],
|
||||
kelurahan: extractedInfo['kelurahan'],
|
||||
kecamatan: extractedInfo['kecamatan'],
|
||||
|
@ -1250,6 +1278,164 @@ class AzureOCRService {
|
|||
);
|
||||
}
|
||||
|
||||
// Check if KTP has all required fields
|
||||
bool isKtpValid(Map<String, String> extractedInfo) {
|
||||
// Required fields for KTP validation
|
||||
final requiredFields = ['nik', 'nama'];
|
||||
|
||||
// Check that all required fields are present and not empty
|
||||
for (var field in requiredFields) {
|
||||
if (!extractedInfo.containsKey(field) ||
|
||||
extractedInfo[field]?.isEmpty == true) {
|
||||
print('KTP validation failed: missing required field $field');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// The NIK should be numeric and exactly 16 digits
|
||||
final nik = extractedInfo['nik'] ?? '';
|
||||
if (nik.length != 16 || int.tryParse(nik) == null) {
|
||||
print('KTP validation failed: NIK should be 16 digits');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Additional field validation - full name should be at least 3 characters
|
||||
final name = extractedInfo['nama'] ?? '';
|
||||
if (name.length < 3) {
|
||||
print('KTP validation failed: name should be at least 3 characters');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Birth place and date should be valid
|
||||
// final birthPlace = extractedInfo['tempat_lahir'] ?? '';
|
||||
// final birthDate = extractedInfo['tanggal_lahir'] ?? '';
|
||||
// if (birthPlace.isEmpty || birthDate.isEmpty) {
|
||||
// print('KTP validation failed: missing birth place or date');
|
||||
// return false;
|
||||
// }
|
||||
|
||||
// // Birth date should be in the format dd-mm-yyyy
|
||||
// final dateRegex = RegExp(r'^\d{1,2}-\d{1,2}-\d{2,4}$');
|
||||
// if (!dateRegex.hasMatch(birthDate)) {
|
||||
// print('KTP validation failed: invalid birth date format');
|
||||
// return false;
|
||||
// }
|
||||
|
||||
// // Gender should be either "L" or "P"
|
||||
// final gender = extractedInfo['jenis_kelamin'] ?? '';
|
||||
// if (gender.isEmpty || gender != 'LAKI-LAKI' || gender != 'PEREMPUAN') {
|
||||
// print('KTP validation failed: Missing or invalid gender');
|
||||
// return false;
|
||||
// }
|
||||
|
||||
// // Nationality should be "WNI"
|
||||
// final nationality = extractedInfo['kewarganegaraan'] ?? 'WNI';
|
||||
|
||||
// if (nationality.isEmpty || nationality != 'WNI') {
|
||||
// print('KTP validation failed: Missing or invalid');
|
||||
// return false;
|
||||
// }
|
||||
|
||||
// Validated successfully
|
||||
isValidKtp = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check if KTA has all required fields
|
||||
bool isKtaValid(Map<String, String> extractedInfo) {
|
||||
// Required fields for KTA validation
|
||||
final requiredFields = ['nama', 'nrp', 'unit', 'kesatuan'];
|
||||
|
||||
for (var field in requiredFields) {
|
||||
if (!extractedInfo.containsKey(field) ||
|
||||
extractedInfo[field]?.isEmpty == true) {
|
||||
print('KTP validation failed: missing required field $field');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// We only need either unit or kesatuan, not both
|
||||
bool hasUnitInfo =
|
||||
extractedInfo.containsKey('unit') &&
|
||||
extractedInfo['unit']?.isNotEmpty == true ||
|
||||
extractedInfo.containsKey('kesatuan') &&
|
||||
extractedInfo['kesatuan']?.isNotEmpty == true;
|
||||
|
||||
// Check for name and NRP required fields
|
||||
if (!extractedInfo.containsKey('nama') ||
|
||||
extractedInfo['nama']?.isEmpty == true) {
|
||||
print('KTA validation failed: missing required field nama');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!extractedInfo.containsKey('nrp') ||
|
||||
extractedInfo['nrp']?.isEmpty == true) {
|
||||
print('KTA validation failed: missing required field nrp');
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!hasUnitInfo) {
|
||||
print('KTA validation failed: missing unit information');
|
||||
return false;
|
||||
}
|
||||
|
||||
// NRP should be 6-8 digits
|
||||
final nrp = extractedInfo['nrp'] ?? '';
|
||||
if (nrp.length < 6 || nrp.length > 8 || int.tryParse(nrp) == null) {
|
||||
print('KTA validation failed: NRP should be 6-8 digits');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Additional field validation - name should be at least 3 characters
|
||||
final name = extractedInfo['nama'] ?? '';
|
||||
if (name.length < 3) {
|
||||
print('KTA validation failed: name should be at least 3 characters');
|
||||
return false;
|
||||
}
|
||||
|
||||
// Validated successfully
|
||||
isValidKta = true;
|
||||
return true;
|
||||
}
|
||||
|
||||
// Detect faces in an image and return face IDs (public method for direct use by controllers)
|
||||
Future<List<dynamic>> detectFacesInImage(XFile imageFile) async {
|
||||
try {
|
||||
return await _detectFaces(imageFile);
|
||||
} catch (e) {
|
||||
print('Face detection error: $e');
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// Compare two face IDs to determine if they are the same person (public method)
|
||||
Future<Map<String, dynamic>> compareFaceIds(
|
||||
String faceId1,
|
||||
String faceId2,
|
||||
) async {
|
||||
try {
|
||||
final matchResult = await _compareFaces(faceId1, faceId2);
|
||||
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) {
|
||||
print('Face comparison error: $e');
|
||||
return {
|
||||
'isMatch': false,
|
||||
'confidence': 0.0,
|
||||
'message': 'Face comparison error: ${e.toString()}',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// Process facial verification between ID card and selfie
|
||||
Future<Map<String, dynamic>> verifyFace(
|
||||
XFile idCardImage,
|
||||
|
|
|
@ -1,6 +1,4 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_native_splash/flutter_native_splash.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:get_storage/get_storage.dart';
|
||||
import 'package:logger/logger.dart';
|
||||
|
@ -37,7 +35,7 @@ class AuthenticationRepository extends GetxController {
|
|||
// ---------------------------------------------------------------------------
|
||||
@override
|
||||
void onReady() {
|
||||
FlutterNativeSplash.remove();
|
||||
// FlutterNativeSplash.remove();
|
||||
screenRedirect();
|
||||
}
|
||||
|
||||
|
@ -69,60 +67,59 @@ class AuthenticationRepository extends GetxController {
|
|||
|
||||
/// Updated screenRedirect method to handle onboarding preferences
|
||||
void screenRedirect({UserMetadataModel? arguments}) async {
|
||||
// Use addPostFrameCallback to ensure navigation happens after the build cycle
|
||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
||||
try {
|
||||
final session = _supabase.auth.currentSession;
|
||||
try {
|
||||
final session = _supabase.auth.currentSession;
|
||||
final bool isFirstTime = storage.read('isFirstTime') ?? false;
|
||||
final isEmailVerified = session?.user.emailConfirmedAt != null;
|
||||
final isProfileComplete =
|
||||
session?.user.userMetadata?['profile_status'] == 'complete';
|
||||
|
||||
// Check if user has completed onboarding
|
||||
final bool isFirstTime = storage.read('isFirstTime') ?? false;
|
||||
// Logger().i('isFirstTime screen redirect: $isFirstTime');
|
||||
|
||||
Logger().i('isFirstTime screen redirect: $isFirstTime');
|
||||
|
||||
if (await _locationService.isLocationValidForFeature() == false) {
|
||||
// Location is not valid, navigate to warning screen
|
||||
Get.offAllNamed(AppRoutes.locationWarning);
|
||||
return;
|
||||
}
|
||||
|
||||
if (session != null) {
|
||||
if (session.user.emailConfirmedAt == null) {
|
||||
// User is not verified, go to email verification screen
|
||||
Get.offAllNamed(AppRoutes.emailVerification);
|
||||
} else if (session.user.userMetadata?['profile_status'] ==
|
||||
'incomplete' &&
|
||||
session.user.emailConfirmedAt != null) {
|
||||
// User is incomplete, go to registration form with arguments if provided
|
||||
Get.offAllNamed(AppRoutes.registrationForm);
|
||||
} else {
|
||||
// User is logged in and verified, go to the main app screen
|
||||
Get.offAllNamed(AppRoutes.panicButton);
|
||||
}
|
||||
} else {
|
||||
// Try biometric login first - but only if we're not already in a navigation
|
||||
if (Get.currentRoute != AppRoutes.signIn &&
|
||||
Get.currentRoute != AppRoutes.onboarding) {
|
||||
bool biometricSuccess = await attemptBiometricLogin();
|
||||
if (!biometricSuccess) {
|
||||
// Check if onboarding is completed
|
||||
if (isFirstTime) {
|
||||
// Skip onboarding and go directly to sign in
|
||||
Get.offAllNamed(AppRoutes.signIn);
|
||||
} else {
|
||||
// First time user, show onboarding
|
||||
Get.offAllNamed(AppRoutes.onboarding);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
Logger().e('Error in screenRedirect: $e');
|
||||
// Fallback to sign in screen on error
|
||||
Get.offAll(() => const SignInScreen());
|
||||
// Cek lokasi terlebih dahulu
|
||||
if (await _locationService.isLocationValidForFeature() == false) {
|
||||
_navigateToRoute(AppRoutes.locationWarning);
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
if (session != null) {
|
||||
if (!isEmailVerified) {
|
||||
_navigateToRoute(AppRoutes.emailVerification);
|
||||
} else if (!isProfileComplete && isEmailVerified) {
|
||||
_navigateToRoute(AppRoutes.registrationForm);
|
||||
} else {
|
||||
_navigateToRoute(AppRoutes.panicButton);
|
||||
}
|
||||
} else {
|
||||
await _handleUnauthenticatedUser(isFirstTime);
|
||||
}
|
||||
} catch (e) {
|
||||
Logger().e('Error in screenRedirect: $e');
|
||||
_navigateToRoute(AppRoutes.signIn);
|
||||
}
|
||||
}
|
||||
|
||||
void _navigateToRoute(String routeName) {
|
||||
if (Get.currentRoute != routeName) {
|
||||
Get.offAllNamed(routeName);
|
||||
}
|
||||
}
|
||||
|
||||
// Pisahkan logic untuk user yang belum login
|
||||
Future<void> _handleUnauthenticatedUser(bool isFirstTime) async {
|
||||
if (Get.currentRoute != AppRoutes.signIn &&
|
||||
Get.currentRoute != AppRoutes.onboarding) {
|
||||
bool biometricSuccess = await attemptBiometricLogin();
|
||||
if (!biometricSuccess) {
|
||||
if (isFirstTime) {
|
||||
_navigateToRoute(AppRoutes.signIn);
|
||||
} else {
|
||||
_navigateToRoute(AppRoutes.onboarding);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// EMAIL & PASSWORD AUTHENTICATION
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
@ -50,9 +50,13 @@ class FormRegistrationController extends GetxController {
|
|||
// Officer data
|
||||
final Rx<OfficerModel?> officerModel = Rx<OfficerModel?>(null);
|
||||
|
||||
|
||||
// Loading state
|
||||
final RxBool isLoading = false.obs;
|
||||
|
||||
// Data to be passed between steps
|
||||
final Rx<dynamic> idCardData = Rx<dynamic>(null);
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
|
@ -298,7 +302,7 @@ class FormRegistrationController extends GetxController {
|
|||
case 1:
|
||||
return idCardVerificationController.validate();
|
||||
case 2:
|
||||
return selfieVerificationController.validate(formKey);
|
||||
return selfieVerificationController.validate();
|
||||
case 3:
|
||||
return selectedRole.value?.isOfficer == true
|
||||
? officerInfoController!.validate(formKey)
|
||||
|
@ -312,17 +316,85 @@ class FormRegistrationController extends GetxController {
|
|||
}
|
||||
}
|
||||
|
||||
// Pass extracted data to the next step
|
||||
void passIdCardDataToNextStep() {
|
||||
try {
|
||||
final idCardVerificationController =
|
||||
Get.find<IdCardVerificationController>();
|
||||
|
||||
if (idCardVerificationController.isIdCardValid.value &&
|
||||
idCardVerificationController.hasConfirmedIdCard.value) {
|
||||
// Get the model from the controller
|
||||
idCardData.value = idCardVerificationController.verifiedIdCardModel;
|
||||
}
|
||||
} catch (e) {
|
||||
print('Error passing ID card data: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Go to next step
|
||||
// void nextStep() async {
|
||||
// final isValid = formKey.currentState?.validate() ?? false;
|
||||
// if (isValid) {
|
||||
// // Validate based on the current step
|
||||
// if (currentStep.value == 0) {
|
||||
// // Personal Info Step
|
||||
// personalInfoController.validate();
|
||||
// if (!personalInfoController.isFormValid.value) return;
|
||||
// } else if (currentStep.value == 1) {
|
||||
// // ID Card Verification Step
|
||||
// final idCardController = Get.find<IdCardVerificationController>();
|
||||
// if (!idCardController.validate()) return;
|
||||
|
||||
// // Pass data to next step if validation succeeded
|
||||
// passIdCardDataToNextStep();
|
||||
// } else if (currentStep.value == 2) {
|
||||
// // Selfie Verification Step
|
||||
// final selfieController = Get.find<SelfieVerificationController>();
|
||||
// if (!selfieController.validate()) return;
|
||||
// } else if (currentStep.value == 3) {
|
||||
// if (selectedRole.value!.isOfficer) {
|
||||
// // Officer Info Step
|
||||
// final officerInfoController = Get.find<OfficerInfoController>();
|
||||
// if (!officerInfoController.validate()) return;
|
||||
// } else {
|
||||
// // Identity Verification Step
|
||||
// final identityVerificationController =
|
||||
// Get.find<IdentityVerificationController>();
|
||||
// if (!identityVerificationController.validate()) return;
|
||||
// }
|
||||
// } else if (currentStep.value == 4 && selectedRole.value!.isOfficer) {
|
||||
// // Unit Info Step
|
||||
// final unitInfoController = Get.find<UnitInfoController>();
|
||||
// if (!unitInfoController.validate()) return;
|
||||
// }
|
||||
|
||||
// if (currentStep.value == totalSteps - 1) {
|
||||
// // This is the last step, submit the form
|
||||
// _submitForm();
|
||||
// } else {
|
||||
// // Move to the next step
|
||||
// if (currentStep.value < totalSteps - 1) {
|
||||
// currentStep.value++;
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
void nextStep() {
|
||||
if (!validateCurrentStep()) return;
|
||||
|
||||
if (currentStep.value < totalSteps - 1) {
|
||||
currentStep.value++;
|
||||
|
||||
if (currentStep.value == 1) {
|
||||
// Pass ID card data to the next step
|
||||
passIdCardDataToNextStep();
|
||||
}
|
||||
} else {
|
||||
submitForm();
|
||||
}
|
||||
}
|
||||
|
||||
void clearPreviousStepErrors() {
|
||||
switch (currentStep.value) {
|
||||
case 0:
|
||||
|
|
|
@ -42,6 +42,10 @@ class IdCardVerificationController extends GetxController {
|
|||
final Rx<KtpModel?> ktpModel = Rx<KtpModel?>(null);
|
||||
final Rx<KtaModel?> ktaModel = Rx<KtaModel?>(null);
|
||||
|
||||
// Store face ID from the ID card for later comparison
|
||||
final RxString idCardFaceId = RxString('');
|
||||
final RxBool hasFaceDetected = RxBool(false);
|
||||
|
||||
bool validate() {
|
||||
clearErrors();
|
||||
|
||||
|
@ -62,7 +66,7 @@ class IdCardVerificationController extends GetxController {
|
|||
|
||||
void clearErrors() {
|
||||
idCardError.value = '';
|
||||
idCardValidationMessage.value = '';
|
||||
// idCardValidationMessage.value = '';
|
||||
}
|
||||
|
||||
// Pick ID Card Image with file size validation
|
||||
|
@ -119,6 +123,10 @@ class IdCardVerificationController extends GetxController {
|
|||
ktpModel.value = null;
|
||||
ktaModel.value = null;
|
||||
|
||||
// Reset face detection flags
|
||||
idCardFaceId.value = '';
|
||||
hasFaceDetected.value = false;
|
||||
|
||||
if (idCardImage.value == null) {
|
||||
idCardError.value = 'Please upload an ID card image first';
|
||||
isIdCardValid.value = false;
|
||||
|
@ -143,40 +151,36 @@ class IdCardVerificationController extends GetxController {
|
|||
extractedInfo.assignAll(result);
|
||||
hasExtractedInfo.value = result.isNotEmpty;
|
||||
|
||||
// Create model from extracted data
|
||||
// Check if the extracted information is valid using our validation methods
|
||||
if (isOfficer) {
|
||||
ktaModel.value = KtaModel(
|
||||
name: result['nama'] ?? '',
|
||||
nrp: result['nrp'] ?? '',
|
||||
policeUnit: result['unit'] ?? result['kesatuan'] ?? '',
|
||||
issueDate: result['tanggal_terbit'] ?? '',
|
||||
cardNumber: result['nomor_kartu'] ?? '',
|
||||
extraData: {
|
||||
'pangkat': result['pangkat'] ?? '',
|
||||
'tanggal_lahir': result['tanggal_lahir'] ?? '',
|
||||
},
|
||||
);
|
||||
isImageValid = _ocrService.isKtaValid(result);
|
||||
} else {
|
||||
ktpModel.value = KtpModel(
|
||||
nik: result['nik'] ?? '',
|
||||
name: result['nama'] ?? '',
|
||||
birthPlace: result['birth_place'] ?? result['birthPlace'] ?? '',
|
||||
birthDate: result['tanggal_lahir'] ?? result['birthDate'] ?? '',
|
||||
gender: result['gender'] ?? result['jenis_kelamin'] ?? '',
|
||||
address: result['alamat'] ?? result['address'] ?? '',
|
||||
nationality:
|
||||
result['nationality'] ?? result['kewarganegaraan'] ?? 'WNI',
|
||||
religion: result['religion'] ?? result['agama'],
|
||||
occupation: result['occupation'] ?? result['pekerjaan'],
|
||||
maritalStatus:
|
||||
result['marital_status'] ?? result['status_perkawinan'],
|
||||
);
|
||||
isImageValid = _ocrService.isKtpValid(result);
|
||||
}
|
||||
|
||||
// If we get here without an exception, the image is likely valid
|
||||
isImageValid = result.isNotEmpty;
|
||||
// Create model from extracted data
|
||||
if (isOfficer) {
|
||||
ktaModel.value = _ocrService.createKtaModel(result);
|
||||
} else {
|
||||
ktpModel.value = _ocrService.createKtpModel(result);
|
||||
}
|
||||
|
||||
// Try to detect faces in the ID card image
|
||||
if (isImageValid) {
|
||||
try {
|
||||
final faces = await _ocrService.detectFacesInImage(
|
||||
idCardImage.value!,
|
||||
);
|
||||
if (faces.isNotEmpty) {
|
||||
idCardFaceId.value = faces[0]['faceId'] ?? '';
|
||||
hasFaceDetected.value = idCardFaceId.value.isNotEmpty;
|
||||
print('Face detected in ID card: ${idCardFaceId.value}');
|
||||
}
|
||||
} catch (faceError) {
|
||||
print('Face detection failed: $faceError');
|
||||
// Don't fail validation if face detection fails
|
||||
}
|
||||
|
||||
isIdCardValid.value = true;
|
||||
idCardValidationMessage.value =
|
||||
'$idCardType image looks valid. Please confirm this is your $idCardType.';
|
||||
|
@ -223,6 +227,13 @@ class IdCardVerificationController extends GetxController {
|
|||
}
|
||||
}
|
||||
|
||||
// Get the ID card image path for face comparison
|
||||
String? get idCardImagePath => idCardImage.value?.path;
|
||||
|
||||
// Check if the ID card has a detected face
|
||||
bool get hasDetectedFace =>
|
||||
hasFaceDetected.value && idCardFaceId.value.isNotEmpty;
|
||||
|
||||
// Clear ID Card Image
|
||||
void clearIdCardImage() {
|
||||
idCardImage.value = null;
|
||||
|
@ -242,4 +253,9 @@ class IdCardVerificationController extends GetxController {
|
|||
hasConfirmedIdCard.value = true;
|
||||
}
|
||||
}
|
||||
|
||||
// Get the verified model for passing to the next step
|
||||
dynamic get verifiedIdCardModel {
|
||||
return isOfficer ? ktaModel.value : ktpModel.value;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -1,408 +1,325 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/image_verification_controller.dart';
|
||||
import 'package:sigap/src/utils/validators/validation.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
|
||||
import 'package:sigap/src/features/daily-ops/data/models/models/kta_model.dart';
|
||||
import 'package:sigap/src/features/personalization/data/models/models/ktp_model.dart';
|
||||
// ... other imports
|
||||
|
||||
class IdentityVerificationController extends GetxController {
|
||||
// Singleton instance
|
||||
static IdentityVerificationController get instance => Get.find();
|
||||
|
||||
// Static form key
|
||||
// final GlobalKey<FormState> formKey = TGlobalFormKey.identityVerification();
|
||||
final AzureOCRService _ocrService = AzureOCRService();
|
||||
// Dependencies
|
||||
final bool isOfficer;
|
||||
final RxBool isFormValid = RxBool(true);
|
||||
final AzureOCRService _ocrService = AzureOCRService();
|
||||
|
||||
IdentityVerificationController({required this.isOfficer});
|
||||
// Controllers
|
||||
final TextEditingController nikController = TextEditingController();
|
||||
final TextEditingController fullNameController = TextEditingController();
|
||||
final TextEditingController placeOfBirthController = TextEditingController();
|
||||
final TextEditingController birthDateController = TextEditingController();
|
||||
final TextEditingController addressController = TextEditingController();
|
||||
|
||||
// Reference to image verification controller to access the validated images
|
||||
late ImageVerificationController imageVerificationController;
|
||||
// Error variables
|
||||
final RxString nikError = RxString('');
|
||||
final RxString fullNameError = RxString('');
|
||||
final RxString placeOfBirthError = RxString('');
|
||||
final RxString birthDateError = RxString('');
|
||||
final RxString genderError = RxString('');
|
||||
final RxString addressError = RxString('');
|
||||
|
||||
// Controllers for viewer (non-officer)
|
||||
final nikController = TextEditingController();
|
||||
final bioController = TextEditingController();
|
||||
final birthDateController = TextEditingController();
|
||||
|
||||
// New controllers for KTP fields
|
||||
final fullNameController = TextEditingController();
|
||||
final placeOfBirthController = TextEditingController();
|
||||
final addressController = TextEditingController();
|
||||
final RxString selectedGender = ''.obs;
|
||||
|
||||
// Error states
|
||||
final RxString nikError = ''.obs;
|
||||
final RxString bioError = ''.obs;
|
||||
final RxString birthDateError = ''.obs;
|
||||
|
||||
// New error states for KTP fields
|
||||
final RxString fullNameError = ''.obs;
|
||||
final RxString placeOfBirthError = ''.obs;
|
||||
final RxString genderError = ''.obs;
|
||||
final RxString addressError = ''.obs;
|
||||
|
||||
// OCR verification states
|
||||
// Verification states
|
||||
final RxBool isVerifying = RxBool(false);
|
||||
final RxBool isVerified = RxBool(false);
|
||||
final RxString verificationMessage = RxString('');
|
||||
|
||||
// Face verification states
|
||||
// Face verification
|
||||
final RxBool isVerifyingFace = RxBool(false);
|
||||
final RxBool isFaceVerified = RxBool(false);
|
||||
final RxString faceVerificationMessage = RxString('');
|
||||
|
||||
// Gender selection
|
||||
final Rx<String?> selectedGender = Rx<String?>(null);
|
||||
|
||||
// Form validation
|
||||
final RxBool isFormValid = RxBool(true);
|
||||
|
||||
IdentityVerificationController({required this.isOfficer}) {
|
||||
// Apply data from previous step if available
|
||||
_applyIdCardData();
|
||||
}
|
||||
|
||||
@override
|
||||
void onInit() {
|
||||
super.onInit();
|
||||
_applyIdCardData();
|
||||
}
|
||||
|
||||
// Get reference to the image verification controller
|
||||
// Apply ID card data from the previous step
|
||||
void _applyIdCardData() {
|
||||
try {
|
||||
imageVerificationController = Get.find<ImageVerificationController>();
|
||||
final formController = Get.find<FormRegistrationController>();
|
||||
final idCardData = formController.idCardData.value;
|
||||
|
||||
if (idCardData != null) {
|
||||
// Fill the form with the extracted data
|
||||
if (!isOfficer && idCardData is KtpModel) {
|
||||
KtpModel ktpModel = idCardData;
|
||||
|
||||
if (ktpModel.nik.isNotEmpty) {
|
||||
nikController.text = ktpModel.nik;
|
||||
}
|
||||
|
||||
if (ktpModel.name.isNotEmpty) {
|
||||
fullNameController.text = ktpModel.name;
|
||||
}
|
||||
|
||||
if (ktpModel.birthPlace.isNotEmpty) {
|
||||
placeOfBirthController.text = ktpModel.birthPlace;
|
||||
}
|
||||
|
||||
if (ktpModel.birthDate.isNotEmpty) {
|
||||
birthDateController.text = ktpModel.birthDate;
|
||||
}
|
||||
|
||||
if (ktpModel.gender.isNotEmpty) {
|
||||
// Convert gender to the format expected by the dropdown
|
||||
String gender = ktpModel.gender.toLowerCase();
|
||||
if (gender.contains('laki') || gender == 'male') {
|
||||
selectedGender.value = 'Male';
|
||||
} else if (gender.contains('perempuan') || gender == 'female') {
|
||||
selectedGender.value = 'Female';
|
||||
}
|
||||
}
|
||||
|
||||
if (ktpModel.address.isNotEmpty) {
|
||||
addressController.text = ktpModel.address;
|
||||
}
|
||||
|
||||
// Mark as verified since we have validated KTP data
|
||||
isVerified.value = true;
|
||||
verificationMessage.value = 'KTP information verified successfully';
|
||||
} else if (isOfficer && idCardData is KtaModel) {
|
||||
KtaModel ktaModel = idCardData;
|
||||
|
||||
// For officer, we'd fill in different fields as needed
|
||||
// Since we don't require NIK for officers, no need to set nikController
|
||||
|
||||
if (ktaModel.name.isNotEmpty) {
|
||||
fullNameController.text = ktaModel.name;
|
||||
}
|
||||
|
||||
// If birthDate is available in extra data
|
||||
if (ktaModel.extraData != null &&
|
||||
ktaModel.extraData!.containsKey('tanggal_lahir') &&
|
||||
ktaModel.extraData!['tanggal_lahir'] != null) {
|
||||
birthDateController.text = ktaModel.extraData!['tanggal_lahir'];
|
||||
}
|
||||
|
||||
// Mark as verified
|
||||
isVerified.value = true;
|
||||
verificationMessage.value = 'KTA information verified successfully';
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
// Controller not initialized yet, will retry later
|
||||
print('Error applying ID card data: $e');
|
||||
}
|
||||
}
|
||||
|
||||
// Validate form inputs
|
||||
bool validate(GlobalKey<FormState> formKey) {
|
||||
clearErrors();
|
||||
|
||||
if (formKey.currentState?.validate() ?? false) {
|
||||
// Form validation passed, now check verification status
|
||||
if (!isVerified.value) {
|
||||
verificationMessage.value = 'Please complete ID card verification';
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!isFaceVerified.value) {
|
||||
faceVerificationMessage.value = 'Please complete face verification';
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
isFormValid.value = true;
|
||||
|
||||
// For non-officers, we need to validate NIK and other KTP-related fields
|
||||
if (!isOfficer) {
|
||||
final nikValidation = TValidators.validateUserInput(
|
||||
'NIK',
|
||||
nikController.text,
|
||||
16,
|
||||
);
|
||||
if (nikValidation != null) {
|
||||
nikError.value = nikValidation;
|
||||
if (nikController.text.isEmpty) {
|
||||
nikError.value = 'NIK is required';
|
||||
isFormValid.value = false;
|
||||
} else if (nikController.text.length != 16) {
|
||||
nikError.value = 'NIK must be 16 digits';
|
||||
isFormValid.value = false;
|
||||
}
|
||||
|
||||
// Validate full name
|
||||
final fullNameValidation = TValidators.validateUserInput(
|
||||
'Full Name',
|
||||
fullNameController.text,
|
||||
100,
|
||||
);
|
||||
if (fullNameValidation != null) {
|
||||
fullNameError.value = fullNameValidation;
|
||||
if (fullNameController.text.isEmpty) {
|
||||
fullNameError.value = 'Full name is required';
|
||||
isFormValid.value = false;
|
||||
}
|
||||
|
||||
// Validate place of birth
|
||||
final placeOfBirthValidation = TValidators.validateUserInput(
|
||||
'Place of Birth',
|
||||
placeOfBirthController.text,
|
||||
50,
|
||||
);
|
||||
if (placeOfBirthValidation != null) {
|
||||
placeOfBirthError.value = placeOfBirthValidation;
|
||||
isFormValid.value = false;
|
||||
}
|
||||
|
||||
// Validate gender
|
||||
if (selectedGender.value.isEmpty) {
|
||||
genderError.value = 'Gender is required';
|
||||
isFormValid.value = false;
|
||||
}
|
||||
|
||||
// Validate address
|
||||
final addressValidation = TValidators.validateUserInput(
|
||||
'Address',
|
||||
addressController.text,
|
||||
255,
|
||||
);
|
||||
if (addressValidation != null) {
|
||||
addressError.value = addressValidation;
|
||||
if (placeOfBirthController.text.isEmpty) {
|
||||
placeOfBirthError.value = 'Place of birth is required';
|
||||
isFormValid.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Bio can be optional, so we validate with required: false
|
||||
final bioValidation = TValidators.validateUserInput(
|
||||
'Bio',
|
||||
bioController.text,
|
||||
255,
|
||||
required: false,
|
||||
);
|
||||
if (bioValidation != null) {
|
||||
bioError.value = bioValidation;
|
||||
// These validations apply to both officers and non-officers
|
||||
if (birthDateController.text.isEmpty) {
|
||||
birthDateError.value = 'Birth date is required';
|
||||
isFormValid.value = false;
|
||||
}
|
||||
|
||||
// Birth date validation
|
||||
final birthDateValidation = TValidators.validateUserInput(
|
||||
'Birth Date',
|
||||
birthDateController.text,
|
||||
10,
|
||||
);
|
||||
if (birthDateValidation != null) {
|
||||
birthDateError.value = birthDateValidation;
|
||||
if (selectedGender.value == null) {
|
||||
genderError.value = 'Please select your gender';
|
||||
isFormValid.value = false;
|
||||
}
|
||||
|
||||
return isFormValid.value && isVerified.value && isFaceVerified.value;
|
||||
if (addressController.text.isEmpty) {
|
||||
addressError.value = 'Address is required';
|
||||
isFormValid.value = false;
|
||||
}
|
||||
|
||||
return isFormValid.value;
|
||||
}
|
||||
|
||||
void clearErrors() {
|
||||
nikError.value = '';
|
||||
bioError.value = '';
|
||||
birthDateError.value = '';
|
||||
fullNameError.value = '';
|
||||
placeOfBirthError.value = '';
|
||||
genderError.value = '';
|
||||
addressError.value = '';
|
||||
}
|
||||
|
||||
// Verify ID Card using OCR and compare with entered data
|
||||
Future<void> verifyIdCardWithOCR() async {
|
||||
// Make sure we have reference to the image controller
|
||||
if (!Get.isRegistered<ImageVerificationController>()) {
|
||||
verificationMessage.value = 'Error: Image verification data unavailable';
|
||||
isVerified.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
imageVerificationController = Get.find<ImageVerificationController>();
|
||||
} catch (e) {
|
||||
verificationMessage.value = 'Error: Image verification data unavailable';
|
||||
isVerified.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
final idCardImage = imageVerificationController.idCardImage.value;
|
||||
|
||||
if (idCardImage == null) {
|
||||
verificationMessage.value =
|
||||
'ID card image missing. Please go back and upload it.';
|
||||
isVerified.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!imageVerificationController.isIdCardValid.value ||
|
||||
!imageVerificationController.hasConfirmedIdCard.value) {
|
||||
verificationMessage.value =
|
||||
'ID card image not validated. Please go back and validate it first.';
|
||||
isVerified.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify ID card information against OCR results
|
||||
void verifyIdCardWithOCR() {
|
||||
try {
|
||||
isVerifying.value = true;
|
||||
final idCardType = isOfficer ? 'KTA' : 'KTP';
|
||||
|
||||
// Call Azure OCR service with the appropriate ID type
|
||||
final result = await _ocrService.processIdCard(idCardImage, isOfficer);
|
||||
// Compare form input with OCR results
|
||||
final formController = Get.find<FormRegistrationController>();
|
||||
final idCardData = formController.idCardData.value;
|
||||
|
||||
// Compare OCR results with user input
|
||||
final bool isMatch =
|
||||
isOfficer ? _verifyKtaResults(result) : _verifyKtpResults(result);
|
||||
if (idCardData != null) {
|
||||
if (!isOfficer && idCardData is KtpModel) {
|
||||
// Verify NIK matches
|
||||
bool nikMatches = nikController.text == idCardData.nik;
|
||||
|
||||
isVerified.value = isMatch;
|
||||
verificationMessage.value =
|
||||
isMatch
|
||||
? '$idCardType verification successful! Your information matches with your $idCardType.'
|
||||
: 'Verification failed. Please ensure your entered information matches your $idCardType.';
|
||||
// Verify name is similar (accounting for slight differences in formatting)
|
||||
bool nameMatches = _compareNames(
|
||||
fullNameController.text,
|
||||
idCardData.name,
|
||||
);
|
||||
|
||||
if (nikMatches && nameMatches) {
|
||||
isVerified.value = true;
|
||||
verificationMessage.value =
|
||||
'KTP information verified successfully!';
|
||||
} else {
|
||||
isVerified.value = false;
|
||||
verificationMessage.value =
|
||||
'Information doesn\'t match with KTP. Please check and try again.';
|
||||
}
|
||||
} else if (isOfficer && idCardData is KtaModel) {
|
||||
// For officers, verify that the name matches
|
||||
bool nameMatches = _compareNames(
|
||||
fullNameController.text,
|
||||
idCardData.name,
|
||||
);
|
||||
|
||||
if (nameMatches) {
|
||||
isVerified.value = true;
|
||||
verificationMessage.value =
|
||||
'KTA information verified successfully!';
|
||||
} else {
|
||||
isVerified.value = false;
|
||||
verificationMessage.value =
|
||||
'Information doesn\'t match with KTA. Please check and try again.';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
isVerified.value = false;
|
||||
verificationMessage.value =
|
||||
'No ID card data available from previous step.';
|
||||
}
|
||||
} catch (e) {
|
||||
isVerified.value = false;
|
||||
verificationMessage.value = 'OCR processing failed: ${e.toString()}';
|
||||
verificationMessage.value = 'Error during verification: ${e.toString()}';
|
||||
print('Error in ID card verification: $e');
|
||||
} finally {
|
||||
isVerifying.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Verify selfie with ID card
|
||||
Future<void> verifyFaceMatch() async {
|
||||
// Make sure we have reference to the image controller
|
||||
if (!Get.isRegistered<ImageVerificationController>()) {
|
||||
faceVerificationMessage.value =
|
||||
'Error: Image verification data unavailable';
|
||||
isFaceVerified.value = false;
|
||||
return;
|
||||
// Simple name comparison function (ignores case, spaces)
|
||||
bool _compareNames(String name1, String name2) {
|
||||
// Normalize names for comparison
|
||||
String normalizedName1 = name1.toLowerCase().trim().replaceAll(
|
||||
RegExp(r'\s+'),
|
||||
' ',
|
||||
);
|
||||
String normalizedName2 = name2.toLowerCase().trim().replaceAll(
|
||||
RegExp(r'\s+'),
|
||||
' ',
|
||||
);
|
||||
|
||||
// Check exact match
|
||||
if (normalizedName1 == normalizedName2) return true;
|
||||
|
||||
// Check if one name is contained within the other
|
||||
if (normalizedName1.contains(normalizedName2) ||
|
||||
normalizedName2.contains(normalizedName1))
|
||||
return true;
|
||||
|
||||
// Split names into parts and check for partial matches
|
||||
var parts1 = normalizedName1.split(' ');
|
||||
var parts2 = normalizedName2.split(' ');
|
||||
|
||||
// Count matching name parts
|
||||
int matches = 0;
|
||||
for (var part1 in parts1) {
|
||||
for (var part2 in parts2) {
|
||||
if (part1.length > 2 &&
|
||||
part2.length > 2 &&
|
||||
(part1.contains(part2) || part2.contains(part1))) {
|
||||
matches++;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
imageVerificationController = Get.find<ImageVerificationController>();
|
||||
} catch (e) {
|
||||
faceVerificationMessage.value =
|
||||
'Error: Image verification data unavailable';
|
||||
isFaceVerified.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
final idCardImage = imageVerificationController.idCardImage.value;
|
||||
final selfieImage = imageVerificationController.selfieImage.value;
|
||||
|
||||
if (idCardImage == null) {
|
||||
faceVerificationMessage.value =
|
||||
'ID card image missing. Please go back and upload it.';
|
||||
isFaceVerified.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!imageVerificationController.isIdCardValid.value ||
|
||||
!imageVerificationController.hasConfirmedIdCard.value) {
|
||||
faceVerificationMessage.value =
|
||||
'ID card image not validated. Please go back and validate it first.';
|
||||
isFaceVerified.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (selfieImage == null) {
|
||||
faceVerificationMessage.value =
|
||||
'Selfie image missing. Please go back and take a selfie.';
|
||||
isFaceVerified.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (!imageVerificationController.isSelfieValid.value ||
|
||||
!imageVerificationController.hasConfirmedSelfie.value) {
|
||||
faceVerificationMessage.value =
|
||||
'Selfie not validated. Please go back and validate it first.';
|
||||
isFaceVerified.value = false;
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isVerifyingFace.value = true;
|
||||
|
||||
// Compare face in ID card with selfie face
|
||||
final result = await _ocrService.verifyFace(idCardImage, selfieImage);
|
||||
|
||||
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;
|
||||
}
|
||||
// If more than half of the name parts match, consider it a match
|
||||
return matches >= (parts1.length / 2).floor();
|
||||
}
|
||||
|
||||
// Compare OCR results with user input for KTP
|
||||
bool _verifyKtpResults(Map<String, String> ocrResults) {
|
||||
int matchCount = 0;
|
||||
int totalFields = 0;
|
||||
// Simple face verification function simulation
|
||||
void verifyFaceMatch() {
|
||||
isVerifyingFace.value = true;
|
||||
|
||||
// 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++;
|
||||
// Simulate verification process with a delay
|
||||
Future.delayed(const Duration(seconds: 2), () {
|
||||
try {
|
||||
// In a real implementation, this would call the proper face verification API
|
||||
final formController = Get.find<FormRegistrationController>();
|
||||
final idCardData = formController.idCardData.value;
|
||||
|
||||
if (idCardData != null) {
|
||||
// Simulate successful match for demonstration
|
||||
isFaceVerified.value = true;
|
||||
faceVerificationMessage.value = 'Face verification successful!';
|
||||
} else {
|
||||
isFaceVerified.value = false;
|
||||
faceVerificationMessage.value =
|
||||
'No ID card data available to verify face.';
|
||||
}
|
||||
} catch (e) {
|
||||
isFaceVerified.value = false;
|
||||
faceVerificationMessage.value = 'Error during face verification.';
|
||||
print('Face verification error: $e');
|
||||
} finally {
|
||||
isVerifyingFace.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// 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 full name
|
||||
if (ocrResults.containsKey('nama') && fullNameController.text.isNotEmpty) {
|
||||
totalFields++;
|
||||
// Case-insensitive comparison for names
|
||||
if (ocrResults['nama']!.toLowerCase().trim() ==
|
||||
fullNameController.text.toLowerCase().trim()) {
|
||||
matchCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Check place of birth
|
||||
if (ocrResults.containsKey('tempat_lahir') &&
|
||||
placeOfBirthController.text.isNotEmpty) {
|
||||
totalFields++;
|
||||
// Case-insensitive comparison for place names
|
||||
if (ocrResults['tempat_lahir']!.toLowerCase().trim().contains(
|
||||
placeOfBirthController.text.toLowerCase().trim(),
|
||||
)) {
|
||||
matchCount++;
|
||||
}
|
||||
}
|
||||
|
||||
// Check address (partial match is acceptable for address)
|
||||
if (ocrResults.containsKey('alamat') && addressController.text.isNotEmpty) {
|
||||
totalFields++;
|
||||
// Check if user-entered address is contained within OCR address
|
||||
if (ocrResults['alamat']!.toLowerCase().contains(
|
||||
addressController.text.toLowerCase(),
|
||||
)) {
|
||||
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<String, String> ocrResults) {
|
||||
// Since we're dealing with officer info in a separate step,
|
||||
// this will compare only birthdate and general info, which is minimal
|
||||
// Clear all error messages
|
||||
void clearErrors() {
|
||||
nikError.value = '';
|
||||
fullNameError.value = '';
|
||||
placeOfBirthError.value = '';
|
||||
birthDateError.value = '';
|
||||
genderError.value = '';
|
||||
addressError.value = '';
|
||||
|
||||
int matchCount = 0;
|
||||
int totalFields = 0;
|
||||
|
||||
// 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++;
|
||||
}
|
||||
}
|
||||
|
||||
// Simpler comparison for KTA at this step
|
||||
return totalFields > 0 ? (matchCount / totalFields) >= 0.5 : true;
|
||||
}
|
||||
|
||||
// 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 {
|
||||
return numericOnly;
|
||||
}
|
||||
isFormValid.value = true;
|
||||
}
|
||||
|
||||
@override
|
||||
void onClose() {
|
||||
nikController.dispose();
|
||||
bioController.dispose();
|
||||
birthDateController.dispose();
|
||||
fullNameController.dispose();
|
||||
placeOfBirthController.dispose();
|
||||
birthDateController.dispose();
|
||||
addressController.dispose();
|
||||
super.onClose();
|
||||
}
|
||||
|
|
|
@ -1,9 +1,9 @@
|
|||
import 'dart:io';
|
||||
|
||||
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/presentasion/controllers/steps/id_card_verification_controller.dart';
|
||||
|
||||
class SelfieVerificationController extends GetxController {
|
||||
// Singleton instance
|
||||
|
@ -19,7 +19,6 @@ class SelfieVerificationController extends GetxController {
|
|||
// For this step, we just need to ensure selfie is uploaded and validated
|
||||
final RxBool isFormValid = RxBool(true);
|
||||
|
||||
|
||||
// Face verification variables
|
||||
final Rx<XFile?> selfieImage = Rx<XFile?>(null);
|
||||
final RxString selfieError = RxString('');
|
||||
|
@ -35,9 +34,14 @@ class SelfieVerificationController extends GetxController {
|
|||
// Confirmation status
|
||||
final RxBool hasConfirmedSelfie = RxBool(false);
|
||||
|
||||
bool validate(GlobalKey<FormState> formKey) {
|
||||
clearErrors();
|
||||
// Face comparison with ID card photo
|
||||
final RxBool isComparingWithIDCard = RxBool(false);
|
||||
final RxBool isMatchWithIDCard = RxBool(false);
|
||||
final RxDouble matchConfidence = RxDouble(0.0);
|
||||
final RxString selfieImageFaceId = RxString('');
|
||||
|
||||
bool validate() {
|
||||
clearErrors();
|
||||
|
||||
if (selfieImage.value == null) {
|
||||
selfieError.value = 'Please take a selfie for verification';
|
||||
|
@ -66,8 +70,10 @@ class SelfieVerificationController extends GetxController {
|
|||
Future<void> pickSelfieImage(ImageSource source) async {
|
||||
try {
|
||||
isUploadingSelfie.value = true;
|
||||
hasConfirmedSelfie.value =
|
||||
false; // Reset confirmation whenever image changes
|
||||
hasConfirmedSelfie.value = false; // Reset confirmation when image changes
|
||||
isMatchWithIDCard.value = false; // Reset face match status
|
||||
matchConfidence.value = 0.0; // Reset confidence score
|
||||
selfieImageFaceId.value = ''; // Reset selfie face ID
|
||||
|
||||
final ImagePicker picker = ImagePicker();
|
||||
final XFile? image = await picker.pickImage(
|
||||
|
@ -128,6 +134,21 @@ class SelfieVerificationController extends GetxController {
|
|||
isSelfieValid.value = true;
|
||||
selfieValidationMessage.value =
|
||||
'Face detected. Please confirm this is you.';
|
||||
|
||||
// Try to detect face and get face ID for later comparison
|
||||
try {
|
||||
final faces = await _ocrService.detectFacesInImage(
|
||||
selfieImage.value!,
|
||||
);
|
||||
if (faces.isNotEmpty) {
|
||||
selfieImageFaceId.value = faces[0]['faceId'] ?? '';
|
||||
|
||||
// Compare with ID card photo if available
|
||||
await compareWithIDCardPhoto();
|
||||
}
|
||||
} catch (faceError) {
|
||||
print('Selfie face detection failed: $faceError');
|
||||
}
|
||||
} else {
|
||||
isSelfieValid.value = false;
|
||||
selfieValidationMessage.value =
|
||||
|
@ -142,6 +163,83 @@ class SelfieVerificationController extends GetxController {
|
|||
}
|
||||
}
|
||||
|
||||
// Compare selfie with ID card photo
|
||||
Future<void> compareWithIDCardPhoto() async {
|
||||
try {
|
||||
final idCardController = Get.find<IdCardVerificationController>();
|
||||
|
||||
// Check if both face IDs are available
|
||||
if (selfieImageFaceId.value.isEmpty ||
|
||||
!idCardController.hasDetectedFace) {
|
||||
print('Cannot compare faces: Missing face ID');
|
||||
return;
|
||||
}
|
||||
|
||||
isComparingWithIDCard.value = true;
|
||||
|
||||
// Compare the two faces
|
||||
final result = await _ocrService.compareFaceIds(
|
||||
idCardController.idCardFaceId.value,
|
||||
selfieImageFaceId.value,
|
||||
);
|
||||
|
||||
isMatchWithIDCard.value = result['isMatch'] ?? false;
|
||||
matchConfidence.value = result['confidence'] ?? 0.0;
|
||||
|
||||
// Update validation message to include face comparison result
|
||||
if (isMatchWithIDCard.value) {
|
||||
selfieValidationMessage.value =
|
||||
'Face verified! Your selfie matches your ID photo with ${(matchConfidence.value * 100).toStringAsFixed(1)}% confidence.';
|
||||
} else if (matchConfidence.value > 0) {
|
||||
selfieValidationMessage.value =
|
||||
'Face verification failed. Your selfie does not match your ID photo (${(matchConfidence.value * 100).toStringAsFixed(1)}% similarity).';
|
||||
}
|
||||
} catch (e) {
|
||||
print('Face comparison error: $e');
|
||||
} finally {
|
||||
isComparingWithIDCard.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Manually trigger face comparison with ID card
|
||||
Future<void> verifyFaceMatchWithIDCard() async {
|
||||
if (selfieImage.value == null) {
|
||||
selfieError.value = 'Please take a selfie first';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
isVerifyingFace.value = true;
|
||||
|
||||
// Get the ID card controller
|
||||
final idCardController = Get.find<IdCardVerificationController>();
|
||||
|
||||
if (!idCardController.hasDetectedFace) {
|
||||
selfieValidationMessage.value =
|
||||
'No face detected in ID card for comparison';
|
||||
return;
|
||||
}
|
||||
|
||||
// If we don't have a selfie face ID yet, detect it now
|
||||
if (selfieImageFaceId.value.isEmpty) {
|
||||
final faces = await _ocrService.detectFacesInImage(selfieImage.value!);
|
||||
if (faces.isNotEmpty) {
|
||||
selfieImageFaceId.value = faces[0]['faceId'] ?? '';
|
||||
} else {
|
||||
selfieValidationMessage.value = 'No face detected in your selfie';
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Compare faces
|
||||
await compareWithIDCardPhoto();
|
||||
} catch (e) {
|
||||
selfieValidationMessage.value = 'Face verification failed: $e';
|
||||
} finally {
|
||||
isVerifyingFace.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear Selfie Image
|
||||
void clearSelfieImage() {
|
||||
selfieImage.value = null;
|
||||
|
@ -150,6 +248,9 @@ class SelfieVerificationController extends GetxController {
|
|||
selfieValidationMessage.value = '';
|
||||
isLivenessCheckPassed.value = false;
|
||||
hasConfirmedSelfie.value = false;
|
||||
isMatchWithIDCard.value = false;
|
||||
matchConfidence.value = 0.0;
|
||||
selfieImageFaceId.value = '';
|
||||
}
|
||||
|
||||
// Confirm Selfie Image
|
||||
|
|
|
@ -27,6 +27,8 @@ class IdCardVerificationStep extends StatelessWidget {
|
|||
final isOfficer = mainController.selectedRole.value?.isOfficer ?? false;
|
||||
final String idCardType = isOfficer ? 'KTA' : 'KTP';
|
||||
|
||||
final isShow = controller.isIdCardValid.value;
|
||||
|
||||
return Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
|
@ -51,7 +53,10 @@ class IdCardVerificationStep extends StatelessWidget {
|
|||
// ID Card Upload Widget
|
||||
Obx(
|
||||
() => ImageUploader(
|
||||
image: controller.idCardImage.value,
|
||||
image:
|
||||
controller.isIdCardValid.value
|
||||
? controller.idCardImage.value
|
||||
: null,
|
||||
title: 'Upload $idCardType Image',
|
||||
subtitle: 'Tap to select an image (max 4MB)',
|
||||
errorMessage: controller.idCardError.value,
|
||||
|
@ -71,7 +76,8 @@ class IdCardVerificationStep extends StatelessWidget {
|
|||
// Display the appropriate model data
|
||||
if (controller.isVerifying.value == false &&
|
||||
controller.idCardImage.value != null &&
|
||||
controller.idCardValidationMessage.value.isNotEmpty) {
|
||||
controller.idCardValidationMessage.value.isNotEmpty &&
|
||||
controller.isIdCardValid.value) {
|
||||
if (isOfficer && controller.ktaModel.value != null) {
|
||||
return _buildKtaResultCard(
|
||||
controller.ktaModel.value!,
|
||||
|
|
|
@ -2,16 +2,10 @@ import 'package:flutter/material.dart';
|
|||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/city_selection.dart';
|
||||
import 'package:sigap/src/shared/widgets/form/date_picker_field.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/face_verification_section.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/id_info_form.dart';
|
||||
import 'package:sigap/src/shared/widgets/form/form_section_header.dart';
|
||||
import 'package:sigap/src/shared/widgets/form/gender_selection.dart';
|
||||
import 'package:sigap/src/shared/widgets/form/verification_status.dart';
|
||||
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
|
||||
import 'package:sigap/src/shared/widgets/verification/validation_message_card.dart';
|
||||
import 'package:sigap/src/utils/constants/colors.dart';
|
||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||
import 'package:sigap/src/utils/validators/validation.dart';
|
||||
|
||||
class IdentityVerificationStep extends StatelessWidget {
|
||||
const IdentityVerificationStep({super.key});
|
||||
|
@ -23,6 +17,8 @@ class IdentityVerificationStep extends StatelessWidget {
|
|||
final mainController = Get.find<FormRegistrationController>();
|
||||
mainController.formKey = formKey;
|
||||
|
||||
final isOfficer = mainController.selectedRole.value?.isOfficer ?? false;
|
||||
|
||||
return Form(
|
||||
key: formKey,
|
||||
child: Column(
|
||||
|
@ -33,276 +29,15 @@ class IdentityVerificationStep extends StatelessWidget {
|
|||
subtitle: 'Please provide additional personal details',
|
||||
),
|
||||
|
||||
// Different fields based on role
|
||||
if (!mainController.selectedRole.value!.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 = '';
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Full Name field
|
||||
Obx(
|
||||
() => CustomTextField(
|
||||
label: 'Full Name',
|
||||
controller: controller.fullNameController,
|
||||
validator:
|
||||
(value) =>
|
||||
TValidators.validateUserInput('Full Name', value, 100),
|
||||
errorText: controller.fullNameError.value,
|
||||
textInputAction: TextInputAction.next,
|
||||
hintText: 'Enter your full name as on KTP',
|
||||
onChanged: (value) {
|
||||
controller.fullNameController.text = value;
|
||||
controller.fullNameError.value = '';
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Place of Birth field with city selection
|
||||
Obx(() => _buildPlaceOfBirthField(context, controller)),
|
||||
],
|
||||
|
||||
// Birth Date field with calendar picker
|
||||
Obx(
|
||||
() => DatePickerField(
|
||||
label: 'Birth Date',
|
||||
controller: controller.birthDateController,
|
||||
errorText: controller.birthDateError.value,
|
||||
onDateSelected: (date) {
|
||||
controller.birthDateError.value = '';
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Gender selection
|
||||
Obx(
|
||||
() => GenderSelection(
|
||||
selectedGender: controller.selectedGender.value,
|
||||
onGenderChanged: (value) {
|
||||
if (value != null) {
|
||||
controller.selectedGender.value = value;
|
||||
controller.genderError.value = '';
|
||||
}
|
||||
},
|
||||
errorText: controller.genderError.value,
|
||||
),
|
||||
),
|
||||
|
||||
// Address field
|
||||
Obx(
|
||||
() => CustomTextField(
|
||||
label: 'Address',
|
||||
controller: controller.addressController,
|
||||
validator:
|
||||
(value) =>
|
||||
TValidators.validateUserInput('Address', value, 255),
|
||||
errorText: controller.addressError.value,
|
||||
textInputAction: TextInputAction.next,
|
||||
hintText: 'Enter your address as on KTP',
|
||||
maxLines: 3,
|
||||
onChanged: (value) {
|
||||
controller.addressController.text = value;
|
||||
controller.addressError.value = '';
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Verification Status
|
||||
Obx(
|
||||
() => VerificationStatus(
|
||||
isVerifying: controller.isVerifying.value,
|
||||
verifyingMessage: 'Processing your personal information...',
|
||||
),
|
||||
),
|
||||
|
||||
// Verification Message
|
||||
Obx(
|
||||
() =>
|
||||
controller.verificationMessage.value.isNotEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: TSizes.spaceBtwItems,
|
||||
),
|
||||
child: ValidationMessageCard(
|
||||
message: controller.verificationMessage.value,
|
||||
isValid: controller.isVerified.value,
|
||||
hasConfirmed: false,
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
// Personal Information Form Section
|
||||
IdInfoForm(controller: controller, isOfficer: isOfficer),
|
||||
|
||||
const SizedBox(height: TSizes.spaceBtwSections),
|
||||
|
||||
// Data verification button
|
||||
ElevatedButton.icon(
|
||||
onPressed:
|
||||
controller.isVerifying.value
|
||||
? null
|
||||
: () => controller.verifyIdCardWithOCR(),
|
||||
icon: const Icon(Icons.verified_user),
|
||||
label: Text(
|
||||
controller.isVerified.value
|
||||
? 'Re-verify Personal Information'
|
||||
: 'Verify Personal Information',
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: TColors.primary,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size(double.infinity, 50),
|
||||
),
|
||||
),
|
||||
|
||||
const SizedBox(height: TSizes.spaceBtwItems),
|
||||
|
||||
// Face verification section
|
||||
const FormSectionHeader(
|
||||
title: 'Face Verification',
|
||||
subtitle: 'Verify that your face matches with your ID card photo',
|
||||
),
|
||||
|
||||
// Face Verification Status
|
||||
Obx(
|
||||
() => VerificationStatus(
|
||||
isVerifying: controller.isVerifyingFace.value,
|
||||
verifyingMessage: 'Comparing face with ID photo...',
|
||||
),
|
||||
),
|
||||
|
||||
// Face match verification button
|
||||
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,
|
||||
),
|
||||
),
|
||||
|
||||
// Face Verification Message
|
||||
Obx(
|
||||
() =>
|
||||
controller.faceVerificationMessage.value.isNotEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: TSizes.spaceBtwItems,
|
||||
),
|
||||
child: ValidationMessageCard(
|
||||
message: controller.faceVerificationMessage.value,
|
||||
isValid: controller.isFaceVerified.value,
|
||||
hasConfirmed: false,
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
// Face Verification Section
|
||||
FaceVerificationSection(controller: controller),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildPlaceOfBirthField(
|
||||
BuildContext context,
|
||||
IdentityVerificationController controller,
|
||||
) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Place of Birth', style: Theme.of(context).textTheme.bodyMedium),
|
||||
const SizedBox(height: TSizes.xs),
|
||||
GestureDetector(
|
||||
onTap: () => _navigateToCitySelection(context, controller),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: TSizes.md,
|
||||
vertical: TSizes.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color:
|
||||
controller.placeOfBirthError.value.isNotEmpty
|
||||
? TColors.error
|
||||
: TColors.textSecondary,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(TSizes.borderRadiusLg),
|
||||
color: Colors.transparent,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
controller.placeOfBirthController.text.isEmpty
|
||||
? 'Select Place of Birth'
|
||||
: controller.placeOfBirthController.text,
|
||||
style: TextStyle(
|
||||
color:
|
||||
controller.placeOfBirthController.text.isEmpty
|
||||
? Theme.of(context).textTheme.bodyMedium?.color
|
||||
: TColors.textSecondary,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.location_city,
|
||||
size: TSizes.iconSm,
|
||||
color: Theme.of(context).textTheme.bodyMedium?.color,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (controller.placeOfBirthError.value.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: TSizes.xs, left: TSizes.xs),
|
||||
child: Text(
|
||||
controller.placeOfBirthError.value,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: TColors.error,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: TSizes.spaceBtwItems),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToCitySelection(
|
||||
BuildContext context,
|
||||
IdentityVerificationController controller,
|
||||
) async {
|
||||
final selectedCity = await Get.to<String>(() => const CitySelectionPage());
|
||||
if (selectedCity != null && selectedCity.isNotEmpty) {
|
||||
controller.placeOfBirthController.text = selectedCity;
|
||||
controller.placeOfBirthError.value = '';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -85,6 +85,72 @@ class SelfieVerificationStep extends StatelessWidget {
|
|||
: const SizedBox.shrink(),
|
||||
),
|
||||
|
||||
// Face match with ID card indicator
|
||||
Obx(() {
|
||||
if (controller.selfieImage.value != null &&
|
||||
controller.isSelfieValid.value) {
|
||||
return Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: TSizes.sm),
|
||||
child: Card(
|
||||
color:
|
||||
controller.isMatchWithIDCard.value
|
||||
? Colors.green.shade50
|
||||
: Colors.orange.shade50,
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.all(TSizes.md),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Row(
|
||||
children: [
|
||||
Icon(
|
||||
controller.isMatchWithIDCard.value
|
||||
? Icons.check_circle
|
||||
: Icons.face,
|
||||
color:
|
||||
controller.isMatchWithIDCard.value
|
||||
? Colors.green
|
||||
: Colors.orange,
|
||||
),
|
||||
const SizedBox(width: TSizes.sm),
|
||||
Expanded(
|
||||
child: Text(
|
||||
'Face ID Match',
|
||||
style: TextStyle(
|
||||
fontWeight: FontWeight.bold,
|
||||
color:
|
||||
controller.isMatchWithIDCard.value
|
||||
? Colors.green
|
||||
: Colors.orange,
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: TSizes.sm),
|
||||
Text(
|
||||
controller.isMatchWithIDCard.value
|
||||
? 'Your selfie matches with your ID card photo (${(controller.matchConfidence.value * 100).toStringAsFixed(1)}% confidence)'
|
||||
: controller.isComparingWithIDCard.value
|
||||
? 'Comparing your selfie with your ID card photo...'
|
||||
: 'Your selfie doesn\'t match with your ID card photo.',
|
||||
),
|
||||
const SizedBox(height: TSizes.sm),
|
||||
if (!controller.isComparingWithIDCard.value &&
|
||||
!controller.isMatchWithIDCard.value)
|
||||
ElevatedButton(
|
||||
onPressed: controller.verifyFaceMatchWithIDCard,
|
||||
child: const Text('Try Face Matching Again'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
return const SizedBox.shrink();
|
||||
}),
|
||||
|
||||
// Error Messages
|
||||
Obx(
|
||||
() =>
|
||||
|
|
|
@ -0,0 +1,82 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart';
|
||||
import 'package:sigap/src/shared/widgets/form/form_section_header.dart';
|
||||
import 'package:sigap/src/shared/widgets/form/verification_status.dart';
|
||||
import 'package:sigap/src/shared/widgets/verification/validation_message_card.dart';
|
||||
import 'package:sigap/src/utils/constants/colors.dart';
|
||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||
|
||||
class FaceVerificationSection extends StatelessWidget {
|
||||
final IdentityVerificationController controller;
|
||||
|
||||
const FaceVerificationSection({super.key, required this.controller});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
const FormSectionHeader(
|
||||
title: 'Face Verification',
|
||||
subtitle: 'Verify that your face matches with your ID card photo',
|
||||
),
|
||||
|
||||
// Face Verification Status
|
||||
Obx(
|
||||
() => VerificationStatus(
|
||||
isVerifying: controller.isVerifyingFace.value,
|
||||
verifyingMessage: 'Comparing face with ID photo...',
|
||||
),
|
||||
),
|
||||
|
||||
// Face match verification button
|
||||
_buildFaceVerificationButton(),
|
||||
|
||||
// Face Verification Message
|
||||
Obx(
|
||||
() =>
|
||||
controller.faceVerificationMessage.value.isNotEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: TSizes.spaceBtwItems,
|
||||
),
|
||||
child: ValidationMessageCard(
|
||||
message: controller.faceVerificationMessage.value,
|
||||
isValid: controller.isFaceVerified.value,
|
||||
hasConfirmed: false,
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildFaceVerificationButton() {
|
||||
return Obx(
|
||||
() => 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,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,146 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/place_of_birth_field.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/verification_action_button.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/verification_status_message.dart';
|
||||
import 'package:sigap/src/shared/widgets/form/date_picker_field.dart';
|
||||
import 'package:sigap/src/shared/widgets/form/gender_selection.dart';
|
||||
import 'package:sigap/src/shared/widgets/form/verification_status.dart';
|
||||
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
|
||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||
import 'package:sigap/src/utils/validators/validation.dart';
|
||||
|
||||
class IdInfoForm extends StatelessWidget {
|
||||
final IdentityVerificationController controller;
|
||||
final bool isOfficer;
|
||||
|
||||
const IdInfoForm({
|
||||
super.key,
|
||||
required this.controller,
|
||||
required this.isOfficer,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
// Different fields based on role
|
||||
if (!isOfficer) ...[
|
||||
// NIK field for non-officers
|
||||
_buildNikField(),
|
||||
|
||||
// Full Name field
|
||||
_buildFullNameField(),
|
||||
|
||||
// Place of Birth field with city selection
|
||||
PlaceOfBirthField(controller: controller),
|
||||
],
|
||||
|
||||
// Birth Date field with calendar picker
|
||||
Obx(
|
||||
() => DatePickerField(
|
||||
label: 'Birth Date',
|
||||
controller: controller.birthDateController,
|
||||
errorText: controller.birthDateError.value,
|
||||
onDateSelected: (date) {
|
||||
controller.birthDateError.value = '';
|
||||
},
|
||||
),
|
||||
),
|
||||
|
||||
// Gender selection
|
||||
Obx(
|
||||
() => GenderSelection(
|
||||
selectedGender: controller.selectedGender.value!,
|
||||
onGenderChanged: (value) {
|
||||
if (value != null) {
|
||||
controller.selectedGender.value = value;
|
||||
controller.genderError.value = '';
|
||||
}
|
||||
},
|
||||
errorText: controller.genderError.value,
|
||||
),
|
||||
),
|
||||
|
||||
// Address field
|
||||
_buildAddressField(),
|
||||
|
||||
// Verification Status
|
||||
Obx(
|
||||
() => VerificationStatus(
|
||||
isVerifying: controller.isVerifying.value,
|
||||
verifyingMessage: 'Processing your personal information...',
|
||||
),
|
||||
),
|
||||
|
||||
// Verification Message
|
||||
VerificationStatusMessage(controller: controller),
|
||||
|
||||
const SizedBox(height: TSizes.spaceBtwItems),
|
||||
|
||||
// Data verification button
|
||||
VerificationActionButton(controller: controller),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
||||
// NIK field
|
||||
Widget _buildNikField() {
|
||||
return 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 = '';
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Full Name field
|
||||
Widget _buildFullNameField() {
|
||||
return Obx(
|
||||
() => CustomTextField(
|
||||
label: 'Full Name',
|
||||
controller: controller.fullNameController,
|
||||
validator:
|
||||
(value) => TValidators.validateUserInput('Full Name', value, 100),
|
||||
errorText: controller.fullNameError.value,
|
||||
textInputAction: TextInputAction.next,
|
||||
hintText: 'Enter your full name as on KTP',
|
||||
onChanged: (value) {
|
||||
controller.fullNameController.text = value;
|
||||
controller.fullNameError.value = '';
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// Address field
|
||||
Widget _buildAddressField() {
|
||||
return Obx(
|
||||
() => CustomTextField(
|
||||
label: 'Address',
|
||||
controller: controller.addressController,
|
||||
validator:
|
||||
(value) => TValidators.validateUserInput('Address', value, 255),
|
||||
errorText: controller.addressError.value,
|
||||
textInputAction: TextInputAction.next,
|
||||
hintText: 'Enter your address as on KTP',
|
||||
maxLines: 3,
|
||||
onChanged: (value) {
|
||||
controller.addressController.text = value;
|
||||
controller.addressError.value = '';
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,85 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/city_selection.dart';
|
||||
import 'package:sigap/src/utils/constants/colors.dart';
|
||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||
|
||||
class PlaceOfBirthField extends StatelessWidget {
|
||||
final IdentityVerificationController controller;
|
||||
|
||||
const PlaceOfBirthField({super.key, required this.controller});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(
|
||||
() => Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text('Place of Birth', style: Theme.of(context).textTheme.bodyMedium),
|
||||
const SizedBox(height: TSizes.xs),
|
||||
GestureDetector(
|
||||
onTap: () => _navigateToCitySelection(context),
|
||||
child: Container(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: TSizes.md,
|
||||
vertical: TSizes.md,
|
||||
),
|
||||
decoration: BoxDecoration(
|
||||
border: Border.all(
|
||||
color:
|
||||
controller.placeOfBirthError.value.isNotEmpty
|
||||
? TColors.error
|
||||
: TColors.textSecondary,
|
||||
),
|
||||
borderRadius: BorderRadius.circular(TSizes.borderRadiusLg),
|
||||
color: Colors.transparent,
|
||||
),
|
||||
child: Row(
|
||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||
children: [
|
||||
Text(
|
||||
controller.placeOfBirthController.text.isEmpty
|
||||
? 'Select Place of Birth'
|
||||
: controller.placeOfBirthController.text,
|
||||
style: TextStyle(
|
||||
color:
|
||||
controller.placeOfBirthController.text.isEmpty
|
||||
? Theme.of(context).textTheme.bodyMedium?.color
|
||||
: TColors.textSecondary,
|
||||
),
|
||||
),
|
||||
Icon(
|
||||
Icons.location_city,
|
||||
size: TSizes.iconSm,
|
||||
color: Theme.of(context).textTheme.bodyMedium?.color,
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
if (controller.placeOfBirthError.value.isNotEmpty)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: TSizes.xs, left: TSizes.xs),
|
||||
child: Text(
|
||||
controller.placeOfBirthError.value,
|
||||
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||
color: TColors.error,
|
||||
fontSize: 12,
|
||||
),
|
||||
),
|
||||
),
|
||||
const SizedBox(height: TSizes.spaceBtwItems),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
void _navigateToCitySelection(BuildContext context) async {
|
||||
final selectedCity = await Get.to<String>(() => const CitySelectionPage());
|
||||
if (selectedCity != null && selectedCity.isNotEmpty) {
|
||||
controller.placeOfBirthController.text = selectedCity;
|
||||
controller.placeOfBirthError.value = '';
|
||||
}
|
||||
}
|
||||
}
|
|
@ -0,0 +1,33 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart';
|
||||
import 'package:sigap/src/utils/constants/colors.dart';
|
||||
|
||||
class VerificationActionButton extends StatelessWidget {
|
||||
final IdentityVerificationController controller;
|
||||
|
||||
const VerificationActionButton({super.key, required this.controller});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(
|
||||
() => ElevatedButton.icon(
|
||||
onPressed:
|
||||
controller.isVerifying.value
|
||||
? null
|
||||
: () => controller.verifyIdCardWithOCR(),
|
||||
icon: const Icon(Icons.verified_user),
|
||||
label: Text(
|
||||
controller.isVerified.value
|
||||
? 'Re-verify Personal Information'
|
||||
: 'Verify Personal Information',
|
||||
),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: TColors.primary,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size(double.infinity, 50),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -0,0 +1,30 @@
|
|||
import 'package:flutter/material.dart';
|
||||
import 'package:get/get.dart';
|
||||
import 'package:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart';
|
||||
import 'package:sigap/src/shared/widgets/verification/validation_message_card.dart';
|
||||
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||
|
||||
class VerificationStatusMessage extends StatelessWidget {
|
||||
final IdentityVerificationController controller;
|
||||
|
||||
const VerificationStatusMessage({super.key, required this.controller});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Obx(
|
||||
() =>
|
||||
controller.verificationMessage.value.isNotEmpty
|
||||
? Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: TSizes.spaceBtwItems,
|
||||
),
|
||||
child: ValidationMessageCard(
|
||||
message: controller.verificationMessage.value,
|
||||
isValid: controller.isVerified.value,
|
||||
hasConfirmed: false,
|
||||
),
|
||||
)
|
||||
: const SizedBox.shrink(),
|
||||
);
|
||||
}
|
||||
}
|
|
@ -234,21 +234,21 @@ class ImageUploader extends StatelessWidget {
|
|||
],
|
||||
),
|
||||
|
||||
// Validate button
|
||||
if (onValidate != null && !isVerifying && !isUploading && !isConfirmed)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: TSizes.sm),
|
||||
child: ElevatedButton.icon(
|
||||
onPressed: onValidate,
|
||||
icon: const Icon(Icons.check_circle),
|
||||
label: Text('Verify Image'),
|
||||
style: ElevatedButton.styleFrom(
|
||||
backgroundColor: TColors.primary,
|
||||
foregroundColor: Colors.white,
|
||||
minimumSize: const Size(double.infinity, 50),
|
||||
),
|
||||
),
|
||||
),
|
||||
// // Validate button
|
||||
// if (onValidate != null && !isVerifying && !isUploading && !isConfirmed)
|
||||
// Padding(
|
||||
// padding: const EdgeInsets.only(top: TSizes.sm),
|
||||
// child: ElevatedButton.icon(
|
||||
// onPressed: onValidate,
|
||||
// icon: const Icon(Icons.check_circle),
|
||||
// label: Text('Verify Image'),
|
||||
// style: ElevatedButton.styleFrom(
|
||||
// backgroundColor: TColors.primary,
|
||||
// foregroundColor: Colors.white,
|
||||
// minimumSize: const Size(double.infinity, 50),
|
||||
// ),
|
||||
// ),
|
||||
// ),
|
||||
],
|
||||
);
|
||||
}
|
||||
|
|
|
@ -8,16 +8,22 @@ class Endpoints {
|
|||
static const String baseUrl = '$devUrl/api';
|
||||
|
||||
static String get azureResource => dotenv.env['AZURE_RESOURCE_NAME'] ?? '';
|
||||
static String get azureFaceResource =>
|
||||
dotenv.env['AZURE_FACE_RESOURCE_NAME'] ?? '';
|
||||
static String get azureSubscriptionKey =>
|
||||
dotenv.env['AZURE_SUBSCRIPTION_KEY'] ?? '';
|
||||
static String get azureFaceSubscriptionKey =>
|
||||
dotenv.env['AZURE_FACE_SUBSCRIPTION_KEY'] ?? '';
|
||||
|
||||
static String get azureEndpoint =>
|
||||
'https://$azureResource.cognitiveservices.azure.com/';
|
||||
static String get azureFaceEndpoint =>
|
||||
'https://$azureFaceResource.cognitiveservices.azure.com/';
|
||||
|
||||
static String get ocrApiPath => 'vision/v3.2/read/analyze';
|
||||
static String ocrResultPath(String operationId) =>
|
||||
'vision/v3.2/read/analyzeResults/$operationId';
|
||||
|
||||
static String get faceApiPath => 'face/v1.0/detect';
|
||||
static String get faceVerifyPath => 'face/v1.0/verify';
|
||||
static String get faceApiPath => 'face/v1.2/detect';
|
||||
static String get faceVerifyPath => 'face/v1.2/verify';
|
||||
}
|
||||
|
|
|
@ -0,0 +1,13 @@
|
|||
import 'package:get/get.dart';
|
||||
|
||||
class TNavigation {
|
||||
void pushNamed(String routeName) {
|
||||
// Implement your navigation logic here
|
||||
}
|
||||
|
||||
void navigateToRoute(String routeName) {
|
||||
if (Get.currentRoute != routeName) {
|
||||
Get.offAllNamed(routeName);
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue