feat: Add registration form steps for officer and personal information, selfie verification, and unit details

- Implemented OfficerInfoStep widget for officer details input.
- Implemented PersonalInfoStep widget for personal details input.
- Implemented SelfieVerificationStep widget for selfie upload and verification.
- Implemented UnitInfoStep widget for unit details input.
- Created step form screen to manage the registration process with navigation between steps.
This commit is contained in:
vergiLgood1 2025-05-19 20:53:09 +07:00
parent b003d8a158
commit ce7d448b2f
29 changed files with 5592 additions and 1992 deletions

View File

@ -39,3 +39,6 @@ MAPBOX_ACCESS_TOKEN=pk.eyJ1IjoidmVyZ2lsZ29vZDEiLCJhIjoiY205b254eGltMGJ5dzJqb2F4c
MAPBOX_TILESET_ID=vergilgood1.cm9x176pl09k11ope7hzkij0r-06afz
NODE_ENV=development
# Azure AI API
AZURE_RESOURCE_NAME="sigap"
AZURE_SUBSCRIPTION_KEY="ANeYAEr78MF7HzCEDg53DEHfKZJg19raPeJCubNEZP2tXGD6xREgJQQJ99BEAC3pKaRXJ3w3AAAFACOGAwA9"

View File

@ -1,8 +1,9 @@
import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/pages/forgot-password/forgot_password.dart';
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/registraion_form_screen.dart';
import 'package:sigap/src/features/auth/presentasion/pages/signin/signin_screen.dart';
import 'package:sigap/src/features/auth/presentasion/pages/signup/signup_screen.dart';
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/registraion_form_screen.dart';
import 'package:sigap/src/features/onboarding/presentasion/pages/location-warning/location_warning_screen.dart';
import 'package:sigap/src/features/onboarding/presentasion/pages/onboarding/onboarding_screen.dart';
import 'package:sigap/src/features/onboarding/presentasion/pages/role-selection/role_selection_screen.dart';
import 'package:sigap/src/features/onboarding/presentasion/pages/welcome/welcome_screen.dart';
@ -38,7 +39,10 @@ class AppPages {
),
GetPage(
name: AppRoutes.locationWarning,
page: () => const LocationWarningScreen(),
)
];
}

View File

@ -3,16 +3,16 @@ import 'dart:io';
import 'package:dio/dio.dart';
import 'package:image_picker/image_picker.dart';
import 'package:sigap/src/utils/constants/api_urls.dart';
import 'package:sigap/src/utils/dio.client/dio_client.dart';
class AzureOCRService {
// Replace with your Azure Computer Vision API endpoint and key
static const String endpoint =
'https://your-azure-endpoint.cognitiveservices.azure.com/';
static const String subscriptionKey = 'your-azure-subscription-key';
static const String ocrApiPath = 'vision/v3.2/ocr';
static const String faceApiPath = 'face/v1.0/detect';
static const String faceVerifyPath = 'face/v1.0/verify';
// Azure OCR API endpoint and subscription key
final String endpoint = Endpoints.azureEndpoint;
final String subscriptionKey = Endpoints.azureSubscriptionKey;
final String ocrApiPath = Endpoints.ocrApiPath;
final String faceApiPath = Endpoints.faceApiPath;
final String faceVerifyPath = Endpoints.faceVerifyPath;
// Process an ID card image and extract relevant information
Future<Map<String, String>> processIdCard(

View File

@ -0,0 +1,530 @@
// List of Indonesian cities and regencies
// This is a partial list - in a real app, you would have a complete list
final List<String> indonesianCities = [
'Jakarta',
'Surabaya',
'Bandung',
'Medan',
'Semarang',
'Makassar',
'Palembang',
'Tangerang',
'Depok',
'Bekasi',
'Bogor',
'Malang',
'Yogyakarta',
'Denpasar',
'Balikpapan',
'Banjarmasin',
'Manado',
'Padang',
'Pekanbaru',
'Pontianak',
'Bandar Lampung',
'Cirebon',
'Tasikmalaya',
'Serang',
'Jambi',
'Bengkulu',
'Ambon',
'Kupang',
'Mataram',
'Palu',
'Samarinda',
'Kendari',
'Jayapura',
'Sorong',
'Gorontalo',
'Ternate',
'Tanjung Pinang',
'Pangkal Pinang',
'Mamuju',
'Banda Aceh',
'Tegal',
'Pekalongan',
'Magelang',
'Sukabumi',
'Cilegon',
'Kediri',
'Pematang Siantar',
'Binjai',
'Tanjung Balai',
'Bitung',
'Pare-Pare',
'Tarakan',
'Bontang',
'Bau-Bau',
'Palopo',
'Sawahlunto',
'Padang Panjang',
'Bukittinggi',
'Payakumbuh',
'Pariaman',
'Solok',
'Lubuklinggau',
'Prabumulih',
'Palangkaraya',
'Singkawang',
'Bima',
'Probolinggo',
'Pasuruan',
'Mojokerto',
'Madiun',
'Blitar',
'Batu',
'Cianjur',
'Garut',
'Purwakarta',
'Ciamis',
'Karawang',
'Subang',
'Indramayu',
'Majalengka',
'Kuningan',
'Banjar',
'Banjarbaru',
'Kotabaru',
'Sampit',
'Pangkalan Bun',
'Tanjung Selor',
'Tanjung Redeb',
'Nunukan',
'Malinau',
'Tanjung Pandan',
'Manggar',
'Sungai Liat',
'Tanjung Pati',
'Sungai Penuh',
'Muara Bungo',
'Muara Bulian',
'Muara Tebo',
'Bangko',
'Kuala Tungkal',
'Muara Sabak',
'Muara Enim',
'Lahat',
'Baturaja',
'Martapura',
'Kayu Agung',
'Sekayu',
'Pangkalpinang',
'Metro',
'Pringsewu',
'Kota Agung',
'Liwa',
'Menggala',
'Kotabumi',
'Krui',
'Sukadana',
'Curup',
'Manna',
'Argamakmur',
'Mukomuko',
'Tais',
'Bintuhan',
'Kaur',
'Kepahiang',
'Lebong',
'Muara Aman',
'Seluma',
'Tanjung Karang',
'Teluk Betung',
'Kalianda',
'Gunung Sugih',
'Blambangan Umpu',
'Kotabumi',
'Gedong Tataan',
'Menggala',
'Kota Agung',
'Sukadana',
'Panarukan',
'Situbondo',
'Bondowoso',
'Jember',
'Banyuwangi',
'Lumajang',
'Kraksaan',
'Bangkalan',
'Sampang',
'Pamekasan',
'Sumenep',
'Ngawi',
'Ponorogo',
'Pacitan',
'Magetan',
'Nganjuk',
'Jombang',
'Tuban',
'Bojonegoro',
'Lamongan',
'Gresik',
'Sidoarjo',
'Mojokerto',
'Pasuruan',
'Probolinggo',
'Lumajang',
'Jember',
'Banyuwangi',
'Situbondo',
'Bondowoso',
'Trenggalek',
'Tulungagung',
'Blitar',
'Kediri',
'Malang',
'Purworejo',
'Kebumen',
'Magelang',
'Wonosobo',
'Temanggung',
'Kendal',
'Batang',
'Pekalongan',
'Pemalang',
'Tegal',
'Brebes',
'Banyumas',
'Cilacap',
'Purbalingga',
'Banjarnegara',
'Sragen',
'Karanganyar',
'Wonogiri',
'Sukoharjo',
'Klaten',
'Boyolali',
'Grobogan',
'Blora',
'Rembang',
'Pati',
'Kudus',
'Jepara',
'Demak',
'Semarang',
'Salatiga',
'Surakarta',
'Bantul',
'Sleman',
'Kulon Progo',
'Gunung Kidul',
'Badung',
'Bangli',
'Buleleng',
'Gianyar',
'Jembrana',
'Karangasem',
'Klungkung',
'Tabanan',
'Mataram',
'Bima',
'Dompu',
'Sumbawa',
'Lombok Barat',
'Lombok Tengah',
'Lombok Timur',
'Lombok Utara',
'Kupang',
'Ende',
'Maumere',
'Labuan Bajo',
'Ruteng',
'Waingapu',
'Waikabubak',
'Atambua',
'Kefamenanu',
'Soe',
'Bajawa',
'Larantuka',
'Lewoleba',
'Kalabahi',
'Sabu',
'Rote',
'Alor',
'Lembata',
'Flores Timur',
'Sikka',
'Ende',
'Ngada',
'Manggarai',
'Manggarai Barat',
'Manggarai Timur',
'Sumba Barat',
'Sumba Timur',
'Sumba Tengah',
'Sumba Barat Daya',
'Belu',
'Malaka',
'Timor Tengah Utara',
'Timor Tengah Selatan',
'Rote Ndao',
'Sabu Raijua',
'Pontianak',
'Singkawang',
'Sambas',
'Bengkayang',
'Landak',
'Mempawah',
'Sanggau',
'Ketapang',
'Sintang',
'Kapuas Hulu',
'Sekadau',
'Melawi',
'Kayong Utara',
'Kubu Raya',
'Palangkaraya',
'Sampit',
'Pangkalan Bun',
'Kuala Kapuas',
'Buntok',
'Muara Teweh',
'Puruk Cahu',
'Kuala Kurun',
'Kuala Pembuang',
'Kasongan',
'Tamiang Layang',
'Nanga Bulik',
'Sukamara',
'Pulang Pisau',
'Lamandau',
'Seruyan',
'Katingan',
'Gunung Mas',
'Barito Timur',
'Barito Utara',
'Barito Selatan',
'Murung Raya',
'Banjarmasin',
'Banjarbaru',
'Martapura',
'Pelaihari',
'Kotabaru',
'Tanjung',
'Barabai',
'Amuntai',
'Kandangan',
'Rantau',
'Marabahan',
'Paringin',
'Balangan',
'Tanah Bumbu',
'Tanah Laut',
'Tapin',
'Hulu Sungai Selatan',
'Hulu Sungai Tengah',
'Hulu Sungai Utara',
'Tabalong',
'Barito Kuala',
'Samarinda',
'Balikpapan',
'Bontang',
'Tenggarong',
'Sangatta',
'Sendawar',
'Tanah Grogot',
'Penajam',
'Tanjung Redeb',
'Tanjung Selor',
'Malinau',
'Tarakan',
'Nunukan',
'Kutai Kartanegara',
'Kutai Timur',
'Kutai Barat',
'Paser',
'Penajam Paser Utara',
'Berau',
'Bulungan',
'Malinau',
'Nunukan',
'Tana Tidung',
'Makassar',
'Pare-Pare',
'Palopo',
'Maros',
'Pangkajene',
'Barru',
'Watampone',
'Watansoppeng',
'Sengkang',
'Makale',
'Rantepao',
'Enrekang',
'Pinrang',
'Sidenreng Rappang',
'Sungguminasa',
'Takalar',
'Jeneponto',
'Bantaeng',
'Bulukumba',
'Sinjai',
'Benteng',
'Bau-Bau',
'Kendari',
'Kolaka',
'Raha',
'Unaaha',
'Andoolo',
'Rumbia',
'Wangi-Wangi',
'Baubau',
'Bombana',
'Buton',
'Buton Utara',
'Kolaka',
'Kolaka Timur',
'Kolaka Utara',
'Konawe',
'Konawe Selatan',
'Konawe Utara',
'Muna',
'Muna Barat',
'Wakatobi',
'Palu',
'Luwuk',
'Toli-Toli',
'Buol',
'Donggala',
'Poso',
'Ampana',
'Bungku',
'Kolonodale',
'Banggai',
'Banggai Kepulauan',
'Banggai Laut',
'Buol',
'Donggala',
'Morowali',
'Morowali Utara',
'Parigi Moutong',
'Poso',
'Sigi',
'Tojo Una-Una',
'Toli-Toli',
'Manado',
'Bitung',
'Tomohon',
'Kotamobagu',
'Tahuna',
'Melonguane',
'Airmadidi',
'Ratahan',
'Amurang',
'Tondano',
'Bolaang Mongondow',
'Bolaang Mongondow Selatan',
'Bolaang Mongondow Timur',
'Bolaang Mongondow Utara',
'Kepulauan Sangihe',
'Kepulauan Siau Tagulandang Biaro',
'Kepulauan Talaud',
'Minahasa',
'Minahasa Selatan',
'Minahasa Tenggara',
'Minahasa Utara',
'Gorontalo',
'Limboto',
'Marisa',
'Tilamuta',
'Suwawa',
'Kwandang',
'Boalemo',
'Bone Bolango',
'Gorontalo',
'Gorontalo Utara',
'Pohuwato',
'Ternate',
'Tidore',
'Jailolo',
'Weda',
'Labuha',
'Tobelo',
'Maba',
'Sanana',
'Daruba',
'Halmahera Barat',
'Halmahera Tengah',
'Halmahera Utara',
'Halmahera Selatan',
'Halmahera Timur',
'Kepulauan Sula',
'Pulau Morotai',
'Pulau Taliabu',
'Ambon',
'Tual',
'Masohi',
'Piru',
'Dobo',
'Saumlaki',
'Namlea',
'Dataran Hunimoa',
'Tiakur',
'Buru',
'Buru Selatan',
'Kepulauan Aru',
'Maluku Barat Daya',
'Maluku Tengah',
'Maluku Tenggara',
'Maluku Tenggara Barat',
'Seram Bagian Barat',
'Seram Bagian Timur',
'Jayapura',
'Merauke',
'Biak',
'Nabire',
'Wamena',
'Timika',
'Sarmi',
'Serui',
'Sentani',
'Agats',
'Asmat',
'Biak Numfor',
'Boven Digoel',
'Deiyai',
'Dogiyai',
'Intan Jaya',
'Jayapura',
'Jayawijaya',
'Keerom',
'Kepulauan Yapen',
'Lanny Jaya',
'Mamberamo Raya',
'Mamberamo Tengah',
'Mappi',
'Merauke',
'Mimika',
'Nabire',
'Nduga',
'Paniai',
'Pegunungan Bintang',
'Puncak',
'Puncak Jaya',
'Sarmi',
'Supiori',
'Tolikara',
'Waropen',
'Yahukimo',
'Yalimo',
'Sorong',
'Manokwari',
'Fak-Fak',
'Kaimana',
'Teminabuan',
'Waisai',
'Rasiei',
'Bintuni',
'Sorong',
'Sorong Selatan',
'Raja Ampat',
'Tambrauw',
'Maybrat',
'Manokwari',
'Manokwari Selatan',
'Pegunungan Arfak',
'Teluk Bintuni',
'Teluk Wondama',
'Fakfak',
'Kaimana',
];

View File

@ -3,7 +3,7 @@ import 'package:sigap/src/features/auth/presentasion/controllers/email_verificat
import 'package:sigap/src/features/auth/presentasion/controllers/forgot_password_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signin_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/signup_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/step_form_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
class AuthControllerBindings extends Bindings {
@override

View File

@ -0,0 +1,340 @@
import 'package:get/get.dart';
import 'package:sigap/src/features/auth/data/models/user_metadata_model.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/officer_info_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/personal_info_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/selfie_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/unit_info_controller.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/utils/constants/app_routes.dart';
import 'package:sigap/src/utils/popups/loaders.dart';
class FormRegistrationController extends GetxController {
static FormRegistrationController get to => Get.find();
// Role information
final Rx<RoleModel?> selectedRole = Rx<RoleModel?>(null);
// Form steps controllers
late final PersonalInfoController personalInfoController;
late final IdCardVerificationController idCardVerificationController;
late final SelfieVerificationController selfieVerificationController;
late final IdentityVerificationController identityController;
late final OfficerInfoController? officerInfoController;
late final UnitInfoController? unitInfoController;
// Current step index
final RxInt currentStep = 0.obs;
// Total number of steps (depends on role)
late final int totalSteps;
// User metadata model
final Rx<UserMetadataModel> userMetadata = UserMetadataModel().obs;
// Loading state
final RxBool isLoading = false.obs;
@override
void onInit() {
super.onInit();
// Get role from arguments
final arguments = Get.arguments;
if (arguments != null && arguments['role'] != null) {
selectedRole.value = arguments['role'] as RoleModel;
// Initialize userMetadata with the selected role information
userMetadata.value = UserMetadataModel(
isOfficer: selectedRole.value?.isOfficer ?? false,
);
_initializeControllers();
} else {
// Get.snackbar(
// 'Error',
// 'No role selected. Please go back and select a role.',
// snackPosition: SnackPosition.BOTTOM,
// backgroundColor: Colors.red,
// colorText: Colors.white,
// );
TLoaders.errorSnackBar(
title: 'Error',
message: 'No role selected. Please go back and select a role.',
);
}
if (selectedRole.value?.isOfficer == true) {
_fetchAvailableUnits();
}
}
void _initializeControllers() {
final isOfficer = selectedRole.value?.isOfficer ?? false;
// Always initialize personal info controller
personalInfoController = Get.put(PersonalInfoController());
// Initialize ID Card verification controller
idCardVerificationController = Get.put(
IdCardVerificationController(isOfficer: isOfficer),
);
// Initialize Selfie verification controller
selfieVerificationController = Get.put(SelfieVerificationController());
// Initialize identity verification controller
identityController = Get.put(
IdentityVerificationController(isOfficer: isOfficer),
);
if (isOfficer) {
// Initialize officer-specific controllers
officerInfoController = Get.put(OfficerInfoController());
unitInfoController = Get.put(UnitInfoController());
totalSteps = 5; // Personal, ID Card, Selfie, Officer Info, Unit Info
} else {
// For civilian users
officerInfoController = null;
unitInfoController = null;
totalSteps = 4; // Personal, ID Card, Selfie, Identity
}
}
// Get step titles based on role
List<String> getStepTitles() {
if (selectedRole.value?.isOfficer ?? false) {
return ['Personal', 'ID Card', 'Selfie', 'Officer Info', 'Unit Info'];
} else {
return ['Personal', 'ID Card', 'Selfie', 'Identity'];
}
}
Future<void> _fetchAvailableUnits() async {
try {
isLoading.value = true;
// Here we would fetch units from repository
// For now we'll use dummy data
await Future.delayed(const Duration(seconds: 1));
// Update the units in the UnitInfoController
if (unitInfoController != null) {
// unitInfoController!.availableUnits.value = fetchedUnits;
}
} catch (e) {
TLoaders.errorSnackBar(
title: 'Error',
message: 'Failed to fetch available units: ${e.toString()}',
);
} finally {
isLoading.value = false;
}
}
// Validate current step
bool validateCurrentStep() {
switch (currentStep.value) {
case 0:
return personalInfoController.validate();
case 1:
return idCardVerificationController.validate();
case 2:
return selfieVerificationController.validate();
case 3:
return selectedRole.value?.isOfficer == true
? officerInfoController!.validate()
: identityController.validate();
case 4:
return selectedRole.value?.isOfficer == true
? unitInfoController!.validate()
: true; // Should not reach here for non-officers
default:
return true;
}
}
// Go to next step
void nextStep() {
if (!validateCurrentStep()) return;
if (currentStep.value < totalSteps - 1) {
currentStep.value++;
} else {
submitForm();
}
}
void clearPreviousStepErrors() {
switch (currentStep.value) {
case 0:
personalInfoController.clearErrors();
break;
case 1:
idCardVerificationController.clearErrors();
break;
case 2:
selfieVerificationController.clearErrors();
break;
case 3:
if (selectedRole.value?.isOfficer == true) {
officerInfoController!.clearErrors();
} else {
identityController.clearErrors();
}
break;
}
}
// Go to previous step
void previousStep() {
if (currentStep.value > 0) {
// Clear previous step errors
clearPreviousStepErrors();
// Decrement step
currentStep.value--;
}
}
// Go to specific step
void goToStep(int step) {
if (step >= 0 && step < totalSteps) {
// Only allow going to a step if all previous steps are valid
bool canProceed = true;
for (int i = 0; i < step; i++) {
currentStep.value = i;
if (!validateCurrentStep()) {
canProceed = false;
break;
}
}
if (canProceed) {
currentStep.value = step;
}
}
}
// Submit the complete form
Future<void> submitForm() async {
// Validate all steps
bool isValid = true;
for (int i = 0; i < totalSteps; i++) {
currentStep.value = i;
if (!validateCurrentStep()) {
isValid = false;
break;
}
}
if (!isValid) return;
try {
isLoading.value = true;
// Prepare UserMetadataModel based on role
if (selectedRole.value?.isOfficer == true) {
// Officer role - create OfficerModel with the data
final officerData = OfficerModel(
id: '', // Will be assigned by backend
unitId: unitInfoController!.unitIdController.text,
roleId: selectedRole.value!.id,
nrp: officerInfoController!.nrpController.text,
name: personalInfoController.nameController.text,
rank: officerInfoController!.rankController.text,
position: unitInfoController!.positionController.text,
phone: personalInfoController.phoneController.text,
);
userMetadata.value = UserMetadataModel(
isOfficer: true,
name: personalInfoController.nameController.text,
phone: personalInfoController.phoneController.text,
officerData: officerData,
// idCardImagePath: idCardVerificationController.idCardImage.value?.path,
// selfieImagePath: selfieVerificationController.selfieImage.value?.path,
additionalData: {
'address': personalInfoController.addressController.text,
},
);
} else {
// Regular user - create profile-related data
userMetadata.value = UserMetadataModel(
isOfficer: false,
nik: identityController.nikController.text,
name: personalInfoController.nameController.text,
phone: personalInfoController.phoneController.text,
// idCardImagePath: idCardVerificationController.idCardImage.value?.path,
// selfieImagePath: selfieVerificationController.selfieImage.value?.path,
profileData: ProfileModel(
id: '', // Will be assigned by backend
userId: '', // Will be assigned by backend
nik: identityController.nikController.text,
firstName: personalInfoController.firstNameController.text.trim(),
lastName: personalInfoController.lastNameController.text.trim(),
bio: identityController.bioController.text,
birthDate: _parseBirthDate(
identityController.birthDateController.text,
),
),
additionalData: {
'address': personalInfoController.addressController.text,
},
);
}
// Navigate to the signup screen with the prepared metadata
Get.toNamed(
AppRoutes.signUp,
arguments: {
'userMetadata': userMetadata.value,
'role': selectedRole.value,
},
);
} catch (e) {
Get.toNamed(
AppRoutes.stateScreen,
arguments: {
'type': 'error',
'title': 'Data Preparation Failed',
'message':
'There was an error preparing your profile: ${e.toString()}',
'buttonText': 'Try Again',
'onButtonPressed': () => Get.back(),
},
);
} finally {
isLoading.value = false;
}
}
// Parse birth date string to DateTime
DateTime? _parseBirthDate(String dateStr) {
try {
// Try to parse in format YYYY-MM-DD
if (dateStr.isEmpty) return null;
// Add validation for different date formats as needed
if (dateStr.contains('-')) {
return DateTime.parse(dateStr);
}
// Handle other formats like DD/MM/YYYY
if (dateStr.contains('/')) {
final parts = dateStr.split('/');
if (parts.length == 3) {
final day = int.parse(parts[0]);
final month = int.parse(parts[1]);
final year = int.parse(parts[2]);
return DateTime(year, month, day);
}
}
return null;
} catch (e) {
return null;
}
}
}

View File

@ -1,905 +0,0 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart';
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
import 'package:sigap/src/features/auth/data/models/user_metadata_model.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/utils/constants/app_routes.dart';
import 'package:sigap/src/utils/popups/loaders.dart';
import 'package:sigap/src/utils/validators/validation.dart';
class FormRegistrationController extends GetxController {
static FormRegistrationController get to => Get.find();
// Role information
final Rx<RoleModel?> selectedRole = Rx<RoleModel?>(null);
// Current step index
final RxInt currentStep = 0.obs;
// Form keys for each step (dynamic based on role)
late List<GlobalKey<FormState>> stepFormKeys;
// Common information (for all roles)
final firstNameController = TextEditingController();
final lastNameController = TextEditingController();
final nameController = TextEditingController(); // For combined name
final phoneController = TextEditingController();
final addressController = TextEditingController();
// Viewer-specific fields
final nikController = TextEditingController();
final bioController = TextEditingController();
final birthDateController = TextEditingController();
// Officer-specific fields
final nrpController = TextEditingController();
final rankController = TextEditingController();
final positionController = TextEditingController();
final unitIdController = TextEditingController();
// User metadata model
final Rx<UserMetadataModel> userMetadata = UserMetadataModel().obs;
// Error states - Common
final RxString firstNameError = ''.obs;
final RxString lastNameError = ''.obs;
final RxString nameError = ''.obs;
final RxString phoneError = ''.obs;
final RxString addressError = ''.obs;
// Error states - Viewer
final RxString nikError = ''.obs;
final RxString bioError = ''.obs;
final RxString birthDateError = ''.obs;
// Error states - Officer
final RxString nrpError = ''.obs;
final RxString rankError = ''.obs;
final RxString positionError = ''.obs;
final RxString unitIdError = ''.obs;
// Loading state
final RxBool isLoading = false.obs;
// Available units for officer role
final RxList<UnitModel> availableUnits = <UnitModel>[].obs;
// ID Card variables
final Rx<XFile?> idCardImage = Rx<XFile?>(null);
final RxString idCardError = RxString('');
final RxBool isVerifying = RxBool(false);
final RxBool isVerified = RxBool(false);
final RxString verificationMessage = RxString('');
// Face verification variables
final Rx<XFile?> selfieImage = Rx<XFile?>(null);
final RxString selfieError = RxString('');
final RxBool isVerifyingFace = RxBool(false);
final RxBool isFaceVerified = RxBool(false);
final RxString faceVerificationMessage = RxString('');
final RxBool isLivenessCheckPassed = RxBool(false);
final RxBool isPerformingLivenessCheck = RxBool(false);
// Azure OCR service
final AzureOCRService _ocrService = AzureOCRService();
@override
void onInit() {
super.onInit();
// Add listeners to first name and last name controllers to update the combined name
firstNameController.addListener(_updateCombinedName);
lastNameController.addListener(_updateCombinedName);
// Get role from arguments
final arguments = Get.arguments;
if (arguments != null && arguments['role'] != null) {
selectedRole.value = arguments['role'] as RoleModel;
// Initialize userMetadata with the selected role information
userMetadata.value = UserMetadataModel(
isOfficer: selectedRole.value?.isOfficer ?? false,
);
_initializeBasedOnRole();
} else {
Get.snackbar(
'Error',
'No role selected. Please go back and select a role.',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: Colors.red,
colorText: Colors.white,
);
}
if (selectedRole.value?.isOfficer == true) {
_fetchAvailableUnits();
}
}
// Update the combined name when either first or last name changes
void _updateCombinedName() {
final firstName = firstNameController.text.trim();
final lastName = lastNameController.text.trim();
if (firstName.isEmpty && lastName.isEmpty) {
nameController.text = '';
} else if (lastName.isEmpty) {
nameController.text = firstName;
} else if (firstName.isEmpty) {
nameController.text = lastName;
} else {
nameController.text = '$firstName $lastName';
}
}
void _initializeBasedOnRole() {
if (selectedRole.value?.isOfficer == true) {
stepFormKeys = List.generate(3, (_) => GlobalKey<FormState>());
} else {
// Viewer role or default
stepFormKeys = List.generate(2, (_) => GlobalKey<FormState>());
}
}
Future<void> _fetchAvailableUnits() async {
try {
isLoading.value = true;
// Here we would fetch units from repository
// For now we'll use dummy data
await Future.delayed(const Duration(seconds: 1));
} catch (e) {
TLoaders.errorSnackBar(
title: 'Error',
message: 'Failed to fetch available units: ${e.toString()}',
);
} finally {
isLoading.value = false;
}
}
@override
void onClose() {
// Remove listeners
firstNameController.removeListener(_updateCombinedName);
lastNameController.removeListener(_updateCombinedName);
// Dispose all controllers
firstNameController.dispose();
lastNameController.dispose();
nameController.dispose();
phoneController.dispose();
addressController.dispose();
nikController.dispose();
bioController.dispose();
birthDateController.dispose();
nrpController.dispose();
rankController.dispose();
positionController.dispose();
unitIdController.dispose();
super.onClose();
}
// Validate current step
bool validateCurrentStep() {
clearErrors(); // Clear previous errors
final currentFormKey = stepFormKeys[currentStep.value];
if (currentFormKey.currentState?.validate() ?? false) {
return true;
}
// If validation failed using the form's built-in validation,
// we may want to perform additional validation and set error messages
if (currentStep.value == 0) {
validatePersonalInfo();
} else if (currentStep.value == 1) {
if (selectedRole.value?.isOfficer == true) {
validateOfficerInfo();
} else {
validateEmergencyContact();
}
} else if (currentStep.value == 2 &&
selectedRole.value?.isOfficer == true) {
validateOfficerAdditionalInfo();
}
return false;
}
void clearErrors() {
// Clear common errors
firstNameError.value = '';
lastNameError.value = '';
nameError.value = '';
phoneError.value = '';
addressError.value = '';
// Clear viewer-specific errors
nikError.value = '';
bioError.value = '';
birthDateError.value = '';
// Clear officer-specific errors
nrpError.value = '';
rankError.value = '';
positionError.value = '';
unitIdError.value = '';
}
bool validatePersonalInfo() {
bool isValid = true;
final firstNameValidation = TValidators.validateUserInput(
'First name',
firstNameController.text,
50,
);
if (firstNameValidation != null) {
firstNameError.value = firstNameValidation;
isValid = false;
}
final lastNameValidation = TValidators.validateUserInput(
'Last name',
lastNameController.text,
50,
required: false, // Last name can be optional
);
if (lastNameValidation != null) {
lastNameError.value = lastNameValidation;
isValid = false;
}
final phoneValidation = TValidators.validatePhoneNumber(
phoneController.text,
);
if (phoneValidation != null) {
phoneError.value = phoneValidation;
isValid = false;
}
final addressValidation = TValidators.validateUserInput(
'Address',
addressController.text,
255,
);
if (addressValidation != null) {
addressError.value = addressValidation;
isValid = false;
}
return isValid;
}
bool validateEmergencyContact() {
bool isValid = true;
final nikValidation = TValidators.validateUserInput(
'NIK',
nikController.text,
16,
);
if (nikValidation != null) {
nikError.value = nikValidation;
isValid = false;
}
// Bio can be optional, so we validate with required: false
final bioValidation = TValidators.validateUserInput(
'Bio',
bioController.text,
255,
required: false,
);
if (bioValidation != null) {
bioError.value = bioValidation;
isValid = false;
}
// Birth date validation
final birthDateValidation = TValidators.validateUserInput(
'Birth Date',
birthDateController.text,
10,
);
if (birthDateValidation != null) {
birthDateError.value = birthDateValidation;
isValid = false;
}
return isValid;
}
bool validateOfficerInfo() {
bool isValid = true;
final nrpValidation = TValidators.validateUserInput(
'NRP',
nrpController.text,
50,
);
if (nrpValidation != null) {
nrpError.value = nrpValidation;
isValid = false;
}
final rankValidation = TValidators.validateUserInput(
'Rank',
rankController.text,
50,
);
if (rankValidation != null) {
rankError.value = rankValidation;
isValid = false;
}
return isValid;
}
bool validateOfficerAdditionalInfo() {
bool isValid = true;
final positionValidation = TValidators.validateUserInput(
'Position',
positionController.text,
100,
);
if (positionValidation != null) {
positionError.value = positionValidation;
isValid = false;
}
if (unitIdController.text.isEmpty) {
unitIdError.value = 'Please select a unit';
isValid = false;
}
return isValid;
}
// Pick ID Card Image
Future<void> pickIdCardImage(ImageSource source) async {
try {
final ImagePicker picker = ImagePicker();
final XFile? image = await picker.pickImage(
source: source,
imageQuality: 80,
);
if (image != null) {
idCardImage.value = image;
idCardError.value = '';
// Reset verification status when a new image is picked
isVerified.value = false;
verificationMessage.value = '';
}
} catch (e) {
idCardError.value = 'Failed to pick image: $e';
}
}
// Take or pick selfie image
Future<void> pickSelfieImage(ImageSource source, bool isLivenessCheck) async {
try {
final ImagePicker picker = ImagePicker();
final XFile? image = await picker.pickImage(
source: source,
preferredCameraDevice: CameraDevice.front,
imageQuality: 80,
);
if (image != null) {
selfieImage.value = image;
selfieError.value = '';
// Reset face verification status
isFaceVerified.value = false;
faceVerificationMessage.value = '';
// If this was taken for liveness check, perform the check
if (isLivenessCheck && source == ImageSource.camera) {
await performLivenessCheck();
}
}
} catch (e) {
selfieError.value = 'Failed to capture selfie: $e';
}
}
// Perform liveness check on selfie
Future<void> performLivenessCheck() async {
if (selfieImage.value == null) {
selfieError.value = 'Please take a selfie first';
return;
}
try {
isPerformingLivenessCheck.value = true;
// Call liveness detection service
final result = await _ocrService.performLivenessCheck(selfieImage.value!);
isLivenessCheckPassed.value = result['isLive'] ?? false;
faceVerificationMessage.value = result['message'] ?? '';
// If liveness check failed, clear the selfie image to force retake
if (!isLivenessCheckPassed.value) {
selfieError.value = 'Liveness check failed. Please retake your selfie.';
}
} catch (e) {
isLivenessCheckPassed.value = false;
faceVerificationMessage.value = 'Liveness check failed: ${e.toString()}';
selfieError.value = 'Error during liveness check: ${e.toString()}';
} finally {
isPerformingLivenessCheck.value = false;
}
}
// Clear ID Card Image
void clearIdCardImage() {
idCardImage.value = null;
idCardError.value = '';
isVerified.value = false;
verificationMessage.value = '';
}
// Clear Selfie Image
void clearSelfieImage() {
selfieImage.value = null;
selfieError.value = '';
isFaceVerified.value = false;
faceVerificationMessage.value = '';
isLivenessCheckPassed.value = false;
}
// Verify ID Card using OCR
Future<void> verifyIdCardWithOCR() async {
if (idCardImage.value == null) {
idCardError.value = 'Please upload an ID card image first';
return;
}
try {
isVerifying.value = true;
final isOfficer = selectedRole.value?.isOfficer ?? false;
// Determine ID card type based on role
final idCardType = isOfficer ? 'KTA' : 'KTP';
// Call Azure OCR service with the appropriate ID type
final result = await _ocrService.processIdCard(
idCardImage.value!,
isOfficer,
);
// Validate if all required fields are present in the image
if (!_ocrService.validateRequiredFields(result, isOfficer)) {
isVerified.value = false;
String missingFields = _ocrService.getMissingFieldsDescription(
result,
isOfficer,
);
verificationMessage.value =
'Invalid $idCardType image. The following information could not be detected: $missingFields. Please upload a clearer image of your ID card.';
return;
}
// Compare OCR results with user input
final bool isMatch =
isOfficer ? _verifyKtaResults(result) : _verifyKtpResults(result);
isVerified.value = isMatch;
verificationMessage.value =
isMatch
? '$idCardType verification successful! Your information matches.'
: 'Verification failed. Please ensure your information matches your $idCardType or upload a clearer image.';
} catch (e) {
isVerified.value = false;
verificationMessage.value = 'OCR processing failed: ${e.toString()}';
} finally {
isVerifying.value = false;
}
}
// Verify selfie with ID card
Future<void> verifyFaceMatch() async {
if (idCardImage.value == null) {
idCardError.value = 'Please upload an ID card image first';
return;
}
if (selfieImage.value == null) {
selfieError.value = 'Please take a selfie first';
return;
}
if (!isLivenessCheckPassed.value) {
selfieError.value = 'Please complete the liveness check first';
return;
}
try {
isVerifyingFace.value = true;
// Compare face in ID card with selfie face
final result = await _ocrService.verifyFace(
idCardImage.value!,
selfieImage.value!,
);
isFaceVerified.value = result['isMatch'] ?? false;
faceVerificationMessage.value = result['message'] ?? '';
} catch (e) {
isFaceVerified.value = false;
faceVerificationMessage.value =
'Face verification failed: ${e.toString()}';
} finally {
isVerifyingFace.value = false;
}
}
// Compare OCR results with user input for KTP
bool _verifyKtpResults(Map<String, String> ocrResults) {
int matchCount = 0;
int totalFields = 0;
// Check name matches (case insensitive, accounting for name formatting differences)
String fullName =
'${firstNameController.text} ${lastNameController.text}'
.trim()
.toLowerCase();
if (ocrResults.containsKey('nama') && fullName.isNotEmpty) {
totalFields++;
String ocrName = ocrResults['nama']!.toLowerCase();
// Use fuzzy matching since OCR might not be perfect
if (ocrName.contains(firstNameController.text.toLowerCase()) ||
fullName.contains(ocrName)) {
matchCount++;
}
}
// Check NIK matches (exact match required for numbers)
if (ocrResults.containsKey('nik') && nikController.text.isNotEmpty) {
totalFields++;
// Clean up any spaces or special characters from OCR result
String ocrNik = ocrResults['nik']!.replaceAll(RegExp(r'[^0-9]'), '');
if (ocrNik == nikController.text) {
matchCount++;
}
}
// Check birth date (flexible format matching)
if (ocrResults.containsKey('tanggal_lahir') &&
birthDateController.text.isNotEmpty) {
totalFields++;
// Convert both to a common format for comparison
String userDate = _normalizeDateFormat(birthDateController.text);
String ocrDate = _normalizeDateFormat(ocrResults['tanggal_lahir']!);
if (userDate == ocrDate) {
matchCount++;
}
}
// Check address (partial matching is acceptable due to OCR limitations and address complexity)
if (ocrResults.containsKey('alamat') && addressController.text.isNotEmpty) {
totalFields++;
String userAddress = addressController.text.toLowerCase();
String ocrAddress = ocrResults['alamat']!.toLowerCase();
// Check if there's significant overlap between the two addresses
List<String> userAddressParts = userAddress.split(' ');
int addressMatchCount = 0;
for (String part in userAddressParts) {
if (part.length > 3 && ocrAddress.contains(part)) {
addressMatchCount++;
}
}
if (addressMatchCount >= userAddressParts.length * 0.5) {
matchCount++;
}
}
// Require at least 60% of the fields to match
return totalFields > 0 && (matchCount / totalFields) >= 0.6;
}
// Compare OCR results with user input for KTA (Officer ID)
bool _verifyKtaResults(Map<String, String> ocrResults) {
int matchCount = 0;
int totalFields = 0;
// Check name matches
String fullName =
'${firstNameController.text} ${lastNameController.text}'
.trim()
.toLowerCase();
if (ocrResults.containsKey('nama') && fullName.isNotEmpty) {
totalFields++;
String ocrName = ocrResults['nama']!.toLowerCase();
if (ocrName.contains(firstNameController.text.toLowerCase()) ||
fullName.contains(ocrName)) {
matchCount++;
}
}
// Check rank matches
if (ocrResults.containsKey('pangkat') && rankController.text.isNotEmpty) {
totalFields++;
String ocrRank = ocrResults['pangkat']!.toLowerCase();
String userRank = rankController.text.toLowerCase();
if (ocrRank.contains(userRank) || userRank.contains(ocrRank)) {
matchCount++;
}
}
// Check NRP matches
if (ocrResults.containsKey('nrp') && nrpController.text.isNotEmpty) {
totalFields++;
String ocrNrp = ocrResults['nrp']!.replaceAll(
RegExp(r'[^0-9a-zA-Z]'),
'',
);
String userNrp = nrpController.text.replaceAll(
RegExp(r'[^0-9a-zA-Z]'),
'',
);
if (ocrNrp.contains(userNrp) || userNrp.contains(ocrNrp)) {
matchCount++;
}
}
// Check unit matches
if (ocrResults.containsKey('unit') && unitIdController.text.isNotEmpty) {
totalFields++;
String ocrUnit = ocrResults['unit']!.toLowerCase();
// Find the matching unit from available units
final selectedUnit = availableUnits.firstWhere(
(unit) => unit.codeUnit == unitIdController.text,
orElse: () => UnitModel(),
);
String userUnit = selectedUnit.name.toLowerCase();
if (ocrUnit.contains(userUnit) || userUnit.contains(ocrUnit)) {
matchCount++;
}
}
// Check birth date if available
if (ocrResults.containsKey('tanggal_lahir') &&
birthDateController.text.isNotEmpty) {
totalFields++;
String userDate = _normalizeDateFormat(birthDateController.text);
String ocrDate = _normalizeDateFormat(ocrResults['tanggal_lahir']!);
if (userDate == ocrDate) {
matchCount++;
}
}
// Require at least 60% of the fields to match
return totalFields > 0 && (matchCount / totalFields) >= 0.6;
}
// Helper method to normalize date formats for comparison
String _normalizeDateFormat(String dateStr) {
// Remove non-numeric characters
String numericOnly = dateStr.replaceAll(RegExp(r'[^0-9]'), '');
// Try to extract year, month, day in a standardized format
if (numericOnly.length >= 8) {
// Assume YYYYMMDD format
return numericOnly.substring(0, 8);
} else {
return numericOnly;
}
}
// Override existing nextStep method to include ID card and face verification
void nextStep() {
// For the identity step, ensure both ID card and face verification are completed
if (currentStep.value == 1 &&
!isLoading.value &&
selectedRole.value != null) {
if (!_validateCurrentStep()) return;
// Check if ID card image is uploaded
if (idCardImage.value == null) {
final idCardType = selectedRole.value!.isOfficer ? 'KTA' : 'KTP';
idCardError.value =
'Please upload your $idCardType image for verification';
return;
}
// Check if ID card is verified
if (!isVerified.value) {
idCardError.value = 'Please verify your ID card first';
return;
}
// Check if selfie is captured
if (selfieImage.value == null) {
selfieError.value = 'Please take a selfie for face verification';
return;
}
// Check if liveness check is passed
if (!isLivenessCheckPassed.value) {
selfieError.value = 'Please complete the liveness check';
return;
}
// Check if face is verified
if (!isFaceVerified.value) {
selfieError.value = 'Please complete face verification';
return;
}
// Proceed with the original logic
if (currentStep.value < stepFormKeys.length - 1) {
currentStep.value++;
} else {
submitForm();
}
} else {
// Original nextStep logic for other steps
if (!validateCurrentStep()) return;
if (currentStep.value < stepFormKeys.length - 1) {
currentStep.value++;
} else {
submitForm();
}
}
}
// Go to previous step
void previousStep() {
if (currentStep.value > 0) {
currentStep.value--;
}
}
// Go to specific step
void goToStep(int step) {
if (step >= 0 && step < stepFormKeys.length) {
// Only allow going to a step if all previous steps are valid
bool canProceed = true;
for (int i = 0; i < step; i++) {
clearErrors(); // Clear errors before validating
final formKey = stepFormKeys[i];
if (!(formKey.currentState?.validate() ?? false)) {
canProceed = false;
currentStep.value = i;
break;
}
}
if (canProceed) {
currentStep.value = step;
}
}
}
// Submit the complete form
Future<void> submitForm() async {
// Validate all steps
bool isValid = true;
for (int i = 0; i < stepFormKeys.length; i++) {
final formKey = stepFormKeys[i];
if (!(formKey.currentState?.validate() ?? false)) {
isValid = false;
currentStep.value = i;
break;
}
}
if (!isValid) return;
try {
isLoading.value = true;
// Prepare UserMetadataModel based on role
if (selectedRole.value?.isOfficer == true) {
// Officer role - create OfficerModel with the data
final officerData = OfficerModel(
id: '', // Will be assigned by backend
unitId: unitIdController.text,
roleId: selectedRole.value!.id,
nrp: nrpController.text,
name: nameController.text, // Use the combined name here
rank: rankController.text,
position: positionController.text,
phone: phoneController.text,
);
userMetadata.value = UserMetadataModel(
isOfficer: true,
name: nameController.text, // Use the combined name
phone: phoneController.text,
officerData: officerData,
additionalData: {'address': addressController.text},
);
} else {
// Regular user - create profile-related data
userMetadata.value = UserMetadataModel(
isOfficer: false,
nik: nikController.text,
name: nameController.text, // Use the combined name
phone: phoneController.text,
profileData: ProfileModel(
id: '', // Will be assigned by backend
userId: '', // Will be assigned by backend
nik: nikController.text,
firstName: firstNameController.text.trim(),
lastName: lastNameController.text.trim(),
bio: bioController.text, // Add the bio field
birthDate: _parseBirthDate(
birthDateController.text,
), // Parse birth date
),
additionalData: {'address': addressController.text},
);
}
// Navigate to the signup screen with the prepared metadata
Get.toNamed(
AppRoutes.signUp,
arguments: {
'userMetadata': userMetadata.value,
'role': selectedRole.value,
},
);
} catch (e) {
Get.toNamed(
AppRoutes.stateScreen,
arguments: {
'type': 'error',
'title': 'Data Preparation Failed',
'message':
'There was an error preparing your profile: ${e.toString()}',
'buttonText': 'Try Again',
'onButtonPressed': () => Get.back(),
},
);
} finally {
isLoading.value = false;
}
}
// Parse birth date string to DateTime
DateTime? _parseBirthDate(String dateStr) {
try {
// Try to parse in format YYYY-MM-DD
if (dateStr.isEmpty) return null;
// Add validation for different date formats as needed
if (dateStr.contains('-')) {
return DateTime.parse(dateStr);
}
// Handle other formats like DD/MM/YYYY
if (dateStr.contains('/')) {
final parts = dateStr.split('/');
if (parts.length == 3) {
final day = int.parse(parts[0]);
final month = int.parse(parts[1]);
final year = int.parse(parts[2]);
return DateTime(year, month, day);
}
}
return null;
} catch (e) {
return null;
}
}
}

View File

@ -0,0 +1,173 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart';
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
class IdCardVerificationController extends GetxController {
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
final AzureOCRService _ocrService = AzureOCRService();
final bool isOfficer;
IdCardVerificationController({required this.isOfficer});
// ID Card variables
final Rx<XFile?> idCardImage = Rx<XFile?>(null);
final RxString idCardError = RxString('');
final RxBool isVerifying = RxBool(false);
final RxBool isIdCardValid = RxBool(false);
final RxString idCardValidationMessage = RxString('');
// Loading states for image uploading
final RxBool isUploadingIdCard = RxBool(false);
// Confirmation status
final RxBool hasConfirmedIdCard = RxBool(false);
bool validate() {
clearErrors();
// For this step, we just need to ensure ID card is uploaded and validated
bool isValid = true;
if (idCardImage.value == null) {
final idCardType = isOfficer ? 'KTA' : 'KTP';
idCardError.value = 'Please upload your $idCardType image';
isValid = false;
} else if (!isIdCardValid.value) {
idCardError.value = 'Your ID card image is not valid';
isValid = false;
} else if (!hasConfirmedIdCard.value) {
idCardError.value = 'Please confirm your ID card image';
isValid = false;
}
return isValid;
}
void clearErrors() {
idCardError.value = '';
idCardValidationMessage.value = '';
}
// Pick ID Card Image
Future<void> pickIdCardImage(ImageSource source) async {
try {
isUploadingIdCard.value = true;
hasConfirmedIdCard.value =
false; // Reset confirmation whenever image changes
final ImagePicker picker = ImagePicker();
final XFile? image = await picker.pickImage(
source: source,
imageQuality: 80,
);
if (image != null) {
// Add artificial delay to show loading state
await Future.delayed(const Duration(seconds: 1));
idCardImage.value = image;
idCardError.value = '';
// Initial validation of the ID card
await validateIdCardImage();
}
} catch (e) {
idCardError.value = 'Failed to pick image: $e';
isIdCardValid.value = false;
} finally {
isUploadingIdCard.value = false;
}
}
// Initial validation of ID card image
Future<void> validateIdCardImage() async {
// Clear previous validation messages
clearErrors();
if (idCardImage.value == null) {
idCardError.value = 'Please upload an ID card image first';
isIdCardValid.value = false;
return;
}
try {
isVerifying.value = true;
final idCardType = isOfficer ? 'KTA' : 'KTP';
// Basic validation to check if the image is clear enough for OCR
bool isImageValid = false;
try {
// Try to process the ID card to check if it can be processed properly
final result = await _ocrService.processIdCard(
idCardImage.value!,
isOfficer,
);
// If we get here without an exception, the image is likely valid
isImageValid = result.isNotEmpty;
if (isImageValid) {
isIdCardValid.value = true;
idCardValidationMessage.value =
'$idCardType image looks valid. Please confirm this is your $idCardType.';
} else {
isIdCardValid.value = false;
idCardValidationMessage.value =
'Unable to verify your $idCardType clearly. Please ensure all text is visible and try again.';
}
} on SocketException catch (e) {
isIdCardValid.value = false;
if (e.message.contains('Connection timed out') ||
e.message.contains('Connection refused')) {
idCardValidationMessage.value =
'Unable to connect to verification service. Please check your internet connection and try again later.';
} else {
idCardValidationMessage.value =
'Network error occurred. Please try again later.';
}
} catch (processingError) {
isIdCardValid.value = false;
idCardValidationMessage.value =
'We\'re having trouble processing your $idCardType. Please try again with a clearer image.';
}
} catch (e) {
isIdCardValid.value = false;
// Provide user-friendly error messages based on error type
if (e is SocketException) {
idCardValidationMessage.value =
'Connection problem detected. Please check your internet connection and try again.';
} else if (e.toString().contains('timeout')) {
idCardValidationMessage.value =
'The verification is taking too long. Please try again later when the connection is better.';
} else {
// Generic but still user-friendly error message
idCardValidationMessage.value =
'We encountered an issue during verification. Please try again later.';
}
// Log the actual error for debugging (wouldn't be shown to the user)
print('ID Card validation error: ${e.toString()}');
} finally {
isVerifying.value = false;
}
}
// Clear ID Card Image
void clearIdCardImage() {
idCardImage.value = null;
idCardError.value = '';
isIdCardValid.value = false;
idCardValidationMessage.value = '';
hasConfirmedIdCard.value = false;
}
// Confirm ID Card Image
void confirmIdCardImage() {
if (isIdCardValid.value) {
hasConfirmedIdCard.value = true;
}
}
}

View File

@ -0,0 +1,407 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/image_verification_controller.dart';
import 'package:sigap/src/utils/validators/validation.dart';
class IdentityVerificationController extends GetxController {
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
final AzureOCRService _ocrService = AzureOCRService();
final bool isOfficer;
IdentityVerificationController({required this.isOfficer});
// Reference to image verification controller to access the validated images
late ImageVerificationController imageVerificationController;
// Controllers for viewer (non-officer)
final nikController = TextEditingController();
final bioController = TextEditingController();
final birthDateController = TextEditingController();
// New controllers for KTP fields
final fullNameController = TextEditingController();
final placeOfBirthController = TextEditingController();
final addressController = TextEditingController();
final RxString selectedGender = ''.obs;
// Error states
final RxString nikError = ''.obs;
final RxString bioError = ''.obs;
final RxString birthDateError = ''.obs;
// New error states for KTP fields
final RxString fullNameError = ''.obs;
final RxString placeOfBirthError = ''.obs;
final RxString genderError = ''.obs;
final RxString addressError = ''.obs;
// OCR verification states
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('');
@override
void onInit() {
super.onInit();
// Get reference to the image verification controller
try {
imageVerificationController = Get.find<ImageVerificationController>();
} catch (e) {
// Controller not initialized yet, will retry later
}
}
bool validate() {
clearErrors();
if (formKey.currentState?.validate() ?? false) {
// Form validation passed, now check verification status
if (!isVerified.value) {
verificationMessage.value = 'Please complete ID card verification';
return false;
}
if (!isFaceVerified.value) {
faceVerificationMessage.value = 'Please complete face verification';
return false;
}
return true;
}
// Manual validation as fallback
bool isValid = true;
if (!isOfficer) {
final nikValidation = TValidators.validateUserInput(
'NIK',
nikController.text,
16,
);
if (nikValidation != null) {
nikError.value = nikValidation;
isValid = false;
}
// Validate full name
final fullNameValidation = TValidators.validateUserInput(
'Full Name',
fullNameController.text,
100,
);
if (fullNameValidation != null) {
fullNameError.value = fullNameValidation;
isValid = false;
}
// Validate place of birth
final placeOfBirthValidation = TValidators.validateUserInput(
'Place of Birth',
placeOfBirthController.text,
50,
);
if (placeOfBirthValidation != null) {
placeOfBirthError.value = placeOfBirthValidation;
isValid = false;
}
// Validate gender
if (selectedGender.value.isEmpty) {
genderError.value = 'Gender is required';
isValid = false;
}
// Validate address
final addressValidation = TValidators.validateUserInput(
'Address',
addressController.text,
255,
);
if (addressValidation != null) {
addressError.value = addressValidation;
isValid = false;
}
}
// Bio can be optional, so we validate with required: false
final bioValidation = TValidators.validateUserInput(
'Bio',
bioController.text,
255,
required: false,
);
if (bioValidation != null) {
bioError.value = bioValidation;
isValid = false;
}
// Birth date validation
final birthDateValidation = TValidators.validateUserInput(
'Birth Date',
birthDateController.text,
10,
);
if (birthDateValidation != null) {
birthDateError.value = birthDateValidation;
isValid = false;
}
return isValid && isVerified.value && isFaceVerified.value;
}
void clearErrors() {
nikError.value = '';
bioError.value = '';
birthDateError.value = '';
fullNameError.value = '';
placeOfBirthError.value = '';
genderError.value = '';
addressError.value = '';
}
// Verify ID Card using OCR and compare with entered data
Future<void> verifyIdCardWithOCR() async {
// Make sure we have reference to the image controller
if (!Get.isRegistered<ImageVerificationController>()) {
verificationMessage.value = 'Error: Image verification data unavailable';
isVerified.value = false;
return;
}
try {
imageVerificationController = Get.find<ImageVerificationController>();
} catch (e) {
verificationMessage.value = 'Error: Image verification data unavailable';
isVerified.value = false;
return;
}
final idCardImage = imageVerificationController.idCardImage.value;
if (idCardImage == null) {
verificationMessage.value =
'ID card image missing. Please go back and upload it.';
isVerified.value = false;
return;
}
if (!imageVerificationController.isIdCardValid.value ||
!imageVerificationController.hasConfirmedIdCard.value) {
verificationMessage.value =
'ID card image not validated. Please go back and validate it first.';
isVerified.value = false;
return;
}
try {
isVerifying.value = true;
final idCardType = isOfficer ? 'KTA' : 'KTP';
// Call Azure OCR service with the appropriate ID type
final result = await _ocrService.processIdCard(idCardImage, isOfficer);
// Compare OCR results with user input
final bool isMatch =
isOfficer ? _verifyKtaResults(result) : _verifyKtpResults(result);
isVerified.value = isMatch;
verificationMessage.value =
isMatch
? '$idCardType verification successful! Your information matches with your $idCardType.'
: 'Verification failed. Please ensure your entered information matches your $idCardType.';
} catch (e) {
isVerified.value = false;
verificationMessage.value = 'OCR processing failed: ${e.toString()}';
} finally {
isVerifying.value = false;
}
}
// Verify selfie with ID card
Future<void> verifyFaceMatch() async {
// Make sure we have reference to the image controller
if (!Get.isRegistered<ImageVerificationController>()) {
faceVerificationMessage.value =
'Error: Image verification data unavailable';
isFaceVerified.value = false;
return;
}
try {
imageVerificationController = Get.find<ImageVerificationController>();
} catch (e) {
faceVerificationMessage.value =
'Error: Image verification data unavailable';
isFaceVerified.value = false;
return;
}
final idCardImage = imageVerificationController.idCardImage.value;
final selfieImage = imageVerificationController.selfieImage.value;
if (idCardImage == null) {
faceVerificationMessage.value =
'ID card image missing. Please go back and upload it.';
isFaceVerified.value = false;
return;
}
if (!imageVerificationController.isIdCardValid.value ||
!imageVerificationController.hasConfirmedIdCard.value) {
faceVerificationMessage.value =
'ID card image not validated. Please go back and validate it first.';
isFaceVerified.value = false;
return;
}
if (selfieImage == null) {
faceVerificationMessage.value =
'Selfie image missing. Please go back and take a selfie.';
isFaceVerified.value = false;
return;
}
if (!imageVerificationController.isSelfieValid.value ||
!imageVerificationController.hasConfirmedSelfie.value) {
faceVerificationMessage.value =
'Selfie not validated. Please go back and validate it first.';
isFaceVerified.value = false;
return;
}
try {
isVerifyingFace.value = true;
// Compare face in ID card with selfie face
final result = await _ocrService.verifyFace(idCardImage, selfieImage);
isFaceVerified.value = result['isMatch'] ?? false;
faceVerificationMessage.value = result['message'] ?? '';
} catch (e) {
isFaceVerified.value = false;
faceVerificationMessage.value =
'Face verification failed: ${e.toString()}';
} finally {
isVerifyingFace.value = false;
}
}
// Compare OCR results with user input for KTP
bool _verifyKtpResults(Map<String, String> ocrResults) {
int matchCount = 0;
int totalFields = 0;
// Check NIK matches (exact match required for numbers)
if (ocrResults.containsKey('nik') && nikController.text.isNotEmpty) {
totalFields++;
// Clean up any spaces or special characters from OCR result
String ocrNik = ocrResults['nik']!.replaceAll(RegExp(r'[^0-9]'), '');
if (ocrNik == nikController.text) {
matchCount++;
}
}
// Check birth date (flexible format matching)
if (ocrResults.containsKey('tanggal_lahir') &&
birthDateController.text.isNotEmpty) {
totalFields++;
// Convert both to a common format for comparison
String userDate = normalizeDateFormat(birthDateController.text);
String ocrDate = normalizeDateFormat(ocrResults['tanggal_lahir']!);
if (userDate == ocrDate) {
matchCount++;
}
}
// Check full name
if (ocrResults.containsKey('nama') && fullNameController.text.isNotEmpty) {
totalFields++;
// Case-insensitive comparison for names
if (ocrResults['nama']!.toLowerCase().trim() ==
fullNameController.text.toLowerCase().trim()) {
matchCount++;
}
}
// Check place of birth
if (ocrResults.containsKey('tempat_lahir') &&
placeOfBirthController.text.isNotEmpty) {
totalFields++;
// Case-insensitive comparison for place names
if (ocrResults['tempat_lahir']!.toLowerCase().trim().contains(
placeOfBirthController.text.toLowerCase().trim(),
)) {
matchCount++;
}
}
// Check address (partial match is acceptable for address)
if (ocrResults.containsKey('alamat') && addressController.text.isNotEmpty) {
totalFields++;
// Check if user-entered address is contained within OCR address
if (ocrResults['alamat']!.toLowerCase().contains(
addressController.text.toLowerCase(),
)) {
matchCount++;
}
}
// Require at least 60% of the fields to match
return totalFields > 0 && (matchCount / totalFields) >= 0.6;
}
// Compare OCR results with user input for KTA (Officer ID)
bool _verifyKtaResults(Map<String, String> ocrResults) {
// Since we're dealing with officer info in a separate step,
// this will compare only birthdate and general info, which is minimal
int matchCount = 0;
int totalFields = 0;
// Check birth date if available
if (ocrResults.containsKey('tanggal_lahir') &&
birthDateController.text.isNotEmpty) {
totalFields++;
String userDate = normalizeDateFormat(birthDateController.text);
String ocrDate = normalizeDateFormat(ocrResults['tanggal_lahir']!);
if (userDate == ocrDate) {
matchCount++;
}
}
// Simpler comparison for KTA at this step
return totalFields > 0 ? (matchCount / totalFields) >= 0.5 : true;
}
// Helper method to normalize date formats for comparison
String normalizeDateFormat(String dateStr) {
// Remove non-numeric characters
String numericOnly = dateStr.replaceAll(RegExp(r'[^0-9]'), '');
// Try to extract year, month, day in a standardized format
if (numericOnly.length >= 8) {
// Assume YYYYMMDD format
return numericOnly.substring(0, 8);
} else {
return numericOnly;
}
}
@override
void onClose() {
nikController.dispose();
bioController.dispose();
birthDateController.dispose();
fullNameController.dispose();
placeOfBirthController.dispose();
addressController.dispose();
super.onClose();
}
}

View File

@ -0,0 +1,250 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart';
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
class ImageVerificationController extends GetxController {
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
final AzureOCRService _ocrService = AzureOCRService();
final bool isOfficer;
ImageVerificationController({required this.isOfficer});
// ID Card variables
final Rx<XFile?> idCardImage = Rx<XFile?>(null);
final RxString idCardError = RxString('');
final RxBool isVerifying = RxBool(false);
final RxBool isIdCardValid = RxBool(false);
final RxString idCardValidationMessage = RxString('');
// Loading states for image uploading
final RxBool isUploadingIdCard = RxBool(false);
final RxBool isUploadingSelfie = RxBool(false);
// Face verification variables
final Rx<XFile?> selfieImage = Rx<XFile?>(null);
final RxString selfieError = RxString('');
final RxBool isVerifyingFace = RxBool(false);
final RxBool isSelfieValid = RxBool(false);
final RxString selfieValidationMessage = RxString('');
final RxBool isLivenessCheckPassed = RxBool(false);
final RxBool isPerformingLivenessCheck = RxBool(false);
// Confirmation status
final RxBool hasConfirmedIdCard = RxBool(false);
final RxBool hasConfirmedSelfie = RxBool(false);
bool validate() {
clearErrors();
// For this step, we just need to ensure both images are uploaded and initially validated
bool isValid = true;
if (idCardImage.value == null) {
final idCardType = isOfficer ? 'KTA' : 'KTP';
idCardError.value = 'Please upload your $idCardType image';
isValid = false;
} else if (!isIdCardValid.value) {
idCardError.value = 'Your ID card image is not valid';
isValid = false;
} else if (!hasConfirmedIdCard.value) {
idCardError.value = 'Please confirm your ID card image';
isValid = false;
}
if (selfieImage.value == null) {
selfieError.value = 'Please take a selfie for verification';
isValid = false;
} else if (!isSelfieValid.value) {
selfieError.value = 'Your selfie image is not valid';
isValid = false;
} else if (!hasConfirmedSelfie.value) {
selfieError.value = 'Please confirm your selfie image';
isValid = false;
}
return isValid;
}
void clearErrors() {
idCardError.value = '';
selfieError.value = '';
}
// Pick ID Card Image
Future<void> pickIdCardImage(ImageSource source) async {
try {
isUploadingIdCard.value = true;
hasConfirmedIdCard.value =
false; // Reset confirmation whenever image changes
final ImagePicker picker = ImagePicker();
final XFile? image = await picker.pickImage(
source: source,
imageQuality: 80,
);
if (image != null) {
// Add artificial delay to show loading state
await Future.delayed(const Duration(seconds: 1));
idCardImage.value = image;
idCardError.value = '';
// Initial validation of the ID card (simple check)
await validateIdCardImage();
}
} catch (e) {
idCardError.value = 'Failed to pick image: $e';
isIdCardValid.value = false;
} finally {
isUploadingIdCard.value = false;
}
}
// Take or pick selfie image
Future<void> pickSelfieImage(ImageSource source) async {
try {
isUploadingSelfie.value = true;
hasConfirmedSelfie.value =
false; // Reset confirmation whenever image changes
final ImagePicker picker = ImagePicker();
final XFile? image = await picker.pickImage(
source: source,
preferredCameraDevice: CameraDevice.front,
imageQuality: 80,
);
if (image != null) {
// Add artificial delay to show loading state
await Future.delayed(const Duration(seconds: 1));
selfieImage.value = image;
selfieError.value = '';
// Initial validation of the selfie (face detection)
await validateSelfieImage();
}
} catch (e) {
selfieError.value = 'Failed to capture selfie: $e';
isSelfieValid.value = false;
} finally {
isUploadingSelfie.value = false;
}
}
// Initial validation of ID card image
Future<void> validateIdCardImage() async {
if (idCardImage.value == null) {
idCardError.value = 'Please upload an ID card image first';
isIdCardValid.value = false;
return;
}
try {
isVerifying.value = true;
final idCardType = isOfficer ? 'KTA' : 'KTP';
// Basic validation to check if the image is clear enough for OCR
// Since the _detectFaces method is private, we'll use a public method to detect faces
// or simulate it for validation purposes
bool isImageValid = true; // Default to true for now
try {
// Try to use processIdCard method to check if the image can be processed properly
final result = await _ocrService.processIdCard(
idCardImage.value!,
isOfficer,
);
// If we get here without an exception, the image is likely valid
isImageValid = result.isNotEmpty;
} catch (processingError) {
// If processing fails, the image might not be valid
isImageValid = false;
}
if (isImageValid) {
isIdCardValid.value = true;
idCardValidationMessage.value =
'$idCardType image looks valid. Please confirm this is your $idCardType.';
} else {
isIdCardValid.value = false;
idCardValidationMessage.value =
'The image doesn\'t appear to be a valid $idCardType. Please upload a clearer image.';
}
} catch (e) {
isIdCardValid.value = false;
idCardValidationMessage.value = 'Validation failed: ${e.toString()}';
} finally {
isVerifying.value = false;
}
}
// Initial validation of selfie image
Future<void> validateSelfieImage() async {
if (selfieImage.value == null) {
selfieError.value = 'Please take a selfie first';
isSelfieValid.value = false;
return;
}
try {
isVerifyingFace.value = true;
// Instead of directly calling _detectFaces, we'll use the liveness check
// which indirectly checks for faces
final livenessResult = await _ocrService.performLivenessCheck(
selfieImage.value!,
);
isLivenessCheckPassed.value = livenessResult['isLive'] ?? false;
if (livenessResult['isLive'] == true) {
isSelfieValid.value = true;
selfieValidationMessage.value =
'Face detected. Please confirm this is you.';
} else {
isSelfieValid.value = false;
selfieValidationMessage.value =
livenessResult['message'] ??
'No face detected or liveness check failed. Please take a clearer selfie.';
}
} catch (e) {
isSelfieValid.value = false;
selfieValidationMessage.value = 'Validation failed: ${e.toString()}';
} finally {
isVerifyingFace.value = false;
}
}
// Clear ID Card Image
void clearIdCardImage() {
idCardImage.value = null;
idCardError.value = '';
isIdCardValid.value = false;
idCardValidationMessage.value = '';
hasConfirmedIdCard.value = false;
}
// Clear Selfie Image
void clearSelfieImage() {
selfieImage.value = null;
selfieError.value = '';
isSelfieValid.value = false;
selfieValidationMessage.value = '';
isLivenessCheckPassed.value = false;
hasConfirmedSelfie.value = false;
}
// Confirm ID Card Image
void confirmIdCardImage() {
if (isIdCardValid.value) {
hasConfirmedIdCard.value = true;
}
}
// Confirm Selfie Image
void confirmSelfieImage() {
if (isSelfieValid.value && isLivenessCheckPassed.value) {
hasConfirmedSelfie.value = true;
}
}
}

View File

@ -0,0 +1,60 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/utils/validators/validation.dart';
class OfficerInfoController extends GetxController {
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
// Controllers
final nrpController = TextEditingController();
final rankController = TextEditingController();
// Error states
final RxString nrpError = ''.obs;
final RxString rankError = ''.obs;
bool validate() {
clearErrors();
if (formKey.currentState?.validate() ?? false) {
return true;
}
// Manual validation as fallback
bool isValid = true;
final nrpValidation = TValidators.validateUserInput(
'NRP',
nrpController.text,
50,
);
if (nrpValidation != null) {
nrpError.value = nrpValidation;
isValid = false;
}
final rankValidation = TValidators.validateUserInput(
'Rank',
rankController.text,
50,
);
if (rankValidation != null) {
rankError.value = rankValidation;
isValid = false;
}
return isValid;
}
void clearErrors() {
nrpError.value = '';
rankError.value = '';
}
@override
void onClose() {
nrpController.dispose();
rankController.dispose();
super.onClose();
}
}

View File

@ -0,0 +1,135 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/utils/validators/validation.dart';
class PersonalInfoController extends GetxController {
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
// Controllers
final firstNameController = TextEditingController();
final lastNameController = TextEditingController();
final nameController = TextEditingController(); // For combined name
final phoneController = TextEditingController();
final bioController = TextEditingController();
final addressController = TextEditingController();
// Error states
final RxString firstNameError = ''.obs;
final RxString lastNameError = ''.obs;
final RxString nameError = ''.obs;
final RxString phoneError = ''.obs;
final RxString bioError = ''.obs;
final RxString addressError = ''.obs;
@override
void onInit() {
super.onInit();
// Add listeners to first name and last name controllers to update the combined name
firstNameController.addListener(_updateCombinedName);
lastNameController.addListener(_updateCombinedName);
}
// Update the combined name when either first or last name changes
void _updateCombinedName() {
final firstName = firstNameController.text.trim();
final lastName = lastNameController.text.trim();
if (firstName.isEmpty && lastName.isEmpty) {
nameController.text = '';
} else if (lastName.isEmpty) {
nameController.text = firstName;
} else if (firstName.isEmpty) {
nameController.text = lastName;
} else {
nameController.text = '$firstName $lastName';
}
}
bool validate() {
clearErrors();
if (formKey.currentState?.validate() ?? false) {
return true;
}
// Manual validation as fallback
bool isValid = true;
final firstNameValidation = TValidators.validateUserInput(
'First name',
firstNameController.text,
50,
);
if (firstNameValidation != null) {
firstNameError.value = firstNameValidation;
isValid = false;
}
final lastNameValidation = TValidators.validateUserInput(
'Last name',
lastNameController.text,
50,
required: false,
);
if (lastNameValidation != null) {
lastNameError.value = lastNameValidation;
isValid = false;
}
final phoneValidation = TValidators.validatePhoneNumber(
phoneController.text,
);
if (phoneValidation != null) {
phoneError.value = phoneValidation;
isValid = false;
}
// Bio can be optional, so we validate with required: false
final bioValidation = TValidators.validateUserInput(
'Bio',
bioController.text,
255,
required: false,
);
if (bioValidation != null) {
bioError.value = bioValidation;
isValid = false;
}
final addressValidation = TValidators.validateUserInput(
'Address',
addressController.text,
255,
);
if (addressValidation != null) {
addressError.value = addressValidation;
isValid = false;
}
return isValid;
}
void clearErrors() {
firstNameError.value = '';
lastNameError.value = '';
nameError.value = '';
phoneError.value = '';
bioError.value = '';
addressError.value = '';
}
@override
void onClose() {
firstNameController.removeListener(_updateCombinedName);
lastNameController.removeListener(_updateCombinedName);
firstNameController.dispose();
lastNameController.dispose();
nameController.dispose();
phoneController.dispose();
bioController.dispose();
addressController.dispose();
super.onClose();
}
}

View File

@ -0,0 +1,139 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart';
import 'package:sigap/src/cores/services/azure_ocr_service.dart';
class SelfieVerificationController extends GetxController {
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
final AzureOCRService _ocrService = AzureOCRService();
// Face verification variables
final Rx<XFile?> selfieImage = Rx<XFile?>(null);
final RxString selfieError = RxString('');
final RxBool isVerifyingFace = RxBool(false);
final RxBool isSelfieValid = RxBool(false);
final RxString selfieValidationMessage = RxString('');
final RxBool isLivenessCheckPassed = RxBool(false);
final RxBool isPerformingLivenessCheck = RxBool(false);
// Loading states for image uploading
final RxBool isUploadingSelfie = RxBool(false);
// Confirmation status
final RxBool hasConfirmedSelfie = RxBool(false);
bool validate() {
clearErrors();
// For this step, we just need to ensure selfie is uploaded and validated
bool isValid = true;
if (selfieImage.value == null) {
selfieError.value = 'Please take a selfie for verification';
isValid = false;
} else if (!isSelfieValid.value) {
selfieError.value = 'Your selfie image is not valid';
isValid = false;
} else if (!hasConfirmedSelfie.value) {
selfieError.value = 'Please confirm your selfie image';
isValid = false;
}
return isValid;
}
void clearErrors() {
selfieError.value = '';
selfieValidationMessage.value = '';
}
void clearSelfieValidationMessage() {
selfieValidationMessage.value = '';
}
// Take or pick selfie image
Future<void> pickSelfieImage(ImageSource source) async {
try {
isUploadingSelfie.value = true;
hasConfirmedSelfie.value =
false; // Reset confirmation whenever image changes
final ImagePicker picker = ImagePicker();
final XFile? image = await picker.pickImage(
source: source,
preferredCameraDevice: CameraDevice.front,
imageQuality: 80,
);
if (image != null) {
// Add artificial delay to show loading state
await Future.delayed(const Duration(seconds: 1));
selfieImage.value = image;
selfieError.value = '';
// Initial validation of the selfie (face detection)
await validateSelfieImage();
}
} catch (e) {
selfieError.value = 'Failed to capture selfie: $e';
isSelfieValid.value = false;
} finally {
isUploadingSelfie.value = false;
}
}
// Initial validation of selfie image
Future<void> validateSelfieImage() async {
// Clear previous validation messages
clearErrors();
if (selfieImage.value == null) {
selfieError.value = 'Please take a selfie first';
isSelfieValid.value = false;
return;
}
try {
isVerifyingFace.value = true;
// Use liveness check to validate if the selfie has a valid face
final livenessResult = await _ocrService.performLivenessCheck(
selfieImage.value!,
);
isLivenessCheckPassed.value = livenessResult['isLive'] ?? false;
if (livenessResult['isLive'] == true) {
isSelfieValid.value = true;
selfieValidationMessage.value =
'Face detected. Please confirm this is you.';
} else {
isSelfieValid.value = false;
selfieValidationMessage.value =
livenessResult['message'] ??
'No face detected or liveness check failed. Please take a clearer selfie.';
}
} catch (e) {
isSelfieValid.value = false;
selfieValidationMessage.value = 'Validation failed: ${e.toString()}';
} finally {
isVerifyingFace.value = false;
}
}
// Clear Selfie Image
void clearSelfieImage() {
selfieImage.value = null;
selfieError.value = '';
isSelfieValid.value = false;
selfieValidationMessage.value = '';
isLivenessCheckPassed.value = false;
hasConfirmedSelfie.value = false;
}
// Confirm Selfie Image
void confirmSelfieImage() {
if (isSelfieValid.value) {
hasConfirmedSelfie.value = true;
}
}
}

View File

@ -0,0 +1,59 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/daily-ops/data/models/index.dart';
import 'package:sigap/src/utils/validators/validation.dart';
class UnitInfoController extends GetxController {
final GlobalKey<FormState> formKey = GlobalKey<FormState>();
// Controllers
final positionController = TextEditingController();
final unitIdController = TextEditingController();
// Available units for officer role
final RxList<UnitModel> availableUnits = <UnitModel>[].obs;
// Error states
final RxString positionError = ''.obs;
final RxString unitIdError = ''.obs;
bool validate() {
clearErrors();
if (formKey.currentState?.validate() ?? false) {
return true;
}
// Manual validation as fallback
bool isValid = true;
final positionValidation = TValidators.validateUserInput(
'Position',
positionController.text,
100,
);
if (positionValidation != null) {
positionError.value = positionValidation;
isValid = false;
}
if (unitIdController.text.isEmpty) {
unitIdError.value = 'Please select a unit';
isValid = false;
}
return isValid;
}
void clearErrors() {
positionError.value = '';
unitIdError.value = '';
}
@override
void onClose() {
positionController.dispose();
unitIdController.dispose();
super.onClose();
}
}

View File

@ -0,0 +1,125 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/auth/data/dummy/indonesian_cities.dart';
import 'package:sigap/src/utils/constants/colors.dart';
import 'package:sigap/src/utils/constants/sizes.dart';
class CitySelectionPage extends StatefulWidget {
const CitySelectionPage({super.key});
@override
State<CitySelectionPage> createState() => _CitySelectionPageState();
}
class _CitySelectionPageState extends State<CitySelectionPage> {
final TextEditingController _searchController = TextEditingController();
final RxList<String> _filteredCities = <String>[].obs;
final List<String> _allCities = indonesianCities;
@override
void initState() {
super.initState();
_filteredCities.value = _allCities;
_searchController.addListener(() {
_filterCities(_searchController.text);
});
}
@override
void dispose() {
_searchController.dispose();
super.dispose();
}
void _filterCities(String query) {
if (query.isEmpty) {
_filteredCities.value = _allCities;
return;
}
final lowercaseQuery = query.toLowerCase();
_filteredCities.value =
_allCities
.where((city) => city.toLowerCase().contains(lowercaseQuery))
.toList();
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Select Place of Birth'),
backgroundColor: TColors.primary,
foregroundColor: Colors.white,
),
body: Column(
children: [
Padding(
padding: const EdgeInsets.all(TSizes.defaultSpace),
child: TextField(
controller: _searchController,
decoration: InputDecoration(
hintText: 'Search city or regency',
prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
),
contentPadding: const EdgeInsets.symmetric(
vertical: TSizes.sm,
horizontal: TSizes.md,
),
filled: true,
fillColor: Colors.grey.withOpacity(0.1),
),
textInputAction: TextInputAction.search,
),
),
Expanded(
child: Obx(
() =>
_filteredCities.isEmpty
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.location_city,
size: 64,
color: Colors.grey.withOpacity(0.5),
),
const SizedBox(height: TSizes.sm),
Text(
'No cities found',
style: TextStyle(
color: Colors.grey.withOpacity(0.8),
fontSize: TSizes.fontSizeMd,
),
),
],
),
)
: ListView.builder(
itemCount: _filteredCities.length,
itemBuilder: (context, index) {
final city = _filteredCities[index];
return ListTile(
title: Text(city),
onTap: () {
Get.back(result: city);
},
trailing: const Icon(Icons.chevron_right),
contentPadding: const EdgeInsets.symmetric(
horizontal: TSizes.defaultSpace,
vertical: TSizes.xs,
),
);
},
),
),
),
],
),
);
}
}

View File

@ -0,0 +1,546 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.dart';
import 'package:sigap/src/utils/constants/colors.dart';
import 'package:sigap/src/utils/constants/sizes.dart';
class IdCardVerificationStep extends StatelessWidget {
const IdCardVerificationStep({super.key});
@override
Widget build(BuildContext context) {
final controller = Get.find<IdCardVerificationController>();
final mainController = Get.find<FormRegistrationController>();
final isOfficer = mainController.selectedRole.value?.isOfficer ?? false;
final String idCardType = isOfficer ? 'KTA' : 'KTP';
return Form(
key: controller.formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'$idCardType Verification',
style: TextStyle(
fontSize: TSizes.fontSizeLg,
fontWeight: FontWeight.bold,
color: TColors.textPrimary,
),
),
const SizedBox(height: TSizes.sm),
Text(
'Upload a clear image of your $idCardType',
style: Theme.of(
context,
).textTheme.headlineSmall?.copyWith(color: TColors.textSecondary),
),
const SizedBox(height: TSizes.xs),
Text(
'Make sure all text and your photo are clearly visible',
style: Theme.of(
context,
).textTheme.labelLarge?.copyWith(color: TColors.textSecondary),
),
const SizedBox(height: TSizes.spaceBtwItems),
// ID Card Upload Widget
_buildIdCardUploader(controller, isOfficer),
// Verification Status for ID Card
// Obx(
// () =>
// controller.isVerifying.value &&
// !controller.isUploadingIdCard.value
// ? const Padding(
// padding: EdgeInsets.symmetric(
// vertical: TSizes.spaceBtwItems,
// ),
// child: Center(
// child: Column(
// children: [
// CircularProgressIndicator(),
// SizedBox(height: TSizes.sm),
// Text('Validating your ID card...'),
// ],
// ),
// ),
// )
// : const SizedBox.shrink(),
// ),
// Verification Message for ID Card
Obx(
() =>
controller.idCardValidationMessage.value.isNotEmpty
? Padding(
padding: const EdgeInsets.symmetric(
vertical: TSizes.spaceBtwItems,
),
child: Container(
padding: const EdgeInsets.all(TSizes.md),
decoration: BoxDecoration(
color:
controller.isIdCardValid.value
? Colors.green.withOpacity(0.1)
: Colors.red.withOpacity(0.1),
borderRadius: BorderRadius.circular(
TSizes.borderRadiusSm,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: Icon(
controller.isIdCardValid.value
? Icons.check_circle
: Icons.error,
color:
controller.isIdCardValid.value
? Colors.green
: Colors.red,
),
),
const SizedBox(width: TSizes.sm),
Expanded(
child: Text(
controller.idCardValidationMessage.value,
style: TextStyle(
color:
controller.isIdCardValid.value
? Colors.green
: Colors.red,
),
),
),
],
),
if (controller.isIdCardValid.value &&
!controller.hasConfirmedIdCard.value) ...[
const SizedBox(height: TSizes.md),
Row(
children: [
Expanded(
child: ElevatedButton(
onPressed:
() => controller.confirmIdCardImage(),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
),
child: const Text('Confirm Image'),
),
),
const SizedBox(width: TSizes.sm),
Expanded(
child: TextButton(
onPressed:
() => controller.clearIdCardImage(),
child: const Text('Try Another Image'),
),
),
],
),
],
if (controller.hasConfirmedIdCard.value)
const Padding(
padding: EdgeInsets.only(top: TSizes.sm),
child: Row(
children: [
Icon(
Icons.check_circle,
color: Colors.green,
size: TSizes.iconSm,
),
SizedBox(width: TSizes.xs),
Text(
'Image confirmed',
style: TextStyle(
color: Colors.green,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
),
)
: const SizedBox.shrink(),
),
// Error Messages
Obx(
() =>
controller.idCardError.value.isNotEmpty
? Padding(
padding: const EdgeInsets.only(top: TSizes.sm),
child: Text(
controller.idCardError.value,
style: const TextStyle(color: Colors.red),
),
)
: const SizedBox.shrink(),
),
],
),
);
}
Widget _buildIdCardUploader(
IdCardVerificationController controller,
bool isOfficer,
) {
final String idCardType = isOfficer ? 'KTA' : 'KTP';
return Obx(() {
// Background color based on error state or confirmation
final backgroundColor =
controller.idCardError.value.isNotEmpty
? TColors.error.withOpacity(0.1)
: controller.hasConfirmedIdCard.value
? Colors.green.withOpacity(0.1)
: Colors.grey.withOpacity(0.1);
// Determine border color based on error state or confirmation
final borderColor =
controller.idCardError.value.isNotEmpty
? TColors.error
: controller.hasConfirmedIdCard.value
? Colors.green
: Colors.grey.withOpacity(0.5);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (controller.idCardImage.value == null)
GestureDetector(
onTap: () => _showImageSourceDialog(controller, isOfficer),
child: Container(
height: 180,
width: double.infinity,
decoration: BoxDecoration(
color: backgroundColor, // Using the dynamic background color
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
border: Border.all(
color: borderColor,
width:
controller.idCardError.value.isNotEmpty
? 2
: 1, // Thicker border for error state
),
),
child:
controller.isUploadingIdCard.value
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: TSizes.sm),
Text(
'Uploading...',
style: TextStyle(color: TColors.textSecondary),
),
],
),
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
controller.idCardError.value.isNotEmpty
? Icons.error_outline
: Icons.add_a_photo,
size: TSizes.iconLg,
color:
controller.idCardError.value.isNotEmpty
? TColors.error
: Colors.grey,
),
const SizedBox(height: TSizes.sm),
Text(
controller.idCardError.value.isNotEmpty
? 'Please upload your $idCardType image first'
: 'Upload $idCardType Image',
style: TextStyle(
color:
controller.idCardError.value.isNotEmpty
? TColors.error
: TColors.textSecondary,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: TSizes.xs),
Text(
'Tap to select an image',
style: TextStyle(
fontSize: TSizes.fontSizeSm,
color:
controller.idCardError.value.isNotEmpty
? TColors.error.withOpacity(0.8)
: TColors.textSecondary,
),
),
],
),
),
)
else
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
alignment: Alignment.topRight,
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
TSizes.borderRadiusMd,
),
border: Border.all(color: borderColor, width: 2),
),
child: Stack(
alignment: Alignment.center,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(
TSizes.borderRadiusMd -
2, // Adjust for border width
),
child: Image.file(
File(controller.idCardImage.value!.path),
height: 200,
width: double.infinity,
fit: BoxFit.cover,
),
),
// Error overlay for uploaded image
if (controller.idCardError.value.isNotEmpty &&
!controller.isUploadingIdCard.value &&
!controller.isVerifying.value)
Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
TSizes.borderRadiusMd -
2, // Adjust for border width
),
color: TColors.error.withOpacity(0.2),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
color: TColors.error,
size: TSizes.iconLg,
),
const SizedBox(height: TSizes.sm),
Text(
'Invalid Image',
style: TextStyle(
color: TColors.error,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: TSizes.xs),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: TSizes.md,
),
child: Text(
'Please upload a clearer image',
textAlign: TextAlign.center,
style: TextStyle(
color: TColors.error,
fontSize: TSizes.fontSizeSm,
),
),
),
],
),
),
),
// Loading overlay
if (controller.isUploadingIdCard.value ||
controller.isVerifying.value)
Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
TSizes.borderRadiusMd -
2, // Adjust for border width
),
color: Colors.black.withOpacity(0.5),
),
child: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
color: Colors.white,
),
SizedBox(height: TSizes.sm),
Text(
'Processing...',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
),
),
if (!controller.hasConfirmedIdCard.value &&
!controller.isUploadingIdCard.value &&
!controller.isVerifying.value)
Positioned(
top: 8,
right: 8,
child: GestureDetector(
onTap: () => controller.clearIdCardImage(),
child: Container(
padding: const EdgeInsets.all(TSizes.xs),
decoration: BoxDecoration(
color:
controller.idCardError.value.isNotEmpty
? TColors.error
: Colors.black54,
shape: BoxShape.circle,
),
child: const Icon(
Icons.close,
color: Colors.white,
size: TSizes.iconSm,
),
),
),
),
],
),
const SizedBox(height: TSizes.sm),
if (!controller.isIdCardValid.value &&
!controller.isVerifying.value &&
!controller.isUploadingIdCard.value)
ElevatedButton.icon(
onPressed: () => controller.validateIdCardImage(),
icon: const Icon(Icons.check_circle),
label: Text('Check $idCardType Validity'),
style: ElevatedButton.styleFrom(
backgroundColor: TColors.primary,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 50),
),
),
],
),
],
);
});
}
void _showImageSourceDialog(
IdCardVerificationController controller,
bool isOfficer,
) {
final String idCardType = isOfficer ? 'KTA' : 'KTP';
final String title = 'Select $idCardType Image Source';
final String message =
'Please ensure your ID card is clear, well-lit, and all text is readable';
Get.dialog(
Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
),
child: Padding(
padding: const EdgeInsets.all(TSizes.defaultSpace),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: TextStyle(
fontSize: TSizes.fontSizeLg,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: TSizes.md),
Text(
message,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: TSizes.fontSizeSm,
color: TColors.textSecondary,
),
),
const SizedBox(height: TSizes.spaceBtwItems),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildImageSourceOption(
icon: Icons.camera_alt,
label: 'Camera',
onTap: () {
controller.pickIdCardImage(ImageSource.camera);
Get.back();
},
),
_buildImageSourceOption(
icon: Icons.image,
label: 'Gallery',
onTap: () {
controller.pickIdCardImage(ImageSource.gallery);
Get.back();
},
),
],
),
],
),
),
),
);
}
Widget _buildImageSourceOption({
required IconData icon,
required String label,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Column(
children: [
Container(
padding: const EdgeInsets.all(TSizes.md),
decoration: BoxDecoration(
color: TColors.primary.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(icon, color: TColors.primary, size: TSizes.iconLg),
),
const SizedBox(height: TSizes.sm),
Text(label),
],
),
);
}
}

View File

@ -0,0 +1,587 @@
import 'package:calendar_date_picker2/calendar_date_picker2.dart';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:intl/intl.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/image_verification_controller.dart';
import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/city_selection.dart';
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
import 'package:sigap/src/utils/constants/colors.dart';
import 'package:sigap/src/utils/constants/sizes.dart';
import 'package:sigap/src/utils/validators/validation.dart';
class IdentityVerificationStep extends StatelessWidget {
const IdentityVerificationStep({super.key});
@override
Widget build(BuildContext context) {
final controller = Get.find<IdentityVerificationController>();
final ImageVerificationController imageController;
try {
imageController = Get.find<ImageVerificationController>();
} catch (e) {
// Handle the case when ImageVerificationController is not registered yet
// Use a local variable or default behavior
}
final mainController = Get.find<FormRegistrationController>();
final isOfficer = mainController.selectedRole.value?.isOfficer ?? false;
final String idCardType = isOfficer ? 'KTA' : 'KTP';
return Form(
key: controller.formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Additional Information',
style: TextStyle(
fontSize: TSizes.fontSizeLg,
fontWeight: FontWeight.bold,
color: TColors.textPrimary,
),
),
const SizedBox(height: TSizes.sm),
Text(
'Please provide additional personal details',
style: TextStyle(
fontSize: TSizes.fontSizeSm,
color: TColors.textSecondary,
),
),
const SizedBox(height: TSizes.spaceBtwItems),
// Different fields based on role
if (!isOfficer) ...[
// NIK field for viewers
Obx(
() => CustomTextField(
label: 'NIK (Identity Number)',
controller: controller.nikController,
validator:
(value) => TValidators.validateUserInput('NIK', value, 16),
errorText: controller.nikError.value,
textInputAction: TextInputAction.next,
keyboardType: TextInputType.number,
hintText: 'e.g., 1234567890123456',
onChanged: (value) {
controller.nikController.text = value;
controller.nikError.value = '';
},
),
),
// Full Name field
Obx(
() => CustomTextField(
label: 'Full Name',
controller: controller.fullNameController,
validator:
(value) =>
TValidators.validateUserInput('Full Name', value, 100),
errorText: controller.fullNameError.value,
textInputAction: TextInputAction.next,
hintText: 'Enter your full name as on KTP',
onChanged: (value) {
controller.fullNameController.text = value;
controller.fullNameError.value = '';
},
),
),
// Place of Birth field with city selection
Obx(() => _buildPlaceOfBirthField(context, controller)),
],
// Birth Date field with calendar picker
Obx(() => _buildBirthDatePicker(context, controller)),
// Gender selection
Obx(() => _buildGenderSelection(context, controller)),
// Address field
Obx(
() => CustomTextField(
label: 'Address',
controller: controller.addressController,
validator:
(value) =>
TValidators.validateUserInput('Address', value, 255),
errorText: controller.addressError.value,
textInputAction: TextInputAction.next,
hintText: 'Enter your address as on KTP',
maxLines: 3,
onChanged: (value) {
controller.addressController.text = value;
controller.addressError.value = '';
},
),
),
// Verification Status
Obx(
() =>
controller.isVerifying.value
? const Padding(
padding: EdgeInsets.symmetric(
vertical: TSizes.spaceBtwItems,
),
child: Center(
child: Column(
children: [
CircularProgressIndicator(),
SizedBox(height: TSizes.sm),
Text('Processing your personal information...'),
],
),
),
)
: const SizedBox.shrink(),
),
// Verification Message
Obx(
() =>
controller.verificationMessage.value.isNotEmpty
? Padding(
padding: const EdgeInsets.symmetric(
vertical: TSizes.spaceBtwItems,
),
child: Container(
padding: const EdgeInsets.all(TSizes.md),
decoration: BoxDecoration(
color:
controller.isVerified.value
? Colors.green.withOpacity(0.1)
: Colors.red.withOpacity(0.1),
borderRadius: BorderRadius.circular(
TSizes.borderRadiusSm,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: Icon(
controller.isVerified.value
? Icons.check_circle
: Icons.error,
color:
controller.isVerified.value
? Colors.green
: Colors.red,
),
),
const SizedBox(width: TSizes.sm),
Expanded(
child: Text(
controller.verificationMessage.value,
style: TextStyle(
color:
controller.isVerified.value
? Colors.green
: Colors.red,
),
),
),
],
),
),
)
: const SizedBox.shrink(),
),
const SizedBox(height: TSizes.spaceBtwSections),
// Data verification button
ElevatedButton.icon(
onPressed:
controller.isVerifying.value
? null
: () => controller.verifyIdCardWithOCR(),
icon: const Icon(Icons.verified_user),
label: Text(
controller.isVerified.value
? 'Re-verify Personal Information'
: 'Verify Personal Information',
),
style: ElevatedButton.styleFrom(
backgroundColor: TColors.primary,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 50),
),
),
const SizedBox(height: TSizes.spaceBtwItems),
// Face verification section
Text(
'Face Verification',
style: TextStyle(
fontSize: TSizes.fontSizeLg,
fontWeight: FontWeight.bold,
color: TColors.textPrimary,
),
),
const SizedBox(height: TSizes.sm),
Text(
'Verify that your face matches with your ID card photo',
style: TextStyle(
fontSize: TSizes.fontSizeSm,
color: TColors.textSecondary,
),
),
const SizedBox(height: TSizes.spaceBtwItems),
// Face Verification Status
Obx(
() =>
controller.isVerifyingFace.value
? const Padding(
padding: EdgeInsets.symmetric(
vertical: TSizes.spaceBtwItems,
),
child: Center(
child: Column(
children: [
CircularProgressIndicator(),
SizedBox(height: TSizes.sm),
Text('Comparing face with ID photo...'),
],
),
),
)
: const SizedBox.shrink(),
),
// Face match verification button
ElevatedButton.icon(
onPressed:
controller.isVerifyingFace.value ||
controller.isFaceVerified.value
? null
: () => controller.verifyFaceMatch(),
icon: const Icon(Icons.face_retouching_natural),
label: Text(
controller.isFaceVerified.value
? 'Face Verified Successfully'
: 'Verify Face Match',
),
style: ElevatedButton.styleFrom(
backgroundColor:
controller.isFaceVerified.value
? Colors.green
: TColors.primary,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 50),
disabledBackgroundColor:
controller.isFaceVerified.value
? Colors.green.withOpacity(0.7)
: null,
),
),
// Face Verification Message
Obx(
() =>
controller.faceVerificationMessage.value.isNotEmpty
? Padding(
padding: const EdgeInsets.symmetric(
vertical: TSizes.spaceBtwItems,
),
child: Container(
padding: const EdgeInsets.all(TSizes.md),
decoration: BoxDecoration(
color:
controller.isFaceVerified.value
? Colors.green.withOpacity(0.1)
: Colors.red.withOpacity(0.1),
borderRadius: BorderRadius.circular(
TSizes.borderRadiusSm,
),
),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: Icon(
controller.isFaceVerified.value
? Icons.check_circle
: Icons.error,
color:
controller.isFaceVerified.value
? Colors.green
: Colors.red,
),
),
const SizedBox(width: TSizes.sm),
Expanded(
child: Text(
controller.faceVerificationMessage.value,
style: TextStyle(
color:
controller.isFaceVerified.value
? Colors.green
: Colors.red,
),
),
),
],
),
),
)
: const SizedBox.shrink(),
),
],
),
);
}
Widget _buildPlaceOfBirthField(
BuildContext context,
IdentityVerificationController controller,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Place of Birth', style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: TSizes.xs),
GestureDetector(
onTap: () => _navigateToCitySelection(context, controller),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: TSizes.md,
vertical: TSizes.md,
),
decoration: BoxDecoration(
border: Border.all(
color:
controller.placeOfBirthError.value.isNotEmpty
? TColors.error
: TColors.textSecondary,
),
borderRadius: BorderRadius.circular(TSizes.borderRadiusLg),
color: Colors.transparent,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
controller.placeOfBirthController.text.isEmpty
? 'Select Place of Birth'
: controller.placeOfBirthController.text,
style: TextStyle(
color:
controller.placeOfBirthController.text.isEmpty
? Theme.of(context).textTheme.bodyMedium?.color
: TColors.textSecondary,
),
),
Icon(
Icons.location_city,
size: TSizes.iconSm,
color: Theme.of(context).textTheme.bodyMedium?.color,
),
],
),
),
),
if (controller.placeOfBirthError.value.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: TSizes.xs, left: TSizes.xs),
child: Text(
controller.placeOfBirthError.value,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: TColors.error,
fontSize: 12,
),
),
),
const SizedBox(height: TSizes.spaceBtwItems),
],
);
}
void _navigateToCitySelection(
BuildContext context,
IdentityVerificationController controller,
) async {
final selectedCity = await Get.to<String>(() => const CitySelectionPage());
if (selectedCity != null && selectedCity.isNotEmpty) {
controller.placeOfBirthController.text = selectedCity;
controller.placeOfBirthError.value = '';
}
}
Widget _buildGenderSelection(
BuildContext context,
IdentityVerificationController controller,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Gender', style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: TSizes.xs),
Row(
children: [
Expanded(
child: RadioListTile<String>(
title: const Text('Male'),
value: 'Male',
groupValue: controller.selectedGender.value,
onChanged: (value) {
controller.selectedGender.value = value!;
controller.genderError.value = '';
},
activeColor: TColors.primary,
contentPadding: EdgeInsets.zero,
dense: true,
),
),
Expanded(
child: RadioListTile<String>(
title: const Text('Female'),
value: 'Female',
groupValue: controller.selectedGender.value,
onChanged: (value) {
controller.selectedGender.value = value!;
controller.genderError.value = '';
},
activeColor: TColors.primary,
contentPadding: EdgeInsets.zero,
dense: true,
),
),
],
),
if (controller.genderError.value.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: TSizes.xs, left: TSizes.xs),
child: Text(
controller.genderError.value,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: TColors.error,
fontSize: 12,
),
),
),
const SizedBox(height: TSizes.spaceBtwItems),
],
);
}
Widget _buildBirthDatePicker(
BuildContext context,
IdentityVerificationController controller,
) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text('Birth Date', style: Theme.of(context).textTheme.bodyMedium),
const SizedBox(height: TSizes.xs),
GestureDetector(
onTap: () => _showDatePicker(context, controller),
child: Container(
padding: const EdgeInsets.symmetric(
horizontal: TSizes.md,
vertical: TSizes.md,
),
decoration: BoxDecoration(
border: Border.all(
color:
controller.birthDateError.value.isNotEmpty
? TColors.error
: TColors.textSecondary,
),
borderRadius: BorderRadius.circular(TSizes.borderRadiusLg),
color: Colors.transparent,
),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Text(
controller.birthDateController.text.isEmpty
? 'Select Birth Date'
: controller.birthDateController.text,
style: TextStyle(
color:
controller.birthDateController.text.isEmpty
? Theme.of(context).textTheme.bodyMedium?.color
: TColors.textSecondary,
),
),
Icon(
Icons.calendar_today,
size: TSizes.iconSm,
color: Theme.of(context).textTheme.bodyMedium?.color,
),
],
),
),
),
if (controller.birthDateError.value.isNotEmpty)
Padding(
padding: const EdgeInsets.only(top: TSizes.xs, left: TSizes.xs),
child: Text(
controller.birthDateError.value,
style: Theme.of(context).textTheme.bodySmall?.copyWith(
color: TColors.error,
fontSize: 12,
),
),
),
const SizedBox(height: TSizes.spaceBtwItems),
],
);
}
void _showDatePicker(
BuildContext context,
IdentityVerificationController controller,
) async {
final results = await showCalendarDatePicker2Dialog(
context: context,
config: CalendarDatePicker2WithActionButtonsConfig(
calendarType: CalendarDatePicker2Type.single,
selectedDayHighlightColor: TColors.primary,
lastDate: DateTime.now(), // Can't select future dates
firstDate: DateTime(1900), // Reasonable minimum birth year
),
dialogSize: const Size(325, 400),
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
value:
controller.birthDateController.text.isNotEmpty
? [_parseDate(controller.birthDateController.text)]
: <DateTime?>[],
);
if (results != null && results.isNotEmpty && results[0] != null) {
final selectedDate = results[0]!;
final formattedDate = DateFormat('yyyy-MM-dd').format(selectedDate);
controller.birthDateController.text = formattedDate;
controller.birthDateError.value = '';
}
}
DateTime? _parseDate(String dateStr) {
try {
return DateFormat('yyyy-MM-dd').parse(dateStr);
} catch (e) {
return null;
}
}
}

View File

@ -0,0 +1,856 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/image_verification_controller.dart';
import 'package:sigap/src/utils/constants/colors.dart';
import 'package:sigap/src/utils/constants/sizes.dart';
class ImageVerificationStep extends StatelessWidget {
const ImageVerificationStep({super.key});
@override
Widget build(BuildContext context) {
final controller = Get.find<ImageVerificationController>();
final mainController = Get.find<FormRegistrationController>();
final isOfficer = mainController.selectedRole.value?.isOfficer ?? false;
final String idCardType = isOfficer ? 'KTA' : 'KTP';
return Form(
key: controller.formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Identity Document Verification',
style: TextStyle(
fontSize: TSizes.fontSizeLg,
fontWeight: FontWeight.bold,
color: TColors.textPrimary,
),
),
const SizedBox(height: TSizes.sm),
Text(
'Please upload your identity documents for verification',
style: TextStyle(
fontSize: TSizes.fontSizeSm,
color: TColors.textSecondary,
),
),
const SizedBox(height: TSizes.spaceBtwItems),
// ID Card Upload Section
Text(
'$idCardType Upload',
style: TextStyle(
fontSize: TSizes.fontSizeMd,
fontWeight: FontWeight.bold,
color: TColors.textPrimary,
),
),
const SizedBox(height: TSizes.sm),
Text(
'Upload a clear image of your $idCardType',
style: TextStyle(
fontSize: TSizes.fontSizeSm,
color: TColors.textSecondary,
),
),
const SizedBox(height: TSizes.xs),
Text(
'Make sure all text and your photo are clearly visible',
style: TextStyle(
fontSize: TSizes.fontSizeXs,
fontStyle: FontStyle.italic,
color: TColors.textSecondary,
),
),
const SizedBox(height: TSizes.spaceBtwItems),
// ID Card Upload Widget
_buildIdCardUploader(controller, isOfficer),
// Verification Status for ID Card
Obx(
() =>
controller.isVerifying.value &&
!controller.isUploadingIdCard.value
? const Padding(
padding: EdgeInsets.symmetric(
vertical: TSizes.spaceBtwItems,
),
child: Center(
child: Column(
children: [
CircularProgressIndicator(),
SizedBox(height: TSizes.sm),
Text('Validating your ID card...'),
],
),
),
)
: const SizedBox.shrink(),
),
// Verification Message for ID Card
Obx(
() =>
controller.idCardValidationMessage.value.isNotEmpty
? Padding(
padding: const EdgeInsets.symmetric(
vertical: TSizes.spaceBtwItems,
),
child: Container(
padding: const EdgeInsets.all(TSizes.md),
decoration: BoxDecoration(
color:
controller.isIdCardValid.value
? Colors.green.withOpacity(0.1)
: Colors.red.withOpacity(0.1),
borderRadius: BorderRadius.circular(
TSizes.borderRadiusSm,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: Icon(
controller.isIdCardValid.value
? Icons.check_circle
: Icons.error,
color:
controller.isIdCardValid.value
? Colors.green
: Colors.red,
),
),
const SizedBox(width: TSizes.sm),
Expanded(
child: Text(
controller.idCardValidationMessage.value,
style: TextStyle(
color:
controller.isIdCardValid.value
? Colors.green
: Colors.red,
),
),
),
],
),
if (controller.isIdCardValid.value &&
!controller.hasConfirmedIdCard.value) ...[
const SizedBox(height: TSizes.md),
Row(
children: [
Expanded(
child: ElevatedButton(
onPressed:
() => controller.confirmIdCardImage(),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
),
child: const Text('Confirm Image'),
),
),
const SizedBox(width: TSizes.sm),
Expanded(
child: TextButton(
onPressed:
() => controller.clearIdCardImage(),
child: const Text('Try Another Image'),
),
),
],
),
],
if (controller.hasConfirmedIdCard.value)
const Padding(
padding: EdgeInsets.only(top: TSizes.sm),
child: Row(
children: [
Icon(
Icons.check_circle,
color: Colors.green,
size: TSizes.iconSm,
),
SizedBox(width: TSizes.xs),
Text(
'Image confirmed',
style: TextStyle(
color: Colors.green,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
),
)
: const SizedBox.shrink(),
),
const SizedBox(height: TSizes.spaceBtwSections),
// Selfie Upload Section
Text(
'Selfie Upload',
style: TextStyle(
fontSize: TSizes.fontSizeMd,
fontWeight: FontWeight.bold,
color: TColors.textPrimary,
),
),
const SizedBox(height: TSizes.sm),
Text(
'Take a clear selfie for identity verification',
style: TextStyle(
fontSize: TSizes.fontSizeSm,
color: TColors.textSecondary,
),
),
const SizedBox(height: TSizes.xs),
Text(
'Make sure your face is well-lit and clearly visible',
style: TextStyle(
fontSize: TSizes.fontSizeXs,
fontStyle: FontStyle.italic,
color: TColors.textSecondary,
),
),
const SizedBox(height: TSizes.spaceBtwItems),
// Selfie Upload Widget
_buildSelfieUploader(controller),
// Verification Status for Selfie
Obx(
() =>
controller.isVerifyingFace.value &&
!controller.isUploadingSelfie.value
? const Padding(
padding: EdgeInsets.symmetric(
vertical: TSizes.spaceBtwItems,
),
child: Center(
child: Column(
children: [
CircularProgressIndicator(),
SizedBox(height: TSizes.sm),
Text('Validating your selfie...'),
],
),
),
)
: const SizedBox.shrink(),
),
// Verification Message for Selfie
Obx(
() =>
controller.selfieValidationMessage.value.isNotEmpty
? Padding(
padding: const EdgeInsets.symmetric(
vertical: TSizes.spaceBtwItems,
),
child: Container(
padding: const EdgeInsets.all(TSizes.md),
decoration: BoxDecoration(
color:
controller.isSelfieValid.value
? Colors.green.withOpacity(0.1)
: Colors.red.withOpacity(0.1),
borderRadius: BorderRadius.circular(
TSizes.borderRadiusSm,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: Icon(
controller.isSelfieValid.value
? Icons.check_circle
: Icons.error,
color:
controller.isSelfieValid.value
? Colors.green
: Colors.red,
),
),
const SizedBox(width: TSizes.sm),
Expanded(
child: Text(
controller.selfieValidationMessage.value,
style: TextStyle(
color:
controller.isSelfieValid.value
? Colors.green
: Colors.red,
),
),
),
],
),
if (controller.isSelfieValid.value &&
!controller.hasConfirmedSelfie.value) ...[
const SizedBox(height: TSizes.md),
Row(
children: [
Expanded(
child: ElevatedButton(
onPressed:
() => controller.confirmSelfieImage(),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
),
child: const Text('Confirm Image'),
),
),
const SizedBox(width: TSizes.sm),
Expanded(
child: TextButton(
onPressed:
() => controller.clearSelfieImage(),
child: const Text('Try Another Image'),
),
),
],
),
],
if (controller.hasConfirmedSelfie.value)
const Padding(
padding: EdgeInsets.only(top: TSizes.sm),
child: Row(
children: [
Icon(
Icons.check_circle,
color: Colors.green,
size: TSizes.iconSm,
),
SizedBox(width: TSizes.xs),
Text(
'Image confirmed',
style: TextStyle(
color: Colors.green,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
),
)
: const SizedBox.shrink(),
),
// Error Messages
Obx(() {
if (controller.idCardError.value.isNotEmpty) {
return Padding(
padding: const EdgeInsets.only(top: TSizes.sm),
child: Text(
controller.idCardError.value,
style: const TextStyle(color: Colors.red),
),
);
}
if (controller.selfieError.value.isNotEmpty) {
return Padding(
padding: const EdgeInsets.only(top: TSizes.sm),
child: Text(
controller.selfieError.value,
style: const TextStyle(color: Colors.red),
),
);
}
return const SizedBox.shrink();
}),
],
),
);
}
Widget _buildIdCardUploader(
ImageVerificationController controller,
bool isOfficer,
) {
final String idCardType = isOfficer ? 'KTA' : 'KTP';
return Obx(() {
// Determine border color based on error state or confirmation
final borderColor =
controller.idCardError.value.isNotEmpty
? TColors.error
: controller.hasConfirmedIdCard.value
? Colors.green
: Colors.grey.withOpacity(0.5);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (controller.idCardImage.value == null)
GestureDetector(
onTap: () => _showImageSourceDialog(controller, true),
child: Container(
height: 180,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
border: Border.all(
color:
controller.idCardError.value.isNotEmpty
? TColors.error
: Colors.grey.withOpacity(0.5),
),
),
child:
controller.isUploadingIdCard.value
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: TSizes.sm),
Text(
'Uploading...',
style: TextStyle(color: TColors.textSecondary),
),
],
),
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.add_a_photo,
size: TSizes.iconLg,
color: Colors.grey,
),
const SizedBox(height: TSizes.sm),
Text(
'Upload $idCardType Image',
style: TextStyle(
color: TColors.textSecondary,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: TSizes.xs),
Text(
'Tap to select an image',
style: TextStyle(
fontSize: TSizes.fontSizeSm,
color: TColors.textSecondary,
),
),
],
),
),
)
else
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
alignment: Alignment.topRight,
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
TSizes.borderRadiusMd,
),
border: Border.all(color: borderColor, width: 2),
),
child: Stack(
alignment: Alignment.center,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(
TSizes.borderRadiusMd,
),
child: Image.file(
File(controller.idCardImage.value!.path),
height: 200,
width: double.infinity,
fit: BoxFit.cover,
),
),
// Loading overlay
if (controller.isUploadingIdCard.value ||
controller.isVerifying.value)
Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
TSizes.borderRadiusMd,
),
color: Colors.black.withOpacity(0.5),
),
child: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
color: Colors.white,
),
SizedBox(height: TSizes.sm),
Text(
'Processing...',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
),
),
if (!controller.hasConfirmedIdCard.value &&
!controller.isUploadingIdCard.value &&
!controller.isVerifying.value)
Positioned(
top: 8,
right: 8,
child: GestureDetector(
onTap: () => controller.clearIdCardImage(),
child: Container(
padding: const EdgeInsets.all(TSizes.xs),
decoration: const BoxDecoration(
color: Colors.black54,
shape: BoxShape.circle,
),
child: const Icon(
Icons.close,
color: Colors.white,
size: TSizes.iconSm,
),
),
),
),
],
),
const SizedBox(height: TSizes.sm),
if (!controller.isIdCardValid.value &&
!controller.isVerifying.value &&
!controller.isUploadingIdCard.value)
ElevatedButton.icon(
onPressed: () => controller.validateIdCardImage(),
icon: const Icon(Icons.check_circle),
label: Text('Check $idCardType Validity'),
style: ElevatedButton.styleFrom(
backgroundColor: TColors.primary,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 50),
),
),
],
),
],
);
});
}
Widget _buildSelfieUploader(ImageVerificationController controller) {
return Obx(() {
// Determine border color based on error state or confirmation
final borderColor =
controller.selfieError.value.isNotEmpty
? TColors.error
: controller.hasConfirmedSelfie.value
? Colors.green
: Colors.grey.withOpacity(0.5);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (controller.selfieImage.value == null)
GestureDetector(
onTap: () => _showSelfieDialog(controller),
child: Container(
height: 180,
width: double.infinity,
decoration: BoxDecoration(
color: Colors.grey.withOpacity(0.1),
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
border: Border.all(
color:
controller.selfieError.value.isNotEmpty
? TColors.error
: Colors.grey.withOpacity(0.5),
),
),
child:
controller.isUploadingSelfie.value
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: TSizes.sm),
Text(
'Uploading...',
style: TextStyle(color: TColors.textSecondary),
),
],
),
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.face,
size: TSizes.iconLg,
color: Colors.grey,
),
const SizedBox(height: TSizes.sm),
Text(
'Take a Selfie',
style: TextStyle(
color: TColors.textSecondary,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: TSizes.xs),
Text(
'Tap to open camera',
style: TextStyle(
fontSize: TSizes.fontSizeSm,
color: TColors.textSecondary,
),
),
],
),
),
)
else
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
alignment: Alignment.topRight,
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
TSizes.borderRadiusMd,
),
border: Border.all(color: borderColor, width: 2),
),
child: Stack(
alignment: Alignment.center,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(
TSizes.borderRadiusMd,
),
child: Image.file(
File(controller.selfieImage.value!.path),
height: 200,
width: double.infinity,
fit: BoxFit.cover,
),
),
// Loading overlay
if (controller.isUploadingSelfie.value ||
controller.isVerifyingFace.value)
Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
TSizes.borderRadiusMd,
),
color: Colors.black.withOpacity(0.5),
),
child: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
color: Colors.white,
),
SizedBox(height: TSizes.sm),
Text(
'Processing...',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
),
),
if (!controller.hasConfirmedSelfie.value &&
!controller.isUploadingSelfie.value &&
!controller.isVerifyingFace.value)
Positioned(
top: 8,
right: 8,
child: GestureDetector(
onTap: () => controller.clearSelfieImage(),
child: Container(
padding: const EdgeInsets.all(TSizes.xs),
decoration: const BoxDecoration(
color: Colors.black54,
shape: BoxShape.circle,
),
child: const Icon(
Icons.close,
color: Colors.white,
size: TSizes.iconSm,
),
),
),
),
],
),
const SizedBox(height: TSizes.sm),
if (!controller.isSelfieValid.value &&
!controller.isVerifyingFace.value &&
!controller.isUploadingSelfie.value)
ElevatedButton.icon(
onPressed: () => controller.validateSelfieImage(),
icon: const Icon(Icons.check_circle),
label: const Text('Check Selfie Validity'),
style: ElevatedButton.styleFrom(
backgroundColor: TColors.primary,
foregroundColor: Colors.white,
minimumSize: const Size(double.infinity, 50),
),
),
],
),
],
);
});
}
void _showImageSourceDialog(
ImageVerificationController controller,
bool isIdCard,
) {
final mainController = Get.find<FormRegistrationController>();
final String title =
isIdCard
? 'Select ${mainController.selectedRole.value?.isOfficer ?? false ? "KTA" : "KTP"} Image Source'
: 'Select Selfie Source';
final String message =
isIdCard
? 'Please ensure your ID card is clear, well-lit, and all text is readable'
: 'Please ensure your face is clearly visible and well-lit';
Get.dialog(
Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
),
child: Padding(
padding: const EdgeInsets.all(TSizes.defaultSpace),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Text(
title,
style: TextStyle(
fontSize: TSizes.fontSizeLg,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: TSizes.md),
Text(
message,
textAlign: TextAlign.center,
style: TextStyle(
fontSize: TSizes.fontSizeSm,
color: TColors.textSecondary,
),
),
const SizedBox(height: TSizes.spaceBtwItems),
Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: [
_buildImageSourceOption(
icon: Icons.camera_alt,
label: 'Camera',
onTap: () {
if (isIdCard) {
controller.pickIdCardImage(ImageSource.camera);
} else {
controller.pickSelfieImage(ImageSource.camera);
}
Get.back();
},
),
if (isIdCard) // Only show gallery option for ID card
_buildImageSourceOption(
icon: Icons.image,
label: 'Gallery',
onTap: () {
controller.pickIdCardImage(ImageSource.gallery);
Get.back();
},
),
],
),
],
),
),
),
);
}
void _showSelfieDialog(ImageVerificationController controller) {
controller.pickSelfieImage(ImageSource.camera);
}
Widget _buildImageSourceOption({
required IconData icon,
required String label,
required VoidCallback onTap,
}) {
return GestureDetector(
onTap: onTap,
child: Column(
children: [
Container(
padding: const EdgeInsets.all(TSizes.md),
decoration: BoxDecoration(
color: TColors.primary.withOpacity(0.1),
shape: BoxShape.circle,
),
child: Icon(icon, color: TColors.primary, size: TSizes.iconLg),
),
const SizedBox(height: TSizes.sm),
Text(label),
],
),
);
}
}

View File

@ -0,0 +1,75 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/officer_info_controller.dart';
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
import 'package:sigap/src/utils/constants/colors.dart';
import 'package:sigap/src/utils/constants/sizes.dart';
import 'package:sigap/src/utils/validators/validation.dart';
class OfficerInfoStep extends StatelessWidget {
const OfficerInfoStep({super.key});
@override
Widget build(BuildContext context) {
final controller = Get.find<OfficerInfoController>();
return Form(
key: controller.formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Officer Information',
style: TextStyle(
fontSize: TSizes.fontSizeLg,
fontWeight: FontWeight.bold,
color: TColors.textPrimary,
),
),
const SizedBox(height: TSizes.sm),
Text(
'Please provide your officer details',
style: TextStyle(
fontSize: TSizes.fontSizeSm,
color: TColors.textSecondary,
),
),
const SizedBox(height: TSizes.spaceBtwItems),
// NRP field
Obx(
() => CustomTextField(
label: 'NRP',
controller: controller.nrpController,
validator: TValidators.validateNRP,
errorText: controller.nrpError.value,
textInputAction: TextInputAction.next,
keyboardType: TextInputType.number,
hintText: 'e.g., 123456789',
onChanged: (value) {
controller.nrpController.text = value;
controller.nrpError.value = '';
},
),
),
// Rank field
Obx(
() => CustomTextField(
label: 'Rank',
controller: controller.rankController,
validator: TValidators.validateRank,
errorText: controller.rankError.value,
textInputAction: TextInputAction.done,
hintText: 'e.g., Captain',
onChanged: (value) {
controller.rankController.text = value;
controller.rankError.value = '';
},
),
),
],
),
);
}
}

View File

@ -0,0 +1,141 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/personal_info_controller.dart';
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
import 'package:sigap/src/utils/constants/colors.dart';
import 'package:sigap/src/utils/constants/sizes.dart';
import 'package:sigap/src/utils/validators/validation.dart';
class PersonalInfoStep extends StatelessWidget {
const PersonalInfoStep({super.key});
@override
Widget build(BuildContext context) {
final controller = Get.find<PersonalInfoController>();
return Form(
key: controller.formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Personal Information',
style: TextStyle(
fontSize: TSizes.fontSizeLg,
fontWeight: FontWeight.bold,
color: TColors.textPrimary,
),
),
const SizedBox(height: TSizes.sm),
Text(
'Please provide your personal details',
style: TextStyle(
fontSize: TSizes.fontSizeSm,
color: TColors.textSecondary,
),
),
const SizedBox(height: TSizes.spaceBtwItems),
// First Name field
Obx(
() => CustomTextField(
label: 'First Name',
controller: controller.firstNameController,
validator:
(value) =>
TValidators.validateUserInput('First name', value, 50),
errorText: controller.firstNameError.value,
textInputAction: TextInputAction.next,
hintText: 'e.g., John',
onChanged: (value) {
controller.firstNameController.text = value;
controller.firstNameError.value = '';
},
),
),
// Last Name field
Obx(
() => CustomTextField(
label: 'Last Name',
controller: controller.lastNameController,
validator:
(value) => TValidators.validateUserInput(
'Last name',
value,
50,
required: true,
),
errorText: controller.lastNameError.value,
textInputAction: TextInputAction.next,
hintText: 'e.g., Doe',
onChanged: (value) {
controller.lastNameController.text = value;
controller.lastNameError.value = '';
},
),
),
// Phone field
Obx(
() => CustomTextField(
label: 'Phone Number',
controller: controller.phoneController,
validator: TValidators.validatePhoneNumber,
errorText: controller.phoneError.value,
keyboardType: TextInputType.phone,
textInputAction: TextInputAction.next,
hintText: 'e.g., 081234567890',
onChanged: (value) {
controller.phoneController.text = value;
controller.phoneError.value = '';
},
),
),
// Address field
Obx(
() => CustomTextField(
label: 'Address',
controller: controller.addressController,
validator:
(value) =>
TValidators.validateUserInput('Address', value, 255),
errorText: controller.addressError.value,
textInputAction: TextInputAction.done,
maxLines: 3,
hintText: 'e.g., 123 Main St, City, Country',
onChanged: (value) {
controller.addressController.text = value;
controller.addressError.value = '';
},
),
),
// Bio field
Obx(
() => CustomTextField(
label: 'Bio',
controller: controller.bioController,
validator:
(value) => TValidators.validateUserInput(
'Bio',
value,
255,
required: false,
),
errorText: controller.bioError.value,
textInputAction: TextInputAction.next,
maxLines: 3,
hintText: 'Tell us a little about yourself (optional)',
onChanged: (value) {
controller.bioController.text = value;
controller.bioError.value = '';
},
),
),
],
),
);
}
}

View File

@ -0,0 +1,530 @@
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:image_picker/image_picker.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/selfie_verification_controller.dart';
import 'package:sigap/src/utils/constants/colors.dart';
import 'package:sigap/src/utils/constants/sizes.dart';
class SelfieVerificationStep extends StatelessWidget {
const SelfieVerificationStep({super.key});
@override
Widget build(BuildContext context) {
final controller = Get.find<SelfieVerificationController>();
return Form(
key: controller.formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Selfie Verification',
style: TextStyle(
fontSize: TSizes.fontSizeLg,
fontWeight: FontWeight.bold,
color: TColors.textPrimary,
),
),
const SizedBox(height: TSizes.sm),
Text(
'Take a clear selfie for identity verification',
style: TextStyle(
fontSize: TSizes.fontSizeSm,
color: TColors.textSecondary,
),
),
const SizedBox(height: TSizes.xs),
Text(
'Make sure your face is well-lit and clearly visible',
style: TextStyle(
fontSize: TSizes.fontSizeXs,
fontStyle: FontStyle.italic,
color: TColors.textSecondary,
),
),
const SizedBox(height: TSizes.spaceBtwItems),
// Selfie Upload Widget
_buildSelfieUploader(controller),
// Verification Status for Selfie
Obx(
() =>
controller.isVerifyingFace.value &&
!controller.isUploadingSelfie.value
? const Padding(
padding: EdgeInsets.symmetric(
vertical: TSizes.spaceBtwItems,
),
child: Center(
child: Column(
children: [
CircularProgressIndicator(),
SizedBox(height: TSizes.sm),
Text('Validating your selfie...'),
],
),
),
)
: const SizedBox.shrink(),
),
// Verification Message for Selfie
Obx(
() =>
controller.selfieValidationMessage.value.isNotEmpty
? Padding(
padding: const EdgeInsets.symmetric(
vertical: TSizes.spaceBtwItems,
),
child: Container(
padding: const EdgeInsets.all(TSizes.md),
decoration: BoxDecoration(
color:
controller.isSelfieValid.value
? Colors.green.withOpacity(0.1)
: Colors.red.withOpacity(0.1),
borderRadius: BorderRadius.circular(
TSizes.borderRadiusSm,
),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(top: 2),
child: Icon(
controller.isSelfieValid.value
? Icons.check_circle
: Icons.error,
color:
controller.isSelfieValid.value
? Colors.green
: Colors.red,
),
),
const SizedBox(width: TSizes.sm),
Expanded(
child: Text(
controller.selfieValidationMessage.value,
style: TextStyle(
color:
controller.isSelfieValid.value
? Colors.green
: Colors.red,
),
),
),
],
),
if (controller.isSelfieValid.value &&
!controller.hasConfirmedSelfie.value) ...[
const SizedBox(height: TSizes.md),
Row(
children: [
Expanded(
child: ElevatedButton(
onPressed:
() => controller.confirmSelfieImage(),
style: ElevatedButton.styleFrom(
backgroundColor: Colors.green,
foregroundColor: Colors.white,
),
child: const Text('Confirm Image'),
),
),
const SizedBox(width: TSizes.sm),
Expanded(
child: TextButton(
onPressed:
() => controller.clearSelfieImage(),
child: const Text('Try Another Image'),
),
),
],
),
],
if (controller.hasConfirmedSelfie.value)
const Padding(
padding: EdgeInsets.only(top: TSizes.sm),
child: Row(
children: [
Icon(
Icons.check_circle,
color: Colors.green,
size: TSizes.iconSm,
),
SizedBox(width: TSizes.xs),
Text(
'Image confirmed',
style: TextStyle(
color: Colors.green,
fontWeight: FontWeight.bold,
),
),
],
),
),
],
),
),
)
: const SizedBox.shrink(),
),
// Error Messages
Obx(
() =>
controller.selfieError.value.isNotEmpty
? Padding(
padding: const EdgeInsets.only(top: TSizes.sm),
child: Text(
controller.selfieError.value,
style: const TextStyle(color: Colors.red),
),
)
: const SizedBox.shrink(),
),
const SizedBox(height: TSizes.spaceBtwSections),
// Tips for taking a good selfie
Container(
padding: const EdgeInsets.all(TSizes.md),
decoration: BoxDecoration(
color: TColors.primary.withOpacity(0.1),
borderRadius: BorderRadius.circular(TSizes.borderRadiusSm),
),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Tips for a Good Selfie:',
style: TextStyle(
fontWeight: FontWeight.bold,
color: TColors.primary,
),
),
const SizedBox(height: TSizes.xs),
_buildTip('Find a well-lit area with even lighting'),
_buildTip('Hold the camera at eye level'),
_buildTip('Look directly at the camera'),
_buildTip('Ensure your entire face is visible'),
_buildTip('Remove glasses and face coverings'),
],
),
),
],
),
);
}
Widget _buildTip(String text) {
return Padding(
padding: const EdgeInsets.only(bottom: TSizes.xs),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Icon(Icons.check_circle, size: TSizes.iconXs, color: TColors.primary),
const SizedBox(width: TSizes.xs),
Expanded(
child: Text(
text,
style: TextStyle(
fontSize: TSizes.fontSizeXs,
color: TColors.textSecondary,
),
),
),
],
),
);
}
Widget _buildSelfieUploader(SelfieVerificationController controller) {
return Obx(() {
// Background color based on error state or confirmation
final backgroundColor =
controller.selfieError.value.isNotEmpty
? TColors.error.withOpacity(0.1)
: controller.hasConfirmedSelfie.value
? Colors.green.withOpacity(0.1)
: Colors.grey.withOpacity(0.1);
// Determine border color based on error state or confirmation
final borderColor =
controller.selfieError.value.isNotEmpty
? TColors.error
: controller.hasConfirmedSelfie.value
? Colors.green
: Colors.grey.withOpacity(0.5);
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (controller.selfieImage.value == null)
GestureDetector(
onTap: () => _captureSelfie(controller),
child: Container(
height: 180,
width: double.infinity,
decoration: BoxDecoration(
color: backgroundColor, // Using the dynamic background color
borderRadius: BorderRadius.circular(TSizes.borderRadiusMd),
border: Border.all(
color: borderColor,
width:
controller.selfieError.value.isNotEmpty
? 2
: 1, // Thicker border for error state
),
),
child:
controller.isUploadingSelfie.value
? Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const CircularProgressIndicator(),
const SizedBox(height: TSizes.sm),
Text(
'Processing...',
style: TextStyle(color: TColors.textSecondary),
),
],
),
)
: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
controller.selfieError.value.isNotEmpty
? Icons.error_outline
: Icons.face,
size: TSizes.iconLg,
color:
controller.selfieError.value.isNotEmpty
? TColors.error
: Colors.grey,
),
const SizedBox(height: TSizes.sm),
Text(
controller.selfieError.value.isNotEmpty
? 'Error: Please try again'
: 'Take a Selfie',
style: TextStyle(
color:
controller.selfieError.value.isNotEmpty
? TColors.error
: TColors.textSecondary,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: TSizes.xs),
Text(
'Tap to open camera',
style: TextStyle(
fontSize: TSizes.fontSizeSm,
color:
controller.selfieError.value.isNotEmpty
? TColors.error.withOpacity(0.8)
: TColors.textSecondary,
),
),
],
),
),
)
else
Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Stack(
alignment: Alignment.topRight,
children: [
Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
TSizes.borderRadiusMd,
),
border: Border.all(color: borderColor, width: 2),
),
child: Stack(
alignment: Alignment.center,
children: [
ClipRRect(
borderRadius: BorderRadius.circular(
TSizes.borderRadiusMd -
2, // Adjust for border width
),
child: Image.file(
File(controller.selfieImage.value!.path),
height: 200,
width: double.infinity,
fit: BoxFit.cover,
),
),
// Error overlay for uploaded image
if (controller.selfieError.value.isNotEmpty &&
!controller.isUploadingSelfie.value &&
!controller.isVerifyingFace.value)
Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
TSizes.borderRadiusMd -
2, // Adjust for border width
),
color: TColors.error.withOpacity(0.2),
),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.error_outline,
color: TColors.error,
size: TSizes.iconLg,
),
const SizedBox(height: TSizes.sm),
Text(
'Invalid Selfie',
style: TextStyle(
color: TColors.error,
fontWeight: FontWeight.bold,
),
),
const SizedBox(height: TSizes.xs),
Padding(
padding: const EdgeInsets.symmetric(
horizontal: TSizes.md,
),
child: Text(
'Please take a clearer selfie',
textAlign: TextAlign.center,
style: TextStyle(
color: TColors.error,
fontSize: TSizes.fontSizeSm,
),
),
),
],
),
),
),
// Loading overlay
if (controller.isUploadingSelfie.value ||
controller.isVerifyingFace.value)
Container(
height: 200,
width: double.infinity,
decoration: BoxDecoration(
borderRadius: BorderRadius.circular(
TSizes.borderRadiusMd -
2, // Adjust for border width
),
color: Colors.black.withOpacity(0.5),
),
child: const Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
CircularProgressIndicator(
color: Colors.white,
),
SizedBox(height: TSizes.sm),
Text(
'Processing...',
style: TextStyle(
color: Colors.white,
fontWeight: FontWeight.bold,
),
),
],
),
),
),
],
),
),
if (!controller.hasConfirmedSelfie.value &&
!controller.isUploadingSelfie.value &&
!controller.isVerifyingFace.value)
Positioned(
top: 8,
right: 8,
child: GestureDetector(
onTap: () => controller.clearSelfieImage(),
child: Container(
padding: const EdgeInsets.all(TSizes.xs),
decoration: BoxDecoration(
color:
controller.selfieError.value.isNotEmpty
? TColors.error
: Colors.black54,
shape: BoxShape.circle,
),
child: const Icon(
Icons.close,
color: Colors.white,
size: TSizes.iconSm,
),
),
),
),
],
),
const SizedBox(height: TSizes.sm),
if (!controller.isSelfieValid.value &&
!controller.isVerifyingFace.value &&
!controller.isUploadingSelfie.value)
Row(
children: [
Expanded(
child: ElevatedButton.icon(
onPressed: () => controller.validateSelfieImage(),
icon: const Icon(Icons.check_circle),
label: const Text('Check Selfie Validity'),
style: ElevatedButton.styleFrom(
backgroundColor: TColors.primary,
foregroundColor: Colors.white,
),
),
),
const SizedBox(width: TSizes.sm),
OutlinedButton.icon(
onPressed: () => _captureSelfie(controller),
icon: const Icon(Icons.refresh),
label: const Text('Retake'),
style: OutlinedButton.styleFrom(
foregroundColor:
controller.selfieError.value.isNotEmpty
? TColors.error
: null,
side: BorderSide(
color:
controller.selfieError.value.isNotEmpty
? TColors.error
: Colors.grey.withOpacity(0.5),
),
),
),
],
),
],
),
],
);
});
}
void _captureSelfie(SelfieVerificationController controller) {
controller.pickSelfieImage(ImageSource.camera);
}
}

View File

@ -0,0 +1,87 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/features/auth/presentasion/controllers/steps/unit_info_controller.dart';
import 'package:sigap/src/shared/widgets/dropdown/custom_dropdown.dart';
import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
import 'package:sigap/src/utils/constants/colors.dart';
import 'package:sigap/src/utils/constants/sizes.dart';
import 'package:sigap/src/utils/validators/validation.dart';
class UnitInfoStep extends StatelessWidget {
const UnitInfoStep({super.key});
@override
Widget build(BuildContext context) {
final controller = Get.find<UnitInfoController>();
return Form(
key: controller.formKey,
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
'Unit Information',
style: TextStyle(
fontSize: TSizes.fontSizeLg,
fontWeight: FontWeight.bold,
color: TColors.textPrimary,
),
),
const SizedBox(height: TSizes.sm),
Text(
'Please provide your unit details',
style: TextStyle(
fontSize: TSizes.fontSizeSm,
color: TColors.textSecondary,
),
),
const SizedBox(height: TSizes.spaceBtwItems),
// Position field
Obx(
() => CustomTextField(
label: 'Position',
controller: controller.positionController,
validator: TValidators.validatePosition,
errorText: controller.positionError.value,
textInputAction: TextInputAction.next,
hintText: 'e.g., Commander',
onChanged: (value) {
controller.positionController.text = value;
controller.positionError.value = '';
},
),
),
// Unit Dropdown
Obx(
() => CustomDropdown(
label: 'Unit',
items:
controller.availableUnits
.map(
(unit) => DropdownMenuItem(
value: unit.codeUnit,
child: Text(unit.name),
),
)
.toList(),
value:
controller.unitIdController.text.isEmpty
? null
: controller.unitIdController.text,
onChanged: (value) {
if (value != null) {
controller.unitIdController.text = value.toString();
controller.unitIdError.value = '';
}
},
validator: (value) => TValidators.validateUnitId(value),
errorText: controller.unitIdError.value,
),
),
],
),
);
}
}

View File

@ -0,0 +1,427 @@
// import 'package:flutter/material.dart';
// import 'package:flutter/services.dart';
// import 'package:get/get.dart';
// import 'package:sigap/src/features/auth/presentasion/controllers/registration_form_controller.dart';
// import 'package:sigap/src/features/auth/presentasion/widgets/auth_button.dart';
// import 'package:sigap/src/features/personalization/data/models/index.dart';
// import 'package:sigap/src/shared/widgets/dropdown/custom_dropdown.dart';
// import 'package:sigap/src/shared/widgets/indicators/step_indicator/index.dart';
// import 'package:sigap/src/shared/widgets/text/custom_text_field.dart';
// import 'package:sigap/src/utils/constants/colors.dart';
// import 'package:sigap/src/utils/validators/validation.dart';
// class FormRegistrationScreen extends StatelessWidget {
// const FormRegistrationScreen({super.key});
// @override
// Widget build(BuildContext context) {
// // Get the controller
// final controller = Get.find<FormRegistrationController>();
// // Set system overlay style
// SystemChrome.setSystemUIOverlayStyle(
// const SystemUiOverlayStyle(
// statusBarColor: Colors.transparent,
// statusBarIconBrightness: Brightness.dark,
// ),
// );
// return Scaffold(
// backgroundColor: TColors.light,
// appBar: AppBar(
// backgroundColor: Colors.transparent,
// elevation: 0,
// title: Obx(
// () => Text(
// 'Complete Your ${controller.selectedRole.value?.name ?? ""} Profile',
// style: TextStyle(
// color: TColors.textPrimary,
// fontWeight: FontWeight.bold,
// ),
// ),
// ),
// centerTitle: true,
// leading: IconButton(
// icon: Icon(Icons.arrow_back, color: TColors.textPrimary),
// onPressed: () => Get.back(),
// ),
// ),
// body: Obx(() {
// // Show loading while initializing
// if (controller.selectedRole.value == null) {
// return const Center(child: CircularProgressIndicator());
// }
// return SafeArea(
// child: Column(
// children: [
// // Step indicator
// Padding(
// padding: const EdgeInsets.all(24.0),
// child: Obx(
// () => StepIndicator(
// currentStep: controller.currentStep.value,
// totalSteps: controller.stepFormKeys.length,
// stepTitles: _getStepTitles(controller.selectedRole.value!),
// onStepTapped: controller.goToStep,
// ),
// ),
// ),
// // Step content
// Expanded(
// child: SingleChildScrollView(
// child: Padding(
// padding: const EdgeInsets.all(24.0),
// child: Obx(() {
// return _buildStepContent(controller);
// }),
// ),
// ),
// ),
// // Navigation buttons
// Padding(
// padding: const EdgeInsets.all(24.0),
// child: Row(
// children: [
// // Back button
// Obx(
// () =>
// controller.currentStep.value > 0
// ? Expanded(
// child: Padding(
// padding: const EdgeInsets.only(right: 8.0),
// child: AuthButton(
// text: 'Previous',
// onPressed: controller.previousStep,
// isPrimary: false,
// ),
// ),
// )
// : const SizedBox.shrink(),
// ),
// // Next/Submit button
// Expanded(
// child: Padding(
// padding: EdgeInsets.only(
// left: controller.currentStep.value > 0 ? 8.0 : 0.0,
// ),
// child: Obx(
// () => AuthButton(
// text:
// controller.currentStep.value ==
// controller.stepFormKeys.length - 1
// ? 'Submit'
// : 'Next',
// onPressed: controller.nextStep,
// isLoading: controller.isLoading.value,
// ),
// ),
// ),
// ),
// ],
// ),
// ),
// ],
// ),
// );
// }),
// );
// }
// List<String> _getStepTitles(RoleModel role) {
// if (role.isOfficer) {
// return ['Personal', 'Officer Info', 'Unit Info'];
// } else {
// return ['Personal', 'Emergency'];
// }
// }
// Widget _buildStepContent(FormRegistrationController controller) {
// final isOfficer = controller.selectedRole.value?.isOfficer ?? false;
// switch (controller.currentStep.value) {
// case 0:
// return _buildPersonalInfoStep(controller);
// case 1:
// return isOfficer
// ? _buildOfficerInfoStep(controller)
// : _buildEmergencyContactStep(controller);
// case 2:
// // This step only exists for officers
// if (isOfficer) {
// return _buildOfficerAdditionalInfoStep(controller);
// }
// return const SizedBox.shrink();
// default:
// return const SizedBox.shrink();
// }
// }
// Widget _buildPersonalInfoStep(FormRegistrationController controller) {
// return Form(
// key: controller.stepFormKeys[0],
// child: Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// Text(
// 'Personal Information',
// style: TextStyle(
// fontSize: 20,
// fontWeight: FontWeight.bold,
// color: TColors.textPrimary,
// ),
// ),
// const SizedBox(height: 8),
// Text(
// 'Please provide your personal details',
// style: TextStyle(fontSize: 14, color: TColors.textSecondary),
// ),
// const SizedBox(height: 24),
// // First Name field
// Obx(
// () => CustomTextField(
// label: 'First Name',
// controller: controller.firstNameController,
// validator:
// (value) =>
// TValidators.validateUserInput('First name', value, 50),
// errorText: controller.firstNameError.value,
// textInputAction: TextInputAction.next,
// ),
// ),
// // Last Name field
// Obx(
// () => CustomTextField(
// label: 'Last Name',
// controller: controller.lastNameController,
// validator:
// (value) => TValidators.validateUserInput(
// 'Last name',
// value,
// 50,
// required: false,
// ),
// errorText: controller.lastNameError.value,
// textInputAction: TextInputAction.next,
// ),
// ),
// // Phone field
// Obx(
// () => CustomTextField(
// label: 'Phone Number',
// controller: controller.phoneController,
// validator: TValidators.validatePhoneNumber,
// errorText: controller.phoneError.value,
// keyboardType: TextInputType.phone,
// textInputAction: TextInputAction.next,
// ),
// ),
// // Address field
// Obx(
// () => CustomTextField(
// label: 'Address',
// controller: controller.addressController,
// validator:
// (value) =>
// TValidators.validateUserInput('Address', value, 255),
// errorText: controller.addressError.value,
// textInputAction: TextInputAction.done,
// maxLines: 3,
// ),
// ),
// ],
// ),
// );
// }
// Widget _buildEmergencyContactStep(FormRegistrationController controller) {
// return Form(
// key: controller.stepFormKeys[1],
// child: Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// Text(
// 'Additional Information',
// style: TextStyle(
// fontSize: 20,
// fontWeight: FontWeight.bold,
// color: TColors.textPrimary,
// ),
// ),
// const SizedBox(height: 8),
// Text(
// 'Please provide additional personal details',
// style: TextStyle(fontSize: 14, color: TColors.textSecondary),
// ),
// const SizedBox(height: 24),
// // NIK field
// Obx(
// () => CustomTextField(
// label: 'NIK (Identity Number)',
// controller: controller.nikController,
// validator:
// (value) => TValidators.validateUserInput('NIK', value, 16),
// errorText: controller.nikError.value,
// textInputAction: TextInputAction.next,
// keyboardType: TextInputType.number,
// ),
// ),
// // Bio field
// Obx(
// () => CustomTextField(
// label: 'Bio',
// controller: controller.bioController,
// validator:
// (value) => TValidators.validateUserInput(
// 'Bio',
// value,
// 255,
// required: false,
// ),
// errorText: controller.bioError.value,
// textInputAction: TextInputAction.next,
// maxLines: 3,
// hintText: 'Tell us a little about yourself (optional)',
// ),
// ),
// // Birth Date field
// Obx(
// () => CustomTextField(
// label: 'Birth Date (YYYY-MM-DD)',
// controller: controller.birthDateController,
// validator:
// (value) =>
// TValidators.validateUserInput('Birth date', value, 10),
// errorText: controller.birthDateError.value,
// textInputAction: TextInputAction.done,
// keyboardType: TextInputType.datetime,
// hintText: 'e.g., 1990-01-31',
// ),
// ),
// ],
// ),
// );
// }
// Widget _buildOfficerInfoStep(FormRegistrationController controller) {
// return Form(
// key: controller.stepFormKeys[1],
// child: Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// Text(
// 'Officer Information',
// style: TextStyle(
// fontSize: 20,
// fontWeight: FontWeight.bold,
// color: TColors.textPrimary,
// ),
// ),
// const SizedBox(height: 8),
// Text(
// 'Please provide your officer details',
// style: TextStyle(fontSize: 14, color: TColors.textSecondary),
// ),
// const SizedBox(height: 24),
// // NRP field
// Obx(
// () => CustomTextField(
// label: 'NRP',
// controller: controller.nrpController,
// validator: TValidators.validateNRP,
// errorText: controller.nrpError.value,
// textInputAction: TextInputAction.next,
// ),
// ),
// // Rank field
// Obx(
// () => CustomTextField(
// label: 'Rank',
// controller: controller.rankController,
// validator: TValidators.validateRank,
// errorText: controller.rankError.value,
// textInputAction: TextInputAction.done,
// ),
// ),
// ],
// ),
// );
// }
// Widget _buildOfficerAdditionalInfoStep(
// FormRegistrationController controller,
// ) {
// return Form(
// key: controller.stepFormKeys[2],
// child: Column(
// crossAxisAlignment: CrossAxisAlignment.start,
// children: [
// Text(
// 'Unit Information',
// style: TextStyle(
// fontSize: 20,
// fontWeight: FontWeight.bold,
// color: TColors.textPrimary,
// ),
// ),
// const SizedBox(height: 8),
// Text(
// 'Please provide your unit details',
// style: TextStyle(fontSize: 14, color: TColors.textSecondary),
// ),
// const SizedBox(height: 24),
// // Position field
// Obx(
// () => CustomTextField(
// label: 'Position',
// controller: controller.positionController,
// validator: TValidators.validatePosition,
// errorText: controller.positionError.value,
// textInputAction: TextInputAction.next,
// ),
// ),
// // Unit Dropdown
// Obx(
// () => CustomDropdown(
// label: 'Unit',
// items:
// controller.availableUnits
// .map(
// (unit) => DropdownMenuItem(
// value: unit.codeUnit,
// child: Text(unit.name),
// ),
// )
// .toList(),
// value:
// controller.unitIdController.text.isEmpty
// ? null
// : controller.unitIdController.text,
// onChanged: (value) {
// if (value != null) {
// controller.unitIdController.text = value.toString();
// }
// },
// validator: (value) => TValidators.validateUnitId(value),
// errorText: controller.unitIdError.value,
// ),
// ),
// ],
// ),
// );
// }
// }

View File

@ -4,6 +4,9 @@ import 'package:get_storage/get_storage.dart';
import 'package:sigap/src/cores/services/location_service.dart';
import 'package:sigap/src/features/onboarding/data/dummy/onboarding_dummy.dart';
import 'package:sigap/src/utils/constants/app_routes.dart';
import 'package:sigap/src/utils/constants/image_strings.dart';
import 'package:sigap/src/utils/popups/full_screen_loader.dart';
import 'package:sigap/src/utils/popups/loaders.dart';
class OnboardingController extends GetxController
with GetSingleTickerProviderStateMixin {
@ -93,22 +96,44 @@ class OnboardingController extends GetxController
isLocationChecking.value = true;
try {
TFullScreenLoader.openLoadingDialog(
'Checking location...',
TImages.loader,
);
// Verify location is valid (in Jember and not mocked)
final isLocationValid =
await _locationService.isLocationValidForFeature();
if (isLocationValid) {
TFullScreenLoader.stopLoading();
if (!isLocationValid) {
// If location is valid, proceed to role selection
Get.offAllNamed(AppRoutes.roleSelection);
TLoaders.successSnackBar(
title: 'Location Valid',
message: 'Checking location was successful',
);
} else {
// If location is invalid, show warning screen
Get.offAllNamed(AppRoutes.locationWarning);
TLoaders.errorSnackBar(
title: 'Location Invalid',
message:
'Please enable location services or ensure you are in Jember area',
);
}
} catch (e) {
// If there's an error, show the location warning screen
// TFullScreenLoader.stopLoading();
Get.offAllNamed(AppRoutes.locationWarning);
} finally {
isLocationChecking.value = false;
// TFullScreenLoader.stopLoading();
}
}

View File

@ -2,7 +2,7 @@ import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:sigap/src/cores/services/location_service.dart';
import 'package:sigap/src/utils/constants/app_routes.dart';
import 'package:sigap/src/utils/constants/colors.dart';
import 'package:sigap/src/utils/constants/sizes.dart';
class LocationWarningScreen extends StatelessWidget {
const LocationWarningScreen({super.key});
@ -13,51 +13,53 @@ class LocationWarningScreen extends StatelessWidget {
final isMocked = locationService.isMockedLocation.value;
final isOutsideJember = !locationService.isInJember();
// Get theme data for consistent styling
final theme = Theme.of(context);
return Scaffold(
backgroundColor: TColors.light,
// Use theme background color instead of hardcoded color
backgroundColor: theme.scaffoldBackgroundColor,
body: SafeArea(
child: Padding(
padding: const EdgeInsets.all(24.0),
padding: const EdgeInsets.all(TSizes.defaultSpace),
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Warning Icon
Icon(Icons.location_off, size: 80, color: TColors.error),
// Warning Icon - use theme error color
Icon(
Icons.location_off,
size: 80,
color: theme.colorScheme.error,
),
const SizedBox(height: 32),
const SizedBox(height: TSizes.spaceBtwSections),
// Warning Title
// Warning Title - use theme headline style
Text(
'Location Restriction',
style: TextStyle(
fontSize: 24,
style: theme.textTheme.headlineMedium?.copyWith(
fontWeight: FontWeight.bold,
color: TColors.textPrimary,
),
textAlign: TextAlign.center,
),
const SizedBox(height: 16),
const SizedBox(height: TSizes.spaceBtwItems),
// Warning Message
// Warning Message - use theme body style
Text(
isMocked
? 'We detected that you are using a mock location app. Please disable it to continue.'
: isOutsideJember
? 'This application is only available within Jember region. Please try again when you are in Jember.'
: 'We could not verify your location. Please ensure your location services are enabled and try again.',
style: TextStyle(
fontSize: 16,
color: TColors.textSecondary,
height: 1.5,
),
style: theme.textTheme.bodyLarge?.copyWith(height: 1.5),
textAlign: TextAlign.center,
),
const SizedBox(height: 40),
const SizedBox(height: TSizes.spaceBtwSections * 1.5),
// Try Again Button
// Try Again Button - full width
SizedBox(
width: double.infinity,
height: 56,
@ -72,43 +74,47 @@ class LocationWarningScreen extends StatelessWidget {
'Location Issue',
'Your location is still not valid. Please ensure you are in Jember with location services enabled.',
snackPosition: SnackPosition.BOTTOM,
backgroundColor: TColors.error.withOpacity(0.1),
colorText: TColors.error,
backgroundColor: theme.colorScheme.error.withOpacity(
0.1,
),
colorText: theme.colorScheme.error,
);
}
},
style: ElevatedButton.styleFrom(
backgroundColor: TColors.primary,
foregroundColor: TColors.white,
minimumSize: const Size.fromHeight(56),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(16),
borderRadius: BorderRadius.circular(TSizes.buttonRadius),
),
elevation: 0,
),
child: const Text(
child: Text(
'Try Again',
style: TextStyle(fontSize: 16, fontWeight: FontWeight.bold),
style: Theme.of(context).textTheme.titleMedium,
),
),
),
const SizedBox(height: 16),
const SizedBox(height: TSizes.spaceBtwItems),
// Exit App Button
TextButton(
// Exit App Button - full width
SizedBox(
width: double.infinity,
child: TextButton(
onPressed: () {
// This will close the app
Get.back();
// This will close the app
Get.back();
},
child: Text(
'Exit App',
style: TextStyle(
color: TColors.textSecondary,
fontWeight: FontWeight.w500,
style: TextButton.styleFrom(
foregroundColor: theme.colorScheme.secondary,
),
child: Text(
'Exit App',
style: theme.textTheme.bodyLarge),
),
),
],
),
),
),

View File

@ -67,6 +67,9 @@ class CustomTextField extends StatelessWidget {
).textTheme.bodyMedium?.copyWith(color: TColors.textSecondary),
errorText:
errorText != null && errorText!.isNotEmpty ? errorText : null,
errorStyle: Theme.of(
context,
).textTheme.bodySmall?.copyWith(color: TColors.error),
contentPadding: const EdgeInsets.symmetric(
horizontal: TSizes.md,
vertical: TSizes.md,

View File

@ -1,7 +1,23 @@
import 'package:flutter_dotenv/flutter_dotenv.dart';
class Endpoints {
// Base URL
static const devUrl = "";
static const String prodUrl = '';
static const String baseUrl = '$devUrl/api';
static String get azureResource => dotenv.env['AZURE_RESOURCE_NAME'] ?? '';
static String get azureSubscriptionKey =>
dotenv.env['AZURE_SUBSCRIPTION_KEY'] ?? '';
static String get azureEndpoint =>
'https://$azureResource.cognitiveservices.azure.com/';
static String get ocrApiPath => 'vision/v3.2/read/analyze';
static String ocrResultPath(String operationId) =>
'vision/v3.2/read/analyzeResults/$operationId';
static String get faceApiPath => 'face/v1.0/detect';
static String get faceVerifyPath => 'face/v1.0/verify';
}

View File

@ -15,6 +15,7 @@ class TSizes {
static const double iconLg = 32.0;
// Font sizes
static const double fontSizeXs = 10.0;
static const double fontSizeSm = 14.0;
static const double fontSizeMd = 16.0;
static const double fontSizeLg = 18.0;