diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart index 1d5cac3..5c1662a 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/basic/registration_form_controller.dart @@ -14,6 +14,7 @@ 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/models/user_metadata_model.dart'; import 'package:sigap/src/features/personalization/data/repositories/roles_repository.dart'; +import 'package:sigap/src/features/personalization/data/repositories/users_repository.dart'; import 'package:sigap/src/utils/constants/num_int.dart'; import 'package:sigap/src/utils/popups/loaders.dart'; @@ -83,7 +84,7 @@ class FormRegistrationController extends GetxController { try { Logger().d('Fetching user data safely without redirects'); - // Get user session directly without going through AuthRepository methods that might trigger redirects + // Get user session directly without going through UserRepositorysitory methods that might trigger redirects final session = SupabaseService.instance.client.auth.currentSession; if (session?.user != null) { @@ -691,8 +692,7 @@ class FormRegistrationController extends GetxController { submitMessage.value = 'Submitting your registration...'; // Save all registration data using the identity verification controller - final identityController = Get.find(); - final result = await identityController.saveRegistrationData(); + final result = await saveRegistrationData(); if (result) { isSubmitSuccess.value = true; @@ -712,4 +712,155 @@ class FormRegistrationController extends GetxController { isSubmitting.value = false; } } + + /// Save registration data by collecting information from all steps + Future saveRegistrationData({Map? summaryData}) async { + try { + Logger().d('Starting registration data save process'); + + // Collect all data from forms if not provided in summaryData + if (summaryData == null) { + collectAllFormData(); + Logger().d('Collected data from all form controllers'); + } else { + Logger().d('Using provided summary data for registration'); + } + + // Create the final data object for submission based on user models + Map finalData = {}; + + // Basic user information (matching UserModel structure) + finalData['user_id'] = userMetadata.value.userId; + finalData['email'] = userMetadata.value.email; + finalData['role_id'] = + userMetadata.value.roleId ?? selectedRole.value?.id; + finalData['is_officer'] = userMetadata.value.isOfficer; + finalData['phone'] = personalInfoController.phoneController.text; + finalData['profile_status'] = 'pending_approval'; + + // Structure user profile data according to ProfileModel + Map profileData = {}; + if (!userMetadata.value.isOfficer) { + // Regular user profile data (matching ProfileModel structure) + profileData = { + 'user_id': userMetadata.value.userId, + 'nik': identityController.nikController.text, + 'first_name': personalInfoController.firstNameController.text, + 'last_name': personalInfoController.lastNameController.text, + 'place_of_birth': identityController.placeOfBirthController.text, + 'birth_date': identityController.birthDateController.text, + 'address': {'address': personalInfoController.addressController.text}, + }; + finalData['profile'] = profileData; + } + + // For officers, structure data according to OfficerModel + if (userMetadata.value.isOfficer) { + // Officer data (matching OfficerModel structure) + Map officerData = { + 'id': userMetadata.value.userId, + 'unit_id': unitInfoController?.unitIdController.text ?? '', + 'role_id': finalData['role_id'], + 'nrp': + identityController + .nikController + .text, // NRP is stored in NIK field for officers + 'name': personalInfoController.nameController.text, + 'rank': officerInfoController?.rankController.text, + 'position': unitInfoController?.positionController.text, + 'phone': personalInfoController.phoneController.text, + 'email': finalData['email'], + 'place_of_birth': identityController.placeOfBirthController.text, + 'date_of_birth': identityController.birthDateController.text, + }; + finalData['officer'] = officerData; + } + + // Store ID card verification data (KTP or KTA) + if (userMetadata.value.isOfficer) { + // KTA data + finalData['id_card'] = { + 'type': 'KTA', + 'nrp': identityController.nikController.text, + 'name': identityController.fullNameController.text, + 'birth_date': identityController.birthDateController.text, + 'gender': identityController.selectedGender.value, + }; + } else { + // KTP data + finalData['id_card'] = { + 'type': 'KTP', + 'nik': identityController.nikController.text, + 'name': identityController.fullNameController.text, + 'place_of_birth': identityController.placeOfBirthController.text, + 'birth_date': identityController.birthDateController.text, + 'gender': identityController.selectedGender.value, + 'address': identityController.addressController.text, + }; + } + + // Face verification data + finalData['face_verification'] = { + 'selfie_valid': selfieVerificationController.isSelfieValid.value, + 'liveness_check_passed': + selfieVerificationController.isLivenessCheckPassed.value, + 'face_match_result': + selfieVerificationController.isMatchWithIDCard.value, + 'match_confidence': selfieVerificationController.matchConfidence.value, + }; + + // Image paths + finalData['images'] = { + 'id_card': idCardVerificationController.idCardImage.value?.path, + 'selfie': selfieVerificationController.selfieImage.value?.path, + }; + + // Merge with provided summary data if available + if (summaryData != null && summaryData.isNotEmpty) { + // Merge summaryData into profile or officer data based on user type + if (userMetadata.value.isOfficer) { + if (summaryData['fullName'] != null) { + finalData['officer']['name'] = summaryData['fullName']; + } + if (summaryData['birthDate'] != null) { + finalData['officer']['date_of_birth'] = summaryData['birthDate']; + } + } else { + if (summaryData['fullName'] != null) { + final names = summaryData['fullName'].toString().split(' '); + if (names.isNotEmpty) { + finalData['profile']['first_name'] = names.first; + if (names.length > 1) { + finalData['profile']['last_name'] = names.sublist(1).join(' '); + } + } + } + if (summaryData['placeOfBirth'] != null) { + finalData['profile']['place_of_birth'] = + summaryData['placeOfBirth']; + } + if (summaryData['birthDate'] != null) { + finalData['profile']['birth_date'] = summaryData['birthDate']; + } + if (summaryData['address'] != null) { + finalData['profile']['address'] = { + 'address': summaryData['address'], + }; + } + } + } + + Logger().d('Registration data prepared and ready for submission'); + + // Submit to user repository + final userRepo = Get.find(); + final result = await userRepo.updateUserProfile(finalData); + + Logger().d('Registration submission result: $result'); + return result; + } catch (e) { + Logger().e('Error saving registration data: $e'); + return false; + } + } } diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/identity-verification/identity_verification_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/identity-verification/identity_verification_controller.dart index e97b6a5..7a8daba 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/controllers/identity-verification/identity_verification_controller.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/identity-verification/identity_verification_controller.dart @@ -4,15 +4,15 @@ 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/presentasion/controllers/selfie-verification/facial_verification_controller.dart'; -import 'package:sigap/src/features/auth/data/bindings/registration_binding.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/data/services/registration_service.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 @@ -21,23 +21,22 @@ class IdentityVerificationController extends GetxController { // Dependencies final bool isOfficer; final AzureOCRService _ocrService = AzureOCRService(); - // Use FacialVerificationService instead of direct EdgeFunction final FacialVerificationService _faceService = FacialVerificationService.instance; - // Local storage keys (matching those in IdCardVerificationController) + // Local storage keys static const String _kOcrResultsKey = 'ocr_results'; static const String _kOcrModelKey = 'ocr_model'; static const String _kIdCardTypeKey = 'id_card_type'; - // Controllers + // Controllers for form fields final TextEditingController nikController = TextEditingController(); final TextEditingController fullNameController = TextEditingController(); final TextEditingController placeOfBirthController = TextEditingController(); final TextEditingController birthDateController = TextEditingController(); final TextEditingController addressController = TextEditingController(); - // Error variables + // Form validation errors final RxString nikError = RxString(''); final RxString fullNameError = RxString(''); final RxString placeOfBirthError = RxString(''); @@ -45,45 +44,51 @@ class IdentityVerificationController extends GetxController { final RxString genderError = RxString(''); final RxString addressError = RxString(''); - // Verification states + // ID verification states final RxBool isVerifying = RxBool(false); final RxBool isVerified = RxBool(false); final RxString verificationMessage = RxString(''); - // Face verification + // Face verification states final RxBool isVerifyingFace = RxBool(false); final RxBool isFaceVerified = RxBool(false); final RxString faceVerificationMessage = RxString(''); - - // Use FaceComparisonResult for face verification final Rx faceComparisonResult = Rx(null); - // Gender selection - initialize with a default value + // Gender selection dropdown final Rx selectedGender = Rx('Male'); - // Form validation + // Form validation state final RxBool isFormValid = RxBool(true); // Flag to prevent infinite loop bool _isApplyingData = false; - // NIK field readonly status + // UI control states final RxBool isNikReadOnly = RxBool(false); - - // Properties to store extracted ID card data - final String? extractedIdCardNumber; - final String? extractedName; final RxBool isPreFilledNik = false.obs; - // Store the loaded OCR data + // Storage for extracted data final RxMap ocrData = RxMap({}); + final String? extractedIdCardNumber; + final String? extractedName; - // Status of data saving + // Data saving states final RxBool isSavingData = RxBool(false); final RxBool isDataSaved = RxBool(false); final RxString dataSaveMessage = RxString(''); + // Summary data for review page + final RxMap summaryData = RxMap({}); + + // 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 = '', @@ -93,14 +98,173 @@ class IdentityVerificationController extends GetxController { @override void onInit() { super.onInit(); - // Make sure selectedGender has a default value + // Set default gender value selectedGender.value = selectedGender.value ?? 'Male'; - // Load OCR data from local storage with debug info - print( - 'Initializing IdentityVerificationController and loading OCR data...', - ); - loadOcrDataFromLocalStorage(); + // Load data in sequence + _initializeData(); + } + + // Initialize all data in sequence + Future _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 loadAllStepsData() async { + try { + isLoadingSummary.value = true; + + // Get references to all controllers + final registrationController = Get.find(); + final idCardController = Get.find(); + final selfieController = Get.find(); + + // 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 @@ -108,7 +272,6 @@ class IdentityVerificationController extends GetxController { try { final prefs = await SharedPreferences.getInstance(); - // Load stored ID card type to verify it matches current flow final String? storedIdCardType = prefs.getString(_kIdCardTypeKey); print( 'Stored ID card type: $storedIdCardType, Current isOfficer: $isOfficer', @@ -121,7 +284,6 @@ class IdentityVerificationController extends GetxController { return; } - // Load OCR results final String? jsonData = prefs.getString(_kOcrResultsKey); if (jsonData != null) { print('Found OCR data in storage: ${jsonData.length} chars'); @@ -129,11 +291,8 @@ class IdentityVerificationController extends GetxController { ocrData.assignAll(results); print('OCR data loaded: ${results.length} items'); - // Load OCR model final String? modelJson = prefs.getString(_kOcrModelKey); if (modelJson != null) { - print('Found OCR model in storage: ${modelJson.length} chars'); - try { if (isOfficer) { final ktaModel = KtaModel.fromJson(jsonDecode(modelJson)); @@ -145,7 +304,6 @@ class IdentityVerificationController extends GetxController { applyKtpDataToForm(ktpModel); } isNikReadOnly.value = true; - print('NIK field set to read-only'); } catch (e) { print('Error parsing model JSON: $e'); } @@ -156,7 +314,6 @@ class IdentityVerificationController extends GetxController { } catch (e) { print('Error loading OCR data from local storage: $e'); } finally { - // If data wasn't loaded from local storage, try from FormRegistrationController if (ocrData.isEmpty) { print('Falling back to FormRegistrationController data'); _safeApplyIdCardData(); @@ -166,24 +323,14 @@ class IdentityVerificationController extends GetxController { // 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) { + 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) { + if (ktpModel.birthDate.isNotEmpty) birthDateController.text = ktpModel.birthDate; - } - + if (ktpModel.gender.isNotEmpty) { - // Convert gender to the format expected by the dropdown String gender = ktpModel.gender.toLowerCase(); if (gender.contains('laki') || gender == 'male') { selectedGender.value = 'Male'; @@ -191,56 +338,42 @@ class IdentityVerificationController extends GetxController { selectedGender.value = 'Female'; } } - - if (ktpModel.address.isNotEmpty) { - addressController.text = ktpModel.address; - } - - // Mark as verified since we have validated KTP data + + 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) { - // For officer, we'd fill in different fields as needed - if (ktaModel.name.isNotEmpty) { - fullNameController.text = ktaModel.name; - } - - // If birthDate is available in extra data + 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']; } - - // Mark as verified + isVerified.value = true; verificationMessage.value = 'KTA information loaded successfully'; } - // Safely apply ID card data without risking stack overflow (fallback method) + // Safe method to apply ID card data without risk of stack overflow void _safeApplyIdCardData() { - if (_isApplyingData) return; // Guard against recursive calls + if (_isApplyingData) return; try { _isApplyingData = true; - // Check if FormRegistrationController is ready - if (!Get.isRegistered()) { - return; - } + if (!Get.isRegistered()) return; final formController = Get.find(); - if (formController.idCardData.value == null) { - return; - } + if (formController.idCardData.value == null) return; final idCardData = formController.idCardData.value; - if (idCardData != null) { - // Fill the form with the extracted data if (!isOfficer && idCardData is KtpModel) { applyKtpDataToForm(idCardData); isNikReadOnly.value = true; @@ -256,11 +389,13 @@ class IdentityVerificationController extends GetxController { } // Validate form inputs - bool validate(GlobalKey formKey) { + bool validate(GlobalKey? formKey) { isFormValid.value = true; + clearErrors(); - // For non-officers, we need to validate NIK and other KTP-related fields + // Validate required fields based on officer status if (!isOfficer) { + // KTP validation if (nikController.text.isEmpty) { nikError.value = 'NIK is required'; isFormValid.value = false; @@ -278,37 +413,64 @@ class IdentityVerificationController extends GetxController { 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; + } } - // These validations apply to both officers and non-officers + // Common validations if (birthDateController.text.isEmpty) { birthDateError.value = 'Birth date is required'; isFormValid.value = false; } - // if (addressController.text.isEmpty) { - // addressError.value = 'Address 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 information against OCR results + // Verify ID card with OCR data void verifyIdCardWithOCR() { try { isVerifying.value = true; - // Compare form input with OCR results final formController = Get.find(); final idCardData = formController.idCardData.value; if (idCardData != null) { if (!isOfficer && idCardData is KtpModel) { - // Verify NIK matches bool nikMatches = nikController.text == idCardData.nik; - - // Verify name is similar (accounting for slight differences in formatting) bool nameMatches = _compareNames( fullNameController.text, idCardData.name, @@ -324,7 +486,6 @@ class IdentityVerificationController extends GetxController { 'Information doesn\'t match with KTP. Please check and try again.'; } } else if (isOfficer && idCardData is KtaModel) { - // For officers, verify that the name matches bool nameMatches = _compareNames( fullNameController.text, idCardData.name, @@ -354,9 +515,8 @@ class IdentityVerificationController extends GetxController { } } - // Simple name comparison function (ignores case, spaces) + // Compare names accounting for formatting differences bool _compareNames(String name1, String name2) { - // Normalize names for comparison String normalizedName1 = name1.toLowerCase().trim().replaceAll( RegExp(r'\s+'), ' ', @@ -366,24 +526,20 @@ class IdentityVerificationController extends GetxController { ' ', ); - // Check exact match if (normalizedName1 == normalizedName2) return true; - // Check if one name is contained within the other if (normalizedName1.contains(normalizedName2) || normalizedName2.contains(normalizedName1)) return true; - // Split names into parts and check for partial matches var parts1 = normalizedName1.split(' '); var parts2 = normalizedName2.split(' '); - // Count matching name parts int matches = 0; for (var part1 in parts1) { for (var part2 in parts2) { if (part1.length > 2 && - part2.length > 2 && + part2.length > 2 && (part1.contains(part2) || part2.contains(part1))) { matches++; break; @@ -391,25 +547,22 @@ class IdentityVerificationController extends GetxController { } } - // If more than half of the name parts match, consider it a match return matches >= (parts1.length / 2).floor(); } - // Face verification function using EdgeFunction instead of AWS directly + // Verify face match using FacialVerificationService void verifyFaceMatch() { - // Set quick verification status for development if (_faceService.skipFaceVerification) { + // Development mode - use dummy data isFaceVerified.value = true; faceVerificationMessage.value = 'Face verification skipped (development mode)'; - // Create dummy comparison result final idCardController = Get.find(); final selfieController = Get.find(); if (idCardController.idCardImage.value != null && selfieController.selfieImage.value != null) { - // Set dummy result faceComparisonResult.value = FaceComparisonResult( sourceFace: FaceModel( imagePath: idCardController.idCardImage.value!.path, @@ -428,38 +581,30 @@ class IdentityVerificationController extends GetxController { message: 'Face verification passed (development mode)', ); } - return; } isVerifyingFace.value = true; - // Get ID card and selfie images - final formController = Get.find(); final idCardController = Get.find(); final selfieController = Get.find(); - // Check if we have both images if (idCardController.idCardImage.value == null || selfieController.selfieImage.value == null) { isFaceVerified.value = false; - faceVerificationMessage.value = + faceVerificationMessage.value = 'Both ID card and selfie are required for face verification.'; isVerifyingFace.value = false; return; } - // Use FacialVerificationService to compare faces _faceService .compareFaces( idCardController.idCardImage.value!, selfieController.selfieImage.value!, ) .then((result) { - // Store the comparison result faceComparisonResult.value = result; - - // Update verification status isFaceVerified.value = result.isMatch; faceVerificationMessage.value = result.message; }) @@ -473,7 +618,7 @@ class IdentityVerificationController extends GetxController { }); } - // Clear all error messages + // Clear all validation errors void clearErrors() { nikError.value = ''; fullNameError.value = ''; @@ -481,21 +626,10 @@ class IdentityVerificationController extends GetxController { birthDateError.value = ''; genderError.value = ''; addressError.value = ''; - isFormValid.value = true; } - @override - void onClose() { - nikController.dispose(); - fullNameController.dispose(); - placeOfBirthController.dispose(); - birthDateController.dispose(); - addressController.dispose(); - super.onClose(); - } - - // Method to pre-fill NIK and Name from the extracted data + // Prefill form with extracted data void prefillExtractedData() { if (extractedIdCardNumber != null && extractedIdCardNumber!.isNotEmpty) { nikController.text = extractedIdCardNumber!; @@ -513,14 +647,39 @@ class IdentityVerificationController extends GetxController { try { isSavingData.value = true; dataSaveMessage.value = 'Saving your registration data...'; - - // Ensure the registration service is available(); - if (!Get.isRegistered()) { - await Get.putAsync(() async => RegistrationService()); - } // Get registration service + + // Final validation + if (!validate(null)) { + dataSaveMessage.value = 'Please fix the errors before submitting'; + return false; + } - final registrationService = Get.find(); - final result = await registrationService.saveRegistrationData(); + // Update summary with final data + _updateSummaryWithFormData(); + + // Format data according to models + Map 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(); + final result = await formController.saveRegistrationData( + summaryData: formattedData, + ); if (result) { isDataSaved.value = true; @@ -541,4 +700,15 @@ class IdentityVerificationController extends GetxController { isSavingData.value = false; } } + + @override + void onClose() { + // Dispose controllers to prevent memory leaks + nikController.dispose(); + fullNameController.dispose(); + placeOfBirthController.dispose(); + birthDateController.dispose(); + addressController.dispose(); + super.onClose(); + } } diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/identity_verification_step.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/identity_verification_step.dart index 1293343..19eb252 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/identity_verification_step.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/basic/identity_verification_step.dart @@ -3,7 +3,9 @@ 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/identity-verification/identity_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/verification_summary.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/sizes.dart'; class IdentityVerificationStep extends StatelessWidget { @@ -15,7 +17,7 @@ class IdentityVerificationStep extends StatelessWidget { final controller = Get.find(); final mainController = Get.find(); mainController.formKey = formKey; - + final isOfficer = mainController.selectedRole.value?.isOfficer ?? false; return Form( @@ -23,21 +25,305 @@ class IdentityVerificationStep extends StatelessWidget { child: Column( crossAxisAlignment: CrossAxisAlignment.start, children: [ + // Section Header FormSectionHeader( - title: 'Additional Information', - subtitle: isOfficer - ? 'Please provide additional personal details' - : 'Please verify your KTP information below. NIK field cannot be edited.', + title: 'Review & Verification', + subtitle: + 'Please review and confirm your information before submitting', ), - + const SizedBox(height: TSizes.spaceBtwItems), - // Personal Information Form Section + // Verification Progress Card + Obx(() => _buildVerificationProgressCard(controller)), + + const SizedBox(height: TSizes.spaceBtwItems), + + // Registration Summary + Obx( + () => VerificationSummary( + summaryData: controller.summaryData, + isOfficer: isOfficer, + ), + ), + + const SizedBox(height: TSizes.spaceBtwItems), + + // Form section header + Container( + padding: const EdgeInsets.only( + top: TSizes.spaceBtwItems, + bottom: TSizes.sm, + ), + decoration: BoxDecoration( + border: Border( + top: BorderSide(color: Colors.grey.withOpacity(0.2), width: 1), + ), + ), + child: FormSectionHeader( + title: 'Confirm Identity Information', + subtitle: + isOfficer + ? 'Please verify the pre-filled information from your KTA' + : 'Please verify the pre-filled information from your KTP', + ), + ), + + const SizedBox(height: TSizes.spaceBtwItems), + + // ID Card Info Form (with pre-filled data) IdInfoForm(controller: controller, isOfficer: isOfficer), const SizedBox(height: TSizes.spaceBtwSections), + + // Save & Submit Button + Obx( + () => ElevatedButton( + onPressed: + controller.isSavingData.value + ? null + : () => _submitRegistrationData(controller, context), + style: ElevatedButton.styleFrom( + backgroundColor: TColors.primary, + foregroundColor: Colors.white, + minimumSize: const Size(double.infinity, 50), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(TSizes.buttonRadius), + ), + 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 + Obx( + () => + controller.dataSaveMessage.value.isNotEmpty + ? Padding( + padding: const EdgeInsets.only(top: TSizes.sm), + child: Text( + controller.dataSaveMessage.value, + style: TextStyle( + color: + controller.isDataSaved.value + ? Colors.green + : TColors.error, + fontWeight: FontWeight.bold, + ), + textAlign: TextAlign.center, + ), + ) + : const SizedBox.shrink(), + ), ], ), ); } + + // Build Verification Progress Card + Widget _buildVerificationProgressCard( + IdentityVerificationController controller, + ) { + final bool allVerified = + controller.isBasicInfoVerified.value && + controller.isIdCardVerified.value && + controller.isSelfieVerified.value; + + return Container( + padding: const EdgeInsets.all(TSizes.md), + decoration: BoxDecoration( + color: + allVerified + ? Colors.green.withOpacity(0.1) + : TColors.warning.withOpacity(0.1), + borderRadius: BorderRadius.circular(TSizes.borderRadiusMd), + border: Border.all( + color: + allVerified + ? Colors.green.withOpacity(0.5) + : TColors.warning.withOpacity(0.5), + ), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + Icon( + allVerified ? Icons.verified : Icons.info_outline, + color: allVerified ? Colors.green : TColors.warning, + size: TSizes.iconMd, + ), + const SizedBox(width: TSizes.sm), + Text( + allVerified + ? 'All verification steps completed!' + : 'Verification Status', + style: TextStyle( + fontWeight: FontWeight.bold, + color: allVerified ? Colors.green : TColors.warning, + ), + ), + ], + ), + const SizedBox(height: TSizes.sm), + + // Basic Info status + _buildVerificationItem( + 'Basic Information', + controller.isBasicInfoVerified.value, + ), + + // ID Card status + _buildVerificationItem( + 'ID Card Verification', + controller.isIdCardVerified.value, + ), + + // Selfie status + _buildVerificationItem( + 'Selfie Verification', + controller.isSelfieVerified.value, + ), + + // Identity Verification (this step) + _buildVerificationItem( + 'Identity Confirmation', + controller.isFormValid.value, + isCurrentStep: true, + ), + ], + ), + ); + } + + // Build verification step item + Widget _buildVerificationItem( + String title, + bool isVerified, { + bool isCurrentStep = false, + }) { + return Padding( + padding: const EdgeInsets.only(bottom: TSizes.xs), + child: Row( + children: [ + Icon( + isVerified + ? Icons.check_circle + : isCurrentStep + ? Icons.edit + : Icons.error_outline, + color: + isVerified + ? Colors.green + : isCurrentStep + ? TColors.primary + : TColors.error, + size: TSizes.iconSm, + ), + const SizedBox(width: TSizes.xs), + Text( + title, + style: TextStyle( + fontSize: TSizes.fontSizeSm, + color: + isVerified + ? Colors.green + : isCurrentStep + ? TColors.primary + : TColors.textSecondary, + fontWeight: isCurrentStep ? FontWeight.bold : FontWeight.normal, + ), + ), + const Spacer(), + Text( + isVerified + ? 'Verified' + : isCurrentStep + ? 'In Progress' + : 'Not Verified', + style: TextStyle( + fontSize: TSizes.fontSizeXs, + color: + isVerified + ? Colors.green + : isCurrentStep + ? TColors.primary + : TColors.textSecondary, + ), + ), + ], + ), + ); + } + + // Submit registration data + void _submitRegistrationData( + IdentityVerificationController controller, + BuildContext context, + ) async { + final formKey = FormRegistrationController().formKey; + // Validate form + if (!controller.validate(formKey)) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('Please complete all required fields'), + backgroundColor: TColors.error, + ), + ); + return; + } + + // Save registration data + final result = await controller.saveRegistrationData(); + + if (result) { + // Navigate to success page or show success dialog + showDialog( + context: context, + barrierDismissible: false, + builder: + (context) => AlertDialog( + title: Row( + children: [ + Icon(Icons.check_circle, color: Colors.green), + SizedBox(width: TSizes.sm), + Text('Registration Successful'), + ], + ), + content: Text( + 'Your registration has been submitted successfully. You will be notified once your account is verified.', + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + // Navigate to login or home page + Get.offAllNamed('/login'); + }, + child: Text('Go to Login'), + ), + ], + ), + ); + } + } } diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/verification_summary.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/verification_summary.dart new file mode 100644 index 0000000..105cc3e --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/verification_summary.dart @@ -0,0 +1,152 @@ +import 'package:flutter/material.dart'; +import 'package:sigap/src/utils/constants/colors.dart'; +import 'package:sigap/src/utils/constants/sizes.dart'; + +class VerificationSummary extends StatelessWidget { + final Map summaryData; + final bool isOfficer; + + const VerificationSummary({ + super.key, + required this.summaryData, + required this.isOfficer, + }); + + @override + Widget build(BuildContext context) { + return Container( + decoration: BoxDecoration( + color: Colors.grey.withOpacity(0.05), + borderRadius: BorderRadius.circular(TSizes.borderRadiusMd), + border: Border.all(color: Colors.grey.withOpacity(0.2)), + ), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // Header + Container( + padding: const EdgeInsets.all(TSizes.md), + decoration: BoxDecoration( + color: TColors.primary.withOpacity(0.1), + borderRadius: BorderRadius.only( + topLeft: Radius.circular(TSizes.borderRadiusMd), + topRight: Radius.circular(TSizes.borderRadiusMd), + ), + ), + child: Row( + children: [ + Icon( + Icons.summarize, + color: TColors.primary, + size: TSizes.iconMd, + ), + const SizedBox(width: TSizes.sm), + Text( + 'Registration Summary', + style: TextStyle( + fontWeight: FontWeight.bold, + color: TColors.primary, + fontSize: TSizes.fontSizeMd, + ), + ), + ], + ), + ), + + // Basic Info Section + _buildSectionHeader(context, 'Basic Information'), + _buildInfoItem('Email', summaryData['email'] ?? '-'), + _buildInfoItem('Phone', summaryData['phone'] ?? '-'), + _buildInfoItem('Role', summaryData['role'] ?? '-'), + + // ID Card Section + _buildSectionHeader( + context, + '${isOfficer ? 'KTA' : 'KTP'} Verification', + ), + _buildInfoItem( + '${isOfficer ? 'KTA' : 'KTP'} Verified', + (summaryData['idCardValid'] ?? false) ? 'Yes' : 'No', + ), + if (!isOfficer) _buildInfoItem('NIK', summaryData['nik'] ?? '-'), + _buildInfoItem('Full Name', summaryData['fullName'] ?? '-'), + if (!isOfficer) + _buildInfoItem( + 'Place of Birth', + summaryData['placeOfBirth'] ?? '-', + ), + _buildInfoItem('Birth Date', summaryData['birthDate'] ?? '-'), + _buildInfoItem('Gender', summaryData['gender'] ?? '-'), + if (!isOfficer) + _buildInfoItem('Address', summaryData['address'] ?? '-'), + + // Selfie Verification Section + _buildSectionHeader(context, 'Selfie Verification'), + _buildInfoItem( + 'Liveness Check', + (summaryData['livenessCheckPassed'] ?? false) + ? 'Passed' + : 'Not Verified', + ), + _buildInfoItem( + 'Face Match with ID Card', + (summaryData['faceMatchResult'] ?? false) + ? 'Matched (${((summaryData['faceMatchConfidence'] ?? 0.0) * 100).toStringAsFixed(1)}% confidence)' + : 'Not Matched', + ), + ], + ), + ); + } + + Widget _buildSectionHeader(BuildContext context, String title) { + return Container( + padding: const EdgeInsets.symmetric( + horizontal: TSizes.md, + vertical: TSizes.sm, + ), + color: Colors.grey.withOpacity(0.1), + child: Text( + title, + style: Theme.of( + context, + ).textTheme.titleSmall?.copyWith(fontWeight: FontWeight.bold), + ), + ); + } + + Widget _buildInfoItem(String label, String value) { + return Padding( + padding: const EdgeInsets.symmetric( + horizontal: TSizes.md, + vertical: TSizes.sm, + ), + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Expanded( + flex: 2, + child: Text( + label, + style: TextStyle( + color: TColors.textSecondary, + fontSize: TSizes.fontSizeSm, + ), + ), + ), + const SizedBox(width: TSizes.sm), + Expanded( + flex: 3, + child: Text( + value, + style: TextStyle( + fontWeight: FontWeight.w500, + fontSize: TSizes.fontSizeSm, + ), + ), + ), + ], + ), + ); + } +} diff --git a/sigap-mobile/lib/src/features/personalization/data/repositories/users_repository.dart b/sigap-mobile/lib/src/features/personalization/data/repositories/users_repository.dart index bfa9980..f1435fb 100644 --- a/sigap-mobile/lib/src/features/personalization/data/repositories/users_repository.dart +++ b/sigap-mobile/lib/src/features/personalization/data/repositories/users_repository.dart @@ -340,4 +340,117 @@ class UserRepository extends GetxController { return false; // Default to not banned } } + + // Update user profile with registration data + Future updateUserProfile(Map data) async { + try { + if (!isAuthenticated) { + throw 'User not authenticated'; + } + + final userId = data['user_id'] ?? currentUserId; + if (userId == null) { + throw 'User ID is required'; + } + + // Update user metadata + final authData = { + 'email': data['email'], + 'phone': data['phone'], + 'is_officer': data['is_officer'] ?? false, + 'role_id': data['role_id'], + 'profile_status': data['profile_status'] ?? 'pending_approval', + }; + + // Add ID card and verification data to user metadata + if (data['id_card'] != null) { + authData['id_card'] = data['id_card']; + } + + if (data['face_verification'] != null) { + authData['face_verification'] = data['face_verification']; + } + + // Add officer-specific data if user is an officer + if (data['is_officer'] == true && data['officer'] != null) { + authData['officer_data'] = data['officer']; + } + + // Update user metadata in auth + await _supabase.auth.updateUser(UserAttributes(data: authData)); + + // Update the database tables based on user type + if (data['is_officer'] == true) { + // Handle officer data + if (data['officer'] != null) { + final officerData = Map.from(data['officer']); + + // Check if officer exists + final existingOfficer = + await _supabase + .from('officers') + .select('id') + .eq('id', userId) + .maybeSingle(); + + if (existingOfficer != null) { + // Update existing officer + await _supabase + .from('officers') + .update(officerData) + .eq('id', userId); + } else { + // Create new officer + await _supabase.from('officers').insert(officerData); + } + } + } else { + // Handle regular user data + if (data['profile'] != null) { + final profileData = Map.from(data['profile']); + + // Check if profile exists + final existingProfile = + await _supabase + .from('profiles') + .select('id') + .eq('user_id', userId) + .maybeSingle(); + + if (existingProfile != null) { + // Update existing profile + await _supabase + .from('profiles') + .update(profileData) + .eq('user_id', userId); + } else { + // Create new profile + await _supabase.from('profiles').insert(profileData); + } + } + } + + // Update users table with common data + await _supabase + .from('users') + .update({ + 'phone': data['phone'], + 'roles_id': data['role_id'], + 'updated_at': DateTime.now().toIso8601String(), + }) + .eq('id', userId); + + _logger.d('User profile updated successfully'); + return true; + } on PostgrestException catch (error) { + _logger.e('PostgrestException in updateUserProfile: ${error.message}'); + return false; + } on AuthException catch (e) { + _logger.e('AuthException in updateUserProfile: ${e.message}'); + return false; + } catch (e) { + _logger.e('Exception in updateUserProfile: $e'); + return false; + } + } }