From ce7d448b2fe593c9cab5b8a84095ac56adef83a5 Mon Sep 17 00:00:00 2001 From: vergiLgood1 Date: Mon, 19 May 2025 20:53:09 +0700 Subject: [PATCH] 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. --- sigap-mobile/.env | 3 + .../lib/src/cores/routes/app_pages.dart | 8 +- .../src/cores/services/azure_ocr_service.dart | 14 +- .../auth/data/dummy/indonesian_cities.dart | 530 +++++++++ .../presentasion/bindings/auth_bindings.dart | 2 +- .../registration_form_controller.dart | 340 ++++++ .../controllers/step_form_controller.dart | 905 -------------- .../id_card_verification_controller.dart | 173 +++ .../identity_verification_controller.dart | 407 +++++++ .../steps/image_verification_controller.dart | 250 ++++ .../steps/officer_info_controller.dart | 60 + .../steps/personal_info_controller.dart | 135 +++ .../steps/selfie_verification_controller.dart | 139 +++ .../steps/unit_info_controller.dart | 59 + .../registraion_form_screen.dart | 1058 +---------------- .../widgets/city_selection.dart | 125 ++ .../widgets/id_card_verification_step.dart | 546 +++++++++ .../widgets/identity_verification_step.dart | 587 +++++++++ .../widgets/image_verification_step.dart | 856 +++++++++++++ .../widgets/officer_info_step.dart | 75 ++ .../widgets/personal_info_step.dart | 141 +++ .../widgets/selfie_verification_step.dart | 530 +++++++++ .../widgets/unit_info_step.dart | 87 ++ .../step-form/step_form_screen.dart | 427 +++++++ .../controllers/onboarding_controller.dart | 27 +- .../location_warning_screen.dart | 78 +- .../widgets/text/custom_text_field.dart | 5 +- .../lib/src/utils/constants/api_urls.dart | 16 + .../lib/src/utils/constants/sizes.dart | 1 + 29 files changed, 5592 insertions(+), 1992 deletions(-) create mode 100644 sigap-mobile/lib/src/features/auth/data/dummy/indonesian_cities.dart create mode 100644 sigap-mobile/lib/src/features/auth/presentasion/controllers/registration_form_controller.dart delete mode 100644 sigap-mobile/lib/src/features/auth/presentasion/controllers/step_form_controller.dart create mode 100644 sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.dart create mode 100644 sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart create mode 100644 sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/image_verification_controller.dart create mode 100644 sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/officer_info_controller.dart create mode 100644 sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/personal_info_controller.dart create mode 100644 sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/selfie_verification_controller.dart create mode 100644 sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/unit_info_controller.dart create mode 100644 sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/city_selection.dart create mode 100644 sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/id_card_verification_step.dart create mode 100644 sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification_step.dart create mode 100644 sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/image_verification_step.dart create mode 100644 sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/officer_info_step.dart create mode 100644 sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/personal_info_step.dart create mode 100644 sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/selfie_verification_step.dart create mode 100644 sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/unit_info_step.dart create mode 100644 sigap-mobile/lib/src/features/auth/presentasion/step-form/step_form_screen.dart diff --git a/sigap-mobile/.env b/sigap-mobile/.env index b402b0e..ab7fb50 100644 --- a/sigap-mobile/.env +++ b/sigap-mobile/.env @@ -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" \ No newline at end of file diff --git a/sigap-mobile/lib/src/cores/routes/app_pages.dart b/sigap-mobile/lib/src/cores/routes/app_pages.dart index cdde2a9..8245239 100644 --- a/sigap-mobile/lib/src/cores/routes/app_pages.dart +++ b/sigap-mobile/lib/src/cores/routes/app_pages.dart @@ -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(), + ) ]; } diff --git a/sigap-mobile/lib/src/cores/services/azure_ocr_service.dart b/sigap-mobile/lib/src/cores/services/azure_ocr_service.dart index 590b4b9..78c88f7 100644 --- a/sigap-mobile/lib/src/cores/services/azure_ocr_service.dart +++ b/sigap-mobile/lib/src/cores/services/azure_ocr_service.dart @@ -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> processIdCard( diff --git a/sigap-mobile/lib/src/features/auth/data/dummy/indonesian_cities.dart b/sigap-mobile/lib/src/features/auth/data/dummy/indonesian_cities.dart new file mode 100644 index 0000000..690ba7a --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/data/dummy/indonesian_cities.dart @@ -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 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', +]; diff --git a/sigap-mobile/lib/src/features/auth/presentasion/bindings/auth_bindings.dart b/sigap-mobile/lib/src/features/auth/presentasion/bindings/auth_bindings.dart index 5701cad..de51241 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/bindings/auth_bindings.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/bindings/auth_bindings.dart @@ -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 diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/registration_form_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/registration_form_controller.dart new file mode 100644 index 0000000..2b4e475 --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/registration_form_controller.dart @@ -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 selectedRole = Rx(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 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 getStepTitles() { + if (selectedRole.value?.isOfficer ?? false) { + return ['Personal', 'ID Card', 'Selfie', 'Officer Info', 'Unit Info']; + } else { + return ['Personal', 'ID Card', 'Selfie', 'Identity']; + } + } + + Future _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 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; + } + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/step_form_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/step_form_controller.dart deleted file mode 100644 index 902d01d..0000000 --- a/sigap-mobile/lib/src/features/auth/presentasion/controllers/step_form_controller.dart +++ /dev/null @@ -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 selectedRole = Rx(null); - - // Current step index - final RxInt currentStep = 0.obs; - - // Form keys for each step (dynamic based on role) - late List> 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 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 availableUnits = [].obs; - - // ID Card variables - final Rx idCardImage = Rx(null); - final RxString idCardError = RxString(''); - final RxBool isVerifying = RxBool(false); - final RxBool isVerified = RxBool(false); - final RxString verificationMessage = RxString(''); - - // Face verification variables - final Rx selfieImage = Rx(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()); - } else { - // Viewer role or default - stepFormKeys = List.generate(2, (_) => GlobalKey()); - } - } - - Future _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 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 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 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 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 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 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 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 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 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; - } - } -} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.dart new file mode 100644 index 0000000..79dc95f --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/id_card_verification_controller.dart @@ -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 formKey = GlobalKey(); + final AzureOCRService _ocrService = AzureOCRService(); + final bool isOfficer; + + IdCardVerificationController({required this.isOfficer}); + + // ID Card variables + final Rx idCardImage = Rx(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 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 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; + } + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart new file mode 100644 index 0000000..1acf08c --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/identity_verification_controller.dart @@ -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 formKey = GlobalKey(); + 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(); + } 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 verifyIdCardWithOCR() async { + // Make sure we have reference to the image controller + if (!Get.isRegistered()) { + verificationMessage.value = 'Error: Image verification data unavailable'; + isVerified.value = false; + return; + } + + try { + imageVerificationController = Get.find(); + } 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 verifyFaceMatch() async { + // Make sure we have reference to the image controller + if (!Get.isRegistered()) { + faceVerificationMessage.value = + 'Error: Image verification data unavailable'; + isFaceVerified.value = false; + return; + } + + try { + imageVerificationController = Get.find(); + } 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 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 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(); + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/image_verification_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/image_verification_controller.dart new file mode 100644 index 0000000..acc2b9d --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/image_verification_controller.dart @@ -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 formKey = GlobalKey(); + final AzureOCRService _ocrService = AzureOCRService(); + final bool isOfficer; + + ImageVerificationController({required this.isOfficer}); + + // ID Card variables + final Rx idCardImage = Rx(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 selfieImage = Rx(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 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 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 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 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; + } + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/officer_info_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/officer_info_controller.dart new file mode 100644 index 0000000..f9aad61 --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/officer_info_controller.dart @@ -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 formKey = GlobalKey(); + + // 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(); + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/personal_info_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/personal_info_controller.dart new file mode 100644 index 0000000..1ea2b1e --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/personal_info_controller.dart @@ -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 formKey = GlobalKey(); + + // 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(); + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/selfie_verification_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/selfie_verification_controller.dart new file mode 100644 index 0000000..3ce9d23 --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/selfie_verification_controller.dart @@ -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 formKey = GlobalKey(); + final AzureOCRService _ocrService = AzureOCRService(); + + // Face verification variables + final Rx selfieImage = Rx(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 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 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; + } + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/unit_info_controller.dart b/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/unit_info_controller.dart new file mode 100644 index 0000000..748b998 --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/controllers/steps/unit_info_controller.dart @@ -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 formKey = GlobalKey(); + + // Controllers + final positionController = TextEditingController(); + final unitIdController = TextEditingController(); + + // Available units for officer role + final RxList availableUnits = [].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(); + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/registraion_form_screen.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/registraion_form_screen.dart index ae528a0..0ed7af8 100644 --- a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/registraion_form_screen.dart +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/registraion_form_screen.dart @@ -1,19 +1,18 @@ -import 'dart:io'; - import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:get/get.dart'; -import 'package:image_picker/image_picker.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'; +import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/id_card_verification_step.dart'; +import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification_step.dart'; +import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/officer_info_step.dart'; +import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/personal_info_step.dart'; +import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/selfie_verification_step.dart'; +import 'package:sigap/src/features/auth/presentasion/pages/registration-form/widgets/unit_info_step.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/step_indicator.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/helpers/helper_functions.dart'; -import 'package:sigap/src/utils/validators/validation.dart'; class FormRegistrationScreen extends StatelessWidget { const FormRegistrationScreen({super.key}); @@ -70,8 +69,8 @@ class FormRegistrationScreen extends StatelessWidget { child: Obx( () => StepIndicator( currentStep: controller.currentStep.value, - totalSteps: controller.stepFormKeys.length, - stepTitles: _getStepTitles(controller.selectedRole.value!), + totalSteps: controller.totalSteps, + stepTitles: controller.getStepTitles(), onStepTapped: controller.goToStep, style: StepIndicatorStyle.standard, ), @@ -127,7 +126,7 @@ class FormRegistrationScreen extends StatelessWidget { () => AuthButton( text: controller.currentStep.value == - controller.stepFormKeys.length - 1 + controller.totalSteps - 1 ? 'Submit' : 'Next', onPressed: controller.nextStep, @@ -146,1047 +145,28 @@ class FormRegistrationScreen extends StatelessWidget { ); } - List _getStepTitles(RoleModel role) { - if (role.isOfficer) { - return ['Personal', 'Officer Info', 'Unit Info']; - } else { - return ['Personal', 'Identity']; - } - } - Widget _buildStepContent(FormRegistrationController controller) { final isOfficer = controller.selectedRole.value?.isOfficer ?? false; switch (controller.currentStep.value) { case 0: - return _buildPersonalInfoStep(controller); + return const PersonalInfoStep(); case 1: - return isOfficer - ? _buildOfficerInfoStep(controller) - : _buildPrivacyIdentity(controller); + return const IdCardVerificationStep(); case 2: + return const SelfieVerificationStep(); + case 3: + return isOfficer + ? const OfficerInfoStep() + : const IdentityVerificationStep(); + case 4: // This step only exists for officers if (isOfficer) { - return _buildOfficerAdditionalInfoStep(controller); + return const UnitInfoStep(); } 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: 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: false, - ), - 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 = ''; - }, - ), - ), - ], - ), - ); - } - - Widget _buildPrivacyIdentity(FormRegistrationController controller) { - final bool isOfficer = controller.selectedRole.value?.isOfficer ?? false; - final String idCardType = isOfficer ? 'KTA' : 'KTP'; - - return Form( - key: controller.stepFormKeys[1], - 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 = ''; - }, - ), - ), - ], - - // 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 = ''; - }, - ), - ), - - // 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', - onChanged: (value) { - controller.birthDateController.text = value; - controller.birthDateError.value = ''; - }, - ), - ), - - // ID Card Upload Section - const SizedBox(height: TSizes.spaceBtwSections), - Text( - '$idCardType Verification', - style: TextStyle( - fontSize: TSizes.fontSizeLg, - fontWeight: FontWeight.bold, - color: TColors.textPrimary, - ), - ), - const SizedBox(height: TSizes.sm), - Text( - 'Upload your $idCardType for verification', - style: TextStyle( - fontSize: TSizes.fontSizeSm, - color: TColors.textSecondary, - ), - ), - const SizedBox(height: TSizes.xs), - Text( - 'Make sure your ID card is clearly visible and all information is readable', - style: TextStyle( - fontSize: TSizes.fontSizeXs, - fontStyle: FontStyle.italic, - color: TColors.textSecondary, - ), - ), - const SizedBox(height: TSizes.spaceBtwItems), - - // ID Card Upload Widget - Obx(() => _buildIdCardUploader(controller, isOfficer)), - - // 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 ID card...'), - ], - ), - ), - ) - : 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(), - ), - - // Selfie and Face Verification Section - if (controller.isVerified.value) ...[ - const SizedBox(height: TSizes.spaceBtwSections), - Text( - 'Face Verification', - style: TextStyle( - fontSize: TSizes.fontSizeLg, - fontWeight: FontWeight.bold, - color: TColors.textPrimary, - ), - ), - const SizedBox(height: TSizes.sm), - Text( - 'Take a selfie for identity verification', - style: TextStyle( - fontSize: TSizes.fontSizeSm, - color: TColors.textSecondary, - ), - ), - const SizedBox(height: TSizes.xs), - Text( - 'Make sure your face is clearly visible and well-lit', - style: TextStyle( - fontSize: TSizes.fontSizeXs, - fontStyle: FontStyle.italic, - color: TColors.textSecondary, - ), - ), - const SizedBox(height: TSizes.spaceBtwItems), - - // Selfie Upload Widget - Obx(() => _buildSelfieUploader(controller)), - - // Liveness Check Status - Obx( - () => - controller.isPerformingLivenessCheck.value - ? const Padding( - padding: EdgeInsets.symmetric( - vertical: TSizes.spaceBtwItems, - ), - child: Center( - child: Column( - children: [ - CircularProgressIndicator(), - SizedBox(height: TSizes.sm), - Text('Performing liveness check...'), - ], - ), - ), - ) - : const SizedBox.shrink(), - ), - - // 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('Verifying your face...'), - ], - ), - ), - ) - : const SizedBox.shrink(), - ), - - // 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 || - controller.isLivenessCheckPassed.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 || - controller - .isLivenessCheckPassed - .value) - ? Icons.check_circle - : Icons.error, - color: - (controller.isFaceVerified.value || - controller - .isLivenessCheckPassed - .value) - ? Colors.green - : Colors.red, - ), - ), - const SizedBox(width: TSizes.sm), - Expanded( - child: Text( - controller.faceVerificationMessage.value, - style: TextStyle( - color: - (controller.isFaceVerified.value || - controller - .isLivenessCheckPassed - .value) - ? Colors.green - : Colors.red, - ), - ), - ), - ], - ), - ), - ) - : const SizedBox.shrink(), - ), - ], - ], - ), - ); - } - - Widget _buildIdCardUploader( - FormRegistrationController controller, - bool isOfficer, - ) { - final String idCardType = isOfficer ? 'KTA' : 'KTP'; - - 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: Colors.grey.withOpacity(0.5)), - ), - child: 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: - controller.idCardError.value.isNotEmpty - ? Colors.red - : Colors.grey.withOpacity(0.5), - width: 2, - ), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular( - TSizes.borderRadiusMd, - ), - child: Image.file( - File(controller.idCardImage.value!.path), - height: 200, - width: double.infinity, - fit: BoxFit.cover, - ), - ), - ), - 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), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - controller.isVerified.value - ? '$idCardType verified successfully' - : 'Image uploaded. Please verify your $idCardType.', - style: TextStyle( - color: - controller.isVerified.value - ? Colors.green - : TColors.textSecondary, - fontSize: TSizes.fontSizeSm, - ), - ), - ), - TextButton( - onPressed: () => _showImageSourceDialog(controller, true), - child: const Text('Change'), - ), - ], - ), - ElevatedButton.icon( - onPressed: - controller.isVerifying.value - ? null - : () => controller.verifyIdCardWithOCR(), - icon: const Icon(Icons.verified_user), - label: Text( - controller.isVerified.value - ? 'Re-verify $idCardType' - : 'Verify $idCardType', - ), - style: ElevatedButton.styleFrom( - backgroundColor: TColors.primary, - foregroundColor: Colors.white, - minimumSize: const Size(double.infinity, 50), - ), - ), - ], - ), - if (controller.idCardError.value.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: TSizes.xs), - child: Text( - controller.idCardError.value, - style: const TextStyle( - color: Colors.red, - fontSize: TSizes.fontSizeSm, - ), - ), - ), - ], - ); - } - - Widget _buildSelfieUploader(FormRegistrationController controller) { - 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: Colors.grey.withOpacity(0.5)), - ), - child: 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: - controller.selfieError.value.isNotEmpty - ? Colors.red - : Colors.grey.withOpacity(0.5), - width: 2, - ), - ), - child: ClipRRect( - borderRadius: BorderRadius.circular( - TSizes.borderRadiusMd, - ), - child: Image.file( - File(controller.selfieImage.value!.path), - height: 200, - width: double.infinity, - fit: BoxFit.cover, - ), - ), - ), - 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), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Expanded( - child: Text( - controller.isLivenessCheckPassed.value - ? 'Liveness check passed!' - : 'Selfie captured. Complete liveness check.', - style: TextStyle( - color: - controller.isLivenessCheckPassed.value - ? Colors.green - : TColors.textSecondary, - fontSize: TSizes.fontSizeSm, - ), - ), - ), - TextButton( - onPressed: () => _showSelfieDialog(controller), - child: const Text('Retake'), - ), - ], - ), - // Liveness check button - if (!controller.isLivenessCheckPassed.value) - ElevatedButton.icon( - onPressed: - controller.isPerformingLivenessCheck.value - ? null - : () => controller.performLivenessCheck(), - icon: const Icon(Icons.security), - label: const Text('Perform Liveness Check'), - style: ElevatedButton.styleFrom( - backgroundColor: Colors.blue, - foregroundColor: Colors.white, - minimumSize: const Size(double.infinity, 50), - ), - ), - - // Face verification button (only shown after liveness check passes) - if (controller.isLivenessCheckPassed.value) - 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, - ), - ), - ], - ), - if (controller.selfieError.value.isNotEmpty) - Padding( - padding: const EdgeInsets.only(top: TSizes.xs), - child: Text( - controller.selfieError.value, - style: const TextStyle( - color: Colors.red, - fontSize: TSizes.fontSizeSm, - ), - ), - ), - ], - ); - } - - void _showImageSourceDialog( - FormRegistrationController controller, - bool isIdCard, - ) { - final String title = - isIdCard - ? 'Select ${controller.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, true); - } - 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(FormRegistrationController controller) { - // For selfie, we only use camera with liveness check - controller.pickSelfieImage(ImageSource.camera, true); - } - - 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), - ], - ), - ); - } - - Widget _buildOfficerInfoStep(FormRegistrationController controller) { - return Form( - key: controller.stepFormKeys[1], - 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 = ''; - }, - ), - ), - ], - ), - ); - } - - Widget _buildOfficerAdditionalInfoStep( - FormRegistrationController controller, - ) { - return Form( - key: controller.stepFormKeys[2], - 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(); - } - }, - validator: (value) => TValidators.validateUnitId(value), - errorText: controller.unitIdError.value, - ), - ), - ], - ), - ); - } } diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/city_selection.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/city_selection.dart new file mode 100644 index 0000000..78497d8 --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/city_selection.dart @@ -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 createState() => _CitySelectionPageState(); +} + +class _CitySelectionPageState extends State { + final TextEditingController _searchController = TextEditingController(); + final RxList _filteredCities = [].obs; + final List _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, + ), + ); + }, + ), + ), + ), + ], + ), + ); + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/id_card_verification_step.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/id_card_verification_step.dart new file mode 100644 index 0000000..29dcb5b --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/id_card_verification_step.dart @@ -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(); + final mainController = Get.find(); + 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), + ], + ), + ); + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification_step.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification_step.dart new file mode 100644 index 0000000..fb099b6 --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/identity_verification_step.dart @@ -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(); + final ImageVerificationController imageController; + + try { + imageController = Get.find(); + } catch (e) { + // Handle the case when ImageVerificationController is not registered yet + // Use a local variable or default behavior + } + + final mainController = Get.find(); + 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(() => 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( + 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( + 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)] + : [], + ); + + 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; + } + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/image_verification_step.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/image_verification_step.dart new file mode 100644 index 0000000..243956f --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/image_verification_step.dart @@ -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(); + final mainController = Get.find(); + 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(); + 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), + ], + ), + ); + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/officer_info_step.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/officer_info_step.dart new file mode 100644 index 0000000..fb5e2e6 --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/officer_info_step.dart @@ -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(); + + 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 = ''; + }, + ), + ), + ], + ), + ); + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/personal_info_step.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/personal_info_step.dart new file mode 100644 index 0000000..7e82290 --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/personal_info_step.dart @@ -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(); + + 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 = ''; + }, + ), + ), + ], + ), + ); + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/selfie_verification_step.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/selfie_verification_step.dart new file mode 100644 index 0000000..0f56860 --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/selfie_verification_step.dart @@ -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(); + + 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); + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/unit_info_step.dart b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/unit_info_step.dart new file mode 100644 index 0000000..420ddf1 --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/pages/registration-form/widgets/unit_info_step.dart @@ -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(); + + 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, + ), + ), + ], + ), + ); + } +} diff --git a/sigap-mobile/lib/src/features/auth/presentasion/step-form/step_form_screen.dart b/sigap-mobile/lib/src/features/auth/presentasion/step-form/step_form_screen.dart new file mode 100644 index 0000000..fcb7a98 --- /dev/null +++ b/sigap-mobile/lib/src/features/auth/presentasion/step-form/step_form_screen.dart @@ -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(); + +// // 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 _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, +// ), +// ), +// ], +// ), +// ); +// } +// } diff --git a/sigap-mobile/lib/src/features/onboarding/presentasion/controllers/onboarding_controller.dart b/sigap-mobile/lib/src/features/onboarding/presentasion/controllers/onboarding_controller.dart index 270ab25..7c20ca9 100644 --- a/sigap-mobile/lib/src/features/onboarding/presentasion/controllers/onboarding_controller.dart +++ b/sigap-mobile/lib/src/features/onboarding/presentasion/controllers/onboarding_controller.dart @@ -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(); } } diff --git a/sigap-mobile/lib/src/features/onboarding/presentasion/pages/location-warning/location_warning_screen.dart b/sigap-mobile/lib/src/features/onboarding/presentasion/pages/location-warning/location_warning_screen.dart index 93674ee..a1c3a66 100644 --- a/sigap-mobile/lib/src/features/onboarding/presentasion/pages/location-warning/location_warning_screen.dart +++ b/sigap-mobile/lib/src/features/onboarding/presentasion/pages/location-warning/location_warning_screen.dart @@ -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}); @@ -12,52 +12,54 @@ class LocationWarningScreen extends StatelessWidget { final locationService = Get.find(); 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), ), ), ], + ), ), ), diff --git a/sigap-mobile/lib/src/shared/widgets/text/custom_text_field.dart b/sigap-mobile/lib/src/shared/widgets/text/custom_text_field.dart index 379247b..5c6dba5 100644 --- a/sigap-mobile/lib/src/shared/widgets/text/custom_text_field.dart +++ b/sigap-mobile/lib/src/shared/widgets/text/custom_text_field.dart @@ -66,7 +66,10 @@ class CustomTextField extends StatelessWidget { context, ).textTheme.bodyMedium?.copyWith(color: TColors.textSecondary), errorText: - errorText != null && errorText!.isNotEmpty ? errorText : null, + 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, diff --git a/sigap-mobile/lib/src/utils/constants/api_urls.dart b/sigap-mobile/lib/src/utils/constants/api_urls.dart index e485331..1af260b 100644 --- a/sigap-mobile/lib/src/utils/constants/api_urls.dart +++ b/sigap-mobile/lib/src/utils/constants/api_urls.dart @@ -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'; } diff --git a/sigap-mobile/lib/src/utils/constants/sizes.dart b/sigap-mobile/lib/src/utils/constants/sizes.dart index e276be5..79363fd 100644 --- a/sigap-mobile/lib/src/utils/constants/sizes.dart +++ b/sigap-mobile/lib/src/utils/constants/sizes.dart @@ -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;