feat: Enhance selfie verification process with ID card comparison
- Added functionality to compare the uploaded selfie with the ID card photo. - Introduced new state variables in SelfieVerificationController to manage face comparison status and confidence levels. - Implemented face detection and ID comparison logic in the selfie verification workflow. - Updated UI components in SelfieVerificationStep to display face match results and provide retry options. - Refactored IdentityVerificationStep to utilize new widget structure for better organization. - Created separate widgets for identity verification fields and face verification section for improved readability and maintainability. - Updated API endpoints for Azure Face API to use the latest version. - Removed deprecated validation button from ImageUploader widget. - Added navigation utility class for better route management.
This commit is contained in:
parent
1908318769
commit
d9fffff68d
|
@ -41,4 +41,6 @@ NODE_ENV=development
|
||||||
|
|
||||||
# Azure AI API
|
# Azure 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"
|
|
@ -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");
|
||||||
|
|
|
@ -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,
|
||||||
|
|
|
@ -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
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
|
@ -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:
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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!,
|
||||||
|
|
|
@ -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 = '';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -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(
|
||||||
() =>
|
() =>
|
||||||
|
|
|
@ -0,0 +1,82 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/form/form_section_header.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/form/verification_status.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/verification/validation_message_card.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/colors.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
|
||||||
|
class FaceVerificationSection extends StatelessWidget {
|
||||||
|
final IdentityVerificationController controller;
|
||||||
|
|
||||||
|
const FaceVerificationSection({super.key, required this.controller});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const FormSectionHeader(
|
||||||
|
title: 'Face Verification',
|
||||||
|
subtitle: 'Verify that your face matches with your ID card photo',
|
||||||
|
),
|
||||||
|
|
||||||
|
// Face Verification Status
|
||||||
|
Obx(
|
||||||
|
() => VerificationStatus(
|
||||||
|
isVerifying: controller.isVerifyingFace.value,
|
||||||
|
verifyingMessage: 'Comparing face with ID photo...',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Face match verification button
|
||||||
|
_buildFaceVerificationButton(),
|
||||||
|
|
||||||
|
// Face Verification Message
|
||||||
|
Obx(
|
||||||
|
() =>
|
||||||
|
controller.faceVerificationMessage.value.isNotEmpty
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: TSizes.spaceBtwItems,
|
||||||
|
),
|
||||||
|
child: ValidationMessageCard(
|
||||||
|
message: controller.faceVerificationMessage.value,
|
||||||
|
isValid: controller.isFaceVerified.value,
|
||||||
|
hasConfirmed: false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildFaceVerificationButton() {
|
||||||
|
return Obx(
|
||||||
|
() => ElevatedButton.icon(
|
||||||
|
onPressed:
|
||||||
|
controller.isVerifyingFace.value || controller.isFaceVerified.value
|
||||||
|
? null
|
||||||
|
: () => controller.verifyFaceMatch(),
|
||||||
|
icon: const Icon(Icons.face_retouching_natural),
|
||||||
|
label: Text(
|
||||||
|
controller.isFaceVerified.value
|
||||||
|
? 'Face Verified Successfully'
|
||||||
|
: 'Verify Face Match',
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor:
|
||||||
|
controller.isFaceVerified.value ? Colors.green : TColors.primary,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
minimumSize: const Size(double.infinity, 50),
|
||||||
|
disabledBackgroundColor:
|
||||||
|
controller.isFaceVerified.value
|
||||||
|
? Colors.green.withOpacity(0.7)
|
||||||
|
: null,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,146 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/place_of_birth_field.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/verification_action_button.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/verification_status_message.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/form/date_picker_field.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/form/gender_selection.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/form/verification_status.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
import 'package:sigap/src/utils/validators/validation.dart';
|
||||||
|
|
||||||
|
class IdInfoForm extends StatelessWidget {
|
||||||
|
final IdentityVerificationController controller;
|
||||||
|
final bool isOfficer;
|
||||||
|
|
||||||
|
const IdInfoForm({
|
||||||
|
super.key,
|
||||||
|
required this.controller,
|
||||||
|
required this.isOfficer,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Different fields based on role
|
||||||
|
if (!isOfficer) ...[
|
||||||
|
// NIK field for non-officers
|
||||||
|
_buildNikField(),
|
||||||
|
|
||||||
|
// Full Name field
|
||||||
|
_buildFullNameField(),
|
||||||
|
|
||||||
|
// Place of Birth field with city selection
|
||||||
|
PlaceOfBirthField(controller: controller),
|
||||||
|
],
|
||||||
|
|
||||||
|
// Birth Date field with calendar picker
|
||||||
|
Obx(
|
||||||
|
() => DatePickerField(
|
||||||
|
label: 'Birth Date',
|
||||||
|
controller: controller.birthDateController,
|
||||||
|
errorText: controller.birthDateError.value,
|
||||||
|
onDateSelected: (date) {
|
||||||
|
controller.birthDateError.value = '';
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Gender selection
|
||||||
|
Obx(
|
||||||
|
() => GenderSelection(
|
||||||
|
selectedGender: controller.selectedGender.value!,
|
||||||
|
onGenderChanged: (value) {
|
||||||
|
if (value != null) {
|
||||||
|
controller.selectedGender.value = value;
|
||||||
|
controller.genderError.value = '';
|
||||||
|
}
|
||||||
|
},
|
||||||
|
errorText: controller.genderError.value,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Address field
|
||||||
|
_buildAddressField(),
|
||||||
|
|
||||||
|
// Verification Status
|
||||||
|
Obx(
|
||||||
|
() => VerificationStatus(
|
||||||
|
isVerifying: controller.isVerifying.value,
|
||||||
|
verifyingMessage: 'Processing your personal information...',
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// Verification Message
|
||||||
|
VerificationStatusMessage(controller: controller),
|
||||||
|
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
|
||||||
|
// Data verification button
|
||||||
|
VerificationActionButton(controller: controller),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// NIK field
|
||||||
|
Widget _buildNikField() {
|
||||||
|
return Obx(
|
||||||
|
() => CustomTextField(
|
||||||
|
label: 'NIK (Identity Number)',
|
||||||
|
controller: controller.nikController,
|
||||||
|
validator: (value) => TValidators.validateUserInput('NIK', value, 16),
|
||||||
|
errorText: controller.nikError.value,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
keyboardType: TextInputType.number,
|
||||||
|
hintText: 'e.g., 1234567890123456',
|
||||||
|
onChanged: (value) {
|
||||||
|
controller.nikController.text = value;
|
||||||
|
controller.nikError.value = '';
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Full Name field
|
||||||
|
Widget _buildFullNameField() {
|
||||||
|
return Obx(
|
||||||
|
() => CustomTextField(
|
||||||
|
label: 'Full Name',
|
||||||
|
controller: controller.fullNameController,
|
||||||
|
validator:
|
||||||
|
(value) => TValidators.validateUserInput('Full Name', value, 100),
|
||||||
|
errorText: controller.fullNameError.value,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
hintText: 'Enter your full name as on KTP',
|
||||||
|
onChanged: (value) {
|
||||||
|
controller.fullNameController.text = value;
|
||||||
|
controller.fullNameError.value = '';
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Address field
|
||||||
|
Widget _buildAddressField() {
|
||||||
|
return Obx(
|
||||||
|
() => CustomTextField(
|
||||||
|
label: 'Address',
|
||||||
|
controller: controller.addressController,
|
||||||
|
validator:
|
||||||
|
(value) => TValidators.validateUserInput('Address', value, 255),
|
||||||
|
errorText: controller.addressError.value,
|
||||||
|
textInputAction: TextInputAction.next,
|
||||||
|
hintText: 'Enter your address as on KTP',
|
||||||
|
maxLines: 3,
|
||||||
|
onChanged: (value) {
|
||||||
|
controller.addressController.text = value;
|
||||||
|
controller.addressError.value = '';
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,85 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/city_selection.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/colors.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
|
||||||
|
class PlaceOfBirthField extends StatelessWidget {
|
||||||
|
final IdentityVerificationController controller;
|
||||||
|
|
||||||
|
const PlaceOfBirthField({super.key, required this.controller});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Obx(
|
||||||
|
() => Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Text('Place of Birth', style: Theme.of(context).textTheme.bodyMedium),
|
||||||
|
const SizedBox(height: TSizes.xs),
|
||||||
|
GestureDetector(
|
||||||
|
onTap: () => _navigateToCitySelection(context),
|
||||||
|
child: Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: TSizes.md,
|
||||||
|
vertical: TSizes.md,
|
||||||
|
),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
border: Border.all(
|
||||||
|
color:
|
||||||
|
controller.placeOfBirthError.value.isNotEmpty
|
||||||
|
? TColors.error
|
||||||
|
: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
borderRadius: BorderRadius.circular(TSizes.borderRadiusLg),
|
||||||
|
color: Colors.transparent,
|
||||||
|
),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
Text(
|
||||||
|
controller.placeOfBirthController.text.isEmpty
|
||||||
|
? 'Select Place of Birth'
|
||||||
|
: controller.placeOfBirthController.text,
|
||||||
|
style: TextStyle(
|
||||||
|
color:
|
||||||
|
controller.placeOfBirthController.text.isEmpty
|
||||||
|
? Theme.of(context).textTheme.bodyMedium?.color
|
||||||
|
: TColors.textSecondary,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Icon(
|
||||||
|
Icons.location_city,
|
||||||
|
size: TSizes.iconSm,
|
||||||
|
color: Theme.of(context).textTheme.bodyMedium?.color,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
if (controller.placeOfBirthError.value.isNotEmpty)
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.only(top: TSizes.xs, left: TSizes.xs),
|
||||||
|
child: Text(
|
||||||
|
controller.placeOfBirthError.value,
|
||||||
|
style: Theme.of(context).textTheme.bodySmall?.copyWith(
|
||||||
|
color: TColors.error,
|
||||||
|
fontSize: 12,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: TSizes.spaceBtwItems),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _navigateToCitySelection(BuildContext context) async {
|
||||||
|
final selectedCity = await Get.to<String>(() => const CitySelectionPage());
|
||||||
|
if (selectedCity != null && selectedCity.isNotEmpty) {
|
||||||
|
controller.placeOfBirthController.text = selectedCity;
|
||||||
|
controller.placeOfBirthError.value = '';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,33 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/colors.dart';
|
||||||
|
|
||||||
|
class VerificationActionButton extends StatelessWidget {
|
||||||
|
final IdentityVerificationController controller;
|
||||||
|
|
||||||
|
const VerificationActionButton({super.key, required this.controller});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Obx(
|
||||||
|
() => ElevatedButton.icon(
|
||||||
|
onPressed:
|
||||||
|
controller.isVerifying.value
|
||||||
|
? null
|
||||||
|
: () => controller.verifyIdCardWithOCR(),
|
||||||
|
icon: const Icon(Icons.verified_user),
|
||||||
|
label: Text(
|
||||||
|
controller.isVerified.value
|
||||||
|
? 'Re-verify Personal Information'
|
||||||
|
: 'Verify Personal Information',
|
||||||
|
),
|
||||||
|
style: ElevatedButton.styleFrom(
|
||||||
|
backgroundColor: TColors.primary,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
minimumSize: const Size(double.infinity, 50),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -0,0 +1,30 @@
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart';
|
||||||
|
import 'package:sigap/src/shared/widgets/verification/validation_message_card.dart';
|
||||||
|
import 'package:sigap/src/utils/constants/sizes.dart';
|
||||||
|
|
||||||
|
class VerificationStatusMessage extends StatelessWidget {
|
||||||
|
final IdentityVerificationController controller;
|
||||||
|
|
||||||
|
const VerificationStatusMessage({super.key, required this.controller});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Obx(
|
||||||
|
() =>
|
||||||
|
controller.verificationMessage.value.isNotEmpty
|
||||||
|
? Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
vertical: TSizes.spaceBtwItems,
|
||||||
|
),
|
||||||
|
child: ValidationMessageCard(
|
||||||
|
message: controller.verificationMessage.value,
|
||||||
|
isValid: controller.isVerified.value,
|
||||||
|
hasConfirmed: false,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: const SizedBox.shrink(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
|
@ -234,21 +234,21 @@ class ImageUploader extends StatelessWidget {
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
// Validate button
|
// // 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),
|
||||||
),
|
// ),
|
||||||
),
|
// ),
|
||||||
),
|
// ),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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';
|
||||||
}
|
}
|
||||||
|
|
|
@ -0,0 +1,13 @@
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
|
||||||
|
class TNavigation {
|
||||||
|
void pushNamed(String routeName) {
|
||||||
|
// Implement your navigation logic here
|
||||||
|
}
|
||||||
|
|
||||||
|
void navigateToRoute(String routeName) {
|
||||||
|
if (Get.currentRoute != routeName) {
|
||||||
|
Get.offAllNamed(routeName);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue