feat: Implement facial verification service and selfie verification controller

- Added FacialVerificationService for handling face detection and comparison.
- Created SelfieVerificationController to manage selfie capture and validation against ID card.
- Integrated liveness detection functionality within the selfie verification process.
- Developed SignupWithRoleController for user registration with role selection.
- Introduced PersonalInfoController for managing user personal information during signup.
- Added CountdownOverlayWidget for displaying countdown during liveness check.
- Enhanced error handling and validation across controllers.
This commit is contained in:
vergiLgood1 2025-05-24 21:51:30 +07:00
parent bd99a3dd40
commit 0ee2d72134
44 changed files with 1997 additions and 1628 deletions

View File

@ -0,0 +1,180 @@
# Face Detection Troubleshooting Guide
## Common Detection Issue
If you're experiencing issues with face detection failing or consistently returning zero faces (`Detected 0 faces`), this guide will help you understand and resolve the problem.
## Understanding the Problem
The ML Kit face detection can fail for several reasons, but one of the most common is related to image orientation and processing. This is especially problematic on iOS devices where camera orientation metadata isn't correctly passed to the ML Kit detector.
### Common Error Logs
```
[LIVENESS_CONTROLLER] Detected 0 faces
[LIVENESS_CONTROLLER] Detected 0 faces
[LIVENESS_CONTROLLER] Detected 0 faces
```
When you see these logs repeatedly, it means that the face detector is unable to identify any faces in the camera frames, even when a face is clearly visible to the user.
## Root Causes
1. **Image Orientation Issues**: On iOS especially, the orientation metadata in images captured from the camera may not be correctly interpreted by ML Kit.
2. **Format Compatibility**: Different platforms use different image formats (YUV420, NV21, BGRA8888), and improper handling can lead to detection failures.
3. **Image Processing**: Camera frames need proper conversion before they can be processed by ML Kit.
4. **Resolution Issues**: Low-resolution images can make face detection difficult.
## Solution Implementation
Our solution implements several fixes based on common patterns found on GitHub and in the ML Kit community:
### 1. Platform-Specific Image Format Handling
```dart
cameraController = CameraController(
frontCamera,
ResolutionPreset.high, // Higher resolution
enableAudio: false,
imageFormatGroup: Platform.isIOS ? ImageFormatGroup.bgra8888 : ImageFormatGroup.yuv420,
);
```
We use different image format groups for iOS and Android to ensure compatibility.
### 2. Proper Image Rotation
For real-time detection, we carefully calculate the correct rotation:
```dart
if (Platform.isIOS) {
// iOS-specific rotation handling
rotation = InputImageRotationValue.fromRawValue(sensorOrientation);
} else if (Platform.isAndroid) {
// Android-specific rotation calculation
var rotationCompensation = orientations[cameraController!.value.deviceOrientation];
if (rotationCompensation == null) return null;
if (camera.lensDirection == CameraLensDirection.front) {
rotationCompensation = (sensorOrientation + rotationCompensation) % 360;
} else {
rotationCompensation = (sensorOrientation - rotationCompensation + 360) % 360;
}
rotation = InputImageRotationValue.fromRawValue(rotationCompensation);
}
```
### 3. Platform-Specific Image Processing
For Android, we handle YUV420 format which requires proper handling of multiple image planes:
```dart
if (Platform.isAndroid) {
// Android requires proper YUV420 handling with all planes
final plane1 = image.planes[0];
final plane2 = image.planes[1];
final plane3 = image.planes[2];
return InputImage.fromBytes(
bytes: Uint8List.fromList([...plane1.bytes, ...plane2.bytes, ...plane3.bytes]),
metadata: InputImageMetadata(
size: Size(image.width.toDouble(), image.height.toDouble()),
rotation: rotation,
format: format,
bytesPerRow: plane1.bytesPerRow,
),
);
}
```
### 4. Fix for Captured Images on iOS
When capturing a still image (as opposed to real-time stream processing), we apply a special fix for iOS to ensure proper orientation:
```dart
Future<XFile> _processAndFixImageOrientation(XFile originalImage) async {
if (Platform.isIOS) {
try {
// Get temp directory
final directory = await getApplicationDocumentsDirectory();
final path = directory.path;
final filename = 'processed_${DateTime.now().millisecondsSinceEpoch}.jpg';
final outputPath = '$path/$filename';
// Read and decode
final imageBytes = await originalImage.readAsBytes();
final originalDecodedImage = imglib.decodeImage(imageBytes);
// Fix orientation
final orientedImage = imglib.bakeOrientation(originalDecodedImage);
// Save
final processedImageFile = File(outputPath);
await processedImageFile.writeAsBytes(imglib.encodeJpg(orientedImage));
return XFile(processedImageFile.path);
} catch (e) {
return originalImage; // Fall back if processing fails
}
}
// No fix needed for Android usually
return originalImage;
}
```
### 5. Image Verification
We added a verification step that checks if a captured image actually contains a face:
```dart
Future<bool> _verifyFaceInImage(XFile image) async {
try {
final inputImage = InputImage.fromFilePath(image.path);
final faces = await faceDetector.processImage(inputImage);
return faces.isNotEmpty;
} catch (e) {
return false;
}
}
```
## How These Fixes Work
1. **For iOS**: We explicitly handle orientation using the `bakeOrientation` method from the `image` package, which ensures that the image pixels are correctly rotated in memory rather than relying on metadata.
2. **For Android**: We handle all three YUV planes and ensure the correct rotation is calculated based on device orientation and camera direction.
3. **For Both Platforms**: We use higher resolution settings and proper image format groups.
## Additional Dependencies Required
To implement these fixes, make sure to add these dependencies to your `pubspec.yaml`:
```yaml
dependencies:
image: ^4.0.17 # For orientation baking
path_provider: ^2.0.15 # For accessing temporary directories
```
## Common Pitfalls
1. **Missing Planes**: Always check that image planes are available before processing.
2. **Rotation Calculation**: Be careful with rotation calculation, as different platforms and camera directions require different formulas.
3. **Memory Usage**: Higher resolution images improve detection but consume more memory. Monitor performance.
4. **Permissions**: Ensure camera permissions are properly granted.
## References
- [Google ML Kit Documentation](https://developers.google.com/ml-kit)
- [Flutter Camera Plugin](https://pub.dev/packages/camera)
- [Image Package Documentation](https://pub.dev/packages/image)
- [Face Detection GitHub Issue - Flutter](https://github.com/flutter/flutter/issues/79116)
## Conclusion
Face detection issues can often be fixed by properly handling image orientation and format. The implementation details vary by platform, so it's important to have platform-specific handling. By applying these fixes, you should see a significant improvement in face detection reliability.

View File

@ -1,10 +1,10 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/basic/email_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/others/email_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/basic/forgot_password_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/others/forgot_password_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/other/registration_form_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/basic/signin_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signin/signin_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/basic/signup_with_role_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/signup_with_role_controller.dart';
class AuthControllerBindings extends Bindings { class AuthControllerBindings extends Bindings {
@override @override

View File

@ -1,714 +0,0 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
import 'package:sigap/src/features/auth/data/models/face_model.dart';
import 'package:sigap/src/features/auth/data/models/kta_model.dart';
import 'package:sigap/src/features/auth/data/models/ktp_model.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/basic/signup_with_role_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/id-card-verification/id_card_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/facial_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/selfie_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/viewer-information/personal_info_controller.dart';
class IdentityVerificationController extends GetxController {
// Singleton instance
static IdentityVerificationController get instance => Get.find();
// Dependencies
final bool isOfficer;
final AzureOCRService _ocrService = AzureOCRService();
final FacialVerificationService _faceService =
FacialVerificationService.instance;
// Local storage keys
static const String _kOcrResultsKey = 'ocr_results';
static const String _kOcrModelKey = 'ocr_model';
static const String _kIdCardTypeKey = 'id_card_type';
// Controllers for form fields
final TextEditingController nikController = TextEditingController();
final TextEditingController fullNameController = TextEditingController();
final TextEditingController placeOfBirthController = TextEditingController();
final TextEditingController birthDateController = TextEditingController();
final TextEditingController addressController = TextEditingController();
// Form validation errors
final RxString nikError = RxString('');
final RxString fullNameError = RxString('');
final RxString placeOfBirthError = RxString('');
final RxString birthDateError = RxString('');
final RxString genderError = RxString('');
final RxString addressError = RxString('');
// ID verification states
final RxBool isVerifying = RxBool(false);
final RxBool isVerified = RxBool(false);
final RxString verificationMessage = RxString('');
// Face verification states
final RxBool isVerifyingFace = RxBool(false);
final RxBool isFaceVerified = RxBool(false);
final RxString faceVerificationMessage = RxString('');
final Rx<FaceComparisonResult?> faceComparisonResult =
Rx<FaceComparisonResult?>(null);
// Gender selection dropdown
final Rx<String?> selectedGender = Rx<String?>('Male');
// Form validation state
final RxBool isFormValid = RxBool(true);
// Flag to prevent infinite loop
bool _isApplyingData = false;
// UI control states
final RxBool isNikReadOnly = RxBool(false);
final RxBool isPreFilledNik = false.obs;
// Storage for extracted data
final RxMap<String, dynamic> ocrData = RxMap<String, dynamic>({});
final String? extractedIdCardNumber;
final String? extractedName;
// Data saving states
final RxBool isSavingData = RxBool(false);
final RxBool isDataSaved = RxBool(false);
final RxString dataSaveMessage = RxString('');
// Summary data for review page
final RxMap<String, dynamic> summaryData = RxMap<String, dynamic>({});
// Verification status of different sections
final RxBool isBasicInfoVerified = RxBool(false);
final RxBool isIdCardVerified = RxBool(false);
final RxBool isSelfieVerified = RxBool(false);
final RxBool isContactInfoVerified = RxBool(false);
final RxBool isLoadingSummary = RxBool(false);
IdentityVerificationController({
this.extractedIdCardNumber = '',
this.extractedName = '',
required this.isOfficer,
});
@override
void onInit() {
super.onInit();
// Set default gender value
selectedGender.value = selectedGender.value ?? 'Male';
// Load data in sequence
_initializeData();
}
// Initialize all data in sequence
Future<void> _initializeData() async {
try {
// First load OCR data
await loadOcrDataFromLocalStorage();
// Then load data from previous steps for summary
await loadAllStepsData();
} catch (e) {
print('Error initializing data: $e');
}
}
// Load data from all previous steps
Future<void> loadAllStepsData() async {
try {
isLoadingSummary.value = true;
// Get references to all controllers
final registrationController = Get.find<FormRegistrationController>();
final idCardController = Get.find<IdCardVerificationController>();
final selfieController = Get.find<SelfieVerificationController>();
// Load data from each controller
_loadBasicInfoData(registrationController);
_loadIdCardData(idCardController);
_loadSelfieData(selfieController);
// Pre-fill form with extracted data
_prefillFormWithExtractedData(idCardController);
} catch (e) {
print('Error loading steps data: $e');
} finally {
isLoadingSummary.value = false;
}
}
// Load basic information
void _loadBasicInfoData(FormRegistrationController controller) {
try {
// Add basic info to summary
summaryData['email'] = SignupWithRoleController().emailController.text;
summaryData['phone'] = PersonalInfoController.instance.phoneController.text;
summaryData['role'] = controller.selectedRole.value?.name ?? 'Unknown';
isBasicInfoVerified.value = true;
} catch (e) {
print('Error loading basic info: $e');
isBasicInfoVerified.value = false;
}
}
// Load ID card data
void _loadIdCardData(IdCardVerificationController controller) {
try {
// Add ID card info to summary
summaryData['idCardType'] = isOfficer ? 'KTA' : 'KTP';
summaryData['idCardValid'] = controller.isIdCardValid.value;
summaryData['idCardConfirmed'] = controller.hasConfirmedIdCard.value;
summaryData['extractedInfo'] = controller.extractedInfo;
// Add model data
if (isOfficer) {
summaryData['ktaModel'] = controller.ktaModel.value?.toJson();
} else {
summaryData['ktpModel'] = controller.ktpModel.value?.toJson();
}
isIdCardVerified.value =
controller.isIdCardValid.value && controller.hasConfirmedIdCard.value;
} catch (e) {
print('Error loading ID card data: $e');
isIdCardVerified.value = false;
}
}
// Load selfie data
void _loadSelfieData(SelfieVerificationController controller) {
try {
// Add selfie verification info to summary
summaryData['selfieValid'] = controller.isSelfieValid.value;
summaryData['selfieConfirmed'] = controller.hasConfirmedSelfie.value;
summaryData['livenessCheckPassed'] =
controller.isLivenessCheckPassed.value;
summaryData['faceMatchResult'] = controller.isMatchWithIDCard.value;
summaryData['faceMatchConfidence'] = controller.matchConfidence.value;
isSelfieVerified.value =
controller.isSelfieValid.value && controller.hasConfirmedSelfie.value;
} catch (e) {
print('Error loading selfie data: $e');
isSelfieVerified.value = false;
}
}
// Pre-fill form with extracted data
void _prefillFormWithExtractedData(IdCardVerificationController controller) {
try {
if (!isOfficer && controller.ktpModel.value != null) {
// Extract KTP data
final ktp = controller.ktpModel.value!;
if (ktp.nik.isNotEmpty) {
nikController.text = ktp.nik;
summaryData['nik'] = ktp.nik;
}
if (ktp.name.isNotEmpty) {
fullNameController.text = ktp.name;
summaryData['fullName'] = ktp.name;
}
if (ktp.birthPlace.isNotEmpty) {
placeOfBirthController.text = ktp.birthPlace;
summaryData['placeOfBirth'] = ktp.birthPlace;
}
if (ktp.birthDate.isNotEmpty) {
birthDateController.text = ktp.birthDate;
summaryData['birthDate'] = ktp.birthDate;
}
if (ktp.gender.isNotEmpty) {
// Convert gender to the format expected by the dropdown
String gender = ktp.gender.toLowerCase();
if (gender.contains('laki') || gender == 'male') {
selectedGender.value = 'Male';
} else if (gender.contains('perempuan') || gender == 'female') {
selectedGender.value = 'Female';
}
summaryData['gender'] = selectedGender.value;
}
if (ktp.address.isNotEmpty) {
addressController.text = ktp.address;
summaryData['address'] = ktp.address;
}
// Make NIK field read-only since it's extracted from KTP
isNikReadOnly.value = true;
} else if (isOfficer && controller.ktaModel.value != null) {
// Extract KTA data
final kta = controller.ktaModel.value!;
if (kta.name.isNotEmpty) {
fullNameController.text = kta.name;
summaryData['fullName'] = kta.name;
}
// Extract birth date from extra data if available
if (kta.extraData != null &&
kta.extraData!.containsKey('tanggal_lahir') &&
kta.extraData!['tanggal_lahir'] != null) {
birthDateController.text = kta.extraData!['tanggal_lahir'];
summaryData['birthDate'] = kta.extraData!['tanggal_lahir'];
}
}
} catch (e) {
print('Error prefilling form with extracted data: $e');
}
}
// Load OCR data from local storage
Future<void> loadOcrDataFromLocalStorage() async {
try {
final prefs = await SharedPreferences.getInstance();
final String? storedIdCardType = prefs.getString(_kIdCardTypeKey);
print(
'Stored ID card type: $storedIdCardType, Current isOfficer: $isOfficer',
);
if (storedIdCardType == null ||
(isOfficer && storedIdCardType != 'KTA') ||
(!isOfficer && storedIdCardType != 'KTP')) {
print('No matching ID card data found in storage or type mismatch');
return;
}
final String? jsonData = prefs.getString(_kOcrResultsKey);
if (jsonData != null) {
print('Found OCR data in storage: ${jsonData.length} chars');
final Map<String, dynamic> results = jsonDecode(jsonData);
ocrData.assignAll(results);
print('OCR data loaded: ${results.length} items');
final String? modelJson = prefs.getString(_kOcrModelKey);
if (modelJson != null) {
try {
if (isOfficer) {
final ktaModel = KtaModel.fromJson(jsonDecode(modelJson));
print('KTA model loaded successfully');
applyKtaDataToForm(ktaModel);
} else {
final ktpModel = KtpModel.fromJson(jsonDecode(modelJson));
print('KTP model loaded successfully - NIK: ${ktpModel.nik}');
applyKtpDataToForm(ktpModel);
}
isNikReadOnly.value = true;
} catch (e) {
print('Error parsing model JSON: $e');
}
}
} else {
print('No OCR data found in storage');
}
} catch (e) {
print('Error loading OCR data from local storage: $e');
} finally {
if (ocrData.isEmpty) {
print('Falling back to FormRegistrationController data');
_safeApplyIdCardData();
}
}
}
// Apply KTP data to form
void applyKtpDataToForm(KtpModel ktpModel) {
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) {
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
isVerified.value = true;
verificationMessage.value = 'KTP information loaded successfully';
}
// Apply KTA data to form
void applyKtaDataToForm(KtaModel ktaModel) {
if (ktaModel.name.isNotEmpty) fullNameController.text = ktaModel.name;
if (ktaModel.extraData != null &&
ktaModel.extraData!.containsKey('tanggal_lahir') &&
ktaModel.extraData!['tanggal_lahir'] != null) {
birthDateController.text = ktaModel.extraData!['tanggal_lahir'];
}
isVerified.value = true;
verificationMessage.value = 'KTA information loaded successfully';
}
// Safe method to apply ID card data without risk of stack overflow
void _safeApplyIdCardData() {
if (_isApplyingData) return;
try {
_isApplyingData = true;
if (!Get.isRegistered<FormRegistrationController>()) return;
final formController = Get.find<FormRegistrationController>();
if (formController.idCardData.value == null) return;
final idCardData = formController.idCardData.value;
if (idCardData != null) {
if (!isOfficer && idCardData is KtpModel) {
applyKtpDataToForm(idCardData);
isNikReadOnly.value = true;
} else if (isOfficer && idCardData is KtaModel) {
applyKtaDataToForm(idCardData);
}
}
} catch (e) {
print('Error applying ID card data: $e');
} finally {
_isApplyingData = false;
}
}
// Validate form inputs
bool validate(GlobalKey<FormState>? formKey) {
isFormValid.value = true;
clearErrors();
// Validate required fields based on officer status
if (!isOfficer) {
// KTP validation
if (nikController.text.isEmpty) {
nikError.value = 'NIK is required';
isFormValid.value = false;
} else if (nikController.text.length != 16) {
nikError.value = 'NIK must be 16 digits';
isFormValid.value = false;
}
if (fullNameController.text.isEmpty) {
fullNameError.value = 'Full name is required';
isFormValid.value = false;
}
if (placeOfBirthController.text.isEmpty) {
placeOfBirthError.value = 'Place of birth is required';
isFormValid.value = false;
}
} else {
// KTA validation
if (fullNameController.text.isEmpty) {
fullNameError.value = 'Full name is required';
isFormValid.value = false;
}
}
// Common validations
if (birthDateController.text.isEmpty) {
birthDateError.value = 'Birth date is required';
isFormValid.value = false;
}
if (selectedGender.value == null) {
genderError.value = 'Gender is required';
isFormValid.value = false;
}
// Verify previous steps completion
bool allPreviousStepsCompleted =
isBasicInfoVerified.value &&
isIdCardVerified.value &&
isSelfieVerified.value;
if (!allPreviousStepsCompleted) {
isFormValid.value = false;
verificationMessage.value =
'Please complete all previous steps before submitting';
}
// Update summary data
_updateSummaryWithFormData();
return isFormValid.value;
}
// Update summary with current form data
void _updateSummaryWithFormData() {
summaryData['nik'] = nikController.text;
summaryData['fullName'] = fullNameController.text;
summaryData['placeOfBirth'] = placeOfBirthController.text;
summaryData['birthDate'] = birthDateController.text;
summaryData['gender'] = selectedGender.value;
summaryData['address'] = addressController.text;
}
// Verify ID card with OCR data
void verifyIdCardWithOCR() {
try {
isVerifying.value = true;
final formController = Get.find<FormRegistrationController>();
final idCardData = formController.idCardData.value;
if (idCardData != null) {
if (!isOfficer && idCardData is KtpModel) {
bool nikMatches = nikController.text == idCardData.nik;
bool nameMatches = _compareNames(
fullNameController.text,
idCardData.name,
);
if (nikMatches && nameMatches) {
isVerified.value = true;
verificationMessage.value =
'KTP information verified successfully!';
} else {
isVerified.value = false;
verificationMessage.value =
'Information doesn\'t match with KTP. Please check and try again.';
}
} else if (isOfficer && idCardData is KtaModel) {
bool nameMatches = _compareNames(
fullNameController.text,
idCardData.name,
);
if (nameMatches) {
isVerified.value = true;
verificationMessage.value =
'KTA information verified successfully!';
} else {
isVerified.value = false;
verificationMessage.value =
'Information doesn\'t match with KTA. Please check and try again.';
}
}
} else {
isVerified.value = false;
verificationMessage.value =
'No ID card data available from previous step.';
}
} catch (e) {
isVerified.value = false;
verificationMessage.value = 'Error during verification: ${e.toString()}';
print('Error in ID card verification: $e');
} finally {
isVerifying.value = false;
}
}
// Compare names accounting for formatting differences
bool _compareNames(String name1, String name2) {
String normalizedName1 = name1.toLowerCase().trim().replaceAll(
RegExp(r'\s+'),
' ',
);
String normalizedName2 = name2.toLowerCase().trim().replaceAll(
RegExp(r'\s+'),
' ',
);
if (normalizedName1 == normalizedName2) return true;
if (normalizedName1.contains(normalizedName2) ||
normalizedName2.contains(normalizedName1))
return true;
var parts1 = normalizedName1.split(' ');
var parts2 = normalizedName2.split(' ');
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;
}
}
}
return matches >= (parts1.length / 2).floor();
}
// Verify face match using FacialVerificationService
void verifyFaceMatch() {
if (_faceService.skipFaceVerification) {
// Development mode - use dummy data
isFaceVerified.value = true;
faceVerificationMessage.value =
'Face verification skipped (development mode)';
final idCardController = Get.find<IdCardVerificationController>();
final selfieController = Get.find<SelfieVerificationController>();
if (idCardController.idCardImage.value != null &&
selfieController.selfieImage.value != null) {
faceComparisonResult.value = FaceComparisonResult(
sourceFace: FaceModel(
imagePath: idCardController.idCardImage.value!.path,
faceId: 'dummy-id-card-id',
confidence: 0.95,
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
),
targetFace: FaceModel(
imagePath: selfieController.selfieImage.value!.path,
faceId: 'dummy-selfie-id',
confidence: 0.95,
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
),
isMatch: true,
confidence: 0.92,
message: 'Face verification passed (development mode)',
);
}
return;
}
isVerifyingFace.value = true;
final idCardController = Get.find<IdCardVerificationController>();
final selfieController = Get.find<SelfieVerificationController>();
if (idCardController.idCardImage.value == null ||
selfieController.selfieImage.value == null) {
isFaceVerified.value = false;
faceVerificationMessage.value =
'Both ID card and selfie are required for face verification.';
isVerifyingFace.value = false;
return;
}
_faceService
.compareFaces(
idCardController.idCardImage.value!,
selfieController.selfieImage.value!,
)
.then((result) {
faceComparisonResult.value = result;
isFaceVerified.value = result.isMatch;
faceVerificationMessage.value = result.message;
})
.catchError((e) {
isFaceVerified.value = false;
faceVerificationMessage.value = 'Error during face verification: $e';
print('Face verification error: $e');
})
.whenComplete(() {
isVerifyingFace.value = false;
});
}
// Clear all validation errors
void clearErrors() {
nikError.value = '';
fullNameError.value = '';
placeOfBirthError.value = '';
birthDateError.value = '';
genderError.value = '';
addressError.value = '';
isFormValid.value = true;
}
// Prefill form with extracted data
void prefillExtractedData() {
if (extractedIdCardNumber != null && extractedIdCardNumber!.isNotEmpty) {
nikController.text = extractedIdCardNumber!;
}
if (extractedName != null && extractedName!.isNotEmpty) {
fullNameController.text = extractedName!;
}
isPreFilledNik.value = true;
}
// Save registration data
Future<bool> saveRegistrationData() async {
try {
isSavingData.value = true;
dataSaveMessage.value = 'Saving your registration data...';
// Final validation
if (!validate(null)) {
dataSaveMessage.value = 'Please fix the errors before submitting';
return false;
}
// Update summary with final data
_updateSummaryWithFormData();
// Format data according to models
Map<String, dynamic> formattedData = {
// Match format from summaryData to match ProfileModel and OfficerModel
'nik': nikController.text,
'fullName': fullNameController.text,
'placeOfBirth': placeOfBirthController.text,
'birthDate': birthDateController.text,
'gender': selectedGender.value,
'address': addressController.text,
};
// Add all other summary data for completeness
summaryData.forEach((key, value) {
if (!formattedData.containsKey(key)) {
formattedData[key] = value;
}
});
// Use FormRegistrationController for actual submission
final formController = Get.find<FormRegistrationController>();
final result = await formController.saveRegistrationData(
summaryData: formattedData,
);
if (result) {
isDataSaved.value = true;
dataSaveMessage.value = 'Registration data saved successfully!';
} else {
isDataSaved.value = false;
dataSaveMessage.value =
'Failed to save registration data. Please try again.';
}
return result;
} catch (e) {
isDataSaved.value = false;
dataSaveMessage.value = 'Error saving registration data: $e';
print('Error saving registration data: $e');
return false;
} finally {
isSavingData.value = false;
}
}
@override
void onClose() {
// Dispose controllers to prevent memory leaks
nikController.dispose();
fullNameController.dispose();
placeOfBirthController.dispose();
birthDateController.dispose();
addressController.dispose();
super.onClose();
}
}

View File

@ -1,379 +0,0 @@
import 'dart:io';
import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart';
import 'package:sigap/src/features/auth/data/models/face_model.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/id-card-verification/id_card_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/facial_verification_controller.dart';
import 'package:sigap/src/utils/constants/app_routes.dart';
import 'package:sigap/src/utils/helpers/error_handler.dart';
import 'package:sigap/src/utils/helpers/error_utils.dart';
class SelfieVerificationController extends GetxController {
// MARK: - Dependencies
final FacialVerificationService _facialVerificationService =
FacialVerificationService.instance;
late FaceLivenessController _livenessController;
// MARK: - Constants
final int maxFileSizeBytes = 4 * 1024 * 1024; // 4MB
// Form validation
final RxBool isFormValid = RxBool(true);
final RxString selfieError = RxString('');
final RxString selfieValidationMessage = RxString('');
// Image state
final Rx<XFile?> selfieImage = Rx<XFile?>(null);
final Rx<FaceModel> selfieFace = Rx<FaceModel>(FaceModel.empty());
// Process state flags
final RxBool isVerifyingFace = RxBool(false);
final RxBool isUploadingSelfie = RxBool(false);
final RxBool isPerformingLivenessCheck = RxBool(false);
final RxBool isComparingWithIDCard = RxBool(false);
// Verification results
final RxBool isSelfieValid = RxBool(false);
final RxBool isLivenessCheckPassed = RxBool(false);
final RxBool hasConfirmedSelfie = RxBool(false);
// Face comparison results
final Rx<FaceComparisonResult?> faceComparisonResult =
Rx<FaceComparisonResult?>(null);
final RxBool isMatchWithIDCard = RxBool(false);
final RxDouble matchConfidence = RxDouble(0.0);
final RxString selfieImageFaceId = RxString('');
@override
void onInit() {
super.onInit();
_livenessController = Get.put(FaceLivenessController());
// Listen to liveness detection completion
ever(_livenessController.status, (LivenessStatus status) {
if (status == LivenessStatus.completed &&
_livenessController.capturedImage != null) {
// When liveness check completes successfully, update selfie data
_processCapturedLivenessImage();
}
});
}
// Process the image captured during liveness detection
Future<void> _processCapturedLivenessImage() async {
if (_livenessController.capturedImage == null) return;
try {
// Update selfie data
selfieImage.value = _livenessController.capturedImage;
// Generate face model from liveness controller
selfieFace.value = _livenessController.generateFaceModel();
isLivenessCheckPassed.value = true;
isSelfieValid.value = true;
selfieImageFaceId.value = selfieFace.value.faceId;
selfieValidationMessage.value =
'Liveness check passed! Your face is verified.';
// Now that we have a valid selfie with liveness verification, compare with ID card
await compareWithIDCardPhoto();
} catch (e) {
_handleError('Failed to process liveness image', e);
} finally {
isPerformingLivenessCheck.value = false;
}
}
// Process the image captured during liveness detection - public for debugging
Future<void> processCapturedLivenessImage() async {
return _processCapturedLivenessImage();
}
// MARK: - Public Methods
/// Validate the selfie data for form submission
bool validate() {
clearErrors();
if (selfieImage.value == null) {
selfieError.value = 'Please take a selfie for verification';
isFormValid.value = false;
} else if (!isSelfieValid.value) {
selfieError.value = 'Your selfie image is not valid';
isFormValid.value = false;
} else if (!hasConfirmedSelfie.value) {
selfieError.value = 'Please confirm your selfie image';
isFormValid.value = false;
}
return isFormValid.value;
}
/// Clear all error messages
void clearErrors() {
selfieError.value = '';
selfieValidationMessage.value = '';
}
/// Perform liveness detection
Future<void> performLivenessDetection() async {
try {
_setLoading(isPerformingLivenessCheck: true);
// Reset any existing selfie data
_resetVerificationData();
// Navigate to liveness detection page
final result = await Get.toNamed(AppRoutes.livenessDetection);
// If user cancelled or closed the screen without completing
if (result == null) {
_setLoading(isPerformingLivenessCheck: false);
}
// Processing will continue when liveness detection is complete,
// handled by _processCapturedLivenessImage() via the status listener
} catch (e) {
_handleError('Failed to start liveness detection', e);
_setLoading(isPerformingLivenessCheck: false);
}
}
/// Clear Selfie Image and reset all verification data
void clearSelfieImage() {
selfieImage.value = null;
_resetVerificationData();
}
/// Confirm the selfie image after validation
void confirmSelfieImage() {
if (isSelfieValid.value && isMatchWithIDCard.value) {
hasConfirmedSelfie.value = true;
}
}
/// Manual trigger for comparing with ID card
Future<void> verifyFaceMatchWithIDCard() async {
if (selfieImage.value == null) {
selfieError.value = 'Please take a selfie first';
return;
}
try {
// Get the ID card controller
final idCardController = Get.find<IdCardVerificationController>();
if (idCardController.idCardImage.value == null) {
selfieValidationMessage.value =
'ID card image is required for comparison';
return;
}
// Compare with ID card photo
await compareWithIDCardPhoto();
} catch (e) {
selfieValidationMessage.value = 'Face verification failed: $e';
}
}
// MARK: - Private Helper Methods
/// Pick an image from the specified source
Future<XFile?> _pickImage(ImageSource source) async {
final ImagePicker picker = ImagePicker();
return picker.pickImage(
source: source,
preferredCameraDevice: CameraDevice.front,
imageQuality: 80,
);
}
/// Check if a file size is within the allowed limit
Future<bool> _isFileSizeValid(XFile file) async {
final fileSize = await File(file.path).length();
return fileSize <= maxFileSizeBytes;
}
/// Update face data with new liveness check results
void _updateFaceData(FaceModel face) {
selfieFace.value = face;
isLivenessCheckPassed.value = face.isLive;
selfieImageFaceId.value = face.faceId;
isSelfieValid.value = face.isLive;
selfieValidationMessage.value = face.message;
}
/// Update comparison results
void _updateComparisonResult(FaceComparisonResult result) {
faceComparisonResult.value = result;
isMatchWithIDCard.value = result.isMatch;
matchConfidence.value = result.confidence;
selfieValidationMessage.value = result.message;
}
/// Reset all verification-related data
void _resetVerificationData() {
// Clear validation state
selfieError.value = '';
selfieValidationMessage.value = '';
isSelfieValid.value = false;
isLivenessCheckPassed.value = false;
hasConfirmedSelfie.value = false;
// Clear face data
selfieFace.value = FaceModel.empty();
// Clear comparison data
faceComparisonResult.value = null;
isMatchWithIDCard.value = false;
matchConfidence.value = 0.0;
selfieImageFaceId.value = '';
}
/// Handle errors in a consistent way
void _handleError(String baseMessage, dynamic error) {
print('$baseMessage: $error');
selfieError.value = ErrorHandler.getUIErrorMessage(error);
isSelfieValid.value = false;
}
/// Set loading states in a consistent way
void _setLoading({
bool? isVerifyingFace,
bool? isUploadingSelfie,
bool? isPerformingLivenessCheck,
bool? isComparingWithIDCard,
}) {
if (isVerifyingFace != null) this.isVerifyingFace.value = isVerifyingFace;
if (isUploadingSelfie != null)
this.isUploadingSelfie.value = isUploadingSelfie;
if (isPerformingLivenessCheck != null)
this.isPerformingLivenessCheck.value = isPerformingLivenessCheck;
if (isComparingWithIDCard != null)
this.isComparingWithIDCard.value = isComparingWithIDCard;
}
/// Compare selfie with ID card photo
Future<void> compareWithIDCardPhoto() async {
final idCardController = Get.find<IdCardVerificationController>();
if (selfieImage.value == null ||
idCardController.idCardImage.value == null) {
print('Cannot compare faces: Missing images');
return;
}
try {
_setLoading(isComparingWithIDCard: true);
if (_facialVerificationService.skipFaceVerification) {
await _handleDevelopmentModeComparison(idCardController);
return;
}
// Pass the existing face models if available to avoid redundant detection
FaceModel? sourceFace =
idCardController.idCardFace.value.hasValidFace
? idCardController.idCardFace.value
: null;
FaceModel? targetFace =
selfieFace.value.hasValidFace ? selfieFace.value : null;
// Compare faces using EdgeFunction via FacialVerificationService
final comparisonResult = await _facialVerificationService.compareFaces(
idCardController.idCardImage.value!,
selfieImage.value!,
sourceModel: sourceFace,
targetModel: targetFace,
);
_updateComparisonResult(comparisonResult);
} on EdgeFunctionException catch (e) {
// Handle specific errors with user-friendly messages
ErrorHandler.logError('Face comparison', e);
faceComparisonResult.value = FaceComparisonResult.error(
FaceModel.empty(),
FaceModel.empty(),
e.message,
);
isMatchWithIDCard.value = false;
matchConfidence.value = 0.0;
selfieValidationMessage.value = e.message;
} catch (e) {
ErrorHandler.logError('Face comparison', e);
selfieValidationMessage.value = ErrorHandler.getUIErrorMessage(e);
isMatchWithIDCard.value = false;
matchConfidence.value = 0.0;
} finally {
_setLoading(isComparingWithIDCard: false);
}
}
/// Also clear loading states when closing liveness detection
Future<void> cancelLivenessDetection() async {
_setLoading(isPerformingLivenessCheck: false, isVerifyingFace: false);
selfieValidationMessage.value = 'Liveness check was cancelled';
}
// Di SelfieVerificationController
void resetVerificationState() {
isLivenessCheckPassed.value = false;
faceComparisonResult.value = null;
matchConfidence.value = 0.0;
selfieError.value = '';
hasConfirmedSelfie.value = false;
// Reset other relevant states
}
/// Handle development mode dummy validation
Future<void> _handleDevelopmentModeValidation() async {
isSelfieValid.value = true;
isLivenessCheckPassed.value = true;
selfieImageFaceId.value =
'dummy-face-id-${DateTime.now().millisecondsSinceEpoch}';
selfieValidationMessage.value =
'Selfie validation successful (development mode)';
selfieFace.value = FaceModel(
imagePath: selfieImage.value!.path,
faceId: selfieImageFaceId.value,
confidence: 0.95,
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
).withLiveness(
isLive: true,
confidence: 0.92,
message: 'Liveness check passed (development mode)',
);
await compareWithIDCardPhoto();
}
/// Handle development mode comparison dummy data
Future<void> _handleDevelopmentModeComparison(
IdCardVerificationController idCardController,
) async {
final sourceFace =
idCardController.idCardFace.value.hasValidFace
? idCardController.idCardFace.value
: FaceModel(
imagePath: idCardController.idCardImage.value!.path,
faceId:
'dummy-id-card-face-${DateTime.now().millisecondsSinceEpoch}',
confidence: 0.95,
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
);
final comparisonResult = FaceComparisonResult(
sourceFace: sourceFace,
targetFace: selfieFace.value,
isMatch: true,
confidence: 0.91,
message: 'Face matching successful (development mode)',
);
_updateComparisonResult(comparisonResult);
}
}

View File

@ -0,0 +1,516 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/auth/data/models/face_model.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup/id-card-verification/id_card_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup/selfie-verification/facial_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup/selfie-verification/selfie_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup/viewer-information/personal_info_controller.dart';
class IdentityVerificationController extends GetxController {
// Singleton instance
static IdentityVerificationController get instance => Get.find();
// Directly reference controllers from previous steps
late IdCardVerificationController idCardController;
late SelfieVerificationController selfieController;
late PersonalInfoController personalInfoController;
late FormRegistrationController mainController;
// Dependencies
final bool isOfficer;
final FacialVerificationService _faceService =
FacialVerificationService.instance;
// Form controllers
final TextEditingController nikController = TextEditingController();
final TextEditingController nrpController = TextEditingController();
final TextEditingController fullNameController = TextEditingController();
final TextEditingController placeOfBirthController = TextEditingController();
final TextEditingController birthDateController = TextEditingController();
final TextEditingController addressController = TextEditingController();
// Form validation errors
final RxString nikError = RxString('');
final RxString nrpError = RxString('');
final RxString fullNameError = RxString('');
final RxString placeOfBirthError = RxString('');
final RxString birthDateError = RxString('');
final RxString genderError = RxString('');
final RxString addressError = RxString('');
// ID verification states
final RxBool isVerifying = RxBool(false);
final RxBool isVerified = RxBool(false);
final RxString verificationMessage = RxString('');
// Face verification states
final RxBool isVerifyingFace = RxBool(false);
final RxBool isFaceVerified = RxBool(false);
final RxString faceVerificationMessage = RxString('');
final Rx<FaceComparisonResult?> faceComparisonResult =
Rx<FaceComparisonResult?>(null);
// UI state variables
final Rx<String?> selectedGender = Rx<String?>('Male');
final RxBool isNikReadOnly = RxBool(false);
final RxBool isNrpReadOnly = RxBool(false);
final RxBool isFormValid = RxBool(true);
final RxBool isPreFilledNik = false.obs;
// Data saving states
final RxBool isSavingData = RxBool(false);
final RxBool isDataSaved = RxBool(false);
final RxString dataSaveMessage = RxString('');
// Summary data for review page
final RxMap<String, dynamic> summaryData = RxMap<String, dynamic>({});
// Storage for extracted data
final String? extractedIdCardNumber;
final String? extractedName;
// Verification status variables (computed from previous steps)
final RxBool isPersonalInfoVerified = RxBool(false);
final RxBool isIdCardVerified = RxBool(false);
final RxBool isSelfieVerified = RxBool(false);
final RxBool isContactInfoVerified = RxBool(false);
final RxBool isLoadingSummary = RxBool(false);
IdentityVerificationController({
this.extractedIdCardNumber = '',
this.extractedName = '',
required this.isOfficer,
});
@override
void onInit() {
super.onInit();
// Get controllers from previous steps
mainController = Get.find<FormRegistrationController>();
personalInfoController = Get.find<PersonalInfoController>();
idCardController = Get.find<IdCardVerificationController>();
selfieController = Get.find<SelfieVerificationController>();
// Set default gender value
selectedGender.value = selectedGender.value ?? 'Male';
// Initialize form controllers
nikController.text = idCardController.ktpModel.value?.nik ?? '';
nrpController.text = idCardController.ktaModel.value?.nrp ?? '';
fullNameController.text =
idCardController.ktpModel.value?.name ??
idCardController.ktaModel.value?.name ??
'';
placeOfBirthController.text =
idCardController.ktpModel.value?.birthPlace ?? '';
birthDateController.text = idCardController.ktpModel.value?.birthDate ?? '';
addressController.text = idCardController.ktpModel.value?.address ?? '';
isNikReadOnly.value = idCardController.ktpModel.value != null;
isNrpReadOnly.value = idCardController.ktaModel.value != null;
// Initialize data
_initializeData();
}
// Initialize all data
Future<void> _initializeData() async {
try {
// Check verification status from previous steps
_updateVerificationStatus();
// Pre-fill form with data from ID card step
_prefillFormFromIdCard();
// Build summary data
_buildSummaryData();
} catch (e) {
print('Error initializing data: $e');
}
}
// Update verification status by checking previous steps
void _updateVerificationStatus() {
// Basic info is from the main registration controller
isPersonalInfoVerified.value = personalInfoController.isFormValid.value;
// ID card verification from id card controller
isIdCardVerified.value =
idCardController.isIdCardValid.value &&
idCardController.hasConfirmedIdCard.value;
// Selfie verification from selfie controller
isSelfieVerified.value =
selfieController.isSelfieValid.value &&
selfieController.hasConfirmedSelfie.value;
}
// Pre-fill form with data from ID card step
void _prefillFormFromIdCard() {
try {
if (!isOfficer && idCardController.ktpModel.value != null) {
// For citizen - use KTP data
final ktp = idCardController.ktpModel.value!;
// Fill form fields
nikController.text = ktp.nik;
fullNameController.text = ktp.name;
placeOfBirthController.text = ktp.birthPlace;
birthDateController.text = ktp.birthDate;
// Set gender selection
if (ktp.gender.toLowerCase().contains('laki') ||
ktp.gender.toLowerCase() == 'male') {
selectedGender.value = 'Male';
} else if (ktp.gender.toLowerCase().contains('perempuan') ||
ktp.gender.toLowerCase() == 'female') {
selectedGender.value = 'Female';
}
// Fill address
addressController.text = ktp.address;
// Lock NIK field as it's from official ID
isNikReadOnly.value = true;
} else if (isOfficer && idCardController.ktaModel.value != null) {
// For officer - use KTA data
final kta = idCardController.ktaModel.value!;
// Fill form fields with available KTA data
fullNameController.text = kta.name;
// KTA often has less data than KTP, check for extra fields
if (kta.extraData != null &&
kta.extraData!.containsKey('tanggal_lahir')) {
birthDateController.text = kta.extraData!['tanggal_lahir'] ?? '';
}
}
} catch (e) {
print('Error pre-filling form: $e');
}
}
// Build summary data from all steps
void _buildSummaryData() {
// Clear existing data
summaryData.clear();
// Add data from main controller
summaryData['firstName'] = personalInfoController.firstNameController.value;
summaryData['lastName'] = personalInfoController.lastNameController.value;
summaryData['phone'] = personalInfoController.phoneController.value;
summaryData['address'] = personalInfoController.addressController.value;
summaryData['bio'] = personalInfoController.bioController.value;
// Add data from ID card controller
summaryData['idCardType'] = isOfficer ? 'KTA' : 'KTP';
summaryData['hasValidIdCard'] = isIdCardVerified.value;
// Add data from selfie controller
summaryData['hasSelfie'] = isSelfieVerified.value;
summaryData['faceMatchConfidence'] = selfieController.matchConfidence.value;
// Add current form values
_updateSummaryWithFormData();
}
// Validate form inputs
bool validate(GlobalKey<FormState>? formKey) {
isFormValid.value = true;
clearErrors();
// Validate required fields based on officer status
if (!isOfficer) {
// KTP validation
if (nikController.text.isEmpty) {
nikError.value = 'NIK is required';
isFormValid.value = false;
} else if (nikController.text.length != 16) {
nikError.value = 'NIK must be 16 digits';
isFormValid.value = false;
}
if (fullNameController.text.isEmpty) {
fullNameError.value = 'Full name is required';
isFormValid.value = false;
}
if (placeOfBirthController.text.isEmpty) {
placeOfBirthError.value = 'Place of birth is required';
isFormValid.value = false;
}
} else {
// KTA validation
if (fullNameController.text.isEmpty) {
fullNameError.value = 'Full name is required';
isFormValid.value = false;
}
}
// Common validations
if (birthDateController.text.isEmpty) {
birthDateError.value = 'Birth date is required';
isFormValid.value = false;
}
if (selectedGender.value == null) {
genderError.value = 'Gender is required';
isFormValid.value = false;
}
// Verify previous steps completion
bool allPreviousStepsCompleted =
isPersonalInfoVerified.value &&
isIdCardVerified.value &&
isSelfieVerified.value;
if (!allPreviousStepsCompleted) {
isFormValid.value = false;
verificationMessage.value =
'Please complete all previous steps before submitting';
}
// Update summary data with latest form values
_updateSummaryWithFormData();
return isFormValid.value;
}
// Update summary with form data
void _updateSummaryWithFormData() {
summaryData['nik'] = nikController.text;
summaryData['fullName'] = fullNameController.text;
summaryData['placeOfBirth'] = placeOfBirthController.text;
summaryData['birthDate'] = birthDateController.text;
summaryData['gender'] = selectedGender.value;
summaryData['address'] = addressController.text;
}
// Clear all validation errors
void clearErrors() {
nikError.value = '';
fullNameError.value = '';
placeOfBirthError.value = '';
birthDateError.value = '';
genderError.value = '';
addressError.value = '';
isFormValid.value = true;
}
// Prefill form with extracted data
void prefillExtractedData() {
if (extractedIdCardNumber != null && extractedIdCardNumber!.isNotEmpty) {
nikController.text = extractedIdCardNumber!;
}
if (extractedName != null && extractedName!.isNotEmpty) {
fullNameController.text = extractedName!;
}
isPreFilledNik.value = true;
}
// Verify ID card with OCR data
void verifyIdCardWithOCR() {
try {
isVerifying.value = true;
if (!isOfficer && idCardController.ktpModel.value != null) {
final ktpModel = idCardController.ktpModel.value!;
bool nikMatches = nikController.text == ktpModel.nik;
bool nameMatches = _compareNames(
fullNameController.text,
ktpModel.name,
);
if (nikMatches && nameMatches) {
isVerified.value = true;
verificationMessage.value = 'KTP information verified successfully!';
} else {
isVerified.value = false;
verificationMessage.value =
'Information doesn\'t match with KTP. Please check and try again.';
}
} else if (isOfficer && idCardController.ktaModel.value != null) {
final ktaModel = idCardController.ktaModel.value!;
bool nameMatches = _compareNames(
fullNameController.text,
ktaModel.name,
);
if (nameMatches) {
isVerified.value = true;
verificationMessage.value = 'KTA information verified successfully!';
} else {
isVerified.value = false;
verificationMessage.value =
'Information doesn\'t match with KTA. Please check and try again.';
}
} else {
isVerified.value = false;
verificationMessage.value =
'No ID card data available from previous step.';
}
} catch (e) {
isVerified.value = false;
verificationMessage.value = 'Error during verification: ${e.toString()}';
print('Error in ID card verification: $e');
} finally {
isVerifying.value = false;
}
}
// Compare names accounting for formatting differences
bool _compareNames(String name1, String name2) {
String normalizedName1 = name1.toLowerCase().trim().replaceAll(
RegExp(r'\s+'),
' ',
);
String normalizedName2 = name2.toLowerCase().trim().replaceAll(
RegExp(r'\s+'),
' ',
);
if (normalizedName1 == normalizedName2) return true;
if (normalizedName1.contains(normalizedName2) ||
normalizedName2.contains(normalizedName1))
return true;
var parts1 = normalizedName1.split(' ');
var parts2 = normalizedName2.split(' ');
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;
}
}
}
return matches >= (parts1.length / 2).floor();
}
// Verify face match using FacialVerificationService
void verifyFaceMatch() {
if (_faceService.skipFaceVerification) {
// Development mode - use dummy data
isFaceVerified.value = true;
faceVerificationMessage.value =
'Face verification skipped (development mode)';
if (idCardController.idCardImage.value != null &&
selfieController.selfieImage.value != null) {
faceComparisonResult.value = FaceComparisonResult(
sourceFace: FaceModel(
imagePath: idCardController.idCardImage.value!.path,
faceId: 'dummy-id-card-id',
confidence: 0.95,
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
),
targetFace: FaceModel(
imagePath: selfieController.selfieImage.value!.path,
faceId: 'dummy-selfie-id',
confidence: 0.95,
boundingBox: {'x': 0.1, 'y': 0.1, 'width': 0.8, 'height': 0.8},
),
isMatch: true,
confidence: 0.92,
similarity: 92.0,
similarityThreshold: 70.0,
message: 'Face verification passed (development mode)',
);
}
return;
}
isVerifyingFace.value = true;
if (idCardController.idCardImage.value == null ||
selfieController.selfieImage.value == null) {
isFaceVerified.value = false;
faceVerificationMessage.value =
'Both ID card and selfie are required for face verification.';
isVerifyingFace.value = false;
return;
}
_faceService
.compareFaces(
idCardController.idCardImage.value!,
selfieController.selfieImage.value!,
)
.then((result) {
faceComparisonResult.value = result;
isFaceVerified.value = result.isMatch;
faceVerificationMessage.value = result.message;
})
.catchError((e) {
isFaceVerified.value = false;
faceVerificationMessage.value = 'Error during face verification: $e';
print('Face verification error: $e');
})
.whenComplete(() {
isVerifyingFace.value = false;
});
}
// Save registration data
Future<bool> saveRegistrationData() async {
try {
isSavingData.value = true;
dataSaveMessage.value = 'Saving your registration data...';
// Final validation
if (!validate(null)) {
dataSaveMessage.value = 'Please fix the errors before submitting';
return false;
}
// Update summary with final form data
_updateSummaryWithFormData();
// Send the data to the main controller for submission
final result = await mainController.saveRegistrationData(
summaryData: summaryData,
);
if (result) {
isDataSaved.value = true;
dataSaveMessage.value = 'Registration data saved successfully!';
} else {
isDataSaved.value = false;
dataSaveMessage.value =
'Failed to save registration data. Please try again.';
}
return result;
} catch (e) {
isDataSaved.value = false;
dataSaveMessage.value = 'Error saving registration data: $e';
print('Error saving registration data: $e');
return false;
} finally {
isSavingData.value = false;
}
}
@override
void onClose() {
// Dispose form controllers
nikController.dispose();
fullNameController.dispose();
placeOfBirthController.dispose();
birthDateController.dispose();
addressController.dispose();
super.onClose();
}
}

View File

@ -4,13 +4,13 @@ import 'package:get_storage/get_storage.dart';
import 'package:logger/logger.dart'; import 'package:logger/logger.dart';
import 'package:sigap/src/cores/services/supabase_service.dart'; import 'package:sigap/src/cores/services/supabase_service.dart';
import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart'; import 'package:sigap/src/features/auth/data/repositories/authentication_repository.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/id-card-verification/id_card_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/identity-verification/identity_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/identity-verification/identity_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/officer-information/officer_info_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/officer-information/officer_info_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/officer-information/unit_info_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/officer-information/unit_info_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/selfie-verification/face_liveness_detection_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/selfie-verification/selfie_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/selfie_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/id-card-verification/id_card_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/viewer-information/personal_info_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/viewer-information/personal_info_controller.dart';
import 'package:sigap/src/features/daily-ops/data/models/index.dart'; import 'package:sigap/src/features/daily-ops/data/models/index.dart';
import 'package:sigap/src/features/personalization/data/models/index.dart'; import 'package:sigap/src/features/personalization/data/models/index.dart';
import 'package:sigap/src/features/personalization/data/models/models/user_metadata_model.dart'; import 'package:sigap/src/features/personalization/data/models/models/user_metadata_model.dart';
@ -445,7 +445,7 @@ class FormRegistrationController extends GetxController {
case 1: case 1:
return idCardVerificationController.validate(); return idCardVerificationController.validate();
case 2: case 2:
return selfieVerificationController.validate(); return selfieVerificationController.isMatchWithIDCard.value;
case 3: case 3:
return selectedRole.value?.isOfficer == true return selectedRole.value?.isOfficer == true
? officerInfoController!.validate(formKey) ? officerInfoController!.validate(formKey)

View File

@ -20,6 +20,7 @@ enum LivenessStatus {
checkSmile, checkSmile,
checkEyesOpen, checkEyesOpen,
readyForPhoto, readyForPhoto,
countdown,
photoTaken, photoTaken,
completed, completed,
failed, failed,
@ -52,6 +53,10 @@ class FaceLivenessController extends GetxController {
final isCaptured = false.obs; final isCaptured = false.obs;
final successfulSteps = <String>[].obs; final successfulSteps = <String>[].obs;
// Countdown timer state
final countdownSeconds = 5.obs;
Timer? _countdownTimer;
// Image processing // Image processing
XFile? capturedImage; XFile? capturedImage;
// Removed imageStreamSubscription as startImageStream does not return a StreamSubscription // Removed imageStreamSubscription as startImageStream does not return a StreamSubscription
@ -92,6 +97,7 @@ class FaceLivenessController extends GetxController {
@override @override
void onClose() { void onClose() {
dev.log('FaceLivenessController closing...', name: 'LIVENESS_CONTROLLER'); dev.log('FaceLivenessController closing...', name: 'LIVENESS_CONTROLLER');
_countdownTimer?.cancel();
_cleanup(); _cleanup();
super.onClose(); super.onClose();
} }
@ -149,7 +155,7 @@ class FaceLivenessController extends GetxController {
cameraController = CameraController( cameraController = CameraController(
frontCamera, frontCamera,
ResolutionPreset ResolutionPreset
.high, // Changed from medium to high for better detection .low, // Changed from medium to high for better detection
enableAudio: false, enableAudio: false,
imageFormatGroup: imageFormatGroup:
Platform.isIOS Platform.isIOS
@ -158,7 +164,7 @@ class FaceLivenessController extends GetxController {
); );
await cameraController!.initialize(); await cameraController!.initialize();
// Set flash off to improve face detection // Set flash off to improve face detection
try { try {
await cameraController!.setFlashMode(FlashMode.off); await cameraController!.setFlashMode(FlashMode.off);
@ -221,7 +227,7 @@ class FaceLivenessController extends GetxController {
// Detect faces // Detect faces
final faces = await faceDetector.processImage(inputImage); final faces = await faceDetector.processImage(inputImage);
// Log the face detection attempt // Log the face detection attempt
if (faces.isEmpty) { if (faces.isEmpty) {
dev.log( dev.log(
@ -234,10 +240,9 @@ class FaceLivenessController extends GetxController {
name: 'LIVENESS_CONTROLLER', name: 'LIVENESS_CONTROLLER',
); );
} }
// Process face detection results // Process face detection results
await _processFaceDetection(faces); await _processFaceDetection(faces);
} catch (e) { } catch (e) {
dev.log('Error processing image: $e', name: 'LIVENESS_CONTROLLER'); dev.log('Error processing image: $e', name: 'LIVENESS_CONTROLLER');
} finally { } finally {
@ -276,7 +281,7 @@ class FaceLivenessController extends GetxController {
var rotationCompensation = var rotationCompensation =
orientations[cameraController!.value.deviceOrientation]; orientations[cameraController!.value.deviceOrientation];
if (rotationCompensation == null) return null; if (rotationCompensation == null) return null;
if (camera.lensDirection == CameraLensDirection.front) { if (camera.lensDirection == CameraLensDirection.front) {
rotationCompensation = rotationCompensation =
(sensorOrientation + rotationCompensation) % 360; (sensorOrientation + rotationCompensation) % 360;
@ -284,7 +289,7 @@ class FaceLivenessController extends GetxController {
rotationCompensation = rotationCompensation =
(sensorOrientation - rotationCompensation + 360) % 360; (sensorOrientation - rotationCompensation + 360) % 360;
} }
rotation = InputImageRotationValue.fromRawValue(rotationCompensation); rotation = InputImageRotationValue.fromRawValue(rotationCompensation);
dev.log( dev.log(
'Android camera rotation set to: $rotationCompensation', 'Android camera rotation set to: $rotationCompensation',
@ -406,25 +411,25 @@ class FaceLivenessController extends GetxController {
case LivenessStatus.checkLeftRotation: case LivenessStatus.checkLeftRotation:
if (isFaceLeft.value) { if (isFaceLeft.value) {
_completeCurrentStep('Looked left'); _completeCurrentStep('Looked left');
} }
break; break;
case LivenessStatus.checkRightRotation: case LivenessStatus.checkRightRotation:
if (isFaceRight.value) { if (isFaceRight.value) {
_completeCurrentStep('Looked right'); _completeCurrentStep('Looked right');
} }
break; break;
case LivenessStatus.checkSmile: case LivenessStatus.checkSmile:
if (isSmiled.value) { if (isSmiled.value) {
_completeCurrentStep('Smiled detected'); _completeCurrentStep('Smiled detected');
} }
break; break;
case LivenessStatus.checkEyesOpen: case LivenessStatus.checkEyesOpen:
if (isEyeOpen.value) { if (isEyeOpen.value) {
_completeCurrentStep('Eyes open confirmed'); _completeCurrentStep('Eyes open confirmed');
} }
break; break;
@ -511,16 +516,42 @@ class FaceLivenessController extends GetxController {
status.value = LivenessStatus.readyForPhoto; status.value = LivenessStatus.readyForPhoto;
isFaceReadyForPhoto.value = true; isFaceReadyForPhoto.value = true;
// Auto-capture after a short delay // Start countdown instead of immediately taking picture
Timer(Duration(seconds: 1), () { _startCountdown();
if (!isCaptured.value) { }
// Start countdown timer before taking the photo
void _startCountdown() {
dev.log('Starting countdown before capture', name: 'LIVENESS_CONTROLLER');
status.value = LivenessStatus.countdown;
countdownSeconds.value = 5; // Reset to 5 seconds
// Cancel any existing timer
_countdownTimer?.cancel();
// Create a periodic timer that fires every second
_countdownTimer = Timer.periodic(Duration(seconds: 1), (timer) {
countdownSeconds.value--;
// When countdown reaches zero, take the picture
if (countdownSeconds.value <= 0) {
timer.cancel();
captureImage(); captureImage();
} }
}); });
} }
// Cancel countdown if needed
void cancelCountdown() {
_countdownTimer?.cancel();
countdownSeconds.value = 5;
status.value = LivenessStatus.readyForPhoto;
}
// Capture image with improved processing // Capture image with improved processing
Future<void> captureImage() async { Future<void> captureImage() async {
_countdownTimer?.cancel(); // Ensure timer is cancelled
try { try {
if (cameraController == null || !cameraController!.value.isInitialized) { if (cameraController == null || !cameraController!.value.isInitialized) {
dev.log('Camera not ready for capture', name: 'LIVENESS_CONTROLLER'); dev.log('Camera not ready for capture', name: 'LIVENESS_CONTROLLER');
@ -571,7 +602,7 @@ class FaceLivenessController extends GetxController {
await Future.delayed(Duration(milliseconds: 500)); await Future.delayed(Duration(milliseconds: 500));
continue; continue;
} }
break; break;
} catch (e) { } catch (e) {
retryCount++; retryCount++;
@ -601,7 +632,7 @@ class FaceLivenessController extends GetxController {
status.value = LivenessStatus.failed; status.value = LivenessStatus.failed;
} }
} }
// Verify that the captured image contains a face // Verify that the captured image contains a face
Future<bool> _verifyFaceInImage(XFile image) async { Future<bool> _verifyFaceInImage(XFile image) async {
try { try {
@ -703,6 +734,8 @@ class FaceLivenessController extends GetxController {
return 'Please smile for the camera'; return 'Please smile for the camera';
case LivenessStatus.checkEyesOpen: case LivenessStatus.checkEyesOpen:
return 'Keep your eyes wide open'; return 'Keep your eyes wide open';
case LivenessStatus.countdown:
return 'Get ready! Taking photo in ${countdownSeconds.value}...';
case LivenessStatus.readyForPhoto: case LivenessStatus.readyForPhoto:
return 'Perfect! Hold still for photo capture'; return 'Perfect! Hold still for photo capture';
case LivenessStatus.photoTaken: case LivenessStatus.photoTaken:
@ -719,6 +752,7 @@ class FaceLivenessController extends GetxController {
// Handle cancellation (called when user goes back) // Handle cancellation (called when user goes back)
void handleCancellation() { void handleCancellation() {
dev.log('Handling cancellation...', name: 'LIVENESS_CONTROLLER'); dev.log('Handling cancellation...', name: 'LIVENESS_CONTROLLER');
_countdownTimer?.cancel(); // Cancel countdown timer
_cleanup(); _cleanup();
} }
@ -747,6 +781,7 @@ class FaceLivenessController extends GetxController {
// Cancel timers // Cancel timers
stepTimer?.cancel(); stepTimer?.cancel();
stabilityTimer?.cancel(); stabilityTimer?.cancel();
_countdownTimer?.cancel();
// Restart the process // Restart the process
status.value = LivenessStatus.detectingFace; status.value = LivenessStatus.detectingFace;
@ -768,10 +803,10 @@ class FaceLivenessController extends GetxController {
// Add all steps as completed // Add all steps as completed
successfulSteps.clear(); successfulSteps.clear();
successfulSteps.addAll([ successfulSteps.addAll([
'Looked left (debug skip)', 'Looked left (debug skip)',
'Looked right (debug skip)', 'Looked right (debug skip)',
'Smiled detected (debug skip)', 'Smiled detected (debug skip)',
'Eyes open confirmed (debug skip)', 'Eyes open confirmed (debug skip)',
]); ]);
currentStepIndex = verificationSteps.length; currentStepIndex = verificationSteps.length;
@ -819,10 +854,14 @@ class FaceLivenessController extends GetxController {
// Cancel timers // Cancel timers
stepTimer?.cancel(); stepTimer?.cancel();
stabilityTimer?.cancel(); stabilityTimer?.cancel();
_countdownTimer?.cancel();
// Stop image stream with error handling // Stop image stream with error handling
try { try {
cameraController?.stopImageStream(); if (cameraController?.value.isInitialized == true &&
cameraController?.value.isStreamingImages == true) {
cameraController?.stopImageStream();
}
} catch (e) { } catch (e) {
dev.log( dev.log(
'Error stopping image stream during cleanup: $e', 'Error stopping image stream during cleanup: $e',
@ -832,11 +871,15 @@ class FaceLivenessController extends GetxController {
// Dispose camera with error handling // Dispose camera with error handling
try { try {
cameraController?.dispose(); if (cameraController?.value.isInitialized == true) {
cameraController?.dispose();
}
} catch (e) { } catch (e) {
dev.log('Error disposing camera: $e', name: 'LIVENESS_CONTROLLER'); dev.log('Error disposing camera: $e', name: 'LIVENESS_CONTROLLER');
} }
cameraController = null;
// Close ML Kit detectors // Close ML Kit detectors
try { try {
faceDetector.close(); faceDetector.close();
@ -849,6 +892,46 @@ class FaceLivenessController extends GetxController {
} }
} }
// Pause the liveness detection process
void pauseDetection() {
dev.log('Pausing liveness detection process', name: 'LIVENESS_CONTROLLER');
_countdownTimer?.cancel();
stepTimer?.cancel();
stabilityTimer?.cancel();
// Stop image stream
try {
if (cameraController?.value.isInitialized == true &&
cameraController?.value.isStreamingImages == true) {
cameraController?.stopImageStream();
}
} catch (e) {
dev.log('Error stopping image stream: $e', name: 'LIVENESS_CONTROLLER');
}
status.value = LivenessStatus.preparing;
}
// Dispose camera
void disposeCamera() {
dev.log('Disposing camera resources', name: 'LIVENESS_CONTROLLER');
_cleanup();
}
// cancel the liveness detection process
void cancelLivenessDetection() {
dev.log(
'Cancelling liveness detection process',
name: 'LIVENESS_CONTROLLER',
);
_cleanup();
status.value = LivenessStatus.preparing;
isFaceInFrame.value = false;
isCaptured.value = false;
capturedImage = null;
}
// Generate face model // Generate face model
FaceModel generateFaceModel() { FaceModel generateFaceModel() {
if (capturedImage == null) { if (capturedImage == null) {

View File

@ -3,7 +3,7 @@ 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/edge_function_service.dart'; import 'package:sigap/src/cores/services/edge_function_service.dart';
import 'package:sigap/src/features/auth/data/models/face_model.dart'; import 'package:sigap/src/features/auth/data/models/face_model.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/selfie-verification/face_liveness_detection_controller.dart';
/// Service for handling facial verification /// Service for handling facial verification
/// This class serves as a bridge between UI controllers and face detection functionality /// This class serves as a bridge between UI controllers and face detection functionality

View File

@ -0,0 +1,257 @@
import 'dart:developer' as dev;
import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart';
import 'package:sigap/src/cores/services/edge_function_service.dart';
import 'package:sigap/src/features/auth/data/models/face_model.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup/id-card-verification/id_card_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup/selfie-verification/face_liveness_detection_controller.dart';
import 'package:sigap/src/utils/constants/app_routes.dart';
class SelfieVerificationController extends GetxController {
// Image picker instance
final _imagePicker = ImagePicker();
// Edge function service for face comparison
final _edgeFunctionService = EdgeFunctionService.instance;
// Main controller reference
IdCardVerificationController? idCardController;
// States
final selfieImage = Rx<XFile?>(null);
final isVerifyingFace = false.obs;
final isSelfieValid = false.obs;
final selfieError = RxString('');
final isUploadingSelfie = false.obs;
final hasConfirmedSelfie = false.obs;
// Liveness detection states
final isPerformingLivenessCheck = false.obs;
final isLivenessCheckPassed = false.obs;
// Face comparison results
final isComparingWithIDCard = false.obs;
final isMatchWithIDCard = false.obs;
final matchConfidence = 0.0.obs;
final Rx<FaceComparisonResult?> faceComparisonResult =
Rx<FaceComparisonResult?>(null);
// Constructor
SelfieVerificationController({this.idCardController});
@override
void onInit() {
super.onInit();
// Try to find the ID card verification controller
try {
idCardController = Get.find<IdCardVerificationController>();
dev.log(
'Found IdCardVerificationController',
name: 'SELFIE_VERIFICATION',
);
} catch (e) {
dev.log(
'IdCardVerificationController not found, will use idCardController fallback',
name: 'SELFIE_VERIFICATION',
);
}
// Listen for changes to selfieImage
ever(selfieImage, (XFile? image) {
if (image != null) {
// When a selfie is set (after liveness check),
// automatically verify it against ID card
_processCapturedLivenessImage();
}
});
}
// Method to perform liveness detection
void performLivenessDetection() async {
try {
isPerformingLivenessCheck.value = true;
// Check if FaceLivenessController is already registered
final bool hasExistingController =
Get.isRegistered<FaceLivenessController>();
// Clear existing controller if it exists to ensure fresh state
if (hasExistingController) {
final existingController = Get.find<FaceLivenessController>();
existingController.handleCancellation();
await Get.delete<FaceLivenessController>();
}
// Register a new controller (will be done automatically by the widget)
final result = await Get.toNamed(AppRoutes.livenessDetection);
if (result is XFile) {
// Liveness check passed and returned an image
selfieImage.value = result;
isLivenessCheckPassed.value = true;
// The _processCapturedLivenessImage will be called automatically
// due to the ever() listener we set up in onInit
} else {
// User cancelled or something went wrong
isPerformingLivenessCheck.value = false;
isLivenessCheckPassed.value = false;
}
} catch (e) {
dev.log('Error in liveness detection: $e', name: 'SELFIE_VERIFICATION');
isPerformingLivenessCheck.value = false;
isLivenessCheckPassed.value = false;
selfieError.value = 'Liveness check failed: $e';
}
}
// Process the captured image after liveness check
Future<void> _processCapturedLivenessImage() async {
if (selfieImage.value == null) return;
try {
isVerifyingFace.value = true;
selfieError.value = '';
// Now verify that the selfie contains a valid face
final faces = await _edgeFunctionService.detectFaces(selfieImage.value!);
if (faces.isEmpty) {
selfieError.value = 'No face detected in your selfie';
isVerifyingFace.value = false;
isSelfieValid.value = false;
return;
}
// Face detected successfully
isSelfieValid.value = true;
isVerifyingFace.value = false;
// Now compare with ID card if available
await _compareWithIdCard();
} catch (e) {
dev.log('Error processing selfie: $e', name: 'SELFIE_VERIFICATION');
selfieError.value = 'Error verifying face: $e';
isVerifyingFace.value = false;
isSelfieValid.value = false;
} finally {
isPerformingLivenessCheck.value = false;
}
}
// Compare selfie with ID card
Future<void> _compareWithIdCard() async {
if (selfieImage.value == null) {
dev.log(
'No selfie image available for comparison',
name: 'SELFIE_VERIFICATION',
);
return;
}
// Check for ID card image from either controller
// Check for ID card image from IdCardVerificationController
XFile? idCardImage;
if (idCardController != null &&
idCardController!.idCardImage.value != null) {
idCardImage = idCardController!.idCardImage.value;
dev.log(
'Using ID card image from IdCardVerificationController',
name: 'SELFIE_VERIFICATION',
);
}
if (idCardImage == null) {
dev.log(
'No ID card image available for comparison',
name: 'SELFIE_VERIFICATION',
);
selfieError.value =
'Cannot compare with ID card - no ID card image found';
return;
}
try {
isComparingWithIDCard.value = true;
dev.log(
'Starting face comparison between ID card and selfie',
name: 'SELFIE_VERIFICATION',
);
dev.log('ID card path: ${idCardImage.path}', name: 'SELFIE_VERIFICATION');
dev.log(
'Selfie path: ${selfieImage.value!.path}',
name: 'SELFIE_VERIFICATION',
);
// Compare faces using edge function
final result = await _edgeFunctionService.compareFaces(
idCardImage,
selfieImage.value!,
similarityThreshold: 70.0, // Use 70% as default threshold
);
// Update comparison results
faceComparisonResult.value = result;
isMatchWithIDCard.value = result.isMatch;
matchConfidence.value = result.confidence;
dev.log(
'Face comparison complete: Match=${result.isMatch}, '
'Confidence=${(result.confidence * 100).toStringAsFixed(1)}%, '
'Message=${result.message}',
name: 'SELFIE_VERIFICATION',
);
if (!result.isMatch) {
selfieError.value = result.message;
}
} catch (e) {
dev.log('Error comparing faces: $e', name: 'SELFIE_VERIFICATION');
selfieError.value = 'Error comparing with ID card: $e';
isMatchWithIDCard.value = false;
matchConfidence.value = 0.0;
} finally {
isComparingWithIDCard.value = false;
}
}
// Clear selfie image
void clearSelfieImage() {
selfieImage.value = null;
isSelfieValid.value = false;
hasConfirmedSelfie.value = false;
selfieError.value = '';
}
// Confirm the selfie
void confirmSelfieImage() {
if (selfieImage.value != null && isSelfieValid.value) {
hasConfirmedSelfie.value = true;
// If we have a main controller, update its state
if (idCardController != null) {
idCardController!.hasConfirmedIdCard.value = true;
}
}
}
void clearErrors() {
selfieError.value = '';
}
// Reset verification state
void resetVerificationState() {
isVerifyingFace.value = false;
isComparingWithIDCard.value = false;
isMatchWithIDCard.value = false;
matchConfidence.value = 0.0;
faceComparisonResult.value = null;
isLivenessCheckPassed.value = false;
isPerformingLivenessCheck.value = false;
hasConfirmedSelfie.value = false;
selfieError.value = '';
}
}

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/basic/email_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/others/email_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/auth_header.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/auth_header.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/otp_input_field.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/otp_input_field.dart';

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/basic/forgot_password_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/others/forgot_password_controller.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/auth_header.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/auth_header.dart';
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart'; import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';

View File

@ -3,7 +3,8 @@ import 'package:get/get.dart';
import 'package:sigap/src/features/auth/data/models/kta_model.dart'; import 'package:sigap/src/features/auth/data/models/kta_model.dart';
import 'package:sigap/src/features/auth/data/models/ktp_model.dart'; import 'package:sigap/src/features/auth/data/models/ktp_model.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/id-card-verification/id_card_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup/id-card-verification/id_card_verification_controller.dart';
import 'package:sigap/src/shared/widgets/image_upload/image_source_dialog.dart'; import 'package:sigap/src/shared/widgets/image_upload/image_source_dialog.dart';
import 'package:sigap/src/shared/widgets/image_upload/image_uploader.dart'; import 'package:sigap/src/shared/widgets/image_upload/image_uploader.dart';
import 'package:sigap/src/shared/widgets/info/tips_container.dart'; import 'package:sigap/src/shared/widgets/info/tips_container.dart';

View File

@ -1,9 +1,11 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:logger/logger.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/identity-verification/identity_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup/identity-verification/identity_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup/selfie-verification/selfie_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/id_info_form.dart'; import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/id_info_form.dart';
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/verification_summary.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/utils/constants/colors.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';
@ -14,10 +16,14 @@ class IdentityVerificationStep extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final formKey = GlobalKey<FormState>(); final formKey = GlobalKey<FormState>();
final controller = Get.find<IdentityVerificationController>();
final mainController = Get.find<FormRegistrationController>(); final mainController = Get.find<FormRegistrationController>();
final controller = Get.find<IdentityVerificationController>();
Get.find<SelfieVerificationController>();
mainController.formKey = formKey; mainController.formKey = formKey;
// Extract isOfficer to avoid the error when used in Obx
final isOfficer = mainController.selectedRole.value?.isOfficer ?? false; final isOfficer = mainController.selectedRole.value?.isOfficer ?? false;
return Form( return Form(
@ -35,16 +41,8 @@ class IdentityVerificationStep extends StatelessWidget {
const SizedBox(height: TSizes.spaceBtwItems), const SizedBox(height: TSizes.spaceBtwItems),
// Verification Progress Card // Verification Progress Card
Obx(() => _buildVerificationProgressCard(controller)), GetBuilder<IdentityVerificationController>(
builder: (ctrl) => _buildVerificationProgressCard(ctrl),
const SizedBox(height: TSizes.spaceBtwItems),
// Registration Summary
Obx(
() => VerificationSummary(
summaryData: controller.summaryData,
isOfficer: isOfficer,
),
), ),
const SizedBox(height: TSizes.spaceBtwItems), const SizedBox(height: TSizes.spaceBtwItems),
@ -77,61 +75,65 @@ class IdentityVerificationStep extends StatelessWidget {
const SizedBox(height: TSizes.spaceBtwSections), const SizedBox(height: TSizes.spaceBtwSections),
// Save & Submit Button // Save & Submit Button
Obx( GetBuilder<IdentityVerificationController>(
() => ElevatedButton( id: 'saveButton',
onPressed: builder:
controller.isSavingData.value (ctrl) => ElevatedButton(
? null onPressed:
: () => _submitRegistrationData(controller, context), ctrl.isSavingData.value
style: ElevatedButton.styleFrom( ? null
backgroundColor: TColors.primary, : () => _submitRegistrationData(ctrl, context),
foregroundColor: Colors.white, style: ElevatedButton.styleFrom(
minimumSize: const Size(double.infinity, 50), backgroundColor: TColors.primary,
shape: RoundedRectangleBorder( foregroundColor: Colors.white,
borderRadius: BorderRadius.circular(TSizes.buttonRadius), minimumSize: const Size(double.infinity, 50),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(TSizes.buttonRadius),
),
disabledBackgroundColor: TColors.primary.withOpacity(0.3),
),
child:
ctrl.isSavingData.value
? const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
),
SizedBox(width: TSizes.sm),
Text('Submitting...'),
],
)
: const Text('Submit Registration'),
), ),
disabledBackgroundColor: TColors.primary.withOpacity(0.3),
),
child:
controller.isSavingData.value
? const Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
),
SizedBox(width: TSizes.sm),
Text('Submitting...'),
],
)
: const Text('Submit Registration'),
),
), ),
// Save Result Message // Save Result Message
Obx( GetBuilder<IdentityVerificationController>(
() => id: 'saveMessage',
controller.dataSaveMessage.value.isNotEmpty builder:
? Padding( (ctrl) =>
padding: const EdgeInsets.only(top: TSizes.sm), ctrl.dataSaveMessage.value.isNotEmpty
child: Text( ? Padding(
controller.dataSaveMessage.value, padding: const EdgeInsets.only(top: TSizes.sm),
style: TextStyle( child: Text(
color: ctrl.dataSaveMessage.value,
controller.isDataSaved.value style: TextStyle(
? Colors.green color:
: TColors.error, ctrl.isDataSaved.value
fontWeight: FontWeight.bold, ? Colors.green
), : TColors.error,
textAlign: TextAlign.center, fontWeight: FontWeight.bold,
), ),
) textAlign: TextAlign.center,
: const SizedBox.shrink(), ),
)
: const SizedBox.shrink(),
), ),
], ],
), ),
@ -142,8 +144,9 @@ class IdentityVerificationStep extends StatelessWidget {
Widget _buildVerificationProgressCard( Widget _buildVerificationProgressCard(
IdentityVerificationController controller, IdentityVerificationController controller,
) { ) {
// Instead of using Obx, we directly use the current values
final bool allVerified = final bool allVerified =
controller.isBasicInfoVerified.value && controller.isPersonalInfoVerified.value &&
controller.isIdCardVerified.value && controller.isIdCardVerified.value &&
controller.isSelfieVerified.value; controller.isSelfieVerified.value;
@ -189,7 +192,7 @@ class IdentityVerificationStep extends StatelessWidget {
// Basic Info status // Basic Info status
_buildVerificationItem( _buildVerificationItem(
'Basic Information', 'Basic Information',
controller.isBasicInfoVerified.value, controller.isPersonalInfoVerified.value,
), ),
// ID Card status // ID Card status
@ -292,38 +295,45 @@ class IdentityVerificationStep extends StatelessWidget {
return; return;
} }
Logger().i('Submitting registration data...');
Logger().i('Nik: ${controller.idCardController.ktpModel.value}');
Logger().i('Selfie: ${controller.selfieController.selfieImage.value}');
Logger().i('ID Card: ${controller.idCardController.idCardImage.value}');
Logger().i(
'Personal Info: ${controller.personalInfoController.phoneController.value}',
);
// Save registration data // Save registration data
final result = await controller.saveRegistrationData(); // final result = await controller.saveRegistrationData();
if (result) { // if (result) {
// Navigate to success page or show success dialog // // Navigate to success page or show success dialog
showDialog( // showDialog(
context: context, // context: context,
barrierDismissible: false, // barrierDismissible: false,
builder: // builder:
(context) => AlertDialog( // (context) => AlertDialog(
title: Row( // title: Row(
children: [ // children: [
Icon(Icons.check_circle, color: Colors.green), // Icon(Icons.check_circle, color: Colors.green),
SizedBox(width: TSizes.sm), // SizedBox(width: TSizes.sm),
Text('Registration Successful'), // Text('Registration Successful'),
], // ],
), // ),
content: Text( // content: Text(
'Your registration has been submitted successfully. You will be notified once your account is verified.', // 'Your registration has been submitted successfully. You will be notified once your account is verified.',
), // ),
actions: [ // actions: [
TextButton( // TextButton(
onPressed: () { // onPressed: () {
Navigator.of(context).pop(); // Navigator.of(context).pop();
// Navigate to login or home page // // Navigate to login or home page
Get.offAllNamed('/login'); // Get.offAllNamed('/login');
}, // },
child: Text('Go to Login'), // child: Text('Go to Login'),
), // ),
], // ],
), // ),
); // );
} // }
} }
} }

View File

@ -2,10 +2,11 @@ import 'dart:developer' as dev;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/selfie-verification/face_liveness_detection_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/selfie_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/selfie-verification/selfie_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/widgets/camera_preview_widget.dart'; import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/widgets/camera_preview_widget.dart';
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/widgets/captured_selfie_view.dart'; import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/widgets/captured_selfie_view.dart';
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/widgets/countdown_overlay_widget.dart';
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/widgets/debug_panel.dart'; import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/widgets/debug_panel.dart';
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/widgets/error_state_widget.dart'; import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/widgets/error_state_widget.dart';
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/widgets/instruction_banner.dart'; import 'package:sigap/src/features/auth/presentasion/pages/registration-form/basic/widgets/instruction_banner.dart';
@ -73,7 +74,8 @@ class LivenessDetectionPage extends StatelessWidget {
name: 'LIVENESS_DEBUG', name: 'LIVENESS_DEBUG',
); );
// Handle cleanup // Handle cleanup
if (selfieController != null) { if (Get.isRegistered<FaceLivenessController>()) {
final controller = Get.find<FaceLivenessController>();
dev.log( dev.log(
'Cancelling liveness detection and resetting loading state', 'Cancelling liveness detection and resetting loading state',
name: 'LIVENESS_DEBUG', name: 'LIVENESS_DEBUG',
@ -85,36 +87,65 @@ class LivenessDetectionPage extends StatelessWidget {
backgroundColor: Colors.white, backgroundColor: Colors.white,
appBar: _buildAppBar(context, controller, selfieController), appBar: _buildAppBar(context, controller, selfieController),
body: Obx(() { body: Obx(() {
dev.log( try {
'Rebuilding body: ' dev.log(
'Camera state: ${controller.cameraController?.value.isInitialized}, ' 'Rebuilding body: '
'Status: ${controller.status.value}, ' 'Camera state: ${controller.cameraController?.value.isInitialized}, '
'Steps: ${controller.successfulSteps.length}', 'Status: ${controller.status.value}, '
name: 'LIVENESS_DEBUG', 'Steps: ${controller.successfulSteps.length}',
); name: 'LIVENESS_DEBUG',
);
// Show loading indicator while camera initializes // Show loading indicator while camera initializes
if (controller.cameraController == null) { if (controller.cameraController == null) {
dev.log('Camera controller is null', name: 'LIVENESS_DEBUG'); dev.log('Camera controller is null', name: 'LIVENESS_DEBUG');
return ErrorStateWidget(message: 'Camera initialization failed'); return ErrorStateWidget(message: 'Camera initialization failed');
} }
if (!controller.cameraController!.value.isInitialized) { if (!controller.cameraController!.value.isInitialized) {
dev.log('Camera not initialized yet', name: 'LIVENESS_DEBUG'); dev.log('Camera not initialized yet', name: 'LIVENESS_DEBUG');
return _buildCameraInitializingState(); return _buildCameraInitializingState();
} }
// Show captured image when complete // Show captured image when complete
if (controller.isCaptured.value) { if (controller.isCaptured.value) {
dev.log('Showing captured view', name: 'LIVENESS_DEBUG'); dev.log('Showing captured view', name: 'LIVENESS_DEBUG');
return CapturedSelfieView( return CapturedSelfieView(
controller: controller, controller: controller,
selfieController: selfieController, selfieController: selfieController,
);
}
// Main liveness detection UI with improved layout
return _buildMainDetectionView(context, controller);
} catch (e) {
dev.log(
'Error in LivenessDetectionPage build: $e',
name: 'LIVENESS_DEBUG',
);
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
size: 64,
color: Colors.red.shade300,
),
SizedBox(height: 16),
Text(
'An error occurred with the camera',
style: TextStyle(fontSize: 16),
),
SizedBox(height: 24),
ElevatedButton(
onPressed: () => Get.back(),
child: Text('Go Back'),
),
],
),
); );
} }
// Main liveness detection UI
return _buildMainDetectionView(context, controller);
}), }),
), ),
); );
@ -184,7 +215,7 @@ class LivenessDetectionPage extends StatelessWidget {
); );
} }
// Main detection view UI // Main detection view UI with the new layout structure
Widget _buildMainDetectionView( Widget _buildMainDetectionView(
BuildContext context, BuildContext context,
FaceLivenessController controller, FaceLivenessController controller,
@ -193,25 +224,34 @@ class LivenessDetectionPage extends StatelessWidget {
return Stack( return Stack(
children: [ children: [
// Main content area with specified layout structure
Column( Column(
children: [ children: [
// Instruction banner // 1. Header with instructions (smaller to give more space to camera)
InstructionBanner(controller: controller), Container(
padding: const EdgeInsets.fromLTRB(16, 8, 16, 8),
child: InstructionBanner(controller: controller),
),
const SizedBox(height: 24), // 2. Content area with camera preview (expanded to fill available space)
// Camera preview with face overlay
Expanded( Expanded(
flex: 8, // Give most of the space to camera
child: CameraPreviewWidget( child: CameraPreviewWidget(
controller: controller, controller: controller,
screenWidth: screenSize.width, screenWidth: screenSize.width,
), ),
), ),
// Completed steps progress // 3. Bottom verification progress list (small fixed height)
VerificationProgressWidget(controller: controller), Container(
padding: const EdgeInsets.only(bottom: 16),
child: VerificationProgressWidget(controller: controller),
),
], ],
), ),
// Overlay components
CountdownOverlayWidget(controller: controller),
], ],
); );
} }

View File

@ -1,7 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/viewer-information/personal_info_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup/viewer-information/personal_info_controller.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/text/custom_text_field.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/constants/sizes.dart';

View File

@ -1,9 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/selfie-verification/facial_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/facial_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/selfie-verification/selfie_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/selfie_verification_controller.dart';
import 'package:sigap/src/shared/widgets/image_upload/image_uploader.dart'; import 'package:sigap/src/shared/widgets/image_upload/image_uploader.dart';
import 'package:sigap/src/shared/widgets/info/tips_container.dart'; import 'package:sigap/src/shared/widgets/info/tips_container.dart';
import 'package:sigap/src/shared/widgets/verification/validation_message_card.dart'; import 'package:sigap/src/shared/widgets/verification/validation_message_card.dart';
@ -32,8 +31,7 @@ class SelfieVerificationStep extends StatelessWidget {
final controller = Get.find<SelfieVerificationController>(); final controller = Get.find<SelfieVerificationController>();
final mainController = Get.find<FormRegistrationController>(); final mainController = Get.find<FormRegistrationController>();
final facialVerificationService = FacialVerificationService.instance; final facialVerificationService = FacialVerificationService.instance;
final FaceLivenessController faceLivenessController =
Get.find<FaceLivenessController>();
mainController.formKey = formKey; mainController.formKey = formKey;
return Form( return Form(

View File

@ -1,5 +1,7 @@
import 'package:camera/camera.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection_controller.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup/selfie-verification/face_liveness_detection_controller.dart';
import 'package:sigap/src/utils/constants/colors.dart'; import 'package:sigap/src/utils/constants/colors.dart';
class CameraPreviewWidget extends StatelessWidget { class CameraPreviewWidget extends StatelessWidget {
@ -14,47 +16,237 @@ class CameraPreviewWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Stack( final double screenHeight = MediaQuery.of(context).size.height;
alignment: Alignment.center, // Calculate available height for camera preview
children: [ final double availableHeight =
// Camera background screenHeight * 0.6; // Use 60% of screen height
Container(
width: screenWidth * 0.85,
height: screenWidth * 0.85,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.05),
borderRadius: BorderRadius.circular(24),
),
),
// Camera preview // Use the smallest dimension to ensure a square preview
ClipRRect( final double previewSize =
borderRadius: BorderRadius.circular(24), availableHeight < screenWidth
child: SizedBox( ? availableHeight
width: screenWidth * 0.85, : screenWidth * 0.92; // Use 92% of screen width if height is large
height: screenWidth * 0.85,
child: controller.cameraController!.buildPreview(),
),
),
// Scanning animation return Obx(() {
Positioned( final bool isInitialized =
top: 0, controller.cameraController?.value.isInitialized ?? false;
child: Container( final bool isActive =
width: screenWidth * 0.65, true; // Always show camera when controller is initialized
height: 2, final bool isCountdown =
decoration: BoxDecoration( controller.status.value == LivenessStatus.countdown;
gradient: LinearGradient(
colors: [ return Container(
Colors.transparent, width: double.infinity,
TColors.primary.withOpacity(0.8), padding: const EdgeInsets.symmetric(horizontal: 16),
Colors.transparent, child: Stack(
alignment: Alignment.center,
children: [
// Camera frame/background
Container(
width: previewSize,
height: previewSize,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.05),
borderRadius: BorderRadius.circular(24),
border: Border.all(color: Colors.grey.shade300, width: 2),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 8,
spreadRadius: 0,
),
], ],
), ),
), ),
),
// Full camera feed
if (isInitialized && isActive)
SizedBox(
width: previewSize,
height: previewSize,
child: ClipRRect(
borderRadius: BorderRadius.circular(22),
child: Stack(
fit: StackFit.expand,
children: [
Center(
child: SizedBox(
width: previewSize,
height: previewSize,
child: FittedBox(
fit: BoxFit.cover,
child: SizedBox(
width:
previewSize /
controller
.cameraController!
.value
.aspectRatio,
height: previewSize,
child: CameraPreview(
controller.cameraController!,
),
),
),
),
),
// Overlay for better face visibility
Container(
decoration: BoxDecoration(
gradient: RadialGradient(
colors: [
Colors.transparent,
Colors.black.withOpacity(0.2),
],
stops: const [0.7, 1.0],
center: Alignment.center,
radius: 0.9,
),
),
),
],
),
),
)
else
// Show placeholder when camera is not active
Container(
width: previewSize,
height: previewSize,
decoration: BoxDecoration(
color: Colors.black.withOpacity(0.05),
borderRadius: BorderRadius.circular(24),
),
child: Center(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.camera_alt_outlined,
size: 48,
color: Colors.grey.shade600,
),
const SizedBox(height: 16),
Text(
'Camera is initializing...',
style: TextStyle(color: Colors.grey.shade700),
),
],
),
),
),
// Scanning animation when not in countdown
if (isInitialized && isActive && !isCountdown)
Positioned(
top: previewSize * 0.2, // Position at 20% from the top
child: _buildScanningAnimation(previewSize),
),
// Face guide overlay
if (isInitialized && isActive)
Center(
child: Container(
width:
previewSize *
0.7, // Make face guide 70% of the camera preview
height: previewSize * 0.7,
decoration: BoxDecoration(
border: Border.all(
color: _getFaceGuideColor(),
width: 2.5,
strokeAlign: BorderSide.strokeAlignOutside,
),
shape: BoxShape.circle,
),
child: Obx(
() =>
controller.isFaceInFrame.value
? Center()
: Center(
child: Icon(
Icons.face,
color: Colors.white.withOpacity(0.7),
size: 48,
),
),
),
),
),
// Instructions overlay
if (isInitialized && isActive && !isCountdown)
Positioned(
bottom: 20,
child: Container(
padding: EdgeInsets.symmetric(vertical: 8, horizontal: 16),
decoration: BoxDecoration(
color: Colors.black26,
borderRadius: BorderRadius.circular(16),
),
child: Text(
_getActionText(),
style: TextStyle(
color: Colors.white,
fontSize: 14,
fontWeight: FontWeight.w500,
),
),
),
),
],
), ),
], );
});
}
// Build scanning animation widget
Widget _buildScanningAnimation(double previewSize) {
return Container(
width: previewSize * 0.8,
height: 3,
decoration: BoxDecoration(
gradient: LinearGradient(
colors: [
Colors.transparent,
TColors.primary.withOpacity(0.8),
Colors.transparent,
],
),
),
); );
} }
// Get action text based on current status
String _getActionText() {
switch (controller.status.value) {
case LivenessStatus.detectingFace:
return 'Position your face within the circle';
case LivenessStatus.checkLeftRotation:
return 'Turn your head to the left';
case LivenessStatus.checkRightRotation:
return 'Turn your head to the right';
case LivenessStatus.checkSmile:
return 'Please smile';
case LivenessStatus.checkEyesOpen:
return 'Keep your eyes open';
case LivenessStatus.readyForPhoto:
return 'Perfect! Hold still';
default:
return 'Follow instructions';
}
}
// Function to color the face guide based on detection state
Color _getFaceGuideColor() {
if (controller.status.value == LivenessStatus.countdown) {
return Colors.green; // Green during countdown
} else if (controller.isFaceInFrame.value) {
return controller.isFaceReadyForPhoto.value
? Colors.green
: TColors.primary;
} else {
return Colors.white.withOpacity(0.7);
}
}
} }

View File

@ -3,11 +3,11 @@ import 'dart:io';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/selfie-verification/face_liveness_detection_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/selfie_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/selfie-verification/selfie_verification_controller.dart';
import 'package:sigap/src/utils/constants/colors.dart'; import 'package:sigap/src/utils/constants/colors.dart';
class CapturedSelfieView extends StatelessWidget { class CapturedSelfieView extends StatefulWidget {
final FaceLivenessController controller; final FaceLivenessController controller;
final SelfieVerificationController? selfieController; final SelfieVerificationController? selfieController;
@ -17,105 +17,195 @@ class CapturedSelfieView extends StatelessWidget {
this.selfieController, this.selfieController,
}); });
@override
State<CapturedSelfieView> createState() => _CapturedSelfieViewState();
}
class _CapturedSelfieViewState extends State<CapturedSelfieView> {
// Add a flag for loading state during edge function comparison
bool isComparingWithID = false;
String? errorMessage;
bool isDisposed = false;
@override
void initState() {
super.initState();
// Ensure camera is paused when showing the captured image
widget.controller.pauseDetection();
}
@override
void dispose() {
isDisposed = true;
super.dispose();
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return SingleChildScrollView(
decoration: BoxDecoration( child: Container(
gradient: LinearGradient( decoration: BoxDecoration(
begin: Alignment.topCenter, gradient: LinearGradient(
end: Alignment.bottomCenter, begin: Alignment.topCenter,
colors: [Colors.white, Colors.green.shade50], end: Alignment.bottomCenter,
colors: [Colors.white, Colors.green.shade50],
),
), ),
), child: SafeArea(
child: Padding( child: Padding(
padding: const EdgeInsets.all(24.0), padding: const EdgeInsets.all(24.0),
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisSize: MainAxisSize.min,
children: [ children: [
// Success icon // Success icon
Container( Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.green.shade50, color: Colors.green.shade50,
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: Icon( child: Icon(
Icons.check_circle_outline, Icons.check_circle_outline,
color: Colors.green.shade600, color: Colors.green.shade600,
size: 48, size: 48,
),
),
const SizedBox(height: 20),
Text(
'Verification Successful!',
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.bold,
color: Colors.green.shade700,
),
),
const SizedBox(height: 8),
Text(
'Your identity has been verified',
style: TextStyle(fontSize: 16, color: Colors.black54),
),
const SizedBox(height: 32),
// Display captured image
if (controller.capturedImage != null)
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(150),
border: Border.all(color: Colors.white, width: 4),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 20,
spreadRadius: 2,
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(150),
child: Image.file(
File(controller.capturedImage!.path),
width: 200,
height: 200,
fit: BoxFit.cover,
), ),
), ),
),
const SizedBox(height: 32), const SizedBox(height: 20),
// Completed steps list Text(
_buildCompletedStepsList(), 'Verification Successful!',
style: TextStyle(
const SizedBox(height: 32), fontSize: 24,
fontWeight: FontWeight.bold,
// Continue button - clear loading state properly color: Colors.green.shade700,
ElevatedButton( ),
onPressed: () => _handleContinueButton(),
style: ElevatedButton.styleFrom(
backgroundColor: TColors.primary,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 56),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
), ),
elevation: 0,
), const SizedBox(height: 8),
child: const Text(
'Continue', Text(
style: TextStyle(fontSize: 16, fontWeight: FontWeight.w600), 'Your selfie has been captured successfully',
), style: TextStyle(fontSize: 16, color: Colors.black54),
),
const SizedBox(height: 32),
// Display captured image
if (widget.controller.capturedImage != null)
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(150),
border: Border.all(color: Colors.white, width: 4),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 20,
spreadRadius: 2,
),
],
),
child: ClipRRect(
borderRadius: BorderRadius.circular(150),
child: Image.file(
File(widget.controller.capturedImage!.path),
width: 200,
height: 200,
fit: BoxFit.cover,
),
),
),
const SizedBox(height: 24),
// Completed steps list
_buildCompletedStepsList(),
const SizedBox(height: 24),
// Show error message if there's any
if (errorMessage != null)
Container(
padding: EdgeInsets.all(16),
margin: EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.red.shade50,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.red.shade200),
),
child: Row(
children: [
Icon(Icons.warning_amber_rounded, color: Colors.red),
SizedBox(width: 12),
Expanded(
child: Text(
errorMessage!,
style: TextStyle(color: Colors.red.shade700),
),
),
],
),
),
// Continue button - clear loading state properly
ElevatedButton(
onPressed: isComparingWithID ? null : _handleContinueButton,
style: ElevatedButton.styleFrom(
backgroundColor: TColors.primary,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 56),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
),
elevation: 0,
),
child:
isComparingWithID
? Row(
mainAxisSize: MainAxisSize.min,
children: [
SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(
color: Colors.white,
strokeWidth: 2,
),
),
SizedBox(width: 12),
Text('Processing...'),
],
)
: const Text(
'Continue',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w600,
),
),
),
// Try again button when there's an error
if (errorMessage != null)
TextButton(
onPressed: () {
// Reset errors and go back to try again
if (!isDisposed) {
setState(() {
errorMessage = null;
});
}
widget.controller.disposeCamera();
Get.back();
},
style: TextButton.styleFrom(
foregroundColor: TColors.primary,
),
child: Text('Try Again'),
),
],
), ),
], ),
), ),
), ),
); );
@ -124,7 +214,7 @@ class CapturedSelfieView extends StatelessWidget {
// Build the completed steps list // Build the completed steps list
Widget _buildCompletedStepsList() { Widget _buildCompletedStepsList() {
return Container( return Container(
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(16), borderRadius: BorderRadius.circular(16),
@ -143,22 +233,24 @@ class CapturedSelfieView extends StatelessWidget {
children: [ children: [
Icon(Icons.verified, color: Colors.green.shade600, size: 20), Icon(Icons.verified, color: Colors.green.shade600, size: 20),
const SizedBox(width: 8), const SizedBox(width: 8),
Text( Flexible(
'All verification steps completed', child: Text(
style: TextStyle( 'All verification steps completed',
fontWeight: FontWeight.w600, style: TextStyle(
fontSize: 16, fontWeight: FontWeight.w600,
color: Colors.black87, fontSize: 16,
color: Colors.black87,
),
), ),
), ),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 12),
...controller.successfulSteps.map( ...widget.controller.successfulSteps.map(
(step) => Padding( (step) => Padding(
padding: const EdgeInsets.only(bottom: 12), padding: const EdgeInsets.only(bottom: 8),
child: Row( child: Row(
children: [ children: [
Container( Container(
@ -173,10 +265,15 @@ class CapturedSelfieView extends StatelessWidget {
size: 14, size: 14,
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 8),
Text( Flexible(
step, child: Text(
style: const TextStyle(fontSize: 14, color: Colors.black87), step,
style: const TextStyle(
fontSize: 14,
color: Colors.black87,
),
),
), ),
], ],
), ),
@ -187,32 +284,61 @@ class CapturedSelfieView extends StatelessWidget {
); );
} }
// Handle the continue button // Handle the continue button with edge function integration
void _handleContinueButton() { Future<void> _handleContinueButton() async {
// Make sure camera is fully disposed when we leave
widget.controller.disposeCamera();
// Avoid setState if widget is disposed
if (!mounted) return;
// Reset loading state in selfie controller before navigating back // Reset loading state in selfie controller before navigating back
try { try {
if (selfieController != null) { if (widget.selfieController != null) {
// Show loading state
setState(() {
isComparingWithID = true;
errorMessage = null;
});
dev.log( dev.log(
'Found SelfieVerificationController, handling success', 'Found SelfieVerificationController, setting captured selfie',
name: 'LIVENESS_DEBUG', name: 'LIVENESS_DEBUG',
); );
// Connect with SelfieVerificationController
if (controller.capturedImage != null) { // Set the captured image
if (widget.controller.capturedImage != null) {
dev.log( dev.log(
'Setting captured image on SelfieVerificationController', 'Setting captured image on SelfieVerificationController',
name: 'LIVENESS_DEBUG', name: 'LIVENESS_DEBUG',
); );
selfieController?.selfieImage.value = controller.capturedImage;
// selfieController._processCapturedLivenessImage(); // First finish the navigation to prevent state updates after dispose
Future.microtask(() {
widget.selfieController!.selfieImage.value =
widget.controller.capturedImage;
Get.back(result: widget.controller.capturedImage);
});
} }
} else {
// If no selfie controller, just go back with the result
Future.microtask(
() => Get.back(result: widget.controller.capturedImage),
);
} }
} catch (e) { } catch (e) {
dev.log( dev.log(
'Error connecting with SelfieVerificationController: $e', 'Error connecting with SelfieVerificationController: $e',
name: 'LIVENESS_DEBUG', name: 'LIVENESS_DEBUG',
); );
// Continue without selfie controller
if (mounted) {
setState(() {
isComparingWithID = false;
errorMessage =
'Failed to process the captured image. Please try again.';
});
}
} }
Get.back(result: controller.capturedImage);
} }
} }

View File

@ -0,0 +1,79 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup/selfie-verification/face_liveness_detection_controller.dart';
import 'package:sigap/src/utils/constants/colors.dart';
class CountdownOverlayWidget extends StatelessWidget {
final FaceLivenessController controller;
const CountdownOverlayWidget({super.key, required this.controller});
@override
Widget build(BuildContext context) {
return Obx(() {
if (controller.status.value != LivenessStatus.countdown) {
return SizedBox.shrink();
}
final seconds = controller.countdownSeconds.value;
return Container(
color: Colors.black54,
alignment: Alignment.center,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Countdown circle
Container(
width: 120,
height: 120,
decoration: BoxDecoration(
shape: BoxShape.circle,
color: Colors.black45,
border: Border.all(color: TColors.primary, width: 4),
),
child: Center(
child: Text(
'$seconds',
style: TextStyle(
color: Colors.white,
fontSize: 64,
fontWeight: FontWeight.bold,
),
),
),
),
SizedBox(height: 24),
Text(
'Hold still',
style: TextStyle(
color: Colors.white,
fontSize: 24,
fontWeight: FontWeight.w500,
),
),
SizedBox(height: 8),
Text(
'Keep your face centered in the frame',
style: TextStyle(color: Colors.white70, fontSize: 16),
),
SizedBox(height: 32),
// Cancel button
TextButton.icon(
onPressed: controller.cancelCountdown,
icon: Icon(Icons.cancel_outlined, color: Colors.white70),
label: Text('Cancel', style: TextStyle(color: Colors.white70)),
style: TextButton.styleFrom(
backgroundColor: Colors.black38,
padding: EdgeInsets.symmetric(horizontal: 16, vertical: 8),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(30),
),
),
),
],
),
);
});
}
}

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/selfie-verification/face_liveness_detection_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/selfie_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/selfie-verification/selfie_verification_controller.dart';
/// Shows the debug panel for liveness detection /// Shows the debug panel for liveness detection
void showLivenessDebugPanel( void showLivenessDebugPanel(

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/selfie-verification/face_liveness_detection_controller.dart';
import 'package:sigap/src/utils/constants/colors.dart'; import 'package:sigap/src/utils/constants/colors.dart';
class ErrorStateWidget extends StatelessWidget { class ErrorStateWidget extends StatelessWidget {

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/selfie-verification/face_liveness_detection_controller.dart';
import 'package:sigap/src/utils/constants/colors.dart'; import 'package:sigap/src/utils/constants/colors.dart';
class InstructionBanner extends StatelessWidget { class InstructionBanner extends StatelessWidget {
@ -10,104 +10,83 @@ class InstructionBanner extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Obx(() {
width: double.infinity, final direction = controller.getCurrentDirection();
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 16), final isCountdown = controller.status.value == LivenessStatus.countdown;
decoration: BoxDecoration(
color: TColors.primary.withOpacity(0.08), // Select appropriate icon based on current status
borderRadius: const BorderRadius.only( IconData iconData;
bottomLeft: Radius.circular(16), Color iconColor;
bottomRight: Radius.circular(16),
), switch (controller.status.value) {
), case LivenessStatus.detectingFace:
child: Row( iconData = Icons.face;
children: [ iconColor = TColors.primary;
Container( break;
padding: const EdgeInsets.all(8), case LivenessStatus.checkLeftRotation:
decoration: BoxDecoration( iconData = Icons.rotate_left;
color: TColors.primary.withOpacity(0.1), iconColor = TColors.primary;
shape: BoxShape.circle, break;
), case LivenessStatus.checkRightRotation:
child: Icon( iconData = Icons.rotate_right;
Icons.face_retouching_natural, iconColor = TColors.primary;
color: TColors.primary, break;
size: 20, case LivenessStatus.checkSmile:
), iconData = Icons.sentiment_satisfied_alt;
iconColor = TColors.primary;
break;
case LivenessStatus.checkEyesOpen:
iconData = Icons.remove_red_eye;
iconColor = TColors.primary;
break;
case LivenessStatus.readyForPhoto:
case LivenessStatus.countdown:
iconData = Icons.check_circle;
iconColor = Colors.green;
break;
case LivenessStatus.failed:
iconData = Icons.error;
iconColor = Colors.red;
break;
default:
iconData = Icons.info;
iconColor = TColors.primary;
}
return Container(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 12),
decoration: BoxDecoration(
color:
isCountdown
? Colors.green.withOpacity(0.1)
: TColors.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(16),
border: Border.all(
color:
isCountdown
? Colors.green.withOpacity(0.3)
: TColors.primary.withOpacity(0.3),
width: 1,
), ),
const SizedBox(width: 16), ),
Expanded( child: Row(
child: Obx( children: [
() => Text( Icon(iconData, color: iconColor, size: 24,
controller.getCurrentDirection(), ),
SizedBox(width: 12),
Expanded(
child: Text(
direction,
style: TextStyle( style: TextStyle(
fontSize: 15, fontSize: 16,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: TColors.primary, color: Colors.black87,
height: 1.4,
), ),
), ),
), ),
), ],
// Status indicator ),
Obx( );
() => Container( });
padding: EdgeInsets.symmetric(horizontal: 8, vertical: 4),
decoration: BoxDecoration(
color: _getStatusColor(controller.status.value),
borderRadius: BorderRadius.circular(12),
),
child: Text(
_getStatusText(controller.status.value),
style: TextStyle(color: Colors.white, fontSize: 12),
),
),
),
],
),
);
}
// Function to get status color
Color _getStatusColor(LivenessStatus status) {
switch (status) {
case LivenessStatus.preparing:
case LivenessStatus.detectingFace:
return Colors.orange;
case LivenessStatus.failed:
return Colors.red;
case LivenessStatus.completed:
case LivenessStatus.photoTaken:
return Colors.green;
default:
return TColors.primary;
}
}
// Function to get status text
String _getStatusText(LivenessStatus status) {
switch (status) {
case LivenessStatus.preparing:
return 'Preparing';
case LivenessStatus.detectingFace:
return 'Detecting';
case LivenessStatus.checkLeftRotation:
return 'Look Left';
case LivenessStatus.checkRightRotation:
return 'Look Right';
case LivenessStatus.checkSmile:
return 'Smile';
case LivenessStatus.checkEyesOpen:
return 'Open Eyes';
case LivenessStatus.readyForPhoto:
return 'Ready';
case LivenessStatus.photoTaken:
return 'Processing';
case LivenessStatus.completed:
return 'Success';
case LivenessStatus.failed:
return 'Failed';
default:
return 'Unknown';
}
} }
} }

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/selfie-verification/face_liveness_detection_controller.dart';
import 'package:sigap/src/utils/constants/colors.dart'; import 'package:sigap/src/utils/constants/colors.dart';
class VerificationProgressWidget extends StatelessWidget { class VerificationProgressWidget extends StatelessWidget {
@ -10,106 +10,106 @@ class VerificationProgressWidget extends StatelessWidget {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Container( return Obx(() {
margin: const EdgeInsets.all(20), final steps = controller.verificationSteps;
padding: const EdgeInsets.all(20), final completedSteps = controller.successfulSteps;
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
spreadRadius: 0,
offset: const Offset(0, 2),
),
],
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
children: [
Icon(Icons.verified_outlined, color: TColors.primary, size: 20),
const SizedBox(width: 8),
Text(
'Verification Progress',
style: TextStyle(
fontWeight: FontWeight.w600,
fontSize: 16,
color: Colors.black87,
),
),
],
),
const SizedBox(height: 16),
// Progress indicator return Container(
Obx( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
() => LinearProgressIndicator( child: Column(
value: controller.successfulSteps.length / 4, crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Progress indicator
LinearProgressIndicator(
value: steps.isEmpty ? 0 : completedSteps.length / steps.length,
backgroundColor: Colors.grey.shade200, backgroundColor: Colors.grey.shade200,
color: TColors.primary, color: TColors.primary,
minHeight: 6, minHeight: 6,
borderRadius: BorderRadius.circular(3), borderRadius: BorderRadius.circular(3),
), ),
), const SizedBox(height: 12),
const SizedBox(height: 16), // Completed steps
Wrap(
spacing: 8,
runSpacing: 8,
children: List.generate(steps.length, (index) {
final isCompleted = index < controller.currentStepIndex;
final isInProgress = index == controller.currentStepIndex;
// Steps list return Container(
Obx(() { padding: const EdgeInsets.symmetric(
if (controller.successfulSteps.isEmpty) { horizontal: 10,
return const Padding( vertical: 6,
padding: EdgeInsets.symmetric(vertical: 8),
child: Text(
'Follow the instructions to complete verification',
style: TextStyle(
fontSize: 14,
color: Colors.black54,
fontStyle: FontStyle.italic,
), ),
), decoration: BoxDecoration(
); color:
} isCompleted
? Colors.green.withOpacity(0.1)
return Column( : isInProgress
children: ? TColors.primary.withOpacity(0.1)
controller.successfulSteps : Colors.grey.withOpacity(0.1),
.map( borderRadius: BorderRadius.circular(12),
(step) => Padding( border: Border.all(
padding: const EdgeInsets.only(bottom: 10), color:
child: Row( isCompleted
children: [ ? Colors.green.withOpacity(0.3)
Container( : isInProgress
padding: const EdgeInsets.all(4), ? TColors.primary.withOpacity(0.3)
decoration: BoxDecoration( : Colors.grey.withOpacity(0.3),
color: Colors.green.shade50, ),
shape: BoxShape.circle, ),
), child: Row(
child: Icon( mainAxisSize: MainAxisSize.min,
Icons.check, children: [
color: Colors.green.shade600, Icon(
size: 14, isCompleted
), ? Icons.check_circle
), : isInProgress
const SizedBox(width: 12), ? Icons.timelapse
Text( : Icons.circle_outlined,
step, size: 16,
style: const TextStyle( color:
fontSize: 14, isCompleted
color: Colors.black87, ? Colors.green
), : isInProgress
), ? TColors.primary
], : Colors.grey,
), ),
const SizedBox(width: 6),
Text(
_getShortStepName(steps[index]),
style: TextStyle(
fontSize: 12,
color:
isCompleted
? Colors.green
: isInProgress
? TColors.primary
: Colors.grey,
fontWeight:
isInProgress
? FontWeight.bold
: FontWeight.normal,
), ),
) ),
.toList(), ],
); ),
}), );
], }),
), ),
); ],
),
);
});
}
// Get shorter step names for chips
String _getShortStepName(String step) {
if (step.contains('left')) return 'Look Left';
if (step.contains('right')) return 'Look Right';
if (step.contains('smile')) return 'Smile';
if (step.contains('eyes')) return 'Eyes Open';
return step;
} }
} }

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/officer-information/officer_info_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/officer-information/officer_info_controller.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/text/custom_text_field.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/constants/sizes.dart';

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/officer-information/unit_info_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/officer-information/unit_info_controller.dart';
import 'package:sigap/src/shared/widgets/dropdown/custom_dropdown.dart'; import 'package:sigap/src/shared/widgets/dropdown/custom_dropdown.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/text/custom_text_field.dart'; import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
@ -27,7 +27,7 @@ class UnitInfoStep extends StatelessWidget {
title: 'Unit Information', title: 'Unit Information',
subtitle: 'Please provide your unit details', subtitle: 'Please provide your unit details',
), ),
const SizedBox(height: TSizes.spaceBtwItems), const SizedBox(height: TSizes.spaceBtwItems),
// Position field // Position field

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/data/models/administrative_division.dart'; import 'package:sigap/src/features/auth/data/models/administrative_division.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/basic/location_selection_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/others/location_selection_controller.dart';
import 'package:sigap/src/utils/constants/colors.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';

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/identity-verification/identity_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/identity-verification/identity_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/facial_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/selfie-verification/facial_verification_controller.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/verification_status.dart'; import 'package:sigap/src/shared/widgets/form/verification_status.dart';
import 'package:sigap/src/shared/widgets/info/tips_container.dart'; import 'package:sigap/src/shared/widgets/info/tips_container.dart';

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/identity-verification/identity_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/identity-verification/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/place_of_birth_field.dart';
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification/verification_status_message.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/date_picker_field.dart';

View File

@ -1,7 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/data/models/administrative_division.dart'; import 'package:sigap/src/features/auth/data/models/administrative_division.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/identity-verification/identity_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/identity-verification/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/city_selection.dart';
import 'package:sigap/src/utils/constants/colors.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';

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/identity-verification/identity_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/identity-verification/identity_verification_controller.dart';
import 'package:sigap/src/shared/widgets/buttons/custom_elevated_button.dart'; import 'package:sigap/src/shared/widgets/buttons/custom_elevated_button.dart';
import 'package:sigap/src/utils/constants/sizes.dart'; import 'package:sigap/src/utils/constants/sizes.dart';

View File

@ -1,6 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/identity-verification/identity_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/identity-verification/identity_verification_controller.dart';
import 'package:sigap/src/shared/widgets/verification/validation_message_card.dart'; import 'package:sigap/src/shared/widgets/verification/validation_message_card.dart';
import 'package:sigap/src/utils/constants/sizes.dart'; import 'package:sigap/src/utils/constants/sizes.dart';

View File

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_tabler_icons/flutter_tabler_icons.dart'; import 'package:flutter_tabler_icons/flutter_tabler_icons.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/basic/signin_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signin/signin_controller.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/auth_divider.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/auth_divider.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/auth_header.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/auth_header.dart';

View File

@ -3,7 +3,7 @@ import 'package:flutter/services.dart';
import 'package:flutter_svg/svg.dart'; import 'package:flutter_svg/svg.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart'; import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/basic/signup_with_role_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/signup_with_role_controller.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/auth_divider.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/auth_divider.dart';
import 'package:sigap/src/features/auth/presentasion/widgets/password_field.dart'; import 'package:sigap/src/features/auth/presentasion/widgets/password_field.dart';

View File

@ -2,8 +2,8 @@ import 'dart:developer' as dev;
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/face_liveness_detection_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/selfie-verification/face_liveness_detection_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/selfie-verification/selfie_verification_controller.dart'; import 'package:sigap/src/features/auth/presentasion/controllers/signup/selfie-verification/selfie_verification_controller.dart';
/// Utility class for debugging the liveness detection and verification process /// Utility class for debugging the liveness detection and verification process
class LivenessDebugUtils { class LivenessDebugUtils {
@ -166,7 +166,7 @@ class LivenessDebugUtils {
if (Get.isRegistered<SelfieVerificationController>()) { if (Get.isRegistered<SelfieVerificationController>()) {
final controller = Get.find<SelfieVerificationController>(); final controller = Get.find<SelfieVerificationController>();
controller.cancelLivenessDetection(); // controller.cancelLivenessDetection();
controller.clearSelfieImage(); controller.clearSelfieImage();
dev.log('Reset SelfieVerificationController', name: _logName); dev.log('Reset SelfieVerificationController', name: _logName);
} }