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:
vergiLgood1 2025-05-22 21:47:01 +07:00
parent 1908318769
commit d9fffff68d
19 changed files with 1355 additions and 863 deletions

View File

@ -41,4 +41,6 @@ NODE_ENV=development
# Azure AI API # Azure AI API
AZURE_RESOURCE_NAME="sigap" AZURE_RESOURCE_NAME="sigap"
AZURE_FACE_RESOURCE_NAME="verify-face"
AZURE_SUBSCRIPTION_KEY="ANeYAEr78MF7HzCEDg53DEHfKZJg19raPeJCubNEZP2tXGD6xREgJQQJ99BEAC3pKaRXJ3w3AAAFACOGAwA9" AZURE_SUBSCRIPTION_KEY="ANeYAEr78MF7HzCEDg53DEHfKZJg19raPeJCubNEZP2tXGD6xREgJQQJ99BEAC3pKaRXJ3w3AAAFACOGAwA9"
AZURE_FACE_SUBSCRIPTION_KEY="6pBJKuYEFWHkrCBaZh8hErDci6ZwYnG0tEaE3VA34P8XPAYj4ZvOJQQJ99BEACqBBLyXJ3w3AAAKACOGYqeW"

View File

@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_dotenv/flutter_dotenv.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:get_storage/get_storage.dart';
import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart'; import 'package:mapbox_maps_flutter/mapbox_maps_flutter.dart';
import 'package:sigap/app.dart'; import 'package:sigap/app.dart';
@ -15,7 +14,7 @@ Future<void> main() async {
const SystemUiOverlayStyle(statusBarColor: Colors.transparent), const SystemUiOverlayStyle(statusBarColor: Colors.transparent),
); );
FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding); // FlutterNativeSplash.preserve(widgetsBinding: widgetsBinding);
// Load environment variables from the .env file // Load environment variables from the .env file
await dotenv.load(fileName: ".env"); await dotenv.load(fileName: ".env");

View File

@ -3,7 +3,6 @@ import 'dart:io';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:image_picker/image_picker.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/daily-ops/data/models/models/kta_model.dart';
import 'package:sigap/src/features/personalization/data/models/models/ktp_model.dart'; import 'package:sigap/src/features/personalization/data/models/models/ktp_model.dart';
import 'package:sigap/src/utils/constants/api_urls.dart'; import 'package:sigap/src/utils/constants/api_urls.dart';
@ -17,6 +16,9 @@ class AzureOCRService {
final String faceApiPath = Endpoints.faceApiPath; final String faceApiPath = Endpoints.faceApiPath;
final String faceVerifyPath = Endpoints.faceVerifyPath; final String faceVerifyPath = Endpoints.faceVerifyPath;
bool isValidKtp = false;
bool isValidKta = false;
// Process an ID card image and extract relevant information // Process an ID card image and extract relevant information
Future<Map<String, String>> processIdCard( Future<Map<String, String>> processIdCard(
XFile imageFile, XFile imageFile,
@ -57,10 +59,8 @@ class AzureOCRService {
// Poll for results // Poll for results
final ocrResult = await _pollForOcrResults(operationLocation); final ocrResult = await _pollForOcrResults(operationLocation);
// Debug: LoggerLogger().i extracted content to help troubleshoot // Debug: print extracted content to help troubleshoot
Logger().i( print('Full extracted text: ${ocrResult['analyzeResult']['content']}');
'Full extracted text: ${ocrResult['analyzeResult']['content']}',
);
// Parse the extracted information based on document type // Parse the extracted information based on document type
return isOfficer return isOfficer
@ -72,7 +72,7 @@ class AzureOCRService {
); );
} }
} catch (e) { } catch (e) {
Logger().i('OCR processing error: $e'); print('OCR processing error: $e');
throw Exception('OCR processing error: $e'); throw Exception('OCR processing error: $e');
} }
} }
@ -139,10 +139,10 @@ class AzureOCRService {
final List<String> allLines = _getAllTextLinesFromReadAPI(ocrResult); final List<String> allLines = _getAllTextLinesFromReadAPI(ocrResult);
final String fullText = _getFullText(ocrResult); final String fullText = _getFullText(ocrResult);
// LoggerLogger().i raw extraction for debugging // print raw extraction for debugging
Logger().i('Extracted ${allLines.length} lines from KTP'); print('Extracted ${allLines.length} lines from KTP');
for (int i = 0; i < allLines.length; i++) { 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) // Extract NIK using various methods (label-based, regex patterns)
@ -187,8 +187,8 @@ class AzureOCRService {
// Extract blood type // Extract blood type
_extractBloodTypeFromKtp(extractedInfo, allLines); _extractBloodTypeFromKtp(extractedInfo, allLines);
// LoggerLogger().i extracted information for debugging // print extracted information for debugging
Logger().i('Extracted KTP info: ${extractedInfo.toString()}'); print('Extracted KTP info: ${extractedInfo.toString()}');
return extractedInfo; 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( void _extractNameFromKtp(
Map<String, String> extractedInfo, Map<String, String> extractedInfo,
List<String> allLines, List<String> allLines,
String fullText, String fullText,
) { ) {
// Look specifically for lines with "Nama" followed by data
for (int i = 0; i < allLines.length; i++) { for (int i = 0; i < allLines.length; i++) {
String line = allLines[i].toLowerCase(); String line = allLines[i].toLowerCase().trim();
if (line.contains('nama') && !line.contains('agama')) {
// Check same line after colon // Check if line contains only 'nama' or 'nama:'
if (line.contains(':')) { if (line == 'nama' || line == 'nama:') {
String name = line.split(':')[1].trim(); // Name should be on the next line (typical KTP format)
if (name.isNotEmpty) {
extractedInfo['nama'] = _normalizeCase(name);
return;
}
}
// Check next line
if (i + 1 < allLines.length) { if (i + 1 < allLines.length) {
String nextLine = allLines[i + 1].trim(); 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); extractedInfo['nama'] = _normalizeCase(nextLine);
return; 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 // Try looking for "Nama:" with name directly on same line
RegExp nameRegex = RegExp( for (int i = 0; i < allLines.length; i++) {
r'nama\s*:\s*([A-Za-z\s]+)', String line = allLines[i];
caseSensitive: false, 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); var nameMatch = nameRegex.firstMatch(fullText);
if (nameMatch != null && nameMatch.groupCount >= 1) { 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 // Process birth information text
void _processBirthInfo(Map<String, String> extractedInfo, String birthInfo) { 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) // Check if contains comma (indicating place, date format)
if (birthInfo.contains(',')) { if (birthInfo.contains(',')) {
List<String> parts = birthInfo.split(','); List<String> parts = birthInfo.split(',');
if (parts.isNotEmpty) { if (parts.isNotEmpty) {
extractedInfo['birth_place'] = _normalizeCase(parts[0].trim()); String place = parts[0].trim();
extractedInfo['birthPlace'] = _normalizeCase(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) { 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}'); RegExp dateRegex = RegExp(r'\d{1,2}[-/\s\.]\d{1,2}[-/\s\.]\d{2,4}');
var match = dateRegex.firstMatch(parts[1]); var match = dateRegex.firstMatch(parts[1]);
if (match != null) { if (match != null) {
extractedInfo['birthDate'] = match.group(0)!;
extractedInfo['tanggal_lahir'] = match.group(0)!; extractedInfo['tanggal_lahir'] = match.group(0)!;
} }
} }
@ -347,19 +397,16 @@ class AzureOCRService {
if (match != null) { if (match != null) {
String dateStr = match.group(0)!; String dateStr = match.group(0)!;
extractedInfo['birthDate'] = dateStr;
extractedInfo['tanggal_lahir'] = dateStr; extractedInfo['tanggal_lahir'] = dateStr;
// Extract birth place by removing the date // Extract birth place by removing the date
String place = birthInfo.replaceAll(dateStr, '').trim(); String place = birthInfo.replaceAll(dateStr, '').trim();
if (place.isNotEmpty) { if (place.isNotEmpty) {
extractedInfo['birth_place'] = _normalizeCase(place); extractedInfo['tempat_lahir'] = _normalizeCase(place);
extractedInfo['birthPlace'] = _normalizeCase(place);
} }
} else { } else {
// If no date is found, consider the entire text as birth place // If no date is found, consider the entire text as birth place
extractedInfo['birth_place'] = _normalizeCase(birthInfo); extractedInfo['tempat_lahir'] = _normalizeCase(birthInfo);
extractedInfo['birthPlace'] = _normalizeCase(birthInfo);
} }
} }
} }
@ -372,8 +419,7 @@ class AzureOCRService {
) { ) {
for (int i = 0; i < allLines.length; i++) { for (int i = 0; i < allLines.length; i++) {
String line = allLines[i].toLowerCase(); String line = allLines[i].toLowerCase();
if ((line.contains('jenis') && line.contains('kelamin')) || if ((line.contains('jenis') && line.contains('kelamin'))) {
line.contains('gender')) {
// Extract from same line if contains colon // Extract from same line if contains colon
if (line.contains(':')) { if (line.contains(':')) {
String genderText = line.split(':')[1].trim(); String genderText = line.split(':')[1].trim();
@ -545,7 +591,6 @@ class AzureOCRService {
if ((line.contains('kel') && line.contains('desa')) || if ((line.contains('kel') && line.contains('desa')) ||
line.contains('kelurahan') || line.contains('kelurahan') ||
line.contains('desa')) { line.contains('desa')) {
// Extract from same line if contains colon // Extract from same line if contains colon
if (line.contains(':')) { if (line.contains(':')) {
String kelurahan = line.split(':')[1].trim(); String kelurahan = line.split(':')[1].trim();
@ -600,11 +645,9 @@ class AzureOCRService {
for (int i = 0; i < allLines.length; i++) { for (int i = 0; i < allLines.length; i++) {
String line = allLines[i].toLowerCase(); String line = allLines[i].toLowerCase();
if (line.contains('agama')) { if (line.contains('agama')) {
// Extract from same line if contains colon // Extract from same line if contains colon
if (line.contains(':')) { if (line.contains(':')) {
String religion = line.split(':')[1].trim(); String religion = line.split(':')[1].trim();
extractedInfo['religion'] = _normalizeCase(religion);
extractedInfo['agama'] = _normalizeCase(religion); extractedInfo['agama'] = _normalizeCase(religion);
return; return;
} }
@ -613,7 +656,6 @@ class AzureOCRService {
if (i + 1 < allLines.length) { if (i + 1 < allLines.length) {
String nextLine = allLines[i + 1].trim(); String nextLine = allLines[i + 1].trim();
if (nextLine.isNotEmpty && !_isLabelLine(nextLine)) { if (nextLine.isNotEmpty && !_isLabelLine(nextLine)) {
extractedInfo['religion'] = _normalizeCase(nextLine);
extractedInfo['agama'] = _normalizeCase(nextLine); extractedInfo['agama'] = _normalizeCase(nextLine);
return; return;
} }
@ -634,7 +676,7 @@ class AzureOCRService {
// Extract from same line if contains colon // Extract from same line if contains colon
if (line.contains(':')) { if (line.contains(':')) {
String status = line.split(':')[1].trim(); String status = line.split(':')[1].trim();
extractedInfo['marital_status'] = _normalizeCase(status);
extractedInfo['status_perkawinan'] = _normalizeCase(status); extractedInfo['status_perkawinan'] = _normalizeCase(status);
return; return;
} }
@ -643,7 +685,6 @@ class AzureOCRService {
if (i + 1 < allLines.length) { if (i + 1 < allLines.length) {
String nextLine = allLines[i + 1].trim(); String nextLine = allLines[i + 1].trim();
if (nextLine.isNotEmpty && !_isLabelLine(nextLine)) { if (nextLine.isNotEmpty && !_isLabelLine(nextLine)) {
extractedInfo['marital_status'] = _normalizeCase(nextLine);
extractedInfo['status_perkawinan'] = _normalizeCase(nextLine); extractedInfo['status_perkawinan'] = _normalizeCase(nextLine);
return; return;
} }
@ -660,11 +701,9 @@ class AzureOCRService {
for (int i = 0; i < allLines.length; i++) { for (int i = 0; i < allLines.length; i++) {
String line = allLines[i].toLowerCase(); String line = allLines[i].toLowerCase();
if (line.contains('pekerjaan')) { if (line.contains('pekerjaan')) {
// Extract from same line if contains colon // Extract from same line if contains colon
if (line.contains(':')) { if (line.contains(':')) {
String occupation = line.split(':')[1].trim(); String occupation = line.split(':')[1].trim();
extractedInfo['occupation'] = _normalizeCase(occupation);
extractedInfo['pekerjaan'] = _normalizeCase(occupation); extractedInfo['pekerjaan'] = _normalizeCase(occupation);
return; return;
} }
@ -673,7 +712,6 @@ class AzureOCRService {
if (i + 1 < allLines.length) { if (i + 1 < allLines.length) {
String nextLine = allLines[i + 1].trim(); String nextLine = allLines[i + 1].trim();
if (nextLine.isNotEmpty && !_isLabelLine(nextLine)) { if (nextLine.isNotEmpty && !_isLabelLine(nextLine)) {
extractedInfo['occupation'] = _normalizeCase(nextLine);
extractedInfo['pekerjaan'] = _normalizeCase(nextLine); extractedInfo['pekerjaan'] = _normalizeCase(nextLine);
return; return;
} }
@ -689,11 +727,11 @@ class AzureOCRService {
) { ) {
for (int i = 0; i < allLines.length; i++) { for (int i = 0; i < allLines.length; i++) {
String line = allLines[i].toLowerCase(); String line = allLines[i].toLowerCase();
if (line.contains('kewarganegaraan') || line.contains('nationality')) { if (line.contains('kewarganegaraan')) {
// Extract from same line if contains colon // Extract from same line if contains colon
if (line.contains(':')) { if (line.contains(':')) {
String nationality = line.split(':')[1].trim(); String nationality = line.split(':')[1].trim();
extractedInfo['nationality'] = _normalizeCase(nationality); // extractedInfo['nationality'] = _normalizeCase(nationality);
extractedInfo['kewarganegaraan'] = _normalizeCase(nationality); extractedInfo['kewarganegaraan'] = _normalizeCase(nationality);
return; return;
} }
@ -702,7 +740,7 @@ class AzureOCRService {
if (i + 1 < allLines.length) { if (i + 1 < allLines.length) {
String nextLine = allLines[i + 1].trim(); String nextLine = allLines[i + 1].trim();
if (nextLine.isNotEmpty && !_isLabelLine(nextLine)) { if (nextLine.isNotEmpty && !_isLabelLine(nextLine)) {
extractedInfo['nationality'] = _normalizeCase(nextLine); // extractedInfo['nationality'] = _normalizeCase(nextLine);
extractedInfo['kewarganegaraan'] = _normalizeCase(nextLine); extractedInfo['kewarganegaraan'] = _normalizeCase(nextLine);
return; return;
} }
@ -711,8 +749,8 @@ class AzureOCRService {
} }
// Default to WNI if not found explicitly // Default to WNI if not found explicitly
if (!extractedInfo.containsKey('nationality')) { if (!extractedInfo.containsKey('kewarganegaraan')) {
extractedInfo['nationality'] = 'WNI'; // extractedInfo['nationality'] = 'WNI';
extractedInfo['kewarganegaraan'] = 'WNI'; extractedInfo['kewarganegaraan'] = 'WNI';
} }
} }
@ -729,7 +767,7 @@ class AzureOCRService {
// Extract from same line if contains colon // Extract from same line if contains colon
if (line.contains(':')) { if (line.contains(':')) {
String validity = line.split(':')[1].trim(); String validity = line.split(':')[1].trim();
extractedInfo['validity_period'] = _normalizeCase(validity);
extractedInfo['berlaku_hingga'] = _normalizeCase(validity); extractedInfo['berlaku_hingga'] = _normalizeCase(validity);
return; return;
} }
@ -738,7 +776,6 @@ class AzureOCRService {
if (i + 1 < allLines.length) { if (i + 1 < allLines.length) {
String nextLine = allLines[i + 1].trim(); String nextLine = allLines[i + 1].trim();
if (nextLine.isNotEmpty && !_isLabelLine(nextLine)) { if (nextLine.isNotEmpty && !_isLabelLine(nextLine)) {
extractedInfo['validity_period'] = _normalizeCase(nextLine);
extractedInfo['berlaku_hingga'] = _normalizeCase(nextLine); extractedInfo['berlaku_hingga'] = _normalizeCase(nextLine);
return; return;
} }
@ -754,12 +791,11 @@ class AzureOCRService {
) { ) {
for (int i = 0; i < allLines.length; i++) { for (int i = 0; i < allLines.length; i++) {
String line = allLines[i].toLowerCase(); String line = allLines[i].toLowerCase();
if ((line.contains('gol') && line.contains('darah')) || if ((line.contains('gol') && line.contains('darah'))) {
line.contains('blood')) {
// Extract from same line if contains colon // Extract from same line if contains colon
if (line.contains(':')) { if (line.contains(':')) {
String bloodType = line.split(':')[1].trim(); String bloodType = line.split(':')[1].trim();
extractedInfo['blood_type'] = bloodType.toUpperCase(); extractedInfo['Golongan_darah'] = bloodType.toUpperCase();
return; return;
} }
@ -767,7 +803,7 @@ class AzureOCRService {
RegExp bloodTypeRegex = RegExp(r'([ABO]|AB)[-+]?'); RegExp bloodTypeRegex = RegExp(r'([ABO]|AB)[-+]?');
var match = bloodTypeRegex.firstMatch(line); var match = bloodTypeRegex.firstMatch(line);
if (match != null) { if (match != null) {
extractedInfo['blood_type'] = match.group(0)!.toUpperCase(); extractedInfo['Golongan_darah'] = match.group(0)!.toUpperCase();
return; return;
} }
@ -775,7 +811,7 @@ class AzureOCRService {
if (i + 1 < allLines.length) { if (i + 1 < allLines.length) {
String nextLine = allLines[i + 1].trim(); String nextLine = allLines[i + 1].trim();
if (nextLine.isNotEmpty && !_isLabelLine(nextLine)) { if (nextLine.isNotEmpty && !_isLabelLine(nextLine)) {
extractedInfo['blood_type'] = nextLine.toUpperCase(); extractedInfo['Golongan_darah'] = nextLine.toUpperCase();
return; return;
} }
} }
@ -789,10 +825,10 @@ class AzureOCRService {
final List<String> allLines = _getAllTextLinesFromReadAPI(ocrResult); final List<String> allLines = _getAllTextLinesFromReadAPI(ocrResult);
final String fullText = _getFullText(ocrResult); final String fullText = _getFullText(ocrResult);
// LoggerLogger().i raw extraction for debugging // print raw extraction for debugging
Logger().i('Extracted ${allLines.length} lines from KTA'); print('Extracted ${allLines.length} lines from KTA');
for (int i = 0; i < allLines.length; i++) { for (int i = 0; i < allLines.length; i++) {
Logger().i('Line $i: ${allLines[i]}'); print('Line $i: ${allLines[i]}');
} }
// Extract officer name // Extract officer name
@ -813,8 +849,8 @@ class AzureOCRService {
// Extract issue date (if available) // Extract issue date (if available)
_extractIssueDateFromKta(extractedInfo, allLines); _extractIssueDateFromKta(extractedInfo, allLines);
// LoggerLogger().i extracted information for debugging // print extracted information for debugging
Logger().i('Extracted KTA info: ${extractedInfo.toString()}'); print('Extracted KTA info: ${extractedInfo.toString()}');
return extractedInfo; return extractedInfo;
} }
@ -1095,7 +1131,7 @@ class AzureOCRService {
} }
return ''; return '';
} catch (e) { } catch (e) {
Logger().i('Error getting full text: $e'); print('Error getting full text: $e');
return ''; return '';
} }
} }
@ -1105,20 +1141,20 @@ class AzureOCRService {
final List<String> allText = []; final List<String> allText = [];
try { try {
// LoggerLogger().i raw structure for debugging // print raw structure for debugging
Logger().i('OCR Result structure: ${ocrResult.keys}'); print('OCR Result structure: ${ocrResult.keys}');
// Check if the response format uses readResults (v3.2 API) // Check if the response format uses readResults (v3.2 API)
if (ocrResult.containsKey('analyzeResult') && if (ocrResult.containsKey('analyzeResult') &&
ocrResult['analyzeResult'].containsKey('readResults')) { ocrResult['analyzeResult'].containsKey('readResults')) {
final List<dynamic> readResults = final List<dynamic> readResults =
ocrResult['analyzeResult']['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) { for (var page in readResults) {
if (page.containsKey('lines')) { if (page.containsKey('lines')) {
final List<dynamic> lines = page['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) { for (var line in lines) {
if (line.containsKey('text')) { if (line.containsKey('text')) {
@ -1132,7 +1168,7 @@ class AzureOCRService {
else if (ocrResult.containsKey('analyzeResult') && else if (ocrResult.containsKey('analyzeResult') &&
ocrResult['analyzeResult'].containsKey('pages')) { ocrResult['analyzeResult'].containsKey('pages')) {
final List<dynamic> pages = ocrResult['analyzeResult']['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) { for (var page in pages) {
if (page.containsKey('lines')) { if (page.containsKey('lines')) {
@ -1149,9 +1185,7 @@ class AzureOCRService {
ocrResult['analyzeResult'].containsKey('paragraphs')) { ocrResult['analyzeResult'].containsKey('paragraphs')) {
final List<dynamic> paragraphs = final List<dynamic> paragraphs =
ocrResult['analyzeResult']['paragraphs']; ocrResult['analyzeResult']['paragraphs'];
Logger().i( print('Found Paragraphs format with ${paragraphs.length} paragraphs');
'Found Paragraphs format with ${paragraphs.length} paragraphs',
);
for (var paragraph in paragraphs) { for (var paragraph in paragraphs) {
if (paragraph.containsKey('content')) { if (paragraph.containsKey('content')) {
@ -1159,15 +1193,15 @@ class AzureOCRService {
} }
} }
} else { } else {
Logger().i( print(
'Unrecognized OCR result format. Keys available: ${ocrResult.keys}', 'Unrecognized OCR result format. Keys available: ${ocrResult.keys}',
); );
if (ocrResult.containsKey('analyzeResult')) { 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, // As a fallback, if no lines were extracted and there's a content field,
// split content by newlines // split content by newlines
@ -1176,11 +1210,11 @@ class AzureOCRService {
ocrResult['analyzeResult'].containsKey('content')) { ocrResult['analyzeResult'].containsKey('content')) {
String content = ocrResult['analyzeResult']['content']; String content = ocrResult['analyzeResult']['content'];
allText.addAll(content.split('\n')); allText.addAll(content.split('\n'));
Logger().i('Used content fallback, extracted ${allText.length} lines'); print('Used content fallback, extracted ${allText.length} lines');
} }
} catch (e) { } catch (e) {
Logger().i('Error extracting text from OCR result: $e'); print('Error extracting text from OCR result: $e');
Logger().i('OCR Result structure that caused error: ${ocrResult.keys}'); print('OCR Result structure that caused error: ${ocrResult.keys}');
} }
return allText; return allText;
@ -1212,21 +1246,15 @@ class AzureOCRService {
return KtpModel( return KtpModel(
nik: extractedInfo['nik'] ?? '', nik: extractedInfo['nik'] ?? '',
name: extractedInfo['nama'] ?? '', name: extractedInfo['nama'] ?? '',
birthPlace: birthPlace: extractedInfo['tempat_lahir'] ?? '',
extractedInfo['birthPlace'] ?? extractedInfo['birth_place'] ?? '', birthDate: extractedInfo['tanggal_lahir'] ?? '',
birthDate: gender: extractedInfo['jenis_kelamin'] ?? '',
extractedInfo['birthDate'] ?? extractedInfo['tanggal_lahir'] ?? '', address: extractedInfo['alamat'] ?? '',
gender: extractedInfo['gender'] ?? extractedInfo['jenis_kelamin'] ?? '', nationality: extractedInfo['kewarganegaraan'] ?? '',
address: extractedInfo['alamat'] ?? extractedInfo['address'] ?? '', religion: extractedInfo['agama'],
nationality: occupation: extractedInfo['pekerjaan'],
extractedInfo['nationality'] ?? maritalStatus: extractedInfo['status_perkawinan'],
extractedInfo['kewarganegaraan'] ?? bloodType: extractedInfo['Golongan_darah'],
'WNI',
religion: extractedInfo['religion'] ?? extractedInfo['agama'],
occupation: extractedInfo['occupation'] ?? extractedInfo['pekerjaan'],
maritalStatus:
extractedInfo['marital_status'] ?? extractedInfo['status_perkawinan'],
bloodType: extractedInfo['blood_type'],
rtRw: extractedInfo['rt_rw'], rtRw: extractedInfo['rt_rw'],
kelurahan: extractedInfo['kelurahan'], kelurahan: extractedInfo['kelurahan'],
kecamatan: extractedInfo['kecamatan'], 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 // Process facial verification between ID card and selfie
Future<Map<String, dynamic>> verifyFace( Future<Map<String, dynamic>> verifyFace(
XFile idCardImage, XFile idCardImage,

View File

@ -1,6 +1,4 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_native_splash/flutter_native_splash.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:get_storage/get_storage.dart'; import 'package:get_storage/get_storage.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
@ -37,7 +35,7 @@ class AuthenticationRepository extends GetxController {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@override @override
void onReady() { void onReady() {
FlutterNativeSplash.remove(); // FlutterNativeSplash.remove();
screenRedirect(); screenRedirect();
} }
@ -69,60 +67,59 @@ class AuthenticationRepository extends GetxController {
/// Updated screenRedirect method to handle onboarding preferences /// Updated screenRedirect method to handle onboarding preferences
void screenRedirect({UserMetadataModel? arguments}) async { void screenRedirect({UserMetadataModel? arguments}) async {
// Use addPostFrameCallback to ensure navigation happens after the build cycle try {
WidgetsBinding.instance.addPostFrameCallback((_) async { final session = _supabase.auth.currentSession;
try { final bool isFirstTime = storage.read('isFirstTime') ?? false;
final session = _supabase.auth.currentSession; final isEmailVerified = session?.user.emailConfirmedAt != null;
final isProfileComplete =
session?.user.userMetadata?['profile_status'] == 'complete';
// Check if user has completed onboarding // Logger().i('isFirstTime screen redirect: $isFirstTime');
final bool isFirstTime = storage.read('isFirstTime') ?? false;
Logger().i('isFirstTime screen redirect: $isFirstTime'); // Cek lokasi terlebih dahulu
if (await _locationService.isLocationValidForFeature() == false) {
if (await _locationService.isLocationValidForFeature() == false) { _navigateToRoute(AppRoutes.locationWarning);
// Location is not valid, navigate to warning screen return;
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());
} }
});
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 // EMAIL & PASSWORD AUTHENTICATION
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@ -50,9 +50,13 @@ class FormRegistrationController extends GetxController {
// Officer data // Officer data
final Rx<OfficerModel?> officerModel = Rx<OfficerModel?>(null); final Rx<OfficerModel?> officerModel = Rx<OfficerModel?>(null);
// Loading state // Loading state
final RxBool isLoading = false.obs; final RxBool isLoading = false.obs;
// Data to be passed between steps
final Rx<dynamic> idCardData = Rx<dynamic>(null);
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
@ -298,7 +302,7 @@ class FormRegistrationController extends GetxController {
case 1: case 1:
return idCardVerificationController.validate(); return idCardVerificationController.validate();
case 2: case 2:
return selfieVerificationController.validate(formKey); return selfieVerificationController.validate();
case 3: case 3:
return selectedRole.value?.isOfficer == true return selectedRole.value?.isOfficer == true
? officerInfoController!.validate(formKey) ? 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 // 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() { void nextStep() {
if (!validateCurrentStep()) return; if (!validateCurrentStep()) return;
if (currentStep.value < totalSteps - 1) { if (currentStep.value < totalSteps - 1) {
currentStep.value++; currentStep.value++;
if (currentStep.value == 1) {
// Pass ID card data to the next step
passIdCardDataToNextStep();
}
} else { } else {
submitForm(); submitForm();
} }
} }
void clearPreviousStepErrors() { void clearPreviousStepErrors() {
switch (currentStep.value) { switch (currentStep.value) {
case 0: case 0:

View File

@ -42,6 +42,10 @@ class IdCardVerificationController extends GetxController {
final Rx<KtpModel?> ktpModel = Rx<KtpModel?>(null); final Rx<KtpModel?> ktpModel = Rx<KtpModel?>(null);
final Rx<KtaModel?> ktaModel = Rx<KtaModel?>(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() { bool validate() {
clearErrors(); clearErrors();
@ -62,7 +66,7 @@ class IdCardVerificationController extends GetxController {
void clearErrors() { void clearErrors() {
idCardError.value = ''; idCardError.value = '';
idCardValidationMessage.value = ''; // idCardValidationMessage.value = '';
} }
// Pick ID Card Image with file size validation // Pick ID Card Image with file size validation
@ -119,6 +123,10 @@ class IdCardVerificationController extends GetxController {
ktpModel.value = null; ktpModel.value = null;
ktaModel.value = null; ktaModel.value = null;
// Reset face detection flags
idCardFaceId.value = '';
hasFaceDetected.value = false;
if (idCardImage.value == null) { if (idCardImage.value == null) {
idCardError.value = 'Please upload an ID card image first'; idCardError.value = 'Please upload an ID card image first';
isIdCardValid.value = false; isIdCardValid.value = false;
@ -143,40 +151,36 @@ class IdCardVerificationController extends GetxController {
extractedInfo.assignAll(result); extractedInfo.assignAll(result);
hasExtractedInfo.value = result.isNotEmpty; hasExtractedInfo.value = result.isNotEmpty;
// Create model from extracted data // Check if the extracted information is valid using our validation methods
if (isOfficer) { if (isOfficer) {
ktaModel.value = KtaModel( isImageValid = _ocrService.isKtaValid(result);
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'] ?? '',
},
);
} else { } else {
ktpModel.value = KtpModel( isImageValid = _ocrService.isKtpValid(result);
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'],
);
} }
// If we get here without an exception, the image is likely valid // Create model from extracted data
isImageValid = result.isNotEmpty; if (isOfficer) {
ktaModel.value = _ocrService.createKtaModel(result);
} else {
ktpModel.value = _ocrService.createKtpModel(result);
}
// Try to detect faces in the ID card image
if (isImageValid) { 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; isIdCardValid.value = true;
idCardValidationMessage.value = idCardValidationMessage.value =
'$idCardType image looks valid. Please confirm this is your $idCardType.'; '$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 // Clear ID Card Image
void clearIdCardImage() { void clearIdCardImage() {
idCardImage.value = null; idCardImage.value = null;
@ -242,4 +253,9 @@ class IdCardVerificationController extends GetxController {
hasConfirmedIdCard.value = true; hasConfirmedIdCard.value = true;
} }
} }
// Get the verified model for passing to the next step
dynamic get verifiedIdCardModel {
return isOfficer ? ktaModel.value : ktpModel.value;
}
} }

View File

@ -1,408 +1,325 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/cores/services/azure_ocr_service.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/features/auth/presentasion/controllers/registration_form_controller.dart';
import 'package:sigap/src/utils/validators/validation.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 { class IdentityVerificationController extends GetxController {
// Singleton instance // Singleton instance
static IdentityVerificationController get instance => Get.find(); static IdentityVerificationController get instance => Get.find();
// Static form key // Dependencies
// final GlobalKey<FormState> formKey = TGlobalFormKey.identityVerification();
final AzureOCRService _ocrService = AzureOCRService();
final bool isOfficer; 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 // Error variables
late ImageVerificationController imageVerificationController; 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) // Verification states
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
final RxBool isVerifying = RxBool(false); final RxBool isVerifying = RxBool(false);
final RxBool isVerified = RxBool(false); final RxBool isVerified = RxBool(false);
final RxString verificationMessage = RxString(''); final RxString verificationMessage = RxString('');
// Face verification states // Face verification
final RxBool isVerifyingFace = RxBool(false); final RxBool isVerifyingFace = RxBool(false);
final RxBool isFaceVerified = RxBool(false); final RxBool isFaceVerified = RxBool(false);
final RxString faceVerificationMessage = RxString(''); 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 @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
_applyIdCardData();
}
// Get reference to the image verification controller // Apply ID card data from the previous step
void _applyIdCardData() {
try { 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) { } catch (e) {
// Controller not initialized yet, will retry later print('Error applying ID card data: $e');
} }
} }
// Validate form inputs
bool validate(GlobalKey<FormState> formKey) { bool validate(GlobalKey<FormState> formKey) {
clearErrors(); isFormValid.value = true;
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;
}
// For non-officers, we need to validate NIK and other KTP-related fields
if (!isOfficer) { if (!isOfficer) {
final nikValidation = TValidators.validateUserInput( if (nikController.text.isEmpty) {
'NIK', nikError.value = 'NIK is required';
nikController.text, isFormValid.value = false;
16, } else if (nikController.text.length != 16) {
); nikError.value = 'NIK must be 16 digits';
if (nikValidation != null) {
nikError.value = nikValidation;
isFormValid.value = false; isFormValid.value = false;
} }
// Validate full name if (fullNameController.text.isEmpty) {
final fullNameValidation = TValidators.validateUserInput( fullNameError.value = 'Full name is required';
'Full Name',
fullNameController.text,
100,
);
if (fullNameValidation != null) {
fullNameError.value = fullNameValidation;
isFormValid.value = false; isFormValid.value = false;
} }
// Validate place of birth if (placeOfBirthController.text.isEmpty) {
final placeOfBirthValidation = TValidators.validateUserInput( placeOfBirthError.value = 'Place of birth is required';
'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;
isFormValid.value = false; isFormValid.value = false;
} }
} }
// Bio can be optional, so we validate with required: false // These validations apply to both officers and non-officers
final bioValidation = TValidators.validateUserInput( if (birthDateController.text.isEmpty) {
'Bio', birthDateError.value = 'Birth date is required';
bioController.text,
255,
required: false,
);
if (bioValidation != null) {
bioError.value = bioValidation;
isFormValid.value = false; isFormValid.value = false;
} }
// Birth date validation if (selectedGender.value == null) {
final birthDateValidation = TValidators.validateUserInput( genderError.value = 'Please select your gender';
'Birth Date',
birthDateController.text,
10,
);
if (birthDateValidation != null) {
birthDateError.value = birthDateValidation;
isFormValid.value = false; 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() { // Verify ID card information against OCR results
nikError.value = ''; void verifyIdCardWithOCR() {
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;
}
try { try {
isVerifying.value = true; isVerifying.value = true;
final idCardType = isOfficer ? 'KTA' : 'KTP';
// Call Azure OCR service with the appropriate ID type // Compare form input with OCR results
final result = await _ocrService.processIdCard(idCardImage, isOfficer); final formController = Get.find<FormRegistrationController>();
final idCardData = formController.idCardData.value;
// Compare OCR results with user input if (idCardData != null) {
final bool isMatch = if (!isOfficer && idCardData is KtpModel) {
isOfficer ? _verifyKtaResults(result) : _verifyKtpResults(result); // Verify NIK matches
bool nikMatches = nikController.text == idCardData.nik;
isVerified.value = isMatch; // Verify name is similar (accounting for slight differences in formatting)
verificationMessage.value = bool nameMatches = _compareNames(
isMatch fullNameController.text,
? '$idCardType verification successful! Your information matches with your $idCardType.' idCardData.name,
: 'Verification failed. Please ensure your entered information matches your $idCardType.'; );
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) { } catch (e) {
isVerified.value = false; 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 { } finally {
isVerifying.value = false; isVerifying.value = false;
} }
} }
// Verify selfie with ID card // Simple name comparison function (ignores case, spaces)
Future<void> verifyFaceMatch() async { bool _compareNames(String name1, String name2) {
// Make sure we have reference to the image controller // Normalize names for comparison
if (!Get.isRegistered<ImageVerificationController>()) { String normalizedName1 = name1.toLowerCase().trim().replaceAll(
faceVerificationMessage.value = RegExp(r'\s+'),
'Error: Image verification data unavailable'; ' ',
isFaceVerified.value = false; );
return; 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 { // If more than half of the name parts match, consider it a match
imageVerificationController = Get.find<ImageVerificationController>(); return matches >= (parts1.length / 2).floor();
} 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;
}
} }
// Compare OCR results with user input for KTP // Simple face verification function simulation
bool _verifyKtpResults(Map<String, String> ocrResults) { void verifyFaceMatch() {
int matchCount = 0; isVerifyingFace.value = true;
int totalFields = 0;
// Check NIK matches (exact match required for numbers) // Simulate verification process with a delay
if (ocrResults.containsKey('nik') && nikController.text.isNotEmpty) { Future.delayed(const Duration(seconds: 2), () {
totalFields++; try {
// Clean up any spaces or special characters from OCR result // In a real implementation, this would call the proper face verification API
String ocrNik = ocrResults['nik']!.replaceAll(RegExp(r'[^0-9]'), ''); final formController = Get.find<FormRegistrationController>();
if (ocrNik == nikController.text) { final idCardData = formController.idCardData.value;
matchCount++;
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) // Clear all error messages
bool _verifyKtaResults(Map<String, String> ocrResults) { void clearErrors() {
// Since we're dealing with officer info in a separate step, nikError.value = '';
// this will compare only birthdate and general info, which is minimal fullNameError.value = '';
placeOfBirthError.value = '';
birthDateError.value = '';
genderError.value = '';
addressError.value = '';
int matchCount = 0; isFormValid.value = true;
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;
}
} }
@override @override
void onClose() { void onClose() {
nikController.dispose(); nikController.dispose();
bioController.dispose();
birthDateController.dispose();
fullNameController.dispose(); fullNameController.dispose();
placeOfBirthController.dispose(); placeOfBirthController.dispose();
birthDateController.dispose();
addressController.dispose(); addressController.dispose();
super.onClose(); super.onClose();
} }

View File

@ -1,9 +1,9 @@
import 'dart:io'; import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart'; import 'package:image_picker/image_picker.dart';
import 'package:sigap/src/cores/services/azure_ocr_service.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 { class SelfieVerificationController extends GetxController {
// Singleton instance // Singleton instance
@ -19,7 +19,6 @@ class SelfieVerificationController extends GetxController {
// For this step, we just need to ensure selfie is uploaded and validated // For this step, we just need to ensure selfie is uploaded and validated
final RxBool isFormValid = RxBool(true); final RxBool isFormValid = RxBool(true);
// Face verification variables // Face verification variables
final Rx<XFile?> selfieImage = Rx<XFile?>(null); final Rx<XFile?> selfieImage = Rx<XFile?>(null);
final RxString selfieError = RxString(''); final RxString selfieError = RxString('');
@ -35,9 +34,14 @@ class SelfieVerificationController extends GetxController {
// Confirmation status // Confirmation status
final RxBool hasConfirmedSelfie = RxBool(false); final RxBool hasConfirmedSelfie = RxBool(false);
bool validate(GlobalKey<FormState> formKey) { // Face comparison with ID card photo
clearErrors(); 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) { if (selfieImage.value == null) {
selfieError.value = 'Please take a selfie for verification'; selfieError.value = 'Please take a selfie for verification';
@ -66,8 +70,10 @@ class SelfieVerificationController extends GetxController {
Future<void> pickSelfieImage(ImageSource source) async { Future<void> pickSelfieImage(ImageSource source) async {
try { try {
isUploadingSelfie.value = true; isUploadingSelfie.value = true;
hasConfirmedSelfie.value = hasConfirmedSelfie.value = false; // Reset confirmation when image changes
false; // Reset confirmation whenever 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 ImagePicker picker = ImagePicker();
final XFile? image = await picker.pickImage( final XFile? image = await picker.pickImage(
@ -128,6 +134,21 @@ class SelfieVerificationController extends GetxController {
isSelfieValid.value = true; isSelfieValid.value = true;
selfieValidationMessage.value = selfieValidationMessage.value =
'Face detected. Please confirm this is you.'; '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 { } else {
isSelfieValid.value = false; isSelfieValid.value = false;
selfieValidationMessage.value = 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 // Clear Selfie Image
void clearSelfieImage() { void clearSelfieImage() {
selfieImage.value = null; selfieImage.value = null;
@ -150,6 +248,9 @@ class SelfieVerificationController extends GetxController {
selfieValidationMessage.value = ''; selfieValidationMessage.value = '';
isLivenessCheckPassed.value = false; isLivenessCheckPassed.value = false;
hasConfirmedSelfie.value = false; hasConfirmedSelfie.value = false;
isMatchWithIDCard.value = false;
matchConfidence.value = 0.0;
selfieImageFaceId.value = '';
} }
// Confirm Selfie Image // Confirm Selfie Image

View File

@ -27,6 +27,8 @@ class IdCardVerificationStep extends StatelessWidget {
final isOfficer = mainController.selectedRole.value?.isOfficer ?? false; final isOfficer = mainController.selectedRole.value?.isOfficer ?? false;
final String idCardType = isOfficer ? 'KTA' : 'KTP'; final String idCardType = isOfficer ? 'KTA' : 'KTP';
final isShow = controller.isIdCardValid.value;
return Form( return Form(
key: formKey, key: formKey,
child: Column( child: Column(
@ -51,7 +53,10 @@ class IdCardVerificationStep extends StatelessWidget {
// ID Card Upload Widget // ID Card Upload Widget
Obx( Obx(
() => ImageUploader( () => ImageUploader(
image: controller.idCardImage.value, image:
controller.isIdCardValid.value
? controller.idCardImage.value
: null,
title: 'Upload $idCardType Image', title: 'Upload $idCardType Image',
subtitle: 'Tap to select an image (max 4MB)', subtitle: 'Tap to select an image (max 4MB)',
errorMessage: controller.idCardError.value, errorMessage: controller.idCardError.value,
@ -71,7 +76,8 @@ class IdCardVerificationStep extends StatelessWidget {
// Display the appropriate model data // Display the appropriate model data
if (controller.isVerifying.value == false && if (controller.isVerifying.value == false &&
controller.idCardImage.value != null && controller.idCardImage.value != null &&
controller.idCardValidationMessage.value.isNotEmpty) { controller.idCardValidationMessage.value.isNotEmpty &&
controller.isIdCardValid.value) {
if (isOfficer && controller.ktaModel.value != null) { if (isOfficer && controller.ktaModel.value != null) {
return _buildKtaResultCard( return _buildKtaResultCard(
controller.ktaModel.value!, controller.ktaModel.value!,

View File

@ -2,16 +2,10 @@ import 'package:flutter/material.dart';
import 'package:get/get.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/registration_form_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_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/features/auth/presentasion/pages/registration-form/widgets/identity_verification/face_verification_section.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/id_info_form.dart';
import 'package:sigap/src/shared/widgets/form/form_section_header.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/constants/sizes.dart';
import 'package:sigap/src/utils/validators/validation.dart';
class IdentityVerificationStep extends StatelessWidget { class IdentityVerificationStep extends StatelessWidget {
const IdentityVerificationStep({super.key}); const IdentityVerificationStep({super.key});
@ -23,6 +17,8 @@ class IdentityVerificationStep extends StatelessWidget {
final mainController = Get.find<FormRegistrationController>(); final mainController = Get.find<FormRegistrationController>();
mainController.formKey = formKey; mainController.formKey = formKey;
final isOfficer = mainController.selectedRole.value?.isOfficer ?? false;
return Form( return Form(
key: formKey, key: formKey,
child: Column( child: Column(
@ -33,276 +29,15 @@ class IdentityVerificationStep extends StatelessWidget {
subtitle: 'Please provide additional personal details', subtitle: 'Please provide additional personal details',
), ),
// Different fields based on role // Personal Information Form Section
if (!mainController.selectedRole.value!.isOfficer) ...[ IdInfoForm(controller: controller, isOfficer: 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(),
),
const SizedBox(height: TSizes.spaceBtwSections), const SizedBox(height: TSizes.spaceBtwSections),
// Data verification button // Face Verification Section
ElevatedButton.icon( FaceVerificationSection(controller: controller),
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(),
),
], ],
), ),
); );
} }
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 = '';
}
}
} }

View File

@ -85,6 +85,72 @@ class SelfieVerificationStep extends StatelessWidget {
: const SizedBox.shrink(), : 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 // Error Messages
Obx( Obx(
() => () =>

View File

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

View File

@ -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 = '';
},
),
);
}
}

View File

@ -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 = '';
}
}
}

View File

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

View File

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

View File

@ -234,21 +234,21 @@ class ImageUploader extends StatelessWidget {
], ],
), ),
// Validate button // // Validate button
if (onValidate != null && !isVerifying && !isUploading && !isConfirmed) // if (onValidate != null && !isVerifying && !isUploading && !isConfirmed)
Padding( // Padding(
padding: const EdgeInsets.only(top: TSizes.sm), // padding: const EdgeInsets.only(top: TSizes.sm),
child: ElevatedButton.icon( // child: ElevatedButton.icon(
onPressed: onValidate, // onPressed: onValidate,
icon: const Icon(Icons.check_circle), // icon: const Icon(Icons.check_circle),
label: Text('Verify Image'), // label: Text('Verify Image'),
style: ElevatedButton.styleFrom( // style: ElevatedButton.styleFrom(
backgroundColor: TColors.primary, // backgroundColor: TColors.primary,
foregroundColor: Colors.white, // foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 50), // minimumSize: const Size(double.infinity, 50),
), // ),
), // ),
), // ),
], ],
); );
} }

View File

@ -8,16 +8,22 @@ class Endpoints {
static const String baseUrl = '$devUrl/api'; static const String baseUrl = '$devUrl/api';
static String get azureResource => dotenv.env['AZURE_RESOURCE_NAME'] ?? ''; static String get azureResource => dotenv.env['AZURE_RESOURCE_NAME'] ?? '';
static String get azureFaceResource =>
dotenv.env['AZURE_FACE_RESOURCE_NAME'] ?? '';
static String get azureSubscriptionKey => static String get azureSubscriptionKey =>
dotenv.env['AZURE_SUBSCRIPTION_KEY'] ?? ''; dotenv.env['AZURE_SUBSCRIPTION_KEY'] ?? '';
static String get azureFaceSubscriptionKey =>
dotenv.env['AZURE_FACE_SUBSCRIPTION_KEY'] ?? '';
static String get azureEndpoint => static String get azureEndpoint =>
'https://$azureResource.cognitiveservices.azure.com/'; '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 get ocrApiPath => 'vision/v3.2/read/analyze';
static String ocrResultPath(String operationId) => static String ocrResultPath(String operationId) =>
'vision/v3.2/read/analyzeResults/$operationId'; 'vision/v3.2/read/analyzeResults/$operationId';
static String get faceApiPath => 'face/v1.0/detect'; static String get faceApiPath => 'face/v1.2/detect';
static String get faceVerifyPath => 'face/v1.0/verify'; static String get faceVerifyPath => 'face/v1.2/verify';
} }

View File

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